1 分

まずPostgreSQLの制約を優先する:AIによるエッジケースのバグを早期に防ぐ

コードやテストより先に、NOT NULL/CHECK/UNIQUE/FOREIGN KEY といった PostgreSQL の制約に頼ることで、AI生成アプリをより安全にデプロイしましょう。

まずPostgreSQLの制約を優先する:AIによるエッジケースのバグを早期に防ぐ

なぜ AI が書いたコードは実データで壊れやすいのか

AI が生成したコードはハッピーパスを扱えているように見えることが多いです。でも本番アプリは「中間の混乱」で壊れます:フォームが null の代わりに空文字を送る、バックグラウンドジョブが再試行で同じレコードを二重に作る、あるいは親行が削除されて子が孤立する。こうしたバグは珍しくありません。必須フィールドが空になる、ユニークなはずの値が重複する、参照先が存在しない孤立行が残る、といった形で現れます。

これらはコードレビューや基本的なテストをすり抜けがちです。理由は単純で、レビュアーは意図を見るので細かなエッジケースまでは追い切れません。テストも典型例をいくつかカバーすることが多く、数週間分の実ユーザーの振る舞いや CSV インポート、ネットワークの再試行、並行リクエストまでは検証しません。アシスタントが生成したコードなら、空白のトリムや範囲チェック、レースコンディションの防止といった小さな重要チェックを見落とすことがあります。

「まず制約、次にコード」とは、データベースに非交渉のルールを置いておき、どの経路から書き込まれても悪いデータが保存されないようにする考え方です。アプリ側ではユーザー向けのわかりやすいエラーメッセージを出すために入力検証を残すべきですが、真実を担保するのはデータベースです。そこに PostgreSQL の制約が威力を発揮します。広範なミスからあなたを守ってくれます。

簡単な例を挙げます。小さな CRM を想像してください。AI が生成したインポートスクリプトが連絡先を作ります。ある行はメールが ""(空文字)で、別の二行は大文字小文字だけ違う同じメールが重複し、さらに一件は参照する account_id が別プロセスで削除されたため存在しない。制約がなければ、これらは本番にそのまま入り込み、後でレポートを壊します。

適切なデータベースルールがあれば、そうした書き込みはすぐに失敗します。発生源に近いところで止められるのです。必須フィールドが欠けていれば保存できず、再試行で重複が混入することを防ぎ、参照が削除されたり存在しない行を指すことを許さず、値が許容範囲外であれば弾きます。

制約で全てのバグが防げるわけではありません。UI が分かりにくい、割引計算が間違っている、クエリが遅い、といった問題は解決できません。しかし、悪いデータが静かに蓄積されるのを止められるため、「AI生成が引き起こすエッジケースのバグ」が高コストになるのを防げます。

なぜデータベースを最後の防衛線にすべきか

アプリはたいてい一つのコードベースと一人のユーザーだけの世界ではありません。典型的なプロダクトにはウェブ UI、モバイル、管理画面、バックグラウンドジョブ、CSV インポート、サードパーティ統合など、複数の経路があります。それぞれの経路がデータを作成・変更します。すべての経路が同じルールを覚えている必要があると、一つは忘れます。

データベースはそれらが共有する唯一の場所です。最終的な門番として扱えば、ルールは自動的にすべてに適用されます。PostgreSQL の制約は「常にそうだと仮定する」から「必ずそうでなければならない、さもなければ書き込みは失敗する」へと変えてくれます。

AI が生成したコードがあると、これはさらに重要になります。モデルは React のフォームバリデーションを追加しても、バックグラウンドジョブの角ケースを見落とすかもしれません。あるいはハッピーパスはうまくいっても、本物のユーザーが予期しない入力をすると壊れます。制約は悪いデータが入ろうとした瞬間に検出し、数週間後の奇妙なレポートで原因を探す必要をなくします。

制約をスキップすると、悪いデータは静かに入り込みます。保存は成功し、アプリは先に進み、問題はサポートチケット、請求のずれ、信用できないダッシュボードとして後で現れます。履歴を直すのは高コストです。過去のデータを修正しなければならないからです。

悪いデータは日常の状況から忍び込みます:新しいクライアントアプリのバージョンがフィールドを "空" にする、再試行で重複が生まれる、管理画面の編集が UI チェックを迂回する、インポートファイルのフォーマットが不揃い、あるいは二人のユーザーが同時に関連レコードを更新する、などです。

実践的な考え方はこうです:境界でしか有効なデータを受け入れない。その境界にはデータベースを含めるべきです。データベースはすべての書き込みを見ているからです。

NOT NULL:必須データの欠落を早く止める

NOT NULL は最も単純な PostgreSQL の制約で、意外に多くのバグを防ぎます。行の意味を成すために値が必須なら、データベースにその enforcement を任せましょう。

NOT NULL は識別子や必須の名前、タイムスタンプに対して妥当なことが多いです。有効なレコードを作るのに値が必要なら、空にさせないでください。小さな CRM でオーナーや作成時刻がないリードは「部分的なリード」ではなく、後で奇妙な挙動を引き起こす壊れたデータです。

NULL は AI が生成したコードで侵入しやすいです。なぜなら「オプショナル」な経路を気づかず作ってしまうことがあるからです。フォームが UI 上は任意、API がキー不在を受け入れる、あるいは作成関数のある分岐が値の割り当てをスキップする。すべてはコンパイルし、ハッピーパステストは通ります。しかし実ユーザーが CSV をインポートして空セルが入ると、あるいはモバイルクライアントが異なるペイロードを送ると NULL が入り込みます。

よいパターンは、システムが管理するフィールドに対して NOT NULL と妥当なデフォルトを組み合わせることです:

  • created_at TIMESTAMP NOT NULL DEFAULT now()
  • status TEXT NOT NULL DEFAULT 'new'
  • is_active BOOLEAN NOT NULL DEFAULT true

ただしデフォルトが常に正解というわけではありません。emailcompany_name のようなユーザー提供のフィールドを NOT NULL を満たすためだけにデフォルト化しないでください。空文字は NULL より「より正しい」わけではなく、問題を隠すだけです。

迷ったら、その値が本当に「未知」なのか、それとも別の状態を表すのかを判断してください。「まだ提供されていない」が意味を持つなら、値を nullable にして別途状態カラムを追加するほうがいい場合があります。例えば phone を nullable にして phone_statusmissingrequestedverified のようにしておけば意味が一貫します。

CHECK:ビジネスルールをデータに近い場所で表現する

CHECK 制約はテーブルが約束するものです:すべての行は常にそのルールを満たさなければなりません。行内の値だけに依存するルール(数値の範囲、許容値、列間の単純な関係)に対して導入すると効果的で、コード上は問題なさそうに見えて実際には意味のないレコードを防げます。

-- 1) Totals should never be negative
ALTER TABLE invoices
  ADD CONSTRAINT invoices_total_nonnegative
  CHECK (total_cents >= 0);

-- 2) Enum-like allowed values without adding a custom type
ALTER TABLE tickets
  ADD CONSTRAINT tickets_status_allowed
  CHECK (status IN ('new', 'open', 'waiting', 'closed'));

-- 3) Date order rules
ALTER TABLE subscriptions
  ADD CONSTRAINT subscriptions_date_order
  CHECK (end_date IS NULL OR end_date >= start_date);

良い CHECK は一目で読めます。データのドキュメントとして扱ってください。式は短く、制約名はわかりやすく、パターンは予測可能にするのが良いでしょう。

CHECK が向かない場合もあります。ルールが他の行を参照したり集計したり、テーブルを横断して比較する必要がある場合(例:「アカウントがプランの上限を超えていない」)は、アプリロジック、トリガ、あるいは制御されたバックグラウンドジョブに任せてください。

UNIQUE:後で後悔する重複を防ぐ

Reduce edge-case bugs
Catch duplicate writes and missing fields early with a constraint-first build workflow.

UNIQUE 制約は単純です:定義した列(または列の組み合わせ)に同じ値を持つ二つ目の行をデータベースが許しません。これで「作成」経路が二度走った、再試行が起きた、二人が同時に同じものを送った、というクラスのバグを防げます。

UNIQUE は定義した正確な値に対して重複を許さないだけで、値が存在するか(NOT NULL)、形式に従うか(CHECK)、あるいは等価性の定義(大文字小文字や空白)まで保証するわけではないことに注意してください。

典型的にユニークにしたい場所は、ユーザテーブルのメール、外部システムから来る external_id、あるいは (account_id, name) のようにアカウント内で一意である必要がある名前などです。

注意点:NULL と UNIQUE の関係。PostgreSQL では NULL は「不明」と扱われるため、UNIQUE 制約のもとでは複数の NULL が許されます。「値が存在し、かつ一意であること」を意味するなら、UNIQUE と NOT NULL を組み合わせてください。

ユーザー向け識別子では大文字小文字を区別しない一意性が実用的です。人は “[email protected]” と書き、後で “[email protected]” と入力して同じだと期待します。

-- Case-insensitive unique email
CREATE UNIQUE INDEX users_email_unique_ci
ON users (lower(email));

-- Unique contact name per account
ALTER TABLE contacts
ADD CONSTRAINT contacts_account_name_unique UNIQUE (account_id, name);

「重複」をユーザーにとって何を意味するか(大文字小文字、空白、アカウント単位かグローバルか)を定義し、それを一回だけスキーマに記述して全ての経路が従うようにしましょう。

FOREIGN KEY:リレーションを一貫させる

FOREIGN KEY は「この行はあちらの本物の行を指していなければならない」と言います。これがないと、孤立したレコードが静かに作られてアプリを後で壊します。例えば、削除された顧客を参照するノートや、存在しないユーザー ID を指す請求書などです。

外部キーは、削除と作成が近接して起きる場合、タイムアウト後の再試行、あるいは古いデータでバックグラウンドジョブが動く場合に特に重要です。データベースは一貫性を強制するのが得意で、すべてのアプリ経路がチェックを覚えておくより確実です。

適切な ON DELETE 挙動を選ぶ

ON DELETE のオプションは現実の関係性に合うように選んでください。「親が消えたら子はどうすべきか?」を考えます。

  • RESTRICT(または NO ACTION):子が存在する場合は親の削除をブロックする。
  • CASCADE:親を削除すると子も削除される。
  • SET NULL:子は残るが参照を外す。

CASCADE は便利ですが、間違えると予想以上に多くのデータを消してしまうので注意してください。

マルチテナントスキーマ:所有権を明示的にモデル化する

マルチテナントアプリでは外部キーは正しさだけでなくアカウント間の漏えい防止にも役立ちます。一般的なパターンは、テナント所有テーブルに account_id を含め、関係を通じて所有権を結びつけることです。

CREATE TABLE contacts (
  account_id bigint NOT NULL,
  id bigint GENERATED ALWAYS AS IDENTITY,
  PRIMARY KEY (account_id, id)
);

CREATE TABLE notes (
  account_id bigint NOT NULL,
  id bigint GENERATED ALWAYS AS IDENTITY,
  contact_id bigint NOT NULL,
  body text NOT NULL,
  PRIMARY KEY (account_id, id),
  FOREIGN KEY (account_id, contact_id)
    REFERENCES contacts (account_id, id)
    ON DELETE RESTRICT
);

これによりスキーマで「誰が何を所有しているか」が強制されます:ノートは別アカウントのコンタクトを指せません。アプリコードや LLM が誤ったクエリを出しても防げます。

ステップバイステップ:本番を壊さずに制約を追加する

Own the generated code
Keep full control by exporting source code after you tighten database rules.

まず不変条件の短いリストを書き出してください:常に成り立たなければならない事実を平易に。例えば「すべてのコンタクトにはメールが必要」「ステータスは許可された値の一つである」「請求書は実在する顧客に属する」といった具合です。これがデータベースに強制させたいルールです。

変更は小さなマイグレーションで展開し、本番を驚かせないようにします:

  • まず破壊的でない方法で新しい列やルールを追加する。
  • 既存行をバッチでバックフィルする。
  • 悪いデータをクリーンアップ(重複解消、無効値修正)するかレビュー用に隔離する。
  • ルールを強化して適用する(NOT NULLUNIQUECHECKFOREIGN KEY)。
  • エラーを明確に扱うようアプリ挙動を整える。

厄介なのは既存の悪いデータです。計画を立ててください。重複には勝者を選び、残りをマージして小さな監査ノートを残す。必須フィールドが欠けている場合は、本当に安全なら安全なデフォルトを選ぶか、そうでなければ隔離します。壊れた参照は子を正しい親に再割り当てするか、悪い行を削除します。

