SQL 11 — Hàm ngày tháng, chuỗi & số
Mở đầu: vì sao cần hàm tích hợp?
Dữ liệu thô gần như không bao giờ ở đúng hình dạng bạn cần để báo cáo. created_at là một dấu thời gian chính xác đến micro-giây, nhưng sếp chỉ muốn biết "mỗi tháng có bao nhiêu giao dịch". balance là một số thập phân dài dằng dặc, nhưng người dùng muốn nhìn thấy 1.250.000 VND. Tên khách hàng nhập tay đầy khoảng trắng thừa và chữ hoa lộn xộn, nhưng bạn cần so khớp chính xác.
Đây chính là lúc các hàm tích hợp (built-in functions) phát huy tác dụng. Bài này đi sâu vào ba nhóm hàm bạn dùng hằng ngày — ngày tháng/thời gian, chuỗi, số — cộng thêm hai kỹ năng nền tảng: ép kiểu (CAST) và xử lý NULL. Tất cả ví dụ dùng cú pháp PostgreSQL và chạy được ngay trong SQL Builder.
Schema sandbox dùng xuyên suốt:
customers(id, full_name, city, created_at)accounts(id, customer_id, account_no, balance, currency)transactions(id, account_id, amount, kind ['credit'|'debit'], created_at)employees(id, name, department_id, salary, hired_at)departments(id, name)
Hàm ngày tháng & thời gian
Lấy thời điểm hiện tại
Trước khi xử lý ngày trong cột, bạn cần biết "bây giờ" là khi nào. PostgreSQL có vài hàm trả về thời điểm hiện tại với mức chi tiết khác nhau:
| Hàm | Trả về | Ghi chú |
|---|---|---|
CURRENT_DATE | Ngày hôm nay (không có giờ) | Kiểu date |
CURRENT_TIME | Giờ hiện tại | Kiểu time |
NOW() / CURRENT_TIMESTAMP | Ngày + giờ + timezone | Kiểu timestamptz |
LOCALTIMESTAMP | Ngày + giờ (không timezone) | Kiểu timestamp |
Điểm quan trọng: trong cùng một câu lệnh, NOW() luôn trả về một giá trị duy nhất — thời điểm bắt đầu transaction — chứ không phải "tick" thay đổi từng dòng. Điều này giúp tính toán nhất quán.
▶ Chạy được trong SQL Builder
SELECT
CURRENT_DATE AS hom_nay,
NOW() AS bay_gio,
CURRENT_TIMESTAMP AS dau_transaction;
date_trunc — cắt cụt về mốc thời gian
date_trunc(field, source) "cắt cụt" một dấu thời gian về đầu của đơn vị bạn chỉ định. Ví dụ date_trunc('month', '2026-06-30 14:25:00') trả về 2026-06-01 00:00:00. Đây là công cụ số một để gom nhóm theo tháng/quý/năm.
Các mốc thường dùng: 'hour', 'day', 'week', 'month', 'quarter', 'year'.
Ví dụ kinh điển: đếm số giao dịch theo từng tháng.
▶ Chạy được trong SQL Builder
SELECT
date_trunc('month', created_at) AS thang,
COUNT(*) AS so_giao_dich,
SUM(amount) AS tong_tien
FROM transactions
GROUP BY date_trunc('month', created_at)
ORDER BY thang;
Vì ta GROUP BY chính biểu thức date_trunc, mọi giao dịch trong cùng một tháng đều "rơi" về cùng một mốc đầu tháng và được gom lại.
EXTRACT & DATE_PART — moi ra một thành phần
Khi bạn chỉ cần một con số — năm, tháng, ngày trong tuần — dùng EXTRACT(field FROM source). Hàm DATE_PART('field', source) cho kết quả tương đương, chỉ khác cú pháp.
| Field | Ý nghĩa | Ví dụ kết quả |
|---|---|---|
year | Năm | 2026 |
month | Tháng (1–12) | 6 |
day | Ngày trong tháng | 30 |
dow | Ngày trong tuần (0=CN) | 2 |
quarter | Quý (1–4) | 2 |
hour | Giờ | 14 |
Ví dụ: tìm năm tuyển dụng của từng nhân viên.
▶ Chạy được trong SQL Builder
SELECT
name,
hired_at,
EXTRACT(year FROM hired_at) AS nam_tuyen_dung
FROM employees
ORDER BY nam_tuyen_dung;
Đếm nhân viên được tuyển theo từng năm (kết hợp EXTRACT với GROUP BY):
▶ Chạy được trong SQL Builder
SELECT
EXTRACT(year FROM hired_at) AS nam,
COUNT(*) AS so_nhan_vien
FROM employees
GROUP BY EXTRACT(year FROM hired_at)
ORDER BY nam;
AGE — khoảng cách giữa hai mốc
AGE(end, start) trả về khoảng thời gian dạng "năm tháng ngày" giữa hai mốc. Nếu chỉ truyền một tham số, PostgreSQL ngầm so với CURRENT_DATE: AGE(hired_at) = thâm niên tính đến hôm nay.
▶ Chạy được trong SQL Builder
SELECT
name,
hired_at,
AGE(CURRENT_DATE, hired_at) AS tham_nien,
EXTRACT(year FROM AGE(CURRENT_DATE, hired_at)) AS so_nam_lam
FROM employees
ORDER BY so_nam_lam DESC;
Mẹo: bọc AGE(...) trong EXTRACT(year FROM ...) để lấy đúng số năm tròn — hữu ích khi xét điều kiện thâm niên.
Cộng trừ INTERVAL
Bạn cộng/trừ thời gian bằng cách thêm một INTERVAL. Cú pháp: INTERVAL '30 days', INTERVAL '1 month', INTERVAL '2 years'. Phép toán trả về cùng kiểu với toán hạng ngày.
Ví dụ: lọc các giao dịch trong 30 ngày gần nhất.
▶ Chạy được trong SQL Builder
SELECT
id,
account_id,
amount,
created_at
FROM transactions
WHERE created_at >= NOW() - INTERVAL '30 days'
ORDER BY created_at DESC;
Tìm nhân viên đã làm trên 5 năm bằng cách so hired_at với hôm nay trừ đi 5 năm:
▶ Chạy được trong SQL Builder
SELECT
name,
hired_at
FROM employees
WHERE hired_at <= CURRENT_DATE - INTERVAL '5 years'
ORDER BY hired_at;
to_char — định dạng ngày thành chuỗi đẹp
to_char(value, format) biến ngày (hoặc số) thành chuỗi theo mẫu. Với ngày, các mẫu hay dùng: YYYY (năm 4 chữ số), MM (tháng), DD (ngày), HH24:MI (giờ:phút), Mon/Month (tên tháng).
▶ Chạy được trong SQL Builder
SELECT
date_trunc('month', created_at) AS moc_thang,
to_char(date_trunc('month', created_at), 'YYYY-MM') AS nhan_thang,
COUNT(*) AS so_gd
FROM transactions
GROUP BY date_trunc('month', created_at)
ORDER BY moc_thang;
Cột nhan_thang cho ra chuỗi gọn gàng kiểu 2026-06 rất tiện hiển thị trên dashboard.
Hàm chuỗi
Chuỗi do người dùng nhập gần như luôn "bẩn": khoảng trắng thừa, chữ hoa chữ thường lẫn lộn, dấu phân cách không nhất quán. Nhóm hàm chuỗi giúp bạn làm sạch và biến đổi.
| Hàm | Công dụng | Ví dụ |
|---|---|---|
LENGTH(s) | Độ dài chuỗi | LENGTH('Hà Nội') → 6 |
LOWER(s) / UPPER(s) | Chuyển hoa/thường | LOWER('ABc') → abc |
TRIM(s) | Bỏ khoảng trắng đầu/cuối | TRIM(' x ') → x |
SUBSTRING(s FROM a FOR b) | Lấy đoạn con | xem dưới |
POSITION(sub IN s) | Vị trí xuất hiện | POSITION('@' IN email) |
REPLACE(s, from, to) | Thay thế | REPLACE('a-b','-','_') → a_b |
LEFT(s, n) / RIGHT(s, n) | Lấy n ký tự trái/phải | LEFT('12345', 2) → 12 |
CONCAT(a, b, ...) / || | Nối chuỗi | 'a' || 'b' → ab |
SPLIT_PART(s, delim, n) | Tách & lấy phần thứ n | xem dưới |
Làm sạch & chuẩn hoá
Khi so khớp tên hoặc thành phố, hãy chuẩn hoá trước: bỏ khoảng trắng thừa và đưa về chữ thường.
▶ Chạy được trong SQL Builder
SELECT
full_name,
LOWER(TRIM(full_name)) AS ten_chuan_hoa,
LENGTH(TRIM(full_name)) AS do_dai
FROM customers
ORDER BY do_dai DESC;
Tách & lấy phần — SPLIT_PART, LEFT/RIGHT
SPLIT_PART(s, delim, n) chia chuỗi theo dấu phân cách rồi lấy mảnh thứ n (đếm từ 1). Cực hữu ích để tách họ/tên, hoặc lấy phần tên miền của email.
Giả sử account_no có dạng VN-001234. Lấy mã chi nhánh (phần đầu) và số tài khoản (phần sau):
▶ Chạy được trong SQL Builder
SELECT
account_no,
SPLIT_PART(account_no, '-', 1) AS ma_chi_nhanh,
SPLIT_PART(account_no, '-', 2) AS so_tk,
RIGHT(account_no, 4) AS bon_so_cuoi
FROM accounts;
Nối chuỗi & lấy chữ cái đầu của tên
Toán tử || nối chuỗi, còn CONCAT() an toàn hơn vì coi NULL như chuỗi rỗng (trong khi || gặp NULL sẽ ra NULL). Kết hợp LEFT để lấy chữ cái đầu mỗi từ:
▶ Chạy được trong SQL Builder
SELECT
full_name,
city,
CONCAT(full_name, ' (', city, ')') AS hien_thi,
UPPER(LEFT(full_name, 1)) AS chu_cai_dau
FROM customers
ORDER BY chu_cai_dau;
SUBSTRING & POSITION
Lấy phần "tên đăng nhập" trước dấu @ của một email giả định bằng cách dùng POSITION tìm vị trí @ rồi SUBSTRING cắt:
▶ Chạy được trong SQL Builder
SELECT
full_name,
SUBSTRING(full_name FROM 1 FOR 3) AS ba_ky_tu_dau,
POSITION(' ' IN full_name) AS vi_tri_dau_cach
FROM customers;
Hàm số & làm tròn
Số tiền cần làm tròn, lãi suất cần ép về 2 chữ số thập phân, mã số cần lấy phần dư. Các hàm số xử lý tất cả.
| Hàm | Công dụng | Ví dụ |
|---|---|---|
ROUND(n, d) | Làm tròn đến d chữ số | ROUND(1234.567, 2) → 1234.57 |
CEIL(n) | Làm tròn lên | CEIL(1.1) → 2 |
FLOOR(n) | Làm tròn xuống | FLOOR(1.9) → 1 |
ABS(n) | Giá trị tuyệt đối | ABS(-50) → 50 |
MOD(a, b) | Phần dư | MOD(10, 3) → 1 |
POWER(a, b) | Luỹ thừa | POWER(2, 10) → 1024 |
Ví dụ: tính số dư trung bình mỗi tài khoản, làm tròn 0 chữ số, và phân loại chẵn/lẻ theo id:
▶ Chạy được trong SQL Builder
SELECT
account_no,
balance,
ROUND(balance, 0) AS so_du_lam_tron,
ABS(balance) AS gia_tri_tuyet_doi,
MOD(id, 2) AS chan_le
FROM accounts
ORDER BY balance DESC;
debit thường lưu dưới dạng số âm; ABS() giúp lấy độ lớn giao dịch bất kể chiều.
Ép kiểu: CAST và ::
Đôi khi kiểu dữ liệu không khớp với phép toán bạn muốn. Ví dụ chia hai số nguyên trong PostgreSQL cho ra số nguyên (cắt cụt phần thập phân) — bạn phải ép một vế về numeric để có kết quả thập phân.
Có hai cú pháp tương đương:
- Chuẩn SQL:
CAST(value AS type) - Rút gọn của PostgreSQL:
value::type
▶ Chạy được trong SQL Builder
SELECT
COUNT(*) FILTER (WHERE kind = 'credit') AS so_credit,
COUNT(*) AS tong,
ROUND(
COUNT(*) FILTER (WHERE kind = 'credit')::numeric / COUNT(*) * 100,
1
) AS phan_tram_credit
FROM transactions;
Nếu bỏ ::numeric, phép chia hai số nguyên sẽ ra 0 hoặc 1 — một lỗi rất hay gặp. Ép kiểu cũng dùng để chuyển chuỗi '2026-06-30' thành date: '2026-06-30'::date.
Xử lý NULL: COALESCE, NULLIF, IS DISTINCT FROM
NULL nghĩa là "không biết". Bất kỳ phép toán nào chạm vào NULL đều cho NULL, và so sánh = NULL luôn ra "unknown" chứ không phải TRUE/FALSE. Ba công cụ sau giúp bạn kiểm soát:
COALESCE(a, b, c, ...)— trả về giá trị không NULL đầu tiên. Dùng để đặt giá trị mặc định.NULLIF(a, b)— trả về NULL nếua = b, ngược lại trả vềa. Mẹo kinh điển: chống chia cho 0.IS DISTINCT FROM— so sánh "an toàn với NULL", coi NULL như một giá trị bình thường (NULLIS DISTINCT FROM5 → TRUE).
Ví dụ: hiển thị city, thay NULL bằng chữ "Chưa rõ":
▶ Chạy được trong SQL Builder
SELECT
full_name,
COALESCE(city, 'Chưa rõ') AS thanh_pho
FROM customers
ORDER BY full_name;
Chống chia cho 0 bằng NULLIF — nếu mẫu số bằng 0, biến nó thành NULL để phép chia trả NULL thay vì lỗi:
▶ Chạy được trong SQL Builder
SELECT
d.name AS phong_ban,
SUM(e.salary) AS tong_luong,
ROUND(
SUM(e.salary)::numeric / NULLIF(COUNT(e.id), 0),
0
) AS luong_tb
FROM departments d
LEFT JOIN employees e ON e.department_id = d.id
GROUP BY d.name
ORDER BY tong_luong DESC NULLS LAST;
Phòng ban không có nhân viên có COUNT(e.id) = 0; NULLIF biến nó thành NULL, tránh lỗi "division by zero", và luong_tb ra NULL một cách hợp lý.
Ví dụ tổng hợp: báo cáo giao dịch theo tháng có định dạng tiền tệ
Gộp tất cả kỹ năng: nhóm theo tháng bằng date_trunc, định dạng nhãn tháng bằng to_char, tính tổng tiền và định dạng theo kiểu tiền tệ, xử lý chiều credit/debit bằng ABS.
▶ Chạy được trong SQL Builder
SELECT
to_char(date_trunc('month', created_at), 'YYYY-MM') AS thang,
COUNT(*) AS so_gd,
SUM(amount) FILTER (WHERE kind = 'credit') AS tong_credit,
ABS(COALESCE(SUM(amount) FILTER (WHERE kind = 'debit'), 0)) AS tong_debit,
to_char(SUM(amount), 'FM999G999G999D00') AS rong_dinh_dang
FROM transactions
GROUP BY date_trunc('month', created_at)
ORDER BY thang;
Mẫu 'FM999G999G999D00' của to_char: 9 là chữ số (ẩn nếu không có), G là dấu phân cách hàng nghìn, D là dấu thập phân, FM bỏ khoảng trắng đệm. Kết quả ra chuỗi kiểu 1.250.000,00 — sẵn sàng hiển thị.
Sơ đồ: luồng xử lý một cột thời gian
Tóm tắt
- Ngày tháng:
CURRENT_DATE/NOW()lấy hiện tại;date_trunc('month', col)để gom nhóm theo mốc;EXTRACT/DATE_PARTmoi một thành phần;AGEtính khoảng cách; cộng/trừINTERVAL '...'để dịch chuyển thời gian;to_charđịnh dạng hiển thị. - Chuỗi:
TRIM+LOWERđể chuẩn hoá;SPLIT_PART,LEFT/RIGHT,SUBSTRINGđể tách;||vàCONCATđể nối (CONCAT an toàn với NULL). - Số:
ROUND,CEIL,FLOOR,ABS,MOD,POWERcho mọi nhu cầu tính toán và làm tròn. - Ép kiểu:
CAST(x AS type)hoặcx::type— nhớ ép::numerictrước khi chia số nguyên. - NULL:
COALESCEđặt mặc định,NULLIFchống chia 0,IS DISTINCT FROMso sánh an toàn.
Tự kiểm tra
- Khác biệt giữa
date_trunc('month', created_at)vàEXTRACT(month FROM created_at)là gì? Khi nào dùng cái nào? - Vì sao
COUNT(*) / COUNT(*)(hai số nguyên) có thể cho kết quả sai khi tính phần trăm, và::numericsửa nó thế nào? - Viết một điều kiện
WHERElọc các giao dịch trong 7 ngày gần nhất bằngINTERVAL. COALESCE(city, 'Chưa rõ')vàNULLIF(count, 0)mỗi cái giải quyết vấn đề gì với NULL?- Cho
account_no = 'VN-001234', làm sao lấy ra001234bằngSPLIT_PART? Còn1234bằngRIGHT? - Tại sao
'a' || NULLcho NULL nhưngCONCAT('a', NULL)cho'a'?
Đọc tiếp
Bài viết liên quan
Cách index hoạt động (B-Tree), đọc EXPLAIN, seq scan vs index scan và mẫu tối ưu truy vấn.
Khoá, ràng buộc, quan hệ và chuẩn hoá 1NF/2NF/3NF — thiết kế lược đồ đúng từ đầu.
Kết nhiều bảng đúng cách: các loại JOIN, bẫy thường gặp và phép hợp tập.
Truy vấn lồng, CTE (WITH), CTE đệ quy và hàm cửa sổ — vũ khí cho phân tích nâng cao.