Claude CodeでEF Coreのマイグレーションを本番適用前に洗う手順
dotnet ef migrations scriptで出したSQLをclaude -pに読ませ、列の削除・型変更・既定値なしのNOT NULL追加を適用前に拾う手順を、JSON出力とCIの止め方まで書きます。
Claude CodeでEF Coreのマイグレーションを本番適用前に洗う手順
EF Coreのマイグレーションは、C#のコードを読んでもテーブルに何が起きるかが見えにくいのが難点です。migrationBuilder.DropColumnの1行が、本番の1列分のデータを消します。
そこで、読む対象をC#ではなくdotnet ef migrations scriptが出すSQLに替え、claude -pに危険な文を拾わせます。この記事では、スクリプトの作り方、拾わせる観点、JSONで受け取ってCIで止める流れ、そしてこの方法で見えないものまでを順に書きます。
C#ではなくSQLを読ませる理由
Microsoftのドキュメントは、デプロイの前に生成SQLを確認・修正したい場合の選択肢としてSQLスクリプトを挙げています。スクリプトはレビューでき、CIで生成でき、DBAに渡して別に保管もできる、という位置づけです。
対比として、マイグレーションバンドルは自動デプロイ向きですが、実行するSQLを事前に見る手段がありません。dotnet ef database updateも、ツールがSQLを直接適用するため、内容を確認する機会が挟まりません。
| 適用方法 | 実行前にSQLを確認できるか | レビュー対象にしやすいか |
|---|---|---|
| SQLスクリプト | 実行前にSQLを確認できるかできる | レビュー対象にしやすいか向く |
| マイグレーションバンドル | 実行前にSQLを確認できるかできない | レビュー対象にしやすいか向かない |
dotnet ef database update | 実行前にSQLを確認できるかできない | レビュー対象にしやすいか向かない |
つまり、AIに読ませる対象としても、人のレビューと同じくSQLスクリプトが素直な入口です。C#のマイグレーションクラスはUpとDown、スナップショット、Designerファイルに分かれていて、「結局どのDDLが流れるか」を組み立てる作業が要ります。その組み立てをdotnet efに任せ、結果のSQLだけを渡します。
読ませるSQLを2通り作る
dotnet ef migrations scriptは、出力先を指定しなければ標準出力に書きます。--output(短縮形-o)でファイルにできます。
# 全マイグレーションを含む、どのバージョンのDBにも流せるスクリプト
dotnet ef migrations script --idempotent \
--output artifacts/migrations.sql
# 本番に適用済みの最新から、今回の範囲だけ
dotnet ef migrations script 20260915093000_AddOrderNote \
--output artifacts/pending.sql--idempotent(-i)は、__EFMigrationsHistoryテーブルで適用済みかを確かめながら、未適用のマイグレーションだけを実行するスクリプトを作ります。どのマイグレーションまで適用されているか分からないDBや、バージョンの異なる複数のDBへ配るときに使えます。ただし対応はプロバイダー次第で、たとえばSQLiteは対応していません。
2つ目の書式は、<FROM>に「本番DBに最後に適用済みのマイグレーション」を渡します。<TO>を省くとプロジェクトの最新までです。FROMに0を渡すと、最初のマイグレーションより前からの全文になります。
役割は次のように分けると扱いやすくなります。
- 配布用:
--idempotentのスクリプトをそのままデプロイ成果物にする - レビュー用: FROM指定の範囲を読ませる
idempotentのスクリプトには過去分のマイグレーションもすべて入っています。過去に適用済みの履歴まで毎回読ませると、指摘が古い変更で埋まります。レビュー用に範囲を絞るのはそのためです。
Visual Studioのパッケージマネージャーコンソールでは、Script-Migration -Idempotent -Output artifacts\migrations.sqlが同じ働きをします。
危険な変更の見分け方
ClaudeにSQLを渡す前に、何を危険と呼ぶかを決めておきます。EF Core固有の事情は、ドキュメントに次のように書かれています。
- プロパティの名前を
NameからFullNameに変えると、EF CoreはDropColumnとAddColumnを生成する。そのまま適用すると、顧客名がすべて失われる - 意図が改名なら、
RenameColumnに書き換える必要がある - データ損失の可能性がある操作(列の削除など)があると、マイグレーションの作成時に警告が出る
この「改名のつもりが削除と追加になる」は、SQLにするとDROP COLUMNとADDが同じテーブルに並ぶ形で現れます。CLIの警告を見落としたままコミットされた場合の、最後の網になります。
拾わせる観点を、次の表にまとめます。表の右列はこの記事で決めた判定ルールで、プロンプトにそのまま写します。
| SQLの形 | 何が起きうるか | 判定 |
|---|---|---|
DROP COLUMN / DROP TABLE | 何が起きうるかデータが消える。同じテーブルに同名に近いADDがあれば改名の取り違え | 判定high |
ALTER COLUMNで型や長さを変更 | 何が起きうるか変換できない値で失敗、または値が切り詰められる | 判定high |
既存テーブルへのNOT NULL列追加でDEFAULTなし | 何が起きうるか既存行がある表では失敗、または意図しない値が入る | 判定high |
NULL許容列をNOT NULLへ変更 | 何が起きうるかNULLの行が残っていれば失敗する | 判定high |
CREATE INDEX・ADD CONSTRAINT | 何が起きうるか大きな表では時間がかかり、その間の更新を待たせることがある | 判定medium |
CREATE TABLE・NULL許容列の追加 | 何が起きうるか既存データに影響しない | 判定low |
mediumの行は、SQLの文面だけでは判断が付きません。表の行数やデータベースの種類・エディションで、待ち時間が桁違いに変わるからです。この観点は、あとの限界の節で触れます。
claude -pに読ませてJSONで受け取る
非対話モードのclaude -pは標準入力を読めます。ログやdiffをパイプで渡す使い方が、公式のヘッドレス実行の基本形です。パイプで渡す利点は、ファイルを読むためのツール許可さえ要らないことです。
cat artifacts/pending.sql | claude --bare -p \
"このEF CoreのSQLスクリプトを適用前レビューする。..." \
--output-format json \
--json-schema "$(cat review-schema.json)" \
| jq '.structured_output'ポイントは3つあります。
--bare: CIで毎回同じ結果にするため、ローカルのhooksやCLAUDE.md、自動メモリーの自動読み込みを切る。ログインではなくANTHROPIC_API_KEYで認証する--output-format jsonと--json-schema: スキーマに沿った結果がstructured_outputフィールドに入る- 標準入力の上限は10MB。超えると非ゼロの終了コードで止まる
--json-schemaの値は、ファイルから$(cat ...)で渡すと長いスキーマを読みやすく保てます。review-schema.jsonの例です。
{
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"statement": {"type": "string"},
"table": {"type": "string"},
"severity": {"enum": ["high", "medium", "low"]},
"reason": {"type": "string"},
"suggestion": {"type": "string"}
},
"required": ["statement", "table", "severity", "reason"]
}
}
},
"required": ["findings"]
}プロンプト本体は、判定ルールを表と同じ中身で渡します。たとえば次のように書きます。
標準入力は dotnet ef migrations script の出力(SQL Server向け)。
次のルールで文ごとに判定し、該当した文だけ findings に入れる。
- DROP COLUMN / DROP TABLE は high。同じテーブルに ADD COLUMN が
並ぶなら、改名の取り違えの可能性を reason に書く
- ALTER COLUMN による型・長さの変更は high
- 既存テーブルへの NOT NULL 列追加で DEFAULT が無ければ high
- NULL許容から NOT NULL への変更は high
- CREATE INDEX と ADD CONSTRAINT は medium
- 該当なしなら findings は空配列にする
SQLに書かれていない事実(行数、ロックの長さ)は推測しない。最後の1行が効きます。SQLの文面から言えること以外を断定させないと、「おそらく数分ロックされます」のような根拠のない見立てが混ざります。
返ってくるJSONは、たとえば次のような形になります(スキーマに沿った例示であり、実際の指摘内容はスクリプトごとに変わります)。
{
"findings": [
{
"statement": "ALTER TABLE [Customers] DROP COLUMN [Name];",
"table": "Customers",
"severity": "high",
"reason": "列の削除。同じ表に FullName の追加があり、改名の取り違えの可能性",
"suggestion": "RenameColumn に書き換える"
}
]
}highがあればCIを落とす
結果が構造化されているので、シェルで判定できます。jq -eは、式の結果がfalseかnullのとき非ゼロで終了します。
cat artifacts/pending.sql | claude --bare -p "$(cat review-prompt.txt)" \
--output-format json \
--json-schema "$(cat review-schema.json)" > review.json
jq -r '.structured_output.findings[]
| "[\(.severity)] \(.table): \(.reason)"' review.json
jq -e '[.structured_output.findings[]
| select(.severity == "high")] | length == 0' review.jsonstructured_outputは、実行が有効な構造化出力を返せなかったとき、レスポンスに含まれません。そのままだとjqがnullを踏んで動作が曖昧になるため、has("structured_output")を先に確かめるガードを1行足しておくと安全です。
CI側では、ヘッドレス実行の呼び方をGitHub Actionsの引数まとめと合わせて決めます。ログのような大きな入力を扱うときの10MB上限は、パイプ入力の10MB制限の記事にまとめています。
スクリプトを作る環境を固定する
dotnet efは、DbContextを組み立てるためにアプリケーションのコードを実行します。プロバイダーの選択、接続文字列、モデルの設定が、実行時の環境に左右されるということです。
環境変数ASPNETCORE_ENVIRONMENTもDOTNET_ENVIRONMENTも設定されていないときは、設計時ツールはDevelopment環境として動きます。開発用の設定でプロバイダーが変わると、スクリプトのSQLの方言まで変わりえます。たとえば開発はSQLite、本番はSQL Serverという構成では、レビューしたSQLが本番向けではありません。
デプロイ用のスクリプトを作るときは、環境を明示します。
ASPNETCORE_ENVIRONMENT=Production \
dotnet ef migrations script --idempotent \
--output artifacts/migrations.sqlこの環境指定は、レビューに回すSQLと本番に流すSQLを一致させるためのものです。スクリプトの生成はCIで1回だけ行い、同じファイルを「Claudeに読ませる」と「デプロイに使う」の両方に渡すと、読んだものと流すものが食い違いません。
あわせて、マイグレーションを実際に適用するIDには、スキーマ変更の権限を持つ別のIDを使い、アプリの実行時のIDにはデータの読み書きに必要な権限だけを持たせます。接続文字列はソース管理に置かず、デプロイ側の秘密情報ストアから渡します。この点はClaude Codeの手元に本番の接続情報を置かない理由と同じです。
手元で使うためのCLAUDE.mdと権限
手元でClaude Codeに対話的に直させるときは、CLAUDE.mdに判定ルールを書いておくと、同じ観点で毎回読ませられます。
## EF Coreのマイグレーション
- マイグレーションを追加したら `dotnet ef migrations script <適用済みの最新>`
を実行し、出力のSQLを読んでから終える
- DROP COLUMN、型変更、既定値なしのNOT NULL追加が出たら、
改名やデータ移行が必要でないかをユーザーに確認する
- `dotnet ef database update` は実行しない(適用は人が行う)最後の1行は、権限の側でも固定できます。dotnet ef migrations scriptは読み取りの扱いにとどめ、データベースを書き換えるdatabase updateは確認に回します。
{
"permissions": {
"allow": ["Bash(dotnet ef migrations script *)"],
"ask": ["Bash(dotnet ef database update *)"]
}
}指摘されたときの直し方
典型は、リネームをRenameColumnに直す対応です。ドキュメントにあるとおり、生成されたDropColumnとAddColumnの組を、次のように置き換えます。
migrationBuilder.RenameColumn(
name: "Name",
table: "Customers",
newName: "FullName");列を差し替えるが値は残したい場合は、移行元のデータを移行先に入れ終えるまで消さない順序にします。値を計算して入れ直すときはmigrationBuilder.Sql()を使います。固定値の投入にはInsertData・UpdateData・DeleteDataが使え、これらはスクリプトやバンドルの生成でもプロバイダー向けのSQLになります。
また、Downは元の値を再現できるときだけ実装します。再現できない変換は、Downで明示的に失敗させ、ロールバックはバックアップからの復元で行う前提にします。
直したらスクリプトを再生成して、同じレビューをもう一度回します。ここまでが1周です。
この方法で見えないもの
SQLの文面から判断できることには限りがあります。次の3点は、別の手段で補います。
- 行数と実行時間:
CREATE INDEXが数秒か数十分かは、表の大きさで決まる。ステージングに本番相当のデータを置いて実測する - データ移行の正しさ:
migrationBuilder.Sql()で書いたUPDATEが意図どおりかは、SQLを読むだけでは保証できない。コピーしたDBで流して結果を比べる - トランザクションの扱い: EF Coreは通常、各マイグレーションを個別のトランザクションで包む。EF Core 9ではすべての保留中マイグレーションを1つのトランザクションにまとめ、EF Core 10で元に戻っている。
--no-transactionsを付けるとスクリプトからトランザクション文が除かれる
3つ目は、レビューの前提にも響きます。トランザクション文を除いたスクリプトを別の手段で流すときは、途中で失敗した場合に中途半端な状態が残りうるためです。
ロックの強さまで踏み込みたいときは、データベース側の知識が要ります。PostgreSQLを使う場合の見方はPostgresのマイグレーションをロックレベルで確かめる記事に、同じ発想のDjango版はDjangoのsqlmigrateを読む記事、Rails版はstrong_migrationsの記事にあります。EF CoreではSQL Serverを使うことが多いので、上の判定ルールはプロバイダーを書き換えて使います。
まとめ
ClaudeにC#のマイグレーションを読ませるより、dotnet ef migrations scriptのSQLを渡すほうが、見たい変更が文の形で現れます。判定ルールを表で決め、--json-schemaで結果を構造化し、highがあればCIを落とす。ここまでを自動化しても、行数や実行時間、データ移行の正しさは人とステージングの仕事です。その分担を決めておけば、DropColumnが紛れ込んでも、本番に届く前に止められます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。