Command Palette

Search for a command to run...

[Advanced Spring Boot] Truy vấn động với Spring Data JPA: Specification, Criteria API và Querydsl

Danh sách sản phẩm cần một ô search: GET /api/products?q=…&category=…&minPrice=…&maxPrice=…&tag=…&inStock=…, cộng thêm page, size và sort. Filter nào cũng tùy chọn, nên một request có thể không gửi filter nào, gửi đủ sáu, hoặc bất kỳ tổ hợp nào ở giữa. Derived query method được chốt lúc ứng dụng khởi động, chuỗi @Query được chốt lúc compile, nên cả hai đều không thể chỉ thêm một điều kiện khi parameter của nó có mặt. Dynamic query sinh ra để làm việc đó: query được dựng theo từng request, từ đúng những filter mà request đó gửi lên.

Bài này dựng endpoint đó theo bốn cách: bằng Specification kết hợp được với nhau, bằng Criteria API nằm bên dưới chúng, bằng Querydsl, và ngắn gọn bằng jOOQ. Trước tất cả, bài chỉ ra vì sao cách lách quen thuộc, (:x is null or …) trong @Query, lỗi trên PostgreSQL và nó làm gì với query plan. Các ví dụ dùng Spring Boot 4.1.1 và Java 21, kết nối PostgreSQL 18.

Vài filter chip ghép lại thành một mệnh đề WHERE

Phần đầu dựng catalogue và record chứa filter; phần còn lại đi từ query không thể chạy đúng đến các công cụ làm được, và kết thúc bằng cách chọn giữa chúng.

Endpoint search và cách tạo ra các output

Project tạo từ Spring Initializr với web, data-jpa, postgresql, flywayvalidation:

Bash
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web,data-jpa,postgresql,flyway,validation" -o demo.zip

Catalogue là catalogue trong bài phân trang của khóa Basics, bỏ cột rating: products, categories, tags và bảng nối product_tags, giờ do Flyway tạo và được Hibernate kiểm tra bằng ddl-auto=validate:

src/main/resources/db/migration/V1__create_catalog.sql
create table categories (
    id   bigint generated by default as identity primary key,
    name varchar(60) not null unique
);
 
create table tags (
    id   bigint generated by default as identity primary key,
    name varchar(40) not null unique
);
 
create table products (
    id          bigint generated by default as identity primary key,
    name        varchar(120)   not null,
    sku         varchar(40)    not null unique,
    price       numeric(10, 2) not null,
    stock       integer        not null,
    category_id bigint         not null references categories (id)
);
 
create index products_category_id_idx on products (category_id);
create index products_price_idx on products (price);
 
create table product_tags (
    product_id bigint not null references products (id),
    tag_id     bigint not null references tags (id),
    primary key (product_id, tag_id)
);
 
create index product_tags_tag_id_idx on product_tags (tag_id);

V2__seed_catalog.sql insert 23 sản phẩm: bảy bàn phím, sáu chuột, bốn màn hình và sáu phụ kiện, với các tag mechanical, wireless, rgb, bestsellerusb-c. Hai chi tiết sẽ quan trọng về sau. Mười sản phẩm có wireless hoặc usb-c, và một trong số đó, MS-05 Travel mouse, có cả hai. Và một cái tên chứa dấu phần trăm, Desk lamp 100% LED, dành cho phần về LIKE.

Entity map một @ManyToOne lazy tới Category và một @ManyToMany tới Tag:

src/main/java/com/example/demo/product/Product.java
@Entity
@Table(name = "products")
public class Product {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false, length = 120)
    private String name;
 
    @Column(nullable = false, unique = true, length = 40)
    private String sku;
 
    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;
 
    @Column(nullable = false)
    private int stock;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "category_id", nullable = false)
    private Category category;
 
    @ManyToMany
    @JoinTable(name = "product_tags",
            joinColumns = @JoinColumn(name = "product_id"),
            inverseJoinColumns = @JoinColumn(name = "tag_id"))
    private Set<Tag> tags = new HashSet<>();
 
    protected Product() {
    }
 
    public Product(String name, String sku, BigDecimal price, int stock, Category category) {
        this.name = name;
        this.sku = sku;
        this.price = price;
        this.stock = stock;
        this.category = category;
    }
 
    // getters only
}

Sáu query parameter bind vào một record. Spring MVC bind các argument của constructor record từ request parameter theo tên, nên ?tag=wireless&tag=usb-c thành một list hai phần tử. Compact constructor đổi chuỗi rỗng thành null, nên ?category= có nghĩa giống hệt không có category:

src/main/java/com/example/demo/product/ProductFilter.java
package com.example.demo.product;
 
import java.math.BigDecimal;
import java.util.List;
 
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
 
public record ProductFilter(
        @Size(max = 100) String q,
        @Size(max = 60) String category,
        @PositiveOrZero BigDecimal minPrice,
        @PositiveOrZero BigDecimal maxPrice,
        @Size(max = 5) List<String> tag,
        Boolean inStock) {
 
    public ProductFilter {
        q = blankToNull(q);
        category = blankToNull(category);
        tag = tag == null ? List.of() : tag.stream().filter(t -> !t.isBlank()).toList();
    }
 
    @AssertTrue(message = "minPrice must not be greater than maxPrice")
    public boolean isPriceRangeValid() {
        return minPrice == null || maxPrice == null || minPrice.compareTo(maxPrice) <= 0;
    }
 
    private static String blankToNull(String value) {
        return value == null || value.isBlank() ? null : value.strip();
    }
}

Ứng dụng nói chuyện với một container PostgreSQL 18, chạy ở port 8208 và log SQL của Hibernate:

Bash
docker run -d --name sba-a8-pg -e POSTGRES_USER=demo -e POSTGRES_PASSWORD=demo -e POSTGRES_DB=demo -p 5508:5432 postgres:18
src/main/resources/application.properties
spring.application.name=demo
server.port=8208
spring.datasource.url=jdbc:postgresql://localhost:5508/demo
spring.datasource.username=demo
spring.datasource.password=demo
spring.jpa.open-in-view=false
spring.jpa.hibernate.ddl-auto=validate
logging.level.org.hibernate.SQL=DEBUG

Phần lớn các lời gọi repository chạy trong một CommandLineRunner sau profile lab: profile này tắt web server, đặt logging.pattern.console=%m%n để mỗi dòng log chỉ còn message, và bật org.hibernate.orm.jdbc.bind=TRACE để thấy giá trị được bind. Mỗi block bắt đầu bằng lời gọi sau >>>, rồi tới các câu SQL của Hibernate, rồi kết quả: sản phẩm dạng id:SKU price, và với Page là dòng numberOfElements=… totalElements=… totalPages=…. Một exception được in thành các dòng !!!, mỗi cause một dòng.

Vì sao derived query và @Query không đủ

Sáu filter tùy chọn tạo ra 2⁶ = 64 tổ hợp có mặt và vắng mặt. Một derived query method mã hóa một tổ hợp trong tên của nó: findByCategoryName, findByCategoryNameAndPriceLessThanEqual, findByCategoryNameAndPriceLessThanEqualAndStockGreaterThan, và cứ thế, 64 method cho một endpoint, cộng thêm một switch để chọn. Không ai viết như vậy, nên lối thoát thường gặp là một @Query duy nhất, tắt từng điều kiện khi parameter của nó là null.

Mẹo (:x is null or …) và SQL mà nó sinh ra

src/main/java/com/example/demo/product/ProductRepository.java
    @Query("""
            select p from Product p
            where (:q is null or lower(p.name) like lower(concat('%', :q, '%')))
              and (:category is null or p.category.name = :category)
              and (:minPrice is null or p.price >= :minPrice)
              and (:maxPrice is null or p.price <= :maxPrice)
              and (:inStock is null or :inStock = false or p.stock > 0)
            """)
    List<Product> searchWithNullChecks(String q, String category, BigDecimal minPrice, BigDecimal maxPrice,
                                       Boolean inStock);

Những con chuột giá tối đa 50.00 còn hàng, không có text search:

Text
>>> searchWithNullChecks(null, "Mice", null, 50, true)
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where (? is null or lower(p1_0.name) like lower(('%'||?||'%')) escape '') and (? is null or c1_0.name=?) and (? is null or p1_0.price>=?) and (? is null or p1_0.price<=?) and (? is null or ?=false or p1_0.stock>0)
binding parameter (1:JAVA_OBJECT) <- [null]
binding parameter (2:JAVA_OBJECT) <- [null]
binding parameter (3:VARCHAR) <- [Mice]
binding parameter (4:VARCHAR) <- [Mice]
binding parameter (5:NUMERIC) <- [null]
binding parameter (6:NUMERIC) <- [null]
binding parameter (7:NUMERIC) <- [50]
binding parameter (8:NUMERIC) <- [50]
binding parameter (9:BOOLEAN) <- [true]
binding parameter (10:BOOLEAN) <- [true]
HHH000247: ErrorCode: 0, SQLState: 42883
ERROR: function lower(bytea) does not exist
  Hint: No function matches the given name and argument types. You might need to add explicit type casts.
  Position: 185
