SQL & Database — Tư tưởng & Nguyên lý
12/52
Bài 12 / 52~14 phútTruy vấn cơ bảnLộ trình · chặng 10/21Miễn phí lượt xem

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 chuẩn không có IF statement như ngôn ngữ lập trình thông thường (SQL chỉ có statement-level IF trong stored procedure của từng engine, như PL/pgSQL của PostgreSQL hay T-SQL của SQL Server). 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 codeSQLVai trò
switch/match expressionCASE ... 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 guarda / NULLIF(b, 0)Tránh divide-by-zero — trả NULL thay vì error
Hàm identityCOALESCE(x, x)Không dùng — nếu x non-NULL thì COALESCE là no-op
💡 Cách nhớ

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. Cơ chế đánh giá — CASE và COALESCE

Hai expression này là một bậc thang, hai cách đọc — nhưng chỉ giống nhau ở tầng kết quả, còn tầng "có tính tiếp hay không" thì khác (mục 7 quay lại chỗ này):

Một bậc thang bốn bậc ở cột giữa, đọc theo CASE WHEN ở cột trái và theo COALESCE ở cột phải: bậc 1 xét ứng viên đầu, thoả thì đây là kết quả, bậc 2 và 3 chỉ tính đến khi bậc trên trượt, hết bậc thì trả giá trị dự phòng; hàng dưới đối chiếu CASE được chuẩn bảo đảm không đánh giá bậc dưới với COALESCE cùng kết quả nhưng chuẩn ANSI không đòi ngừng tính các đối số sau

3. 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).

SQL
-- 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;
SQL
-- 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à đủ.

Hệ quản trị tuân thủ SQL chuẩn (PostgreSQL, SQL Server) yêu cầu 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:

SQL
-- ERROR theo SQL chuan: integer vs text, khong tu convert
-- CASE WHEN id > 5 THEN id ELSE 'small' END

-- Fix: cast ve cung type
-- PostgreSQL: id::text hoac CAST(id AS TEXT)
-- SQL chuan (portable): CAST(id AS VARCHAR)
SELECT
  CASE WHEN id > 5 THEN CAST(id AS VARCHAR) 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.

4. 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.

SQL
-- 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'
SQL
-- 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)
SQL
-- 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.

Thử ngẫmfallback chain COALESCE(nickname, name, email, 'Anonymous') đang chạy đúng. Nếu bạn đổi thứ tự thành COALESCE(email, nickname, name, 'Anonymous'), dashboard hiển thị đổi ở những user nào?

5. 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:

SQL
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:

SQL
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

Điểm dễ hiểu sai: NULLIF không "bắt lỗi" phép chia. Nó đổi mẫu số sang giá trị mà phép chia có định nghĩa — chia cho 0 là lỗi, chia cho NULL thì không:

Cùng một campaign có visitors bằng 0 rẽ hai đường: đường trái chia thẳng revenue cho visitors nên engine ném lỗi division by zero và cả query dừng; đường phải qua NULLIF visitors 0 biến 0 thành NULL, revenue chia NULL cho ra NULL mà không lỗi, rồi COALESCE hiển thị 0 cho người dùng

Kết hợp COALESCE để thay NULL bằng giá trị mặc định khi muốn có số cụ thể thay vì NULL:

SQL
-- Tra 0 thay vi NULL khi visitors = 0
SELECT
  campaign_id,
  COALESCE(revenue / NULLIF(visitors, 0), 0) AS revenue_per_visitor
FROM campaigns;

6. Pitfall — type mismatch trong CASE branches

Pitfall — CASE branch type mismatch

Hệ quản trị tuân thủ SQL chuẩn 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.

SQL
-- ERROR theo SQL chuan: 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 (PostgreSQL)

-- Fix: cast explicit ve cung type (CAST la SQL chuan)
SELECT CASE WHEN id > 5 THEN CAST(id AS VARCHAR) ELSE 'small' END FROM tasks;

COALESCE args cũng phải cùng type — ANSI strict, dù một số vendor lax hơn:

SQL
-- Co the error tuy vendor: COALESCE(numeric_col, 'unknown')
-- Safe: cast truoc (CAST la SQL chuan)
SELECT COALESCE(CAST(score AS VARCHAR), '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 engine strict (PostgreSQL, SQL Server) vì lý do này.

7. Pitfall — COALESCE không guarantee short-circuit

Pitfall — COALESCE evaluate args 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.

SQL
-- 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:

SQL
-- 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ế, hầu hết hệ quản trị hiện đại (PostgreSQL, SQL Server) short-circuit COALESCE, nhưng nên dùng CASE khi logic quan trọng và bạn muốn behavior rõ ràng và portable hơn.

Thử ngẫmreport doanh thu của bạn đang gọi COALESCE(cached_total, recompute_expensive_total()) cho 50.000 dòng mỗi đêm. Pitfall vừa nêu ở trên đáng lo tới mức nào ở quy mô đó?

8. Applied — TaskFlow scenarios

SQL
-- 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;
SQL
-- 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)'
SQL
-- 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
SQL
-- Scenario 4: bucketize task age bang CASE searched form
-- INTERVAL '1 day' la SQL chuan; MySQL co the dung INTERVAL 1 DAY (khong co quotes)
SELECT
  id,
  title,
  CASE
    WHEN CURRENT_TIMESTAMP - created_at < INTERVAL '1 day'  THEN 'today'
    WHEN CURRENT_TIMESTAMP - created_at < INTERVAL '7 days' THEN 'this week'
    WHEN CURRENT_TIMESTAMP - created_at < INTERVAL '30 days' THEN 'this month'
    ELSE 'older'
  END AS age_bucket
FROM tasks
ORDER BY created_at DESC;

9. Deep Dive — Conditional expressions

📚 Deep Dive — Conditional expressions

Ghi chú: Wikipedia cho lịch sử và cross-vendor overview. Modern SQL cho comparison thực tế. PG docs khi cần cú pháp và edge case của PostgreSQL.

10. Liên hệ các bài khác

11. Tóm tắt

  • CASE searched 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ơn IFNULL/ISNULL hai-args của MySQL/MSSQL.
  • NULLIF(a, b) trả NULL khi a = 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 theo SQL chuẩn — cast tường minh (CAST(x AS type)) nếu cần; MySQL lax hơn nên code migrate sang engine strict có thể break.
  • Forward link: Module 3 — JOIN, aggregation & window đi vào FILTER clause 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 7 — Transactions & consistency đi vào stored procedure và control flow mức engine — dùng khi cần IF/ELSIF/ELSE statement thật sự, không chỉ expression.

12. Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Vì sao SQL chuẩn không có `IF` statement như app code? Khi nào CASE expression là đủ, và khi nào cần stored procedure?
  2. Q2
    Phâ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?
  3. Q3
    Query 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?
  4. Q4
    CASE branch trả `id` (integer) và `'small'` (text) gây error trong engine strict nhưng chạy được trên MySQL. Vì sao? Implication khi migrate?
  5. Q5
    Khi 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ì?

Bài tiếp theo: Mini-challenge: dashboard query TaskFlow

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

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

Đặt 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

Bài tiếp theo

Mini-challenge: dashboard query — diagnose 3 bug M02