綴Photo の勉強ノートに戻る
コードレビュー学習2026.06.11

Drift のマイグレーションは「凍らない」— addColumn / alterTable が見るのは最新のテーブル定義

FlutterDriftSQLiteマイグレーションORM綴Photo

Claude の多段レビュー(7観点の並列レビュー + 反証検証)で浮かんだ「マイグレーションが凍らない」まわりの観察を、勉強のためにまとめたものです。

綴Photo のモバイル版は、写真と「綴り」を端末ローカルの DB(Drift / SQLite)に保存している。アップグレード経路を実際に通すテスト(別記事)を入れたら、Drift(や同種の ORM)固有の罠が見えてきた。「過去に書いたマイグレーションは凍っている」という思い込みが、実は成り立たないという話だ。

コードで起きていたこと

マイグレーションの中で列を追加するとき、Drift では宣言的にこう書けます:

dart
await m.addColumn(photos, photos.title); // 写真テーブルに title 列を足す
実データは端末 SQLite の photos。定義は tables.dart のコードで、書いた当時の形は保存しない。tables.dart最新の Photos クラスweb_selected 在るfile_size 無い書いた当時の形は保存しない実行のたびに読むapp_database.dartマイグレーション関数m.addColumn / m.alterTablephotos は最新定義への参照スナップショットは無い端末 SQLite の photos実データの列。閉じても残る。定義の履歴はここにも無い。

直感的には「ここに書いた photos.title という列定義を1つ足す」だけに見える。

何が問題か。将来、写真テーブルに新しい列を足したとします。すると過去に書いた alterTable は、実行時に最新形(新しい列を含むテーブル)で作り直そうとします。古いバージョンから順に上げてくると、途中の alterTable 段階で既に最新形のテーブルができてしまい、その後ろの段にある「その新しい列を addColumn する」処理が duplicate column(列が既にある)で落ちる、という事故が起き得ます。「過去のマイグレーションは凍結されている」という思い込みが裏切られる形です。

実際このコードベースには、過去に同種の罠を踏んだ痕跡が残っていた。本来なら宣言的に addColumn で足せるはずの列を、わざわざ生 SQL で書いている箇所がある(v2→v3 の段):

dart
// fileSize カラムは v7(v6→v7 の alterTable)で削除されるが、この段では追加が必要だったため raw SQL で対応
await customStatement('ALTER TABLE photos ADD COLUMN file_size INTEGER');

この file_size 列は v7(v6→v7 の alterTable)で削除される列だ。最新のテーブル定義にはもう存在しない。だから m.addColumn(photos, photos.fileSize) と宣言的に書こうにも、最新スキーマにその列が無いので参照できない。「最新形を見る」性質のせいで、後で消す予定の列をマイグレーションで一時的に足すには、定義に頼らず生 SQL で直書きするしかなかった、というわけだ。コメントはその回避の理由を残したものだった。

もう一段、より直接的な実例がある。v6→v7 では alterTable でテーブルを再作成する処理が入っているが、この alterTable が参照する「今のテーブル定義」には、後になって v11→v12 で足した web_selected 列が含まれてしまう。つまり v5 以前から順番に上げてくると、v6→v7 の段で既に web_selected 入りのテーブルが作られてしまい、その後の v11→v12 で addColumn を呼ぼうとすると duplicate column で落ちる。これを防ぐために v11→v12 は列の存在を確認してから足す「冪等ガード」になっている:

dart
// v11→v12: web_selected を追加するが、v6→v7 の alterTable で既に作られている可能性がある
final colCheck = await customSelect(
  "SELECT 1 FROM pragma_table_info('photos') WHERE name = 'web_selected'",
).get();
if (colCheck.isEmpty) {
  await m.addColumn(photos, photos.webSelected);
}

この2つは症状が真逆だが、根は同じで、起点も同じ v6→v7 の alterTable だ。この段の本来の仕事は「fileSize を消す」ことだ。

実データは端末 SQLite の photos。最新の列定義は tables.dart のコードで、書いた当時は保存しない。tables.dart 最新の Photosweb_selected 在る / file_size 無い(コード。保存しない)alterTable が最新形で作り直す端末 SQLite photosv2→v3 のあとfile_size 在るweb_selected 無い生 SQL で足した。定義を見ない端末 SQLite photosv6→v7 のあとfile_size 無いweb_selected 在る最新形。列が前倒しで生える端末 SQLite photosv11→v12 で addColumnfile_size 無いweb_selected 既に在るduplicate column になりうる同じ「最新を見る」が、無い列は宣言で書けず生 SQL、在る列は再作成で前倒しになる。定義の履歴は tables.dart にも SQLite にも残らない。

