0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PostgRESTの1000行上限でプルダウンの候補が消える——検証データを1,050件にして初めて見えた不具合

0
Posted at

環境

項目 バージョン / 設定
Supabase クラウド版(PostgreSQL 15)
PostgREST Supabase 同梱(API > Max Rows = 1000)
supabase-js v2 系
フロント Next.js 14 / React 18 / TypeScript 5
フォーム react-hook-form + <select>
検証データ properties 1,050件 / customers 1,050件

管理システム(物件・顧客を扱う編集画面)で発生した不具合の記録です。一覧のページ送りは直したのに、編集フォームのプルダウン(<select>)で同じ問題が残っていました。

症状:編集中のレコードが候補に無く、保存が必須エラーで通らない

編集画面を開くと、物件を選ぶ <select> が「選択してください」のままになっていました。DBには対象の物件が存在しているのに、候補一覧に出てこない。バリデーションは必須なので、保存ボタンを押すとそのままエラーで止まります。

一覧画面のページ送りは既に対応済みでした。.range() で1,000件ずつ取得し、count: 'exact' で総件数を出してページャを組んでいたからです。ところがプルダウンは別実装で、素の .select() を1回投げているだけでした。

// ❌ 上限を意識していない実装
const { data, error } = await supabase
  .from('properties')
  .select('id, name')
  .order('name', { ascending: true })

.range() を付けないリクエストは、Supabase の API 設定 Max Rows(PostgREST の db-max-rows)で暗黙に切り詰められます。デフォルトは 1000。つまり 1,001件目以降の物件は、エラーも警告もなく候補から消えます。

厄介なのは、HTTPは 200 で返ることです。Content-Range ヘッダを見ない限り、レスポンスは「成功」に見えます。フロント側では data が配列として返るので、型チェックもテストも通ってしまいます。

さらに <select> の挙動が追い打ちをかけます。value に一致する <option> が存在しない場合、ブラウザは選択を確定できず selectedIndex = -1 になります。画面には「選択してください」(プレースホルダ)が表示され、ユーザーからは「データが消えた」ように見えます。

なぜ一覧だけ直して満足してしまったのか

原因は検証データが1,000件未満だったことです。手元のシードデータは数百件規模。上限を超えない限り、この不具合はテストもCIも緑のまま通過します。

「上限のある経路は、上限を超えるデータで試さない限り検証にならない」——これが今回の核心です。同じ .select() を使っているという理由で一覧とプルダウンを同質に扱ってしまったのが、見落としの直接原因でした。

対策:編集中のレコードだけを id で1件別引きし、先頭に足す

設計方針はシンプルです。

  • プルダウンの候補は「上限内で取れる範囲」でよい(そもそも全件出すUIではない)
  • ただし編集中レコードが候補に無いと保存できないので、その1件だけを id 指定で確実に取る
  • 「候補に無ければ先頭に挿入する」処理をヘルパーに集約する
type Option = { id: string; name: string }

async function fetchOptionsWithCurrent(
  table: 'properties' | 'customers',
  currentId: string | null,
): Promise<Option[]> {
  // 1) 候補は上限内で取得(並び順に一意キーを足して安定させる)
  const { data: options, error } = await supabase
    .from(table)
    .select('id, name')
    .order('name', { ascending: true })
    .order('id', { ascending: true })
    .limit(1000)
  if (error) throw error

  const list = (options ?? []) as Option[]
  if (!currentId) return list
  if (list.some((o) => o.id === currentId)) return list

  // 2) 編集中レコードが上限の外にいても、id で1件だけ確実に引く
  const { data: current, error: currentError } = await supabase
    .from(table)
    .select('id, name')
    .eq('id', currentId)
    .maybeSingle()
  if (currentError) throw currentError

  // 3) 候補の先頭に足して、<select> が必ず選択状態を作れるようにする
  return current ? [current as Option, ...list] : list
}

.eq('id', ...) は主キー1件なので上限の影響を受けません。この構造なら、将来マスタが10万件に増えても編集画面は壊れません。

代替案との比較

方式 実装コスト 1000行上限の影響 向いているケース
.limit(1000) のみ 低 受ける(候補が欠ける) マスタが数百件と保証できる
.range() で全件ループ 中 受けないが、並び順が非一意だと重複・欠落 全件が本当に必要
サーバ側のインクリメンタル検索 中〜高 受けない 候補が数千件以上
1件別引き+マージ(本記事) 低 受けない(編集中レコードのみ) 編集画面のプルダウン

検証手順:上限の外に対象を押し出してから確認する

  1. 1,050件を投入する(上限1,000を超えるデータ)
  2. 対象レコードを上限の外に押し出す(name 昇順で1,001件目以降に来る名前を付ける)
  3. 編集画面を開き、プルダウンの先頭に対象が出ているか確認
  4. 保存して必須エラーが出ないことを確認
  5. CIに「上限外の編集中レコードが候補に含まれる」テストを追加
-- 1,050件投入して、上限の外側にレコードを作る
insert into properties (name, created_at)
select 'prop_' || lpad(i::text, 5, '0'), now() - (i || ' minutes')::interval
from generate_series(1, 1050) as i;

-- name順で 1,001件目以降が実在することを確認
select count(*) from properties;                        -- 1050
select id, name from properties order by name asc offset 1000 limit 10;  -- 50件返る

対象のレコードは、name 昇順で確実に圏外へ行くよう zzz_target_property のような値にしておきます。

test('上限外の編集中レコードも選択肢に含まれる', async () => {
  const list = await fetchOptionsWithCurrent('properties', targetId)
  expect(list[0].id).toBe(targetId) // 先頭に挿入されている
})

上限に気づくための診断コード

「静かに欠ける」のが一番危険なので、生のHTTPで Content-Range を確認する癖をつけておくと安全です。

const res = await fetch(`${SUPABASE_URL}/rest/v1/properties?select=id`, {
  headers: {
    apikey: SUPABASE_ANON_KEY,
    Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
    Prefer: 'count=exact',
  },
})
console.log(res.headers.get('content-range')) // 例: 0-999/1050

0-999/1050 なら「1,050件中1,000件しか返していない」ことが一目で分かります。

ハマりポイント

  • .limit(5000) と書いても超えられない。Max Rows はサーバ側の天井なので、クライアントの指定より常に優先されます
  • count: 'exact' は件数しか返さない。総件数が正しくても中身は切られます。「件数は合っているのに中身が足りない」という混乱の元
  • 並び順が非一意だとページングが壊れる。同姓同名があると、1,000件ずつループしても重複と欠落が発生します。order('name').order('id') のようにタイブレーカーを必ず入れる
  • RLSで見える件数が変わる。管理者と一般ユーザーで候補件数が変わるため、権限別の検証も必要
  • ローカルと本番で設定が違いうる。Max Rows を誰かが変更していると、検証環境だけ再現しないことがあります

FAQ

Q. Max Rows を10,000に上げれば解決では?
A. 応急処置にはなりますが、API全体のレスポンスが肥大し、取得コストも増えます。10,001件目で同じ不具合が再発するため、根本対策にはなりません。

Q. 一覧のページ送りと同じ .range() を使えばいい?
A. 全件表示が目的なら有効です。ただしプルダウンで数千件を描画するのはUX的にも重いので、編集中レコードの別引き+サーバ側検索の組み合わせが現実的です。

Q. 検証データは何件用意すべき?
A. 上限+αです。上限1,000なら 1,000 / 1,001 / 1,050 の境界値を試します。1,000件ちょうどで通ってしまうのが一番危険な落とし穴です。

まとめ

上限は「エラー」ではなく「静かな切り捨て」として現れます。そして上限以下のデータで検証している限り、テストは緑のままです。まず自分たちの経路にどんな上限があるかを洗い出し、その上限を超えるデータをCIに常備するところから始めましょう。


この記事を書いた人

BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。

GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。

👉 BENTEN Web Works — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?