!!! org.springframework.dao.InvalidDataAccessResourceUsageException: JDBC exception executing SQL [ERROR: function lower(bytea) does not exist
...
!!! caused by org.hibernate.exception.SQLGrammarException: JDBC exception executing SQL [ERROR: function lower(bytea) does not exist
...
!!! caused by org.postgresql.util.PSQLException: ERROR: function lower(bytea) does not exist

Hai dòng bind đầu tiên nói lý do. Hibernate không suy ra được type cho :q, vì nó chỉ xuất hiện trong is null và bên trong concat, nên nó bind giá trị null dưới dạng JAVA_OBJECT, và PostgreSQL hiểu giá trị null không có type bên trong lower(...)bytea. Cùng method đó với "mouse" bind VARCHAR hai lần và trả về bảy sản phẩm. Nghĩa là query này qua mọi test có điền ô search, rồi lỗi ở lần đầu tiên một request thật để trống ô đó. Một lệnh cast cho parameter một type:

src/main/java/com/example/demo/product/ProductRepository.java
            where (:q is null or lower(p.name) like lower(concat('%', :q, '%'))) 
            where (:q is null or lower(p.name) like lower(concat('%', cast(:q as String), '%'))) 
Text
>>> searchWithNullChecksCast(null, "Mice", null, 50, true)
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where (? is null or lower(p1_0.name) like lower(('%'||cast(? as varchar)||'%')) escape '') and (? is null or c1_0.name=?) and (? is null or p1_0.price>=?) and (? is null or p1_0.price<=?) and (? is null or ?=false or p1_0.stock>0)
binding parameter (1:JAVA_OBJECT) <- [null]
binding parameter (2:JAVA_OBJECT) <- [null]
binding parameter (3:VARCHAR) <- [Mice]
binding parameter (4:VARCHAR) <- [Mice]
binding parameter (5:NUMERIC) <- [null]
binding parameter (6:NUMERIC) <- [null]
binding parameter (7:NUMERIC) <- [50]
binding parameter (8:NUMERIC) <- [50]
binding parameter (9:BOOLEAN) <- [true]
binding parameter (10:BOOLEAN) <- [true]
    [8:MS-01 24.50, 9:MS-02 49.90, 12:MS-05 19.90, 13:MS-06 22.00] (4 rows)

Lab giữ cả hai phiên bản song song, bản có cast tên là searchWithNullChecksCast. Nó chạy, và SQL cho thấy cái giá phải trả ngay cả khi chạy đúng. Mọi điều kiện đều có mặt trong mọi statement, mỗi parameter bị bind hai lần, và join tới categories có mặt ở mọi request vì p.category.name nằm trong chuỗi query, dù request có hỏi category hay không. Filter theo tag thì hoàn toàn vắng mặt, vì một điều kiện trên parameter dạng collection không vừa với khuôn này, và % trong text search không được escape, như Basics 27 đã chỉ ra với concat trong JPQL.

Query kiểm tra null có bị PostgreSQL lập plan tệ không?

Để xem plan, statement này được chạy dưới dạng prepared statement trong psql, với $1 tới $10 thay cho các placeholder của Hibernate, trên một database thứ hai, bench, được Flyway migrate rồi một script đổ thêm 200.000 sản phẩm vào 200 category:

seed-bench.sql
select setseed(0.8);
insert into categories (name) select 'Category ' || g from generate_series(5, 200) as g;
insert into products (name, sku, price, stock, category_id)
select 'Product ' || g,
       'SKU-' || lpad(g::text, 6, '0'),
       round((5 + random() * 495)::numeric, 2),
       (random() * 50)::int,
       1 + (g % 200)
from generate_series(1, 200000) as g;
analyze;

Request chỉ lọc theo khoảng giá, không gì khác: minPrice=100&maxPrice=101. Bên cạnh là prepared statement thứ hai chứa SQL mà một Specification sinh ra cho cùng request, chỉ có đúng hai điều kiện có mặt:

explain.sql
prepare nullchecks(text, varchar, varchar, varchar, numeric, numeric, numeric, numeric, boolean, boolean) as
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where ($1 is null or lower(p1_0.name) like lower(('%'||cast($2 as varchar)||'%')) escape '') and ($3 is null or c1_0.name=$4) and ($5 is null or p1_0.price>=$6) and ($7 is null or p1_0.price<=$8) and ($9 is null or $10=false or p1_0.stock>0);
prepare spec(numeric, numeric) as
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.price>=$1 and p1_0.price<=$2;
 
set plan_cache_mode = force_custom_plan;
explain (analyze, buffers, timing off, summary off, costs off) execute nullchecks(null, null, null, null, 100, 100, 101, 101, null, null);
set plan_cache_mode = force_generic_plan;
explain (analyze, buffers, timing off, summary off, costs off) execute nullchecks(null, null, null, null, 100, 100, 101, 101, null, null);
explain (analyze, buffers, timing off, summary off, costs off) execute spec(100, 101);

Custom plan được lập cho một bộ giá trị cụ thể. PostgreSQL rút gọn mọi null is null thành true, bỏ các điều kiện đó và dùng index trên giá; join tới categories vẫn còn:

Text
 Hash Join (actual rows=411.00 loops=1)
   Hash Cond: (p1_0.category_id = c1_0.id)
   Buffers: shared hit=381
   ->  Bitmap Heap Scan on products p1_0 (actual rows=411.00 loops=1)
         Recheck Cond: ((price >= '100'::numeric) AND (price <= '101'::numeric))
         Heap Blocks: exact=374
         Buffers: shared hit=379
         ->  Bitmap Index Scan on products_price_idx (actual rows=411.00 loops=1)
               Index Cond: ((price >= '100'::numeric) AND (price <= '101'::numeric))
               Index Searches: 1
               Buffers: shared hit=5
   ->  Hash (actual rows=200.00 loops=1)
         Buckets: 1024  Batches: 1  Memory Usage: 16kB
         Buffers: shared hit=2
         ->  Seq Scan on categories c1_0 (actual rows=200.00 loops=1)
               Buffers: shared hit=2

Generic plan được lập một lần, cho mọi bộ giá trị. Nó không biết parameter nào sẽ là null, nên mọi $n IS NULL OR … vẫn là một filter đánh giá từng dòng, và price >= $6 không còn là thứ index phục vụ được:

Text
 Nested Loop (actual rows=411.00 loops=1)
   Buffers: shared hit=200403
   ->  Seq Scan on categories c1_0 (actual rows=200.00 loops=1)
         Filter: (($3 IS NULL) OR ((name)::text = ($4)::text))
         Buffers: shared hit=2
   ->  Bitmap Heap Scan on products p1_0 (actual rows=2.06 loops=200)
         Recheck Cond: (category_id = c1_0.id)
         Filter: ((($5 IS NULL) OR (price >= $6)) AND (($7 IS NULL) OR (price <= $8)) AND (($9 IS NULL) OR (NOT $10) OR (stock > 0)) AND (($1 IS NULL) OR (lower((name)::text) ~~ like_escape(lower((('%'::text || ($2)::text) || '%'::text)), ''::text))))
         Rows Removed by Filter: 998
         Heap Blocks: exact=200001
         Buffers: shared hit=200403
         ->  Bitmap Index Scan on products_category_id_idx (actual rows=1000.12 loops=200)
               Index Cond: (category_id = c1_0.id)
               Index Searches: 200
               Buffers: shared hit=400

Plan này đi qua mọi sản phẩm bằng index của category để trả về 411 sản phẩm: 200.403 buffer so với 381. SQL của Specification, dưới dạng generic plan, vẫn dùng index trên giá, vì các điều kiện của nó là phép so sánh thuần:

Text
 Bitmap Heap Scan on products p1_0 (actual rows=411.00 loops=1)
   Recheck Cond: ((price >= $1) AND (price <= $2))
   Heap Blocks: exact=374
   Buffers: shared hit=379
   ->  Bitmap Index Scan on products_price_idx (actual rows=411.00 loops=1)
         Index Cond: ((price >= $1) AND (price <= $2))
         Index Searches: 1
         Buffers: shared hit=5

Query kiểm tra null có bao giờ nhận generic plan hay không là do plan cache của PostgreSQL quyết định. Với mặc định plan_cache_mode = auto, bảy lần chạy tiếp theo với các khoảng giá khác nhau đều nhận custom plan; pg_prepared_statements báo generic_plans 1, chính là lần ép ở trên, và custom_plans 8. Nên plan tệ là một rủi ro tùy vào ước lượng chi phí và một setting, không phải chắc chắn xảy ra. Thứ chắc chắn nằm ngay trong SQL: mọi request đều trả giá cho mọi điều kiện và cho join. Query dựng từ đúng những filter có mặt thì không mắc cả hai vấn đề, và phần còn lại của bài dựng chính loại query đó.

Specification với JpaSpecificationExecutor

Một Specification<T> là một function nhận các thành phần của một JPA Criteria query và trả về một điều kiện: toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb) trả về một Predicate. Repository có các method nhận specification khi extend JpaSpecificationExecutor<T>:

src/main/java/com/example/demo/product/ProductRepository.java
package com.example.demo.product;
 
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
 
public interface ProductRepository extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {
}

Trong 4.1.1, interface này có findOne, findAll có và không có Sort hoặc Pageable, findAll(spec, countSpec, pageable) với một specification riêng cho count, count, exists, update, deletefindBy dạng fluent, phần lớn có hai overload, một nhận Specification và một nhận PredicateSpecification, thứ mà phần về API của Spring Data 4 sẽ quay lại.

Mỗi filter một Specification nhỏ

Mỗi filter thành một static factory trả về Specification<Product>. Phiên bản đầu gọi tên attribute bằng chuỗi, thứ mà phần về Criteria API sẽ thay thế:

src/main/java/com/example/demo/product/ProductSpecifications.java
package com.example.demo.product;
 
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
 
import jakarta.persistence.criteria.Join;
 
import org.springframework.data.jpa.domain.Specification;
 
public final class ProductSpecifications {
 
    private ProductSpecifications() {
    }
 
    public static Specification<Product> nameContains(String text) {
        String pattern = "%" + escapeLike(text.toLowerCase(Locale.ROOT)) + "%";
        return (root, query, cb) -> cb.like(cb.lower(root.get("name")), pattern, '\\');
    }
 
    public static Specification<Product> inCategory(String categoryName) {
        return (root, query, cb) -> cb.equal(root.get("category").get("name"), categoryName);
    }
 
    public static Specification<Product> priceAtLeast(BigDecimal min) {
        return (root, query, cb) -> cb.greaterThanOrEqualTo(root.get("price"), min);
    }
 
    public static Specification<Product> priceAtMost(BigDecimal max) {
        return (root, query, cb) -> cb.lessThanOrEqualTo(root.get("price"), max);
    }
 
    public static Specification<Product> inStock() {
        return (root, query, cb) -> cb.greaterThan(root.get("stock"), 0);
    }
 
    public static Specification<Product> hasAnyTag(Collection<String> tagNames) {
        return (root, query, cb) -> {
            Join<Product, Tag> tags = root.join("tags");
            return tags.get("name").in(tagNames);
        };
    }
 
    public static Specification<Product> matching(ProductFilter filter) {
        List<Specification<Product>> specs = new ArrayList<>();
        if (filter.q() != null) specs.add(nameContains(filter.q()));
        if (filter.category() != null) specs.add(inCategory(filter.category()));
        if (filter.minPrice() != null) specs.add(priceAtLeast(filter.minPrice()));
        if (filter.maxPrice() != null) specs.add(priceAtMost(filter.maxPrice()));
        if (!filter.tag().isEmpty()) specs.add(hasAnyTag(filter.tag()));
        if (Boolean.TRUE.equals(filter.inStock())) specs.add(inStock());
        return Specification.allOf(specs);
    }
 
    static String escapeLike(String text) {
        return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
    }
}

Các factory không bao giờ thấy null. matching quyết định filter nào có mặt và thêm một specification cho mỗi filter đó; factory chỉ mô tả một điều kiện. hasAnyTag là phiên bản ngây thơ, và phần về join to-many sẽ cho thấy nó làm gì với một page.

Service truyền specification đã kết hợp cùng Pageable cho repository, và controller bind cả hai từ request:

src/main/java/com/example/demo/product/ProductService.java
    @Transactional(readOnly = true)
    public Page<ProductResponse> search(ProductFilter filter, Pageable pageable) {
        return repository.findAll(ProductSpecifications.matching(filter), pageable)
                .map(ProductResponse::from);
    }
src/main/java/com/example/demo/product/ProductController.java
    @GetMapping
    public PageResponse<ProductResponse> search(@Valid ProductFilter filter,
                                                @PageableDefault(size = 20, sort = "id") Pageable pageable) {
        return PageResponse.from(service.search(filter, pageable));
    }

ProductResponse là một record chứa id, name, SKU, price, stock và tên category, còn PageResponse là record trong bài phân trang của khóa Basics.

Kết hợp specification bằng allOf, and, or và not

Specification.allOf(specs) nối một list bằng and, và list rỗng cho ra một specification không có điều kiện. Cùng filter object đó với ba field được đặt, category Mice, giá tối đa 50 và còn hàng:

Text
>>> findAll(matching(category=Mice, maxPrice=50, inStock=true), page 0 size 3 sort price,id)
select p1_0.id,p1_0.category_id,c1_0.id,c1_0.name,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where c1_0.name=? and p1_0.price<=? and p1_0.stock>? order by p1_0.price,p1_0.id offset ? rows fetch first ? rows only
binding parameter (1:VARCHAR) <- [Mice]
binding parameter (2:NUMERIC) <- [50]
binding parameter (3:INTEGER) <- [0]
binding parameter (4:INTEGER) <- [0]
binding parameter (5:INTEGER) <- [3]
select count(p1_0.id) from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where c1_0.name=? and p1_0.price<=? and p1_0.stock>?
binding parameter (1:VARCHAR) <- [Mice]
binding parameter (2:NUMERIC) <- [50]
binding parameter (3:INTEGER) <- [0]
    content [12:MS-05 19.90, 13:MS-06 22.00, 8:MS-01 24.50]
    numberOfElements=3 totalElements=4 totalPages=2

Ba filter, ba điều kiện, không thêm gì. Hibernate bind cả literal 0 của stock > 0 dưới dạng parameter. Khi mọi field đều trống, list rỗng và query hoàn toàn không có where:

Text
>>> findAll(matching(empty filter), page 0 size 3)
select p1_0.id,p1_0.category_id,c1_0.id,c1_0.name,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id order by p1_0.id offset ? rows fetch first ? rows only
binding parameter (1:INTEGER) <- [0]
binding parameter (2:INTEGER) <- [3]
select count(p1_0.id) from products p1_0
    content [1:KB-01 89.90, 2:KB-02 59.00, 3:KB-03 45.50]
    numberOfElements=3 totalElements=23 totalPages=8

Ba filter có mặt và ba vắng mặt trong một request, các specification được tạo cho ba filter có mặt, allOf gộp chúng thành một predicate AND, và mệnh đề WHERE Hibernate sinh ra từ đó

Specification còn kết hợp được bằng and, orSpecification.not, và cây kết quả giữ nguyên cách nhóm trong SQL:

Java
repository.findAll(inCategory("Mice").or(inCategory("Keyboards")).and(Specification.not(inStock())));
Text
>>> findAll(inCategory(Mice).or(inCategory(Keyboards)).and(Specification.not(inStock())))
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where (c1_0.name=? or c1_0.name=?) and p1_0.stock<=?
binding parameter (1:VARCHAR) <- [Mice]
binding parameter (2:VARCHAR) <- [Keyboards]
binding parameter (3:INTEGER) <- [0]
    [3:KB-03 45.50, 10:MS-03 39.00] (2 rows)

or trở thành một nhóm trong ngoặc, đúng thứ mà tên derived query không diễn đạt được. Hibernate viết not (stock > 0) thành stock <= ?.

API Specification thay đổi gì trong Spring Data JPA 4

Phần lớn ví dụ về Specification được viết cho Spring Data JPA 2 hoặc 3, và chúng dựa vào việc null vô hại: factory trả về null khi thiếu parameter, và Specification.where(a).and(b).and(c) bỏ qua nó. Trong spring-data-jpa 3.5.0, where vẫn hoạt động như vậy: bytecode của nó trả về một specification không có điều kiện khi argument là null, và method mang @Deprecated(since = "3.5.0", forRemoval = true). Trong 4.1.1, where không còn deprecated, javap -v không tìm thấy @Deprecated trên method nào của Specification hay JpaSpecificationExecutor, và sự dễ dãi với null đã biến mất:

Text
>>> Specification.where((Specification<Product>) null)
!!! java.lang.IllegalArgumentException: Specification must not be null
>>> inStock().and((Specification<Product>) null)
!!! java.lang.IllegalArgumentException: Other specification must not be null
>>> Specification.allOf(inStock(), null)
!!! java.lang.IllegalArgumentException: Other specification must not be null

Nên các factory trả về null trong code cũ sẽ lỗi ở request đầu tiên bỏ trống một filter. Các cách của 4.x để nói "không có điều kiện" đều chạy và đều cho ra query không có where: Specification.unrestricted(), allOf trên một list rỗng như trong matching, và một toPredicate trả về null, thứ mà interface đánh dấu là JSpecify @Nullable.

Spring Data JPA 4 còn thêm ba người anh em mà 3.5.0 không có:

TypetoPredicate nhậnDùng bởi
Specification<T>Root<T>, CriteriaQuery<?>, CriteriaBuilderfindOne, findAll có và không có Sort hoặc Pageable, count, exists, findBy
PredicateSpecification<T>From<?, T>, CriteriaBuilderfindOne, findAll không phân trang, count, exists, delete, findBy; các overload where, andor của ba type còn lại cũng nhận nó
UpdateSpecification<T>Root<T>, CriteriaUpdate<T>, CriteriaBuilderupdate
DeleteSpecification<T>Root<T>, CriteriaDelete<T>, CriteriaBuilderdelete

PredicateSpecification không có query để chạm vào, nên nó không thêm được subquery hay distinct; đổi lại, một điều kiện phục vụ được cả select, update lẫn delete:

Java
PredicateSpecification<Product> soldOut = (from, cb) -> cb.equal(from.get(Product_.stock), 0);
UpdateSpecification<Product> restock = UpdateSpecification.<Product>update(
        (root, update, cb) -> update.set(root.get(Product_.stock), 5)).where(soldOut);
long updated = repository.update(restock);
Text
>>> repository.count(soldOut)
select count(p1_0.id) from products p1_0 where p1_0.stock=?
binding parameter (1:INTEGER) <- [0]
    returned 2 (java.lang.Long)
>>> repository.update(restock) without a transaction
update products p1_0 set stock=? where p1_0.stock=?
binding parameter (1:INTEGER) <- [5]
binding parameter (2:INTEGER) <- [0]
    returned 2 (java.lang.Long)

Khác với một @Modifying query method, thứ mà Basics 27 thấy lỗi với "No active transaction for update or delete query", update chạy được mà không có transaction bao ngoài, và psql sau đó cho thấy cả hai dòng có stock 5: method của repository đã commit trong transaction của chính nó.

Escape % và _ trong specification dùng LIKE

Basics 27 thấy Spring Data escape %_ cho derived query Containing nhưng không làm vậy với JPQL viết tay. Specification không thuộc loại nào trong hai: Criteria API đưa pattern cho Hibernate nguyên trạng, và việc escape là trách nhiệm của factory. nameContains escape dấu backslash, %_, rồi khai báo \ là ký tự escape bằng argument thứ ba của cb.like:

Text
>>> findAll(nameContains("%"))
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where lower(p1_0.name) like ? escape '\'
binding parameter (1:VARCHAR) <- [%\%%]
    [23:AC-06 45.50] (1 rows)
>>> findAll(nameContains("_"))
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where lower(p1_0.name) like ? escape '\'
binding parameter (1:VARCHAR) <- [%\_%]
    [] (0 rows)
>>> findAll(cb.like(lower(name), "%" + "%" + "%")) without escaping
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where lower(p1_0.name) like ? escape ''
binding parameter (1:VARCHAR) <- [%%%]
    [1:KB-01 89.90, 2:KB-02 59.00, 3:KB-03 45.50, 4:KB-04 129.00, 5:KB-05 99.00, 6:KB-06 74.00, 7:KB-07 19.90, 8:MS-01 24.50, 9:MS-02 49.90, 10:MS-03 39.00, 11:MS-04 54.00, 12:MS-05 19.90, 13:MS-06 22.00, 14:MN-01 279.00, 15:MN-02 449.00, 16:MN-03 189.00, 17:MN-04 399.00, 18:AC-01 39.00, 19:AC-02 34.90, 20:AC-03 19.90, 21:AC-04 64.00, 22:AC-05 12.50, 23:AC-06 45.50] (23 rows)

Có escape, một % tìm ra đúng sản phẩm có dấu phần trăm trong tên. Không escape, Hibernate viết escape '', tức là tắt escape, và % của người dùng khớp với cả 23 sản phẩm. Method escape phải escape chính ký tự escape trước, nếu không một dấu backslash từ người dùng sẽ nuốt mất ký tự đứng sau nó.

Phân trang một specification: hai câu SQL

findAll(spec, pageable) gửi câu query lấy dòng với offsetfetch first, rồi một count query dựng từ cùng specification, như lần chạy ba filter ở trên đã cho thấy. Spring Data bỏ qua count khi page đầu tiên chưa đầy, vì lúc đó tổng chính là kích thước của page. ProductResponse cần tên category, và open-in-view đang tắt, nên category phải đi kèm sản phẩm. Khai báo lại method của executor với một entity graph làm việc đó cho riêng query này:

src/main/java/com/example/demo/product/ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {
 
    @Override
    @EntityGraph(attributePaths = "category") 
    Page<Product> findAll(Specification<Product> spec, Pageable pageable); 
}

Đó là nguồn gốc của c1_0.id,c1_0.name trong câu query lấy dòng ở trên, và count query không có join trừ khi một filter cần tới. Khi có filter category, Hibernate dùng một join duy nhất cho cả fetch lẫn điều kiện. Load association hiệu quả là chủ đề của bài 5; điều quan trọng ở đây là fetch đặt ở đâu. Đặt root.fetch("category") bên trong specification, và câu query lấy dòng chạy được, rồi count query lỗi, vì Spring Data áp cùng specification đó lên một query select count(...):

Text
>>> findAll(spec with root.fetch(category), page 0 size 3)
select p1_0.id,p1_0.category_id,c1_0.id,c1_0.name,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where p1_0.price<=? order by p1_0.id offset ? rows fetch first ? rows only
binding parameter (1:NUMERIC) <- [50]
binding parameter (2:INTEGER) <- [0]
binding parameter (3:INTEGER) <- [3]
!!! org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.query.SemanticException: Query specified join fetching, but the owner of the fetched association was not present in the select list [SqmSingularJoin(com.example.demo.product.Product(var_1).category(var_1_1) : category)]
!!! caused by java.lang.IllegalArgumentException: org.hibernate.query.SemanticException: Query specified join fetching, but the owner of the fetched association was not present in the select list [SqmSingularJoin(com.example.demo.product.Product(var_1).category(var_1_1) : category)]
!!! caused by org.hibernate.query.SemanticException: Query specified join fetching, but the owner of the fetched association was not present in the select list [SqmSingularJoin(com.example.demo.product.Product(var_1).category(var_1_1) : category)]

Specification được gọi một lần cho mỗi query mà Spring Data dựng từ nó, nên nó chỉ nên mô tả điều kiện và để repository quyết định hình dạng kết quả.

Filter trên join to-many: dòng trùng, distinct và exists

tag=wireless&tag=usb-c hỏi các sản phẩm mang một trong hai tag. Mười sản phẩm khớp, và MS-05 khớp hai lần, mỗi tag một lần. hasAnyTag ở trên join collection, và một join sinh ra một dòng cho mỗi tag khớp.

Join thường làm gì với một page

Page 0 và page 1, năm sản phẩm mỗi page:

Text
>>> findAll(hasAnyTag(wireless, usb-c) with a plain join, page 0 size 5)
select p1_0.id,p1_0.category_id,c1_0.id,c1_0.name,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 join product_tags t1_0 on p1_0.id=t1_0.product_id join tags t1_1 on t1_1.id=t1_0.tag_id join categories c1_0 on c1_0.id=p1_0.category_id where t1_1.name in (?,?) order by p1_0.id offset ? rows fetch first ? rows only
binding parameter (1:VARCHAR) <- [wireless]
binding parameter (2:VARCHAR) <- [usb-c]
binding parameter (3:INTEGER) <- [0]
binding parameter (4:INTEGER) <- [5]
select count(p1_0.id) from products p1_0 join product_tags t1_0 on p1_0.id=t1_0.product_id join tags t1_1 on t1_1.id=t1_0.tag_id where t1_1.name in (?,?)
binding parameter (1:VARCHAR) <- [wireless]
binding parameter (2:VARCHAR) <- [usb-c]
    content [3:KB-03 45.50, 6:KB-06 74.00, 8:MS-01 24.50, 11:MS-04 54.00, 12:MS-05 19.90]
    numberOfElements=5 totalElements=11 totalPages=3
>>> findAll(hasAnyTag plain join, page 1 size 5)
    content [12:MS-05 19.90, 14:MN-01 279.00, 15:MN-02 449.00, 16:MN-03 189.00, 18:AC-01 39.00]
    numberOfElements=5 totalElements=11 totalPages=3

Đã có hai chỗ sai. Count đếm dòng của join, 11, chứ không phải sản phẩm, 10, nên pager hứa ba page cho một kết quả vừa đủ hai. Và MS-05 vừa là sản phẩm cuối của page 0 vừa là sản phẩm đầu của page 1, vì hai dòng của nó rơi vào hai phía của offset. Với sáu sản phẩm mỗi page, lab chạy dưới tên joinTags, vẫn là join thường đó, mọi thứ còn tệ hơn:

Text
>>> findAll(joinTags(wireless, usb-c), page 0 size 6) [entity graph]
    content [3:KB-03 45.50, 6:KB-06 74.00, 8:MS-01 24.50, 11:MS-04 54.00, 12:MS-05 19.90]
    numberOfElements=5 totalElements=5 totalPages=1
>>> findAll(joinTags(wireless, usb-c), page 1 size 6) [entity graph]
    content [14:MN-01 279.00, 15:MN-02 449.00, 16:MN-03 189.00, 18:AC-01 39.00, 21:AC-04 64.00]
    numberOfElements=5 totalElements=11 totalPages=2

Database trả về sáu dòng cho page 0, MS-05 hai lần. Hibernate 7.4 bỏ entity bị lặp khỏi result list, nên page chỉ còn năm sản phẩm. Spring Data thấy năm kết quả cho một page sáu phần tử, kết luận đây là page cuối, bỏ qua count query và báo totalElements=5, totalPages=1. Client sẽ dừng ở đó và không bao giờ thấy năm sản phẩm ở page 1. Cùng specification đó dưới dạng List thường trả về 10 sản phẩm từ 11 dòng mà psql đếm được cho join, nên việc khử trùng trong bộ nhớ là của Hibernate và không liên quan tới phân trang.

query.distinct(true) và count query

Cách sửa trong sách giáo khoa là yêu cầu query trả về dòng không trùng:

Java
static Specification<Product> distinctTags(List<String> tagNames) {
    return (root, query, cb) -> {
        query.distinct(true);
        Join<Product, Tag> tags = root.join("tags");
        return tags.get("name").in(tagNames);
    };
}
Text
>>> findAll(hasAnyTag with query.distinct(true), page 0 size 5)
select distinct p1_0.id,p1_0.category_id,c1_0.id,c1_0.name,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 join product_tags t1_0 on p1_0.id=t1_0.product_id join tags t1_1 on t1_1.id=t1_0.tag_id join categories c1_0 on c1_0.id=p1_0.category_id where t1_1.name in (?,?) order by p1_0.id offset ? rows fetch first ? rows only
binding parameter (1:VARCHAR) <- [wireless]
binding parameter (2:VARCHAR) <- [usb-c]
binding parameter (3:INTEGER) <- [0]
binding parameter (4:INTEGER) <- [5]
select distinct count(distinct p1_0.id) from products p1_0 join product_tags t1_0 on p1_0.id=t1_0.product_id join tags t1_1 on t1_1.id=t1_0.tag_id where t1_1.name in (?,?)
binding parameter (1:VARCHAR) <- [wireless]
binding parameter (2:VARCHAR) <- [usb-c]
    content [3:KB-03 45.50, 6:KB-06 74.00, 8:MS-01 24.50, 11:MS-04 54.00, 12:MS-05 19.90]
    numberOfElements=5 totalElements=10 totalPages=2

Câu query lấy dòng thành select distinct, và vì specification cũng chạy trên count query và đặt distinct ở đó, Spring Data đếm count(distinct p1_0.id): 10, đúng. Count query ra dạng select distinct count(distinct …), vô hại nhưng là dấu hiệu của một cờ bị đặt lên query không dành cho nó. distinct có cái giá riêng: database phải so sánh nguyên dòng, và PostgreSQL yêu cầu mọi biểu thức trong order by phải nằm trong select list. Có entity graph, các cột category được select và sort=category.name chạy được. Qua findAll(spec, Sort), method không có entity graph, cùng cách sort đó lỗi:

Text
>>> findAll(hasAnyTag with query.distinct(true), Sort.by(category.name)) without entity graph
select distinct p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 join product_tags t1_0 on p1_0.id=t1_0.product_id join tags t1_1 on t1_1.id=t1_0.tag_id join categories c1_0 on c1_0.id=p1_0.category_id where t1_1.name in (?,?) order by c1_0.name
binding parameter (1:VARCHAR) <- [wireless]
binding parameter (2:VARCHAR) <- [usb-c]
HHH000247: ErrorCode: 0, SQLState: 42P10
ERROR: for SELECT DISTINCT, ORDER BY expressions must appear in select list
  Position: 275
!!! org.springframework.dao.InvalidDataAccessResourceUsageException: JDBC exception executing SQL [ERROR: for SELECT DISTINCT, ORDER BY expressions must appear in select list
...

Dùng subquery exists thay cho join

Câu hỏi "sản phẩm này có một trong các tag này không?" là có hoặc không cho từng sản phẩm, đúng thứ exists hỏi. Một correlated subquery giữ collection hoàn toàn ngoài query bên ngoài:

src/main/java/com/example/demo/product/ProductSpecifications.java
    public static Specification<Product> hasAnyTag(Collection<String> tagNames) {
        return (root, query, cb) -> {
            Join<Product, Tag> tags = root.join("tags"); 
            return tags.get("name").in(tagNames); 
            Subquery<Integer> sub = query.subquery(Integer.class); 
            Join<Product, Tag> tags = sub.correlate(root).join("tags"); 
            sub.select(cb.literal(1)).where(tags.get("name").in(tagNames)); 
            return cb.exists(sub); 
        };
    }
Text
>>> findAll(hasAnyTag as correlated exists, page 0 size 5)
select p1_0.id,p1_0.category_id,c1_0.id,c1_0.name,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where exists(select 1 from product_tags t1_0 join tags t1_1 on t1_1.id=t1_0.tag_id where t1_1.name in (?,?) and p1_0.id=t1_0.product_id) order by p1_0.id offset ? rows fetch first ? rows only
binding parameter (1:VARCHAR) <- [wireless]
binding parameter (2:VARCHAR) <- [usb-c]
binding parameter (3:INTEGER) <- [0]
binding parameter (4:INTEGER) <- [5]
select count(p1_0.id) from products p1_0 where exists(select 1 from product_tags t1_0 join tags t1_1 on t1_1.id=t1_0.tag_id where t1_1.name in (?,?) and p1_0.id=t1_0.product_id)
binding parameter (1:VARCHAR) <- [wireless]
binding parameter (2:VARCHAR) <- [usb-c]
    content [3:KB-03 45.50, 6:KB-06 74.00, 8:MS-01 24.50, 11:MS-04 54.00, 12:MS-05 19.90]
    numberOfElements=5 totalElements=10 totalPages=2

Mỗi sản phẩm một dòng, một count(p1_0.id) thường ra 10, và không có distinct. Page 1 với sáu phần tử mỗi page chứa bốn sản phẩm còn lại, và sort=category.name chạy được qua cả hai method findAll. sub.correlate(root) là thứ khiến Hibernate viết p1_0.id=t1_0.product_id trên alias của query bên ngoài; một subquery có from(Product.class) riêng cùng một equal trên id cũng trả về đúng mười sản phẩm, nhưng SQL của nó đặt thêm một bản products thứ hai bên trong subquery, select p2_0.id from products p2_0 join product_tags … where p2_0.id=p1_0.id and …. Phiên bản này cần query.subquery, và đó là lý do hasAnyTag vẫn là một Specification chứ không thể là PredicateSpecification.

Criteria API bên dưới Specification

Specification là một tầng mỏng bên trên JPA Criteria API: Spring Data tạo CriteriaQuery, RootCriteriaBuilder, gọi toPredicate, rồi lo phần còn lại. Có hai thứ đáng lấy từ tầng bên dưới: reference tới attribute an toàn về type, và những query có hình dạng mà Specification không thay đổi được.

JPA static metamodel với hibernate-processor

root.get("prize") compile được. Nó lỗi lúc query được dựng, ở request đầu tiên dùng filter đó, chứ không phải lúc startup như một derived query viết sai tên:

Text
>>> findAll(root.get("prize"))
!!! org.springframework.dao.InvalidDataAccessApiUsageException: Could not resolve attribute 'prize' of 'com.example.demo.product.Product'
!!! caused by org.hibernate.query.sqm.PathElementException: Could not resolve attribute 'prize' of 'com.example.demo.product.Product'

Annotation processor của Hibernate sinh ra một class static metamodel cho mỗi entity lúc compile. Artifact là org.hibernate.orm:hibernate-processor, Spring Boot quản lý version của nó và resolve thành 7.4.5.Final; hibernate-jpamodelgen 7.4.5.Final vẫn còn trên Maven Central, nhưng chỉ là một relocation POM với description "The hibernate-jpamodelgen module has been renamed hibernate-processor".

build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    // the other starters, flyway-database-postgresql and the PostgreSQL driver as generated
    annotationProcessor 'org.hibernate.orm:hibernate-processor'
}

compileJava in ra Note: Hibernate compile-time tooling 7.4.5.Final và ghi Product_, Category_Tag_ vào build/generated/sources/annotationProcessor/java/main, thư mục mà Gradle compile cùng phần còn lại. Mỗi class có một attribute có type và một hằng chuỗi cho mỗi field:

build/generated/sources/annotationProcessor/java/main/com/example/demo/product/Product_.java
@StaticMetamodel(Product.class)
@Generated("org.hibernate.processor.HibernateProcessor")
public abstract class Product_ {
 
	public static final String ID = "id";
	public static final String NAME = "name";
	public static final String SKU = "sku";
	public static final String PRICE = "price";
	public static final String STOCK = "stock";
	public static final String CATEGORY = "category";
	public static final String TAGS = "tags";
 
	public static volatile EntityType<Product> class_;
	public static volatile SingularAttribute<Product, Long> id;
	public static volatile SingularAttribute<Product, String> name;
	public static volatile SingularAttribute<Product, String> sku;
	public static volatile SingularAttribute<Product, BigDecimal> price;
	public static volatile SingularAttribute<Product, Integer> stock;
	public static volatile SingularAttribute<Product, Category> category;
	public static volatile SetAttribute<Product, Tag> tags;
}

Các specification chuyển sang dùng nó:

src/main/java/com/example/demo/product/ProductSpecifications.java
        return (root, query, cb) -> cb.like(cb.lower(root.get("name")), pattern, '\\'); 
        return (root, query, cb) -> cb.like(cb.lower(root.get(Product_.name)), pattern, '\\'); 
        return (root, query, cb) -> cb.equal(root.get("category").get("name"), categoryName); 
        return (root, query, cb) -> cb.equal(root.get(Product_.category).get(Category_.name), categoryName); 
        return (root, query, cb) -> cb.greaterThanOrEqualTo(root.get("price"), min); 
        return (root, query, cb) -> cb.greaterThanOrEqualTo(root.get(Product_.price), min); 
        return (root, query, cb) -> cb.lessThanOrEqualTo(root.get("price"), max); 
        return (root, query, cb) -> cb.lessThanOrEqualTo(root.get(Product_.price), max); 
        return (root, query, cb) -> cb.greaterThan(root.get("stock"), 0); 
        return (root, query, cb) -> cb.greaterThan(root.get(Product_.stock), 0); 
            Join<Product, Tag> tags = sub.correlate(root).join("tags"); 
            Join<Product, Tag> tags = sub.correlate(root).join(Product_.tags); 
            sub.select(cb.literal(1)).where(tags.get("name").in(tagNames)); 
            sub.select(cb.literal(1)).where(tags.get(Tag_.name).in(tagNames)); 

SQL sinh ra không đổi. Góc nhìn của compiler thì đổi: một attribute viết sai và một phép so sánh sai type, những thứ phiên bản dùng chuỗi chấp nhận, đều làm build dừng lại:

Text
src/main/java/com/example/demo/product/ProductSpecifications.java:29: error: cannot find symbol
        return (root, query, cb) -> cb.greaterThanOrEqualTo(root.get(Product_.prize), min);
                                                                             ^
  symbol:   variable prize
  location: class Product_
src/main/java/com/example/demo/product/ProductSpecifications.java:37: error: no suitable method found for greaterThan(Path<Integer>,BigDecimal)
        return (root, query, cb) -> cb.greaterThan(root.get(Product_.stock), new BigDecimal("0.5"));
                                      ^

Với chuỗi, cùng phép so sánh đó, cb.greaterThan(root.get("stock"), new BigDecimal("0.5")), compile được và lỗi ở lần gọi đầu với JpaSystemException: Error coercing value, cause là ArithmeticException: Rounding necessary. Các hằng chuỗi cũng có ích: Product_.PRICE là cái tên mà whitelist cho sort trong phần validation dùng để so sánh.

Cùng search đó trong một repository fragment tự viết

Một repository fragment gồm một interface và một class mang tên interface đó cộng hậu tố Impl; Spring Data tìm class đó và gộp các method của nó vào proxy của repository:

src/main/java/com/example/demo/product/ProductSearchRepository.java
package com.example.demo.product;
 
import java.util.List;
 
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
 
public interface ProductSearchRepository {
 
    Page<Product> search(ProductFilter filter, Pageable pageable);
 
    List<CategoryCount> countByCategory(ProductFilter filter);
}
src/main/java/com/example/demo/product/ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> { 
public interface ProductRepository extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product>, 
        ProductSearchRepository { 

search làm bằng tay những gì findAll(spec, pageable) đã làm cho Specification. Nó dùng lại chính Specification đó, vì một Specification chỉ là một function từ root, query và builder tới một predicate, và query Criteria nào cũng gọi được nó:

src/main/java/com/example/demo/product/ProductSearchRepositoryImpl.java
class ProductSearchRepositoryImpl implements ProductSearchRepository {
 
    private final EntityManager entityManager;
 
    ProductSearchRepositoryImpl(EntityManager entityManager) {
        this.entityManager = entityManager;
    }
 
    @Override
    public Page<Product> search(ProductFilter filter, Pageable pageable) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        Specification<Product> spec = ProductSpecifications.matching(filter);
 
        CriteriaQuery<Product> query = cb.createQuery(Product.class);
        Root<Product> product = query.from(Product.class);
        product.fetch(Product_.category);
        query.select(product);
        Predicate where = spec.toPredicate(product, query, cb);
        if (where != null) {
            query.where(where);
        }
        query.orderBy(QueryUtils.toOrders(pageable.getSort(), product, cb));
        List<Product> content = entityManager.createQuery(query)
                .setFirstResult((int) pageable.getOffset())
                .setMaxResults(pageable.getPageSize())
                .getResultList();
 
        CriteriaQuery<Long> countQuery = cb.createQuery(Long.class);
        Root<Product> counted = countQuery.from(Product.class);
        countQuery.select(cb.count(counted));
        Predicate countWhere = spec.toPredicate(counted, countQuery, cb);
        if (countWhere != null) {
            countQuery.where(countWhere);
        }
        return PageableExecutionUtils.getPage(content, pageable,
                () -> entityManager.createQuery(countQuery).getSingleResult());
    }
}

Đó là danh sách những thứ Specification che đi: hai query với hai root, specification áp lên từng query, Sort được QueryUtils.toOrders của Spring Data đổi thành các object Order, offset và limit, và PageableExecutionUtils, thứ chỉ chạy supplier của count khi page chưa tự cho biết tổng. Ở đây fetch an toàn, vì nó chỉ nằm trên câu query lấy dòng. Với category Mice, giá tối đa 50, cả hai tag và còn hàng:

Text
>>> search(category=Mice, maxPrice=50, tag=[wireless, usb-c], inStock=true), page 0 size 3 sort price,id [Criteria]
select p1_0.id,p1_0.category_id,c1_0.id,c1_0.name,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where c1_0.name=? and p1_0.price<=? and exists(select 1 from product_tags t1_0 join tags t1_1 on t1_1.id=t1_0.tag_id where t1_1.name in (?,?) and p1_0.id=t1_0.product_id) and p1_0.stock>? order by p1_0.price,p1_0.id offset ? rows fetch first ? rows only
binding parameter (1:VARCHAR) <- [Mice]
binding parameter (2:NUMERIC) <- [50]
binding parameter (3:VARCHAR) <- [wireless]
binding parameter (4:VARCHAR) <- [usb-c]
binding parameter (5:INTEGER) <- [0]
binding parameter (6:INTEGER) <- [0]
binding parameter (7:INTEGER) <- [3]
    content [12:MS-05 19.90, 8:MS-01 24.50]
    numberOfElements=2 totalElements=2 totalPages=1

Hai sản phẩm trên một page ba phần tử, nên PageableExecutionUtils không hề gọi count. Page 1 của maxPrice=50 thì cần và gửi select count(p1_0.id) from products p1_0 where p1_0.price<=?: 12 sản phẩm.

CriteriaQuery cùng Root, fetch join, bốn predicate dựng từ metamodel attribute, order và giới hạn page, mỗi thứ nối với mệnh đề SQL nó sinh ra

Đếm theo category, thứ Specification không trả về được

Trang search thường hiện số lượng bên cạnh các filter: mỗi category có bao nhiêu kết quả cho lần search hiện tại. Mọi method của JpaSpecificationExecutor đều trả về entity, một con số count hoặc một boolean; không method nào group được. Một tuple query của Criteria thì làm được, và vẫn dùng lại Specification cho phần where:

src/main/java/com/example/demo/product/ProductSearchRepositoryImpl.java
    @Override
    public List<CategoryCount> countByCategory(ProductFilter filter) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Tuple> query = cb.createTupleQuery();
        Root<Product> product = query.from(Product.class);
        Join<Product, Category> category = product.join(Product_.category);
        Path<String> categoryName = category.get(Category_.name);
 
        query.multiselect(categoryName, cb.count(product));
        Predicate where = ProductSpecifications.matching(filter).toPredicate(product, query, cb);
        if (where != null) {
            query.where(where);
        }
        query.groupBy(categoryName).orderBy(cb.asc(categoryName));
 
        return entityManager.createQuery(query).getResultList().stream()
                .map(row -> new CategoryCount(row.get(0, String.class), row.get(1, Long.class)))
                .toList();
    }
Text
>>> countByCategory(maxPrice=50, inStock=true) [Criteria]
select c1_0.name,count(p1_0.id) from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where p1_0.price<=? and p1_0.stock>? group by 1 order by 1
binding parameter (1:NUMERIC) <- [50]
binding parameter (2:INTEGER) <- [0]
    [CategoryCount[category=Accessories, products=5], CategoryCount[category=Keyboards, products=1], CategoryCount[category=Mice, products=4]]
>>> countByCategory(category=Mice) [Criteria]
select c1_0.name,count(p1_0.id) from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where c1_0.name=? group by 1 order by 1
binding parameter (1:VARCHAR) <- [Mice]
    [CategoryCount[category=Mice, products=6]]

Hibernate viết group by 1 order by 1, tức là vị trí trong select list, và khi có filter category, category.name của specification dùng lại join tường minh thay vì thêm join thứ hai. Ở đây các dòng được map từ Tuple theo vị trí; select thẳng vào một record hoặc interface, tức projection, là chủ đề của bài 5. Mở ra thành GET /api/products/category-counts, lời gọi đầu tiên trả về:

Text
[{"category":"Accessories","products":5},{"category":"Keyboards","products":1},{"category":"Mice","products":4}]

Querydsl 5.1 với Spring Boot 4 và Hibernate 7

Querydsl sinh ra một query type cho mỗi entity, QProduct cho Product, và dựng query từ đó bằng một fluent API. Bản release cuối dưới com.querydsl là 5.1.0, ra đời trước khi có Hibernate 7, nên việc nó còn chạy hay không là một câu hỏi chính đáng. Dependency management của Spring Boot 4.1.1 đặt querydsl.version là 5.1.0, và POM của spring-data-jpa 4.1.1 khai báo querydsl-jpa với classifier jakarta là một optional dependency. Mọi bước bên dưới đều chạy trên Hibernate 7.4.5, và không bước nào lỗi.

Cài đặt: jakarta classifier và các Q-class

build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    // ...
    implementation 'com.querydsl:querydsl-jpa::jakarta'
    annotationProcessor 'com.querydsl:querydsl-apt::jakarta'
    annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
    annotationProcessor 'org.hibernate.orm:hibernate-processor'
}

