Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

reminder_log_kind_checkを正規表現で抽出するテストが偽greenだった

公開 読了時間 約10分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

制約名の文字列そのものをJSの正規表現の起点にすると、SQLコメントで過去の定義を引用しただけの行や、別テーブルの同名パターンのcheck(kind in (...))まで拾い、テストは合格したまま実際より緩い許可集合を検証してしまう。

結論

DB の CHECK 制約の許可値を、SQL ファイルから正規表現で抜き出して検証する静的テストがあった。その正規表現は「制約名という文字列がどこかに現れたら、そこから最初に見つかる check (kind in (...)) を拾う」という書き方だったため、制約名をコメントで言及しただけの行や、別テーブルの同名パターンの check (kind in (...)) まで誤って許可値と解釈しうる状態だった。 直し方は二段構えで、(a) 制約名の「言及」ではなく実際の add constraint <name> check (...) という DDL 構文にマッチをアンカーし、(b) それでも SQL コメント中に同じ構文が書かれていれば拾ってしまうため、コメント行を正規表現で除去してから探索するようにした。両方とも同じ日のコードレビューで見つかり、11分違いの2つのコミットで直っている。

何のためのテストだったか

対象は kimiteras-portalreminder_log テーブル。督促・催促メールの二重送信を防ぐためのログで、kind カラムに 'unsigned' 'renewal' などの文字列を入れる。TypeScript 側では ReminderKind という union 型でこの値を定義し、DB 側では reminder_log_kind_check という CHECK 制約で「入れてよい値」を宣言する。両者は別々のファイルに書かれているので、手で揃え続ける限りいつか食い違う。

実際に食い違ったことがある。テストファイル冒頭のコメントに経緯が残っている。

// 背景(2026-07-02 の潜在バグ): customer-reminders.ts は 0052 以来 kind='overdue' を
//   INSERT していたが、制約は一度も 'overdue' を含まず(0052/0064)、督促 INSERT が
//   毎回 CHECK 違反 → claimReminder=false → remind_overdue が無音で 1 通も送れなかった。
//   0065 で 'overdue' を許可。以後、コード側 union と DB 制約の乖離をこのテストで検出する。

customer-reminders.ts は 0052 のマイグレーションの時点から kind='overdue' で INSERT していたのに、reminder_log_kind_check は 0065 まで一度も 'overdue' を許可していなかった。INSERT は毎回 CHECK 違反で失敗し、呼び出し元は try/catch で例外を飲み込む best-effort 設計だったため、督促メールが無音で1通も送れない状態が続いていた。0065 で 'overdue' を許可値に加えて直し、同じ乖離を二度と踏まないよう、コード側の union と DB 側の制約が一致しているかをビルド前に確認する回帰テストとして書かれたのが、この reminder-log-kind-constraint.test.ts だった。

原因

このテストの allowedKindsFromMigrations() は、supabase/migrations/ 配下の .sql ファイルをファイル名の辞書順(=ゼロ埋め番号なので適用順と一致)に読み、各ファイルの中から最新の許可値を正規表現で抜き出す。導入時点(2026-07-12)の実装はこうだった。

function allowedKindsFromMigrations(): string[] {
  const files = readdirSync(migrationsDir)
    .filter((f) => f.endsWith(".sql"))
    .sort(); // 0052 < 0064 < 0065 … ゼロ埋め番号なので辞書順=適用順
  let allowed: string[] | null = null;
  for (const f of files) {
    const sql = readFileSync(`${migrationsDir}/${f}`, "utf8");
    // `... reminder_log_kind_check ... check (kind in ('a','b',...))` の最後の定義を採用。
    const re = /reminder_log_kind_check[\s\S]*?check\s*\(\s*kind\s+in\s*\(([^)]*)\)/gi;
    let mm: RegExpExecArray | null;
    while ((mm = re.exec(sql)) !== null) {
      allowed = [...mm[1].matchAll(/'([^']+)'/g)].map((x) => x[1]);
    }
  }
  if (!allowed) throw new Error("reminder_log_kind_check の check 定義が migration に見つからない");
  return allowed;
}

この正規表現は「reminder_log_kind_check という文字列」を起点に、そこから非貪欲([\s\S]*?)に最初に見つかる check (kind in (...)) までを1つの定義として拾う。問題は、起点になる「reminder_log_kind_check という文字列」が、実際の DDL 定義(add constraint reminder_log_kind_check ...)でなくても、単なるコメント中の言及でもマッチしてしまうことだった。修正コミットのコード内コメントは、この穴を2通りに整理している。

  1. 名前をコメントで書いただけの migration が、別の check (kind in (...))(例: contracts.kind)を含むと、そちらを許可値と誤認しうる
  2. 過去の定義をコメントで引用する書き方(このリポジトリの慣習)をしていると、コメント中の定義を拾ってしまう

(1) の状況は実在した。0070_sponsor_contracts.sql は同じファイルの中に、reminder_log_kind_check を再定義する前に、別テーブルの同名パターンを含んでいる。

alter table public.contracts
  add column if not exists kind text not null default 'standard'
    check (kind in ('standard', 'sponsor'));
-- 移行案内メールの同日二重送信ガード用 kind(0052/0064/0065 と同じ流儀で許可値を追加)。
alter table public.reminder_log drop constraint if exists reminder_log_kind_check;
alter table public.reminder_log
  add constraint reminder_log_kind_check
  check (kind in ('unsigned','unsubmitted','renewal','renewal_60','renewal_7','creative_expiry','overdue','sponsor_transition'));

このファイル単体では、正規表現の起点になる「reminder_log_kind_check」という文字列が最初に現れるのはコメント行の直後(reminder_log 用の drop/add)であり、contracts.kind の定義はそれより前に書かれている。非貪欲マッチは起点より前を探さないので、このファイルの中だけを見れば実害は無い。だが allowedKindsFromMigrations() はファイルをまたいで allowed上書きし続ける設計で、「最後に処理したファイルでマッチした値」を最終結果として採用する。もし 0070 より後の番号の migration が、reminder_log_kind_check に軽く触れるコメント((2) の書き方)を残したまま、その後ろに reminder_log とは無関係な check (kind in (...)) を書いていたら、そちらが最終結果を上書きし、テストはそのまま合格する。この時点の migration 群は 0070 が最後で、かつ 0070 自身が正しい定義を持っていたため事故には至らなかったが、それは「たまたま最後に処理されるファイルの中身が正しかった」だけで、正規表現の作り自体がファイルの処理順に依存する脆さを持っていた。

直し方(1段目)

2026-07-24 15:55 のコミットで、起点を「制約名の言及」から「実際の DDL 構文」に変えた。

// `add constraint reminder_log_kind_check check (kind in ('a','b',...))` の最後の**定義**を採用。
// ⚠ 制約名の「言及」に食いつかせないこと: 名前をコメントで書いただけの migration が
// 別の `check (kind in (...))`(例: contracts.kind)を含むと、そちらを許可値と誤認する。
// 誤って広い集合を拾うと **false green**(誤った制約を検証して合格)になり、
// このテストが防ごうとしている 0065 の穴(督促 INSERT 全断)を再び見逃す。
const re =
  /add\s+constraint\s+reminder_log_kind_check\s+check\s*\(\s*kind\s+in\s*\(([^)]*)\)/gi;

reminder_log_kind_check という文字列単体ではなく、add constraint reminder_log_kind_check check (kind in (...)) という実際の定義構文全体にマッチをアンカーした。単なるコメントでの言及や、別テーブルの check (kind in (...)) は、この一続きの構文と一致しない限り拾われなくなる。

直し方(2段目)

ところがこの1段目の修正には、まだ穴が残っていた。JS の正規表現は SQL の -- コメント構文を知らない。 add constraint reminder_log_kind_check check (kind in (...)) という文字列そのものが -- の後ろ、つまりコメント中に書かれていても、正規表現としては同じようにマッチしてしまう。このリポジトリは 0065_reminder_log_overdue.sql のように、過去の許可値をコメントで引用しながら経緯を説明する書き方を慣習にしている。同じ慣習で「過去の add constraint ... 文をそのままコメントで引用する」migration が書かれれば、1段目の修正だけでは同じ false green を再現できる。

