Command Palette

Search for a command to run...

[Spring Boot Basics] Truy vấn với Spring Data JPA: derived query method, @Query với JPQL và native query

Bài 26 đã map Product vào table products và thay repository in-memory bằng ProductRepository extends JpaRepository<Product, Long>, nhờ đó có sẵn save, findById, findAlldelete mà không phải viết dòng code nào. Nhưng một catalogue cần nhiều hơn việc tìm theo id: các bàn phím dưới một mức giá, một ô tìm kiếm, báo cáo hàng sắp hết, tăng giá cho cả một category. Bài này đi qua ba cách viết những query đó trong một Spring Data repository, gồm derived query method, @Query với JPQL và native SQL, rồi đến bulk update và delete với @Modifying, cùng những chi tiết dễ làm sai: ký tự wildcard trong input của người dùng, SQL injection, và entity bị stale sau một bulk update.

Các ví dụ dùng Spring Boot 4.1.1 và Java 21, trên một project Initializr có các starter web, validation và Spring Data JPA, cùng H2 và PostgreSQL driver. Phần lớn output lấy từ database H2 in-memory; native query và những gì gắn với dialect chạy trên PostgreSQL 18 trong Docker, và output nào cũng ghi rõ database nào sinh ra nó.

Ba mẩu code, một tên derived method, @Query và nativeQuery = true, cùng đi vào một database

Bài bắt đầu từ dữ liệu sẽ được query, rồi đi từ ít code nhất đến nhiều quyền kiểm soát nhất: tên method, JPQL, native SQL, và cuối cùng là các statement thay đổi dữ liệu.

Catalogue dùng trong bài và cách lấy output

Entity là Product của bài 26: table products, một enum ProductStatus (ACTIVE, OUT_OF_STOCK, DISCONTINUED) lưu dưới dạng chuỗi, và một column category không cho phép null mà bài 28 sẽ biến thành entity. Bài này thêm đúng một field. Các keyword True, FalseIsNull cần một column có thể là true, false hoặc null, nên featured là một Boolean cho phép null: true đưa sản phẩm lên trang chủ, false cố ý để nó ngoài trang chủ, còn null nghĩa là chưa ai quyết định.

src/main/java/com/example/demo/product/Product.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
 
@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, length = 40, unique = true)
    private String sku;
 
    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;
 
    private int stock;
 
    @Column(nullable = false, length = 60)
    private String category;
 
    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private ProductStatus status = ProductStatus.ACTIVE;
 
    private Boolean featured; 
 
    protected Product() {
    }
 
    public Product(String name, String sku, BigDecimal price, int stock, String category) {
        this.name = name;
        this.sku = sku;
        this.price = price;
        this.stock = stock;
        this.category = category;
    }
 
    public Boolean getFeatured() { return featured; } 
    public void setFeatured(Boolean featured) { this.featured = featured; } 
 
    // the other getters and setters, equals, hashCode and toString as in article 26
}

Mười chín sản phẩm trong năm category: ba sản phẩm featured, ba sản phẩm cố ý không featured và mười ba sản phẩm chưa quyết định, hai sản phẩm discontinued và một sản phẩm out of stock, hai sản phẩm cùng giá, và một cái tên có dấu % dành cho phần wildcard:

src/main/java/com/example/demo/product/CatalogSeeder.java
package com.example.demo.product;
 
import static com.example.demo.product.ProductStatus.ACTIVE;
import static com.example.demo.product.ProductStatus.DISCONTINUED;
import static com.example.demo.product.ProductStatus.OUT_OF_STOCK;
 
import java.math.BigDecimal;
import java.util.List;
 
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
 
@Component
@Order(1)
class CatalogSeeder implements CommandLineRunner {
 
    private final ProductRepository repository;
 
    CatalogSeeder(ProductRepository repository) {
        this.repository = repository;
    }
 
    @Override
    public void run(String... args) {
        if (repository.count() > 0) {
            return;
        }
        repository.saveAll(List.of(
                product("Mechanical keyboard", "KB-01", "89.90", 25, "keyboards", ACTIVE, true),
                product("Compact keyboard", "KB-02", "59.00", 12, "keyboards", ACTIVE, null),
                product("Wireless keyboard", "KB-03", "45.50", 0, "keyboards", DISCONTINUED, false),
                product("Ergonomic keyboard", "KB-04", "129.00", 7, "keyboards", ACTIVE, null),
                product("Wireless mouse", "MS-01", "24.50", 3, "mice", ACTIVE, null),
                product("Gaming mouse", "MS-02", "49.90", 18, "mice", ACTIVE, null),
                product("Vertical mouse", "MS-03", "39.00", 0, "mice", OUT_OF_STOCK, false),
                product("27-inch monitor", "MN-01", "279.00", 6, "monitors", ACTIVE, null),
                product("32-inch 4K monitor", "MN-02", "449.00", 2, "monitors", ACTIVE, true),
                product("Portable monitor", "MN-03", "189.00", 9, "monitors", ACTIVE, null),
                product("Noise-cancelling headphones", "AU-01", "199.00", 11, "audio", ACTIVE, true),
                product("USB microphone", "AU-02", "89.90", 4, "audio", ACTIVE, null),
                product("Desk speakers", "AU-03", "79.00", 0, "audio", DISCONTINUED, false),
                product("USB-C hub", "AC-01", "39.00", 40, "accessories", ACTIVE, null),
                product("Laptop stand", "AC-02", "34.90", 15, "accessories", ACTIVE, null),
                product("Mouse pad XL", "AC-03", "19.90", 60, "accessories", ACTIVE, null),
                product("Screen cleaner, 100% alcohol-free", "AC-04", "9.90", 80, "accessories", ACTIVE, null),
                product("Webcam 1080p", "AC-05", "64.00", 14, "accessories", ACTIVE, null),
                product("Cable organiser", "AC-06", "12.50", 33, "accessories", ACTIVE, null)));
    }
 
    private static Product product(String name, String sku, String price, int stock, String category,
                                   ProductStatus status, Boolean featured) {
        Product product = new Product(name, sku, new BigDecimal(price), stock, category);
        product.setStatus(status);
        product.setFeatured(featured);
        return product;
    }
}

Hai SQL logger của bài 26 vẫn bật. Bài này dựa vào logger thứ hai: một dấu % có bị escape hay không thì chỉ nhìn thấy qua giá trị được bind vào ?.

src/main/resources/application.properties
spring.application.name=demo
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE

H2 là mặc định. Các lần chạy trên PostgreSQL dùng profile postgres của bài 26, trỏ tới một container PostgreSQL 18, và thêm một dòng để Hibernate cũng tự tạo table ở đó:

src/main/resources/application-postgres.properties
spring.jpa.hibernate.ddl-auto=create 

Các query được gọi từ một CommandLineRunner chạy sau seeder. Nó in ra lời gọi, sau đó các dòng log của Hibernate xuất hiện khi query chạy, rồi mỗi sản phẩm một dòng: SKU, tên, giá, stock, category, status và cờ featured.

src/main/java/com/example/demo/lab/QueryLab.java
package com.example.demo.lab;
 
import java.util.List;
import java.util.function.Supplier;
 
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
 
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
 
@Component
@Order(2)
class QueryLab implements CommandLineRunner {
 
    private final ProductRepository repository;
 
    QueryLab(ProductRepository repository) {
        this.repository = repository;
    }
 
    @Override
    public void run(String... args) {
        show("findByCategory(\"monitors\")", () -> repository.findByCategory("monitors"));
    }
 
    private void show(String call, Supplier<List<Product>> query) {
        System.out.println(">>> " + call);
        List<Product> products = query.get();
        for (Product p : products) {
            System.out.println(String.format("%-5s  %-33s %7s  %3d  %-11s %-12s %s",
                    p.getSku(), p.getName(), p.getPrice(), p.getStock(), p.getCategory(),
                    p.getStatus(), p.getFeatured()));
        }
        System.out.println("(" + products.size() + " rows)");
    }
}

Bản đầy đủ dùng cho bài này in các loại kết quả khác theo cùng cách: Optional thành Optional present hoặc Optional empty, một giá trị đơn thành result <value> (<type>), còn exception là một dòng !!! và mỗi cause thêm một dòng !!! caused by. Trong các output bên dưới, mỗi dòng log chỉ giữ lại phần message; timestamp, level, process id, thread và tên logger đã được cắt bỏ.

