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

SQL ইন্টারভিউর সমস্যা — প্যাটার্ন ধরে সমাধান

SQL Interview Problems by Pattern — Nth Highest, Top-N per Group, Duplicates, Anti Join and Division, Running Totals and Growth, Streaks, First Events, Pivot, Self Join, Funnels, Retention Cohorts, Median and Calendars

Taught in Bangla, this page solves SQL interview problems by pattern: an interview framework, Nth highest and top-N per group, duplicates and anti-join and division questions, running totals, monthly growth, streaks and first events, pivots and self joins, funnels and retention cohorts, median, percentiles and calendars without built-ins, complexity and indexes, common mistakes, with a cheat sheet and live SQL.

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

  • SQL ইন্টারভিউর পাঁচ ধাপের পদ্ধতি, আর কথা বলতে বলতে কোয়েরি লেখার অভ্যাস
  • ১৩টি প্রচলিত প্যাটার্ন চেনার সংকেত আর প্রতিটির টেমপ্লেট — ranking, anti join, gaps and islands, pivot, cohort আর আরও
  • প্রতিটি সমাধানে NULL, tie, duplicate আর খালি দলের ফাঁদ নিজে থেকে ধরা
  • কোয়েরির জটিলতা আর কোন index সাহায্য করবে, তা EXPLAIN দেখে বুঝিয়ে বলা
  • ইন্টারভিউয়াররা যে ভুলগুলো খোঁজেন — bare column, NULL তুলনা, fan-out, WHERE বনাম HAVING — সেগুলো এড়ানো

১ · ইন্টারভিউর কাঠামো — প্রশ্ন, সরল সংস্করণ, পরীক্ষা, উন্নতি, ব্যাখ্যা

পর্ব ৬৩–৬৫-এ আপনি কোয়েরির ফল হাতে বের করা, MCQ আর লিখিত-মৌখিক প্রশ্নের অনুশীলন করেছেন। SQL ইন্টারভিউ এর থেকে আলাদা: একটি ব্যবসার প্রশ্ন দেওয়া হয় (“গত মাসে প্রতিটি শহরের সেরা গ্রাহক কে?”), কয়েকটি টেবিলের বর্ণনা দেওয়া হয়, আর ১৫–৪৫ মিনিটে আপনাকে একটি সঠিক কোয়েরি লিখে বুঝিয়ে বলতে হয়। কখনো শেয়ার করা editor-এ চালানো যায়, কখনো শুধু কাগজে বা হোয়াইটবোর্ডে। পদভেদে জোর আলাদা হয়, তবে প্যাটার্নগুলো প্রায় একই:

পদসাধারণত যে ধরনের প্রশ্নএই পাতার যে অংশ বেশি কাজে লাগবে
Data analystব্যবসার মেট্রিক: মাসিক আয়, বৃদ্ধি, সেরা পণ্য, funnel, retention; ফলের অর্থ বোঝানোঅংশ ২, ৪, ৫, ৬, ৭
Backend developerJOIN আর aggregation-এর সঠিকতা, “কখনো না” ধরনের খোঁজ, index আর কোয়েরির গতি, duplicate ঠেকানোঅংশ ২, ৩, ৫, ৮
Data engineerবিশাল টেবিলে deduplicate, প্রথম/শেষ ঘটনা, sessionization, ক্যালেন্ডার, জটিলতা আর partitionঅংশ ৩, ৪, ৭, ৮

পাঁচ ধাপের পদ্ধতি

চাপের মধ্যে সবচেয়ে বড় ভুল হলো প্রশ্ন শুনেই লিখতে শুরু করা। মুদি দোকানের হিসাবরক্ষককে মালিক বললেন, “গত মাসের বিক্রি কত?” — অভিজ্ঞ হিসাবরক্ষক আগে জিজ্ঞেস করবেন, “ফেরত দেওয়া মাল বাদ দেব? বাকির বিক্রি ধরব?” SQL ইন্টারভিউতে ঠিক এটাই দেখা হয়। পদ্ধতিটি এরকম:

প্রশ্ন করে schema ও edge case পরিষ্কার → সরল সংস্করণ → ছোট ডেটায় পরীক্ষা → উন্নতি → ব্যাখ্যা

সমস্যা · প্রতিটি শহরের সেরা গ্রাহক (মাঝারি): db-shop ডেটাবেস দেওয়া আছে: customers (customer_id, name, city — NULL হতে পারে), orders (order_id, customer_id, order_date, status: pending, shipped, delivered বা cancelled), order_items (order_id, product_id, quantity, unit_price — প্রতিটি অর্ডারের প্রতিটি পণ্যের একটি সারি)। প্রশ্ন: “প্রতিটি শহরে যিনি সবচেয়ে বেশি টাকার কেনাকাটা করেছেন, তাঁর নাম, অর্ডার সংখ্যা আর মোট খরচ দেখান।” নিচে “পরে” চাপুন:

  1. ১ · প্রশ্নschema, সংজ্ঞা, edge case
  2. ২ · সরল সংস্করণআগে একটি চলা কোয়েরি
  3. ৩ · পরীক্ষাএকজনের হিসাব হাতে মেলান
  4. ৪ · উন্নতিভুল সারান, প্যাটার্ন বসান
  5. ৫ · ব্যাখ্যাসিদ্ধান্ত, জটিলতা, index
আপনি জিজ্ঞেস করছেনলেখা শুরুর আগে
- "খরচ" = SUM(quantity * unit_price)?   (হ্যাঁ)
- cancelled অর্ডার বাদ? pending ধরব?   (বাদ / ধরব)
- একই শহরে দুজন সমান হলে?              (দুজনই)
- city NULL হলে?                         (আলাদা দল)
- কোনো বৈধ অর্ডারই নেই এমন গ্রাহক?     (বাদ)
-> RANK, COALESCE(city), status <> 'cancelled'
সরল সংস্করণআগে গ্রাহকপ্রতি হিসাব
SELECT c.city, c.name,
       COUNT(o.order_id) AS orders,
       SUM(i.quantity * i.unit_price) AS spent
FROM customers c
JOIN orders o      ON ...
JOIN order_items i ON ...
WHERE o.status <> 'cancelled'
GROUP BY c.customer_id
-- শহরের সেরা বাছাই পরে, আগে এটা ঠিক হোক
একজনকে হাতে মেলানোRahim Uddin
অর্ডার: 1001, 1003, 1009     -> 3টি
খরচ:   17400 + 1780 + 18000  -> 37180  ✓
কোয়েরির ফল: orders = 6   ✗
প্রতিটি অর্ডারে ২টি করে পণ্য-সারি,
JOIN-এর পর অর্ডার ৩টি থেকে ৬টি সারি
-> fan-out: COUNT ভুল, SUM ঠিক
উন্নতিদুটি পরিবর্তন
১. COUNT(DISTINCT o.order_id)
   (বা আগে অর্ডারপ্রতি যোগফল, তারপর JOIN)
২. শহরের ভেতরে ranking:
   RANK() OVER (PARTITION BY city
                ORDER BY spent DESC)
   বাইরে WHERE rnk = 1
ROW_NUMBER নয় — tie হলে দুজনই চাই
শেষে যা বলবেন৩০ সেকেন্ডে
"cancelled বাদ, pending রেখেছি — বদলানো সহজ।
 tie-তে দুজনই আসবেন (RANK).
 NULL city আলাদা দল '(অজানা)'।
 খরচ: JOIN গুলো hash বা index দিয়ে প্রায় রৈখিক,
 GROUP BY আর window-এ একবার sort: O(n log n).
 বড় টেবিলে orders(customer_id) আর
 order_items-এর (order_id, ...) PK সাহায্য করে।"
  1. প্রশ্ন করুন। ব্যবসার প্রশ্ন সবসময় একটু অস্পষ্ট থাকে — অনেক সময় ইচ্ছা করেই। “খরচ” কীভাবে মাপব, বাতিল অর্ডার বাদ যাবে কি না, সমান খরচে কী হবে, শহর NULL হলে কী হবে — এগুলো একেকটি clarifying question. প্রতিটি উত্তর কোয়েরির একটি নির্দিষ্ট অংশ ঠিক করে দেয়, ডান দিকের শেষ লাইনে যেমন দেখছেন।
  2. সরল সংস্করণ। সরাসরি “শহরের সেরা” লিখতে না গিয়ে আগে গ্রাহকপ্রতি অর্ডার সংখ্যা আর খরচ বের করুন। এটা চলে, আর এর ওপর পরের ধাপ বসবে। বড় সমস্যাকে ছোট, পরীক্ষাযোগ্য ধাপে ভাঙাই CTE-র কাজ (পর্ব ২৫)।
  3. ছোট ডেটায় পরীক্ষা। একজন গ্রাহক বেছে তাঁর হিসাব হাতে করুন। Rahim-এর অর্ডার ৩টি, কিন্তু কোয়েরি বলছে ৬টি — কারণ order_items-এর সাথে JOIN-এর পর প্রতিটি অর্ডার তার পণ্য-সারির সংখ্যা অনুযায়ী বহুগুণ হয়েছে। এটাই fan-out. SUM ঠিক আছে, কারণ যোগ হচ্ছে পণ্য-সারির নিজের মান।
  4. উন্নতি। অর্ডার গুনতে COUNT(DISTINCT o.order_id), তারপর শহরের ভেতরে RANK দিয়ে নম্বর বসিয়ে বাইরে rnk = 1 ছাঁকা — এটি প্যাটার্ন ২-এর top-N per group, N = 1. tie-তে দুজনকেই রাখতে ROW_NUMBER নয়, RANK.
  5. ব্যাখ্যা। শেষে নিজের সিদ্ধান্তগুলো (cancelled বাদ, tie-তে সবাই, NULL শহর আলাদা) আর খরচ এক নিঃশ্বাসে বলুন। ইন্টারভিউয়ার শর্ত বদলালে (“pending-ও বাদ দিন”) দেখাতে পারবেন ঠিক কোন লাইন বদলাবে।

এবার দুটি সংস্করণই চালিয়ে দেখুন — প্রথমটি সরল, দ্বিতীয়টি সঠিক:

-- ১. সরল সংস্করণ: অর্ডার সংখ্যায় fan-out
SELECT c.city, c.name, COUNT(o.order_id) AS orders,
       SUM(i.quantity * i.unit_price) AS spent
FROM customers c
JOIN orders o      ON o.customer_id = c.customer_id
JOIN order_items i ON i.order_id = o.order_id
WHERE o.status <> 'cancelled'
GROUP BY c.customer_id ORDER BY spent DESC;
-- ২. সঠিক: DISTINCT দিয়ে গোনা, শহরের ভেতরে RANK
WITH spend AS (
  SELECT COALESCE(c.city, '(অজানা)') AS city, c.name,
         COUNT(DISTINCT o.order_id) AS orders,
         SUM(i.quantity * i.unit_price) AS spent
  FROM customers c
  JOIN orders o      ON o.customer_id = c.customer_id AND o.status <> 'cancelled'
  JOIN order_items i ON i.order_id = o.order_id
  GROUP BY c.customer_id, c.name, c.city
), ranked AS (
  SELECT *, RANK() OVER (PARTITION BY city ORDER BY spent DESC) AS rnk FROM spend
)
SELECT city, name, orders, spent FROM ranked WHERE rnk = 1 ORDER BY spent DESC;

প্রথম ফলে ৬টি সারি; Rahim-এর সারি Dhaka | Rahim Uddin | 6 | 37180 — অর্ডার ৬টি দেখাচ্ছে, আসলে ৩টি। Karima-র ক্ষেত্রেও 4 (আসলে 2). দ্বিতীয় ফলে ৫টি সারি: Dhaka | Rahim Uddin | 3 | 37180, Rajshahi | Nusrat Jahan | 1 | 5050, Chattogram | Karima Begum | 2 | 5000, Sylhet | Farhana Akter | 1 | 3500, (অজানা) | Imran Hossain | 1 | 1040. Dhaka-র Mitu (550) শীর্ষে নেই, তাই বাদ। Sabbir-এর একমাত্র অর্ডার বাতিল আর Tanvir-এর কোনো অর্ডার নেই — inner JOIN দুজনকেই বাদ দিয়েছে, যা প্রথম ধাপে ঠিক করা সিদ্ধান্তের সাথে মেলে। লক্ষ করুন, প্রথম কোয়েরিতে GROUP BY c.customer_id দিয়ে city আর name দেখানো চলে, কারণ দুটোই primary key-র ওপর নির্ভরশীল; PostgreSQL-ও এটি মানে, কিন্তু অন্য কলামে এমন bare column বিপজ্জনক (অংশ ৮)।

কথা বলতে বলতে কোয়েরি লেখা

ইন্টারভিউয়ার আপনার মাথার ভেতর দেখতে পান না, তাই think aloud — ভাবনাটা জোরে বলা — এখানে সবচেয়ে বড় দক্ষতা। কয়েকটি কাজের অভ্যাস:

  • Schema আগে বলে নিন: “orders আর order_items এক-থেকে-বহু, তাই এই JOIN-এর পর সারি বাড়বে।” টেবিলের grain (একটি সারি মানে কী) বলে ফেললেই fan-out-এর অর্ধেক ঝুঁকি কমে।
  • অনুমান লিখে রাখুন: উত্তর না পেলে নিজে একটি যুক্তিসঙ্গত অনুমান করুন আর বলুন, “ধরে নিচ্ছি cancelled বাদ — দরকারে এক লাইনে বদলানো যাবে।”
  • CTE-তে নাম দিন: spend, ranked — নামগুলোই আপনার ব্যাখ্যা। এক বিশাল nested কোয়েরির চেয়ে ধাপে ধাপে CTE পড়া আর ডিবাগ করা অনেক সহজ।
  • প্রতিটি ধাপে সারির সংখ্যা ভাবুন: “এখানে ১০টি অর্ডার, JOIN-এর পর ১৬টি পণ্য-সারি, GROUP BY-এর পর ৬ জন গ্রাহক।” সংখ্যা অদ্ভুত লাগলে সেটাই বাগের সংকেত।
  • সময় ফুরালে: সঠিক কিন্তু ধীর সমাধান (correlated subquery) আগে, দ্রুতটি পরে। ভুল দ্রুত সমাধানের চেয়ে সঠিক ধীর সমাধান সবসময় ভালো।
মূল ধারণা

SQL ইন্টারভিউতে দেখা হয় আপনি অস্পষ্ট প্রশ্নকে সুনির্দিষ্ট সংজ্ঞায় আনতে পারেন কি না, আর নিজের কোয়েরি নিজে সন্দেহ করে যাচাই করেন কি না। প্রতিটি প্যাটার্নের edge case — NULL, tie, duplicate, খালি দল — নিজে থেকে তুললে সেটাই আপনাকে আলাদা করে।

এই পাতার বাকি অংশে ১৩টি প্যাটার্ন আছে। প্রতিটির শুরুতে রিভিশনের পর্ব দেওয়া আছে। এডিটরের নিচে “নমুনা ডেটাবেস” খুলে সব টেবিল আর সারি দেখতে পাবেন।

২ · Ranking — দ্বিতীয় বা N-তম সর্বোচ্চ, আর প্রতি দলের সেরা N

প্যাটার্ন ১ · দ্বিতীয় (বা N-তম) সর্বোচ্চ মান

রিভিশন: পর্ব ২৬ · Window function, পর্ব ২১ · Subquery, পর্ব ১৫ · SELECT, LIMIT আর OFFSET

চেনার সংকেত: “দ্বিতীয় সর্বোচ্চ বেতন”, “তৃতীয় সবচেয়ে দামি পণ্য”, “N-তম স্থান”, “না থাকলে NULL দিন”। সম্ভবত সবচেয়ে বেশি জিজ্ঞাসিত SQL প্রশ্ন — কারণ এক লাইনের সরল উত্তরটি tie থাকলে ভুল হয়।

WITH r AS (SELECT t.*, DENSE_RANK() OVER (ORDER BY value DESC) AS dr
           FROM t WHERE value IS NOT NULL)
SELECT * FROM r WHERE dr = :n;          -- N-তম আলাদা মান, tie-তে সবাই

