এই পর্বে যা শিখবেন
- প্রতিটি দলের সেরা N-টি সারি আর সর্বশেষ সারি — তিন রকম সমাধান আর সমান মানের ঝামেলা
- Pivot (সারি থেকে কলাম) আর unpivot (কলাম থেকে সারি), আর কেন “যত কলাম দরকার” SQL নিজে ঠিক করতে পারে না
- অগোছালো import টেবিল থেকে duplicate খোঁজা, একটি রেখে বাকিগুলো মোছা, আর ভবিষ্যতে ঠেকানো
- Gaps and islands, ওয়ালেটের running balance, প্রথমবারের গ্রাহক, median আর percentile
- Relational division — “সব” প্রশ্নের দুই সমাধান — আর প্রতিটি প্যাটার্নে AI-এর লেখা কোয়েরি কীভাবে যাচাই করবেন
১ · প্যাটার্ন কেন — একই প্রশ্ন বারবার ফিরে আসে
পুরান ঢাকার একজন অভিজ্ঞ বাবুর্চি প্রতিদিন নতুন রান্না আবিষ্কার করেন না। কাচ্চি, রেজালা, বোরহানি — প্রতিটির একটি পরীক্ষিত পদ্ধতি তাঁর মাথায় আছে। নতুন অনুষ্ঠানে শুধু পরিমাণ আর মসলা একটু বদলান। SQL-এর কাজও অনেকটা এমন। দোকান, ব্যাংক, বিশ্ববিদ্যালয় বা হাসপাতাল — যেখানেই কাজ করুন, ঘুরেফিরে একই ধরনের প্রশ্ন আসে। প্রতিবার শূন্য থেকে ভাবলে সময় যায়, আর ভুলও হয় একই জায়গায়।
এই পর্বটি একটি রান্নার বইয়ের মতো। এখানে দশটি query pattern আছে — অর্থাৎ বারবার আসা একটি প্রশ্নের পরীক্ষিত সমাধান-কাঠামো। প্রতিটি প্যাটার্ন চার ধাপে দেখবেন: (১) ব্যবসার প্রশ্নটি কী, (২) প্রথমে মাথায় আসা সরল চেষ্টা, যা প্রায়ই ভুল, (৩) সঠিক প্যাটার্ন, আর (৪) বিকল্প পথ ও তাদের সুবিধা-অসুবিধা। আগের পর্বগুলোর তিনটি হাতিয়ার এখানে একসাথে কাজ করবে: পর্ব ২১-এর subquery, পর্ব ২৫-এর CTE আর পর্ব ২৬-এর window function.
| প্যাটার্ন | ব্যবসার প্রশ্ন | মূল হাতিয়ার |
|---|---|---|
| Top-N per group | প্রতি শ্রেণির সবচেয়ে বেশি আয়ের ২টি পণ্য | ROW_NUMBER / RANK + CTE |
| সর্বশেষ সারি | প্রতিটি গ্রাহকের শেষ অর্ডার | ROW_NUMBER, NOT EXISTS |
| Pivot / unpivot | মাসভিত্তিক অর্ডারের অবস্থা কলামে কলামে | SUM(CASE …), FILTER, UNION ALL |
| Deduplicate | import করা তালিকায় একই গ্রাহক দুবার | GROUP BY … HAVING, ROW_NUMBER, UNIQUE |
| Upsert | নতুন হলে যোগ, পুরোনো হলে হালনাগাদ | ON CONFLICT, MERGE |
| Gaps and islands | টানা কত দিন লগইন; কোন চালান নম্বর হারিয়েছে | ROW_NUMBER, LEAD |
| Running balance | ওয়ালেটের প্রতিটি লেনদেনের পর ব্যালান্স | SUM() OVER + ROWS frame |
| প্রথমবারের গ্রাহক | প্রতি মাসে নতুন গ্রাহক কতজন | MIN + running SUM |
| Median / percentile | সাধারণ একটি অর্ডারের মূল্য কত | ROW_NUMBER, COUNT() OVER, CUME_DIST |
| Relational division | CSE-র সব মূল কোর্স নিয়েছেন কারা | দুই স্তরের NOT EXISTS, COUNT |
লাইভ SQL চলে আগের পর্বগুলোর দুটি নমুনা ডেটাবেসে। db-shop একটি অনলাইন দোকান: customers (৮ জন), categories (1 Electronics, 2 Books, 3 Clothing, 4 Grocery), products (product_id, name, category_id, price, stock — ১০টি), orders (order_id, customer_id, order_date, status — ১০টি; status হলো pending, shipped, delivered বা cancelled) আর order_items (order_id, product_id, quantity, unit_price). db-uni একটি বিশ্ববিদ্যালয়: departments, instructors, students (student_id, name, dept_id, batch, cgpa), courses (course_id, title, dept_id, credits) আর enrollments (student_id, course_id, term, grade). কিছু প্যাটার্নের জন্য ছোট আলাদা টেবিল লাগবে — messy_import, logins, wallet_ledger — সেগুলো যেখানে আসবে, সেখানেই বর্ণনা পাবেন। প্রতিটি এডিটরের নিচের “নমুনা ডেটা” বাক্সে পুরো টেবিল দেখা যায়, আর প্রতিবার চালালে ডেটাবেস নতুন করে তৈরি হয়।
২ · Top-N per group — প্রতি শ্রেণির সেরা ২টি পণ্য
ব্যবসার প্রশ্ন: দোকানের মালিক জানতে চান, প্রতিটি শ্রেণিতে (category) কোন ২টি পণ্য সবচেয়ে বেশি টাকা এনেছে — বাতিল অর্ডার বাদ দিয়ে। এটিই বিখ্যাত top-N per group সমস্যা। ক্রিকেটের ভাষায়: “প্রতিটি দলের সর্বোচ্চ রান করা ২ জন ব্যাটসম্যান”। পুরো টুর্নামেন্টের সেরা ২ জন নয়, প্রতিটি দলের আলাদা সেরা ২ জন।
সরল চেষ্টা: প্রথমে প্রতিটি পণ্যের আয় হিসাব করে ORDER BY revenue DESC LIMIT 2. এটি চলে, কিন্তু উত্তর দেয় পুরো দোকানের সেরা ২টি: Mobile Phone (29500) আর Smart Watch (7000) — দুটিই Electronics. Books, Clothing, Grocery উধাও। LIMIT পুরো ফলের ওপর একবার কাজ করে, দলের ভেতরে নয়। আরেকটি চেষ্টা হলো WHERE-এ সরাসরি window function লেখা:
SELECT name FROM products
WHERE ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY price DESC) <= 2;
-- SQLite: misuse of window function ROW_NUMBER()ত্রুটির কারণ পর্ব ১৫-এর logical processing order: WHERE চলে আগে, window function হিসাব হয় SELECT-এর সময়, অনেক পরে। WHERE যখন চলছে, তখন rn-এর কোনো অস্তিত্বই নেই। সমাধান তাই দুই ধাপ: ভেতরের ধাপে নম্বর বসানো, বাইরের ধাপে ছাঁকা। CTE এই দুই ধাপকে নাম দিয়ে পরিষ্কার করে।
WITH rev AS ( -- ধাপ ১: প্রতিটি পণ্যের আয়
SELECT p.category_id, p.name, SUM(i.quantity * i.unit_price) AS revenue
FROM products p
JOIN order_items i ON i.product_id = p.product_id
JOIN orders o ON o.order_id = i.order_id
WHERE o.status <> 'cancelled'
GROUP BY p.product_id, p.category_id, p.name
), ranked AS ( -- ধাপ ২: শ্রেণির ভেতরে ক্রমিক নম্বর
SELECT category_id, name, revenue,
ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY revenue DESC) AS rn
FROM rev
)
SELECT category_id, name, revenue, rn -- ধাপ ৩: ছাঁকা
FROM ranked WHERE rn <= 2
ORDER BY category_id, rn;ফলে ৭টি সারি: 1 | Mobile Phone | 29500 | 1, 1 | Smart Watch | 7000 | 2, 2 | SQL Book | 1650 | 1, 2 | DSA Book | 1300 | 2, 3 | Panjabi | 4400 | 1, 4 | Rice 5kg | 3360 | 1, 4 | Mustard Oil 1L | 1560 | 2. Electronics-এর Headphone (3550) তৃতীয়, তাই বাদ। Clothing-এ একটিই সারি, কারণ Saree-র একমাত্র অর্ডার (1004) বাতিল — “প্রতি দলে ২টি” মানে “সর্বোচ্চ ২টি”। Power Bank কখনো বিক্রি হয়নি, তাই inner JOIN তাকে ধাপ ১-এই বাদ দিয়েছে। শূন্য আয়ের পণ্যও দেখাতে চাইলে products থেকে LEFT JOIN আর COALESCE(…, 0) লাগত।
ROW_NUMBER ভেতরে ঠিক কী করছে, ধাপে ধাপে দেখুন:
| দল (partition) | |
| সারি | |
| cat 1-এর rn |
- ধাপ ১-এর ফল। rev CTE প্রতিটি বিক্রি হওয়া পণ্যের মোট আয় দেয় — ৮টি সারি, কোনো বিশেষ ক্রমে নয়। Saree আর Power Bank নেই: একটির অর্ডার বাতিল, অন্যটি কখনো বিক্রি হয়নি।
- PARTITION BY category_id. Window function সারিগুলোকে শ্রেণি অনুযায়ী চারটি আলাদা দলে ভাগ করে। GROUP BY-এর মতো সারি এক হয়ে যায় না — প্রতিটি সারি নিজের জায়গায় থাকে, শুধু জানে সে কোন দলের।
- ORDER BY revenue DESC. প্রতিটি দলের ভেতরে আয় বেশি থেকে কম। cat 1-এ Mobile Phone (29500), Smart Watch (7000), Headphone (3550).
- ROW_NUMBER(). প্রতিটি দলে 1 থেকে নতুন করে গোনা শুরু। cat 1 পায় 1, 2, 3; cat 3-এর একমাত্র সারি পায় 1. এখন প্রতিটি সারির পাশে একটি rn কলাম আছে।
- WHERE rn <= 2. বাইরের কোয়েরিতে rn এখন একটি সাধারণ কলাম, তাই WHERE তাকে দেখতে পায়। শুধু Headphone (rn 3) বাদ পড়ে।
- ফল: ৭টি সারি। প্রতিটি শ্রেণি থেকে সর্বোচ্চ ২টি। N বদলাতে শুধু
2সংখ্যাটি বদলান; “সেরা ১টি” চাইলেrn = 1— যা পরের অংশের প্যাটার্ন।
সমান মান (tie) থাকলে কী হবে?
এবার প্রশ্নটি একটু বদলান: আয় নয়, বিক্রির সংখ্যা (কয়টি ইউনিট) দিয়ে সেরা ২টি। Electronics-এ Headphone ৩টি, আর Mobile Phone ও Smart Watch দুটিই ২টি করে বিক্রি হয়েছে। দ্বিতীয় স্থান কার? ROW_NUMBER এই প্রশ্নের উত্তর জানে না, কিন্তু তবু একজনকে 2 আর অন্যজনকে 3 দেবে — কাকে, তা নিশ্চিত নয়। RANK সমান মানকে সমান নম্বর দেয় আর পরের নম্বর লাফিয়ে যায় (1, 2, 2, 4). DENSE_RANK সমান নম্বর দেয় কিন্তু লাফায় না (1, 2, 2, 3). নিচে তিনটি একসাথে, একটি নামযুক্ত WINDOW দিয়ে, যাতে একই window তিনবার লিখতে না হয়:
WITH units AS (
SELECT p.category_id, p.name, SUM(i.quantity) AS sold
FROM products p
JOIN order_items i ON i.product_id = p.product_id
JOIN orders o ON o.order_id = i.order_id AND o.status <> 'cancelled'
GROUP BY p.product_id, p.category_id, p.name
)
SELECT category_id, name, sold,
ROW_NUMBER() OVER w AS rn,
RANK() OVER w AS rnk,
DENSE_RANK() OVER w AS drnk
FROM units
WINDOW w AS (PARTITION BY category_id ORDER BY sold DESC)
ORDER BY category_id, sold DESC;ফলে ৮টি সারি। Electronics-এর তিনটি গুরুত্বপূর্ণ: Headphone | 3 | 1 | 1 | 1, Mobile Phone | 2 | 2 | 2 | 2, Smart Watch | 2 | 3 | 2 | 2. ROW_NUMBER জোর করে Smart Watch-কে 3 দিয়েছে; rn <= 2 দিলে সে বাদ পড়ত, অথচ তার বিক্রি Mobile Phone-এর সমান। এই engine-এ আজ Mobile Phone আগে এসেছে, কিন্তু ORDER BY-তে সমান মানের সারির ক্রম SQL কোনোদিন নিশ্চিত করে না। rnk <= 2 দিলে দুজনই থাকে — Electronics থেকে ৩টি সারি। বাকি শ্রেণিতে tie নেই: Books (SQL Book 3, DSA Book 2), Clothing (Panjabi 2), Grocery (Rice 5kg 8, Mustard Oil 1L 6).
ঠিক N-টি সারিই চাই (যেমন হোমপেজে ঠিক ২টি পণ্যের জায়গা): ROW_NUMBER, আর ORDER BY-তে একটি অনন্য tiebreaker যোগ করুন — ORDER BY sold DESC, product_id — যাতে ফল প্রতিবার একই হয়। সমান হলে সবাই থাকুক (পুরস্কার, মেধাতালিকা): RANK. শীর্ষ N-টি আলাদা মান (“সর্বোচ্চ দুই রকম বেতন যাঁরা পান”): DENSE_RANK. পার্থক্যটি দেখা যায় শীর্ষে tie হলে: মান 9, 9, 7, 5 হলে RANK দেয় 1, 1, 3, 4 (তাই <= 2-এ ২টি সারি), আর DENSE_RANK দেয় 1, 1, 2, 3 (৩টি সারি)।
বিকল্প পথ আর তাদের দাম
-- units CTE আগের মতোই; "আমার চেয়ে বেশি বিক্রি এমন পণ্য ২টির কম"
SELECT u.category_id, u.name, u.sold
FROM units u
WHERE (SELECT COUNT(*) FROM units u2
WHERE u2.category_id = u.category_id AND u2.sold > u.sold) < 2
ORDER BY u.category_id, u.sold DESC;Window function আসার আগের পুরোনো পথ। “আমার চেয়ে কঠোরভাবে বেশি কয়টি” গোনা আসলে RANK − 1, তাই অর্থ RANK-এর মতো: ফল ৮টি সারিই, Mobile Phone আর Smart Watch দুটিই থাকে। সব DBMS-এ চলে, কিন্তু যুক্তিতে প্রতিটি সারির জন্য নিজের দলটি আবার পড়ে — দলের আকার k হলে দলপ্রতি O(k²) তুলনা। বড় দলে ধীর।
SELECT c.name AS category, t.name, t.revenue
FROM categories c
CROSS JOIN LATERAL (
SELECT p.name, SUM(i.quantity * i.unit_price) AS revenue
FROM products p
JOIN order_items i ON i.product_id = p.product_id
JOIN orders o ON o.order_id = i.order_id
WHERE p.category_id = c.category_id AND o.status <> 'cancelled'
GROUP BY p.product_id, p.name
ORDER BY revenue DESC LIMIT 2
) AS t;
-- SQL Server: CROSS APPLY (SELECT TOP 2 ... ORDER BY revenue DESC) AS tLATERAL মানে FROM-এর subquery বাঁ দিকের টেবিলের কলাম (c.category_id) দেখতে পারে — প্রতিটি শ্রেণির জন্য একটি ছোট “সেরা ২টি” কোয়েরি। PostgreSQL ৯.৩ থেকে, MySQL ৮.০.১৪ থেকে; SQL Server-এ একই কাজ CROSS APPLY. SQLite-এ LATERAL নেই। দল অল্প, আর (category_id, revenue)-এর মতো index থাকলে এটি সবচেয়ে দ্রুত হতে পারে, কারণ প্রতিটি দলে শুধু প্রথম N-টি সারি পড়ে থামে।
আরও গভীরে: ROW_NUMBER-এর পথে DBMS সাধারণত পুরো টেবিলটি (category_id, revenue) অনুযায়ী সাজায় — n সারিতে O(n log n), তারপর ছাঁকে। PostgreSQL ১৫ থেকে “run condition” নামে একটি উন্নতি আছে: rn <= 2 দেখে প্রতিটি partition-এ ২টি সারির পর আর হিসাব করে না। Oracle-এর plan-এ একই ধরনের কৌশল “WINDOW SORT PUSHED RANK” নামে দেখা যায়। আর মিলিয়ে থাকা index-সহ LATERAL পথের খরচ মোটামুটি দলের সংখ্যা G × (log n + N) পৃষ্ঠা-পড়া — G ছোট আর n বিশাল হলে পুরো সাজানোর চেয়ে অনেক কম। কোনটি জিতবে, তা EXPLAIN দেখে ঠিক করুন (পর্ব ৪১, ৪৩)।
৩ · প্রতিটি দলের সর্বশেষ সারি — প্রতিটি গ্রাহকের শেষ অর্ডার
ব্যবসার প্রশ্ন: কাস্টমার সার্ভিস প্রতিটি গ্রাহকের সবচেয়ে নতুন অর্ডারটি দেখতে চায় — order_id আর তারিখসহ। এটি top-N-এর বিশেষ রূপ, N = 1; ইংরেজিতে প্রায়ই বলে greatest-per-group. পর্ব ২১-এ correlated subquery দিয়ে এটি একবার করেছিলেন, আর সেখানে একটি সতর্কতা ছিল: একই দিনে দুটি অর্ডার থাকলে দুটিই আসবে। এবার সেটি ইচ্ছা করে ঘটানো হলো। এডিটরের setup-এ Karima Begum (customer 2)-এর জন্য 1011 নম্বর অর্ডার যোগ করা আছে, তারিখ 2025-10-01 — তাঁর 1007 নম্বর অর্ডারের দিনেই।
সরল চেষ্টা: SELECT customer_id, MAX(order_date) FROM orders GROUP BY customer_id. তারিখ ঠিক আসে, কিন্তু order_id আনতে গেলেই সমস্যা — GROUP BY-এর বাইরের কলাম standard SQL-এ নিষিদ্ধ (পর্ব ১৮)। SQLite এখানে একটি বিশেষ ছাড় দেয়: শুধু MAX বা MIN থাকলে “bare column” সেই সারি থেকেই আসে যেখানে MAX পাওয়া গেছে। Karima-র ক্ষেত্রে দুটি সারিতেই MAX, আর SQLite আজ 1007 দিয়েছে। কিন্তু এটি SQLite-এর নিজস্ব আচরণ; PostgreSQL ত্রুটি দেয়, আর MySQL-এর ONLY_FULL_GROUP_BY মোডেও ত্রুটি আসে। এর ওপর ভরসা করবেন না। নিচে তিনটি বহনযোগ্য পথ:
-- উপায় ১: ROW_NUMBER, tiebreaker order_id
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;
-- উপায় ২: দলের MAX তারিখের সাথে JOIN
SELECT o.customer_id, o.order_id, o.order_date FROM orders o
JOIN (SELECT customer_id, MAX(order_date) AS last_date FROM orders GROUP BY customer_id) m
ON m.customer_id = o.customer_id AND m.last_date = o.order_date
ORDER BY o.customer_id;
-- উপায় ৩: NOT EXISTS — এর চেয়ে নতুন কোনো অর্ডার নেই
SELECT o.customer_id, o.order_id, o.order_date FROM orders o
WHERE NOT EXISTS (SELECT 1 FROM orders n WHERE n.customer_id = o.customer_id
AND (n.order_date > o.order_date OR (n.order_date = o.order_date AND n.order_id > o.order_id)))
ORDER BY o.customer_id;উপায় ১ আর ৩-এর ফল একই ৭টি সারি: 1 | 1009, 2 | 1011, 3 | 1004, 4 | 1005, 6 | 1006, 7 | 1010, 8 | 1008 (তারিখসহ)। উপায় ২-এ ৮টি সারি — customer 2 দুবার এসেছেন: 2 | 1007 | 2025-10-01 আর 2 | 1011 | 2025-10-01, কারণ দুটির তারিখই MAX. উপায় ১-এ tiebreaker order_id DESC বলে দিয়েছে বড় নম্বরটি (পরে তৈরি) জিতবে। উপায় ৩-এ একই নিয়ম শর্তের ভেতরে লেখা: “তারিখে নতুন, অথবা একই তারিখে বড় নম্বর” এমন কোনো অর্ডার নেই। Tanvir (5)-এর কোনো অর্ডার নেই, তাই তিনটিতেই অনুপস্থিত।
| উপায় | সুবিধা | অসুবিধা |
|---|---|---|
| ROW_NUMBER | একটিই সারি নিশ্চিত; পুরো সারির সব কলাম পাওয়া যায়; N বদলানো সহজ | পুরো টেবিল সাজাতে হয়; tiebreaker ভুলে গেলে ফল অনিশ্চিত |
| MAX-এর সাথে JOIN | সব DBMS-এ চলে, পুরোনো সংস্করণেও | tie-তে duplicate; tiebreaker যোগ করা কষ্টকর |
| NOT EXISTS | (customer_id, order_date) index থাকলে খুব দ্রুত anti join | tiebreaker-সহ শর্ত লম্বা আর ভুল করা সহজ |
-- PostgreSQL-এর নিজস্ব সংক্ষিপ্ত রূপ: প্রতিটি customer_id-এর প্রথম সারি
SELECT DISTINCT ON (customer_id) customer_id, order_id, order_date
FROM orders
ORDER BY customer_id, order_date DESC, order_id DESC;PostgreSQL-এর DISTINCT ON ঠিক এই প্যাটার্নের জন্য: ORDER BY অনুযায়ী প্রতিটি দলের প্রথম সারি রাখে। ORDER BY-এর শুরু DISTINCT ON-এর কলাম দিয়ে হতে হয়। এটি standard SQL নয়; অন্য DBMS-এ নিয়ে গেলে ROW_NUMBER-এ বদলাতে হবে।
৪ · Pivot আর unpivot — সারিকে কলাম, কলামকে সারি
ব্যবসার প্রশ্ন: ম্যানেজার একটি Excel-ধাঁচের রিপোর্ট চান: প্রতিটি মাস একটি সারি, আর প্রতিটি অর্ডার-অবস্থা (delivered, shipped, pending, cancelled) একটি করে কলাম। ডেটাবেসে কিন্তু ডেটা থাকে “লম্বা” আকারে: প্রতিটি অর্ডার একটি সারি, অবস্থা একটি কলামের মান। লম্বা থেকে “চওড়া” আকারে নেওয়াকে বলে pivot, আর এমন সারণিকে crosstab. উল্টো কাজটি, চওড়া থেকে লম্বা, unpivot.
সরল চেষ্টা: GROUP BY month, status. এটি ভুল নয়, কিন্তু ফল লম্বা: ৭টি সারি, যেমন 2025-09 | cancelled | 1, 2025-09 | delivered | 3… চোখে তুলনা করা কঠিন। চওড়া করার কৌশল হলো পর্ব ১৬–১৮-এর conditional aggregation: প্রতিটি কলামের জন্য একটি aggregate, যার ভেতরে CASE শুধু সেই অবস্থার সারি গোনে। SQL standard-এর FILTER একই কাজ পরিষ্কারভাবে লেখে; SQLite ৩.৩০ (২০১৯) আর PostgreSQL ৯.৪ থেকে এটি বোঝে। নিচে তুলনার জন্য cancelled কলামটি FILTER দিয়ে লেখা। একই এডিটরে unpivot-ও আছে: setup-এ sales_sheet নামে একটি টেবিল, যেন কেউ Excel থেকে এনেছেন — category আর দুটি মাসের কলাম (sep_2025, oct_2025); Clothing-এর অক্টোবরের ঘর ফাঁকা (NULL).
-- pivot: প্রতিটি অবস্থা একটি কলাম
SELECT SUBSTR(order_date, 1, 7) AS month,
SUM(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END) AS delivered,
SUM(CASE WHEN status = 'shipped' THEN 1 ELSE 0 END) AS shipped,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending,
COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled,
COUNT(*) AS total
FROM orders
GROUP BY month ORDER BY month;
-- unpivot: প্রতিটি মাসের কলাম আলাদা সারি
SELECT category, '2025-09' AS month, sep_2025 AS revenue FROM sales_sheet
UNION ALL
SELECT category, '2025-10', oct_2025 FROM sales_sheet WHERE oct_2025 IS NOT NULL
ORDER BY category, month;Pivot-এর ফল দুটি সারি: 2025-09 | 3 | 1 | 1 | 1 | 6 আর 2025-10 | 2 | 1 | 1 | 0 | 4. অক্টোবরে বাতিল অর্ডার নেই, তবু কলামটি আছে আর মান 0 — FILTER দেওয়া COUNT কোনো সারি না পেলে 0 দেয়। প্রতিটি সারি GROUP BY-তে একবারই পড়া হয়; CASE শুধু ঠিক করে সারিটি কোন কলামের যোগফলে যাবে। Unpivot-এর ফল ৭টি সারি: Books দুই মাস (2400, 550), Clothing শুধু সেপ্টেম্বর (4400), Electronics (20900, 19150), Grocery (1780, 3140). প্রতিটি মাসের কলামের জন্য একটি SELECT, আর UNION ALL দিয়ে জোড়া — UNION নয়, কারণ duplicate মোছার দরকার নেই আর তাতে বাড়তি সাজানোর খরচ হয়। NULL ঘর রাখবেন কি না, সেটি ব্যবসার সিদ্ধান্ত; এখানে বাদ দেওয়া হয়েছে।
একটি পুরোনো ফাঁদ: COUNT(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END). COUNT গোনে NULL নয় এমন সব মান, আর 0-ও একটি মান! তাই এটি প্রতিটি সারি গোনে — সেপ্টেম্বরে 6, অক্টোবরে 4. দুটি সঠিক রূপ: SUM(CASE … THEN 1 ELSE 0 END), অথবা ELSE ছাড়া COUNT(CASE … THEN 1 END), যেখানে না মিললে CASE NULL দেয়।
অন্য DBMS-এর বিশেষ pivot
SELECT month, [delivered], [shipped], [pending], [cancelled]
FROM (SELECT LEFT(order_date, 7) AS month, status, order_id FROM orders) AS src
PIVOT (COUNT(order_id) FOR status IN ([delivered], [shipped], [pending], [cancelled])) AS p
ORDER BY month;SQL Server ২০০৫ থেকে PIVOT আর UNPIVOT অপারেটর আছে; Oracle ১১g থেকেও আছে। লেখা ছোট হয়, কিন্তু কলামের তালিকা (IN (…)) তবু হাতে লিখতে হয়।
CREATE EXTENSION IF NOT EXISTS tablefunc;
SELECT * FROM crosstab(
$$SELECT to_char(order_date::date, 'YYYY-MM'), status, COUNT(*)
FROM orders GROUP BY 1, 2 ORDER BY 1, 2$$,
$$VALUES ('cancelled'), ('delivered'), ('pending'), ('shipped')$$
) AS ct(month text, cancelled bigint, delivered bigint, pending bigint, shipped bigint);PostgreSQL-এ pivot আসে tablefunc extension-এর crosstab() ফাংশন থেকে। ফলের কলামের নাম আর টাইপ AS ct(…)-তে আগে থেকে বলে দিতে হয়। বেশিরভাগ দল তাই FILTER-ওয়ালা সাধারণ GROUP BY-ই পছন্দ করে।
সব পদ্ধতিতে একটি সীমা সাধারণ: ফলের কলামগুলো কোয়েরি লেখার সময়েই জানা থাকতে হবে। SQL-এ একটি কোয়েরির ফলের গঠন (কয়টি কলাম, কী নাম) কোয়েরি চালানোর আগেই ঠিক হয় — optimizer আর অ্যাপ দুজনেরই তা লাগে। আগামী মাসে নতুন একটি status ('returned') এলে কোনো কলাম নিজে থেকে তৈরি হবে না। “যত মাস, তত কলাম” ধরনের dynamic pivot করতে হয় অ্যাপের কোডে: আগে আলাদা কোয়েরিতে মানগুলো এনে SQL লেখা তৈরি করা। তখন মানগুলোকে কখনো সরাসরি SQL-এ জুড়বেন না; একটি অনুমোদিত তালিকার সাথে মিলিয়ে নিন, নইলে SQL injection-এর দরজা খোলে (পর্ব ৫১)। আর অনেক ক্ষেত্রে সবচেয়ে সহজ হলো লম্বা ফল পাঠিয়ে রিপোর্ট টুলে (Excel, BI টুল বা pandas) pivot করা।
৫ · Duplicate — খোঁজা, মোছা আর ঠেকানো
ব্যবসার প্রশ্ন: দোকানের তিনটি শাখা নিজেদের Excel থেকে গ্রাহক-তালিকা পাঠিয়েছে, আর সব একটি messy_import টেবিলে তোলা হয়েছে: import_id (ক্রমিক নম্বর), name, phone, city. একই মানুষ একাধিকবার এসেছেন, কিন্তু সবসময় হুবহু একইভাবে নয় — কোথাও নাম ছোট হাতের অক্ষরে, কোথাও নম্বরের আগে +88, কোথাও মাঝে ফাঁকা। আটটি সারি:
| import_id | name | phone | city |
|---|---|---|---|
| 1 | Rahim Uddin | 01711000001 | Dhaka |
| 2 | rahim uddin | 01711000001 | Dhaka |
| 3 | Karima Begum | 01811000002 | Chattogram |
| 4 | Karima Begum | +8801811000002 | Ctg |
| 5 | Sabbir Hasan | 01911000003 | Dhaka |
| 6 | Sabbir Hasan | 01911000003 | Dhaka |
| 7 | Nusrat Jahan | 01611000004 | Rajshahi |
| 8 | Mitu Rani | 0141 1000008 | Dhaka |
সরল চেষ্টা: SELECT DISTINCT name, phone, city. এটি ৮টির জায়গায় ৭টি সারি দেয় — শুধু Sabbir-এর হুবহু একই দুটি সারি এক হয়েছে। “Rahim Uddin” আর “rahim uddin” DISTINCT-এর চোখে আলাদা। আসল প্রশ্ন হলো, দুটি সারি কখন “একই মানুষ”? এখানে নিয়মটি ব্যবসা থেকে আসে: একই মোবাইল নম্বর মানে একই গ্রাহক। Deduplication-এর প্রথম ধাপ তাই এই “পরিচয়ের key” ঠিক করা আর তাকে একটি নিয়মিত রূপে আনা (data cleaning): ফাঁকা সরানো, আর শেষের ১১টি অঙ্ক রাখা, যাতে +880… আর 0… একই হয়।
-- ১. সরল খোঁজ: হুবহু একই phone
SELECT phone, COUNT(*) AS n FROM messy_import GROUP BY phone HAVING COUNT(*) > 1;
-- ২. আগে পরিষ্কার: ফাঁকা বাদ, শেষের ১১টি অঙ্ক; তারপর আবার খোঁজ
UPDATE messy_import SET phone = SUBSTR(REPLACE(phone, ' ', ''), -11);
SELECT phone, GROUP_CONCAT(import_id) AS ids FROM messy_import
GROUP BY phone HAVING COUNT(*) > 1;
-- ৩. প্রতিটি দলে সবচেয়ে পুরোনোটি রেখে বাকিগুলো মুছুন
DELETE FROM messy_import WHERE import_id IN (
SELECT import_id FROM (
SELECT import_id, ROW_NUMBER() OVER (PARTITION BY phone ORDER BY import_id) AS rn
FROM messy_import) WHERE rn > 1);
-- ৪. ঠেকান, তারপর upsert: নতুন হলে যোগ, পুরোনো হলে শহর হালনাগাদ
CREATE UNIQUE INDEX ux_import_phone ON messy_import(phone);
INSERT INTO messy_import (name, phone, city) VALUES
('Rahim Uddin', '01711000001', 'Gazipur'), ('Rakib Hasan', '01711000010', 'Dhaka')
ON CONFLICT(phone) DO UPDATE SET city = excluded.city WHERE city IS NOT excluded.city;
SELECT * FROM messy_import;ধাপ ১ মাত্র দুটি দল পায়: 01711000001 | 2 আর 01911000003 | 2 — Karima-র +88-ওয়ালা সারি ধরা পড়েনি। ধাপ ২-এর UPDATE-এ “৮টি সারি বদলেছে” (সব সারিতে নিয়ম চলেছে, যদিও মান বদলেছে মাত্র দুটির), তারপর তিনটি দল: 01711000001 | 1,2, 01811000002 | 3,4, 01911000003 | 5,6. ধাপ ৩-এ “৩টি সারি বদলেছে” — 2, 4, 6 মুছে গেছে। ধাপ ৪-এর CREATE INDEX দেখায় ✓, আর upsert-এ “২টি সারি বদলেছে”: Rahim-এর শহর Gazipur হলো, Rakib Hasan নতুন সারি (import_id 9) হিসেবে এলেন। শেষ ফলে ৬টি সারি: 1 Rahim Uddin (Gazipur), 3 Karima Begum, 5 Sabbir Hasan, 7 Nusrat Jahan, 8 Mitu Rani (নম্বর এখন 01411000008), 9 Rakib Hasan.
এই চার ধাপ যেকোনো deduplication কাজের কাঠামো। কয়েকটি সিদ্ধান্ত আপনাকেই নিতে হবে:
- কোনটি রাখবেন? এখানে
ORDER BY import_id— প্রথম আসা সারিটি। সবচেয়ে নতুনটি চাইলেORDER BY import_id DESC; সবচেয়ে সম্পূর্ণটি চাইলেORDER BY (city IS NULL), import_id, যাতে শহর-থাকা সারি আগে আসে। Karima-র ক্ষেত্রে 3 রাখায় “Chattogram” টিকেছে, “Ctg” নয় — ভাগ্য ভালো ছিল। বাস্তবে আগে মুছে ফেলা সারির ভালো তথ্য রাখা সারিতে মিলিয়ে নেওয়ার (merge) ধাপও লাগে। - মোছার আগে দেখুন। DELETE-এর ভেতরের SELECT আলাদা চালিয়ে দেখুন কোন import_id মুছবে, আর পুরো কাজ একটি transaction-এ চালান (পর্ব ৪৪), যাতে ভুল হলে ROLLBACK করা যায়। যে টেবিলে primary key-ও নেই (হুবহু একই দুটি সারি), সেখানে SQLite-এর
rowidবা PostgreSQL-এরctidদিয়ে সারি আলাদা করা যায়, অথবা DISTINCT দিয়ে নতুন টেবিল বানিয়ে পুরোনোটির জায়গায় বসানো যায়। - ঠেকানো আসল সমাধান। পরিষ্কার করার পর UNIQUE index (বা UNIQUE constraint, পর্ব ১৩) বসান। অ্যাপে “আগে খুঁজে দেখি আছে কি না, তারপর INSERT” যথেষ্ট নয়: দুটি অনুরোধ একই মুহূর্তে এলে দুটিই “নেই” দেখে INSERT করতে পারে — একটি race condition. ডেটাবেসের UNIQUE নিয়ম এই দৌড়ে কখনো হারে না।
Upsert আর MERGE — একটু পুনরালোচনা
উপরের শেষ INSERT-টি পর্ব ১৪-এর upsert: conflict হলে ত্রুটি না দিয়ে UPDATE. excluded মানে “যে সারিটি ঢুকতে চেয়েছিল”। শেষের WHERE city IS NOT excluded.city একে শর্তসাপেক্ষ করেছে: শহর আসলেই বদলালে তবেই লেখা হবে, যাতে অকারণ লেখা, trigger বা “শেষ পরিবর্তনের সময়” না বদলায়। IS NOT ব্যবহার হয়েছে কারণ এটি NULL-এ নিরাপদ তুলনা (পর্ব ১৬); <> দিলে পুরোনো শহর NULL হলে নতুন শহর কখনো লেখা হতো না।
-- SQL Server আর Oracle-এও MERGE আছে, তবে শর্ত আর alias লেখার রীতি একটু আলাদা
MERGE INTO customers_clean AS t
USING messy_import AS s ON t.phone = s.phone
WHEN MATCHED AND t.city IS DISTINCT FROM s.city THEN
UPDATE SET city = s.city
WHEN NOT MATCHED THEN
INSERT (name, phone, city) VALUES (s.name, s.phone, s.city);
-- MySQL: INSERT ... ON DUPLICATE KEY UPDATE city = VALUES(city) (8.0.20+ এ নতুন alias রীতি)SQL:২০০৩ standard-এর MERGE একটি পুরো টেবিলকে আরেকটির সাথে মেলায়: মিললে UPDATE (বা DELETE), না মিললে INSERT. Oracle আর SQL Server বহু বছর ধরে এটি দেয়, PostgreSQL দেয় ১৫ সংস্করণ (২০২২) থেকে। SQLite আর MySQL-এ MERGE নেই; সেখানে upsert-ই পথ। মনে রাখবেন, upsert নির্ভর করে একটি UNIQUE index-এর ওপর — যে কলামে conflict ধরা হবে, সেখানে UNIQUE না থাকলে ON CONFLICT ত্রুটি দেয়। তাই deduplicate → UNIQUE → upsert, এই ক্রম।
৬ · Gaps and islands — টানা দিন আর হারানো নম্বর
ব্যবসার প্রশ্ন: একটি শেখার অ্যাপ প্রতিদিন লগইন করলে “streak” দেখায় — টানা কত দিন এসেছেন। logins টেবিলে প্রতিটি লগইন একটি সারি: user_id আর login_date. একই দিনে দুবার লগইন করলে দুটি সারি। প্রশ্ন: প্রতিটি ব্যবহারকারীর টানা দিনের প্রতিটি দল কোথা থেকে কোথা পর্যন্ত, আর কত দিনের? টানা মানগুলোর একটি দলকে বলে island (দ্বীপ), আর দুই দলের মাঝের ফাঁকা অংশকে gap. পুরো সমস্যাটির নাম তাই gaps and islands.
সরল চেষ্টা: LAG(login_date) দিয়ে আগের দিনের সাথে তুলনা করে “আগের দিনের পরের দিন কি না” জানা যায়। কিন্তু তাতে শুধু প্রতিটি সারির পাশে হ্যাঁ/না আসে — দলগুলোর শুরু, শেষ আর দৈর্ঘ্য পেতে আরও দুই ধাপ লাগে। আরেকটি সাধারণ ভুল হলো DISTINCT ভুলে যাওয়া: একই দিনের দুটি লগইন হিসাব গুলিয়ে দেয়, যা নিচে দেখবেন। এর চেয়ে সুন্দর একটি কৌশল আছে: তারিখ থেকে তার ক্রমিক নম্বর বিয়োগ করুন।
| সারি | |
| আলাদা anchor | |
| দীর্ঘতম streak |
- কাঁচা ডেটা। user 1-এর ১০টি লগইন। 2025-10-02-এ দুবার এসেছেন। এই দ্বিতীয় সারিটি রেখে দিলে ক্রমিক নম্বর এক বেশি বেড়ে যাবে আর একটি দল ভেঙে যাবে।
- DISTINCT. প্রশ্নটি “দিন” নিয়ে, লগইন নিয়ে নয়, তাই প্রতিটি দিন একবার। এখন ৯টি আলাদা দিন।
- ROW_NUMBER. তারিখের ক্রমে 1 থেকে 9. কোনো ফাঁক নেই, প্রতিটি সারিতে ঠিক ১ করে বাড়ে।
- তারিখ − rn. টানা দিনে তারিখও ১ বাড়ে, rn-ও ১ বাড়ে, তাই বিয়োগফল একই থাকে: প্রথম তিন দিনে 2025-09-30. 10-04 বাদ পড়ায় 10-05-এ তারিখ ২ বেড়েছে কিন্তু rn ১, তাই anchor ১ দিন এগিয়ে 2025-10-01.
- GROUP BY anchor. একই anchor মানেই একই টানা দল। তিনটি anchor, তাই তিনটি island. মাঝের ফাঁকগুলো 10-04 আর 10-07–08.
- ফল। প্রতিটি দলের MIN তারিখ শুরু, MAX শেষ, COUNT দৈর্ঘ্য। দীর্ঘতম streak ৪ দিন (10-09 থেকে 10-12). anchor-এর মান নিজে অর্থহীন; সে শুধু দলের নাম।
DISTINCT-এর পর তারিখগুলো কঠোরভাবে বাড়ে: d₁ < d₂ < … (দিনের সংখ্যা হিসেবে), আর rn = 1, 2, 3, … ধরুন aᵢ = dᵢ − i. পাশাপাশি দুটি সারিতে ai+1 − aᵢ = (di+1 − dᵢ) − 1. তারিখ আলাদা বলে di+1 − dᵢ ≥ 1, তাই পার্থক্যটি ≥ 0, আর ঠিক 0 হয় যদি এবং কেবল যদি দুটি দিন পাশাপাশি। অর্থাৎ anchor কখনো কমে না, একটি টানা দলের ভেতরে স্থির থাকে, আর প্রতিটি ফাঁকে কঠোরভাবে বাড়ে। তাই দুটি আলাদা island কখনো একই anchor পায় না — GROUP BY anchor ঠিক island-গুলোই দেয়। DISTINCT না দিলে একই দিনের দুটি সারিতে পার্থক্য হতো −1, আর প্রমাণটি ভেঙে যেত।
এবার দুটি ব্যবহারকারী নিয়ে পুরো কোয়েরি চালান। SQLite-এ তারিখ থেকে দিন বিয়োগ করা হয় DATE(d, '-3 days') modifier দিয়ে (পর্ব ১৭), তাই rn-কে লেখায় জুড়ে modifier বানানো হয়েছে। একই এডিটরে দ্বিতীয় প্রশ্ন: invoices টেবিলে চালান নম্বর 1 থেকে 12 হওয়ার কথা, কিন্তু কয়েকটি নেই — অডিটর জানতে চান কোনগুলো। এখানে LEAD প্রতিটি সারির পাশে পরের নম্বরটি এনে দেয়; পার্থক্য ১-এর বেশি হলেই ফাঁক।
-- island: তারিখ − ক্রমিক নম্বর = দলের নাম (anchor)
WITH d AS (SELECT DISTINCT user_id, login_date FROM logins),
g AS (SELECT user_id, login_date,
DATE(login_date, '-' || ROW_NUMBER() OVER (PARTITION BY user_id
ORDER BY login_date) || ' days') AS anchor
FROM d)
SELECT user_id, MIN(login_date) AS first_day, MAX(login_date) AS last_day,
COUNT(*) AS streak
FROM g GROUP BY user_id, anchor ORDER BY user_id, first_day;
-- gap: পরের নম্বরের সাথে ফাঁক
SELECT invoice_no + 1 AS gap_from, next_no - 1 AS gap_to
FROM (SELECT invoice_no, LEAD(invoice_no) OVER (ORDER BY invoice_no) AS next_no
FROM invoices)
WHERE next_no - invoice_no > 1;প্রথম ফলে ৫টি সারি। user 1: 2025-10-01 | 2025-10-03 | 3, 2025-10-05 | 2025-10-06 | 2, 2025-10-09 | 2025-10-12 | 4 — ঠিক ডায়াগ্রামের মতো। user 2: 2025-10-01 | 2025-10-01 | 1 আর 2025-10-03 | 2025-10-04 | 2. PARTITION BY user_id থাকায় প্রতিটি ব্যবহারকারীর নম্বর আলাদা করে 1 থেকে শুরু হয়েছে, আর GROUP BY-তে user_id-ও আছে, যাতে দুজনের anchor ঘটনাক্রমে মিলে গেলেও দল না মেশে। দ্বিতীয় ফল দুটি সারি: 4 | 5 আর 9 | 9 — 4 থেকে 5 আর 9 নম্বর চালান নেই। শেষ সারি 12-এর LEAD NULL, তাই NULL - 12 > 1 UNKNOWN, সারিটি নিজে থেকেই বাদ। খেয়াল রাখুন, এই পদ্ধতি প্রথম নম্বরের আগের বা শেষ নম্বরের পরের ফাঁক ধরে না; 1 থেকে শুরু হওয়ার কথা থাকলে সেটি আলাদা পরীক্ষা করতে হবে।
একই কৌশল সংখ্যায়ও চলে (id − ROW_NUMBER()), আর “টানা” মানে যদি ১ দিনের বদলে অন্য কিছু হয় — যেমন ৩০ মিনিটের বেশি বিরতি না থাকলে একই “session” — তখন LAG দিয়ে প্রতিটি দলের শুরু চিহ্নিত করে সেই চিহ্নের running SUM-কে দলের নম্বর বানানো হয়। ওয়েবসাইটের ভিজিট বিশ্লেষণে এটিকে বলে sessionization; ভাবনার প্রশ্ন ২-এ আরও আছে।
৭ · Running total — ওয়ালেটের ব্যালান্স আর প্রথমবারের গ্রাহক
ব্যবসার প্রশ্ন: bKash বা Nagad-এর মতো একটি মোবাইল ওয়ালেটের খাতা (ledger) ভাবুন। প্রতিটি লেনদেন একটি সারি: wallet_ledger টেবিলে entry_id, wallet (মোবাইল নম্বর), txn_time, kind ('credit' মানে টাকা ঢুকেছে — Cash In বা Received; 'debit' মানে বেরিয়েছে — Send Money, Payment, Cash Out), amount (টাকা, পূর্ণসংখ্যা) আর note. ব্যালান্স আলাদা কলামে রাখা হয় না — প্রতিটি লেনদেনের পরের ব্যালান্স হলো সেই মুহূর্ত পর্যন্ত সব credit-এর যোগফল বিয়োগ সব debit-এর যোগফল। এটি একটি running total. প্রশ্ন দুটি: প্রতিটি লেনদেনের পর ব্যালান্স কত, আর কোথাও ব্যালান্স শূন্যের নিচে গেছে কি না — গেলে সেটি ডেটার ভুল বা সিস্টেমের ত্রুটি, তদন্ত দরকার।
Window function-এ SUM(…) OVER (PARTITION BY wallet ORDER BY txn_time) লিখলেই running total পাওয়া যায় — পর্ব ২৬-এ দেখেছেন। কিন্তু এখানে একটি সূক্ষ্ম ফাঁদ আছে। খাতায় entry 5 (Cash Out 2500) আর entry 7 (Received 1000) একই মিনিটে, 2025-10-03 20:10. ORDER BY থাকলে কিন্তু frame না বললে ডিফল্ট window frame হলো RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — আর RANGE সমান ORDER BY মানের সব সারিকে (peer) একসাথে নেয়।
-- সরল চেষ্টা: শুধু সময় দিয়ে সাজানো (ডিফল্ট RANGE frame)
SELECT entry_id, wallet, txn_time, note,
SUM(CASE kind WHEN 'credit' THEN amount ELSE -amount END)
OVER (PARTITION BY wallet ORDER BY txn_time) AS balance
FROM wallet_ledger ORDER BY wallet, txn_time, entry_id;
-- সঠিক: tiebreaker entry_id + ROWS frame, তারপর ঋণাত্মক খোঁজা
WITH b AS (
SELECT entry_id, wallet, txn_time, note,
SUM(CASE kind WHEN 'credit' THEN amount ELSE -amount END)
OVER (PARTITION BY wallet ORDER BY txn_time, entry_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS balance
FROM wallet_ledger)
SELECT *, CASE WHEN balance < 0 THEN 'NEGATIVE' END AS flag
FROM b ORDER BY wallet, txn_time, entry_id;প্রথম ওয়ালেট (01711000001) দুই ফলেই একই: 5000, 3800, 800, 2300, 0. দ্বিতীয় ওয়ালেটে পার্থক্য। সরল চেষ্টায়: entry 3 → 2000, entry 5 → 500, entry 7 → 500. একই মিনিটের দুটি সারি peer, তাই দুটিই পেয়েছে 2000 − 2500 + 1000 = 500; মাঝের অবস্থাটি হারিয়ে গেছে। সঠিক কোয়েরিতে: 2000, −500 (flag NEGATIVE), 500. entry 5 যখন প্রক্রিয়া হয়েছিল, তখন ওয়ালেটে ছিল 2000, আর বেরিয়েছে 2500 — ঠিক এই ঘটনাটিই খোঁজা হচ্ছিল, আর সরল চেষ্টা তাকে লুকিয়ে ফেলেছিল। শুধু ঋণাত্মক সারিগুলো চাইলে শেষে WHERE balance < 0 দিন — window-এর ফল ছাঁকতে আবারও CTE লাগে, অংশ ২-এর কারণেই।
দুটি নিয়ম মুখস্থ রাখুন। (১) Running total-এ ORDER BY-কে অনন্য করুন — সময়ের সাথে entry_id. (২) ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW স্পষ্ট লিখুন; এতে অর্থ পরিষ্কার হয়, আর অনেক DBMS-এ ROWS frame, RANGE-এর চেয়ে দ্রুতও চলে। Window function আসার আগে running total লেখা হতো correlated subquery দিয়ে (SELECT SUM(…) WHERE l2.entry_id <= l.entry_id) — প্রতিটি সারিতে আগের সব সারি আবার যোগ, তাই n সারিতে O(n²); window function একবার সাজিয়ে O(n log n)-এ, আর আগেই সাজানো থাকলে O(n)-এ করে। এটি আসলে DSA-র prefix sum.
প্রথমবারের গ্রাহক আর ক্রমযোজিত আলাদা গণনা
ব্যবসার প্রশ্ন: প্রতি মাসে কতজন সক্রিয় গ্রাহক, তাঁদের কতজন প্রথমবার কিনলেন, আর শুরু থেকে এ পর্যন্ত মোট কতজন আলাদা গ্রাহক পেয়েছি (বাতিল অর্ডার বাদ)? শেষ সংখ্যাটি একটি “cumulative distinct count”. সরল চেষ্টা: COUNT(DISTINCT customer_id) OVER (ORDER BY month) — SQLite ত্রুটি দেয় DISTINCT is not supported for window functions; PostgreSQL আর SQL Server-ও window-এর ভেতরে DISTINCT সমর্থন করে না। কৌশলটি হলো প্রশ্নটি উল্টে দেখা: “এ পর্যন্ত আলাদা গ্রাহক” = “এ পর্যন্ত প্রতি মাসের নতুন গ্রাহকের যোগফল”, কারণ প্রত্যেক গ্রাহক ঠিক একটি মাসেই “নতুন”। আর নতুন মানে, ওই মাসটিই তাঁর প্রথম অর্ডারের মাস (MIN).
WITH valid AS (SELECT customer_id, SUBSTR(order_date, 1, 7) AS month
FROM orders WHERE status <> 'cancelled'),
firsts AS (SELECT customer_id, MIN(month) AS first_month
FROM valid GROUP BY customer_id)
SELECT v.month,
COUNT(DISTINCT v.customer_id) AS active,
COUNT(DISTINCT f.customer_id) AS new_customers,
SUM(COUNT(DISTINCT f.customer_id)) OVER (ORDER BY v.month) AS customers_so_far
FROM valid v
LEFT JOIN firsts f ON f.customer_id = v.customer_id AND f.first_month = v.month
GROUP BY v.month ORDER BY v.month;
-- সরল চেষ্টা (ইচ্ছা করে শেষে রাখা, কারণ এটি ত্রুটি দেয়)
SELECT SUBSTR(order_date, 1, 7) AS month,
COUNT(DISTINCT customer_id) OVER (ORDER BY SUBSTR(order_date, 1, 7)) AS seen
FROM orders;প্রথম ফল: 2025-09 | 4 | 4 | 4 আর 2025-10 | 4 | 2 | 6. সেপ্টেম্বরে সক্রিয় 1, 2, 4, 6 — চারজনই নতুন (Sabbir (3)-এর একমাত্র অর্ডার বাতিল, তাই তিনি গোনায় নেই)। অক্টোবরে সক্রিয় 1, 2, 7, 8; নতুন শুধু Imran (7) আর Mitu (8), বাকি দুজন ফিরে আসা গ্রাহক। এ পর্যন্ত মোট 4 + 2 = 6. লক্ষ করুন SUM(COUNT(…)) OVER: ভেতরের COUNT হলো GROUP BY-এর aggregate, বাইরের SUM হলো তার ওপর window — window function চলে GROUP BY-এর পরে, তাই এটি বৈধ। এরপর দ্বিতীয় কোয়েরি ত্রুটি দেয়: DISTINCT is not supported for window functions; এই এডিটর তাই ইচ্ছা করে ত্রুটি দেখায়।
৮ · Median আর percentile — “সাধারণ” অর্ডারের মূল্য
ব্যবসার প্রশ্ন: একটি সাধারণ অর্ডারে গ্রাহক কত টাকা খরচ করেন? গড় (AVG) এখানে বিভ্রান্ত করে। দোকানের ১০টি অর্ডারের গড় 5682 টাকা, অথচ ১০টির ৮টিই এর চেয়ে ছোট — দুটি মোবাইল ফোনের অর্ডার (17400, 18000) গড়কে টেনে তুলেছে। একটি বড় অঙ্কের বেতন পুরো অফিসের “গড় বেতন” বাড়িয়ে দেওয়ার মতো। Median হলো সাজানো তালিকার ঠিক মাঝের মান: অর্ধেক এর নিচে, অর্ধেক ওপরে। মান জোড় সংখ্যক হলে মাঝের দুটির গড়। আরও সাধারণভাবে, p-তম percentile হলো সেই মান, যার নিচে মোটামুটি p% মান থাকে; median মানে 50th percentile.
সরল চেষ্টা: SELECT MEDIAN(total) …. SQLite-এর সাধারণ build-এ এমন কোনো ফাংশন নেই — no such function: median. (SQLite ৩.৪৭ থেকে একটি ঐচ্ছিক percentile extension আছে, যা আলাদা করে চালু করে compile করতে হয়; এই পাতার engine-এ সেটি নেই।) তাই সংজ্ঞা থেকেই বানাই: ROW_NUMBER দিয়ে ক্রমিক নম্বর, COUNT(*) OVER () দিয়ে মোট সংখ্যা n, তারপর মাঝের অবস্থান। n বিজোড় হলে মাঝের অবস্থান (n+1)/2; জোড় হলে n/2 আর n/2 + 1. পূর্ণসংখ্যার ভাগে (n + 1) / 2 আর (n + 2) / 2 দুই ক্ষেত্রেই ঠিক অবস্থানগুলো দেয়: n = 10-এ 5 আর 6, n = 7-এ 4 আর 4.
-- median: মাঝের এক বা দুটি সারির গড়
WITH t AS (SELECT order_id, SUM(quantity * unit_price) AS total
FROM order_items GROUP BY order_id),
r AS (SELECT total, ROW_NUMBER() OVER (ORDER BY total) AS rn,
COUNT(*) OVER () AS n FROM t)
SELECT AVG(total) AS median_total, (SELECT AVG(total) FROM t) AS mean_total
FROM r WHERE rn IN ((n + 1) / 2, (n + 2) / 2);
-- 90th percentile (nearest-rank): যেখানে CUME_DIST প্রথম 0.9 ছোঁয়
WITH t AS (SELECT order_id, SUM(quantity * unit_price) AS total
FROM order_items GROUP BY order_id),
c AS (SELECT total, CUME_DIST() OVER (ORDER BY total) AS cd FROM t)
SELECT MIN(total) AS p90 FROM c WHERE cd >= 0.9;প্রথম ফল: 3375 | 5682. সাজানো ১০টি মোট: 550, 1040, 1750, 1780, 3250, 3500, 4500, 5050, 17400, 18000. পঞ্চম আর ষষ্ঠের গড় (3250 + 3500) / 2 = 3375 — গড়ের প্রায় অর্ধেক। “সাধারণ অর্ডার” বোঝাতে এটিই সৎ সংখ্যা। দ্বিতীয় ফল: 17400. CUME_DIST প্রতিটি মানের জন্য দেয় “এই মান বা এর চেয়ে ছোট মানের অনুপাত”: 550-এ 0.1, … 17400-এ 0.9. যে প্রথম মানে অনুপাত 0.9 ছোঁয়, সেটিই nearest-rank পদ্ধতির 90th percentile. মনে রাখবেন, এখানে বাতিল অর্ডার 1004-ও আছে; বাস্তব রিপোর্টে কোন অর্ডার গুনবেন, আগে ঠিক করুন।
WITH t AS (SELECT order_id, SUM(quantity * unit_price) AS total
FROM order_items GROUP BY order_id)
SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY total) AS median, -- 3375
percentile_cont(0.9) WITHIN GROUP (ORDER BY total) AS p90_cont, -- 17460
percentile_disc(0.9) WITHIN GROUP (ORDER BY total) AS p90_disc -- 17400
FROM t;PostgreSQL-এ এগুলো standard-এর ordered-set aggregate: WITHIN GROUP (ORDER BY …). percentile_disc তালিকার একটি আসল মান দেয় (CUME_DIST-এর পদ্ধতির মতো), আর percentile_cont দুই মানের মাঝে রৈখিকভাবে আনুমানিক মান বসায়: অবস্থান 1 + 0.9 × (10 − 1) = 9.1, তাই 17400 + 0.1 × (18000 − 17400) = 17460. Oracle-এ সরাসরি MEDIAN() ফাংশনও আছে। MySQL-এ এগুলোর কোনোটি নেই; সেখানেও উপরের ROW_NUMBER পদ্ধতিই চলে। দলভিত্তিক median চাইলে (যেমন বিভাগভিত্তিক cgpa) দুটি window-তে PARTITION BY যোগ করে শেষে GROUP BY করুন — অনুশীলন ৪-এ সেটিই।
৯ · Relational division — “সব” কোর্স নিয়েছেন কারা
ব্যবসার প্রশ্ন: বিশ্ববিদ্যালয় জানতে চায়, কোন শিক্ষার্থীরা CSE-র সব মূল (core) কোর্সে ভর্তি হয়েছেন। এখানে মূল কোর্স মানে CSE বিভাগের ৩ ক্রেডিটের কোর্স: CSE101, CSE201, CSE301 (ল্যাব CSE302 দেড় ক্রেডিটের, তাই বাদ)। “অন্তত একটি” প্রশ্ন সহজ; “সব” প্রশ্নটিই কঠিন। একে বলে relational division, কারণ relational algebra-য় এর চিহ্ন ÷ (পর্ব ৩৩-এ বিস্তারিত)। পর্ব ২১-এর ভাবনার প্রশ্নে এর প্রথম স্বাদ পেয়েছিলেন।
সরল চেষ্টা: WHERE e.course_id IN ('CSE101', 'CSE201', 'CSE301'). ফল ৪ জন — Arif, Bristy, Dilruba, Gulshan. কিন্তু IN মানে “এর যেকোনো একটি”: Gulshan শুধু CSE101 নিয়েছেন, তবু এসেছেন। সঠিক উত্তরের জন্য দুটি পথ। প্রথমটি যুক্তিবিদ্যার সমতুল্যতা: “প্রতিটি মূল কোর্স সে নিয়েছে” ≡ “এমন কোনো মূল কোর্স নেই, যা সে নেয়নি” — প্রতীকে ∀c P(c) ≡ ¬∃c ¬P(c). দুটি “না” পাশাপাশি, তাই দুই স্তরের NOT EXISTS. দ্বিতীয়টি গোনা: যতগুলো মূল কোর্স আছে, ঠিক ততগুলো আলাদা মূল কোর্স সে নিয়েছে কি না।
| যাচাই হচ্ছে | |
| “নেয়নি” কোর্স | |
| এ পর্যন্ত ফলে |
- ভাজক। courses থেকে মূল কোর্সের সেট S: তিনটি কোর্স। প্রতিটি শিক্ষার্থীর জন্য প্রশ্ন একটিই — S-এ এমন কোনো কোর্স আছে কি, যাতে তিনি ভর্তি নন?
- Arif. ভেতরের NOT EXISTS প্রতিটি কোর্সে enrollments খোঁজে। CSE101-এ তাঁর কোনো সারি নেই, তাই CSE101 একটি “নেয়নি” কোর্স। একটি পাওয়াই যথেষ্ট — বাইরের NOT EXISTS FALSE, Arif বাদ। (CSE302 তাঁর আছে, কিন্তু সেটি S-এ নেই।)
- Bristy. একই অবস্থা: CSE101 নেই। বাদ।
- Dilruba. তিনটি কোর্সেই সারি আছে। CSE301-এর grade এখনো NULL, কিন্তু প্রশ্ন “ভর্তি হয়েছেন”, তাই এটি গোনায় আসে। “নেয়নি” সেট ফাঁকা, বাইরের NOT EXISTS TRUE — Dilruba ফলে।
- Gulshan ও বাকিরা। Gulshan-এর CSE201 আর CSE301 নেই। Chayon, Emon, Fahim আর Habib-এর কোনো মূল কোর্সই নেই। সবাই বাদ।
- ফল আর গোনার পথ। একজনই: Dilruba Khatun. COUNT পদ্ধতি একই উত্তর দেয়: শুধু তাঁর আলাদা মূল কোর্সের সংখ্যা 3, যা │S│-এর সমান।
-- পদ্ধতি ১: এমন কোনো মূল কোর্স নেই, যা এই শিক্ষার্থী নেননি
SELECT s.name FROM students s
WHERE NOT EXISTS (
SELECT 1 FROM courses c
WHERE c.dept_id = 1 AND c.credits = 3
AND NOT EXISTS (SELECT 1 FROM enrollments e
WHERE e.student_id = s.student_id AND e.course_id = c.course_id));
-- পদ্ধতি ২: আলাদা মূল কোর্স গুনে মোটের সাথে মেলানো
SELECT s.name, COUNT(DISTINCT e.course_id) AS core_taken
FROM students s
JOIN enrollments e ON e.student_id = s.student_id
JOIN courses c ON c.course_id = e.course_id AND c.dept_id = 1 AND c.credits = 3
GROUP BY s.student_id, s.name
HAVING COUNT(DISTINCT e.course_id) =
(SELECT COUNT(*) FROM courses WHERE dept_id = 1 AND credits = 3);দুটির ফলই Dilruba Khatun; দ্বিতীয়টিতে পাশে 3. পদ্ধতি ২-এ DISTINCT জরুরি: enrollments-এর primary key (student_id, course_id, term), তাই কেউ একটি কোর্স দুই term-এ (ফেল করে আবার) নিলে দুটি সারি থাকে। DISTINCT ছাড়া তিনি একটি কোর্স দুবার নিয়ে আর একটি না নিয়েও 3 গুনতেন। আর JOIN-এর শর্তে c.dept_id = 1 AND c.credits = 3 থাকায় শুধু মূল কোর্সই গোনা হয় — নইলে Arif-এর CSE302 যোগ হয়ে তাঁর গণনাও 3 হতো। প্রশ্নটি “পাস করেছেন” হলে দুই পদ্ধতিতেই e.grade IS NOT NULL AND e.grade <> 'F' যোগ করতে হতো, আর তখন ফল ফাঁকা — Dilruba-র CSE301-এর ফল এখনো আসেনি।
কোনটি বেছে নেবেন? দুই স্তরের NOT EXISTS সরাসরি সংজ্ঞাটি লেখে আর ভাজক ফাঁকা হলেও যুক্তিসম্মত আচরণ করে (ভাবনার প্রশ্ন ৫-এ এই কিনারাটি)। COUNT পদ্ধতি অনেকের কাছে পড়তে সহজ আর বড় ডেটায় প্রায়ই দ্রুত — একবার JOIN আর GROUP BY. কিন্তু এর দুটি শর্ত মানতে হয়: গোনা হবে আলাদা মান (DISTINCT), আর শুধু ভাজকের মধ্যের মান (JOIN-এ ফিল্টার)। চাকরির ইন্টারভিউতে প্রশ্নটি নানা চেহারায় আসে: “যে গ্রাহক সব শ্রেণি থেকে কিনেছেন”, “যে কর্মী সব প্রকল্পে কাজ করেছেন”।
১০ · AI-এর যুগে প্যাটার্ন
এই পর্বের প্রায় প্রতিটি প্রশ্ন AI-কে বললে সে চলার মতো একটি কোয়েরি দেবে। সমস্যা হলো, এই প্যাটার্নগুলোর ভুল কোনো ত্রুটি দেখায় না — একটি সারি কম, একটি দল বেশি, বা একটি ঋণাত্মক ব্যালান্স লুকিয়ে থাকে। প্যাটার্নের নাম জানা থাকলে আপনি AI-কে ঠিক প্রশ্নটি করতে পারবেন, আর তার উত্তরও ঠিক জায়গায় যাচাই করতে পারবেন।
| AI-এর ভুল | ফল | কীভাবে ধরবেন |
|---|---|---|
top-N-এ ORDER BY … LIMIT N | পুরো টেবিলের সেরা N, দলের নয় | প্রতিটি দল থেকে সারি এসেছে কি না গুনুন |
| ROW_NUMBER, tiebreaker ছাড়া | সমান মানে এলোমেলো বাছাই; প্রতিবার ভিন্ন ফল সম্ভব | ORDER BY-তে একটি অনন্য কলাম আছে কি না দেখুন; tie-ওয়ালা পরীক্ষার ডেটা দিন |
| সর্বশেষ সারিতে MAX-এর সাথে JOIN | একই সময়ের দুটি সারিতে duplicate | ফলে দলপ্রতি একটির বেশি সারি আছে কি না: GROUP BY key HAVING COUNT(*) > 1 |
| running total-এ ডিফল্ট RANGE frame | একই সময়ের লেনদেনে মাঝের ব্যালান্স হারায় | একই timestamp-এর দুটি সারি দিয়ে পরীক্ষা |
| gaps-and-islands-এ DISTINCT নেই | একই দিনের দুটি লগইনে দল ভাঙে | এক দিনে দুবারের ডেটা দিয়ে পরীক্ষা |
| division-এ IN, বা COUNT DISTINCT ছাড়া | “যেকোনো একটি” বা পুনরায় নেওয়া কোর্স গোনা | এমন শিক্ষার্থী বানান যিনি একটি কোর্স দুবার নিয়েছেন |
| MEDIAN বা PERCENTILE_CONT SQLite-এ | no such function | লক্ষ্য DBMS নির্দিষ্ট করে বলুন |
AI-কে schema, DBMS আর কিনারার শর্তগুলো দিন, আর যাচাইয়ের ডেটাও চেয়ে নিন:
AI-এর লেখা প্যাটার্ন-কোয়েরি হাতে পেলে — যাচাইয়ের চেকলিস্ট
পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:
- প্রতিটি বিভাগের সর্বোচ্চ বেতনের ২ জন কর্মী বের করুন। — CTE-তে
DENSE_RANK()বাROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC), বাইরেWHERE rn <= 2; tie-র নিয়ম বলে দিন। - একটি টেবিল থেকে duplicate মুছুন, একটি রেখে। — ROW_NUMBER দিয়ে প্রতিটি key-দলে নম্বর, তারপর
DELETE … WHERE id IN (… rn > 1); শেষে UNIQUE constraint. - টানা তিন দিন বা তার বেশি লগইন করেছেন কারা? — DISTINCT দিন, তারপর তারিখ − ROW_NUMBER দিয়ে দল,
HAVING COUNT(*) >= 3. - ROW_NUMBER, RANK আর DENSE_RANK-এর পার্থক্য? — 9, 9, 7-এ যথাক্রমে 1-2-3, 1-1-3, আর 1-1-2.
- যাঁরা সব পণ্য কিনেছেন, তাঁদের বের করুন। — Relational division: দুই স্তরের NOT EXISTS, অথবা
HAVING COUNT(DISTINCT product_id) = (SELECT COUNT(*) FROM products).
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১Window function আসার আগে “প্রতি দলের সেরা N-টি” কীভাবে লেখা হতো, আর window function কীভাবে SQL-এ ঢুকল? এই ইতিহাস থেকে আজকের কাজের জন্য কী শেখার আছে?
১৯৯০-এর দশক পর্যন্ত SQL-এ একটি সারি তার পাশের সারিগুলো “দেখতে” পারত না। GROUP BY দলের সারিগুলো একটিতে গলিয়ে দেয়, আর সাধারণ SELECT প্রতিটি সারিকে আলাদা দেখে। তাই top-N per group লিখতে হতো correlated subquery দিয়ে: “আমার দলে আমার চেয়ে বেশি মানের সারি N-টির কম” — এই পর্বের বিকল্প ট্যাবের মতো। Running total ছিল self-join বা correlated SUM; median ছিল আরও জটিল self-join. এগুলো চলত, কিন্তু খরচ দ্বিঘাত: দলের আকার k হলে দলপ্রতি প্রায় k² তুলনা। আর লেখাগুলো এত দুর্বোধ্য ছিল যে বহু দল এমন হিসাব ডেটাবেস থেকে বের করে অ্যাপের loop-এ করত।
পরিবর্তন আসে ডেটা বিশ্লেষণের (OLAP) চাহিদা থেকে। Oracle ৮i (১৯৯৯) “analytic function” নামে OVER (PARTITION BY … ORDER BY …) আনে। প্রায় একই সময়ে IBM আর Oracle-এর প্রস্তাবের ভিত্তিতে SQL standard-এ এটি যোগ হয় — প্রথমে SQL:১৯৯৯-এর একটি OLAP সংযোজনে, পরে SQL:২০০৩-এ পূর্ণ standard হিসেবে। এরপর অন্যরা ধীরে ধীরে আনে: SQL Server ২০০৫-এ ROW_NUMBER আর RANK (পূর্ণ frame সমর্থন ২০১২-তে), PostgreSQL ৮.৪ (২০০৯), আর MySQL ৮.০ আর SQLite ৩.২৫ — দুটিই ২০১৮ সালে। অর্থাৎ standard হওয়ার প্রায় পনেরো বছর পর সবচেয়ে জনপ্রিয় দুটি open-source ডেটাবেসে এটি পৌঁছায়।
এই দেরির একটি ফল আজও দেখা যায়: ইন্টারনেটের পুরোনো উত্তর, পুরোনো বই, আর সেগুলো থেকে শেখা AI-ও অনেক সময় correlated subquery বা self-join সমাধান দেয়, যা আজ অপ্রয়োজনে ধীর আর ভুলপ্রবণ। উল্টো দিকে, কোনো পুরোনো সিস্টেমে (MySQL ৫.৭ এখনো অনেক জায়গায় চলে) window function না-ও থাকতে পারে — তখন পুরোনো কৌশলই একমাত্র পথ।
আরেকটি শিক্ষা হলো তত্ত্ব আর চর্চার সম্পর্ক। Window function relational algebra-র মৌলিক অপারেশন নয়; এটি “সাজানো ক্রম” নির্ভর, অথচ relation হলো ক্রমহীন সেট। SQL এখানে বাস্তব প্রয়োজনে তত্ত্ব থেকে সরে এসেছে — যেমন duplicate-ওয়ালা multiset আর NULL-এর ক্ষেত্রেও।
মূল উপলব্ধি: কোনো সমাধানের বয়স দেখে তার মান বিচার করুন। একই প্রশ্নের আধুনিক রূপ প্রায়ই ছোট, দ্রুত আর কম ভুলপ্রবণ — তবে লক্ষ্য DBMS-এর সংস্করণ সেটি সমর্থন করে কি না, আগে নিশ্চিত হোন।
প্র ০২“তারিখ − ROW_NUMBER” কৌশলটি কে আবিষ্কার করেছেন বলা কঠিন, কিন্তু এটি নানা নামে পরিচিত। “টানা” মানে যদি ১ দিন না হয় — যেমন ওয়েবসাইটে ৩০ মিনিটের বেশি বিরতি না থাকলে একই session — তখন কী করবেন?
কৌশলটি SQL সমাজে বহু বছর ধরে ঘুরছে। SQL Server জগতে Itzik Ben-Gan-এর বই আর প্রবন্ধে “gaps and islands” নামটি ব্যাপক পরিচিতি পায়। Oracle-এর ফোরামে একই কৌশলকে কেউ কেউ “Tabibitosan” নামে ডাকেন — জাপানের এক ধরনের পাটিগণিত-সমস্যার নাম থেকে, যা এক ব্যবহারকারী জনপ্রিয় করেছিলেন বলে শোনা যায়। নাম যা-ই হোক, ভিত্তি এই পর্বের প্রমাণটি: দুটি সমান হারে বাড়া ক্রমের পার্থক্য একটি টানা দলের ভেতরে স্থির থাকে।
কিন্তু প্রমাণটির একটি শর্ত আছে: “টানা” মানে ঠিক ১ একক। লগইনের দিন বা চালান নম্বরে তা সত্য। ওয়েবসাইটের ক্লিকে সত্য নয়: ১০:০০, ১০:০৭, ১০:৩০ আর ১১:৪৫-এর ক্লিক — প্রথম তিনটি একটি ভিজিট (প্রতিটি বিরতি ৩০ মিনিটের কম), শেষটি নতুন ভিজিট। এখানে বিয়োগের কৌশল চলে না, কারণ সময়গুলো সমান হারে বাড়ে না।
সাধারণ সমাধান তিন ধাপের। প্রথমে LAG(ts) OVER (PARTITION BY user_id ORDER BY ts) দিয়ে আগের ক্লিকের সময় আনুন। তারপর একটি “নতুন দল শুরু” চিহ্ন: আগের ক্লিক নেই, অথবা বিরতি ৩০ মিনিটের বেশি হলে 1, নইলে 0. শেষে সেই চিহ্নের running SUM — SUM(is_start) OVER (PARTITION BY user_id ORDER BY ts) — প্রতিটি সারিকে তার দলের নম্বর দেয়: প্রথম তিনটি ক্লিক দল 1, শেষটি দল 2. এরপর আগের মতোই GROUP BY করে শুরু, শেষ আর ক্লিকের সংখ্যা। এটিই sessionization, যা ওয়েব অ্যানালিটিক্স টুলগুলোর একটি মৌলিক হিসাব।
এই LAG + running SUM পদ্ধতি আসলে বেশি সাধারণ: “টানা” মানে যা-ই হোক — একই অবস্থা (মেশিন চালু/বন্ধ), নির্দিষ্ট সীমার মধ্যে তাপমাত্রা, বা একই দামে পরপর লেনদেন — শর্তটি is_start-এ লিখলেই হলো। বিয়োগের কৌশলটি তার একটি বিশেষ, সংক্ষিপ্ত রূপ। দুটির খরচই একটি sort আর এক বা দুটি window pass — n log n.
মূল উপলব্ধি: Island মানে “একটি দলের শুরু চিহ্নিত করা, তারপর চিহ্ন গুনে দল নম্বর দেওয়া”। তারিখ − ROW_NUMBER এই ধারণার একটি চতুর শর্টকাট, যা শুধু ১-একক ধাপে চলে।
প্র ০৩SUM, COUNT, AVG প্রায় সব DBMS-এ আছে, অথচ MEDIAN অনেক জায়গায় নেই। median হিসাব করা মৌলিকভাবে কঠিন কেন, আর বড় ডেটায় বাস্তব সিস্টেমগুলো কী করে?
১৯৯০-এর দশকের মাঝামাঝি Jim Gray ও সহকর্মীরা “Data Cube” প্রবন্ধে aggregate ফাংশনকে তিন ভাগে ভাগ করেন। Distributive: ডেটা টুকরো করে প্রতিটির ফল বের করে সেই ফলগুলো মেলালেই উত্তর — SUM, COUNT, MIN, MAX. Algebraic: অল্প কয়েকটি distributive মান রাখলেই চলে — AVG = SUM / COUNT. Holistic: কোনো নির্দিষ্ট আকারের সারাংশ যথেষ্ট নয় — median, mode, rank. দুই ভাগের median জানলেও পুরোটার median জানা যায় না; ভাগ দুটি ৫, ৬, ৭ আর ১, ২, ১০০ হলে median দুটি ৬ আর ২, অথচ পুরোটার median ৫ আর ৬-এর গড় ৫.৫, যা এই দুই সংখ্যা থেকে বের করা অসম্ভব।
এর বাস্তব ফল বড়। SUM-কে হাজারটি মেশিনে ভাগ করে চালানো যায়, প্রতিটি একটি সংখ্যা ফেরত দেয়। Median-এর জন্য, নীতিগতভাবে, সব মান এক জায়গায় এনে সাজাতে হয় (O(n log n)), বা অন্তত quickselect-এর মতো অ্যালগরিদমে সব মান মেমরিতে রাখতে হয়। স্ট্রিমিং ডেটায় (একবার দেখেই ফেলে দিতে হয়) নির্ভুল median-এর জন্য মেমরি ডেটার আকারের অনুপাতে লাগে।
তাই বড় সিস্টেমগুলো আনুমানিক পথ নেয়। Greenwald–Khanna-র মতো sketch অ্যালগরিদম আর Ted Dunning-এর t-digest অল্প মেমরিতে নির্দিষ্ট ত্রুটিসীমার মধ্যে percentile দেয়, আর মজার ব্যাপার হলো এদের অনেকগুলো (যেমন t-digest) মেলানো যায় — অর্থাৎ আনুমানিক median-কে প্রায় distributive বানানো যায়। অনেক analytics ডেটাবেস আর monitoring টুল তাই approx_percentile-জাতীয় ফাংশন দেয়; ওয়েবসাইটের p95 বা p99 latency রিপোর্টগুলো সাধারণত এভাবেই হিসাব হয়।
OLTP ডেটাবেসে, যেখানে দলগুলো ছোট, এই পর্বের ROW_NUMBER পদ্ধতি বা PostgreSQL-এর percentile_cont যথেষ্ট — খরচ মূলত একটি sort. কিন্তু “দশ কোটি লেনদেনের median” চাইলে প্রথম প্রশ্ন হওয়া উচিত: নির্ভুল উত্তর কি সত্যিই লাগবে?
মূল উপলব্ধি: Median holistic — টুকরোর উত্তর মিলিয়ে পুরোটার উত্তর হয় না। ছোট ডেটায় সাজিয়ে নিন; বিশাল ডেটায় আনুমানিক sketch আর তার ত্রুটিসীমা মেনে নিন।
প্র ০৪সরকারি ভাতা বা বিতরণ তালিকায় একই মানুষের নাম দুবার থাকা একটি পরিচিত সমস্যা। ডেটাবেসের দিক থেকে duplicate কেন এত সহজে তৈরি হয়, আর কেবল SQL দিয়ে কতটা সমাধান সম্ভব?
Duplicate তৈরি হয় তিন পথে। প্রথম, একই সত্তার ভিন্ন লেখা: “Md. Rahim Uddin” আর “Mohammad Rahim Uddin”, “+8801711…” আর “01711…”, বাংলা আর ইংরেজি বানান। দ্বিতীয়, ভিন্ন উৎস জোড়া: তিনটি শাখার তালিকা, প্রতিটির নিজের ক্রমিক নম্বর, তাই কোনো সাধারণ key নেই। তৃতীয়, প্রক্রিয়ার ত্রুটি: ফর্ম “Submit” দুবার চাপা, নেটওয়ার্ক বিভ্রাটে অ্যাপের একই অনুরোধ আবার পাঠানো, বা “আগে খুঁজে দেখি, তারপর INSERT” কোডের race condition.
তৃতীয়টির সমাধান পুরোপুরি ডেটাবেসের হাতে, আর সেটি নির্ভুল: স্বাভাবিক key-তে (NID নম্বর, পরিষ্কার করা মোবাইল নম্বর) UNIQUE constraint. দুটি transaction একসাথে একই নম্বর ঢোকাতে চাইলে DBMS-এর index আর lock নিশ্চিত করে একটি সফল হবে, অন্যটি ত্রুটি পাবে — অ্যাপের কোনো চেক এই নিশ্চয়তা দিতে পারে না। পুনরায় পাঠানো অনুরোধের জন্য অনেক পেমেন্ট সিস্টেম একটি “idempotency key” রাখে, যার ওপর UNIQUE — একই অনুরোধ দুবার এলে দ্বিতীয়টি নতুন লেনদেন তৈরি করে না।
প্রথম দুটি অনেক কঠিন, কারণ প্রশ্নটি তখন “এই দুটি সারি কি একই মানুষ?” — যার উত্তর প্রায়ই সম্ভাবনার। এই সমস্যার নাম record linkage বা entity resolution. ১৯৬৯ সালে Ivan Fellegi আর Alan Sunter এর একটি সম্ভাবনাভিত্তিক তত্ত্ব দেন: প্রতিটি জোড়ার জন্য ক্ষেত্রভিত্তিক মিল (নাম, জন্মতারিখ, ঠিকানা) থেকে একটি স্কোর, তারপর দুটি সীমা — ওপরে নিশ্চিত মিল, নিচে নিশ্চিত অমিল, মাঝে মানুষের হাতে যাচাই। আধুনিক টুলগুলো এর সাথে বানানের দূরত্ব (edit distance) আর উচ্চারণভিত্তিক তুলনা যোগ করে।
SQL এখানে কাঠামো দেয়, বিচারবুদ্ধি নয়: পরিষ্কার করা (normalization of values), সম্ভাব্য জোড়া খোঁজা (একই জন্মতারিখ আর জেলা — “blocking”), স্কোরের হিসাব, আর শেষে ROW_NUMBER দিয়ে একটি রেখে বাকিগুলো মোছা বা মূল সারির সাথে যুক্ত করা। “কাকে একই মানুষ ধরব” — সেই নিয়মটি ব্যবসা আর আইনের, আর ভুল মিলের মূল্য (একজনের ভাতা আরেকজনের নামে চলে যাওয়া) প্রায়ই ভুল অমিলের চেয়ে বেশি।
মূল উপলব্ধি: প্রক্রিয়ার duplicate ঠেকায় UNIQUE constraint — নির্ভুলভাবে। পরিচয়ের duplicate খুঁজতে SQL হাতিয়ার দেয়, কিন্তু “একই কি না” সিদ্ধান্তে মানুষ আর স্পষ্ট নিয়ম লাগে।
প্র ০৫ভাজক ফাঁকা হলে — ধরুন CSE-র কোনো মূল কোর্সই নেই — দুই স্তরের NOT EXISTS আর COUNT পদ্ধতি কী ফল দেয়? কোনটি “ঠিক”, আর Joe Celko-র বিখ্যাত পাইলট-উদাহরণ এখানে কী শেখায়?
চালিয়ে দেখা যাক। db-uni-তে শর্ত c.dept_id = 9 দিলে (এমন বিভাগ নেই) দুই স্তরের NOT EXISTS ৮ জন শিক্ষার্থীর সবাইকে ফেরত দেয়। কারণ “এমন কোনো কোর্স নেই যা সে নেয়নি” — কোর্সই নেই, তাই সবার জন্য কথাটি সত্য। যুক্তিবিদ্যায় এটি vacuous truth: ফাঁকা সেটের “প্রতিটি সদস্য” সম্পর্কে যেকোনো দাবি সত্য। Relational algebra-র division-এর সংজ্ঞাও একই: R ÷ ∅ = R-এর সব ছাত্র।
COUNT পদ্ধতি দেয় শূন্য সারি। JOIN-এ কোনো মূল কোর্স মেলে না, তাই কোনো দলই তৈরি হয় না, আর HAVING-এর = 0 পরীক্ষার সুযোগই আসে না। আবার COUNT-কে LEFT JOIN দিয়ে লিখলে ফল বদলে যায় — লেখার ধরন অনুযায়ী উত্তর ভিন্ন।
কোনটি ঠিক? গণিতে প্রথমটি। ব্যবসায় প্রশ্নটির অর্থ দেখতে হয়। SQL বিশেষজ্ঞ Joe Celko division ব্যাখ্যা করতেন একটি উদাহরণে: “কোন পাইলটরা হ্যাঙ্গারের সব বিমান চালাতে পারেন?” হ্যাঙ্গার ফাঁকা হলে, যুক্তিতে সব পাইলটই যোগ্য। কিন্তু কেউ যদি এই তালিকা দেখে পুরস্কার দেন, তা অর্থহীন হবে। তিনি তাই দুই রূপ আলাদা করতেন: “division with remainder” (পাইলট হ্যাঙ্গারের বাইরের বিমানও চালাতে পারেন — এই পর্বের প্রশ্নের মতো; Arif-এর CSE302) আর “exact division” (ঠিক ভাজকের বিমানগুলোই, বেশি বা কম নয়)। Exact division-এ COUNT পদ্ধতিতে দুটি শর্ত লাগে: ভাজকের মধ্যে সংখ্যা = │S│, আর মোট সংখ্যাও = │S│.
ব্যবহারিক পরামর্শ: ভাজক ফাঁকা হতে পারে এমন ক্ষেত্রে (নতুন বিভাগ, এখনো কোর্স নেই) কাঙ্ক্ষিত আচরণটি স্পষ্ট লিখুন — যেমন বাইরে AND EXISTS (SELECT 1 FROM courses WHERE …) দিয়ে “ভাজক থাকলেই তবে”। আর পরীক্ষায় division-এর প্রশ্নে এই কিনারাটি উল্লেখ করলে উত্তরটি পূর্ণ হয়।
মূল উপলব্ধি: “সব” প্রশ্নের দুই SQL রূপ সাধারণ ডেটায় একই উত্তর দেয়, কিন্তু ফাঁকা ভাজকে আলাদা। NOT EXISTS গণিতের সংজ্ঞা মানে; COUNT মানে না — কোনটি চাই, প্রশ্নের অর্থ থেকে ঠিক করুন।
অনুশীলন
উপরের যেকোনো db-shop বা db-uni এডিটরে কোড বদলে নিজের উত্তর চালিয়ে দেখতে পারেন।
-
কোয়েরি লিখুন: db-shop-এ প্রতিটি শ্রেণির সবচেয়ে দামি পণ্য (products.price) বের করুন। দুটি পণ্যের দাম সমান হলে দুটিই দেখাতে হবে। ফলে কয়টি সারি আসবে?
SELECT category_id, name, price FROM ( SELECT category_id, name, price, RANK() OVER (PARTITION BY category_id ORDER BY price DESC) AS r FROM products) WHERE r = 1 ORDER BY category_id;৪টি সারি:
1 | Mobile Phone | 15000,2 | DSA Book | 650,3 | Saree | 4500,4 | Rice 5kg | 420. tie রাখতে হবে বলে RANK (DENSE_RANK-ও চলত, কারণ r = 1-এ দুটি একই)। ROW_NUMBER দিলে সমান দামের একটি পণ্য নীরবে বাদ পড়ত। এখানে derived table ব্যবহার হয়েছে; CTE-তেও একই। -
হাতে trace করুন: একজন ব্যবহারকারী নভেম্বরের এই দিনগুলোতে লগইন করেছেন (DISTINCT করার পর): 03, 04, 05, 08, 09, 11. প্রতিটির ROW_NUMBER আর “তারিখ − rn” (anchor) লিখুন। কয়টি island, প্রতিটির দৈর্ঘ্য কত?
rn যথাক্রমে 1–6. anchor: 03 − 1 = 02, 04 − 2 = 02, 05 − 3 = 02, 08 − 4 = 04, 09 − 5 = 04, 11 − 6 = 05 (সব নভেম্বরের তারিখ)। তিনটি আলাদা anchor, তাই তিনটি island: 03–05 (৩ দিন), 08–09 (২ দিন), 11 (১ দিন)। খেয়াল করুন, anchor প্রতিটি ফাঁকে বাড়ে — 06 আর 07 দুই দিনের ফাঁকে anchor ২ বেড়েছে (02 থেকে 04), 10-এর এক দিনের ফাঁকে ১ (04 থেকে 05). বৃদ্ধির পরিমাণই ফাঁকের দৈর্ঘ্য।
-
Pivot লিখুন: db-uni-তে প্রতিটি বিভাগের নাম একটি সারি, আর batch 2021, 2022, 2023 তিনটি কলাম — প্রতিটি ঘরে শিক্ষার্থীর সংখ্যা। কোনো শিক্ষার্থী নেই এমন বিভাগও দেখাতে হবে। English বিভাগের সারিতে কী থাকবে, আর Habib Ullah (বিভাগ NULL)-এর কী হবে?
SELECT d.name AS dept, SUM(CASE WHEN s.batch = 2021 THEN 1 ELSE 0 END) AS b2021, SUM(CASE WHEN s.batch = 2022 THEN 1 ELSE 0 END) AS b2022, SUM(CASE WHEN s.batch = 2023 THEN 1 ELSE 0 END) AS b2023 FROM departments d LEFT JOIN students s ON s.dept_id = d.dept_id GROUP BY d.dept_id, d.name ORDER BY d.dept_id;ফল:
CSE | 2 | 1 | 1,EEE | 1 | 1 | 0,BBA | 0 | 1 | 0,English | 0 | 0 | 0. English-এর জন্য LEFT JOIN একটি NULL-ভরা সারি দেয়;NULL = 2021UNKNOWN, তাই CASE ELSE-এ যায় — তিনটিতেই 0. Habib কোনো বিভাগের সাথে মেলেন না, তাই এই রিপোর্টে নেই। তাঁকেও দেখাতে চাইলে students থেকে LEFT JOIN করুন (তখন English বাদ পড়বে) — দুটোই চাইলে FULL OUTER JOIN (পর্ব ১৯)। -
ফল আগে বলুন, তারপর লিখুন: db-uni-তে বিভাগভিত্তিক median cgpa কত? cgpa NULL বাদ দিন। CSE-তে cgpa 3.75, 3.40, 3.90, 3.55; EEE-তে 2.95 আর একটি NULL; BBA-তে 3.10; আর বিভাগ NULL একজনের 2.80. হাতে হিসাব করে তারপর কোয়েরিটি লিখুন।
WITH r AS (SELECT dept_id, cgpa, ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY cgpa) AS rn, COUNT(*) OVER (PARTITION BY dept_id) AS n FROM students WHERE cgpa IS NOT NULL) SELECT dept_id, AVG(cgpa) AS median_cgpa FROM r WHERE rn IN ((n + 1) / 2, (n + 2) / 2) GROUP BY dept_id;হাতের হিসাব: CSE সাজালে 3.40, 3.55, 3.75, 3.90 — n = 4, মাঝের দুটির গড় (3.55 + 3.75) / 2 = 3.65. EEE-তে NULL বাদে একটিই মান, median 2.95. BBA 3.10. বিভাগ NULL-এর দলেও একটি মান, 2.80 — PARTITION BY আর GROUP BY দুটিই NULL-কে একটি দল ধরে। কোয়েরির ফলও তাই:
NULL | 2.8,1 | 3.65,2 | 2.95,3 | 3.1.WHERE cgpa IS NOT NULLনা দিলে EEE-তে n = 2 হতো, আর NULL সারিটি rn-এ জায়গা নিয়ে median ভুল করত।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমলগইন টেবিল থেকে প্রতিটি ব্যবহারকারীর টানা দিনের দল (gaps and islands) বের করার ধাপগুলো সাজান (▲▼ চেপে সরান)
- একই দিনের একাধিক লগইন এক করা (DISTINCT user_id, login_date)
- প্রতিটি ব্যবহারকারীর ভেতরে তারিখের ক্রমে ROW_NUMBER দেওয়া
- প্রতিটি তারিখ থেকে তার ক্রমিক নম্বর-সংখ্যক দিন বিয়োগ করে anchor বানানো
- user_id আর anchor দিয়ে GROUP BY করা
- প্রতিটি দলের MIN, MAX আর COUNT দিয়ে শুরু, শেষ আর দৈর্ঘ্য বের করা
একটি দলে বিক্রির সংখ্যা 9, 9, 7, 5. প্রতিটি দলের “সেরা ২টি” পেতে RANK() OVER (PARTITION BY … ORDER BY sold DESC) AS r হিসাব করে WHERE r <= 2 দেওয়া হলো। এই দল থেকে কয়টি সারি আসবে?
- ১টি
- ৩টি
- ২টি
- ৪টি
SELECT name FROM products WHERE ROW_NUMBER() OVER (ORDER BY price DESC) <= 3; চালালে ত্রুটি আসে। মূল কারণ কী?
- ROW_NUMBER-এ PARTITION BY বাধ্যতামূলক
- Window function-এর মান WHERE চলার সময় তৈরিই হয়নি
- window-এর ভেতরে DESC লেখা যায় না
- WHERE-এর বদলে HAVING লিখলেই ঠিক হয়ে যাবে
একটি টেবিলে status কলাম আছে। মাসভিত্তিক delivered অর্ডারের সংখ্যা গুনতে নিচের কোনটি ভুল ফল দেয় (প্রতিটি মাসের সব অর্ডার গোনে)?
SUM(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END)COUNT(CASE WHEN status = 'delivered' THEN 1 END)COUNT(*) FILTER (WHERE status = 'delivered')COUNT(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END)
একটি ওয়ালেটের খাতায় দুটি লেনদেনের txn_time হুবহু একই। SUM(amount) OVER (PARTITION BY wallet ORDER BY txn_time) — কোনো frame উল্লেখ নেই — এই দুটি সারিতে কী দেখাবে?
- দুটি সারিতেই একই running total, দুটি লেনদেন যোগ হওয়ার পরের মান
- প্রথমটিতে মাঝের মান, দ্বিতীয়টিতে চূড়ান্ত মান
- ত্রুটি, কারণ ORDER BY-এর মান অনন্য নয়
- দ্বিতীয় সারিটি ফল থেকে বাদ পড়বে
চারটি অর্ডারের মোট মূল্য 10, 20, 40 আর 100 টাকা। এদের median কত?
- 42.5
- 20
- 30
- 40
“যে শিক্ষার্থী তালিকার সব কোর্স নিয়েছেন” — দুই স্তরের NOT EXISTS দিয়ে লেখা হলো: এমন কোনো কোর্স নেই যা তিনি নেননি। কোর্সের তালিকাটি যদি ফাঁকা হয়, ফল কী?
- কোনো শিক্ষার্থী আসবে না
- সব শিক্ষার্থী আসবে
- ত্রুটি দেবে
- ফল UNKNOWN, তাই NULL-ভরা সারি আসবে
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- Top-N আর সর্বশেষ সারি: CTE-তে
ROW_NUMBER() OVER (PARTITION BY … ORDER BY …), বাইরেrn <= N. ঠিক N-টি চাইলে tiebreaker; tie রাখতে RANK; আলাদা মান গুনতে DENSE_RANK. - Pivot = প্রতিটি কলামের জন্য
SUM(CASE …)বাFILTER; কলামের তালিকা লেখার সময়েই জানতে হয়। Unpivot = প্রতিটি কলামের একটি SELECT, UNION ALL দিয়ে জোড়া। - Deduplicate: আগে key পরিষ্কার, তারপর
GROUP BY … HAVING COUNT(*) > 1, ROW_NUMBER দিয়ে একটি রেখে DELETE, শেষে UNIQUE আর upsert — এই ক্রম। - Gaps and islands: DISTINCT দিন, তারপর তারিখ − ROW_NUMBER = দলের নাম; হারানো নম্বর খুঁজতে LEAD. Running balance-এ tiebreaker আর ROWS frame.
- Median holistic — SQLite-এ ROW_NUMBER আর COUNT দিয়ে মাঝের অবস্থান। “সব” প্রশ্ন = relational division: দুই স্তরের NOT EXISTS, বা COUNT(DISTINCT) = │ভাজক│; ফাঁকা ভাজকে দুটি আলাদা।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ২৮ · Stored procedure আর user-defined functionপরবর্তী পর্বকয়েক ধাপের কাজ ডেটাবেসের ভেতরে একটি নামে।
- ডেটাবেস (DBMS) ও SQL · কোর্স হোম৬৭টি পর্বপুরো কোর্সের রোডম্যাপ — ১০টি মডিউল।
- DSA · Prefix sumগভীরে যানRunning total-এর পেছনের অ্যালগরিদম আর তার জটিলতা।
- Python · ডেটা সায়েন্স পরিচিতিপাশের ট্র্যাকpandas-এ pivot, duplicate আর median — SQL-এর সাথে তুলনা করে।
- Python · SQLite ডেটাবেসহাতে-কলমেনিজের অ্যাপ থেকে এই প্যাটার্নগুলো চালানো।