CASE + COALESCE + NULLIF — switch/fallback/safe-divide trong SQL
Conditional expression không có IF. CASE cho switch, COALESCE cho fallback, NULLIF tránh divide-by-zero. Pattern bucketize, status icon, defensive arithmetic.
Dashboard TaskFlow cần hiển thị assignee_name cho mỗi task — fallback "Unassigned" nếu NULL. Trong app code bạn viết if (!assignee) return "Unassigned"; rồi trả về client. Nhưng khi cần logic này trong một JOIN phức tạp với 10 điều kiện GROUP BY, round-trip server chỉ để xử lý fallback là lãng phí.
Trong SQL không có IF statement như ngôn ngữ lập trình thông thường (chỉ có trong PL/pgSQL function). Thay vào đó, SQL cung cấp ba expression cho conditional logic ngay tại query layer: CASE, COALESCE, và NULLIF. Bài này map cách dùng và pattern thực chiến của cả ba.
1. Analogy — Switch/match expression của ngôn ngữ lập trình
Ba expression này không phải SQL đặc thù — chúng tương đương trực tiếp với construct quen thuộc trong code.
| App code | SQL | Vai trò |
|---|---|---|
switch/match expression | CASE ... WHEN ... THEN ... | Phân nhánh theo điều kiện |
?? (null-coalescing operator) | COALESCE(a, b, c) | Fallback chain, lấy giá trị non-NULL đầu tiên |
| "trả null nếu bằng" | NULLIF(a, b) | Trả NULL khi a = b, dùng để safe-divide |
a / b với b != 0 guard | a / NULLIF(b, 0) | Tránh divide-by-zero — trả NULL thay vì error |
Hàm identity | COALESCE(x, x) | Không dùng — nếu x non-NULL thì COALESCE là no-op |
CASE = switch expression. COALESCE = ?? operator. NULLIF = "poison pill" — biến giá trị cụ thể thành NULL để chain với COALESCE hoặc tránh lỗi arithmetic.
2. CASE — switch của SQL
CASE có hai dạng: searched form (dùng predicate tùy ý) và simple form (so sánh equality với một column).
-- Searched form: linh hoat, dung predicate bat ky
SELECT
id,
title,
CASE
WHEN status = 'todo' THEN '⏸ Pending'
WHEN status = 'doing' THEN '▶ In progress'
WHEN status = 'done' THEN '✓ Done'
ELSE '? Unknown'
END AS status_label
FROM tasks;
-- Simple form: so sanh equality voi mot column, ngan hon
SELECT
id,
CASE status
WHEN 'todo' THEN 1
WHEN 'doing' THEN 2
WHEN 'done' THEN 3
END AS status_order
FROM tasks
ORDER BY status_order;
Searched form mạnh hơn vì branch có thể dùng bất kỳ predicate nào (<, BETWEEN, IS NULL, subquery). Simple form chỉ so sánh equality với một column nhưng ngắn hơn khi đó là đủ.
PostgreSQL có type inference nghiêm ngặt: tất cả các branch của CASE phải trả về cùng type. Nếu mix type thì cần cast tường minh:
-- ERROR: integer vs text, PG khong tu convert
-- CASE WHEN id > 5 THEN id ELSE 'small' END
-- Fix: cast ve cung type truoc khi compare
SELECT
CASE WHEN id > 5 THEN id::text ELSE 'small' END AS size_label
FROM tasks;
CASE không có ELSE thì trả NULL khi không match branch nào — tương đương ELSE NULL.
3. COALESCE — fallback chain
COALESCE(a, b, c, ...) trả về giá trị non-NULL đầu tiên trong danh sách. Nếu tất cả NULL thì trả NULL.
-- Fallback chain: nickname -> name -> email -> 'Anonymous'
SELECT COALESCE(nickname, name, email, 'Anonymous') AS display_name
FROM users;
-- Neu nickname NULL -> thu name; name NULL -> thu email; email NULL -> 'Anonymous'
-- Trong UPDATE: giu gia tri cu neu input NULL ($1)
UPDATE tasks
SET assignee_id = COALESCE($1, assignee_id)
WHERE id = $2;
-- Neu $1 IS NULL -> COALESCE tra assignee_id (giu nguyen)
-- Neu $1 co gia tri -> COALESCE tra $1 (cap nhat moi)
-- Trong WHERE: treat NULL nhu gia tri mac dinh de so sanh
SELECT * FROM tasks
WHERE COALESCE(priority, 0) < 3;
-- Row co priority NULL duoc xem la priority = 0, van xuat hien
-- Khac voi: WHERE priority < 3 (bo qua NULL row vi NULL < 3 = UNKNOWN)
Cross-vendor note: MySQL/SQLite có IFNULL(a, b) (chỉ 2 args); MSSQL có ISNULL(a, b) (chỉ 2 args). COALESCE là ANSI SQL standard, portable nhất và hỗ trợ n args.
4. NULLIF — safe-divide pattern
NULLIF(a, b) trả NULL khi a = b, ngược lại trả a. Đây là pattern chính để tránh divide-by-zero:
SELECT NULLIF(0, 0); -- NULL
SELECT NULLIF(5, 0); -- 5
SELECT NULLIF('', ''); -- NULL (chuyen empty string thanh NULL)
Khi visitors = 0, phép chia revenue / visitors ném error. NULLIF biến 0 thành NULL trước khi chia:
SELECT
campaign_id,
revenue,
visitors,
revenue / NULLIF(visitors, 0) AS revenue_per_visitor
FROM campaigns;
-- visitors = 0 -> NULLIF tra NULL -> NULL / anything = NULL (khong error)
-- visitors > 0 -> NULLIF tra visitors -> phep chia binh thuong
Kết hợp COALESCE để thay NULL bằng giá trị mặc định khi muốn có số cụ thể thay vì NULL:
-- Tra 0 thay vi NULL khi visitors = 0
SELECT
campaign_id,
COALESCE(revenue / NULLIF(visitors, 0), 0) AS revenue_per_visitor
FROM campaigns;
5. Pitfall — type mismatch trong CASE branches
PostgreSQL suy luận type của CASE expression từ tất cả các branch. Nếu branch trả về type khác nhau mà không có implicit cast, query fail với type error.
-- ERROR: integer va text khong co implicit cast
SELECT CASE WHEN id > 5 THEN id ELSE 'small' END FROM tasks;
-- ERROR: CASE/WHEN could not convert type integer to text
-- Fix: cast explicit ve cung type
SELECT CASE WHEN id > 5 THEN id::text ELSE 'small' END FROM tasks;
COALESCE args cũng phải cùng type — ANSI strict, dù một số vendor lax hơn:
-- Co the error tuy vendor: COALESCE(numeric_col, 'unknown')
-- Safe: cast truoc
SELECT COALESCE(score::text, 'N/A') AS display_score FROM results;
MySQL lax hơn và tự convert ngầm — code chạy trên MySQL có thể fail khi migrate sang PostgreSQL vì lý do này.
6. Pitfall — COALESCE không guarantee short-circuit
ANSI SQL không yêu cầu COALESCE dừng evaluate khi đã có giá trị non-NULL. PostgreSQL thực tế có short-circuit, nhưng đây không phải hành vi được đảm bảo trong spec.
-- Nguy hiem neu da dua vao short-circuit implicit:
SELECT COALESCE(simple_col, expensive_function(other_col)) FROM t;
-- simple_col non-NULL nhung expensive_function co the van chay tuy vendor
Nếu cần đảm bảo không evaluate argument thứ hai khi argument đầu non-NULL, dùng CASE:
-- Guaranteed: expensive_function chi chay khi simple_col IS NULL
SELECT
CASE WHEN simple_col IS NOT NULL THEN simple_col
ELSE expensive_function(other_col)
END
FROM t;
Trong thực tế với PostgreSQL, COALESCE short-circuit, nhưng nên dùng CASE khi logic quan trọng và bạn muốn behavior rõ ràng hơn.
7. Applied — TaskFlow scenarios
-- Scenario 1: status icon mapping (simple CASE)
SELECT
id,
title,
CASE status
WHEN 'todo' THEN '⏸'
WHEN 'doing' THEN '▶'
WHEN 'done' THEN '✓'
ELSE '?'
END AS icon
FROM tasks;
-- Scenario 2: assignee fallback sau LEFT JOIN
SELECT
t.id,
t.title,
COALESCE(u.name, '(Unassigned)') AS assignee
FROM tasks t
LEFT JOIN users u ON t.assignee_id = u.id;
-- Cac task chua assign: u.name = NULL -> COALESCE tra '(Unassigned)'
-- Scenario 3: completion rate per project (safe divide)
SELECT
project_id,
COUNT(*) FILTER (WHERE status = 'done') AS done_count,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'done') * 100.0 / NULLIF(COUNT(*), 0) AS completion_pct
FROM tasks
GROUP BY project_id;
-- Project rong (COUNT(*) = 0) -> NULLIF tra NULL -> NULL pct thay vi error
-- Scenario 4: bucketize task age bang CASE searched form
SELECT
id,
title,
CASE
WHEN now() - created_at < INTERVAL '1 day' THEN 'today'
WHEN now() - created_at < INTERVAL '7 days' THEN 'this week'
WHEN now() - created_at < INTERVAL '30 days' THEN 'this month'
ELSE 'older'
END AS age_bucket
FROM tasks
ORDER BY created_at DESC;
8. Deep Dive — Conditional expressions
- PostgreSQL Documentation 9.18 "Conditional Expressions" — spec chính thức cho CASE, COALESCE, NULLIF, GREATEST/LEAST trong PostgreSQL. Đọc phần CASE trước để hiểu searched vs simple form, rồi COALESCE và NULLIF.
- SQL CASE expression — Wikipedia overview — lịch sử cú pháp ANSI SQL CASE và các biến thể cross-vendor (IIF trong Access/MSSQL, DECODE trong Oracle, IF() trong MySQL).
Ghi chú: PG docs cho cú pháp chính xác và edge case behavior (type inference, NULL handling). Wikipedia hữu ích để hiểu context cross-vendor khi bạn làm việc với nhiều database khác nhau hoặc đọc code legacy.
9. Tóm tắt
CASEsearched form dùng predicate tùy ý; simple form ngắn hơn cho equality comparison với một column.COALESCE(a, b, c)là ANSI standard fallback chain — trả giá trị non-NULL đầu tiên; portable hơnIFNULL/ISNULLhai-args của MySQL/MSSQL.NULLIF(a, b)trả NULL khia = b— pattern cốt lõi làvalue / NULLIF(divisor, 0)để tránh divide-by-zero error.COALESCE(x / NULLIF(y, 0), 0)kết hợp cả hai: safe-divide và thay NULL bằng default.- CASE branches phải cùng type trong PostgreSQL — cast tường minh nếu cần; MySQL lax hơn nên code migrate có thể break.
- Forward link: Module 3 của khoá này đi vào
FILTERclause trong aggregate (khác cú pháp CASE nhưng cùng mục đích lọc điều kiện khi tính). Module 6 của khoá này đi vào PL/pgSQL function — dùng khi cầnIF/ELSIF/ELSEstatement thật sự, không chỉ expression.
10. Tự kiểm tra
Q1Vì sao SQL không có `IF` statement như app code? Khi nào CASE expression là đủ, và khi nào cần PL/pgSQL?▸
SQL là ngôn ngữ declarative — bạn mô tả kết quả muốn lấy, không phải trình tự bước thực hiện. IF statement là construct imperative (rẽ nhánh flow execution), không phù hợp với mô hình này. CASE là expression — nó trả về một giá trị trong một query, phù hợp với model quan hệ.
CASE đủ khi bạn cần phân nhánh logic để tính toán hoặc format một column trong SELECT, WHERE, ORDER BY, hoặc aggregate. PL/pgSQL cần thiết khi bạn muốn control flow thật sự: vòng lặp, nhiều câu lệnh SQL, exception handling, hay transaction logic phức tạp trong stored procedure hoặc trigger.
Q2Phân biệt `COALESCE(x, y)` vs `CASE WHEN x IS NOT NULL THEN x ELSE y END`. Hai dạng này có khác nhau về behavior không?▸
Về kết quả, hai dạng tương đương: đều trả x nếu x không NULL, ngược lại trả y. COALESCE thực chất là syntactic sugar cho CASE IS NOT NULL chain.
Sự khác biệt thực tế: CASE với IS NOT NULL đảm bảo short-circuit theo ANSI spec — branch ELSE chỉ evaluate khi x IS NULL. COALESCE không có đảm bảo này trong spec (dù PostgreSQL thực tế short-circuit). Khi argument thứ hai là hàm tốn kém, CASE form rõ ràng hơn về ý định.
COALESCE ngắn hơn và đọc dễ hơn cho n args: COALESCE(a, b, c, d) thay vì CASE lồng nhau bốn tầng.
Q3Query revenue / visitors ném lỗi division by zero khi visitors = 0. Hai cách fix với NULLIF là gì? Tradeoff giữa hai cách?▸
revenue / visitors ném lỗi division by zero khi visitors = 0. Hai cách fix với NULLIF là gì? Tradeoff giữa hai cách?Cách 1 — Trả NULL khi divide-by-zero:
revenue / NULLIF(visitors, 0)Kết quả NULL khi visitors = 0. Phù hợp khi NULL có nghĩa "không tính được" — dashboard hiển thị ô trống hoặc "N/A" thay vì con số sai lệch.
Cách 2 — Trả 0 (hoặc default) thay vì NULL:
COALESCE(revenue / NULLIF(visitors, 0), 0)Kết quả 0 khi visitors = 0. Phù hợp khi cần con số để tính tiếp (vd tổng hợp, sắp xếp) hoặc khi NULL phá vỡ downstream logic. Tradeoff: 0 có thể misleading — campaign không có visitor và campaign có visitor nhưng không có revenue đều cho cùng revenue_per_visitor = 0.
Chọn NULL hay 0 phụ thuộc vào business logic: NULL rõ ràng hơn về "không đủ data", 0 tiện hơn cho arithmetic pipeline.
Q4CASE branch trả `id` (integer) và `'small'` (text) gây error trong PostgreSQL nhưng chạy được trên MySQL. Vì sao? Implication khi migrate?▸
PostgreSQL theo ANSI SQL strict: CASE expression phải có một type duy nhất, suy luận từ tất cả branches. Khi branches có type khác nhau mà không có implicit cast đăng ký, query fail với type error ngay lúc parse — không đợi đến runtime.
MySQL lax hơn: tự convert ngầm (integer sang varchar hoặc ngược lại) khi cần, query chạy được nhưng kết quả đôi khi bất ngờ. Behavior này làm code dễ viết nhưng khó debug khi convert sai.
Implication khi migrate từ MySQL sang PostgreSQL: mọi CASE, COALESCE, UNION có mixed type đều cần review và thêm cast tường minh. Đây là một trong những nguyên nhân phổ biến nhất khiến query MySQL không compile trên PostgreSQL — cần audit toàn bộ query layer trước khi migrate.
Q5Khi COALESCE(a, expensive_call(b)) được dùng và a non-NULL, tại sao expensive_call vẫn có thể chạy? Workaround là gì?▸
COALESCE(a, expensive_call(b)) được dùng và a non-NULL, tại sao expensive_call vẫn có thể chạy? Workaround là gì?ANSI SQL spec không yêu cầu COALESCE dừng evaluate khi đã có giá trị non-NULL — đây là implementation detail, không phải đảm bảo của ngôn ngữ. Một số vendor evaluate tất cả args trước khi trả kết quả.
PostgreSQL thực tế có short-circuit COALESCE, nhưng nếu bạn viết code phụ thuộc vào điều này và sau này chạy trên vendor khác (hoặc PostgreSQL thay đổi optimizer), behavior bị break mà không có warning.
Workaround: dùng CASE để guarantee short-circuit theo spec:
CASE WHEN a IS NOT NULL THEN a
ELSE expensive_call(b)
ENDCASE spec đảm bảo rằng khi branch đầu match, các branch còn lại không được evaluate. Đây là hành vi được định nghĩa rõ trong ANSI SQL, không phải implementation detail.
Bài tiếp theo: Mini-challenge: dashboard query TaskFlow
Bài này có giúp bạn hiểu bản chất không?