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

失敗を『成功』に化けさせる穴埋め — null と空文字を取り違えると、やり直しの機会ごと消える

DartFlutterデータ保全エラーハンドリング綴Photo

Codex のフルレビューを Claude が観点別に検証する中で出てきた、綴Photo モバイル版で一番重い指摘(静かなデータ消失)を、勉強のためにまとめたものです。

綴Photo のモバイル版は、撮った写真と「綴り(気持ち)」を端末のローカルDBに保存していて、これからクラウドへバックアップする仕組みを少しずつ足している。クラウドに上がった画像は、いまのところ端末の外に残る唯一のコピーになる予定だ。だから「上がったかどうか」の記録は、雑に扱うと写真そのものを失いかねません。

レビューで分かったのは、その記録が失敗を黙って『成功』に書き換えていたということ。サーバの応答が壊れていてURLを取れなかったとき、空文字を「アップロード済みの印」として書き込み、しかも成功件数にカウントしていた。そして一度そう書かれた写真は、二度と再アップロードの対象に戻ってきません。画面には「完了」と出るので、誰も気づけません。原因は ?? '' という一行だった。まとめておく。

何が起きていたか

アップロードのコードは、ざっくりこういう形をしていた。

dart
// サーバへ画像を送り、返ってきた応答から画像URLを取り出して記録する
final res = await api.uploadImage(photo);
await db.markImageUploaded(
  photo.id,
  extractImageUrl(res) ?? '', // ← URLを取れなかったら空文字を入れる
);
uploaded++; // ← 常に「成功」として数える

extractImageUrl(res) は、応答の中から画像URLの文字列を探して返す関数です。ただし応答が壊れている・キーが無い・本体が空、といったときは null を返します。

印は端末 SQLite に残る。応答も件数もメモリで、保存しない。届いた応答HTTP の bodyメモリ・保存しないURL を取れず null?? '' で書く端末 SQLitePhotos.remoteUrl空文字が残る閉じても残る印はここの列今回の件数uploaded++メモリ・保存しないリトライは見ない

本来、印の列には確定したURLだけが入るはずだった。サーバが200を返しさえすれば、中身が空でも「上がったことになる」状態だった。

なぜ空文字が「やり直し不能」を意味したのか

問題は、再アップロードの対象をどう選ぶかにあった。「まだ上がっていない写真」を拾うクエリは、こういう条件になっていた。

dart
// クラウドにまだ上がっていない写真 = リモートURLが NULL のもの
// ↓ この isNull() は SQL の「IS NULL」に変換される = 空文字('')はここに引っかからない
where((photo) =>
    photo.syncState.equals('synced') &
    photo.remoteUrl.isNull());

「まだ上がっていない」を NULL で表していた。

ところが SQL では、空文字は NULL ではありません。'' IS NULL は偽になります。

同じ列の2つの値。置き場所はどちらも端末 SQLite の Photos.remoteUrl。NULLまだ無い・未確定IS NULL は真リトライの網にかかる次回また拾われる空文字無いと確定した値が入っているIS NULL は偽網からこぼれる永久に対象外人間の感覚ではどちらも「無い」。判定は左だけをまだと見る。空文字は値が入っているので、NULL の網にはかからない。

しかも回復経路が無い。空文字を NULL に戻す処理はどこにも無かったので、一度こうなった写真は永久に再アップロードされません。端末の外に残るはずだった唯一のコピーが、作られないまま「完了」で固定されます。静かなデータ消失だ。

null と空文字は「別の意味」だった

ここが今回の核心だ。

?? '' は、この意味の違う2つを1つに潰す操作だった。潰した結果として消えたのは、データそのものというより「やり直す資格」だ。失敗を握りつぶすコードは、エラーを消すだけじゃなくて、回復の手がかりまで一緒に消すことがあります。

直した方向

考え方はシンプルで、「URLを確定できないなら、成功扱いにしない・記録も書かない」に寄せた。

dart
// URLを先に取り出して評価する。確定できなければ失敗として数え、remoteUrl は触らない
final imageUrl = extractImageUrl(res);
if (imageUrl == null || imageUrl.isEmpty) {
  failed++;
  continue; // remoteUrl は NULL のまま → 次回また再アップロード対象になる
}
await db.markImageUploaded(photo.id, imageUrl);
uploaded++;

空文字を書かないだけで、冪等な再挑戦が復活します。

さらに多層防御として、記録する関数の側でも空文字を弾くようにした。

dart
// remoteUrl は「確定URLのみ」が不変条件。空文字は受け付けない
if (remoteUrl.isEmpty) {
  throw ArgumentError.value(remoteUrl, 'remoteUrl', '空文字は許可しない(確定URLのみ)');
}

「呼び出し側が気をつける」だけだと、別の経路からまた空文字が紛れ込む余地が残ります。書く一番手前で弾く。

最後に、過去のバグで既に空文字が固定されてしまった写真を救う処理を、アップロードの冒頭に一つ足した。NULL に戻しても、次のアップロードで上がり直すだけなので安全です。

3つの直しは、全部同じ列を触る。新しい表は無い。取れないとき書かない。NULL のまま。書く直前空文字は拒否。入らない。アップロード冒頭既にある空文字を NULL へ。端末 SQLitePhotos.remoteUrl閉じても残るNULL なら次回拾う空文字は入れない件数の箱は増やしていない。リトライの正は、なお Photos.remoteUrl である。

学びポイント

ざっくり言うと、ここで起きていたのは同じ形のミスが2つだ。どちらも「まだ」と「済み」の境界をボカして、失敗を成功の側に置いている。

何を潰したか 結果
値 の側 ?? '' で null と空文字を1つに潰した 「まだ」が「済み」に化けた
位置 の側 uploaded++ を無条件の場所に置いた 「失敗」が「成功」に化けた

どちらも画面には「完了」としか出ないので、外からは気づけません。

  • ?? '' のような「null を無難な値で穴埋め」は、失敗を正常値に化けさせて握りつぶすことがあります。穴埋めする前に「これは本当に穴埋めしていい欠損か?」を考えます
  • null(まだ無い)と空文字(無いと確定)は別の状態。SQL では '' IS NULL は偽なので、リトライ条件を NULL で書いているなら空文字を混ぜた時点で分類が変わります
  • リトライの設計が「対象=NULL のもの」なら、失敗時は値を書かずに NULL のまま残すのが冪等の素直なやり方。中途半端な値を書くとリトライの網から落ちます
  • 「成功カウント」と「確定値を記録」は分けて考えます。URLを取れていないのに uploaded++ していたのは、その2つが同じ行に同居していたから
  • 失敗を握りつぶすコードは、エラーだけでなく回復の手がかりも一緒に消すことがあります。最後の砦(DBに書く直前)に不変条件の壁を立てておくと、別経路からの再発も止められます

おわりに

?? '' 自体はよく見る書き方で、多くの場面では本当に無害です。今回まずかったのは、その空文字がリトライの判定に使われている列に流れ込んでいたこと。「無難なデフォルト値」がどこへ行き着くかまで追うと、無害なはずの穴埋めが、回復不能なデータ状態を作っていた。

レビューがこれを見つけられたのは、?? '' の一行だけを見たからではなく、「この remoteUrl は後でどう読まれるのか」=再アップロードの判定条件まで辿ったからだった。値を書く場所と、その値を読む場所を別々に見ていると「null と空文字の取り違え」は見えてきません。書き手と読み手をつなげて初めて、穴埋めの行き先に穴があると分かります。


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