সমস্যা · দ্বিতীয় সর্বোচ্চ বেতন (সহজ): db-uni-এর instructors টেবিলে (instructor_id, name, dept_id, salary, mentor_id) শিক্ষকদের বেতন আছে: 120000, 85000, 110000, 70000, 90000, 65000. প্রশ্ন: দ্বিতীয় সর্বোচ্চ বেতন কত, আর কে কে পান? প্রথমে জিজ্ঞেস করুন: “সর্বোচ্চ বেতন দুজন পেলে দ্বিতীয় কে — একই মানের দ্বিতীয় জন, নাকি পরের আলাদা মান?” প্রায় সবসময় উত্তর: পরের আলাদা মান। ফাঁদটি দেখাতে এডিটরের প্রথম লাইনে Nadia Sultana-কে সর্বোচ্চ বেতনেই যোগ করা হয়েছে — লাইনটি মুছে দিয়েও চালিয়ে দেখুন।

INSERT INTO instructors VALUES (7, 'Nadia Sultana', 3, 120000, NULL);  -- tie
-- ১. সরল চেষ্টা: সাজিয়ে দ্বিতীয় সারি
SELECT name, salary FROM instructors ORDER BY salary DESC LIMIT 1 OFFSET 1;
-- ২. DENSE_RANK: দ্বিতীয় আলাদা বেতন যাঁরা পান, সবাই
WITH r AS (SELECT name, salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS dr
           FROM instructors WHERE salary IS NOT NULL)
SELECT name, salary FROM r WHERE dr = 2;
-- ৩. শুধু মানটি; না থাকলে NULL (scalar subquery)
SELECT (SELECT DISTINCT salary FROM instructors WHERE salary IS NOT NULL
        ORDER BY salary DESC LIMIT 1 OFFSET 1) AS second_highest;
-- ৪. পুরোনো পথ: সর্বোচ্চের চেয়ে কম যা, তার সর্বোচ্চ
SELECT MAX(salary) AS second_highest FROM instructors
WHERE salary < (SELECT MAX(salary) FROM instructors);

প্রথম কোয়েরি দিল Nadia Sultana | 120000 — সর্বোচ্চ বেতনটিই, কারণ 120000 দুজনের, আর OFFSET 1 শুধু একটি সারি টপকায়, একটি মান নয় (কোন জন আসবেন তাও নিশ্চিত নয়, tie-র ক্রম SQL ঠিক করে দেয় না)। বাকি তিনটির উত্তর একই: Dr. Mahbub Alam | 110000, আর শুধু মান 110000. DENSE_RANK সমান মানকে একই নম্বর দেয় আর পরের নম্বর লাফায় না, তাই dr = 2 মানে “দ্বিতীয় আলাদা মান”। প্রথম লাইনটি মুছে চালালে চারটিই 110000 দেয় — এজন্যই tie ছাড়া ডেটায় ভুলটি চোখে পড়ে না।

  • DISTINCT-এর জায়গা: ৩ নম্বরে DISTINCT থাকায় 120000 একবারই গোনা হয়, তাই OFFSET 1 ঠিক কাজ করে। ১ নম্বরে DISTINCT বসালে মানটি ঠিক আসত, কিন্তু নাম হারাত।
  • “না থাকলে NULL”: সবার বেতন সমান হলে বা একজনই থাকলে ২ নম্বর কোনো সারি দেয় না; ৩ নম্বর বাইরের SELECT (…)-এর কারণে একটি সারি দেয়, মান NULL. প্রশ্নে “NULL দিন” থাকলে এই রূপ।
  • NULL বেতন: SQLite আর MySQL-এ NULL সবচেয়ে ছোট ধরা হয়, তাই DESC-এ শেষে যায়; PostgreSQL আর Oracle-এ NULL সবচেয়ে বড়, তাই DESC-এ প্রথমে — DENSE_RANK-এ NULL পেত 1. WHERE salary IS NOT NULL সব DBMS-এ একই ফল নিশ্চিত করে।
  • বিকল্পের দাম: ৪ নম্বর সব DBMS-এ চলে, কিন্তু N বাড়লে nested subquery-র স্তর বাড়ে। N-তমের জন্য সাধারণ পুরোনো রূপ: WHERE N - 1 = (SELECT COUNT(DISTINCT s2.salary) FROM instructors s2 WHERE s2.salary > s1.salary) — correlated, তাই index না থাকলে প্রায় O(n²)। DENSE_RANK-এ একবার sort: O(n log n).
অনু ১.১db-uni-এ প্রতিটি বিভাগের দ্বিতীয় সর্বোচ্চ বেতন আর সেই শিক্ষকের নাম বের করুন। যে বিভাগে দ্বিতীয় বেতন নেই, সেটি কী দেখাবে? (মাঝারি)

উত্তর: DENSE_RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) বসিয়ে বাইরে dr = 2. ফল ২টি সারি: CSE | Shahana Parvin | 85000 আর EEE | Rumana Islam | 70000. BBA-তে একজনই (Kamrul Hasan), আর English বিভাগে কোনো শিক্ষক নেই — দুটোই ফলে অনুপস্থিত। প্রতিটি বিভাগ NULL-সহ দেখাতে চাইলে departments থেকে LEFT JOIN করুন, শর্ত ON r.dept_id = d.dept_id AND r.dr = 2 — তখন ৪টি সারি, BBA আর English-এ NULL. শর্তটি WHERE-এ দিলে LEFT JOIN আবার inner join হয়ে যেত।

অনু ১.২মূল instructors টেবিলে (৬ জন, সবার বেতন আলাদা) ৪র্থ সর্বোচ্চ বেতন কত? আর N = 10 চাইলে প্রতিটি পদ্ধতি কী দেয়? (সহজ)

উত্তর: সাজানো আলাদা বেতন 120000, 110000, 90000, 85000, 70000, 65000 — ৪র্থ 85000 (Shahana Parvin): LIMIT 1 OFFSET 3 বা dr = 4. N = 10 হলে DENSE_RANK আর LIMIT/OFFSET দুটোই কোনো সারি দেয় না; scalar subquery-র রূপ একটি সারি দেয়, মান NULL. ইন্টারভিউতে বলুন কোনটি চাওয়া হচ্ছে — “খালি ফল” নাকি “NULL-এর একটি সারি”, কারণ অ্যাপের কোড দুটো আলাদাভাবে সামলায়।

প্যাটার্ন ২ · প্রতি দলের সেরা N (top-N per group)

রিভিশন: পর্ব ২৬ · Window function, পর্ব ২৭ · SQL-এর প্রচলিত প্যাটার্ন

চেনার সংকেত: “প্রতিটি বিভাগের সেরা ২ জন”, “প্রতি শ্রেণির সবচেয়ে দামি ৩টি পণ্য”, “প্রতিটি গ্রাহকের সবচেয়ে বড় অর্ডার”। শব্দ “প্রতিটি … সেরা/প্রথম/শেষ N” মানেই PARTITION BY.

WITH r AS (SELECT t.*, RANK() OVER (PARTITION BY grp ORDER BY score DESC) AS rnk
           FROM t)
SELECT * FROM r WHERE rnk <= :n;   -- ঠিক N টি চাইলে ROW_NUMBER + tiebreaker

সমস্যা · প্রতিটি বিভাগের সেরা ২ জন শিক্ষার্থী (মাঝারি): db-uni-এর students (student_id, name, dept_id, batch, cgpa) আর departments (dept_id, name) দেওয়া। প্রতিটি বিভাগের CGPA-তে সেরা ২ জনের নাম দেখান। জিজ্ঞাসা করার মতো প্রশ্ন: CGPA NULL (ফল আসেনি) হলে? বিভাগ NULL হলে? সমান CGPA হলে? এখানে উত্তর ধরা হলো: NULL CGPA বাদ, বিভাগহীনরা আলাদা দল, tie-তে সবাই। এডিটরের প্রথম লাইন CSE-তে Arif-এর সমান 3.75 পাওয়া একজনকে যোগ করে।

সরল চেষ্টা দুটি, দুটোই ভুল: ORDER BY cgpa DESC LIMIT 2 পুরো টেবিলের সেরা ২ জন দেয়, বিভাগপ্রতি নয়। GROUP BY dept_id আর MAX(cgpa) প্রতিটি দলে একজনই দেয়, আর তাঁর নাম আনতে গেলে bare column-এর ফাঁদ। আর WHERE ROW_NUMBER() OVER (…) <= 2 ত্রুটি দেয়, কারণ WHERE চলে window function-এর আগে (পর্ব ১৫-এর logical processing order).

INSERT INTO students VALUES (2304, 'Jannatul Ferdous', 1, 2023, 3.75);  -- tie
WITH r AS (
  SELECT COALESCE(d.name, '(বিভাগ নেই)') AS dept, s.name, s.cgpa,
         ROW_NUMBER() OVER w AS rn,
         RANK()       OVER w AS rnk
  FROM students s
  LEFT JOIN departments d ON d.dept_id = s.dept_id
  WHERE s.cgpa IS NOT NULL
  WINDOW w AS (PARTITION BY s.dept_id ORDER BY s.cgpa DESC)
)
SELECT dept, name, cgpa, rn, rnk
FROM r WHERE rnk <= 2
ORDER BY dept, rnk, name;

ফলে ৬টি সারি: (বিভাগ নেই) | Habib Ullah | 2.8 | 1 | 1, BBA | Emon Mia | 3.1 | 1 | 1, CSE | Dilruba Khatun | 3.9 | 1 | 1, CSE | Arif Hossain | 3.75 | … | 2, CSE | Jannatul Ferdous | 3.75 | … | 2, EEE | Chayon Roy | 2.95 | 1 | 1. CSE-তে দুজনেরই rnk 2, কিন্তু rn একজনের 2 আর অন্যজনের 3 — কে কোনটি পাবেন, SQL নিশ্চিত করে না। তাই rn <= 2 দিলে একজন এলোমেলোভাবে বাদ পড়তেন। EEE-তে Fahim-এর CGPA NULL, তাই WHERE-এ বাদ; না ছাঁকলে SQLite-এ NULL DESC-এ শেষে গিয়ে rnk 2 পেত, আর PostgreSQL-এ প্রথমে গিয়ে rnk 1. English বিভাগে কোনো শিক্ষার্থী নেই, তাই দলটিই নেই — খালি দলও দেখাতে চাইলে departments থেকে শুরু করতে হতো।

-- "আমার চেয়ে বেশি CGPA পাওয়া একই বিভাগের মানুষ ২ জনের কম"
SELECT s.dept_id, s.name, s.cgpa FROM students s
WHERE s.cgpa IS NOT NULL
  AND (SELECT COUNT(*) FROM students s2
       WHERE s2.dept_id = s.dept_id AND s2.cgpa > s.cgpa) < 2;

Window function ছাড়া সব DBMS-এ চলে, আর অর্থ RANK-এর মতো (tie-তে সবাই)। দুটি ফাঁদ: s2.dept_id = s.dept_id NULL বিভাগে কখনো সত্য হয় না, তাই বিভাগহীন সবাই “সেরা” হয়ে যান; আর দলের আকার k হলে দলপ্রতি O(k²) তুলনা।

SELECT d.name, t.name, t.cgpa
FROM departments d
CROSS JOIN LATERAL (
  SELECT s.name, s.cgpa FROM students s
  WHERE s.dept_id = d.dept_id AND s.cgpa IS NOT NULL
  ORDER BY s.cgpa DESC LIMIT 2) AS t;

প্রতিটি বিভাগের জন্য একটি ছোট “সেরা ২” কোয়েরি। (dept_id, cgpa) index থাকলে প্রতিটি দলে মাত্র ২টি সারি পড়েই থামে — দল কম আর টেবিল বিশাল হলে দ্রুততম। এটি ঠিক N টি দেয় (ROW_NUMBER-এর মতো), tie-তে নয়। SQLite-এ LATERAL নেই।

অনু ২.১db-shop-এর products (product_id, name, category_id, price, stock) থেকে প্রতিটি শ্রেণির স্টকে থাকা (stock > 0) সবচেয়ে সস্তা ২টি পণ্য দেখান। Electronics-এ কোন দুটি আসবে? (সহজ)

উত্তর: WHERE stock > 0 দিয়ে ছেঁকে ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY price, product_id), বাইরে rn <= 2. ফল ৮টি সারি। Electronics-এ Headphone (1200) আর Smart Watch (3500) — Power Bank (1800) দ্বিতীয় সস্তা হলেও stock 0, তাই বাদ। ছাঁকাটা window-এর আগে (ভেতরের WHERE-এ) হওয়া জরুরি; বাইরে ছাঁকলে Power Bank rn 2 নিয়ে বাদ পড়ত আর Electronics-এ মাত্র একটি পণ্য আসত। বাকিগুলো: Books — SQL Book 550, DSA Book 650; Clothing — Panjabi 2200, Saree 4500; Grocery — Mustard Oil 1L 260, Rice 5kg 420.

৩ · অস্তিত্বের প্রশ্ন — duplicate, “কখনো না” আর “সবসময়”

প্যাটার্ন ৩ · Duplicate খোঁজা আর একটি রেখে মোছা

রিভিশন: পর্ব ১৮ · GROUP BY আর HAVING, পর্ব ১৪ · DELETE, পর্ব ২৭ · Deduplicate

চেনার সংকেত: “একই ফোন নম্বর একাধিকবার”, “দুবার import হয়েছে”, “প্রতিটি থেকে একটি রাখুন”, “সবচেয়ে পুরোনোটি রেখে বাকি মুছুন”। Data engineer আর backend — দুই পদেই খুব প্রচলিত, কারণ বাস্তব ডেটায় duplicate প্রায় অবধারিত।

-- খোঁজা
SELECT key_cols, COUNT(*) FROM t GROUP BY key_cols HAVING COUNT(*) > 1;
-- একটি রেখে মোছা (কোনটি থাকবে, তা ORDER BY ঠিক করে)
DELETE FROM t WHERE id IN (
  SELECT id FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY key_cols ORDER BY keep_rule) AS rn
                  FROM t) WHERE rn > 1);

সমস্যা · নিউজলেটারের duplicate গ্রাহক (মাঝারি): একটি অনলাইন দোকানের subscribers টেবিলে (id, name, phone, joined) একই মানুষ কয়েকবার সাইন আপ করেছেন। প্রতিটি ফোন নম্বরের সবচেয়ে পুরোনো সাইন-আপটি রেখে বাকি মুছতে হবে। প্রশ্ন করুন: duplicate মানে কোন কলাম মেলা — নাম বদলে লেখা (“Rakib” আর “Rakib H.”) হলেও? ফোন NULL হলে? “+8801…” আর “01…” কি একই নম্বর? এখানে ধরা হলো: শুধু ফোন দেখব, শেষ ১১ অঙ্ক মিললে একই, আর NULL ফোনের মানুষ আলাদা।

-- ১. খোঁজা: কোন ফোন একাধিকবার?
SELECT phone, COUNT(*) AS n, MIN(id) AS first_id
FROM subscribers GROUP BY phone HAVING COUNT(*) > 1;
-- ২. প্রতিটি নম্বরের প্রথমটি রেখে বাকি মোছা (শেষ ১১ অঙ্কে তুলনা, NULL বাদ)
DELETE FROM subscribers WHERE id IN (
  SELECT id FROM (
    SELECT id, ROW_NUMBER() OVER (PARTITION BY SUBSTR(phone, -11)
                                  ORDER BY joined, id) AS rn
    FROM subscribers WHERE phone IS NOT NULL)
  WHERE rn > 1);
-- ৩. যা রইল
SELECT id, name, phone FROM subscribers ORDER BY id;
-- ৪. ভবিষ্যতে ঠেকানো: expression-এর ওপর unique index
CREATE UNIQUE INDEX ux_sub_phone ON subscribers (SUBSTR(phone, -11));