Bash
./gradlew bootJar
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar
Text
>>> findByCategory("monitors")
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.category=?
binding parameter (1:VARCHAR) <- [monitors]
MN-01  27-inch monitor                    279.00    6  monitors    ACTIVE       null
MN-02  32-inch 4K monitor                 449.00    2  monitors    ACTIVE       true
MN-03  Portable monitor                   189.00    9  monitors    ACTIVE       null
(3 rows)

Đó là toàn bộ derived query method đầu tiên: một method abstract, List<Product> findByCategory(String category), và không có implementation ở đâu cả.

Spring Data biến tên method thành query như thế nào?

Derived query method là một method trên repository interface không gắn annotation nào. Khi Spring tạo bean repository lúc khởi động, Spring Data tách tên method tại chữ By:

  • subject, phần trước By, cho biết đây là loại query gì: find trả về entity, stream trả về một Stream, exists trả về boolean, count trả về một con số, delete thì xóa. Distinct, FirstTopN cũng nằm ở phần này.
  • predicate, phần sau By, là danh sách các property path, tức tên field của entity viết hoa chữ cái đầu, mỗi cái có thể đi kèm một keyword như LessThan hay Containing. Các điều kiện nối với nhau bằng AndOr, và tên method có thể kết thúc bằng OrderBy<Property>Asc hoặc Desc.

Các argument của method được lấy theo thứ tự, điều kiện nào cần giá trị thì lấy một argument. Một method có đủ mọi thành phần:

src/main/java/com/example/demo/product/ProductRepository.java
package com.example.demo.product;
 
import java.math.BigDecimal;
import java.util.List;
 
import org.springframework.data.jpa.repository.JpaRepository;
 
public interface ProductRepository extends JpaRepository<Product, Long> {
 
    List<Product> findByCategory(String category);
 
    List<Product> findTop3ByCategoryAndPriceLessThanOrderByPriceDesc(String category, BigDecimal price); 
}

Spring Data ghi ra log JPQL mà nó dựng từ tên method khi package query của nó ở mức DEBUG:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --logging.level.org.springframework.data.jpa.repository.query=DEBUG

Gọi với ("keyboards", new BigDecimal("100")) trên H2:

Text
>>> findTop3ByCategoryAndPriceLessThanOrderByPriceDesc("keyboards", 100)
QueryPreparer: Derived query for query method [public abstract java.util.List com.example.demo.product.ProductRepository.findTop3ByCategoryAndPriceLessThanOrderByPriceDesc(java.lang.String,java.math.BigDecimal)]: 'SELECT p FROM Product p WHERE p.category = :category AND p.price < :price ORDER BY p.price desc'
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.category=? and p1_0.price<? order by p1_0.price desc fetch first ? rows only
binding parameter (1:VARCHAR) <- [keyboards]
binding parameter (2:NUMERIC) <- [100]
binding parameter (3:INTEGER) <- [3]
KB-01  Mechanical keyboard                 89.90   25  keyboards   ACTIVE       true
KB-02  Compact keyboard                    59.00   12  keyboards   ACTIVE       null
KB-03  Wireless keyboard                   45.50    0  keyboards   DISCONTINUED false
(3 rows)

Có thể thấy rõ hai bước. Spring Data chuyển tên method thành một JPQL query, đặt tên placeholder theo tên parameter của method, rồi Hibernate chuyển JPQL đó thành SQL cho H2. Top3 hoàn toàn không nằm trong JPQL: nó xuất hiện trong SQL dưới dạng fetch first ? rows only với giá trị 3 được bind. Ergonomic keyboard, giá 129.00, là bàn phím duy nhất bị điều kiện giá loại ra.

Tên method được tách thành subject, property path và keyword, JPQL do Spring Data dựng, SQL do Hibernate gửi đi, và lỗi khởi động khi gõ sai tên property

And gắn chặt hơn Or, giống như trong SQL, và tên method không có cách nào viết dấu ngoặc:

src/main/java/com/example/demo/product/ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long> {
 
    List<Product> findByCategoryAndStockGreaterThanOrStatus(String category, int stock, ProductStatus status); 
}
Text
>>> findByCategoryAndStockGreaterThanOrStatus("keyboards", 10, DISCONTINUED)
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.category=? and p1_0.stock>? or p1_0.status=?
binding parameter (1:VARCHAR) <- [keyboards]
binding parameter (2:INTEGER) <- [10]
binding parameter (3:ENUM) <- [DISCONTINUED]
KB-01  Mechanical keyboard                 89.90   25  keyboards   ACTIVE       true
KB-02  Compact keyboard                    59.00   12  keyboards   ACTIVE       null
KB-03  Wireless keyboard                   45.50    0  keyboards   DISCONTINUED false
AU-03  Desk speakers                       79.00    0  audio       DISCONTINUED false
(4 rows)

Điều kiện này được hiểu là (category = 'keyboards' and stock > 10) or status = 'DISCONTINUED', nên bộ loa đã discontinued lọt vào kết quả của một query trông như chỉ tìm bàn phím. Điều kiện cần dấu ngoặc thì phải viết bằng @Query. Enum được gửi xuống H2 dưới kiểu ENUM riêng của H2, đúng kiểu column mà bài 26 thấy Hibernate tạo ra ở đó.

Các keyword của derived query và SQL chúng sinh ra

Mỗi dòng dưới đây là một method, gọi trên H2. Mọi query đều select cùng một danh sách column, select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0, nên cột thứ ba chỉ ghi phần Hibernate nối thêm vào sau đó. Argument kiểu BigDecimal được viết như số thường.

KeywordLời gọiSQL sinh ra, sau danh sách columnGiá trị được bindSố row
AndfindByCategoryAndPriceLessThan("keyboards", 60)where p1_0.category=? and p1_0.price<?keyboards, 602
BetweenfindByPriceBetween(40, 100)where p1_0.price between ? and ?40, 1007
LessThanfindByStockLessThan(5)where p1_0.stock<?56
GreaterThanEqualfindByPriceGreaterThanEqual(199)where p1_0.price>=?1993
ContainingfindByNameContaining("keyboard")where p1_0.name like ? escape '\'%keyboard%4
StartingWithfindBySkuStartingWith("MS")where p1_0.sku like ? escape '\'MS%3
LikefindByNameLike("%mouse%")where p1_0.name like ? escape '\'%mouse%3
LikefindByNameLike("mouse")where p1_0.name like ? escape '\'mouse0
ContainingIgnoreCasefindByNameContainingIgnoreCase("mouse")where upper(p1_0.name) like upper(?) escape '\'%mouse%4
InfindBySkuIn(List.of("KB-01", "MS-02", "XX-99"))where p1_0.sku in (?,?,?)KB-01, MS-02, XX-992
IsNullfindByFeaturedIsNull()where p1_0.featured is nullkhông có13
TruefindByFeaturedTrue()where p1_0.featured=truekhông có3
FalsefindByFeaturedFalse()where p1_0.featured=falsekhông có3
OrderBy…DescfindByCategoryOrderByPriceDesc("monitors")where p1_0.category=? order by p1_0.price descmonitors3
Top3findTop3ByOrderByPriceDesc()order by p1_0.price desc fetch first ? rows only33
FirstfindFirstByCategoryOrderByPriceAsc("keyboards")where p1_0.category=? order by p1_0.price fetch first ? rows onlykeyboards, 11
DistinctfindDistinctByStockLessThan(5)select distinct thay cho select, rồi where p1_0.stock<?56

Bảng này cho thấy nhiều hơn cú pháp:

  • ContainingStartingWith tự thêm %. Like dùng nguyên argument làm pattern, nên findByNameLike("mouse") so sánh cả cái tên với mouse và không tìm thấy gì.
  • IgnoreCase bọc cả hai vế trong upper(). Nó tìm thấy Mouse pad XL, thứ mà pattern phân biệt hoa thường %mouse% bỏ sót.
  • In bung list thành mỗi phần tử một placeholder. XX-99 đơn giản là không khớp row nào.
  • TrueFalse mỗi keyword tìm ra ba sản phẩm, còn IsNull tìm ra mười ba sản phẩm còn lại. featured=false không khớp với row có featured là null, vì trong SQL phép so sánh với null không bao giờ đúng: sản phẩm chưa được quyết định thì không featured, mà cũng không bị loại.
  • First chính là Top1, và với return type Optional<Product> nó trả về bàn phím rẻ nhất, KB-03 đã discontinued.
  • Distinct trả về đúng sáu row của findByStockLessThan(5). Trên một table duy nhất, mỗi row vốn đã có id riêng; distinct chỉ có tác dụng khi join làm row bị lặp lại, ở bài 28.
  • OrderBy cố định thứ tự ngay trong tên method. Truyền thứ tự vào dưới dạng argument, với SortPageable, là nội dung của bài 29.

