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 RepositoryCrudRepositoryPagingAndSortingRepositoryJpaRepository, mỗi tầng thêm một nhóm method:

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:

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.

Tên method findByStatusAndOwnerId đi xuống ba tầng: PartTree tách token, JPQL viết bằng tên entity và tên field Java, cuối cùng SQL Postgres viết bằng tên bảng tasks và tên cột owner_id — chỉ tầng cuối biết tới tên cột

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.

Ghép hai điều trên lại — proxy sinh lúc startup, query parse lúc startup — sẽ thấy toàn bộ phần đắt nằm gọn ở pha khởi động, còn mỗi lời gọi về sau chỉ còn chi phí bind tham số:

Hai cột đối chiếu hai pha thời gian: cột khởi động quét interface, sinh proxy động, parse tên method thành JPQL rồi compile ra TypedQuery nằm sẵn trong proxy; cột mỗi lời gọi thì proxy uỷ quyền xuống SimpleJpaRepository, bind tham số vào TypedQuery có sẵn và chạy SQL mà không đọc lại tên method

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
0/5 câu đã trả lời
  1. 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.
  2. 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ì?
  3. 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.
  4. Q4
    Method sau gây lỗi gì và vì sao? Optional<Task> findByProjectId(Long projectId). Sửa đúng.
  5. 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.

Bài tiếp theo: @Query, modifying & projection

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

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

Bài tiếp theo

@Query, @Modifying và Projection — viết query tuỳ chỉnh và trả về đúng shape