সরাসরি পর্বে যান
পর্ব ৩৪ · ৯৩-এর মধ্যে · মডিউল ৫

Pagination, filter আর sort — offset বনাম cursor

Pagination, Filtering and Sorting — Offset vs Cursor

Taught in Bangla, this lesson measures why a full list is too big, explains offset pagination and why deep pages get slow, rows that shift or repeat while paging, cursor pagination with a unique sort order, opaque cursors, next and prev links and total counts with limit plus one, which index each query needs, rules for filter query parameters and safe queries, and mapping sort names through an allowlist to prevent ORDER BY injection.

এই পর্বে যা শিখবেন

  • Offset query লিখতে, আর গভীর পাতা কেন ধীর তা সংখ্যা দিয়ে হিসাব করতে
  • পাতা পড়ার মাঝে সারি কীভাবে দুবার আসে বা হারিয়ে যায়, তা ধরতে
  • Composite cursor, tie-breaker, opaque cursor আর next/prev বানাতে
  • hasMore আর মোট সংখ্যার খরচ বিচার করে সিদ্ধান্ত নিতে
  • Query parameter দিয়ে filter আর sort নিরাপদে দিতে, আর ORDER BY injection ঠেকাতে

১ · সব একসাথে কেন নয়

বাজারঘরের গুদামে এখন ১০ লাখ পণ্যের হিসাব। এক ক্রেতা ফোনে “সব পণ্য” পাতা খুললেন। সবচেয়ে সরল API হলো SELECT * চালিয়ে পুরো তালিকা JSON করে পাঠানো। ছোট ডেমোতে এটি চলে, তাই অনেক প্রকল্প এভাবেই শুরু হয়। ডেটা বাড়লে একই কোড সবার আগে ভাঙে।

একটি মুদি দোকানের কথা ভাবুন। ক্রেতা জিজ্ঞেস করলেন, “কী কী আছে?” দোকানি পুরো গুদামের খাতা তাঁর হাতে ধরিয়ে দেন না। তিনি প্রথম পাতাটি দেখান, আর বলেন, “আরও লাগলে বলবেন।” Pagination ঠিক এই কাজ: বড় তালিকাকে ছোট পাতায় ভাগ করে দেওয়া, প্রতি অনুরোধে একটি পাতা।

কথাটি সংখ্যায় দেখা যাক। নিচের বাস্তব Express 5 API-তে ১০ লাখ সারির একটি SQLite টেবিল আছে। একবার পুরো তালিকা, একবার ২০টির একটি পাতা চাওয়া হলো।

সত্যিকারের run · একটি local কম্পিউটারে একবার মাপা · 127.0.0.1 · Node.js 24.15, Express 5.3.0, better-sqlite3 13.0.3 (SQLite 3.53.4) · ~/bazarghar

GET /api/products-all     200   103.9 MB   1046 ms
GET /api/products?limit=20  200   2264 byte   2.6 ms

পার্থক্য প্রায় ৪৫,৯০০ গুণ, আর এটি শুধু একই কম্পিউটারের ভেতরের হিসাব, যেখানে network নেই। বাস্তবে আরও চারটি খরচ যোগ হয়। Server-এ পুরো ফল মেমরিতে আসে। Event loop JSON বানাতে আটকে থাকে, ফলে সেই সময় অন্য অনুরোধ অপেক্ষা করে। মোবাইল ডেটায় ক্রেতার টাকা খরচ হয়। Browser-কে ১০ লাখ সারি parse করতে হয়। আর rate limit না থাকলে কেউ এই URL বারবার ডেকেই server ব্যস্ত করে ফেলতে পারেন।

মূল ধারণা

তালিকা ফেরানো প্রতিটি endpoint-এর একটি সর্বোচ্চ সীমা থাকবে। ডিফল্টেও পুরো টেবিল নয়। পর্ব ২৭-এ (Request যাচাই) আমরা limit-কে ১ থেকে ১০০-র মধ্যে আর page-কে ১০০০-এর মধ্যে বেঁধেছি। এই পর্ব সেই সীমার পরের প্রশ্ন: পাতা কীভাবে আনবেন।

পদ্ধতি মূলত দুটি। Offset: “প্রথম ৪০টি বাদ দিয়ে পরের ২০টি দিন।” আর cursor: “৯৯৯৯৯৭ নম্বর সারির পর থেকে ২০টি দিন।” দুটি বাক্য কাছাকাছি শোনায়, কিন্তু ডেটাবেসের কাজে তাদের ফারাক বিশাল। REST নকশার সাধারণ দিকটি REST API নকশা পর্বে আছে। এখানে আমরা বাজারঘরের আসল Express কোড আর মাপা সংখ্যা নিয়ে কাজ করব।

২ · Offset pagination — সবচেয়ে সহজ পথ

Offset pagination-এ client বলে কোন পাতা চাই। Server একটি সরল সূত্রে সেটি SQL-এ বদলায়:

OFFSET = (page − 1) × limit
পাতা ৩, limit ২০ → (৩ − ১) × ২০ = ৪০টি সারি বাদ
SELECT id, name, category, price, created_at
FROM products
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;

এর সুবিধাগুলো সত্যিই বড়। Query যে কেউ বোঝেন। “পাতা ৭-এ যান” সহজে করা যায়। পাতার নম্বর URL-এ থাকে, তাই link শেয়ার করা যায়। ছোট admin তালিকার জন্য এটি প্রায়ই সঠিক পছন্দ। ORDER BY কিন্তু বাদ দেওয়া চলবে না। PostgreSQL-এর docs স্পষ্ট বলে, অনন্য ক্রম ছাড়া LIMIT “an unpredictable subset” দিতে পারে, আর এটি bug নয়, SQL-এর স্বভাব। এই নিয়মটির নাম stable order, ৫ নম্বর অংশে আবার আসবে।

আমাদের API-র বাস্তব উত্তর। limit=2, পাতা ৩:

সত্যিকারের run · 127.0.0.1 · উত্তর ছাঁটা

GET /api/products?limit=2&page=3
200 application/json; charset=utf-8
Link: </api/products?limit=2&page=4>; rel="next", </api/products?limit=2&page=2>; rel="prev"
{"data":[{"id":999996,"name":"Product 999996","category":"sabun","price":475,…},
         {"id":999995,"name":"Product 999995","category":"sabun","price":385,…}],
 "page":{"page":3,"limit":2,"hasMore":true}}

হিসাব মিলছে। সব মিলিয়ে id 1000000 থেকে শুরু, পাতা ১-এ দুটি, পাতা ২-এ দুটি, তাই পাতা ৩ শুরু হলো 999996 থেকে। Link header নিয়ে ৬ নম্বর অংশে কথা হবে।

৩ · গভীর পাতা কেন ধীর — মাপা ফল

ধরুন আপনি খাতার ৯ লাখ নম্বর লাইনে যেতে চান, কিন্তু খাতায় পাতার নম্বর নেই। তাহলে প্রথম থেকে এক এক করে গুনতে হবে। OFFSET-এর কাজও তাই। Index সারিগুলো সাজিয়ে রাখে, কিন্তু “৯ লাখতম সারি কোনটি” সরাসরি বলে না। ডেটাবেস সারি পেরিয়ে যায়, গোনে আর ফেলে দেয়। PostgreSQL-এর docs-এর ভাষায়, বাদ দেওয়া সারি “still have to be computed inside the server”. SQLite-এর docs-ও বলে, OFFSET-এ সময় লাগে offset-এর সমানুপাতিক।

নিচের টেবিলে একই ১০ লাখ সারি, একই index, পাতার আকার ২০. PostgreSQL 18.6-ও একটি বহনযোগ্য build (npm-এর embedded-postgres) দিয়ে সত্যিই চালানো হয়েছে।

কতটি সারি বাদSQLite OFFSETSQLite cursorPostgreSQL OFFSETPostgreSQL cursor
00.0210.0320.2860.369
1,0000.0370.0220.3810.186
10,0000.1540.0210.9860.148
100,0001.4060.0237.6510.197
500,00015.9460.02337.8770.234
900,00029.0450.02568.6740.201
999,98032.6920.02476.0990.335

সময় ms-এ · সত্যিকারের run, একটি local কম্পিউটারে একবার · প্রতিটি query ৯ বার চালিয়ে মধ্যমা · সব ডেটা মেমরিতে · এটি সাধারণ ফল নয়। SQLite চলেছে Node-এর ভেতরেই, আর PostgreSQL-এর সময়ে driver ও loopback যুক্ত, তাই দুই ডেটাবেস তুলনা করবেন না

তিনটি জিনিস লক্ষ করুন। প্রথম, offset-এর সময় সারির সাথে প্রায় সরলরেখায় বাড়ে। দ্বিতীয়, cursor-এর সংখ্যা প্রায় নড়ে না। তৃতীয়, একদম শুরুতে offset সামান্য এগিয়ে, আর ১০,০০০ সারি পর্যন্ত সময় এক ms-এর নিচে। তাই ছোট তালিকায় offset-কে শত্রু ভাবার দরকার নেই।

কারণটি EXPLAIN সরাসরি দেখায়। PostgreSQL-এ EXPLAIN (ANALYZE, BUFFERS) query চালিয়ে আসল সারি আর পড়া page গোনে।

সত্যিকারের run · PostgreSQL 18.6 · COSTS OFF · cursor (100001) ঠিক ৯ লাখ সারি পরের অবস্থান

> SELECT id,name FROM products ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 900000
Limit (actual time=97.022..97.024 rows=20.00 loops=1)
  Buffers: shared hit=11863
  ->  Index Scan using idx_new on products (actual time=0.008..79.833 rows=900020.00 loops=1)
Execution Time: 97.034 ms

> SELECT id,name FROM products WHERE (created_at,id) < ('2024-01-24T03:33:00.000Z',100001)
  ORDER BY created_at DESC, id DESC LIMIT 20
Limit (actual time=0.009..0.011 rows=20.00 loops=1)
  Buffers: shared hit=4
  ->  Index Scan using idx_new on products (actual time=0.008..0.010 rows=20.00 loops=1)
        Index Cond: (ROW(created_at, id) < ROW('2024-01-24 09:33:00+06'::timestamp with time zone, 100001))
Execution Time: 0.030 ms

দুটি query একই ২০টি সারি দেয়। প্রথমটি ৯,০০,০২০টি সারি পড়ে, ১১,৮৬৩টি page ছুঁয়ে। দ্বিতীয়টি ২০টি সারি পড়ে, ৪টি page ছুঁয়ে। (EXPLAIN ANALYZE-এর নিজের মাপার খরচ আছে, তাই ৯৭ ms টেবিলের ৬৮.৬৭ ms-এর চেয়ে বেশি।) SQLite-এর EXPLAIN QUERY PLAN একই গল্প বলে, শুধু সংক্ষেপে: offset-এ SCAN products USING INDEX idx_new, আর cursor-এ SEARCH products USING INDEX idx_new (created_at<?). SCAN মানে হাঁটা, SEARCH মানে সরাসরি পৌঁছানো।