「マイグレーションは凍っている」という前提で書くと、後から別の段が副作用を起こしたときにこのガードが必要になる理由に気づけません。アップグレード経路を順に踏むテストがあれば、この衝突は実行時に必ず表面化します。

なぜそうなりやすいか

「マイグレーションは過去の事実の記録だから、一度書いたら凍る」というメンタルモデルが自然すぎるからです。Git のコミットや会計の仕訳のように、過去の1段は確定して動かないものという前提で読まれやすいです。

でも Drift の宣言的 API はそう動きません。マイグレーション関数は「書いた時点」と「実行される時点」でスキーマ認識がズレます。しかもこのズレは、新規作成テストでは絶対に表面化しません。新規作成は最新形を一気に作るだけで、途中段を順に踏まないからです。アップグレード経路を実際に通すテストを入れて初めて、この種の事故を機械的に拾えるようになります。

declarative で書ける所・生 SQL が要る所

この「最新を見る」性質が分かると、宣言的 API(m.addColumn / m.createTable / m.alterTable)と生 SQL(customStatement)の使い分けが見えてきます。最新スキーマに「在る」ものは declarative でいけて、生 SQL は次の3つの逃げ場に絞られます。

  1. 最新スキーマに無い構造 — 一時的に足して後で消す列(file_size)や、後で drop するテーブル。photos.fileSize というシンボルが最新定義に無いので、そもそも宣言的に書けない。
  2. データの変形 — 行の統合・付け替え・移動(v8→v9 の重複解消)。スキーマ API は DDL 担当で、UPDATE / DELETE / INSERT … SELECT のような DML は守備範囲外(単純な更新はクエリビルダでも書けるが、相関サブクエリは生 SQL が現実的)。
  3. Drift が表現できない SQL — 部分 UNIQUE インデックス(CREATE UNIQUE INDEX … WHERE …)や IF NOT EXISTS の冪等性など。

効くのは、①の生 SQL が「仕方なく」ではなく正しい選択だという点です。生 SQL で書く=その段を最新スキーマから切り離して凍らせること。後で消える列を最新定義に縛りつけないための手段になります。declarative は便利だが常に最新を見る/生 SQL は冗長だが凍る——「この段は凍らせたい」ときに生 SQL を選びます。

しかもオール・オア・ナッシングではありません。さきほどの webSelected ガードは、存在確認は生 SQL(pragma_table_info)・実際の追加は declarative(m.addColumn)の混在だった。在る部分は宣言的に、表現できない部分だけ生 SQL に、と混ぜられます。

改善の方向

  • 後で削除する列・最新定義に残らないものをマイグレーションで触るときは、生 SQL(customStatement)で直書きして、最新のテーブル定義への参照を断つ。そして「なぜ宣言的に書かないのか」をコメントで残す(このコードベースはそうしていた)
  • alterTable のようにテーブルを再作成する操作は特に慎重に。実行時の最新形で作り直されるので、後続の段との順序矛盾が起きやすい
  • そして何より、古いバージョンから実際に上げ切るテストを1本持つこと。順序の事故は「全段を順に踏む」ことでしか再現しない

学びポイント

  • Drift の addColumn / alterTable が参照するのは「書いた当時」ではなく「最新のテーブル定義」。マイグレーションは凍らない
  • 後で消す列をマイグレーションで足すような「最新定義に残らない操作」は、生 SQL で書いて参照を断つ
  • 最新スキーマに「在る」ものは declarative、無いもの/データ変形/Drift が表現できない SQL は生 SQL。生 SQL はその段を最新から切り離して「凍らせる」道具でもある
  • この種の順序事故は新規作成テストでは絶対に出ず、「気をつける」でも防げない。旧バージョンから上げ切るテストだけが機械的に拾える

おわりに

「過去に書いたマイグレーションは凍っている」——Git のコミットや会計の仕訳のような直感は、Drift では裏切られます。

厄介なのは、「気をつける」では防げない種類のバグだということです。後段で addColumn を書く瞬間、それは完全に正しく見える——遠く離れた v6→v7 の alterTable が未来の列を前倒しで作る、なんて手元からは見えない。しかも新規作成テストは緑のまま。「凍っている」前提が自然すぎて、疑うきっかけがありません。

だから効くのは記憶や注意力ではなく、古いバージョンから全段を順に通すテスト(別記事)です。これが duplicate column を実行時に機械的に叩き出します。気づいて初めて「ここはガードと newColumns が要る」と書けます。注意して書く→テスト、ではなく、テストが教えてくれる→正しく書ける、の順番です。


この記事は2026年6月時点の自分の理解で書いています。Drift / SQLite のマイグレーション API は変わることがあるので、最新の公式ドキュメントも合わせて参照してください。