The REST API — PostgREST
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”PostgREST อ่าน schema ของ Postgres แล้วเปลี่ยนทุก table และ view ที่ expose ไว้ให้กลายเป็น REST endpoint โดยอัตโนมัติ — คุณไม่ต้องเขียนโค้ด API เอง
จาก table สู่ endpoint
หัวข้อที่มีชื่อว่า “จาก table สู่ endpoint”เมื่อคุณสร้าง table public.movies คุณไม่ต้องเขียน controller, route หรือ serializer ให้เลย PostgREST เชื่อมต่อกับ database ของคุณ introspect schema (table, column, type, foreign key) แล้ว generate REST endpoint ที่ตรงกันตอน request มาถึง table นี้จะเรียกใช้ได้ที่
GET /rest/v1/moviesนี่คือกลไกเบื้องหลัง Data API ของทุกโปรเจกต์ Supabase — PostgREST อยู่หน้าฐาน Postgres และแปลง HTTP request เป็น SQL query เพิ่ม column เข้าไป response shape ก็เปลี่ยนใน request ถัดไปทันที เพิ่ม table ใหม่ ก็มี endpoint ใหม่เกิดขึ้น — ไม่ต้อง deploy ไม่ต้องเขียนโค้ดเพิ่ม
create table public.movies ( id bigint generated always as identity primary key, title text not null, year int not null, director_id bigint references public.directors (id));Filter, select และ order ด้วย query parameter
หัวข้อที่มีชื่อว่า “Filter, select และ order ด้วย query parameter”PostgREST อ่าน URL query parameter ธรรมดาแล้วแปลงเป็น SQL where, select และ order by ให้เอง เลือกว่าจะเอา column ไหนกลับมาด้วย select
GET /rest/v1/movies?select=id,title,yearfilter row ด้วย syntax column=operator.value ของ PostgREST operator ที่ใช้บ่อยคือ eq. (เท่ากับ), gt./gte. (มากกว่า / มากกว่าหรือเท่ากับ), lt./lte. และ like. (จับ pattern) รวมถึง in. (อยู่ใน list)
GET /rest/v1/movies?year=gte.2020GET /rest/v1/movies?title=like.*Matrix*GET /rest/v1/movies?year=in.(2020,2021,2022)เรียงผลลัพธ์ด้วย order และใส่ .desc เพื่อเรียงจากมากไปน้อย
GET /rest/v1/movies?order=year.descทั้งสามอย่างรวมกันใน request เดียวได้
GET /rest/v1/movies?select=id,title,year&year=gte.2020&order=year.descEmbed related resource ผ่าน foreign key
หัวข้อที่มีชื่อว่า “Embed related resource ผ่าน foreign key”PostgREST ตาม foreign-key constraint แล้วส่ง row ที่เกี่ยวข้องกลับมาแบบ nested ใน response เดียว — ไม่ต้อง query แยก ไม่ต้อง join เองใน application code เพราะ public.movies.director_id reference ไปที่ public.directors.id คุณขอ director มาพร้อมกับแต่ละ movie ได้เลย
GET /rest/v1/movies?select=title,director:directors(name)request นี้คืนแต่ละ movie พร้อม object director แบบ nested ที่มี name อยู่ข้างใน PostgREST หาความสัมพันธ์นี้ได้จากการอ่าน foreign-key constraint จริงใน schema ของคุณ — ไม่มีไฟล์ mapping ความสัมพันธ์แยกต่างหากให้ดูแล ถ้า constraint เปลี่ยนหรือถูกลบ การ embed ก็จะหยุดทำงานทันที เพราะ schema คือ source of truth เดียว
พิสูจน์ ด้วย agapi_directors/agapi_movies คู่จริง — movie หนึ่งเรื่องตั้งใจให้ไม่มี director:
-- supabase/migrations/4201_agapi_movies_embedding.sqlcreate table public.agapi_directors ( id bigint generated always as identity primary key, name text not null);
create table public.agapi_movies ( id bigint generated always as identity primary key, title text not null unique, year int not null, rating numeric not null default 5.0, director_id bigint references public.agapi_directors (id));
insert into public.agapi_directors (name) values ('Denis Villeneuve'), ('Greta Gerwig');
insert into public.agapi_movies (title, year, director_id) values ('Dune', 2021, 1), ('Barbie', 2023, 2), ('Untitled Indie Film', 2024, null);curl "$API_URL/rest/v1/agapi_movies?select=title,director:agapi_directors(name)&order=id" \ -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY"[{"title":"Dune","director":{"name":"Denis Villeneuve"}}, {"title":"Barbie","director":{"name":"Greta Gerwig"}}, {"title":"Untitled Indie Film","director":null}]embed แบบธรรมดาเป็น left join: “Untitled Indie Film” ไม่มี director_id แล้วมันก็ยังกลับมาอยู่ดี พร้อม director: null ใส่ !inner ต่อท้าย resource ที่ embed บังคับให้เป็น inner join แทน — row ที่ไม่มีคู่ตรงจะถูกตัดออกจากผลลัพธ์ทั้งแถว ไม่ใช่แค่ใส่ null:
curl "$API_URL/rest/v1/agapi_movies?select=title,director:agapi_directors!inner(name)&order=id" \ -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY"[{"title":"Dune","director":{"name":"Denis Villeneuve"}}, {"title":"Barbie","director":{"name":"Greta Gerwig"}}]query เดียวกัน data เดียวกัน — “Untitled Indie Film” หายไปจาก response เลย ไม่ใช่อยู่แบบ null !inner ยังเป็นวิธี filter movie ด้วย column บน table ที่ ถูก embed ด้วย (เช่น agapi_directors!inner(name)&agapi_directors.name=eq.Denis+Villeneuve) — embed ธรรมดา filter row ชั้นนอกแบบนั้นไม่ได้ เพราะ left join ต้องเก็บทุก row ชั้นนอกไว้ตามนิยามของมัน
count=exact, count=planned, count=estimated
หัวข้อที่มีชื่อว่า “count=exact, count=planned, count=estimated”PostgREST รายงานได้ว่ามี row ที่ตรงเงื่อนไข กี่แถว แยกอิสระจากจำนวนที่ return จริงจาก Range/limit ผ่าน Prefer: count=... และ header Content-Range ของ response มีสามโหมด และใช้แทนกันไม่ได้ ตาม docs ของ PostgREST เอง (docs.postgrest.org — Pagination and Count): exact รัน count(*) จริง; planned อ่าน row estimate ของ query planner ของ Postgres เอง (เร็ว แต่แม่นแค่ไหนขึ้นกับสถิติของ table); estimated ใช้ exact count ถ้าต่ำกว่า threshold ที่ตั้งไว้ (db-max-rows) แล้ว fallback ไปใช้ planned estimate ถ้าเกิน พิสูจน์ ด้วย agapi_movies table สามแถวเดิม ยิง request ละแบบ:
curl -D - -o /dev/null "$API_URL/rest/v1/agapi_movies?select=id&limit=1" \ -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY" -H "Prefer: count=exact"curl -D - -o /dev/null "$API_URL/rest/v1/agapi_movies?select=id&limit=1" \ -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY" -H "Prefer: count=planned"curl -D - -o /dev/null "$API_URL/rest/v1/agapi_movies?select=id&limit=1" \ -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY" -H "Prefer: count=estimated"count=exact -> Content-Range: 0-0/3 (a real count(*): exactly 3 rows)count=planned -> Content-Range: 0-0/720 (Postgres's own planner guess — this table was never ANALYZEd, so it is a generic default estimate, not the real 3)count=estimated -> Content-Range: 0-0/3 (3 rows is below db-max-rows, so estimated fell back to an exact count here)720 ของ planned ไม่ใช่บั๊กหรือการปัดเศษ — มันคือค่าที่ query planner ของ Postgres เดาไว้จริง ๆ สำหรับ table เล็กที่ไม่มีสถิติสดใหม่ ยกมาตรง ๆ นี่คือ trade-off ทั้งหมดในตัวเลขเดียว: exact ถูกต้องเสมอแต่ช้าได้บน table ใหญ่; planned เร็วเสมอแต่ผิดได้มากบน table เล็กที่ไม่ถูก analyze; estimated พยายามเอาข้อดีทั้งสองมารวมกันด้วยการสลับตามขนาด table
Prefer: header — request/response จริงของแต่ละตัว
หัวข้อที่มีชื่อว่า “Prefer: header — request/response จริงของแต่ละตัว”return=representation— โดย default การinsert/update/deleteที่สำเร็จจะคืน body ว่างพร้อม201/204; header นี้ทำให้ PostgREST คืน row ที่ได้รับผลแทน อยู่ใน response เดียวกัน:
curl -X POST "$API_URL/rest/v1/agapi_movies" \ -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY" \ -H "Content-Type: application/json" -H "Prefer: return=representation" \ -d '{"title":"Poor Things","year":2023,"director_id":2}'201 [{"id":4,"title":"Poor Things","year":2023,"rating":5.0,"director_id":2}]resolution=merge-duplicates— เปลี่ยน insert ที่ขัดกับ unique constraint ให้กลายเป็น upsert (insert ... on conflict ... do update) แทนที่จะได้409ต้องมี?on_conflict=<column>ระบุ constraint ที่จะใช้แก้ conflict:
# Without the header, inserting the same title again is a real conflict:curl -X POST "$API_URL/rest/v1/agapi_movies" \ -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY" \ -H "Content-Type: application/json" \ -d '{"title":"Poor Things","year":2023,"director_id":2}'
# With it, the same request updates the existing row instead:curl -X POST "$API_URL/rest/v1/agapi_movies?on_conflict=title" \ -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY" \ -H "Content-Type: application/json" -H "Prefer: return=representation,resolution=merge-duplicates" \ -d '{"title":"Poor Things","year":2023,"director_id":2,"rating":9.1}'without header -> 409 {"code":"23505","details":"Key (title)=(Poor Things) already exists.","hint":null,"message":"duplicate key value violates unique constraint \"agapi_movies_title_key\""}with header -> 200 [{"id":4,"title":"Poor Things","year":2023,"rating":9.1,"director_id":2}]missing=default— bulk insert ที่ row ไม่ได้มี key JSON ตรงกันทุกตัวถูกปฏิเสธทันทีโดย default (PGRST102, “All object keys must match”) header นี้ บวกกับ?columns=ที่ระบุทุก column ที่ batch ใช้อย่างชัดเจน จะเติม key ที่ขาดไปในแต่ละ row ด้วย default ของ column นั้นแทนการ error:
curl -X POST "$API_URL/rest/v1/agapi_movies?columns=title,year,rating" \ -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY" \ -H "Content-Type: application/json" -H "Prefer: return=representation,missing=default" \ -d '[{"title":"Movie With Rating","year":2020,"rating":8.0},{"title":"Movie Without Rating","year":2021}]'201 [{"id":7,"title":"Movie With Rating","year":2020,"rating":8.0,"director_id":null}, {"id":8,"title":"Movie Without Rating","year":2021,"rating":5.0,"director_id":null}]row ที่สองไม่ได้พูดถึง rating เลย — มันลงเอยเป็น 5.0 ซึ่งเป็น default ของ column ไม่ใช่ null ถ้าไม่มี missing=default (หรือไม่มี ?columns= ที่ตรงกัน) request แบบเดียวกันเป๊ะจะถูกปฏิเสธก่อนไปถึง Postgres เลยด้วยซ้ำ
GraphQL ผ่าน pg_graphql — และ RLS ยังบังคับใช้อยู่
หัวข้อที่มีชื่อว่า “GraphQL ผ่าน pg_graphql — และ RLS ยังบังคับใช้อยู่”schema เดียวกันนี้ยังเข้าถึงได้ผ่าน GraphQL แต่บน local stack นี้มันไม่ได้อัตโนมัติ — pg_graphql เป็น Postgres extension ที่มีให้ใช้ ไม่ใช่ตัวที่เปิดไว้โดย default:
-- sql/agapi_enable_pg_graphql.sqlcreate extension if not exists pg_graphql;curl -X POST "$API_URL/graphql/v1" -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY" \ -H "Content-Type: application/json" -d '{"query":"query { agapi_moviesCollection { edges { node { title } } } } "}'{"errors":[{"message":"pg_graphql extension is not enabled."}]}เปิดแล้ว กฎ authorization เดียวกันเป๊ะกับ REST ใช้ได้ — reuse table agapi_notes และ RLS policy จากบทก่อนหน้า (แต่ละ row เป็นของ user จริงคนละคนใน user จริงสองคน) GraphQL query ที่ ไม่ filter อะไรเลย ตัวเดียวกันคืน row ต่างกันให้ผู้เรียกต่างกัน:
curl -X POST "$API_URL/graphql/v1" -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ALICE_TOKEN" \ -H "Content-Type: application/json" -d '{"query":"query { agapi_notesCollection { edges { node { id body } } } }"}'curl -X POST "$API_URL/graphql/v1" -H "apikey: $ANON_KEY" -H "Authorization: Bearer $BOB_TOKEN" \ -H "Content-Type: application/json" -d '{"query":"query { agapi_notesCollection { edges { node { id body } } } }"}'alice -> {"data":{"agapi_notesCollection":{"edges":[{"node":{"id":"16ff28b0-...","body":"alice private note"}}]}}}bob -> {"data":{"agapi_notesCollection":{"edges":[{"node":{"id":"6e11f6a2-...","body":"bob private note"}}]}}}pg_graphql สร้าง schema ของมันจาก Postgres catalog เดียวกับที่ PostgREST อ่าน และรันผ่าน Postgres connection เดียวกัน ด้วย role เดียวกันและ RLS เดียวกัน — ไม่มี authorization layer แยกต่างหากสำหรับ GraphQL ให้ต้อง config หรือลืม ความปลอดภัยของ RLS ใน RPC แบบ security invoker (พิสูจน์แล้วบทก่อนหน้า) กับความปลอดภัยของ RLS ใน GraphQL query นี้ คือ guarantee เดียวกันเป๊ะ แค่เข้าถึงผ่านสอง protocol ที่ต่างกัน
flowchart LR
tbl[("public.movies (with FK to directors)")] --> introspect["PostgREST introspects the schema"]
introspect --> endpoint["GET /rest/v1/movies"]
endpoint --> select["?select= chooses columns"]
endpoint --> filter["?year=gte.2020 filters rows"]
endpoint --> order["?order=year.desc sorts rows"]
endpoint --> embed["?select=title,director:directors(name) embeds via FK"] Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”AI assistant ที่ถูกขอให้ “ให้ admin bulk-import หนังจาก spreadsheet โดยข้ามตัวที่มีอยู่แล้ว” สร้างโค้ดนี้ขึ้นมา:
🐛 หา bug ในโค้ดนี้
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);
async function importMovies(rows: { title: string; year: number; rating?: number }[]) {const { data, error } = await supabase .from('agapi_movies') .upsert(rows, { onConflict: 'title', ignoreDuplicates: true });
if (error) { console.error('import failed:', error.message); return;}
console.log(`imported ${rows.length} movies`);}เฉลย
สามปัญหาแยกกัน แต่ละอันดูสมเหตุสมผลในตัวเอง อันแรก console.log บรรทัดสุดท้ายรายงาน rows.length — ขนาด input — ไม่ใช่สิ่งที่เกิดขึ้นจริง: ด้วย ignoreDuplicates: true row ที่มีอยู่แล้วถูกข้ามไปเงียบ ๆ ดังนั้น “imported 40 movies” อาจหมายถึง 0 ถึง 40 ตัวที่เป็นของใหม่จริง ๆ ก็ได้ โค้ดไม่เคยดู data ที่ return กลับมาเลยเพื่อรู้ความจริง อันที่สอง row ที่มี key ไม่ตรงกัน (บางอันมี rating บางอันไม่มี ตรงกับสถานการณ์ import จาก spreadsheet เป๊ะ) จะเจอ PGRST102 "All object keys must match" แบบเดียวกับที่พิสูจน์ไว้ด้านบน — .upsert() ของ supabase-js ไม่ได้ปิดบังปัญหานี้ มันยังเป็น PostgREST bulk request ตัวเดียวอยู่ดีข้างใต้ ดังนั้น batch ที่มี key ไม่ตรงกันจะล้มเหลวทันที เว้นแต่ผู้เรียกจะส่ง option ที่ map ไปเป็น missing=default ด้วย พร้อมระบุ column ให้ครบ อันที่สาม และพลาดง่ายที่สุด: โค้ดนี้ใช้ key anon จาก script ที่พูดถึง “admin” — ถ้า agapi_movies grant insert ให้ anon/authenticated อยู่แล้ว (ตามบทก่อนหน้า เกิดขึ้นโดยไม่ตั้งใจได้ง่าย) การ import แบบ “admin” นี้ก็รันด้วยสิทธิ์เดียวกับที่ user คนไหนก็ตามที่ login มีอยู่แล้ว ไม่ใช่ credential เฉพาะ admin เลย ไม่มีจุดไหนในโค้ดเช็คเลยว่าผู้เรียกเป็น admin จริงไหม
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”PostgREST คือ compiler ไม่ใช่ proxy: ทุก request — select, filter, embed, Prefer header, count mode — คือ source code ในภาษา query เล็ก ๆ ที่เขียนเป็น URL กับ header บางตัว และหน้าที่เดียวของ PostgREST คือ compile มันเป็น SQL statement เดียวแล้วส่งให้ Postgres ข้างใน transaction เดียว ไม่มี “REST layer” ระดับ framework ที่ทำอะไรที่ Postgres เองทำไม่ได้เลย — !inner compile เป็น SQL join จริง ไม่ใช่ left join, count=planned compile เป็นการอ่าน pg_class/สถิติของ planner แทนที่จะรัน count(*), resolution=merge-duplicates compile เป็น on conflict do update เมื่อมองแบบนี้ “URL นี้จะทำอะไรจริง ๆ” กลายเป็นคำถามที่มีคำตอบเชิงกลไก: ถ้าเขียน SQL เองจะเขียนยังไง แล้ว Postgres เองรองรับมันไหม
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”- การ embed แทนที่ join/query ที่สองด้วยตนเองด้วย URL เดียว — แลกกับ default ที่เป็น left join ที่ต้อง override เอง (
!inner) ทันทีที่ “ไม่มี related row” ควรตัดผลลัพธ์ออกแทนที่จะใส่ null count=exactถูกต้องแบบไม่มีข้อสงสัย — แลกกับต้นทุนเทียบเท่า full-table scan จริง บน table ใหญ่ที่ไม่มี index;plannedแทบไม่มีต้นทุนแต่แม่นแค่ไหนขึ้นกับANALYZEล่าสุดของ Postgres;estimatedเป็นจุดกึ่งกลางที่ UI ส่วนใหญ่อยากได้จริง ๆ แต่ความแม่นยำขึ้นกับ threshold ฝั่ง server ที่ client คุมไม่ได้อย่างเงียบ ๆPrefer:header เพิ่ม capability จริง (upsert, เติม default, response shape) โดยไม่ต้องมี endpoint เพิ่ม — แลกกับพฤติกรรมที่มองไม่เห็นเว้นแต่จะอ่าน header list:resolution=merge-duplicatesต้องมี?on_conflict=,missing=defaultต้องมี?columns=, และtx=rollbackต้องมี server opt-in ที่อาจไม่ได้ตั้งไว้ ทำให้ header เดียวกันเป็น no-op ได้ขึ้นกับ config ฝั่ง server ไม่ใช่ intent ของ clientpg_graphqlให้ query language ที่สองบน authorization model เดียวกันฟรี ๆ — แลกกับการที่มันต้องถูกเปิดก่อนเลยบน local stack ใหม่ ๆ ไม่เหมือนโปรเจกต์ hosted ที่มักเปิดไว้แล้ว — พิสูจน์แล้วด้านบนด้วย error"pg_graphql extension is not enabled"จริงที่บล็อกจนกว่าจะรันcreate extension
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “Embedding คือ join เลยไง movie ที่ไม่มี director ก็ไม่ควรปรากฏ” default เป็น left join — มันปรากฏ พร้อม field ที่ embed เป็น
nullมีแค่!innerเท่านั้นที่ตัดมันออก และนั่นคือ opt-in ที่ต้องเขียนเอง - “
count=plannedพังถ้ามันไม่ตรงกับจำนวน row จริง” มันทำงานตามที่ออกแบบไว้ — เป็น planner estimate ไม่ใช่ count และสดใหม่แค่ไหนขึ้นกับANALYZEล่าสุดของ table พิสูจน์แล้วด้านบน:720จริงบน table 3 แถวจริง ไม่ใช่บั๊ก - “ส่ง
Prefer: tx=rollbackแปลว่า test write ของฉันไม่ persist แน่นอน” พิสูจน์แล้วด้านบนว่ามันไม่มีผลอะไรเลยแบบเงียบ ๆ ได้ —db-tx-endฝั่ง server เป็นตัวตัดสินว่า header นี้จะถูกฟังหรือไม่ และ default ของ local stack ไม่ฟังมัน ตรวจสอบด้วยการ query หา row ทีหลังทุกครั้งที่พึ่งพามัน - “GraphQL กับ REST มี security model แยกกัน ต้อง config RLS สองรอบ” ทั้งสองแชร์ Postgres connection, role และ RLS policy เดียวกัน พิสูจน์แล้วด้านบนด้วย notes/RLS setup ตัวเดียวกันจากบท RPC-based REST ที่ทำงานเหมือนเดิมผ่าน
/graphql/v1ขั้นตอนเดียวที่เพิ่มมาบน local คือต้องเปิด extension ก่อน