查詢、篩選與分頁 | 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-js | REST 寫法 | 說明 | 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}' |
幾個容易搞混的重點:
likevsilike:兩者都是模糊比對,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 查詢。下一篇見。