খোঁজার ফলে ৩টি দল: NULL | 2 | 4, 01711000001 | 3 | 1, 01811000002 | 2 | 2. প্রথম দলটিই ফাঁদ — GROUP BY সব NULL-কে এক দলে রাখে, তাই Joy আর Mou “duplicate” দেখাচ্ছেন, অথচ তাঁরা আলাদা মানুষ। আবার Tania-র দুটি সারি ধরা পড়েনি, কারণ লেখার ধরন আলাদা। DELETE ৪টি সারি মুছেছে (3, 5, 7, 9), আর রইল ৫টি: 1 Rakib, 2 Sumaiya, 4 Joy, 6 Mou, 8 Tania. শেষে unique index বসায় একই নম্বর আবার ঢোকাতে গেলে ত্রুটি আসবে; NULL-গুলো UNIQUE-এ একে অপরের সাথে সংঘর্ষ করে না (SQLite, PostgreSQL, MySQL — তিনটিতেই)।

  • কোনটি রাখবেন: ORDER BY-ই নিয়ম। “সবচেয়ে নতুন তথ্য রাখুন” হলে ORDER BY joined DESC. সমান তারিখের জন্য id tiebreaker না দিলে কোনটি টিকবে, তা নিশ্চিত নয়।
  • পুরোনো পথ: DELETE … WHERE id NOT IN (SELECT MIN(id) FROM subscribers GROUP BY phone) — চলে, কিন্তু NULL ফোনগুলোকে এক দল ধরে Mou-কে মুছে দেয় (অনু ৩.২)। MySQL-এ আবার DELETE-এর ভেতরে একই টেবিল থেকে subquery-তে বিধিনিষেধ আছে, তখন self-join দিয়ে লেখা হয়।
  • বাস্তবে: বিশাল টেবিলে এক DELETE-এ লাখো সারি মুছলে লম্বা transaction আর lock তৈরি হয়। ডুপ্লিকেট-মুক্ত নতুন টেবিল বানিয়ে (CREATE TABLE … AS SELECT … WHERE rn = 1) নাম বদলানো, বা ছোট ছোট batch-এ মোছা — data engineer ইন্টারভিউতে এই ফলো-আপ প্রায়ই আসে। আর মূল সমাধান UNIQUE constraint আর upsert (পর্ব ১৪, ২৩)।
অনু ৩.১একই subscribers ডেটায় duplicate-এর সংজ্ঞা বদলান: নাম আর ফোন দুটোই মিললে তবেই duplicate. নম্বর যেমন লেখা আছে তেমন তুলনা করলে কয়টি সারি মুছবে, আর শেষ ১১ অঙ্কে তুলনা করলে কয়টি? (মাঝারি)

উত্তর: PARTITION BY name, phone (NULL ফোন বাদ)। যেমন লেখা তেমন তুলনায় দল দুটি: (Rakib, 01711000001) — id 1 আর 7; (Sumaiya, 01811000002) — id 2 আর 5. মুছবে ২টি (7, 5). “Rakib H.” নামের id 3 নাম না মেলায় থেকে যায়। শেষ ১১ অঙ্কে (PARTITION BY name, SUBSTR(phone, -11)) Tania-র দুটি সারিও মেলে, তাই মুছবে ৩টি (7, 5, 9). শিক্ষা: duplicate-এর সংজ্ঞাই আসল প্রশ্ন — ইন্টারভিউতে আগে সেটি ঠিক করুন।

অনু ৩.২মূল subscribers ডেটায় কেউ চালালেন DELETE FROM subscribers WHERE id NOT IN (SELECT MIN(id) FROM subscribers GROUP BY phone). কোন কোন id মুছবে, আর এর মধ্যে কোনটি ভুল করে? (মাঝারি)

উত্তর: প্রতিটি phone-দলের MIN(id): 01711000001 → 1, 01811000002 → 2, NULL → 4, +8801911000003 → 8, 01911000003 → 9. এগুলো ছাড়া বাকি সব মোছে: 3, 5, 6, 7 — মোট ৪টি। 6 (Mou) মোছা ভুল, কারণ NULL ফোনের দুজনকে GROUP BY এক দল ধরেছে; আর Tania-র 9 থেকে যায়, কারণ লেখার ধরন আলাদা। এখানে NOT IN নিরাপদ, কারণ MIN(id) কখনো NULL হয় না — কিন্তু subquery-তে NULL থাকতে পারলে NOT IN-এর বিপদ পরের প্যাটার্নে দেখুন।

প্যাটার্ন ৪ · “কখনো না” আর “সবসময়” — anti join আর relational division

রিভিশন: পর্ব ২০ · Semi join আর anti join, পর্ব ২১ · EXISTS, পর্ব ১৬ · NULL-এর তিন-মানের যুক্তি, পর্ব ৩৩ · Division

চেনার সংকেত: “কখনো অর্ডার করেননি”, “কোনো কোর্স পড়ান না”, “একটিও … নেই” → anti join. “সব অর্ডারই delivered”, “প্রতিটি … কিনেছেন”, “সব কোর্স নিয়েছেন” → “সবসময়” বা relational division. শব্দ “কখনো না / সব / প্রতিটি” দেখলেই এই প্যাটার্ন।

-- কখনো না: NOT EXISTS (NULL-নিরাপদ)
SELECT a.* FROM a WHERE NOT EXISTS (SELECT 1 FROM b WHERE b.a_id = a.id);
-- সবসময়: ব্যতিক্রম গুনে শূন্য
SELECT a_id FROM b GROUP BY a_id HAVING SUM(CASE WHEN NOT ok THEN 1 ELSE 0 END) = 0;
-- সব X: গুনে মেলানো
... GROUP BY a_id HAVING COUNT(DISTINCT x) = (SELECT COUNT(*) FROM X);

সমস্যা · কোনো কোর্স পড়ান না এমন শিক্ষক (সহজ, কিন্তু ফাঁদসহ): db-uni-এর courses টেবিলে (course_id, title, dept_id, credits, instructor_id) প্রতিটি কোর্সের শিক্ষক আছেন; ENG101-এর শিক্ষক এখনো ঠিক হয়নি, তাই instructor_id NULL. কোন শিক্ষক কোনো কোর্স পড়ান না?

-- ১. সরল চেষ্টা: NOT IN
SELECT name FROM instructors
WHERE instructor_id NOT IN (SELECT instructor_id FROM courses);
-- ২. NOT EXISTS
SELECT i.name FROM instructors i
WHERE NOT EXISTS (SELECT 1 FROM courses c WHERE c.instructor_id = i.instructor_id);
-- ৩. LEFT JOIN … IS NULL
SELECT i.name FROM instructors i
LEFT JOIN courses c ON c.instructor_id = i.instructor_id
WHERE c.course_id IS NULL;

NOT IN দিল কোনো সারি নেই; বাকি দুটি দিল Rumana Islam. কারণ তিন-মানের যুক্তি: subquery-র তালিকায় একটি NULL আছে (ENG101), তাই Rumana-র জন্য শর্তটি দাঁড়ায় 4 <> 2 AND 4 <> 1 AND … AND 4 <> NULL. শেষ অংশ UNKNOWN, তাই পুরোটা কখনো TRUE হয় না — প্রতিটি সারি বাদ। NOT EXISTS শুধু জিজ্ঞেস করে “মেলে এমন সারি আছে কি”, NULL সেখানে কিছুই মেলায় না, তাই ফল ঠিক। LEFT JOIN-এর পথে IS NULL পরীক্ষা করুন এমন কলামে যা মিললে কখনো NULL হয় না — এখানে primary key course_id.

এবার “সবসময়” আর “সব”। সমস্যা (মাঝারি): db-shop-এ (ক) কোন গ্রাহকদের সব অর্ডার delivered? (খ) কোন গ্রাহক Books শ্রেণির (category_id 2) প্রতিটি পণ্য কিনেছেন (বাতিল অর্ডার বাদ)? (ক)-তে একটি প্রশ্ন জরুরি: যাঁর কোনো অর্ডারই নেই, তাঁর “সব অর্ডার delivered” কি সত্য?

-- ক-১. ব্যতিক্রম গুনে শূন্য (অন্তত একটি অর্ডার আছে এমনরা)
SELECT c.name, COUNT(*) AS orders
FROM customers c JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.name
HAVING SUM(CASE WHEN o.status <> 'delivered' THEN 1 ELSE 0 END) = 0
ORDER BY c.customer_id;
-- ক-২. NOT EXISTS: "এমন কোনো অর্ডার নেই যা delivered নয়"
SELECT c.name FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o
                  WHERE o.customer_id = c.customer_id AND o.status <> 'delivered');
-- খ. division: Books-এর সব পণ্য কিনেছেন যাঁরা
SELECT c.name
FROM customers c
JOIN orders o      ON o.customer_id = c.customer_id AND o.status <> 'cancelled'
JOIN order_items i ON i.order_id = o.order_id
JOIN products p    ON p.product_id = i.product_id AND p.category_id = 2
GROUP BY c.customer_id, c.name
HAVING COUNT(DISTINCT p.product_id) = (SELECT COUNT(*) FROM products WHERE category_id = 2);

ক-১ দিল ৩ জন: Karima Begum | 2, Nusrat Jahan | 1, Imran Hossain | 1. ক-২ দিল ৪ জন — এঁদের সাথে Tanvir Ahmed, যাঁর কোনো অর্ডারই নেই। যুক্তিবিদ্যায় এটি vacuous truth: খালি সেটের “প্রতিটি সদস্য” যেকোনো শর্ত মানে। কোনটি ঠিক, তা ব্যবসার সংজ্ঞা — ইন্টারভিউতে প্রশ্নটি তোলাই নম্বর পাওয়ার জায়গা। খ দিল শুধু Karima Begum: তিনি 1002 নম্বর অর্ডারে DSA Book আর SQL Book দুটিই কিনেছেন; Nusrat শুধু DSA Book, Mitu শুধু SQL Book. COUNT(DISTINCT …) জরুরি — কেউ একই বই দুবার কিনলে সাধারণ COUNT ২ হয়ে ভুল মিল দিত।

  • খালি ভাজক: Books শ্রেণিতে কোনো পণ্য না থাকলে গুনে মেলানোর পথ কাউকে দেয় না (JOIN-এ কেউ টেকে না), অথচ দুই স্তরের NOT EXISTS-এর পথ সবাইকে দেয় (vacuous truth). পর্ব ৩৩-এ দেখেছেন, ÷-এর algebra সূত্র আবার তৃতীয় একটি উত্তর দেয় — ভাজ্যে (কেনাকাটার টেবিলে) আছেন এমন সবাই। তাই খালি ভাজকে কী চাই, তা আগে ঠিক করে নিন।
  • জটিলতা: NOT EXISTS আর LEFT JOIN … IS NULL — দুটোকেই আধুনিক optimizer সাধারণত একই anti join plan-এ নেয় (hash anti join: O(n + m)). courses(instructor_id)-এ index থাকলে প্রতিটি শিক্ষকের জন্য একটি index খোঁজ: O(n log m).
অনু ৪.১db-uni-এ কোন বিভাগে একজনও শিক্ষার্থী নেই? WHERE dept_id NOT IN (SELECT dept_id FROM students) লিখলে কী আসবে, আর কেন? (সহজ)

উত্তর: সঠিক উত্তর English (dept_id 4). কিন্তু NOT IN কোনো সারি দেয় না, কারণ students-এ Habib Ullah-র dept_id NULL — তালিকায় একটি NULL থাকলেই NOT IN কখনো TRUE হয় না। সমাধান: NOT EXISTS (SELECT 1 FROM students s WHERE s.dept_id = d.dept_id), বা subquery-তে WHERE dept_id IS NOT NULL যোগ করা। অভ্যাস করে নিন: anti join-এ সবসময় NOT EXISTS.

অনু ৪.২db-shop-এ কোন গ্রাহকেরা Electronics (category 1) আর Grocery (category 4) — দুই শ্রেণি থেকেই কিনেছেন (বাতিল বাদ)? (মাঝারি)

উত্তর: শর্তে দুই শ্রেণি রেখে গুনে মেলান: WHERE p.category_id IN (1, 4) GROUP BY c.customer_id HAVING COUNT(DISTINCT p.category_id) = 2. ফল: Rahim Uddin (Mobile Phone, Headphone, Rice, Mustard Oil) আর Karima Begum (Headphone আর Rice 5kg — 1007 নম্বর অর্ডারে)। বিকল্প: দুটি EXISTS-কে AND দিয়ে জোড়া, অথবা দুই সেটের INTERSECT (পর্ব ২২)। ভুল পথ: WHERE category_id = 1 AND category_id = 4 — একটি সারির শ্রেণি একসাথে দুটি হতে পারে না, তাই ফল সবসময় খালি।

৪ · সময়ের ধারা — running total, মাসিক বৃদ্ধি, টানা দিন আর প্রথম ঘটনা

প্যাটার্ন ৫ · Running total আর মাস-থেকে-মাস বৃদ্ধি

রিভিশন: পর্ব ২৬ · LAG আর running total, পর্ব ১৭ · Date ফাংশন

চেনার সংকেত: “এ পর্যন্ত মোট”, “জমা ব্যালান্স”, “আগের মাসের তুলনায় কত শতাংশ বেড়েছে”, “month-over-month”, “৭ দিনের গড়”। Data analyst ইন্টারভিউর নিয়মিত প্রশ্ন। দুই ধাপ: আগে মাসে গুটিয়ে আনা (GROUP BY), তারপর window.

WITH m AS (SELECT month, SUM(x) AS v FROM t GROUP BY month)
SELECT month, v,
       SUM(v) OVER (ORDER BY month ROWS UNBOUNDED PRECEDING) AS running,
       ROUND(100.0 * (v - LAG(v) OVER (ORDER BY month))
             / NULLIF(LAG(v) OVER (ORDER BY month), 0), 1) AS growth_pct
FROM m;

সমস্যা · একটি মিষ্টির দোকানের মাসিক বিক্রি (মাঝারি): sales টেবিলে (sale_id, sale_date, amount — টাকা, পূর্ণসংখ্যা) ২০২৫-এর জানুয়ারি থেকে মে পর্যন্ত বিক্রি আছে; মার্চে দোকান সংস্কারের জন্য বন্ধ ছিল, কোনো বিক্রি নেই। প্রতি মাসের বিক্রি, এ পর্যন্ত মোট, আর আগের মাসের তুলনায় শতাংশ বৃদ্ধি দেখান।

-- ১. সরল চেষ্টা: পূর্ণসংখ্যার ভাগ
WITH m AS (SELECT SUBSTR(sale_date, 1, 7) AS month, SUM(amount) AS revenue
           FROM sales GROUP BY SUBSTR(sale_date, 1, 7))
SELECT month, revenue,
       (revenue - LAG(revenue) OVER w) / LAG(revenue) OVER w * 100 AS growth_pct
FROM m WINDOW w AS (ORDER BY month) ORDER BY month;
-- ২. সঠিক: running total, 100.0 দিয়ে ভাগ, শূন্যে NULLIF
WITH m AS (SELECT SUBSTR(sale_date, 1, 7) AS month, SUM(amount) AS revenue
           FROM sales GROUP BY SUBSTR(sale_date, 1, 7))
SELECT month, revenue,
       SUM(revenue) OVER (ORDER BY month ROWS UNBOUNDED PRECEDING) AS running_total,
       LAG(revenue) OVER w AS prev_month,
       ROUND(100.0 * (revenue - LAG(revenue) OVER w)
             / NULLIF(LAG(revenue) OVER w, 0), 1) AS growth_pct
FROM m WINDOW w AS (ORDER BY month) ORDER BY month;

প্রথম ফলে growth_pct: জানুয়ারি NULL, তারপর তিনটিই 0. কারণ integer division: 5000 / 20000 = 0 (পূর্ণসংখ্যার ভাগে ভগ্নাংশ বাদ), তারপর × 100 = 0. SQLite আর PostgreSQL দুটোতেই এমন; MySQL-এর / অবশ্য দশমিক দেয়। দ্বিতীয় ফল: 2025-01 | 20000 | 20000 | NULL | NULL, 2025-02 | 25000 | 45000 | 20000 | 25, 2025-04 | 30000 | 75000 | 25000 | 20, 2025-05 | 27000 | 102000 | 30000 | -10. কিন্তু এপ্রিলের সারিটি লক্ষ করুন: LAG “আগের সারি” দেয়, “আগের মাস” নয় — মার্চের সারিই নেই, তাই এপ্রিলকে ফেব্রুয়ারির সাথে তুলনা করা হয়েছে। এটা ঠিক করতে আগে একটি ক্যালেন্ডার বানিয়ে খালি মাস 0 দিয়ে ভরতে হয় (প্যাটার্ন ১৩, আর অনু ৫.১)।

  • ROWS বনাম RANGE: ORDER BY থাকলে ডিফল্ট frame হলো RANGE … CURRENT ROW, যা সমান মানের সব সারি (peer) একসাথে নেয়। মাসে গুটানোর পর প্রতিটি মাস অনন্য, তাই এখানে পার্থক্য নেই; কিন্তু দৈনিক লেনদেনে একই তারিখের দুটি সারি থাকলে running total লাফিয়ে যায়। ROWS আর একটি tiebreaker দিন (পর্ব ২৬)।
  • মাসের key: SUBSTR(sale_date, 1, 7) কাজ করে কারণ তারিখ ISO আকারে ('YYYY-MM-DD') লেখা। PostgreSQL-এ date_trunc('month', sale_date), MySQL-এ DATE_FORMAT(sale_date, '%Y-%m').
অনু ৫.১মিষ্টির দোকানের ডেটায় মার্চকে 0 বিক্রির মাস হিসেবে যোগ করে আবার বৃদ্ধি হিসাব করুন। মার্চ আর এপ্রিলের growth_pct কত আসবে? (কঠিন)

উত্তর: recursive CTE দিয়ে '2025-01-01' থেকে মাসে মাসে date(d, '+1 month') করে '2025-05-01' পর্যন্ত ৫টি মাস বানান, তারপর LEFT JOIN করে COALESCE(SUM(amount), 0). ফল: জানুয়ারি NULL, ফেব্রুয়ারি 25, মার্চ -100 (25000 থেকে 0), এপ্রিল NULL — আগের মাস 0, আর NULLIF(0, 0) NULL করায় শূন্য দিয়ে ভাগ এড়ানো গেছে (শূন্য থেকে বৃদ্ধির শতাংশ সংজ্ঞায়িত নয়), মে -10. NULLIF না দিলে SQLite NULL-ই দিত, কিন্তু PostgreSQL “division by zero” ত্রুটি দেয় — তাই NULLIF অভ্যাসে রাখুন।

অনু ৫.২db-shop-এ প্রতিটি গ্রাহকের অর্ডারের ক্রমে জমা খরচ (cumulative spend) বের করুন (সব অবস্থার অর্ডার ধরে)। Rahim Uddin-এর কোন অর্ডারে মোট খরচ প্রথম ২০,০০০ টাকা পেরোয়? (মাঝারি)

উত্তর: আগে অর্ডারপ্রতি মোট (order_items-এ GROUP BY order_id), তারপর SUM(total) OVER (PARTITION BY customer_id ORDER BY order_date, order_id ROWS UNBOUNDED PRECEDING). Rahim: 1001 → 17400, 1003 → 19180, 1009 → 37180 — তাই 1009 নম্বর অর্ডারে প্রথম ২০,০০০ পেরোয়। “প্রথম পেরোনো” বের করতে বাইরের কোয়েরিতে WHERE cum >= 20000 দিয়ে প্রতিটি গ্রাহকের সবচেয়ে আগের সারি নিন (ROW_NUMBER-এ rn = 1). আগে অর্ডারপ্রতি না গুটিয়ে সরাসরি order_items-এর ওপর window চালালে প্রতিটি পণ্য-সারি আলাদা ধাপ হতো।

প্যাটার্ন ৬ · টানা দিন বা streak — gaps and islands

রিভিশন: পর্ব ২৭ · Gaps and islands, পর্ব ২৬ · ROW_NUMBER

চেনার সংকেত: “টানা কত দিন লগইন”, “সবচেয়ে লম্বা streak”, “পরপর ৩ দিন”, “বিরতিহীন সময়কাল”, “হারানো নম্বর”। কৌশলের নাম gaps and islands: টানা মানের একটি “দ্বীপ”-এর ভেতরে (তারিখ − ক্রমিক নম্বর) স্থির থাকে।

WITH d AS (SELECT DISTINCT user_id, day FROM t),            -- দিনে একটি সারি
g AS (SELECT user_id, day,
        julianday(day) - ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY day) AS grp
      FROM d)
SELECT user_id, MIN(day), MAX(day), COUNT(*) AS len FROM g GROUP BY user_id, grp;

সমস্যা · অন্তত ৩ দিন টানা লগইন (কঠিন): একটি শেখার অ্যাপের logins টেবিলে (user_id, login_date) প্রতিটি লগইনের একটি সারি — একই দিনে দুবার লগইন করলে দুটি সারি। কোন ব্যবহারকারী অন্তত ৩ দিন টানা লগইন করেছেন, কবে থেকে কবে? জিজ্ঞাসা করুন: একই দিনে একাধিক লগইন একটি দিন ধরব তো? (হ্যাঁ।) আগে কৌশলটি ব্যবহারকারী 1-এর ডেটায় হাতে দেখুন:

ব্যবহারকারী 1 · অক্টোবর ২০২৫দিন = তারিখের দিন-অংশ

            
অবস্থা
সারি
দল
সবচেয়ে লম্বা
  1. ডেটা। ব্যবহারকারী 1 অক্টোবরের ১, ২, ৩, ৫ আর ৬ তারিখে লগইন করেছেন; ২ তারিখে দুবার, তাই ৬টি সারি। আসল উত্তর: ১–৩ তারিখ, ৩ দিনের streak; ৪ তারিখে বিরতি।
  2. সরাসরি ROW_NUMBER. দিন থেকে ক্রমিক নম্বর বাদ দিলে টানা দিনে ফলটি স্থির থাকার কথা — দিন ১ বাড়লে rn-ও ১ বাড়ে। কিন্তু duplicate সারিতে দিন বাড়েনি অথচ rn বেড়েছে, তাই ফল 0 থেকে -1-এ নেমে গেছে। আর ৫ তারিখের বিরতির পর ফল আবার 0 হয়ে গেছে।
  3. ভুল দল। grp 0-তে এসেছে ১, ২, ৫, ৬ — বিরতির দুই পাশের দিন এক দলে! COUNT(*) বলছে ৪ দিনের streak, ১ থেকে ৬ তারিখ — যা কখনো ঘটেনি। ছোট ডেটায় হাতে না মেলালে এই বাগ চোখেই পড়ে না।
  4. আগে DISTINCT. প্রতিদিনে একটি সারি রাখলে ৫টি সারি। এখন দিন − rn: ১, ২, ৩ তারিখে 0; ৫ আর ৬ তারিখে 1 — বিরতির পর ফল ঠিক ১ বেড়েছে, যত দিন বিরতি তত।
  5. সঠিক দ্বীপ। grp 0 = ১–৩ তারিখ (৩ দিন), grp 1 = ৫–৬ তারিখ (২ দিন)। HAVING COUNT(*) >= 3 শুধু প্রথমটি রাখে। কোডে “দিন” হিসেবে julianday() লাগে, যাতে মাস পেরোলেও (৩১ → ১) হিসাব ঠিক থাকে।
-- ১. সরল চেষ্টা: DISTINCT ছাড়া
WITH g AS (SELECT user_id, login_date,
             julianday(login_date)
             - ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) AS grp
           FROM logins)
SELECT user_id, MIN(login_date) AS start_day, MAX(login_date) AS end_day, COUNT(*) AS days
FROM g GROUP BY user_id, grp HAVING COUNT(*) >= 3 ORDER BY user_id;
-- ২. সঠিক: আগে প্রতিদিন একটি সারি
WITH d AS (SELECT DISTINCT user_id, login_date FROM logins),
g AS (SELECT user_id, login_date,
        julianday(login_date)
        - ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) AS grp
      FROM d)
SELECT user_id, MIN(login_date) AS start_day, MAX(login_date) AS end_day, COUNT(*) AS days
FROM g GROUP BY user_id, grp HAVING COUNT(*) >= 3 ORDER BY user_id, start_day;

সরল চেষ্টা দিল 1 | 2025-10-01 | 2025-10-06 | 4 আর 2 | 2025-10-03 | 2025-10-06 | 4 — ব্যবহারকারী 1-এর জন্য ভুয়া ৪ দিনের streak, স্টেপারে যেমন দেখলেন। সঠিক কোয়েরি দিল 1 | 2025-10-01 | 2025-10-03 | 3 আর 2 | 2025-10-03 | 2025-10-06 | 4. ব্যবহারকারী 2-এর ডেটায় duplicate নেই, তাই দুই কোয়েরিতেই তাঁর ফল একই — ফাঁদটি শুধু যেখানে duplicate আছে সেখানে দেখা দেয়। ব্যবহারকারী 3 একদিনই এসেছেন, তাই বাদ।

-- PostgreSQL: date থেকে integer বাদ দিলে date পাওয়া যায়
SELECT user_id, login_date,
       login_date - (ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date))::int AS grp
FROM (SELECT DISTINCT user_id, login_date FROM logins) d;

বিকল্প: LAG দিয়ে আগের দিনের সাথে পার্থক্য দেখে “নতুন দ্বীপ শুরু” পতাকা (১ বা ০) বসান, তারপর পতাকার running SUM-ই দ্বীপের নম্বর। এটি সাধারণীকরণে সুবিধাজনক — যেমন “৩০ মিনিটের বেশি বিরতি হলে নতুন session” (sessionization), যেখানে “দিন − rn” কৌশল কাজ করে না। দুটোই একবার sort: O(n log n).

অনু ৬.১একই logins ডেটায় প্রতিটি ব্যবহারকারীর সবচেয়ে লম্বা streak কত দিনের (সবাইকে দেখাতে হবে, এক দিনের হলেও)? (মাঝারি)

উত্তর: সঠিক কোয়েরির শেষ ধাপে HAVING বাদ দিয়ে দ্বীপগুলো আরেকটি CTE-তে রাখুন, তারপর SELECT user_id, MAX(days) … GROUP BY user_id. ফল: ব্যবহারকারী 1 → 3, 2 → 4, 3 → 1. ব্যবহারকারী 1-এর দুটি দ্বীপ (৩ আর ২ দিন), তাই MAX লাগে। কোন তারিখে শুরু, সেটিও চাইলে MAX-এর বদলে ROW_NUMBER (days DESC, start_day) দিয়ে প্রথম সারি নিন — tie-তে আগের streak.

অনু ৬.২db-shop-এর orders-এ (order_date) দোকানে টানা যে দিনগুলোতে অন্তত একটি অর্ডার এসেছে, তার সবচেয়ে লম্বা অংশ কোনটি? (মাঝারি)

উত্তর: অর্ডারের দিনগুলো: সেপ্টেম্বরের ১, ৩, ১০, ১২, ১৫, ২০ আর অক্টোবরের ১, ৫, ৮, ৯। প্রায় সব দ্বীপ এক দিনের; সবচেয়ে লম্বা 2025-10-08 থেকে 2025-10-09, ২ দিন (অর্ডার 1009 আর 1010). এখানে duplicate নেই, তবু SELECT DISTINCT order_date দিয়ে শুরু করুন — একই দিনে দুটি অর্ডার এলেই নইলে ভুল হতো। সেপ্টেম্বর ৩০ আর অক্টোবর ১ পাশাপাশি দিন — julianday এটা ঠিকভাবে ধরে, কিন্তু দিনের অংশ (SUBSTR) দিয়ে হিসাব করলে মাস পেরোনোয় ভুল হতো।

প্যাটার্ন ৭ · প্রতিটি ব্যবহারকারীর প্রথম বা শেষ ঘটনা

রিভিশন: পর্ব ২৭ · সর্বশেষ সারি, পর্ব ১৯ · LEFT JOIN

চেনার সংকেত: “প্রথম অর্ডার”, “সর্বশেষ লগইন”, “প্রথম কেনা পণ্য”, “শেষ অবস্থা”, “প্রথম ও দ্বিতীয় অর্ডারের মাঝে কত দিন”। এটি greatest-per-group — top-N-এর N = 1 রূপ, কিন্তু প্রায়ই সাথে আসে “পুরো সারি চাই” আর “যাঁর ঘটনা নেই তিনিও থাকবেন”।

সমস্যা · প্রতিটি গ্রাহকের প্রথম অর্ডার (মাঝারি): db-shop-এ প্রতিটি গ্রাহকের নাম, প্রথম অর্ডারের নম্বর, তারিখ, সেই অর্ডারের মোট মূল্য আর তাঁর মোট অর্ডার সংখ্যা দেখান। যাঁর কোনো অর্ডার নেই, তিনিও NULL-সহ থাকবেন। সরল চেষ্টা SELECT customer_id, MIN(order_date), order_id … GROUP BY customer_id — order_id এখানে bare column; SQLite এটিকে MIN-এর সারি থেকে নেয়, কিন্তু PostgreSQL ত্রুটি দেয়, আর মোট মূল্য আনার উপায় থাকে না।

WITH tot AS (            -- অর্ডারপ্রতি মোট মূল্য
  SELECT o.customer_id, o.order_id, o.order_date,
         SUM(i.quantity * i.unit_price) AS total
  FROM orders o JOIN order_items i ON i.order_id = o.order_id
  GROUP BY o.order_id, o.customer_id, o.order_date
), r AS (                -- গ্রাহকের ভেতরে ক্রম, আর মোট অর্ডার
  SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id
                               ORDER BY order_date, order_id) AS rn,
         COUNT(*) OVER (PARTITION BY customer_id) AS n_orders
  FROM tot
)
SELECT c.name, r.order_id AS first_order, r.order_date, r.total, r.n_orders
FROM customers c
LEFT JOIN r ON r.customer_id = c.customer_id AND r.rn = 1   -- শর্ত ON-এ!
ORDER BY c.customer_id;

ফলে ৮ জন: Rahim Uddin | 1001 | 2025-09-01 | 17400 | 3, Karima Begum | 1002 | 2025-09-03 | 1750 | 2, Sabbir Hasan | 1004 | 2025-09-12 | 4500 | 1, Nusrat Jahan | 1005 | 2025-09-15 | 5050 | 1, Tanvir Ahmed | NULL | NULL | NULL | NULL, Farhana Akter | 1006 | 2025-09-20 | 3500 | 1, Imran Hossain | 1010 | 2025-10-09 | 1040 | 1, Mitu Rani | 1008 | 2025-10-05 | 550 | 1. r.rn = 1 শর্তটি ON-এ রাখায় Tanvir টিকে আছেন; WHERE-এ দিলে তাঁর সারিতে rn NULL, শর্ত UNKNOWN, আর LEFT JOIN কার্যত inner join হয়ে তিনি বাদ পড়তেন। Sabbir-এর প্রথম অর্ডার বাতিল — “প্রথম অর্ডার” বাতিলটিও ধরবে কি না, সেটি জিজ্ঞাসা করার মতো প্রশ্ন।

বিকল্প: FIRST_VALUE(order_id) OVER (PARTITION BY customer_id ORDER BY order_date, order_id) প্রতিটি সারিতে প্রথম অর্ডারটি বসায় — বাকি সারিগুলো রেখে “প্রথম অর্ডারের সাথে তুলনা” করতে কাজের। সর্বশেষটির জন্য শুধু ORDER BY … DESC; LAST_VALUE ব্যবহার করলে frame ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING দিতে ভুলবেন না, নইলে ডিফল্ট frame বর্তমান সারিতেই থামে।

অনু ৭.১db-shop-এ প্রতিটি গ্রাহকের প্রথম আর দ্বিতীয় অর্ডারের মাঝে কত দিন? কোন দুজনের উত্তর আসবে? (মাঝারি)

উত্তর: LEAD(order_date) OVER (PARTITION BY customer_id ORDER BY order_date, order_id) দিয়ে পরের অর্ডারের তারিখ আনুন, rn = 1 সারিতে julianday(next_date) - julianday(order_date). দুজনের দ্বিতীয় অর্ডার আছে: Rahim Uddin — ৯ দিন (০১ থেকে ১০ সেপ্টেম্বর) আর Karima Begum — ২৮ দিন (৩ সেপ্টেম্বর থেকে ১ অক্টোবর)। বাকিদের LEAD NULL, তাই পার্থক্যও NULL — “এখনো দ্বিতীয় অর্ডার নেই” বোঝাতে এগুলো রাখবেন কি না, জিজ্ঞেস করুন।

