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.
![]()
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, flyway và validation:
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.zipCatalogue 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:
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, bestseller và usb-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:
@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:
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:
docker run -d --name sba-a8-pg -e POSTGRES_USER=demo -e POSTGRES_PASSWORD=demo -e POSTGRES_DB=demo -p 5508:5432 postgres:18spring.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=DEBUGPhầ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
@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:
>>> 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 existHai 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(...) là 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:
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), '%'))) >>> 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:
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:
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:
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=2Generic 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:
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=400Plan 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:
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=5Query 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>:
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, delete và findBy 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ế:
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:
@Transactional(readOnly = true)
public Page<ProductResponse> search(ProductFilter filter, Pageable pageable) {
return repository.findAll(ProductSpecifications.matching(filter), pageable)
.map(ProductResponse::from);
} @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:
>>> 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=2Ba 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:
>>> 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
Specification còn kết hợp được bằng and, or và Specification.not, và cây kết quả giữ nguyên cách nhóm trong SQL:
repository.findAll(inCategory("Mice").or(inCategory("Keyboards")).and(Specification.not(inStock())));>>> 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:
>>> 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 nullNê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ó:
| Type | toPredicate nhận | Dùng bởi |
|---|---|---|
Specification<T> | Root<T>, CriteriaQuery<?>, CriteriaBuilder | findOne, findAll có và không có Sort hoặc Pageable, count, exists, findBy |
PredicateSpecification<T> | From<?, T>, CriteriaBuilder | findOne, findAll không phân trang, count, exists, delete, findBy; các overload where, and và or của ba type còn lại cũng nhận nó |
UpdateSpecification<T> | Root<T>, CriteriaUpdate<T>, CriteriaBuilder | update |
DeleteSpecification<T> | Root<T>, CriteriaDelete<T>, CriteriaBuilder | delete |
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:
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);>>> 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 % và _ 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, % và _, rồi khai báo \ là ký tự escape bằng argument thứ ba của cb.like:
>>> 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 offset và fetch 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:
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(...):
>>> 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:
>>> 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:
>>> 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=2Database 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:
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);
};
}>>> 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=2Câ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:
>>> 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:
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);
};
}>>> 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=2Mỗ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, Root và CriteriaBuilder, 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:
>>> 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".
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_ và 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:
@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ó:
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:
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:
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);
}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ó:
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:
>>> 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=1Hai 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.

Đế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:
@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();
}>>> 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ề:
[{"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
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, QCategory và QTag xuất hiện bên cạnh Product_, Category_ và 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:
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ọ:
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:
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=1Cù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:
>>> 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à đủ:
@Configuration
class QuerydslConfig {
@Bean
JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
return new JPAQueryFactory(entityManager);
}
}Đếm theo category, với cùng predicate:
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();
}>>> 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:
@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:
| Request | Predicate | Kết quả |
|---|---|---|
?sku=MS-02 | product.sku = MS-02 | ["MS-02"] |
?category.name=Mice&stock=0 | product.category.name = Mice && product.stock = 0 | ["MS-03"] |
?name=mouse | product.name = mouse | [] |
?tags.name=rgb | any(product.tags).name = rgb | bốn sản phẩm |
?id=1&id=2 | product.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ó:
@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:
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, Tags và ProductTags 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:
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:
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:
@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);
}
}curl -i 'http://localhost:8208/api/products?minPrice=100&maxPrice=50'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" và "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:
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);
}
} 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 và ?sort=tags.name đều trả lời:
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:
curl 'http://localhost:8208/api/products?category=Mice&maxPrice=50&inStock=true&sort=price,asc&size=3'{"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":
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));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: stock là int 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 minPrice và maxPrice 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ách | An toàn về type | Khả năng kết hợp | Kiểm soát SQL | Chi phí cài đặt | Khi nào dùng |
|---|---|---|---|---|---|
| Derived query | Tên property kiểm tra lúc startup | Không có: một method cho mỗi tổ hợp | Sinh tự động, cố định | Không | Mộ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 cast | Không có: mọi điều kiện luôn được gửi | Mọi điều kiện và join trong mọi statement | Không | Hai, ba filter tùy chọn trên một bảng nhỏ, khi đã biết rõ cái giá |
| Specification | Chuỗi lỗi ở lần gọi đầu; metamodel dời lỗi về lúc compile | allOf, and, or, not; dùng lại được trong Criteria query | Chỉ điều kiện; select, count và phân trang là của Spring Data | JpaSpecificationExecutor; processor metamodel để an toàn về type | Mặc định cho các endpoint search trả về entity |
| Criteria API fragment | Metamodel attribute, kiểm tra lúc compile | Dùng lại Specification | Toàn quyền: tuple, group by, subquery, hai query tự viết | Một interface và một class fragment | Aggregate, facet, mọi thứ Specification không định hình được |
| Querydsl 5.1.0 | Q-class, kiểm tra lúc compile | BooleanBuilder, predicate là giá trị | Toàn quyền JPQL qua JPAQueryFactory | Classifier jakarta, một processor, một bean | Dynamic query nặng mà độ dễ đọc quan trọng, ví dụ any() cho collection |
| jOOQ 3.21.7 | Sinh từ schema, kiểm tra lúc compile | Giá trị Condition từ noCondition() | Toàn quyền SQL, tính năng riêng của database, DTO trực tiếp | Code 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 và @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, or và not, từ chối null ở những chỗ 3.5 chấp nhận, và có thêm PredicateSpecification, UpdateSpecification và DeleteSpecification, 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.