綴Photo の勉強ノートに戻る
Flutter技術2026.06.19

識別の列を1本から2本に分けたら、5バージョン前のマイグレーションが静かに壊れていた

FlutterDriftマイグレーションコードレビュー綴Photo

「また今度」した写真の識別を1列から2列に分ける作業の記録。制約を1個外したら、5バージョン前のマイグレーションが静かに壊れていた。テストは全部緑だった。

綴Photo には「今は言葉が浮かばない写真を先送りする」機能があります。先送りした写真は DB に1行ずつ記録していて、その識別を assetId という1つのカラムだけで持っていました。今回はそれを2カラムに分ける作業をした。最後に思わぬ所が壊れていたのに気づいたので、その顛末を残す。

1列に2つの意味を持たせていた

先送り写真の識別カラムには、実は種類の違う2つの値が入っていました。

  • スマホ版では、カメラロールの写真を指す ID
  • PC版では、フォルダ内のファイルのパス

同じ assetId という1列に、ID とパスという別物が混ざっていました。呼び出す側は「今これは ID なのかパスなのか」を暗黙に覚えておく必要があります。写真本体を持つ別のテーブルはとっくに assetId / localPath の2列に分けてあったのに、先送りテーブルだけがこの古い1列のまま取り残されていました。

種類が違うものを1列に詰めるのは、いつか取り違えます。分けることにした。

分け方とスコープを決める

分け方は、写真本体テーブルと同じ assetId / localPath の2列に揃える形にした。新しい流儀(種別カラム+値カラム、みたいな)を持ち込むと、同じ「写真の識別」に2つのやり方が並んで、それはそれで不整合になります。既にある形に合わせるのが一番おさまりがいいです。

ついでに、写真本体テーブル側で見つけた別の小さい穴も一緒に塞ぐと決めた。詳細は省くが「新規インストールの端末にだけ、ある index が張られていない」という状態で、隣を触るついでなら低コストで揃えられる、という判断だった。

移行で踏んだ小さい罠 2つ

本題の前に、移行で引っかかった2つ。どちらもすぐ抜けられた。

1つ目。スマホの写真 ID は、iOS では …/L0/001 のようにスラッシュを含みます。だから「スラッシュがあればパス」みたいな文字列での見分けは効きません。幸い1台の端末はスマホかPCのどちらか一方なので、移行時はファイルの値を覗くのではなく、動いている OS で「この端末のデータは全部 ID/全部パス」と確定させた。OS 判定を直接書くとテストがホスト依存になるので、テストから差し替えられる入口だけ用意しておいた。

2つ目。Drift(使っている DB ライブラリ)でテーブルを作り直すとき、新しく足した localPath カラムを古いテーブルからコピーしようとして「そんなカラムは無い」で止まった。「これは新規カラムだから古い方からは持ってこない」と明示する指定が要る、というだけの話。

dart
// 新規カラムは newColumns に挙げて「旧テーブルからコピーしない」と教える
await m.alterTable(TableMigration(
  passedPhotos,
  newColumns: [passedPhotos.localPath],
));

本題 — 制約を1個外したら、遠くが壊れた

今回、先送りテーブルの assetId から UNIQUE 制約(重複を許さない指定)を外した。2列に分けるのに伴って、一意性の担保を別のやり方に移したからだ。完全にローカルな変更のつもりだった。先送りテーブルの定義をいじっただけ。

ところが。5バージョン前のマイグレーション(このテーブルを最初に作った所)が、こういう SQL を実行していた。

sql
-- 5バージョン前。先送りテーブルへ初期データを移していた所
INSERT INTO passed_photos (...)
SELECT ... FROM skip_status
ON CONFLICT(asset_id) DO NOTHING  -- ← asset_id に UNIQUE 制約があることが前提。制約がないとここでエラー

ON CONFLICT(asset_id) は、「asset_id がぶつかったら何もしない」という重複回避の書き方で、これは asset_id に対応する UNIQUE 制約が存在しないと成立しません。SQLite は制約が無いと「その ON CONFLICT に合う制約が無い」とエラーを投げます。

ここで効いてくるのが、さっきの Callout の話。DB を作り直すとき、Drift は「今のテーブル定義」から CREATE TABLE を組みます。つまり、5バージョン前のマイグレーションが走る瞬間でも、作られるテーブルは今回 UNIQUE を外した最新の定義になります。制約の無いテーブルに、制約を前提にした ON CONFLICT が走ります。

図にすると、下でやった変更が上の手順に回り込んでいる。

起動時に再生される手順v5 → v6① CREATE TABLE passed_photos② INSERT … ON CONFLICT(asset_id)対応する UNIQUE が無い=起動できないv6 → v7⋮v10 → v11(今回)asset_id の UNIQUE を外すいまのテーブル定義(Dart)passed_photosasset_id ← UNIQUE なしlocal_pathテーブルはここから作るここでの変更がこの定義に入る