অনু ৭.২db-uni-এর enrollments-এ term লেখা '2022-Spring', '2023-Fall' ধরনের। প্রতিটি শিক্ষার্থীর সর্বশেষ term বের করতে MAX(term) কেন বিপজ্জনক? (কঠিন)

উত্তর: MAX বর্ণানুক্রমে তুলনা করে, আর 'F' < 'S' — তাই '2023-Spring' > '2023-Fall' ধরা হয়, অথচ একই বছরে Fall আসে পরে। এই নমুনা ডেটায় কারও একই বছরে দুটি term নেই, তাই MAX আজ ঠিক উত্তর দেয় (যেমন 2201 → '2024-Fall', 2101 → '2023-Fall') — ঠিক এই কারণেই বাগটি লুকিয়ে থাকে। নিরাপদ পথ: একটি সাজানোর key বানান, যেমন SUBSTR(term, 1, 4) || CASE WHEN term LIKE '%Spring' THEN '1' ELSE '2' END, অথবা ডিজাইনেই term-এর শুরুর তারিখ রাখুন। ইন্টারভিউতে “string-এ রাখা তারিখ বা ক্রম কীভাবে সাজবে” প্রশ্ন তোলা মানে আপনি ডেটা পড়ে দেখেন।

৫ · আকার বদল আর নিজের সাথে join — pivot আর self join

প্যাটার্ন ৮ · Pivot — conditional aggregation

রিভিশন: পর্ব ১৮ · FILTER আর conditional aggregation, পর্ব ১৬ · CASE, পর্ব ২৭ · Pivot

চেনার সংকেত: “প্রতিটি অবস্থা একটি কলামে”, “মাসগুলো পাশাপাশি”, “Excel-এর মতো রিপোর্ট”, “একটি সারিতে delivered, cancelled-এর সংখ্যা”। সারির মানকে কলাম বানানো = pivot, আর SQL-এ এর বহনযোগ্য পথ conditional aggregation: প্রতিটি কলামের জন্য একটি SUM(CASE …).

সমস্যা · শহর অনুযায়ী অর্ডারের অবস্থা (সহজ): db-shop-এ প্রতিটি গ্রাহক-শহরের জন্য একটি সারি, আর delivered, shipped, pending, cancelled — প্রতিটি অবস্থার অর্ডার-সংখ্যা আলাদা কলামে, শেষে মোট। শহর NULL হলে “(অজানা)”।

-- ১. সরল চেষ্টা: COUNT আর ELSE 0
SELECT COALESCE(c.city, '(অজানা)') AS city,
       COUNT(CASE WHEN o.status = 'delivered' THEN 1 ELSE 0 END) AS delivered,
       COUNT(*) AS total
FROM orders o JOIN customers c ON c.customer_id = o.customer_id
GROUP BY c.city;
-- ২. সঠিক: SUM(CASE …) বা COUNT(*) FILTER
SELECT COALESCE(c.city, '(অজানা)') AS city,
       SUM(CASE WHEN o.status = 'delivered' THEN 1 ELSE 0 END) AS delivered,
       SUM(CASE WHEN o.status = 'shipped'   THEN 1 ELSE 0 END) AS shipped,
       SUM(CASE WHEN o.status = 'pending'   THEN 1 ELSE 0 END) AS pending,
       COUNT(*) FILTER (WHERE o.status = 'cancelled')          AS cancelled,
       COUNT(*) AS total
FROM orders o JOIN customers c ON c.customer_id = o.customer_id
GROUP BY c.city
ORDER BY total DESC, city;

সরল চেষ্টায় প্রতিটি শহরে delivered = total (Dhaka-তে 5 আর 5) — কারণ COUNT NULL ছাড়া সব গোনে, আর ELSE 0 দিলে 0-ও একটি মান। সঠিক ফল ৫টি সারি: Dhaka | 1 | 2 | 1 | 1 | 5, Chattogram | 2 | 0 | 0 | 0 | 2, (অজানা) | 1 | 0 | 0 | 0 | 1, Rajshahi | 1 | 0 | 0 | 0 | 1, Sylhet | 0 | 0 | 1 | 0 | 1. নিয়মটি মনে রাখুন: COUNT-এর সাথে ELSE নয় (NULL থাকুক), SUM-এর সাথে ELSE 0. FILTER standard SQL, PostgreSQL আর SQLite (৩.৩০ থেকে) মানে, কিন্তু MySQL মানে না — সেখানে SUM(CASE …)। Khulna (Tanvir) নেই, কারণ inner JOIN; অর্ডারহীন শহরও দেখাতে customers থেকে LEFT JOIN করে COUNT(o.order_id) নিতে হবে।

সীমাবদ্ধতা: কলামের নাম কোয়েরিতে আগেই লিখে দিতে হয়। নতুন অবস্থা ('returned') যোগ হলে কোয়েরি বদলাতে হবে — “কলাম সংখ্যা ডেটার ওপর নির্ভর করবে” এমন pivot-এর জন্য dynamic SQL লাগে (SQL Server আর Oracle-এ PIVOT অপারেটর আছে, PostgreSQL-এ crosstab extension), অথবা লম্বা আকারে রেখে রিপোর্টিং টুলে pivot করা ভালো।

অনু ৮.১db-uni-এ প্রতিটি বিভাগের (departments থেকে, শিক্ষার্থী না থাকলেও) batch 2021, 2022, 2023-এর শিক্ষার্থী-সংখ্যা তিনটি কলামে দেখান। English আর বিভাগহীন শিক্ষার্থীর কী হবে? (মাঝারি)

উত্তর: departments d LEFT JOIN students s ON s.dept_id = d.dept_id, তারপর SUM(CASE WHEN s.batch = 2021 THEN 1 ELSE 0 END) ইত্যাদি, GROUP BY d.dept_id, d.name. ফল: CSE 2, 1, 1; EEE 1, 1, 0; BBA 0, 1, 0; English 0, 0, 0 — শিক্ষার্থী না থাকলে LEFT JOIN-এর NULL সারিতে সব CASE ELSE 0 দেয়। বিভাগহীন Habib Ullah (batch 2023) এই রিপোর্টে আসেন না, কারণ departments থেকে শুরু; তাঁকেও দেখাতে আলাদা একটি সারি UNION ALL করুন (0, 0, 1). এখানে COUNT(*) দিলে English-এর মোট 1 আসত (NULL সারিটি গুনে) — খালি দলের পুরোনো ফাঁদ।

প্যাটার্ন ৯ · Self join — নিজের mentor-এর চেয়ে বেশি বেতন

রিভিশন: পর্ব ২০ · Self join, পর্ব ২৫ · Recursive CTE

চেনার সংকেত: একই টেবিলের দুটি সারির তুলনা — “নিজের ম্যানেজার/mentor-এর চেয়ে বেশি পান”, “একই বিভাগের সহকর্মী জোড়া”, “একই দিনে দুটি লেনদেন”, “আগের দিনের চেয়ে তাপমাত্রা বেশি”। টেবিলে নিজের দিকে ইঙ্গিত করা কলাম (mentor_id → instructor_id) থাকলেই self join.

সমস্যা (সহজ): db-uni-এর instructors-এ mentor_id প্রত্যেকের mentor-এর instructor_id (NULL = mentor নেই)। কোন শিক্ষক নিজের mentor-এর চেয়ে বেশি বেতন পান? মূল ডেটায় উত্তর খালি — Shahana (85000) < Anisur (120000), Rumana (70000) < Mahbub (110000), Tasnim (65000) < Shahana (85000). খালি ফলও একটি edge case: আপনার কোয়েরি সত্যিই ঠিক, নাকি ভুল করে খালি? পরীক্ষার জন্য এডিটরের প্রথম লাইনে Tasnim-এর বেতন বাড়ানো হয়েছে; লাইনটি মুছে খালি ফলও দেখে নিন।

UPDATE instructors SET salary = 90000 WHERE instructor_id = 6;  -- পরীক্ষার ডেটা
-- ১. যাঁরা নিজের mentor-এর চেয়ে বেশি পান
SELECT e.name AS instructor, e.salary, m.name AS mentor, m.salary AS mentor_salary
FROM instructors e
JOIN instructors m ON m.instructor_id = e.mentor_id
WHERE e.salary > m.salary;
-- ২. সবাই, mentor না থাকলেও — পার্থক্যসহ
SELECT e.name, e.salary, COALESCE(m.name, '(mentor নেই)') AS mentor,
       e.salary - m.salary AS diff
FROM instructors e
LEFT JOIN instructors m ON m.instructor_id = e.mentor_id
ORDER BY e.instructor_id;

UPDATE ১টি সারি বদলেছে। প্রথম কোয়েরি: Tasnim Chowdhury | 90000 | Shahana Parvin | 85000. দ্বিতীয়টি ৬টি সারি; mentor-হীন তিনজনের (Anisur, Mahbub, Kamrul) mentor কলামে “(mentor নেই)” আর diff NULL — কারণ e.salary - NULL NULL. বাকিদের diff: Shahana -35000, Rumana -40000, Tasnim 5000. টেবিলের দুটি alias (e = কর্মী, m = mentor) নাম দিয়ে ভাবুন — “একই টেবিলের দুটি কপি” কল্পনা করলে self join সহজ। প্রথম কোয়েরিতে inner JOIN mentor-হীনদের এমনিতেই বাদ দেয়, যা এখানে ঠিক: যাঁর mentor নেই, তিনি mentor-এর চেয়ে বেশি পান কি না — প্রশ্নটিই খাটে না।

ফলো-আপ যা প্রায়ই আসে: “mentor-এর mentor-এর চেয়েও বেশি?” → আরেকটি self join, বা যেকোনো গভীরতার জন্য recursive CTE (পর্ব ২৫)। “প্রতিটি বিভাগের সর্বোচ্চ বেতনের শিক্ষক” → self anti join: এমন কেউ নেই যিনি একই বিভাগে বেশি পান। জটিলতা: instructor_id primary key, তাই প্রতিটি সারির mentor খোঁজা একটি index lookup — O(n log n); index না থাকলে nested loop-এ O(n²)।

অনু ৯.১মূল db-uni-এ একই বিভাগের দুই শিক্ষকের প্রতিটি জোড়া একবার করে দেখান (A–B আর B–A দুবার নয়, নিজের সাথে জোড়াও নয়)। কয়টি জোড়া? (সহজ)

উত্তর: FROM instructors a JOIN instructors b ON a.dept_id = b.dept_id AND a.instructor_id < b.instructor_id. < একই সাথে নিজের সাথে জোড়া (=) আর উল্টো জোড়া — দুটোই বাদ দেয়; <> দিলে প্রতিটি জোড়া দুবার আসত। ফল ৪টি: CSE-তে (Anisur, Shahana), (Anisur, Tasnim), (Shahana, Tasnim); EEE-তে (Mahbub, Rumana). BBA-তে একজন, তাই জোড়া নেই। k জনের দলে জোড়া k(k − 1)/2 — CSE-তে 3·2/2 = 3.

অনু ৯.২মূল db-uni-এ প্রতিটি শিক্ষকের সরাসরি mentee কতজন — যাঁর কেউ নেই তাঁর 0 সহ? (সহজ)

উত্তর: FROM instructors m LEFT JOIN instructors e ON e.mentor_id = m.instructor_id GROUP BY m.instructor_id, m.name, আর গণনা COUNT(e.instructor_id) — COUNT(*) দিলে mentee-হীনদের 1 আসত। ফল: Dr. Anisur Rahman 1, Shahana Parvin 1, Dr. Mahbub Alam 1, Rumana Islam 0, Kamrul Hasan 0, Tasnim Chowdhury 0.

৬ · প্রোডাক্ট অ্যানালিটিক্স — funnel আর retention cohort

অ্যাপ বা ই-কমার্স প্রতিষ্ঠানের data analyst পদে দুটি প্রশ্ন প্রায় সবসময় কোনো না কোনো রূপে আসে: “কতজন ব্যবহারকারী কোন ধাপে ঝরে পড়ছেন?” আর “যাঁরা জানুয়ারিতে এসেছেন, তাঁদের কতজন পরের মাসেও ফিরেছেন?” দুটোর জন্য একই app_events টেবিল: (user_id, event, event_date) — event-এর মান signup, view (পণ্য দেখা), cart (ঝুড়িতে রাখা) বা purchase (কেনা)। ৬ জন ব্যবহারকারী, জানুয়ারি থেকে মার্চ ২০২৫।

প্যাটার্ন ১০ · Funnel আর conversion হার

রিভিশন: পর্ব ১৮ · Conditional aggregation, পর্ব ৫৫ · Fact table আর মেট্রিক

চেনার সংকেত: “কতজন signup থেকে purchase পর্যন্ত গেছেন”, “প্রতিটি ধাপের conversion”, “কোথায় সবচেয়ে বেশি ঝরে পড়ছেন”। এটি funnel analysis: প্রথমে ব্যবহারকারীপ্রতি একটি সারিতে প্রতিটি ধাপের প্রথম সময় (MIN), তারপর ক্রম মেনে গোনা।

সমস্যা (মাঝারি): চার ধাপের funnel — signup → view → cart → purchase — প্রতিটি ধাপে কতজন পৌঁছেছেন, আর signup থেকে purchase-এর মোট হার কত? প্রশ্ন করুন: আগের ধাপ বাদ দিয়ে কেউ সরাসরি কিনলে (ধরুন “এখনই কিনুন” বোতাম) তিনি কি funnel-এ গণ্য? এখানে ধরা হলো: কঠোর funnel — প্রতিটি ধাপ আগের ধাপের পরে (বা একই দিনে) হতে হবে।

-- ১. সরল চেষ্টা: প্রতিটি event কতজন করেছেন
SELECT event, COUNT(DISTINCT user_id) AS users
FROM app_events GROUP BY event ORDER BY users DESC, event;
-- ২. কঠোর funnel: ব্যবহারকারীপ্রতি প্রথম সময়, তারপর ক্রম মেনে
WITH u AS (
  SELECT user_id,
         MIN(CASE WHEN event = 'signup'   THEN event_date END) AS s,
         MIN(CASE WHEN event = 'view'     THEN event_date END) AS v,
         MIN(CASE WHEN event = 'cart'     THEN event_date END) AS c,
         MIN(CASE WHEN event = 'purchase' THEN event_date END) AS p
  FROM app_events GROUP BY user_id)
SELECT COUNT(s) AS signup,
       COUNT(CASE WHEN v >= s THEN 1 END) AS viewed,
       COUNT(CASE WHEN v >= s AND c >= v THEN 1 END) AS carted,
       COUNT(CASE WHEN v >= s AND c >= v AND p >= c THEN 1 END) AS purchased,
       ROUND(100.0 * COUNT(CASE WHEN v >= s AND c >= v AND p >= c THEN 1 END)
             / COUNT(s), 1) AS overall_pct
FROM u;

সরল গণনা: signup 6, view 5, cart 3, purchase 3 — দেখে মনে হয় cart থেকে purchase-এ ১০০% রূপান্তর। কঠোর funnel: 6 | 5 | 3 | 2 | 33.3. পার্থক্য ব্যবহারকারী 6: তিনি cart ছাড়াই কিনেছেন, তাই তাঁর c NULL, আর p >= NULL UNKNOWN — CASE-এ গণ্য হননি। আর cart-এ পৌঁছানো ব্যবহারকারী 2 কেনেননি। দুটি সংখ্যাই “ঠিক” হতে পারে — কোন সংজ্ঞা চাওয়া হচ্ছে, সেটাই প্রশ্ন। এখানে শুধু তারিখ আছে, তাই একই দিনের cart আর purchase-কে ক্রমে ধরা হয়েছে (>=); বাস্তবে timestamp দিয়ে কঠোর > নিন।

অনু ১০.১একই app_events ডেটায় কোন ব্যবহারকারীরা পণ্য দেখার পর cart ছাড়াই কিনেছেন? cart-এ রাখা থেকে কেনায় কঠোর রূপান্তর হার কত? (মাঝারি)

উত্তর: u CTE থেকে WHERE p IS NOT NULL AND c IS NULL → শুধু ব্যবহারকারী 6. কঠোর হিসাবে cart-এ পৌঁছেছেন ৩ জন (1, 2, 4), তার মধ্যে কিনেছেন ২ জন (1, 4) — হার 2/3 ≈ 66.7%. সরল গণনায় 3/3 = 100% আসত, কারণ সেখানে ব্যবহারকারী 6-এর কেনাও ধরা হয়, অথচ তিনি cart-এ ছিলেন না। পণ্যদল সাধারণত এ ধরনের “ধাপ টপকানো” আলাদা করে দেখতে চায় — এটা নিজে থেকে বলুন।

প্যাটার্ন ১১ · Retention cohort — signup মাস × সক্রিয় মাস

রিভিশন: পর্ব ১৭ · Date ফাংশন, পর্ব ২৫ · CTE, পর্ব ১৮ · COUNT DISTINCT

চেনার সংকেত: “জানুয়ারিতে যোগ দেওয়াদের কতজন এক মাস পরেও সক্রিয়”, “cohort টেবিল”, “month 0, month 1, month 2”, “retention কমছে কি না”। Cohort analysis-এ প্রতিটি ব্যবহারকারীকে তাঁর শুরুর মাস দিয়ে দলে ভাগ করা হয়, তারপর প্রতিটি দলের retention মাসে মাসে মাপা হয়।

সমস্যা (কঠিন): একই app_events-এ প্রতিটি signup-মাসের দল (cohort) কত বড়, আর signup-এর পরে month 0, 1, 2-তে সে দলের কতজন কোনো না কোনো event করেছেন? month 1-এর retention শতাংশও দেখান।

WITH first AS (          -- প্রতিটি ব্যবহারকারীর signup মাস
  SELECT user_id, SUBSTR(MIN(event_date), 1, 7) AS cohort
  FROM app_events WHERE event = 'signup' GROUP BY user_id
), act AS (              -- কোন ব্যবহারকারী signup-এর কত মাস পরে সক্রিয়
  SELECT DISTINCT e.user_id, f.cohort,
         (CAST(SUBSTR(e.event_date, 1, 4) AS INTEGER) - CAST(SUBSTR(f.cohort, 1, 4) AS INTEGER)) * 12
         + CAST(SUBSTR(e.event_date, 6, 2) AS INTEGER) - CAST(SUBSTR(f.cohort, 6, 2) AS INTEGER) AS month_no
  FROM app_events e JOIN first f ON f.user_id = e.user_id
), size AS (SELECT cohort, COUNT(*) AS n FROM first GROUP BY cohort)
SELECT s.cohort, s.n AS cohort_size,
       COUNT(DISTINCT CASE WHEN a.month_no = 0 THEN a.user_id END) AS m0,
       COUNT(DISTINCT CASE WHEN a.month_no = 1 THEN a.user_id END) AS m1,
       COUNT(DISTINCT CASE WHEN a.month_no = 2 THEN a.user_id END) AS m2,
       ROUND(100.0 * COUNT(DISTINCT CASE WHEN a.month_no = 1 THEN a.user_id END) / s.n, 1) AS m1_pct
FROM size s LEFT JOIN act a ON a.cohort = s.cohort
GROUP BY s.cohort, s.n ORDER BY s.cohort;

ফল ২টি সারি: 2025-01 | 3 | 3 | 2 | 1 | 66.7 আর 2025-02 | 3 | 3 | 2 | 0 | 66.7. জানুয়ারির দলে (1, 2, 3) ফেব্রুয়ারিতে ফিরেছেন 1 আর 2, মার্চে শুধু 1. ফেব্রুয়ারির দলে (4, 5, 6) মার্চে 4 আর 6. দুটি সূক্ষ্মতা: (১) ভাগ হচ্ছে cohort-এর আকার দিয়ে (signup-এর সংখ্যা), month 0-এর সক্রিয়দের দিয়ে নয়; (২) ফেব্রুয়ারি দলের m2 = 0 আসলে “শূন্য” নয় — ডেটা শেষ ১৫ মার্চে, এপ্রিল এখনো আসেইনি। বাস্তব রিপোর্টে “এখনো মাপা যায়নি” ঘরগুলো NULL রাখুন, যেমন CASE WHEN cohort-এর মাস + 2 ≤ ডেটার শেষ মাস THEN … END. মাসের পার্থক্য এখানে বছর × 12 + মাস দিয়ে বের করা; PostgreSQL-এ date_trunc আর age(), MySQL-এ PERIOD_DIFF-এর মতো ফাংশন আছে।

অনু ১১.১একই ডেটায় কোন ব্যবহারকারীরা signup-এর মাসের পরে আর কখনো কোনো event করেননি? (মাঝারি)

উত্তর: act CTE থেকে GROUP BY user_id HAVING MAX(month_no) = 0 → ব্যবহারকারী 3 আর 5. 3 জানুয়ারিতে signup আর view করে আর ফেরেননি; 5 শুধু signup করেছেন। বিকল্প: NOT EXISTS (month_no > 0 এমন কোনো সারি নেই) — প্যাটার্ন ৪-এর anti join. সতর্কতা: ফেব্রুয়ারির শেষে যোগ দেওয়া কেউ মার্চে না ফিরলে তাঁকে “churned” বলা হয়তো তাড়াহুড়ো — পর্যবেক্ষণের সময় সমান রাখার কথা ইন্টারভিউতে বলুন।

৭ · Built-in ছাড়া হিসাব — median, percentile আর ক্যালেন্ডার

প্যাটার্ন ১২ · Median আর percentile, built-in ফাংশন ছাড়া

রিভিশন: পর্ব ২৬ · ROW_NUMBER আর COUNT OVER, পর্ব ২৭ · Median

চেনার সংকেত: “মাঝের মান”, “typical অর্ডার কত টাকার”, “৯০% অর্ডার কত টাকার নিচে”, “AVG কেন বিভ্রান্তিকর”। SQLite আর MySQL-এ MEDIAN বা PERCENTILE ফাংশন নেই, তাই ইন্টারভিউয়াররা প্রায়ই বলেন “built-in ছাড়া লিখুন”। মূল কৌশল: ক্রমিক নম্বর (ROW_NUMBER) আর মোট সংখ্যা (COUNT OVER) পাশাপাশি রাখা।

WITH r AS (SELECT x, ROW_NUMBER() OVER (ORDER BY x) AS rn, COUNT(*) OVER () AS cnt
           FROM t WHERE x IS NOT NULL)
SELECT AVG(x) AS median FROM r
WHERE rn IN ((cnt + 1) / 2, (cnt + 2) / 2);   -- বিজোড়: একটি সারি, জোড়: মাঝের দুটি

সমস্যা · অর্ডারের median মূল্য (মাঝারি): db-shop-এ বাতিল ছাড়া অর্ডারগুলোর মোট মূল্যের median, গড় আর 90th percentile (nearest-rank পদ্ধতি) বের করুন। Nearest-rank মানে: p-তম percentile হলো সাজানো তালিকার ⌈p/100 × n⌉-তম মান।

WITH t AS (               -- অর্ডারপ্রতি মোট মূল্য, বাতিল বাদ
  SELECT o.order_id, SUM(i.quantity * i.unit_price) AS total
  FROM orders o JOIN order_items i ON i.order_id = o.order_id
  WHERE o.status <> 'cancelled'
  GROUP BY o.order_id
), r AS (
  SELECT total, ROW_NUMBER() OVER (ORDER BY total) AS rn, COUNT(*) OVER () AS cnt
  FROM t
)
SELECT (SELECT AVG(total) FROM r WHERE rn IN ((cnt + 1) / 2, (cnt + 2) / 2)) AS median,
       (SELECT ROUND(AVG(total), 1) FROM t)                                  AS mean,
       (SELECT total FROM r WHERE rn = (cnt * 90 + 99) / 100)                AS p90,
       (SELECT MAX(cnt) FROM r)                                              AS n;

ফল: 3250 | 5813.3 | 18000 | 9. সাজানো ৯টি মূল্য: 550, 1040, 1750, 1780, 3250, 3500, 5050, 17400, 18000 — n = 9 বিজোড়, তাই (9 + 1) / 2 আর (9 + 2) / 2 দুটোই integer ভাগে 5, অর্থাৎ ৫ম মান। গড় 5813.3, median-এর প্রায় দ্বিগুণ — দুটি বড় অর্ডার (Mobile Phone-সহ) গড়কে টেনে তুলেছে। “সাধারণ অর্ডার কত টাকার” প্রশ্নে তাই median. 90th percentile: ⌈0.9 × 9⌉ = ⌈8.1⌉ = 9, কোডে (9 × 90 + 99) / 100 = 9 — integer ভাগে ceiling বের করার পুরোনো কৌশল, কারণ সব SQLite build-এ CEIL ফাংশন থাকে না। বাতিল অর্ডারটিও (4500) ধরলে n = 10, মাঝের দুটি 3250 আর 3500, median হতো 3375.

-- PostgreSQL / Oracle: ordered-set aggregate
SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY total) AS median,      -- মাঝের দুটির গড়
       percentile_disc(0.9) WITHIN GROUP (ORDER BY total) AS p90          -- তালিকার একটি মান
FROM t;

percentile_cont দুটি মানের মাঝে রৈখিক interpolation করে, percentile_disc তালিকারই একটি মান দেয় — জোড় সংখ্যায় median-এর দুই সংজ্ঞা দুটো আলাদা ফল দিতে পারে, ইন্টারভিউতে কোনটি চাওয়া হচ্ছে জিজ্ঞেস করুন। জটিলতা: সব পথেই একবার sort, O(n log n); তত্ত্বে O(n)-এ median বের করার selection অ্যালগরিদম আছে, কিন্তু SQL engine সাধারণত sort-ই করে। বিশাল ডেটায় অনেক warehouse আনুমানিক percentile ফাংশন দেয় (যেমন APPROX_QUANTILES), যা অল্প মেমরিতে কাছাকাছি উত্তর দেয়।

অনু ১২.১db-shop-এ প্রতিটি গ্রাহক-শহরের median অর্ডার মূল্য (বাতিল বাদ) বের করুন। Dhaka-র median কত? (কঠিন)

উত্তর: টেমপ্লেটে PARTITION BY city যোগ করুন — ROW_NUMBER আর COUNT দুটোতেই — আর বাইরে GROUP BY city করে AVG. Dhaka-র বৈধ অর্ডার ৪টি: 550 (Mitu), 1780, 17400, 18000 (Rahim) — জোড়, তাই মাঝের দুটির গড় (1780 + 17400) / 2 = 9590. Chattogram: 1750 আর 3250 → 2500; Rajshahi 5050, Sylhet 3500, আর শহর NULL (Imran) 1040 — একটি করে অর্ডার, median সেটিই। NULL শহর PARTITION-এ নিজের দল পায়, তাই আলাদা সারিতে আসে।

প্যাটার্ন ১৩ · তারিখের সারি আর ক্যালেন্ডার — শূন্য অর্ডারের দিন

রিভিশন: পর্ব ২৫ · Recursive CTE আর ক্যালেন্ডার, পর্ব ১৯ · LEFT JOIN, পর্ব ১৭ · date()

চেনার সংকেত: “যেসব দিনে কোনো অর্ডার আসেনি”, “প্রতিটি দিন দেখান, শূন্য হলেও”, “খালি মাসসহ চার্ট”, “প্রতিদিনের সক্রিয় সদস্যপদ”। ডেটায় যা নেই, GROUP BY তা দেখাতে পারে না — আগে সব দিনের একটি তালিকা (calendar) বানাতে হয়, তারপর LEFT JOIN.

সমস্যা (মাঝারি): db-shop-এ ২০২৫-এর ১ থেকে ১০ অক্টোবর প্রতিদিনের অর্ডার-সংখ্যা দেখান, শূন্যসহ; আর আলাদা করে শুধু শূন্য-অর্ডারের দিনগুলো।

WITH RECURSIVE days(d) AS (
  SELECT '2025-10-01'
  UNION ALL
  SELECT date(d, '+1 day') FROM days WHERE d < '2025-10-10'
)
SELECT d AS day, COUNT(o.order_id) AS orders, COUNT(*) AS count_star
FROM days LEFT JOIN orders o ON o.order_date = d
GROUP BY d ORDER BY d;
-- শুধু শূন্য-অর্ডারের দিন: EXCEPT
WITH RECURSIVE days(d) AS (
  SELECT '2025-10-01' UNION ALL SELECT date(d, '+1 day') FROM days WHERE d < '2025-10-10')
SELECT d AS zero_day FROM days
EXCEPT SELECT order_date FROM orders
ORDER BY 1;

প্রথম ফল ১০টি সারি। orders কলামে 01, 05, 08, 09 তারিখে 1, বাকি দিনে 0. কিন্তু count_star কলামে সব দিনেই 1 — কারণ LEFT JOIN মিল না পেলেও একটি সারি রাখে (orders-এর কলাম NULL), আর COUNT(*) সারি গোনে, মান নয়। তাই এ ধরনের রিপোর্টে সবসময় ডান টেবিলের একটি NOT NULL কলাম গুনুন। দ্বিতীয় ফল ৬টি দিন: 2025-10-02, 03, 04, 06, 07, 10. Recursive CTE-র শর্ত d < '2025-10-10' শেষ দিনটিও তৈরি করে, কারণ 09 থেকে এক দিন যোগ করার পর থামে। string তুলনা এখানে নিরাপদ, কারণ ISO তারিখ বর্ণানুক্রমেই কালানুক্রমে সাজে।

SELECT g.d::date AS day, COUNT(o.order_id) AS orders
FROM generate_series(DATE '2025-10-01', DATE '2025-10-10', INTERVAL '1 day') AS g(d)
LEFT JOIN orders o ON o.order_date = g.d::date
GROUP BY g.d ORDER BY g.d;

PostgreSQL-এ generate_series সরাসরি তারিখের সারি দেয়; MySQL ৮-এ recursive CTE লাগে (ডিফল্টে cte_max_recursion_depth ১০০০, বড় পরিসরে সীমাটি জানা দরকার)। বাস্তবের warehouse-এ সাধারণত একটি স্থায়ী date dimension টেবিল থাকে (পর্ব ৫৫) — ছুটির দিন, সপ্তাহ, অর্থবছরের কলামসহ — যা প্রতিবার বানানোর চেয়ে সহজ ও দ্রুত।

অনু ১৩.১২০২৫ সালের সেপ্টেম্বরে (৩০ দিন) db-shop-এ কয়টি দিনে একটিও অর্ডার আসেনি? (সহজ)

উত্তর: ক্যালেন্ডার '2025-09-01' থেকে '2025-09-30', তারপর EXCEPT বা LEFT JOIN … HAVING COUNT(o.order_id) = 0. সেপ্টেম্বরে অর্ডার এসেছে ৬টি আলাদা দিনে (১, ৩, ১০, ১২, ১৫, ২০), তাই শূন্যের দিন ৩০ − ৬ = ২৪টি। একই দিনে দুটি অর্ডার থাকলেও উত্তর বদলাত না, কারণ দিন গোনা হচ্ছে, অর্ডার নয়। বাতিল অর্ডারের দিন (১২ তারিখ) “অর্ডার এসেছে” ধরব কি না — সেটা আবার সংজ্ঞার প্রশ্ন; বাদ দিলে ২৫টি।

৮ · জটিলতা, index আর ইন্টারভিউয়ারের চোখে সাধারণ ভুল

“আপনার কোয়েরির জটিলতা কত, কোন index সাহায্য করবে?”

রিভিশন: পর্ব ৪০ · B+ tree, পর্ব ৪১ · Composite index আর EXPLAIN, পর্ব ৪২ · Join অ্যালগরিদম, পর্ব ৪৩ · Optimizer

Backend আর data engineer ইন্টারভিউতে কোয়েরি লেখার পর এই প্রশ্ন প্রায় নিশ্চিত। উত্তরের কাঠামো তিন ভাগে: (১) কোন টেবিল কতটা পড়া হচ্ছে — পুরো scan O(n), নাকি index দিয়ে খোঁজ O(log n + k), যেখানে k মিলে যাওয়া সারি; (২) join-এর ধরন — hash join মোটামুটি O(n + m), index nested loop O(n log m), index ছাড়া nested loop O(n · m); (৩) sort লাগছে কি না — ORDER BY, window function আর অনেক GROUP BY-তে O(n log n), যদি না একটি index আগে থেকেই সেই ক্রমে সাজানো থাকে। B+ tree-র উচ্চতা ছোট — প্রতিটি node-এ শত শত key থাকায় কয়েক কোটি সারিতেও সাধারণত ৩–৪ স্তর — তাই index খোঁজা কার্যত কয়েকটি page পড়া।