পুরো তালিকা ঘুরে দেখার খরচ — একটি পূর্ণ numerical

একটি export job পুরো তালিকা পাতায় পাতায় পড়ছে। ধরুন মোট সারি N, পাতার আকার p, মোট পাতা P = N/p. k-তম পাতার জন্য offset-এ পড়তে হয় (k − 1)p + p = kp টি সারি। সব পাতা যোগ করলে:

মোট পড়া = p(1 + 2 + … + P) = p · P(P + 1) / 2 ≈ N² / (2p)

N = ১০,০০,০০০ আর p = ২০ হলে P = ৫০,০০০. মোট পড়া = ২০ × ৫০,০০০ × ৫০,০০১ / ২ = ২৫,০০,০৫,০০,০০০ সারি, মানে প্রায় ২,৫০০ কোটি। Cursor-এ প্রতি পাতায় B-tree-র উচ্চতা h (ধরা যাক ৪) আর ২০টি সারি। মোট ≈ ৫০,০০০ × ২৪ = ১২,০০,০০০. অনুপাত প্রায় ২০,৮০০ গুণ। সূত্রে N² থাকায় ডেটা দ্বিগুণ হলে offset-এর কাজ চারগুণ, cursor-এর প্রায় দ্বিগুণ।

পুরো ১০ লাখ হাঁটা অনেক সময় নিত, তাই প্রথম ১,০০,০০০ সারি সত্যিই হাঁটা হলো। সেখানে সূত্র বলে offset পড়বে ২০ × ৫,০০০ × ৫,০০১ / ২ = ২৫,০০,৫০,০০০টি সারি।

সত্যিকারের run · একটি local কম্পিউটারে একবার · প্রথম ১,০০,০০০ সারি, পাতা ২০

SQLite      offset walk   3663 ms    cursor walk   121 ms
PostgreSQL  offset walk  19889 ms    cursor walk   769 ms

সময়ের অনুপাত এখানে প্রায় ৩০ আর ২৬ গুণ, সারির অনুপাতের চেয়ে অনেক কম। কেন? কারণ প্রতিটি query-র একটি স্থির খরচ আছে (parse, প্রস্তুতি, driver), আর একটি সারি পেরোনো খুব সস্তা। ১ লাখের হাঁটায় সারিপ্রতি প্রায় ১৪.৬ ns পড়ে। টেবিলের গভীরতম সারিতে পড়ে প্রায় ৩২.৭ ns. সেই হারে পুরো ১০ লাখের হাঁটা হতো মোটামুটি ৬ থেকে ১৪ মিনিট, এটি হিসাবের অনুমান, মাপা ফল নয়। এই ডেটা মেমরিতে ছিল। ডিস্ক থেকে পড়তে হলে সারি পেরোনো অনেক দামি হতে পারে।

পর্ব ২৭-এর সীমা এখানে কাজে লাগে। page ≤ 1000 আর limit ≤ 100 মানে সবচেয়ে গভীর offset (১০০০ − ১) × ১০০ = ৯৯,৯০০. আমাদের API-তে HTTP দিয়ে মাপা হলো: page=1000-এ 2.07 ms, একই গভীরতায় cursor-এ 0.51 ms. সীমাটি offset-কে ঠিক করে দেয় না, শুধু সবচেয়ে খারাপ অনুরোধটিকে ছোট রাখে।

৪ · পড়ার মাঝে সারি সরে যায়

গতির চেয়েও সূক্ষ্ম একটি সমস্যা আছে। কিউতে দাঁড়ানো মানুষের কথা ভাবুন। আপনি বললেন, “প্রথম ৬ জনের পরের লোকটিকে ডাকুন।” এর মধ্যে সামনে একজন ঢুকে পড়লে “৬ জনের পরের লোক” আর সেই মানুষটি নেই। Offset জায়গার হিসাব রাখে, মানুষের নয়। নতুন সারি ঢুকলে বা পুরোনো সারি মুছলে পাতার সীমা সরে যায়।

নিচে ১২টি পণ্য, নতুন আগে, প্রতি পাতায় ৩টি। একজন পাঠক পাতা ১ আর ২ পড়েছেন। “পরে” চেপে দেখুন পাতা ৩-এর আগে কী ঘটে।

1SELECT id FROM products2  ORDER BY created_at DESC, id DESC3  LIMIT 3 OFFSET :offset;45INSERT INTO products … ; -- id 136DELETE FROM products WHERE id = 11;
এই মুহূর্তের মান
টেবিলের ক্রম
OFFSET
এই পাতায় এলো
  1. পাতা ১. OFFSET ০. ডেটাবেস প্রথম তিনটি দিল: ১২, ১১, ১০.
  2. পাতা ২. OFFSET ৩. তিনটি সারি পেরিয়ে এলো ৯, ৮, ৭. এখন পর্যন্ত সব ঠিক।
  3. Insert. অন্য একজন বিক্রেতা নতুন পণ্য ১৩ যোগ করলেন। এটি তালিকার মাথায় বসল, তাই আগের প্রতিটি সারি এক ঘর পেছনে সরে গেল। পাঠক এ খবর জানেন না।
  4. পাতা ৩. OFFSET ৬ এখন পেরোয় ১৩, ১২, ১১, ১০, ৯, ৮. তাই প্রথম সারি হলো ৭.
  5. ফল: সারি দুবার। পণ্য ৭ পাতা ২-এর শেষে ছিল, আবার পাতা ৩-এর শুরুতে এলো। React তালিকায় এটি একই key-র দুটি সারি হিসেবে সমস্যা করতে পারে।
  6. উল্টো ঘটনা: delete. ধরুন শুরু থেকে আবার ভাবি: পাতা ২-এর পরে insert হয়নি, বরং ১১ মুছে গেছে। সবাই এক ঘর এগিয়ে এলো, OFFSET ৬ পেরোয় ১২, ১০, ৯, ৮, ৭, ৬. পাতা ৩ দিল ৫, ৪, ৩. পণ্য ৬ পাঠক কখনোই দেখলেন না, আর কোনো error-ও হলো না।

নিচের ছোট Node প্রোগ্রাম একই পরিস্থিতি মেপে দেখে। প্রতিটি পদ্ধতি পাতা ১ পড়ার পরে তালিকা বদলায়, তারপর বাকি পাতা পড়ে। শেষে হিসাব: কী দুবার এলো, কী হারাল।

// Products newest first, 3 per page. The list changes after page 1.
const SIZE = 3;

function walk(useCursor, change) {
  let rows = [9, 8, 7, 6, 5, 4, 3, 2, 1];
  const original = [...rows];
  const seen = [];
  let page = 1, last = null;

  while (true) {
    const batch = useCursor
      ? rows.filter((id) => last === null || id < last).slice(0, SIZE)
      : rows.slice((page - 1) * SIZE, page * SIZE);
    if (batch.length === 0) break;
    seen.push(...batch);
    last = batch[batch.length - 1];
    if (page === 1) rows = change(rows);
    page++;
  }
  const twice = seen.filter((id, i) => seen.indexOf(id) !== i);
  const lost = original.filter((id) => !seen.includes(id) && rows.includes(id));
  return `seen ${seen.join(' ')} | twice [${twice}] | lost [${lost}]`;
}

const addTop = (r) => [10, ...r];
const remove8 = (r) => r.filter((id) => id !== 8);

console.log('insert, offset:', walk(false, addTop));
console.log('insert, cursor:', walk(true, addTop));
console.log('delete, offset:', walk(false, remove8));
console.log('delete, cursor:', walk(true, remove8));

আউটপুট:

insert, offset: seen 9 8 7 7 6 5 4 3 2 1 | twice [7] | lost []
insert, cursor: seen 9 8 7 6 5 4 3 2 1 | twice [] | lost []
delete, offset: seen 9 8 7 5 4 3 2 1 | twice [] | lost [6]
delete, cursor: seen 9 8 7 6 5 4 3 2 1 | twice [] | lost []

Offset-এ insert দিল twice [7], আর delete দিল lost [6]. মুছে যাওয়া ৮ পাতা ১-এই দেখা হয়ে গেছে, তাই তাকে ক্ষতি ধরা হয়নি। Cursor দুই ক্ষেত্রেই ঠিক ছিল, কারণ সে জায়গা নয়, শেষ সারির মান মনে রেখেছে।

এটি শুধু সিমুলেশনের ফল নয়। বাস্তব API-তে পাতা ১ পড়ার পরে একটি পণ্য যোগ করে পাতা ২ আনা হলো, পরে একটি পণ্য মুছে আবার:

সত্যিকারের run · Express 5.3.0 · limit=3

before insert: page 1 = 1000000 999999 999998 | cursor page 1 = 1000000 999999 999998
inserted { id: 1000001 }
after insert: offset page 2 = 999998 999997 999996 | cursor page 2 = 999997 999996 999995

page1 1000000 999999 999998  deleted 1000000
offset page 2 = 999996 999995 999994 | cursor page 2 = 999997 999996 999995

Insert-এ offset 999998 আবার দিল। Delete-এ offset 999997 ফেলে গেল। দ্বিতীয় ক্ষেত্রে cursor-এর উত্তর একই থাকল, যদিও সে যে সারি থেকে শুরু, তার আগের সারি মুছে গেছে। মনে রাখুন, cursor সারিটিকে খোঁজে না, শুধু মানের সাথে তুলনা করে। তাই cursor-এর সারিটিই মুছে গেলেও কাজ চলে।

৫ · Cursor pagination — যেখানে থেমেছিলেন, সেখান থেকে

বইয়ে বুকমার্ক রাখলে আপনি পরের দিন ২০১ পৃষ্ঠা গুনে আসেন না, সোজা সেই জায়গায় খোলেন। Keyset pagination-এ সেই বুকমার্কের নাম cursor: আগের পাতার শেষ সারির sort কলামের মান। পরের query “এই মানের পরের সারি দিন” বলে WHERE-তে, আর index সেখানে সোজা নিয়ে যায়।

একই গন্তব্য, দুই পথ

Offset walks the leaf rows; keyset descends the B-tree

Offset আর keyset-এর পথ: সারি পেরোনো বনাম B-tree-তে নামা OFFSET 900000: সব পেরোতে হয় ৯,০০,০০০ সারি পড়ে ফেলা ২০টি Keyset: গাছ বেয়ে সোজা নামা মূল মাঝের ধাপ মাঝের ধাপ পাতা পাতা: ২০টি
ছবি ৩৪.১ — Offset পাতার সারি একে একে হাঁটে। Keyset B-tree-র কয়েকটি ধাপ নেমেই পৌঁছে যায়। PostgreSQL-এর run-এ ১১,৮৬৩ বনাম ৪টি page.

বাজারঘরের সহজ cursor query (নতুন আগে) দেখতে এমন:

SELECT id, name, price, created_at
FROM products
WHERE (created_at, id) < (?, ?)      -- last row of the previous page
ORDER BY created_at DESC, id DESC
LIMIT 21;                            -- limit + 1, see section 6

(created_at, id) < (?, ?)-কে বলে row value তুলনা। এটি অভিধানের মতো বাঁ থেকে ডানে মেলায়: আগে created_at, সেটি সমান হলে id. দীর্ঘ রূপটি created_at < ? OR (created_at = ? AND id < ?)-এর সমান। SQLite ৩.১৫ (২০১৬) থেকে এই গঠন আছে, PostgreSQL-এও চলে।

Tie-breaker কেন বাধ্যতামূলক

শুধু created_at দিয়ে cursor বানালে কী হয়? বাজারঘরে একই মিনিটে অনেক পণ্য যোগ হয়। আগের পাতা যদি ০৯:২০-এর একটি পণ্যে থামে, created_at < '09:20' সেই মিনিটের বাকি সব পণ্য ফেলে দেবে। এজন্য ক্রমের শেষে একটি অনন্য কলাম লাগে, যাকে বলে tie-breaker. সাধারণত id. দামে sort করলে এটি আরও জরুরি, কারণ একই দামের হাজারো পণ্য থাকে। আমাদের ডেটায় ৫০ টাকার পণ্যই ৪,৯২৬টি। সত্যিকারের ফল:

সত্যিকারের run · SQLite 3.53.4 · ১০ লাখ সারি · দামে sort, পাতার আকার ৩, প্রথম পাতার শেষ সারি (price 50, id 240)

page 1            id 55, 130, 240          price 50 50 50
price > 50        id 11, 466, 562          price 55 55 55   // 4,923 rows skipped
(price,id) > ...  id 836, 845, 920         price 50 50 50   // correct

৪,৯২৩টি পণ্য কোনো error ছাড়াই হারাল। এবার নিচের স্টেপারে একটি cursor-এর পুরো যাত্রা দেখুন। ১২টি পণ্য, আর সময়ে ইচ্ছা করেই সমান মান রাখা আছে: ১২, ১১ → ০৯:৩০. ১০, ৯, ৮ → ০৯:২০. ৭, ৬, ৫ → ০৯:১০. ৪, ৩, ২, ১ → ০৯:০০.

1const where = c ? '(created_at, id) < (?, ?)' : '';2const rows = query(where, c?.k, limit + 1);3const hasMore = rows.length > limit;4const page = rows.slice(0, limit);5const last = page.at(-1);6const next = hasMore ? encode(last) : null;7// meanwhile: INSERT id 13
এই মুহূর্তের মান
পেছনের cursor
পড়া সারি
hasMore
নতুন cursor
  1. প্রথম অনুরোধ, cursor নেই. limit ৩, তাই query ৪টি সারি চায়: ১২, ১১, ১০, ৯. WHERE নেই, তবে ORDER BY আছে।
  2. চতুর্থ সারিটি শুধু খবর। ৪টি এসেছে, মানে ৩-এর চেয়ে বেশি আছে, তাই hasMore সত্য। ক্রেতাকে যাবে ১২, ১১, ১০.
  3. Cursor বানানো। শেষ দেখানো সারি ১০, সময় ০৯:২০. Cursor হলো ('09:20', 10). ৯ নয়, কারণ ৯ এখনো দেখানো হয়নি।
  4. এর মধ্যে নতুন পণ্য ১৩ এলো। এটি তালিকার মাথায় বসল। Offset হলে সব পাতা সরে যেত। এখানে cursor-এর কোনো নড়চড় নেই, কারণ সে ১০-এর মানে আটকে আছে।
  5. দ্বিতীয় অনুরোধ. WHERE (created_at, id) < ('09:20', 10). Index সোজা ১০-এর পরে নামল, পড়ল ৯, ৮, ৭, ৬. খেয়াল করুন, ৯ আর ৮-এর সময়ও ০৯:২০. শুধু created_at < '09:20' হলে এ দুটি হারাত।
  6. আবার একই ছন্দ। ক্রেতা পেলেন ৯, ৮, ৭. নতুন cursor ('09:10', 7).
  7. শেষ পাতা। মাঝের পাতা ৩ একই নিয়মে দিয়েছে ৬, ৫, ৪, আর cursor হয়েছে ('09:00', 4). এবার এলো শুধু ৩, ২, ১, চতুর্থ সারি নেই। তাই hasMore মিথ্যা আর nextCursor হলো null.

এবার নিজে চালিয়ে দেখুন। নিচে ৫০,০০০ পণ্যের একটি টেবিল আপনার browser-এই তৈরি হয়। প্রথমে একই গভীর পাতা দুই পথে, তারপর দামের cursor-এ tie-breaker না থাকার ফল। Query বদলে পরীক্ষাও করতে পারেন।

-- 1) Offset: walk 40,000 rows to reach a deep page
EXPLAIN QUERY PLAN
SELECT id FROM products
ORDER BY created_at DESC, id DESC LIMIT 3 OFFSET 40000;
SELECT group_concat(id, ' ') AS offset_page FROM (SELECT id FROM products
  ORDER BY created_at DESC, id DESC LIMIT 3 OFFSET 40000);

-- 2) Keyset: the last row of the previous page is the cursor
SELECT id, created_at FROM products
ORDER BY created_at DESC, id DESC LIMIT 1 OFFSET 39999;
EXPLAIN QUERY PLAN
SELECT id FROM products
WHERE (created_at, id) < ('2024-01-03 07:33:00', 10001)
ORDER BY created_at DESC, id DESC LIMIT 3;
SELECT group_concat(id, ' ') AS keyset_page FROM (SELECT id FROM products
  WHERE (created_at, id) < ('2024-01-03 07:33:00', 10001)
  ORDER BY created_at DESC, id DESC LIMIT 3);

-- 3) Price cursor: with and without the tie-breaker
SELECT group_concat(id, ' ') AS page_1 FROM (SELECT id FROM products
  ORDER BY price, id LIMIT 3);
SELECT group_concat(id || ':' || price, ' ') AS no_tie_breaker FROM (
  SELECT id, price FROM products WHERE price > 50 ORDER BY price, id LIMIT 3);
SELECT group_concat(id || ':' || price, ' ') AS with_tie_breaker FROM (
  SELECT id, price FROM products WHERE (price, id) > (50, 600)
  ORDER BY price, id LIMIT 3);

প্রথম plan-এর detail ঘরে SCAN products USING COVERING INDEX idx_new, আর ফল 10000 9999 9998. Index থাকলেও ৪০,০০০ সারি হাঁটতে হলো। চতুর্থ ফলে দ্বিতীয় plan: SEARCH products USING COVERING INDEX idx_new (created_at<?), আর ফল হুবহু 10000 9999 9998. একই সারি, ভিন্ন পথ। শুধু id চাওয়ায় এখানে index একাই উত্তর দিতে পেরেছে, তাই COVERING.

page_1            200 400 600
no_tie_breaker    79:55 279:55 479:55
with_tie_breaker  800:50 1000:50 1200:50

পাতা ১ থেমেছে id 600-এ, দাম 50. price > 50 সোজা ৫৫ টাকায় চলে গেল, অথচ ৫০ টাকার আরও সারি বাকি ছিল: 800:50, 1000:50 আর তাদের পরের সব সারি। (price, id) > (50, 600) ঠিক সেখান থেকেই শুরু করল। Query বদলে দেখুন: id বাদ দিয়ে ORDER BY লিখলে plan-এ কী আসে?

তিনটি সীমাবদ্ধতা আর ফাঁদ

  • পাতা ৭-এ লাফ নেই। Cursor শুধু “পরের” আর “আগের” জানে। “মোট কত পাতা” বা “পাতা ৭” চাইলে offset লাগবে।
  • NULL: (1, 2) < (1, NULL)-এর ফল সত্য নয়, unknown. তাই NULL থাকা sort কলামে সারি চুপচাপ বাদ পড়তে পারে। Sort কলাম NOT NULL রাখা সবচেয়ে সরল।
  • মিশ্র দিক: ORDER BY price DESC, id ASC-এ row value তুলনা চলে না, কারণ দুই কলামের দিক উল্টো। সবচেয়ে সহজ পথ হলো দুই কলামের দিক এক রাখা।
  • সময়ের সূক্ষ্মতা: PostgreSQL-এর timestamptz microsecond রাখে, কিন্তু JavaScript-এর Date শুধু millisecond. Date-এ বদলানো সময় দিয়ে cursor বানালে সমান সময়ের কাছাকাছি সারি ভুল হতে পারে। Cursor-এ সময় text হিসেবে রাখুন। আমাদের ডেটা পুরো মিনিটে, তাই run-এ এটি ধরা পড়েনি।

৬ · Opaque cursor, next, prev, Link আর মোট সংখ্যা

Cursor কে পড়বে? কেউ না

?after_id=10&after_time=… লিখলেও চলে, কিন্তু তখন client সেই গঠনের ওপর নির্ভর করে ফেলে। কাল sort বদলালে API ভাঙবে। তাই সাধারণ অভ্যাস হলো cursor-কে opaque রাখা: একটি ছোট JSON-কে base64url করে দেওয়া, আর client-কে বলা, এটি শুধু ফেরত পাঠান। এটি Base64-এর URL-নিরাপদ রূপ। পার্সেলের সিল করা কাগজের মতো ভাবুন: কুরিয়ারের ছেলে সেটি বহন করেন, পড়ে কিছু ঠিক করেন না।

Base64 গোপনীয়তা দেয় না। যে কেউ decode করতে পারে। তাই cursor-এ ব্যক্তিগত তথ্য রাখবেন না। বদলানো ঠেকাতে চাইলে HMAC স্বাক্ষর যোগ করুন। Decode করা মান সবসময় parameterized query-তেই বসান, cursor-ও বাইরে থেকে আসা ইনপুট।

const { createHmac, timingSafeEqual } = require('node:crypto');
const KEY = 'example-secret';
const sign = (b) => createHmac('sha256', KEY).update(b).digest('base64url').slice(0, 16);

function encode(obj) {
  const body = Buffer.from(JSON.stringify(obj)).toString('base64url');
  return `${body}.${sign(body)}`;
}
function decode(text) {
  const [body, mac] = String(text).split('.');
  if (!body || !mac) return null;
  const a = Buffer.from(mac), b = Buffer.from(sign(body));
  if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
  try {
    const c = JSON.parse(Buffer.from(body, 'base64url').toString());
    return Array.isArray(c.k) && c.k.length === 2 ? c : null;
  } catch { return null; }
}

const cursor = encode({ v: 1, s: 'price_asc', k: [100, 1966] });
console.log('cursor :', cursor);
console.log('decode :', JSON.stringify(decode(cursor)));

const body = cursor.split('.')[0];
console.log('peek   :', Buffer.from(body, 'base64url').toString());

const forged = Buffer.from('{"v":1,"s":"price_asc","k":[0,1]}').toString('base64url');
console.log('forged :', decode(`${forged}.${cursor.split('.')[1]}`));
console.log('junk   :', decode('hello'));
cursor : eyJ2IjoxLCJzIjoicHJpY2VfYXNjIiwiayI6WzEwMCwxOTY2XX0.Tp7tyvm9kSu7_UV_
decode : {"v":1,"s":"price_asc","k":[100,1966]}
peek   : {"v":1,"s":"price_asc","k":[100,1966]}
forged : null
junk   : null

peek লাইনটি দেখায়, key ছাড়াই ভেতরের লেখা পড়া গেল: base64url গোপন কিছু করে না। কিন্তু মান বদলে পুরোনো স্বাক্ষর জুড়ে দিলে forged হলো null, কারণ নতুন লেখার স্বাক্ষর মেলে না। junk-ও null, crash নয়। আসল API-তে এই null-ই 400 {"code":"BAD_CURSOR"} হয়েছে। অন্য sort-এর cursor পাঠালেও একই 400 এসেছে।

Cursor-এ v (গঠনের version) আর s (কোন sort) রাখা হয়েছে। অন্য sort-এর সাথে পুরোনো cursor পাঠালে মান অর্থহীন হয়, তাই server সেটি ফেরায়। ভুল cursor-কে 500 নয়, পরিষ্কার client error বানান। পর্ব ৩৩ (Status code আর error format) এই ছাঁচ নিয়েই কথা বলে।

next আর prev

“আগের পাতা” মানে উল্টো হাঁটা। Cursor-এ দিক (d) রাখা হয়। পেছনে যেতে তুলনার চিহ্ন আর ORDER BY দুটিই উল্টে যায়, আর ফলের সারি আবার উল্টে দেখাতে হয়। আমাদের API দুই দিকের cursor-ই দেয়:

সত্যিকারের run · Express 5.3.0 · ছোট একটি fetch script · cursor ও body ছাঁটা

GET /api/products?limit=2
200
Link: </api/products?limit=2&cursor=eyJ2IjoxLCJz…23fwDMqAzUfbP1cB>; rel="next"
{"data":[{"id":1000000,…},{"id":999999,…}],
 "page":{"limit":2,"hasMore":true,"nextCursor":"eyJ2…","prevCursor":null}}

GET /api/products?limit=2&cursor=eyJ2IjoxLCJz…   (next)
Link: <…cursor=eyJ2…3WlF5lbUQ7ekSMw4>; rel="next", <…cursor=eyJ2…csqDnQ1Mpxlj5ajt>; rel="prev"

p1 1000000 999999 | p2 999998 999997 | back to 1000000 999999

“পেছনে” গিয়ে ঠিক পাতা ১ ফিরে এসেছে, আর সেখানে prevCursor আবার null. React-এ এই cursor সরাসরি infinite query-তে বসে: পর্ব ১৯-এর (Server state) getNextPageParam-এ lastPage.page.nextCursor ফেরালেই হয়, null হলে “আরও দেখুন” থেমে যায়।

Link header

Body-র বদলে বা পাশাপাশি পরের URL HTTP header-এও দেওয়া যায়। Link header-এর নিয়ম RFC 8288-এ (অক্টোবর ২০১৭) লেখা, আর next, prev, first, last IANA-র link relation তালিকায় আছে। Express-এ এর জন্য res.links() আছে, যা ওপরের header বানিয়েছে। সুবিধা হলো body-র গঠন ছাড়াই যেকোনো client পরের পাতা পায়। অসুবিধা হলো browser-এ অন্য origin থেকে পড়তে Access-Control-Expose-Headers লাগে (পর্ব ২৬-এর CORS). বহু দল তাই body-তেই দেয়, দুটিই রাখলেও ক্ষতি নেই।

hasMore বনাম মোট সংখ্যা

“মোট ১,২৫,২৫৫টি পণ্য” দেখাতে ভালো লাগে, কিন্তু এর দাম আছে। সংখ্যাটি জানতে ডেটাবেসকে মেলা সব সারি গুনতে হয়, যেখানে পাতাটি আনতে শুধু প্রথম ২০টি লাগে। “আরও আছে কি না” জানতে গোনার দরকারই নেই: limit + 1টি সারি আনুন। ২১তম সারি এলে hasMore সত্য, আর সেটি কেটে বাদ দিন।

কাজSQLitePostgreSQL
COUNT(*), পুরো টেবিল0.11223.081
COUNT(*), dal + দাম ১০০–২০০ (১৩,১২১টি মিলল)237.48816.993
একই filter, LIMIT 210.0520.180

সময় ms-এ · সত্যিকারের run, একটি local কম্পিউটারে একবার · ১০ লাখ সারি · ৯ বারের মধ্যমা

Filter-সহ গোনা এখানে SQLite-এ LIMIT 21-এর চেয়ে প্রায় ৪,৫০০ গুণ, PostgreSQL-এ প্রায় ৯৪ গুণ ধীর। পুরো টেবিলের ক্ষেত্রে SQLite-এর ০.১১ ms দেখে ভুল শিক্ষা নেবেন না। Filter ছাড়া গোনার জন্য তার সংক্ষিপ্ত পথ আছে। PostgreSQL-এর plan-এ একই কাজে Parallel Seq Scan আর ৯,৩৪৬টি page পড়া দেখা গেছে, কারণ MVCC-তে কোন সারি কোন transaction-এ দৃশ্যমান, তা দেখে গুনতে হয়।

বাস্তবের চারটি সিদ্ধান্ত: (ক) hasMore-ই যথেষ্ট, যা feed-এ চলে। (খ) মোট সংখ্যা ঐচ্ছিক, যেমন আমাদের ?total=true. (গ) সীমা দিয়ে গোনা, “১০,০০০+” দেখানো। (ঘ) আনুমানিক মান বা cache করা সংখ্যা। PostgreSQL-এর pg_class.reltuples-এ একটি অনুমান থাকে (run-এ 0.086 ms), তবে এটি শেষ ANALYZE-এর মতোই তাজা। Cache নিয়ে পর্ব ৪৪-এ কথা হবে।

৭ · কোন query-র জন্য কোন index

Cursor-এর গতি জাদু নয়, index-এর দান। Sort-এর ক্রম আর index-এর ক্রম মিললে তবেই ডেটাবেস সোজা নামে। Index-এর তত্ত্ব আবার শেখাব না, সেটি Indexing আর Index ডিজাইন পর্বের কাজ। মনে রাখার নিয়ম একটি: composite index-এ আগে সমতার কলাম, তারপর sort-এর কলাম, শেষে tie-breaker. আমাদের তিনটি index:

CREATE INDEX idx_new     ON products(created_at DESC, id DESC);
CREATE INDEX idx_cat_new ON products(category, created_at DESC, id DESC);
CREATE INDEX idx_price   ON products(price, id);
Query (LIMIT ২১ বা ২০)Query plan (SQLite)ms
নতুন আগেSEARCH … idx_new (created_at<?)0.024
dal, নতুন আগে, দাম ১০০–২০০SEARCH … idx_cat_new (category=?)0.041
দামে sort, সব পণ্যidx_price ধরে ক্রমে পড়া0.036
dal, দামে sortSEARCH … idx_cat_new + USE TEMP B-TREE FOR ORDER BY58.177
stock-এ sort, index নেইSCAN products + USE TEMP B-TREE FOR ORDER BY76.323

সত্যিকারের run, একটি local কম্পিউটারে একবার · SQLite 3.53.4 · ১০ লাখ সারি · ৯ বা ৫ বারের মধ্যমা

চতুর্থ সারিটি দেখুন। Index আছে, কিন্তু ক্রম ভুল। সব dal (১,২৫,২৫৫টি) তুলে আলাদা sort করতে হলো, তাই সময় ৫৮ ms. (category, price, id) index যোগ করলে এই সমস্যা যায়। TEMP B-TREE শব্দটিই ধরার চিহ্ন: ডেটাবেস ক্রম পায়নি, নিজে sort করছে। এই অবস্থায় LIMIT 20-ও ছোট কাজ নয়।

দ্বিতীয় সারিটি একটি সূক্ষ্ম কথা বলে। Index ক্রম দিচ্ছে, কিন্তু দামের শর্ত index-এ নেই, তাই মেলা সারি পড়ে পড়ে বাদ দেওয়া হচ্ছে। PostgreSQL-এর plan-এ এটি Rows Removed by Filter: 169 হিসেবে দেখা গেছে। শর্তটি বিরল হলে এই বাদ দেওয়ার সংখ্যা অনেক বেড়ে যেতে পারে। সব সম্ভাব্য filter আর sort-এর জন্য index বানানোও ঠিক নয়, কারণ প্রতিটি index লেখাকে ধীর করে। তাই পরের দুটি অংশে আমরা ঠিক করব API কোন filter আর sort দেবে।

৮ · Filter — query parameter-এর নিয়ম আর নিরাপদ query

Filter মানে দোকানে বলা, “শুধু ১০০ থেকে ১২০ টাকার ডাল আর তেল দেখান।” তালিকার resource একই থাকে, তাই এগুলো path নয়, query string-এ বসে (পর্ব ২৫: Express route আর query). এর কোনো একক মানদণ্ড নেই, তাই একটি রীতি বেছে সব endpoint-এ একই রাখুন।

প্রয়োজনউদাহরণমন্তব্য
সমান?category=dalসবচেয়ে সরল
একাধিক মান?category=dal&category=telExpress 5-এ array হয়। একটি মান এলে string, তাই দুটিই সামলাতে হয়
পরিসর?minPrice=100&maxPrice=120min ≤ max যাচাই করুন
তারিখের পরিসর?from=2026-01-01&to=2026-02-01from অন্তর্ভুক্ত, to বাদ, এই নিয়ম লিখে রাখুন
Bracket রীতি?price[gte]=100Express 5-এর ডিফল্ট parser-এ এটি nested হয় না

শেষ সারিটি AI-এর লেখা কোডে প্রায়ই পাবেন। পর্ব ২৭-এ দেখেছেন, Express 5-এর ডিফল্ট simple query parser-এ price[gte] একটি সাধারণ key মাত্র। সবচেয়ে খারাপ হলো filter উপেক্ষা করে চুপচাপ সব ফল ফেরানো, তাই আমাদের schema এমন অজানা key ফিরিয়ে দেয়।

Allowlist: কোন ঘর filter করা যাবে

বিপজ্জনক লোভটি হলো req.query-র প্রতিটি key-কে কলাম ধরে নেওয়া। এতে দুটি সমস্যা। Key-এর নাম SQL-এ ঢুকলে SQL injection-এর পথ খোলে। আর ক্রেতা লুকানো ঘরেও filter করতে পারেন। ধরুন cost_price কলাম উত্তরে দেখানো হয় না, কিন্তু filter খোলা। তাহলে cost_price ৫০০-র কম, ২৫০-এর কম, এভাবে ভাগ করে করে যে কেউ গোপন মান বের করে ফেলতে পারেন। উত্তরে ঘরটি না থাকলেও তথ্য ফাঁস হয়। তাই allowlist: API-তে যেটুকু লেখা, সেটুকুই চলবে।

import { z } from 'zod';

export const SORTS = {
  new:        { col: 'created_at', dir: 'DESC', sql: 'created_at DESC, id DESC' },
  price_asc:  { col: 'price',      dir: 'ASC',  sql: 'price ASC, id ASC' },
  price_desc: { col: 'price',      dir: 'DESC', sql: 'price DESC, id DESC' },
};
const CATS = ['chal', 'dal', 'tel', 'mosla', 'chini', 'lobon', 'ata', 'sabun'];

export const listQuery = z.strictObject({            // unknown keys rejected
  limit: z.coerce.number().int().min(1).max(100).default(20),
  page: z.coerce.number().int().min(1).max(1000).optional(),
  cursor: z.string().max(200).optional(),
  sort: z.enum(Object.keys(SORTS)).default('new'),
  category: z.union([z.enum(CATS), z.array(z.enum(CATS)).max(8)]).optional()
    .transform((v) => (v === undefined ? [] : [v].flat())),
  minPrice: z.coerce.number().int().min(0).optional(),
  maxPrice: z.coerce.number().int().min(0).optional(),
}).refine((q) => !(q.page && q.cursor), { message: 'page আর cursor একসাথে নয়' })
  .refine((q) => q.minPrice === undefined || q.maxPrice === undefined
    || q.minPrice <= q.maxPrice, { message: 'minPrice <= maxPrice হতে হবে' });

z.strictObject অজানা key ফেরায়, z.enum তালিকার বাইরের মান ফেরায়, আর max(8) বিশাল IN তালিকা ঠেকায়। লেখার সময়ে Zod 4.6.7 দিয়ে চালানো সত্যিকারের ফল:

সত্যিকারের run · Express 5.3.0, Zod 4.6.7

GET /api/products?limit=2&category=sona
422 {"code":"VALIDATION_FAILED","errors":[{"path":"category","message":"Invalid input"}]}

GET /api/products?limit=2&costPrice=10
422 {"code":"VALIDATION_FAILED","errors":[{"path":"","message":"Unrecognized key: \"costPrice\""}]}

GET /api/products?limit=2&price[gte]=100
422 {"code":"VALIDATION_FAILED","errors":[{"path":"","message":"Unrecognized key: \"price[gte]\""}]}

GET /api/products?limit=2&minPrice=300&maxPrice=100
422 {"code":"VALIDATION_FAILED","errors":[{"path":"","message":"minPrice <= maxPrice হতে হবে"}]}

GET /api/products?limit=2&page=2&cursor=abc
422 {"code":"VALIDATION_FAILED","errors":[{"path":"","message":"page আর cursor একসাথে নয়"}]}

মনে রাখুন, filter-এ কিছু না মিললে উত্তর 200 আর data: []. “কিছু পাওয়া যায়নি” তালিকার জন্য ভুল নয়, তাই 404 নয়। এবার query তৈরির ধাপগুলো দেখুন। নিয়ম একটাই: SQL-এর লেখা শুধু আমাদের নিজের string থেকে, মান শুধু placeholder দিয়ে। ধরুন অনুরোধটি ?limit=2&category=dal&category=tel&minPrice=100&maxPrice=120&sort=price_asc.

1const where = [], params = [];2if (q.category.length) {3  where.push(`category IN (${marks})`);4  params.push(...q.category);5}6if (q.minPrice !== undefined) { where.push('price >= ?'); params.push(q.minPrice); }7if (q.maxPrice !== undefined) { where.push('price <= ?'); params.push(q.maxPrice); }8const order = SORTS[q.sort].sql;9const sql = `SELECT … WHERE ${where.join(' AND ')} ORDER BY ${order} LIMIT ?`;10db.prepare(sql).all(...params, q.limit + 1);
এই মুহূর্তের মান
where
params
order
  1. শুরু। Zod আগেই যাচাই করেছে, আর category এখন array. দুটি খালি তালিকা: একটিতে SQL-এর টুকরো, অন্যটিতে মান।
  2. Placeholder গোনা। দুটি ক্যাটাগরি, তাই marks হলো ?,?. এখানে শুধু প্রশ্নচিহ্ন বসছে, মান নয়।
  3. মান আলাদা ঘরে। dal আর tel SQL-এর লেখায় ঢুকল না, গেল params-এ।
  4. পরিসরের নিচের সীমা। price >= ? টুকরোটি আমাদের লেখা string. মান ১০০ যোগ হলো params-এ।
  5. পরিসরের ওপরের সীমা। একইভাবে ১২০. দাম না থাকলে এই লাইনগুলো কিছুই যোগ করত না।
  6. Sort-এর ম্যাপ। Client পাঠিয়েছে price_asc নাম। SQL-এ যাচ্ছে সেই নামের জন্য আমাদের লেখা price ASC, id ASC. Client-এর লেখা SQL-এ যায় না।
  7. SQL জোড়া। পুরো string-এ এখন শুধু আমাদের নিজের টুকরো আর ?. বাইরে থেকে আসা একটি অক্ষরও এখানে নেই।
  8. চালানো। limit + 1 মানে ৩ শেষে যোগ হলো। আসল run-এ এলো id 711 আর 1966, তৃতীয় সারিটি hasMore সত্য করল। ক্রম লক্ষ করুন: দাম সমান, তাই tie-breaker id ক্রম ঠিক করেছে।
  9. তালিকার বাইরের মান। ?category=sona হলে Zod-ই ফিরিয়ে দিল, এই কোড চলেইনি। মান যদি তালিকায় থাকতও, তবু সে params-এ যেত, SQL-এর লেখায় নয়। দুটি স্তর একসাথে কাজ করছে।

৯ · Sort, ORDER BY injection আর খোঁজার কথা

Sort-এও একই নীতি, শুধু ফাঁদটি আলাদা। নতুনদের সহজ চিন্তা: “Parameterized query তো শিখেছি, ORDER BY ? লিখি।” চালিয়ে দেখা যাক। প্রত্যাশা, stock অনুযায়ী সাজানো ক্রম।

সত্যিকারের run · একই ৪টি সারি · ORDER BY ?-তে মান 'stock'

SQLite      ORDER BY ?      id 1, 4, 3, 2     stock 271, 371, 298, 56
PostgreSQL  ORDER BY $1     id 1, 2, 3, 4     stock 271, 56, 298, 371
দুটিতেই     ORDER BY stock  id 2, 1, 3, 4     stock 56, 271, 298, 371

কোনো error নেই, কিন্তু ফল sort হয়নি। Placeholder মান বসায়, কলামের নাম নয়। ORDER BY 'stock' মানে প্রতিটি সারিতে একই ধ্রুবক লেখা ধরে sort করা, যা কিছুই করে না। তাই লোকে string জোড়া দেয়, আর সেখানেই বিপদ। ল্যাবের একটি ইচ্ছাকৃত ভুল route (শুধু 127.0.0.1-এ):

// WRONG: lab only. Never ship this.
const sort = String(req.query.sort ?? 'id');
db.prepare(`SELECT id, price FROM products WHERE id <= 5 ORDER BY ${sort}`).all();

সত্যিকারের run · 127.0.0.1 · ইচ্ছাকৃত ভুল route

sort=price                    200 id 1, 4, 5, 3, 2
sort=price DESC               200 id 2, 3, 5, 4, 1     // the client wrote SQL
sort=costPrice                500 {"thrown":"no such column: costPrice"}   // schema leaks
sort=(SELECT 1) * price * -1  200 id 2, 3, 5, 4, 1     // a subquery ran

শেষ লাইনটি নিরীহ দেখালেও মূল সত্য বলে: client-এর লেখা একটি subquery ডেটাবেসে চলেছে। একটি শর্তের উত্তর অনুযায়ী ক্রম বদলালে উত্তরের ক্রম থেকেই গোপন তথ্যের হ্যাঁ বা না জানা যায়। আক্রমণের বিস্তার আর প্রতিরক্ষা Injection প্রতিরক্ষা পর্বে আছে। আমাদের কাজ হলো পথটিই বন্ধ করা: client একটি নাম পাঠাবে, আর আমরা নিজের লেখা SQL টুকরো বেছে নেব।

// Client sends a NAME. We pick our own SQL. The name is never in the SQL.
const SORTS = new Map([
  ['new',        'created_at DESC, id DESC'],
  ['price_asc',  'price ASC, id ASC'],
  ['price_desc', 'price DESC, id DESC'],
]);

function orderBy(name = 'new') {
  const sql = SORTS.get(name);
  if (!sql) return { status: 422, error: `unknown sort: ${name}` };
  return { status: 200, sql: `ORDER BY ${sql}` };
}

const asObject = { new: 'created_at DESC, id DESC' };

for (const input of [undefined, 'price_asc', 'stock', 'price; DROP TABLE products',
  '(SELECT 1)', 'constructor', '__proto__']) {
  console.log(JSON.stringify(input ?? null).padEnd(30), JSON.stringify(orderBy(input)));
}

console.log('\nplain object lookup:');
console.log('constructor ->', typeof asObject['constructor']);
console.log('__proto__   ->', typeof asObject['__proto__']);
console.log('hasOwn      ->', Object.hasOwn(asObject, 'constructor'));
null                           {"status":200,"sql":"ORDER BY created_at DESC, id DESC"}
"price_asc"                    {"status":200,"sql":"ORDER BY price ASC, id ASC"}
"stock"                        {"status":422,"error":"unknown sort: stock"}
"price; DROP TABLE products"   {"status":422,"error":"unknown sort: price; DROP TABLE products"}
"(SELECT 1)"                   {"status":422,"error":"unknown sort: (SELECT 1)"}
"constructor"                  {"status":422,"error":"unknown sort: constructor"}
"__proto__"                    {"status":422,"error":"unknown sort: __proto__"}

plain object lookup:
constructor -> function
__proto__   -> object
hasOwn      -> false

তালিকার নাম SQL টুকরো পেল, আর undefined পেল ডিফল্ট। price; DROP TABLE products বা (SELECT 1) শুধু “অজানা নাম”, SQL-এর কাছেই পৌঁছায় না। শেষ তিন লাইন একটি সূক্ষ্ম ফাঁদ দেখায়। সাধারণ object-এ asObject['constructor'] কিছু একটা ফেরায়, কারণ এগুলো prototype থেকে আসে, আর তখন “নাম আছে” যাচাই ভুল করে সত্য হয়। তাই এখানে Map ব্যবহার হয়েছে। Object রাখলে Object.hasOwn দিয়ে যাচাই করুন। আমাদের Express কোডে z.enum আগেই এই নামগুলো আটকায়।

Sort-এর নিয়ম চারটি: নাম allowlist-এ, ক্রমের শেষে অনন্য id, শুধু সেই sort দিন যার index আছে বা টেবিল ছোট, আর ডিফল্ট sort একটি থাকবে। sort=stock আমাদের API-তে সত্যিই ফিরল: 422 … expected one of "new"|"price_asc"|"price_desc". একটি সাধারণ রীতি হলো ?sort=-price, যেখানে মাইনাস মানে উল্টো ক্রম। আমরা স্পষ্ট নাম রেখেছি, কারণ এতে প্রতিটি sort আলাদা করে ভাবা ও index করা হয়।

পরিস্থিতিবেছে নিনকারণ
Admin টেবিল, কয়েক হাজার সারিOffsetপাতায় লাফ আর মোট সংখ্যা দরকার, খরচ কম
Feed, “আরও দেখুন”, infinite scrollCursorনতুন সারি আসতে থাকে, পুনরাবৃত্তি চলবে না
Export বা sync jobCursorপুরো তালিকা হাঁটতে হয়, N² চলবে না
পণ্য তালিকায় পাতা নম্বর চাইOffset, সীমা সহগভীর পাতা বন্ধ, filter দিয়ে তালিকা ছোট
Client-এর ইচ্ছামতো অনেক sortআগে নকশা ভাবুনপ্রতিটি sort-এর index লাগে

Search: একটি ছোট নোট

Filter নির্দিষ্ট ঘরে মেলায়, আর search ক্রেতার লেখা শব্দ খোঁজে। name LIKE '%চাল%' লিখলে সামনে % থাকায় index কাজে লাগে না, plan-এ দেখা গেল SCAN products. বড় টেবিলে দরকার full-text search: ডেটাবেসের নিজের ব্যবস্থা, অথবা আলাদা search engine (Meilisearch, OpenSearch এমন কয়েকটি উদাহরণ)।

বাংলা লেখায় সতর্ক থাকুন। এই ফলগুলো একটি local run-এর, আর version বা locale বদলালে বদলাতে পারে। PostgreSQL 18.6-এর তালিকায় hindi, nepali, tamil ছিল, bengali ছিল না, তাই simple ব্যবহার করতে হলো। সেখানে “চালের দাম” “চাল” দিয়ে মিলল না, কারণ শব্দের মূল বের করার stemmer নেই। Locale C-তে pg_trgm “চাল”-এর জন্য খালি ফল দিল। SQLite FTS5-এ উল্টো “চালের গুঁড়া” মিলে গেল, “চালক” মিলল না, সম্ভবত স্বরচিহ্নে শব্দ ভেঙে যাওয়ার কারণে। আর LIKE “চালক”-কেও ধরল। তাই বাংলা search নিজের আসল ডেটায় পরীক্ষা না করে ঠিক ধরে নেবেন না। লেখার normalization নিয়ে পর্ব ৫৯-এ কথা হবে।

আরও গভীরে: “No offset” নামটি ছড়িয়েছে মূলত Markus Winand-এর ২০১৪ সালের দিকের লেখা থেকে, যিনি এই পদ্ধতিকে seek method-ও বলেন। তাঁর মতে offset বেছে নেওয়ার মূল কারণ হলো tool-এর সমর্থনের অভাব। SQLite-এর row value পাতায় ঠিক এই scrolling window query-র উদাহরণ আছে। PostgreSQL-এ (a, b) < (?, ?) একটি index condition হিসেবে চলে, যা ওপরের plan-এ Index Cond: ROW(...) দেখা গেছে। OR দিয়ে লেখা দীর্ঘ রূপটি অর্থে এক হলেও planner সবসময় তা একইভাবে index-এ বসায় না, তাই নিজের plan দেখে নিন।

পরীক্ষা ও ইন্টারভিউতে:

  • Offset গভীর পাতায় ধীর কেন? বাদ দেওয়া সারিগুলো পড়তে হয়, তাই খরচ offset-এর সমানুপাতিক।
  • Keyset-এ duplicate কেন হয় না? সে অবস্থান নয়, শেষ সারির মানের সাথে তুলনা করে।
  • Tie-breaker কেন? Sort কলামের মান সমান হলে অনন্য ক্রম হয় না, তাই সারি হারায়।
  • ORDER BY-তে placeholder চলে না কেন? Placeholder মান বসায়, শনাক্তকারী নয়; তাই allowlist ম্যাপ লাগে।
  • COUNT ছাড়া আরও পাতা আছে কি না কীভাবে জানবেন? limit + 1টি সারি এনে।

১০ · AI-এর যুগে pagination

“Express-এ pagination লিখে দাও” বললে AI কয়েক সেকেন্ডে চলে এমন কোড দেয়। সমস্যা হলো এই কোডের ভুল ছোট ডেটায় ধরা পড়ে না। ২০ সারির টেবিলে সব ঠিক থাকে, আর ক্ষতি হয় ১০ লাখে পৌঁছে। তাই যাচাইটি আপনার।

AI-এর ভুলকেন ক্ষতিকরযেভাবে ধরবেন
ORDER BY ${req.query.sort}SQL injectionDiff-এ template string খুঁজুন
limit-এর সীমা নেই?limit=1000000 সব টেনে আনে?limit=1000 পাঠিয়ে 422 দেখুন
ORDER BY created_at, tie-breaker নেইপাতার মধ্যে সারি হারায়সমান সময়ের সারি দিয়ে test লিখুন
প্রতি অনুরোধে COUNT(*)Filter-সহ পাতায় ধীরlimit + 1 ব্যবহার করুন
Index ছাড়া cursorCursor থাকলেও ধীরEXPLAIN চালিয়ে SCAN আর TEMP B-TREE খুঁজুন
Base64-কে নিরাপদ ভাবাযে কেউ পড়তে পারে, decode-এ crash হয়?cursor=hello পাঠিয়ে দেখুন
price[gte] রীতি, Express 4-এর ধারণায়Express 5-এ nested হয় নাVersion-এর docs আর আসল run দেখুন

নিচের prompt-টি AI-কে শুধু কোড নয়, প্রমাণও দিতে বলে। শেষের অনুরোধটি খেয়াল করুন: test আর plan ছাড়া কোড গ্রহণ করবেন না।

আমি Express 5 আর better-sqlite3 দিয়ে products তালিকার API লিখছি। টেবিলে ১০ লাখ সারি, কলাম id, name, category, price, created_at. দরকার cursor pagination (নতুন আগে আর দামে sort), category আর দামের filter. শর্ত: ১. limit সর্বোচ্চ ১০০, Zod দিয়ে যাচাই, অজানা key বাতিল। ২. sort নাম allowlist-এ, ORDER BY-তে client-এর কোনো লেখা নয়। ৩. ক্রমের শেষে id, row value তুলনা, hasMore limit + 1 দিয়ে। ৪. Cursor opaque, ভুল cursor-এ 400, মান সবসময় parameter দিয়ে। কী index লাগবে বলো, আর কেন। তারপর এমন test দাও যেখানে একই created_at-এর সারি পাতার সীমায় পড়ে। কোনো package বা option নিয়ে নিশ্চিত না হলে সেটি বলো, বানিয়ে লিখো না।

AI-এর লেখা pagination হাতে পেলে — যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১Offset-এর ধীরতা কি ডেটাবেসের দুর্বলতা? ডেটাবেস কি একটু চালাক হয়ে সোজা ৯ লাখতম সারিতে লাফ দিতে পারে না?

তত্ত্বে সমস্যাটির একটি সমাধান আছে। B-tree-র প্রতিটি ভেতরের ঘরে যদি লেখা থাকে “এর নিচে কতটি সারি”, তাহলে গণনা করে সোজা ৯ লাখতম অবস্থানে নামা যায়। এই গঠনকে order statistic tree-র ধারণার সাথে তুলনা করা হয়। তাহলে প্রচলিত ডেটাবেসগুলো সাধারণভাবে এটি করে না কেন?

প্রথম বাধা হলো দৃশ্যমানতা। PostgreSQL-এর মতো MVCC ডেটাবেসে index-এ যে entry আছে, সেটি আপনার transaction-এ দৃশ্যমান কি না, তা আগে যাচাই করতে হতে পারে। ধরুন আপনার query শুরুর পরে অন্য একজন একটি সারি মুছলেন। তখনও আপনাকে সেটি দেখতে হবে, আবার অন্য transaction-কে দেখা যাবে না। এক সংখ্যা দিয়ে সবার “৯ লাখতম” বলা যায় না। দ্বিতীয় বাধা হলো filter. WHERE-এর পর কোন সারি টিকবে, তা index-এর গণনায় আগে থেকে জানা নেই। তৃতীয় বাধা হলো লেখার খরচ: প্রতিটি insert-এ পথের সব ঘরের সংখ্যা বদলাতে হবে, যা একসাথে অনেক লেখার সময় ভিড় বাড়ায়।

এজন্যই Markus Winand ২০১৪ সালের দিকের “No Offset” লেখায় একে implementation-এর ত্রুটি নয়, বরং পদ্ধতির স্বভাব বলেছেন। PostgreSQL-এর docs-ও সরাসরি লেখে, বাদ দেওয়া সারি server-এর ভেতরে গণনা করতেই হয়। আর SQLite-এর docs সমাধান হিসেবে row value query দেখায়। অর্থাৎ ডেটাবেসের নির্মাতারা নিজেরাই বলছেন, প্রশ্নটি বদলান।

আমাদের মাপা ফলও এটি মেলায়: একই ২০টি সারির জন্য ৯,০০,০২০ সারি বনাম ২০ সারি, কারণ দুটি query আসলে দুটি ভিন্ন প্রশ্ন করছে।

মূল উপলব্ধি: “৯ লাখতম সারি” একটি অবস্থানের প্রশ্ন, আর “এই মানের পরের সারি” একটি মানের প্রশ্ন। Index মান খোঁজায় পারদর্শী, অবস্থান গোনায় নয়। তাই ধীরতা ঠিক করার পথ হলো ডেটাবেসকে বকা নয়, প্রশ্নটি বদলানো।

প্র ০২Infinite scroll আর পাতা নম্বর — কোনটি ভালো? Cursor আছে বলেই কি পাতা নম্বর বাদ দেওয়া উচিত?

না। এটি কারিগরি নয়, পণ্যের সিদ্ধান্ত, আর কারিগরি সীমা শুধু তা প্রভাবিত করে। পাতা নম্বরের সুবিধা হলো অবস্থানবোধ: ব্যবহারকারী জানেন তিনি কোথায়, “পাতা ৪-এ যে তেলটি দেখেছিলাম” সেখানে ফিরতে পারেন, link শেয়ার করতে পারেন। সার্চ ইঞ্জিনের জন্যও স্থির URL সহজ, কারণ crawler সাধারণত scroll করে নতুন ডেটা আনে না (Next.js-এর SEO নিয়ে পর্ব ৫৮). Infinite scroll-এর সুবিধা হলো মোবাইলে ঘর্ষণ কম, বিশেষ করে যেখানে মানুষ ঘুরে দেখেন, লক্ষ্য নিয়ে খোঁজেন না।

Infinite scroll-এর খরচও আছে। ফুটার পর্যন্ত পৌঁছানো কঠিন হয়, back বোতামে অবস্থান হারাতে পারে, আর কয়েক হাজার সারি DOM-এ জমলে ফোন ধীর হয় (তখন virtualization দরকার, পর্ব ২৩-এর performance ভাবনা). বহু দোকানের সাইটে “আরও দেখুন” বোতাম দুই পথের মাঝামাঝি।

বাস্তবের আরেকটি পথ হলো hybrid. প্রথম কয়েক পাতার নম্বর দিন, আর গভীর পাতা বন্ধ করুন। কেউ ৪০০ নম্বর পাতায় সত্যিই যান না, যান crawler আর scraper. তাঁদের জন্য filter আর sort আরও ভালো পথ। এই চিন্তাই পর্ব ২৭-এর page ≤ 1000 সীমার পেছনে আছে, আর একটি কম সীমাও ঠিক হতে পারে। মাপা ফল বলছে, ১০,০০০ সারির মধ্যে offset এক ms-এর নিচে, তাই ছোট ক্যাটাগরি তালিকায় cursor-এর জন্য জটিলতা বাড়ানোর দরকার নাও হতে পারে। তবে এটি একটি local run, আপনার ডেটায় মেপে নিন।

আরেকটি বিষয়, পাতা নম্বরের URL স্থায়ী নয়। “পাতা ৪” আগামীকাল অন্য পণ্য দেখাবে। Cursor URL আরও ভঙ্গুর, কারণ cursor-এর গঠন বদলালে পুরোনো link কাজ নাও করতে পারে। তাই cursor শেয়ার করার link হিসেবে ভালো নয়।

মূল উপলব্ধি: আগে জিজ্ঞেস করুন ব্যবহারকারী কী করছেন: খুঁজছেন, ঘুরছেন, না মিলিয়ে দেখছেন। Pagination-এর ধরন সেই উত্তরের অনুসরণ করে, উল্টোটা নয়।

প্র ০৩একটি সহজ COUNT(*) কেন কখনো এত দামি? MySQL-এর পুরোনো engine-এ তো এটি সাথে সাথে আসত।

MySQL-এর পুরোনো MyISAM engine টেবিলের মোট সারির সংখ্যা জমা রাখত, তাই filter ছাড়া COUNT(*) প্রায় তাৎক্ষণিক ছিল। কিন্তু MyISAM-এ transaction ছিল না। একই MySQL-এর InnoDB, যা transaction দেয়, এমন একটি সংখ্যা রাখে না। কারণটি একই MVCC.

ভাবুন দুটি transaction একসাথে চলছে। একটি ১,০০০ সারি যোগ করেছে, এখনো commit করেনি। তার নিজের চোখে টেবিলে ১০,০১,০০০ সারি, অন্যজনের চোখে ১০,০০,০০০. একটি মাত্র সংখ্যা দুজনের জন্য সঠিক হতে পারে না। তাই গোনার সময় সারিগুলো দেখে ঠিক করতে হয় কোনটি কার চোখে আছে। PostgreSQL-এর run-এ Parallel Seq Scan, দুই worker আর ৯,৩৪৬টি page ঠিক সেই কাজ।

SQLite-এ filter ছাড়া গোনা ০.১১ ms, কিন্তু filter দিলে ২৩৭ ms. এই জায়গায় শিক্ষাটি লুকানো: “COUNT(*) ধীর” কথাটি অসম্পূর্ণ। দাম নির্ভর করে engine, index আর filter-এর ওপর। উল্টো দিকে PostgreSQL-এর filter-সহ গোনা ১৭ ms, যা SQLite-এর চেয়ে কম, কারণ সে bitmap ব্যবহার করেছে। তাই এক ডেটাবেসের অভিজ্ঞতা অন্যটিতে নিয়ে যাবেন না।

সমাধানের পথগুলো হলো trade-off. Trigger দিয়ে নিজের counter টেবিল রাখা যায়, কিন্তু প্রতিটি লেখার সাথে একটি ঘর ব্যস্ত হয়। reltuples-এর মতো পরিসংখ্যান তাৎক্ষণিক, তবে আনুমানিক। “১০,০০০+” দেখালে একটি সীমার পরে থামা যায়। অনেক পণ্য-তালিকায় ব্যবহারকারী আসলে ঠিক সংখ্যাটি চান না, “অনেক আছে” জানলেই চলে।

মূল উপলব্ধি: নির্ভুল সংখ্যা transaction-এর জগতে সস্তা নয়। আগে জিজ্ঞেস করুন, ব্যবহারকারীর কি সত্যিই ঠিক সংখ্যাটি দরকার, না “আরও আছে” জানাই যথেষ্ট।

প্র ০৪Cursor কি একটি নিখুঁত ছবি দেয়? পুরো তালিকা হাঁটার মাঝে কোনো পণ্যের দাম বদলালে কী হবে?

না। Cursor insert আর delete-এর সমস্যা মেটায়, কিন্তু সে কোনো snapshot নয়। প্রতিটি পাতা আলাদা query, আলাদা সময়ে। ধরুন দামে sort করা তালিকায় আপনি ১৫০ টাকায় আছেন। এক পণ্যের দাম ২০০ থেকে ১০০ হলো। সে আপনার পেছনে চলে গেল, আপনি তাকে কখনো দেখবেন না। উল্টো দিকে ১০০ টাকার একটি পণ্য, যা আপনি দেখে এসেছেন, ২০০ হলে আবার সামনে দেখবেন, দ্বিতীয়বার। অর্থাৎ sort-এর কলাম বদলালে cursor-ও ঠকে। এজন্য অনেক দল feed-এ অপরিবর্তনীয় কলাম (id, created_at) ধরে sort করে।

আরেকটি ঘটনা: created_at-এর আসল ক্রম আর commit-এর ক্রম এক নয়। Transaction A ১০:০০:০১ সময় লিখল, কিন্তু commit করল ১০:০০:০৫-এ। এর মাঝে B ১০:০০:০৩-এ লিখে commit করে ফেলল। যে sync client ১০:০০:০৩ পর্যন্ত পড়ে গেছে, A-র সারিটি তার পেছনে পড়ল, সে আর দেখবে না। পরিবর্তন নকল করার sync ব্যবস্থায় এটি পরিচিত সমস্যা। তাই সেখানে সাধারণত উপরি একটি নিরাপত্তা-জানালা রাখা হয়, অথবা database-এর change log ব্যবহার করা হয়।

সত্যিকারের snapshot চাইলে পথ আলাদা। একটি লম্বা repeatable read transaction-এ পুরো export চালানো যায়, তবে সেটি পুরোনো version ধরে রাখার চাপ বাড়ায়। অথবা একটি background job (পর্ব ৬২) ফাইল বানিয়ে দিতে পারে। হিসাবের রিপোর্টের মতো কাজে এটিই সঠিক, ক্রেতার feed-এ নয়।

GraphQL জগতের Relay connection নকশাও cursor-কে opaque বলে, কিন্তু সে consistency-র গ্যারান্টি দেয় না। পর্ব ৩৬-এ এর সাথে দেখা হবে।

মূল উপলব্ধি: Pagination একটি পড়ার কৌশল, consistency-র প্রতিশ্রুতি নয়। Cursor “ক্রমে এগোনো” দেয়, “এক মুহূর্তের ছবি” নয়। Feed-এ এটি যথেষ্ট, টাকার হিসাবে নয়।

প্র ০৫Client-কে ইচ্ছামতো filter আর sort দেওয়া তো নমনীয়। তাহলে allowlist কেন এত কড়া? GraphQL বা OData তো অনেক স্বাধীনতা দেয়।

নমনীয়তার একটি বিল আছে, যা server দেয়। প্রতিটি sort আর filter-এর জোড়া সস্তা হতে হলে তার মতো index লাগে। ৫টি filter ঘর আর ৪টি sort-এর সব জোড়ার জন্য composite index বানানো অসম্ভব, আর প্রতিটি index প্রতিটি লেখার খরচ বাড়ায়। আমাদের ৭ নম্বর অংশে দেখলেন, ঠিক index না থাকায় একই ধরনের query ০.০৪ ms থেকে ৫৮ ms হয়েছে। স্বাধীন API মানে যে কেউ সবচেয়ে দামি query-টি বারবার পাঠাতে পারেন। এটি নিরাপত্তারও প্রশ্ন, কারণ ব্যয়বহুল query দিয়ে server-কে ব্যস্ত রাখা যায়।

OData-র মতো মান (Microsoft-এর উদ্যোগে শুরু, পরে OASIS মান) client-কে query ভাষা দেয়, আর GraphQL client-কে আকার বাছতে দেয়। এগুলো ভুল নয়। কিন্তু খোলা ব্যবহারে এদের সঙ্গে query-র খরচ হিসাব, গভীরতার সীমা বা persisted query-র মতো ব্যবস্থাও লাগে। অর্থাৎ allowlist অন্য নামে ফিরে আসে। অভ্যন্তরীণ dashboard-এ, যেখানে দল নিজেই client লেখে, স্বাধীনতার দাম কম। সাধারণ সবার জন্য খোলা API-তে দাম অনেক বেশি।

আরেকটি যুক্তি হলো চুক্তি। আপনি যা খোলেন, তা কেউ না কেউ ব্যবহার করবেন, আর পরে বদলানো কঠিন হবে। একে প্রায়ই Hyrum's Law নামে ডাকা হয়। আজ ?sort=stock খুললে কাল কলামটি বদলাতে গেলে কারও অ্যাপ ভাঙবে। Allowlist তাই ডেটাবেসের নকশাকে API-র চুক্তি থেকে আলাদা রাখে। একই কারণে cursor opaque. পর্ব ৩৫-এ চুক্তি লেখা আর versioning নিয়ে এটি আরও পরিষ্কার হবে।

তবে কড়া হওয়া মানে অপ্রয়োজনে কৃপণ হওয়া নয়। আসল ব্যবহার দেখে filter যোগ করা সহজ, সরানো কঠিন। তাই ছোট থেকে শুরু করে মেপে বাড়ান।

মূল উপলব্ধি: প্রতিটি filter আর sort একটি প্রতিশ্রুতি: “এটি দ্রুত আর নিরাপদ থাকবে।” যে প্রতিশ্রুতি রাখতে পারবেন, শুধু সেটিই API-তে লিখুন।

অনুশীলন

প্রথমে নিজে করে দেখুন, তারপর উত্তর মেলান।

  1. হিসাব: একটি টেবিলে ২,০০,০০০ সারি। পাতার আকার ৫০. (ক) offset দিয়ে পুরো তালিকা হাঁটলে মোট কতটি সারি পড়তে হবে? (খ) B-tree-র উচ্চতা ৩ ধরে cursor-এ কত? (গ) অনুপাত কত? (ঘ) পর্ব ২৭-এর সীমায় page=1000&limit=100-এ একটি অনুরোধে কত সারি পড়া হয়?

    (ক) P = ২,০০,০০০ / ৫০ = ৪,০০০. মোট = p · P(P + 1) / 2 = ৫০ × ৪,০০০ × ৪,০০১ / ২ = ৪০,০১,০০,০০০ সারি।

    (খ) প্রতি পাতায় ৩ + ৫০ = ৫৩, মোট ৫৩ × ৪,০০০ = ২,১২,০০০.

    (গ) ৪০,০১,০০,০০০ / ২,১২,০০০ ≈ ১,৮৮৭ গুণ.

    (ঘ) Offset = (১০০০ − ১) × ১০০ = ৯৯,৯০০, তার সাথে ১০০টি, মোট ১,০০,০০০ সারি।

  2. Output অনুমান: টেবিলে id ৯ থেকে ১ (নতুন আগে), পাতায় ৩টি। পাঠক পাতা ১ পড়লেন: ৯, ৮, ৭. এরপর ৯ আর ৮ মুছে গেল। (ক) Offset-এ পাতা ২ কী দেবে? কোন সারি হারাল? (খ) Cursor (৭) দিয়ে পরের পাতা কী দেবে?

    মোছার পরে তালিকা: ৭, ৬, ৫, ৪, ৩, ২, ১.

    (ক) OFFSET ৩ পেরোয় ৭, ৬, ৫. পাতা ২ দেয় ৪, ৩, ২. পণ্য ৬ আর ৫ পাঠক কখনো দেখলেন না।

    (খ) WHERE id < 7 দেয় ৬, ৫, ৪. কিছুই হারায়নি।

  3. Code review: AI নিচের route দিয়েছে। কমপক্ষে পাঁচটি সমস্যা ধরুন, আর প্রতিটির সমাধান বলুন।
    app.get('/api/products', (req, res) => {
      const { page = 1, limit = 20, sort = 'id', ...filters } = req.query;
      const where = Object.keys(filters).map((k) => `${k} = '${filters[k]}'`);
      const clause = where.length ? 'WHERE ' + where.join(' AND ') : '';
      const rows = db.prepare(`SELECT * FROM products ${clause}
        ORDER BY ${sort} LIMIT ${limit} OFFSET ${(page - 1) * limit}`).all();
      const total = db.prepare(`SELECT COUNT(*) c FROM products ${clause}`).get().c;
      res.json({ rows, total });
    });
    • Filter-এর মান SQL-এ: সরাসরি SQL injection. সমাধান: placeholder আর params.
    • Filter-এর key SQL-এ: যেকোনো কলাম, লুকানো কলামও। সমাধান: strict schema, নির্দিষ্ট filter.
    • ORDER BY ${sort}: injection. সমাধান: নাম থেকে নিজের SQL-এ map.
    • limit আর page অযাচাই: ?limit=1000000 বা ভুল string. সমাধান: Zod, সীমা, আর limit-ও placeholder-এ।
    • Tie-breaker নেই, ডিফল্ট ছাড়া অন্য sort-এ: ক্রমের শেষে id.
    • SELECT *: ভবিষ্যতের লুকানো কলামও বেরিয়ে যাবে। নির্দিষ্ট কলাম লিখুন।
    • প্রতি অনুরোধে COUNT(*): সমাধান: limit + 1, মোট সংখ্যা ঐচ্ছিক।
  4. নকশা: আমাদের cursor-এ sort আছে, কিন্তু filter নেই। কেউ category=dal-এর cursor নিয়ে category=tel-এ পাঠালে কী হবে? এটি বিপজ্জনক কি না, আর আপনি কীভাবে ঠিক করবেন?

    Query ত্রুটি দেবে না। সে tel-এর ভেতরে ওই সময়ের পরের সারি দেবে। ফল সঠিক দেখায়, কিন্তু তালিকার মাঝখান থেকে শুরু হয়। নিরাপত্তার সমস্যা নয়, কারণ মান parameter-এ যাচ্ছে আর সবই সবার জন্য খোলা পণ্য, তবে এটি নীরব ভুল।

    সমাধান: cursor বানানোর সময় filter-গুলো একটি সাজানো ক্রমে JSON করে hash করে ছোট একটি f ঘরে রাখুন। অনুরোধে একই hash হিসাব করে মিলান, না মিললে 400 দিন। কিছু দল উল্টো পথ নেয়: cursor-এই সব filter রাখে, আর cursor এলে query string-এর filter উপেক্ষা করে। এতে URL ছোট হয়, তবে cursor বড় হয়। মালিকানার তথ্য বা ব্যবহারকারীর id যেন cursor থেকে না আসে, সেটি সবসময় session থেকে নিন (পর্ব ৪৮).

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমCursor pagination-এ একটি পরের পাতার অনুরোধের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Query parameter যাচাই, sort নাম allowlist-এ মেলানো
  2. Cursor decode করে স্বাক্ষর আর গঠন যাচাই
  3. WHERE (col, id) < (?, ?) দিয়ে limit + 1টি সারি আনা
  4. অতিরিক্ত সারি দেখে hasMore ঠিক করা, সেটি কেটে বাদ দেওয়া
  5. শেষ সারি থেকে নতুন cursor বানিয়ে উত্তর পাঠানো

১০ লাখ সারির টেবিলে ORDER BY id LIMIT 20 OFFSET 900000 কেন ধীর?

  • id-তে index থাকে না বলে
  • বাদ দেওয়া ৯ লাখ সারিও ডেটাবেসকে পেরোতে হয়
  • ২০টি সারি network-এ পাঠাতে সময় লাগে বলে
  • LIMIT সবসময় ধীর বলে

নতুন আগে সাজানো তালিকায় পাতা ১ (limit ৩) দিল ১২, ১১, ১০. এরপর একটি নতুন সারি ১৩ যোগ হলো। Offset দিয়ে পাতা ২ কী দেবে?

  • ৯, ৮, ৭
  • ১৩, ১২, ১১
  • ১০, ৯, ৮
  • কিছুই আসবে না

দামে sort করা cursor-এ শুধু WHERE price > ? লিখলে কী ভুল হয়?

  • SQL syntax error হয়
  • Query সবসময় full scan করে
  • সবচেয়ে দামি পণ্য বাদ পড়ে
  • শেষ দেখানো দামের সমান দামের বাকি সারিগুলো হারিয়ে যায়

API-তে hasMore জানার সবচেয়ে সস্তা সাধারণ উপায় কোনটি?

  • limit + 1টি সারি এনে দেখা অতিরিক্ত সারি এলো কি না
  • প্রতিবার filter-সহ COUNT(*) চালানো
  • Client-কে পরের পাতা চেয়ে দেখতে বলা, খালি হলে শেষ
  • সারির সংখ্যা limit-এর সমান হলেই hasMore সত্য ধরা

Client ?sort=price পাঠালে SQL-এ ORDER BY ? লিখে মান 'price' বসালে কী হয়?

  • দামে সঠিকভাবে sort হয়
  • Query চলে, কিন্তু কলাম ধরে sort হয় না, কারণ placeholder শুধু মান বসায়
  • SQL injection হয়
  • সবসময় syntax error হয়

Opaque cursor সম্পর্কে কোন কথাটি সঠিক?

  • Base64 করলে cursor গোপন ও নিরাপদ হয়
  • Client cursor-এর গঠন পড়ে নিজেই পরের cursor বানাবে
  • Client শুধু ফেরত পাঠায়, তবে server decode করা মানকে অবিশ্বস্ত ইনপুট ধরেই ব্যবহার করে
  • Cursor কখনো ভুল হতে পারে না, তাই যাচাই লাগে না

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. তালিকা ফেরানো প্রতিটি endpoint-এ সীমা থাকবে। পুরো টেবিল কখনো নয়।
  2. Offset সহজ, পাতায় লাফ দেয়, কিন্তু খরচ offset-এর সমানুপাতিক, আর ডেটা বদলালে সারি দুবার আসে বা হারায়।
  3. Cursor মানের সাথে তুলনা করে, গভীরতায় স্থির, কিন্তু পাতায় লাফ দেয় না। ক্রমের শেষে tie-breaker বাধ্যতামূলক।
  4. hasMore-এর জন্য limit + 1, আর EXPLAIN-এ SCAN বা TEMP B-TREE দেখলে index ভাবুন।
  5. Filter আর sort শুধু allowlist থেকে। মান placeholder-এ, আর ORDER BY নিজের লেখা SQL টুকরো থেকে।

পরের পর্বে: আপনার API এখন বেশ কিছু নিয়ম মেনে চলে: কোন parameter চলে, কোন error আসে, cursor কীভাবে ফেরে। পর্ব ৩৫-এ দেখব এই চুক্তি OpenAPI-তে লেখা আর পুরোনো client না ভেঙে API বদলানোর পথ।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান