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

Aggregation — GROUP BY আর HAVING

Aggregation in SQL — COUNT, SUM, AVG and NULLs, GROUP BY, HAVING vs WHERE, Conditional Aggregation and ROLLUP

Taught in Bangla, this lesson covers aggregate functions COUNT, SUM and AVG and how they treat NULLs, the empty-set trap where SUM returns NULL and COUNT returns zero, GROUP BY and the rule that every selected column must be grouped or aggregated, WHERE versus HAVING, grouping by computed values such as month, conditional aggregation and ROLLUP subtotals.

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

  • পাঁচটি aggregate ফাংশন, COUNT(*), COUNT(col) আর COUNT(DISTINCT col)-এর পার্থক্য, আর NULL ও ফাঁকা সেটে প্রতিটির আচরণ
  • GROUP BY এক ও একাধিক কলামে — ভেতরে কী ঘটে, আর “SELECT-এর প্রতিটি কলাম GROUP BY-তে” নিয়ম কেন
  • WHERE বনাম HAVING — SELECT-এর যৌক্তিক ক্রম (FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY)
  • হিসাব করা মান দিয়ে দল (মাস, দামের সীমা), GROUP_CONCAT/STRING_AGG আর FILTER দিয়ে শর্তসাপেক্ষ গোনা
  • ROLLUP, CUBE, GROUPING SETS দিয়ে উপ-মোট — আর ভারিত গড় বনাম গড়ের গড়ের ফাঁদ

১ · অনেক সারি থেকে একটি উত্তর — aggregate ফাংশন

পর্ব ১৫-এ SELECT দিয়ে সারি বাছাই, ফিল্টার আর সাজানো শিখেছেন। কিন্তু সেখানে প্রতিটি ফল-সারি ছিল টেবিলের একটি সারির প্রতিচ্ছবি। বাস্তবের বেশিরভাগ প্রশ্ন এমন নয়: “এ মাসে কয়টি অর্ডার এসেছে?”, “মোট বিক্রি কত টাকা?”, “CSE বিভাগের গড় CGPA কত?” — এদের উত্তর একটি সংখ্যা, যা অনেক সারি মিলিয়ে বের হয়। এই কাজটির নাম aggregation (একত্রীকরণ), আর যে ফাংশন অনেক সারি নিয়ে একটি মান দেয়, তাকে বলে aggregate function.

মুদি দোকানের মালিক সারা মাস খাতায় প্রতিটি বিক্রি এক লাইনে লেখেন। মাস শেষে তিনি প্রতিটি লাইন আবার পড়েন না — শুধু জানতে চান মোট কত টাকা (SUM), কয়টি বিক্রি (COUNT), সবচেয়ে বড় বিক্রি কোনটি (MAX). আর যদি জানতে চান “চাল, তেল আর ডাল — কোনটিতে কত বিক্রি”, তাহলে খাতার লাইনগুলো পণ্য অনুযায়ী আলাদা আলাদা স্তূপে ভাগ করে প্রতিটি স্তূপের যোগফল লেখেন। সেই স্তূপে ভাগ করাই GROUP BY — অংশ ৩-এ আসছে।

SQL standard-এর পাঁচটি মূল aggregate ফাংশন প্রায় সব DBMS-এ একই নামে আছে:

ফাংশনকী দেয়NULL-কে কী করেফাঁকা সেটে ফল
COUNT(*)সারির সংখ্যাগোনে — সারিটি আছে, এটুকুই যথেষ্ট0
COUNT(col)col যেসব সারিতে NULL নয়, তাদের সংখ্যাবাদ দেয়0
COUNT(DISTINCT col)col-এর আলাদা আলাদা মানের সংখ্যাবাদ দেয়0
SUM(col)যোগফলবাদ দেয়NULL
AVG(col)গড় = SUM ÷ COUNT(col)বাদ দেয় — ভাগও হয় NULL-বিহীন সংখ্যায়NULL
MIN(col), MAX(col)সবচেয়ে ছোট আর বড় মান (লেখা আর তারিখেও চলে)বাদ দেয়NULL

টেবিলের তৃতীয় কলামটিই এই পর্বের প্রথম শিক্ষা। পর্ব ১৬-এ দেখেছেন NULL মানে “অজানা”, শূন্য নয়। অজানা মান যোগ করলে কী হয়? 3.75 + NULL একটি সাধারণ হিসাবে NULL — কিন্তু aggregate ফাংশন সেভাবে চলে না। SQL standard-এর নিয়ম: aggregate ফাংশন হিসাবের আগে NULL মানগুলো ফেলে দেয়, তারপর বাকিদের ওপর কাজ করে। একমাত্র ব্যতিক্রম COUNT(*), যা কোনো কলামের মান দেখেই না — শুধু সারি গোনে।

পরীক্ষা করে দেখি। 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 (বিভাগ বাছেননি)। পুরো ডেটা এডিটরের নিচের “নমুনা ডেটা” বাক্সে।

SELECT COUNT(*)                AS all_rows,
       COUNT(cgpa)             AS with_cgpa,
       COUNT(DISTINCT dept_id) AS depts,
       SUM(cgpa) AS total, AVG(cgpa) AS average,
       MIN(cgpa) AS lowest, MAX(cgpa) AS highest
FROM students;

-- গড় নিজে হাতে: কোন সংখ্যা দিয়ে ভাগ করবেন?
SELECT SUM(cgpa) / COUNT(*)    AS divided_by_8,
       SUM(cgpa) / COUNT(cgpa) AS divided_by_7
FROM students;

প্রথম ফল একটি সারি: 8 | 7 | 3 | 23.45 | 3.35 | 2.8 | 3.9. আটজন শিক্ষার্থী (COUNT(*)), কিন্তু cgpa আছে সাতজনের (COUNT(cgpa)) — Fahim বাদ। আলাদা বিভাগ ৩টি, যদিও dept_id কলামে চারটি “রকম” দেখা যায় (1, 2, 3 আর NULL) — COUNT(DISTINCT) NULL-কে মান হিসেবে ধরে না। SUM = 23.45, আর AVG = 23.45 ÷ 7 = 3.35.

দ্বিতীয় ফল 2.93125 | 3.35. নিজে গড় বানাতে গিয়ে COUNT(*) দিয়ে ভাগ করলে Fahim-কে শূন্য ধরে নেওয়া হয় — ফল 2.93, যা ভুল। AVG নিজে থেকেই NULL-বিহীন সংখ্যা (৭) দিয়ে ভাগ করে। কোনটি ঠিক, তা নির্ভর করে প্রশ্নের ওপর: “যাঁদের cgpa জানা, তাঁদের গড়” হলে 3.35; “অজানাকে শূন্য ধরুন” হলে স্পষ্ট করে AVG(COALESCE(cgpa, 0)) লিখুন।

নিয়ম

COUNT(*) সারি গোনে; COUNT(col), SUM, AVG, MIN, MAX আগে NULL ফেলে দেয়, তারপর হিসাব করে। DISTINCT লিখলে আগে একই মানের নকল ফেলে দেয় — তাই COUNT(DISTINCT col) ≤ COUNT(col) ≤ COUNT(*), সবসময়।

দুটি ছোট কিন্তু কাজের তথ্য। প্রথমত, COUNT(1) আর COUNT(*) একই কাজ করে — 1 কখনো NULL নয়, তাই প্রতিটি সারি গোনা হয়; “COUNT(1) দ্রুত” কথাটি আধুনিক DBMS-এ একটি পুরোনো গুজব। দ্বিতীয়ত, SUM(DISTINCT col) আর AVG(DISTINCT col)-ও লেখা যায়, কিন্তু খুব কমই কাজে লাগে: দুটি অর্ডারের দাম কাকতালীয়ভাবে সমান হলে একটি বাদ পড়ে যাবে — এটি প্রায় সবসময় ভুল উত্তর।

২ · ফাঁকা সেটের ফাঁদ — SUM কেন NULL, আর COUNT কেন 0

এবার একটি প্রশ্ন যা দেখতে নিরীহ: “যে গ্রাহক কিছুই কেনেননি, তিনি মোট কত টাকা খরচ করেছেন?” উত্তর তো শূন্য টাকা। কিন্তু SQL বলে NULL. দোকানের ডেটাবেস db-shop দিয়ে দেখি। এতে পাঁচটি টেবিল: customers (customer_id, name, city, phone, joined_on — ৮ জন গ্রাহক), categories (category_id, name — ৪টি শ্রেণি), 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 — ১৬টি লাইন; unit_price হলো বিক্রির দিনের দাম)। গ্রাহক Tanvir Ahmed (customer_id 5) একটিও অর্ডার দেননি।

-- Tanvir (5)-এর অর্ডার: কোনো সারি মেলে না
SELECT COUNT(*) AS orders, MAX(order_date) AS last_order
FROM orders WHERE customer_id = 5;

-- তাঁর খরচ: SUM ফাঁকা সেটে NULL, COALESCE বদলে দেয় 0-তে
SELECT SUM(quantity * unit_price)              AS spent,
       COALESCE(SUM(quantity * unit_price), 0) AS spent_safe
FROM order_items
WHERE order_id IN (SELECT order_id FROM orders WHERE customer_id = 5);

-- GROUP BY থাকলে: ফাঁকা সেট থেকে কোনো দলই হয় না
SELECT customer_id, COUNT(*) FROM orders
WHERE customer_id = 5 GROUP BY customer_id;

প্রথম ফল 0 | NULL: কোনো সারি নেই, তাই COUNT শূন্য, কিন্তু “সবচেয়ে শেষ তারিখ” বলে কিছু নেই — MAX দেয় NULL. দ্বিতীয় ফল NULL | 0. (WHERE-এর ভেতরের IN (SELECT …) অংশটি Tanvir-এর অর্ডার নম্বরগুলোর তালিকা বানায় — এখানে ফাঁকা তালিকা; এমন ভেতরের কোয়েরি, অর্থাৎ subquery, পর্ব ২১-এ বিস্তারিত।) তৃতীয় ফলটি সবচেয়ে চমকপ্রদ: কোনো সারিই নেই, একটি শূন্যও নয়। GROUP BY না থাকলে পুরো টেবিল একটি দল, আর ফাঁকা হলেও সেই একটি দলের একটি ফল-সারি আসে; GROUP BY থাকলে ফাঁকা সেট থেকে একটিও দল তৈরি হয় না।

কেন এমন নকশা? একটিও সংখ্যা না থাকলে যোগফল শূন্য — গণিতে এটাই স্বাভাবিক। কিন্তু MAX বা AVG-এর ক্ষেত্রে নয়: একটিও সংখ্যা না থাকলে গড় হয় ০ ÷ ০, যার কোনো মান নেই। সবার জন্য একটি নিয়ম রাখতে standard বলেছে: সারি না থাকলে COUNT ছাড়া সবাই NULL. এই সিদ্ধান্ত নিয়ে চার দশক ধরে তর্ক চলছে (ভাবনার প্রশ্ন ১), কিন্তু বাস্তবে নিয়মটি মেনে চলতে হয়। কাজের কৌশল তিনটি:

  • COALESCE(SUM(x), 0) — COALESCE তালিকার প্রথম অ-NULL মান ফেরত দেয়। রিপোর্টে “০ টাকা” দেখাতে এটিই সবচেয়ে বহনযোগ্য (portable) উপায়।
  • SQLite-এর TOTAL(x) — SUM-এর মতো, কিন্তু ফাঁকা সেটে 0.0 দেয় আর সবসময় দশমিক সংখ্যা ফেরত দেয়। শুধু SQLite-এ আছে, তাই বহনযোগ্য কোডে না লেখাই ভালো।
  • অ্যাপের কোডে NULL ধরে রাখা — Python-এ None, Java-তে null আসবে। total + 100 লিখলে ক্র্যাশ বা ভুল — তাই হয় SQL-এ COALESCE, নয়তো অ্যাপে স্পষ্ট পরীক্ষা।

COALESCE কোথায় বসাবেন, সেটিও গুরুত্বপূর্ণ। COALESCE(SUM(x), 0) মানে “ফল NULL হলে 0”. আর SUM(COALESCE(x, 0)) মানে “প্রতিটি NULL মানকে 0 ধরে যোগ” — যোগফলে পার্থক্য নেই, কিন্তু ফাঁকা সেটে এটি এখনো NULL দেয় (যোগ করার মতো একটি সারিও নেই)। আর AVG-এর ক্ষেত্রে AVG(COALESCE(x, 0)) ভাগের সংখ্যাই বদলে দেয় — অংশ ১-এর 2.93 বনাম 3.35.

