Skip to content

บทที่ 2 — Pagination, Filter, Sort, Search

← บทที่ 1 | สารบัญ | บทที่ 3 →

หลังจบบท คุณจะ:

  • ออกแบบ pagination ที่ scale (offset + keyset)
  • Filter API ที่ flexible + ไม่กลายเป็น "SQL injection"
  • Sort multi-field + ranking
  • Search ด้วย full-text + suggestion
  • Cursor-based pagination สำหรับ feed

1. ทำไม Pagination สำคัญ

GET /users → return 10 ล้าน row → network ตาย + client crash

ต้องจำกัด — เลือก 1 ใน 3 strategy:

Pagination (อ่าน เพ-จิ-เน-ชั่น) = การแบ่งผลลัพธ์เป็น "หน้า" / Cursor (อ่าน เคอร์-เซอร์) = ตัวชี้ตำแหน่ง "ดึงจากตรงนี้ต่อ"

StrategyURLดีไม่ดี
Offset?page=2&size=20ง่าย, jump ไปหน้าไหนก็ได้ช้าเมื่อ deep page, data shift, ห้ามใช้กับ public API ที่ scale
Keyset (cursor)?after=12345เร็วเสมอ, ไม่มี data shiftไป page ไหนก็ได้ไม่ได้, code ซับซ้อน
Cursor (opaque)?cursor=eyJpZCI6MTJ9hide internal, version-friendlystateless ถ้าออกแบบเป็น encoded position; stateful ถ้าใช้ server-side cursor

opaque (อ่าน โอ-เพ้ค) = ทึบ มองไม่เห็นข้างใน — cursor ที่ encode เป็น string ลึกลับ client ไม่ต้องเข้าใจ format

📊 Offset vs Cursor (ภาพรวม)

OFFSET (page=3, size=20):
DB ต้อง scan 40 row ทิ้ง แล้วเอา 20 row ถัดมา
[ row 1..20 ][ row 21..40 ][ ✅ row 41..60 ]
      ทิ้ง         ทิ้ง         เอา
→ ยิ่ง page ลึก ยิ่งช้า

CURSOR (after=id_40):
DB ใช้ index หาตำแหน่ง id=40 แล้วเอา 20 row ถัดมา
                          ↓ jump ด้วย index
[ row 1..40 .................. ][ ✅ row 41..60 ]
                                       เอา
→ เร็วเท่ากันทุกหน้า (O(log N))

2. Offset Pagination

วิธี paginate ที่คุ้นเคยและง่ายสุดคือ offset (page + size) — ข้ามไป N รายการแล้วเอามา M รายการ ดีตรงที่กระโดดหน้าได้ (ไปหน้า 5 เลย) และคำนวณ total/totalPages ได้ แต่มีจุดอ่อนเรื่อง performance และข้อมูลซ้ำ/หายเมื่อมีการเพิ่ม-ลบระหว่างหน้า (จะอธิบายต่อในหัวข้อ cursor):

http
GET /api/v1/users?page=2&size=20

Response (สมมติ 1-indexed: page=1 คือหน้าแรก):

json
{
    "data": [...20 users],
    "meta": {
        "page": 2,
        "size": 20,
        "total": 150,
        "totalPages": 8,
        "hasNext": true,
        "hasPrev": true
    }
}

⚠️ ระวัง page indexing — Spring Data default ใช้ 0-indexed (page=0 คือหน้าแรก) ส่วน REST API ทั่วไปนิยม 1-indexed. ตัวอย่างข้างบนคือ 1-indexed (page=2 → hasPrev: true เพราะ page=1 มีอยู่). เลือก convention เดียวแล้ว document ให้ชัด

Plain HTTP first (ไม่ใช่ Spring)

ก่อนจะดูโค้ด Spring — ในทุกภาษา URL ก็คือ ?page=...&size=.... backend แค่อ่าน 2 query param นี้ แปลงเป็น SQL LIMIT size OFFSET (page-1)*size เท่านั้น

