SELECT count(*) AS tong_luot_thue, -- 16044 count(return_date) AS da_tra, -- 15861 (bỏ qua 183 dòng NULL) count(*) - count(return_date) AS chua_traFROM rental;
count(*) đếm dòng; count(cot) đếm giá trị không NULL của cột đó.
Hệ quả với LEFT JOIN
-- ❌ SAI: khách chưa thuê lần nào bị đếm thành 1SELECT c.customer_id, count(*) AS so_luotFROM customer cLEFT JOIN rental r ON r.customer_id = c.customer_idGROUP BY c.customer_id;-- ✅ ĐÚNG: đếm cột của bảng bên phảiSELECT c.customer_id, count(r.rental_id) AS so_luotFROM customer cLEFT JOIN rental r ON r.customer_id = c.customer_idGROUP BY c.customer_id;
Với LEFT JOIN, khách không khớp vẫn sinh ra 1 dòng (với các cột phải là NULL), nên count(*) trả về 1 thay vì 0.
1.3. avg và phép chia số nguyên
SELECT avg(length) FROM film;-- 115.2720000000000000 ← đúng, avg tự trả về numericSELECT sum(length) / count(*) FROM film;-- 115 ← ⚠️ SAI! chia hai số nguyên → cắt cụt phần thập phânSELECT sum(length)::numeric / count(*) FROM film;-- 115.272... ← đúng
SELECT f.title, string_agg(a.first_name || ' ' || a.last_name, ', ' ORDER BY a.last_name) AS dan_dien_vienFROM film fJOIN film_actor fa ON fa.film_id = f.film_idJOIN actor a ON a.actor_id = fa.actor_idWHERE f.film_id = 1GROUP BY f.film_id, f.title;
title | dan_dien_vien------------------+------------------------------------------------- Academy Dinosaur | Rock Dukakis, Mary Keitel, Penelope Guiness, ...
ORDER BYbên trong hàm tổng hợp quyết định thứ tự nối chuỗi — rất hữu ích.
2. GROUP BY
GROUP BY chia các dòng thành nhóm theo giá trị của một hoặc nhiều cột.
SELECT cot_nhom, ham_tong_hop(cot_khac)FROM ten_bangGROUP BY cot_nhom;
2.1. Nhóm theo một cột
SELECT rating, count(*) AS so_phimFROM filmGROUP BY ratingORDER BY so_phim DESC;
rating | so_phim--------+--------- PG-13 | 223 NC-17 | 210 R | 195 PG | 194 G | 178(5 rows)
2.2. Nhóm theo nhiều cột
SELECT rating, CASE WHEN length > 120 THEN 'Dài' ELSE 'Ngắn' END AS do_dai, count(*) AS so_phim, round(avg(rental_rate), 2) AS gia_tbFROM filmGROUP BY rating, do_daiORDER BY rating, do_dai;
Mỗi tổ hợp giá trị tạo thành một nhóm.
2.3. GROUP BY với JOIN
SELECT c.name AS the_loai, count(*) AS so_phim, round(avg(f.length), 1) AS thoi_luong_tbFROM category cJOIN film_category fc ON fc.category_id = c.category_idJOIN film f ON f.film_id = fc.film_idGROUP BY c.category_id, c.nameORDER BY so_phim DESC;
Vì sao GROUP BY c.category_id, c.name mà không chỉ c.name?
Nhóm theo khóa chính rồi thêm các cột phụ thuộc là thói quen tốt: nếu hai thể loại trùng tên, nhóm theo name sẽ gộp nhầm chúng lại.
Thực ra PostgreSQL đủ thông minh để cho phép GROUP BY c.category_id rồi SELECT c.name (vì category_id là khóa chính nên name được xác định duy nhất). Tính năng này gọi là functional dependency và chỉ PostgreSQL 9.1+ mới có.
2.4. Quy tắc bắt buộc
Mọi cột trong SELECT phải hoặc:
Xuất hiện trong GROUP BY, hoặc
Nằm trong một hàm tổng hợp.
-- ❌ LỖISELECT rating, title, count(*)FROM filmGROUP BY rating;-- ERROR: column "film.title" must appear in the GROUP BY clause-- or be used in an aggregate function
Lý do hiển nhiên khi nghĩ kỹ: nhóm rating = 'R' có 195 phim với 195 tựa đề khác nhau — Postgres biết chọn tựa nào?
Cách sửa tùy ý định:
-- Muốn một tựa bất kỳ đại diệnSELECT rating, min(title), count(*) FROM film GROUP BY rating;-- Muốn tất cả tựaSELECT rating, string_agg(title, ', '), count(*) FROM film GROUP BY rating;-- Muốn tựa của phim dài nhất mỗi nhóm → dùng DISTINCT ONSELECT DISTINCT ON (rating) rating, title, lengthFROM film ORDER BY rating, length DESC, film_id;
2.5. GROUP BY không cần hàm tổng hợp
SELECT customer_id FROM payment GROUP BY customer_id;-- tương đươngSELECT DISTINCT customer_id FROM payment;
Khác với toán tử =, GROUP BY coi tất cả NULL là bằng nhau và tạo một nhóm NULL duy nhất:
SELECT return_date IS NULL AS chua_tra, count(*)FROM rentalGROUP BY return_date IS NULL;
chua_tra | count----------+------- f | 15861 t | 183
3. HAVING
HAVING lọc nhóm sau khi GROUP BY đã chạy.
SELECT cot_nhom, ham_tong_hop(x)FROM ten_bangGROUP BY cot_nhomHAVING dieu_kien_tren_nhom;
3.1. WHERE vs HAVING
WHERE
HAVING
Lọc cái gì
Từng dòng
Từng nhóm
Chạy khi nào
Trước GROUP BY
Sau GROUP BY
Dùng hàm tổng hợp
❌ Không
✅ Có
Dùng bí danh cột
❌ Không
✅ Có (đặc cách PostgreSQL)
-- Tìm khách hàng chi tiêu trên $200SELECT customer_id, sum(amount) AS tong_chiFROM paymentGROUP BY customer_idHAVING sum(amount) > 200ORDER BY tong_chi DESC;
customer_id | tong_chi-------------+---------- 148 | 211.55 526 | 208.58 178 | 194.61 ← không, dòng này bị loại
Không thể viết WHERE sum(amount) > 200 — tại thời điểm WHERE chạy, các nhóm chưa tồn tại.
3.2. Dùng cả hai
-- Lọc dòng bằng WHERE (trước), lọc nhóm bằng HAVING (sau)SELECT customer_id, count(*) AS so_lan_tt, sum(amount) AS tong_chiFROM paymentWHERE payment_date >= '2007-01-01' -- ① lọc dòngGROUP BY customer_id -- ② gom nhómHAVING count(*) > 30 -- ③ lọc nhómORDER BY tong_chi DESC; -- ④ sắp xếp
Ưu tiên WHERE hơn HAVING khi có thể
-- ❌ Chậm: gom nhóm tất cả rồi mới bỏ điGROUP BY rating HAVING rating <> 'NC-17'-- ✅ Nhanh: loại bỏ dòng trước khi gom nhómWHERE rating <> 'NC-17' GROUP BY rating
WHERE giảm số dòng phải xử lý; HAVING chỉ vứt bỏ kết quả đã tính xong. Với bảng lớn, chênh lệch rất đáng kể.
3.3. HAVING không cần GROUP BY
Khi không có GROUP BY, toàn bảng là một nhóm:
SELECT count(*) FROM film HAVING count(*) > 500;-- trả về 1000SELECT count(*) FROM film HAVING count(*) > 5000;-- trả về 0 dòng (không phải giá trị 0!)
Ít dùng nhưng đôi khi tiện để kiểm tra điều kiện tồn tại.
3.4. Ví dụ tổng hợp thực tế
Tìm các thể loại có trên 60 phim và giá thuê trung bình trên $2.90:
SELECT c.name AS the_loai, count(*) AS so_phim, round(avg(f.rental_rate), 2) AS gia_tb, round(avg(f.length), 1) AS thoi_luong_tbFROM category cJOIN film_category fc ON fc.category_id = c.category_idJOIN film f ON f.film_id = fc.film_idWHERE f.rating <> 'NC-17'GROUP BY c.category_id, c.nameHAVING count(*) > 40 AND avg(f.rental_rate) > 2.90ORDER BY gia_tb DESC;
4. Hàm cửa sổ — khi GROUP BY là không đủ
GROUP BYthu gọn nhiều dòng thành một. Nếu bạn muốn giữ nguyên số dòng nhưng vẫn có giá trị tổng hợp, dùng hàm cửa sổ (window function).
SELECT title, rating, length, avg(length) OVER (PARTITION BY rating) AS tb_cung_rating, length - avg(length) OVER (PARTITION BY rating) AS chenh_lech, rank() OVER (PARTITION BY rating ORDER BY length DESC) AS thu_hangFROM filmWHERE rating = 'G'ORDER BY thu_hangLIMIT 5;
So sánh trực quan:
GROUP BY
OVER (PARTITION BY ...)
Số dòng kết quả
Bằng số nhóm
Bằng số dòng gốc
Giữ chi tiết từng dòng
❌ Không
✅ Có
Các hàm cửa sổ hay dùng:
row_number() OVER (ORDER BY length DESC) -- 1, 2, 3, 4 (luôn khác nhau)rank() OVER (ORDER BY length DESC) -- 1, 2, 2, 4 (nhảy số khi hòa)dense_rank() OVER (ORDER BY length DESC) -- 1, 2, 2, 3 (không nhảy số)lag(length) OVER (ORDER BY film_id) -- giá trị dòng trướclead(length) OVER (ORDER BY film_id) -- giá trị dòng sausum(amount) OVER (ORDER BY payment_date) -- tổng lũy kế
Ví dụ — top 3 phim dài nhất mỗi thể loại:
WITH xep_hang AS ( SELECT c.name AS the_loai, f.title, f.length, row_number() OVER (PARTITION BY c.category_id ORDER BY f.length DESC, f.film_id) AS hang FROM film f JOIN film_category fc ON fc.film_id = f.film_id JOIN category c ON c.category_id = fc.category_id)SELECT the_loai, title, lengthFROM xep_hangWHERE hang <= 3ORDER BY the_loai, hang;
Không lọc hàm cửa sổ trong WHERE
Hàm cửa sổ được tính sauWHERE và GROUP BY, nên WHERE row_number() <= 3 sẽ báo lỗi. Phải bọc trong CTE hoặc subquery như ví dụ trên. → CTE và truy vấn đệ quy
5. Bài tập
Đếm số phim theo từng rating, chỉ hiện các rating có trên 200 phim.
Tính tổng doanh thu (sum(amount)) theo từng nhân viên (staff_id).
Tìm 5 khách hàng thuê nhiều phim nhất, hiển thị cả họ tên.
Với mỗi thể loại, đếm số phim thuộc từng rating — dùng FILTER.
Đếm số lượt thuê theo từng tháng của năm 2005.
Tìm các diễn viên đóng trên 35 phim.
Giải thích vì sao câu sau bị lỗi và đưa ra 2 cách sửa khác nhau:
SELECT rating, title, count(*) FROM film GROUP BY rating;
Với mỗi khách hàng, tính tổng chi tiêu và tỷ lệ phần trăm so với tổng doanh thu toàn hệ thống (gợi ý: hàm cửa sổ).
Đáp án
1.
SELECT rating, count(*) AS so_phimFROM filmGROUP BY ratingHAVING count(*) > 200ORDER BY so_phim DESC;
Kết quả: PG-13 (223) và NC-17 (210).
2.
SELECT s.staff_id, s.first_name || ' ' || s.last_name AS nhan_vien, count(*) AS so_giao_dich, sum(p.amount) AS doanh_thuFROM staff sJOIN payment p ON p.staff_id = s.staff_idGROUP BY s.staff_id, s.first_name, s.last_nameORDER BY doanh_thu DESC;
3.
SELECT c.customer_id, c.first_name || ' ' || c.last_name AS khach_hang, count(r.rental_id) AS so_luot_thueFROM customer cJOIN rental r ON r.customer_id = c.customer_idGROUP BY c.customer_id, c.first_name, c.last_nameORDER BY so_luot_thue DESC, c.customer_idLIMIT 5;
4.
SELECT c.name AS the_loai, count(*) AS tong, count(*) FILTER (WHERE f.rating = 'G') AS g, count(*) FILTER (WHERE f.rating = 'PG') AS pg, count(*) FILTER (WHERE f.rating = 'PG-13') AS pg13, count(*) FILTER (WHERE f.rating = 'R') AS r, count(*) FILTER (WHERE f.rating = 'NC-17') AS nc17FROM category cJOIN film_category fc ON fc.category_id = c.category_idJOIN film f ON f.film_id = fc.film_idGROUP BY c.category_id, c.nameORDER BY c.name;
5.
SELECT date_trunc('month', rental_date)::date AS thang, count(*) AS so_luotFROM rentalWHERE rental_date >= '2005-01-01' AND rental_date < '2006-01-01'GROUP BY thangORDER BY thang;
date_trunc cắt timestamp về đầu tháng — cách chuẩn để nhóm theo thời gian. → Kiểu dữ liệu chuyên sâu
6.
SELECT a.actor_id, a.first_name || ' ' || a.last_name AS dien_vien, count(*) AS so_phimFROM actor aJOIN film_actor fa ON fa.actor_id = a.actor_idGROUP BY a.actor_id, a.first_name, a.last_nameHAVING count(*) > 35ORDER BY so_phim DESC;
7. Lỗi vì title không nằm trong GROUP BY cũng không nằm trong hàm tổng hợp — mỗi nhóm rating có hàng trăm tựa khác nhau.
Cách 1 — gom tựa thành chuỗi:
SELECT rating, string_agg(title, ', ' ORDER BY title), count(*)FROM film GROUP BY rating;
Cách 2 — nhóm theo cả hai cột (mỗi nhóm chỉ 1 dòng, count luôn = 1, ít hữu ích nhưng hợp lệ):
SELECT rating, title, count(*) FROM film GROUP BY rating, title;
Cách 3 (thường là ý người viết muốn) — dùng hàm cửa sổ để giữ chi tiết:
SELECT rating, title, count(*) OVER (PARTITION BY rating) FROM film;
8.
SELECT customer_id, sum(amount) AS tong_chi, round(100.0 * sum(amount) / sum(sum(amount)) OVER (), 3) AS phan_tramFROM paymentGROUP BY customer_idORDER BY tong_chi DESCLIMIT 10;
sum(sum(amount)) OVER () trông lạ nhưng hợp lệ: hàm tổng hợp bên trong chạy trước (theo nhóm), hàm cửa sổ bên ngoài chạy sau (trên toàn bộ kết quả đã nhóm).
6. Tóm tắt
Hàm tổng hợp bỏ qua NULL, trừ count(*).
Với LEFT JOIN, dùng count(cot_bang_phai) chứ không phải count(*).
FILTER (WHERE ...) cho phép mỗi hàm tổng hợp có điều kiện riêng.
Mọi cột trong SELECT phải nằm trong GROUP BY hoặc trong hàm tổng hợp.
WHERE lọc dòng (trước nhóm), HAVING lọc nhóm (sau nhóm). Ưu tiên WHERE để nhanh hơn.
Chia số nguyên cho số nguyên bị cắt cụt — nhớ ép ::numeric.
Cần giữ chi tiết từng dòng mà vẫn có giá trị tổng hợp → dùng hàm cửa sổ OVER (PARTITION BY ...).