SQL & Database — Thực chiến PostgreSQL/CASE + COALESCE + NULLIF — switch/fallback/safe-divide trong SQL
~14 phútTruy vấn cơ bản 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 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 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. 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

Pitfall — CASE branch type mismatch

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

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.

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

📚 Deep Dive — Conditional expressions

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

  • 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 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 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 6 của khoá này đi vào PL/pgSQL function — dùng khi cần IF/ELSIF/ELSE statement thật sự, không chỉ expression.

10. Tự kiểm tra

Tự kiểm tra
Q1
Vì 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. CASEexpression — 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.

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?

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.

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?

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.

Q4
CASE 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.

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ì?

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)
END

CASE 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?