Skip to content

Ngày 5: Toán tử, biểu thức và hàm thường dùng ​

Tóm tắt: Truy vấn không chỉ lấy dữ liệu ra mà còn biến đổi nó: tính tiền, ghép chuỗi, tách ngày tháng, đổi kiểu, thay NULL bằng giá trị mặc định. Bài này đi qua các toán tử số học (và cái bẫy chia số nguyên), hàm chuỗi, hàm ngày giờ, CAST, ROUND, COALESCE, NULLIF, rồi dùng chúng để làm sạch cột customers.city đang bị viết lộn xộn.

Sau bài này bạn có thể:

  • Tính toán đúng trên cột số, biết khi nào / là chia nguyên và cách lấy kết quả thập phân.
  • Ghép, cắt, đổi hoa thường và thay thế chuỗi bằng ||, CONCAT, UPPER, LOWER, TRIM, LENGTH, SUBSTRING, REPLACE.
  • Lấy phần năm/tháng, làm tròn về đầu tháng, cộng trừ khoảng thời gian và tính tuổi bằng EXTRACT, DATE_TRUNC, INTERVAL, AGE.
  • Đổi kiểu bằng CAST/::, làm tròn bằng ROUND, xử lý NULL bằng COALESCE và tránh chia cho 0 bằng NULLIF.
  • Viết được một biểu thức làm sạch dữ liệu chuỗi nhập tay không thống nhất.

Cần biết trước: Ngày 3 (SELECT, alias, WHERE, NULL), Ngày 4 (ORDER BY, LIMIT).

1. Toán tử số học ​

Toán tửÝ nghĩaVí dụKết quả
+cộng7 + 29
-trừ7 - 25
*nhân7 * 214
/chia7 / 23 (!)
%chia lấy dư7 % 21
sql
SELECT 7 + 2  AS cong,
       7 - 2  AS tru,
       7 * 2  AS nhan,
       7 / 2  AS chia,
       7 % 2  AS du;
congtrunhanchiadu
951431

Chia số nguyên cho số nguyên ra số nguyên ​

7 / 2 ra 3, không phải 3.5. Trong PostgreSQL, khi cả hai vế là số nguyên (INT, BIGINT, ...) thì / là chia nguyên, phần thập phân bị cắt bỏ (cắt về phía 0, nên -7 / 2 là -3). Chỉ cần một vế là số thập phân (NUMERIC) là kết quả có phần thập phân:

sql
SELECT 7 / 2         AS int_int,
       7 / 2.0       AS int_numeric,
       7::numeric / 2 AS ep_kieu,
       -7 / 2        AS am,
       -7 % 2        AS du_am;
int_intint_numericep_kieuamdu_am
33.50000000000000003.5000000000000000-3-1

(7::numeric là ép kiểu sang NUMERIC, xem mục 5. Dấu của phần dư theo dấu của số bị chia: -7 % 2 là -1.)

Chia nguyên rất có ích khi đó đúng là điều bạn muốn. Cáp sạc đóng hộp 12 cái, mỗi mã còn được bao nhiêu hộp nguyên và bao nhiêu cái lẻ?

sql
SELECT sku,
       name,
       stock_quantity,
       stock_quantity / 12 AS so_hop_12,
       stock_quantity % 12 AS con_le
FROM products
WHERE category_id = 12;
skunamestock_quantityso_hop_12con_le
ACC-007Sạc Anker 65W4034
ACC-008Cáp USB-C to USB-C 1m200168

Nhưng khi tính tỉ lệ phần trăm, chia nguyên là một lỗi âm thầm. Kho chứa tối đa 200 cái mỗi mã, mỗi mã đang lấp bao nhiêu phần trăm?

sql
SELECT sku,
       stock_quantity,
       stock_quantity / 200 * 100       AS sai_phan_tram,
       stock_quantity * 100.0 / 200     AS dung_phan_tram
FROM products
WHERE category_id = 12;
skustock_quantitysai_phan_tramdung_phan_tram
ACC-00740020.0000000000000000
ACC-008200100100.0000000000000000

40 / 200 là 0 (chia nguyên), nhân 100 vẫn là 0. Viết 100.0 (có dấu chấm) để biến phép tính thành số thập phân ngay từ đầu.

Ghi chú dialect: SQL Server và SQLite cũng chia nguyên khi hai vế là số nguyên. MySQL thì khác: / luôn trả số thập phân (7 / 2 ra 3.5000), muốn chia nguyên phải viết 7 DIV 2. Code chuyển từ MySQL sang PostgreSQL rất hay dính lỗi này.

Toán tử % không chỉ cho số nguyên ​

% (hoặc hàm MOD(a, b)) lấy phần dư, dùng được cả với NUMERIC:

sql
SELECT 5.5 % 2     AS du_thap_phan,
       MOD(17, 5)  AS ham_mod;
du_thap_phanham_mod
1.52

Thực tế % thường dùng để kiểm tra chẵn lẻ (order_id % 2 = 0) hoặc chia vòng (chính file seed của DSC Shop dùng order_id % 3 để chọn phương thức thanh toán).

Thứ tự ưu tiên và ngoặc ​

Như toán phổ thông: *, /, % trước +, -. Toàn bộ phép toán được tính trước phép so sánh (=, <, ...), rồi mới tới NOT, AND, OR (đã học ở Ngày 3).

sql
SELECT 2 + 3 * 4   AS khong_ngoac,
       (2 + 3) * 4 AS co_ngoac;
khong_ngoacco_ngoac
1420

Ví dụ thật: tiền một dòng hàng là quantity * unit_price * (1 - discount). Ngoặc quanh 1 - discount là bắt buộc.