৩ · GROUP BY — সারিগুলো ঝুড়িতে ভাগ, প্রতি ঝুড়িতে একটি উত্তর

এখন পর্যন্ত aggregate পুরো টেবিলকে একটি দল ধরে একটি সারি দিয়েছে। দোকানের মালিকের পরের প্রশ্ন: “প্রতিটি অবস্থার (status) কয়টি অর্ডার?” এখানে চারটি উত্তর চাই, প্রতিটি status-এর জন্য একটি। GROUP BY ঠিক এই কাজ করে: যে কলাম বা কলামগুলো দেওয়া হয়, তাদের মান যেসব সারিতে এক, সেগুলো একটি দলে (group) যায়; তারপর প্রতিটি দলে আলাদা করে aggregate হিসাব হয়, আর প্রতিটি দল থেকে ঠিক একটি ফল-সারি আসে। নিচের ডায়াগ্রামে দশটি অর্ডারের ওপর GROUP BY status ধাপে ধাপে দেখুন:

orders (১০টি সারি)এখনো কোনো ভাগ নেই
SELECT status, COUNT(*) AS orders FROM orders
GROUP BY status ORDER BY orders DESC, status;

 order_id │ customer_id │ status
 1001     │ 1           │ delivered
 1002     │ 2           │ delivered
 1003     │ 1           │ shipped
 1004     │ 3           │ cancelled
 1005     │ 4           │ delivered
 1006     │ 6           │ pending
 1007     │ 2           │ delivered
 1008     │ 8           │ shipped
 1009     │ 1           │ pending
 1010     │ 7           │ delivered
status অনুযায়ী ঝুড়িতে ভাগএকই মান = একই ঝুড়ি
[delivered]  1001  1002  1005  1007  1010
[shipped]    1003  1008
[pending]    1006  1009
[cancelled]  1004

customer_id আর order_date সারিগুলোর সাথেই
ঝুড়িতে আছে, কিন্তু ফলে আসবে না
প্রতিটি ঝুড়িতে COUNT(*)ঝুড়ির ভেতরে আলাদা হিসাব
[delivered]  1001  1002  1005  1007  1010   → COUNT = 5
[shipped]    1003  1008                     → COUNT = 2
[pending]    1006  1009                     → COUNT = 2
[cancelled]  1004                           → COUNT = 1
প্রতি ঝুড়ি থেকে একটি সারিদলের মান + aggregate
 status    │ orders
 delivered │ 5
 shipped   │ 2
 pending   │ 2
 cancelled │ 1

এখনো ক্রম নিশ্চিত নয় — DBMS যে ক্রমে
দল বানিয়েছে, সেই ক্রমেই আসতে পারে
ORDER BY orders DESC, statusচূড়ান্ত ফল
 status    │ orders
 delivered │ 5
 pending   │ 2
 shipped   │ 2
 cancelled │ 1

pending আর shipped দুটিতেই 2 —
দ্বিতীয় key (status) টাই ভাঙে
হিসাব
ইনপুট সারি
ঝুড়ি (দল)
ফল-সারি
  1. শুরুতে দশটি সারি। orders টেবিলের প্রতিটি অর্ডার এক সারিতে। লাল দাগ দেওয়া পাঁচটি সারির status একই — delivered.
  2. ঝুড়িতে ভাগ। DBMS প্রতিটি সারির status দেখে তাকে সেই নামের ঝুড়িতে রাখে। চারটি আলাদা মান, তাই চারটি ঝুড়ি। সারির বাকি কলামগুলোও ঝুড়িতে যায়, কিন্তু এক ঝুড়িতে customer_id-এর অনেক মান — সেগুলো একটি ফল-সারিতে বসানো যাবে না।
  3. প্রতিটি ঝুড়িতে aggregate. COUNT(*) প্রতিটি ঝুড়ির ভেতরের সারি আলাদা করে গোনে: 5, 2, 2, 1. মোট ১০ — কোনো সারি হারায়নি বা দুবার গোনা হয়নি।
  4. প্রতি ঝুড়িতে একটি ফল-সারি। প্রতিটি সারিতে দুটি জিনিস: দলের মান (status — ঝুড়ির সবার জন্য এক) আর aggregate-এর ফল। দশটি সারি থেকে চারটি সারি।
  5. সাজানো। ORDER BY বেশি অর্ডার আগে; pending আর shipped দুটিতেই 2, তাই দ্বিতীয় key status বর্ণানুক্রমে টাই ভাঙে — pending আগে। ORDER BY না দিলে ফলের ক্রমের কোনো নিশ্চয়তা নেই।

এবার নিজে চালান। দ্বিতীয় কোয়েরিতে দুটি কলাম দিয়ে দল: (customer_id, status) — দুটির মানই যেসব সারিতে এক, তারা এক দলে।

-- প্রতিটি status-এ কয়টি অর্ডার
SELECT status, COUNT(*) AS orders
FROM orders
GROUP BY status
ORDER BY orders DESC, status;

-- একাধিক কলামে দল: প্রতিজন গ্রাহকের প্রতিটি status-এ কয়টি
SELECT customer_id, status, COUNT(*) AS orders
FROM orders
GROUP BY customer_id, status
ORDER BY customer_id, status;

প্রথম ফল চারটি সারি — ডায়াগ্রামের শেষ ধাপের মতোই: delivered 5, pending 2, shipped 2, cancelled 1. দ্বিতীয় ফলে নয়টি সারি: 1 | delivered | 1, 1 | pending | 1, 1 | shipped | 1, 2 | delivered | 2, 3 | cancelled | 1, 4 | delivered | 1, 6 | pending | 1, 7 | delivered | 1, 8 | shipped | 1. Rahim (1)-এর তিনটি অর্ডার তিনটি আলাদা status-এ, তাই তিনটি দল; Karima (2)-র দুটি অর্ডারই delivered, তাই একটি দলে 2. যত কলাম যোগ করবেন, দল তত সূক্ষ্ম হবে — সবচেয়ে বেশি দল হতে পারে সারির সংখ্যার সমান। আর Tanvir (5)-এর কোনো অর্ডার নেই বলে তিনি কোনো দলেই আসেননি — GROUP BY শুধু যা আছে, তা থেকে দল বানায়।

বিশ্ববিদ্যালয়ের দিকে একই প্রশ্ন: প্রতিটি বিভাগে কতজন শিক্ষার্থী আর তাঁদের গড় CGPA কত? এখানে দেখবেন NULL নিজেও একটি দল হয়।

SELECT dept_id,
       COUNT(*)             AS students,
       COUNT(cgpa)          AS with_cgpa,
       ROUND(AVG(cgpa), 2)  AS avg_cgpa,
       MAX(cgpa)            AS best
FROM students
GROUP BY dept_id
ORDER BY dept_id;

-- সব শিক্ষার্থী মিলিয়ে গড় (অংশ ৯-এ কাজে লাগবে)
SELECT ROUND(AVG(cgpa), 2) AS overall FROM students;

চারটি দল: NULL | 1 | 1 | 2.8 | 2.8 (Habib Ullah — বিভাগ নেই), 1 | 4 | 4 | 3.65 | 3.9 (CSE), 2 | 2 | 1 | 2.95 | 2.95 (EEE — Fahim-এর cgpa NULL, তাই দুজনের মধ্যে একজনের গড়) আর 3 | 1 | 1 | 3.1 | 3.1 (BBA). সবার গড় 3.35. দুটি জিনিস খেয়াল করুন। প্রথমত, WHERE-এ NULL = NULL সত্য নয় (পর্ব ১৬), কিন্তু GROUP BY সব NULL-কে একটি দলে রাখে — standard এখানে NULL-কে “একই রকম” ধরে, যেমন DISTINCT-ও ধরে। দ্বিতীয়ত, SQLite ছোট থেকে বড় সাজাতে NULL আগে রাখে; PostgreSQL আর Oracle রাখে শেষে — দরকার হলে ORDER BY dept_id NULLS LAST লিখে নিশ্চিত করুন (SQLite 3.30 থেকে এটি চলে)।

আরও গভীরে: DBMS দুইভাবে দল বানায়। Sort-ভিত্তিক: সারিগুলো group-কলাম অনুযায়ী সাজিয়ে নেয়, তারপর একবার পড়ে যায় — মান বদলালেই একটি দল শেষ; খরচ সাজানোর, অর্থাৎ O(n log n), আর সেই কলামে index থাকলে সাজানোই লাগে না। Hash-ভিত্তিক: প্রতিটি দলের মানকে key করে একটি hash table-এ চলমান COUNT/SUM রাখে — গড়ে O(n), কিন্তু দলের সংখ্যা মেমরির চেয়ে বেশি হলে ডিস্কে ভাগ করতে হয় (PostgreSQL 13 থেকে HashAggregate প্রয়োজনে ডিস্কে লিখতে পারে)। PostgreSQL-এর EXPLAIN-এ এদের নাম GroupAggregate আর HashAggregate; SQLite সাধারণত সাজানোর পথ নেয় — কোয়েরি প্ল্যানে “USE TEMP B-TREE FOR GROUP BY” দেখায়। বিস্তারিত পর্ব ৪২-এ, আর hash table-এর ভেতরটা DSA কোর্সে।

৪ · SELECT-এর লৌহ নিয়ম — প্রতিটি কলাম GROUP BY-তে, নয়তো aggregate-এ

অংশ ৩-এর ডায়াগ্রামের দ্বিতীয় ধাপ মনে করুন: delivered ঝুড়িতে পাঁচটি সারি, পাঁচজন আলাদা গ্রাহক। এখন কেউ যদি লেখেন SELECT status, customer_id, COUNT(*) … GROUP BY status, তাহলে delivered-এর একমাত্র ফল-সারিতে customer_id কোনটি বসবে — 1, 2, 4, 2 না 7? প্রশ্নটির কোনো অর্থপূর্ণ উত্তর নেই। তাই SQL standard-এর নিয়ম:

নিয়ম

GROUP BY থাকলে (বা SELECT-এ কোনো aggregate থাকলে) SELECT, HAVING আর ORDER BY-এর প্রতিটি কলাম হয় GROUP BY-তে থাকতে হবে, নয়তো একটি aggregate ফাংশনের ভেতরে — কারণ শুধু এ দুই ধরনের মানই প্রতিটি দলে একটি নিশ্চিত মান দেয়। SQL:1999 থেকে একটি তৃতীয় সুযোগ: যে কলাম group-কলাম থেকে নির্ধারিত (functionally dependent), যেমন GROUP BY-তে primary key থাকলে সেই টেবিলের বাকি কলাম।

কিন্তু সব DBMS নিয়মটি পাহারা দেয় না। SQLite এমন কোয়েরি চুপচাপ চালিয়ে দেয় — দলের বাইরের এমন কলামকে বলে bare column (“খালি কলাম”)। নিচে প্রতিটি বিভাগের সেরা শিক্ষার্থীর নাম বের করার একটি “শর্টকাট” আর তার বিপদ দেখুন:

-- name GROUP BY-তে নেই, aggregate-এও নেই — তবু SQLite চালায়
SELECT dept_id, name, MAX(cgpa) AS best
FROM students GROUP BY dept_id;

-- একই কৌশল AVG দিয়ে: name এখন কার?
SELECT dept_id, name, ROUND(AVG(cgpa), 2) AS avg_cgpa
FROM students GROUP BY dept_id;

প্রথম ফল দেখে মনে হয় সব ঠিক: NULL | Habib Ullah | 2.8, 1 | Dilruba Khatun | 3.9, 2 | Chayon Roy | 2.95, 3 | Emon Mia | 3.1 — Dilruba সত্যিই CSE-র সর্বোচ্চ। এটি SQLite-এর একটি নথিভুক্ত বিশেষ সুবিধা: কোয়েরিতে ঠিক একটি MIN বা MAX থাকলে bare column সেই সারি থেকে নেওয়া হয়, যেখানে MIN/MAX পাওয়া গেছে। দ্বিতীয় ফল: 1 | Arif Hossain | 3.65 — Arif কি CSE-র “গড় শিক্ষার্থী”? না, গড়ের কোনো নির্দিষ্ট সারি নেই; SQLite দলের কোনো একটি সারি থেকে নাম তুলেছে। অন্য সংস্করণে, অন্য index থাকলে বা ডেটা অন্য ক্রমে ঢোকালে নামটি বদলে যেতে পারে — কোনো ত্রুটি ছাড়াই। আর MAX-এ টাই হলে (দুজনেরই 3.9) কার নাম আসবে, তারও নিশ্চয়তা নেই।

PostgreSQL একই প্রথম কোয়েরি চালাতেই দেয় না:

SELECT dept_id, name, MAX(cgpa) AS best
FROM students GROUP BY dept_id;

-- ERROR:  column "students.name" must appear in the GROUP BY clause
--         or be used in an aggregate function
-- LINE 1: SELECT dept_id, name, MAX(cgpa) AS best

MySQL-এর গল্পটি ইতিহাসের। পুরোনো MySQL (5.6 পর্যন্ত, ডিফল্ট সেটিংসে) SQLite-এর মতোই bare column মেনে নিত, আর অনেক বছর ধরে লাখো ওয়েবসাইটের কোড এর ওপর দাঁড়িয়ে ছিল। MySQL 5.7.5 থেকে ONLY_FULL_GROUP_BY নামের SQL mode ডিফল্টে চালু, যা প্রায় PostgreSQL-এর মতো ত্রুটি দেয়: ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column … which is not functionally dependent on columns in GROUP BY clause. পুরোনো প্রজেক্টে কেউ কেউ এই mode বন্ধ করে দেন — তাতে ত্রুটি চলে যায়, ভুলটি থেকে যায়।

তাহলে “প্রতিটি বিভাগের সেরা শিক্ষার্থীর নাম” সঠিকভাবে কীভাবে? তিনটি পরিষ্কার পথ:

  • নামটি সত্যিই একই হলে GROUP BY-তে যোগ করুন — যেমন GROUP BY s.dept_id, d.name, যেখানে বিভাগের নাম প্রতিটি dept_id-এর জন্য একটিই। GROUP BY-তে primary key থাকলে PostgreSQL (9.1 থেকে) আর MySQL (5.7 থেকে) বাকি কলাম নিজেই মেনে নেয়, কারণ তারা functional dependency চেনে।
  • “যেকোনো একটি হলেই চলবে” হলে স্পষ্ট বলুন — MySQL-এ ANY_VALUE(name), PostgreSQL 16 থেকে any_value(name) (SQL:2023 standard-এ যোগ হয়েছে), অথবা সব DBMS-এ MIN(name).
  • “সর্বোচ্চ মানের সারিটি” চাইলে — এটি আসলে “প্রতি দলে top-1” সমস্যা: subquery (পর্ব ২১) বা ROW_NUMBER() window function (পর্ব ২৬) দিয়ে, যা টাই-ও ঠিকভাবে সামলায় — বিস্তারিত পর্ব ২৭-এ।

অভ্যাস করুন যেন সব DBMS-এ চলে। SQLite-এ শেখার সময় bare column কাজ করে বলে ভরসা করবেন না — কোডটি PostgreSQL বা নতুন MySQL-এ নিলেই ভাঙবে, আর SQLite-এও ফলের মানে অনিশ্চিত। সহজ পরীক্ষা: SELECT-এর প্রতিটি কলামে আঙুল রেখে জিজ্ঞেস করুন — “এটি কি GROUP BY-তে আছে, নাকি একটি aggregate-এর ভেতরে?”

৫ · WHERE বনাম HAVING — ভাগের আগে ছাঁকা, ভাগের পরে ছাঁকা

মালিক এবার জানতে চান: “বাতিল অর্ডার বাদ দিয়ে, কোন কোন অর্ডারের মোট দাম ৫,০০০ টাকার বেশি?” এখানে দুটি শর্ত, আর দুটির চরিত্র আলাদা। “বাতিল নয়” একটি সারির শর্ত — প্রতিটি লাইন দেখেই বলা যায়। “মোট দাম ৫,০০০-এর বেশি” একটি দলের শর্ত — দলের সব লাইন যোগ না করে বলা যায় না। প্রথমটি যায় WHERE-এ, দ্বিতীয়টি HAVING-এ। কেন, তা বুঝতে SELECT-এর যৌক্তিক ক্রম জানতে হবে — আপনি যে ক্রমে লেখেন (SELECT আগে), DBMS সেই ক্রমে ভাবে না:

  1. FROMorder_itemsসব সারি
  2. WHEREসারি ছাঁকাভাগের আগে
  3. GROUP BYঝুড়ি + SUMদল আর হিসাব
  4. HAVINGদল ছাঁকাভাগের পরে
  5. SELECT → ORDER BYকলাম, সাজানোশেষে
FROM order_items১৬টি লাইন
order │ পণ্য │ qty × দাম      = লাইন
1001  │ 101  │ 1 × 15000      = 15000
1001  │ 102  │ 2 × 1200       =  2400
1002  │ 104  │ 1 × 650        =   650
1002  │ 105  │ 2 × 550        =  1100
1003  │ 108  │ 3 × 420        =  1260
1003  │ 109  │ 2 × 260        =   520
1004  │ 107  │ 1 × 4500       =  4500
1005  │ 106  │ 2 × 2200       =  4400
1005  │ 104  │ 1 × 650        =   650
1006  │ 110  │ 1 × 3500       =  3500
1007  │ 102  │ 1 × 1150       =  1150
1007  │ 108  │ 5 × 420        =  2100
1008  │ 105  │ 1 × 550        =   550
1009  │ 101  │ 1 × 14500      = 14500
1009  │ 110  │ 1 × 3500       =  3500
1010  │ 109  │ 4 × 260        =  1040
WHERE: বাতিল অর্ডার বাদ১৫টি লাইন থাকে
order │ পণ্য │ qty × দাম      = লাইন
1001  │ 101  │ 1 × 15000      = 15000
1001  │ 102  │ 2 × 1200       =  2400
1002  │ 104  │ 1 × 650        =   650
1002  │ 105  │ 2 × 550        =  1100
1003  │ 108  │ 3 × 420        =  1260
1003  │ 109  │ 2 × 260        =   520
1004  │ 107  │ 1 × 4500       ✗ cancelled
1005  │ 106  │ 2 × 2200       =  4400
1005  │ 104  │ 1 × 650        =   650
1006  │ 110  │ 1 × 3500       =  3500
1007  │ 102  │ 1 × 1150       =  1150
1007  │ 108  │ 5 × 420        =  2100
1008  │ 105  │ 1 × 550        =   550
1009  │ 101  │ 1 × 14500      = 14500
1009  │ 110  │ 1 × 3500       =  3500
1010  │ 109  │ 4 × 260        =  1040
GROUP BY order_id৯টি ঝুড়ি
[1001]  15000 + 2400
[1002]  650 + 1100
[1003]  1260 + 520
[1005]  4400 + 650
[1006]  3500
[1007]  1150 + 2100
[1008]  550
[1009]  14500 + 3500
[1010]  1040
প্রতিটি ঝুড়িতে SUM(quantity * unit_price)এখন দলের মোট জানা
[1001]  15000 + 2400   = 17400
[1002]  650 + 1100     = 1750
[1003]  1260 + 520     = 1780
[1005]  4400 + 650     = 5050
[1006]  3500           = 3500
[1007]  1150 + 2100    = 3250
[1008]  550            = 550
[1009]  14500 + 3500   = 18000
[1010]  1040           = 1040
HAVING SUM(…) > 5000৩টি দল টিকে থাকে
[1001]  17400   ✓
[1002]   1750
[1003]   1780
[1005]   5050   ✓
[1006]   3500
[1007]   3250
[1008]    550
[1009]  18000   ✓
[1010]   1040
SELECT order_id, … AS totalফল-সারি তৈরি
 order_id │ total
 1001     │ 17400
 1005     │ 5050
 1009     │ 18000

alias “total” এই ধাপে জন্মায় —
তাই WHERE তাকে চেনে না
ORDER BY total DESCচূড়ান্ত ফল
 order_id │ total
 1009     │ 18000
 1001     │ 17400
 1005     │ 5050
হিসাব
সারি / দল
এখন যা জানা
  1. FROM — সব লাইন। order_items-এর ১৬টি লাইন, প্রতিটির quantity × unit_price হিসাব করা যায়। এখনো কোনো দল নেই।
  2. WHERE — সারি ধরে ছাঁকা। অর্ডার 1004 বাতিল, তাই তার একমাত্র লাইন (Saree, 4500) এখানেই বাদ। এই মুহূর্তে কোনো দলের মোট জানা নেই — তাই WHERE-এ SUM(…) লেখা অসম্ভব।
  3. GROUP BY — ঝুড়িতে ভাগ। বাকি ১৫টি লাইন order_id অনুযায়ী ৯টি ঝুড়িতে। 1004-এর কোনো ঝুড়িই তৈরি হলো না, কারণ তার লাইন আগেই বাদ।
  4. Aggregate — প্রতিটি ঝুড়ির মোট। প্রতিটি দলের SUM: 1001-এ 17400, 1009-এ 18000 ইত্যাদি। এখন প্রথমবার দলের মোট জানা হলো।
  5. HAVING — দল ধরে ছাঁকা। মোট ৫,০০০-এর বেশি এমন তিনটি দল টিকে থাকে: 1001, 1005 (5050) আর 1009. বাকি ছয়টি দল বাদ।
  6. SELECT — ফল-সারি। প্রতিটি টিকে থাকা দল থেকে order_id আর total. alias “total” এখানে তৈরি হয় — WHERE আর GROUP BY তার আগে চলেছে বলে standard SQL-এ তারা alias চেনে না।
  7. ORDER BY — সাজানো। সবার শেষে সাজানো হয়, তাই ORDER BY alias চেনে: 1009 (18000), 1001 (17400), 1005 (5050). LIMIT থাকলে সেটি আরও পরে চলে।
SELECT order_id, SUM(quantity * unit_price) AS total
FROM order_items
WHERE order_id NOT IN
      (SELECT order_id FROM orders WHERE status = 'cancelled')
GROUP BY order_id
HAVING SUM(quantity * unit_price) > 5000
ORDER BY total DESC;

-- ভুল জায়গায় aggregate: WHERE-এ SUM
SELECT order_id FROM order_items
WHERE SUM(quantity * unit_price) > 5000 GROUP BY order_id;

প্রথম ফল ঠিক ডায়াগ্রামের মতো তিনটি সারি: 1009 | 18000, 1001 | 17400, 1005 | 5050. পুরো আয়ের হিসাব order_items একাই দিতে পারে, কারণ প্রতিটি লাইনে বিক্রির দাম আছে; শুধু status জানতে orders টেবিলে একবার তাকানো হয়েছে, NOT IN (SELECT …) দিয়ে। দ্বিতীয় কোয়েরিতে ত্রুটি: misuse of aggregate: SUM() — WHERE চলে দল তৈরির আগে, তখন SUM-এর কিছুই জানা নেই। PostgreSQL-এ বার্তা: aggregate functions are not allowed in WHERE.

এখানে একটি বহনযোগ্যতার কথা: HAVING-এ আমরা পুরো SUM(quantity * unit_price) আবার লিখেছি, alias total নয়। SQLite আর MySQL HAVING-এ SELECT-এর alias মেনে নেয়, কিন্তু PostgreSQL আর SQL Server নেয় না (column "total" does not exist) — standard-এর যৌক্তিক ক্রমে HAVING চলে SELECT-এর আগে। পুনরাবৃত্তিটি দেখতে খারাপ, কিন্তু সব জায়গায় চলে; DBMS একই হিসাব দুবার করে না।

দুটি টেবিল মিলিয়ে: বেশি খরচের গ্রাহক

“কোন গ্রাহকেরা বাতিল বাদে মোট ৫,০০০ টাকার বেশি খরচ করেছেন?” — এখানে গ্রাহক থাকে orders-এ, টাকা থাকে order_items-এ, তাই দুটিকে মেলাতে হয়। নিচের JOIN অংশটি শুধু বলে: “orders-এর প্রতিটি সারির পাশে একই order_id-এর order_items লাইনগুলো বসাও”। JOIN-এর পুরো গল্প পরের পর্বে (পর্ব ১৯); এখানে মূল বিষয় GROUP BY আর HAVING.

