Claude Media
Claude Codeでsqlcを回す — .sqlを直したら再生成させるhook設定

Claude Codeでsqlcを回す — .sqlを直したら再生成させるhook設定

sqlcでSQLからGoコードを作る流れを、Claude Codeが.sqlを直すたびに自動で回す設定です。generateとvetのPostToolUse hook、生成物へのdeny、sqlc diffのStop hookを示します。

sqlcは、SQLからGoの型付きコードを生成するツールです。人間もClaude Codeも編集する場所は.sqlだけにして、Goのコードは必ずsqlc generateで作り直す。この分担を決めておけば、Claude Codeはクエリを直すたびに生成まで回し、型の合わないコードを残さずに済みます。

この記事では、sqlc.yamlの最小構成、編集直後にsqlc generateとsqlc vetを走らせるPostToolUse hook、生成物への手編集を止める設定、取りこぼしを拾うStop hookを順に組みます。Goのlintを同じ型で回す話はClaude Codeでgolangci-lintを回すに、Go開発全体の設定はClaude CodeでGoアプリを開発する手順にあります。ここはsqlcの流れだけを掘ります。

直す場所と直さない場所を分ける

sqlcの入力は2種類あります。DBの形を書くスキーマと、-- name: GetAuthor :oneのような注釈を付けたクエリです。出力は、db.go・models.go・query.sql.goの3ファイルからなるGoのパッケージです。

出力を手で直すと、次のsqlc generateで消えます。型が合わないと感じたときに生成ファイルを書き換えるのが、いちばん起きやすい事故です。直す場所を表にして、Claude Codeに渡します。

層置き場所の例Claude Codeの扱い
スキーマ置き場所の例db/migrations/Claude Codeの扱い編集可。編集後に再生成
クエリ置き場所の例db/queries/Claude Codeの扱い編集可。編集後に再生成とvet
設定置き場所の例sqlc.yamlClaude Codeの扱い変更は理由を添えて確認を取る
生成物置き場所の例internal/db/Claude Codeの扱い編集不可(denyで止める)

OpenAPI Generatorの記事も、生成物を直さず元を直して再生成させる同じ考え方です。sqlcは入力がSQLなので、生成が失敗した理由もSQLの文法や型として返ってきます。

sqlc.yamlの最小構成

設定はsqlc.yamlに書きます。チュートリアルの形をベースに、スキーマをマイグレーションのディレクトリ、クエリを専用ディレクトリに向けます。

version: "2"
sql:
  - engine: "postgresql"
    schema: "db/migrations"
    queries: "db/queries"
    gen:
      go:
        package: "db"
        out: "internal/db"
        sql_package: "pgx/v5"

schemaとqueriesには、ファイルのパスもディレクトリも指定できます。sql_packageはpgx/v4・pgx/v5・database/sqlから選び、省略するとdatabase/sqlになります。

schemaにマイグレーションのディレクトリを指定すると、sqlcはCREATE TABLEやALTER TABLEを順に読んでテーブルの形を組み立てます。対応するツールは、atlas・dbmate・golang-migrate・goose・sql-migrate・ternです。sqlcが見るのはup側だけで、down側のマイグレーションは無視されます。マイグレーションそのものをDBに適用する機能はありません。適用は、使っているマイグレーションツールの仕事です。

インストールはHomebrewならbrew install sqlc、Goが1.21以上ならgo installでも入ります。

brew install sqlc
sqlc version
sqlc generate

sqlc generateは、問題がなければ何も出力しません。出力が空のときは成功です。エラーはSQLの文法や存在しない列への参照として、ファイルと行付きで返ります。

編集直後にgenerateとvetを走らせるhook

