Tuần 2

Lọc nâng cao, biến đổi và xử lý NULL

SQL cho Interview — Lộ trình 10 tuần

Nhấn phím → để bắt đầu · T để xem mục lục

1. Tổng quan tuần 2

1.1

1.1 Mục tiêu và nguyên tắc tuần này

Tuần trước bạn đã viết được query đơn giản. Tuần này nâng cấp vũ khí lọc và học cách sống chung với NULL — kẻ thù số một trong interview SQL.

Sau tuần 2 bạn cần làm được

Câu hỏi định hướng
Nếu một interviewer đưa bạn 5 query đã viết sẵn và hỏi "query nào có bug?", bạn cần trả lời được trong 30 giây mỗi query. Tuần này luyện reflex đó.
1.2

1.2 Tại sao NULL là bug interview #1

Trong khảo sát các bài LeetCode SQL Easy/Medium được report nhiều nhất, hơn 60% lỗi của user đến từ NULL handling. Có 3 lý do:

  1. NULL không phải là một giá trị — nó là "thiếu thông tin". Nó không bằng chính nó (NULL = NULL trả về NULL, không phải TRUE).
  2. Logic ba giá trị (TRUE/FALSE/NULL) phá vỡ trực giác Boolean của lập trình viên đến từ Python/Java.
  3. Aggregation và NULL ứng xử khác nhau tùy hàm: COUNT(*) đếm tất, COUNT(col) bỏ NULL.
Reflex cần xây trong tuần này
Mỗi khi nhìn thấy <>, NOT IN, !=, hoặc tham gia trong logic AND/OR phức tạp — phải tự hỏi: "cột này có thể NULL không?" Nếu có, viết OR ... IS NULL hoặc COALESCE.

2. Predicate nâng cao

2.1

2.1 BETWEEN — bao gồm cả hai biên

Định nghĩa
x BETWEEN a AND b tương đương x >= a AND x <= b. Bao gồm cả hai biên.
-- Lấy đơn hàng quý 1 năm 2024
SELECT * FROM orders
WHERE order_date BETWEEN '2024-01-01' AND '2024-03-31';
Bẫy date — fence-post error

Cột order_date kiểu TIMESTAMP (có giờ phút giây): BETWEEN '2024-03-01' AND '2024-03-31' sẽ bỏ sót đơn lúc 14:00 ngày 31/3, vì '2024-03-31' được hiểu là '2024-03-31 00:00:00'.

-- ĐÚNG cho TIMESTAMP
WHERE order_date >= '2024-03-01'
  AND order_date <  '2024-04-01';   -- nửa-mở half-open
Nửa-mở (\([a, b)\)) là pattern an toàn cho mọi date arithmetic.
2.2

2.2 IN — kiểm tra thuộc tập

-- Tương đương 3 OR:
WHERE country IN ('Vietnam', 'Australia', 'Japan')
-- = WHERE country = 'Vietnam' OR country = 'Australia' OR country = 'Japan'

IN có thể nhận một danh sách hằng, hoặc một subquery trả về 1 cột (sẽ học chi tiết tuần 5):

-- Subquery: lấy customer đã có ít nhất 1 đơn
SELECT name FROM customers
WHERE customer_id IN (SELECT customer_id FROM orders);
IN vs OR — chọn cái nào?
2.3

2.3 LIKE — pattern matching cơ bản

WildcardÝ nghĩaVí dụ
%không hoặc nhiều ký tự bất kỳ'A%' khớp 'An', 'Anna', 'A'
_đúng một ký tự'_n' khớp 'An', 'In', không khớp 'Ann'
-- Tên bắt đầu bằng 'A'
WHERE name LIKE 'A%'
-- Email gmail
WHERE email LIKE '%@gmail.com'
-- Tên có đúng 4 ký tự bắt đầu bằng B
WHERE name LIKE 'B___'
-- Escape % thật (không phải wildcard) bằng ESCAPE
WHERE description LIKE '%50\%%' ESCAPE '\'
Performance trap
LIKE 'A%' dùng được index (prefix). LIKE '%A' hoặc LIKE '%A%' KHÔNG dùng được index — full scan. Khi cần search trong text, nghĩ đến full-text index.
Phương ngữ
MySQL mặc định LIKE không phân biệt hoa-thường (do collation). PostgreSQL phân biệt; dùng ILIKE để case-insensitive.
2.4

2.4 Regex — khi LIKE không đủ

Khi pattern phức tạp (chữ số, alternation, character class), regex mạnh hơn LIKE rất nhiều — nhưng cú pháp khác giữa các phương ngữ.

Phương ngữToán tửVí dụ
MySQLREGEXP hoặc RLIKEname REGEXP '^[A-Z][a-z]+$'
PostgreSQL~ (match), ~* (case-insensitive)name ~ '^[A-Z][a-z]+$'
SQL Serverkhông có regex (chỉ LIKE mở rộng)name LIKE '[A-Z][a-z]%'
OracleREGEXP_LIKE(col, pat)REGEXP_LIKE(name, '^[A-Z]')
Ví dụ — LeetCode 1517 Find Users With Valid E-Mails
-- MySQL
SELECT * FROM Users
WHERE mail REGEXP '^[A-Za-z][A-Za-z0-9_.\\-]*@leetcode[.]com$';

3. NULL — Sinh tồn nâng cao

3.1

3.1 Recap logic ba giá trị (3VL)

SQL không phải Boolean logic. Mỗi predicate có thể trả về TRUE, FALSE, hoặc UNKNOWN. WHERE chỉ giữ TRUE.

Biểu thứcKết quả
5 = NULLUNKNOWN
5 <> NULLUNKNOWN
NULL = NULLUNKNOWN (KHÔNG phải TRUE!)
5 IS NULLFALSE
NULL IS NULLTRUE
TRUE OR NULLTRUE
FALSE OR NULLNULL
TRUE AND NULLNULL
FALSE AND NULLFALSE
Mnemonic
OR coi NULL là FALSE (TRUE thắng). AND coi NULL là TRUE (FALSE thắng). Đó là semantic "có khả năng đúng/sai" — không xác định.
3.2

3.2 NOT IN với NULL — bug kinh điển interview

Yêu cầu
Lấy customer chưa từng đặt đơn nào. Schema: orders.customer_id có thể NULL (một số đơn guest checkout).
-- "Trông có vẻ đúng"
SELECT name FROM customers
WHERE customer_id NOT IN (SELECT customer_id FROM orders);
Kết quả: bảng RỖNG. Tại sao?

Nếu subquery trả về tập có chứa NULL, ví dụ (1, 2, NULL), thì:

x NOT IN (1, 2, NULL) = x <> 1 AND x <> 2 AND x <> NULL

Vế cuối luôn UNKNOWN ⇒ AND tổng luôn UNKNOWN hoặc FALSE ⇒ không dòng nào pass WHERE.

Cách fix

-- Cách 1: thêm IS NOT NULL trong subquery
SELECT name FROM customers
WHERE customer_id NOT IN (
    SELECT customer_id FROM orders WHERE customer_id IS NOT NULL
);

-- Cách 2 (an toàn nhất): NOT EXISTS — không có bug NULL
SELECT name FROM customers c
WHERE NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);

Khuyên: dùng NOT EXISTS mặc định. NOT IN chỉ an toàn khi subquery chắc chắn không có NULL.

3.3

3.3 COALESCE / IFNULL / NVL — thay thế NULL

Định nghĩa
COALESCE(a, b, c, ...) trả về giá trị không-NULL đầu tiên. Nếu tất cả đều NULL, trả NULL.
-- Hiển thị 'Unknown' nếu country NULL
SELECT name, COALESCE(country, 'Unknown') AS country
FROM customers;

-- Fallback chuỗi: ưu tiên nickname, không có thì name, không có nữa thì 'guest'
SELECT COALESCE(nickname, name, 'guest') AS display
FROM users;
HàmPhương ngữGhi chú
COALESCE(a, b, ...)Chuẩn ANSI — chạy mọi phương ngữKhuyên dùng
IFNULL(a, b)MySQL, SQLiteChỉ 2 đối số
ISNULL(a, b)SQL ServerChỉ 2 đối số. KHÁC IS NULL!
NVL(a, b)OracleChỉ 2 đối số
Khuyên
Luôn dùng COALESCE. Là chuẩn ANSI, di chuyển dialect không phải sửa, và nhận nhiều đối số.
3.4

3.4 NULLIF và IS DISTINCT FROM

NULLIF — chuyển giá trị thành NULL

NULLIF(a, b) trả NULL nếu a = b, ngược lại trả a. Ứng dụng phổ biến: tránh divide by zero.

-- Nếu denominator = 0, NULLIF cho ra NULL → phép chia trả NULL thay vì lỗi
SELECT revenue / NULLIF(num_users, 0) AS rev_per_user
FROM metrics;

IS DISTINCT FROM — so sánh "an toàn NULL"

Toán tử so sánh thông thường gặp NULL trả UNKNOWN. IS DISTINCT FROM coi NULL như giá trị có thật:

Biểu thứcKết quả
5 IS DISTINCT FROM 5FALSE
5 IS DISTINCT FROM NULLTRUE
NULL IS DISTINCT FROM NULLFALSE
Phương ngữ

4. CASE WHEN — Branching trong SQL

4.1

4.1 Hai dạng CASE: Simple và Searched

Simple CASE — so khớp giá trị

SELECT name,
       CASE country
            WHEN 'Vietnam'   THEN 'VN'
            WHEN 'Australia' THEN 'AU'
            ELSE 'Other'
       END AS country_code
FROM customers;

Searched CASE — predicate tự do (mạnh hơn, dùng phổ biến hơn)

SELECT name,
       CASE WHEN salary >= 100000 THEN 'High'
            WHEN salary >= 50000  THEN 'Mid'
            WHEN salary IS NULL    THEN 'Unknown'
            ELSE 'Low'
       END AS bracket
FROM employees;
Quy tắc
4.2

4.2 CASE trong SELECT — biến đổi cột

Đây là use case phổ biến nhất: tạo cột mới dựa trên logic điều kiện.

LeetCode 627 — Swap Salary (đơn giản hóa)

Yêu cầu: hiển thị giới tính ngược lại.

SELECT id, name,
       CASE sex WHEN 'm' THEN 'f' ELSE 'm' END AS sex_swapped
FROM Salary;
Categorisation thực tế
SELECT order_id, total,
       CASE WHEN total >= 1000 THEN 'Premium'
            WHEN total >= 100  THEN 'Standard'
            ELSE 'Light'
       END AS tier
FROM orders;
4.3

4.3 CASE trong WHERE và ORDER BY

Trong WHERE — lọc có điều kiện theo cột khác

-- Khách Premium yêu cầu total > 5000, khách thường chỉ cần > 100
SELECT * FROM orders
WHERE total > CASE WHEN tier = 'Premium' THEN 5000 ELSE 100 END;

Trong ORDER BY — sắp xếp tùy ý

-- Đặt status 'urgent' lên đầu, sau đó theo ngày
SELECT * FROM tickets
ORDER BY CASE status WHEN 'urgent'  THEN 1
                     WHEN 'high'    THEN 2
                     WHEN 'normal'  THEN 3
                     ELSE 4 END,
         created_at;
Use case interview
"Sắp xếp employee Vietnam lên đầu, các nước khác sắp tự nhiên" — đây là CASE trong ORDER BY. Nhiều bài Medium ẩn pattern này.
4.4

4.4 Conditional aggregation — preview tuần 3

Đặt CASE bên trong aggregator là kỹ thuật cực mạnh — biến một query thành "đếm có điều kiện" đa chiều trong một lần quét bảng.

-- Đếm số đơn từng status, một dòng kết quả
SELECT
    COUNT(*)                                          AS total,
    SUM(CASE WHEN status = 'paid'      THEN 1 ELSE 0 END) AS n_paid,
    SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS n_cancelled,
    SUM(CASE WHEN status = 'pending'   THEN 1 ELSE 0 END) AS n_pending
FROM orders;

Đây là pivot query kinh điển — biến dòng thành cột. Sẽ dạy chi tiết tuần 8.

Tip
Có thể thay SUM(CASE WHEN ... THEN 1 ELSE 0 END) bằng ngắn hơn:
COUNT(CASE WHEN status = 'paid' THEN 1 END) AS n_paid
COUNT bỏ qua NULL — không có ELSE thì giá trị ngầm định là NULL.

5. String functions

5.1

5.1 Concatenation và length

Nối chuỗi — đa phương ngữ

Phương ngữCú pháp
Chuẩn ANSI, PostgreSQL, Oracle, SQLite'A' || 'B'
MySQL, SQL Server, PostgreSQLCONCAT('A', 'B')
SQL Server (truyền thống)'A' + 'B'

Khuyên: dùng CONCAT — chạy MySQL/PostgreSQL/SQL Server hiện đại. Tránh || nếu code có thể chạy trên MySQL (mặc định || là OR logic ở MySQL).

CONCAT và NULL

MySQL: CONCAT('Hello ', NULL) = NULL (toàn bộ kết quả NULL!).
PostgreSQL CONCAT: bỏ qua NULL → trả 'Hello '.
Dùng CONCAT_WS (separator) hoặc COALESCE trên từng phần để an toàn:

CONCAT('Hi ', COALESCE(name, 'guest'))

Length — đếm ký tự

Quy tắc với Tiếng Việt: dùng CHAR_LENGTH, không phải LENGTH.

5.2

5.2 Case conversion, TRIM, SUBSTRING

UPPER('hello')      -- 'HELLO'
LOWER('HELLO')      -- 'hello'

TRIM('  abc  ')     -- 'abc' (bỏ space hai đầu)
LTRIM('  abc  ')    -- 'abc  '
RTRIM('  abc  ')    -- '  abc'
TRIM('x' FROM 'xxabcxx')  -- 'abc' (bỏ ký tự cụ thể)

SUBSTRING(s FROM start FOR length)   -- chuẩn ANSI
SUBSTR(s, start, length)              -- MySQL/PostgreSQL/SQLite
LEFT(s, n)                            -- n ký tự đầu
RIGHT(s, n)                           -- n ký tự cuối
Ví dụ — chuẩn hóa email viết hoa thành lowercase
SELECT LOWER(TRIM(email)) AS clean_email FROM users;
Ví dụ — lấy tên domain từ email
-- MySQL
SELECT SUBSTRING(email FROM POSITION('@' IN email) + 1) AS domain
FROM users;

Lưu ý vị trí: SQL hầu hết tính từ 1 (không phải 0 như Python). SUBSTRING('abc', 1, 2) = 'ab'.

5.3

5.3 REPLACE, POSITION, padding

REPLACE — thay thế chuỗi con

REPLACE('hello world', 'world', 'SQL')  -- 'hello SQL'
-- Loại bỏ ký tự
REPLACE(phone, '-', '')                  -- '0901234567' từ '090-123-4567'

POSITION / INSTR / CHARINDEX — tìm vị trí

Phương ngữCú pháp
Chuẩn ANSI, PostgreSQL, MySQLPOSITION('@' IN email)
MySQL, OracleINSTR(email, '@')
SQL ServerCHARINDEX('@', email)

Trả vị trí ký tự đầu tiên match (1-indexed). Trả 0 nếu không tìm thấy.

LPAD / RPAD — đệm chuỗi

LPAD('5', 3, '0')   -- '005'  (đệm trái)
RPAD('A', 5, '.')   -- 'A....' (đệm phải)

Dùng cho format mã, ID, số có chữ số cố định.

6. Date / Time functions

6.1

6.1 Current date/time và arithmetic

HàmMySQLPostgreSQLSQL Server
Hiện tại (datetime)NOW()NOW()GETDATE()
Hiện tại (date)CURDATE()CURRENT_DATECAST(GETDATE() AS DATE)

Cộng/trừ thời gian

-- MySQL
DATE_ADD(order_date, INTERVAL 7 DAY)
order_date + INTERVAL 7 DAY
-- PostgreSQL
order_date + INTERVAL '7 days'
-- SQL Server
DATEADD(day, 7, order_date)

Khoảng cách giữa hai ngày

-- MySQL: số ngày
DATEDIFF(end_date, start_date)
-- PostgreSQL: trừ trực tiếp ra interval
end_date - start_date
-- SQL Server: chỉ rõ unit
DATEDIFF(day, start_date, end_date)
6.2

6.2 Trích xuất thành phần ngày tháng

EXTRACT — chuẩn ANSI, chạy nhiều phương ngữ

EXTRACT(YEAR  FROM order_date)   -- 2024
EXTRACT(MONTH FROM order_date)   -- 3
EXTRACT(DAY   FROM order_date)   -- 15
EXTRACT(DOW   FROM order_date)   -- thứ trong tuần (0-6, PostgreSQL)
EXTRACT(WEEK  FROM order_date)   -- tuần thứ N trong năm

Hàm tắt

YEAR(order_date)    MONTH(order_date)    DAY(order_date)        -- MySQL, SQL Server
DATE_TRUNC('month', order_date)   -- PostgreSQL: cắt về đầu tháng
Pattern interview — group theo tháng
-- MySQL
SELECT YEAR(order_date) AS y, MONTH(order_date) AS m, COUNT(*) AS n
FROM orders
GROUP BY y, m
ORDER BY y, m;

-- PostgreSQL — gọn hơn với DATE_TRUNC
SELECT DATE_TRUNC('month', order_date) AS month, COUNT(*) AS n
FROM orders
GROUP BY month
ORDER BY month;
6.3

6.3 Format và bug timezone

Format ngày thành chuỗi

-- MySQL
DATE_FORMAT(order_date, '%Y-%m-%d')        -- '2024-03-15'
DATE_FORMAT(order_date, '%M %d, %Y')       -- 'March 15, 2024'
-- PostgreSQL
TO_CHAR(order_date, 'YYYY-MM-DD')
TO_CHAR(order_date, 'Mon DD, YYYY')
-- SQL Server
FORMAT(order_date, 'yyyy-MM-dd')
Bug interview — timezone

Nếu cột là TIMESTAMP WITH TIME ZONE nhưng bạn so sánh với '2024-03-15', kết quả phụ thuộc session timezone. Một đơn lúc 23:30 Việt Nam có thể thuộc ngày 15 hoặc 14 tùy timezone server.

Trong mock interview, hỏi clarification: "Cột này có timezone không? Cần group theo timezone nào?" Đây là dấu hiệu của candidate senior.

Date arithmetic — fence-post lần nữa
Dùng nửa-mở: date >= '2024-03-01' AND date < '2024-04-01'. An toàn cho cả DATE và TIMESTAMP, không bao giờ sót/thừa biên.

7. Worked Examples

7.1

7.1 LeetCode 1873 — Calculate Special Bonus

Schema

Employees(employee_id INT PK, name VARCHAR, salary INT)

Yêu cầu

Tính bonus cho mỗi nhân viên: bonus = salary nếu employee_id lẻ VÀ name không bắt đầu bằng 'M'; ngược lại bonus = 0. Trả về (employee_id, bonus) sắp xếp theo employee_id.

Phân rã

  1. Cho mỗi dòng, tính bonus theo điều kiện kép → CASE WHEN.
  2. Chiếu hai cột → π.
  3. Sắp xếp.

Lời giải

SELECT employee_id,
       CASE WHEN employee_id % 2 = 1
             AND name NOT LIKE 'M%'
            THEN salary
            ELSE 0
       END AS bonus
FROM Employees
ORDER BY employee_id;
Take-away
Ba kỹ thuật mới của tuần được dùng cùng nhau: modulo arithmetic, NOT LIKE pattern, CASE WHEN. Đây là pattern "biến đổi cột với điều kiện" — rất phổ biến trong report queries.
7.2

7.2 LeetCode 1683 — Invalid Tweets

Schema

Tweets(tweet_id INT PK, content VARCHAR(140))

Yêu cầu

Trả về tweet_id của các tweet invalid — content có nhiều hơn 15 ký tự.

Phân rã

$$\pi_{\text{tweet\_id}}\Big(\sigma_{\text{CHAR\_LENGTH(content)} > 15}(\text{Tweets})\Big)$$

Lời giải

SELECT tweet_id
FROM Tweets
WHERE CHAR_LENGTH(content) > 15;
Bẫy LENGTH vs CHAR_LENGTH

Nếu user chứa Tiếng Việt có dấu hoặc emoji, LENGTH ở MySQL trả byte, không phải ký tự. Tweet "Xin chào 👋 Việt Nam!" có thể là 18 ký tự nhưng 30+ byte.

LeetCode 1683 dùng LENGTH cũng pass vì test case không có Unicode, nhưng trong production luôn dùng CHAR_LENGTH.

7.3

