Mini-challenge M03 — top closer tháng + weekly completion
Combine M03 concepts: window DENSE_RANK + date_trunc + FILTER aggregate + UNION ALL. 2 query + 1 combined output cho TaskFlow analytics dashboard.
PM vừa gửi yêu cầu: TaskFlow analytics dashboard cần 2 metric mới trước cuộc họp board sáng mai.
Metric 1 — Top 3 user closer mỗi tháng: rank, số task hoàn thành, thời gian trung bình hoàn thành (ngày). Dùng để theo dõi productivity và phân bổ workload hợp lý.
Metric 2 — Completion rate theo ISO week: tổng task tạo ra trong tuần, số task đã done, tỉ lệ phần trăm. Dùng để phát hiện tuần nào backlog tắc nghẽn.
Hai query này chạm toàn bộ kiến thức Module 3 của khoá này: window function với DENSE_RANK, date_trunc, FILTER aggregate, và JOIN. Bài này build từng bước, bắt lỗi phổ biến, rồi combine hai query vào một endpoint duy nhất.
1. Spec chi tiết
| Field | Query 1 — top 3 monthly | Query 2 — weekly completion |
|---|---|---|
| Group | năm + tháng | ISO week |
| Output | user_id, name, done_count, rank, avg_complete_days | week, total, done, completion_pct |
| Sort | month DESC, rank ASC | week DESC |
| Filter | 6 tháng gần nhất | 12 tuần gần nhất |
| Kết hợp | UNION ALL với label column | (combined output cho 1 endpoint) |
flowchart TD A["tasks\n(status='done', 6 thang gan nhat)"] --> B["GROUP BY month + assignee_id\n(COUNT done, AVG days)"] B --> C["DENSE_RANK OVER\n(PARTITION BY month\nORDER BY done_count DESC)"] C --> D["Filter rank <= 3\n(top 3 per month)"] D --> E["JOIN users\n(lay ten assignee)"] E --> R["Result: month, rank, name,\ndone_count, avg_complete_days"]
2. Step 1 — Build Query 1 (top 3 closer mỗi tháng)
2.1 Stage 1 — aggregate per user per month
-- Stage 1: aggregate per user per month
SELECT
date_trunc('month', updated_at) AS month,
assignee_id,
COUNT(*) AS done_count,
AVG(EXTRACT(DAY FROM (updated_at - created_at))) AS avg_days
FROM tasks
WHERE status = 'done'
AND updated_at >= CURRENT_TIMESTAMP - INTERVAL '6' MONTH
-- date arithmetic dialect: PG: now()-INTERVAL '6 months'; MySQL: DATE_SUB(NOW(),INTERVAL 6 MONTH)
AND assignee_id IS NOT NULL
GROUP BY 1, 2;
date_trunc('month', updated_at) là cú pháp PostgreSQL — cắt timestamp về đầu tháng. MySQL tương đương: DATE_FORMAT(updated_at,'%Y-%m-01'). SQL Server: DATEADD(month, DATEDIFF(month,0,updated_at), 0). 2026-05-14 09:30 trở thành 2026-05-01 00:00. EXTRACT(DAY FROM interval) lấy phần ngày của khoảng thời gian hoàn thành. — 2026-05-14 09:30 trở thành 2026-05-01 00:00. Toàn bộ task trong tháng 5 gom về cùng một bucket. EXTRACT(DAY FROM interval) lấy phần ngày của khoảng thời gian hoàn thành.
2.2 Stage 2 — thêm DENSE_RANK
-- Stage 2: wrap CTE, add rank
WITH monthly AS (
SELECT
date_trunc('month', updated_at) AS month,
assignee_id,
COUNT(*) AS done_count,
AVG(EXTRACT(DAY FROM (updated_at - created_at))) AS avg_days
FROM tasks
WHERE status = 'done'
AND updated_at >= CURRENT_TIMESTAMP - INTERVAL '6' MONTH
AND assignee_id IS NOT NULL
GROUP BY 1, 2
)
SELECT
month,
assignee_id,
done_count,
ROUND(CAST(avg_days AS NUMERIC), 1) AS avg_complete_days,
-- PG shorthand: avg_days::numeric -- MySQL/SQL Server: CAST(avg_days AS DECIMAL(10,2))
DENSE_RANK() OVER (PARTITION BY month ORDER BY done_count DESC) AS rank
FROM monthly;
DENSE_RANK thay vì RANK: khi hai user cùng số task done, cả hai nhận rank 2 và user tiếp theo nhận rank 3 (không có gap). RANK tạo gap — rank 4 xuất hiện sau hai người rank 2. Với leaderboard "top 3", DENSE_RANK cho kết quả trực quan hơn.
2.3 Stage 3 — filter rank nhỏ hơn hoặc bằng 3 + JOIN users
-- Stage 3: filter top 3 + JOIN users
WITH monthly AS (
SELECT
date_trunc('month', updated_at) AS month,
assignee_id,
COUNT(*) AS done_count,
AVG(EXTRACT(DAY FROM (updated_at - created_at))) AS avg_days
FROM tasks
WHERE status = 'done'
AND updated_at >= CURRENT_TIMESTAMP - INTERVAL '6' MONTH
AND assignee_id IS NOT NULL
GROUP BY 1, 2
),
ranked AS (
SELECT
month,
assignee_id,
done_count,
ROUND(CAST(avg_days AS NUMERIC), 1) AS avg_complete_days,
DENSE_RANK() OVER (PARTITION BY month ORDER BY done_count DESC) AS rank
FROM monthly
)
SELECT
r.month,
r.rank,
u.name,
r.done_count,
r.avg_complete_days
FROM ranked r
INNER JOIN users u ON r.assignee_id = u.id
WHERE r.rank <= 3
ORDER BY r.month DESC, r.rank;
Window function nằm trong SELECT (bước 5 của logical order). WHERE chạy ở bước 2 — trước khi window được tính. Viết WHERE rank <= 3 ngay sau DENSE_RANK() OVER (...) AS rank trong cùng một query sẽ báo lỗi column "rank" does not exist. Wrap CTE (như Stage 3 ở trên) hoặc subquery là bắt buộc — xem Module 3 bài 6 của khoá này.
3. Step 2 — Build Query 2 (weekly completion rate)
-- Per ISO week: total task vs done vs ratio
SELECT
date_trunc('week', created_at) AS week,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'done') AS done,
ROUND(
COUNT(*) FILTER (WHERE status = 'done') * 100.0
/ NULLIF(COUNT(*), 0),
2
) AS completion_pct
FROM tasks
WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '12' WEEK
-- PG: now() - INTERVAL '12 weeks'; MySQL: DATE_SUB(NOW(), INTERVAL 12 WEEK)
GROUP BY 1
ORDER BY week DESC;
Hai điểm quan trọng trong công thức completion_pct:
Integer division: COUNT(*) FILTER (...) / COUNT(*) chia số nguyên cho số nguyên — kết quả cũng là số nguyên, mất phần thập phân. 45 / 100 = 0 thay vì 0.45. Fix bằng cách nhân 100.0 (ép float) trước khi chia.
Divide-by-zero: NULLIF(COUNT(*), 0) trả về NULL khi COUNT(*) = 0, tránh lỗi division by zero. PostgreSQL trả về NULL thay vì crash khi chia cho NULL. Trường hợp nhóm rỗng hiếm xảy ra trong GROUP BY nhưng cách xử lý defensive này nên trở thành thói quen.
4. Step 3 — Combine 2 query qua UNION ALL
Schema của hai query khác nhau (Query 1 có 5 cột, Query 2 có 4 cột) — không thể UNION ALL trực tiếp. Cần cast về common schema.
Option A — 2 endpoint riêng (recommended cho production): schema rõ ràng, frontend nhận đúng kiểu dữ liệu, dễ cache và đánh index riêng.
Option B — 1 endpoint với label column (single combined output):
-- Cast 2 query ve common schema (metric_type, period, rank, subject, metric_value, extra)
SELECT
'monthly_top' AS metric_type,
to_char(month, 'YYYY-MM') AS period,
-- to_char: PG/Oracle. MySQL: DATE_FORMAT(month,'%Y-%m'). SQL Server: FORMAT(month,'yyyy-MM')
CAST(rank AS INT) AS rank,
name AS subject,
done_count AS metric_value,
CAST(avg_complete_days AS CHAR) AS extra
-- PG shorthand: rank::int, avg_complete_days::text
FROM (
-- ...Stage 3 ranked top 3 query...
WITH monthly AS (
SELECT
date_trunc('month', updated_at) AS month,
assignee_id,
COUNT(*) AS done_count,
AVG(EXTRACT(DAY FROM (updated_at - created_at))) AS avg_days
FROM tasks
WHERE status = 'done'
AND updated_at >= CURRENT_TIMESTAMP - INTERVAL '6' MONTH
AND assignee_id IS NOT NULL
GROUP BY 1, 2
),
ranked AS (
SELECT
month, assignee_id, done_count,
ROUND(CAST(avg_days AS NUMERIC), 1) AS avg_complete_days,
DENSE_RANK() OVER (PARTITION BY month ORDER BY done_count DESC) AS rank
FROM monthly
)
SELECT r.month, r.rank, u.name, r.done_count, r.avg_complete_days
FROM ranked r
INNER JOIN users u ON r.assignee_id = u.id
WHERE r.rank <= 3
) q1
UNION ALL
SELECT
'weekly_completion' AS metric_type,
to_char(week, 'YYYY-WW') AS period,
CAST(NULL AS INT) AS rank,
NULL AS subject,
total AS metric_value,
CAST(completion_pct AS CHAR) AS extra
FROM (
SELECT
date_trunc('week', created_at) AS week,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'done') AS done,
ROUND(
COUNT(*) FILTER (WHERE status = 'done') * 100.0
/ NULLIF(COUNT(*), 0),
2
) AS completion_pct
FROM tasks
WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '12' WEEK
-- PG: now() - INTERVAL '12 weeks'; MySQL: DATE_SUB(NOW(), INTERVAL 12 WEEK)
GROUP BY 1
) q2
ORDER BY metric_type, period DESC, rank;
Schema chung (metric_type, period, rank, subject, metric_value, extra) cho phép frontend parse bằng metric_type. Trường rank và subject là NULL với weekly rows — frontend cần xử lý nullable.
5. Step 4 — Benchmark query
Seed dữ liệu thử: cách chuẩn ANSI là dùng một loop hoặc script app-layer để INSERT nhiều row. PostgreSQL cung cấp hàm generate_series(1, 100000) riêng — hàm này không có trong ANSI SQL và không tồn tại trên MySQL/SQL Server. Dưới đây là ví dụ PostgreSQL để tham khảo kỹ thuật:
-- Seed 100k task voi PostgreSQL generate_series (PG-specific)
-- MySQL: dung stored procedure loop; SQL Server: WITH RECURSIVE CTE
INSERT INTO tasks (project_id, assignee_id, title, status, created_at, updated_at)
SELECT
CAST(random() * 5 + 1 AS INT), -- PG shorthand: (...)::int
CAST(random() * 10 + 1 AS INT),
'Task ' || gs,
CASE WHEN random() < 0.4 THEN 'done' ELSE 'todo' END,
CURRENT_TIMESTAMP - CAST(random() * 180 AS INT) * INTERVAL '1' DAY,
CURRENT_TIMESTAMP - CAST(random() * 30 AS INT) * INTERVAL '1' DAY
FROM generate_series(1, 100000) AS gs;
Để benchmark query sau khi seed, đo thời gian thực thi bằng client tool của database:
-- Benchmark Query 1 (chay roi do thoi gian o client tool)
-- PostgreSQL: bat \timing truoc khi chay, hoac dung EXPLAIN ANALYZE
-- MySQL: xem SHOW PROFILES hoac dung benchmark tool
-- SQL Server: dung SET STATISTICS TIME ON
WITH monthly AS (
SELECT
date_trunc('month', updated_at) AS month,
-- date_trunc: PG/Oracle. MySQL: DATE_FORMAT; SQL Server: DATEADD/DATEDIFF
assignee_id,
COUNT(*) AS done_count,
AVG(EXTRACT(DAY FROM (updated_at - created_at))) AS avg_days
FROM tasks
WHERE status = 'done'
AND updated_at >= CURRENT_TIMESTAMP - INTERVAL '6' MONTH
AND assignee_id IS NOT NULL
GROUP BY 1, 2
),
ranked AS (
SELECT month, assignee_id, done_count,
ROUND(CAST(avg_days AS NUMERIC), 1) AS avg_complete_days,
DENSE_RANK() OVER (PARTITION BY month ORDER BY done_count DESC) AS rank
FROM monthly
)
SELECT r.month, r.rank, u.name, r.done_count, r.avg_complete_days
FROM ranked r
INNER JOIN users u ON r.assignee_id = u.id
WHERE r.rank <= 3
ORDER BY r.month DESC, r.rank;
-- Expected plan: sequential scan -> hash aggregate -> window -> hash join -> filter
-- Estimated: ~150ms tren 100k task; composite index (status, updated_at) giam scan cost
Query optimizer của hầu hết database sẽ chọn plan: scan table với filter status = 'done' → hash aggregate (GROUP BY) → window function (DENSE_RANK) → join với users → filter rank. Module 6 của khoá này đi sâu index strategy — composite index (status, updated_at) giảm cost scan đáng kể.
6. Pitfall tổng hợp
-- BUG 1: WHERE rank cung level voi window function
SELECT
assignee_id,
DENSE_RANK() OVER (PARTITION BY month ORDER BY done_count DESC) AS rank
FROM monthly
WHERE rank <= 3;
-- ERROR: column "rank" does not exist
-- Fix: wrap CTE hoac subquery nhu Stage 3 o tren
-- BUG 2: integer division mat decimal
COUNT(*) FILTER (WHERE status = 'done') / COUNT(*)
-- int / int = int: 45 / 100 = 0, khong phai 0.45
-- Fix A: nhan 100.0 truoc khi chia
COUNT(*) FILTER (WHERE status = 'done') * 100.0 / NULLIF(COUNT(*), 0)
-- Fix B: cast explicit
CAST(COUNT(*) FILTER (WHERE status = 'done') AS NUMERIC) * 100 / NULLIF(COUNT(*), 0)
-- PG shorthand: ...::numeric * 100 / ...
-- BUG 3: date_trunc khong nhat quan timezone
date_trunc('week', created_at)
-- Truncate theo session timezone -- khac nhau giua servers
-- Fix: explicit AT TIME ZONE truoc khi truncate
date_trunc('week', created_at AT TIME ZONE 'UTC')
-- BUG 4: avg_days NULL khi khong co row done
AVG(EXTRACT(DAY FROM (updated_at - created_at)))
-- Neu khong co row nao match WHERE status = 'done', AVG tra ve NULL
-- Fix defensive:
COALESCE(AVG(EXTRACT(DAY FROM (updated_at - created_at))), 0)
date_trunc('week', created_at) là cú pháp PostgreSQL — cắt theo timezone của session hiện tại. Trên hệ thống multi-region với server ở timezone khác nhau, cùng một timestamp sẽ rơi vào các tuần khác nhau. Fix bắt buộc trong production: convert về UTC trước — date_trunc('week', created_at AT TIME ZONE 'UTC'). Hoặc lưu created_at dưới dạng TIMESTAMP WITH TIME ZONE (ANSI SQL: TIMESTAMP WITH TIME ZONE; PostgreSQL alias: timestamptz) và đảm bảo database server dùng UTC làm default timezone.
7. Deliverable
Tạo 3 file SQL:
query_1_monthly_top.sql — full Query 1 (Stage 3): top 3 closer theo tháng, 6 tháng gần nhất, JOIN users.
query_2_weekly_completion.sql — full Query 2: weekly completion rate, 12 tuần gần nhất, FILTER aggregate + NULLIF.
combined.sql — UNION ALL combined output với label column metric_type.
Optional: 1 file TypeScript implement node-postgres cho 2 endpoint riêng (Option A) — GET /api/analytics/monthly-top và GET /api/analytics/weekly-completion. Option A cho schema rõ ràng, dễ cache, dễ test riêng từng query.
Kèm 1 file notes.md ngắn ghi lại: bug nào đã phát hiện và fix, window pattern nào sử dụng (DENSE_RANK thay vì RANK), lý do chọn Option A hay B cho endpoint.
8. Module tiếp theo
| Topic | Bài học |
|---|---|
Index strategy cho query này — composite (status, updated_at) | Module 6 của khoá này |
| Query execution plan nâng cao — đọc plan node, cost estimate | Module 8 của khoá này |
| Schema design và data types | Module 5 của khoá này |
| Advanced SQL (CTE, subquery, materialized view) | Module 4 của khoá này |
9. Tóm tắt
DENSE_RANKvàWHERE rank <= 3cần CTE wrap —WHEREchạy trướcSELECTnên không thấy window column (Module 3 bài 6 của khoá này).- Truncate timestamp về tháng/tuần: PostgreSQL dùng
date_trunc— MySQL dùngDATE_FORMAT, SQL Server dùngDATEADD/DATEDIFF. Nguyên tắc agnostic: tìm hàm tương đương của engine đang dùng. - Integer division trong ratio mất phần thập phân — nhân
100.0hoặc dùngCAST(... AS NUMERIC)trước khi chia;NULLIF(COUNT(*), 0)ngăn divide-by-zero. UNION ALLcombine hai query schema khác nhau cần cast về common schema với label column.- Production nên dùng 2 endpoint riêng (Option A) thay vì 1 combined endpoint — schema rõ ràng hơn, dễ cache và test hơn.
- Timezone nhất quán: convert về UTC trước khi truncate timestamp —
created_at AT TIME ZONE 'UTC'(ANSI SQL:AT TIME ZONEchuẩn); lưu timestamp dạngTIMESTAMP WITH TIME ZONEđể data layer nhất quán.
10. Tự kiểm tra
Q1Vì sao WHERE rank <= 3 ở cùng level với DENSE_RANK báo lỗi? Cách fix nào cleaner hơn — CTE hay subquery — và khi nào chọn cách nào?▸
Nguyên nhân: SQL logical processing order là FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. WHERE chạy ở bước 2, trước SELECT (bước 5) — tại thời điểm đó column rank chưa được tính, chưa tồn tại trong scope. PostgreSQL báo column "rank" does not exist.
CTE vs subquery: Về execution plan trên PostgreSQL, CTE và subquery thường tương đương — optimizer inline CTE trong hầu hết trường hợp. CTE dễ đọc hơn khi query phức tạp, có nhiều stage, hoặc cần tái dùng kết quả nhiều lần trong cùng query. Subquery gọn hơn cho trường hợp đơn giản, 1 lần dùng.
Với bài toán "top 3 closer" có 2 stage (aggregate + rank), CTE rõ ràng hơn: mỗi CTE là một bước logic độc lập, dễ debug từng stage riêng bằng cách SELECT * FROM monthly hay SELECT * FROM ranked.
Q2Tại sao COUNT(*) FILTER (WHERE status = 'done') / COUNT(*) trả về 0 thay vì tỉ lệ thực? Nêu 2 cách fix với rationale.▸
COUNT(*) FILTER (WHERE status = 'done') / COUNT(*) trả về 0 thay vì tỉ lệ thực? Nêu 2 cách fix với rationale.Nguyên nhân: Cả hai toán hạng đều là kiểu bigint (integer). PostgreSQL thực hiện integer division: 45 / 100 = 0, mất phần thập phân. Đây là hành vi chuẩn của mọi ngôn ngữ lập trình, không phải bug riêng của SQL.
Fix A — nhân 100.0:
COUNT(*) FILTER (WHERE status = 'done') * 100.0 / NULLIF(COUNT(*), 0)Nhân với float literal 100.0 ép PostgreSQL nâng kiểu toán hạng lên numeric trước khi chia. Trực quan, ít ký tự nhất.
Fix B — cast explicit:
CAST(COUNT(*) FILTER (WHERE status = 'done') AS NUMERIC) * 100 / NULLIF(COUNT(*), 0)CAST(... AS NUMERIC) rõ ràng hơn về intent — "tôi muốn numeric division". Dùng khi code review yêu cầu explicit over implicit.
NULLIF(COUNT(*), 0) cần thiết trong cả hai cách — trả về NULL khi mẫu bằng 0, tránh division by zero error. PostgreSQL trả về NULL khi chia cho NULL, query không crash.
Q3date_trunc('week', created_at) không nhất quán timezone trên multi-region production — vấn đề cụ thể là gì và cách fix?▸
Vấn đề: date_trunc cắt timestamp theo timezone của session PostgreSQL. Timestamp 2026-05-04 01:00:00+07 (Việt Nam) tương đương 2026-05-03 18:00:00 UTC. Nếu server A dùng UTC+7 và server B dùng UTC, cùng timestamp đó sẽ rơi vào tuần khác nhau — server A cắt vào tuần bắt đầu 2026-05-04, server B cắt vào tuần bắt đầu 2026-04-27.
Fix 1 — AT TIME ZONE trước khi truncate:
date_trunc('week', created_at AT TIME ZONE 'UTC')Convert về UTC trước, rồi mới cắt. Kết quả nhất quán bất kể session timezone. Đây là cách phổ biến nhất.
Fix 2 — PostgreSQL config: Đặt timezone = 'UTC' trong postgresql.conf hoặc SET timezone = 'UTC' đầu session. Toàn bộ date_trunc trong session đó tự dùng UTC.
Fix 3 — timestamptz: Lưu created_at dưới dạng TIMESTAMP WITH TIME ZONE. PostgreSQL tự normalize về UTC khi lưu — date_trunc vẫn cần explicit timezone nhưng data layer nhất quán hơn.
Q4Combined endpoint UNION ALL vs 2 endpoint riêng — production tradeoff về performance, code complexity, và frontend?▸
UNION ALL (1 endpoint):
- Ưu: 1 round-trip, ít API surface, frontend gọi 1 lần.
- Nhược: Schema heterogeneous —
rankvàsubjectlàNULLvới weekly rows, frontend phải parse theometric_type. Khó cache riêng từng metric (monthly top cache 1 giờ, weekly cache 15 phút — không thể phân tách). TypeScript type phức tạp hơn. Khó test từng query độc lập.
2 endpoint riêng (Option A):
- Ưu: Schema rõ ràng, TypeScript type đơn giản cho mỗi endpoint. Cache riêng với TTL khác nhau. Test đơn giản — mock từng query. Scale độc lập nếu 1 query chậm hơn.
- Nhược: 2 round-trip từ frontend (giải quyết bằng
Promise.all). Nhiều API route hơn.
Kết luận: Production nên dùng Option A. Overhead 2 round-trip gần như không đáng kể với Promise.all. Lợi ích cache riêng và schema rõ ràng quan trọng hơn ở hệ thống production.
Liên hệ: Nếu cần ôn lại window pattern đã dùng trong bài này, xem bài 07 — Window rank/lag/running total.
Bài tiếp theo: Module 4 — SQL Nâng cao: Subquery, CTE, Lateral
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên