查詢、篩選與分頁 | Supabase 完整教學

2026/10/02
查詢、篩選與分頁 | Supabase 完整教學

學會了 PostgREST 自動 API 之後,真正的日常查詢幾乎不會是「把整張表撈出來」——你需要精準地篩選、排序,並在大量資料裡分頁。這一篇會帶你把 Supabase 的讀取查詢用到位:用 select 只取需要的欄位、用 eq/gt/ilike/in/is/contains 等篩選運算子組出精準條件、用 or 串邏輯、用 order 排序、用 range 與 limit 分頁、用 count 拿總筆數,最後用 single/maybeSingle 優雅地取單筆。全程以 supabase-js 為主、附上 REST 對照,讓你一眼看穿 SDK 底層發了什麼。

前言

一句話定義本篇主題:PostgREST 的查詢語法,是一套「把 SQL 的 SELECT … WHERE … ORDER BY … LIMIT」搬到 HTTP 上的宣告式查詢介面——你不寫 SQL,而是用 .select().eq().order().range() 這樣的鏈式方法(或等價的 URL query 參數)描述「我想要什麼樣的資料」,PostgREST 幫你翻成單一 SQL 語句去執行。

上一篇《PostgREST 自動 API 入門》我們解決了「一張表怎麼變成一個端點、怎麼帶對 header 安全存取」。但那時的查詢還停留在 select() 撈全部的階段。真實世界的讀取需求複雜得多:商品列表要能「篩出價格低於 100、且分類是電子產品的」、要能「依上架時間新到舊排序」、要能「一次只載入 20 筆、往下滑再載入下一頁」、還要能顯示「總共有幾筆結果」。這些全都不需要你寫後端——PostgREST 都內建好了。

用一個類比:PostgREST 的查詢語法就像一張「點餐單」。你不用進廚房(資料庫)自己炒菜(寫 SQL),只要在點餐單上勾選:「要哪些欄位(select)、要什麼條件的(篩選運算子)、怎麼排(order)、要第幾份到第幾份(range)、順便告訴我總共有幾份(count)」。這張單子送進廚房,PostgREST 這位大廚就會把它翻成一道精準的 SQL 料理端出來。你的工作只是把單子填清楚。

本篇你會學到:

  • 垂直過濾:用 select('col1,col2') 只取需要的欄位,以及欄位別名與 JSONB 存取
  • 橫向過濾:eq/neq/gt/gte/lt/like/ilike/in/is/contains 等運算子怎麼用,以及 or/and 邏輯組合
  • 排序與分頁:order 排序、range/limit 分頁(含「從 0 起、含兩端」的關鍵細節)
  • 總筆數與取單筆:count 的 exact/planned/estimated 三種模式,以及 single/maybeSingle

核心概念

PostgREST 把一個查詢拆成幾個彼此獨立、可自由組合的維度。先在腦中建立這張全景圖:

一個讀取查詢 = 選欄位 + 篩選 + 排序 + 分頁 + 計數
                │        │       │       │       │
   supabase-js  .select  .eq/.gt .order  .range  { count }
                │        │       │       │       │
   REST query   select=  col=op. order=  Range   Prefer:count

每個維度都可以獨立加減、且能鏈式串接。以下逐一拆解。

垂直過濾:select 選欄位

垂直過濾(Vertical Filtering) 指的是「只取需要的欄位」(相當於 SQL 的 SELECT col1, col2)。用 .select() 傳入以逗號分隔的欄位清單即可:

.select('id, name, price')      // 只取這三欄
.select('product_id:id')        // 欄位別名:把 id 回傳成 product_id
.select('metadata->city')       // JSONB 深度存取(-> 取子欄位)

只取需要的欄位不只是潔癖——它能減少資料庫 I/O 與網路傳輸量,是效能最佳實踐的第一條。避免動不動就 select('*') 撈全表所有欄位。想像一張商品表有 30 個欄位、其中還包含大段的商品描述文字與 JSONB 規格;如果列表頁只需要顯示名稱與價格,卻用 select('*') 把每一列的所有欄位都拉回來,等於平白搬運了數十倍的資料量。欄位別名(product_id:id)則在你想把資料庫的欄位名對應到前端習慣的命名時特別好用,不必在拿到資料後再手動改鍵名。