sql
SELECT order_id,
       product_id,
       quantity,
       unit_price,
       discount,
       quantity * unit_price * (1 - discount) AS line_total
FROM order_items
WHERE order_id IN (4, 22);
order_idproduct_idquantityunit_pricediscountline_total
420159900000.105391000.00
417124900000.002490000.00
2220159900000.155091500.00
2212148900000.104401000.00

Chia cho 0 ​

sql
SELECT price / stock_quantity
FROM products;
text
ERROR:  division by zero

Chỉ cần một sản phẩm hết hàng (stock_quantity = 0) là cả câu truy vấn thất bại. Cách xử lý gọn ở mục 6 (NULLIF).

2. NULL trong biểu thức ​

Ở Ngày 3 bạn đã thấy so sánh với NULL cho ra UNKNOWN. Phép tính cũng vậy: hầu hết phép toán và hàm gặp NULL đều trả NULL. "Giá bán trừ một số chưa biết" thì vẫn là chưa biết.

sql
SELECT name,
       price,
       cost_price,
       price - cost_price AS profit
FROM products
WHERE cost_price IS NULL;
namepricecost_priceprofit
HP Pavilion 1516990000NULLNULL
Lót chuột DSC XXL250000NULLNULL
sql
SELECT NULL + 1          AS cong_null,
       NULL * 0          AS nhan_khong,
       'DSC' || NULL     AS noi_null,
       CONCAT('DSC', NULL) AS concat_null;
cong_nullnhan_khongnoi_nullconcat_null
NULLNULLNULLDSC

Kể cả NULL * 0 cũng là NULL. Ngoại lệ đáng nhớ là hàm CONCAT: nó bỏ qua các đối số NULL. Mục tiếp theo nói rõ hơn.

3. Hàm chuỗi ​

Nối chuỗi: || và CONCAT ​

sql
SELECT sku || ' - ' || name AS san_pham,
       CONCAT(name, ' (', price, ' đ)') AS nhan_gia
FROM products
WHERE category_id = 8;
san_phamnhan_gia
TAB-001 - iPad Air M2 11 inchiPad Air M2 11 inch (16990000 đ)
TAB-002 - Samsung Galaxy Tab S9 FESamsung Galaxy Tab S9 FE (9490000 đ)

CONCAT tự đổi số sang chuỗi. Khác biệt quan trọng nằm ở NULL:

sql
SELECT full_name,
       full_name || ' <' || email || '>'       AS dung_noi,
       CONCAT(full_name, ' <', email, '>')     AS dung_concat
FROM customers
WHERE customer_id IN (1, 5, 17);
full_namedung_noidung_concat
Nguyễn Văn AnNguyễn Văn An <[email protected]>Nguyễn Văn An <[email protected]>
Hoàng Thị EmNULLHoàng Thị Em <>
Châu Ngọc TrâmNULLChâu Ngọc Trâm <>

|| gặp NULL thì mất cả chuỗi. CONCAT giữ được tên nhưng để lại cặp <> trống. Cách đẹp nhất là thay NULL bằng một chữ có nghĩa bằng COALESCE (mục 6, và Bài tập 2).

Ghi chú dialect: Trong MySQL, || mặc định là phép OR logic chứ không phải nối chuỗi (trừ khi bật chế độ PIPES_AS_CONCAT), nên ở MySQL hãy dùng CONCAT. SQL Server nối chuỗi bằng + hoặc CONCAT. CONCAT chạy được ở cả ba hệ.

Hoa thường, khoảng trắng, độ dài ​

HàmÝ nghĩa
UPPER(s), LOWER(s)viết hoa hết, viết thường hết
INITCAP(s)viết hoa chữ cái đầu mỗi từ (riêng PostgreSQL)
TRIM(s)bỏ khoảng trắng hai đầu (LTRIM, RTRIM cho từng đầu)
LENGTH(s)số ký tự

Các hàm này xử lý đúng chữ tiếng Việt có dấu:

sql
SELECT city,
       UPPER(city)   AS hoa,
       LOWER(city)   AS thuong,
       INITCAP(city) AS hoa_dau_tu
FROM customers
WHERE customer_id IN (3, 8);
cityhoathuonghoa_dau_tu
Đà NẵngĐÀ NẴNGđà nẵngĐà Nẵng
đà nẵngĐÀ NẴNGđà nẵngĐà Nẵng

Hai cách viết khác nhau, qua LOWER hay INITCAP thì thành một. Đây là chìa khóa để làm sạch dữ liệu ở mục 7.

sql
SELECT '[' || '  Hà Nội  ' || ']'         AS goc,
       '[' || TRIM('  Hà Nội  ') || ']'   AS da_trim,
       LENGTH('  Hà Nội  ')               AS do_dai_goc,
       LENGTH(TRIM('  Hà Nội  '))         AS do_dai_trim;
gocda_trimdo_dai_gocdo_dai_trim
[ Hà Nội ][Hà Nội]106

LENGTH đếm ký tự, không phải byte. Chữ có dấu trong UTF-8 chiếm nhiều byte hơn:

sql
SELECT name,
       LENGTH(name)       AS so_ky_tu,
       OCTET_LENGTH(name) AS so_byte
FROM products
WHERE sku IN ('PC-003', 'ACC-001');
nameso_ky_tuso_byte
PC văn phòng DSC Basic2224
Chuột Logitech G1021921

Cắt chuỗi: SUBSTRING, LEFT, RIGHT, POSITION ​