SELECT o.customer_id,
       COUNT(DISTINCT o.order_id)        AS orders,
       COUNT(*)                          AS lines,
       SUM(oi.quantity * oi.unit_price)  AS spent
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.status <> 'cancelled'
GROUP BY o.customer_id
HAVING SUM(oi.quantity * oi.unit_price) > 5000
ORDER BY spent DESC;

দুটি সারি: 1 | 3 | 6 | 37180 (Rahim — অর্ডার 1001, 1003, 1009) আর 4 | 1 | 2 | 5050 (Nusrat). দুটি শিক্ষা। প্রথমত, orders আর lines-এর পার্থক্য দেখুন: JOIN-এর পর প্রতিটি অর্ডার তার লাইনের সংখ্যা যতবার, ততবার আসে — Rahim-এর ৩টি অর্ডার, কিন্তু ৬টি সারি। “কয়টি অর্ডার” গুনতে তাই COUNT(DISTINCT o.order_id); শুধু COUNT(*) লিখলে 6 আসত। দ্বিতীয়ত, Karima Begum (2) আসেননি: তাঁর মোট ঠিক 5000 (1750 + 3250), আর শর্ত “> 5000” — “অন্তত ৫,০০০” বোঝাতে চাইলে >= লিখতে হতো। সীমানার মান নিয়ে প্রশ্নকর্তাকে জিজ্ঞেস করুন, অনুমান করবেন না।

কোনটি কোথায় — আর পারফরম্যান্স

যে শর্ত aggregate ছাড়াই লেখা যায়, সেটি HAVING-এ লিখলেও প্রায়ই একই ফল আসে — যেমন HAVING order_id <> 1004. কিন্তু তখন DBMS আগে বাতিল অর্ডারের লাইনগুলোও পড়ে, দলে ভাগ করে, যোগ করে, আর শেষে দলটি ফেলে দেয় — অকারণ কাজ। WHERE-এ লিখলে সারিগুলো শুরুতেই বাদ যায়, আর সেই কলামে index থাকলে সেগুলো পড়াই লাগে না। দশটি অর্ডারে পার্থক্য চোখে পড়বে না; এক কোটি লাইনের bKash-ধাঁচের লেনদেন টেবিলে “শুধু গত সপ্তাহ” শর্তটি WHERE-এ থাকা আর HAVING-এ থাকার পার্থক্য সেকেন্ড বনাম মিনিট হতে পারে। PostgreSQL-এর optimizer aggregate-বিহীন HAVING শর্ত নিজেই WHERE-এ সরিয়ে নিতে পারে, কিন্তু সব DBMS-এ সব ক্ষেত্রে তা নিশ্চিত নয়। নিয়মটি তাই সহজ:

WHEREHAVING
কখন চলেদলে ভাগের আগেদলে ভাগ আর aggregate হিসাবের পরে
কী ছাঁকেএকেকটি সারিএকেকটি দল
aggregate লেখা যায়?নাহ্যাঁ — এটিই তার কাজ
কোন কলাম চলেটেবিলের যেকোনো কলামgroup-কলাম আর aggregate (অংশ ৪-এর নিয়ম)
GROUP BY ছাড়াস্বাভাবিকপুরো ফল একটি দল; হয় একটি সারি, নয় কিছুই না
পরামর্শসারির সব শর্ত এখানেশুধু aggregate-এর শর্ত এখানে

৬ · হিসাব করা মান দিয়ে দল — মাস, দামের সীমা আর দল সাজানো

GROUP BY শুধু কলামের নাম নেয় না, যেকোনো expression নিতে পারে। এতে খুব কাজের দুটি রিপোর্ট সহজ হয়: সময় অনুযায়ী (মাসে মাসে বিক্রি) আর পরিসর অনুযায়ী (দামের সীমা, বয়সের ধাপ, CGPA-র শ্রেণি)। পর্ব ১৭-এর তারিখ ফাংশন strftime('%Y-%m', order_date) একটি তারিখ থেকে শুধু বছর-মাস বের করে ('2025-09'), আর পর্ব ১৬-এর CASE একটি দাম দেখে তার “সীমা”র নাম দেয়:

-- মাসে মাসে অর্ডারের সংখ্যা
SELECT strftime('%Y-%m', order_date) AS month, COUNT(*) AS orders
FROM orders
GROUP BY month
ORDER BY month;

-- দামের সীমা অনুযায়ী পণ্য
SELECT CASE WHEN price < 1000 THEN '1: below 1000'
            WHEN price < 5000 THEN '2: 1000-4999'
            ELSE '3: 5000+' END AS band,
       COUNT(*) AS products, MIN(price) AS low, MAX(price) AS high
FROM products
GROUP BY band
ORDER BY band;

প্রথম ফল: 2025-09 | 6 আর 2025-10 | 4 — দশটি অর্ডার দুই মাসে। দ্বিতীয় ফল: 1: below 1000 | 4 | 260 | 650 (বই আর মুদি), 2: 1000-4999 | 5 | 1200 | 4500 আর 3: 5000+ | 1 | 15000 | 15000 (শুধু Mobile Phone). সীমার নামের শুরুতে 1, 2, 3 রাখা একটি ছোট কৌশল: ORDER BY লেখা অনুযায়ী সাজায়, তাই নম্বর না থাকলে “5000+” আগে আর “below” পরে চলে যেত। CASE-এর WHEN ওপর থেকে নিচে পরীক্ষা হয়, তাই দ্বিতীয় শর্তে “price >= 1000” আবার লিখতে হয়নি।

এখানে GROUP BY month আর GROUP BY band — SELECT-এর alias দিয়ে দল। SQLite, MySQL আর PostgreSQL এটি মেনে নেয়, কিন্তু SQL Server আর (23ai সংস্করণের আগের) Oracle নেয় না; সেখানে পুরো expression আবার লিখতে হয়: GROUP BY strftime(…)-এর সমতুল্য। একইভাবে GROUP BY 1 (SELECT-এর প্রথম কলাম) SQLite, MySQL আর PostgreSQL-এ চলে, SQL Server-এ চলে না; আর কলামের ক্রম বদলালে নিঃশব্দে ভুল দল হয় বলে অনেক দলের কোডিং নিয়মে এটি নিষেধ।

-- PostgreSQL-এ order_date আসল DATE টাইপ হলে
SELECT date_trunc('month', order_date)::date AS month, COUNT(*) AS orders
FROM orders
GROUP BY 1
ORDER BY 1;
-- MySQL:      GROUP BY DATE_FORMAT(order_date, '%Y-%m')
-- SQL Server: GROUP BY FORMAT(order_date, 'yyyy-MM')  (বা YEAR() আর MONTH() দুটি কলামে)

দল সাজানো আর “কোন মাসে কিছুই হয়নি”

দল সাজাতে ORDER BY-তে group-কলাম, aggregate বা তার alias — তিনটিই চলে: ORDER BY COUNT(*) DESC সবচেয়ে বেশি অর্ডারের মাস আগে দেখায়। “Top 3 শ্রেণি” চাইলে শেষে LIMIT 3. কিন্তু একটি ফাঁদ মনে রাখুন: GROUP BY শুধু ডেটায় যা আছে তা থেকে দল বানায়। নভেম্বরে একটিও অর্ডার না এলে ফলে “2025-11 | 0” সারিটি আসবে না — সারিটিই থাকবে না। মাসিক চার্টে এটি একটি ফাঁকা জায়গা হয়ে যায়, আর অনেকে ভুল করে ধরে নেন ডেটা হারিয়েছে। সমাধান হলো সব মাসের একটি তালিকা (calendar টেবিল বা recursive CTE, পর্ব ২৫) বানিয়ে তার সাথে LEFT JOIN (পর্ব ১৯) করা — এখানে ঠিক অংশ ২-এর COALESCE আবার কাজে লাগে।

৭ · শর্তসাপেক্ষ গোনা আর দলের ভেতরের তালিকা — FILTER, CASE আর GROUP_CONCAT

অনেক রিপোর্টে একই সারিতে কয়েকটি আলাদা গোনা লাগে: “মোট অর্ডার, তার মধ্যে কয়টি delivered, কয়টি এখনো খোলা, কয়টি বাতিল” — পাশাপাশি চারটি কলাম। চারটি আলাদা কোয়েরি না লিখে একটিতেই করা যায়, এই কৌশলের নাম conditional aggregation. দুটি উপায়: standard-এর FILTER clause (COUNT(*) FILTER (WHERE …)) — PostgreSQL 9.4 আর SQLite 3.30 থেকে আছে; আর সব DBMS-এ চলে এমন পুরোনো কৌশল SUM(CASE WHEN … THEN 1 ELSE 0 END):

SELECT COUNT(*) AS all_orders,
       COUNT(*) FILTER (WHERE status = 'delivered')         AS delivered,
       COUNT(*) FILTER (WHERE status IN ('pending', 'shipped')) AS still_open,
       SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled
FROM orders;

-- মাসে মাসে, একই কৌশল
SELECT strftime('%Y-%m', order_date) AS month,
       COUNT(*) FILTER (WHERE status = 'delivered')         AS delivered,
       SUM(CASE WHEN status <> 'delivered' THEN 1 ELSE 0 END) AS others
FROM orders
GROUP BY month ORDER BY month;

প্রথম ফল: 10 | 5 | 4 | 1 — দশটি অর্ডার, ৫টি পৌঁছেছে, ৪টি পথে বা অপেক্ষায়, ১টি বাতিল; যোগফল মিলে যায় (৫ + ৪ + ১ = ১০), যা যাচাইয়ের সহজ উপায়। দ্বিতীয় ফল: 2025-09 | 3 | 3 আর 2025-10 | 2 | 2. FILTER-এর ভেতরের WHERE শুধু ওই একটি aggregate-এর জন্য সারি বাছে; মূল কোয়েরির WHERE সবার জন্য। CASE-এর সংস্করণে ELSE 0 না লিখলে মিল-না-হওয়া সারিতে NULL আসে — SUM তা বাদ দেয়, তাই সাধারণত ফল একই থাকে, কিন্তু কোনো সারিই না মিললে ফল 0-এর বদলে NULL (অংশ ২-এর ফাঁদ)।

আরও তিনটি সমতুল্য লেখা প্রায়ই দেখবেন: COUNT(CASE WHEN status = 'delivered' THEN 1 END) (ELSE নেই, তাই অমিল সারিতে NULL, আর COUNT তা গোনে না), MySQL আর SQLite-এ SUM(status = 'delivered') (তুলনার ফল 1 বা 0 — সংক্ষিপ্ত, কিন্তু PostgreSQL-এ boolean-এর SUM চলে না), আর FILTER শুধু COUNT নয়, যেকোনো aggregate-এ বসে: SUM(quantity * unit_price) FILTER (WHERE …). MySQL আর SQL Server-এ FILTER নেই — সেখানে CASE-ই ভরসা।

এই কৌশলের আরেক নাম “pivot”: সারির মান (delivered, pending…) কলামের শিরোনাম হয়ে যায়। Excel-এর pivot table এভাবেই কাজ করে; SQL-এ বড় আকারের pivot পর্ব ২৭-এ।

দলের সদস্যদের নাম এক ঘরে — GROUP_CONCAT আর STRING_AGG

কখনো কখনো দলের সংখ্যা নয়, সদস্যদের তালিকা চাই: “প্রতিটি শ্রেণিতে কোন কোন পণ্য?” SQLite আর MySQL-এ এর ফাংশন GROUP_CONCAT, PostgreSQL আর SQL Server-এ STRING_AGG — দুটিই দলের লেখাগুলো একটি বিভাজক (separator) দিয়ে জোড়া লাগায়:

SELECT category_id, COUNT(*) AS n,
       GROUP_CONCAT(name, ', ') AS products
FROM products
GROUP BY category_id;

-- তালিকার ভেতরেও ক্রম (SQLite 3.44+)
SELECT category_id,
       GROUP_CONCAT(name, ' / ' ORDER BY price DESC) AS by_price
FROM products
GROUP BY category_id;

প্রথম ফল চারটি সারি: 1 | 4 | Mobile Phone, Headphone, Power Bank, Smart Watch, 2 | 2 | DSA Book, SQL Book, 3 | 2 | Panjabi, Saree, 4 | 2 | Rice 5kg, Mustard Oil 1L. এখানে নামগুলো product_id-এর ক্রমে এসেছে, কিন্তু সেটি কাকতালীয় — standard বা SQLite কেউই ক্রমের প্রতিশ্রুতি দেয় না। দ্বিতীয় কোয়েরিতে ক্রম স্পষ্ট: 1 | Mobile Phone / Smart Watch / Power Bank / Headphone (দাম কমার ক্রমে), 3 | Saree / Panjabi. aggregate-এর ভেতরে ORDER BY SQLite 3.44 (২০২৩) থেকে চলে। শ্রেণির নাম চাইলে categories-এর সাথে JOIN লাগবে — পর্ব ১৯।

-- MySQL: বিভাজক SEPARATOR দিয়ে; ডিফল্ট ফলের সীমা 1024 byte!
SELECT category_id, GROUP_CONCAT(name ORDER BY price DESC SEPARATOR ', ')
FROM products GROUP BY category_id;

-- PostgreSQL
SELECT category_id, STRING_AGG(name, ', ' ORDER BY price DESC)
FROM products GROUP BY category_id;

-- SQL Server 2017+ (Oracle-এ একই রূপে LISTAGG)
SELECT category_id, STRING_AGG(name, ', ') WITHIN GROUP (ORDER BY price DESC)
FROM products GROUP BY category_id;

MySQL-এর নিঃশব্দ কাটছাঁট। MySQL-এ GROUP_CONCAT-এর ফল group_concat_max_len (ডিফল্ট 1024 byte) ছাড়ালে বাকি অংশ কেটে ফেলা হয় — শুধু একটি warning, কোনো ত্রুটি নয়। বাংলা নামে প্রতিটি অক্ষর UTF-8-এ সাধারণত ৩ byte, তাই সীমা আরও দ্রুত আসে। বড় তালিকা লাগলে সেশনে সীমা বাড়ান, অথবা তালিকাটি অ্যাপে আলাদা কোয়েরিতে আনুন। আর মনে রাখুন, GROUP_CONCAT-এর ফল দেখানোর জন্য; সেটিকে আবার ডেটাবেসে কমা-দেওয়া তালিকা হিসেবে রাখা পর্ব ১১-এর “দুর্গন্ধ”।

৮ · উপ-মোট আর সর্বমোট — ROLLUP, CUBE আর GROUPING SETS

হিসাবরক্ষকের রিপোর্টে প্রায়ই একসাথে তিন স্তর লাগে: প্রতিটি মাসের প্রতিটি status-এ কয়টি অর্ডার, প্রতিটি মাসের উপ-মোট (subtotal), আর সব মিলিয়ে সর্বমোট — ঠিক যেমন বার্ষিক হিসাবের খাতায় প্রতি পাতার নিচে পাতার যোগফল আর শেষ পাতায় মোট। একটি সাধারণ GROUP BY একটি স্তরই দেয়। SQL:1999 standard এর জন্য তিনটি সম্প্রসারণ এনেছে: ROLLUP, CUBE আর GROUPING SETS. PostgreSQL (9.5 থেকে), SQL Server আর Oracle তিনটিই সমর্থন করে; MySQL শুধু WITH ROLLUP; আর SQLite কোনোটিই সমর্থন করে না — তাই নিচের কোড পাতায় চালানো যাবে না।

SELECT to_char(order_date, 'YYYY-MM') AS month, status, COUNT(*) AS orders
FROM orders
GROUP BY ROLLUP (to_char(order_date, 'YYYY-MM'), status)
ORDER BY month, status;
 month   │ status    │ orders
 2025-09 │ cancelled │ 1
 2025-09 │ delivered │ 3
 2025-09 │ pending   │ 1
 2025-09 │ shipped   │ 1
 2025-09 │ NULL      │ 6     ← সেপ্টেম্বরের উপ-মোট
 2025-10 │ delivered │ 2
 2025-10 │ pending   │ 1
 2025-10 │ shipped   │ 1
 2025-10 │ NULL      │ 4     ← অক্টোবরের উপ-মোট
 NULL    │ NULL      │ 10    ← সর্বমোট

ROLLUP (a, b) মানে তিনটি দলবদ্ধতা একসাথে: (a, b), (a), আর () — ডান দিক থেকে একটি একটি কলাম “গুটিয়ে” নেওয়া। n কলামে ROLLUP দেয় n + 1টি স্তর। CUBE (a, b) দেয় সব সম্ভাব্য উপসেট: (a, b), (a), (b), () — n কলামে 2nটি স্তর; এখানে অতিরিক্ত চারটি সারি আসত, প্রতিটি status-এর দুই মাস মিলিয়ে মোট (delivered 5, pending 2, shipped 2, cancelled 1). আর GROUPING SETS ((month), (status), ()) দিয়ে নিজে বেছে নেন কোন স্তরগুলো চাই।

উপ-মোটের সারিতে NULL মানে “সব”। কিন্তু ডেটায় আসল NULL থাকলে (যেমন বিভাগহীন Habib) দুটি আলাদা করবেন কীভাবে? এর জন্য GROUPING(status) ফাংশন: উপ-মোটের সারিতে 1, সাধারণ সারিতে 0 — CASE WHEN GROUPING(status) = 1 THEN 'All' ELSE status END লিখে লেবেল বসানো যায়। MySQL-এ লেখার ধরন আলাদা:

SELECT DATE_FORMAT(order_date, '%Y-%m') AS month, status, COUNT(*) AS orders
FROM orders
GROUP BY month, status WITH ROLLUP;   -- GROUPING() চলে MySQL 8.0 থেকে

SQLite-এ একই ফল পেতে তিনটি স্তর আলাদা তিনটি কোয়েরিতে হিসাব করে UNION ALL দিয়ে জোড়া লাগান (পর্ব ২২-এ বিস্তারিত)। নিচের কোয়েরি অংশ ৩-এর যেকোনো db-shop এডিটরে বসিয়ে চালাতে পারেন — দশটি সারি আসবে, ঠিক উপরের মতো সংখ্যা; lvl কলাম বলে দেয় কোন সারি কোন স্তরের:

SELECT 1 AS lvl, strftime('%Y-%m', order_date) AS month, status, COUNT(*) AS orders
FROM orders GROUP BY strftime('%Y-%m', order_date), status
UNION ALL
SELECT 2, strftime('%Y-%m', order_date), NULL, COUNT(*)
FROM orders GROUP BY strftime('%Y-%m', order_date)
UNION ALL
SELECT 3, NULL, NULL, COUNT(*) FROM orders
ORDER BY lvl, month, status;

খরচের পার্থক্যটি বোঝার মতো: UNION ALL সংস্করণে orders টেবিল তিনবার পড়া হয়। ROLLUP-সমর্থিত DBMS সাধারণত একবার সাজিয়ে সব স্তর একসাথে বের করে — কারণ (month, status)-এর দলগুলো থেকেই (month)-এর উপ-মোট যোগ করে পাওয়া যায়। COUNT আর SUM-এর জন্য এই “ছোট দলের যোগফল থেকে বড় দল” কৌশল কাজ করে; AVG-এর জন্য করে না — কেন, তা পরের অংশে। ডেটা ওয়্যারহাউসের OLAP রিপোর্টে (পর্ব ৫৫) ROLLUP আর CUBE-ই মূল হাতিয়ার।

৯ · গড়ের গড় — সাধারণ গড় বনাম ভারিত গড়ের ফাঁদ

এই অংশের মূল শব্দ ভারিত গড় (weighted average) — যেখানে প্রতিটি মান তার “ওজন” অনুযায়ী গুরুত্ব পায়। অংশ ৩-এর ফল আবার দেখুন: চারটি দলের গড় CGPA 2.8, 3.65, 2.95 আর 3.1; আর সবার গড় 3.35. এখন কেউ রিপোর্টে লিখলেন, “বিভাগগুলোর গড় নিয়ে তার গড় = সবার গড়”। ঠিক কি? নিচে হিসাবটি ধাপে ধাপে:

students.cgpa, বিভাগ অনুযায়ীNULL cgpa বাদ
[CSE  1]  3.75  3.40  3.90  3.55
[EEE  2]  2.95  (Fahim: NULL)
[BBA  3]  3.10
[NULL  ]  2.80          (Habib — বিভাগ নেই)

মোট ৭টি জানা cgpa
প্রতিটি দলের AVG আর COUNTGROUP BY dept_id
[CSE  1]  14.60 ÷ 4 = 3.65   (n = 4)
[EEE  2]   2.95 ÷ 1 = 2.95   (n = 1)
[BBA  3]   3.10 ÷ 1 = 3.10   (n = 1)
[NULL  ]   2.80 ÷ 1 = 2.80   (n = 1)
ভুল পথ: গড়গুলোর সাধারণ গড়প্রতিটি দলের ওজন সমান
(3.65 + 2.95 + 3.10 + 2.80) ÷ 4
  = 12.50 ÷ 4
  = 3.125

একজনের BBA আর চারজনের CSE —
দুটিকেই সমান গুরুত্ব দেওয়া হলো
সঠিক পথ: ভারিত গড়ওজন = দলের সদস্যসংখ্যা
(3.65 × 4 + 2.95 × 1 + 3.10 × 1 + 2.80 × 1) ÷ (4 + 1 + 1 + 1)
  = (14.60 + 2.95 + 3.10 + 2.80) ÷ 7
  = 23.45 ÷ 7
  = 3.35
নিয়ম: গড় নয়, SUM আর COUNT বয়ে নিনসরাসরি AVG(cgpa) = 3.35
দল      SUM     COUNT
CSE     14.60   4
EEE      2.95   1
BBA      3.10   1
NULL     2.80   1
------  ------  -----
মোট    23.45   7   → 23.45 ÷ 7 = 3.35

SUM-এর যোগ = মোট SUM, COUNT-এর যোগ = মোট COUNT
AVG-এর গড় ≠ মোট AVG
হিসাব
সাধারণ গড়
ভারিত গড়
AVG(cgpa) সব সারিতে
  1. সাতটি জানা cgpa, চারটি দলে। CSE-তে চারজন, বাকি তিনটি দলে একজন করে (EEE-র Fahim-এর cgpa NULL, তাই হিসাবে নেই)। সরাসরি AVG(cgpa) = 3.35 — এটিই আসল উত্তর।
  2. প্রতিটি দলের গড়। GROUP BY dept_id দেয় 3.65, 2.95, 3.10, 2.80. কিন্তু প্রতিটি গড়ের পেছনে কতজন, সেই তথ্য গড়ের মধ্যে আর থাকে না — CSE-র 3.65 চারজনের, বাকিগুলো একজনের।
  3. গড়গুলোর সাধারণ গড় = 3.125. চারটি সংখ্যা যোগ করে ৪ দিয়ে ভাগ। এখানে একজনের দলও চারজনের দলের সমান ওজন পেল — ফল আসল গড়ের চেয়ে প্রায় 0.23 কম।
  4. ভারিত গড় = 3.35. প্রতিটি দলের গড়কে তার সদস্যসংখ্যা দিয়ে গুণ করে যোগ, তারপর মোট সদস্যসংখ্যা দিয়ে ভাগ। ফল সরাসরি AVG-এর সাথে হুবহু মেলে।
  5. নিয়মটি। SUM আর COUNT “যোগ করা যায়” — ছোট দলের SUM যোগ করলে বড় দলের SUM. AVG যায় না। তাই উপ-মোট, ড্যাশবোর্ড বা দুই ধাপের রিপোর্টে গড় নয়, SUM আর COUNT বয়ে নিন, আর একেবারে শেষে ভাগ করুন।

গাণিতিকভাবে: দল i-এর গড় mi আর সদস্যসংখ্যা ni হলে আসল গড় = Σ nimi ÷ Σ ni. সাধারণ গড় Σ mi ÷ k এর সমান হয় কেবল তখনই, যখন সব ni সমান — অথবা কাকতালীয়ভাবে। দলের আকার যত অসমান, ভুল তত বড়। বাস্তবে এর সবচেয়ে পরিচিত উদাহরণ আপনার নিজের CGPA: প্রতিটি কোর্সের grade point-এর সাধারণ গড় নয়, credit দিয়ে ভারিত গড় — ৩ credit-এর কোর্স ১.৫ credit-এর ল্যাবের দ্বিগুণ ওজন পায়। SQL-এ তাই লিখতে হয় SUM(credits * point) / SUM(credits), AVG(point) নয় (পর্ব ১১-এ ঠিক এই হিসাব দেখেছেন)।

গড়ের ফাঁদের সবচেয়ে বিখ্যাত রূপের নাম Simpson's paradox, ১৯৫১ সালে Edward Simpson-এর একটি প্রবন্ধের নামে (যদিও Karl Pearson আর Udny Yule আরও আগে একই ঘটনা লক্ষ করেছিলেন)। এতে প্রতিটি দলে একটি প্রবণতা থাকে, কিন্তু দলগুলো মিলিয়ে দেখলে প্রবণতা উল্টে যায়। পরিচিত উদাহরণ ১৯৭৩ সালের University of California, Berkeley-র স্নাতকোত্তর ভর্তির পরিসংখ্যান: সব বিভাগ মিলিয়ে নারী আবেদনকারীদের ভর্তির হার পুরুষদের চেয়ে কম দেখাচ্ছিল, কিন্তু ১৯৭৫ সালে Bickel ও সহকর্মীরা Science পত্রিকায় দেখান, বিভাগ ধরে দেখলে বেশিরভাগ বিভাগে পার্থক্য নেই বা নারীদের পক্ষে — নারীরা বেশি আবেদন করেছিলেন সেই বিভাগগুলোতে, যেখানে সবার জন্যই ভর্তি কঠিন। দলের আকার আর গঠন না দেখে মোট সংখ্যা পড়লে ভুল সিদ্ধান্ত আসে।

১০ · AI-এর যুগে aggregation — যে কোয়েরি চলে, কিন্তু ভুল বলে

“মাসিক বিক্রির রিপোর্ট দাও” লিখলে AI কয়েক সেকেন্ডে একটি GROUP BY দেয়, আর সেটি প্রায় সবসময় চলে। সমস্যা হলো, aggregation-এর ভুল প্রায় কখনো ত্রুটি দেয় না — ফল আসে, শুধু সংখ্যাটি ভুল। তাই AI-এর লেখা aggregate কোয়েরিতে যে ভুলগুলো সবচেয়ে বেশি দেখা যায়:

AI-এর ভুলফলকীভাবে ধরবেন
JOIN-এর পর COUNT(*) দিয়ে অর্ডার গোনালাইনের সংখ্যা আসে, অর্ডারের নয় (৬ বনাম ৩)একজন গ্রাহকের ফল হাতে গুনে মিলিয়ে নিন; COUNT(DISTINCT …)
JOIN-এর পর SUM — এক-থেকে-অনেক দুই দিকেটাকা দুবার-তিনবার যোগ হয় (fan-out)JOIN-এর আগে আর পরে সারির সংখ্যা তুলনা করুন
bare column (SQLite/পুরোনো MySQL-এ চলে)এলোমেলো নাম বা তারিখপ্রতিটি SELECT-কলাম GROUP BY-তে বা aggregate-এ কি না দেখুন
ফাঁকা সেট বা না-থাকা মাস ভুলে যাওয়াNULL টাকা, চার্টে ফাঁকা মাসকোনো অর্ডার নেই এমন গ্রাহক/মাস দিয়ে পরীক্ষা
AVG(COALESCE(x, 0)) বা NULL-কে শূন্য ধরাগড় কম দেখায়COUNT(*) আর COUNT(x) পাশাপাশি দেখুন
দলের গড়ের গড়অসমান দলে ভুল গড়সরাসরি AVG বা SUM ÷ COUNT-এর সাথে তুলনা
সারির শর্ত HAVING-এ, বা alias HAVING-এধীর কোয়েরি; PostgreSQL-এ ত্রুটিaggregate-বিহীন শর্ত WHERE-এ সরান
বাতিল অর্ডার বা ফেরত বাদ দিতে ভুলে যাওয়া; নেই এমন কলাম (total_amount)বাড়তি আয়; কোয়েরি চলেই না“আয়”-এর সংজ্ঞা লিখে দিন; schema পাশে রাখুন

AI-কে কোয়েরি লেখানোর আগে প্রশ্নটি নিখুঁত করুন — বিশেষ করে “কী গুনব, কী বাদ দেব, NULL আর ফাঁকা হলে কী দেখাব”। আর ফলের সাথে যাচাইয়ের পরীক্ষাও চেয়ে নিন:

আমার SQLite ডেটাবেসের schema নিচে দিলাম (orders, order_items, customers). তুমি নতুন কলাম বা টেবিলের নাম অনুমান করবে না — শুধু এই নামগুলো ব্যবহার করবে। প্রশ্ন: প্রতিটি মাসে (strftime('%Y-%m', order_date)) কয়টি অর্ডার, আর বাতিল (status = 'cancelled') বাদে মোট আয় কত (SUM(quantity * unit_price)). নিয়ম: ১. অর্ডার গুনবে COUNT(DISTINCT order_id) দিয়ে, কারণ JOIN-এর পর একটি অর্ডার কয়েকবার আসে। ২. কোনো মাসে আয় না থাকলে 0 দেখাবে, NULL নয়। ৩. SELECT-এর প্রতিটি কলাম হয় GROUP BY-তে, নয়তো aggregate-এ থাকবে — PostgreSQL-এও যেন চলে। ৪. সারির শর্ত WHERE-এ, শুধু aggregate-এর শর্ত HAVING-এ। ৫. কোয়েরির পাশে তিনটি যাচাই-কোয়েরি দাও: সব মাসের অর্ডারের যোগফল কি মোট অর্ডারের সমান? JOIN-এর আগে-পরে সারির সংখ্যা কত? একটি মাসের আয় হাতে মেলানোর জন্য সেই মাসের লাইনগুলোর তালিকা। [এখানে CREATE TABLE]

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

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

  • WHERE আর HAVING-এর পার্থক্য কী? — WHERE দলে ভাগের আগে সারি ছাঁকে, aggregate চলে না; HAVING ভাগের পরে দল ছাঁকে, aggregate চলে।
  • COUNT(*), COUNT(col) আর COUNT(DISTINCT col)-এর পার্থক্য? — সব সারি; col NULL নয় এমন সারি; col-এর আলাদা অ-NULL মানের সংখ্যা।
  • একটি টেবিলে নকল (duplicate) ইমেইল খুঁজবেন কীভাবে? — SELECT email, COUNT(*) FROM t GROUP BY email HAVING COUNT(*) > 1.
  • কোনো সারি না মিললে SUM কী দেয়? — NULL (COUNT দেয় 0); শূন্য দেখাতে COALESCE(SUM(x), 0).
  • SELECT-এর যৌক্তিক ক্রম বলুন। — FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT; তাই WHERE-এ SELECT-এর alias চলে না।

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

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

প্র ০১কোনো সারি না থাকলে SUM শূন্য না দিয়ে NULL দেয় — গণিতে তো শূন্যটি সংখ্যার যোগফল শূন্য। SQL-এর নকশাকারেরা কি ভুল করেছিলেন?

প্রশ্নটি নতুন নয়, আর এর পেছনে relational মডেলের দুই প্রধান চিন্তাবিদের দীর্ঘ তর্ক আছে। গণিতে ফাঁকা সেটের যোগফল শূন্য — কারণ শূন্য যোগের “অভেদ” (identity) মান: যেকোনো সংখ্যার সাথে শূন্য যোগ করলে সংখ্যাটি বদলায় না। একইভাবে ফাঁকা সেটের গুণফল ১। কিন্তু ফাঁকা সেটের সর্বোচ্চ (MAX) বা গড় (০ ÷ ০)-এর কোনো স্বাভাবিক মান নেই। SQL-এর প্রথম নকশাকারেরা (১৯৭০-এর দশকে IBM-এর System R প্রকল্পে Donald Chamberlin ও Raymond Boyce-এর SEQUEL থেকে শুরু) পাঁচটি ফাংশনের জন্য একটিই সরল নিয়ম চেয়েছিলেন: “হিসাব করার মতো কিছু নেই” মানে NULL — অজানা বা প্রযোজ্য নয়। COUNT ব্যতিক্রম, কারণ “কয়টি?” প্রশ্নের উত্তর সবসময় জানা: শূন্যটি।

Relational মডেলের আরেক প্রবক্তা Chris Date বহু বছর ধরে এর কড়া সমালোচনা করেছেন। তাঁর যুক্তি: SUM-এর ফাঁকা সেটে শূন্যই একমাত্র যুক্তিসংগত উত্তর, আর NULL দিয়ে SQL একটি সঠিক উত্তরকে “অজানা” বানিয়ে দিয়েছে; NULL-কেন্দ্রিক এই সব জটিলতা তাঁর মতে তিন-মানের যুক্তির (পর্ব ১৬) মূল সমস্যারই অংশ। অন্যদিকে Edgar Codd নিজে NULL আর অনুপস্থিত তথ্যের ধারণাকে আরও বাড়িয়ে দুই রকম NULL প্রস্তাব করেছিলেন। দুজনের তর্ক কখনো মীমাংসা হয়নি, আর ১৯৮৬ সালে প্রথম ANSI SQL standard যে আচরণ লিখে ফেলেছিল, লাখো কোয়েরির সামঞ্জস্যের জন্য তা আর বদলানো সম্ভব হয়নি।

বাস্তব প্রভাব বড়। রিপোর্টে “নতুন শাখার বিক্রি: NULL” দেখালে ম্যানেজার ভাবতে পারেন সিস্টেম নষ্ট। আবার কোড total > 0 পরীক্ষা করলে NULL-এর জন্য ফল অজানা, শর্ত মিথ্যা ধরা হয় — কোনো ত্রুটি ছাড়াই শাখাটি তালিকা থেকে বাদ পড়ে। এজন্য অনেক দলের নিয়ম: রিপোর্টে প্রতিটি SUM-এর বাইরে COALESCE. কিছু DBMS নিজস্ব সমাধানও দিয়েছে, যেমন SQLite-এর TOTAL(). আবার কিছু ক্ষেত্রে NULL-ই সঠিক: “এখনো কোনো পরীক্ষার ফল নেই” আর “ফল শূন্য” আলাদা তথ্য, যা NULL ধরে রাখে।

মূল উপলব্ধি: SUM-এর NULL একটি নকশাগত আপস, গাণিতিক সত্য নয়। প্রতিটি রিপোর্টে নিজে সিদ্ধান্ত নিন — “কিছু নেই” আপনার ব্যবসায় শূন্য, নাকি অজানা — আর সেটি COALESCE দিয়ে স্পষ্ট লিখুন।

প্র ০২MySQL বহু বছর bare column মেনে নিত, আর SQLite এখনো নেয়। কোনো জনপ্রিয় DBMS এমন “অনিশ্চিত” আচরণ কেন রাখবে? এর পক্ষে কি কোনো যুক্তি আছে?

যুক্তি আছে, আর সেটি বোঝা দরকার — কারণ ভুলটি প্রায় সবসময় সুবিধার ছদ্মবেশে আসে। সবচেয়ে সাধারণ ক্ষেত্র: SELECT c.customer_id, c.name, c.city, SUM(…) FROM customers c JOIN orders … GROUP BY c.customer_id. এখানে name আর city GROUP BY-তে নেই, কিন্তু customer_id primary key বলে প্রতিটি দলে name একটিই — উত্তরটি আসলে নিশ্চিত। কঠোর SQL-92-এ এটি লিখতে হতো GROUP BY c.customer_id, c.name, c.city — লম্বা, আর দলে ভাগের কাজও বাড়ত। MySQL-এর প্রথম দিকের নকশাকারেরা (১৯৯০-এর দশকের মাঝামাঝি) গতি আর সরলতাকে অগ্রাধিকার দিয়েছিলেন: পরীক্ষা না করে যেকোনো একটি মান তুলে নেওয়া। ওয়েব যুগের শুরুতে PHP আর MySQL একসাথে ছড়িয়ে পড়ায় লাখো কোড এই আচরণের ওপর দাঁড়িয়ে যায়।

সমস্যা হলো DBMS জানত না কোনটি “নিরাপদ” (primary key-র ওপর নির্ভরশীল) আর কোনটি আসল ভুল (একটি দলে অনেক আলাদা নাম)। দুটিই নিঃশব্দে চলত। SQL:1999 standard এর একটি মধ্যপন্থী সমাধান দেয়: functional dependency চিনে নেওয়া — GROUP BY-তে primary key থাকলে সেই টেবিলের বাকি কলাম বৈধ। PostgreSQL 9.1 (২০১১) এটি বাস্তবায়ন করে, আর MySQL 5.7 (২০১৫) একই সাথে ONLY_FULL_GROUP_BY ডিফল্টে চালু করে — অর্থাৎ নিরাপদ ক্ষেত্র চলবে, অনিশ্চিত ক্ষেত্র ত্রুটি দেবে। তবু আপগ্রেডের পর অনেক পুরোনো অ্যাপ ভেঙে যায়, আর বহু দল mode বন্ধ করে দেয়।

SQLite-এর দর্শন আলাদা: এটি অ্যাপের ভেতরে বসে থাকা ছোট লাইব্রেরি, আর পুরোনো আচরণ না ভাঙা (backward compatibility) এর সবচেয়ে বড় প্রতিশ্রুতিগুলোর একটি। তার ওপর SQLite bare column-কে কাজের একটি বৈশিষ্ট্যে পরিণত করেছে: একটি MIN বা MAX থাকলে bare column সেই সারি থেকেই আসে — “প্রতি বিভাগের সেরা শিক্ষার্থী” এক লাইনে। কিন্তু এটি SQLite-এর বাইরে বহনযোগ্য নয়, আর টাই হলে বা দুটি MAX থাকলে আবার অনিশ্চিত।

মূল উপলব্ধি: নিঃশব্দ সুবিধা আর নিঃশব্দ ভুল একই নিয়ম থেকে আসে। ভালো নিয়ম হলো যা নিশ্চিত, তা মেনে নেওয়া (functional dependency), আর যা অনিশ্চিত, তা স্পষ্ট করে লেখানো — ANY_VALUE, MIN বা window function দিয়ে।

প্র ০৩“প্রতিটি শাখার গড় লেনদেন” বের করে সেগুলোর গড় নিয়ে “দেশের গড় লেনদেন” রিপোর্ট করা কেন বিপজ্জনক? বাস্তবে এই ভুল কোথায় দেখা যায়?

কারণ গড় একটি অনুপাত, আর অনুপাতের সাধারণ গড় নেওয়া মানে প্রতিটি দলকে সমান ওজন দেওয়া। একটি মোবাইল ব্যাংকিং সেবার কথা ভাবুন, যার ঢাকার একটি এজেন্ট পয়েন্টে মাসে ১০,০০০ লেনদেন, গড় ২,০০০ টাকা; আর একটি গ্রামের পয়েন্টে মাসে ৫০টি লেনদেন, গড় ৮,০০০ টাকা (কয়েকটি বড় রেমিট্যান্স)। সংখ্যাগুলো কাল্পনিক, কিন্তু কাঠামো বাস্তবসম্মত। দুই গড়ের সাধারণ গড় ৫,০০০ টাকা — অথচ আসল গড় (১০,০০০ × ২,০০০ + ৫০ × ৮,০০০) ÷ ১০,০৫০ ≈ ২,০৩০ টাকা। প্রায় আড়াই গুণ ভুল, একটি গ্রামের পয়েন্টের কারণে। বাস্তবে এমন ভুল বিশেষ করে ড্যাশবোর্ডে ঘটে: একটি টুল প্রতিটি শাখার গড় হিসাব করে রাখে, আরেকটি টুল সেই টেবিল নিয়ে “মোট” কলামে আবার AVG চালায়।

দ্বিতীয় রূপ আরও সূক্ষ্ম: শতাংশের গড়। প্রতিটি বিভাগের পাসের হার নিয়ে সাধারণ গড় করলে বিশ্ববিদ্যালয়ের পাসের হার আসে না, যদি বিভাগগুলোর শিক্ষার্থীসংখ্যা আলাদা হয়। অংশ ৯-এর Simpson's paradox এর চরম রূপ — যেখানে দলের ভেতরের প্রবণতা আর মোট প্রবণতা উল্টো দিকে যায়। Berkeley-র ভর্তি বিশ্লেষণের পর পরিসংখ্যানবিদরা বারবার দেখিয়েছেন, চিকিৎসা-গবেষণায় একটি ওষুধ প্রতিটি রোগী-দলে ভালো ফল দিয়েও মোট হিসাবে খারাপ দেখাতে পারে, যদি অসুস্থতর রোগীরা বেশি করে সেই ওষুধ পান।

ডেটাবেসের দিক থেকে সমাধান কারিগরি আর সহজ: যা যোগ করা যায়, তা-ই সংরক্ষণ করুন। ডেটা ওয়্যারহাউসের ভাষায় SUM আর COUNT হলো “additive measure”, আর গড় বা অনুপাত “non-additive” (পর্ব ৫৫)। সারাংশ-টেবিলে প্রতিটি শাখার sum_amount আর txn_count রাখুন, গড় নয়; যেকোনো স্তরে গড় লাগলে SUM(sum_amount) ÷ SUM(txn_count). ROLLUP-ও একই কারণে ভেতরে SUM আর COUNT বয়ে নেয়।

মূল উপলব্ধি: গড়ের সাথে সবসময় জিজ্ঞেস করুন “কতজনের গড়?” — ওজন হারিয়ে গেলে গড় আর মেলানো যায় না। রিপোর্টের প্রতিটি স্তরে গড় নয়, যোগফল আর সংখ্যা বহন করুন।

প্র ০৪এক কোটি সারির টেবিলে SELECT COUNT(*) FROM t কিছু DBMS-এ মুহূর্তে আসে, PostgreSQL-এ কয়েক সেকেন্ড লাগে। একটি সংখ্যা গুনতে এত সময় কেন?

উত্তরটি লুকিয়ে আছে “কোন সারি এখন দৃশ্যমান” প্রশ্নে। MySQL-এর পুরোনো MyISAM storage engine প্রতিটি টেবিলের মোট সারির সংখ্যা একটি কাউন্টারে রাখত, তাই WHERE-বিহীন COUNT(*) তাৎক্ষণিক। কিন্তু MyISAM transaction সমর্থন করত না। আধুনিক DBMS — PostgreSQL আর MySQL-এর InnoDB — MVCC (multi-version concurrency control, পর্ব ৪৭) ব্যবহার করে: একই সময়ে চলা দুটি transaction একই টেবিলের আলাদা “সংস্করণ” দেখতে পারে। আপনি যখন গুনছেন, তখন অন্য কেউ ১০০টি সারি ঢুকিয়েছে কিন্তু এখনো commit করেনি — আপনার গোনায় সেগুলো থাকবে না, তার নিজের গোনায় থাকবে। তাই কোনো একক “সঠিক” কাউন্টার রাখা সম্ভব নয়; প্রতিটি সারি (বা index-এর এন্ট্রি) দেখে যাচাই করতে হয় সেটি এই transaction-এর কাছে দৃশ্যমান কি না।

PostgreSQL এই কাজ কমাতে দুটি কৌশল নেয়: আকারে ছোট একটি index থাকলে “index-only scan”, যেখানে visibility map নামের একটি bitmap বলে দেয় কোন page-এর সব সারি সবার কাছে দৃশ্যমান — সেগুলোর জন্য আসল টেবিল পড়াই লাগে না; আর ৯.৬ থেকে parallel aggregate — কয়েকটি worker প্রক্রিয়া টেবিলের আলাদা অংশ গুনে নিজেদের আংশিক COUNT যোগ করে (আবার সেই “যোগ করা যায়” নীতি, ভাবনার প্রশ্ন ৩)। InnoDB-ও প্রতিবার গোনে, তবে সবচেয়ে ছোট index বেছে নেয়।

বাস্তবের সমাধান প্রশ্নটিকে প্রশ্ন করা। “ঠিক কতটি” লাগে, নাকি “মোটামুটি কতটি”? ড্যাশবোর্ডে “প্রায় ১ কোটি ২০ লাখ ব্যবহারকারী” দেখাতে PostgreSQL-এর system catalog-এর অনুমান (pg_class.reltuples) মিলিসেকেন্ডে আসে। ঠিক সংখ্যা ঘন ঘন লাগলে আলাদা একটি কাউন্টার-টেবিল, যা trigger বা একই transaction-এ হালনাগাদ হয় — আবার পর্ব ১১-এর derived value-এর দাম। আর “অন্তত একটি আছে কি?” জানতে COUNT(*) নয়, EXISTS (পর্ব ২১) — প্রথম সারি পেলেই থামে।

মূল উপলব্ধি: COUNT(*) সরল দেখায়, কিন্তু concurrency-র যুগে “কয়টি সারি” প্রশ্নের উত্তর নির্ভর করে কে, কখন জিজ্ঞেস করছে তার ওপর। ঠিক সংখ্যা, অনুমান আর অস্তিত্ব — তিনটি আলাদা প্রশ্ন, আলাদা খরচ।

প্র ০৫SQL Server-এ একটি INT কলামের AVG 3.5 না দিয়ে 3 দেয়, আর বড় টেবিলে SUM কখনো overflow ত্রুটি দেয়। aggregate-এর ফলের ডেটা টাইপ কে ঠিক করে, আর কোন DBMS কী করে?

SQL standard ফলের টাইপের ব্যাপারে অনেকটা স্বাধীনতা দিয়েছে — বলেছে AVG-এর ফল একটি সংখ্যা-টাইপ, যার নির্ভুলতা “implementation-defined”, অর্থাৎ প্রতিটি DBMS নিজে ঠিক করবে। ফলে একই কোয়েরি চার জায়গায় চার রকম আচরণ করে। SQL Server-এ AVG ইনপুটের টাইপই রাখে: INT কলামের গড় INT, তাই ৩ আর ৪-এর গড় 3 — ভগ্নাংশ কেটে ফেলা হয়। ঠিক করতে AVG(CAST(x AS DECIMAL(10,2))) বা AVG(x * 1.0) লিখতে হয়। PostgreSQL-এ integer-এর AVG ফেরত দেয় numeric (নির্ভুল দশমিক), তাই 3.5000…; SQLite-এ AVG সবসময় দশমিক (floating-point) — এই পর্বের এডিটরেও দেখেছেন। MySQL integer-এর AVG দেয় DECIMAL.

SUM-এর গল্পও আলাদা। SQL Server-এ INT কলামের SUM ফলও INT, তাই প্রায় ২১৪ কোটির বেশি হলে “Arithmetic overflow” ত্রুটি — এক কোটি লেনদেনে গড়ে ২১৫ টাকা হলেই যথেষ্ট; সমাধান SUM(CAST(x AS BIGINT)). PostgreSQL integer-এর SUM-কে bigint-এ আর bigint-এর SUM-কে numeric-এ তুলে নেয়, তাই overflow প্রায় অসম্ভব। SQLite-এ integer-এর SUM ৬৪-বিট সীমা ছাড়ালে “integer overflow” ত্রুটি দেয় (আর TOTAL() দশমিকে হিসাব করে চালিয়ে যায়)। আর REAL বা FLOAT কলামের SUM-এ পর্ব ১১-এর floating-point ভুল জমতে পারে — টাকা পয়সায় INTEGER রাখার আরেকটি কারণ।

এখান থেকে দুটি অভ্যাস আসে। এক, aggregate-এর ফল কোন টাইপে আসছে তা জানুন — বিশেষ করে কোড এক DBMS থেকে আরেকটিতে নেওয়ার সময় (পর্ব ৩১)। SQLite-এ typeof(AVG(x)) দিয়ে নিজেই দেখতে পারেন। দুই, ভাগ নিজে করলে (SUM ÷ COUNT) integer division-এর ফাঁদ মনে রাখুন: PostgreSQL আর SQL Server-এ 7 / 2 = 3; দশমিক চাইলে একটি দিক দশমিকে বদলান। ভারিত গড়ের SUM(credits * point) / SUM(credits)-এ credits দশমিক বলে এখানে সমস্যা নেই, কিন্তু সব কলামে এমন থাকবে না।