7.3 LeetCode 1527 — Patients with a Condition

Schema

Patients(patient_id INT PK, patient_name VARCHAR, conditions VARCHAR) -- conditions: chuỗi các code bệnh cách nhau bởi dấu space, ví dụ 'DIAB100 ABC123'

Yêu cầu

Trả về bệnh nhân có ít nhất một condition là tiểu đường loại I — code bắt đầu bằng 'DIAB1'.

Bẫy

Naive: conditions LIKE 'DIAB1%' — chỉ match khi DIAB1 ở đầu chuỗi. Bỏ sót các bệnh nhân có 'ABC123 DIAB100' (DIAB1 ở giữa).

Lời giải

SELECT patient_id, patient_name, conditions
FROM Patients
WHERE conditions LIKE 'DIAB1%'        -- DIAB1 ở đầu
   OR conditions LIKE '% DIAB1%';     -- DIAB1 sau một space
Pattern: word boundary trong LIKE

SQL LIKE không có khái niệm "word boundary" như regex (\b). Trick chuẩn: thêm space ở đầu và cuối, rồi search ' DIAB1':

WHERE CONCAT(' ', conditions, ' ') LIKE '% DIAB1%'

Một dòng, không sót case. Trong mock interview, đề xuất giải pháp này khi interviewer hỏi "có cách nào gọn hơn không?".

8. Knowledge Check

8.Q

Knowledge Check — Tuần 2

Q1: Subquery SELECT manager_id FROM employees trả {1, 2, NULL}. Query SELECT id FROM employees WHERE id NOT IN (SELECT manager_id FROM employees) trả gì?
x NOT IN (1, 2, NULL) = x <> 1 AND x <> 2 AND x <> NULL. Vế cuối luôn UNKNOWN ⇒ tổng AND luôn UNKNOWN/FALSE ⇒ không dòng nào pass. Fix: dùng NOT EXISTS hoặc thêm IS NOT NULL trong subquery.
Q2: CONCAT('Hi ', NULL, '!') trả gì trong MySQL?
MySQL CONCAT trả NULL ngay khi gặp một đối số NULL. PostgreSQL CONCAT thì bỏ qua NULL. Khác biệt phương ngữ này hay xuất hiện trong interview. Để an toàn dùng CONCAT_WS hoặc COALESCE trên từng phần.
Q3: Cột order_date kiểu TIMESTAMP. Query nào lấy đúng tất cả đơn trong tháng 3/2024?
A bỏ sót đơn ngày 31/3 sau 00:00:00 ('2024-03-31' được hiểu là đầu ngày). C lấy cả tháng 3 các năm khác. D không hoạt động trên kiểu TIMESTAMP. Nửa-mở (B) là pattern an toàn cho mọi date arithmetic và dùng được index.

9. Bài tập về nhà

9.1

9.1 Bài tập LeetCode (5 problem Easy)

  1. 1873. Calculate Special Bonus — đã giải tại lớp; viết lại không nhìn.
  2. 1683. Invalid Tweets — chú ý CHAR_LENGTH vs LENGTH.
  3. 1527. Patients with a Condition — pattern word boundary trong LIKE.
  4. 1517. Find Users With Valid E-Mails — regex (REGEXP / ~ tùy phương ngữ).
  5. 1693. Daily Leads and Partners — COUNT(DISTINCT) + GROUP BY (preview tuần 3).

Bonus — debug challenge

Cho query sau, tìm bug và sửa:

-- Yêu cầu: lấy customer KHÔNG ở Vietnam HOẶC chưa rõ quốc gia
SELECT name FROM customers
WHERE country <> 'Vietnam';

Gợi ý: query bỏ sót dòng nào? Sửa thế nào? Viết 2 cách fix khác nhau.

9.2

9.2 Đọc thêm và tự kiểm tra

Đọc

Tự kiểm tra (không nhìn slide)

Tuần sau — Aggregation
Tuần 3: GROUP BY, HAVING, COUNT/SUM/AVG/MIN/MAX, COUNT(DISTINCT), conditional aggregation chuyên sâu. Bạn đã preview bằng CASE trong SUM ở slide 4.4 — tuần sau sẽ làm chủ pattern này.

Mục lục

Nhấn T hoặc Escape để đóng