SQL cho Interview — Lộ trình 10 tuần
Nhấn phím → để bắt đầu · T để xem mục lục
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.
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:
-- Lấy đơn hàng quý 1 năm 2024
SELECT * FROM orders
WHERE order_date BETWEEN '2024-01-01' AND '2024-03-31';
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.
-- 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);
| Wildcard | Ý nghĩa | Ví 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 '\'
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ụ |
|---|---|---|
| MySQL | REGEXP hoặc RLIKE | name REGEXP '^[A-Z][a-z]+$' |
| PostgreSQL | ~ (match), ~* (case-insensitive) | name ~ '^[A-Z][a-z]+$' |
| SQL Server | không có regex (chỉ LIKE mở rộng) | name LIKE '[A-Z][a-z]%' |
| Oracle | REGEXP_LIKE(col, pat) | REGEXP_LIKE(name, '^[A-Z]') |
-- MySQL
SELECT * FROM Users
WHERE mail REGEXP '^[A-Za-z][A-Za-z0-9_.\\-]*@leetcode[.]com$';
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ức | Kết quả |
|---|---|
| 5 = NULL | UNKNOWN |
| 5 <> NULL | UNKNOWN |
| NULL = NULL | UNKNOWN (KHÔNG phải TRUE!) |
| 5 IS NULL | FALSE |
| NULL IS NULL | TRUE |
| TRUE OR NULL | TRUE |
| FALSE OR NULL | NULL |
| TRUE AND NULL | NULL |
| FALSE AND NULL | FALSE |
-- "Trông có vẻ đúng"
SELECT name FROM customers
WHERE customer_id NOT IN (SELECT customer_id FROM orders);
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 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.
-- 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àm | Phươ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, SQLite | Chỉ 2 đối số |
| ISNULL(a, b) | SQL Server | Chỉ 2 đối số. KHÁC IS NULL! |
| NVL(a, b) | Oracle | Chỉ 2 đối số |
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;
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ức | Kết quả |
|---|---|
| 5 IS DISTINCT FROM 5 | FALSE |
| 5 IS DISTINCT FROM NULL | TRUE |
| NULL IS DISTINCT FROM NULL | FALSE |
SELECT name,
CASE country
WHEN 'Vietnam' THEN 'VN'
WHEN 'Australia' THEN 'AU'
ELSE 'Other'
END AS country_code
FROM customers;
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;
Đây là use case phổ biến nhất: tạo cột mới dựa trên logic điều kiện.
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;
SELECT order_id, total,
CASE WHEN total >= 1000 THEN 'Premium'
WHEN total >= 100 THEN 'Standard'
ELSE 'Light'
END AS tier
FROM orders;
-- 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;
-- Đặ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;
Đặ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.
COUNT(CASE WHEN status = 'paid' THEN 1 END) AS n_paid
vì COUNT bỏ qua NULL — không có ELSE thì giá trị ngầm định là NULL.
| Phương ngữ | Cú pháp |
|---|---|
| Chuẩn ANSI, PostgreSQL, Oracle, SQLite | 'A' || 'B' |
| MySQL, SQL Server, PostgreSQL | CONCAT('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).
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'))
Quy tắc với Tiếng Việt: dùng CHAR_LENGTH, không phải LENGTH.
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
SELECT LOWER(TRIM(email)) AS clean_email FROM users;
-- 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'.
REPLACE('hello world', 'world', 'SQL') -- 'hello SQL'
-- Loại bỏ ký tự
REPLACE(phone, '-', '') -- '0901234567' từ '090-123-4567'
| Phương ngữ | Cú pháp |
|---|---|
| Chuẩn ANSI, PostgreSQL, MySQL | POSITION('@' IN email) |
| MySQL, Oracle | INSTR(email, '@') |
| SQL Server | CHARINDEX('@', email) |
Trả vị trí ký tự đầu tiên match (1-indexed). Trả 0 nếu không tìm thấy.
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.
| Hàm | MySQL | PostgreSQL | SQL Server |
|---|---|---|---|
| Hiện tại (datetime) | NOW() | NOW() | GETDATE() |
| Hiện tại (date) | CURDATE() | CURRENT_DATE | CAST(GETDATE() AS DATE) |
-- 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)
-- 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)
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
YEAR(order_date) MONTH(order_date) DAY(order_date) -- MySQL, SQL Server
DATE_TRUNC('month', order_date) -- PostgreSQL: cắt về đầu 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;
-- 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')
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.
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.
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;
Trả về tweet_id của các tweet invalid — content có nhiều hơn 15 ký tự.
$$\pi_{\text{tweet\_id}}\Big(\sigma_{\text{CHAR\_LENGTH(content)} > 15}(\text{Tweets})\Big)$$
SELECT tweet_id
FROM Tweets
WHERE CHAR_LENGTH(content) > 15;
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.
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'.
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).
SELECT patient_id, patient_name, conditions
FROM Patients
WHERE conditions LIKE 'DIAB1%' -- DIAB1 ở đầu
OR conditions LIKE '% DIAB1%'; -- DIAB1 sau một space
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?".
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.
Nhấn T hoặc Escape để đóng