Spring Boot Implementation

📘 Pageable / @PageableDefault / Page<T> = ของสำเร็จรูปของ Spring Data ที่ห่อ page, size, sort ให้ — ถ้าไม่ใช่สาย Java/Spring อ่านเอา idea ว่า "Spring แปลง query param เป็น object ให้ controller ใช้" ก็พอ

java
@GetMapping
public Page<UserResponse> list(
    @PageableDefault(size = 20) Pageable pageable
) {
    return userRepo.findAll(pageable).map(UserResponse::from);
}

Spring แปลง ?page=...&size=...&sort=... เป็น Pageable ให้

URL Convention

?page=0&size=20            # 0-indexed (Spring default)
?page=1&size=20            # 1-indexed
?limit=20&offset=40         # SQL-style
?per_page=20&page=2         # GitHub style

ทีมเลือก + consistent

⚠️ คำแนะนำปี 2026 สำหรับ public API ที่ scale — offset pagination เป็น anti-pattern เพราะ deep page ช้ามากและข้อมูลซ้ำ/หายเมื่อมี insert/delete. API ใหม่ใน 2026 (Stripe starting_after, GitHub v4 GraphQL cursor) → cursor-based เป็น default. offset เหมาะกับ admin UI / internal เท่านั้น

⚠️ Offset Problems

Problem 1: Deep Page = ช้า

sql
SELECT * FROM users ORDER BY id LIMIT 20 OFFSET 1000000;
-- DB ต้อง scan 1M + 20 row, ทิ้ง 1M

⚠️ ถ้า column ที่ ORDER BY ไม่มี index จะแย่ยิ่งกว่านั้น — DB ต้อง sort ทั้งตารางก่อน (O(N log N)) แล้วค่อย skip 1M row. แม้ใช้ offset น้อย ๆ ก็ช้า

Problem 2: Data Shift

GET /users?page=1   → users 1-20
[someone inserts new user]
GET /users?page=2   → users 20-39  ← user 20 ซ้ำ (shift)

3. Keyset Pagination — Solution

ใช้ "last id" ของ page ก่อนเป็น cursor:

http
GET /users?limit=20                     # first page
GET /users?limit=20&afterId=12345        # next page (after id 12345)
sql
-- First page
SELECT * FROM users ORDER BY id LIMIT 20;

-- Next page
SELECT * FROM users WHERE id > 12345 ORDER BY id LIMIT 20;

→ ใช้ index → O(log N) — เร็วเสมอ

O(log N) เป็นวิธีบอก "ความเร็วของอัลกอริทึม" (เรียกว่า Big-O) — แปลเป็นภาษาคนคือ "เร็วแม้ข้อมูลเยอะมาก" ต่อให้ข้อมูลโตเป็นล้านแถว ความช้าก็แทบไม่เพิ่ม (ต่างจาก offset ที่ยิ่ง deep page ยิ่งช้า)

เปรียบเทียบตัวเลข ที่ 1 ล้านแถว: O(log N)20 ขั้น (log₂(1,000,000) ≈ 20) / O(N) = 1 ล้านขั้น — ต่างกัน 50,000 เท่า

Response

json
{
    "data": [...20 users],
    "meta": {
        "nextCursor": "12365",
        "hasNext": true
    }
}

Multiple Sort Field — Tuple Comparison (เทียบหลาย field พร้อมกัน)

WHERE (created_at, id) < (...) อ่านยังไง? มันคือการเทียบ "เป็นคู่" แบบ lexicographic (เรียงเหมือนพจนานุกรม) — เทียบ created_at ก่อน ถ้าเท่ากันค่อยใช้ id ตัดสิน เทียบเท่ากับ:

sql
WHERE created_at < '2026-05-18 10:00'
   OR (created_at = '2026-05-18 10:00' AND id < 999)

แต่ syntax tuple อ่านง่ายและ optimizer ใช้ index ได้ดีกว่า

💡 ทำไม < (น้อยกว่า) ทั้งที่อยาก "ถัดไป"? เพราะเราเรียงแบบ DESC (ใหม่ → เก่า) — "หน้าถัดไป" คือ "เก่ากว่าแถวสุดท้ายของหน้านี้" = ค่า tuple น้อยกว่า

เดินตามตัวอย่าง: หน้าแรกได้แถวล่าสุดจนถึง (2026-05-18 10:00, 999) → หน้าถัดไปต้องเริ่มจากแถวที่ created_at เก่ากว่า 10:00 (หรือ created_at เท่ากันแต่ id < 999)

sql
-- Sort by created_at DESC, id DESC (กัน duplicate)
SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT 20;

-- Next page — last row was (2026-05-18 10:00, 999)
SELECT * FROM posts 
WHERE (created_at, id) < ('2026-05-18 10:00', 999)
ORDER BY created_at DESC, id DESC LIMIT 20;

⚠️ DB compatibilityPostgreSQL รองรับ tuple comparison เต็มที่และ optimizer ใช้ composite index ได้ดี. MySQL/MariaDB รองรับ syntax แต่ optimizer อาจไม่เลือก index → ตรวจด้วย EXPLAIN ทุกครั้ง

⚠️ อย่าใช้ UUID v4 เป็น cursorUUID v4 ไม่มีลำดับเวลา (random) → tuple comparison ไม่ทำงาน. UUIDv7 (มี timestamp prefix, ordered) หรือ auto-increment id ใช้ได้

⚠️ Keyset Limitations

  • ❌ Jump ไป page 50 ไม่ได้ — sequential เท่านั้น
  • ❌ Total count ไม่ได้ (มัก) — แสดง "load more" แทน
  • ⚠️ Sort field ต้อง unique หรือ + id เป็น tiebreaker

4. Opaque Cursor — Hide Implementation

🚀 โซนขั้นสูง — ข้ามได้ ถ้าเพิ่งเริ่ม: opaque cursor เป็นการต่อยอดจาก keyset ให้ "ซ่อน" รายละเอียดภายใน เริ่มต้นใช้ offset หรือ keyset ธรรมดาก่อนก็พอ แล้วค่อยกลับมาอ่านตอนต้องทำ feed จริงจัง

http
GET /users?cursor=eyJpZCI6MTIzNDV9

cursor = base64-encoded JSON: {"id": 12345}

base64 (อ่าน เบส-ซิก-สตี้-โฟร์) = วิธีเข้ารหัสข้อความให้เป็นตัวอักษรที่ปลอดภัยใส่ใน URL ได้ — ไม่ใช่การเข้ารหัสลับ ใครก็ decode (ถอด) กลับมาดูได้ ดังนั้นห้ามใส่ข้อมูลลับลงไป มันแค่ทำให้ cursor ดู "ทึบ" (opaque) เพื่อซ่อน format ภายในจาก client เท่านั้น

ข้อดี:

  • Hide internal — client ไม่เห็น id
  • Version safe — เปลี่ยน format ได้
  • Encode multiple field (cursor = encoded id + timestamp)