Return type: List, Optional, một entity, Stream, boolean và long

Subject quyết định thứ được trả về, và return type được khai báo cũng vậy. Thêm sáu method:

src/main/java/com/example/demo/product/ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long> {
 
    Optional<Product> findBySku(String sku); 
 
    Optional<Product> findByPrice(BigDecimal price); 
 
    Product findByStock(int stock); 
 
    Stream<Product> streamByCategory(String category); 
 
    boolean existsBySku(String sku); 
 
    long countByCategory(String category); 
}
Return typeLời gọi (H2)Kết quả
List<Product>findByNameLike("mouse")Một list rỗng
Optional<Product>findBySku("KB-01"), rồi findBySku("XX-99")Optional present với KB-01, rồi Optional empty
Optional<Product>findByPrice(89.90)IncorrectResultSizeDataAccessException: KB-01 và AU-02 cùng giá 89.90
ProductfindByStock(99), rồi findByStock(0)null, rồi IncorrectResultSizeDataAccessException với 3 kết quả
Stream<Product>streamByCategory("keyboards") khi không có transactionInvalidDataAccessApiUsageException, trước khi có SQL nào được gửi đi
booleanexistsBySku("KB-01")true, từ select p1_0.id from products p1_0 where p1_0.sku=? fetch first ? rows only
longcountByCategory("keyboards")4, từ select count(p1_0.id) from products p1_0 where p1_0.category=?

Optional nghĩa là không có hoặc có một row, không phải "row đầu tiên trong số nhiều row":

Text
>>> findByPrice(89.90)
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.price=?
binding parameter (1:NUMERIC) <- [89.90]
!!! org.springframework.dao.IncorrectResultSizeDataAccessException: Query did not return a unique result: 2 results were returned
!!! caused by org.hibernate.NonUniqueResultException: Query did not return a unique result: 2 results were returned

Chỉ khai báo kết quả đơn cho những gì database đảm bảo là unique, như sku. Với các trường hợp khác, findFirstBy…OrderBy… nói rõ bạn muốn row nào.

Một Stream giữ JDBC result set mở trong lúc bạn duyệt nó, nên Spring Data từ chối bắt đầu stream bên ngoài transaction:

Text
>>> streamByCategory("keyboards") without a transaction
!!! org.springframework.dao.InvalidDataAccessApiUsageException: You're trying to execute a streaming query method without a surrounding transaction that keeps the connection open so that the Stream can actually be consumed; Make sure the code consuming the stream uses @Transactional or any other way of declaring a (read-only) transaction

Trong một transaction read-only, và được đóng bằng try-with-resources, cùng method đó trả về [KB-01, KB-02, KB-03, KB-04]:

src/main/java/com/example/demo/product/ProductService.java
@Transactional(readOnly = true)
public List<String> skusInCategory(String category) {
    try (Stream<Product> products = repository.streamByCategory(category)) {
        return products.map(Product::getSku).toList();
    }
}

@Transactional có bài riêng, bài 30. Ở đây nó chỉ là thứ mà một Stream, và lát nữa là một bulk update, cần phải có.

Sai tên property thì ứng dụng không khởi động

Property path phải tồn tại trên entity. Thiếu đúng một chữ cái:

src/main/java/com/example/demo/product/ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long> {
 
    List<Product> findByCategory(String category);
 
    List<Product> findByCategry(String category); 
}

Ứng dụng không khởi động được. Từ console, các stack frame nằm giữa những cause đã được cắt bớt:

Text
2026-09-13T16:55:07.431+07:00 ERROR 37010 --- [demo] [           main] o.s.boot.SpringApplication               : Application run failed
...
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'productRepository' defined in com.example.demo.product.ProductRepository defined in @EnableJpaRepositories declared on DataJpaRepositoriesRegistrar.EnableJpaRepositoriesConfiguration: No property 'categry' found for type 'Product'; Did you mean 'category'
...
Caused by: org.springframework.data.repository.query.QueryCreationException: Cannot create query for method [ProductRepository.findByCategry(java.lang.String)]; No property 'categry' found for type 'Product'; Did you mean 'category'
	at org.springframework.data.repository.query.QueryCreationException.create(QueryCreationException.java:109) ~[spring-data-commons-4.1.1.jar!/:4.1.1]
...
Caused by: org.springframework.data.core.PropertyReferenceException: No property 'categry' found for type 'Product'; Did you mean 'category'
	at org.springframework.data.core.SimplePropertyPath.<init>(SimplePropertyPath.java:94) ~[spring-data-commons-4.1.1.jar!/:4.1.1]

Spring Data so cái tên không tồn tại với các property của entity và gợi ý cái gần nhất. Việc kiểm tra chạy lúc tạo bean repository, mà trước đó log khởi động đã báo là Bootstrapping Spring Data JPA repositories in DEFAULT mode., tức giá trị mặc định của spring.data.jpa.repositories.bootstrap-mode. Tên phải khớp là tên field Java, không phải tên column. Để ý cả package: trong Spring Data 4, exception này là org.springframework.data.core.PropertyReferenceException.

Đây là điểm mạnh chính của derived query: gõ sai, đổi tên hay xóa một field đều làm ứng dụng dừng ngay lúc khởi động, thay vì làm hỏng một request trên production.

Truy vấn với @Query và JPQL

Khi tên method trở nên khó đọc, hoặc điều kiện cần dấu ngoặc, function hay aggregate, hãy tự viết query. @Query nhận JPQL, một ngôn ngữ query trông giống SQL nhưng viết theo entity và các field của nó; Hibernate dịch nó sang SQL cho database đang dùng.

Named parameter: còn cần @Param không?

src/main/java/com/example/demo/product/ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long> {
 
    @Query("select p from Product p where p.category = :category and p.price <= :maxPrice order by p.price") 
    List<Product> findInCategoryUpTo(String category, BigDecimal maxPrice); 
}
Text
>>> findInCategoryUpTo("keyboards", 90)
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.category=? and p1_0.price<=? order by p1_0.price
binding parameter (1:VARCHAR) <- [keyboards]
binding parameter (2:NUMERIC) <- [90]
KB-03  Wireless keyboard                   45.50    0  keyboards   DISCONTINUED false
KB-02  Compact keyboard                    59.00   12  keyboards   ACTIVE       null
KB-01  Mechanical keyboard                 89.90   25  keyboards   ACTIVE       true
(3 rows)

Không có @Param("category") nào, vậy mà :category vẫn nhận được giá trị. Spring Data khớp nó với parameter của method theo tên, và tên đó còn tồn tại lúc runtime vì plugin Gradle của Spring Boot compile với -parameters. Interface sau khi compile có attribute MethodParameters:

Bash
javap -v -cp build/classes/java/main com.example.demo.product.ProductRepository | grep -m1 -A2 'MethodParameters:'
Text
    MethodParameters:
      Name                           Flags
      category

Để xem điều gì xảy ra khi không có flag này, có thể bỏ nó khỏi build:

build.gradle
tasks.withType(JavaCompile).configureEach {
    options.compilerArgs.remove('-parameters')
}

Với build đó, javap không tìm thấy attribute MethodParameters nào. Ứng dụng vẫn khởi động, và mỗi query dùng named parameter đều lỗi ở lần gọi đầu tiên:

Text
>>> searchByName("MOUSE")
!!! org.springframework.dao.InvalidDataAccessApiUsageException: For queries with named parameters you need to provide names for method parameters; Use @Param for query method parameters, or when on Java 8+ use the javac flag -parameters
!!! caused by java.lang.IllegalStateException: For queries with named parameters you need to provide names for method parameters; Use @Param for query method parameters, or when on Java 8+ use the javac flag -parameters

@Param đặt tên cho từng parameter một cách tường minh, và không cần đến flag:

src/main/java/com/example/demo/product/ProductRepository.java
    List<Product> findInCategoryUpTo(String category, BigDecimal maxPrice); 
    List<Product> findInCategoryUpTo(@Param("category") String category, @Param("maxPrice") BigDecimal maxPrice); 

Trong build không có -parameters, phiên bản này trả về đúng ba bàn phím như trước, query dùng positional parameter ở mục tiếp theo vẫn chạy, còn các query dùng named parameter mà thiếu @Param thì lỗi như trên. Vậy trong một project Spring Boot build bằng plugin của nó, @Param là tùy chọn. Nó vẫn đáng được thêm vào trong một library, hay bất kỳ code nào có thể được compile ở nơi khác, nơi không ai đảm bảo có flag.