各マイグレーション後には、失敗すべき書き込みで検証してください:必須値が欠けた行を挿入する、重複キーを挿入する、範囲外の値を挿入する、存在しない親を参照する行を挿入する。書き込みの失敗は有益なシグナルです。どの経路が「ベストエフォート」に頼っていたかを示してくれます。

現実的な例:きれいな状態を保つ小さな CRM

小さな CRM を想像してください:アカウント(SaaS のお客様)、取り引き先の会社、会社の担当者、会社に結びつく商談。

チャットツールで素早く生成されるアプリはこうした構成が多く、デモでは問題がないように見えますが実データはすぐに混乱します。初期に見つかるバグは大抵二つ:連絡先の重複(同じメールが少し違う形で複数登録される)と、company_id を設定し忘れて会社なしで商談が作られること。リファクタやパースのミスで商談金額が負になるケースもよくあります。

対処は if 文を増やすことではなく、悪いデータを保存不可能にする適切な制約をいくつか置くことです。

CRM をクリーンに保つための制約例

-- Contacts: prevent duplicates per account
ALTER TABLE contacts
  ADD CONSTRAINT contacts_account_email_uniq UNIQUE (account_id, email);

-- Deals: require a company and keep the relationship valid
ALTER TABLE deals
  ALTER COLUMN company_id SET NOT NULL,
  ADD CONSTRAINT deals_company_fk
    FOREIGN KEY (company_id) REFERENCES companies(id);

-- Deals: deal value cannot be negative
ALTER TABLE deals
  ADD CONSTRAINT deals_value_nonneg CHECK (deal_value >= 0);

-- A few obvious required fields
ALTER TABLE companies
  ALTER COLUMN name SET NOT NULL;
ALTER TABLE contacts
  ALTER COLUMN email SET NOT NULL;

これはただ厳しくするためのものではありません。あいまいな期待を、どの経路から書き込まれてもデータベースが強制するルールに変えるのです。

制約導入後にアプリはどう変わるか

これらの制約が入るとアプリは単純になります。事後に重複を検出する防御的チェックの多くを減らせます。失敗は明確で対応しやすくなります(例:「このアカウントですでにメールが存在します」など)。生成された API ルートがフィールドを忘れても、書き込みが即座に失敗してデータベースが破損するのを防ぎます。

制約を痛いものにしてしまうよくある誤り

Plan your schema first
Use planning mode to define tables, constraints, and edge cases before generating code.

制約はビジネスの実態に合うとき最も効果的です。多くの痛みは、その場しのぎで「安全そう」に見えるルールを入れてしまい、後で驚きになる場合に起きます。

よくある落とし穴は ON DELETE CASCADE を至る所で使うことです。便利に見えますが、誰かが親を削除するとシステムの半分が消えてしまうことがあります。草稿の行項目など本当に単独で存在してはいけないデータには適切ですが、顧客や請求書のように重要なデータにはリスクがあります。確信がないなら RESTRICT を選び、削除を意図的に扱いましょう。

別の問題は CHECK を狭く書きすぎることです。「status は 'new', 'won', 'lost' のいずれか」だと仮定すると、後で 'paused' や 'archived' が必要になったときに困ります。良い CHECK は安定的な真実を記述するべきで、一時的な UI 選択肢ではありません。例えば amount >= 0 は長持ちしますが、country in (...) はしばしば変わります。

生成されたコードが既に出ているチームが後から制約を入れるときに繰り返し見る問題:

  • CASCADE をクリーニング用途と誤用して意図以上にデータを消す。
  • CHECK を狭くし過ぎて将来の正当なケースをブロックする。
  • NULL が関わると UNIQUE が重複を止めないと誤解する。
  • 既に違反している行をどう直すか計画せずにルールを変更する。

パフォーマンス面では:PostgreSQL は UNIQUE のために自動的にインデックスを作りますが、外部キーの参照側の列には自動でインデックスは作りません。参照列にインデックスがないと、親の更新や削除時に子テーブルを全走査して参照をチェックする必要が出て遅くなります。

ルールを厳しくする前に、そのルールに違反する既存行を探し、修正するか隔離する手順を決め、段階的に展開してください。

次にやるべきこと:簡単なチェックリスト

出荷前に各テーブルについて5分程度で「常に正しいこと」を書き出してください。英語で簡潔に言えれば、その多くは制約で強制できます。

各テーブルに対して自問してください:

  • 何が NULL であってはいけないか?
  • 何が重複してはいけないか?
  • 何が負または範囲外であってはいけないか?
  • 親なしで存在してはいけないものは何か(孤立行はないか)?
  • 単純なルールに従うべきものは何か(許可状態、開始日と終了日の順序など)?

チャット駆動のビルドツールを使うなら、これら不変条件をデータに関する受け入れ基準として扱ってください。例えば:「商談金額は非負であること」「コンタクトのメールはワークスペースごとに一意であること」「タスクは実在するコンタクトを参照すること」など。ルールを具体化するほど、偶発的なエッジケースが減ります。

Koder.ai (koder.ai) は planning mode、スナップショットとロールバック、ソースコードのエクスポートなどの機能を提供しており、制約を段階的に強化しながら安全にスキーマ変更を繰り返すのを助けます。

現実的に機能する単純なロールアウトパターン:一つの価値の高いテーブル(users、orders、invoices、contacts)を選び、最も致命的な失敗を防ぐ 1~2 個の制約(多くは NOT NULLUNIQUE)を追加し、失敗する書き込みを修正し、これを繰り返す。時間をかけてルールを厳しくしていく方が一度に大きなリスクを取るより安全です。

よくある質問

データルールをアプリだけでなくPostgreSQLにも置くべき理由は?

常に守る必要があるルールはPostgreSQLに置き、すべてのアプリ経路でそれに従わせましょう。UIやAPIのバリデーションでは分かりやすいメッセージを表示でき、制約はインポート、ジョブ、再試行、管理ツールからの無効な書き込みを防ぎます。

`NOT NULL` はいつ使うべきですか?

所有者ID、名前、作成時刻のように、値がなければ行として意味をなさない場合は NOT NULL を使います。ユーザー入力の欠落を空文字列で代用しないでください。エラーが見えにくくなります。

`CHECK` 制約は何に役立ちますか?

CHECK は、amount >= 0、許可するステータス、開始日より後の終了日など、1行内で完結するルールに使います。将来のプロダクト変更で有効なデータがエラーにならないよう、ルールは短く安定したものにしてください。

`UNIQUE` は欠損値も防ぎますか?

外部IDの重複や、同じアカウント内で同じメールアドレスを持つ連絡先が2件あるケースなど、重複が実際の問題につながる場合は UNIQUE を追加します。すべてのレコードでその値が必須なら NOT NULL と組み合わせてください。PostgreSQLでは標準の一意制約でも複数のNULLを許可します。

外部キーは孤立レコードをどのように防ぎますか?

外部キーにより、存在しない親を参照する子行はデータベースによって拒否されます。有効な会社を持たない案件や、削除済みの顧客に紐づくメモのような孤立レコードを防げます。

`ON DELETE CASCADE` はどこでも使うべきですか?

親を削除する際に意図的な判断が必要なら、まず RESTRICT を使います。下書き中の一時アイテムのように、親なしでは子レコードに本当に価値がない場合にだけ CASCADE を選びます。

大文字・小文字が異なる重複メールアドレスを防ぐには?

大文字・小文字の違いも同じメールアドレスとして扱うことをユーザーが期待するなら、lower(email) のように正規化した値に一意インデックスを作成します。アプリ側でも入力のトリミングと正規化を行うと、より分かりやすいエラーメッセージを表示できます。

本番データベースに制約を安全に追加するには?

まず新しいルールに違反している行を見つけます。バッチで修正、統合、または隔離してから制約を追加し、書き込みに失敗したときにアプリが役立つエラーを表示するよう更新します。

外部キーにはインデックスが必要ですか?

一意制約は独自のインデックスを作成しますが、外部キーは子側の列に自動でインデックスを作成しません。テーブルが大きい場合や、親行を頻繁に更新・削除する場合は、参照する列にインデックスを追加してください。

最初に追加すべき制約はどれですか?

まずは金額、顧客、または本人確認に関わるテーブルを1つ選びます。必須の所有者、アカウントごとに一意のメールアドレス、0以上の金額など、価値が明確なルールを1つか2つ追加し、失敗すべき書き込みをテストします。

Related posts