Ba chi tiết trong bốn dòng. Version để trống giữa :: cho phép dependency management của Boot cung cấp 5.1.0 trong khi tọa độ vẫn chỉ đúng classifier jakarta. Không có classifier, bạn nhận bản build cho API javax.persistence cũ: javap trên JPAQueryFactory của nó cho thấy constructor nhận javax.persistence.EntityManager, còn bản jar jakarta nhận jakarta.persistence.EntityManager, type mà Hibernate 7 cung cấp. Dạng map mà nhiều hướng dẫn dùng, implementation(group: 'com.querydsl', name: 'querydsl-jpa', classifier: 'jakarta'), vẫn chạy trên Gradle 9.7.1 nhưng log ra "Declaring dependencies using multi-string notation has been deprecated. This will fail with an error in Gradle 10. Please use single-string notation ("com.querydsl:querydsl-jpa::jakarta")". Và querydsl-apt cần persistence API trên processor path: khi chỉ có processor của Querydsl, compileJava lỗi với java.lang.NoClassDefFoundError: jakarta/persistence/Entity. hibernate-processor kéo API đó theo dạng transitive, nên trong build này dòng thứ ba là thừa, nhưng nó giữ Querydsl chạy được nếu bỏ processor của metamodel.

Cả hai processor chạy trong cùng một lần compile: QProduct, QCategoryQTag xuất hiện bên cạnh Product_, Category_Tag_ trong cùng thư mục generated sources.