.sqlを編集するたびに生成まで回すには、PostToolUse hookを使います。PostToolUseはツールの実行が成功した直後に動き、入力のJSONにtool_input.file_pathとして編集したファイルの絶対パスが入ります。Edit|Writeにマッチさせ、対象ファイルかどうかの判定はスクリプト側で行います。

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"${CLAUDE_PROJECT_DIR}\"/.claude/hooks/sqlc.sh",
            "timeout": 120
          }
        ]
      }
    ]
  }
}

スクリプトは、対象のパスならsqlc generateとsqlc vetを順に走らせ、失敗したら終了コード2でstderrを返します。PostToolUseの終了コード2は、ツールを取り消せませんが、stderrの内容がClaudeに届きます。終了コード0のときのstderrはClaudeに見えないので、失敗は必ず2で返します。

mkdir -p .claude/hooks
cat > .claude/hooks/sqlc.sh <<'SH'
#!/bin/bash
file_path=$(jq -r '.tool_input.file_path // empty')
case "$file_path" in
  */db/migrations/*.sql|*/db/queries/*.sql|*/sqlc.yaml) ;;
  *) exit 0 ;;
esac
 
if ! command -v sqlc >/dev/null; then
  echo "sqlc が見つかりません" >&2
  exit 2
fi
 
cd "$CLAUDE_PROJECT_DIR" || exit 1
 
# 並列編集で同時に走らないよう、ロックが空くまで待つ
lock="${TMPDIR:-/tmp}/sqlc-hook.lock"
for _ in $(seq 1 50); do
  mkdir "$lock" 2>/dev/null && locked=1 && break
  sleep 0.2
done
trap '[ -n "$locked" ] && rmdir "$lock"' EXIT
 
if ! out=$(sqlc generate 2>&1); then
  echo "sqlc generate が失敗しました。.sql 側を直してください。" >&2
  echo "$out" >&2
  exit 2
fi
if ! out=$(sqlc vet 2>&1); then
  echo "sqlc vet が指摘しました。" >&2
  echo "$out" >&2
  exit 2
fi
exit 0
SH
chmod +x .claude/hooks/sqlc.sh

ロックを入れているのは、PostToolUseがツール1回ごとに発火し、Claudeが複数のファイルを並列に編集すると並行して走るためです。生成物の出力先は1か所なので、同時にsqlc generateが走ると書き込みが重なる余地があります。待ってから順に走らせれば、その心配をせずに済みます。

sqlc vetはsqlc.yamlにrulesがないと、検査する対象がありません。次の節でルールを足します。hookの仕組み全般はPostToolUse hookでツール実行後の後処理を自動化するに、言語ごとの自動修正の比較はPostToolUse hookのLint自動修正の早見表にあります。

vetのルールで通るが危ないクエリを止める

sqlc vetは、クエリをルールに照らして検査します。ルールは名前・メッセージ・CEL(Common Expression Language)の式からなり、式がtrueになるとエラーとして報告されます。ルールはsqlc.yamlのrulesに定義し、使うパッケージ側のrulesにその名前を並べると有効になります。

version: "2"
sql:
  - engine: "postgresql"
    schema: "db/migrations"
    queries: "db/queries"
    gen:
      go:
        package: "db"
        out: "internal/db"
        sql_package: "pgx/v5"
    rules:
      - no-select-star
      - no-delete
rules:
  - name: no-select-star
    message: "SELECT * は使わず、列を明示する"
    rule: |
      query.sql.contains("SELECT *")
  - name: no-delete
    message: "DELETE は人が確認する"
    rule: |
      query.sql.contains("DELETE")

式から見える情報は、設定のengine、クエリのSQL本文(query.sql)、クエリ名、:oneや:manyなどの種別(query.cmd)、パラメータです。containsは文字列の部分一致なので、小文字で書かれたselect *は拾えません。表記ゆれを許したくなければ、規約を決めてから式を書きます。

ルールは、Claudeに渡す約束事を機械の検査に変える手段です。CLAUDE.mdに「SELECT *は使わない」と書いても守られるとは限りませんが、vetが毎回エラーで返せば、Claudeはクエリ側を直します。例外にしたいクエリには/* @sqlc-vet-disable no-delete */の注釈を付けると、そのルールだけ外せます。引数なしの@sqlc-vet-disableは全ルールを外すので、Claudeに付けさせる運用には向きません。