📘 Base64 ในตัวอย่างนี้Base64.encode แปลง JSON string เป็น string ที่ใส่ใน URL ได้ปลอดภัย (ไม่มี ", {, } ที่ต้อง escape). Base64.decode ทำกลับด้าน. ใน Java จริงคือ java.util.Base64.getUrlEncoder() / getUrlDecoder() (ใช้ -_ แทน +/ เพื่อปลอดภัยใน URL)

java
// เข้ารหัส (encode) — แปลง JSON เป็น cursor
String cursor = Base64.encode("{\"id\":12345,\"ts\":\"2026-05-18T10:00\"}");

// ถอดรหัส (decode) — แปลง cursor กลับเป็นข้อมูล
CursorData data = objectMapper.readValue(Base64.decode(cursor), CursorData.class);

return userRepo.findAfter(data.id(), data.ts(), 20);

ใช้ใน: Facebook, Twitter, Stripe


5. Pagination Headers (Alternative)

แทนที่จะใส่ pagination meta ใน response body บาง API ใส่ใน HTTP header แทน — Link header (RFC 8288, เดิม RFC 5988) บอก URL หน้า prev/next/first/last และ X-Total-Count บอกจำนวนรวม ข้อดีคือ body มีแต่ data ล้วน (GitHub ใช้แบบนี้):

แทนที่ใส่ meta ใน body:

http
GET /users?page=2&size=20

Response:
HTTP/1.1 200 OK
Link: </users?page=1&size=20>; rel="prev",
      </users?page=3&size=20>; rel="next",
      </users?page=1&size=20>; rel="first",
      </users?page=8&size=20>; rel="last"
X-Total-Count: 150

📘 Link header format = <URL>; rel="ความสัมพันธ์", <URL>; rel="ความสัมพันธ์", ... — ใช้ comma คั่นหลายลิงก์, URL ครอบด้วย <...>, แต่ละลิงก์มี rel= บอกความสัมพันธ์ (prev, next, first, last)

GitHub ใช้ pattern นี้ — Link header ตาม RFC 8288 (Web Linking, obsoletes RFC 5988 ตั้งแต่ ต.ค. 2017)


6. Filtering

filtering ให้ client เลือกเฉพาะข้อมูลที่ต้องการผ่าน query parameter — ตั้งแต่ filter ง่าย ๆ (?status=active), range (?priceMin=), list (IN), boolean ไปจนถึง dynamic filter ด้วย Specification ⚠️ อย่าออกแบบ query DSL ซับซ้อนในURL ให้ใช้ parameter เฉพาะเจาะจงที่อ่านง่ายและปลอดภัยกว่า:

Simple Query Parameter

GET /users?status=active&role=admin
java
@GetMapping
public Page<UserResponse> list(
    @RequestParam(required = false) String status,
    @RequestParam(required = false) String role,
    Pageable pageable
) {
    return userRepo.findByStatusAndRole(status, role, pageable);
}

Range Filter

GET /products?priceMin=100&priceMax=500
GET /orders?createdAfter=2026-01-01&createdBefore=2026-06-30

List Filter (IN)

GET /users?status=active,pending,suspended
java
@RequestParam(required = false) List<String> status
// ⚠️ Spring split "," ให้ "เฉพาะ" ตอนรับเป็น List<String> และ default Converter ทำงาน
// บางสถานการณ์ (custom Converter, encoding) ได้ ["active,pending"] แทน ["active","pending"]
// ปลอดภัยกว่า: ใช้ ?status=active&status=pending (ส่ง key ซ้ำ)

⚠️ Multi-value query param มี 2 convention:

  • ?status=a,b,c (comma-separated) — Spring split ให้ในกรณีส่วนใหญ่ แต่ encoding ผิดได้
  • ?status=a&status=b&status=c (key ซ้ำ) — มาตรฐาน HTTP, ทุก backend รองรับ, แนะนำ

Boolean

GET /products?inStock=true

⚠️ Avoid Overcomplexity

❌ GET /users?filter=status:active,role:admin AND (age>18 OR isVerified:true)

📘 DSL = Domain-Specific Language = ภาษาเล็ก ๆ ที่ออกแบบเฉพาะงาน (เช่น SQL = DSL สำหรับ query database). ตัวอย่างข้างบนคือ "query DSL" ใน URL — อ่านยาก, parse ยาก, เปิดช่อง injection ได้ถ้าไม่ระวัง

→ ออกแบบ specific query parameter แทน — readable + safe

Spring Boot Specification (dynamic filter)

🚀 โซนขั้นสูง — ข้ามได้ ถ้ายังไม่คุ้นกับ JPA: Specification คือวิธีสร้าง query แบบ "ประกอบเงื่อนไขตามที่ user ส่งมา" ของ Spring. lambda (root, q, cb) -> ... คือ JPA Criteria APIroot แทน entity, cb แทน CriteriaBuilder ที่สร้างเงื่อนไขทีละชิ้น (cb.equal, cb.gt, ...). มือใหม่ใช้ @RequestParam ธรรมดา (แบบด้านบน) ไปก่อนได้

Pseudo-code เข้าใจง่ายกว่า:

filters = []
if (status != null) filters.add("status = " + status)
if (role != null) filters.add("role = " + role)
sql = "SELECT * FROM users WHERE " + AND(filters)

โค้ดจริง Spring Specification:

java
public class UserSpec {
    public static Specification<User> withStatus(String status) {
        return (root, q, cb) -> status == null ? null : cb.equal(root.get("status"), status);
    }
    public static Specification<User> withRole(String role) {
        return (root, q, cb) -> role == null ? null : cb.equal(root.get("role"), role);
    }
}

@GetMapping
public Page<UserResponse> list(@RequestParam(required = false) String status, ...) {
    Specification<User> spec = Specification.where(withStatus(status)).and(withRole(role));
    return userRepo.findAll(spec, pageable).map(UserResponse::from);
}

7. Sort

การจัดเรียงผลลัพธ์ทำผ่าน ?sort= — มีหลาย convention (-name แบบ Stripe, name,desc แบบ Spring) เลือกแบบเดียวให้ consistent ⚠️ จุดที่พลาดง่ายและเป็นช่องโหว่ security คือต้อง whitelist field ที่ sort ได้ ไม่งั้น user อาจ sort ด้วย column ภายใน (password_hash) จน leak ข้อมูล:

GET /users?sort=name                    # ascending
GET /users?sort=-name                    # descending (Stripe / JSON:API style)
GET /users?sort=-createdAt,name          # multi-field: createdAt DESC, name ASC
GET /users?sort=name,asc                 # explicit (Spring style)
GET /users?sort=createdAt,desc&sort=name,asc   # multiple (Spring)

💡 convention ปี 2026 ที่นิยม?sort=-createdAt,name (ขีดหน้า = DESC, comma คั่นหลาย field) เพราะสั้น อ่านง่าย และ JSON:API spec ใช้แบบนี้

Spring Boot

GET /users?sort=name,asc&sort=age,desc
java
public Page<UserResponse> list(@PageableDefault(sort = "createdAt") Pageable pageable) {
    return ...;
}

⚠️ Whitelist Sort Field

java
// ❌ ใครก็ sort ด้วย column ภายในได้ (password_hash, internal_notes)
findAll(pageable);   // pageable มาจาก user input

// ✅ Whitelist
Set<String> ALLOWED_SORT = Set.of("name", "email", "createdAt");
for (Sort.Order order : pageable.getSort()) {
    if (!ALLOWED_SORT.contains(order.getProperty())) {
        throw new ValidationException("Invalid sort field");
    }
}

💡 หมายเหตุ threat model — Spring Data JPA map sort เป็น entity property (ไม่ใช่ SQL column ตรง ๆ) ดังนั้นการ sort ด้วย password_hash จะ leak ค่า hash ออกมาตรง ๆ ก็ต่อเมื่อ entity field นั้นถูก expose ใน response. แต่แม้ไม่ leak ค่า, response timing (sort เร็ว/ช้า) ก็ใช้ enumerate ค่า hash ได้ — ดังนั้น whitelist อยู่ดี


search ต่างจาก filter ตรงที่เป็นการค้นแบบ fuzzy/full-text ไม่ใช่ match แบบเป๊ะ — เริ่มจาก ?q= ค้นใน field ที่กำหนด (ILIKE) สำหรับงานเล็ก แล้วยกระดับเป็น full-text search (Postgres tsvector) หรือ Elasticsearch เมื่อต้องการ ranking/relevance:

Simple Query

GET /users?q=anna
GET /users?search=anna

→ search ใน field ที่กำหนด (name, email)

java
@GetMapping
public Page<UserResponse> list(@RequestParam(required = false) String q, Pageable pageable) {
    if (q != null) {
        return userRepo.search(q, pageable);  // SQL: WHERE name ILIKE %q% OR email ILIKE %q%
    }
    return userRepo.findAll(pageable);
}

Full-Text Search (Postgres)

📘 Full-text search รายละเอียดอยู่ใน Database book บทที่ 7 — ตรงนี้ดูแค่ shape ของ query พอ (ไม่ต้องเข้าใจทุก operator)

sql
SELECT * FROM articles 
-- @@ = ตัวดำเนินการ "ข้อความนี้ตรงกับคำค้นไหม" ของ Postgres
-- plainto_tsquery(...) = แปลงคำค้นของ user เป็น "คำค้นแบบ full-text" (tsquery)
WHERE search_vector @@ plainto_tsquery('english', :query)
-- ts_rank(...) = ให้คะแนนความเกี่ยวข้อง (relevance) แล้วเรียงผลที่ตรงที่สุดขึ้นก่อน
ORDER BY ts_rank(search_vector, plainto_tsquery('english', :query)) DESC;

FTS (อ่าน เอฟ-ที-เอส, ย่อจาก Full-Text Search) = การค้นข้อความแบบ "ค้นในเนื้อหา" ที่ฉลาดกว่าการ match ตรง ๆ (รู้จักรากศัพท์ จัดอันดับความเกี่ยวข้องได้) — Postgres มีมาให้ในตัว

Search + Filter ผสม

GET /products?q=phone&category=electronics&priceMax=500

→ search ใน name/description, filter โดย category + price


9. Field Selection (Sparse Fieldset)

ลด data ที่ส่งเมื่อ client ต้องการเฉพาะบางอย่าง:

GET /users?fields=id,name,email
json
[
    { "id": 1, "name": "Anna", "email": "anna@x.com" },
    ...
]

ใช้กับ:

  • Mobile (bandwidth limit)
  • List view ที่ใช้แค่ 2-3 field

Spring:

java
@GetMapping
public List<Map<String, Object>> list(@RequestParam(required = false) Set<String> fields) {
    return users.stream().map(u -> selectFields(u, fields)).toList();
}

GET /orders/123                          → order without related
GET /orders/123?include=user,items       → order + nested user + items
json
{
    "id": 123,
    "total": 100,
    "user": { "id": 1, "name": "Anna" },         // included
    "items": [{...}, {...}]                       // included
}

ตัวอย่างใหญ่ ๆ — JSON:API spec มี standard:

GET /orders/123?include=user,items&fields[user]=name,email

⚠️ bracket syntax fields[user]=... ต้อง URL-encode เป็น fields%5Buser%5D=... ตามมาตรฐาน. Spring @RequestParam ไม่ parse bracket ให้อัตโนมัติ — ต้องใช้ library JSON:API หรือ custom resolver. ใช้ syntax ง่ายกว่าเช่น ?userFields=name,email ก็ได้

→ ดู jsonapi.org — มาตรฐานละเอียดมาก


11. ตัวอย่าง — Product Search API

มาดูว่าทุกอย่างในบท (pagination + filter + sort + search + projection) ประกอบกันใน endpoint เดียวยังไง — product search API ที่รวม query, filter หลายแบบ, sort, fields และ pagination เป็นตัวอย่างที่ก๊อปไปปรับใช้กับ list endpoint จริงได้:

http
GET /api/v1/products?
    q=phone
    &category=electronics
    &priceMin=100&priceMax=500
    &inStock=true
    &brand=apple,samsung
    &sort=-price
    &fields=id,name,price,brand
    &page=0&size=20

Response:

json
{
    "data": [
        { "id": 1, "name": "iPhone 15", "price": 999, "brand": "Apple" },
        ...
    ],
    "meta": {
        "page": 0,
        "size": 20,
        "total": 87,
        "totalPages": 5
    },
    "links": {
        "self": "/api/v1/products?q=phone&page=0&size=20",
        "next": "/api/v1/products?q=phone&page=1&size=20"
    }
}

12. Cursor-based Feed (Infinite Scroll)

สำหรับ feed แบบ infinite scroll (Twitter/Facebook) offset pagination ใช้ไม่ได้ดี — ข้อมูลใหม่เข้ามาตลอดทำให้รายการซ้ำ/หาย cursor-based แก้ด้วยการชี้ "ตำแหน่ง" ด้วย before/after แทนเลขหน้า ทำให้ scroll ต่อเนื่องได้ถูกต้องแม้ข้อมูลเปลี่ยน และ performance ดีกว่าตอน dataset ใหญ่:

Twitter/Facebook style:

http
GET /api/v1/feed?limit=20
GET /api/v1/feed?limit=20&before=2026-05-18T10:00:00Z   # older
GET /api/v1/feed?limit=20&after=2026-05-18T10:00:00Z    # newer (poll)

Response:

json
{
    "data": [...],
    "paging": {
        "before": "2026-05-18T09:30:00Z",        // ใช้ดึง older
        "after": "2026-05-18T10:00:00Z",         // ใช้ดึง newer
        "hasOlder": true
    }
}

React Implementation (TanStack Query v5+)

📘 ถ้าไม่ใช่ React/TypeScript — อ่านเอาแค่ idea: client เก็บ cursor ของ page ก่อน (lastPage.paging.before) แล้วส่งต่อตอนขอ page ถัดไป. ทุก client library (axios, ky, plain fetch) ทำแบบเดียวกันได้

tsx
// requires TanStack Query v5+ (v4 มี signature ต่างกัน — ไม่มี initialPageParam)
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
    queryKey: ['feed'],
    queryFn: ({ pageParam }) => fetchFeed({ before: pageParam, limit: 20 }),
    initialPageParam: undefined,
    getNextPageParam: (lastPage) => lastPage.paging.hasOlder 
        ? lastPage.paging.before 
        : undefined,
});