Search bằng BooleanBuilder với QuerydslPredicateExecutor

BooleanBuilder là một predicate có thể thay đổi, bắt đầu rỗng, và and trên nó nối thêm một điều kiện:

src/main/java/com/example/demo/product/ProductPredicates.java
package com.example.demo.product;
 
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.Predicate;
 
public final class ProductPredicates {
 
    private ProductPredicates() {
    }
 
    public static Predicate matching(ProductFilter filter) {
        QProduct product = QProduct.product;
        BooleanBuilder where = new BooleanBuilder();
        if (filter.q() != null) where.and(product.name.containsIgnoreCase(filter.q()));
        if (filter.category() != null) where.and(product.category.name.eq(filter.category()));
        if (filter.minPrice() != null) where.and(product.price.goe(filter.minPrice()));
        if (filter.maxPrice() != null) where.and(product.price.loe(filter.maxPrice()));
        if (!filter.tag().isEmpty()) where.and(product.tags.any().name.in(filter.tag()));
        if (Boolean.TRUE.equals(filter.inStock())) where.and(product.stock.gt(0));
        return where;
    }
}

QuerydslPredicateExecutor<Product> trên repository thêm findAll(Predicate, Pageable) và các method cùng họ:

src/main/java/com/example/demo/product/ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product>,
        ProductSearchRepository { 
        QuerydslPredicateExecutor<Product>, ProductSearchRepository { 

Một predicate in ra gần giống query mà nó đại diện:

Text
    predicate: product.category.name = Mice && product.price <= 50 && any(product.tags).name in [wireless, usb-c] && product.stock > 0
>>> findAll(ProductPredicates.matching(category=Mice, maxPrice=50, tag=[wireless, usb-c], inStock=true), page 0 size 3 sort price,id) [Querydsl]
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where c1_0.name=? and p1_0.price<=? and exists(select 1 from product_tags t1_0 join tags t1_1 on t1_1.id=t1_0.tag_id where t1_1.name in (?,?) and p1_0.id=t1_0.product_id) and p1_0.stock>? order by p1_0.price,p1_0.id offset ? rows fetch first ? rows only
binding parameter (1:VARCHAR) <- [Mice]
binding parameter (2:NUMERIC) <- [50]
binding parameter (3:VARCHAR) <- [wireless]
binding parameter (4:VARCHAR) <- [usb-c]
binding parameter (5:INTEGER) <- [0]
binding parameter (6:INTEGER) <- [0]
binding parameter (7:INTEGER) <- [3]
    content [12:MS-05 19.90, 8:MS-01 24.50]
    numberOfElements=2 totalElements=2 totalPages=1

Cùng mệnh đề where với search bằng Criteria, và cùng hai sản phẩm. Các cột category không có trong select list vì entity graph nằm trên method Specification, không phải method này. Page 1 của maxPrice=50 gửi select count(p1_0.id) from products p1_0 where p1_0.price<=?, y hệt hai cách kia. containsIgnoreCase tự escape, với ! là ký tự escape:

Text
>>> findAll(ProductPredicates.matching(q=%)) [Querydsl]
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where lower(p1_0.name) like ? escape '!'
binding parameter (1:VARCHAR) <- [%!%%]
    [23:AC-06 45.50] (1 rows)

_ không tìm thấy gì, và một BooleanBuilder rỗng cho ra query không có where với tổng 23.

Chỗ Querydsl dễ đọc hơn: any() và JPAQueryFactory

Filter theo tag cho thấy sự khác biệt. Phiên bản Criteria cần một subquery, một correlation, một join, một select và một exists, bốn dòng, sau một phiên bản đầu dùng join thường đã đếm sai. Querydsl nói điều đó bằng một biểu thức, product.tags.any().name.in(filter.tag()), và sinh ra đúng exists correlated đó. Dòng đó không có cách nào sinh ra dòng trùng.

Với những query không thuộc dạng "các entity khớp một predicate", JPAQueryFactory viết JPQL thông qua các Q-type. Nó cần một EntityManager, và một bean là đủ:

src/main/java/com/example/demo/common/QuerydslConfig.java
@Configuration
class QuerydslConfig {
 
    @Bean
    JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
        return new JPAQueryFactory(entityManager);
    }
}

Đếm theo category, với cùng predicate:

src/main/java/com/example/demo/product/ProductQueries.java
    public List<CategoryCount> countByCategory(ProductFilter filter) {
        return queryFactory
                .select(category.name, product.count())
                .from(product)
                .join(product.category, category)
                .where(ProductPredicates.matching(filter))
                .groupBy(category.name)
                .orderBy(category.name.asc())
                .fetch()
                .stream()
                .map(row -> new CategoryCount(row.get(category.name), row.get(product.count())))
                .toList();
    }
Text
>>> countByCategory(maxPrice=50, inStock=true) [Querydsl]
select c1_0.name,count(p1_0.id) from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where p1_0.price<=? and p1_0.stock>? group by c1_0.name order by c1_0.name
binding parameter (1:NUMERIC) <- [50]
binding parameter (2:INTEGER) <- [0]
    [CategoryCount[category=Accessories, products=5], CategoryCount[category=Keyboards, products=1], CategoryCount[category=Mice, products=4]]

Cùng các con số với phiên bản Criteria, trong đoạn code đọc theo đúng thứ tự của SQL: select, from, join, where, group by, order by. Giá trị của tuple được đọc bằng chính biểu thức đã select nó, row.get(category.name), thay vì theo vị trí.

@QuerydslPredicate mặc định bind mọi property

Web support của Spring Data có thể dựng predicate từ chính request:

Java
    @GetMapping("/qdsl-binding")
    public List<String> qdslBinding(@QuerydslPredicate(root = Product.class) Predicate predicate) {
        List<String> skus = new ArrayList<>();
        repository.findAll(predicate).forEach(p -> skus.add(p.getSku()));
        return skus;
    }

Không cấu hình gì, mọi property path của Product đều thành một filter so sánh bằng:

RequestPredicateKết quả
?sku=MS-02product.sku = MS-02["MS-02"]
?category.name=Mice&stock=0product.category.name = Mice && product.stock = 0["MS-03"]
?name=mouseproduct.name = mouse[]
?tags.name=rgbany(product.tags).name = rgbbốn sản phẩm
?id=1&id=2product.id in [1, 2]["KB-01","KB-02"]

Đó là một ngôn ngữ query công khai trên toàn bộ entity, kể cả những path không ai định để lộ ra. Implement QuerydslBinderCustomizer<QProduct> trên repository để giới hạn nó:

Java
    @Override
    default void customize(QuerydslBindings bindings, QProduct product) {
        bindings.including(product.name, product.category.name, product.price);
        bindings.excludeUnlistedProperties(true);
        bindings.bind(product.name).first(StringExpression::containsIgnoreCase);
    }

Sau đó, ?name=mouse thành containsIc(product.name,mouse) và tìm ra bảy sản phẩm, còn ?sku=MS-02 bị bỏ qua: predicate là một BooleanBuilder rỗng và response liệt kê cả 23 sản phẩm. Bỏ qua không phải là từ chối, và phần về filter rỗng sẽ quay lại điểm này. Endpoint của bài này giữ record ProductFilter tường minh, nơi các parameter là đúng những cái mà code gọi tên.

jOOQ: khi chính câu SQL mới là trọng tâm

jOOQ không phải công cụ của JPA. Nó sinh ra các class Java từ schema của database, mỗi bảng một class với một field có type cho mỗi cột, và dựng SQL bằng chúng; kết quả trả về dạng record hoặc DTO, không bao giờ là managed entity, nên không có persistence context, không có lazy loading và không có dirty checking. Spring Boot quản lý jOOQ 3.21.7 và tự cấu hình một DSLContext trên DataSource của ứng dụng với spring-boot-starter-jooq. Gradle plugin chính thức sinh các class từ PostgreSQL đang chạy:

build.gradle
plugins {
    // ...
    id 'org.jooq.jooq-codegen-gradle' version '3.21.7'
}
 
dependencies {
    // ...
    implementation 'org.springframework.boot:spring-boot-starter-jooq'
    jooqCodegen 'org.postgresql:postgresql'
}
 
jooq { 
    configuration { 
        jdbc { 
            driver = 'org.postgresql.Driver'
            url = 'jdbc:postgresql://localhost:5508/demo'
            user = 'demo'
            password = 'demo'
        } 
        generator { 
            database { 
                inputSchema = 'public'
                excludes = 'flyway_schema_history'
            } 
            target { 
                packageName = 'com.example.demo.jooq'
            } 
        } 
    } 
} 
 
sourceSets.main.java.srcDir(tasks.named('jooqCodegen')) 

Dòng cuối quan trọng: thiếu nó, ./gradlew jooqCodegen vẫn ghi Products, Categories, TagsProductTags vào build/generated-sources/jooq, nhưng compileJava lỗi với error: package com.example.demo.jooq does not exist. Có nó, jooqCodegen chạy trước mọi compileJava, kể cả những lần build không có gì thay đổi, nên build nào cũng cần kết nối được database. Cùng search đó, với DSL.noCondition() làm điểm xuất phát rỗng:

src/main/java/com/example/demo/product/ProductJooqSearch.java
    public List<ProductResponse> search(ProductFilter filter, int page, int size) {
        return dsl.select(PRODUCTS.ID, PRODUCTS.NAME, PRODUCTS.SKU, PRODUCTS.PRICE, PRODUCTS.STOCK, CATEGORIES.NAME)
                .from(PRODUCTS)
                .join(CATEGORIES).on(CATEGORIES.ID.eq(PRODUCTS.CATEGORY_ID))
                .where(conditions(filter))
                .orderBy(PRODUCTS.PRICE, PRODUCTS.ID)
                .limit(size)
                .offset(page * size)
                .fetch(r -> new ProductResponse(r.value1(), r.value2(), r.value3(), r.value4(), r.value5(), r.value6()));
    }
 
    private static Condition conditions(ProductFilter filter) {
        Condition where = noCondition();
        if (filter.q() != null) where = where.and(PRODUCTS.NAME.containsIgnoreCase(filter.q()));
        if (filter.category() != null) where = where.and(CATEGORIES.NAME.eq(filter.category()));
        if (filter.minPrice() != null) where = where.and(PRODUCTS.PRICE.ge(filter.minPrice()));
        if (filter.maxPrice() != null) where = where.and(PRODUCTS.PRICE.le(filter.maxPrice()));
        if (!filter.tag().isEmpty()) {
            where = where.and(exists(selectOne()
                    .from(PRODUCT_TAGS)
                    .join(TAGS).on(TAGS.ID.eq(PRODUCT_TAGS.TAG_ID))
                    .where(PRODUCT_TAGS.PRODUCT_ID.eq(PRODUCTS.ID))
                    .and(TAGS.NAME.in(filter.tag()))));
        }
        if (Boolean.TRUE.equals(filter.inStock())) where = where.and(PRODUCTS.STOCK.gt(0));
        return where;
    }

Với logging.level.org.jooq.tools.LoggerListener=DEBUG, jOOQ log statement mà nó gửi đi:

Text
Executing query          : select "public"."products"."id", "public"."products"."name", "public"."products"."sku", "public"."products"."price", "public"."products"."stock", "public"."categories"."name" from "public"."products" join "public"."categories" on "public"."categories"."id" = "public"."products"."category_id" where ("public"."categories"."name" = ? and "public"."products"."price" <= ? and exists (select 1 as "one" from "public"."product_tags" join "public"."tags" on "public"."tags"."id" = "public"."product_tags"."tag_id" where ("public"."product_tags"."product_id" = "public"."products"."id" and "public"."tags"."name" in (?, ?))) and "public"."products"."stock" > ?) order by "public"."products"."price", "public"."products"."id" offset ? rows fetch next ? rows only
Version                  : Database version is supported by dialect SQLDialect.POSTGRES: 18.6 (Debian 18.6-1.pgdg13+2)

Nó trả về MS-05 và MS-01, giống ba cách kia. SQL chính là code Java, từng mệnh đề một, và select list chỉ chứa sáu cột mà response cần. containsIgnoreCase("100%") escape input ngay bên trong SQL, replace(replace(replace(lower(?), '!', '!!'), '%', '!%'), '_', '!_') với escape '!', và tìm ra chiếc đèn bàn. Query đầu tiên còn in logo ASCII art của jOOQ và một "tip of the day" ra log.

jOOQ hợp hơn khi chính câu SQL là trọng tâm: báo cáo, window function, common table expression, tính năng riêng của từng database, query trên những bảng không entity nào map, hoặc một service hoàn toàn không dùng JPA. Với một search trả về entity mà ứng dụng sau đó sẽ sửa, các công cụ JPA ở trên giữ được một model duy nhất. Dùng cả hai trong một ứng dụng là được, vì chúng dùng chung DataSource, với cái giá là hai cách mô tả cùng một bộ bảng.

Validation, whitelist cho sort và filter rỗng

Dynamic query dời các quyết định từ lúc compile sang lúc request tới, nên request phải được kiểm tra.

400 ProblemDetail khi minPrice lớn hơn maxPrice

Các constraint trên ProductFilter chạy nhờ @Valid, và lỗi tới dưới dạng MethodArgumentNotValidException. Advice extend ResponseEntityExceptionHandler và đặt các lỗi field vào ProblemDetail:

src/main/java/com/example/demo/common/GlobalExceptionHandler.java
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
 
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
        Map<String, String> errors = new LinkedHashMap<>();
        for (ObjectError error : ex.getBindingResult().getAllErrors()) {
            String name = error instanceof FieldError field ? field.getField() : error.getObjectName();
            errors.put(name, error.getDefaultMessage());
        }
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Invalid search parameters");
        problem.setProperty("errors", errors);
        return handleExceptionInternal(ex, problem, headers, HttpStatus.BAD_REQUEST, request);
    }
}
Bash
curl -i 'http://localhost:8208/api/products?minPrice=100&maxPrice=50'
Text
HTTP/1.1 400
Content-Type: application/problem+json
 
