Spring REST API & Data JPA/JpaRepository & Derived Queries — zero-code query từ tên method
30/46
Bài 30 / 46~12 phútRepository & QueriesMiễn phí lượt xem

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 (findByStatus 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.

Repository interface
Repository
JPQL parse
JPQL sinh tu dong
PostgreSQL
SQL / PostgreSQL

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&lt;T, ID&gt;<br/>marker interface -- khong co method"]
    CrudRepository["CrudRepository&lt;T, ID&gt;<br/>save, findById, findAll<br/>count, existsById, delete, deleteById"]
    PagingAndSortingRepository["PagingAndSortingRepository&lt;T, ID&gt;<br/>+ findAll(Pageable)<br/>+ findAll(Sort)"]
    JpaRepository["JpaRepository&lt;T, ID&gt;<br/>+ flush, saveAndFlush<br/>+ deleteInBatch, getReferenceById<br/>+ findAll(Example)"]

    Repository --> CrudRepository
    CrudRepository --> PagingAndSortingRepository
    PagingAndSortingRepository --> JpaRepository

    style JpaRepository fill:#fef3c7,stroke:#d97706
InterfaceThêm gì
RepositoryMarker — trống, Spring Data detect bằng nó để scan
CrudRepository9 method cốt lõi: save, saveAll, findById, findAll, count, existsById, delete, deleteById, deleteAll
PagingAndSortingRepositoryfindAll(Pageable) trả Page<T>, findAll(Sort)
JpaRepositoryflush, 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"| DB

Hệ 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&lt;Task&gt;"]
    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"| S

Ba bước biến đổi:

  1. Parse tên method (PartTree): tách findBy (verb + subject), Status (property đầu tiên), And (toán tử logic), OwnerId (property thứ hai). Từ đây Spring biết cần WHERE t.status = ? AND t.ownerId = ?.
  2. 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.
  3. Hibernate dịch JPQL sang SQL theo Dialect của database đang dùng (PostgreSQL, MySQL, H2 …). Tên column đến từ Hibernate naming strategy (ownerIdowner_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ĩaReturn type
find…BySELECTOptional<T>, List<T>, Page<T>, Stream<T>
count…BySELECT COUNTlong
exists…BySELECT 1boolean
delete…ByDELETE (cần @Modifying)long, void

Keyword property:

KeywordSQL tương ứng
(không có) / Equals= ?
Not!= ?
LessThan / LessThanEqual< ? / <= ?
GreaterThan / GreaterThanEqual> ? / >= ?
BetweenBETWEEN ? AND ?
In / NotInIN (?) / NOT IN (?)
ContainingLIKE %?%
StartingWithLIKE ?%
EndingWithLIKE %?
IgnoreCaseLOWER(col) = LOWER(?)
IsNull / IsNotNullIS NULL / IS NOT NULL
True / False= true / = false
OrderByORDER 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ệnKhuyến nghị
1-3Derived query — ngắn, tự documenting
4-5Câ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
Derived query không hỗ trợ aggregate (SUM/AVG/MAX)

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 @QuerySpecification — 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 Pageable trên bảng lớn = OOM

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
Optional cho query trả nhiều row = IncorrectResultSizeDataAccessException

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ào EntityManager, 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

  • JpaRepository kế thừa CrudRepositoryPagingAndSortingRepositoryJpaRepository. Dùng JpaRepository mặ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), Optional cho query có thể trả nhiều row.

Tự kiểm tra

Tự kiểm tra
Q1
Khi 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.

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.

Q2
Tạ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".

Q3
Bạ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.

(1) Task chưa có assignee:

List<Task> findByAssigneeIdIsNull();
// SQL: SELECT t.* FROM tasks t WHERE t.assignee_id IS NULL

Dù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 5

Top5 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.

Q4
Method sau gây lỗi gì và vì sao? 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 index

Quy 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.

Q5
Khi nào nên chuyển từ derived query sang @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

Đặt 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