SUBSTRING(s FROM vi_tri FOR so_ky_tu) lấy một đoạn, vị trí đếm từ 1. Cũng viết được SUBSTRING(s, vi_tri, so_ky_tu). LEFT(s, n)/RIGHT(s, n) lấy n ký tự đầu/cuối.

sql
SELECT sku,
       SUBSTRING(sku FROM 1 FOR 3) AS loai,
       SUBSTRING(sku, 5)           AS so_thu_tu,
       LEFT(sku, 3)                AS loai_left
FROM products
WHERE product_id IN (1, 7, 10, 16);
skuloaiso_thu_tuloai_left
LAP-001LAP001LAP
PC-001PC-01PC-
PHN-001PHN001PHN
ACC-001ACC001ACC

Dòng PC-001 cho thấy giới hạn của việc cắt theo vị trí cố định: mã PC chỉ có 2 chữ nên lấy 3 ký tự bị dính dấu -. Khi độ dài thay đổi, hãy cắt theo vị trí của một ký tự mốc. POSITION('@' IN email) cho biết @ nằm ở đâu, từ đó lấy tên miền email:

sql
SELECT email,
       POSITION('@' IN email)                        AS vi_tri_a_cong,
       SUBSTRING(email FROM POSITION('@' IN email) + 1) AS ten_mien
FROM customers
WHERE customer_id IN (1, 3, 6, 8);
emailvi_tri_a_congten_mien
[email protected]10gmail.com
[email protected]9outlook.com
[email protected]9student.hcmute.edu.vn
[email protected]9yahoo.com

Thay thế: REPLACE ​

REPLACE(s, tu, thanh) thay mọi chỗ xuất hiện của tu bằng thanh, có phân biệt hoa thường:

sql
SELECT name,
       REPLACE(name, 'inch', '"') AS ten_ngan
FROM products
WHERE name LIKE '%inch%';
nameten_ngan
MacBook Air M2 13 inchMacBook Air M2 13 "
Màn hình LG UltraGear 27 inchMàn hình LG UltraGear 27 "
iPad Air M2 11 inchiPad Air M2 11 "

4. Hàm ngày giờ ​

Nhắc lại kiểu dữ liệu (Ngày 2): DATE chỉ có ngày (birth_date), TIMESTAMPTZ có ngày, giờ và múi giờ (order_date, created_at). Database DSC Shop đặt múi giờ Asia/Ho_Chi_Minh, nên giờ hiện ra kèm +07.

Thời điểm hiện tại ​

sql
SELECT CURRENT_DATE AS hom_nay,
       now()        AS bay_gio;
hom_naybay_gio
2026-10-172026-10-17 16:12:13.443293+07

Kết quả tùy lúc bạn chạy. now() (hoặc CURRENT_TIMESTAMP) trả thời điểm bắt đầu transaction, nên gọi nhiều lần trong một câu truy vấn vẫn ra cùng một giá trị.

EXTRACT: lấy một phần của ngày giờ ​

sql
SELECT order_id,
       order_date,
       EXTRACT(YEAR FROM order_date)  AS nam,
       EXTRACT(MONTH FROM order_date) AS thang,
       EXTRACT(HOUR FROM order_date)  AS gio
FROM orders
WHERE order_id IN (1, 22, 40);
order_idorder_datenamthanggio
12025-01-12 10:15:00+072025110
222025-11-11 23:30:00+0720251123
402026-10-14 22:00:00+0720261022

Các phần hay dùng khác: DAY, MINUTE, QUARTER (quý), ISODOW (thứ trong tuần, 1 là thứ Hai, 7 là Chủ nhật), EPOCH (số giây từ 1970).

Ghi chú dialect: MySQL và SQL Server có hàm riêng YEAR(d), MONTH(d), DAY(d). PostgreSQL không có các hàm này (báo lỗi function year(...) does not exist), hãy dùng EXTRACT(YEAR FROM d), cú pháp chuẩn SQL mà MySQL cũng hiểu.

DATE_TRUNC và ép về ngày ​

DATE_TRUNC('month', t) cắt về đầu tháng (tương tự 'year', 'week', 'day', 'hour'). Rất hay dùng để gom dữ liệu theo tháng khi học GROUP BY ở Ngày 9. order_date::date bỏ phần giờ, chỉ giữ ngày.

sql
SELECT order_id,
       order_date,
       DATE_TRUNC('month', order_date) AS dau_thang,
       order_date::date                AS chi_ngay
FROM orders
WHERE order_id IN (1, 22, 40);
order_idorder_datedau_thangchi_ngay
12025-01-12 10:15:00+072025-01-01 00:00:00+072025-01-12
222025-11-11 23:30:00+072025-11-01 00:00:00+072025-11-11
402026-10-14 22:00:00+072026-10-01 00:00:00+072026-10-14

TO_CHAR: định dạng để hiển thị ​

sql
SELECT order_id,
       TO_CHAR(order_date, 'DD/MM/YYYY HH24:MI') AS ngay_dat
FROM orders
WHERE order_id IN (1, 22, 40);
order_idngay_dat
112/01/2025 10:15
2211/11/2025 23:30
4014/10/2026 22:00

Kết quả của TO_CHAR là chuỗi: đẹp để hiển thị nhưng đừng dùng nó để so sánh hay sắp xếp ngày (chuỗi '12/01/2025' đứng trước '14/10/2024' khi sắp theo chữ).

INTERVAL: cộng trừ thời gian ​

INTERVAL '3 days', INTERVAL '2 hours', INTERVAL '1 month' là các khoảng thời gian. Cộng vào ngày giờ để ra ngày giờ mới:

sql
SELECT order_id,
       order_date,
       order_date + INTERVAL '3 days'   AS du_kien_giao,
       order_date - INTERVAL '2 hours'  AS truoc_2_gio
FROM orders
WHERE order_id IN (1, 40);
order_idorder_datedu_kien_giaotruoc_2_gio
12025-01-12 10:15:00+072025-01-15 10:15:00+072025-01-12 08:15:00+07
402026-10-14 22:00:00+072026-10-17 22:00:00+072026-10-14 20:00:00+07

Trừ hai DATE ra số ngày (số nguyên). Trừ hai TIMESTAMPTZ ra một INTERVAL:

sql
SELECT DATE '2026-10-17' - DATE '2026-09-02'       AS so_ngay,
       TIMESTAMPTZ '2026-10-17 12:00+07'
         - TIMESTAMPTZ '2026-10-14 22:00+07'       AS khoang_thoi_gian;
so_ngaykhoang_thoi_gian
452 days 14:00:00

(DATE '2026-10-17' là cách viết một hằng ngày tháng có kiểu rõ ràng.)

Kết hợp với now() để lọc "30 ngày gần đây":

sql
SELECT order_id, order_date, status
FROM orders
WHERE order_date >= now() - INTERVAL '30 days';

Kết quả khi chạy ngày 17/10/2026 (bạn chạy ngày khác sẽ ra khác, có thể 0 dòng):

order_idorder_datestatus
382026-09-20 16:30:00+07paid
392026-10-05 09:00:00+07paid
402026-10-14 22:00:00+07pending

AGE: tính tuổi ​

AGE(moc, ngay_sinh) trả khoảng cách dạng năm/tháng/ngày. Lấy phần năm bằng EXTRACT là ra tuổi. Ví dụ dùng mốc cố định 17/10/2026 để ai chạy cũng ra cùng kết quả; thực tế bạn dùng AGE(birth_date) (mốc là hôm nay).

sql
SELECT full_name,
       birth_date,
       AGE(DATE '2026-10-17', birth_date)                   AS tuoi_day_du,
       EXTRACT(YEAR FROM AGE(DATE '2026-10-17', birth_date)) AS tuoi
FROM customers
WHERE customer_id IN (1, 8, 10);
full_namebirth_datetuoi_day_dutuoi
Nguyễn Văn An2002-03-1524 years 7 mons 2 days24
Bùi Văn Hùng1985-04-1841 years 5 mons 29 days41
Dương Minh Khoa2005-02-1421 years 8 mons 3 days21

Lọc theo năm, tháng: dùng khoảng thay vì EXTRACT ​

WHERE EXTRACT(YEAR FROM order_date) = 2026 cho kết quả đúng, nhưng khi bảng lớn và có index trên order_date, PostgreSQL không dùng được index đó vì cột đã bị bọc trong hàm. Thói quen tốt là lọc bằng khoảng nửa mở như ở Ngày 3. Đơn quý 1/2026:

sql
SELECT order_id, order_date
FROM orders
WHERE order_date >= '2026-01-01'
  AND order_date <  '2026-04-01';
order_idorder_date
262026-01-08 09:30:00+07
272026-01-20 15:00:00+07
282026-02-02 10:45:00+07
292026-02-25 13:30:00+07
302026-03-15 21:00:00+07

Lý do chi tiết (gọi là sargability) sẽ được đo bằng số liệu ở Ngày 15 và Ngày 28.

5. Ép kiểu và làm tròn ​

CAST và :: ​

CAST(gia_tri AS kieu) là cú pháp chuẩn. PostgreSQL có cách viết ngắn gia_tri::kieu, hai cách như nhau.

sql
SELECT CAST('123' AS INT)       AS chuoi_sang_so,
       '2026-10-17'::DATE        AS chuoi_sang_ngay,
       2.5::INT                  AS lam_tron_2_5,
       3.49::INT                 AS lam_tron_3_49,
       price::TEXT || ' đ'        AS so_sang_chuoi
FROM products
WHERE product_id = 1;
chuoi_sang_sochuoi_sang_ngaylam_tron_2_5lam_tron_3_49so_sang_chuoi
1232026-10-173332990000 đ

Đổi NUMERIC sang INT là làm tròn (2.5 thành 3), không phải cắt.

Với cột ngày giờ, ::DATE giữ phần ngày, ::TIME giữ phần giờ (theo múi giờ của phiên, ở đây là +07):

sql
SELECT order_id,
       order_date,
       order_date::DATE AS ngay,
       order_date::TIME AS gio
FROM orders
WHERE order_id IN (1, 22);
order_idorder_datengaygio
12025-01-12 10:15:00+072025-01-1210:15:00
222025-11-11 23:30:00+072025-11-1123:30:00

Chuỗi không hợp lệ thì báo lỗi ngay:

sql
SELECT CAST('abc' AS INT);
text
ERROR:  invalid input syntax for type integer: "abc"
LINE 1: SELECT CAST('abc' AS INT);
                    ^

Ghi chú dialect: :: chỉ có trong PostgreSQL. Code cần chạy trên nhiều hệ thì dùng CAST(... AS ...). MySQL ép sang số nguyên bằng CAST(x AS SIGNED) chứ không phải AS INT.

ROUND ​

ROUND(x) làm tròn tới số nguyên, ROUND(x, n) giữ n chữ số thập phân. n âm thì làm tròn sang trái dấu phẩy: -3 là tròn nghìn, rất hợp với tiền VNĐ.

sql
SELECT order_id,
       product_id,
       quantity * unit_price * (1 - discount)            AS line_total,
       ROUND(quantity * unit_price * (1 - discount))     AS lam_tron_dong,
       ROUND(quantity * unit_price * (1 - discount), -3) AS lam_tron_nghin
FROM order_items
WHERE order_id IN (4, 22);
order_idproduct_idline_totallam_tron_donglam_tron_nghin
4205391000.0053910005391000
4172490000.0024900002490000
22205091500.0050915005092000
22124401000.0044010004401000

Chỉ làm tròn ở bước hiển thị cuối cùng. Làm tròn từng dòng rồi mới cộng tổng có thể lệch so với cộng rồi mới làm tròn.

6. COALESCE và NULLIF ​

COALESCE: giá trị đầu tiên không NULL ​

COALESCE(a, b, c, ...) trả đối số đầu tiên khác NULL. Dùng để thay NULL bằng giá trị mặc định, hoặc chọn nguồn dự phòng:

sql
SELECT full_name,
       COALESCE(phone, 'chưa có')             AS phone,
       COALESCE(email, phone, 'không liên lạc được') AS lien_he
FROM customers
WHERE customer_id IN (3, 5, 17);
full_namephonelien_he
Lê Hoàng Cườngchưa có[email protected]
Hoàng Thị Em09345678900934567890
Châu Ngọc Trâmchưa cókhông liên lạc được

Các đối số phải cùng kiểu (hoặc đổi được về cùng kiểu). COALESCE(cost_price, 'chưa có') sẽ lỗi vì trộn số với chữ; muốn vậy phải ép số sang chuỗi trước (Bài tập 6).

Cẩn thận: thay NULL bằng 0 có thể nói dối.

sql
SELECT name,
       price,
       cost_price,
       price - COALESCE(cost_price, 0) AS profit_sai
FROM products
WHERE cost_price IS NULL;
namepricecost_priceprofit_sai
HP Pavilion 1516990000NULL16990000
Lót chuột DSC XXL250000NULL250000

Báo cáo này nói chiếc HP lãi gần 17 triệu, điều không thể đúng. "Chưa biết giá nhập" không phải "giá nhập bằng 0". Chỉ thay NULL bằng 0 khi 0 thật sự đúng nghĩa (ví dụ "chưa có đơn nào" thì số đơn là 0).

NULLIF: biến một giá trị thành NULL ​

NULLIF(a, b) trả NULL nếu a = b, ngược lại trả a. Công dụng phổ biến nhất là tránh chia cho 0: biến mẫu số 0 thành NULL, phép chia ra NULL thay vì làm sập cả truy vấn.

sql
SELECT NULLIF(5, 5)     AS bang_nhau,
       NULLIF(5, 0)     AS khac_nhau,
       10 / NULLIF(0, 0) AS chia_an_toan;
bang_nhaukhac_nhauchia_an_toan
NULL5NULL

Quay lại câu bị lỗi division by zero ở mục 1:

sql
SELECT sku,
       price,
       stock_quantity,
       price / NULLIF(stock_quantity, 0) AS gia_chia_ton
FROM products
WHERE category_id = 11;
skupricestock_quantitygia_chia_ton
ACC-00479900008998750.000000000000
ACC-005599000012499166.666666666667
ACC-00626900000NULL

Công dụng thứ hai: biến chuỗi rỗng thành NULL, ví dụ NULLIF(TRIM(note), '') coi ghi chú chỉ có dấu cách như không có ghi chú.

7. Thực hành: làm sạch customers.city ​

Từ Ngày 3 ta đã biết cột city được nhập tay và rất lộn xộn:

sql
SELECT DISTINCT city
FROM customers
ORDER BY city;
city
Biên Hòa
Cần Thơ
đà nẵng
Đà Nẵng
Hải Phòng
Hà Nội
Hồ Chí Minh
TP.HCM
TP. Hồ Chí Minh
NULL

10 giá trị, nhưng thực ra chỉ có 6 thành phố cộng với "chưa rõ". Mục tiêu: viết một biểu thức biến mọi cách viết về một dạng chuẩn. Ta chọn dạng chuẩn là Hồ Chí Minh, Đà Nẵng, ... và Chưa rõ cho NULL.

Thử lần 1: TRIM rồi INITCAP để thống nhất hoa thường.

sql
SELECT DISTINCT
       city,
       INITCAP(TRIM(city)) AS buoc_1
FROM customers
ORDER BY city;
citybuoc_1
Biên HòaBiên Hòa
Cần ThơCần Thơ
đà nẵngĐà Nẵng
Đà NẵngĐà Nẵng
Hải PhòngHải Phòng
Hà NộiHà Nội
Hồ Chí MinhHồ Chí Minh
TP.HCMTp.Hcm
TP. Hồ Chí MinhTp. Hồ Chí Minh
NULLNULL

Đà Nẵng đã ổn. Nhưng INITCAP biến TP.HCM thành Tp.Hcm: nếu sau đó mới REPLACE(..., 'TP.HCM', ...) thì không khớp nữa vì REPLACE phân biệt hoa thường. Thứ tự gọi hàm quan trọng.

Thử lần 2: REPLACE các dạng viết tắt trước, INITCAP sau. Hai lần REPLACE lồng nhau: trong cùng đổi TP.HCM thành Hồ Chí Minh, ngoài đó bỏ tiền tố TP. .

sql
SELECT DISTINCT
       city,
       INITCAP(REPLACE(REPLACE(TRIM(city), 'TP.HCM', 'Hồ Chí Minh'), 'TP. ', '')) AS buoc_2
FROM customers
ORDER BY buoc_2;
citybuoc_2
Biên HòaBiên Hòa
Cần ThơCần Thơ
đà nẵngĐà Nẵng
Đà NẵngĐà Nẵng
Hải PhòngHải Phòng
Hà NộiHà Nội
Hồ Chí MinhHồ Chí Minh
TP.HCMHồ Chí Minh
TP. Hồ Chí MinhHồ Chí Minh
NULLNULL

Bước cuối: bọc ngoài bằng COALESCE cho NULL, rồi xem còn bao nhiêu giá trị khác nhau:

sql
SELECT DISTINCT
       COALESCE(
           INITCAP(REPLACE(REPLACE(TRIM(city), 'TP.HCM', 'Hồ Chí Minh'), 'TP. ', '')),
           'Chưa rõ'
       ) AS city_clean
FROM customers
ORDER BY city_clean;
city_clean
Biên Hòa
Cần Thơ
Chưa rõ
Đà Nẵng
Hải Phòng
Hà Nội
Hồ Chí Minh

Từ 10 còn 7, đúng 6 thành phố và một nhóm "Chưa rõ".

Biểu thức làm sạch cột customers.city chạy từ trong ra ngoài qua 5 bước: TRIM bỏ khoảng trắng thừa; REPLACE đổi 'TP.HCM' thành 'Hồ Chí Minh'; REPLACE bỏ tiền tố 'TP. ' nên 'TP. Hồ Chí Minh' thành 'Hồ Chí Minh'; INITCAP đổi 'đà nẵng' thành 'Đà Nẵng'; COALESCE đổi NULL thành 'Chưa rõ'. Sáu cách viết gốc 'TP. Hồ Chí Minh', 'Hồ Chí Minh', 'TP.HCM', 'Đà Nẵng', 'đà nẵng', NULL thu về ba giá trị 'Hồ Chí Minh', 'Đà Nẵng', 'Chưa rõ'. Cả cột có 10 giá trị khác nhau, còn 7 sau khi làm sạch. Nếu INITCAP chạy trước REPLACE thì 'TP.HCM' thành 'Tp.Hcm' và không khớp nữa.Biểu thức làm sạch cột customers.city chạy từ trong ra ngoài qua 5 bước: TRIM bỏ khoảng trắng thừa; REPLACE đổi 'TP.HCM' thành 'Hồ Chí Minh'; REPLACE bỏ tiền tố 'TP. ' nên 'TP. Hồ Chí Minh' thành 'Hồ Chí Minh'; INITCAP đổi 'đà nẵng' thành 'Đà Nẵng'; COALESCE đổi NULL thành 'Chưa rõ'. Sáu cách viết gốc 'TP. Hồ Chí Minh', 'Hồ Chí Minh', 'TP.HCM', 'Đà Nẵng', 'đà nẵng', NULL thu về ba giá trị 'Hồ Chí Minh', 'Đà Nẵng', 'Chưa rõ'. Cả cột có 10 giá trị khác nhau, còn 7 sau khi làm sạch. Nếu INITCAP chạy trước REPLACE thì 'TP.HCM' thành 'Tp.Hcm' và không khớp nữa.

File gốc: nền sáng · nền tối

Giờ tìm "mọi khách ở TP. Hồ Chí Minh" chỉ cần một điều kiện:

sql
SELECT customer_id,
       full_name,
       city,
       COALESCE(
           INITCAP(REPLACE(REPLACE(TRIM(city), 'TP.HCM', 'Hồ Chí Minh'), 'TP. ', '')),
           'Chưa rõ'
       ) AS city_clean
FROM customers
WHERE INITCAP(REPLACE(REPLACE(TRIM(city), 'TP.HCM', 'Hồ Chí Minh'), 'TP. ', '')) = 'Hồ Chí Minh'
ORDER BY customer_id;
customer_idfull_namecitycity_clean
1Nguyễn Văn AnTP. Hồ Chí MinhHồ Chí Minh
4Phạm Minh DuyTP. Hồ Chí MinhHồ Chí Minh
6Võ Quốc PhongHồ Chí MinhHồ Chí Minh
10Dương Minh KhoaTP. Hồ Chí MinhHồ Chí Minh
14Huỳnh Tấn PhátTP.HCMHồ Chí Minh

Lưu ý:

  • Đây mới chỉ là SELECT: bảng gốc không đổi. Ghi giá trị sạch vào bảng bằng UPDATE là việc của Ngày 6.
  • Phải lặp lại biểu thức trong WHERE vì alias city_clean chưa tồn tại lúc WHERE chạy (Ngày 3).
  • Chuỗi REPLACE lồng nhau chỉ hợp với vài trường hợp. Khi có nhiều quy tắc hơn, CASE (Ngày 13) hoặc một bảng ánh xạ "cách viết → tên chuẩn" kết hợp JOIN (Ngày 10) sẽ dễ bảo trì hơn. Còn cách bền vững nhất là chặn dữ liệu bẩn từ đầu: bảng cities riêng và khóa ngoại (Ngày 27).

Lỗi thường gặp ​

1. Chia số nguyên ra 0. stock_quantity / 200 * 100 ra 0 với mọi giá trị dưới 200.

sql
-- Sai: Sạc Anker 40 cái ra 0%
SELECT sku, stock_quantity / 200 * 100 FROM products WHERE category_id = 12;
-- Đúng: ra 20%
SELECT sku, stock_quantity * 100.0 / 200 FROM products WHERE category_id = 12;

2. Quên NULL lan truyền. price - cost_price, 'Tên: ' || email đều thành NULL khi một vế là NULL. Kiểm tra cột nào có thể NULL trước khi tính.

3. Lạm dụng COALESCE(x, 0). Giá nhập chưa biết không phải bằng 0; thay bừa sẽ ra lãi ảo. Hỏi "0 có đúng nghĩa không?" trước khi viết.

