栽培管理アプリを作ってます⑭-3:マスタを削除したら履歴も消えた話【削除設計とデータ保護】

目次

ちょっと怖いことに気づいた

栽培管理アプリを開発していて、ある日こんな問題に気づきました。

マスタを全削除すると、過去の作業履歴も消える。

マスタとは?(補足)
このアプリには農薬・肥料・作業の3種類のマスタがあります。作業を登録するとき、マスタにあらかじめ登録しておくことで、作業時は選ぶだけで登録できます。入力ミスも防げるので、履歴の品質も安定します。関連記事はこちら

開発中は気づきにくいのですが、本番運用では大事故レベルです。今回はその原因と、どう対策したかを書きます。


きっかけ

農薬マスタ・肥料マスタ・作業マスタには「一括削除」機能があります。

例えば農薬マスタで全選択して削除ボタンを押すと、選択した農薬がまとめて削除されます。ここまでは普通の動作です。

しかし――その農薬が過去の作業履歴で使われていた場合、マスタを削除すると履歴も消える(あるいは表示できなくなる)ことに気づきました。


なぜ履歴が消えるのか?

テーブルの構造はこうなっています。

cult_pesticides(農薬マスタ)
        ↑
        │ pesticide_id(外部キー)
        ↓
cult_log_pesticides(作業履歴)

作業履歴テーブルは、pesticide_id でマスタを参照しています。

つまり流れはこうです。

  1. マスタを削除する
  2. 外部キー参照が切れる
  3. 履歴も削除される(またはNULLになって表示できなくなる)

これはデータベースの外部キー制約による自然な動作なのですが、使う側の視点では「履歴が消えた」という事態になります。

外部キーとは?(初心者向け補足)
別のテーブルのIDを参照する仕組みです。「この履歴はこの農薬マスタのデータを使っている」という紐付けです。参照先が消えると、参照元のデータも整合性が取れなくなります。


対応の選択肢を考えた

対策としていくつか検討しました。

① 従来通り完全削除

シンプルですが、事故リスクが高い。却下。

② 論理削除(is_deleted)

is_deleted = true にして一覧から非表示にする方法です。データは残るので安全ではあります。

ただし今回は採用しませんでした。理由は以下の通りです。

  • クエリに毎回 WHERE is_deleted = false の条件が必要になる
  • 履歴取得時には逆に除外条件を外す必要がある
  • 現在のアプリ規模では少し過剰
  • 削除しないのでデータが蓄積し続ける(使わなくなった農薬もDBに残り続ける)

将来的にユーザー数やデータ量が増えたときがあまりよろしくないかと。

③ 履歴があるマスタは削除禁止

一番シンプルで安全。今回はこちらを採用しました。


実装した仕様

UI側の変更

  • 履歴があるマスタには「履歴あり」バッジを表示
  • 削除ボタンを無効化

Server Action側でも保険をかける

UIだけで防ぐのは危険です。フロントのバグやAPIへの直接アクセスに備えて、Server Action側でも削除前にチェックを入れました。

// 削除対象のIDが履歴で使われていないか確認
const { data: used } = await supabase
  .from("cult_log_pesticides")
  .select("pesticide_id")
  .in("pesticide_id", ids);

// 使われていれば削除をブロック
if (used.length > 0) {
  return {
    success: false,
    blockedIds: used.map(u => u.pesticide_id),
  };
}

注意: 上記はロジックの概念を示すイメージコードです。

これで、フロントがバグっても・APIを直接叩かれても、削除は実行されません。



まとめ

今回やったこと:

  • 外部キー参照によって「マスタ削除=履歴消失」が起きることを理解した
  • 履歴があるマスタは削除禁止に仕様変更
  • UIで理由を表示(モーダルなし、その場で表示)
  • Server側でも削除をブロック
  • 論理削除は今回は採用せず

削除は一番怖い操作です。UIでの防御・Serverでの防御・データ設計の理解、すべてがセットで必要でした。

特に「マスタ削除=履歴に影響する」という視点は、実際に運用してみて初めて実感しました。開発中はデータが少ないので気づきにくい問題です。


目次