ちょっと怖いことに気づいた
栽培管理アプリを開発していて、ある日こんな問題に気づきました。
マスタを全削除すると、過去の作業履歴も消える。
マスタとは?(補足)
このアプリには農薬・肥料・作業の3種類のマスタがあります。作業を登録するとき、マスタにあらかじめ登録しておくことで、作業時は選ぶだけで登録できます。入力ミスも防げるので、履歴の品質も安定します。関連記事はこちら。
開発中は気づきにくいのですが、本番運用では大事故レベルです。今回はその原因と、どう対策したかを書きます。
きっかけ
農薬マスタ・肥料マスタ・作業マスタには「一括削除」機能があります。

例えば農薬マスタで全選択して削除ボタンを押すと、選択した農薬がまとめて削除されます。ここまでは普通の動作です。
しかし――その農薬が過去の作業履歴で使われていた場合、マスタを削除すると履歴も消える(あるいは表示できなくなる)ことに気づきました。
なぜ履歴が消えるのか?
テーブルの構造はこうなっています。
cult_pesticides(農薬マスタ)
↑
│ pesticide_id(外部キー)
↓
cult_log_pesticides(作業履歴)
作業履歴テーブルは、pesticide_id でマスタを参照しています。
つまり流れはこうです。
- マスタを削除する
- 外部キー参照が切れる
- 履歴も削除される(または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での防御・データ設計の理解、すべてがセットで必要でした。
特に「マスタ削除=履歴に影響する」という視点は、実際に運用してみて初めて実感しました。開発中はデータが少ないので気づきにくい問題です。