4. Chia cho 0 làm sập cả truy vấn. Bọc mẫu số bằng NULLIF(mau_so, 0).

5. Dùng hàm của MySQL trong PostgreSQL. YEAR(order_date), DATE_ADD(...), IFNULL(...) không có trong PostgreSQL. Dùng EXTRACT(YEAR FROM ...), + INTERVAL '...', COALESCE(...).

6. So sánh và sắp xếp bằng chuỗi ngày đã định dạng. TO_CHAR(order_date, 'DD/MM/YYYY') là chuỗi; sắp theo nó sẽ sai thứ tự. Sắp và lọc trên cột ngày gốc, chỉ TO_CHAR khi hiển thị.

7. Gọi hàm sai thứ tự khi làm sạch chuỗi. INITCAP trước REPLACE biến TP.HCM thành Tp.Hcm rồi REPLACE không khớp. Chạy thử từng bước với SELECT DISTINCT như mục 7.

Bài tập ​

Cơ bản ​

Bài 1. Cửa hàng giảm giá 15% cho điện thoại (category_id = 7). Hiển thị sku, name, price và giá sau giảm làm tròn tới nghìn đồng (gia_sale), đắt trước.

Đáp án
sql
SELECT sku,
       name,
       price,
       ROUND(price * 0.85, -3) AS gia_sale
FROM products
WHERE category_id = 7
ORDER BY price DESC;

Kết quả:

skunamepricegia_sale
PHN-001iPhone 15 128GB1999000016992000
PHN-002Samsung Galaxy S241899000016142000
PHN-003Xiaomi Redmi Note 1348900004157000
PHN-004Nokia 105 4G690000587000

Bài 2. In danh bạ dạng Tên <email> cho 5 khách đầu tiên (id 1 đến 5). Khách không có email thì hiện <không có email>.

Đáp án
sql
SELECT full_name || ' <' || COALESCE(email, 'không có email') || '>' AS lien_he
FROM customers
WHERE customer_id <= 5
ORDER BY customer_id;

Kết quả:

lien_he
Nguyễn Văn An <[email protected]>
Trần Thị Bình <[email protected]>
Lê Hoàng Cường <[email protected]>
Phạm Minh Duy <[email protected]>
Hoàng Thị Em <không có email>

Thiếu COALESCE thì dòng của Hoàng Thị Em thành NULL vì || gặp NULL.

Bài 3. Liệt kê các đơn đặt vào cuối tuần (thứ Bảy, Chủ nhật) từ đầu năm 2026. Hiển thị ngày dạng DD/MM/YYYY và số thứ trong tuần.

Đáp án
sql
SELECT order_id,
       TO_CHAR(order_date, 'DD/MM/YYYY') AS ngay,
       EXTRACT(ISODOW FROM order_date)   AS thu_trong_tuan
FROM orders
WHERE EXTRACT(ISODOW FROM order_date) IN (6, 7)
  AND order_date >= '2026-01-01'
ORDER BY order_date;

Kết quả:

order_idngaythu_trong_tuan
3015/03/20267
3608/08/20266
3820/09/20267

ISODOW đánh số thứ Hai là 1, Chủ nhật là 7. (Còn DOW đánh số Chủ nhật là 0.) Sắp xếp theo order_date gốc, không theo chuỗi ngay.

Vận dụng ​

Bài 4. Năm khách hàng trẻ nhất (đã khai ngày sinh) và tuổi của họ, tính tại ngày 17/10/2026.

Đáp án
sql
SELECT full_name,
       birth_date,
       EXTRACT(YEAR FROM AGE(DATE '2026-10-17', birth_date)) AS tuoi
FROM customers
WHERE birth_date IS NOT NULL
ORDER BY tuoi
LIMIT 5;

Kết quả:

full_namebirth_datetuoi
Dương Minh Khoa2005-02-1421
Võ Quốc Phong2004-05-1222
Huỳnh Tấn Phát2004-08-2022
Phạm Minh Duy2003-01-3023
Tạ Quang Sơn2002-12-1223

Thiếu WHERE birth_date IS NOT NULL thì sao? ORDER BY tuoi tăng dần đặt NULL cuối nên kết quả vẫn đúng ở đây, nhưng viết rõ điều kiện giúp truy vấn không phụ thuộc vào chi tiết đó. Sắp theo birth_date DESC cũng được và tránh phải tính tuổi để sắp.

Bài 5. Tính biên lợi nhuận theo phần trăm của các laptop (danh mục 3, 4): (price - cost_price) / cost_price * 100, làm tròn 1 chữ số thập phân. Sắp biên cao nhất trước, sản phẩm chưa có giá nhập xuống cuối.

Đáp án
sql
SELECT sku,
       name,
       price,
       cost_price,
       ROUND((price - cost_price) * 100.0 / cost_price, 1) AS bien_loi_nhuan_pct
FROM products
WHERE category_id IN (3, 4)
ORDER BY bien_loi_nhuan_pct DESC NULLS LAST;

Kết quả:

skunamepricecost_pricebien_loi_nhuan_pct
LAP-001ASUS ROG Strix G16329900002850000015.8
LAP-003Acer Nitro V 15219900001900000015.7
LAP-004Dell Inspiron 14184900001600000015.6
LAP-002Lenovo Legion 5299900002600000015.3
LAP-005MacBook Air M2 13 inch249900002200000013.6
LAP-006HP Pavilion 1516990000NULLNULL

price và cost_price là NUMERIC nên không bị chia nguyên; viết 100.0 vẫn là thói quen tốt.

Bài 6. Cũng bảng trên nhưng hiển thị biên lợi nhuận thành chữ, ví dụ 15.8%; sản phẩm chưa có giá nhập ghi chưa có giá nhập. Sắp theo sku.

