Rebounder Tech Blog

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

PostgRESTの.or()にカンマを含む検索語を渡すと無音でフィルタが壊れる

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

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

結論

supabase-jsの.or()フィルタに生の検索語を差し込むと、カンマ等のPostgREST予約文字でフィルタ文法が壊れ、throwせず{data:null}を返すため該当カテゴリだけが無音で検索結果から消える。

結論

PostgREST の .or() フィルタに生の検索語をテンプレートリテラルで差し込むと、カンマ等の予約文字を含む語で文法が壊れる。 さらに supabase-js は通信/PostgREST 失敗でも throw せず {data: null, error} という戻り値で表現するため、呼び出し側が .data ?? [] で受けていると、フィルタが壊れて datanull になった瞬間、そのカテゴリのヒットは例外もログも出さずに空配列として扱われる。全体検索の担当者クエリがこの形で壊れていて、直し方は氏名・メールを個別の parameterized .ilike() に分け、結果を JS 側でマージすることだった。

症状になりうる状態

対象は kimiteras-portal の全体検索(グローバル検索・コマンドパレットの供給元)。企業・担当者・案件・契約・学校・タスク・書類を横断して Promise.all で1リクエストにまとめて引く作りで、担当者だけは氏名とメールの両方を対象にする必要があり、修正前はこう書かれていた。

supabase
  .from("contacts")
  .select("id, name, email, company_id, school_id")
  .or(`name.ilike.${like},email.ilike.${like}`)
  .limit(5),

like は検索語を %...% に整形した文字列で、ワイルドカード文字(% _ \)はエスケープしていたが、.or() 自体が使う区切り文字(カンマ)はエスケープしていなかった。この結果はほかのカテゴリと同じように受け止められる。

for (const c of contacts.data ?? [])
  hits.push({
    type: "担当者",
    label: c.email ? `${c.name}(${c.email})` : c.name,
    ...
  });

.or() の第一引数は name.ilike.<値>,email.ilike.<値> という PostgREST 独自の文法文字列で、カンマは条件の区切りとして解釈される。検索語自体にカンマを含む場合(例えば「山田,花子」のように姓名を並べて打つ、あるいは名刺の氏名欄をそのまま貼り付けるような入力)、生成される文字列はカンマの数だけ余分な条件に割れ、PostgREST 側でフィルタ文法として解釈できなくなる。

原因

このときの失敗のしかたが問題だった。supabase-js のクエリメソッドは Promise を reject しない設計で、通信エラーも PostgREST のエラー応答も同じ {data, error} という戻り値の形で表現する。このコードは contacts.data ?? []data だけを見ており、error を分割代入すらしていない。フィルタが壊れて PostgREST がエラーを返しても、datanull になるだけで例外は投げられず、?? [] によって「担当者が0件ヒットした」という正常系の一種として静かに扱われる。企業・案件・契約など他のカテゴリは正常にヒットするため、画面上は「検索結果が少し薄い」程度にしか見えず、担当者カテゴリだけが特定の検索語で消えていることに気づく手段がなかった。

直し方

氏名・メールをそれぞれ個別の parameterized .ilike() クエリに分け、結果を JS 側でマージして重複を排除する形に変更した。

// 氏名・メールを個別の parameterized .ilike で引き JS で統合する。`.or()` に生の入力を
// 差し込むと、カンマ等の PostgREST 予約文字がフィルタ文法を壊し、担当者カテゴリが無音で
// 落ちる(例: q="山田,花子")。.ilike は値をパラメータ束縛するので予約文字も安全。
supabase
  .from("contacts")
  .select("id, name, email, company_id, school_id")
  .ilike("name", like)
  .limit(5),
supabase
  .from("contacts")
  .select("id, name, email, company_id, school_id")
  .ilike("email", like)
  .limit(5),

.ilike() は第二引数の値をパラメータとして束縛するため、値の中にカンマや . のような PostgREST 予約文字が入っていても、フィルタ文法そのものを壊さない。氏名ヒットとメールヒットは同じ担当者を重複して返しうるため、マージ側で Set を使って ID ごとに1回だけ hits に積む。

const seenContact = new Set<string>();
for (const c of [...(contactsByName.data ?? []), ...(contactsByEmail.data ?? [])]) {
  if (seenContact.has(c.id)) continue; // 氏名・メール両方にヒットした担当者の重複を排除
  seenContact.add(c.id);
  hits.push({ ... });
}

再発を防ぐため、.or() への生挿入そのものを禁止する静的ガードのテストも追加している。

it("検索クエリを PostgREST の .or() フィルタ文法へ生挿入しない(インジェクション防止)", () => {
  expect(src).not.toContain(".or(`");
  expect(src).not.toContain("ilike.${");
});

ソースコードの文字列を直接検査するテストなので、誰かが将来同じ理由で .or() にテンプレートリテラルを差し戻しても、CI がその時点で落ちる。

教訓

?? [] は「値が無いときの安全なデフォルト」としてよく使う書き方だが、supabase-js のように失敗も同じ形の戻り値で表現するクライアントと組み合わせると、「データが0件だった」と「クエリ自体が失敗した」を区別できなくする副作用がある。今回は .or() への生挿入という別の不具合が引き金になったが、根本の危うさは error を見ずに data だけを取り出している箇所すべてに共通する。1リクエストで複数カテゴリを Promise.all で束ねる構成では、失敗したカテゴリだけが静かに消え、他のカテゴリが正常に返ることでその欠落自体が気づかれにくくなる。

よくある質問

Q1なぜエラーにならず無音で消えるのですか?

supabase-jsのクエリメソッドは通信/PostgREST失敗でもthrowせず{data,error}という戻り値で表現する。呼び出し側がdataだけを見てerrorを見ず、結果を.data ?? []で受けていると、フィルタ文法が壊れてdataがnullになっても空配列として扱われ、該当カテゴリのヒットが静かにゼロ件になる。

Q2どんな検索語で壊れますか?

PostgRESTの.orフィルタはカンマで条件を区切る文法のため、検索語自体にカンマを含む場合(例: 「山田,花子」のような姓名の羅列)に生挿入すると文法が崩れる。ほかにも.や(のような予約文字を含む語で同様に壊れうる。

Q3この不具合の直し方は何ですか?

氏名・メールをそれぞれ個別のparameterized .ilike()クエリで引き、結果をJS側でマージしてIDで重複排除する形に変更した。.ilike()は値をパラメータ束縛するため予約文字を含む検索語でも壊れない。

Q4本番で実際に検索結果が消えた記録はありますか?

根拠のコミットは修正内容を記録しているのみで、本番でどの検索語がどの期間影響したかのログは残っていない。コードの構造上、カンマを含む担当者名やメールを検索すると発生しうる状態だったことがコードから分かる。

確認した環境

  • @supabase/supabase-js ^2.106.2 / @supabase/ssr ^0.10.3
  • 2026-07-13 にレビューで発見・同日中に修正

この記事の根拠

  • TypeScriptファイル 29〜33行目コミット 4873430
  • TypeScriptファイル 58〜68行目コミット 4873430
  • TypeScriptファイル 29〜41行目コミット 8931860
  • TypeScriptファイル 100〜115行目コミット 8931860

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