11分後の 16:06 のコミットで、コメント行を除去してから探索するように直した。

for (const f of files) {
  // ⚠ SQL コメント行を落としてから探す。この repo は「過去の定義をコメントで引用する」
  // 書き方をするため、コメント中の定義を拾うと(最後に現れた方が勝つので)実際の最新定義を
  // 上書きし、広い許可集合で **false green**(誤った制約を検証して合格)になる。
  const sql = readFileSync(`${migrationsDir}/${f}`, "utf8").replace(
    /^[ \t]*--.*$/gm,
    ""
  );
  const re =
    /add\s+constraint\s+reminder_log_kind_check\s+check\s*\(\s*kind\s+in\s*\(([^)]*)\)/gi;
  let mm: RegExpExecArray | null;
  while ((mm = re.exec(sql)) !== null) {
    allowed = [...mm[1].matchAll(/'([^']+)'/g)].map((x) => x[1]);
  }
}

/^[ \t]*--.*$/gm で、行頭が(先頭の空白を挟んで)-- から始まる行を丸ごと空文字に置換してから、アンカー済みの正規表現を走らせる。これで「実際の DDL 構文としての定義」と「コメントでの引用・言及」を、文字列の見た目ではなく構文的な位置で区別できるようになった。

再発防止

2回の修正はどちらも allowedKindsFromMigrations() 自身のロジックを直しただけで、この2つの落とし穴(別テーブルの同名パターンを拾う/コメント中の定義を拾う)を専用に再現・検証する it() は追加されていない。既存の2つのテストケースは「union の全メンバーが許可されているか」「'overdue' が許可値に含まれるか」を確認するもので、どちらも実際の migration 群に対して正しい答えが返ってくることしか見ていない。今回のような、正規表現の起点の選び方そのものに起因する構造的な穴は、たまたま今のファイル群がそれを踏まない並びになっているために green のまま隠れうる。次に誰かが「別テーブルの check (kind in (...))」や「過去の定義をコメントで引用する新しい migration」を書いたとき、この修正が本当に効いているかどうかは、テストではなく次のコードレビュー任せになっている。

よくある質問

Q1なぜ最初の修正(アンカーをadd constraintに変える)だけでは不十分だったのですか?

JSの正規表現はSQLの--コメント構文を理解しないため、add constraint reminder_log_kind_check check (kind in (...))という文字列がコメント中に書かれていても同じようにマッチする。このリポジトリは過去の許可値をコメントで引用する書き方をしていたため、コメント行を先に除去する2段目の修正が同日中に追加された。

Q2このテストは本番で実際に誤った許可集合を検証したまま合格したのですか?

根拠のコミットメッセージにはコードレビューで見つけて修正したと書かれているだけで、実際に誤った許可集合のまま合格した記録は無い。当時のmigrationファイル群では、たまたま最後に処理されるファイルが正しい定義を持っていたため、この状態のままでは事故に至っていない。

Q3なぜ正規表現でSQLファイルからCHECK制約をパースするような設計になっていたのですか?

reminder_log.kindへのINSERTで使うTypeScript側のReminderKind unionと、DB側のCHECK制約が2026-07-02に一度乖離し、督促メールが無音で1通も送れなかった事故があった。以後この乖離をビルド前に検出する回帰テストとして作られた。

Q42回の修正のあと、この落とし穴自体を確認する新しいテストは追加されましたか?

追加されていない。2回の修正はいずれも許可値を抽出するロジック自体の変更で、コメント中の言及や別テーブルの定義を誤って拾わないことを直接確認するit()は無い。

確認した環境

  • vitest ^4.1.8 / TypeScript ^5
  • 2026-07-12 に導入・2026-07-24 のコードレビューで発見し同日中に2段階で修正(本番での誤検出の記録は無し)

この記事の根拠

  • TypeScriptファイル 1〜42行目コミット d0ef434
  • SQLファイル 1〜18行目コミット d0ef434
  • SQLファイル 1〜21行目コミット d0ef434
  • TypeScriptファイル 25〜47行目コミット 856a822
  • TypeScriptファイル 25〜50行目コミット 4973c36

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。