Đáp án

COALESCE cần các đối số cùng kiểu, nên phải ép số sang TEXT trước khi nối %:

sql
SELECT sku,
       COALESCE(ROUND((price - cost_price) * 100.0 / cost_price, 1)::TEXT || '%',
                'chưa có giá nhập') AS bien_loi_nhuan
FROM products
WHERE category_id IN (3, 4)
ORDER BY sku;

Kết quả:

skubien_loi_nhuan
LAP-00115.8%
LAP-00215.3%
LAP-00315.7%
LAP-00415.6%
LAP-00513.6%
LAP-006chưa có giá nhập

Với LAP-006, cả biểu thức ROUND(...)::TEXT || '%' là NULL (vì || gặp NULL), nên COALESCE chuyển sang đối số thứ hai. Ở đây tính chất "NULL lan truyền" lại có ích.

Bài 7. Che số điện thoại khi hiển thị: giữ 4 số đầu và 3 số cuối, giữa là *** (ví dụ 0901***567). Khách không có số thì hiện ẩn. Làm cho khách id 1 đến 6.

Đáp án
sql
SELECT full_name,
       COALESCE(LEFT(phone, 4) || '***' || RIGHT(phone, 3), 'ẩn') AS phone_che
FROM customers
WHERE customer_id <= 6
ORDER BY customer_id;

Kết quả:

full_namephone_che
Nguyễn Văn An0901***567
Trần Thị Bình0912***678
Lê Hoàng Cườngẩn
Phạm Minh Duy0987***321
Hoàng Thị Em0934***890
Võ Quốc Phong0976***210

Viết bằng SUBSTRING cũng được: SUBSTRING(phone FROM 1 FOR 4) || '***' || SUBSTRING(phone FROM LENGTH(phone) - 2).

Thử thách ​

Bài 8. Tìm mọi khách ở Đà Nẵng, bất kể viết hoa hay thường, có khoảng trắng thừa hay không. Không được liệt kê từng cách viết.

Đáp án
sql
SELECT customer_id,
       full_name,
       city
FROM customers
WHERE LOWER(TRIM(city)) = 'đà nẵng'
ORDER BY customer_id;

Kết quả:

customer_idfull_namecity
3Lê Hoàng CườngĐà Nẵng
8Bùi Văn Hùngđà nẵng
18Mai Đức UyĐà Nẵng

Đưa cả hai vế về cùng một dạng (LOWER) rồi mới so sánh. city ILIKE 'đà nẵng' cũng cho cùng kết quả nhưng không xử lý khoảng trắng thừa.

Bài 9. Với các đơn chưa giao xong (pending, paid, shipped), cửa hàng cam kết giao trong 3 ngày kể từ lúc đặt. Hiển thị hạn giao (dạng DD/MM/YYYY) và số ngày khách đã chờ tính đến ngày 17/10/2026, đơn cũ nhất trước.

Đáp án
sql
SELECT order_id,
       order_date,
       TO_CHAR(order_date + INTERVAL '3 days', 'DD/MM/YYYY') AS han_giao,
       DATE '2026-10-17' - order_date::date AS so_ngay_cho
FROM orders
WHERE status IN ('pending', 'paid', 'shipped')
ORDER BY order_date;

Kết quả:

order_idorder_datehan_giaoso_ngay_cho
372026-09-02 10:00:00+0705/09/202645
382026-09-20 16:30:00+0723/09/202627
392026-10-05 09:00:00+0708/10/202612
402026-10-14 22:00:00+0717/10/20263

order_date::date bỏ phần giờ để phép trừ hai DATE ra số ngày nguyên. Trong ứng dụng thật, thay DATE '2026-10-17' bằng CURRENT_DATE.

Tóm tắt ​

  • / giữa hai số nguyên là chia nguyên (7 / 2 = 3); cần thập phân thì cho một vế là NUMERIC (7 / 2.0, 7::numeric / 2). % lấy phần dư, dùng được với NUMERIC.
  • Nhân chia trước cộng trừ, phép toán trước so sánh, so sánh trước NOT/AND/OR. Có nghi ngờ thì đặt ngoặc.
  • Phép toán và hàm gặp NULL thường trả NULL; CONCAT là ngoại lệ (bỏ qua NULL).
  • Hàm chuỗi: ||, CONCAT, UPPER, LOWER, INITCAP, TRIM, LENGTH, SUBSTRING, LEFT/RIGHT, POSITION, REPLACE.
  • Hàm ngày giờ: CURRENT_DATE, now(), EXTRACT, DATE_TRUNC, TO_CHAR, AGE; cộng trừ bằng INTERVAL; DATE - DATE ra số ngày.
  • Lọc theo khoảng thời gian bằng >= đầu AND < cuối, không bọc cột trong EXTRACT.
  • CAST(x AS kieu) hoặc x::kieu để đổi kiểu; ROUND(x, n) làm tròn, n = -3 là tròn nghìn.
  • COALESCE lấy giá trị khác NULL đầu tiên, nhưng đừng biến "chưa biết" thành 0 khi 0 sai nghĩa. NULLIF(x, 0) chặn lỗi chia cho 0.
  • Làm sạch chuỗi: thử từng bước bằng SELECT DISTINCT, chú ý thứ tự gọi hàm.

Đọc thêm ​


⬅️ Ngày 4: ORDER BY và LIMIT, sắp xếp và phân trang | 📚 Mục lục | Ngày 6: Thêm, sửa, xóa dữ liệu (INSERT, UPDATE, DELETE) ➡️

Phát hành theo giấy phép MIT.