橫向過濾:篩選運算子

橫向過濾(Horizontal Filtering) 指的是「只取符合條件的列」(相當於 SQL 的 WHERE)。在 REST URL 裡,篩選的格式一律是 column=operator.value;在 supabase-js 裡,則對應成同名的鏈式方法。以下是最常用的運算子對照表:

supabase-jsREST 寫法說明SQL 等價
.eq('col', v)col=eq.v等於col = v
.neq('col', v)col=neq.v不等於col <> v
.gt('col', v)col=gt.v大於col > v
.gte('col', v)col=gte.v大於等於col >= v
.lt('col', v)col=lt.v小於col < v
.lte('col', v)col=lte.v小於等於col <= v
.like('col', '%x%')col=like.*x*模糊比對(分大小寫)col LIKE '%x%'
.ilike('col', '%x%')col=ilike.*x*模糊比對(不分大小寫)col ILIKE '%x%'
.in('col', [a,b])col=in.(a,b)值在清單中col IN (a, b)
.is('col', null)col=is.null與 NULL/TRUE/FALSE 精確比對col IS NULL
.contains('col', [x])col=cs.{x}陣列/JSONB 包含col @> '{x}'

幾個容易搞混的重點:

  • like vs ilike:兩者都是模糊比對,ilike 不分大小寫。在 supabase-js 用 SQL 風格的 % 當萬用字元;在 REST URL 裡萬用字元是 *(因為 % 在 URL 有特殊意義)。
  • is 不是 eq:判斷 NULL 一定要用 .is('col', null),不能用 .eq('col', null)。SQL 裡 col = NULL 永遠為假,NULL 的比對必須用 IS。TRUE/FALSE 同理。
  • in 傳陣列:.in('status', ['active', 'pending']),一次比對多個值。
  • contains 用於陣列或 JSONB:判斷某個陣列欄位「包含」指定元素,例如 tags 欄位包含 'sale'。

邏輯組合:or 與 and

預設情況下,多個篩選方法彼此是 AND 關係——.eq('a', 1).gt('b', 2) 等於 WHERE a = 1 AND b = 2。當你需要 OR 時,用 .or(),裡面用逗號分隔多個條件:

.or('role.eq.admin,role.eq.editor')            // role 是 admin 或 editor
.or('price.lt.100,and(stock.gt.0,sale.is.true)') // 巢狀:便宜的,或(有庫存且特價)

注意 .or() 內部用的是 REST 風格的 column.operator.value 語法(用點分隔,不是箭頭方法)。要否定某條件則用 .not('status', 'eq', 'deleted')。

排序:order

用 .order() 排序,可指定升降冪與 NULL 的位置:

.order('created_at', { ascending: false })              // 依建立時間新到舊
.order('price', { ascending: true, nullsFirst: false }) // 價格低到高,NULL 排最後

可以串多個 .order() 做多欄排序(先依第一個、再依第二個)。REST 對照為 order=created_at.desc 或 order=price.asc.nullslast。

分頁:range 與 limit

分頁有兩種寫法。.limit(n) 只限制「最多幾筆」;.range(from, to) 則指定「從第幾筆到第幾筆」,這是分頁最常用的方法:

.range(0, 9)    // 第 1 頁:第 0~9 筆,共 10 筆(含兩端!)
.range(10, 19)  // 第 2 頁:第 10~19 筆

這裡有全篇最容易踩的坑:range 的索引從 0 開始,而且「含兩端」(inclusive)。 所以每頁 pageSize 筆時,頁碼從 0 起算的第 page 頁範圍是 from = page * pageSize、to = from + pageSize - 1。它底層對應的是 HTTP 的 Range: 0-9 header,語意和 JavaScript 的 slice(0, 10)(不含結尾)差一,換算時務必當心。

計數:count 的三種模式

要在拿資料的同時知道「總共有幾筆」,把 count 選項傳給 .select():

模式準確度速度適用場景
exact精確慢(大表更慢)需要精確頁數的後台管理
planned估算(讀 planner 統計)最快只需概數、不在意誤差
estimated小資料精確/大資料估算折衷一般列表的「約 N 筆結果」

exact 會實際跑一次 COUNT,資料量大時很吃效能;planned 完全不查資料、直接讀查詢規劃器的統計估算,最快但可能因統計過期而失準;estimated 則在資料量小時給精確值、超過門檻才退回估算。一個實用的心法是:使用者其實很少真的在乎「精確到個位數的總筆數」——當結果有上萬筆時,「約 12,000 筆結果」和「12,047 筆結果」對體驗毫無差別,卻可能差了好幾秒的查詢時間。所以除非是需要精算頁數的後台報表,日常列表用 estimated 幾乎都是更聰明的選擇。

取單筆:single 與 maybeSingle

一般查詢回傳的是陣列(即使只有一筆)。若你確定要「單一物件」,用 .single() 或 .maybeSingle():

  • .single():嚴格要求剛好一筆,0 筆或多筆都回 error(PGRST116)。用於「一定存在」的主鍵查詢。
  • .maybeSingle():查到 0 筆時回 data: null、不當錯誤;只有多筆才報錯。用於「可能不存在」的查詢。

實作範例

以下範例假設你有一張 products 表,欄位包含 id、name、price、category、stock、tags(陣列)、created_at。每個範例都把 supabase-js 與 REST(curl) 並排,讓你看清 SDK 底層在組什麼請求。先假設已建好 client:

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
)

curl 範例沿用上一篇的環境變數:

export SUPABASE_URL="https://<project_ref>.supabase.co"
export ANON_KEY="<你的 anon key>"

範例一:select 選欄位 + 基本篩選

只取三個欄位、篩出價格低於 100 的商品:

// supabase-js:垂直過濾(選欄位)+ 橫向過濾(條件)
const { data, error } = await supabase
  .from('products')
  .select('id, name, price')   // 只取這三欄
  .lt('price', 100)            // WHERE price < 100

// data 例如:[{ id: 1, name: 'Widget', price: 29.99 }, ...]

對照的 REST 請求——select 與篩選都是 URL query 參數:

# REST:select=欄位清單、price=lt.100 就是篩選條件
curl "$SUPABASE_URL/rest/v1/products?select=id,name,price&price=lt.100" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $ANON_KEY"

看出對應了嗎?.select('id, name, price') 就是 select=id,name,price、.lt('price', 100) 就是 price=lt.100。supabase-js 只是幫你把這些 query 參數組好。

範例二:多種篩選運算子綜合應用

把幾個常用運算子串在一起:分類在清單內、有庫存、名稱模糊比對(不分大小寫)、tags 包含 sale:

// 多條件預設是 AND:分類 in、庫存 > 0、名稱含 pro、tags 包含 'sale'
const { data } = await supabase
  .from('products')
  .select('id, name, category, stock')
  .in('category', ['electronics', 'gadgets'])  // category IN (...)
  .gt('stock', 0)                              // 有庫存
  .ilike('name', '%pro%')                      // 名稱含 pro(不分大小寫)
  .contains('tags', ['sale'])                  // tags 陣列包含 'sale'

用 .or() 表達「便宜的,或(有庫存且特價)」這種混合邏輯:

// or 內用 REST 風格語法,and(...) 做巢狀
const { data } = await supabase
  .from('products')
  .select('id, name, price')
  .or('price.lt.50,and(stock.gt.0,tags.cs.{sale})')

判斷 NULL 一定要用 .is(),不能用 .eq():

// ✅ 正確:找出還沒設定分類的商品
const { data } = await supabase.from('products').select('id').is('category', null)

