সরাসরি পর্বে যান
পর্ব ২৬ · ৬৭-এর মধ্যে · মডিউল ৪

Window function — ROW_NUMBER, RANK, LAG আর running total

SQL Window Functions — OVER and PARTITION BY, Ranking, LAG/LEAD, Running Totals, Moving Averages and Window Frames

Taught in Bangla, this lesson teaches SQL window functions that compute across rows without collapsing them: OVER, PARTITION BY and ORDER BY, ranking with ROW_NUMBER, RANK and DENSE_RANK, LAG and LEAD, running totals, moving averages and percent of total, ROWS, RANGE and GROUPS frames, the default-frame trap with LAST_VALUE, logical evaluation order and filtering results, with live SQL editors for every example.

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

  • GROUP BY আর window function-এর মূল পার্থক্য, আর OVER (), PARTITION BY ও ORDER BY-এর কাজ
  • ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK, CUME_DIST — টাই হলে কে কী দেয়
  • LAG/LEAD, FIRST_VALUE/LAST_VALUE/NTH_VALUE, running total, moving average আর মোটের শতাংশ
  • Frame-এর তিন একক ROWS, RANGE আর GROUPS, default frame, আর LAST_VALUE-এর ফাঁদ
  • WINDOW clause, যৌক্তিক ক্রমে window function-এর জায়গা, CTE দিয়ে ছাঁকা, পারফরম্যান্স আর কোন DBMS-এ কী আছে

১ · সারি না হারিয়ে হিসাব — window function-এর মূল ধারণা

পর্ব ১৮-এ (GROUP BY আর HAVING) দেখেছেন, GROUP BY সারিগুলোকে দলে ভাগ করে প্রতিটি দল থেকে একটি ফল-সারি দেয়। তাতে একটি দাম দিতে হয়: দলের ভেতরের সারিগুলো হারিয়ে যায়। এখন একটি সাধারণ প্রশ্ন ভাবুন — “প্রতিজন শিক্ষার্থীর নাম, তাঁর CGPA, আর তাঁর বিভাগের গড় CGPA পাশাপাশি দেখাও।” এখানে নামও চাই (প্রতিটি সারি), গড়ও চাই (দলের হিসাব)। GROUP BY দিলে নাম হারায়; না দিলে গড় পাওয়া যায় না।

এই সমস্যার সমাধান window function. এটি একটি aggregate বা বিশেষ ফাংশন, যা প্রতিটি সারির জন্য আলাদা করে হিসাব করে, কিন্তু হিসাবের সময় সেই সারির আশেপাশের কিছু সারি — তার “জানালা” (window) — দেখে। ফল-সারির সংখ্যা কমে না। চেনার উপায় একটিই: ফাংশনের পরে OVER শব্দ।

বিশ্ববিদ্যালয়ের ফল প্রকাশের বোর্ডের কথা ভাবুন। একটি নোটিশে লেখা থাকে শুধু “CSE বিভাগের গড় 3.65” — এটি GROUP BY. আরেকটি নোটিশে প্রতিজন শিক্ষার্থীর নাম, তাঁর CGPA, আর পাশের কলামে “বিভাগে অবস্থান: ২য়” বা “বিভাগের গড় 3.65” — এটি window function. দ্বিতীয় নোটিশে কারও নাম মুছে যায়নি; প্রতিটি লাইনে দলের তথ্য জুড়ে দেওয়া হয়েছে।

নিচের ডায়াগ্রামে db-uni-র আটজন শিক্ষার্থীর ওপর দুই পথ পাশাপাশি দেখুন। (students টেবিলের কলাম: student_id, name, dept_id — 1 = CSE, 2 = EEE, 3 = BBA, batch আর cgpa. Fahim Karim-এর cgpa NULL, Habib Ullah-র dept_id NULL.)

students (৮টি সারি)শুরুর অবস্থা
 name           │ dept │ cgpa
 Arif Hossain   │ 1    │ 3.75
 Bristy Saha    │ 1    │ 3.40
 Chayon Roy     │ 2    │ 2.95
 Dilruba Khatun │ 1    │ 3.90
 Emon Mia       │ 3    │ 3.10
 Fahim Karim    │ 2    │ NULL
 Gulshan Ara    │ 1    │ 3.55
 Habib Ullah    │ NULL │ 2.80
পথ ১: GROUP BY dept_id৮ সারি → ৪ সারি
SELECT dept_id, AVG(cgpa) FROM students
GROUP BY dept_id;

 dept │ AVG(cgpa)
 NULL │ 2.8
 1    │ 3.65
 2    │ 2.95
 3    │ 3.1

নামগুলো কোথায়? দলে গুটিয়ে গেছে —
এক সারিতে চারটি নাম বসানো যায় না
পথ ২: OVER (PARTITION BY dept_id)ভাগ হলো, কিন্তু গোটানো নয়
[dept NULL]  Habib 2.80
[dept 1   ]  Dilruba 3.90 │ Arif 3.75 │ Gulshan 3.55 │ Bristy 3.40
[dept 2   ]  Chayon 2.95 │ Fahim NULL
[dept 3   ]  Emon 3.10

প্রতিটি ভাগকে বলে partition —
সারিগুলো নিজের জায়গায় থাকে
প্রতিটি সারির পাশে তার partition-এর গড়৮ সারি → ৮ সারি
 name           │ dept │ cgpa │ dept_avg
 Habib Ullah    │ NULL │ 2.80 │ 2.8
 Dilruba Khatun │ 1    │ 3.90 │ 3.65
 Arif Hossain   │ 1    │ 3.75 │ 3.65
 Gulshan Ara    │ 1    │ 3.55 │ 3.65
 Bristy Saha    │ 1    │ 3.40 │ 3.65
 Chayon Roy     │ 2    │ 2.95 │ 2.95
 Fahim Karim    │ 2    │ NULL │ 2.95
 Emon Mia       │ 3    │ 3.10 │ 3.1
একই সারিতে দুই স্তরের তথ্যcgpa − dept_avg
 name           │ cgpa │ dept_avg │ diff
 Dilruba Khatun │ 3.90 │ 3.65     │ +0.25
 Arif Hossain   │ 3.75 │ 3.65     │ +0.10
 Gulshan Ara    │ 3.55 │ 3.65     │ −0.10
 Bristy Saha    │ 3.40 │ 3.65     │ −0.25
 Fahim Karim    │ NULL │ 2.95     │ NULL

GROUP BY দিয়ে এটি পেতে লাগত
আলাদা কোয়েরি আর একটি JOIN
হিসাব
ফল-সারি
নাম আছে?
দলের গড় আছে?
  1. আটটি সারি। CSE-তে চারজন, EEE-তে দুজন, BBA-তে একজন, আর Habib-এর বিভাগ NULL.
  2. GROUP BY গুটিয়ে ফেলে। চারটি দল, চারটি ফল-সারি। CSE-র গড় 3.65 পাওয়া গেল, কিন্তু Arif, Bristy, Dilruba, Gulshan — চারটি নাম এক সারিতে বসানো সম্ভব নয়, তাই নামগুলো হারাল।
  3. PARTITION BY ভাগ করে, গোটায় না। একই dept_id-এর সারিগুলো একটি partition-এ যায়, যেমন GROUP BY-তে দলে যেত — কিন্তু প্রতিটি সারি আলাদা থেকে যায়।
  4. প্রতিটি সারির পাশে partition-এর হিসাব। CSE-র চারটি সারির প্রতিটিতে 3.65; EEE-তে 2.95 (Fahim-এর NULL বাদ দিয়ে একজনের গড়)। ফল-সারি আটটিই।
  5. এবার সারি আর দল একসাথে। একই SELECT-এ cgpa থেকে dept_avg বিয়োগ করা যায়: Dilruba বিভাগের গড়ের 0.25 ওপরে, Bristy 0.25 নিচে। NULL থেকে বিয়োগ করলে ফল NULL.

এবার নিজে চালান — প্রথমে GROUP BY, তারপর একই হিসাব window function দিয়ে:

-- পথ ১: GROUP BY — প্রতি বিভাগে একটি সারি
SELECT dept_id, ROUND(AVG(cgpa), 2) AS dept_avg
FROM students GROUP BY dept_id;

-- পথ ২: window — প্রতিটি শিক্ষার্থী, পাশে বিভাগের গড়
SELECT name, dept_id, cgpa,
       ROUND(AVG(cgpa) OVER (PARTITION BY dept_id), 2)        AS dept_avg,
       ROUND(cgpa - AVG(cgpa) OVER (PARTITION BY dept_id), 2) AS diff
FROM students
ORDER BY dept_id, cgpa DESC;

প্রথম ফল চারটি সারি: NULL | 2.8, 1 | 3.65, 2 | 2.95, 3 | 3.1. দ্বিতীয় ফল আটটি সারি: Habib Ullah | NULL | 2.8 | 2.8 | 0, তারপর CSE-র চারজন — Dilruba 3.9 (diff 0.25), Arif 3.75 (0.1), Gulshan 3.55 (−0.1), Bristy 3.4 (−0.25), প্রত্যেকের dept_avg 3.65; তারপর Chayon Roy | 2 | 2.95 | 2.95 | 0, Fahim Karim | 2 | NULL | 2.95 | NULL আর Emon Mia | 3 | 3.1 | 3.1 | 0. খেয়াল করুন: GROUP BY নেই, তবু AVG চলছে — কারণ OVER বলে দিচ্ছে, এটি সারি গোটানো aggregate নয়, window aggregate. Habib-এর dept_id NULL হলেও তিনি নিজের একটি partition পেয়েছেন — PARTITION BY-ও GROUP BY-এর মতো সব NULL-কে এক দলে রাখে।

২ · OVER-এর তিনটি অংশ — OVER (), PARTITION BY আর ORDER BY

প্রতিটি window function-এর গঠন একই: ফাংশন(…) OVER ( [PARTITION BY …] [ORDER BY …] [frame] ). বন্ধনীর ভেতরের তিনটি অংশই ঐচ্ছিক, আর প্রতিটি একটি আলাদা প্রশ্নের উত্তর দেয়:

অংশকোন প্রশ্নের উত্তরনা দিলে
PARTITION BY colকোন সারিরা একসাথে হিসাব হবে? (একই col-মানের সারিরা একটি partition)পুরো ফল একটি partition
ORDER BY colpartition-এর ভেতরে সারিগুলোর ক্রম কী? (ranking, LAG, running total-এর জন্য জরুরি)কোনো ক্রম নেই — সব সারি সমান
frame (ROWS/RANGE/GROUPS …)বর্তমান সারির জন্য partition-এর ঠিক কোন অংশ দেখা হবে?একটি default frame (অংশ ৬)

সবচেয়ে সরল রূপ OVER () — খালি বন্ধনী। তখন পুরো ফল একটি জানালা, তাই COUNT(*) OVER () প্রতিটি সারিতে মোট সারির সংখ্যা বসায়। পরের কোয়েরিতে তিনটি রূপ একসাথে: সব অর্ডারের সংখ্যা, গ্রাহকের নিজের অর্ডারের সংখ্যা, আর গ্রাহকের কততম অর্ডার। db-shop-এর orders টেবিলে দশটি অর্ডার: order_id, customer_id, order_date (তারিখ, 'YYYY-MM-DD' লেখা হিসেবে) আর status.

SELECT order_id, customer_id, order_date,
       COUNT(*)     OVER ()                         AS all_orders,
       COUNT(*)     OVER (PARTITION BY customer_id) AS cust_orders,
       ROW_NUMBER() OVER (PARTITION BY customer_id
                          ORDER BY order_date)      AS nth
FROM orders
ORDER BY customer_id, order_date;

দশটি সারি, প্রতিটিতে all_orders = 10. Rahim (customer 1)-এর তিনটি সারি: 1001 | 1 | 2025-09-01 | 10 | 3 | 1, 1003 | … | 3 | 2, 1009 | … | 3 | 3; Karima (2)-র দুটি: 1002 nth 1, 1007 nth 2; বাকি পাঁচজনের একটি করে অর্ডার, তাই cust_orders 1, nth 1. ROW_NUMBER() প্রতিটি partition-এ 1 থেকে নতুন করে গোনা শুরু করে — “এটি গ্রাহকের কততম কেনাকাটা” প্রশ্নের এক লাইনের উত্তর। প্রথম কেনাকাটার অফার কারা পাবেন, তা বের করতে nth = 1 লাগবে — কিন্তু সেটি WHERE-এ সরাসরি লেখা যায় না, অংশ ৮-এ কারণ দেখবেন।

OVER-এর ভেতরের ORDER BY ফলকে সাজায় না। এটি শুধু বলে দেয় window-এর ভেতরে কোন সারি আগে, কোনটি পরে। চূড়ান্ত ফল কোন ক্রমে আসবে, তা নিশ্চিত করতে কোয়েরির শেষে আলাদা ORDER BY লাগে। অনেক সময় দুটি একই দেখায় — কারণ DBMS window-এর জন্য সাজিয়েছে, আর সেই ক্রমই ফলে এসেছে — কিন্তু এর কোনো নিশ্চয়তা নেই।

৩ · Ranking — ROW_NUMBER, RANK আর DENSE_RANK

মেধাতালিকা বানাতে গেলেই প্রথম প্রশ্ন: দুজনের নম্বর সমান হলে কী হবে? SQL-এ তিনটি ranking function, আর তিনটির পার্থক্য শুধু টাই (tie)-তে:

  • ROW_NUMBER() — সবসময় 1, 2, 3, 4 … ; টাই হলেও আলাদা নম্বর, আর কে আগে পাবে তা অনির্দিষ্ট।
  • RANK() — সমান মানে সমান অবস্থান, তারপর ফাঁক: 1, 2, 2, 4. খেলাধুলার প্রচলিত নিয়ম — দুজন দ্বিতীয় হলে কেউ তৃতীয় হন না।
  • DENSE_RANK() — সমান মানে সমান অবস্থান, কিন্তু ফাঁক নেই: 1, 2, 2, 3. “কততম আলাদা মান” — যেমন “দ্বিতীয় সর্বোচ্চ বেতন”।

db-uni-র CGPA-তে কোনো টাই নেই, তাই এই অংশের এডিটরে (data-setup দিয়ে) একজন নতুন CSE শিক্ষার্থী যোগ করা হয়েছে: Ishita Das (2303), batch 2023, CGPA 3.75 — ঠিক Arif-এর সমান। এবার CSE বিভাগের মেধাক্রম তিন ফাংশনে পাশাপাশি:

namecgpaROW_NUMBERRANKDENSE_RANK
Dilruba Khatun3.90111
Arif Hossain3.75222
Ishita Das3.75322
Gulshan Ara3.55443
Bristy Saha3.40554

সূত্র মনে রাখার সহজ উপায়: RANK = (আমার চেয়ে ভালো মানের সারির সংখ্যা) + 1; DENSE_RANK = (আমার চেয়ে ভালো আলাদা মানের সংখ্যা) + 1. Gulshan-এর চেয়ে ভালো সারি তিনটি (3.90, 3.75, 3.75), তাই RANK 4; কিন্তু আলাদা মান দুটি (3.90, 3.75), তাই DENSE_RANK 3. ফলে শেষ সারিতে RANK সবসময় ≤ সারির সংখ্যা, আর DENSE_RANK-এর সর্বোচ্চ মান = আলাদা মানের সংখ্যা।

Ranking-এর সাথে আরও তিনটি “অবস্থান” ফাংশন আছে, যারা পরিসংখ্যানের ভাষায় উত্তর দেয়:

  • NTILE(k) — ক্রম অনুযায়ী সারিগুলোকে k-টি প্রায় সমান ভাগে ভাগ করে ভাগ-নম্বর দেয়। NTILE(4) মানে চার ভাগ, অর্থাৎ quartile. n সারি k ভাগে না মিললে প্রথম (n mod k)-টি ভাগে একটি করে সারি বেশি যায়।
  • PERCENT_RANK() = (RANK − 1) ÷ (n − 1) — প্রথমজন 0, শেষজন 1.
  • CUME_DIST() = (ক্রমে আমি পর্যন্ত, আমার সমান মানের সবাইসহ, কয়টি সারি) ÷ n — “কত শতাংশ সারি আমার সমান বা আগে”। ফল কখনো 0 নয়, শেষজনের 1.
-- প্রতিটি বিভাগের ভেতরে তিন রকম মেধাক্রম
SELECT dept_id, name, cgpa,
       ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY cgpa DESC) AS rn,
       RANK()       OVER (PARTITION BY dept_id ORDER BY cgpa DESC) AS rnk,
       DENSE_RANK() OVER (PARTITION BY dept_id ORDER BY cgpa DESC) AS drnk
FROM students
ORDER BY dept_id, rn;

-- সবাই মিলিয়ে: quartile, percent rank, cumulative distribution
SELECT name, cgpa,
       NTILE(4)                           OVER (ORDER BY cgpa DESC)    AS quartile,
       ROUND(PERCENT_RANK() OVER (ORDER BY cgpa DESC), 3) AS pct_rank,
       ROUND(CUME_DIST()    OVER (ORDER BY cgpa DESC), 3) AS cume
FROM students WHERE cgpa IS NOT NULL
ORDER BY cgpa DESC;

প্রথম ফলে CSE-র পাঁচটি সারি ঠিক ওপরের টেবিলের মতো (Arif rn 2, Ishita rn 3, দুজনেরই rnk 2 আর drnk 2). বাকি partition-গুলো দেখুন: NULL | Habib Ullah | 2.8 | 1 | 1 | 1 — বিভাগহীন Habib নিজের partition-এ প্রথম; 2 | Chayon Roy | 2.95 | 1 | 1 | 1 আর 2 | Fahim Karim | NULL | 2 | 2 | 2 — SQLite বড় থেকে ছোট সাজালে NULL রাখে শেষে; 3 | Emon Mia | 3.1 | 1 | 1 | 1. দ্বিতীয় ফলে আটটি জানা CGPA: Dilruba 1 | 0 | 0.125, Arif 1 | 0.143 | 0.375, Ishita 2 | 0.143 | 0.375, Gulshan 2 | 0.429 | 0.5, Bristy 3 | 0.571 | 0.625, Emon 3 | 0.714 | 0.75, Chayon 4 | 0.857 | 0.875, Habib 4 | 1 | 1. Arif আর Ishita-র PERCENT_RANK সমান — (2 − 1) ÷ 7 = 0.143 — আর CUME_DIST দুজনেরই 3 ÷ 8 = 0.375, কারণ টাই-এর সবাইকে একসাথে গোনা হয়। কিন্তু NTILE তাঁদের দুই আলাদা quartile-এ ফেলেছে (1 আর 2)! NTILE শুধু সারি গুনে ভাগ করে, মান দেখে না — সমান CGPA-র দুজন আলাদা ভাগে যেতে পারেন।

ROW_NUMBER-এ টাই মানে অনিশ্চয়তা। Arif rn 2 আর Ishita rn 3 — কেন উল্টো নয়? কোনো কারণ নেই; SQLite যে ক্রমে সারি পেয়েছে, সেভাবেই নম্বর দিয়েছে। অন্য DBMS, অন্য index বা পরের সংস্করণে উল্টে যেতে পারে। ROW_NUMBER দিয়ে “প্রতি বিভাগে একজন সেরা” বাছলে বা পাতায় পাতায় ফল দেখালে ORDER BY-তে একটি অনন্য tiebreaker যোগ করুন: ORDER BY cgpa DESC, student_id. আর PostgreSQL ও Oracle বড় থেকে ছোট সাজালে NULL রাখে আগে — সেখানে Fahim EEE-তে প্রথম হয়ে যাবেন! সব DBMS-এ এক ফল চাইলে ORDER BY cgpa DESC NULLS LAST লিখুন (NULLS LAST PostgreSQL, Oracle আর SQLite 3.30+-এ চলে; MySQL আর SQL Server-এ এটি নেই)।

৪ · আগের আর পরের সারি — LAG আর LEAD

মুদি দোকানের খাতায় মালিক প্রায়ই জিজ্ঞেস করেন: “রহিম সাহেব আগেরবার কবে এসেছিলেন?” বা “আজকের বিক্রি গতকালের চেয়ে কত বেশি?” দুটিই একই ধরনের প্রশ্ন — বর্তমান সারির সাথে ক্রমে আগের সারির তুলনা। Window function আসার আগে এর জন্য টেবিলকে নিজের সাথে JOIN করতে হতো। এখন আছে দুটি offset function:

গঠন

LAG(expr, n, default) OVER (PARTITION BY … ORDER BY …) — partition-এর ভেতরে n সারি আগের সারির expr (n না দিলে 1). আগে কোনো সারি না থাকলে default (না দিলে NULL). LEAD(…) একই, কিন্তু n সারি পরের। LAG আর LEAD frame মানে না — সবসময় পুরো partition-এর ক্রম ধরে চলে।

পরের এডিটরে দুটি প্রশ্ন। এক: প্রতিটি অর্ডারের জন্য সেই গ্রাহকের আগের অর্ডারের তারিখ, আর মাঝে কত দিন। SQLite-এ তারিখ লেখা হিসেবে থাকে, তাই দিন গুনতে julianday() লাগে (পর্ব ১৭) — এটি তারিখকে একটি দিন-সংখ্যায় বদলায়, আর দুটির বিয়োগফল দিনের পার্থক্য। দুই: দিনে দিনে বিক্রি কত বাড়ল বা কমল। এর জন্য প্রথমে দৈনিক আয়ের একটি CTE (পর্ব ২৫), তারপর তার ওপর LAG. order_items টেবিলে প্রতিটি অর্ডারের লাইন: order_id, product_id, quantity আর unit_price; বাতিল (cancelled) অর্ডার আয় থেকে বাদ।

-- ১. গ্রাহকের আগের অর্ডার আর মাঝের দিন
SELECT customer_id, order_id, order_date,
       LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS prev_date,
       julianday(order_date) - julianday(LAG(order_date)
           OVER (PARTITION BY customer_id ORDER BY order_date))          AS gap_days
FROM orders ORDER BY customer_id, order_date;

-- ২. দিনে দিনে আয়ের পরিবর্তন
WITH daily AS (
  SELECT o.order_date, SUM(i.quantity * i.unit_price) AS revenue
  FROM orders o JOIN order_items i ON i.order_id = o.order_id
  WHERE o.status <> 'cancelled' GROUP BY o.order_date)
SELECT order_date, revenue,
       LAG(revenue, 1, 0) OVER (ORDER BY order_date)     AS prev,
       revenue - LAG(revenue) OVER (ORDER BY order_date) AS change
FROM daily ORDER BY order_date;

প্রথম ফলে Rahim-এর তিনটি অর্ডার: 1 | 1001 | 2025-09-01 | NULL | NULL, 1 | 1003 | 2025-09-10 | 2025-09-01 | 9, 1 | 1009 | 2025-10-08 | 2025-09-10 | 28; Karima-র 1007-এ আগের তারিখ 2025-09-03, gap 28. বাকি গ্রাহকদের একটি করে অর্ডার, তাই prev_date আর gap_days NULL — PARTITION BY থাকায় LAG অন্য গ্রাহকের অর্ডারে “পিছলে” যায়নি। দ্বিতীয় ফলে নয়টি দিন: প্রথম দিন 2025-09-01 | 17400 | 0 | NULL — prev-এ default 0 দেওয়া, কিন্তু change-এর LAG-এ default নেই বলে NULL; তারপর 2025-09-03 | 1750 | 17400 | -15650, … , 2025-10-08 | 18000 | 550 | 17450, 2025-10-09 | 1040 | 18000 | -16960. প্রথম দিনের পরিবর্তন 0 না NULL — সেটি ব্যবসার সিদ্ধান্ত: “আগের দিন নেই” আর “আগের দিন শূন্য বিক্রি” আলাদা কথা।

তারিখের বিয়োগ প্রতিটি DBMS-এ আলাদা। SQLite-এ '2025-09-10' - '2025-09-01' লিখলে কোনো ত্রুটি আসে না, ফল আসে 0 — দুটি লেখার শুরুর “2025” সংখ্যায় বদলে বিয়োগ হয়! তাই julianday বাধ্যতামূলক। অন্যদিকে:

-- PostgreSQL: DATE − DATE = দিনের সংখ্যা (integer)
SELECT order_id,
       order_date - LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS gap_days
FROM orders;

-- MySQL 8.0: DATEDIFF(পরের, আগের)
SELECT order_id,
       DATEDIFF(order_date, LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date)) AS gap_days
FROM orders;

৫ · Running total, moving average আর মোটের ভাগ

SUM, AVG, COUNT, MIN, MAX — পর্ব ১৮-এর সব aggregate-এর পরে OVER বসালেই তারা window function. ORDER BY ছাড়া তারা পুরো partition-এর হিসাব দেয় (অংশ ১-এর বিভাগের গড়)। ORDER BY দিলে অর্থ বদলে যায়: প্রতিটি সারিতে “শুরু থেকে এই সারি পর্যন্ত” হিসাব — অর্থাৎ running total (চলমান যোগফল)। bKash-এর লেনদেনের তালিকায় প্রতিটি লাইনের পাশের “ব্যালান্স” কলামটি ঠিক এটি।

পরের এডিটরে আরেকটি নতুন জিনিস: window function চলছে GROUP BY-এর ফলের ওপর। প্রথমে দিন অনুযায়ী দল, প্রতিটি দলের SUM(…); তারপর SUM(SUM(…)) OVER (ORDER BY …) — ভেতরের SUM দলের aggregate, বাইরের SUM সেই দলগুলোর ওপর window. আর মোটের শতাংশে SUM(SUM(…)) OVER () সব দিনের মোট দেয়।

SELECT o.order_date,
       SUM(i.quantity * i.unit_price)                                   AS revenue,
       SUM(SUM(i.quantity * i.unit_price)) OVER (ORDER BY o.order_date) AS running,
       ROUND(100.0 * SUM(i.quantity * i.unit_price)
             / SUM(SUM(i.quantity * i.unit_price)) OVER (), 1)         AS pct
FROM orders o JOIN order_items i ON i.order_id = o.order_id
WHERE o.status <> 'cancelled'
GROUP BY o.order_date
ORDER BY o.order_date;

নয়টি সারি। running কলাম: 17400, 19150, 20930, 25980, 29480, 32730, 33280, 51280, 52320 — প্রতিটি আগেরটির সাথে সেদিনের revenue যোগ। শেষ সারির running (52320) মোট আয়, যা pct-এর হর। pct: 2025-09-01-এ 33.3, 2025-10-08-এ 34.4 — দশটি অর্ডারের মধ্যে দুটি দিনেই মোট আয়ের দুই-তৃতীয়াংশের বেশি (দুটিতেই একটি Mobile Phone). যোগফল 100-এর কাছে আসে (গোল করার কারণে ঠিক 100 না-ও হতে পারে)। 100.0 লেখা হয়েছে যাতে ভাগ দশমিকে হয় — PostgreSQL বা SQL Server-এ integer ÷ integer ভগ্নাংশ ফেলে দেয়।

এখন moving average (চলমান গড়): প্রতিটি অর্ডারে “এটিসহ শেষ তিনটি অর্ডারের গড়”। এখানে “শুরু থেকে” নয়, একটি নির্দিষ্ট মাপের জানালা লাগে, যা প্রতিটি সারির সাথে সামনে সরে — এর জন্য frame স্পষ্ট লিখতে হয়: ROWS BETWEEN 2 PRECEDING AND CURRENT ROW (দুই সারি আগে থেকে বর্তমান সারি পর্যন্ত)। নিচে জানালাটি সরতে দেখুন:

বাতিল বাদে ৯টি অর্ডারের মোটorder_date অনুযায়ী সাজানো
 order │ date       │ total
 1001  │ 2025-09-01 │ 17400
 1002  │ 2025-09-03 │  1750
 1003  │ 2025-09-10 │  1780
 1005  │ 2025-09-15 │  5050
 1006  │ 2025-09-20 │  3500
 1007  │ 2025-10-01 │  3250
 1008  │ 2025-10-05 │   550
 1009  │ 2025-10-08 │ 18000
 1010  │ 2025-10-09 │  1040
বর্তমান সারি 1001আগে কোনো সারি নেই
▶ 1001  │ 17400   ← frame
  1002  │  1750
  1003  │  1780
  1005  │  5050
  …

frame-এ ১টি সারি: 17400 ÷ 1 = 17400
বর্তমান সারি 1003জানালা প্রথমবার ভরা
  1001  │ 17400   ┐
  1002  │  1750   │ frame
▶ 1003  │  1780   ┘
  1005  │  5050
  …

(17400 + 1750 + 1780) ÷ 3 = 6976.7
বর্তমান সারি 1005জানালা এক ধাপ সরল
  1001  │ 17400   ← বাইরে চলে গেল
  1002  │  1750   ┐
  1003  │  1780   │ frame
▶ 1005  │  5050   ┘
  1006  │  3500
  …

(1750 + 1780 + 5050) ÷ 3 = 2860
বর্তমান সারি 1009বড় অর্ডার মসৃণ হলো
  …
  1006  │  3500
  1007  │  3250   ┐
  1008  │   550   │ frame
▶ 1009  │ 18000   ┘
  1010  │  1040

(3250 + 550 + 18000) ÷ 3 = 7266.7
একদিনের 18000 গড়ে 7266.7 — লাফ নরম হয়
হিসাব
বর্তমান সারি
frame-এ সারি
moving avg
running total
  1. সাজানো ইনপুট। বাতিল 1004 বাদে নয়টি অর্ডার, তারিখ অনুযায়ী। window-এর ORDER BY এই ক্রম ঠিক করে।
  2. প্রথম সারিতে জানালা ছোট। “দুই সারি আগে” বলে কিছু নেই, তাই frame-এ শুধু 1001 নিজে — গড় 17400. ত্রুটি হয় না, জানালা নিজে থেকে ছোট হয়।
  3. তৃতীয় সারিতে জানালা পূর্ণ। 1001, 1002, 1003 — তিনটির গড় 6976.7. running total (শুরু থেকে সব) এখানে 20930.
  4. জানালা সরে। 1005-এ নতুন সারি ঢুকল, সবচেয়ে পুরোনো 1001 বেরিয়ে গেল। গড় নেমে 2860 — 17400-এর বড় অর্ডারের প্রভাব শেষ। running total কিন্তু সবকিছু মনে রাখে: 25980.
  5. লাফ মসৃণ হয়। 1009-এর 18000 একা দেখলে বিশাল লাফ, কিন্তু তিন অর্ডারের গড়ে 7266.7. দাম, তাপমাত্রা বা দৈনিক বিক্রির “প্রবণতা” দেখতে moving average এজন্যই ব্যবহৃত হয়।
WITH t AS (
  SELECT o.order_id, o.order_date, SUM(i.quantity * i.unit_price) AS total
  FROM orders o JOIN order_items i ON i.order_id = o.order_id
  WHERE o.status <> 'cancelled'
  GROUP BY o.order_id, o.order_date)
SELECT order_id, order_date, total,
       COUNT(*) OVER (ORDER BY order_date
                      ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)     AS n,
       ROUND(AVG(total) OVER (ORDER BY order_date
                      ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 1) AS avg3
FROM t ORDER BY order_date;

নয়টি সারি; n কলাম 1, 2, 3, 3, 3, 3, 3, 3, 3 — প্রথম দুটি সারিতে জানালা পূর্ণ হয়নি। avg3: 17400, 9575, 6976.7, 2860, 3443.3, 3933.3, 2433.3, 7266.7, 6530 — ডায়াগ্রামের মানগুলো এখানে মিলিয়ে নিন। n কলামটি রাখা একটি ভালো অভ্যাস: শুরুর দিকের গড় কম সারির ওপর দাঁড়িয়ে, তাই অনেক রিপোর্টে n < 3 হলে গড় দেখানোই হয় না। আরেকটি সূক্ষ্মতা: এটি “শেষ তিনটি অর্ডার”, “শেষ তিন দিন” নয় — দিনের জানালার জন্য RANGE লাগে, পরের অংশে।

৬ · Frame গভীরে — ROWS, RANGE আর GROUPS

Frame-এর পূর্ণ গঠন: {ROWS | RANGE | GROUPS} BETWEEN শুরু AND শেষ. শুরু আর শেষ হতে পারে UNBOUNDED PRECEDING (partition-এর শুরু), n PRECEDING, CURRENT ROW, n FOLLOWING বা UNBOUNDED FOLLOWING (partition-এর শেষ)। শুধু শুরু লিখলে (ROWS UNBOUNDED PRECEDING) শেষটি ধরা হয় CURRENT ROW. কিন্তু “n সারি আগে” — সারি কীভাবে মাপা হবে? তিনটি একক:

একককী গোনে2 PRECEDING মানেসমান মানের সারি (peer)
ROWSআসল সারি, একটি একটি করেঠিক দুটি সারি আগেআলাদা আলাদা — টাই-এর ভেতরের ক্রম অনিশ্চিত
RANGEORDER BY কলামের মানযেসব সারির মান (বর্তমান − 2) থেকে বর্তমান পর্যন্তসবসময় একসাথে ঢোকে বা বেরোয়
GROUPSসমান মানের সারির দলদুটি peer-দল আগেএকসাথে, দল হিসেবে গোনা হয়

টেবিলের শেষ কলামটিই মূল কথা। Window-এর ORDER BY-তে যেসব সারির মান সমান, তাদের বলে peer (সমকক্ষ)। ROWS peer চেনে না; RANGE আর GROUPS চেনে। আর এখানেই SQL-এর সবচেয়ে ভুল-বোঝা নিয়মটি:

Default frame

OVER-এ ORDER BY না থাকলে frame পুরো partition. ORDER BY থাকলে কিন্তু frame না লিখলে frame হলো RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — partition-এর শুরু থেকে বর্তমান সারি এবং তার সব peer পর্যন্ত। (Ranking function আর LAG/LEAD frame মানে না; frame প্রযোজ্য aggregate আর FIRST_VALUE/LAST_VALUE/NTH_VALUE-এ।)

অংশ ৫-এ প্রতিটি দিনে একটিই সারি ছিল, তাই peer ছিল না, আর RANGE ও ROWS একই ফল দিয়েছে। কিন্তু অর্ডারের লাইন ধরে running total করলে একই তারিখে দুটি লাইন থাকে। প্রথম তিনটি অর্ডারের ছয়টি লাইনে দেখুন:

অর্ডার 1001–1003-এর ৬টি লাইনORDER BY order_date
 # │ order_date │ product │ amount
 1 │ 2025-09-01 │ 101     │ 15000   ┐ peer-দল ক
 2 │ 2025-09-01 │ 102     │  2400   ┘
 3 │ 2025-09-03 │ 104     │   650   ┐ peer-দল খ
 4 │ 2025-09-03 │ 105     │  1100   ┘
 5 │ 2025-09-10 │ 108     │  1260   ┐ peer-দল গ
 6 │ 2025-09-10 │ 109     │   520   ┘
লাইন 1 · ROWS UNBOUNDED PRECEDINGসারি ধরে
▶ 1 │ 2025-09-01 │ 15000   ← frame
  2 │ 2025-09-01 │  2400
  3 │ 2025-09-03 │   650
  …

SUM = 15000
লাইন 1 · default (RANGE … CURRENT ROW)peer-সহ
▶ 1 │ 2025-09-01 │ 15000   ┐ frame
  2 │ 2025-09-01 │  2400   ┘ (peer)
  3 │ 2025-09-03 │   650
  …

SUM = 15000 + 2400 = 17400
“এই সারি পর্যন্ত” মানে “এই তারিখ পর্যন্ত”
লাইন 3 · ROWS বনাম RANGEপাশাপাশি
ROWS:  লাইন 1, 2, 3
       15000 + 2400 + 650          = 18050

RANGE: লাইন 1, 2, 3, 4  (4 হলো 3-এর peer)
       15000 + 2400 + 650 + 1100   = 19150

RANGE-এ লাইন 3 আর 4 একই উত্তর পায়
লাইন 5 · GROUPS BETWEEN 1 PRECEDING AND CURRENT ROWদল ধরে
  1 │ 2025-09-01 │ 15000   দল ক — বাইরে
  2 │ 2025-09-01 │  2400
  3 │ 2025-09-03 │   650   ┐ দল খ (১টি দল আগে)
  4 │ 2025-09-03 │  1100   │
▶ 5 │ 2025-09-10 │  1260   │ দল গ (বর্তমান)
  6 │ 2025-09-10 │   520   ┘

SUM = 650 + 1100 + 1260 + 520 = 3530
হিসাব
বর্তমান লাইন
frame-এর একক
frame-এ লাইন
SUM
  1. ছয়টি লাইন, তিনটি peer-দল। প্রতিটি তারিখে দুটি লাইন — ORDER BY order_date-এর চোখে একই তারিখের দুটি লাইন সমান, অর্থাৎ peer.
  2. ROWS: শুধু আসল সারি। লাইন 1-এর frame-এ শুধু লাইন 1 — যোগফল 15000. কিন্তু লাইন 1 আর 2-এর মধ্যে কে আগে, তা ORDER BY ঠিক করেনি; ক্রম উল্টালে ফলও বদলাত।
  3. RANGE (default): peer-সহ। CURRENT ROW মানে “বর্তমান সারি আর তার সব peer”, তাই লাইন 1-এর frame-এ লাইন 2-ও — 17400. ফল এখন টাই-এর ক্রমের ওপর নির্ভর করে না।
  4. পার্থক্য স্পষ্ট। লাইন 3-এ ROWS দেয় 18050, RANGE দেয় 19150 (লাইন 4-সহ)। RANGE-এ লাইন 3 আর 4 দুটিই 19150 — running total সিঁড়ির মতো লাফিয়ে বাড়ে, প্রতি তারিখে একবার।
  5. GROUPS: দল গুনে। “এক দল আগে থেকে বর্তমান দল পর্যন্ত” — লাইন 5-এর জন্য 09-03 আর 09-10-এর চারটি লাইন: 3530. দল ক বাইরে, যদিও সংখ্যায় দুটি “সারি” আগেই দল খ শুরু।
WITH lines AS (
  SELECT o.order_date, i.product_id, i.quantity * i.unit_price AS amount
  FROM order_items i JOIN orders o ON o.order_id = i.order_id
  WHERE o.order_id <= 1003)
SELECT order_date, product_id, amount,
       SUM(amount) OVER (ORDER BY order_date)                        AS dflt_range,
       SUM(amount) OVER (ORDER BY order_date, product_id
                         ROWS UNBOUNDED PRECEDING)                   AS by_rows,
       SUM(amount) OVER (ORDER BY order_date
                         GROUPS BETWEEN 1 PRECEDING AND CURRENT ROW) AS last2_groups
FROM lines ORDER BY order_date, product_id;

ছয়টি সারি। dflt_range: 17400, 17400, 19150, 19150, 20930, 20930 — প্রতিটি তারিখের দুটি লাইনে একই মান। by_rows: 15000, 17400, 18050, 19150, 20410, 20930 — প্রতিটি লাইনে আলাদা, আর যেহেতু ORDER BY-তে product_id যোগ করে টাই ভাঙা হয়েছে, এই ফল নিশ্চিত। last2_groups: 17400, 17400, 19150, 19150, 3530, 3530 — শেষ দুই তারিখের লাইন। খেয়াল করুন, প্রতিটি peer-দলের শেষ লাইনে তিনটি running total একই (17400, 19150, 20930) — পার্থক্য শুধু দলের ভেতরে।

কোনটি কখন? “প্রতিটি লেনদেনের পরের ব্যালান্স” চাইলে ROWS, সাথে অনন্য tiebreaker (লেনদেন নম্বর)। “দিনের শেষের মোট” চাইলে RANGE-এর সিঁড়িই ঠিক। আর RANGE-এ সংখ্যার offset দিয়ে মানের জানালা হয়: RANGE BETWEEN 6 PRECEDING AND CURRENT ROW মানে “ORDER BY-এর মান যেসব সারিতে বর্তমানের চেয়ে সর্বোচ্চ ৬ কম” — ORDER BY julianday(order_date) হলে শেষ ৭ দিন, মাঝে কিছু দিন অর্ডার না থাকলেও। এর জন্য ORDER BY-তে ঠিক একটি সংখ্যা-কলাম লাগে; SQLite-এ লেখা-তারিখে offset দিলে ত্রুটি আসে না, কিন্তু frame-এ শুধু বর্তমান সারি আর তার peer থাকে — ফল নিঃশব্দে ভুল। PostgreSQL-এ DATE কলামে RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW সরাসরি চলে।

আরেকটি কম-জানা অংশ frame exclusion: frame-এর শেষে EXCLUDE CURRENT ROW, EXCLUDE GROUP, EXCLUDE TIES বা EXCLUDE NO OTHERS (default). যেমন AVG(cgpa) OVER (PARTITION BY dept_id ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE CURRENT ROW) — “বিভাগের বাকিদের গড়”, নিজেকে বাদ দিয়ে। PostgreSQL 11 আর SQLite 3.28 থেকে এটি আছে; MySQL আর SQL Server-এ নেই, GROUPS-ও নেই।

৭ · FIRST_VALUE, LAST_VALUE, NTH_VALUE — আর default frame-এর ফাঁদ

তিনটি ফাংশন frame-এর ভেতর থেকে একটি নির্দিষ্ট সারির মান তুলে আনে: FIRST_VALUE(expr) — frame-এর প্রথম সারি; LAST_VALUE(expr) — frame-এর শেষ সারি; NTH_VALUE(expr, n) — frame-এর n-তম সারি। “প্রতিটি শিক্ষার্থীর পাশে বিভাগের সেরা জনের নাম” — FIRST_VALUE দিয়ে সহজ। “বিভাগের সবচেয়ে নিচের জনের নাম” — LAST_VALUE দিয়ে, তাই না? চালিয়ে দেখুন (আবার Ishita Das যোগ করা আছে):

SELECT name, cgpa,
       FIRST_VALUE(name) OVER (ORDER BY cgpa DESC) AS top,
       -- ফাঁদ: default frame শেষ হয় বর্তমান সারিতে (আর তার peer-এ)
       LAST_VALUE(name)  OVER (ORDER BY cgpa DESC) AS last_wrong,
       -- সমাধান: frame পুরো partition পর্যন্ত টেনে দিন
       LAST_VALUE(name)  OVER (ORDER BY cgpa DESC
           ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS last_right,
       NTH_VALUE(name, 2) OVER (ORDER BY cgpa DESC) AS second
FROM students
WHERE dept_id = 1
ORDER BY cgpa DESC;

পাঁচটি সারি। top সবখানে Dilruba Khatun — ঠিক। last_right সবখানে Bristy Saha — ঠিক। কিন্তু last_wrong: Dilruba-র সারিতে Dilruba Khatun, Arif আর Ishita দুজনের সারিতেই Ishita Das, Gulshan-এ Gulshan Ara, Bristy-তে Bristy Saha — প্রায় প্রতিটি সারি নিজেকেই “শেষ” বলছে! কারণ default frame RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: frame শেষ হয় বর্তমান সারিতে, তাই তার শেষ সারি বর্তমান সারিটিই। Arif-এর সারিতে Ishita এসেছে কারণ সে Arif-এর peer (একই 3.75), আর RANGE peer-কে frame-এ নেয়। second কলামও একই কারণে Dilruba-র সারিতে NULL — তখন frame-এ মাত্র একটি সারি, দ্বিতীয়টি নেই; পরের সারিগুলোতে Arif Hossain.

নিয়ম করে নিন: LAST_VALUE বা NTH_VALUE লিখলেই frame স্পষ্ট লিখুন — ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING. অথবা LAST_VALUE এড়িয়ে উল্টো ক্রমে FIRST_VALUE নিন: FIRST_VALUE(name) OVER (ORDER BY cgpa ASC) — default frame সবসময় partition-এর শুরু থেকে ধরে, তাই FIRST_VALUE-এ default frame-ই ঠিক ফল দেয়। আর NULL cgpa থাকলে “শেষ” জন কে, তা আবার NULL-এর সাজানোর নিয়মের ওপর নির্ভর করবে।

WINDOW clause — একই window বারবার না লিখে নাম দিন

ওপরের সমাধানে frame-টি কত লম্বা, দেখেছেন; আর অংশ ৩-এর প্রথম কোয়েরিতে PARTITION BY dept_id ORDER BY cgpa DESC তিনবার লিখতে হয়েছে। লম্বা, আর একটিতে ভুল করে বদলালে বাকিগুলোর সাথে মিলবে না। SQL:2003 standard এর সমাধান দিয়েছে WINDOW clause: কোয়েরির FROM/WHERE/GROUP BY/HAVING-এর পরে, ORDER BY-এর আগে WINDOW w AS (…) লিখে একটি নাম দিন, তারপর OVER w. নামের ওপর আরও কিছু যোগও করা যায়: OVER (w ORDER BY …) — শর্ত হলো, w-এ যা নেই শুধু তা-ই যোগ করা যাবে (w-এ ORDER BY থাকলে আবার ORDER BY নয়)।

উদাহরণ: প্রতিটি পণ্যের দাম তার ক্যাটাগরির মোট দামের কত শতাংশ, আর ক্যাটাগরিতে দামের ক্রম। products টেবিলে product_id, name, category_id, price আর stock; categories-এ category_id আর name.

SELECT c.name AS category, p.name, p.price,
       SUM(p.price) OVER cat                             AS cat_total,
       ROUND(100.0 * p.price / SUM(p.price) OVER cat, 1) AS pct,
       RANK() OVER (cat ORDER BY p.price DESC)           AS rnk
FROM products p JOIN categories c ON c.category_id = p.category_id
WINDOW cat AS (PARTITION BY p.category_id)
ORDER BY c.name, p.price DESC;

দশটি সারি। Books: DSA Book 650 | 1200 | 54.2 | 1, SQL Book 550 | 1200 | 45.8 | 2. Clothing: Saree 67.2%, Panjabi 32.8%. Electronics (মোট 21500): Mobile Phone 69.8 | 1, Smart Watch 16.3 | 2, Power Bank 8.4 | 3, Headphone 5.6 | 4. Grocery (680): Rice 5kg 61.8%, Mustard Oil 1L 38.2%. প্রতিটি ক্যাটাগরির শতাংশের যোগ 100. এখানে cat-এ ORDER BY নেই, তাই SUM পুরো partition-এর মোট দিয়েছে; RANK-এর জন্য (cat ORDER BY p.price DESC) লিখে একই partition-এ ক্রম যোগ করা হয়েছে। WINDOW clause PostgreSQL 8.4, MySQL 8.0 আর SQLite 3.25 থেকে চলে (নামের ওপর ORDER BY যোগ করা SQLite-এ 3.28 থেকে); SQL Server-এ এসেছে 2022 সংস্করণে।

৮ · কখন চলে window function — যৌক্তিক ক্রম আর WHERE-এ ছাঁকার সমস্যা

পর্ব ১৮-এ SELECT-এর যৌক্তিক ক্রম দেখেছেন: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. Window function-এর জায়গা HAVING-এর পরে, SELECT-এর কলাম তৈরির সময় — অর্থাৎ DISTINCT, ORDER BY আর LIMIT-এর আগে। এর দুটি পরিণতি। এক: window function শুধু SELECT-এর তালিকা আর শেষের ORDER BY-তে লেখা যায়, WHERE, GROUP BY বা HAVING-এ নয়। দুই: window যে সারিগুলো দেখে, সেগুলো WHERE আগেই ছেঁকে রেখেছে। নিচে batch 2022-এর শিক্ষার্থীদের ওপর একটি RANK দেখুন:

  1. FROMstudents৮ সারি
  2. WHEREbatch = 2022৩ সারি
  3. GROUP BY · HAVINGএখানে নেইসারি যেমন ছিল
  4. WINDOWRANK() OVER৩ সারির ওপর
  5. SELECT → ORDER BY → LIMITalias r, সাজানোশেষে
FROM students৮টি সারি
SELECT name, cgpa,
       RANK() OVER (ORDER BY cgpa DESC) AS r
FROM students WHERE batch = 2022 ORDER BY r;

 name           │ batch │ cgpa
 Arif Hossain   │ 2021  │ 3.75
 Bristy Saha    │ 2021  │ 3.40
 Chayon Roy     │ 2021  │ 2.95
 Dilruba Khatun │ 2022  │ 3.90
 Emon Mia       │ 2022  │ 3.10
 Fahim Karim    │ 2022  │ NULL
 Gulshan Ara    │ 2023  │ 3.55
 Habib Ullah    │ 2023  │ 2.80
WHERE batch = 2022৩টি সারি থাকে
 Dilruba Khatun │ 2022 │ 3.90
 Emon Mia       │ 2022 │ 3.10
 Fahim Karim    │ 2022 │ NULL

এই মুহূর্তে r বলে কিছু নেই —
WHERE r <= 2 লেখা অসম্ভব
GROUP BY / HAVINGএই কোয়েরিতে নেই
 Dilruba Khatun │ 3.90
 Emon Mia       │ 3.10
 Fahim Karim    │ NULL

থাকলে window চলত দলের ফলের ওপর —
যেমন SUM(SUM(x)) OVER (…)
Window: RANK() OVER (ORDER BY cgpa DESC)শুধু টিকে থাকা ৩ সারি
 name           │ cgpa │ r
 Dilruba Khatun │ 3.90 │ 1
 Emon Mia       │ 3.10 │ 2
 Fahim Karim    │ NULL │ 3

সবার মধ্যে Emon 5ম — কিন্তু বাকিরা
WHERE-এ আগেই বাদ পড়েছে
SELECT → ORDER BY rচূড়ান্ত ফল
 name           │ cgpa │ r
 Dilruba Khatun │ 3.9  │ 1
 Emon Mia       │ 3.1  │ 2
 Fahim Karim    │ NULL │ 3

ORDER BY window-এর পরে চলে,
তাই ORDER BY r চলে
অবস্থা
সারি
r আছে?
  1. FROM — আটটি সারি। কোয়েরিতে window function আছে, কিন্তু সেটি এখনো চলেনি।
  2. WHERE — তিনটি সারি টেকে। Dilruba, Emon আর Fahim. এখানে r-এর কোনো অস্তিত্ব নেই, তাই WHERE-এ r বা RANK() লেখা ত্রুটি।
  3. GROUP BY · HAVING — এখানে কিছু নেই। থাকলে window function চলত দলগুলোর ওপর — অংশ ৫-এর SUM(SUM(…)) OVER-এর মতো।
  4. Window — শুধু টিকে থাকাদের মধ্যে ক্রম। Dilruba 1, Emon 2, Fahim 3 (NULL শেষে)। সব শিক্ষার্থীর মধ্যে Emon আসলে ৫ম, কিন্তু WHERE অন্যদের আগেই সরিয়েছে। “সবার মধ্যে ক্রম, দেখাও শুধু 2022” চাইলে আগে RANK, পরে ছাঁকা — অর্থাৎ CTE.
  5. SELECT → ORDER BY → LIMIT. alias r এখন আছে, আর ORDER BY window-এর পরে চলে বলে ORDER BY r বৈধ।

তাহলে “প্রতিটি বিভাগের সেরা দুজন” কীভাবে? Window-এর ফল একটি CTE বা FROM-এর subquery-তে রাখুন — তখন বাইরের কোয়েরির কাছে r একটি সাধারণ কলাম, যা WHERE-এ চলে। এই কৌশলটি top-N per group সমস্যার মানক সমাধান (পর্ব ২৭-এ আরও রূপ)। এডিটরে ঠিক পথটি আগে, ভুল পথটি শেষে — কারণ ত্রুটি হলে এডিটর সেখানেই থামে:

-- ঠিক পথ: আগে ক্রম, তারপর বাইরে ছাঁকা
WITH ranked AS (
  SELECT dept_id, name, cgpa,
         DENSE_RANK() OVER (PARTITION BY dept_id ORDER BY cgpa DESC) AS r
  FROM students
  WHERE cgpa IS NOT NULL
)
SELECT dept_id, name, cgpa, r FROM ranked
WHERE r <= 2
ORDER BY dept_id, r;

-- ভুল পথ: WHERE-এ window-এর alias
SELECT name, cgpa, RANK() OVER (ORDER BY cgpa DESC) AS r
FROM students WHERE r <= 2;

প্রথম ফল পাঁচটি সারি: NULL | Habib Ullah | 2.8 | 1, 1 | Dilruba Khatun | 3.9 | 1, 1 | Arif Hossain | 3.75 | 2, 2 | Chayon Roy | 2.95 | 1, 3 | Emon Mia | 3.1 | 1. ভেতরের WHERE (cgpa IS NOT NULL) window-এর আগে চলেছে, বাইরের WHERE (r <= 2) পরে — একই কোয়েরিতে দুই স্তরের ছাঁকা। দ্বিতীয় কোয়েরি থামে ত্রুটিতে: misuse of aliased window function r. alias না দিয়ে সরাসরি WHERE RANK() OVER (…) <= 2 লিখলেও ত্রুটি: misuse of window function RANK(). HAVING-এও একই ফল — HAVING-ও window-এর আগে চলে।

কিছু DBMS এই ঝামেলা কমাতে একটি অতিরিক্ত clause এনেছে — QUALIFY, যা window function-এর পরে চলা একটি WHERE. এটি প্রথম আসে Teradata-তে; এখন Snowflake, Google BigQuery, DuckDB আর Databricks-এ আছে। কিন্তু এটি ISO SQL standard-এর অংশ নয়, আর PostgreSQL, MySQL, SQLite বা SQL Server-এ নেই — তাই বহনযোগ্য কোডে CTE-ই নিরাপদ।

SELECT dept_id, name, cgpa
FROM students
WHERE cgpa IS NOT NULL
QUALIFY DENSE_RANK() OVER (PARTITION BY dept_id ORDER BY cgpa DESC) <= 2;

আরও দুটি নিয়ম, যা এই ক্রম থেকেই আসে: window function-এর ভেতরে আরেকটি window function বসানো যায় না (দরকার হলে CTE-তে ধাপে ধাপে)। আর window aggregate-এ DISTINCT চলে না — COUNT(DISTINCT dept_id) OVER () SQLite-এ দেয় DISTINCT is not supported for window functions, PostgreSQL আর SQL Server-এও নিষেধ।

৯ · পারফরম্যান্স, ইতিহাস আর কোন DBMS-এ কী আছে

খরচ কোথায়। ORDER BY বা PARTITION BY থাকলে DBMS সাধারণত প্রথমে সারিগুলো (partition-কলাম, তারপর order-কলাম অনুযায়ী) সাজায় — n সারির জন্য O(n log n). তারপর একবার পড়ে যায়: প্রতিটি partition-এর শুরুতে হিসাব নতুন করে শুরু। ROW_NUMBER, RANK, LAG বা running total এই এক পাসে প্রতি সারিতে O(1) কাজে হয়, কারণ আগের হিসাবের ওপর একটি করে মান যোগ হয় — prefix sum-এর মতো। ফলে পুরো কোয়েরি O(n log n), যেখানে একই running total correlated subquery দিয়ে লিখলে প্রতিটি সারির জন্য আগের সব সারি আবার পড়তে হয়: মোট 1 + 2 + … + n = n(n + 1)/2 সারি-পড়া, অর্থাৎ O(n²).

  • Index সাজানো বাঁচায়। (customer_id, order_date)-এ index থাকলে PARTITION BY customer_id ORDER BY order_date-এর জন্য DBMS index-এর ক্রমেই পড়তে পারে — আলাদা sort লাগে না (পর্ব ৪১)।
  • আলাদা আলাদা window মানে আলাদা sort. একটি কোয়েরিতে ORDER BY cgpa আর ORDER BY batch — দুটি window হলে সাধারণত দুবার সাজাতে হয়। একই PARTITION BY/ORDER BY-ওয়ালা window-গুলো DBMS একটি sort-এ সারে, তাই WINDOW clause দিয়ে মিলিয়ে রাখা কাজের।
  • সরে-চলা frame. ROWS BETWEEN 2 PRECEDING AND CURRENT ROW-এ SUM-এর জন্য নতুন মান যোগ আর পুরোনোটি বিয়োগ করলেই চলে; কিন্তু MIN/MAX-এ “বিয়োগ” হয় না, তাই সেখানে বেশি কাজ লাগে।

আরও গভীরে: PostgreSQL-এর EXPLAIN-এ window দেখায় WindowAgg নামে, যার নিচে সাধারণত একটি Sort. সরে-চলা frame দ্রুত করতে PostgreSQL 9.4 থেকে aggregate-এর একটি “inverse transition” ফাংশন থাকতে পারে (frame থেকে বেরোনো মান বাদ দেওয়ার উপায়), আর SQLite-এর window-ফাংশন API-তেও একই উদ্দেশ্যে xInverse callback আছে। যে aggregate উল্টানো যায় না (MIN, MAX), তার সরে-চলা জানালার জন্য প্রচলিত কৌশল monotonic deque — প্রতি সারিতে গড়ে O(1) (sliding window). আর PostgreSQL 15 থেকে বাইরের WHERE rn <= N দেখে ROW_NUMBER/RANK-এর WindowAgg আগেভাগে থামতে পারে, যদিও ফিল্টারটি CTE-র বাইরে লেখা।

Window function-এর ধারণা বাণিজ্যিক ডেটাবেসে আসে ১৯৯০-এর দশকের শেষে, ডেটা বিশ্লেষণ (OLAP) আর রিপোর্টের চাপে — Oracle 8i-তে “analytic function” নামে, IBM DB2-তে “OLAP function” নামে। ১৯৯৯–২০০০ সালের দিকে এগুলো SQL standard-এর একটি OLAP সংযোজনী হিসেবে প্রস্তাবিত হয়, আর SQL:2003-এ মূল standard-এর অংশ হয়: OVER, PARTITION BY, ROWS/RANGE frame, ROW_NUMBER, RANK, DENSE_RANK, PERCENT_RANK, CUME_DIST আর WINDOW clause. LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE আর GROUPS frame পরে, SQL:2011-এ যোগ হয় — যদিও Oracle-সহ কয়েকটি DBMS-এ এগুলো তার আগে থেকেই ছিল। বিনামূল্যের ডেটাবেসে এসেছে দেরিতে: PostgreSQL 8.4 (২০০৯), SQLite 3.25 (২০১৮; GROUPS আর EXCLUDE 3.28-এ, ২০১৯), MySQL 8.0 (২০১৮), MariaDB 10.2 (২০১৭)। SQL Server 2005-এ ranking আর ORDER BY-বিহীন aggregate OVER আনে, কিন্তু running total-এর frame আর LAG/LEAD আসে SQL Server 2012-এ।

বৈশিষ্ট্যSQLitePostgreSQLMySQLSQL Server
মৌলিক window function (OVER, ranking, LAG/LEAD)3.25+8.4+8.0+2012+ (সম্পূর্ণ)
ROWS আর RANGE frameহ্যাঁহ্যাঁ (RANGE offset 11+)হ্যাঁহ্যাঁ (RANGE-এ শুধু UNBOUNDED/CURRENT ROW)
GROUPS frame, EXCLUDE3.28+11+নানা
NTH_VALUEহ্যাঁহ্যাঁহ্যাঁনা
WINDOW clause3.25+হ্যাঁহ্যাঁ2022+
QUALIFYনানানানা

১০ · AI-এর যুগে window function — চলে, কিন্তু সারি ভুল জায়গায়

“প্রতিটি গ্রাহকের শেষ অর্ডার দাও” বা “মাসিক running total” লিখলে AI চমৎকার দেখতে window কোয়েরি দেয়। কিন্তু window-এর ভুল প্রায় কখনো ত্রুটি দেয় না: টাই-এ এলোমেলো সারি, partition ভুলে গেলে এক গ্রাহকের LAG-এ আরেক গ্রাহকের তারিখ, default frame-এ ভুল LAST_VALUE. সবচেয়ে বেশি যা দেখা যায়:

AI-এর ভুলফলকীভাবে ধরবেন
“দ্বিতীয় সর্বোচ্চ বেতন”-এ ROW_NUMBERটাই থাকলে ভুল লোক বা একই বেতন দুবারটাই-ওয়ালা নমুনা দিয়ে চালান; DENSE_RANK কি না দেখুন
ORDER BY-তে অনন্য tiebreaker নেইপ্রতিবার আলাদা “top-1”ORDER BY-এর কলামগুলো মিলে অনন্য কি না যাচাই
PARTITION BY ভুলে যাওয়াLAG/ROW_NUMBER গ্রাহক পেরিয়ে চলেপ্রতিটি গ্রাহকের প্রথম সারিতে LAG NULL কি না দেখুন
LAST_VALUE বা NTH_VALUE frame ছাড়াবর্তমান সারির মানসব সারিতে একই মান আসছে কি না
ডুপ্লিকেট তারিখে default RANGE-এ running totalএকই দিনের লাইনে একই মোটROWS আর RANGE দুটি পাশাপাশি চালান
WHERE বা HAVING-এ window function; বা DISTINCT windowত্রুটি, অথবা AI ভুল “সমাধান” দেয়CTE-তে মুড়ে বাইরে ছাঁকুন
DESC ক্রমে NULL-এর জায়গা ধরে নেওয়া; QUALIFY বা GROUPS যেখানে নেইPostgreSQL-এ NULL প্রথমে; MySQL-এ ত্রুটিNULLS LAST লিখুন; লক্ষ্য DBMS-এ চালান
লেখা-তারিখ সরাসরি বিয়োগ (SQLite)সব gap 0julianday দিয়ে হিসাব, হাতে একটি মিলিয়ে দেখা

AI-কে window কোয়েরি লেখাতে চাইলে তিনটি জিনিস স্পষ্ট বলুন: partition কী, ক্রম কী (আর টাই হলে কী), frame কী। সাথে যাচাইয়ের কোয়েরিও চেয়ে নিন:

আমার SQLite ডেটাবেসের schema নিচে দিলাম (customers, orders, order_items). তুমি এর বাইরে কোনো কলাম বা টেবিলের নাম বানাবে না। প্রশ্ন: প্রতিটি গ্রাহকের প্রতিটি অর্ডারের পাশে তাঁর আগের অর্ডারের তারিখ, মাঝের দিন, আর গ্রাহকভিত্তিক running total খরচ (SUM(quantity * unit_price)), বাতিল অর্ডার বাদে। নিয়ম: ১. PARTITION BY customer_id লিখবে; ORDER BY order_date, order_id — যাতে একই দিনের দুটি অর্ডারেও ক্রম নিশ্চিত হয়। ২. running total-এ frame স্পষ্ট লিখবে: ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. ৩. দিনের পার্থক্য julianday() দিয়ে; প্রথম অর্ডারে NULL থাকবে, 0 নয়। ৪. window-এর ফল দিয়ে ছাঁকতে হলে CTE ব্যবহার করবে, WHERE-এ window function নয়। ৫. সাথে দুটি যাচাই-কোয়েরি দাও: প্রতিটি গ্রাহকের শেষ running total কি GROUP BY customer_id-এর SUM-এর সমান? প্রতিটি গ্রাহকের প্রথম অর্ডারে আগের তারিখ কি NULL? [এখানে CREATE TABLE]

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

পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:

  • GROUP BY আর window function-এর পার্থক্য কী? — GROUP BY প্রতি দলে একটি সারি দেয়; window function সব সারি রেখে প্রতিটির পাশে হিসাব বসায়।
  • ROW_NUMBER, RANK আর DENSE_RANK-এর পার্থক্য? — টাই-তে: 1-2-3-4, 1-2-2-4, 1-2-2-3.
  • প্রতি বিভাগে দ্বিতীয় সর্বোচ্চ বেতন কীভাবে? — CTE-তে DENSE_RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC), বাইরে WHERE dr = 2.
  • WHERE-এ ROW_NUMBER() কেন চলে না? — window function চলে WHERE, GROUP BY আর HAVING-এর পরে; তাই subquery বা CTE লাগে।
  • ORDER BY দিলে default frame কী? — RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW; তাই LAST_VALUE বর্তমান সারি (বা তার শেষ peer) দেয়।

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

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

প্র ০১MySQL 8.0-এর আগে (২০১৮ পর্যন্ত) লাখো ওয়েবসাইট MySQL-এ চলত, যেখানে window function ছিল না। মানুষ তখন running total বা মেধাক্রম কীভাবে বের করত, আর সেই পদ্ধতিগুলোর সমস্যা কী ছিল?

তিনটি প্রচলিত পথ ছিল। প্রথমটি correlated subquery: প্রতিটি অর্ডারের জন্য (SELECT SUM(total) FROM t AS t2 WHERE t2.order_date <= t.order_date). ফল ঠিক আসে, কিন্তু প্রতিটি সারির জন্য আগের সব সারি আবার পড়তে হয় — n সারিতে প্রায় n²/2 সারি-পড়া। এক লাখ লেনদেনে প্রায় ৫০০ কোটি; ছোট টেস্ট ডেটায় দ্রুত, আসল ডেটায় কয়েক ঘণ্টা। মেধাক্রমও একই রকম: 1 + (আমার চেয়ে বেশি CGPA কতজনের) — এটি আসলে RANK-এর সংজ্ঞা, কিন্তু O(n²) খরচে। দ্বিতীয় পথ self-join আর GROUP BY, যার খরচও একই শ্রেণির।

তৃতীয় পথটি ছিল MySQL-এর নিজস্ব “কৌশল” — user variable: SELECT @rn := @rn + 1 AS rn, name FROM students, (SELECT @rn := 0) x ORDER BY cgpa DESC. এক পাসে চলত, তাই দ্রুত, আর অসংখ্য ব্লগ ও ফোরামে এটি “সমাধান” হিসেবে ছড়িয়েছিল। সমস্যা: MySQL-এর নিজের documentation বলত, একই কোয়েরিতে variable-এ মান বসানো আর পড়ার ক্রম নিশ্চিত নয়। ORDER BY আগে চলবে নাকি variable-এর হিসাব আগে, তা optimizer-এর সিদ্ধান্ত — index বদলালে বা সংস্করণ বদলালে ফল নিঃশব্দে বদলে যেতে পারত। MySQL 8.0 এসে window function দেয়, আর SET ছাড়া অন্য expression-এর ভেতরে user variable-এ মান বসানোকে deprecated ঘোষণা করে।

Window function এই তিনটি সমস্যাই একসাথে মেটায়: অর্থ standard-এ নির্দিষ্ট (কোনো optimizer-নির্ভর আচরণ নেই), খরচ একটি sort আর একটি পাস — O(n log n), আর লেখা প্রশ্নের ভাষার কাছাকাছি। তবে পুরোনো কোডবেসে এখনো সেই subquery আর variable-কৌশল পাওয়া যায়; সেগুলো আধুনিক করা একটি সাধারণ, লাভজনক কাজ।

মূল উপলব্ধি: একটি ফিচারের অভাবে মানুষ হয় ধীর কিন্তু সঠিক, নয়তো দ্রুত কিন্তু অনিশ্চিত পথ নেয়। Window function দুটিই — সঠিক আর দ্রুত — এক সাথে দেয়; আর “চলে” মানেই “নিশ্চিত” নয়, এটি পুরোনো variable-কৌশলের বড় শিক্ষা।

প্র ০২ORDER BY দিলে default frame কেন RANGE … CURRENT ROW, ROWS … নয়? এই নকশার কারণেই তো LAST_VALUE-এর ফাঁদ তৈরি হয়েছে। standard-এর নকশাকারেরা কী ভেবেছিলেন?

মূল কারণ নির্ধারণযোগ্যতা (determinism). ধরুন একই তারিখে দুটি লাইন, 15000 আর 2400. ROWS frame-এ প্রথম লাইনের running total কোনটি — 15000, না 2400? তা নির্ভর করে DBMS দুটি লাইনকে কোন ক্রমে পড়েছে তার ওপর, যা ORDER BY ঠিক করেনি। অর্থাৎ ROWS default হলে একই ডেটা, একই কোয়েরি — ভিন্ন সংস্করণে বা ভিন্ন plan-এ ভিন্ন উত্তর। RANGE frame-এ peer-রা একসাথে ঢোকে, তাই দুটি লাইনই পায় 17400 — উত্তর শুধু ডেটার ওপর নির্ভর করে, পড়ার ক্রমের ওপর নয়। Standard-এর একটি সাধারণ নীতি হলো, কোয়েরির ফল যতটা সম্ভব ক্রম-নিরপেক্ষ রাখা, আর এই default সেই নীতির ফল।

নকশাটির আরেকটি সৌন্দর্য আছে। ORDER BY না দিলে frame পুরো partition — এটি আলাদা নিয়ম মনে হয়, কিন্তু আসলে একই নিয়ম। ORDER BY না থাকলে সব সারি পরস্পরের peer (কারও ক্রম আলাদা নয়), তাই “শুরু থেকে বর্তমান সারি আর তার সব peer পর্যন্ত” মানে পুরো partition. একটি নিয়মেই দুই আচরণ — অংশ ১-এর বিভাগের গড় আর অংশ ৫-এর running total.

দাম দিতে হয়েছে দুই জায়গায়। এক, LAST_VALUE আর NTH_VALUE-এর ফাঁদ — frame বর্তমান সারিতে থামে বলে “শেষ” মানে প্রায় নিজেই। এটি এত পরিচিত অভিযোগ যে প্রায় প্রতিটি DBMS-এর documentation আলাদা করে সতর্ক করে। দুই, কিছু DBMS-এ RANGE ধীর: peer খোঁজার জন্য বাড়তি তুলনা লাগে, আর SQL Server-এ দেখা গেছে, running total-এ ROWS লিখলে RANGE-এর চেয়ে অনেক দ্রুত চলে, কারণ ROWS-এর জন্য ভেতরের কাজের টেবিল মেমরিতে রাখা যায়। তাই অনেক দলের নিয়ম: frame সবসময় স্পষ্ট লিখুন, আর টাই ভাঙতে ORDER BY-তে অনন্য কলাম দিন — তখন ROWS-ও নির্ধারণযোগ্য।

মূল উপলব্ধি: default frame ভুল নয় — এটি নির্ধারণযোগ্যতাকে সুবিধার ওপরে রেখেছে। default-এর ওপর ভরসা না করে frame নিজে লিখলে দুই জগতের সুবিধাই পাওয়া যায়।

প্র ০৩“প্রতি বিভাগে সেরা দুজন” লিখতে কেন CTE লাগবে — DBMS কি WHERE-এর শর্তটি একটু পরে চালাতে পারত না? QUALIFY-এর মতো সমাধান standard-এ নেই কেন?

WHERE-এর অর্থ standard-এ স্থির: এটি FROM-এর প্রতিটি সারির ওপর চলে, দলে ভাগ আর window-এর আগে। এই ক্রম শুধু নিয়ম নয়, অর্থের অংশ। অংশ ৮-এর ডায়াগ্রামে দেখেছেন, WHERE batch = 2022 আগে চললে Emon-এর RANK 2, পরে চললে 5 — দুটিই বৈধ প্রশ্ন, আলাদা উত্তর। WHERE-কে কখনো আগে, কখনো পরে চালালে একই কোয়েরির অর্থ শর্তের ধরনের ওপর নির্ভর করত: WHERE batch = 2022 AND r <= 2-এ কোন অংশ কখন? এমন অস্পষ্টতা ভাষায় রাখা বিপজ্জনক। তাই SQL একই কারণে HAVING আলাদা করেছিল (দলের পরের শর্ত), আর window-এর পরের শর্তের জন্য standard-এর উত্তর: আরেকটি query স্তর — derived table বা CTE.

Teradata এর একটি সংক্ষিপ্ত রূপ বানিয়েছিল: QUALIFY — window-এর পরে চলা আলাদা clause, যেমন HAVING হলো GROUP BY-এর পরের। এতে অস্পষ্টতা নেই, কারণ clause-টি আলাদা। বিশ্লেষণধর্মী (analytics) ডেটাবেসগুলো — Snowflake, BigQuery, DuckDB, Databricks — এটি গ্রহণ করেছে, কারণ তাদের ব্যবহারকারীরা দিনে বহুবার top-N আর deduplicate লেখেন। কিন্তু ISO standard কমিটি এখনো এটি যোগ করেনি, আর PostgreSQL, MySQL, SQLite, SQL Server-এ এটি নেই (এই লেখার সময় পর্যন্ত)। একটি কারণ হলো, CTE ইতিমধ্যে সমান শক্তিশালী আর বহনযোগ্য; নতুন keyword যোগ করা মানে প্রতিটি DBMS-এর parser আর optimizer-এ কাজ।

পারফরম্যান্সের দিক থেকে CTE আর QUALIFY-এর পার্থক্য সাধারণত সামান্য — optimizer বাইরের শর্তটি ভেতরের window-এর সাথে মিলিয়ে দেখতে পারে। যেমন PostgreSQL 15 থেকে WHERE rn <= 2 দেখে প্রতিটি partition-এ দুটি সারির পরে ROW_NUMBER-এর হিসাব থামাতে পারে। আর PARTITION BY-এর কলামে বাইরের শর্ত (WHERE dept_id = 1) থাকলে অনেক optimizer সেটি window-এর নিচে ঠেলে দেয়, কারণ তাতে অর্থ বদলায় না — এক বিভাগের partition অন্য বিভাগের সারির ওপর নির্ভর করে না।

মূল উপলব্ধি: SQL-এর প্রতিটি clause একটি নির্দিষ্ট সময়ে চলে, আর সেই সময়ই তার অর্থ। window-এর পরের শর্তের জন্য একটি নতুন স্তর (CTE) লাগে — এটি দুর্বলতা নয়, স্পষ্টতার দাম।

প্র ০৪চাকরির মেধাতালিকা, ক্রিকেটের র‍্যাংকিং বা পরীক্ষার ফল — বাস্তবে টাই সামলানোর কোন নিয়ম কোন SQL ফাংশনের সাথে মেলে? আর ROW_NUMBER কোথায় বিপজ্জনক?

গণিতে টাই সামলানোর কয়েকটি প্রচলিত রীতি আছে। “1224” রীতি (standard competition ranking) খেলাধুলায় সবচেয়ে পরিচিত: দুজন যুগ্মভাবে দ্বিতীয় হলে পরের জন চতুর্থ। অলিম্পিকে স্বর্ণপদকে টাই হলে সাধারণত রৌপ্যপদক দেওয়া হয় না — একই যুক্তি। SQL-এ এটি RANK. “1223” রীতি (dense ranking) — পরের জন তৃতীয় — তখন উপযোগী, যখন প্রশ্নটি “কততম আলাদা মান”: দ্বিতীয় সর্বোচ্চ বেতন, তৃতীয় সেরা স্কোর। SQL-এ DENSE_RANK; ইন্টারভিউর পরিচিত “Nth highest salary” প্রশ্নের সঠিক উত্তর এটিই, কারণ ROW_NUMBER দিলে দুজনের সমান সর্বোচ্চ বেতন থাকলে “দ্বিতীয়” আসলে প্রথমের সমান হবে।

চাকরি বা ভর্তির মেধাতালিকায় সাধারণত টাই রাখা হয় না — একটি টাই-ভাঙার নিয়ম আগে থেকে ঘোষিত থাকে, যেমন লিখিত অংশের নম্বর, বা নির্দিষ্ট বিষয়ের নম্বর, বা বয়স। (প্রতিটি প্রতিষ্ঠানের নিয়ম আলাদা, তাই বিজ্ঞপ্তিই চূড়ান্ত।) SQL-এ এর সঠিক রূপ হলো সেই নিয়মগুলো ORDER BY-তে পরপর লেখা: ROW_NUMBER() OVER (ORDER BY total DESC, written DESC, birth_date DESC, roll) — শেষে একটি অনন্য কলাম, যাতে কোনো অবস্থাতেই অনিশ্চয়তা না থাকে। এখানে ROW_NUMBER ঠিক, কারণ ORDER BY নিজেই সব টাই ভেঙেছে।

ROW_NUMBER বিপজ্জনক তখন, যখন ORDER BY অনন্য নয়। “প্রতিটি গ্রাহকের শেষ অর্ডার” — একই দিনে দুটি অর্ডার থাকলে কোনটি “শেষ”, তা প্রতিবার আলাদা হতে পারে; রিপোর্ট আজ এক, কাল আরেক। ডুপ্লিকেট সারি মোছার সময় (পর্ব ২৭) এটি আরও গুরুতর — ভুল কপিটি মুছে যেতে পারে। পাতায় পাতায় ফল দেখানোতেও একই সমস্যা: অনির্দিষ্ট ক্রমে একটি সারি দুই পাতায় আসতে পারে, আরেকটি কোনোটিতেই না। আর NTILE দিয়ে quartile বানালে টাই-এর দুজন আলাদা ভাগে যেতে পারেন, অংশ ৩-এ যেমন দেখেছেন — বেতন বা বৃত্তির সীমা ঠিক করতে সেটি অন্যায্য হতে পারে, তখন PERCENT_RANK বা CUME_DIST-এর সীমা নিন।

মূল উপলব্ধি: ranking ফাংশন বাছাই আসলে একটি নীতিগত সিদ্ধান্ত — টাই হলে কী ন্যায্য। নীতিটি আগে লিখে নিন, তারপর ফাংশন আর ORDER BY সেই নীতি অনুযায়ী বাছুন।

প্র ০৫মোবাইল ব্যাংকিং বা ব্যাংকের স্টেটমেন্টে প্রতিটি লেনদেনের পাশে “ব্যালান্স” থাকে। সেটি কি প্রতিবার SUM(amount) OVER (ORDER BY …) দিয়ে হিসাব করা উচিত, নাকি একটি balance কলামে জমা রাখা উচিত?

দুই পথেরই পক্ষে শক্ত যুক্তি আছে, আর বাস্তবের বড় সিস্টেম সাধারণত দুটি মিলিয়ে নেয়। প্রতিবার হিসাব করার সুবিধা হলো সত্যের উৎস একটিই — লেনদেনের ledger. ব্যালান্স কখনো ledger-এর সাথে অমিল হতে পারে না, কারণ সেটি ledger থেকেই আসে। কিন্তু একজন গ্রাহকের দশ বছরের কয়েক হাজার লেনদেন থাকলে প্রতিটি স্টেটমেন্টে সব সারি সাজিয়ে যোগ করতে হয়; আর লাখো গ্রাহক একসাথে অ্যাপ খুললে সেই খরচ বিশাল। শুধু শেষ ব্যালান্স জানতে পুরো ইতিহাস পড়া অপচয়।

balance কলাম রাখলে পড়া তাৎক্ষণিক, কিন্তু লেখা জটিল হয়। দুটি লেনদেন একই সময়ে এলে — ধরুন একটি Cash In আর একটি বিল পরিশোধ — দুটিই যদি পুরোনো ব্যালান্স পড়ে নিজের হিসাব লেখে, একটির পরিবর্তন হারিয়ে যায়: lost update (পর্ব ৪৪ আর ৪৭)। তাই প্রতিটি লেনদেনকে row lock বা উপযুক্ত isolation দিয়ে একটি transaction-এ ব্যালান্স হালনাগাদ করতে হয়। আর কোনো bug-এ একবার ব্যালান্স ভুল হলে, সেই ভুল পরের সব লেনদেনে বয়ে চলে।

প্রচলিত মিশ্র নকশা: ledger কখনো বদলানো হয় না (শুধু নতুন সারি যোগ), প্রতিটি অ্যাকাউন্টের বর্তমান ব্যালান্স একটি আলাদা টেবিলে transaction-এর ভেতরে হালনাগাদ হয়, আর নিয়মিত (যেমন প্রতিদিন) একটি reconciliation কাজ ledger থেকে window function দিয়ে ব্যালান্স আবার হিসাব করে জমা ব্যালান্সের সাথে মেলায় — অমিল হলেই সতর্কবার্তা। স্টেটমেন্টের প্রতিটি লাইনের ব্যালান্স দেখাতে অনেক সিস্টেম মাস-শেষের জমা ব্যালান্স (snapshot) থেকে শুরু করে শুধু সেই মাসের লেনদেনে running total চালায় — পুরো ইতিহাস নয়। আর এখানে ROWS frame, লেনদেন নম্বরকে tiebreaker করে, বাধ্যতামূলক: একই সেকেন্ডের দুটি লেনদেনে RANGE দুটিকেই একই ব্যালান্স দেখাবে, যা গ্রাহকের কাছে বিভ্রান্তিকর।

মূল উপলব্ধি: জমা রাখা মান দ্রুত, হিসাব করা মান নির্ভরযোগ্য। window function শুধু রিপোর্টের হাতিয়ার নয় — জমা মান সঠিক আছে কি না, তা যাচাইয়েরও হাতিয়ার।

অনুশীলন

দুটি কোয়েরি লেখা আর দুটি হাতে হিসাব — যেকোনো এডিটরে চালিয়ে মিলিয়ে নিন।

  1. কোয়েরি লিখুন: db-shop থেকে প্রতিটি গ্রাহকের সবচেয়ে সাম্প্রতিক অর্ডারটি (order_id আর order_date) বের করুন — প্রতি গ্রাহকে ঠিক একটি সারি। কয়টি সারি আসবে, আর Tanvir (5) কেন নেই?
    WITH r AS (
      SELECT customer_id, order_id, order_date,
             ROW_NUMBER() OVER (PARTITION BY customer_id
                                ORDER BY order_date DESC, order_id DESC) AS rn
      FROM orders)
    SELECT customer_id, order_id, order_date
    FROM r WHERE rn = 1 ORDER BY customer_id;

    সাতটি সারি: 1 | 1009 | 2025-10-08, 2 | 1007 | 2025-10-01, 3 | 1004 | 2025-09-12, 4 | 1005, 6 | 1006, 7 | 1010, 8 | 1008. Tanvir-এর কোনো অর্ডার নেই, তাই orders-এ তাঁর কোনো সারিই নেই — দরকার হলে customers থেকে LEFT JOIN (পর্ব ১৯)। order_id DESC tiebreaker হিসেবে দেওয়া হয়েছে: একই দিনে দুটি অর্ডার থাকলেও ফল নিশ্চিত। এখানে ROW_NUMBER ঠিক, কারণ প্রশ্ন “ঠিক একটি”; “শেষ দিনের সব অর্ডার” চাইলে RANK.

  2. ফল আগে বলুন (trace): একটি টেবিল t(d, v)-এ চারটি সারি: (1, 10), (2, 20), (2, 30), (3, 40). প্রতিটি সারির জন্য হাতে বের করুন: SUM(v) OVER (ORDER BY d), SUM(v) OVER (ORDER BY d, v ROWS UNBOUNDED PRECEDING), COUNT(*) OVER (ORDER BY d), RANK() OVER (ORDER BY d), DENSE_RANK() OVER (ORDER BY d) আর SUM(v) OVER ().

    d = 2-এর দুটি সারি peer. Default frame (RANGE) peer-সহ নেয়, ROWS নেয় না।

    • (1, 10): 10, 10, 1, 1, 1, 100
    • (2, 20): 60, 30, 3, 2, 2, 100 — RANGE-এ peer (2, 30)-সহ 10 + 20 + 30; COUNT-ও peer গোনে, তাই 3
    • (2, 30): 60, 60, 3, 2, 2, 100
    • (3, 40): 100, 100, 4, 4, 3, 100 — আগের তিনটি সারি থেকে RANK 4, দুটি আলাদা মান থেকে DENSE_RANK 3

    শেষ কলাম সবখানে 100, কারণ OVER ()-এ ORDER BY নেই, frame পুরো ফল। যাচাই করতে যেকোনো এডিটরের শুরুতে CREATE TABLE t(d, v); INSERT INTO t VALUES (1,10),(2,20),(2,30),(3,40); লিখে কোয়েরিটি চালান।

  3. কোয়েরি লিখুন: db-uni-র instructors টেবিল (instructor_id, name, dept_id, salary, mentor_id) থেকে প্রতিটি বিভাগের দ্বিতীয় সর্বোচ্চ বেতনের শিক্ষক। কোন বিভাগ ফলে আসবে না, কেন?
    WITH r AS (
      SELECT dept_id, name, salary,
             DENSE_RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS dr
      FROM instructors)
    SELECT dept_id, name, salary
    FROM r WHERE dr = 2 ORDER BY dept_id;

    দুটি সারি: 1 | Shahana Parvin | 85000 (CSE-তে 120000, 85000, 65000) আর 2 | Rumana Islam | 70000. BBA-তে একজনই শিক্ষক (Kamrul Hasan), তাই dr = 2 কেউ নেই; English বিভাগে কোনো শিক্ষকই নেই। DENSE_RANK কেন: CSE-তে দুজনের বেতন 120000 হলে RANK দিয়ে দ্বিতীয় অবস্থানে কেউ থাকত না (1, 1, 3), আর ROW_NUMBER দিলে “দ্বিতীয়” হতেন সর্বোচ্চ বেতনের আরেকজন।

  4. ফল আগে বলুন (trace): db-uni-র students টেবিলে (অংশ ১-এর আটজন) দুটি কোয়েরি: (ক) SELECT name, RANK() OVER (ORDER BY cgpa DESC) AS g FROM students WHERE batch = 2022; (খ) একই RANK একটি CTE-তে সব শিক্ষার্থীর ওপর, তারপর বাইরে WHERE batch = 2022. দুটিতে Emon Mia আর Fahim Karim-এর g কত?

    (ক)-তে WHERE আগে চলে, window দেখে শুধু Dilruba (3.90), Emon (3.10) আর Fahim (NULL): g = 1, 2, 3. (খ)-তে window আগে, সব আটজনের ক্রমে: Dilruba 1, Arif 2, Gulshan 3, Bristy 4, Emon 5, Chayon 6, Habib 7, Fahim 8 (SQLite-এ বড় থেকে ছোট সাজালে NULL শেষে); তারপর ছাঁকায় Dilruba 1, Emon 5, Fahim 8. দুটি প্রশ্নই বৈধ — “ব্যাচের ভেতরে অবস্থান” বনাম “সবার মধ্যে অবস্থান” — কিন্তু উত্তর আলাদা। PostgreSQL-এ (খ)-তে Fahim হতেন 1, কারণ সেখানে DESC-এ NULL আগে আসে।

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

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

ক্রমwindow function-সহ একটি SELECT কোয়েরির অংশগুলো DBMS যে যৌক্তিক ক্রমে মূল্যায়ন করে, সেভাবে সাজান (▲▼ চেপে সরান)

  1. FROM আর JOIN — কোন টেবিলের সারি
  2. WHERE — সারি ধরে ছাঁকা
  3. GROUP BY আর HAVING — দলে ভাগ আর দল ছাঁকা
  4. Window function — OVER (…) হিসাব
  5. ORDER BY — ফল সাজানো
  6. LIMIT — প্রথম কয়েকটি সারি রাখা

students(name, dept_id, cgpa) টেবিলে ৮টি সারি আর ৪টি আলাদা dept_id. SELECT name, AVG(cgpa) OVER (PARTITION BY dept_id) FROM students; কয়টি সারি ফেরত দেবে?

  • ৪টি
  • ১টি
  • ৮টি
  • ত্রুটি — GROUP BY লাগবে

চারজনের নম্বর বড় থেকে ছোট: 90, 85, 85, 80. ORDER BY marks DESC দিয়ে 80 পাওয়া শিক্ষার্থীর RANK() আর DENSE_RANK() কত?

  • RANK 4, DENSE_RANK 4
  • RANK 4, DENSE_RANK 3
  • RANK 3, DENSE_RANK 3
  • RANK 3, DENSE_RANK 4

OVER (…)-এর ভেতরে ORDER BY আছে কিন্তু কোনো frame লেখা নেই। SQL standard অনুযায়ী frame কোনটি?

  • RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  • ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  • ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
  • ROWS BETWEEN CURRENT ROW AND CURRENT ROW

scores(name, score) টেবিলে সবার score আলাদা। SELECT name, LAST_VALUE(name) OVER (ORDER BY score) FROM scores; দ্বিতীয় কলামে কী আসবে?

  • প্রতিটি সারিতে সবচেয়ে বেশি score-এর নাম
  • প্রতিটি সারিতে সবচেয়ে কম score-এর নাম
  • ত্রুটি — frame বাধ্যতামূলক
  • প্রতিটি সারিতে সেই সারির নিজের নাম

employees(name, dept, salary) টেবিল থেকে প্রতি বিভাগে সর্বোচ্চ বেতনের একজনকে বের করতে কেউ লিখলেন SELECT name FROM employees WHERE ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) = 1; কী হবে?

  • প্রতি বিভাগে একজনের নাম আসবে
  • ত্রুটি; WHERE-এর জায়গায় HAVING লিখলে ঠিক হবে
  • ত্রুটি; ROW_NUMBER-কে CTE বা subquery-তে নিয়ে বাইরের WHERE-এ ছাঁকতে হবে
  • ত্রুটি; GROUP BY dept যোগ করলে ঠিক হবে

sales(day, amount)-এ day অনুযায়ী তিনটি সারি: amount 100, 200, 600. AVG(amount) OVER (ORDER BY day ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) দ্বিতীয় সারিতে কত দেবে?

  • 100 (300 ÷ 3)
  • 150
  • 300
  • NULL

শব্দকোষ

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

মনে রাখুন

  1. Window function = OVER-ওয়ালা ফাংশন; সারি গোটায় না, প্রতিটি সারির পাশে partition-এর হিসাব বসায়।
  2. PARTITION BY কারা একসাথে, ORDER BY ভেতরের ক্রম, frame কতটুকু জানালা; ভেতরের ORDER BY ফল সাজায় না।
  3. টাই-তে ROW_NUMBER 1-2-3-4 (অনিশ্চিত — tiebreaker দিন), RANK 1-2-2-4, DENSE_RANK 1-2-2-3; LAG/LEAD আগের-পরের সারি, প্রথম সারিতে NULL.
  4. ORDER BY দিলে default frame RANGE … UNBOUNDED PRECEDING AND CURRENT ROW — peer একসাথে; LAST_VALUE আর moving average-এ frame নিজে লিখুন।
  5. Window চলে WHERE/GROUP BY/HAVING-এর পরে, ORDER BY/LIMIT-এর আগে; তার ফল দিয়ে ছাঁকতে CTE. খরচ মূলত একটি sort — O(n log n).

পরের পর্বে: এই পর্বে বারবার “top-N per group” আর ডুপ্লিকেট বাছাইয়ের কথা এসেছে। পর্ব ২৭-এ SQL-এর প্রচলিত প্যাটার্ন — top-N, pivot, deduplicate আর gaps-and-islands — যেখানে window function-ই প্রধান হাতিয়ার।

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

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