結果:古いバージョンで止まっていた端末がアップデートすると、起動時のマイグレーションでこの行が例外を吐いて、アプリが立ち上がらなくなります。しかも ON CONFLICT は対象データが0件でも、文を準備した時点で「合う制約が無い」と弾かれます。データの有無に関係なく、必ず落ちます。

そして — テストは全部緑だった。理由はあとで分かった。マイグレーションのテストは v8 から始めていて、問題の v5→v6 を一度も通っていなかった。緑は「テストが踏んだ範囲が緑」でしかなくて、踏んでいない古い経路は丸ごと死角だった。

テストのカバー範囲が「最近のバージョンから」になりやすいのには構造的な理由があります。マイグレーションを書いた時点では古い経路は全部通っていて、新しいバージョンを足すたびに「最新のテスト」を書き足していきます。すると自然に「今動かしているバージョン近辺のテスト」が厚くなり、初期バージョンの手順は「一度書いたから大丈夫」と暗黙に前提になっていきます。制約を外したような横断的な変更は、この「昔の経路は触っていない」という前提を静かに崩します。

これを見つけたのは、実装が一通り終わった後に、わざと粗探しさせる多視点のレビューに通したから。マイグレーション・制約・DBへの読み書き役(DAO)・呼び出し側を、それぞれ別の観点で「データが壊れる所はないか」と探させた。そのうちの一つが、この5バージョン前の ON CONFLICT を掘り当てた。

実装した当人は、意識が「今いじった先送りテーブルの周り」に張り付いていて、5バージョン前のマイグレーションが今回の変更の巻き添えになる、という線はそもそも視野に入りません。直し自体は、制約に依存しない書き方(重複を別の条件で弾く)に置き換えるだけ。

sql
-- 制約に依存しない形へ。重複は WHERE NOT EXISTS で弾く
INSERT INTO passed_photos (...)
SELECT ... FROM skip_status ss
WHERE NOT EXISTS (
  SELECT 1 FROM passed_photos pp WHERE pp.asset_id = ss.asset_id
)

つまり ON CONFLICT が「入れてみて、ぶつかったら DB に握り潰してもらう」だったのに対して、NOT EXISTS は「入れる前に自分で見て、ぶつかる行はそもそも選ばない」。例外を捕まえるのをやめて、例外が起きない形にした、ということだ。

ただしどこでも同じ置き換えができるわけではありません。本来は ON CONFLICT の方が堅いです。あちらは「見る」と「入れる」が DB の中で1動作なので間に誰も割り込めませんが、NOT EXISTS は2動作なので、その隙に誰かが同じ行を入れると重複が通ります。今回それで問題にならなかったのは、条件が2つ揃っていたからだった。

  • マイグレーション中は他に書き込む人がいません — DB を開いた瞬間に単一接続で走るので、割り込む隙が存在しません
  • 移行元にも重複がありません — skip_status の asset_id 側には UNIQUE が付いたままなので、元データに同じ ID は1件しかありません

置き換えが等価になったのは、マイグレーションという場所の性質に乗っかった結果で、アプリが動いている最中のコード(複数箇所から同時に書く)で同じことをやると、今度は競合でバグります。

直したあと、v5 から通すテストを足して、修正前のコードに戻すと確かにその ON CONFLICT で落ちることも見てから、直した形に戻した。「テストが本当にそのバグを踏んでいる」を確認してから直す、という順にした。

学びポイント

  • 制約(UNIQUE 等)を外すのは、局所的に見えて遠くを静かに壊します。 その制約に寄りかかっていた離れたコードが、何も言わずに前提を失います。今回は5バージョン前のマイグレーションだった。ON CONFLICT で全文検索すれば一発ですが、触った箇所の周辺しか探さないと、そもそも調べようとも思いません。
  • テストが「最近のバージョンから」しか始まっていないと、古い経路は死角になります。 全部緑でも、それは踏んだ範囲の緑。今回マイグレーションのテストは v8 起点で、v5→v6 は誰も通っていなかった。
  • 実装した本人は、直した箇所にアンカーされて遠くが見えなくなります。 文脈をリセットした別の目(今回は観点を分けた敵対的レビュー)を実装後に一回通すのは、この盲点を埋めるのに効いた。直す手は数行でも、見つける所が本体だった。

おわりに

やったこと自体は「1列を2列に分けて、移行手順を書いて、型で取り違えを防ぐ」で、地味な整理作業のはずだった。一番危なかったのは、その整理のために外した制約が、5バージョン前の自分(が書いたわけではないが、過去のコード)の前提を崩していた所。緑のテストは安心の証明にはならない、というのを具体的な形でもう一度確認した回だった。


この記事は2026年6月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。