sqlc/db-prepareはDB接続とスキーマの鮮度が前提

組み込みのsqlc/db-prepareは、各クエリを接続中のDBに対してprepareし、失敗を報告します。DBの接続設定が必要で、PostgreSQLかMySQLならEXPLAINの結果をCELから参照するルールも書けます。ただしsqlcはスキーマ変更をDBに適用しません。DBのスキーマが古いままsqlc vetを走らせると、クエリは正しいのに失敗が出ます。

この失敗はhookからClaudeに返るため、Claudeがクエリのほうを直そうとして迷走しかねません。db-prepareを使うなら、hookの中でマイグレーションを手元のDBに適用してからsqlc vetを走らせるか、sqlcのマネージドDB(database: managed: true、sqlc Cloudのプロジェクトが前提)を使います。接続先は手元の開発DBに限り、本番の接続文字列をsqlc.yamlに書かない運用にします。

生成物の手編集をdenyで止める

生成物のディレクトリは、権限のdenyルールで編集を止められます。Edit(/path)のパスは、プロジェクトの設定ファイルに書くとプライマリ作業ディレクトリからの相対位置を指します。

{
  "permissions": {
    "deny": [
      "Edit(/internal/db/**)",
      "Edit(/sqlc.yaml)"
    ]
  }
}

ファイルへの書き込みはEdit(path)の規則で検査されます。Write(path)の規則は書いても一致しないので、Editで書きます。sqlc.yamlもdenyに入れると、設定の変更はClaudeにはできなくなり、変えたいときは人が編集します。sqlc.yamlを書き換えてrulesを外す近道を、最初から塞げます。

CLAUDE.mdには、denyの理由と直し方を書きます。denyはツールの操作を止めるだけで、なぜ止まるのかはClaudeに伝わらないからです。

## sqlc(Go)
- スキーマは db/migrations、クエリは db/queries に書く
- internal/db は sqlc の生成物なので編集しない。型が合わないときは .sql 側を直す
- 編集後は hook が sqlc generate と sqlc vet を走らせる。返った指摘は .sql 側で直す
- sqlc.yaml と vet のルールは変更しない。変えたいときは理由を説明して確認を取る
- 連番のマイグレーションは先頭をゼロ埋めする(001_, 002_)

Bashやマイグレーションの抜けをStop hookで補う

PostToolUseでEdit|Writeにマッチさせたhookは、EditツールとWriteツールが書いたときにしか動きません。Bashでマイグレーションツールがファイルを生成したり、sed -iで書き換えたりしても、このhookは発火しません。生成物をBash経由で直接書き換えられた場合も同じで、denyの対象はファイルツールの規則です。

最終関門には、Stop hookを置きます。sqlc diffは、生成されるファイルと既存のファイルを比べるコマンドです。Claudeが止まる直前に走らせ、差があれば終了コード2で止めて作り直させます。

cat > .claude/hooks/sqlc-stop.sh <<'SH'
#!/bin/bash
input=$(cat)
if [ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi
 
cd "$CLAUDE_PROJECT_DIR" || exit 1
if ! out=$(sqlc diff 2>&1); then
  echo "生成物が .sql と食い違っています。sqlc generate を実行してください。" >&2
  echo "$out" >&2
  exit 2
fi
exit 0
SH
chmod +x .claude/hooks/sqlc-stop.sh

.claude/settings.jsonには、hooksの下にStopを並べます。Stopにはmatcherがありません。

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"${CLAUDE_PROJECT_DIR}\"/.claude/hooks/sqlc-stop.sh",
            "timeout": 120
          }
        ]
      }
    ]
  }
}