13. Performance Considerations

Total Count — แพง

sql
SELECT COUNT(*) FROM users WHERE status = 'active';
-- ต้อง scan ใหญ่ (Postgres)

แก้:

  1. อย่า return total — แค่ hasNext
  2. Approximate count (Postgres):
    sql
    SELECT reltuples FROM pg_class WHERE relname = 'users';

    reltuples = ค่าประมาณจำนวนแถวที่ Postgres planner เก็บไว้ (update ตอน ANALYZE) — เร็วกว่า COUNT(*) หลายพันเท่า แต่ไม่ตรงเป๊ะ เหมาะแสดง "ประมาณ 10,000 ผลลัพธ์"

  3. Cache count บ่อย ๆ
  4. Show "10,000+" ถ้าเกิน threshold

Cursor + Index

sql
-- Cursor pagination ต้องมี index
CREATE INDEX idx_users_id ON users(id);
CREATE INDEX idx_posts_created_id ON posts(created_at DESC, id DESC);

Limit Max

java
@RequestParam(defaultValue = "20") int size

// ⚠️ check max
if (size > 100) size = 100;

ป้องกัน ?size=10000000 → DB ตาย


14. URL Encoding

GET /users?q=hello world   # ❌ space
GET /users?q=hello%20world # ✅ encoded
GET /users?q=hello+world   # ✅ encoded (alternative)

GET /users?tags=red,blue        # OK
GET /users?tags=red%2Cblue       # encoded comma

ทุก library (axios, fetch) encode ให้อัตโนมัติ — แต่ตอน test manual ระวัง


15. ⚠️ Common Pitfalls

ส่ง 100k row ใน 1 responsepagination เสมอ
Offset deep page → slowkeyset / cursor
ไม่ limit max sizeserver-side max (100/1000)
Sort field ไม่ whitelistsecurity risk
Filter ผ่าน free SQL stringpredefined query param
Search ไม่ใช้ indexfull-text index หรือ external (ES)
Total count ทุก requesthasNext แทน
ไม่มี response metaclient ไม่รู้จะ paginate ยังไง

16. Standards เปรียบเทียบ

แทนที่จะคิด convention เอง มีมาตรฐานสำเร็จรูปให้ยืมแนวคิด — JSON:API (กำหนด format ละเอียด), GraphQL Relay Cursor (มาตรฐาน pagination ของ GraphQL) ดูไว้เพื่อ "ยืมของดี" หรือเลือกใช้ตามทั้งชุดเมื่อต้องการความเป็นมาตรฐาน:

JSON:API

GET /users?
    page[number]=2
    &page[size]=20
    &filter[status]=active
    &sort=-createdAt,name
    &fields[user]=id,name
    &include=orders