মূল উপলব্ধি: aggregate-এর ফলের টাইপ ভাষার অংশ নয়, প্রতিটি DBMS-এর সিদ্ধান্ত। গড় আর ভাগের প্রতিটি জায়গায় ইচ্ছাকৃত CAST লিখুন, আর বড় যোগফলে টাইপের সীমা মাথায় রাখুন।

অনুশীলন

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

  1. কোয়েরি লিখুন: db-shop-এর customers টেবিল থেকে প্রতিটি শহরে কতজন গ্রাহক, কিন্তু শুধু সেই শহরগুলো যেখানে অন্তত দুজন গ্রাহক আছেন। শহর জানা নেই (NULL) এমন গ্রাহক কোথায় যাবেন?
    SELECT city, COUNT(*) AS customers
    FROM customers
    GROUP BY city
    HAVING COUNT(*) >= 2;

    একটি সারি: Dhaka | 3 (Rahim, Sabbir, Mitu). HAVING না দিলে ছয়টি দল আসত — Dhaka 3 আর Chattogram, Khulna, Rajshahi, Sylhet আর NULL প্রতিটিতে 1. Imran Hossain-এর শহর NULL, তিনি NULL নামের নিজস্ব দলে যান (GROUP BY সব NULL-কে এক দলে রাখে), কিন্তু সেখানে একজন বলে HAVING তাঁকে বাদ দেয়। শর্তটি “অন্তত দুজন”, তাই >= 2, > 2 নয়।

  2. ফল আগে বলুন (trace): একটি টেবিল t(g, v)-এ ছয়টি সারি: ('a', 10), ('a', NULL), ('b', 5), ('b', 5), ('c', NULL), (NULL, 7). এই কোয়েরির ফল হাতে বের করুন, তারপর যেকোনো এডিটরে টেবিলটি বানিয়ে মিলিয়ে নিন: SELECT g, COUNT(*), COUNT(v), COUNT(DISTINCT v), SUM(v), AVG(v) FROM t GROUP BY g HAVING SUM(v) > 6;

    প্রথমে চারটি দল: NULL, a, b, c. প্রতিটির হিসাব —

    • NULL দল (7): COUNT(*) 1, COUNT(v) 1, DISTINCT 1, SUM 7, AVG 7.0 → 7 > 6, থাকে।
    • a (10, NULL): COUNT(*) 2, COUNT(v) 1, DISTINCT 1, SUM 10, AVG 10.0 — NULL বাদ দিয়ে ১ দিয়ে ভাগ → থাকে।
    • b (5, 5): COUNT(*) 2, COUNT(v) 2, DISTINCT 1, SUM 10, AVG 5.0 → থাকে।
    • c (NULL): COUNT(*) 1, COUNT(v) 0, DISTINCT 0, SUM NULL, AVG NULL → NULL > 6 অজানা, তাই বাদ।

    ফল তিনটি সারি: NULL | 1 | 1 | 1 | 7 | 7, a | 2 | 1 | 1 | 10 | 10, b | 2 | 2 | 1 | 10 | 5 (SQLite-এ AVG দশমিক সংখ্যা, যেমন 7.0, কিন্তু এডিটর শেষের .0 দেখায় না)। মূল শিক্ষা: c-এর দলে একটি সারি আছে, দলটি ফাঁকা নয়; তবু SUM NULL, কারণ শুধু NULL-ওয়ালা দলও aggregate-এর চোখে ফাঁকা সেট।

  3. কোয়েরি লিখুন: শুধু order_items টেবিল ব্যবহার করে প্রতিটি পণ্যের মোট বিক্রিত একক (units) আর আয় বের করুন, বাতিল অর্ডার 1004 বাদ দিয়ে; সবচেয়ে বেশি একক আগে, টাই হলে product_id. কোন পণ্য একক-সংখ্যায় প্রথম, আর কোনটি আয়ে?
    SELECT product_id,
           SUM(quantity)              AS units,
           SUM(quantity * unit_price) AS revenue
    FROM order_items
    WHERE order_id <> 1004
    GROUP BY product_id
    ORDER BY units DESC, product_id;

    আটটি সারি: 108 (Rice 5kg) 8 একক, 3360; 109 (Mustard Oil) 6, 1560; 102 (Headphone) 3, 3550; 105 (SQL Book) 3, 1650; 101 (Mobile Phone) 2, 29500; 104 2, 1300; 106 2, 4400; 110 (Smart Watch) 2, 7000. এককে প্রথম চাল, কিন্তু আয়ে প্রথম Mobile Phone — 29500, চালের প্রায় নয় গুণ। “সবচেয়ে বেশি বিক্রি” কথাটির দুই অর্থ, তাই প্রশ্নকর্তাকে জিজ্ঞেস করুন কোনটি চান। Saree (107) আসেনি, কারণ তার একমাত্র বিক্রি বাতিল অর্ডারে; Power Bank (103) কখনো বিক্রিই হয়নি। এখানে 1004 সরাসরি লেখা হয়েছে; বাস্তবে অংশ ৫-এর মতো status দেখে বাদ দিন, যাতে নতুন বাতিল অর্ডারও বাদ পড়ে।

  4. ভুল খুঁজুন: একজন সহকর্মী AI দিয়ে “প্রতিটি গ্রাহকের অর্ডারের সংখ্যা আর শেষ অর্ডারের তারিখ, শুধু ২টির বেশি অর্ডার থাকলে” কোয়েরিটি লিখিয়েছেন: SELECT o.customer_id, c.name, c.city, COUNT(*) AS orders, order_date FROM orders o JOIN order_items oi ON oi.order_id = o.order_id JOIN customers c ON c.customer_id = o.customer_id WHERE COUNT(*) > 2 GROUP BY o.customer_id; কয়টি সমস্যা পাচ্ছেন?
    • WHERE-এ aggregate: COUNT(*) > 2 HAVING-এ যাবে — এই অবস্থায় কোয়েরি চলবেই না (misuse of aggregate).
    • COUNT(*) লাইন গোনে: order_items-এর সাথে JOIN করায় প্রতিটি অর্ডার তার লাইন-সংখ্যা যতবার আসে। order_items-এর JOIN দরকারই নেই — সরিয়ে দিন, অথবা COUNT(DISTINCT o.order_id).
    • bare column order_date: এটি GROUP BY-তে বা aggregate-এ নেই। “শেষ অর্ডার” মানে MAX(o.order_date).
    • c.name, c.city: GROUP BY-তে শুধু o.customer_id; PostgreSQL এটি মানবে না, কারণ GROUP BY-তে customers-এর primary key নেই। GROUP BY c.customer_id, c.name, c.city লিখুন।

    সংশোধিত রূপ: SELECT c.customer_id, c.name, c.city, COUNT(*) AS orders, MAX(o.order_date) AS last_order FROM customers c JOIN orders o ON o.customer_id = c.customer_id GROUP BY c.customer_id, c.name, c.city HAVING COUNT(*) > 2; — ফল একটি সারি: Rahim Uddin, Dhaka, 3, 2025-10-08. পুরোনো ভুল কোয়েরি HAVING-এ সরালেও Rahim-এর জন্য 6 দেখাত, আর Karima (২টি অর্ডার, ৪টি লাইন)-ও তালিকায় ঢুকে যেতেন।

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

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

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

  1. FROM — কোন টেবিল (আর JOIN)
  2. WHERE — সারি ধরে ছাঁকা
  3. GROUP BY — দলে ভাগ আর aggregate হিসাব
  4. HAVING — দল ধরে ছাঁকা
  5. SELECT — কলাম আর alias তৈরি
  6. ORDER BY — ফল সাজানো

একটি students টেবিলে ১০টি সারি, যার ৩টিতে cgpa কলাম NULL; বাকি ৭টিতে cgpa-র মান ৫টি আলাদা সংখ্যা। SELECT COUNT(*), COUNT(cgpa), COUNT(DISTINCT cgpa) FROM students;-এর ফল কোনটি?

  • 10, 10, 5
  • 10, 7, 5
  • 7, 7, 5
  • 10, 7, 6

order_items(order_id, quantity, unit_price) টেবিলে order_id = 9999 এমন কোনো সারি নেই। SELECT COUNT(*), SUM(quantity * unit_price) FROM order_items WHERE order_id = 9999;-এর ফল কী?

  • কোনো সারি আসে না
  • একটি সারি: 0, 0
  • একটি সারি: 0, NULL
  • ত্রুটি: no rows

“যেসব বিভাগে গড় CGPA 3.5-এর বেশি” — students(dept_id, cgpa) টেবিল থেকে এই প্রশ্নের সঠিক কোয়েরি কোনটি?

  • SELECT dept_id FROM students WHERE AVG(cgpa) > 3.5 GROUP BY dept_id
  • SELECT dept_id FROM students WHERE cgpa > 3.5 GROUP BY dept_id
  • SELECT dept_id FROM students HAVING AVG(cgpa) > 3.5
  • SELECT dept_id FROM students GROUP BY dept_id HAVING AVG(cgpa) > 3.5

students(student_id, name, dept_id, cgpa) টেবিলে PostgreSQL-এ SELECT dept_id, name, MAX(cgpa) FROM students GROUP BY dept_id; চালালে কী হয়?

  • ত্রুটি — name GROUP BY-তে বা aggregate-এ থাকতে হবে
  • প্রতিটি বিভাগের সর্বোচ্চ cgpa-ধারীর নাম আসে
  • প্রতিটি বিভাগের যেকোনো একজনের নাম আসে
  • প্রতিটি শিক্ষার্থীর জন্য একটি সারি আসে

একটি কোম্পানির দুটি শাখা: শাখা A-তে ৯০ জন কর্মী, গড় বেতন ২০,০০০ টাকা; শাখা B-তে ১০ জন, গড় বেতন ৬০,০০০ টাকা। সব কর্মী মিলিয়ে গড় বেতন কত?

  • ৪০,০০০ টাকা
  • ২০,০০০ টাকা
  • ২৪,০০০ টাকা
  • ৩৬,০০০ টাকা

orders(order_id, status) টেবিল থেকে একটি সারিতে মোট অর্ডার আর delivered অর্ডারের সংখ্যা পাশাপাশি চাই, এমনভাবে যেন MySQL, PostgreSQL, SQLite আর SQL Server — সবখানে চলে। কোনটি লিখবেন?

  • COUNT(*), COUNT(*) FILTER (WHERE status = 'delivered')
  • COUNT(*), SUM(status = 'delivered')
  • COUNT(*), COUNT(status) … WHERE status = 'delivered'
  • COUNT(*), SUM(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END)

শব্দকোষ

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

মনে রাখুন

  1. COUNT(*) সারি গোনে; COUNT(col), SUM, AVG, MIN, MAX আগে NULL বাদ দেয় — AVG তাই অ-NULL মানের সংখ্যা দিয়ে ভাগ করে।
  2. ফাঁকা সেটে COUNT 0, বাকিরা NULL; রিপোর্টে COALESCE(SUM(x), 0). GROUP BY থাকলে ফাঁকা সেট থেকে কোনো সারিই আসে না।
  3. GROUP BY = ঝুড়িতে ভাগ, প্রতি ঝুড়িতে একটি সারি; SELECT-এর প্রতিটি কলাম GROUP BY-তে বা aggregate-এ — SQLite-এর bare column-এ ভরসা নয়।
  4. যৌক্তিক ক্রম FROM → WHERE (সারি) → GROUP BY → HAVING (দল) → SELECT → ORDER BY; সারির শর্ত WHERE-এ রাখলে কাজ কম।
  5. FILTER বা SUM(CASE …) দিয়ে শর্তসাপেক্ষ গোনা, GROUP_CONCAT/STRING_AGG দিয়ে তালিকা, ROLLUP দিয়ে উপ-মোট; আর গড়ের গড় নয় — SUM আর COUNT বয়ে নিন।

পরের পর্বে: এই পর্বে বারবার দেখেছেন, আসল প্রশ্নে তথ্য থাকে কয়েকটি টেবিলে — গ্রাহকের নাম customers-এ, টাকা order_items-এ। পর্ব ১৯-এ JOIN: INNER, LEFT, RIGHT আর FULL — কীভাবে টেবিল মেলাতে হয়, আর কোন JOIN কোন সারি হারায়।

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

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