Stopの終了コード2は、Claudeが止まるのを防いで会話を続けさせます。stop_hook_activeは、Stop hookの結果ですでに続行中のときにtrueになる値です。これを見て抜けないと、解消しない条件で止め続けます。

CLI一覧のdiffの説明は「生成物と既存ファイルの比較」だけで、差分があるときの終了コードは載っていません。差分のある状態で一度走らせ、echo $?で非ゼロを返すことを確かめてから、上のif !の条件に使います。0を返す場合は、出力の有無で判定する形に書き換えます。

つまずきやすい点

マイグレーションのファイル名が連番だと順序が狂う

sqlcはマイグレーションのファイルを辞書順に読みます。1_initial.sqlから9_foo.sql、10_bar.sqlと続けると、10_bar.sqlは9_foo.sqlより前に読まれます。001_initial.sqlのように桁をそろえます。Claudeが新しいマイグレーションを作るとき、前のファイル名を見て桁をそろえるよう、CLAUDE.mdに書いておきます。

nullableな列の型をClaudeに直させない

nullableな列は、database/sqlならsql.NullString、pgxならpgtype.Textのような型になります。ポインタ(*string)にしたいときは、sqlc.yamlのemit_pointers_for_null_typesをtrueにします。PostgreSQLではsql_packageがpgx/v4かpgx/v5のときだけ効く設定で、SQLiteでも使えます。生成コードを使う側のコンパイルエラーを、生成ファイルの型を書き換えて直そうとするのが典型的な迷走です。設定を変えるべき場面では、sqlc.yamlがdenyで編集不可なので、Claudeは理由を添えて人に確認を求めます。

生成物をコミットするか

生成物をリポジトリに含める運用では、Stop hookのsqlc diffがそのままCIの検査にもなります。含めない運用なら、CIでsqlc generateを走らせてからビルドします。どちらでも、Claudeに渡す規約は変わりません。

sqlc verifyとpushは人とCIに渡す

sqlc verifyは、過去に送ったクエリを新しいスキーマ変更に当てて、本番で壊れるクエリがないかを確かめるコマンドです。sqlc generateだけでは通ってしまう、後方互換のないスキーマ変更を拾います。使うには、リリースのたびにsqlc pushでスキーマとクエリをsqlc Cloudへ送り、認証トークンをSQLC_AUTH_TOKEN環境変数に置きます。

ローカルのClaude Codeに任せる範囲は、generate・vet・diffまでが妥当です。pushはクエリとスキーマを外部サービスへ送る操作なので、権限のdenyに入れて人とCIの領分にします。

{
  "permissions": {
    "deny": [
      "Bash(sqlc push *)"
    ]
  }
}

まとめ

sqlcの流れは、入力のSQLを直し、生成と検査を機械に回させ、出力は触らない、の3つに分けると任せやすくなります。まず.sqlの編集直後にgenerateとvetを走らせるhookを入れ、次に生成物とsqlc.yamlをdenyで守り、最後にsqlc diffのStop hookで取りこぼしを拾います。db-prepareを使うかどうかは、手元のDBにマイグレーションを適用する手間をhookに持たせられるかで決まります。

DBのマイグレーションを書かせる別の型はClaude CodeでAlembicのマイグレーションを書くにあります。ORM側の設計はPrismaスキーマ設計とDrizzle ORMを見比べると、sqlcのSQLファースト型との違いがつかめます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/hooks
  2. code.claude.com/docs/en/permissions
  3. docs.sqlc.dev/en/latest
  4. docs.sqlc.dev/en/latest/tutorials/getting-started-postgresql.html
  5. docs.sqlc.dev/en/latest/howto/vet.html
  6. docs.sqlc.dev/en/latest/howto/ddl.html
  7. docs.sqlc.dev/en/latest/reference/config.html
  8. docs.sqlc.dev/en/latest/reference/cli.html
  9. docs.sqlc.dev/en/latest/howto/verify.html
この記事を共有:XはてブLinkedIn