JpaRepository & Derived Queries — zero-code query từ tên method
Spring Data JPA parse tên method thành JPQL tự động lúc startup. Bài này bóc interface hierarchy CrudRepository→PagingAndSortingRepository→JpaRepository, cơ chế Spring Data parse findByStatusAndOwnerId thành JPQL rồi Hibernate dịch sang SQL, và khi nào derived query đủ — khi nào nên nhường chỗ cho @Query.
TL;DR: JpaRepository là interface đỉnh trong cây phân cấp Spring Data, kế thừa CrudRepository (save/find/delete) và PagingAndSortingRepository (Pageable/Sort). Khi bạn khai báo method findByStatusAndOwnerId, Spring Data parse tên method lúc startup thành một cây token (findBy → Status And OwnerId), sinh JPQL tương ứng, rồi Hibernate dịch JPQL sang SQL theo dialect. Không viết một dòng SQL. Giới hạn: method name dài vượt 4-5 điều kiện trở nên khó đọc — đó là tín hiệu chuyển sang @Query.
Bài EntityManager & JPQL đã giới thiệu cách Spring Data sinh query ở mức tổng quan. Bài này đào sâu một tier: derived query — query dẫn xuất từ tên method: Spring Data parse tên method (findByStatus…) thành JPQL, bạn không viết câu query nào. Zero-code nhưng có cơ chế parse riêng cần hiểu để tránh pitfall.
1. JpaRepository — cây phân cấp interface
Khi bạn viết extends JpaRepository<Task, Long>, bạn đang kế thừa toàn bộ cây interface sau:
flowchart TB
Repository["Repository<T, ID><br/>marker interface -- khong co method"]
CrudRepository["CrudRepository<T, ID><br/>save, findById, findAll<br/>count, existsById, delete, deleteById"]
PagingAndSortingRepository["PagingAndSortingRepository<T, ID><br/>+ findAll(Pageable)<br/>+ findAll(Sort)"]
JpaRepository["JpaRepository<T, ID><br/>+ flush, saveAndFlush<br/>+ deleteInBatch, getReferenceById<br/>+ findAll(Example)"]
Repository --> CrudRepository
CrudRepository --> PagingAndSortingRepository
PagingAndSortingRepository --> JpaRepository
style JpaRepository fill:#fef3c7,stroke:#d97706| Interface | Thêm gì |
|---|---|
Repository | Marker — trống, Spring Data detect bằng nó để scan |
CrudRepository | 9 method cốt lõi: save, saveAll, findById, findAll, count, existsById, delete, deleteById, deleteAll |
PagingAndSortingRepository | findAll(Pageable) trả Page<T>, findAll(Sort) |
JpaRepository | flush, saveAndFlush, deleteInBatch, getReferenceById (reference không load ngay), findAll(Example) |
Trong thực tế, 99% case chỉ cần extends JpaRepository — nó là superset của 3 interface kia. Trường hợp duy nhất nên extends interface thấp hơn là khi muốn giới hạn API (tránh expose deleteAll ra service layer).
Proxy sinh tự động
Khi app khởi động, Spring Data quét tất cả interface extends Repository, rồi sinh proxy động implement chúng thông qua JdkDynamicAopProxy hoặc ProxyFactory. Proxy này uỷ quyền xuống SimpleJpaRepository — implementation mặc định — rồi SimpleJpaRepository gọi EntityManager JPA chuẩn:
flowchart LR
Iface["TaskRepository<br/>interface ban khai bao"]
Proxy["Spring Data Proxy<br/>sinh luc startup<br/>(JdkDynamicAopProxy)"]
Simple["SimpleJpaRepository<br/>implementation mac dinh"]
EM["EntityManager<br/>JPA standard"]
DB["Database"]
Iface -->|"Spring scan extends Repository"| Proxy
Proxy -->|"delegate method call"| Simple
Simple -->|"em.persist / em.find / JPQL"| EM
EM -->|"SQL + JDBC"| DBHệ quả quan trọng: bạn không viết implementation — proxy sinh ra lúc startup. Nếu Spring không thể tạo proxy (ví dụ entity class không có @Entity, hoặc kiểu ID sai), lỗi xuất hiện ngay khi app khởi động, không phải lúc gọi method.
2. Cơ chế bên dưới — parse tên method thành JPQL
Đây là phần cốt lõi của bài. Khi bạn khai báo:
List<Task> findByStatusAndOwnerId(TaskStatus status, Long ownerId);
Spring Data không ghi nhớ tên method như string. Thay vào đó, nó parse tên method thành cây token ngay lúc startup (trong PartTreeJpaQuery), sinh JPQL từ cây đó, và compile JPQL thành TypedQuery của EntityManager. Sau đó, mỗi lần method được gọi, TypedQuery đã compile được tái sử dụng — không parse lại.
flowchart TB
M["findByStatusAndOwnerId<br/>method name"]
T["Phan tich cu phap (PartTree)<br/>Subject: Task<br/>Predicate: status = ? AND ownerId = ?<br/>Return type: List<Task>"]
J["JPQL sinh ra<br/>SELECT t FROM Task t<br/>WHERE t.status = :status<br/>AND t.ownerId = :ownerId"]
S["SQL (Postgres) - Hibernate dich theo dialect<br/>SELECT t.* FROM tasks t<br/>WHERE t.status = ?<br/>AND t.owner_id = ?"]
M -->|"PartTreeJpaQuery.parse()<br/>luc startup"| T
T -->|"QueryUtils.toJpql()"| J
J -->|"Hibernate SessionFactory compile<br/>theo PostgreSQL94Dialect"| SBa bước biến đổi:
- Parse tên method (
PartTree): táchfindBy(verb + subject),Status(property đầu tiên),And(toán tử logic),OwnerId(property thứ hai). Từ đây Spring biết cầnWHERE t.status = ? AND t.ownerId = ?. - Sinh JPQL (
QueryUtils): dựng câu JPQL dùng tên entity và tên field Java — không phải tên bảng hay cột SQL. - Hibernate dịch JPQL sang SQL theo
Dialectcủa database đang dùng (PostgreSQL, MySQL, H2 …). Tên column đến từ Hibernate naming strategy (ownerId→owner_id).
Vì sao thiết kế này đáng giá: khi đổi tên cột PostgreSQL trong migration Flyway, bạn chỉ cần sửa @Column(name = "...") trong entity — JPQL + tên method repository không thay đổi. Database concern nằm đúng trong entity mapping, không rò rỉ ra repository.
2.1 Grammar tên method đầy đủ
[verb] [Distinct]? [Top|First N]? By [property] [keyword] ... [OrderBy ...]
Verb hợp lệ:
| Verb | Ý nghĩa | Return type |
|---|---|---|
find…By | SELECT | Optional<T>, List<T>, Page<T>, Stream<T> |
count…By | SELECT COUNT | long |
exists…By | SELECT 1 | boolean |
delete…By | DELETE (cần @Modifying) | long, void |
Keyword property:
| Keyword | SQL tương ứng |
|---|---|
(không có) / Equals | = ? |
Not | != ? |
LessThan / LessThanEqual | < ? / <= ? |
GreaterThan / GreaterThanEqual | > ? / >= ? |
Between | BETWEEN ? AND ? |
In / NotIn | IN (?) / NOT IN (?) |
Containing | LIKE %?% |
StartingWith | LIKE ?% |
EndingWith | LIKE %? |
IgnoreCase | LOWER(col) = LOWER(?) |
IsNull / IsNotNull | IS NULL / IS NOT NULL |
True / False | = true / = false |
OrderBy | ORDER BY |
Ví dụ thực tế cho Task entity:
public interface TaskRepository extends JpaRepository<Task, Long> {
// Lookup don gian
Optional<Task> findByTitle(String title);
List<Task> findByStatus(TaskStatus status);
long countByStatus(TaskStatus status);
boolean existsByTitleAndProjectId(String title, Long projectId);
// Nhieu dieu kien
List<Task> findByStatusAndProjectId(TaskStatus status, Long projectId);
List<Task> findByDueDateBeforeAndStatusNot(Instant deadline, TaskStatus exclude);
// String matching
List<Task> findByTitleContainingIgnoreCase(String fragment); // LIKE %?% case-insensitive
List<Task> findByTitleStartingWith(String prefix); // LIKE ?%
// Null check
List<Task> findByAssigneeIsNull();
List<Task> findByAssigneeIsNotNull();
// In collection
List<Task> findByStatusIn(Collection<TaskStatus> statuses);
// Sort + limit
List<Task> findTop5ByProjectIdOrderByCreatedAtDesc(Long projectId);
List<Task> findDistinctByStatusOrderByDueDateAsc(TaskStatus status);
// Pageable
Page<Task> findByProjectId(Long projectId, Pageable pageable);
}
2.2 Xác minh SQL sinh ra
Bật log Hibernate để xem SQL thật, đặc biệt khi lần đầu viết derived query:
# application.yaml (dev profile)
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE
Output trong console:
DEBUG o.h.SQL: select t1_0.id, t1_0.title, t1_0.status, t1_0.owner_id
from tasks t1_0
where t1_0.status=? and t1_0.owner_id=?
TRACE o.h.o.j.b: binding parameter [1] as [VARCHAR] - [ACTIVE]
TRACE o.h.o.j.b: binding parameter [2] as [BIGINT] - [42]
3. Khi nào derived query đủ — khi nào chuyển @Query
Derived query tốt cho query đơn giản, ít điều kiện. Ngưỡng thực tế:
| Số điều kiện | Khuyến nghị |
|---|---|
| 1-3 | Derived query — ngắn, tự documenting |
| 4-5 | Cân nhắc — method name bắt đầu dài |
| 6+ | Chuyển sang @Query JPQL |
Ba trường hợp derived query không đủ:
// CASE 1: Method name qua dai — kho doc
List<Task> findByStatusAndProjectIdAndAssigneeIsNotNullAndDueDateBeforeAndPriorityGreaterThan(
TaskStatus status, Long projectId, Instant deadline, int minPriority);
// -> Switch @Query JPQL
// CASE 2: Aggregate function (SUM, AVG, MAX) -- khong support derived
// SAI - khong compile:
// Integer sumPriorityByProjectId(Long projectId);
// -> Phai dung @Query
// CASE 3: JOIN entity lien ket voi dieu kien phuc tap
// -> @Query voi JOIN explicit ro rang hon
Grammar tên method chỉ sinh được SELECT entity / COUNT / EXISTS / DELETE. Method như sumPriorityByProjectId không compile — Spring Data báo No property 'sumPriority' found lúc startup. Mọi aggregate function phải viết bằng @Query("SELECT SUM(t.priority) FROM Task t …").
Khi vượt ngưỡng, chuyển sang @Query và Specification — xem bài tiếp theo: @Query, modifying & projection.
4. Pitfall phổ biến
Pitfall 1 — Tên property sai, lỗi lúc startup:
// SAI -- entity Task co field "ownerId" nhung method dung "userId"
List<Task> findByUserId(Long userId);
// Spring throw: No property 'userId' found for type 'Task'!
// App KHONG khoi dong duoc
// DUNG -- khop ten field trong entity
List<Task> findByOwnerId(Long ownerId);
Lỗi No property X found xuất hiện ngay lúc startup — đây là "fail fast" có chủ đích. Không cần chờ request đầu tiên để phát hiện tên method sai.
Pitfall 2 — findAll() không Pageable cho bảng lớn:
// SAI -- load toan bo table vao RAM
List<Task> all = repo.findAll();
// Table 1M row -> OOM
// DUNG -- phan trang
Page<Task> page = repo.findAll(PageRequest.of(0, 50, Sort.by("createdAt").descending()));
findAll() không giới hạn số row — bảng 1M row nghĩa là 1M entity load vào heap, app chết bằng OutOfMemoryError đúng lúc traffic cao. Quy tắc cho mọi list endpoint: luôn nhận Pageable từ controller xuống repository, hoặc tối thiểu thêm điều kiện findBy... giới hạn tập kết quả.
Pitfall 3 — Nhầm findBy trả Optional vs List:
// Neu co nhieu row khop ma return Optional -> throw IncorrectResultSizeDataAccessException
Optional<Task> findByStatus(TaskStatus status); // nguy hiem neu > 1 row
// DUNG -- neu co the nhieu row
List<Task> findByStatus(TaskStatus status);
// DUNG -- chi khi biet chinh xac 1 row (unique constraint)
Optional<Task> findByTitle(String title); // chi khi title la unique
Return type Optional khiến Spring Data gọi getSingleResult() — JPA yêu cầu đúng 1 row. Query khớp 2 row trở lên throw IncorrectResultSizeDataAccessException lúc runtime, không phải lúc startup, nên test với 1 row vẫn xanh. Chỉ dùng Optional khi có unique constraint đảm bảo tối đa 1 row.
5. Liên hệ các bài khác
- EntityManager & JPQL: nền tảng JPQL + 3 tier query của Spring Data (Tier 1 built-in, Tier 2 derived, Tier 3
@Query) — bài này là deep dive Tier 2; JPQL sinh ra cuối cùng đi vàoEntityManager, hiểu nó giúp debug khi derived query sinh SQL không như kỳ vọng. - @Query, modifying & projection: bước tiếp — khi derived query không đủ, viết JPQL/native tường minh bằng
@Query, và cách dùng projection DTO để chỉ select field cần.
Tóm tắt
JpaRepositorykế thừaCrudRepository→PagingAndSortingRepository→JpaRepository. DùngJpaRepositorymặc định cho mọi repository.- Spring Data sinh proxy tự động lúc startup — không cần viết implementation. Lỗi cấu hình lộ ra ngay khi khởi động (fail fast).
- Derived query hoạt động theo 3 bước: parse tên method (PartTree) → sinh JPQL (QueryUtils) → Hibernate dịch sang SQL theo dialect. Toàn bộ quá trình xảy ra lúc startup, không lặp lại mỗi request.
- Grammar:
[verb][Distinct]?[Top N]? By [property] [keyword] ... [OrderBy ...]. Keyword phổ biến:And,Or,Not,Between,In,Containing,IsNull,OrderBy. - Giới hạn: không hỗ trợ aggregate function, method name dài trên 4-5 điều kiện kém đọc. Khi đó chuyển
@Query. - Pitfall chính: sai tên property (fail lúc startup),
findAll()không Pageable (OOM),Optionalcho query có thể trả nhiều row.
Tự kiểm tra
Q1Khi bạn khai báo List<Task> findByStatusAndOwnerId(TaskStatus status, Long ownerId), Spring Data làm gì lúc startup? Mô tả 3 bước biến đổi từ tên method đến SQL thật.▸
List<Task> findByStatusAndOwnerId(TaskStatus status, Long ownerId), Spring Data làm gì lúc startup? Mô tả 3 bước biến đổi từ tên method đến SQL thật.Bước 1 — Parse tên method (PartTree): Spring Data đọc tên method findByStatusAndOwnerId, tách thành verb (findBy), property đầu (Status), toán tử (And), property thứ hai (OwnerId). Kết quả là một cây PartTree mô tả: "SELECT entity Task WHERE status = ? AND ownerId = ?".
Bước 2 — Sinh JPQL (QueryUtils): từ cây PartTree, Spring Data dựng câu JPQL dùng tên entity Java và tên field Java: SELECT t FROM Task t WHERE t.status = :status AND t.ownerId = :ownerId. JPQL không chứa tên bảng hay tên cột SQL.
Bước 3 — Hibernate dịch sang SQL: EntityManager compile JPQL thành TypedQuery. Hibernate dùng Dialect của database (ví dụ PostgreSQLDialect) để dịch JPQL thành SQL thật, áp dụng naming strategy để chuyển ownerId thành owner_id.
Ba bước này diễn ra một lần lúc startup. Mỗi lần gọi method sau đó chỉ bind parameter và chạy TypedQuery đã compile — không parse lại.
Q2Tại sao Spring Data chọn parse tên method lúc startup thay vì lúc method được gọi lần đầu? Lợi ích thiết kế là gì?▸
Spring Data parse và compile derived query lúc startup để thực hiện chiến lược fail fast: nếu tên method sai (property không tồn tại, keyword không hợp lệ, kiểu return không tương thích), app không khởi động được ngay thay vì âm thầm ném exception khi có request đầu tiên.
Lợi ích cụ thể: lỗi lộ ra tại môi trường CI/CD (khi chạy build + test) hoặc ngay khi developer khởi động app local — không phải lúc 2 giờ sáng khi user gửi request vào production.
Lợi ích thứ hai là performance: parsing tên method và compile JPQL là thao tác tốn CPU. Làm một lần lúc startup và cache lại TypedQuery giúp mỗi request chỉ mất chi phí bind parameter + execute SQL, không parse lại.
Đây là cùng triết lý với ApplicationContext eager-init singleton lúc startup — toàn bộ Spring framework ưu tiên "phát hiện sớm" hơn "lazy discover khi đã muộn".
Q3Bạn có entity Task với field assigneeId. Viết derived query để: (1) tìm task chưa có assignee, (2) tìm 5 task mới nhất của một project, (3) đếm task theo status. Viết đúng tên method và return type.▸
Task với field assigneeId. Viết derived query để: (1) tìm task chưa có assignee, (2) tìm 5 task mới nhất của một project, (3) đếm task theo status. Viết đúng tên method và return type.(1) Task chưa có assignee:
List<Task> findByAssigneeIdIsNull();
// SQL: SELECT t.* FROM tasks t WHERE t.assignee_id IS NULLDùng keyword IsNull — Spring Data ánh xạ thành IS NULL trong SQL.
(2) 5 task mới nhất của một project:
List<Task> findTop5ByProjectIdOrderByCreatedAtDesc(Long projectId);
// SQL: SELECT t.* FROM tasks t
// WHERE t.project_id = ?
// ORDER BY t.created_at DESC
// LIMIT 5Top5 thêm LIMIT 5. OrderByCreatedAtDesc ánh xạ thành ORDER BY created_at DESC.
(3) Đếm task theo status:
long countByStatus(TaskStatus status);
// SQL: SELECT COUNT(*) FROM tasks t WHERE t.status = ?Verb count yêu cầu return type long hoặc Long. Spring Data sinh SELECT COUNT(*), không load entity.
Q4Method sau gây lỗi gì và vì sao? Optional<Task> findByProjectId(Long projectId). Sửa đúng.▸
Optional<Task> findByProjectId(Long projectId). Sửa đúng.Method này không gây lỗi lúc startup — Spring Data compile được vì projectId là property hợp lệ và Optional là return type hợp lệ.
Lỗi xảy ra lúc runtime khi có nhiều hơn 1 Task có cùng projectId (điều rất bình thường vì một project có nhiều task). Hibernate ném IncorrectResultSizeDataAccessException: query did not return a unique result.
Nguyên nhân: Spring Data dùng getSingleResult() của TypedQuery khi return type là Optional<T> — JPA spec yêu cầu query phải trả đúng 1 row, không hơn.
Sửa đúng theo mục đích thực tế:
// Neu can nhieu task (binh thuong):
List<Task> findByProjectId(Long projectId);
// Neu can phan trang:
Page<Task> findByProjectId(Long projectId, Pageable pageable);
// Optional chi dung khi co unique constraint dam bao 1 row:
// Vd: Optional<Task> findByExternalId(String externalId);
// -- khi externalId la unique indexQuy tắc: chỉ dùng Optional<T> khi có unique constraint đảm bảo tối đa 1 row khớp điều kiện.
Q5Khi nào nên chuyển từ derived query sang @Query? Cho 3 ví dụ cụ thể với tên method thật.▸
@Query? Cho 3 ví dụ cụ thể với tên method thật.Trường hợp 1 — Method name quá dài (vượt 4-5 điều kiện):
// SAI -- kho doc, de sai
List<Task> findByStatusAndProjectIdAndAssigneeIsNotNullAndDueDateBeforeAndPriorityGreaterThanEqual(
TaskStatus status, Long projectId, Instant deadline, int minPriority);
// DUNG -- @Query JPQL
@Query("""
SELECT t FROM Task t
WHERE t.status = :status
AND t.projectId = :projectId
AND t.assignee IS NOT NULL
AND t.dueDate < :deadline
AND t.priority >= :minPriority
""")
List<Task> findUrgentAssigned(...);Trường hợp 2 — Aggregate function (SUM, AVG, MAX):
// Derived KHONG support -- khong compile
// Integer sumPriorityByProjectId(Long projectId);
// DUNG:
@Query("SELECT SUM(t.priority) FROM Task t WHERE t.projectId = :projectId")
Long sumPriorityByProject(@Param("projectId") Long projectId);Trường hợp 3 — JOIN với điều kiện trên entity liên kết:
// Derived khong the express dieu kien tren entity join
// Tim task cua project co status = ACTIVE:
@Query("""
SELECT t FROM Task t
JOIN t.project p
WHERE p.status = :projectStatus
AND t.status = :taskStatus
""")
List<Task> findByProjectStatusAndTaskStatus(
@Param("projectStatus") ProjectStatus projectStatus,
@Param("taskStatus") TaskStatus taskStatus);Nguyên tắc chọn: nếu đặt tên method mà cảm thấy khó đọc hoặc phải giải thích, đó là tín hiệu chuyển @Query. Mục tiêu là code tự documenting — cả derived lẫn @Query đều phục vụ điều đó, chỉ khác ngưỡng phức tạp.
Bài tiếp theo: @Query, modifying & projection
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên