অধ্যায় 1 · অ্যাডভান্সড কোয়েরি
অ্যানালিটিক্স প্যাটার্ন: গ্রোথ, কোহর্ট, রিটেনশন আর গ্যাপ
- পৃষ্ঠা 4 / 22
- 16 মিনিট পড়া
কিছু প্রশ্ন বারবার ফিরে আসে, অ্যানালিটিক্সের কাজে আর SQL ইন্টারভিউতে: দ্বিতীয় সর্বোচ্চ বেতন কত? এই মাসে রেভিনিউ কতটা বাড়ল? জানুয়ারিতে যাঁরা যোগ দিয়েছিলেন, তাঁদের কতজন মার্চেও সক্রিয় ছিলেন? প্রত্যেক ইউজারের সবচেয়ে লম্বা টানা ধারা কত দিনের? তাঁরা কয়টা চ্যাট সেশন করেছেন? প্রতিটার একটা প্রচলিত ছাঁচ আছে, যা আপনার জানা জিনিস দিয়েই তৈরি — CTE, GROUP BY, জয়েন আর উইন্ডো ফাংশন। ছাঁচগুলো একবার শিখে নিলে সবখানে চিনতে পারবেন।
AI-এর কাজেও এগুলো জরুরি। রিটেনশন আর টানা ধারা (streak) হলো চার্ন আর এনগেজমেন্ট মডেলের লেবেল আর ফিচার, আর "মানুষ আমাদের অ্যাসিস্ট্যান্ট কীভাবে ব্যবহার করছে?" — এমন যেকোনো বিশ্লেষণের প্রথম ধাপ হলো LLM চ্যাট লগকে সেশনে ভাগ করা।
যা শিখবেন
- N-তম সর্বোচ্চ মান সঠিকভাবে বের করা — টাই থাকলে, গ্রুপ ধরে, আর মানটা না থাকলে
- মাস-থেকে-মাস (MoM) গ্রোথ হিসাব, যেসব মাসে ডেটা নেই সেগুলোসহ
- দৈনিক অ্যাক্টিভিটি লগ থেকে কোহর্ট রিটেনশন টেবিল বানানো
- গ্যাপস-অ্যান্ড-আইল্যান্ডস কৌশলে টানা দিনের ধারা খোঁজা
LAGআর রানিংSUMদিয়ে একটানা ইভেন্টগুলোকে সেশনে ভাগ করা
দ্বিতীয় সর্বোচ্চ মান
ইন্টারভিউয়ের ক্লাসিক প্রশ্ন। সঠিক উত্তর কয়েকটা, আর একটা জনপ্রিয় ভুল উত্তর। বেতনগুলো হলো ২,৫০,০০০, ১,৮০,০০০, ১,৬০,০০০, ১,২০,০০০ (দুবার), ১,১০,০০০ আর ৯৫,০০০:
-- 1. সর্বোচ্চের চেয়ে ছোটদের মধ্যে সবচেয়ে বড়টা
SELECT MAX(salary) AS second_highest
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);
-- 2. আলাদা আলাদা মান, একটা বাদ দিয়ে
SELECT DISTINCT salary AS second_highest
FROM employees
ORDER BY salary DESC
LIMIT 1 OFFSET 1;
-- 3. DENSE_RANK, যা "N-তম"-তেও খাটে
WITH ranked AS (
SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
FROM employees
)
SELECT DISTINCT salary AS second_highest FROM ranked WHERE rnk = 2;+----------------+
| second_highest |
+----------------+
| 180000 |
+----------------+
+----------------+
| second_highest |
+----------------+
| 180000 |
+----------------+
+----------------+
| second_highest |
+----------------+
| 180000 |
+----------------+তিনটাই বলছে ১,৮০,০০০। ভুল উত্তরটা হলো DISTINCT ছাড়া ORDER BY salary DESC LIMIT 1 OFFSET 1: এটা দেয় দ্বিতীয় সারি, যা দ্বিতীয় মান হয় কেবল তখনই, যখন সর্বোচ্চ মানে টাই নেই। নিজেকে জিজ্ঞেস করুন, "দ্বিতীয়" মানে দ্বিতীয় মানুষ, নাকি দ্বিতীয় আলাদা অঙ্ক; ইন্টারভিউতে সাধারণত অঙ্কটাই বোঝানো হয়।
গ্রুপ ধরে, আর কোনো গ্রুপে দ্বিতীয় মান না থাকলে: DENSE_RANK দিয়ে র্যাংক করুন, তারপর ডিপার্টমেন্টের তালিকা থেকে শুরু করে একটা স্কেলার সাবকোয়েরি দিয়ে র্যাংক ২ খুঁজুন, যাতে প্রতিটা ডিপার্টমেন্ট ফলাফলে থাকে:
WITH ranked AS (
SELECT department, salary,
DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rnk
FROM employees
),
departments AS (SELECT DISTINCT department FROM employees)
SELECT d.department,
(SELECT MAX(r.salary) FROM ranked AS r
WHERE r.department = d.department AND r.rnk = 2) AS second_highest,
(SELECT COUNT(*) FROM ranked AS r
WHERE r.department = d.department AND r.rnk = 2) AS people_at_it
FROM departments AS d
ORDER BY d.department;+-------------+----------------+--------------+
| department | second_highest | people_at_it |
+-------------+----------------+--------------+
| Data | 110000 | 1 |
| Engineering | 120000 | 2 |
| Management | NULL | 0 |
+-------------+----------------+--------------+Engineering-এর দ্বিতীয় সর্বোচ্চ বেতন ১,২০,০০০, আর দুজন সেটা পান; Management-এ বেতন একটাই, তাই সঠিক উত্তর হলো NULL — সারিটা একেবারে বাদ পড়ে যাওয়া নয়। ইন্টারভিউয়াররা ঠিক এই খুঁটিনাটিটাই দেখেন।
মাস-থেকে-মাস গ্রোথ
গ্রোথ হলো (এই মাস − গত মাস) / গত মাস। LAG গত মাসকে একই সারিতে নিয়ে আসে, আর NULLIF(prev, 0) ভাজক শূন্য হলে এরর বা অর্থহীন সংখ্যার বদলে NULL দেয়:
WITH monthly AS (
SELECT strftime('%Y-%m', o.order_date) AS month,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
WHERE o.status <> 'cancelled'
GROUP BY month
)
SELECT month,
revenue,
LAG(revenue) OVER (ORDER BY month) AS prev,
ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
/ NULLIF(LAG(revenue) OVER (ORDER BY month), 0), 1) AS growth_pct
FROM monthly
ORDER BY month;+---------+---------+-------+------------+
| month | revenue | prev | growth_pct |
+---------+---------+-------+------------+
| 2026-01 | 6600 | NULL | NULL |
| 2026-02 | 7000 | 6600 | 6.1 |
| 2026-03 | 21400 | 7000 | 205.7 |
| 2026-04 | 23450 | 21400 | 9.6 |
| 2026-05 | 8500 | 23450 | -63.8 |
| 2026-06 | 18100 | 8500 | 112.9 |
+---------+---------+-------+------------+বাদ পড়া মাসের ফাঁদ
প্রতি মাসেই কিছু না কিছু বিক্রি হয়েছে, তাই এটা সহজ ছিল। ক্যাটাগরি ধরে দেখলে মাস বাদ পড়ে — Courses ক্যাটাগরিতে জানুয়ারি বা এপ্রিলে কিছুই বিক্রি হয়নি — আর তখন LAG কোনো সতর্কতা ছাড়াই ভুল মাসের সাথে তুলনা করে। সমাধান হলো একটা ক্যালেন্ডার: রিকার্সিভ CTE দিয়ে প্রতিটা মাস বানান, আর বিক্রিগুলো তার সাথে LEFT JOIN করুন। (সব ক্যাটাগরি একসাথে চাইলে আগে মাসগুলোকে ক্যাটাগরির সাথে CROSS JOIN করুন, তারপর দুটো কলাম মিলিয়ে জয়েন করুন।)
WITH RECURSIVE months(month) AS (
SELECT '2026-01'
UNION ALL
SELECT strftime('%Y-%m', month || '-01', '+1 month') FROM months WHERE month < '2026-06'
),
sales AS (
SELECT strftime('%Y-%m', o.order_date) AS month, SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
JOIN products AS p ON p.product_id = oi.product_id
WHERE o.status <> 'cancelled' AND p.category_id = 3 -- Courses ক্যাটাগরি
GROUP BY month
),
grid AS (
SELECT m.month, COALESCE(s.revenue, 0) AS revenue
FROM months AS m LEFT JOIN sales AS s ON s.month = m.month
)
SELECT month,
revenue,
LAG(revenue) OVER (ORDER BY month) AS prev,
ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
/ NULLIF(LAG(revenue) OVER (ORDER BY month), 0), 1) AS growth_pct
FROM grid
ORDER BY month;+---------+---------+-------+------------+
| month | revenue | prev | growth_pct |
+---------+---------+-------+------------+
| 2026-01 | 0 | NULL | NULL |
| 2026-02 | 3000 | 0 | NULL |
| 2026-03 | 15000 | 3000 | 400.0 |
| 2026-04 | 0 | 15000 | -100.0 |
| 2026-05 | 3000 | 0 | NULL |
| 2026-06 | 15000 | 3000 | 400.0 |
+---------+---------+-------+------------+এখন এপ্রিলে আসল −১০০% পতন দেখা যাচ্ছে, আর মে মাসের গ্রোথ NULL (শূন্য থেকে গ্রোথ সংজ্ঞায়িত নয়) — মে-কে আর মার্চের সাথে তুলনা করা হচ্ছে না। সার্ভারে মাস অনুযায়ী ভাগ করবেন সেই ডায়ালেক্টের নিজস্ব তারিখ-ফাংশন দিয়ে; উইন্ডোর অংশটা বদলায় না:
-- PostgreSQL: date_trunc তারিখকে মাসে ভাগ করে; generate_series ক্যালেন্ডার বানায়
WITH months AS (
SELECT generate_series(DATE '2026-01-01', DATE '2026-06-01', INTERVAL '1 month')::date AS month
),
sales AS (
SELECT date_trunc('month', o.order_date)::date AS month, SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
JOIN products AS p ON p.product_id = oi.product_id
WHERE o.status <> 'cancelled' AND p.category_id = 3
GROUP BY 1
)
SELECT to_char(m.month, 'YYYY-MM') AS month,
COALESCE(s.revenue, 0) AS revenue,
ROUND(100.0 * (COALESCE(s.revenue, 0) - LAG(COALESCE(s.revenue, 0)) OVER (ORDER BY m.month))
/ NULLIF(LAG(COALESCE(s.revenue, 0)) OVER (ORDER BY m.month), 0), 1) AS growth_pct
FROM months AS m
LEFT JOIN sales AS s ON s.month = m.month
ORDER BY m.month;+---------+---------+------------+
| month | revenue | growth_pct |
+---------+---------+------------+
| 2026-01 | 0 | NULL |
| 2026-02 | 3000 | NULL |
| 2026-03 | 15000 | 400.0 |
| 2026-04 | 0 | -100.0 |
| 2026-05 | 3000 | NULL |
| 2026-06 | 15000 | 400.0 |
+---------+---------+------------+MySQL-এ মাসের জন্য লিখবেন DATE_FORMAT(order_date, '%Y-%m'), আর ক্যালেন্ডারের জন্য একটা রিকার্সিভ CTE।
বিশ্লেষণের জন্য একটা দৈনিক অ্যাক্টিভিটি টেবিল
কোহর্ট, টানা ধারা আর রিটেনশনের জন্য অনেক দিনের ইউজার অ্যাক্টিভিটি লাগে — শপের ডেটায় যত আছে তার চেয়ে বেশি। নিচের Python ব্লক (সিড দেওয়া) সেটা বানায়: একটা লার্নিং অ্যাপে ৩০ জন শিক্ষার্থী ২০২৬ সালের জানুয়ারি থেকে মার্চের মধ্যে সাইন আপ করেন; প্রত্যেকে র্যান্ডম কিছু দিন ধরে, র্যান্ডম বিরতি দিয়ে ফিরে আসেন। সিডের কারণে প্রতিটা মেশিনে ডেটা হুবহু এক হয়:
import random
from datetime import date, timedelta
from sqlhelp import con, show
random.seed(7)
con.execute("DROP TABLE IF EXISTS activity")
con.execute("""CREATE TABLE activity (
user_id INTEGER NOT NULL,
active_on DATE NOT NULL,
PRIMARY KEY (user_id, active_on))""")
rows = []
for user in range(1, 31):
signup = date(2026, 1, 1) + timedelta(days=random.randrange(90))
stays_for = random.choice([5, 20, 40, 70, 150]) # কত দিন পর আসা বন্ধ করবে
day = signup
while day < date(2026, 5, 1) and (day - signup).days < stays_for:
rows.append((user, day.isoformat()))
day += timedelta(days=random.choice([1, 1, 1, 2, 4, 7]))
con.executemany("INSERT INTO activity VALUES (?, ?)", rows)
con.commit()
show("SELECT COUNT(*) AS rows, COUNT(DISTINCT user_id) AS users, "
"MIN(active_on) AS first_day, MAX(active_on) AS last_day FROM activity")+------+-------+------------+------------+
| rows | users | first_day | last_day |
+------+-------+------------+------------+
| 525 | 30 | 2026-01-03 | 2026-04-30 |
+------+-------+------------+------------+প্রতি ইউজারের প্রতিটা সক্রিয় দিনের জন্য একটা সারি: ডুপ্লিকেট সরানোর পর বেশিরভাগ আসল অ্যাক্টিভিটি লগের চেহারা এমনই (পেজ ভিউ, API কল, খোলা লেসন)।
কোহর্ট বিশ্লেষণ আর রিটেনশন
কোহর্ট (cohort) হলো একই সময়ে শুরু করা ইউজারদের একটা দল। রিটেনশন জানতে চায়: একটা কোহর্টের কত ভাগ N মাস পরেও সক্রিয় ছিল? কোহর্টগুলো পাশাপাশি রাখলে বোঝা যায় প্রোডাক্টটা মানুষকে ধরে রাখতে আগের চেয়ে ভালো করছে কি না — একটা মাত্র "সক্রিয় ইউজার" সংখ্যা দেখে যা বোঝা যায় না।
ধাপ ১ হলো লম্বা রূপ: প্রত্যেক ইউজারের কোহর্ট মাস, আর প্রতিটা সক্রিয় মাস শুরুর কত মাস পরে (month_no):
WITH user_months AS (
SELECT DISTINCT user_id,
CAST(strftime('%Y', active_on) AS INTEGER) * 12
+ CAST(strftime('%m', active_on) AS INTEGER) AS month_idx
FROM activity
),
cohorts AS (
SELECT user_id, MIN(month_idx) AS cohort_idx FROM user_months GROUP BY user_id
)
SELECT c.cohort_idx,
um.month_idx - c.cohort_idx AS month_no,
COUNT(*) AS active_users
FROM user_months AS um
JOIN cohorts AS c ON c.user_id = um.user_id
GROUP BY c.cohort_idx, month_no
ORDER BY c.cohort_idx, month_no;+------------+----------+--------------+
| cohort_idx | month_no | active_users |
+------------+----------+--------------+
| 24313 | 0 | 12 |
| 24313 | 1 | 10 |
| 24313 | 2 | 7 |
| 24313 | 3 | 5 |
| 24314 | 0 | 9 |
| 24314 | 1 | 5 |
| 24314 | 2 | 3 |
| 24315 | 0 | 9 |
| 24315 | 1 | 7 |
+------------+----------+--------------+month_idx (বছর × ১২ + মাস) মাসগুলোকে পরপর পূর্ণসংখ্যায় পরিণত করে, ফলে "কত মাস পরে" একটা বিয়োগ মাত্র। 24313 মানে ২০২৬ সালের জানুয়ারি। ধাপ ২-এ কন্ডিশনাল অ্যাগ্রিগেশন দিয়ে এটাকে পরিচিত ত্রিভুজ-আকারের টেবিলে পিভট (pivot) করা হয়, কোহর্টের আকারের শতাংশ হিসেবে। যে মাস এখনো আসেনি, সেই মাসে কোহর্ট মাপা যায় না — ডেটা এপ্রিলে শেষ — তাই সেই ঘরগুলো হতে হবে NULL, 0 নয়:
WITH user_months AS (
SELECT DISTINCT user_id,
CAST(strftime('%Y', active_on) AS INTEGER) * 12
+ CAST(strftime('%m', active_on) AS INTEGER) AS month_idx
FROM activity
),
cohorts AS (
SELECT user_id, MIN(month_idx) AS cohort_idx FROM user_months GROUP BY user_id
),
counts AS (
SELECT c.cohort_idx, um.month_idx - c.cohort_idx AS month_no, COUNT(*) AS users
FROM user_months AS um JOIN cohorts AS c ON c.user_id = um.user_id
GROUP BY c.cohort_idx, month_no
),
last AS (SELECT MAX(month_idx) AS last_idx FROM user_months)
SELECT printf('%d-%02d', (cohort_idx - 1) / 12, (cohort_idx - 1) % 12 + 1) AS cohort,
MAX(CASE WHEN month_no = 0 THEN users END) AS size,
ROUND(100.0 * MAX(CASE WHEN month_no = 1 THEN users END)
/ MAX(CASE WHEN month_no = 0 THEN users END), 0) AS m1_pct,
CASE WHEN cohort_idx + 2 <= last_idx THEN
ROUND(100.0 * COALESCE(MAX(CASE WHEN month_no = 2 THEN users END), 0)
/ MAX(CASE WHEN month_no = 0 THEN users END), 0) END AS m2_pct,
CASE WHEN cohort_idx + 3 <= last_idx THEN
ROUND(100.0 * COALESCE(MAX(CASE WHEN month_no = 3 THEN users END), 0)
/ MAX(CASE WHEN month_no = 0 THEN users END), 0) END AS m3_pct
FROM counts CROSS JOIN last
GROUP BY cohort_idx, last_idx
ORDER BY cohort_idx;+---------+------+--------+--------+--------+
| cohort | size | m1_pct | m2_pct | m3_pct |
+---------+------+--------+--------+--------+
| 2026-01 | 12 | 83.0 | 58.0 | 42.0 |
| 2026-02 | 9 | 56.0 | 33.0 | NULL |
| 2026-03 | 9 | 78.0 | NULL | NULL |
+---------+------+--------+--------+--------+সারি ধরে পড়ুন: জানুয়ারির ১২ জন শিক্ষার্থীর ৮৩% ফেব্রুয়ারিতে ফিরে এসেছেন, ৫৮% মার্চে আর ৪২% এপ্রিলে। টেবিলটা ত্রিভুজ, কারণ পরের কোহর্টগুলো কম সময় পেয়েছে। CASE WHEN cohort_idx + n <= last_idx শর্তটাই "কেউ ফেরেননি" আর "এখনো জানা সম্ভব নয়"-এর পার্থক্য করে — দুটো গুলিয়ে ফেললে নতুন কোহর্টগুলোকে অকারণে খুব খারাপ দেখায়।
গ্যাপস অ্যান্ড আইল্যান্ডস: টানা দিনের ধারা
আইল্যান্ড হলো পরপর দিনের একটা ধারা; গ্যাপ হলো দুই ধারার মাঝের বিরতি। কৌশলটা হলো: প্রত্যেক ইউজারের দিনগুলোকে ROW_NUMBER() দিয়ে নম্বর দিন, আর তারিখ থেকে সেই নম্বর বিয়োগ করুন। একটা ধারার ভেতরে দুটোই প্রতি সারিতে ১ করে বাড়ে, তাই পার্থক্যটা একই থাকে; বিরতির পরে সেটা হঠাৎ বেড়ে যায়। শিক্ষার্থী ৬-কে দেখুন:
SELECT active_on,
ROW_NUMBER() OVER (ORDER BY active_on) AS rn,
date(active_on, '-' || ROW_NUMBER() OVER (ORDER BY active_on) || ' days') AS island
FROM activity
WHERE user_id = 6
ORDER BY active_on;+------------+----+------------+
| active_on | rn | island |
+------------+----+------------+
| 2026-02-18 | 1 | 2026-02-17 |
| 2026-02-19 | 2 | 2026-02-17 |
| 2026-02-20 | 3 | 2026-02-17 |
| 2026-02-21 | 4 | 2026-02-17 |
| 2026-02-22 | 5 | 2026-02-17 |
| 2026-02-23 | 6 | 2026-02-17 |
| 2026-03-02 | 7 | 2026-02-23 |
| 2026-03-03 | 8 | 2026-02-23 |
| 2026-03-04 | 9 | 2026-02-23 |
| 2026-03-06 | 10 | 2026-02-24 |
+------------+----+------------+island কলামটা একই ধারার সব দিনের একটা অভিন্ন লেবেল। এটা দিয়ে গ্রুপ করলেই ধারাগুলো পেয়ে যাবেন:
WITH labelled AS (
SELECT user_id, active_on,
julianday(active_on)
- ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY active_on) AS island
FROM activity
),
streaks AS (
SELECT user_id, MIN(active_on) AS started, MAX(active_on) AS ended, COUNT(*) AS days
FROM labelled
GROUP BY user_id, island
)
SELECT user_id, started, ended, days
FROM streaks
WHERE days >= 6
ORDER BY days DESC, user_id;+---------+------------+------------+------+
| user_id | started | ended | days |
+---------+------------+------------+------+
| 29 | 2026-03-14 | 2026-03-22 | 9 |
| 30 | 2026-02-18 | 2026-02-26 | 9 |
| 20 | 2026-01-18 | 2026-01-24 | 7 |
| 21 | 2026-02-27 | 2026-03-05 | 7 |
| 6 | 2026-02-18 | 2026-02-23 | 6 |
| 26 | 2026-04-25 | 2026-04-30 | 6 |
+---------+------------+------------+------+একই প্যাটার্ন দিয়ে মনিটরিং ডেটায় সার্ভিস বন্ধ থাকার সময়, পরপর ব্যর্থ লগইনের ধারা, বা যেসব দিনে একটা মডেলের অ্যাকুরেসি লক্ষ্যের নিচে ছিল সেই সময়কাল খোঁজা যায়। PostgreSQL-এ লেবেলটা সোজা active_on - ROW_NUMBER() OVER (…)::int (তারিখ থেকে পূর্ণসংখ্যা বিয়োগ করলে তারিখই হয়); MySQL-এ DATE_SUB(active_on, INTERVAL ROW_NUMBER() OVER (…) DAY)।
সেশনে ভাগ করা (sessionisation)
কাঁচা ইভেন্টে (পেজ ভিউ, চ্যাট মেসেজ, API কল) টাইমস্ট্যাম্প থাকে, কিন্তু "সেশন" থাকে না। প্রচলিত নিয়ম: ৩০ মিনিটের বেশি বিরতি মানে নতুন সেশন। এটা আসলে গ্যাপস-অ্যান্ড-আইল্যান্ডস, শুধু "ঠিক এক দিন"-এর বদলে একটা সীমা — তিন ধাপে: LAG দিয়ে বিরতি মাপুন, নতুন সেশন চিহ্নিত করুন, তারপর চিহ্নগুলোর রানিং SUM থেকে সেশনের নম্বর পান। নিচে একটা AI স্টাডি অ্যাসিস্ট্যান্টকে পাঠানো মেসেজ:
CREATE TABLE chat_events (
user_id INTEGER NOT NULL,
sent_at TEXT NOT NULL
);
INSERT INTO chat_events (user_id, sent_at) VALUES
(1, '2026-05-04 09:00'), (1, '2026-05-04 09:05'), (1, '2026-05-04 09:20'),
(1, '2026-05-04 10:30'), (1, '2026-05-04 10:31'), (1, '2026-05-04 14:00'),
(2, '2026-05-04 09:10'), (2, '2026-05-04 09:50'), (2, '2026-05-04 09:55');
WITH gaps AS (
SELECT user_id, sent_at,
ROUND((julianday(sent_at)
- julianday(LAG(sent_at) OVER (PARTITION BY user_id ORDER BY sent_at))) * 24 * 60)
AS gap_min
FROM chat_events
),
flagged AS (
SELECT *, CASE WHEN gap_min IS NULL OR gap_min > 30 THEN 1 ELSE 0 END AS new_session
FROM gaps
)
SELECT user_id, sent_at, gap_min, new_session,
SUM(new_session) OVER (PARTITION BY user_id ORDER BY sent_at
ROWS UNBOUNDED PRECEDING) AS session_no
FROM flagged
ORDER BY user_id, sent_at;+---------+------------------+---------+-------------+------------+
| user_id | sent_at | gap_min | new_session | session_no |
+---------+------------------+---------+-------------+------------+
| 1 | 2026-05-04 09:00 | NULL | 1 | 1 |
| 1 | 2026-05-04 09:05 | 5.0 | 0 | 1 |
| 1 | 2026-05-04 09:20 | 15.0 | 0 | 1 |
| 1 | 2026-05-04 10:30 | 70.0 | 1 | 2 |
| 1 | 2026-05-04 10:31 | 1.0 | 0 | 2 |
| 1 | 2026-05-04 14:00 | 209.0 | 1 | 3 |
| 2 | 2026-05-04 09:10 | NULL | 1 | 1 |
| 2 | 2026-05-04 09:50 | 40.0 | 1 | 2 |
| 2 | 2026-05-04 09:55 | 5.0 | 0 | 2 |
+---------+------------------+---------+-------------+------------+এটাকে আরেকটা CTE-তে মুড়ে GROUP BY user_id, session_no করলে প্রতিটা সেশনের জন্য একটা সারি পাবেন — শুরু, শেষ, দৈর্ঘ্য আর মেসেজের সংখ্যাসহ। একটা অ্যাসিস্ট্যান্টের ড্যাশবোর্ডে "গড় সেশনের দৈর্ঘ্য" আর "সেশনপ্রতি মেসেজ"-এর পেছনে এই টেবিলটাই থাকে।
বাস্তবে কোথায় দেখবেন
| প্যাটার্ন | সাধারণ প্রশ্ন | মূল হাতিয়ার |
|---|---|---|
| N-তম সর্বোচ্চ | দ্বিতীয় সেরা মডেল রান, রানার-আপ প্রোডাক্ট | DENSE_RANK |
| গ্রোথ | MoM রেভিনিউ, সপ্তাহ-থেকে-সপ্তাহ টোকেন খরচ | LAG + ক্যালেন্ডার + NULLIF |
| কোহর্ট / রিটেনশন | নতুন ইউজাররা কি আগের চেয়ে বেশি টিকে থাকছে? | ইউজারপ্রতি MIN + কন্ডিশনাল অ্যাগ্রিগেশন |
| গ্যাপস অ্যান্ড আইল্যান্ডস | টানা ধারা, সার্ভিস বন্ধ, পরপর ব্যর্থতা | তারিখ − ROW_NUMBER() |
| সেশন | ইউজারপ্রতি সেশন, সেশনের দৈর্ঘ্য | LAG + চিহ্ন + রানিং SUM |
সাধারণ ভুল
DISTINCTছাড়া বাRANKদিয়ে "দ্বিতীয় সর্বোচ্চ"। টাই থাকলেLIMIT 1 OFFSET 1আবার সর্বোচ্চ মানটাই দেয়, আরRANKটাইয়ের পরে নম্বর লাফিয়ে যায়, ফলেrnk = 2না-ও থাকতে পারে।DENSE_RANKবাDISTINCTব্যবহার করুন।- বাদ পড়া সময়কাল পেরিয়ে গ্রোথ। শুধু বিক্রি-হওয়া মাসগুলোর ওপর
LAGভুল মাসের সাথে তুলনা করে। ক্যালেন্ডার বানিয়েLEFT JOINকরুন। - শূন্য বা
NULLদিয়ে ভাগ, কোনো সতর্কতা ছাড়াই। SQLite আর MySQLx / 0-এর জন্যNULLদেয়; PostgreSQLdivision by zeroএরর দেয়।NULLIF(prev, 0)দিয়ে আপনার উদ্দেশ্য স্পষ্ট করে লিখুন। - কোহর্ট যে মাসে পৌঁছায়নি, সেখানে শূন্য। মাপা-যায়-না আর চলে-গেছে এক জিনিস নয়। ওপরের মতো ডেটার শেষ মাস দিয়ে শর্ত বসান।
- ডুপ্লিকেটসহ ডেটায় গ্যাপস-অ্যান্ড-আইল্যান্ডস। একই দিনের দুটো সারি দুটো নম্বর পায় আর ধারা ভেঙে দেয়। আগে ডুপ্লিকেট সরান (
SELECT DISTINCT user_id, active_on); এখানে প্রাইমারি কি আগেই সেটা নিশ্চিত করেছে।
নিজে চেষ্টা করুন
- সহজ: প্রোডাক্টের তৃতীয় সর্বোচ্চ আলাদা দাম বের করুন।
- মাঝারি:
activityথেকে প্রতি মাসের সক্রিয় ইউজার গুনুন, আর সক্রিয় ইউজারের মাস-থেকে-মাস গ্রোথ হিসাব করুন (শতাংশে, ১ দশমিক ঘর)। - কঠিন: প্রত্যেক শিক্ষার্থীর সবচেয়ে লম্বা ধারা কত দিনের বের করুন; সবচেয়ে লম্বা ধারা অনুযায়ী সেরা ৫ জনকে দেখান (টাই হলে
user_id)।
উত্তর
-- সহজ
WITH ranked AS (
SELECT price, DENSE_RANK() OVER (ORDER BY price DESC) AS rnk FROM products
)
SELECT DISTINCT price FROM ranked WHERE rnk = 3;-- মাঝারি
WITH monthly AS (
SELECT strftime('%Y-%m', active_on) AS month, COUNT(DISTINCT user_id) AS users
FROM activity
GROUP BY month
)
SELECT month, users,
ROUND(100.0 * (users - LAG(users) OVER (ORDER BY month))
/ NULLIF(LAG(users) OVER (ORDER BY month), 0), 1) AS growth_pct
FROM monthly
ORDER BY month;-- কঠিন
WITH labelled AS (
SELECT user_id,
julianday(active_on) - ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY active_on) AS island
FROM activity
),
streaks AS (
SELECT user_id, COUNT(*) AS days FROM labelled GROUP BY user_id, island
)
SELECT user_id, MAX(days) AS longest_streak
FROM streaks
GROUP BY user_id
ORDER BY longest_streak DESC, user_id
LIMIT 5;সারসংক্ষেপ
- N-তম সর্বোচ্চ:
DENSE_RANK(অথবাDISTINCT … LIMIT 1 OFFSET n-1); টাই মানে কী তা ঠিক করুন, আর উত্তর না থাকলেNULLদিন। - গ্রোথ: সম্পূর্ণ ক্যালেন্ডারের ওপর
LAG, আর শূন্য দিয়ে ভাগ ঠেকাতেNULLIF। - কোহর্ট: ইউজারপ্রতি প্রথম সময়কাল, শুরু থেকে কত মাস, তারপর কন্ডিশনাল অ্যাগ্রিগেশনে পিভট; যা মাপা যায় না তা
NULLরাখুন। - আইল্যান্ড: একটা ধারার ভেতরে তারিখ বিয়োগ
ROW_NUMBER()স্থির থাকে; সেটা দিয়ে গ্রুপ করুন। - সেশন:
LAGদিয়ে বিরতি → নতুন-সেশনের চিহ্ন → চিহ্নগুলোর রানিংSUM।
এরপর: ডেটাবেস ডিজাইন: কি, ER ডায়াগ্রাম আর নরমালাইজেশন পাতায় কোয়েরি থেকে এক ধাপ পিছিয়ে ডিজাইনের দিকে যাওয়া হবে: চাহিদা থেকে কীভাবে টেবিল বানাতে হয়, কি বেছে নিতে হয়, আর এলোমেলো একটা টেবিলকে নরমালাইজ করতে হয়, যাতে এ ধরনের কোয়েরি সঠিক থাকে।