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

宣言しただけでは守られない — SQLite の外部キーは PRAGMA で有効化しないと強制されない

SQLiteDriftFlutterデータベース外部キー綴Photo

Claude の多段レビュー(7観点の並列レビュー+反証検証)で浮かんだ「外部キーを宣言したのに強制されていなかった」話を、勉強のためにまとめたものです。

綴Photo のモバイル版は、写真と「綴り(気持ち)」をローカルの SQLite に保存している。テーブル定義の上では「綴りは必ずどれかの写真に属する」という外部キーの関係を宣言していた。ところがレビューで分かったのは、その宣言がただのコメントと同じ状態だったということ。SQLite は黙って何も強制していなかった。まとめておく。

コードで起きていたこと

ORM(綴Photo では Drift を使っている)のテーブル定義では、外部キーをこう宣言していた。

dart
// 綴りテーブルの定義(写真IDが写真テーブルを参照する)
class Meanings extends Table {
  IntColumn get id => integer().autoIncrement()();
  IntColumn get photoId => integer().references(Photos, #id)(); // ← 外部キー宣言
  // ...
}

.references(Photos, #id) の部分が「この photoId は写真テーブルの id を指す外部キーだ」という宣言です。

ところが SQLite は、接続のたびに PRAGMA foreign_keys = ON を実行しない限り、この REFERENCES を一切強制しません。そして SQLite のデフォルトはこのスイッチが OFF になっています。Drift(正確には接続を作る drift_flutter)も、自動ではこのスイッチを入れてくれません。

宣言は端末 SQLite のスキーマに残る。スイッチは接続のメモリにあり、保存しない。端末 SQLite のスキーマmeanings.photo_id REFERENCES photos(id)Dart の .references() がこう書くファイルに残る。強制はしない接続のスイッチPRAGMA foreign_keys = OFF既定。Drift は自動で入れない接続のメモリ。保存しない端末 SQLite の行(閉じても残る)photos に id=1。meanings に photo_id=1(親あり)photo_id=999 の綴りも、OFF だと無警告で入る行は photos と meanings に保存する。スイッチはどのテーブルにも書かない。

なぜそうなりやすいか

これは SQLite の歴史的な経緯によります。外部キー制約は SQLite に後から追加された機能で、既存のアプリを壊さないために後方互換としてデフォルト OFF にされました。ONにすると、それまで素通りしていた不正な書き込みが急にエラーになり、古いアプリが動かなくなるからです。多くの他のデータベースでは外部キーは書けば効くので、その感覚で SQLite を触ると「書いたのに効いていない」という食い違いにハマります。

綴Photo の場合、今は綴りや写真を削除する機能が無いので、この問題は表に出ていなかった。だが将来「写真を削除する」「クラウドと同期する」といった機能を入れたとき、写真を消したのに綴りだけが残る、といった整合性の崩れが無警告で発生し得た。レビューはまだ顕在化していないこの穴を先回りで見つけた格好だ。

改善の方向

接続が開かれた直後に PRAGMA foreign_keys = ON を一度実行して、スイッチを入れる。Drift にはこの「DBを開いた直後に走る処理」を書く場所(beforeOpen)があるので、そこに置いた。

dart
MigrationStrategy get migration => MigrationStrategy(
  onCreate: (m) async => m.createAll(),
  onUpgrade: (m, from, to) async {
    // 既存のスキーマ移行処理(省略)
  },
  beforeOpen: (details) async {
    await customStatement('PRAGMA foreign_keys = ON'); // ← 外部キーを有効化
  },
);

ここで一つ注意点があります。綴Photo の過去のマイグレーションには、写真IDを別の写真に付け替えるデータ手術(重複した写真を1つに集約する処理)が含まれていて、その途中では一時的に外部キーの整合性が崩れる瞬間がある。もし外部キー強制をマイグレーション中に効かせると、この手術が途中でエラーになりかねません。

だが beforeOpen は マイグレーション(onUpgrade)が全部終わった後 に走ります。さらに Drift はマイグレーションの最中そのものでは外部キーを扱わない設計になっています。

接続を開いたあとの順。スイッチは接続のメモリ、行は端末 SQLite。① 端末 SQLite の行meanings の photo_id を付け替える閉じても残る一時的に親なしになり得るこの時点のスイッチは OFF接続のメモリ。保存しない② 接続のメモリPRAGMA foreign_keys = ONスイッチを入れる行は触らない保存しないテーブルには書かない③ 端末 SQLite の行孤児の INSERT は弾くphotos と meanings閉じても行は残るスイッチはこの接続が切れるまで ON。保存しない付け替えは meanings の行を書き換える。ON は接続のメモリにだけ効く。新しいスイッチと古い手術は、この順だから衝突しない。

もう一つ大事なのは、これは「挙動が変わる」変更だということ。今までスルーされていた不正な書き込みが、今後はエラーになります。だから入れる前にテストを全部通して、既存の正常な書き込みがどれもエラーにならないことを確認した上で入れた。「制約を厳しくする」変更は、それ自体がリグレッションを生む可能性があります。安全機能でも、足す前にテストの全通過を確認したのはそのためだ。

学びポイント

  • SQLite では「外部キーを宣言する」と「外部キーを守らせる」は別。PRAGMA foreign_keys = ON を接続ごとに実行しないと、REFERENCES はただのドキュメントになる
  • デフォルト OFF は後方互換のための歴史的経緯。他のDBの感覚で「書けば効く」と思い込むと食い違う
  • 有効化は「挙動が変わる」変更。今までスルーされていた不正データが今後はエラーになるので、テスト全通過を確認してから入れる
  • 「今は削除機能が無いから問題が出ていない」は「問題が無い」ではない。将来の機能で顕在化する穴は先に塞いでおける

この同じ綴りテーブルの外部キーには、もう一つ別の落とし穴があった。「外部キーの列にインデックスが自動で張られない」という話で、別記事「外部キーを張っても、索引は付いてこない」にまとめた。どちらも「スキーマに書いた/宣言しただけでは、思った挙動になっていない」という根は同じです。

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