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?

Supabase の .range() で全件取ったら、合計は合うのに内訳がずれていた → .order() が無いと行が重複・欠落する

0
Posted at

Supabase(PostgREST)で .range() を使ってテーブルを全件回したら、合計の行数は合っているのに、区分ごとの内訳だけがずれることがありました。原因は .order() を付けていなかったことです。

件数の合計が一致するので「取りこぼしは無い」と読んでしまい、気づくのが遅れました。同じ書き方をしている人向けに、症状・原因・直し方をまとめます。

何が起きたか

約7万6千行のテーブルを、1,000行ずつ .range() で取って、ある列の値ごとに数えていました。

// 順序を指定していない(これがまずい)
const PAGE = 1000;
let from = 0;
const rows = [];
while (true) {
  const { data, error } = await supabase
    .from("companies")
    .select("id, status, detail") // detail は jsonb の重い列
    .range(from, from + PAGE - 1);
  if (error) throw error;
  rows.push(...data);
  if (data.length < PAGE) break;
  from += PAGE;
}

同じテーブルを、区分ごとに count: "exact", head: true で数えた結果と比べると、こうなりました。

区分 count: "exact" 順序なし .range() で集計
A 10,502 9,722
B 48,965 49,094
C 7,191 6,578
合計 76,011 76,011

合計は同じです。中身を見ると、同じ id が2回出ている行と、一度も出てこない行が両方ありました。重複と欠落が打ち消し合って、件数だけ合っていたわけです。

原因:ORDER BY が無いと、行の順番は毎回同じとは限らない

.range(from, to) は PostgREST 側で LIMIT / OFFSET になります。PostgreSQL は ORDER BY が無い問い合わせの行の順番を保証しません。

ページごとに別の問い合わせとして実行されるので、

  • 1ページ目と2ページ目で、行の並びが違っていてもおかしくない
  • その結果、ある行は2ページに出て、別の行はどのページにも出ない

ということが起きます。たとえば PostgreSQL の同期スキャン(synchronize_seqscans、既定で on)は、大きいテーブルの全件スキャンを、別のスキャンが進んでいる途中の位置から始めることがあります。私の場合は、id だけを選ぶ軽い問い合わせでは再現せず、jsonb の重い列を含めたときに出ました。

直し方

1. .range() の前に、主キーで .order() を付ける

const { data, error } = await supabase
  .from("companies")
  .select("id, status, detail")
  .order("id", { ascending: true }) // 必ず付ける
  .range(from, from + PAGE - 1);

これで count: "exact" の結果と区分ごとに一致しました。

並べる列は**値が重ならない列(主キーなど)**にします。created_at のように同じ値が複数ありうる列だけで並べると、同じ値の行どうしの順番は決まらないので、同じことが起きえます。その場合は .order("created_at").order("id") のように主キーを足します。

2. もっと堅くするなら、OFFSET をやめて「前回の最後の id より後」で取る

let lastId = 0;
while (true) {
  const { data, error } = await supabase
    .from("companies")
    .select("id, status, detail")
    .gt("id", lastId)
    .order("id", { ascending: true })
    .limit(PAGE);
  if (error) throw error;
  if (data.length === 0) break;
  rows.push(...data);
  lastId = data[data.length - 1].id;
}

OFFSET は先頭から読み飛ばすので、後ろのページほど遅くなります。途中で行が追加・削除されたときにずれる心配も減ります。

気づくための検算

全件を回して集計するコードを書いたら、合計ではなく内訳を count と突き合わせます。

const { count } = await supabase
  .from("companies")
  .select("id", { count: "exact", head: true })
  .eq("status", "A");

もう一つ、取れた id の重複を数えるのも手早いです。

const ids = rows.map((r) => r.id);
console.log(ids.length, new Set(ids).size); // 2つが違えば重複している

まとめ

  • .range() で回すなら、直前に主キーで .order()
  • 合計が合っていても、内訳がずれていることがある
  • 正は count: "exact", head: true。集計を作ったら内訳で突き合わせる

重複チェックのような処理(「すでに渡した行を除く」など)でこれを落とすと、同じ行を2回扱うことになるので特に注意が要ります。


このテーブルのような営業リストを、裏を取って作る手順(有料・980円):https://note.com/kaito_cometa/n/n9632db4153bf

生成AI(Claude Code)に書かせて一人で会社を回している記録を note に書いています。
https://note.com/kaito_cometa

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?