// ❌ 錯誤:eq null 永遠查不到(SQL 中 col = NULL 恆為假)
// .eq('category', null)

範例三:排序

依價格低到高、NULL 價格排最後;同價格再依建立時間新到舊:

// 多欄排序:先 price 升冪,再 created_at 降冪
const { data } = await supabase
  .from('products')
  .select('id, name, price, created_at')
  .order('price', { ascending: true, nullsFirst: false })
  .order('created_at', { ascending: false })

REST 對照——多個排序欄位用逗號串在同一個 order 參數:

curl "$SUPABASE_URL/rest/v1/products?select=id,name,price&order=price.asc.nullslast,created_at.desc" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $ANON_KEY"

範例四:range 分頁 + count 總筆數

這是最實用的組合:一次拿到「這一頁的資料」加「總筆數」,就能算出總頁數。注意 range 從 0 起、含兩端:

// 每頁 20 筆,取第 3 頁(頁碼從 0 起 → page = 2)
const pageSize = 20
const page = 2
const from = page * pageSize          // 40
const to = from + pageSize - 1        // 59

const { data, count, error } = await supabase
  .from('products')
  .select('id, name, price', { count: 'estimated' })  // 同時要總筆數
  .order('created_at', { ascending: false })
  .range(from, to)                                     // range(40, 59) → 共 20 筆

// count 是符合條件的總筆數;總頁數 = Math.ceil(count / pageSize)
const totalPages = Math.ceil((count ?? 0) / pageSize)

REST 對照——分頁走 HTTP 的 Range header,count 走 Prefer header:

# Range: 40-59 對應 range(40, 59);Prefer: count=estimated 要總筆數
curl "$SUPABASE_URL/rest/v1/products?select=id,name,price&order=created_at.desc" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $ANON_KEY" \
  -H "Range: 40-59" \
  -H "Prefer: count=estimated"
# 回應的 Content-Range header 會像:40-59/12345(總筆數在斜線後)

REST 的總筆數藏在回應的 Content-Range header(例如 40-59/12345);supabase-js 已幫你解析成 count 欄位。若只想限制筆數、不做偏移,用 .limit(20) 即可(對應 REST 的 ?limit=20)。

範例五:single 與 maybeSingle 取單筆

用主鍵查「一定存在」的商品,用 .single()——不存在時明確噴錯:

// 用 id 查單筆,回傳單一物件(非陣列)
const { data: product, error } = await supabase
  .from('products')
  .select('*')
  .eq('id', 42)
  .single()   // 剛好一筆 → data 是物件;0 筆或多筆 → error(PGRST116)

檢查「某 email 是否已註冊」這種「可能不存在」的情境,用 .maybeSingle()——查無此人是正常流程:

// 查不到時 data 為 null、error 也是 null(不當錯誤處理)
const { data: existing } = await supabase
  .from('users')
  .select('id')
  .eq('email', 'test@example.com')
  .maybeSingle()

if (existing) {
  // 這個 email 已被註冊
}

常見錯誤與最佳實踐

坑一:range(0, 9) 撈幾筆搞不清,分頁「差一」。 range 從 0 起、且含兩端,range(0, 9) 是 10 筆不是 9 筆。很多人套用 slice 的直覺(不含結尾)而算錯。正確做法:固定用 from = page * pageSize、to = from + pageSize - 1 這組公式(頁碼從 0 起)。每頁 20 筆的第 1 頁是 range(0, 19)、第 2 頁是 range(20, 39),不要憑感覺填數字。

坑二:大 offset 分頁越翻越慢。 range 底層是 SQL 的 OFFSET,當你翻到第 5000 頁,資料庫仍得先掃過並丟棄前面 10 萬列才能取到你要的那 20 筆——offset 越大越慢。正確做法:資料量大或需要「無限滾動」時,改用游標分頁(keyset / cursor pagination):記住上一頁最後一筆的排序值,下一頁用 .gt() 接續,配合索引,效能與頁數無關。

// 游標分頁:用上一頁最後一筆的 id 接續(需在排序欄位建索引)
const { data } = await supabase
  .from('products')
  .select('id, name')
  .gt('id', lastSeenId)      // 接續上一頁
  .order('id', { ascending: true })
  .limit(20)

坑三:count: 'exact' 在大表上拖垮查詢。 exact 每次都實跑一次 COUNT,百萬列的表上可能要好幾秒,尤其篩選欄位沒索引時。正確做法:只要顯示「約 N 筆」用 estimated 或 planned;真的需要精確頁數,就在常用的篩選/排序欄位建索引,別讓 COUNT 全表掃描。前端也可考慮「只在第一頁要一次 count、翻頁時不重複要」以減少負擔。

坑四:判斷 NULL 用了 eq。 .eq('col', null) 永遠查不到任何列,因為 SQL 中 col = NULL 恆為假。正確做法:NULL/TRUE/FALSE 的比對一律用 .is()——.is('col', null)、.is('active', true)。

坑五:在會回傳多筆的查詢上用 .single()。 用非唯一欄位查詢卻接 .single(),一旦符合條件的列超過一筆就直接報 PGRST116 錯誤。正確做法:只有主鍵/唯一鍵查詢才用 .single();「可能 0 筆」用 .maybeSingle();本來就會多筆的查詢別硬收成單筆。

坑六:like 的萬用字元在 REST 與 SDK 不一樣。 在 supabase-js 用 %(SQL 風格):.ilike('name', '%pro%');但直接打 REST URL 時萬用字元是 *:name=ilike.*pro*。搞混會查不到預期結果。正確做法:用 SDK 就寫 %、手打 URL 就寫 *,別把兩者混用。

最佳實踐總結:

  • 只選需要的欄位:select('id,name') 勝過 select('*'),省 I/O 與頻寬
  • 分頁公式化:from = page * pageSize、to = from + pageSize - 1,避免「差一」
  • 大資料用游標分頁:offset 大時改用 .gt() + 索引,效能與頁數無關
  • count 依需求選模式:概數用 estimated/planned,精確頁數用 exact 但要建索引
  • NULL 用 is、單筆分清 single/maybeSingle:一定存在用 single、可能不存在用 maybeSingle

小結

這是 Supabase 系列教學的第 013 篇,延續第二大主題 SB-2「存取層」。上一篇《PostgREST 自動 API 入門》帶你認識了「一張表如何變成一個端點、怎麼帶對 header 安全存取」;這一篇則深入了這套自動 API 的讀取查詢語法,讓你能精準地取資料、而不只是把整張表撈出來。

核心觀念濃縮成一句話:PostgREST 把 SQL 的 SELECT/WHERE/ORDER BY/LIMIT 搬到了 HTTP 上——你用 .select().eq().order().range() 這樣的宣告式鏈式方法描述需求,它就翻成單一 SQL 幫你執行,而 supabase-js 與 REST 底層完全同源。

  • 選欄位與篩選:select('col1,col2') 做垂直過濾;eq/gt/ilike/in/is/contains 做橫向過濾,多條件預設 AND,用 or 組 OR
  • 排序與分頁:order 排序;range(from, to) 分頁(從 0 起、含兩端),大資料改用游標分頁
  • 計數與單筆:count 的 exact/planned/estimated 依準確度與速度取捨;single 要剛好一筆、maybeSingle 容許 0 筆
  • SDK 與 REST 同源:.lt('price', 100) 就是 price=lt.100、.range(40, 59) 就是 Range: 40-59

現在你已經能對單一資料表做出又準又快的查詢了。但真實資料很少是孤立的——商品有分類、訂單有客戶、文章有作者,這些關聯該怎麼在一次查詢裡一起撈出來,而不用發好幾支 API?下一篇《Embedded Resources 關聯查詢》,我們就來看 PostgREST 如何靠外鍵自動偵測表間關係,用 .select('title, categories(name)') 這種寫法把關聯資料一次展開成巢狀 JSON,徹底告別 N+1 查詢。下一篇見。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →