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. 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á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. 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. 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.

Thử ba mươi giây

Đếm số nhánh if trong phương thức tìm kiếm của bạn:

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.

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.