Positional parameter

src/main/java/com/example/demo/product/ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long> {
 
    @Query("select p from Product p where p.status = ?1 and p.stock < ?2 order by p.stock") 
    List<Product> findRunningLow(ProductStatus status, int threshold); 
}
Text
>>> findRunningLow(ACTIVE, 5)
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.status=? and p1_0.stock<? order by p1_0.stock
binding parameter (1:ENUM) <- [ACTIVE]
binding parameter (2:INTEGER) <- [5]
MN-02  32-inch 4K monitor                 449.00    2  monitors    ACTIVE       true
MS-01  Wireless mouse                      24.50    3  mice        ACTIVE       null
AU-02  USB microphone                      89.90    4  audio       ACTIVE       null
(3 rows)

?1 là parameter đầu tiên của method, ?2 là parameter thứ hai. Vị trí gắn với thứ tự các parameter, nên đổi thứ tự sẽ đổi giá trị được bind; nếu hai parameter cùng kiểu dữ liệu thì chẳng có gì báo lỗi. Named parameter dễ đọc hơn và không bị ảnh hưởng khi refactor như vậy. Trên PostgreSQL, cùng lời gọi đó bind ACTIVE dưới dạng VARCHAR, khớp với column varchar(20) mà bài 26 có ở đó, còn H2 dùng kiểu ENUM của nó.

JPQL dùng tên entity, không phải tên table

Table tên là products, lấy từ @Table của bài 26, còn entity là Product. Viết tên table vào JPQL:

src/main/java/com/example/demo/product/ProductRepository.java
    @Query("select p from Product p where p.category = :category and p.price <= :maxPrice order by p.price") 
    @Query("select p from products p where p.category = :category and p.price <= :maxPrice order by p.price") 
    List<Product> findInCategoryUpTo(String category, BigDecimal maxPrice);

Ứng dụng dừng lúc khởi động, giống như khi sai tên property:

Text
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'productRepository' defined in com.example.demo.product.ProductRepository defined in @EnableJpaRepositories declared on DataJpaRepositoriesRegistrar.EnableJpaRepositoriesConfiguration: Query validation failed for 'select p from products p where p.category = :category and p.price <= :maxPrice order by p.price'
...
Caused by: org.springframework.data.repository.query.QueryCreationException: Cannot create query for method [ProductRepository.findInCategoryUpTo(java.lang.String,java.math.BigDecimal)]; Query validation failed for 'select p from products p where p.category = :category and p.price <= :maxPrice order by p.price'
...
Caused by: java.lang.IllegalArgumentException: org.hibernate.query.sqm.UnknownEntityException: Could not resolve root entity 'products'
...
Caused by: org.hibernate.query.sqm.UnknownEntityException: Could not resolve root entity 'products'

Viết SQL thuần, select * from products where category = :category and price <= :maxPrice order by price, cũng lỗi lúc khởi động, và còn sớm hơn một bước. Spring Data parse JPQL bằng grammar riêng trước khi Hibernate nhìn thấy nó, và từ chối dấu *:

Text
Caused by: org.springframework.data.jpa.repository.query.BadJpqlGrammarException: At 1:7 and token '*', extraneous input '*' expecting {'(', '[', ':', '{', '?', ID, VERSION, VERSIONED, NATURALID, FK, ABSENT, ALL, AND, ANY, ARRAY, AS, ASC, AVG, BETWEEN, BOTH, BREADTH, BY, CASE, CAST, COLLATE, COLUMN, COLUMNS, CONDITIONAL, CONFLICT, CONSTRAINT, ... HEX_LITERAL, BINARY_LITERAL, '{ts', '{d', '{t', '+', '-', IDENTIFIER, QUOTED_IDENTIFIER}; Bad HQL grammar [select * from products where category = :category and price <= :maxPrice order by price]

Dòng này dài 2.240 ký tự, và phần giữa của danh sách token được mong đợi đã bị cắt. Trong JPQL, Product là tên entity, mặc định là tên class, còn p.category là một field; tên table và tên column chỉ xuất hiện trong SQL mà Hibernate sinh ra.

Tìm không phân biệt hoa thường với LOWER và CONCAT

src/main/java/com/example/demo/product/ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long> {
 
    @Query("select p from Product p where lower(p.name) like lower(concat('%', :q, '%'))") 
    List<Product> searchByName(String q); 
}
Text
>>> searchByName("MOUSE")
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where lower(p1_0.name) like lower(('%'||?||'%')) escape ''
binding parameter (1:VARCHAR) <- [MOUSE]
MS-01  Wireless mouse                      24.50    3  mice        ACTIVE       null
MS-02  Gaming mouse                        49.90   18  mice        ACTIVE       null
MS-03  Vertical mouse                      39.00    0  mice        OUT_OF_STOCK false
AC-03  Mouse pad XL                        19.90   60  accessories ACTIVE       null
(4 rows)

Hibernate đổi concat thành ||, và thêm escape '', tức một ký tự escape rỗng. Giá trị được bind là input nguyên gốc. Cả hai chi tiết đều quan trọng với input của người dùng, và phần wildcard bên dưới sẽ quay lại chuyện này.

IN với parameter là collection

src/main/java/com/example/demo/product/ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long> {
 
    @Query("select p from Product p where p.sku in :skus") 
    List<Product> findAllBySkus(Collection<String> skus); 
}
Text
>>> findAllBySkus([KB-01, MS-02, XX-99])
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.sku in (?,?,?)
binding parameter (1:VARCHAR) <- [KB-01]
binding parameter (2:VARCHAR) <- [MS-02]
binding parameter (3:VARCHAR) <- [XX-99]
KB-01  Mechanical keyboard                 89.90   25  keyboards   ACTIVE       true
MS-02  Gaming mouse                        49.90   18  mice        ACTIVE       null
(2 rows)

Không có dấu ngoặc quanh :skus trong JPQL: Hibernate bung collection thành mỗi phần tử một placeholder, đúng như keyword In của derived query.

Aggregate: một giá trị đơn hoặc list Object[]

Query không nhất thiết phải trả về entity. Một aggregate duy nhất map sang return type là một giá trị đơn, còn nhiều giá trị được select trên mỗi row thì map sang Object[]:

src/main/java/com/example/demo/product/ProductRepository.java
    @Query("select avg(p.price) from Product p where p.category = :category")
    Double averagePrice(String category);
 
    @Query("""
            select p.category, count(p), avg(p.price)
            from Product p
            group by p.category
            order by p.category
            """)
    List<Object[]> priceStatsByCategory();

Trên H2, mỗi Object[] được in bằng Arrays.toString, theo sau là class của từng phần tử:

Text
>>> averagePrice("keyboards")
select avg(p1_0.price) from products p1_0 where p1_0.category=?
binding parameter (1:VARCHAR) <- [keyboards]
result 80.85 (Double)
>>> priceStatsByCategory()
select p1_0.category,count(p1_0.id),avg(p1_0.price) from products p1_0 group by p1_0.category order by p1_0.category
[accessories, 6, 30.033333333333]  (String, Long, Double)
[audio, 3, 122.633333333333]  (String, Long, Double)
[keyboards, 4, 80.85]  (String, Long, Double)
[mice, 3, 37.8]  (String, Long, Double)
[monitors, 3, 305.666666666667]  (String, Long, Double)
(5 rows)

avg trả về Double kể cả khi field là BigDecimal, còn count(p) trả về Long. Cùng query đó trên PostgreSQL trả về đúng các kiểu dữ liệu này nhưng nhiều chữ số hơn: 30.033333333333335, 122.63333333333334305.6666666666667. Đọc row[2] theo vị trí khiến code gắn chặt với thứ tự của danh sách select; trả về một record hoặc interface có field được đặt tên, tức projection, được trình bày trong khóa Advanced.

Spring Data JPA có escape % và _ trong input của người dùng không?

Ô tìm kiếm truyền đi bất cứ thứ gì người dùng gõ. Trong LIKE, % khớp với một chuỗi ký tự bất kỳ và _ khớp với đúng một ký tự, nên người dùng gõ % phải tìm ra những sản phẩm có dấu phần trăm trong tên, chứ không phải mọi sản phẩm. Hai method tìm kiếm ở trên, chạy trên H2:

Text
>>> findByNameContaining("%")
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.name like ? escape '\'
binding parameter (1:VARCHAR) <- [%\%%]
AC-04  Screen cleaner, 100% alcohol-free    9.90   80  accessories ACTIVE       null
(1 rows)
>>> findByNameContaining("_")
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.name like ? escape '\'
binding parameter (1:VARCHAR) <- [%\_%]
(0 rows)
>>> searchByName("%")  [JPQL concat]
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where lower(p1_0.name) like lower(('%'||?||'%')) escape ''
binding parameter (1:VARCHAR) <- [%]
KB-01  Mechanical keyboard                 89.90   25  keyboards   ACTIVE       true
KB-02  Compact keyboard                    59.00   12  keyboards   ACTIVE       null
...
AC-06  Cable organiser                     12.50   33  accessories ACTIVE       null
(19 rows)
MethodInputGiá trị được bindSố row (H2 và PostgreSQL)
findByNameContaining%%\%%1
findByNameContaining_%\_%0
findByNameContainingIgnoreCase100%%100\%%1
searchByName, JPQL dùng concat%%19
searchByName, JPQL dùng concat__19
findByNameLike%%19

Containing của derived query escape input: nó đặt dấu backslash trước %_, khai báo escape '\', và dấu % của người dùng chỉ khớp với dấu phần trăm thật. PostgreSQL cho ra đúng các con số đó. Query JPQL không escape gì cả, nên %_ đều khớp cả 19 sản phẩm: ô tìm kiếm trả về toàn bộ table. Like cũng không bao giờ escape, và đó là chủ ý: argument của nó vốn là một pattern do code của bạn dựng.

Với query JPQL nhận input của người dùng, Spring Data có thể escape ngay trong query bằng SpEL:

src/main/java/com/example/demo/product/ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long> {
 
    @Query("select p from Product p where p.name like %?#{escape([0])}% escape ?#{escapeCharacter()}") 
    List<Product> searchByNameEscaped(String q); 
}
Text
>>> searchByNameEscaped("%")
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.name like ? escape ?
binding parameter (1:VARCHAR) <- [%\%%]
binding parameter (2:CHAR) <- [\]
AC-04  Screen cleaner, 100% alcohol-free    9.90   80  accessories ACTIVE       null
(1 rows)

escape([0]) escape argument đầu tiên, escapeCharacter() cung cấp đúng dấu backslash đó, và %…% bao quanh biểu thức thêm wildcard vào. Với _ nó trả về 0 row, với keyboard là bốn bàn phím, giống hệt findByNameContaining.

Native query với nativeQuery = true

JPQL đáp ứng những gì một query theo entity thường cần, nhưng không phải mọi thứ database có. Full-text search của PostgreSQL so khớp từ theo gốc từ, nên tìm "keyboards" sẽ ra "keyboard"; LIKE chỉ so sánh ký tự. Native query là SQL mà Spring Data chuyển đi nguyên vẹn:

src/main/java/com/example/demo/product/ProductRepository.java
    @Query(value = """
            select * from products
            where to_tsvector('english', name) @@ websearch_to_tsquery('english', :terms)
            order by price
            """, nativeQuery = true)
    List<Product> fullTextSearch(String terms);

Nó được viết theo table và các column, productsname, và select * được map ngược về Product theo tên column. Trên PostgreSQL 18.6:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres
Text
>>> fullTextSearch("keyboards")
select * from products
where to_tsvector('english', name) @@ websearch_to_tsquery('english', ?)
order by price
 
binding parameter (1:VARCHAR) <- [keyboards]
KB-03  Wireless keyboard                   45.50    0  keyboards   DISCONTINUED false
KB-02  Compact keyboard                    59.00   12  keyboards   ACTIVE       null
KB-01  Mechanical keyboard                 89.90   25  keyboards   ACTIVE       true
KB-04  Ergonomic keyboard                 129.00    7  keyboards   ACTIVE       null
(4 rows)
>>> fullTextSearch("wireless keyboards")
select * from products
where to_tsvector('english', name) @@ websearch_to_tsquery('english', ?)
order by price
 
binding parameter (1:VARCHAR) <- [wireless keyboards]
KB-03  Wireless keyboard                   45.50    0  keyboards   DISCONTINUED false
(1 rows)
>>> fullTextSearch("mouse -gaming")
select * from products
where to_tsvector('english', name) @@ websearch_to_tsquery('english', ?)
order by price
 
binding parameter (1:VARCHAR) <- [mouse -gaming]
AC-03  Mouse pad XL                        19.90   60  accessories ACTIVE       null
MS-01  Wireless mouse                      24.50    3  mice        ACTIVE       null
MS-03  Vertical mouse                      39.00    0  mice        OUT_OF_STOCK false
(3 rows)

Hibernate ghi SQL string đúng như nó là, giữ nguyên các dòng, với :terms được thay bằng placeholder. websearch_to_tsquery hiểu hai từ là "có cả hai", còn -gaming là "không có gaming". Trên cùng database đó, findByNameContainingIgnoreCase("keyboards") trả về 0 row, vì không tên nào chứa dạng số nhiều. @NativeQuery trong cùng package org.springframework.data.jpa.repository là cách viết ngắn hơn của @Query(nativeQuery = true); với cùng SQL, nó trả về đúng bốn bàn phím đó.

Cái giá về tính portable: cùng query đó trên H2

Database H2 dùng lúc phát triển không có to_tsvector. Ứng dụng vẫn khởi động bình thường, log có Started DemoApplication, và method lỗi ở lần gọi đầu tiên:

Text
>>> fullTextSearch("keyboards")
select * from products
where to_tsvector('english', name) @@ websearch_to_tsquery('english', ?)
order by price
 
HHH000247: ErrorCode: 90022, SQLState: 90022
Function "TO_TSVECTOR" not found; SQL statement:
select * from products
where to_tsvector('english', name) @@ websearch_to_tsquery('english', ?)
order by price
 [90022-240]
!!! org.springframework.dao.InvalidDataAccessResourceUsageException: Could not prepare statement [Function "TO_TSVECTOR" not found; SQL statement:
...
!!! caused by org.hibernate.exception.SQLGrammarException: Could not prepare statement [Function "TO_TSVECTOR" not found; SQL statement:
...
!!! caused by org.h2.jdbc.JdbcSQLSyntaxErrorException: Function "TO_TSVECTOR" not found; SQL statement:

Hai cái giá trong cùng một output. Native query không được kiểm tra lúc khởi động, nên gõ sai tên column cũng phải đợi đến lần gọi đầu tiên mới lộ ra. Và nó trói method vào một database: test chạy trên H2 hoàn toàn không chạy được method này.

JPQL được Hibernate dịch thành SQL khác nhau cho H2 và PostgreSQL, bên cạnh native full-text query mà PostgreSQL chạy được còn H2 từ chối

JPQL thì đánh đổi theo chiều ngược lại. Phần lớn query trong bài đã được chạy trên cả hai database, và SQL sinh ra giống hệt nhau, trừ hai chỗ: bulk update ở phần tiếp theo, nơi Hibernate thêm cast(? as numeric(10,2)) chỉ cho H2, và các statement insert của seeder, kết thúc bằng values (?,?,?,?,?,?,?,default) trên H2 và bỏ hẳn column id ra trên PostgreSQL. Hibernate tự chọn từng khác biệt đó; code của repository không đổi gì.

SQL injection: bind parameter và nối chuỗi

⚠️ Class bên dưới là ví dụ về thứ không được viết. Nó chỉ có mặt để cho thấy dữ liệu bị lộ ra như thế nào.

Mọi Spring Data query trong bài đều gửi giá trị dưới dạng bind parameter: SQL chứa ?, còn giá trị được gửi riêng. Chuỗi trong @Query là hằng số lúc compile, nên không thể chứa input của người dùng. Rủi ro nằm ở code dựng chuỗi query lúc runtime:

src/main/java/com/example/demo/product/UnsafeProductSearch.java
package com.example.demo.product;
 
import java.util.List;
 
import jakarta.persistence.EntityManager;
 
import org.springframework.stereotype.Component;
 
@Component
public class UnsafeProductSearch {
 
    private final EntityManager entityManager;
 
    public UnsafeProductSearch(EntityManager entityManager) {
        this.entityManager = entityManager;
    }
 
    // DO NOT DO THIS: user input concatenated into SQL
    @SuppressWarnings("unchecked")
    public List<Product> findByName(String name) {
        String sql = "select * from products where name = '" + name + "'";
        return entityManager.createNativeQuery(sql, Product.class).getResultList();
    }
}

Input là ' OR '1'='1, loại chuỗi hay xuất hiện trong một search parameter. Trên H2:

Text
>>> unsafe.findByName("' OR '1'='1")
select * from products where name = '' OR '1'='1'
KB-01  Mechanical keyboard                 89.90   25  keyboards   ACTIVE       true
KB-02  Compact keyboard                    59.00   12  keyboards   ACTIVE       null
...
AC-06  Cable organiser                     12.50   33  accessories ACTIVE       null
(19 rows)

Dấu nháy trong input đóng string literal lại, và OR '1'='1' trở thành một phần của điều kiện: cả 19 sản phẩm, kể cả những sản phẩm đã discontinued. Cùng hình dạng SQL đó nhưng là native query có bind parameter:

src/main/java/com/example/demo/product/ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long> {
 
    @Query(value = "select * from products where name = :name", nativeQuery = true) 
    List<Product> findByNameNative(String name); 
}
Text
>>> repository.findByNameNative("' OR '1'='1")
select * from products where name = ?
binding parameter (1:VARCHAR) <- [' OR '1'='1]
(0 rows)
>>> repository.findByNameNative("USB-C hub")
select * from products where name = ?
binding parameter (1:VARCHAR) <- [USB-C hub]
AC-01  USB-C hub                           39.00   40  accessories ACTIVE       null
(1 rows)

Toàn bộ input được so sánh như một cái tên, và không có sản phẩm nào tên là ' OR '1'='1. PostgreSQL cho ra đúng 19 và 0. Hãy bind giá trị bằng :name hoặc ?1, và khi chính một phần của query phải thay đổi, như column dùng để sort, hãy chọn nó từ một danh sách cố định trong code chứ không lấy từ input.

Bulk update và delete với @Modifying

Tăng giá mọi bàn phím thêm 10% bằng findByCategorysave sẽ load bốn entity và ghi bốn UPDATE. Một JPQL update làm việc đó bằng một statement:

src/main/java/com/example/demo/product/ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long> {
 
    @Modifying
    @Query("update Product p set p.price = p.price * :factor where p.category = :category") 
    int changePrices(String category, BigDecimal factor); 
}

Chuyện gì xảy ra khi thiếu transaction hoặc thiếu @Modifying?

Gọi thẳng từ runner, không có transaction bao quanh, không có gì tới được database:

Text
>>> repository.changePrices("keyboards", 1.10) without a transaction
!!! org.springframework.dao.InvalidDataAccessApiUsageException: No active transaction for update or delete query
!!! caused by jakarta.persistence.TransactionRequiredException: No active transaction for update or delete query

Query method bạn khai báo trên interface chạy mà không có transaction riêng, và JPA từ chối thực thi update hay delete bên ngoài transaction.

@Modifying là thứ khiến Spring Data gọi executeUpdate() thay vì đọc kết quả. Một bản sao của method không có annotation này, changePricesWithoutModifying, được gọi bên trong transaction:

Text
>>> service.changePricesWithoutModifying("keyboards", 1.10)
!!! org.springframework.dao.InvalidDataAccessApiUsageException: Query executed via 'getResultList()' or 'getSingleResult()' must be a 'select' query [update Product p set p.price = p.price * :factor where p.category = :category]
!!! caused by java.lang.IllegalStateException: Query executed via 'getResultList()' or 'getSingleResult()' must be a 'select' query [update Product p set p.price = p.price * :factor where p.category = :category]
!!! caused by org.hibernate.query.IllegalSelectQueryException: Expecting a SELECT Query [org.hibernate.query.sqm.tree.select.SqmSelectStatement], but found org.hibernate.query.sqm.tree.update.SqmUpdateStatement [update Product p set p.price = p.price * :factor where p.category = :category]

Cần cả hai. Transaction đặt trên service method sở hữu use case đó, đúng chỗ mà bài 21 đã nói:

src/main/java/com/example/demo/product/ProductService.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
@Service
public class ProductService {
 
    private final ProductRepository repository;
 
    public ProductService(ProductRepository repository) {
        this.repository = repository;
    }
 
    @Transactional
    public int changePrices(String category, BigDecimal factor) {
        return repository.changePrices(category, factor);
    }
}
Text
>>> service.changePrices("keyboards", 1.10)
update products p1_0 set price=(p1_0.price*cast(? as numeric(10,2))) where p1_0.category=?
binding parameter (1:NUMERIC) <- [1.10]
binding parameter (2:VARCHAR) <- [keyboards]
result 4 (Integer)
>>> findByCategory("keyboards") after
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.category=?
binding parameter (1:VARCHAR) <- [keyboards]
KB-01  Mechanical keyboard                 98.89   25  keyboards   ACTIVE       true
KB-02  Compact keyboard                    64.90   12  keyboards   ACTIVE       null
KB-03  Wireless keyboard                   50.05    0  keyboards   DISCONTINUED false
KB-04  Ergonomic keyboard                 141.90    7  keyboards   ACTIVE       null
(4 rows)
>>> service.changePrices("nothing", 1.10)
update products p1_0 set price=(p1_0.price*cast(? as numeric(10,2))) where p1_0.category=?
binding parameter (1:NUMERIC) <- [1.10]
binding parameter (2:VARCHAR) <- [nothing]
result 0 (Integer)

Giá trị int là số row mà database đã thay đổi, 4 với keyboards và 0 với một category không tồn tại, nên đây là cách rẻ để phân biệt "đã cập nhật" với "không khớp gì". Trên PostgreSQL, statement là update products p1_0 set price=(p1_0.price*?) where p1_0.category=? và bốn mức giá mới giống hệt.

Cái bẫy persistence context bị stale

Bulk update đi thẳng xuống database. Nó không đụng tới các entity mà persistence context đang giữ, và trong một transaction, chính các entity đó là thứ mọi lần đọc sau trả về. Một service method, bốn lời gọi:

src/main/java/com/example/demo/product/ProductService.java
@Transactional
public void priceChangeTrace() {
    Product keyboard = repository.findBySku("KB-01").orElseThrow();
    System.out.println("1. loaded       KB-01 price " + keyboard.getPrice());
 
    int rows = repository.changePrices("keyboards", new BigDecimal("1.10"));
    System.out.println("2. bulk update  " + rows + " rows");
 
    Product byId = repository.findById(keyboard.getId()).orElseThrow();
    System.out.println("3. findById     KB-01 price " + byId.getPrice() + ", same object: " + (byId == keyboard));
 
    Product bySku = repository.findBySku("KB-01").orElseThrow();
    System.out.println("4. findBySku    KB-01 price " + bySku.getPrice() + ", same object: " + (bySku == keyboard));
    System.out.println("5. old variable KB-01 price " + keyboard.getPrice());
}
Text
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.sku=?
binding parameter (1:VARCHAR) <- [KB-01]
1. loaded       KB-01 price 89.90
update products p1_0 set price=(p1_0.price*cast(? as numeric(10,2))) where p1_0.category=?
binding parameter (1:NUMERIC) <- [1.10]
binding parameter (2:VARCHAR) <- [keyboards]
2. bulk update  4 rows
3. findById     KB-01 price 89.90, same object: true
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.sku=?
binding parameter (1:VARCHAR) <- [KB-01]
4. findBySku    KB-01 price 89.90, same object: true
5. old variable KB-01 price 89.90

Row trong database giờ là 98.89, còn transaction thì đọc ra 89.90 cả ba lần. Bước 3 không gửi SQL nào: findById tìm thấy KB-01 trong persistence context và trả về luôn. Bước 4 có gửi select, vậy mà vẫn trả về cùng object với giá cũ: khi id của một row thuộc về một entity mà persistence context đang quản lý, Hibernate trả lại chính entity đó và không ghi đè nó bằng dữ liệu của row. Mọi phép tính tổng tiền, giảm giá hay kiểm tra nào chạy sau đó trong transaction đều dùng một mức giá không còn tồn tại.

Trace bốn lời gọi trong một transaction: entity được load, bulk UPDATE chỉ đổi database, hai lần đọc đều ra 89.90, và cùng các lời gọi đó với clearAutomatically = true đọc ra 98.89

Còn tệ hơn nếu entity bị stale đó bị sửa. Transaction bên dưới đổi stock của KB-01 đã load trước bulk update:

src/main/java/com/example/demo/product/ProductService.java
@Transactional
public void staleEntityWrittenBack() {
    Product keyboard = repository.findBySku("KB-01").orElseThrow();
    int rows = repository.changePrices("keyboards", new BigDecimal("1.10"));
    System.out.println("1. bulk update  " + rows + " rows");
    keyboard.setStock(24);
    System.out.println("2. stock of the loaded KB-01 set to 24");
}
Text
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.sku=?
binding parameter (1:VARCHAR) <- [KB-01]
update products p1_0 set price=(p1_0.price*cast(? as numeric(10,2))) where p1_0.category=?
binding parameter (1:NUMERIC) <- [1.10]
binding parameter (2:VARCHAR) <- [keyboards]
1. bulk update  4 rows
2. stock of the loaded KB-01 set to 24
update products set category=?,featured=?,name=?,price=?,sku=?,status=?,stock=? where id=?
binding parameter (1:VARCHAR) <- [keyboards]
binding parameter (2:BOOLEAN) <- [true]
binding parameter (3:VARCHAR) <- [Mechanical keyboard]
binding parameter (4:NUMERIC) <- [89.90]
binding parameter (5:VARCHAR) <- [KB-01]
binding parameter (6:ENUM) <- [ACTIVE]
binding parameter (7:INTEGER) <- [24]
binding parameter (8:BIGINT) <- [1]
>>> findByCategory("keyboards") after the transaction
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.category=?
binding parameter (1:VARCHAR) <- [keyboards]
KB-01  Mechanical keyboard                 89.90   24  keyboards   ACTIVE       true
KB-02  Compact keyboard                    64.90   12  keyboards   ACTIVE       null
KB-03  Wireless keyboard                   50.05    0  keyboards   DISCONTINUED false
KB-04  Ergonomic keyboard                 141.90    7  keyboards   ACTIVE       null
(4 rows)

Lúc commit, Hibernate flush entity bị thay đổi bằng một UPDATE ghi mọi column, và price quay về 89.90. Ba bàn phím nhận giá mới, một bàn phím lặng lẽ giữ giá cũ.

clearAutomatically = true

clearAutomatically = true khiến Spring Data clear persistence context ngay sau bulk statement:

src/main/java/com/example/demo/product/ProductRepository.java
    @Modifying
    @Modifying(clearAutomatically = true) 
    @Query("update Product p set p.price = p.price * :factor where p.category = :category")
    int changePrices(String category, BigDecimal factor);

Cùng priceChangeTrace() đó:

Text
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.sku=?
binding parameter (1:VARCHAR) <- [KB-01]
1. loaded       KB-01 price 89.90
update products p1_0 set price=(p1_0.price*cast(? as numeric(10,2))) where p1_0.category=?
binding parameter (1:NUMERIC) <- [1.10]
binding parameter (2:VARCHAR) <- [keyboards]
2. bulk update  4 rows
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.id=?
binding parameter (1:BIGINT) <- [1]
3. findById     KB-01 price 98.89, same object: false
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.sku=?
binding parameter (1:VARCHAR) <- [KB-01]
4. findBySku    KB-01 price 98.89, same object: false
5. old variable KB-01 price 89.90

Giờ findById đi xuống database, và cả hai lần đọc đều trả về một object mới với giá 98.89. Variable keyboard vẫn giữ object cũ, đã detached khỏi persistence context, và vẫn là 89.90. Detached cũng có nghĩa là bị bỏ qua: với clearAutomatically = true, staleEntityWrittenBack() không gửi UPDATE nào sau bulk statement, và KB-01 kết thúc transaction với giá 98.89 và stock vẫn là 25. Lời gọi setStock(24) mất đi mà không có lỗi nào.

Còn một thay đổi được thực hiện trước bulk update và chưa flush thì sao? Một transaction đặt stock của KB-01 về 0 rồi gọi changePrices:

Text
1. loaded KB-01, stock set to 0 in memory
update products set category=?,featured=?,name=?,price=?,sku=?,status=?,stock=? where id=?
...
binding parameter (7:INTEGER) <- [0]
binding parameter (8:BIGINT) <- [1]
update products p1_0 set price=(p1_0.price*cast(? as numeric(10,2))) where p1_0.category=?
binding parameter (1:NUMERIC) <- [1.10]
binding parameter (2:VARCHAR) <- [keyboards]
2. bulk update  4 rows
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.sku=?
binding parameter (1:VARCHAR) <- [KB-01]
3. findBySku    KB-01 price 98.89, stock 0

Hibernate flush thay đổi đang chờ trước khi chạy bulk UPDATE, nên việc clear không làm mất nó. @Modifying còn có flushAutomatically = true, khiến Spring Data tự gọi EntityManager.flush() trước executeUpdate(), dành cho khi bạn không muốn dựa vào hành vi đó. Cách an toàn thì đơn giản hơn cả hai flag: chạy bulk statement ở đầu transaction, hoặc ở cuối, và đừng tiếp tục dùng các entity đã load trước nó.

Derived deleteBy và bulk DELETE

Xóa cả một category có thể dùng derived method hoặc JPQL delete:

src/main/java/com/example/demo/product/ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long> {
 
    long deleteByCategory(String category); 
 
    @Modifying
    @Query("delete from Product p where p.category = :category") 
    int deleteAllInCategory(String category); 
}

Cả hai đều cần transaction, và khi thiếu thì lỗi theo cách khác nhau. Derived delete đã gửi SELECT đi rồi mới lỗi:

Text
>>> repository.deleteByCategory("audio") without a transaction
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.category=?
binding parameter (1:VARCHAR) <- [audio]
!!! org.springframework.dao.InvalidDataAccessApiUsageException: No EntityManager with actual transaction available for current thread - cannot reliably process 'remove' call
!!! caused by jakarta.persistence.TransactionRequiredException: No EntityManager with actual transaction available for current thread - cannot reliably process 'remove' call
>>> repository.deleteAllInCategory("audio") without a transaction
!!! org.springframework.dao.InvalidDataAccessApiUsageException: No active transaction for update or delete query
!!! caused by jakarta.persistence.TransactionRequiredException: No active transaction for update or delete query

Bên trong các service method có @Transactional, với một method @PreRemove tạm thời trên Product in ra SKU của từng entity sắp bị xóa:

src/main/java/com/example/demo/product/Product.java
    @PreRemove
    void printRemoval() { 
        System.out.println("@PreRemove " + sku); 
    } 
Text
>>> service.deleteCategoryDerived("audio")
select p1_0.id,p1_0.category,p1_0.featured,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.category=?
binding parameter (1:VARCHAR) <- [audio]
@PreRemove AU-01
@PreRemove AU-02
@PreRemove AU-03
delete from products where id=?
binding parameter (1:BIGINT) <- [11]
delete from products where id=?
binding parameter (1:BIGINT) <- [12]
delete from products where id=?
binding parameter (1:BIGINT) <- [13]
result 3 (Long)
>>> service.deleteCategoryInBulk("mice")
delete from products p1_0 where p1_0.category=?
binding parameter (1:VARCHAR) <- [mice]
result 3 (Integer)
Derived deleteByCategoryJPQL delete với @Modifying
Statement cho 3 row1 SELECT, rồi 3 DELETE theo id1 DELETE
Load entityCó, từng entity được đưa vào EntityManager.removeKhông
@PreRemove chạyCó, mỗi sản phẩm một lầnKhông
Persistence contextBiết các entity đã bị xóaKhông được báo; các entity đã load vẫn nằm trong đó
Khi thiếu transactionLỗi sau SELECTLỗi trước khi có SQL nào
Giá trị trả vềlong, số entity đã xóaint, số row đã xóa

Bản derived gửi mỗi row một DELETE, và là lựa chọn khi entity callback, hoặc cascade mà bài 28 thêm vào, bắt buộc phải chạy. Bản bulk dành cho số lượng row lớn, kèm cùng lưu ý về các entity đã load như với bulk update.

Ngoài phạm vi bài: Query by Example, Specifications, Querydsl và projection

Spring Data JPA còn bốn cách query khác, và mỗi cách được trình bày riêng trong khóa Advanced:

  • Query by Example: điền vào một Product các giá trị cần khớp rồi truyền Example.of(probe) cho findAll, method mà JpaRepository thừa hưởng từ QueryByExampleExecutor.
  • Specifications và Criteria API: dựng predicate thành các object và kết hợp chúng lúc runtime, hợp với các form tìm kiếm mà filter nào cũng là tùy chọn.
  • Querydsl: query type-safe viết theo các class được sinh ra từ entity.
  • Projection: trả về record hoặc interface chỉ chứa các column được select, thay vì cả entity hay Object[].

Derived query, JPQL hay native query: dùng cái nào?

Derived query method@Query với JPQLNative query
Viết theoTên method: property của entity và keywordTên entity và fieldTên table và column
Được kiểm traLúc khởi động: PropertyReferenceExceptionLúc khởi động: Query validation failed, BadJpqlGrammarExceptionỞ lần gọi đầu tiên
SQLHibernate sinh theo từng dialectHibernate sinh theo từng dialectGửi nguyên văn
%_ trong input người dùngContainingStartingWith escapeKhông escape, trừ khi viết với escape([0])Tùy SQL bạn viết
Hợp vớiMột đến ba điều kiện, exists, count, Top/First, OrderByDấu ngoặc, function, aggregate, list IN, bulk updatedeleteTính năng JPQL không có: full-text search, function riêng của database, SQL tinh chỉnh bằng tay
Bắt đầu gây khóNhững cái tên như findTop3ByCategoryAndPriceLessThanOrderByPriceDescCác filter đều là tùy chọn, trường hợp hợp với Specifications hơnMọi database mà nó phải chạy trên đó, kể cả database dùng cho test

Thứ tự thực tế: bắt đầu bằng derived method, chuyển sang @Query ngay khi cái tên hết dễ đọc, và chỉ dùng native query cho tính năng mà database có còn JPQL không có, với hiểu biết rằng test của nó cần đúng database đó.

FAQ

Spring Data JPA còn cần @Param không?

Không, trong một project Spring Boot build bằng plugin Gradle của Spring Boot, vì plugin compile với -parameters, nên :category khớp với parameter tên category. Nếu compile thiếu flag đó, ứng dụng vẫn khởi động, và mỗi query dùng named parameter lỗi ở lần gọi đầu với thông báo "For queries with named parameters you need to provide names for method parameters". @Param chạy được trong cả hai trường hợp, nên library và code compile ở nơi khác nên giữ nó. Positional parameter như ?1 không bao giờ cần tên.

Vì sao @Modifying query báo "No active transaction for update or delete query"?

Vì query method khai báo trên repository interface chạy mà không có transaction riêng, còn JPA chỉ thực thi update và delete bên trong một transaction. Hãy gọi method đó từ một service method có @Transactional.

Vì sao findById trả về giá trị cũ sau bulk update?

Một @Modifying query đổi row trong database nhưng không đổi các entity đã nằm trong persistence context, và findById trả về entity đang được quản lý mà không query. Ngay cả query có chạy SELECT cũng trả về object đang được quản lý mà không cập nhật nó. Thêm clearAutomatically = true vào annotation @Modifying, hoặc chạy bulk statement trước khi load entity. Nếu không làm cách nào, lần flush sau của entity bị stale đó sẽ ghi giá trị cũ đè lên kết quả của bulk update.

Spring Data có escape wildcard trong LIKE query không?

ContainingStartingWith của derived query thì có: input % được bind thành %\%% với escape '\', và khớp một sản phẩm thay vì mười chín. Like và JPQL tự viết như like concat('%', :q, '%') thì không, nên % hay _ từ người dùng khớp với mọi row. Trong @Query, like %?#{escape([0])}% escape ?#{escapeCharacter()} escape input theo đúng cách đó.

Spring Data kiểm tra query method lúc nào?

Derived query và method @Query dùng JPQL được kiểm tra khi tạo bean repository, mà ở bootstrap mode mặc định là trong lúc khởi động: sai property, viết tên entity thành tên table, hay viết SQL thay cho JPQL đều làm ứng dụng dừng. Native query không được kiểm tra; lỗi của nó xuất hiện ở lần gọi đầu tiên.

Derived query method có dùng được dấu ngoặc giữa And và Or không?

Không. And gắn chặt hơn Or, nên findByCategoryAndStockGreaterThanOrStatus nghĩa là (category and stock) or status, và không có cú pháp nào để nhóm theo cách khác. Hãy viết điều kiện đó trong @Query với dấu ngoặc tường minh.

Kết luận

Derived query method chuyển một cái tên thành JPQL lúc khởi động, rồi Hibernate chuyển JPQL thành SQL cho từng database. Nó ít code nhất và lỗi sớm nhất: sai tên property làm ứng dụng dừng kèm gợi ý "Did you mean". Return type đặt ra hợp đồng, nên Optional ném exception khi có hai row, Stream cần transaction, và Containing escape % với _ trong input của người dùng, điều mà LIKE tự viết không làm; còn False thì không bao giờ khớp null. @Query với JPQL thêm dấu ngoặc, function, list IN và aggregate, vẫn được kiểm tra lúc khởi động; với plugin Gradle của Boot, named parameter chạy mà không cần @Param. Native query mở ra các tính năng như full-text search, đổi lại mất phần kiểm tra lúc khởi động và tính portable, và đã lỗi trên H2 ở lần gọi đầu tiên. Dù viết query theo cách nào, hãy bind giá trị: một chuỗi ' OR '1'='1 bị nối vào SQL đã trả về mọi sản phẩm.

Với thao tác ghi, @Modifying cần transaction và trả về số row. Bulk update đi vòng qua persistence context: một entity đã load vẫn đọc ra 89.90 sau khi row đã thành 98.89, và flush nó đã ghi 89.90 trở lại. clearAutomatically = true sửa được các lần đọc nhưng làm các object cũ bị detached, và mọi thay đổi sau đó trên chúng đều mất. Derived deleteBy load và xóa từng entity một, có chạy callback, còn bulk DELETE là một statement và không chạy callback.

Bài tiếp theo map quan hệ giữa các entity: @OneToOne, @OneToMany, @ManyToOne@ManyToMany, cascade, và FetchType.

Bài viết liên quan

[Spring Boot Basics] Auditing với Spring Data JPA: @CreatedDate, @LastModifiedDate và @CreatedBy

Auditing với Spring Data JPA trên Spring Boot 4.1.1 và PostgreSQL: @EnableJpaAuditing, AuditingEntityListener và một base class @MappedSuperclass, Flyway migration thêm column audit NOT NULL vào table đã có dữ liệu, giá trị null âm thầm khi thiếu annotation hoặc listener, Instant so với LocalDateTime và OffsetDateTime cùng giá trị timestamptz thực sự lưu, @LastModifiedDate đổi khi nào và modifyOnCreate thay đổi gì, save() một detached entity ghi null vào created_at và @Column(updatable = false), @CreatedBy lấy từ header X-User qua AuditorAware, DateTimeProvider dựa trên Clock, bulk update và native update bỏ qua auditing, và so sánh với @CreationTimestamp, @UpdateTimestamp của Hibernate.

[Spring Boot Basics] Nhận và trả dữ liệu trong Spring Boot: @PathVariable, @RequestParam, @RequestBody, @RequestHeader và ResponseEntity

Bind dữ liệu của HTTP request trong Spring Boot 4.1.1: @PathVariable và flag -parameters của compiler, @RequestParam với defaultValue, Optional, List và Map, @RequestHeader và @CookieValue, @RequestBody cùng các lỗi 415 và 400, status code cho mọi giá trị bị thiếu hoặc không convert được, và ResponseEntity cho 201 Created kèm Location, 204 và 404.

[Spring Boot Basics] Validation trong Spring Boot: Bean Validation, @Valid và custom validator

Bean Validation trong Spring Boot 4.1.1 với Hibernate Validator: spring-boot-starter-validation, @NotNull, @NotEmpty và @NotBlank khác nhau ra sao, @Size, @DecimalMin, @Digits, @Email và @Pattern trên DTO record, @Valid với @RequestBody và response 400 mặc định, object lồng nhau và list, validate @PathVariable và @RequestParam cùng cái bẫy 500 của @Validated, validation group, ValidationMessages.properties và Accept-Language, custom ConstraintValidator và constraint liên quan nhiều field, và validation ở service layer.

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

Dynamic query cho một search sản phẩm có filter tùy chọn trên Spring Boot 4.1.1 và PostgreSQL: vì sao derived query và mẹo @Query (:x is null or …) không đủ, kèm lỗi lower(bytea) và generic plan đọc 200.403 buffer, Specification kết hợp được với API của Spring Data 4 (allOf, unrestricted, PredicateSpecification, UpdateSpecification, where(null) giờ throw), escape LIKE, join to-many làm count phình ra và page co lại, distinct so với subquery exists, Criteria API trong repository fragment tự viết với metamodel của hibernate-processor và đếm theo category, Querydsl với jakarta classifier, QuerydslPredicateExecutor và JPAQueryFactory, code generation của jOOQ, whitelist cho sort, 400 ProblemDetail khi minPrice lớn hơn maxPrice, và Query by Example.