সমস্যা · একজন গ্রাহকের অর্ডার, নতুনটি আগে (মাঝারি): অ্যাপের “আমার অর্ডার” পাতা চালায় WHERE customer_id = ? ORDER BY order_date DESC. কোন index দেবেন, আর মাসভিত্তিক রিপোর্টের SUBSTR(order_date, 1, 7) = '2025-09' শর্ত index ব্যবহার করে কি? SQLite-এর EXPLAIN QUERY PLAN দিয়ে দেখুন:

-- ১. index-এর আগে
EXPLAIN QUERY PLAN
SELECT order_id, order_date FROM orders WHERE customer_id = 1 ORDER BY order_date DESC;
-- ২. composite index: আগে সমতার কলাম, পরে সাজানোর কলাম
CREATE INDEX idx_orders_cust_date ON orders (customer_id, order_date);
EXPLAIN QUERY PLAN
SELECT order_id, order_date FROM orders WHERE customer_id = 1 ORDER BY order_date DESC;
-- ৩. কলামে ফাংশন বনাম পরিসর
CREATE INDEX idx_orders_date ON orders (order_date);
EXPLAIN QUERY PLAN
SELECT order_id FROM orders WHERE SUBSTR(order_date, 1, 7) = '2025-09';
EXPLAIN QUERY PLAN
SELECT order_id FROM orders WHERE order_date >= '2025-09-01' AND order_date < '2025-10-01';

প্রথম plan-এ দুটি সারি: SCAN orders আর USE TEMP B-TREE FOR ORDER BY — পুরো টেবিল পড়া (O(n)), তারপর মিলে যাওয়া k টি সারি আলাদা করে সাজানো (O(k log k)). Composite index-এর পরে একটি সারি: SEARCH orders USING COVERING INDEX idx_orders_cust_date (customer_id=?) — B+ tree-তে সরাসরি customer 1-এর অংশে যাওয়া (O(log n + k)), আর সেখানে সারিগুলো আগেই order_date ক্রমে, তাই temp B-tree উধাও (উল্টো দিকে পড়লেই DESC). “COVERING” মানে টেবিলে ফিরতেই হয়নি: SQLite-এ INTEGER PRIMARY KEY (order_id) প্রতিটি index-এ থাকে, তাই দরকারি সব কলাম index-এই ছিল (covering index). শেষ দুটি plan-এর তফাতই আসল শিক্ষা: SUBSTR(order_date, 1, 7) = …-এ SCAN … USING COVERING INDEX idx_orders_date — index-টি শুধু ছোট কপি হিসেবে আগাগোড়া পড়া হয়েছে, খোঁজা নয়, কারণ কলামে ফাংশন বসলে শর্তটি আর sargable থাকে না। অর্ধ-খোলা পরিসরে SEARCH … (order_date>? AND order_date<?) — সরাসরি সেপ্টেম্বরের অংশে খোঁজ। (notused কলামের সংখ্যাগুলো SQLite-এর ভেতরের, সেগুলো উপেক্ষা করুন।)

আরও গভীরে: আসল খরচ ঠিক করে optimizer-এর cardinality estimation — কোন শর্তে কতগুলো সারি টিকবে, তার আন্দাজ। ছোট টেবিলে (যেমন এই ১০টি অর্ডার) পুরো scan প্রায়ই index-এর চেয়ে সস্তা, তাই বাস্তব DBMS অনেক সময় index থাকা সত্ত্বেও ব্যবহার করে না; PostgreSQL-এ EXPLAIN ANALYZE অনুমান আর বাস্তব সারি-সংখ্যা পাশাপাশি দেখায়। ইন্টারভিউতে তাই বলুন “এই index ব্যবহারযোগ্য, আর বড় টেবিলে optimizer সম্ভবত নেবে; নিশ্চিত হতে EXPLAIN দেখব” — শুধু “index দিলেই দ্রুত” নয়।

অনু ১৪.১প্যাটার্ন ২-এর top-N কোয়েরি (PARTITION BY dept_id ORDER BY cgpa DESC) দশ লাখ শিক্ষার্থীর টেবিলে চালাতে হবে। জটিলতা কত, আর কোন index সাহায্য করতে পারে? (কঠিন)

উত্তর: index ছাড়া: পুরো টেবিল পড়া O(n), তারপর (dept_id, cgpa) অনুযায়ী sort O(n log n), window হিসাব O(n) — মোট O(n log n), আর sort মেমরিতে না আঁটলে ডিস্কে external merge sort (পর্ব ৪২)। (dept_id, cgpa) composite index থাকলে engine index-এর ক্রমেই সারি পড়ে sort এড়াতে পারে — তবু সব সারি পড়ে, O(n). সবচেয়ে বড় লাভ LATERAL-ধাঁচের পথে: প্রতিটি বিভাগে index দিয়ে শুধু প্রথম N টি পড়া — বিভাগ G টি হলে মোটামুটি O(G · (log n + N)). এখানে NULL cgpa বাদ দেওয়ার শর্তও index-এর সাথে মিলিয়ে ভাবতে হয়। আর বলুন: “দেখতে EXPLAIN চালাব” — অনুমান যাচাই না করে দাবি নয়।

ইন্টারভিউয়াররা যে ভুলগুলো খোঁজেন

অনেক ইন্টারভিউয়ার একটি মানসিক তালিকা নিয়ে আপনার কোয়েরি পড়েন। এই পাতার প্রায় প্রতিটি সরল চেষ্টা এর কোনো একটিতে পড়েছিল:

ভুলকেমন দেখায়ঠিক উপায়
GROUP BY-এর বাইরের bare columnSELECT dept_id, name, MAX(cgpa) … GROUP BY dept_id — SQLite চালায়, PostgreSQL ত্রুটি দেয়, MySQL-এ মোড অনুযায়ীwindow function বা JOIN-এ ফিরিয়ে আনা (প্যাটার্ন ২, ৭)
NULL তুলনাWHERE phone = NULL (কখনো সত্য নয়), NULL-সহ subquery-তে NOT INIS NULL, NOT EXISTS (প্যাটার্ন ৪)
Fan-out-এ দ্বিগুণ গোনাএক-থেকে-বহু JOIN-এর পর COUNT বা অর্ডার-স্তরের SUMCOUNT(DISTINCT), বা আগে গুটিয়ে তারপর JOIN (অংশ ১)
WHERE বনাম HAVINGWHERE COUNT(*) > 1 (ত্রুটি), বা সারির শর্ত HAVING-এ (ধীর, অস্পষ্ট)সারির শর্ত WHERE-এ, দলের শর্ত HAVING-এ
Tie উপেক্ষাLIMIT 1 OFFSET 1, tiebreaker ছাড়া ROW_NUMBERDENSE_RANK / RANK, বা ROW_NUMBER + অনন্য tiebreaker (প্যাটার্ন ১, ২)
LEFT JOIN-এর শর্ত WHERE-এডান টেবিলের শর্ত WHERE-এ দিয়ে inner join বানিয়ে ফেলাশর্ত ON-এ (প্যাটার্ন ৭)
খালি দলে COUNT(*)LEFT JOIN-এর পর শূন্যের দিনে 1ডান টেবিলের NOT NULL কলাম গোনা (প্যাটার্ন ১৩)
Integer ভাগa / b * 100 → 0100.0 * a / NULLIF(b, 0) (প্যাটার্ন ৫)
কলামে ফাংশনWHERE SUBSTR(order_date, 1, 7) = … index এড়িয়ে যায়অর্ধ-খোলা পরিসর >= শুরু AND < পরের শুরু

SQLite-এর উদারতায় ভরসা নয়: এই পাতার এডিটর SQLite-এ চলে, যা bare column, string-এ সংখ্যা আর ভুল টাইপ অনেক ক্ষেত্রে চুপচাপ মেনে নেয়। ইন্টারভিউতে সাধারণত PostgreSQL বা MySQL ধরা হয় — কোন DBMS, শুরুতেই জিজ্ঞেস করুন, আর standard SQL-এ লিখুন (পর্ব ৩১)।

৯ · প্যাটার্ন চেনার চিটশিট আর ভাবনার প্রশ্ন

ইন্টারভিউর আগের রাতের জন্য: বাঁ কলামের শব্দ বা অবস্থা প্রশ্নে দেখলে মাঝের প্যাটার্নটি প্রথমে ভাবুন। এটি শুরুর ইঙ্গিত, নিশ্চিত নিয়ম নয় — একটি সমস্যায় প্রায়ই দুটি প্যাটার্ন লাগে (যেমন ক্যালেন্ডার + মাসিক বৃদ্ধি, বা দ্বীপ + সর্বোচ্চ)।

সংকেত (প্রশ্নে যা দেখবেন)প্যাটার্ন ও মূল টুকরোএই পাতায় · রিভিশন
দ্বিতীয় / N-তম সর্বোচ্চ, “না থাকলে NULL”DENSE_RANK = N, বা DISTINCT + OFFSET-এর scalar subqueryপ্যাটার্ন ১ · পর্ব ২৬
প্রতিটি … সেরা / সবচেয়ে বড় NPARTITION BY + RANK বা ROW_NUMBER, বাইরে ছাঁকাপ্যাটার্ন ২ · পর্ব ২৭
একাধিকবার, দুবার import, একটি রাখুনGROUP BY … HAVING COUNT(*) > 1; ROW_NUMBER > 1 মোছা; UNIQUEপ্যাটার্ন ৩ · পর্ব ১৪
কখনো না, একটিও নেইNOT EXISTS / LEFT JOIN … IS NULL (NOT IN নয়)প্যাটার্ন ৪ · পর্ব ২০
সব, প্রতিটি, সবসময়ব্যতিক্রম গুনে 0; COUNT(DISTINCT) = মোট; দুই স্তরের NOT EXISTSপ্যাটার্ন ৪ · পর্ব ৩৩
এ পর্যন্ত মোট, আগের মাসের তুলনায় %SUM OVER (ROWS …), LAG, 100.0 * … / NULLIFপ্যাটার্ন ৫ · পর্ব ২৬
টানা দিন, streak, বিরতি, sessionDISTINCT → তারিখ − ROW_NUMBER; বা LAG-পতাকার running SUMপ্যাটার্ন ৬ · পর্ব ২৭
প্রথম / শেষ ঘটনা, পুরো সারি চাইROW_NUMBER = 1 + tiebreaker; শর্ত LEFT JOIN-এর ON-এপ্যাটার্ন ৭ · পর্ব ২৭
অবস্থা / মাস আলাদা কলামেSUM(CASE … ELSE 0), COUNT(*) FILTERপ্যাটার্ন ৮ · পর্ব ১৮
নিজের ম্যানেজার / mentor, একই টেবিলের জোড়াSelf join, জোড়ায় a.id < b.id; গভীরতায় recursive CTEপ্যাটার্ন ৯ · পর্ব ২০, ২৫
ধাপ, conversion, কোথায় ঝরে পড়ছেব্যবহারকারীপ্রতি MIN(CASE …), তারপর ক্রম মেনে COUNTপ্যাটার্ন ১০ · পর্ব ১৮
cohort, month 0/1/2, ফিরে এসেছেন কতজনপ্রথম মাস + মাসের পার্থক্য, cohort-আকারে ভাগপ্যাটার্ন ১১ · পর্ব ১৭
median, percentile, “সাধারণ” মানROW_NUMBER + COUNT OVER, মাঝের এক বা দুটির AVGপ্যাটার্ন ১২ · পর্ব ২৬
শূন্যসহ প্রতিটি দিন / মাসrecursive CTE ক্যালেন্ডার + LEFT JOIN + COUNT(কলাম)প্যাটার্ন ১৩ · পর্ব ২৫
“কত দ্রুত”, “কোন index”scan / seek, join-এর ধরন, sort; composite index-এ আগে সমতাঅংশ ৮ · পর্ব ৪১

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

কোয়েরি লেখার পরে ইন্টারভিউয়ার প্রায়ই প্রস্তুতি আর পদ্ধতি নিয়েও প্রশ্ন করেন। প্রতিটি আগে নিজে ভাবুন, তারপর খুলুন।

প্র ০১Window function না জানলে কি SQL ইন্টারভিউ পার হওয়া যায়? আর পুরোনো DBMS-এ এগুলো না থাকলে কী করবেন? (মাঝারি)

এই পাতার ১৩টি প্যাটার্নের অন্তত ৮টিতে window function সবচেয়ে পরিষ্কার সমাধান দেয় — ranking, top-N, deduplicate, running total, LAG, gaps and islands, প্রথম ঘটনা, median. এগুলো standard SQL-এ এসেছে ২০০৩ সালের সংস্করণে, কিন্তু সব DBMS একসাথে আনেনি: PostgreSQL ৮.৪ (২০০৯), আর MySQL ৮.০ ও SQLite ৩.২৫ দুটোই ২০১৮ সালে। তাই অনেক পুরোনো প্রজেক্টে এখনো correlated subquery আর self join দিয়ে লেখা কোড দেখবেন।

ইন্টারভিউর দিক থেকে: আজকের data analyst বা data engineer পদে window function প্রায় মৌলিক দক্ষতা ধরা হয়। তবে বিকল্প জানাও মূল্যবান, কারণ ফলো-আপে প্রায়ই আসে “window function ছাড়া পারবেন?” প্রতিটি window প্যাটার্নের একটি পুরোনো রূপ আছে: RANK − 1 = “আমার চেয়ে বেশি কয়টি” (correlated COUNT), LAG = “আমার আগের সবচেয়ে বড় তারিখ” (correlated MAX), running total = “আমার সমান বা আগের সব সারির SUM” (self join). এগুলো সাধারণত O(n²)-এর দিকে যায়, তাই বড় টেবিলে ধীর — আর এটা বলাটাও উত্তরের অংশ।

শেখার ক্রম তাই এমন: আগে GROUP BY আর JOIN-এ পোক্ত হোন, তারপর window function-এর তিন অংশ — PARTITION BY (কাদের মধ্যে), ORDER BY (কোন ক্রমে), frame (কতদূর পর্যন্ত) — আলাদা করে ভাবতে শিখুন, তারপর প্রতিটির একটি subquery-বিকল্প একবার নিজে লিখে দেখুন।

মূল উপলব্ধি: window function আধুনিক SQL ইন্টারভিউর কেন্দ্রে, কিন্তু তার পুরোনো বিকল্প আর সেগুলোর জটিলতা জানলে আপনি যেকোনো DBMS-এ আর যেকোনো ফলো-আপে টিকে থাকবেন।

প্র ০২ইন্টারভিউয়াররা কেন প্রশ্নটি ইচ্ছা করে অস্পষ্ট রাখেন, আর “সঠিক উত্তর”-এর চেয়ে প্রশ্ন করাকে বেশি মূল্য দেন? (মাঝারি)

বাস্তব কাজে কেউ আপনাকে নিখুঁত সংজ্ঞাসহ প্রশ্ন দেবেন না। ম্যানেজার বলবেন “গত মাসে active user কত?” — কিন্তু active মানে লগইন, নাকি অন্তত একটি কেনা? “গত মাস” মানে ক্যালেন্ডার মাস, নাকি শেষ ৩০ দিন? বাংলাদেশ সময়ে, নাকি UTC-তে রাখা timestamp অনুযায়ী? প্রতিটি উত্তর সংখ্যাটি বদলে দেয়, আর ভুল সংজ্ঞায় হিসাব করা একটি মেট্রিক ভুল ব্যবসায়িক সিদ্ধান্তে নিয়ে যেতে পারে। এই পাতাতেই দেখলেন: funnel-এ একই ডেটায় ৩ জন বা ২ জন ক্রেতা, “সব অর্ডার delivered”-এ ৩ জন বা ৪ জন — দুটোই কোনো না কোনো সংজ্ঞায় “ঠিক”।

