Query methods, @Query, Specification và Projection
Tiếp đi: các cách viết query.
Bài này học các cách viết query trong Spring Data JPA, từ đơn giản đến linh hoạt, vẫn trên shop-app.
1. Bài toán
Chúng ta muốn tìm:
- Product theo tên.
- Product có giá lớn hơn 100.
- Product còn hàng.
- Product có tên chứa từ khóa.
- Màn hình admin: lọc theo tên, khoảng giá, còn hàng, mỗi điều kiện có thể có hoặc không.
2. Vấn đề
- Viết JDBC tay cho từng câu → lặp code mapping.
- Filter động bằng cộng chuỗi SQL → dễ SQL injection, khó đọc.
- Trả nguyên Entity khi API chỉ cần 2 field → load thừa, dính lazy loading.
3. Giải pháp: 5 công cụ
| Loại | Khi nào dùng |
|---|---|
| Derived Query | Query đơn giản |
JPQL (@Query) | Query có điều kiện, join, projection |
| Native SQL | Query đặc thù database hoặc cần SQL phức tạp |
| Specification | Filter động với nhiều điều kiện |
| Projection | Chỉ lấy vài field |
4. Code từng bước
Bước 1: Dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
Bước 2: Derived Query Methods. Spring Data đọc tên method và sinh query:
public interface ProductRepository
extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {
List<Product> findByName(String name);
List<Product> findByPriceGreaterThan(BigDecimal price);
List<Product> findByStockGreaterThan(Integer stock);
List<Product> findByNameContainingIgnoreCase(String keyword);
boolean existsByName(String name);
long countByStockEquals(Integer stock);
}
repository.findByPriceGreaterThan(new BigDecimal("100"));
-- SQL tương đương:
SELECT * FROM products WHERE price > 100;
Sai tên field (ví dụ findByPrize) → app fail lúc khởi động, không đợi tới runtime.
Bước 3a: JPQL cho query phức tạp hơn:
@Query("""
SELECT p
FROM Product p
WHERE p.price >= :minPrice
AND p.stock > 0
""")
List<Product> findAvailableProducts(@Param("minPrice") BigDecimal minPrice);
Điểm quan trọng: JPQL dùng tên Entity và Java property, không phải tên bảng và cột. SELECT p FROM Product p: Product là Entity class.
Bước 3b: Native query
@Query(value = """
SELECT *
FROM products
WHERE price >= :minPrice
""", nativeQuery = true)
List<Product> findProductsNative(@Param("minPrice") BigDecimal minPrice);
Native SQL làm việc trực tiếp với cú pháp database (ví dụ jsonb, window function của PostgreSQL).
Bước 3c: Query sửa dữ liệu cần @Modifying:
@Modifying(clearAutomatically = true)
@Query("UPDATE Product p SET p.price = p.price * 1.10 WHERE p.category = :c")
int increasePrice(@Param("c") String category);
Bước 3d: Specification cho filter động:
public final class ProductSpecs {
public static Specification<Product> nameContains(String kw) {
return (root, q, cb) -> kw == null ? null
: cb.like(cb.lower(root.get("name")), "%" + kw.toLowerCase() + "%");
}
public static Specification<Product> priceBetween(BigDecimal min, BigDecimal max) {
return (root, q, cb) -> {
if (min == null && max == null) return null;
if (min == null) return cb.le(root.get("price"), max);
if (max == null) return cb.ge(root.get("price"), min);
return cb.between(root.get("price"), min, max);
};
}
public static Specification<Product> inStock(Boolean only) {
return (root, q, cb) -> Boolean.TRUE.equals(only) ? cb.gt(root.get("stock"), 0) : null;
}
}
// Service
Specification<Product> spec = Specification.allOf(
ProductSpecs.nameContains(kw),
ProductSpecs.priceBetween(min, max),
ProductSpecs.inStock(inStock));
List<Product> result = productRepository.findAll(spec);
Specification trả null nghĩa là "bỏ qua điều kiện này". Giá trị luôn đi qua tham số bind, không cộng chuỗi. (Bản Spring Data cũ hơn dùng Specification.where(a).and(b).)
Bước 3e: Projection, chỉ lấy field cần:
// Interface projection
public interface ProductSummary {
Long getId();
String getName();
BigDecimal getPrice();
}
List<ProductSummary> findByStockGreaterThan(int stock);
// DTO projection (record) bằng JPQL constructor expression
public record OrderSummary(Long id, String status, String userName) {}
@Query("""
SELECT new com.example.shop.dto.OrderSummary(o.id, o.status, u.name)
FROM Order o JOIN o.user u
WHERE o.status = :status
""")
List<OrderSummary> findSummaries(@Param("status") String status);
Projection chỉ SELECT đúng cột, không có proxy nên không lo LazyInitializationException.
Bước 3f: Querydsl (sơ lược). Sinh class QProduct từ entity bằng annotation processor, viết query type-safe:
QProduct p = QProduct.product;
BooleanBuilder where = new BooleanBuilder();
if (kw != null) where.and(p.name.containsIgnoreCase(kw));
if (min != null) where.and(p.price.goe(min));
productRepository.findAll(where); // repo extends QuerydslPredicateExecutor<Product>
Ưu điểm: sai tên field là lỗi compile. Nhược: thêm build step, thư viện cần kiểm tra mức độ bảo trì trước khi chọn.
Bước 4: Test bằng curl
curl "http://localhost:8080/products?kw=phone&min=100&inStock=true"
# log: SELECT ... FROM products p WHERE lower(p.name) LIKE ? AND p.price >= ? AND p.stock > ?
5. Cách hoạt động
findByNameContainingIgnoreCase(kw)
│ (lúc khởi động)
▼
PartTree parser: find | By | Name | Containing | IgnoreCase
│
▼
JPA Criteria query ──► Hibernate ──► SQL
SELECT ... WHERE upper(name) LIKE upper(?)
@Query JPQL ──► Hibernate dịch Entity/property → bảng/cột ──► SQL
@Query native ──────────────────────────────────────────────► SQL nguyên văn
Specification ─► Predicate (Criteria API) ghép AND/OR ──────► SQL
6. So sánh
| Derived | JPQL | Native | Specification | |
|---|---|---|---|---|
| Làm việc với | Tên method | Entity, property | Bảng, cột | Criteria API |
| Kiểm tra lúc khởi động | Có | Có | Không | Không (runtime) |
| Portable giữa DB | Có | Có | Không | Có |
| Filter động | Không | Khó | Khó | Tốt |
| Dễ đọc khi phức tạp | Tên method rất dài | Tốt | Tốt | Trung bình |
7. Khi nào nên dùng / không nên dùng
- Derived: 1–2 điều kiện. Tên method dài quá 3 điều kiện thì chuyển sang
@Query. - JPQL: join, fetch join, DTO projection, cố định điều kiện.
- Native: tính năng riêng PostgreSQL, báo cáo phức tạp. Đổi lại mất portability.
- Specification / Querydsl: màn hình search nhiều filter tùy chọn.
- Không nên: cộng chuỗi SQL từ input người dùng.
findAll(spec) trên một triệu Product sẽ kéo hết vào memory. Cần phân trang → Bài 4.3.9.JPQL khác SQL thế nào?
Đúng câu này rất hay bị hỏi. JPQL query trên entity object và property; SQL query trực tiếp trên bảng và cột.
JPQL: SELECT o FROM Order o WHERE o.user.name = :name
SQL : SELECT o.* FROM orders o JOIN users u ON u.id = o.user_id WHERE u.name = ?
JPQL hiểu quan hệ (o.user.name), Hibernate tự sinh JOIN và dịch sang dialect của DB. Native thì bạn tự viết đúng cú pháp DB và nhận lại cột, map vào entity hoặc interface projection.
@Modifying query xong, entity đang load trong transaction có tự cập nhật không?
Không. Bulk UPDATE/DELETE bằng JPQL đi thẳng xuống DB, bỏ qua Persistence Context: entity đã load vẫn giữ giá trị cũ (stale), không chạy dirty checking, không tăng @Version. Vì vậy:
flushAutomatically = true: flush thay đổi đang chờ trước khi chạy bulk query.clearAutomatically = true: clear context sau đó để lần đọc tiếp lấy dữ liệu mới.
Phỏng vấn hỏi về query trong Spring Data JPA thì trả lời sao?
Q1. What is the difference between JPQL and native SQL?
🇻🇳 Tiếng Việt:
JPQL query trên entity và Java property, Hibernate dịch sang SQL theo dialect, nên portable và được kiểm tra khi khởi động. Native SQL query trực tiếp trên bảng và cột, dùng được tính năng riêng của database nhưng mất portability. Em ưu tiên JPQL, chỉ dùng native khi cần tính năng đặc thù hoặc tối ưu.
🇬🇧 English:
JPQL queries entity objects and their properties, and Hibernate translates them to SQL for the configured dialect, so they are portable and validated at startup. Native SQL queries database tables and columns directly; it can use database-specific features but loses portability. I prefer JPQL and use native queries only for database-specific features or tuning.
Q2. Khi nào dùng derived query, @Query, Specification?
🇻🇳 Tiếng Việt:
Derived query cho điều kiện đơn giản, Spring Data sinh query từ tên method. Khi có join, fetch join hoặc DTO projection, em dùng
@Query. Khi màn hình search có nhiều filter tùy chọn, em dùng Specification hoặc Querydsl để ghép điều kiện động mà vẫn dùng tham số bind, tránh SQL injection.
🇬🇧 English:
Derived queries cover simple conditions; Spring Data builds the query from the method name. For joins, fetch joins or DTO projections I use
@Query. For search screens with many optional filters I use Specifications or Querydsl to compose predicates dynamically while still using bind parameters, which avoids SQL injection.
Q3. Projection là gì, vì sao dùng?
🇻🇳 Tiếng Việt:
Projection là chỉ lấy những field cần thiết thay vì cả entity, bằng interface projection hoặc DTO/record qua constructor expression. Nó giảm dữ liệu đọc, không tạo managed entity nên không tốn dirty checking và không gặp LazyInitializationException. Phù hợp cho API đọc và báo cáo.
🇬🇧 English:
A projection selects only the fields we need instead of the whole entity, using an interface projection or a DTO/record via a constructor expression. It reads less data, creates no managed entities, so there is no dirty-checking cost and no LazyInitializationException. It suits read APIs and reports.
Cách nhớ ngắn gọn
- Đơn giản → derived; cố định phức tạp →
@Query; động → Specification. - JPQL = entity/property; native = bảng/cột.
- Đọc để hiển thị → projection DTO.
@Modifyingbỏ qua Persistence Context → clear/flush.
Tự kiểm tra
Viết sai tên field trong derived query thì lỗi lúc nào?
Lúc khởi động ứng dụng, khi Spring Data parse tên method.
Trong JPQL, FROM Product là tên gì?
Tên Entity class, không phải tên bảng.
Specification trả null có ý nghĩa gì?
Bỏ qua điều kiện đó khi ghép, tiện cho filter tùy chọn.
Sau @Modifying bulk update, entity đã load có đúng không?
Không, nó stale. Dùng clearAutomatically = true hoặc load lại.
Lợi ích của DTO projection so với trả Entity?
Ít cột hơn, không managed entity, không dirty checking, không lazy proxy.