EzCode # Bài 12 — Bộ lọc và tìm kiếm
Học xong bài này bạn sẽ:
- Dựng được câu SQL thay đổi theo lựa chọn của người dùng
- Sinh được số lượng dấu
?động cho mệnh đềIN (...)- Hiểu vì sao
ORDER BYkhông dùng được prepared statement và phải xử lý ra saoCần học trước: Bài 11
1. Vấn đề đặt ra
Ở Bài 11 bạn dựng xong giao diện lọc, nhưng bấm nút không có tác dụng gì. Bây giờ làm cho nó hoạt động.
Vấn đề: người dùng có bao nhiêu cách lọc?
- Gõ từ khóa hoặc không
- Tick 0, 1, 2... tới 12 giảng viên
- Tick 0, 1, 2... tới 33 danh mục
- Chọn 1 trong 4 kiểu sắp xếp
Số tổ hợp lên tới hàng tỷ. Không thể viết sẵn từng câu SQL cho mỗi trường hợp.
Câu SQL phải được dựng động, ghép từng mảnh tùy theo người dùng chọn gì.
Đây là bài kỹ thuật khó nhất trong Phần III, nhưng cũng là bài dạy bạn nhiều nhất về SQL và về cách viết code linh hoạt mà vẫn an toàn.
2. Kiến thức mới
2.1. Dựng SQL động bằng .=
Ý tưởng: bắt đầu bằng một câu SQL luôn đúng, rồi nối thêm điều kiện khi cần.
<?php
$sql = "SELECT * FROM courses WHERE status = 1";
if (!empty($keyword)) {
$sql .= " AND title LIKE ?";
}
if (!empty($minPrice)) {
$sql .= " AND price >= ?";
}
?>
Toán tử .= nối chuỗi vào cuối, bạn đã học ở Bài 2 mục 2.4.
Vì sao câu gốc phải có sẵn WHERE? Vì nếu không, bạn sẽ phải kiểm tra "đây là điều kiện đầu tiên hay không" để quyết định viết WHERE hay AND. Rất rối. Bắt đầu bằng một điều kiện luôn cần thiết như status = 1 giải quyết gọn ghẽ.
Chú ý dấu cách đầu mỗi mảnh nối. " AND c.title LIKE ?" có một khoảng trắng ở đầu. Thiếu nó, hai mảnh dính liền thành status = 1AND c.title và MySQL báo lỗi cú pháp.
2.2. Mảng tham số chạy song song
Đây là phần dễ sai nhất.
Mỗi lần bạn nối thêm một dấu ? vào SQL, bạn phải thêm đúng một giá trị vào mảng tham số, theo đúng thứ tự.
<?php
$sql = "SELECT * FROM courses WHERE status = 1";
$params = [];
if (!empty($keyword)) {
$sql .= " AND title LIKE ?";
$params[] = "%$keyword%"; // thêm ngay, cùng lúc
}
if (!empty($minPrice)) {
$sql .= " AND price >= ?";
$params[] = $minPrice; // thêm ngay, cùng lúc
}
?>
Quy tắc vàng: dòng thêm ? vào SQL và dòng thêm giá trị vào $params phải nằm cạnh nhau. Tách chúng ra là sớm muộn cũng lệch thứ tự.
Nếu lệch, MySQL không báo lỗi — nó vẫn chạy, chỉ là kết quả sai. Loại lỗi này rất khó tìm.
2.3. LIKE và ký tự %
= tìm giá trị khớp chính xác. LIKE tìm theo mẫu:
| Mẫu | Khớp với |
|---|---|
'Python' |
Đúng chuỗi "Python" |
'Python%' |
Bắt đầu bằng "Python" |
'%Python' |
Kết thúc bằng "Python" |
'%Python%' |
Chứa "Python" ở bất kỳ đâu |
Ký tự % nghĩa là "không hoặc nhiều ký tự bất kỳ".
Tìm kiếm trên website thường dùng %tuKhoa% để người dùng gõ một phần cũng ra kết quả.
Điểm quan trọng về vị trí dấu %:
$sql .= " AND c.title LIKE ?";
$params[] = "%$keyword%";
Dấu % được ghép vào giá trị, không phải vào câu SQL. Viết LIKE '%?%' là sai — MySQL sẽ hiểu đó là tìm chuỗi chứa đúng ký tự dấu hỏi.
Đây là chỗ dễ nhầm, hãy nhớ: placeholder thay cho cả giá trị, kể cả phần %.
2.4. Sinh dấu ? động cho IN (...)
Đây là kỹ thuật khó nhất của bài.
Mệnh đề IN kiểm tra một cột có nằm trong danh sách giá trị không:
SELECT * FROM courses WHERE teacher_id IN (1, 5, 9)
Nhưng người dùng tick bao nhiêu giảng viên là tùy họ. Bạn không biết trước cần bao nhiêu dấu ?.
Không được dùng một ? cho cả danh sách. Cách này trông hợp lý nhưng sai hoàn toàn:
$sql .= " AND teacher_id IN (?)";
$params[] = implode(',', $teachers); // SAI!
MySQL sẽ nhận được chuỗi "1,5,9" như một giá trị duy nhất, và đi tìm khóa học có teacher_id bằng đúng chuỗi đó. Kết quả rỗng.
Cách đúng là sinh đúng số dấu ? cần thiết:
$placeholders = str_repeat('?,', count($teachers) - 1) . '?';
$sql .= " AND c.teacher_id IN ($placeholders)";
$params = array_merge($params, $teachers);
Ta mổ xẻ dòng đầu tiên.
str_repeat($chuoi, $soLan) lặp một chuỗi nhiều lần:
str_repeat('?,', 3); // "?,?,?,"
Với 3 giảng viên, ta cần ?,?,? — tức 3 dấu hỏi và 2 dấu phẩy. Nên công thức là: lặp '?,' đúng count - 1 lần rồi cộng thêm một '?' cuối:
str_repeat('?,', 3 - 1) . '?'; // "?," lặp 2 lần = "?,?," rồi + "?" = "?,?,?"
Bảng kiểm chứng:
| Số phần tử | str_repeat('?,', n-1) |
Cộng '?' |
Kết quả |
|---|---|---|---|
| 1 | str_repeat('?,', 0) = "" |
"" + "?" |
? |
| 2 | str_repeat('?,', 1) = "?," |
"?," + "?" |
?,? |
| 3 | str_repeat('?,', 2) = "?,?," |
+ "?" |
?,?,? |
Đúng cho mọi trường hợp — trừ khi mảng rỗng. Với count = 0, str_repeat('?,', -1) sẽ báo lỗi. Đó là lý do toàn bộ khối được bọc trong if (!empty($teachers)).
2.5. array_merge() gộp tham số
$params = array_merge($params, $teachers);
array_merge() nối hai mảng thành một:
<?php
$a = ["%Python%"];
$b = ["1", "5", "9"];
$c = array_merge($a, $b); // ["%Python%", "1", "5", "9"]
?>
Vì sao không dùng $params[] = $teachers;? Vì cách đó sẽ nhét cả mảng $teachers vào một ô của $params, tạo ra mảng lồng nhau. execute() cần mảng phẳng.
Kiểm chứng thứ tự. Giả sử người dùng gõ từ khóa "Python" và tick giảng viên 1, 5, 9. Sau khi chạy hết, ta có:
$sql = "SELECT c.*, u.name as teacher_name FROM courses c
INNER JOIN users u ON c.teacher_id = u.id
WHERE c.status = 1 AND c.title LIKE ? AND c.teacher_id IN (?,?,?) ORDER BY c.id DESC";
$params = ["%Python%", "1", "5", "9"];
Bốn dấu ? trong SQL, bốn giá trị trong $params, đúng thứ tự. Đây là điều bạn phải luôn kiểm tra khi dựng SQL động.
2.6. ORDER BY không dùng được placeholder
Bây giờ tới một điều rất quan trọng về bảo mật.
Bạn có thể nghĩ: đã dùng ? cho mọi thứ, sao không dùng luôn cho sắp xếp?
$sql .= " ORDER BY ?";
$params[] = $sort; // KHÔNG HOẠT ĐỘNG
Không chạy được. Prepared statement chỉ thay ? bằng giá trị, không thay bằng tên cột hay từ khóa SQL.
MySQL sẽ hiểu câu trên là "sắp xếp theo hằng số chuỗi 'price'" — mà mọi dòng đều có cùng hằng số đó, nên chẳng sắp xếp gì cả.
Điều này đúng với: tên bảng, tên cột, ASC/DESC, LIMIT ở một số cấu hình, và mọi từ khóa cấu trúc.
Vậy phải làm sao? Nếu nối thẳng thì mở toang cửa cho SQL Injection:
$sql .= " ORDER BY " . $_GET['sort']; // CỰC KỲ NGUY HIỂM
Kẻ xấu gõ ?sort=id; DROP TABLE courses; -- là mất sạch dữ liệu.
Giải pháp là danh sách trắng (whitelist): chỉ chấp nhận đúng những giá trị bạn đã liệt kê sẵn.
switch ($sort) {
case 'price_asc':
$sql .= " ORDER BY c.price ASC";
break;
case 'price_desc':
$sql .= " ORDER BY c.price DESC";
break;
case 'oldest':
$sql .= " ORDER BY c.id ASC";
break;
case 'newest':
default:
$sql .= " ORDER BY c.id DESC";
break;
}
Điểm mấu chốt: giá trị $sort không bao giờ đi vào câu SQL. Nó chỉ được dùng để chọn một trong bốn chuỗi đã viết cứng.
Người dùng gõ ?sort=DROP TABLE thì switch không khớp case nào, rơi vào default, và câu SQL nhận được chuỗi an toàn " ORDER BY c.id DESC".
Đây là nguyên tắc chung, không chỉ cho
ORDER BY.Bất cứ khi nào dữ liệu người dùng phải trở thành cấu trúc chứ không phải giá trị, hãy dùng danh sách trắng. Không có ngoại lệ.
2.7. Nhiều case chung một khối
case 'newest':
default:
$sql .= " ORDER BY c.id DESC";
break;
Hai nhãn case 'newest': và default: viết liền nhau, không có break ở giữa. Nghĩa là cả hai đều chạy cùng một khối code.
Đây là cách dùng "rơi xuyên" có chủ đích — khác với việc quên break mà Bài 2 mục 2.7 đã cảnh báo. Nó nói rõ ý định: "mới nhất là mặc định".
2.8. Trải mảng khi gọi
return $this->db->query($sql, ...$params);
Nhớ lại Bài 8 mục 2.10: query($sql, ...$args) gom mọi đối số sau $sql vào mảng $args.
Ở đây ta có sẵn một mảng $params và cần biến nó thành nhiều đối số riêng. Toán tử ... khi gọi hàm làm đúng việc đó — bạn đã học ở Bài 9 mục 2.7.
$params = ["%Python%", "1", "5", "9"];
$this->db->query($sql, ...$params);
// tương đương: $this->db->query($sql, "%Python%", "1", "5", "9")
// bên trong query(), $args lại gom về thành ["%Python%", "1", "5", "9"]
Nhìn thì có vẻ vòng vo — trải ra rồi lại gom lại. Đúng vậy. Nếu query() được thiết kế nhận thẳng một mảng thì gọn hơn:
function query($sql, $args = []) { ... }
$this->db->query($sql, $params);
Nhưng tác giả đã chọn kiểu ...$args để mọi truy vấn đơn giản viết được gọn query($sql, $id) thay vì query($sql, [$id]). Đây là đánh đổi hợp lý: trường hợp phổ biến thì gọn, trường hợp hiếm thì dài hơn một chút.
3. Áp dụng vào EzCode
3.1. Viết phương thức filter()
Mở Models/Courses.php và thêm phương thức này vào trước dấu } cuối cùng.
📁
Models/Courses.php— sửa
function filter($keyword = '', $teachers = [], $categories = [], $sort = 'newest')
{
$sql = "SELECT c.*, u.name as teacher_name FROM courses c
INNER JOIN users u ON c.teacher_id = u.id
WHERE c.status = 1";
$params = [];
// Filter by keyword
if (!empty($keyword)) {
$sql .= " AND c.title LIKE ?";
$params[] = "%$keyword%";
}
// Filter by teachers
if (!empty($teachers)) {
$placeholders = str_repeat('?,', count($teachers) - 1) . '?';
$sql .= " AND c.teacher_id IN ($placeholders)";
$params = array_merge($params, $teachers);
}
// Filter by categories
if (!empty($categories)) {
$placeholders = str_repeat('?,', count($categories) - 1) . '?';
$sql .= " AND c.category_id IN ($placeholders)";
$params = array_merge($params, $categories);
}
// Sort
switch ($sort) {
case 'price_asc':
$sql .= " ORDER BY c.price ASC";
break;
case 'price_desc':
$sql .= " ORDER BY c.price DESC";
break;
case 'oldest':
$sql .= " ORDER BY c.id ASC";
break;
case 'newest':
default:
$sql .= " ORDER BY c.id DESC";
break;
}
return $this->db->query($sql, ...$params);
}
Đây là phương thức dài nhất trong Models/Courses.php. Ta đọc từng khối.
Khai báo với bốn tham số mặc định:
function filter($keyword = '', $teachers = [], $categories = [], $sort = 'newest')
Cả bốn đều có giá trị mặc định, nên gọi filter() không tham số cũng chạy được và cho ra toàn bộ khóa học. Kỹ thuật tham số mặc định bạn học ở Bài 2 mục 2.9.
Câu SQL gốc viết trên nhiều dòng:
$sql = "SELECT c.*, u.name as teacher_name FROM courses c
INNER JOIN users u ON c.teacher_id = u.id
WHERE c.status = 1";
PHP cho phép chuỗi trong nháy kép trải nhiều dòng. Ký tự xuống dòng và khoảng trắng thụt lề đều nằm trong chuỗi, nhưng MySQL coi chúng là khoảng trắng bình thường nên không sao.
Câu này dùng INNER JOIN để lấy kèm tên giảng viên — kỹ thuật mà Bài 13 sẽ dạy kỹ. Bây giờ chỉ cần biết: c là bí danh của bảng courses, u là bí danh của users, và u.name as teacher_name lấy cột name của bảng users rồi đổi tên thành teacher_name để không lẫn với các cột khác.
Khối lọc từ khóa dùng LIKE với % ở mục 2.3.
Hai khối lọc giảng viên và danh mục giống hệt nhau, chỉ khác tên cột. Đây là chỗ code lặp lại mà lẽ ra nên tách thành một hàm dùng chung — bài tập 3 sẽ bàn.
Khối switch dùng danh sách trắng ở mục 2.6.
Dòng cuối trải mảng tham số ở mục 2.8.
3.2. Bật bộ lọc trong Controller
Ở Bài 11 bạn đã tạm đổi dòng gọi filter() thành getAll(). Giờ trả lại nguyên bản.
📁
Controllers/CourseController.php— sửa
// Lấy các tham số filter từ $_GET
$keyword = isset($_GET['keyword']) ? $_GET['keyword'] : '';
$teachers = isset($_GET['teacher']) ? $_GET['teacher'] : [];
$categories = isset($_GET['category']) ? $_GET['category'] : [];
$sort = isset($_GET['sort']) ? $_GET['sort'] : 'newest';
include_once "./Models/Courses.php"; // Include the Courses model
$coursesModel = new Courses(); // Create an instance of the Courses model
$coursesList = $coursesModel->filter($keyword, $teachers, $categories, $sort); // Fetch filtered courses
Bốn dòng đọc tham số ở đầu phương thức — vốn đã có từ Bài 11 nhưng chưa dùng tới — giờ được truyền vào filter().
Chú ý thứ tự bốn tham số phải khớp với thứ tự khai báo trong filter(). Đảo $teachers và $categories thì trang vẫn chạy nhưng lọc sai bảng.
Đây là nhược điểm của việc truyền nhiều tham số vị trí. Với những hàm có 4 tham số trở lên, cách tốt hơn là truyền một mảng kết hợp:
$coursesModel->filter([
'keyword' => $keyword,
'teachers' => $teachers,
'categories' => $categories,
'sort' => $sort
]);
Cách này không nhầm thứ tự được. Bạn cứ gõ theo repo, và ghi nhớ cho project riêng.
4. Chạy thử
Mở ?ctrl=course&act=list và thử lần lượt:
Thử 1 — Không lọc gì. Bấm "Xóa bộ lọc". Bạn thấy đủ 14 khóa học, mới nhất lên đầu.
Thử 2 — Lọc theo từ khóa. Gõ Python vào ô tìm kiếm, bấm "Áp dụng bộ lọc".
Kết quả: 2 khóa học — "Python cơ bản" và "Lập trình Python".
Thử gõ python viết thường — vẫn ra 2 kết quả. Vì bảng chữ utf8mb4_general_ci có đuôi _ci nghĩa là case insensitive, không phân biệt hoa thường.
Thử gõ thon — vẫn ra 2 kết quả, nhờ dấu % hai bên.
Thử 3 — Lọc theo giảng viên. Xóa từ khóa, tick giảng viên "Hoàng Nguyễn". Kết quả: 2 khóa học của người đó (id 20 và 21).
Tick thêm "Nguyễn Văn A". Kết quả: 4 khóa học. Đây là IN (?,?) đang làm việc.
Thử 4 — Sắp xếp. Chọn "Giá tăng dần". Khóa 25.000 lên đầu, khóa 800.000 xuống cuối.
Thử 5 — Kết hợp nhiều bộ lọc. Tick 2 giảng viên, chọn "Giá giảm dần", gõ từ khóa. Mọi điều kiện áp dụng cùng lúc vì chúng nối bằng AND.
Thử 6 — Không có kết quả. Gõ từ khóa xyzabc. Bạn thấy thông báo "Không tìm thấy khóa học nào".
Thử 7 — Tấn công thử. Gõ thẳng vào thanh địa chỉ:
?ctrl=course&act=list&sort=id;DROP TABLE courses
Trang vẫn chạy bình thường, sắp xếp theo mặc định. switch đã chặn — chuỗi độc hại không khớp case nào nên rơi vào default.
Sau đó thử:
?ctrl=course&act=list&keyword=' OR '1'='1
Kết quả: không tìm thấy khóa học nào. Đúng như Bài 8 bài tập 3 đã chứng minh — prepared statement biến chuỗi tấn công thành một từ khóa tìm kiếm vô hại.
Bảng xử lý sự cố:
| Hiện tượng | Nguyên nhân | Cách sửa |
|---|---|---|
str_repeat(): Argument #2 must be greater than or equal to 0 |
Mảng rỗng lọt vào khối | Kiểm tra if (!empty($teachers)) |
SQLSTATE[HY093]: Invalid parameter number |
Số ? không khớp số phần tử $params |
Rà lại từng khối, mỗi ? phải có một giá trị |
| Lọc giảng viên không có tác dụng | $_GET['teacher'] không phải mảng |
Kiểm tra name="teacher[]" trong View |
| Kết quả luôn rỗng khi tick giảng viên | Dùng implode thay vì sinh nhiều ? |
Xem lại mục 2.4 |
syntax error near 'AND' |
Thiếu dấu cách đầu mảnh SQL nối vào | Mỗi .= " AND ..." phải có khoảng trắng đầu |
5. Bài tập
Bài tập 1
Thêm bộ lọc theo khoảng giá.
- Thêm vào
Views/course_list.phpmột nhóm lọc mới có hai ô số:min_pricevàmax_price. - Đọc chúng trong
CourseController::list(). - Thêm vào
filter()một tham số thứ năm và thứ sáu, cùng khối điều kiện dùngBETWEEN ? AND ?.
Điều kiện chỉ áp dụng khi cả hai ô đều có giá trị.
Bài tập 2
Thêm lựa chọn sắp xếp theo tên A đến Z.
- Thêm một
<option value="title_asc">vào View. - Thêm một
casetương ứng vàoswitchtrongfilter().
Bài tập 3
Hai khối lọc giảng viên và danh mục trong filter() giống hệt nhau, chỉ khác tên cột. Hãy tách chúng thành một phương thức private dùng chung.
Gợi ý chữ ký:
private function themDieuKienIn(&$sql, &$params, $tenCot, $giaTri)
Ký hiệu
&trước tham số nghĩa là "truyền theo tham chiếu" — hàm sửa được biến gốc bên ngoài. Đây là kiến thức mới, hãy tra cứu hoặc xem đáp án.
Sau khi tách xong, trả lời: việc tách này làm code tốt hơn hay tệ hơn, và vì sao?
6. Đáp án
Đáp án bài tập 1
📄 Views/course_list.php — thêm nhóm lọc mới trước nhóm "Sắp xếp theo"
<div class="filter-group">
<h4>Khoảng giá</h4>
<input type="number" name="min_price" placeholder="Từ" min="0"
value="<?= isset($_GET['min_price']) ? htmlspecialchars($_GET['min_price']) : '' ?>" />
<input type="number" name="max_price" placeholder="Đến" min="0"
value="<?= isset($_GET['max_price']) ? htmlspecialchars($_GET['max_price']) : '' ?>" />
</div>
📄 Controllers/CourseController.php
$minPrice = isset($_GET['min_price']) ? $_GET['min_price'] : '';
$maxPrice = isset($_GET['max_price']) ? $_GET['max_price'] : '';
$coursesList = $coursesModel->filter($keyword, $teachers, $categories, $sort, $minPrice, $maxPrice);
📄 Models/Courses.php
function filter($keyword = '', $teachers = [], $categories = [], $sort = 'newest', $minPrice = '', $maxPrice = '')
Và thêm khối này sau khối lọc danh mục:
// Filter by price range
if ($minPrice !== '' && $maxPrice !== '') {
$sql .= " AND c.price BETWEEN ? AND ?";
$params[] = (int)$minPrice;
$params[] = (int)$maxPrice;
}
Ba điểm đáng chú ý:
Thứ nhất, hai tham số mới phải đặt ở cuối danh sách. Nếu chèn vào giữa, mọi lời gọi cũ sẽ truyền sai vị trí.
Thứ hai, dùng !== '' chứ không dùng !empty(). Vì empty('0') trả về true — bạn đã học ở Bài 3 mục 2.5. Nếu người dùng gõ giá tối thiểu là 0, empty() sẽ coi như họ không nhập gì.
Thứ ba, BETWEEN ? AND ? cần đúng hai giá trị, và cả hai đều được thêm vào $params ngay tại chỗ, đúng quy tắc vàng ở mục 2.2.
Đáp án bài tập 2
📄 Views/course_list.php
<option value="title_asc" <?= (isset($_GET['sort']) && $_GET['sort'] == 'title_asc') ? 'selected' : '' ?>>Tên A-Z</option>
📄 Models/Courses.php — thêm case vào switch
case 'title_asc':
$sql .= " ORDER BY c.title ASC";
break;
Điểm quan trọng của bài tập này: bạn phải sửa hai chỗ thì tính năng mới hoạt động. Thêm option mà quên case thì người dùng chọn được nhưng không có tác dụng — nó rơi vào default và sắp xếp theo mới nhất.
Đây chính là điểm yếu của danh sách trắng: nó an toàn nhưng phải bảo trì thủ công. Đánh đổi hoàn toàn xứng đáng so với rủi ro SQL Injection.
Đáp án bài tập 3
📄 Models/Courses.php — thêm phương thức private
private function themDieuKienIn(&$sql, &$params, $tenCot, $giaTri)
{
if (empty($giaTri)) {
return;
}
$placeholders = str_repeat('?,', count($giaTri) - 1) . '?';
$sql .= " AND $tenCot IN ($placeholders)";
$params = array_merge($params, $giaTri);
}
Rồi thay hai khối cũ bằng hai dòng:
$this->themDieuKienIn($sql, $params, 'c.teacher_id', $teachers);
$this->themDieuKienIn($sql, $params, 'c.category_id', $categories);
Về ký hiệu &:
Bình thường PHP truyền tham số theo giá trị — hàm nhận một bản sao, sửa nó không ảnh hưởng biến gốc:
<?php
function themChu($chuoi) {
$chuoi .= " thêm";
}
$a = "gốc";
themChu($a);
echo $a; // "gốc" — không đổi
?>
Thêm & thì hàm nhận chính biến gốc:
<?php
function themChu(&$chuoi) {
$chuoi .= " thêm";
}
$a = "gốc";
themChu($a);
echo $a; // "gốc thêm" — đã đổi
?>
Ở đây ta cần & vì phương thức phải sửa được cả $sql lẫn $params của hàm gọi.
Việc tách này tốt hơn hay tệ hơn?
Tốt hơn ở ba điểm:
- Bớt lặp lại. Hai khối 5 dòng giống hệt nhau thành một phương thức. Sửa lỗi một lần là sửa cả hai chỗ.
- Dễ mở rộng. Muốn thêm bộ lọc theo trạng thái, chỉ cần gọi thêm một dòng.
- Tên hàm nói lên ý định.
themDieuKienIn(...)đọc rõ hơn 5 dòngstr_repeatvàarray_merge.
Tệ hơn ở hai điểm:
-
Tham số
$tenCotđược nối thẳng vào SQL. Đây là chỗ nguy hiểm — nếu ai đó gọi phương thức này với tên cột lấy từ$_GET, lỗ hổng SQL Injection mở ra ngay.Vì phương thức để
privatenên chỉ code trong class gọi được, và hiện tại hai lời gọi đều truyền chuỗi viết cứng. An toàn — nhưng phụ thuộc vào việc người bảo trì sau này cũng cẩn thận như vậy.Cách chắc chắn hơn là kiểm tra tên cột bằng danh sách trắng ngay trong phương thức:
$cotChoPhep = ['c.teacher_id', 'c.category_id', 'c.status']; if (!in_array($tenCot, $cotChoPhep, true)) { return; } -
Truyền tham chiếu làm code khó theo dõi. Nhìn dòng
$this->themDieuKienIn($sql, $params, ...)bạn không thấy được rằng$sqlvà$paramssẽ bị thay đổi. Người đọc phải mở phương thức ra mới biết.Cách rõ ràng hơn là trả về giá trị:
private function dieuKienIn($tenCot, $giaTri) { if (empty($giaTri)) { return ['sql' => '', 'params' => []]; } $placeholders = str_repeat('?,', count($giaTri) - 1) . '?'; return [ 'sql' => " AND $tenCot IN ($placeholders)", 'params' => $giaTri ]; }Rồi dùng:
$dk = $this->dieuKienIn('c.teacher_id', $teachers); $sql .= $dk['sql']; $params = array_merge($params, $dk['params']);Dài hơn nhưng nhìn là biết cái gì bị thay đổi.
Kết luận: tách ra là đúng hướng, nhưng nên tránh truyền tham chiếu và phải thêm danh sách trắng cho tên cột. Đây là kiểu suy nghĩ mà một lập trình viên có kinh nghiệm sẽ làm mỗi khi refactor: không chỉ hỏi "gọn hơn không" mà còn hỏi "có mở ra rủi ro mới không".
➡️ Bài tiếp theo: Bài 13 — Chi tiết khóa học và danh sách bài học
⬅️ Về mục lục · Bài trước
All rights reserved