GraphQL Relay Cursor

graphql
query {
    users(first: 20, after: "cursor123") {
        edges { node { id, name }, cursor }
        pageInfo { hasNextPage, endCursor }
    }
}

Stripe

GET /v1/customers?limit=10&starting_after=cus_xxx

→ มีหลาย style — ทีมเลือก 1 + consistent

📚 Glossary มาตรฐาน

คำย่อ / มาตรฐานย่อมาจากคือ
JSON:APIspec มาตรฐานสำหรับ JSON REST API (jsonapi.org) — กำหนด format response, pagination (page[number]), filter (filter[status]), sparse fieldset (fields[type])
Relay Cursorspec ของ Facebook สำหรับ GraphQL pagination — ใช้ first/after/last/before + edges/pageInfo/endCursor
RFC 8288Web Linkingมาตรฐาน Link header (obsoletes RFC 5988)
RFC 9110HTTP Semanticsมาตรฐาน HTTP method, status code (รวม PUT/PATCH/idempotency)

17. Checkpoint

🛠️ Checkpoint 2.1 — Pagination Compare
Implement 2 endpoint:

  • GET /api/posts?page=N&size=20 (offset)
  • GET /api/posts?after=ID&limit=20 (keyset)

วัด:

  • เวลาที่ page 1 vs page 1000
  • DB CPU ใน EXPLAIN ANALYZE

🛠️ Checkpoint 2.2 — Multi-field Sort + Filter
Endpoint /products:

  • filter: category, brand (multi), priceMin, priceMax, inStock
  • sort: name | price | createdAt + asc/desc
  • pagination

🛠️ Checkpoint 2.3 — Infinite Feed
สร้าง feed endpoint ที่:

  • cursor-based pagination
  • response มี before + hasOlder
  • React ใช้ useInfiniteQuery

🛠️ Checkpoint 2.4 — Search
ทำ /products?q=phone:

  • search ใน name + description (Postgres FTS)
  • ranked by relevance
  • highlight matched terms

18. สรุปบท

✅ Pagination มี 3 style: offset, keyset, opaque cursor
Offset ง่ายแต่ช้าเมื่อ deep page — ใช้กับ admin UI ที่ page ไม่ลึก
Keyset / cursor เร็วเสมอ — ใช้กับ feed + mobile
✅ Filter: simple query param > complex DSL — predefined whitelist
✅ Sort: multi-field + whitelist column
✅ Search: simple q= ก่อน, full-text เมื่อต้องการ relevance
✅ Field selection + include = สำหรับ optimize ของ mobile
✅ Limit max size + index sort/filter field — prevent DoS
✅ Headers (Link, X-Total-Count) = alternative ของ meta ใน body


← บทที่ 1 | บทที่ 3 → Versioning + Errors