তাই ইন্টারভিউয়ার দেখতে চান আপনি অস্পষ্টতা চিনতে পারেন কি না। ভালো প্রার্থী প্রশ্ন শুনে তিন রকম জিনিস যাচাই করেন: schema (কোন টেবিলে কোন grain, কোন কলাম NULL হতে পারে), সংজ্ঞা (মেট্রিকের সীমা, কী বাদ যাবে), আর edge case (tie, duplicate, খালি দল, সময়ের সীমানা)। উত্তর না পেলে একটি যুক্তিসঙ্গত অনুমান জোরে বলে এগিয়ে যান — তাতে সময়ও বাঁচে, আর ইন্টারভিউয়ার আপনার যুক্তি অনুসরণ করতে পারেন।

তবে ভারসাম্য দরকার। পাঁচ মিনিট ধরে প্রশ্ন করতে থাকলে মনে হতে পারে আপনি লেখা এড়াচ্ছেন। ২–৪টি গুরুত্বপূর্ণ প্রশ্ন — যেগুলোর উত্তর কোয়েরির গঠন বদলায় — যথেষ্ট; বাকিগুলো অনুমান হিসেবে লিখে রাখুন।

মূল উপলব্ধি: SQL-এর সিনট্যাক্স AI-ও লিখতে পারে; ব্যবসার অস্পষ্ট প্রশ্নকে নির্ভুল সংজ্ঞায় আনা মানুষের কাজ — ইন্টারভিউ সেটাই মাপে।

প্র ০৩এই পাতার কোয়েরিগুলো দশ সারিতে চলল। দশ কোটি সারিতে কী বদলাবে? (কঠিন)

যুক্তি একই থাকে, কিন্তু খরচের হিসাব বদলায়। প্রথমত sort: window function, DISTINCT আর অনেক GROUP BY-তে sort লাগে; দশ কোটি সারি মেমরিতে না আঁটলে ডিস্কে external sort হয় (পর্ব ৪২), যা অনেক গুণ ধীর। তাই প্রথম প্রশ্ন: কোন কলামে আগে ছাঁকা যায়? “গত ৯০ দিনের” মতো একটি শর্ত আগে বসালে sort-এর আকার অনেক কমে, আর তারিখ অনুযায়ী partition করা টেবিলে বাকি partition পড়াই হয় না (partition pruning, পর্ব ৫৩)।

দ্বিতীয়ত COUNT(DISTINCT): বিশাল ডেটায় এটি মেমরি-ক্ষুধার্ত। অনেক analytics warehouse আনুমানিক ফাংশন দেয় (HyperLogLog-ভিত্তিক), যা অল্প মেমরিতে সামান্য ত্রুটিসহ গণনা দেয় — funnel বা দৈনিক সক্রিয় ব্যবহারকারীর ড্যাশবোর্ডে সাধারণত যথেষ্ট, কিন্তু টাকার হিসাবে নয়। median আর percentile-এর জন্যও একই রকম আনুমানিক ফাংশন আছে।

তৃতীয়ত বারবার হিসাব না করা: retention cohort প্রতিদিন পুরো ইতিহাস থেকে হিসাব না করে নতুন দিনের ডেটা যোগ করে আপডেট করা (incremental load, পর্ব ৫৫), বা materialized view-তে রাখা (পর্ব ২৪)। Duplicate মোছার জায়গায় নতুন টেবিল লিখে নাম বদলানো, যাতে লম্বা lock না হয়। আর OLTP ডেটাবেসে ভারী analytics কোয়েরি না চালিয়ে replica বা warehouse-এ চালানো (পর্ব ৫২, ৫৫)।

Data engineer ইন্টারভিউতে এই আলোচনাই প্রায়ই আসল অংশ: “আপনার কোয়েরি ঠিক আছে — এবার প্রতিদিন ২০০ কোটি event এলে কী করবেন?”

মূল উপলব্ধি: ছোট ডেটায় সঠিকতা, বড় ডেটায় সঠিকতার সাথে আগে ছাঁকা, partition, আনুমানিক গণনা আর incremental হিসাব — দুটো স্তরেই ভাবতে পারা চাই।

১০ · AI দিয়ে প্রস্তুতি — সৎ ব্যবহার আর নমুনা ডেটায় যাচাই

ইন্টারভিউ চলাকালীন AI-এর সাহায্য অনেক প্রতিষ্ঠানেই নিষিদ্ধ — live SQL round-এ, online assessment-এ, অনেক সময় take-home-এও। কিছু প্রতিষ্ঠান অনুমতি দিয়ে দেখতে চায় আপনি AI-এর কোয়েরি কীভাবে যাচাই করেন। নিয়ম জায়গাভেদে আলাদা — আগে জিজ্ঞেস করুন, আর অনুমতি না থাকলে ব্যবহার করবেন না।

প্রস্তুতির সময় AI হতে পারে চমৎকার mock interview-এর সঙ্গী, শর্ত একটাই: উত্তর নয়, প্রশ্ন আর মূল্যায়ন চাইবেন। আর মনে রাখবেন, AI-এর লেখা SQL প্রায়ই চলে কিন্তু ভুল:

AI-এর সাধারণ ভুলকোথায় দেখা যায়কীভাবে ধরবেন
NULL-এ অন্ধNOT IN, = NULL, COUNT(*) বনাম COUNT(কলাম)নমুনা ডেটায় একটি NULL সারি যোগ করে আবার চালান
Tie আর duplicate উপেক্ষাLIMIT/OFFSET-এ দ্বিতীয় সর্বোচ্চ, tiebreaker ছাড়া ROW_NUMBER, DISTINCT ছাড়া streakইচ্ছা করে সমান মান আর duplicate সারি ঢোকান
Fan-outদুটি এক-থেকে-বহু JOIN-এর পর SUM বা COUNTএকজনের হিসাব হাতে মিলিয়ে দেখুন, JOIN-এর আগে-পরে সারি গুনুন
বানানো কলাম বা ভুল উপভাষানেই এমন কলামের নাম, SQLite-এ PostgreSQL-এর ফাংশনschema পাশে রেখে চালান; ত্রুটি পড়ুন
Index আর জটিলতার অতিরঞ্জন“এটা O(1)”, কলামে ফাংশন দিয়েও “index ব্যবহার হবে”EXPLAIN চালিয়ে plan দেখুন

নিচের prompt দিয়ে একটি পূর্ণ mock SQL interview চালান — AI-কে স্পষ্ট বলা হয়েছে সমাধান না দিতে, ফাঁদ রাখতে আর শেষে কঠোর মূল্যায়ন করতে:

তুমি একজন অভিজ্ঞ data analyst / backend ইঞ্জিনিয়ার, আমার SQL ইন্টারভিউ নিচ্ছ। আমি PostgreSQL-এর standard SQL-এ লিখব। নিয়ম: ১. ৩–৪টি টেবিলের একটি ছোট schema দাও (কলাম, টাইপ, কোনটি NULL হতে পারে, primary/foreign key). একটি এক-থেকে-বহু সম্পর্ক যেন থাকে। ২. একটি মাঝারি ব্যবসার প্রশ্ন দাও — top-N per group, anti join, running total, gaps and islands, pivot, self join, funnel, cohort, median বা ক্যালেন্ডারের যেকোনো একটি — কিন্তু প্যাটার্নের নাম বলবে না, আর সংজ্ঞায় ইচ্ছা করে একটু অস্পষ্টতা রাখবে। ৩. আমি প্রশ্ন করলে উত্তর দেবে; না করলে শেষে জানাবে কোন প্রশ্নগুলো করা উচিত ছিল। ৪. আমি না চাইলে সমাধান দেবে না; আটকে গেলে একবারে একটি ছোট hint. ৫. আমার কোয়েরি পেলে নিজে "ঠিক" বলবে না — ৫–৮ সারির একটি নমুনা ডেটা দেবে, যাতে অন্তত একটি NULL, একটি tie আর একটি duplicate থাকে, আর আমাকে হাতে ফল বের করতে বলবে। ৬. এরপর জিজ্ঞেস করবে জটিলতা কত আর কোন index দেবে, তারপর একটি ফলো-আপ (শর্ত বদলে বা ডেটা ১০০০ গুণ বড় করে)। ৭. শেষে পাঁচটি দিকে ১–৫ নম্বর দেবে: প্রশ্ন করা, সঠিকতা, edge case, জটিলতা ও index, ব্যাখ্যা — প্রতিটির জন্য একটি নির্দিষ্ট পরামর্শ। প্রশংসা নয়, সৎ মূল্যায়ন। প্রথম schema আর প্রশ্ন দিয়ে শুরু করো।

নিজের বা AI-এর লেখা কোয়েরি যাচাইয়ের সবচেয়ে কাজের উপায় এই পাতার এডিটরগুলোর কৌশল: ছোট একটি নমুনা ডেটা বানান, যাতে ফাঁদগুলো ইচ্ছা করে রাখা আছে (NULL, tie, duplicate, খালি দল, মাস পেরোনো তারিখ), তারপর হাতে হিসাব করা উত্তরের সাথে মেলান। দুটি আলাদা পদ্ধতিতে (যেমন NOT EXISTS আর LEFT JOIN) একই প্রশ্নের উত্তর লিখে ফল মেলানোও শক্তিশালী পরীক্ষা — দুটো একই ভুল করবে, এমন সম্ভাবনা কম।

AI দিয়ে অনুশীলনের চেকলিস্ট

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

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

ক্রমSQL ইন্টারভিউতে একটি প্রশ্ন পেলে ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. ❓ প্রশ্ন করে schema, মেট্রিকের সংজ্ঞা আর edge case পরিষ্কার করা
  2. 🐢 একটি সরল, চলা সংস্করণ লেখা (যেমন আগে ব্যবহারকারীপ্রতি হিসাব)
  3. 🔍 ছোট ডেটায় একজনের ফল হাতে মিলিয়ে পরীক্ষা
  4. 🧭 ভুল সারিয়ে প্যাটার্ন বসানো আর প্রয়োজনে দ্রুত করা
  5. 🗣️ সিদ্ধান্ত, জটিলতা আর সহায়ক index ব্যাখ্যা করা

(সহজ) একটি টেবিলের salary কলামে মান 900, 900, 800, 700. SELECT salary FROM t ORDER BY salary DESC LIMIT 1 OFFSET 1 কী দেয়?

  • 800
  • 900
  • NULL
  • 700

(মাঝারি) instructors (instructor_id) আর courses (instructor_id, যেখানে একটি কোর্সের instructor_id NULL) টেবিলে SELECT name FROM instructors WHERE instructor_id NOT IN (SELECT instructor_id FROM courses) চালালে কী হয়?

  • ত্রুটি দেয়, কারণ subquery-তে NULL আছে
  • কোনো কোর্স পড়ান না এমন সব শিক্ষককে দেয়
  • শুধু যাঁদের instructor_id NULL, তাঁদের বাদ দেয়
  • কোনো সারি দেয় না

(মাঝারি) একটি ক্যালেন্ডার CTE (days) থেকে orders-এ LEFT JOIN করে প্রতিদিনের অর্ডার গোনা হলো। যে দিনে কোনো অর্ডার নেই, সেখানে COUNT(*) আর COUNT(o.order_id) কী দেয়?

  • COUNT(*) = 1, COUNT(o.order_id) = 0
  • দুটোই 0
  • দুটোই 1
  • COUNT(*) = 0, COUNT(o.order_id) = NULL

(সহজ) “যেসব গ্রাহকের ৩টির বেশি অর্ডার আছে” — orders (order_id, customer_id) টেবিলে শর্তটি কোথায় বসবে?

  • WHERE COUNT(*) > 3, তারপর GROUP BY customer_id
  • ORDER BY COUNT(*) > 3
  • GROUP BY customer_id HAVING COUNT(*) > 3
  • WHERE order_id > 3

(মাঝারি) customers, orders (প্রতিটি গ্রাহকের কয়েকটি অর্ডার) আর order_items (প্রতিটি অর্ডারের কয়েকটি পণ্য-সারি) JOIN করে COUNT(o.order_id) দিয়ে গ্রাহকপ্রতি অর্ডার গোনা হলো। একজনের ৩টি অর্ডার, প্রতিটিতে ২টি পণ্য। ফল কত, আর সমাধান কী?

  • 3 — JOIN সারির সংখ্যা বদলায় না
  • 6 — COUNT(DISTINCT o.order_id) বা আগে গুটিয়ে নেওয়া
  • 9 — LEFT JOIN দিতে হবে
  • 2 — ROW_NUMBER দিয়ে গুনতে হবে

(মাঝারি) gaps and islands কৌশলে প্রতিটি ব্যবহারকারীর (একদিনে একটি সারি রেখে) julianday(day) − ROW_NUMBER() হিসাব করা হয়। টানা দিনের একটি দলে এই মানটির বৈশিষ্ট্য কী?

  • প্রতিটি সারিতে ১ করে বাড়ে
  • সবসময় 0 হয়
  • বিরতির পর কমে যায়
  • একটি দলের ভেতরে স্থির থাকে, বিরতির পর বাড়ে

(মাঝারি) n টি মান 1-থেকে-n ক্রমিক নম্বর (rn) পেয়ে সাজানো আছে। median-এর জন্য WHERE rn IN ((n + 1) / 2, (n + 2) / 2) (integer ভাগ) দিয়ে AVG নেওয়া হলো। n = 10 হলে কোন সারিগুলো নেওয়া হয়?

  • rn 5 আর 6
  • rn 6 আর 7
  • শুধু rn 5
  • rn 4 আর 5

(মাঝারি) একটি অ্যাপ বারবার চালায় SELECT … FROM orders WHERE customer_id = ? ORDER BY order_date DESC. কোন একটি index সবচেয়ে ভালো?

  • (order_date, customer_id)
  • শুধু (customer_id)
  • (customer_id, order_date)
  • দুটি আলাদা index: (customer_id) আর (order_date)

(কঠিন) customers থেকে orders-এ LEFT JOIN করে প্রতিটি গ্রাহকের প্রথম অর্ডার (rn = 1) দেখানো হচ্ছে; যাঁর অর্ডার নেই তিনিও থাকবেন। rn = 1 শর্তটি WHERE-এ দিলে কী হয়?

  • কিছুই বদলায় না
  • ত্রুটি দেয়, কারণ rn একটি window কলাম
  • প্রতিটি গ্রাহক একাধিকবার আসেন
  • অর্ডার নেই এমন গ্রাহকেরা বাদ পড়েন

শব্দকোষ

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

মনে রাখুন

  1. প্রতিটি প্রশ্নে পাঁচ ধাপ: প্রশ্ন করে schema ও সংজ্ঞা পরিষ্কার → সরল সংস্করণ → ছোট ডেটায় হাতে মেলানো → উন্নতি → ব্যাখ্যা, পুরো সময় জোরে ভেবে।
  2. প্রতিটি সমাধানে চারটি ফাঁদ নিজে তুলুন: NULL, tie, duplicate আর খালি দল — আর একটি নমুনা ডেটায় ইচ্ছা করে সেগুলো রেখে চালান।
  3. Ranking-এ DENSE_RANK / RANK / ROW_NUMBER + tiebreaker; “কখনো না”-তে NOT EXISTS, NOT IN নয়; “সব”-এ ব্যতিক্রম গোনা বা COUNT(DISTINCT) মেলানো, আর vacuous truth-এর প্রশ্নটি তোলা।
  4. সময়ের ধারায় আগে গুটিয়ে তারপর window: LAG আগের সারি দেয়, আগের মাস নয় — ফাঁকা মাস ভরতে ক্যালেন্ডার; streak-এ আগে DISTINCT, তারপর তারিখ − ROW_NUMBER.
  5. জটিলতা বলুন তিন ভাগে — scan না seek, join-এর ধরন, sort লাগে কি না — আর index-এর দাবি EXPLAIN দিয়ে যাচাই করুন; composite index-এ আগে সমতার কলাম।

পরের পর্বে: পর্ব ৬৭-এ ডেটাবেস ডিজাইন ইন্টারভিউ — একটি অস্পষ্ট চাহিদা থেকে schema বানানো, key আর index বাছাই, আর ব্যবহারকারী বাড়লে scaling-এর আলোচনা। এই কোর্সের শেষ পর্ব।

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

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