SQL Nâng Cao: Window Functions, CTEs & Recursive Queries

Giới Thiệu
Ở DB-2: SQL Cơ Bản, bạn đã học SELECT, JOINs, GROUP BY và subqueries. Những công cụ đó xử lý được hầu hết các queries hàng ngày. Nhưng khi ai đó hỏi "xếp hạng customers theo chi tiêu trong từng region" hay "hiện running total revenue theo thời gian" hay "duyệt qua cây category" — SQL cơ bản bí rồi.
Đó là lúc SQL nâng cao lên sàn. Window functions cho phép bạn tính rankings, running totals và moving averages mà không collapse rows thành groups. CTEs biến những query phức tạp thành dạng dễ đọc, dễ ghép nối. Recursive CTEs duyệt dữ liệu phân cấp — org charts, category trees, comment threads — hoàn toàn bằng SQL.
Đây không phải feature hiếm khi dùng. Chúng là thứ phân biệt developer biết viết SQL với developer biết tư duy bằng SQL. Và chúng xuất hiện liên tục trong interviews, analytics dashboards và production reporting.
Yêu Cầu Trước: Bài này là một phần của Lộ Trình Học SQL & NoSQL. Hoàn thành DB-2: SQL Cơ Bản trước.
Những Gì Bạn Sẽ Học
✅ Viết CTEs để chia query phức tạp thành các bước dễ đọc
✅ Dùng window functions cho rankings, running totals và moving averages
✅ Hiểu PARTITION BY, ORDER BY và frame specifications
✅ Duyệt dữ liệu phân cấp với recursive CTEs
✅ Áp dụng CASE, COALESCE và conditional logic trong queries
✅ Sử dụng các hàm string, date và math cho biến đổi dữ liệu thực tế
✅ Xây dựng analytics dashboard queries từ đầu
Schema Mẫu (Mở Rộng)
Chúng ta sẽ tái sử dụng e-commerce schema từ DB-2 và thêm vài tables cho analytics và hierarchy:
-- Tái sử dụng từ DB-2: customers, products, orders, order_items
-- Thêm bảng employees cho ví dụ hierarchy
CREATE TABLE employees (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
title VARCHAR(100) NOT NULL,
department VARCHAR(50) NOT NULL,
salary NUMERIC(10, 2) NOT NULL,
manager_id INT REFERENCES employees(id),
hired_at DATE NOT NULL
);
INSERT INTO employees (name, title, department, salary, manager_id, hired_at) VALUES
('Sarah Chen', 'CEO', 'Executive', 250000, NULL, '2018-01-15'),
('Mike Ross', 'VP Engineering', 'Engineering', 200000, 1, '2018-06-01'),
('Lisa Park', 'VP Sales', 'Sales', 190000, 1, '2019-03-10'),
('Tom Wilson', 'Senior Engineer', 'Engineering', 150000, 2, '2020-01-20'),
('Anna Kim', 'Senior Engineer', 'Engineering', 145000, 2, '2020-04-15'),
('James Liu', 'Engineer', 'Engineering', 120000, 4, '2021-07-01'),
('Emma Davis', 'Engineer', 'Engineering', 115000, 4, '2021-09-15'),
('Chris Lee', 'Sales Manager', 'Sales', 130000, 3, '2020-02-01'),
('Nina Patel', 'Sales Rep', 'Sales', 85000, 8, '2022-01-10'),
('Oscar Brown', 'Sales Rep', 'Sales', 82000, 8, '2022-06-15');
-- Thêm bảng categories cho recursive hierarchy
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
parent_id INT REFERENCES categories(id)
);
INSERT INTO categories (name, parent_id) VALUES
('All Products', NULL),
('Electronics', 1),
('Computers', 2),
('Laptops', 3),
('Desktops', 3),
('Phones', 2),
('Furniture', 1),
('Desks', 7),
('Chairs', 7);Thêm orders với dates trải rộng qua các tháng cho time-series analytics:
-- Thêm orders cho phân tích time-series (thêm vào orders hiện có)
INSERT INTO orders (customer_id, total, status, created_at) VALUES
(1, 299.00, 'completed', '2025-01-15'),
(2, 549.00, 'completed', '2025-01-22'),
(1, 1299.00, 'completed', '2025-02-03'),
(3, 199.00, 'completed', '2025-02-18'),
(2, 899.00, 'completed', '2025-03-05'),
(4, 459.00, 'pending', '2025-03-12'),
(1, 749.00, 'completed', '2025-03-20'),
(3, 1599.00, 'completed', '2025-04-01'),
(2, 329.00, 'completed', '2025-04-15'),
(4, 899.00, 'completed', '2025-04-22'),
(1, 199.00, 'completed', '2025-05-10'),
(3, 649.00, 'completed', '2025-05-18');Common Table Expressions (CTEs)
CTE là một tập kết quả tạm có tên, chỉ tồn tại trong phạm vi một query duy nhất. Coi nó như một view dùng xong là bỏ — bạn define bằng WITH, rồi reference bằng tên.
Cú Pháp CTE Cơ Bản
WITH cte_name AS (
SELECT ...
FROM ...
)
SELECT *
FROM cte_name;Ví dụ thực tế. Không có CTE, tìm customers chi tiêu trên trung bình cần subquery lồng nhau:
-- Phiên bản subquery (khó đọc khi complexity tăng)
SELECT c.name, customer_totals.total_spent
FROM customers c
JOIN (
SELECT customer_id, SUM(total) AS total_spent
FROM orders
WHERE status = 'completed'
GROUP BY customer_id
) AS customer_totals ON customer_totals.customer_id = c.id
WHERE customer_totals.total_spent > (
SELECT AVG(total_spent)
FROM (
SELECT SUM(total) AS total_spent
FROM orders
WHERE status = 'completed'
GROUP BY customer_id
) AS avg_calc
);Với CTE, cùng logic đó dễ đọc hơn nhiều:
WITH customer_spending AS (
SELECT customer_id, SUM(total) AS total_spent
FROM orders
WHERE status = 'completed'
GROUP BY customer_id
)
SELECT c.name, cs.total_spent
FROM customers c
JOIN customer_spending cs ON cs.customer_id = c.id
WHERE cs.total_spent > (SELECT AVG(total_spent) FROM customer_spending);CTE được define một lần và reference hai lần — không lặp logic, không nested subqueries.
Nhiều CTEs
Nối nhiều CTEs cách nhau bằng dấu phẩy. Mỗi CTE có thể reference các CTE đã define trước nó:
WITH monthly_revenue AS (
SELECT
DATE_TRUNC('month', created_at) AS month,
SUM(total) AS revenue
FROM orders
WHERE status = 'completed'
GROUP BY DATE_TRUNC('month', created_at)
),
revenue_with_growth AS (
SELECT
month,
revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_month_revenue,
revenue - LAG(revenue) OVER (ORDER BY month) AS growth
FROM monthly_revenue
)
SELECT
month,
revenue,
prev_month_revenue,
growth,
CASE
WHEN prev_month_revenue > 0
THEN ROUND(growth / prev_month_revenue * 100, 1)
ELSE NULL
END AS growth_pct
FROM revenue_with_growth
ORDER BY month;Pipeline này: raw orders → monthly totals → month-over-month growth. Mỗi bước là một đơn vị có tên, dễ đọc.
CTEs vs Subqueries vs Temp Tables
| Feature | CTE | Subquery | Temp Table |
|---|---|---|---|
| Scope | Một query | Một query | Toàn session |
| Tái sử dụng | Nhiều lần trong cùng query | Phải viết lại | Nhiều queries |
| Dễ đọc | Cao (có tên, top-down) | Thấp (lồng nhau) | Trung bình |
| Performance | Thường giống subquery | Giống CTE | Materialized (lưu trữ) |
| Recursion | Có | Không | Không |
| Indexable | Không | Không | Có |
Rule of thumb: Dùng CTEs cho readability và khi bạn reference cùng logic nhiều lần. Dùng temp tables khi cần indexes trên kết quả trung gian hoặc cần data qua nhiều queries.
Window Functions
Window functions thực hiện tính toán trên một tập rows liên quan đến row hiện tại — mà không collapse chúng thành một output row duy nhất như GROUP BY. Đó là điểm khác biệt then chốt.
GROUP BY vs Window Functions
-- GROUP BY: collapse rows — một row mỗi department
SELECT department, AVG(salary) AS avg_salary
FROM employees
GROUP BY department;| department | avg_salary |
|---|---|
| Executive | 250000 |
| Engineering | 146000 |
| Sales | 121750 |
-- Window function: giữ tất cả rows, thêm avg bên cạnh mỗi row
SELECT
name,
department,
salary,
AVG(salary) OVER (PARTITION BY department) AS dept_avg_salary
FROM employees;| name | department | salary | dept_avg_salary |
|---|---|---|---|
| Sarah Chen | Executive | 250000 | 250000 |
| Mike Ross | Engineering | 200000 | 146000 |
| Tom Wilson | Engineering | 150000 | 146000 |
| Anna Kim | Engineering | 145000 | 146000 |
| James Liu | Engineering | 120000 | 146000 |
| Emma Davis | Engineering | 115000 | 146000 |
| ... | ... | ... | ... |
Mọi row đều được giữ nguyên. Mỗi row nhìn thấy giá trị trung bình của department mình. Điều này không thể làm được chỉ với GROUP BY.
Cú Pháp Window Function
function_name(...) OVER (
[PARTITION BY column1, column2, ...]
[ORDER BY column3, column4, ...]
[frame_clause]
)- PARTITION BY: Chia rows thành groups (như GROUP BY, nhưng không collapse)
- ORDER BY: Xác định thứ tự trong mỗi partition
- Frame clause: Xác định rows nào so với row hiện tại được bao gồm trong phép tính
Ranking Functions
ROW_NUMBER()
Gán số thứ tự duy nhất tuần tự cho mỗi row trong một partition. Không có trùng — nếu hai giá trị bằng nhau, một cái sẽ nhận số cao hơn (thứ tự tùy ý trừ khi bạn chỉ định).
SELECT
name,
department,
salary,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rank_in_dept
FROM employees;| name | department | salary | rank_in_dept |
|---|---|---|---|
| Sarah Chen | Executive | 250000 | 1 |
| Mike Ross | Engineering | 200000 | 1 |
| Tom Wilson | Engineering | 150000 | 2 |
| Anna Kim | Engineering | 145000 | 3 |
| James Liu | Engineering | 120000 | 4 |
| Emma Davis | Engineering | 115000 | 5 |
| Lisa Park | Sales | 190000 | 1 |
| Chris Lee | Sales | 130000 | 2 |
| Nina Patel | Sales | 85000 | 3 |
| Oscar Brown | Sales | 82000 | 4 |
Pattern kinh điển — Top N per group:
-- Top 2 nhân viên lương cao nhất mỗi department
WITH ranked AS (
SELECT
name,
department,
salary,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rn
FROM employees
)
SELECT name, department, salary
FROM ranked
WHERE rn <= 2;Đây là một trong những câu hỏi SQL interview phổ biến nhất và cũng là một trong những pattern production thường gặp nhất.
RANK() và DENSE_RANK()
RANK() cho cùng số cho các giá trị bằng nhau nhưng bỏ gap. DENSE_RANK() cho cùng số nhưng không bỏ gap:
-- Tưởng tượng hai employees có cùng salary
SELECT
name,
salary,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num, -- 1, 2, 3, 4
RANK() OVER (ORDER BY salary DESC) AS rank, -- 1, 2, 2, 4 (có gap)
DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rank -- 1, 2, 2, 3 (không gap)
FROM employees;Khi nào dùng cái nào:
ROW_NUMBER(): Khi cần chính xác N rows (pagination, top-N, deduplication)RANK(): Khi ties quan trọng và cần standard competition ranking (1st, 2nd, 2nd, 4th)DENSE_RANK(): Khi ties quan trọng và muốn consecutive ranks (1st, 2nd, 2nd, 3rd)
LAG() và LEAD() — Truy Cập Rows Liền Kề
LAG() nhìn row trước. LEAD() nhìn row sau. Cả hai đều yêu cầu ORDER BY.
SELECT
DATE_TRUNC('month', created_at) AS month,
SUM(total) AS revenue,
LAG(SUM(total)) OVER (ORDER BY DATE_TRUNC('month', created_at)) AS prev_month,
LEAD(SUM(total)) OVER (ORDER BY DATE_TRUNC('month', created_at)) AS next_month
FROM orders
WHERE status = 'completed'
GROUP BY DATE_TRUNC('month', created_at)
ORDER BY month;| month | revenue | prev_month | next_month |
|---|---|---|---|
| 2025-01-01 | 848.00 | NULL | 1498.00 |
| 2025-02-01 | 1498.00 | 848.00 | 1648.00 |
| 2025-03-01 | 1648.00 | 1498.00 | 2827.00 |
| 2025-04-01 | 2827.00 | 1648.00 | 848.00 |
| 2025-05-01 | 848.00 | 2827.00 | NULL |
LAG và LEAD nhận optional offset (mặc định 1) và giá trị default:
-- Nhìn lùi 2 rows, mặc định 0 nếu không có row
LAG(revenue, 2, 0) OVER (ORDER BY month)Running Totals và Moving Averages
Dùng aggregate functions với OVER để tính cumulative:
-- Running total revenue
SELECT
DATE_TRUNC('month', created_at) AS month,
SUM(total) AS monthly_revenue,
SUM(SUM(total)) OVER (ORDER BY DATE_TRUNC('month', created_at)) AS running_total
FROM orders
WHERE status = 'completed'
GROUP BY DATE_TRUNC('month', created_at)
ORDER BY month;| month | monthly_revenue | running_total |
|---|---|---|
| 2025-01-01 | 848.00 | 848.00 |
| 2025-02-01 | 1498.00 | 2346.00 |
| 2025-03-01 | 1648.00 | 3994.00 |
| 2025-04-01 | 2827.00 | 6821.00 |
| 2025-05-01 | 848.00 | 7669.00 |
Lưu ý SUM(SUM(total)) — SUM bên trong là GROUP BY aggregate, SUM ... OVER bên ngoài là window function.
Moving average 3 tháng:
SELECT
month,
revenue,
AVG(revenue) OVER (
ORDER BY month
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS moving_avg_3m
FROM (
SELECT
DATE_TRUNC('month', created_at) AS month,
SUM(total) AS revenue
FROM orders
WHERE status = 'completed'
GROUP BY DATE_TRUNC('month', created_at)
) AS monthly;NTILE() — Chia Rows Thành Buckets
NTILE(n) chia rows thành n groups gần bằng nhau:
-- Chia employees thành 4 phân vị lương
SELECT
name,
salary,
NTILE(4) OVER (ORDER BY salary) AS salary_quartile
FROM employees;Hữu ích cho percentile analysis, phân nhóm A/B test, hay chia work thành batches.
FIRST_VALUE(), LAST_VALUE(), NTH_VALUE()
Truy cập rows cụ thể trong một window:
-- So sánh mỗi employee với người lương cao nhất trong department
SELECT
name,
department,
salary,
FIRST_VALUE(name) OVER (
PARTITION BY department ORDER BY salary DESC
) AS highest_paid_name,
FIRST_VALUE(salary) OVER (
PARTITION BY department ORDER BY salary DESC
) AS highest_salary,
salary - FIRST_VALUE(salary) OVER (
PARTITION BY department ORDER BY salary DESC
) AS diff_from_top
FROM employees;Cảnh báo về LAST_VALUE: Mặc định, window frame kết thúc ở row hiện tại, không phải row cuối của partition. Để lấy actual last value, bạn cần explicit frame:
LAST_VALUE(salary) OVER (
PARTITION BY department ORDER BY salary DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS lowest_salaryFrame Specifications
Frame clause kiểm soát window function "nhìn thấy" rows nào so với row hiện tại.
ROWS BETWEEN <start> AND <end>Trong đó <start> và <end> có thể là:
UNBOUNDED PRECEDING— từ row đầu tiên của partitionN PRECEDING— N rows trước row hiện tạiCURRENT ROW— row hiện tạiN FOLLOWING— N rows sau row hiện tạiUNBOUNDED FOLLOWING— đến row cuối cùng của partition
Default frames:
- Có
ORDER BY:RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW(running total) - Không có
ORDER BY:RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING(toàn bộ partition)
ROWS vs RANGE vs GROUPS:
ROWS: Physical rows. Đếm chính xác.RANGE: Logical range dựa trên giá trị ORDER BY. Rows có cùng giá trị được coi là peers.GROUPS: Nhóm các peer rows (PostgreSQL 11+).1 PRECEDINGcó nghĩa là nhóm giá trị trùng trước đó.
-- Running total (default frame với ORDER BY)
SUM(revenue) OVER (ORDER BY month)
-- Moving average 3 rows
AVG(revenue) OVER (ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)
-- Toàn bộ partition (explicit — hữu ích cho tính tỷ lệ)
SUM(salary) OVER (PARTITION BY department) AS dept_total
-- Centered 5-row window
AVG(value) OVER (ORDER BY ts ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING)Percentage of Total — Pattern Kinh Điển
-- Lương mỗi employee như phần trăm của tổng department
SELECT
name,
department,
salary,
SUM(salary) OVER (PARTITION BY department) AS dept_total,
ROUND(salary * 100.0 / SUM(salary) OVER (PARTITION BY department), 1) AS pct_of_dept
FROM employees
ORDER BY department, salary DESC;Recursive CTEs
Recursive CTEs cho phép SQL duyệt dữ liệu phân cấp và cấu trúc đồ thị. Chúng gồm hai phần nối bằng UNION ALL:
- Anchor member: Điểm bắt đầu (base case)
- Recursive member: Tham chiếu lại chính CTE (recursive step)
Cú Pháp
WITH RECURSIVE cte_name AS (
-- Anchor: base case
SELECT ...
WHERE <điều kiện bắt đầu>
UNION ALL
-- Recursive: tham chiếu cte_name
SELECT ...
FROM cte_name
JOIN other_table ON ...
)
SELECT * FROM cte_name;Ví Dụ: Duyệt Org Chart
Tìm chuỗi báo cáo hoàn chỉnh cho mọi employee:
WITH RECURSIVE org_chart AS (
-- Anchor: bắt đầu từ CEO (không có manager)
SELECT
id,
name,
title,
manager_id,
0 AS depth,
name AS path
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Recursive: tìm direct reports của level hiện tại
SELECT
e.id,
e.name,
e.title,
e.manager_id,
oc.depth + 1,
oc.path || ' → ' || e.name
FROM employees e
INNER JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT depth, name, title, path
FROM org_chart
ORDER BY path;| depth | name | title | path |
|---|---|---|---|
| 0 | Sarah Chen | CEO | Sarah Chen |
| 1 | Lisa Park | VP Sales | Sarah Chen → Lisa Park |
| 2 | Chris Lee | Sales Manager | Sarah Chen → Lisa Park → Chris Lee |
| 3 | Nina Patel | Sales Rep | Sarah Chen → Lisa Park → Chris Lee → Nina Patel |
| 3 | Oscar Brown | Sales Rep | Sarah Chen → Lisa Park → Chris Lee → Oscar Brown |
| 1 | Mike Ross | VP Engineering | Sarah Chen → Mike Ross |
| 2 | Tom Wilson | Senior Engineer | Sarah Chen → Mike Ross → Tom Wilson |
| 3 | James Liu | Engineer | Sarah Chen → Mike Ross → Tom Wilson → James Liu |
| 3 | Emma Davis | Engineer | Sarah Chen → Mike Ross → Tom Wilson → Emma Davis |
| 2 | Anna Kim | Senior Engineer | Sarah Chen → Mike Ross → Anna Kim |
Ví Dụ: Cây Category
Liệt kê tất cả categories với đường dẫn đầy đủ:
WITH RECURSIVE category_tree AS (
-- Anchor: root categories (không có parent)
SELECT
id,
name,
parent_id,
0 AS depth,
name AS full_path
FROM categories
WHERE parent_id IS NULL
UNION ALL
SELECT
c.id,
c.name,
c.parent_id,
ct.depth + 1,
ct.full_path || ' > ' || c.name
FROM categories c
INNER JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT
REPEAT(' ', depth) || name AS indented_name,
full_path
FROM category_tree
ORDER BY full_path;| indented_name | full_path |
|---|---|
| All Products | All Products |
| Electronics | All Products > Electronics |
| Computers | All Products > Electronics > Computers |
| Desktops | All Products > Electronics > Computers > Desktops |
| Laptops | All Products > Electronics > Computers > Laptops |
| Phones | All Products > Electronics > Phones |
| Furniture | All Products > Furniture |
| Chairs | All Products > Furniture > Chairs |
| Desks | All Products > Furniture > Desks |
Tìm Tất Cả Con Cháu
"Cho tôi tất cả sub-categories dưới Electronics":
WITH RECURSIVE descendants AS (
SELECT id, name, parent_id
FROM categories
WHERE name = 'Electronics' -- điểm bắt đầu
UNION ALL
SELECT c.id, c.name, c.parent_id
FROM categories c
INNER JOIN descendants d ON c.parent_id = d.id
)
SELECT * FROM descendants;Phát Hiện Cycle
Nếu data có thể chứa cycles (A → B → C → A), recursive CTE sẽ chạy mãi không dừng. PostgreSQL 14+ có built-in cycle detection:
WITH RECURSIVE traversal AS (
SELECT id, name, manager_id, ARRAY[id] AS visited
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.manager_id, t.visited || e.id
FROM employees e
JOIN traversal t ON e.manager_id = t.id
WHERE e.id != ALL(t.visited) -- ngăn cycles
)
SELECT * FROM traversal;PostgreSQL 14+ cũng hỗ trợ CYCLE clause:
WITH RECURSIVE traversal AS (
...
) CYCLE id SET is_cycle USING pathCASE Expressions và Conditional Logic
CASE thêm if/else logic trực tiếp trong SQL:
-- Phân loại orders theo size
SELECT
id,
total,
CASE
WHEN total >= 1000 THEN 'Large'
WHEN total >= 500 THEN 'Medium'
WHEN total >= 100 THEN 'Small'
ELSE 'Micro'
END AS order_size
FROM orders;CASE trong Aggregation (Pivot Pattern)
-- Đếm orders theo status — pivot thành columns
SELECT
DATE_TRUNC('month', created_at) AS month,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'completed') AS completed,
COUNT(*) FILTER (WHERE status = 'pending') AS pending,
COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled
FROM orders
GROUP BY DATE_TRUNC('month', created_at)
ORDER BY month;Nếu database không hỗ trợ FILTER, dùng CASE:
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completedCOALESCE, NULLIF, GREATEST, LEAST
-- COALESCE: giá trị non-NULL đầu tiên
SELECT COALESCE(nickname, name, 'Anonymous') AS display_name FROM users;
-- NULLIF: trả về NULL nếu hai giá trị bằng nhau (tránh chia cho 0)
SELECT revenue / NULLIF(order_count, 0) AS avg_order_value FROM metrics;
-- GREATEST / LEAST: max/min qua các columns (không phải rows)
SELECT GREATEST(score_a, score_b, score_c) AS best_score FROM exams;
SELECT LEAST(price, sale_price, clearance_price) AS lowest_price FROM products;Các Hàm Thiết Yếu
String Functions
-- Nối chuỗi
SELECT CONCAT(first_name, ' ', last_name) AS full_name FROM users;
SELECT first_name || ' ' || last_name AS full_name FROM users; -- PostgreSQL
-- Chuyển đổi chữ hoa/thường
SELECT UPPER('hello'), LOWER('HELLO'), INITCAP('hello world');
-- HELLO, hello, Hello World
-- Substring và position
SELECT SUBSTRING('PostgreSQL' FROM 1 FOR 4); -- Post
SELECT POSITION('SQL' IN 'PostgreSQL'); -- 8
SELECT LENGTH('Hello'); -- 5
-- Trim
SELECT TRIM(' hello '); -- 'hello'
SELECT LTRIM(' hello'); -- 'hello'
SELECT RTRIM('hello '); -- 'hello'
SELECT TRIM(LEADING '0' FROM '00042'); -- '42'
-- Replace và split
SELECT REPLACE('2025-01-15', '-', '/'); -- 2025/01/15
SELECT SPLIT_PART('[email protected]', '@', 2); -- example.com
-- Pattern matching với REGEXP
SELECT 'abc123' ~ '^[a-z]+[0-9]+$'; -- true (PostgreSQL regex)
SELECT REGEXP_REPLACE('Phone: 123-456-7890', '[^0-9]', '', 'g'); -- 1234567890Date/Time Functions
-- Timestamp hiện tại
SELECT NOW(), CURRENT_DATE, CURRENT_TIME;
-- Truncate theo đơn vị thời gian
SELECT DATE_TRUNC('month', TIMESTAMP '2025-03-15 14:30:00'); -- 2025-03-01 00:00:00
SELECT DATE_TRUNC('week', TIMESTAMP '2025-03-15 14:30:00'); -- 2025-03-10 00:00:00
-- Trích xuất phần
SELECT EXTRACT(YEAR FROM TIMESTAMP '2025-03-15'); -- 2025
SELECT EXTRACT(MONTH FROM TIMESTAMP '2025-03-15'); -- 3
SELECT EXTRACT(DOW FROM TIMESTAMP '2025-03-15'); -- 6 (Thứ Bảy, 0=Chủ Nhật)
-- Tuổi và intervals
SELECT AGE(TIMESTAMP '2025-01-01', TIMESTAMP '2020-06-15'); -- 4 years 6 mons 16 days
SELECT NOW() - INTERVAL '30 days'; -- 30 ngày trước
SELECT NOW() + INTERVAL '1 hour'; -- 1 giờ nữa
-- Tạo chuỗi ngày (hữu ích để lấp gaps)
SELECT generate_series(
'2025-01-01'::date,
'2025-12-01'::date,
'1 month'::interval
) AS month;Pattern date arithmetic — lấp date gaps:
-- Revenue theo tháng, bao gồm cả tháng $0
WITH months AS (
SELECT generate_series(
'2025-01-01'::date,
'2025-12-01'::date,
'1 month'::interval
)::date AS month
)
SELECT
m.month,
COALESCE(SUM(o.total), 0) AS revenue
FROM months m
LEFT JOIN orders o
ON DATE_TRUNC('month', o.created_at)::date = m.month
AND o.status = 'completed'
GROUP BY m.month
ORDER BY m.month;Thực Hành: Analytics Dashboard Queries
Ghép tất cả lại với những queries bạn thực sự sẽ xây cho analytics dashboard thực tế.
1. Revenue Trend với Month-over-Month Growth
WITH monthly AS (
SELECT
DATE_TRUNC('month', created_at) AS month,
SUM(total) AS revenue,
COUNT(*) AS order_count
FROM orders
WHERE status = 'completed'
GROUP BY DATE_TRUNC('month', created_at)
)
SELECT
month,
revenue,
order_count,
LAG(revenue) OVER (ORDER BY month) AS prev_revenue,
ROUND(
(revenue - LAG(revenue) OVER (ORDER BY month))
/ NULLIF(LAG(revenue) OVER (ORDER BY month), 0) * 100,
1
) AS growth_pct,
SUM(revenue) OVER (ORDER BY month) AS ytd_revenue
FROM monthly
ORDER BY month;2. Customer Cohort Retention
Tháng nào mỗi customer đặt hàng lần đầu, và bao nhiêu quay lại các tháng tiếp theo?
WITH first_order AS (
SELECT
customer_id,
DATE_TRUNC('month', MIN(created_at)) AS cohort_month
FROM orders
GROUP BY customer_id
),
activity AS (
SELECT
f.cohort_month,
DATE_TRUNC('month', o.created_at) AS activity_month,
COUNT(DISTINCT o.customer_id) AS active_customers
FROM orders o
JOIN first_order f ON f.customer_id = o.customer_id
GROUP BY f.cohort_month, DATE_TRUNC('month', o.created_at)
)
SELECT
cohort_month,
activity_month,
EXTRACT(MONTH FROM AGE(activity_month, cohort_month)) AS months_since_first,
active_customers
FROM activity
ORDER BY cohort_month, activity_month;3. Phân Bố Lương Employee với Percentiles
SELECT
name,
department,
salary,
ROUND(AVG(salary) OVER (PARTITION BY department), 0) AS dept_avg,
ROUND(salary - AVG(salary) OVER (PARTITION BY department), 0) AS diff_from_avg,
NTILE(4) OVER (ORDER BY salary) AS company_quartile,
PERCENT_RANK() OVER (ORDER BY salary) AS percentile_rank,
DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dept_rank
FROM employees
ORDER BY department, salary DESC;4. Top Product Mỗi Customer
Sản phẩm nào mỗi customer mua nhiều nhất?
WITH customer_products AS (
SELECT
c.name AS customer,
p.name AS product,
SUM(oi.quantity) AS total_qty,
ROW_NUMBER() OVER (
PARTITION BY c.id
ORDER BY SUM(oi.quantity) DESC
) AS rn
FROM customers c
JOIN orders o ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
GROUP BY c.id, c.name, p.id, p.name
)
SELECT customer, product, total_qty
FROM customer_products
WHERE rn = 1;5. Leaderboard với Running Rankings
WITH daily_sales AS (
SELECT
DATE_TRUNC('day', o.created_at) AS day,
c.name AS customer,
SUM(o.total) AS daily_total
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'completed'
GROUP BY DATE_TRUNC('day', o.created_at), c.id, c.name
)
SELECT
day,
customer,
daily_total,
SUM(daily_total) OVER (PARTITION BY customer ORDER BY day) AS cumulative_total,
RANK() OVER (
PARTITION BY day
ORDER BY daily_total DESC
) AS daily_rank
FROM daily_sales
ORDER BY day, daily_rank;Cân Nhắc Performance
Window Functions
- Window functions chạy sau WHERE, GROUP BY và HAVING — không thể dùng trong WHERE clauses
- Để filter theo kết quả window function, bọc trong CTE hoặc subquery:
-- SAI: không thể dùng window function trong WHERE
SELECT *, ROW_NUMBER() OVER (...) AS rn
FROM employees
WHERE rn <= 5; -- ERROR
-- ĐÚNG: bọc trong CTE
WITH ranked AS (
SELECT *, ROW_NUMBER() OVER (...) AS rn
FROM employees
)
SELECT * FROM ranked WHERE rn <= 5;- Nhiều window functions với cùng
OVERclause được tính trong một lần duyệt. Dùng named window để giữ DRY:
SELECT
name,
salary,
ROW_NUMBER() OVER w AS row_num,
RANK() OVER w AS rank,
DENSE_RANK() OVER w AS dense_rank
FROM employees
WINDOW w AS (PARTITION BY department ORDER BY salary DESC);CTEs
- Từ PostgreSQL 12+, CTEs được inlined (tối ưu như subqueries) theo mặc định. Trước PostgreSQL 12, CTEs luôn được materialized (optimization fence)
- Ép materialization khi muốn ngăn optimizer re-execute CTE:
WITH expensive_calc AS MATERIALIZED (
SELECT ... -- tính toán phức tạp
)
SELECT * FROM expensive_calc;Recursive CTEs
- Luôn bao gồm điều kiện kết thúc để ngăn infinite loops
- Dùng
UNION ALL(không phảiUNION) trừ khi bạn cần deduplication cụ thể — nó nhanh hơn - Đặt safety limit:
WHERE depth < 100hoặcmax_recursive_iterationscủa PostgreSQL
Bài Tập
Dùng schema mẫu và giải các bài sau:
- Running total: Hiện mỗi order với running total revenue mỗi customer, sắp xếp theo ngày.
- Rank theo department: Xếp hạng tất cả employees theo salary trong department. Hiện top 2 mỗi department.
- Month-over-month: Tính phần trăm thay đổi revenue orders hoàn thành từ tháng này sang tháng kia.
- Cây category: Dùng bảng categories, hiện tất cả categories với depth level và full path.
- Chuỗi quản lý: Cho mỗi employee, hiện full management chain từ CEO xuống đến họ.
- Moving average: Tính moving average 3 tháng của order revenue.
- Percentile: Hiện salary percentile của mỗi employee trong công ty (0.0 đến 1.0).
- Phát hiện gaps: Dùng
generate_seriesvà LEFT JOIN, tìm các tháng trong 2025 không có completed orders.
Tổng Kết và Điểm Chính
Kết Quả Học Tập:
✅ CTEs chia query phức tạp thành các bước có tên, dễ đọc — define một lần, reference nhiều lần
✅ Window functions tính toán qua rows mà không collapse chúng — khác với GROUP BY
✅ ROW_NUMBER, RANK, DENSE_RANK xử lý rankings; LAG/LEAD truy cập rows liền kề
✅ Frame specifications (ROWS BETWEEN) kiểm soát chính xác window nhìn thấy rows nào
✅ Recursive CTEs duyệt dữ liệu phân cấp: org charts, category trees, graph structures
✅ CASE, COALESCE, NULLIF thêm conditional logic trực tiếp trong queries
✅ Date functions (DATE_TRUNC, EXTRACT, generate_series) hỗ trợ time-series analytics
Tiếp Theo
DB-4: NoSQL Cơ Bản — Document, Key-Value, Column & Graph →
Bây giờ bạn đã master SQL từ cơ bản đến analytics nâng cao, đã đến lúc khám phá phía bên kia: NoSQL databases. Bạn sẽ học khi nào documents, key-value stores, column families và graphs thực sự vượt trội so với relational databases — và khi nào thì không.
Series: Lộ Trình Học SQL & NoSQL
Trước: DB-2: SQL Cơ Bản — SELECT, JOIN & Subqueries
Sau: DB-4: NoSQL Cơ Bản — Document, Key-Value, Column & Graph
📬 Đăng ký nhận bản tin
Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.
Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.
💬 Comments
Đăng nhập để bình luận
Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.