{"detail":"Invalid search parameters","instance":"/api/products","status":400,"title":"Bad Request","errors":{"priceRangeValid":"minPrice must not be greater than maxPrice"}}

@AssertTrue trên isPriceRangeValid() báo lỗi dưới tên property priceRangeValid. Các kiểm tra khác trả lời theo cùng cách: ?minPrice=-5 cùng một q dài 101 ký tự liệt kê cả hai field, "minPrice":"must be greater than or equal to 0""q":"size must be between 0 and 100", còn ?maxPrice=abc cho một 400 mà mục maxPrice là thông báo chuyển đổi, "Failed to convert value of type 'java.lang.String' to required type 'java.math.BigDecimal'…". Không có kiểm tra này, minPrice=100&maxPrice=50 là một query hợp lệ luôn trả về rỗng, trông giống "không có sản phẩm" hơn là "request sai".

Whitelist các property được sort

Basics 29 đã map một sort property không tồn tại thành 400 và ghi nhận rằng mọi thứ khác resolve được đều lọt qua, kể cả collection. Với một search cố ý tránh join, điều đó quan trọng: sort search theo tag bằng tags.name đưa left join product_tags t1_0 on p1_0.id=t1_0.product_id left join tags t1_1 on t1_1.id=t1_0.tag_id trở lại câu query lấy dòng, đúng loại join đã nhân bản dòng ở phần trước. Một danh sách tường minh chốt những gì được sort, và các hằng chuỗi của metamodel giữ các cái tên gắn với entity:

src/main/java/com/example/demo/product/ProductSort.java
final class ProductSort {
 
    private static final Set<String> SORTABLE = Set.of(Product_.ID, Product_.NAME, Product_.PRICE, Product_.STOCK);
 
    private ProductSort() {
    }
 
    static Pageable checked(Pageable pageable) {
        for (Sort.Order order : pageable.getSort()) {
            if (!SORTABLE.contains(order.getProperty())) {
                throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
                        "Cannot sort by '" + order.getProperty() + "'; allowed: id, name, price, stock");
            }
        }
        Sort sort = pageable.getSort().getOrderFor(Product_.ID) == null
                ? pageable.getSort().and(Sort.by(Product_.ID))
                : pageable.getSort();
        return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort);
    }
}
src/main/java/com/example/demo/product/ProductController.java
        return PageResponse.from(service.search(filter, pageable)); 
        return PageResponse.from(service.search(filter, ProductSort.checked(pageable))); 

Nó còn thêm id làm khóa sort cuối cùng, thứ tie-breaker mà Basics 29 khuyên dùng. ?sort=category.name?sort=tags.name đều trả lời:

Text
HTTP/1.1 400
Content-Type: application/problem+json
 
{"detail":"Cannot sort by 'category.name'; allowed: id, name, price, stock","instance":"/api/products","status":400,"title":"Bad Request"}

ResponseStatusException là một ErrorResponseException, nên class cha của advice biến nó thành ProblemDetail mà không cần handler riêng. Và request ba filter trong phần kết hợp specification, qua HTTP:

Bash
curl 'http://localhost:8208/api/products?category=Mice&maxPrice=50&inStock=true&sort=price,asc&size=3'
Text
{"content":[{"id":12,"name":"Travel mouse","sku":"MS-05","price":19.90,"stock":22,"category":"Mice"},{"id":13,"name":"Silent mouse","sku":"MS-06","price":22.00,"stock":40,"category":"Mice"},{"id":8,"name":"Wireless mouse","sku":"MS-01","price":24.50,"stock":3,"category":"Mice"}],"page":0,"size":3,"totalElements":4,"totalPages":2,"hasNext":true}

SQL mà nó log kết thúc bằng order by p1_0.price,p1_0.id. Mọi giá trị trong đó đều đi dưới dạng parameter được bind; không cách nào trong bốn cách của bài này đặt giá trị filter vào chuỗi SQL, đúng tính chất mà Basics 27 cho thấy bị mất khi nối chuỗi.

Filter rỗng: trả về tất cả có chủ đích, hay do vô tình

Một filter rỗng trả về cả catalogue, có phân trang, là thiết kế của endpoint này: GET /api/products?size=2 trả "totalElements":23, và ?category=&q=%20&size=2 cũng vậy, vì compact constructor đổi cả hai giá trị trống thành null. Cùng câu trả lời đó cũng tới từ ?categroy=Mice&size=2, một parameter viết sai: Spring MVC bind những parameter nó biết và bỏ qua phần còn lại, nên lỗi gõ phím xóa mất filter thay vì báo lỗi. @QuerydslPredicate với excludeUnlistedProperties cũng làm y như vậy với ?sku=. Với một trang search, điều đó thường chấp nhận được. Ở những chỗ mà "không có filter" không bao giờ được hiểu là "tất cả", như export, bulk update hoặc delete điều khiển bởi cùng bộ filter, hãy từ chối một cách tường minh: kiểm tra có ít nhất một field của record được đặt, hoặc từ chối những tên parameter không khớp với tên component của record. Điều này cũng đúng với các method ghi của Spring Data 4: một update với điều kiện PredicateSpecification.unrestricted() gửi update products p1_0 set stock=? và đổi cả 23 dòng, trong một transaction mà lần chạy này đã rollback.

Query by Example: dùng khi nào

Query by Example dựng điều kiện từ một entity mẫu (probe): JpaRepository kế thừa findAll(Example) từ QueryByExampleExecutor, và mọi property khác null của probe thành một phép so sánh bằng, hoặc, với ExampleMatcher, một phép khớp chuỗi. Những con chuột có tên chứa "mouse":

Java
Product probe = new Product("mouse", null, null, 0, new Category("Mice"));
ExampleMatcher contains = ExampleMatcher.matching()
        .withStringMatcher(ExampleMatcher.StringMatcher.CONTAINING)
        .withIgnoreCase();
repository.findAll(Example.of(probe, contains));
Text
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where lower(c1_0.name) like ? escape '\' and lower(p1_0.name) like ? escape '\' and p1_0.stock=?
binding parameter (1:VARCHAR) <- [%mice%]
binding parameter (2:VARCHAR) <- [%mouse%]
binding parameter (3:INTEGER) <- [0]
    [10:MS-03 39.00] (1 rows)

Một sản phẩm thay vì sáu: stockint nguyên thủy, không bao giờ null, nên số 0 của nó thành p1_0.stock=?, và chỉ con chuột hết hàng khớp. withIgnorePaths("stock") bỏ nó đi và trả về đủ sáu. Matcher còn áp CONTAINING lên tên category lồng bên trong, và escape 100% thành %100\%%. Query by Example hợp với một form mà các field map một-một vào property của entity bằng phép so sánh bằng hoặc khớp chuỗi. ExampleMatcher không có matcher cho khoảng giá trị, nên minPricemaxPrice không diễn đạt được. Một tag trong set tags của probe bị bỏ qua: query không có where và trả về cả 23 sản phẩm. Và các property kết hợp toàn bộ bằng and, hoặc với ExampleMatcher.matchingAny() toàn bộ bằng or, thứ đã biến probe ở trên thành where lower(c1_0.name) like ? escape '\' or lower(p1_0.name) like ? escape '\'; không có cách trộn hai loại.

Chọn giữa Specification, Criteria, Querydsl và jOOQ

Cùng filter category và tag viết bằng derived query, @Query có kiểm tra null, Specification, Criteria API, Querydsl và jOOQ, mỗi cách kèm SQL nó sinh ra và thời điểm lỗi của nó lộ ra

CáchAn toàn về typeKhả năng kết hợpKiểm soát SQLChi phí cài đặtKhi nào dùng
Derived queryTên property kiểm tra lúc startupKhông có: một method cho mỗi tổ hợpSinh tự động, cố địnhKhôngMột tới ba filter luôn có mặt
@Query với (:x is null or …)JPQL kiểm tra lúc startup; String null lỗi trên PostgreSQL lúc runtime nếu thiếu castKhông có: mọi điều kiện luôn được gửiMọi điều kiện và join trong mọi statementKhôngHai, ba filter tùy chọn trên một bảng nhỏ, khi đã biết rõ cái giá
SpecificationChuỗi lỗi ở lần gọi đầu; metamodel dời lỗi về lúc compileallOf, and, or, not; dùng lại được trong Criteria queryChỉ điều kiện; select, count và phân trang là của Spring DataJpaSpecificationExecutor; processor metamodel để an toàn về typeMặc định cho các endpoint search trả về entity
Criteria API fragmentMetamodel attribute, kiểm tra lúc compileDùng lại SpecificationToàn quyền: tuple, group by, subquery, hai query tự viếtMột interface và một class fragmentAggregate, facet, mọi thứ Specification không định hình được
Querydsl 5.1.0Q-class, kiểm tra lúc compileBooleanBuilder, predicate là giá trịToàn quyền JPQL qua JPAQueryFactoryClassifier jakarta, một processor, một beanDynamic query nặng mà độ dễ đọc quan trọng, ví dụ any() cho collection
jOOQ 3.21.7Sinh từ schema, kiểm tra lúc compileGiá trị Condition từ noCondition()Toàn quyền SQL, tính năng riêng của database, DTO trực tiếpCode generation trên database ở mọi lần buildĐọc dữ liệu theo lối SQL trước, báo cáo, service không dùng JPA

Với endpoint này, Specification thắng: search trả về entity, phần kết hợp chỉ hơn chục dòng, metamodel khiến tên attribute được kiểm tra lúc compile, và query duy nhất chúng không diễn đạt được, đếm theo category, dùng lại chúng từ một Criteria fragment. Querydsl sinh ra cùng SQL với ít nghi thức hơn cho filter trên collection, và nó chạy trên Hibernate 7.4.5 và Spring Data JPA 4.1.1 mà không có một lỗi nào; cái giá là một dependency mà nhánh com.querydsl chưa có bản release nào kể từ 5.1.0.

FAQ

Làm sao viết dynamic query với parameter tùy chọn trong Spring Data JPA?

Extend JpaSpecificationExecutor<Product>, viết mỗi filter một Specification nhỏ, chỉ thêm những cái có parameter vào một list, rồi truyền Specification.allOf(list) cùng Pageable cho findAll. List rỗng cho ra query không có where. Tránh một @Query duy nhất với (:x is null or …): trên PostgreSQL một parameter String null lỗi với "function lower(bytea) does not exist", và SQL luôn mang theo mọi điều kiện.

Specification.where có bị deprecated trong Spring Data JPA không?

Có, trong spring-data-jpa 3.5.0, với @Deprecated(since = "3.5.0", forRemoval = true), và khi đó nó nhận null. Trong 4.1.1 nó không còn deprecated, nhưng từ chối null với IllegalArgumentException: Specification must not be null, và and, or, allOf cũng vậy. Dùng Specification.unrestricted() hoặc allOf trên một list để nói "không có điều kiện".

Vì sao Specification trả về bản ghi trùng hoặc tổng sai?

Join tới một collection trả về một dòng cho mỗi phần tử khớp. Trong lần chạy này, một sản phẩm có hai tag khớp làm count thành 11 cho 10 sản phẩm, và việc khử trùng trong bộ nhớ của Hibernate làm page sáu phần tử co còn năm, khiến Spring Data báo chỉ có một page. query.distinct(true) sửa được dòng và count nhưng làm hỏng order by trên các cột nằm ngoài select list trên PostgreSQL; subquery exists với sub.correlate(root) sửa được cả ba.

Vì sao count query lỗi khi Specification dùng fetch?

Spring Data áp cùng specification lên count query, thứ select count(...), và Hibernate từ chối một fetch join mà owner không được select: "Query specified join fetching, but the owner of the fetched association was not present in the select list". Đừng đặt fetch trong specification; khai báo lại findAll(Specification, Pageable) trong repository với @EntityGraph, hoặc fetch trong một Criteria query tự viết.

Querydsl có chạy với Spring Boot 4 và Hibernate 7 không?

Querydsl 5.1.0 chạy được, trên Spring Boot 4.1.1, Spring Data JPA 4.1.1 và Hibernate ORM 7.4.5: sinh Q-class, QuerydslPredicateExecutor có phân trang, tuple query với JPAQueryFactory, any() thành subquery exists@QuerydslPredicate đều chạy không lỗi. Dùng classifier jakarta cho cả querydsl-jpa lẫn querydsl-apt, và giữ jakarta.persistence-api trên annotation processor path.

Nên dùng Specification hay Querydsl?

Specification không cần gì ngoài Spring Data JPA, và với metamodel của Hibernate thì cũng an toàn về type. Querydsl dễ đọc hơn với điều kiện phức tạp và filter trên collection, và JPAQueryFactory bao được những query mà Specification không định hình được, với cái giá là thêm một dependency và một code generator thứ hai. Với vài filter trên một entity, Specification là đủ.

Kết luận

Search với filter tùy chọn cần một query dựng theo từng request. Derived query không làm được nếu không có một method cho mỗi tổ hợp, còn @Query với (:x is null or …) làm được nhưng tệ: trên PostgreSQL 18.6 một parameter String null lỗi với function lower(bytea) does not exist, SQL mang theo mọi điều kiện và join ở mọi request, và generic plan của nó đọc 200.403 buffer trong khi SQL dựng từ các filter có mặt chỉ đọc 379. Specification dựng đúng loại SQL đó. Trong Spring Data JPA 4.1.1, chúng kết hợp bằng allOf, and, ornot, từ chối null ở những chỗ 3.5 chấp nhận, và có thêm PredicateSpecification, UpdateSpecificationDeleteSpecification, trong đó update commit được mà không cần transaction bao ngoài. Có hai cái bẫy nằm trong chúng: join tới collection đẩy count lên 11, và việc khử trùng trong bộ nhớ khiến một page sáu phần tử tự nhận là page duy nhất, thứ mà distinct sửa được một phần còn subquery exists sửa trọn vẹn; và fetch bên trong specification làm hỏng count query.

Bên dưới Specification là Criteria API: metamodel của hibernate-processor biến một attribute viết sai từ PathElementException lúc runtime thành lỗi compile, và một repository fragment dùng lại cùng Specification cho search có phân trang lẫn cho phép đếm theo category được group ngay trong SQL. Querydsl 5.1.0 chạy trên Hibernate 7.4.5 với classifier jakarta, viết filter tag trong một dòng, và bind mọi property của entity qua @QuerydslPredicate cho tới khi bị giới hạn. jOOQ 3.21.7 sinh class từ database và viết SQL từng mệnh đề một. Bao quanh tất cả, request cần có luật: 400 ProblemDetail khi minPrice lớn hơn maxPrice, whitelist cho sort, và một quyết định về ý nghĩa của filter rỗng.

Bài tiếp theo nói về caching: Spring Cache abstraction, Caffeine làm cache cục bộ, Redis làm cache phân tán, và các chiến lược invalidation.

Bài viết liên quan

[Advanced Spring Boot] NoSQL với Spring Data: MongoDB và Redis

Spring Data MongoDB và Spring Data Redis trên Spring Boot 4.1.1: @Document, id là String hay ObjectId, field _class, embedded hay @DocumentReference cùng các query mỗi cách gửi đi, MongoRepository và MongoTemplate kèm command đã log, $push/$inc so với load-modify-save dưới 50 thread, @Version, Boot có tạo index từ @Indexed không, COLLSCAN và IXSCAN trên 300.000 document, một aggregation pipeline, transaction trên replica set, một field bị đổi tên, serializer của RedisTemplate, INCR, sorted set, hash, TTL, key của @RedisHash và phantom key, pipelining và Lettuce.

[Advanced Spring Boot] Locking và concurrency trong Spring Boot: optimistic @Version, pessimistic lock và race condition

Locking và concurrency trong Spring Boot 4.1.1 trên PostgreSQL: lost update khi hai người cùng sửa một product, @Version và câu update … where version=? nó gửi đi, chuỗi exception tới được code của bạn, saveAll, dirty checking và bulk update @Modifying bỏ qua version, version qua HTTP trả về 409 ProblemDetail, retry một conflict bao quanh cả transaction và cách đặt retry khiến nó không bao giờ retry, PESSIMISTIC_WRITE so với PESSIMISTIC_READ, NOWAIT và jakarta.persistence.lock.timeout thành set local lock_timeout, SKIP LOCKED cho work queue, một deadlock thật (40P01) và cách sửa, atomic update có điều kiện, CHECK constraint, và một bảng để chọn giữa chúng.

[Advanced Spring Boot] Multi-tenancy và soft delete với Spring Boot và Hibernate

Multi-tenancy và soft delete trên Spring Boot 4.1.1 với Hibernate và PostgreSQL: filter đọc X-Tenant-Id với ThreadLocal bị rò rỉ trên thread Tomcat được tái sử dụng và bị mất trên @Async, discriminator column với @TenantId và CurrentTenantIdentifierResolver (predicate tenant trên find, JPQL, derived query, Specification và bulk update, không có trên native SQL hay JdbcClient), schema per tenant với MultiTenantConnectionProvider, bẫy tái sử dụng connection giữa setSchema và SET search_path trên HikariCP, Flyway cho từng tenant schema và TenantSchemaMapper của Hibernate, database per tenant với 100 connection cho mười tenant, row-level security của PostgreSQL với set_config và FORCE, các strategy của @SoftDelete so với @SQLDelete và @SQLRestriction, lỗi to-one LAZY, partial unique index cho SKU đã soft delete, và khôi phục các dòng đã xóa.

[Advanced Spring Boot] Mở rộng container của Spring: BeanFactoryPostProcessor, BeanPostProcessor và Aware

Các extension point của container trong Spring Boot 4.1.1, trace qua một lần startup: bảng đánh số từ EnvironmentPostProcessor tới các runner, cách đăng ký từng callback trong Boot 4 và lý do context.initializer.classes không còn tác dụng, BeanFactoryPostProcessor so với BeanDefinitionRegistryPostProcessor, một BeanPostProcessor trả về JDK dynamic proxy, cái bẫy "not eligible for getting processed by all BeanPostProcessors" làm mất transaction trong im lặng, chain post-processor đo được cho thấy @Order bị bỏ qua, các interface Aware đáng biết, và exception trong ApplicationRunner ảnh hưởng thế nào tới exit code.