Màn hình tìm kiếm có bốn ô lọc, mỗi ô có thể bỏ trống. Đó là mười sáu tổ hợp. Hình dung mỗi điều kiện lọc như một tấm kính lắp vào giá: tấm nào có giá trị thì chặn theo tiêu chí của nó, tấm để trống là kính trong — vẫn lắp lên nhưng không chặn gì. Bài này về cách không viết mười sáu truy vấn.

Cách sai vẫn hay gặp

if (ten != null && trangThai != null) return repo.findByTenAndTrangThai(ten, trangThai);
if (ten != null) return repo.findByTen(ten);
if (trangThai != null) return repo.findByTrangThai(trangThai);
return repo.findAll();

Bốn nhánh cho hai ô lọc. Thêm ô thứ ba là tám, thứ tư là mười sáu — cắt tay một cái giá riêng cho từng tổ hợp.

Cách sai thứ hai là ghép chuỗi SQL — và đó là mở cửa cho SQL injection, đúng bài 65 sê-ri Java, nơi một chuỗi 28 ký tự xoá được cả bảng.

Specification

interface RepoTacGia extends JpaRepository<TacGia, Long>,
                             JpaSpecificationExecutor<TacGia> { }

static Specification<TacGia> tenChua(String s) {
    return (root, q, cb) -> s == null ? null
            : cb.like(cb.lower(root.get("ten")), "%" + s.toLowerCase() + "%");
}
static Specification<TacGia> coItNhat(int n) {
    return (root, q, cb) -> n <= 0 ? null
            : cb.greaterThanOrEqualTo(cb.size(root.get("bais")), n);
}
repo.findAll(Specification.where(tenChua(ten)).and(coItNhat(soBai)));
  Specification (2 điều kiện)       -> 11 kết quả, 1 truy vấn
  Specification (1 điều kiện null)  -> 11 kết quả, 1 truy vấn

Điểm mấu chốt: trả null thì điều kiện đó bị bỏ khỏi câu truy vấn. Đó là tấm kính trong: lắp lên mà không chặn gì. Nên bạn ghép tất cả điều kiện lại và để mỗi cái tự quyết định có tham gia hay không.

Bốn ô lọc thành bốn phương thức, ghép bằng .and(). Không có nhánh if nào.

Và cả hai trường hợp đều một truy vấn — Spring dựng một câu SQL duy nhất, không phải lọc trong bộ nhớ.

Kết hợp

Specification<TacGia> spec = Specification.where(null);
if (dieuKien.coTen())      spec = spec.and(tenChua(dieuKien.ten()));
if (dieuKien.coNgay())     spec = spec.and(trongKhoang(dieuKien.tu(), dieuKien.den()));
spec = spec.or(laNoiBat());

return repo.findAll(spec, pageable);      // đi cùng phân trang

Specification.where(null) là điểm bắt đầu trung tính — cái giá trống chưa lắp tấm nào. findAll(spec, pageable) trả về Page, nên bạn được phân trang và sắp xếp miễn phí — nhớ bài 22: Page tốn thêm một câu COUNT.

Đi qua quan hệ

static Specification<Bai> tacGiaTen(String ten) {
    return (root, q, cb) -> {
        Join<Bai, TacGia> tg = root.join("tacGia");
        return cb.equal(tg.get("ten"), ten);
    };
}

Cẩn thận hai chuyện:

Join lặp. Ghép nhiều Specification cùng join một quan hệ sẽ sinh nhiều JOIN. Với root.join, JPA thường gộp lại; với fetch thì không.

distinct khi join collection. Join một-nhiều sinh dòng lặp:

q.distinct(true);

Criteria API: mạnh và khó đọc

Specification là lớp mỏng trên Criteria API. Dùng thẳng Criteria khi cần truy vấn thật phức tạp:

CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Object[]> q = cb.createQuery(Object[].class);
Root<TacGia> t = q.from(TacGia.class);
q.multiselect(t.get("ten"), cb.count(t)).groupBy(t.get("ten"))
 .having(cb.gt(cb.count(t), 5L));

So với SQL tương đương thì dài gấp ba và khó đọc hơn nhiều. Đổi lại: an toàn kiểu ở mức nào đó và ghép động được.

Chuỗi "ten" vẫn là chuỗi, nên đổi tên trường vẫn gãy lúc chạy. Muốn an toàn thật thì bật metamodel:

<annotationProcessorPath>
  <groupId>org.hibernate.orm</groupId><artifactId>hibernate-jpamodelgen</artifactId>
</annotationProcessorPath>
cb.equal(root.get(TacGia_.ten), "x")      // đổi tên trường -> lỗi biên dịch

Thêm một bước sinh mã, nhưng nó biến một lớp lỗi lúc chạy thành lỗi lúc biên dịch. Với dự án dùng Criteria nhiều, tôi bật.

Ba lựa chọn khác

Query by Example — gọn nhất cho lọc bằng nhau:

var mau = new TacGia(); mau.ten = "Minh";
repo.findAll(Example.of(mau, ExampleMatcher.matching().withIgnoreNullValues()));

Chỉ làm được so sánh bằng và like cơ bản. Không có khoảng, không có or, không đi sâu quan hệ. Đủ cho những màn hình lọc đơn giản.

QueryDSL — thư viện ngoài, cú pháp dễ đọc hơn Criteria nhiều:

queryFactory.selectFrom(tacGia)
    .where(tacGia.ten.containsIgnoreCase(ten), tacGia.bais.size().goe(5))
    .fetch();

Nhiều tham số trong where được ghép bằng AND, và tham số null bị bỏ qua — cùng ý tưởng với Specification nhưng gọn hơn. Cái giá là một bước sinh mã và một phụ thuộc nữa.

Quay lại SQL. Với báo cáo, thống kê, truy vấn có hàm cửa sổ hay CTE đệ quy — đừng cố nhồi vào Criteria. Viết SQL, dùng JdbcClient, ánh xạ sang record. Bài 31 sẽ nói.

Đừng để người dùng chọn cột sắp xếp

Pageable đọc ?sort= từ URL, và Spring đưa thẳng tên cột đó vào SQL. Người dùng đưa một tên bất kỳ là lộ ra cấu trúc bảng qua thông báo lỗi, hoặc sắp xếp theo cột bạn không định cho họ thấy.

Đối chiếu với danh sách cho phép:

private static final Set<String> COT_HOP_LE = Set.of("ngayTao", "ten", "trangThai");

Đây là cùng bài học ở bài 65 sê-ri Java: tên bảng và tên cột không thay bằng dấu ? được, nên phải dùng danh sách cho phép.

Nếu chỉ làm một thứ sau bài này, đếm số nhánh if trong phương thức tìm kiếm của bạn, trong ba mươi giây:

grep -c "if (" DichVuTimKiem.java

Nhiều hơn ba nhánh chỉ để chọn phương thức repository nào là lúc chuyển sang Specification — và số nhánh sẽ về không, không phải giảm bớt.

Mẫu số chung

Dựng hành vi phức tạp bằng cách ghép những mảnh nhỏ, độc lập, tự-quyết-định thay vì liệt kê từng trường hợp — và cú nổ tổ hợp 2^N của các ô lọc co lại thành N mảnh ghép được. Bản năng ghép-hơn-liệt-kê này ở đâu cũng có: các bộ dựng truy vấn (Specification, QueryDSL, jOOQ, đối tượng Q của Django, SQLAlchemy, scope của ActiveRecord), chuỗi decorator và middleware, đường ống hàm. Cái làm nó gọn là một phần tử trung tính — một mảnh vô-hiệu (trả null trong chuỗi AND, như Specification.where(null), middleware rỗng, cộng 0, nhân 1) — để bạn ghép vô điều kiện và những phần không hoạt động đơn giản là biến mất; thêm một ô lọc là thêm một mảnh, không bao giờ nhân đôi số nhánh.

Điều thứ hai: cấu trúc không phải dữ liệu. Tên bảng, tên cột, một định danh — không buộc được vào dấu ?, nên dựng truy vấn bằng cách dán chuỗi là SQL injection, và cho người dùng chọn cột sắp xếp hay trường lọc là chính cái lỗ đó qua một cửa khác. Thuốc chữa là danh sách cho phép, không phải thoát ký tự — cùng sợi chỉ đừng-tin-đầu-vào, tham-số-hoá-giá-trị như bài tải tệp và bài validation. Một bộ dựng truy vấn tốt trả công chính bằng việc tham-số-hoá giá trị hộ bạn; nhưng cái cột mà người dùng chọn thì vẫn phải soi qua một tập cố định, vì dấu giữ chỗ chưa bao giờ định bảo vệ nó.

Ngày mai: projection và DTO — lấy đúng cột cần, và vì sao entity không phải là câu trả lời cho việc đọc.