Specification & JPA Auditing — dynamic filter và timestamp tự động
Specification bọc JPA Criteria API để build dynamic query không cần viết N method. JPA Auditing tự set createdAt/updatedAt qua AuditingEntityListener. Bài này đào sâu cơ chế bên dưới cả hai: Predicate tree, PredicateVisitor, EntityCallback lifecycle.
TL;DR: Specification<T> là wrapper quanh JPA Criteria API — mỗi Specification là một hàm (root, query, cb) -> Predicate có thể and/or runtime, sinh WHERE clause dynamic mà không cần viết N method cho N tổ hợp filter. JPA Auditing tự set createdAt/updatedAt khi persist/update qua AuditingEntityListener đăng ký qua @EntityListeners — Spring nhét vào JPA lifecycle callback @PrePersist/@PreUpdate, không cần tay set timestamp trong service. Hai tính năng tách biệt nhưng hay dùng cùng nhau cho CRUD production: Specification để đọc (filter list), Auditing để ghi (metadata tự động).
1. Vấn đề: filter động không viết được bằng derived query
Giả sử màn hình admin có 5 filter tuỳ chọn: status, createdAfter, createdBefore, minPriority, nameContains. Mỗi field có thể null — user chỉ chọn một số. Với derived query, số method cần tăng theo lũy thừa:
5 field optional → tối đa 2^5 = 32 method
Viết 32 method riêng là bất khả thi. Một method nhận hết 5 param nhưng không skip null cũng không work — derived query sinh SQL cố định, không bỏ qua condition khi param null.
@Query JPQL cũng không giải quyết được vì chuỗi JPQL là string tĩnh, không thể bỏ bớt mệnh đề WHERE runtime.
Giải pháp: Specification — build Predicate tree tại runtime, gắn chỉ những condition không null.
2. Specification là gì về mặt type
Specification<T> là functional interface trong Spring Data JPA:
// File: org/springframework/data/jpa/domain/Specification.java (rut gon)
@FunctionalInterface
public interface Specification<T> {
@Nullable
Predicate toPredicate(Root<T> root,
CriteriaQuery<?> query,
CriteriaBuilder criteriaBuilder);
default Specification<T> and(Specification<T> other) { ... }
default Specification<T> or(Specification<T> other) { ... }
static <T> Specification<T> where(@Nullable Specification<T> spec) { ... }
static <T> Specification<T> not(Specification<T> spec) { ... }
}
Ba tham số trong toPredicate:
Root<T> root— entry point truy cập field entity:root.get("status"),root.get("createdAt").CriteriaQuery<?> query— đại diện toàn bộ query đang build; dùng khi cầnSELECT DISTINCThoặc subquery.CriteriaBuilder cb— factory tạo Predicate:cb.equal(...),cb.like(...),cb.greaterThan(...).
Mỗi Specification trả về một Predicate — đại diện một điều kiện WHERE. Các Specification combine bằng and/or tạo cây Predicate, Spring Data truyền vào Hibernate để dịch sang SQL.
flowchart TB F["ProjectFilter<br/>status=ACTIVE<br/>from=2024-01-01<br/>name=null"] S1["Specification: status"] S2["Specification: createdAfter"] AND["Specification.and()"] PRED["Predicate tree<br/>status=ACTIVE AND created_at > ?"] SQL["SQL WHERE<br/>status = 'ACTIVE'<br/>AND created_at > '2024-01-01'"] F -->|"status != null"| S1 F -->|"from != null"| S2 F -->|"name == null, skip"| AND S1 -->|"and()"| AND S2 -->|"and()"| AND AND --> PRED PRED -->|"Hibernate translate"| SQL
3. Cơ chế bên dưới: Predicate tree và SQL generation
Khi bạn gọi spec.and(otherSpec), Spring Data tạo một ComposedSpecification bọc cả hai:
// Simplified internal (Specification.java)
default Specification<T> and(Specification<T> other) {
return (root, query, cb) -> {
Predicate left = this.toPredicate(root, query, cb);
Predicate right = other.toPredicate(root, query, cb);
if (left == null) return right;
if (right == null) return left;
return cb.and(left, right); // JPA CriteriaBuilder.and()
};
}
Cuối cùng repo.findAll(spec, pageable) gọi xuống SimpleJpaRepository:
// SimpleJpaRepository.java (rut gon)
public Page<T> findAll(Specification<T> spec, Pageable pageable) {
TypedQuery<T> query = getQuery(spec, pageable.getSort());
return readPage(query, getDomainClass(), pageable, spec);
}
protected TypedQuery<T> getQuery(Specification<T> spec, Sort sort) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<T> cq = cb.createQuery(domainClass);
Root<T> root = cq.from(domainClass);
Predicate predicate = spec.toPredicate(root, cq, cb); // <-- goi Specification
if (predicate != null) {
cq.where(predicate); // <-- nhet vao WHERE
}
// apply sort...
return em.createQuery(cq);
}
Không có string SQL ở đây. Hibernate nhận CriteriaQuery object với cây Predicate và tự sinh SQL theo dialect (Postgres, MySQL...). Đó là lý do Specification type-safe và dialect-independent.
4. Setup và dùng Specification thực tế
Bước 1: Repository phải extend thêm JpaSpecificationExecutor<T>:
public interface ProjectRepository
extends JpaRepository<Project, Long>,
JpaSpecificationExecutor<Project> { // them interface nay
}
JpaSpecificationExecutor cung cấp findAll(Specification, Pageable), count(Specification), findOne(Specification).
Bước 2: Tạo class chứa Specification factory method (tái sử dụng tốt hơn inline lambda):
public class ProjectSpecs {
public static Specification<Project> hasStatus(ProjectStatus status) {
return status == null ? null
: (root, q, cb) -> cb.equal(root.get("status"), status);
}
public static Specification<Project> createdAfter(Instant from) {
return from == null ? null
: (root, q, cb) -> cb.greaterThan(root.get("createdAt"), from);
}
public static Specification<Project> createdBefore(Instant to) {
return to == null ? null
: (root, q, cb) -> cb.lessThan(root.get("createdAt"), to);
}
public static Specification<Project> nameContains(String fragment) {
return fragment == null ? null
: (root, q, cb) ->
cb.like(cb.lower(root.get("name")),
"%" + fragment.toLowerCase() + "%");
}
}
Trả null khi param null — Specification.where(null).and(null) bỏ qua condition đó nhờ logic trong and() ở phần 3.
Bước 3: Service combine và query:
@Service
@RequiredArgsConstructor
public class ProjectService {
private final ProjectRepository repo;
public Page<Project> search(ProjectFilter filter, Pageable pageable) {
Specification<Project> spec =
Specification.where(ProjectSpecs.hasStatus(filter.status()))
.and(ProjectSpecs.createdAfter(filter.createdAfter()))
.and(ProjectSpecs.createdBefore(filter.createdBefore()))
.and(ProjectSpecs.nameContains(filter.nameContains()));
return repo.findAll(spec, pageable);
}
}
SQL sinh ra phụ thuộc filter nào khác null:
-- filter: status=ACTIVE, nameContains="Mobile"
SELECT p.* FROM projects p
WHERE p.status = 'ACTIVE'
AND LOWER(p.name) LIKE '%mobile%'
ORDER BY p.created_at DESC
LIMIT 20 OFFSET 0;
So sánh với bài Derived query & @Query: derived query sinh SQL cố định lúc startup; Specification sinh SQL tại runtime theo input — đây là điểm khác biệt cốt lõi.
5. JPA Auditing — vấn đề và giải pháp
Mọi entity production cần ít nhất hai trường timestamp: createdAt (lúc insert, không đổi) và updatedAt (lúc update gần nhất). Không có auditing tự động, service phải set tay:
// KHONG nen — service polluted
public Project create(Project p) {
p.setCreatedAt(Instant.now()); // de quen
p.setUpdatedAt(Instant.now());
return repo.save(p);
}
public Project update(Long id, ProjectUpdate req) {
Project p = repo.findById(id).orElseThrow();
p.setName(req.name());
p.setUpdatedAt(Instant.now()); // de quen
return repo.save(p);
}
Set tay có hai lỗi phổ biến: quên set, hoặc set sai field (set createdAt lúc update). JPA Auditing loại bỏ hoàn toàn code này.
6. Cơ chế bên dưới: AuditingEntityListener và JPA lifecycle callback
@EnableJpaAuditing kích hoạt AuditingBeanFactoryPostProcessor đăng ký một AuditingEntityListener vào Hibernate. Listener này hook vào JPA entity lifecycle callback:
flowchart TB SAVE["repo.save(entity)"] PP["@PrePersist callback<br/>AuditingEntityListener.touchForCreate()"] PU["@PreUpdate callback<br/>AuditingEntityListener.touchForUpdate()"] SET1["set createdAt = Instant.now()<br/>set createdBy = auditor"] SET2["set updatedAt = Instant.now()<br/>set updatedBy = auditor"] SQL_I["INSERT INTO projects<br/>(..., created_at, updated_at, ...)"] SQL_U["UPDATE projects<br/>SET updated_at = ...<br/>WHERE id = ?"] SAVE -->|"first persist"| PP --> SET1 --> SQL_I SAVE -->|"subsequent save"| PU --> SET2 --> SQL_U
JPA lifecycle callback (@PrePersist, @PreUpdate) là điểm hook chuẩn của Jakarta Persistence spec — không phải magic Spring. AuditingEntityListener implement callback này và set field được đánh dấu @CreatedDate/@LastModifiedDate qua reflection.
// Simplified AuditingEntityListener (Spring Data Commons)
public class AuditingEntityListener {
@PrePersist
public void touchForCreate(Object target) {
AuditableBeanWrapper<?> wrapper = factory.getBeanWrapperFor(target);
wrapper.setCreatedDate(dateTimeForNow()); // @CreatedDate field
wrapper.setCreatedBy(auditor.getCurrentAuditor()); // @CreatedBy field
wrapper.setLastModifiedDate(dateTimeForNow()); // @LastModifiedDate
wrapper.setLastModifiedBy(auditor.getCurrentAuditor()); // @LastModifiedBy
}
@PreUpdate
public void touchForUpdate(Object target) {
AuditableBeanWrapper<?> wrapper = factory.getBeanWrapperFor(target);
wrapper.setLastModifiedDate(dateTimeForNow()); // @LastModifiedDate
wrapper.setLastModifiedBy(auditor.getCurrentAuditor()); // @LastModifiedBy
// createdDate va createdBy KHONG set lai
}
}
updatable = false trên @Column(createdAt) là tầng bảo vệ thứ hai — ngay cả khi code nhầm gọi setter, Hibernate không sinh UPDATE ... SET created_at = ?.
7. Setup JPA Auditing
Bước 1: Bật auditing trong config:
@Configuration
@EnableJpaAuditing
public class JpaConfig {
// Neu chi dung @CreatedDate / @LastModifiedDate (timestamp),
// khong can AuditorAware
}
Bước 2: Base entity dùng chung (@MappedSuperclass — không tạo table riêng):
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AuditableEntity {
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
}
Bước 3: Entity extend base:
@Entity
@Table(name = "projects")
public class Project extends AuditableEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Enumerated(EnumType.STRING)
private ProjectStatus status;
// createdAt, updatedAt inherit tu AuditableEntity
}
Service không cần set timestamp nữa:
@Service
@Transactional
@RequiredArgsConstructor
public class ProjectService {
private final ProjectRepository repo;
public Project create(String name) {
Project p = new Project();
p.setName(name);
p.setStatus(ProjectStatus.ACTIVE);
return repo.save(p);
// AuditingEntityListener tu set createdAt, updatedAt truoc INSERT
}
}
Thêm @CreatedBy / @LastModifiedBy (user nào tạo/sửa) cần AuditorAware:
@Component
public class SecurityAuditorAware implements AuditorAware<String> {
@Override
public Optional<String> getCurrentAuditor() {
return Optional.ofNullable(
SecurityContextHolder.getContext().getAuthentication())
.filter(Authentication::isAuthenticated)
.map(Authentication::getName)
.or(() -> Optional.of("system")); // fallback cho background job
}
}
// Tham chieu auditor bean:
@EnableJpaAuditing(auditorAwareRef = "securityAuditorAware")
Và thêm field vào AuditableEntity:
@CreatedBy
@Column(name = "created_by", updatable = false, length = 50)
private String createdBy;
@LastModifiedBy
@Column(name = "updated_by", length = 50)
private String updatedBy;
8. Pitfall
Pitfall 1 — @EnableJpaAuditing thiếu hoặc @EntityListeners thiếu:
// SAI: Entity extend AuditableEntity nhung base class thieu @EntityListeners
@MappedSuperclass // thieu @EntityListeners(...)
public abstract class AuditableEntity { ... }
Triệu chứng: createdAt null sau insert mặc dù field không null trong DB schema — NOT NULL constraint fail hoặc lưu null. Fix: thêm @EntityListeners(AuditingEntityListener.class) vào base class.
Pitfall 2 — Specification LIKE không dùng index:
// Van dung index cho prefix search:
cb.like(root.get("name"), fragment + "%") // LIKE 'Mobile%' → index range scan
// KHONG dung index:
cb.like(cb.lower(root.get("name")), "%" + fragment.toLowerCase() + "%")
// LIKE '%mobile%' → full table scan
LIKE '%...' (leading wildcard) không dùng B-tree index — Postgres phải scan toàn bảng. Với text search thật sự, dùng tsvector/tsquery hoặc Elasticsearch.
Pitfall 3 — Specification cho JpaRepository không extend JpaSpecificationExecutor:
// SAI:
public interface ProjectRepository extends JpaRepository<Project, Long> { }
// Service:
repo.findAll(spec, pageable); // compile error: no matching method
JpaRepository không có findAll(Specification, Pageable) — phải extend thêm JpaSpecificationExecutor<Project>.
Pitfall 4 — Set createdAt thủ công sau khi bật Auditing:
// SAI: conflict voi AuditingEntityListener
public Project create(Project p) {
p.setCreatedAt(Instant.now()); // Listener se ghi de, nhung code gay confusion
return repo.save(p);
}
Chỉ cần không set — Listener set trước INSERT. Giữ field private, không expose setter setCreatedAt.
9. Liên hệ các bài khác
- Bài 02 — @Query & projection: derived query và
@Querysinh SQL tĩnh lúc startup — Specification là tier tiếp theo khi query cần dynamic runtime. Hiểu sự tương phản (tĩnh vs động) để chọn đúng tier. - Bài 04 — Pageable & Sort:
repo.findAll(spec, pageable)kết hợp cả hai — Specification cung cấp WHERE, Pageable cung cấp LIMIT/OFFSET/ORDER BY. Hai bài này hay dùng cùng nhau trong search endpoint. - Column, enum & embeddable: nền mapping
@Column/@Embeddablemà Auditing base entity (@MappedSuperclass) tái dùng —@MappedSuperclasskhông tạo table riêng, chỉ copy field mapping xuống entity con. - @Transactional & AOP proxy: Auditing listener chạy trong cùng transaction với
repo.save(). Khi transaction rollback, timestamp không persist — hành vi đúng vì entity không được lưu.
Tóm tắt
Specification<T>là functional interface(root, query, cb) -> Predicate— build WHERE clause tại runtime, không phải lúc startup.and()/or()tạoComposedSpecification— Hibernate nhận cây Predicate, dịch sang SQL dialect-specific.- Repository phải extend
JpaSpecificationExecutor<T>để dùngfindAll(Specification, Pageable). - Tách Specification thành factory method tĩnh (class
XxxSpecs) — tái sử dụng giữa list, count, export endpoint. - JPA Auditing hook vào lifecycle callback
@PrePersist/@PreUpdatequaAuditingEntityListener— không cần set timestamp trong service. @EnableJpaAuditing+@EntityListeners(AuditingEntityListener.class)là hai điểm bắt buộc — thiếu một trong hai thì field null.@CreatedDate/@LastModifiedDatechỉ cần timestamp.@CreatedBy/@LastModifiedBycần thêmAuditorAwarebean.updatable = falsetrêncreatedAtbảo vệ tầng DB — Hibernate không sinh UPDATE cho field này.
Tự kiểm tra
Q1Tại sao derived query không giải quyết được bài toán dynamic filter với 5 optional field? Specification giải quyết bằng cơ chế gì?▸
Derived query sinh JPQL tĩnh lúc startup — mọi condition trong method name đều xuất hiện trong SQL, không bỏ qua khi param null. Với 5 field optional, số method cần thiết tăng theo lũy thừa hai (tối đa 32 method), vừa bất khả thi vừa không maintainable.
Specification giải quyết bằng cách build Predicate tree tại runtime: mỗi Specification là một lambda (root, query, cb) -> Predicate trả null khi param null. Method and() nội bộ bỏ qua operand null — chỉ append condition khi có giá trị thật.
Hibernate nhận CriteriaQuery object (không phải string SQL) với cây Predicate đã build, dịch sang SQL theo dialect. Kết quả: WHERE clause chỉ chứa các condition user thực sự truyền vào.
Quy tắc chọn: filter cố định 1-2 field dùng derived query hoặc @Query; từ 3 optional field trở lên dùng Specification.
Q2Repository khai báo extends JpaRepository nhưng không extend thêm gì. Gọi repo.findAll(spec, pageable) có compile không? Cần sửa gì?▸
extends JpaRepository nhưng không extend thêm gì. Gọi repo.findAll(spec, pageable) có compile không? Cần sửa gì?Không compile. JpaRepository không khai báo method findAll(Specification, Pageable) — method này thuộc JpaSpecificationExecutor, một interface riêng biệt.
Sửa bằng cách extend thêm interface:
public interface ProjectRepository
extends JpaRepository<Project, Long>,
JpaSpecificationExecutor<Project> { }JpaSpecificationExecutor cung cấp: findAll(Specification), findAll(Specification, Pageable), findAll(Specification, Sort), count(Specification), findOne(Specification), exists(Specification). Không cần viết implementation — Spring Data sinh proxy như với JpaRepository.
Q3Giải thích lý do updatable = false trên field createdAt là cần thiết ngay cả khi AuditingEntityListener không set lại field này lúc update.▸
updatable = false trên field createdAt là cần thiết ngay cả khi AuditingEntityListener không set lại field này lúc update.AuditingEntityListener.touchForUpdate() chỉ set updatedAt/updatedBy, không động tới createdAt/createdBy. Nếu chỉ dựa vào Listener, trường hợp nguy hiểm là: code trong service vô tình gọi entity.setCreatedAt(someValue) (nhầm lẫn, refactor sai) — Hibernate sẽ sinh UPDATE ... SET created_at = ? và ghi đè giá trị gốc.
updatable = false là bảo vệ tại tầng Hibernate column mapping — bất kể entity có dirty field createdAt hay không, Hibernate không sinh câu UPDATE cho column đó. Đây là defense-in-depth: Listener không set, và ngay cả khi setter bị gọi, DB không bị ghi đè.
Tương tự, nullable = false trên created_at đảm bảo DB schema enforce NOT NULL — ngay cả khi Listener bị misconfigure và không set field, INSERT sẽ fail rõ ràng thay vì lưu null silently.
Q4App bật @EnableJpaAuditing, entity extend AuditableEntity, nhưng sau repo.save(newProject) field createdAt vẫn null. Liệt kê 3 nguyên nhân có thể và cách kiểm tra từng cái.▸
@EnableJpaAuditing, entity extend AuditableEntity, nhưng sau repo.save(newProject) field createdAt vẫn null. Liệt kê 3 nguyên nhân có thể và cách kiểm tra từng cái.Nguyên nhân 1 — Base class thiếu @EntityListeners:
AuditingEntityListener phải được đăng ký qua @EntityListeners(AuditingEntityListener.class) trên base class hoặc chính entity. Thiếu annotation này, không có callback nào được gọi. Kiểm tra: grep @EntityListeners trong AuditableEntity.java.
Nguyên nhân 2 — @EnableJpaAuditing trên class không được load:
Annotation đặt trên @Configuration class nhưng class đó không được Spring scan (sai package, hoặc class bị @Conditional disable). Kiểm tra: thêm breakpoint trong JpaConfig constructor, hoặc actuator /beans endpoint tìm jpaAuditingHandler bean.
Nguyên nhân 3 — Field type không được Auditing support:
Spring Data Auditing support Instant, LocalDateTime, ZonedDateTime, Date, Long (epoch). Dùng type ngoài danh sách (vd OffsetDateTime trước Spring Data 3.x) có thể không set được. Kiểm tra Spring Data version + supported types trong reference doc.
Q5Service cần filter project theo status, ownerId, và date range — tất cả đều optional. Viết ProjectSpecs factory class và service method kết hợp Specification với Pageable.▸
status, ownerId, và date range — tất cả đều optional. Viết ProjectSpecs factory class và service method kết hợp Specification với Pageable.Factory class Specification:
public class ProjectSpecs {
public static Specification<Project> hasStatus(ProjectStatus status) {
return status == null ? null
: (root, q, cb) -> cb.equal(root.get("status"), status);
}
public static Specification<Project> hasOwner(Long ownerId) {
return ownerId == null ? null
: (root, q, cb) -> cb.equal(root.get("ownerId"), ownerId);
}
public static Specification<Project> createdBetween(Instant from, Instant to) {
if (from == null && to == null) return null;
return (root, q, cb) -> {
if (from != null && to != null)
return cb.between(root.get("createdAt"), from, to);
if (from != null)
return cb.greaterThanOrEqualTo(root.get("createdAt"), from);
return cb.lessThanOrEqualTo(root.get("createdAt"), to);
};
}
}Repository:
public interface ProjectRepository
extends JpaRepository<Project, Long>,
JpaSpecificationExecutor<Project> { }Service method:
@Service
@RequiredArgsConstructor
public class ProjectService {
private final ProjectRepository repo;
public Page<Project> search(ProjectFilter filter, Pageable pageable) {
Specification<Project> spec =
Specification.where(ProjectSpecs.hasStatus(filter.status()))
.and(ProjectSpecs.hasOwner(filter.ownerId()))
.and(ProjectSpecs.createdBetween(filter.from(), filter.to()));
return repo.findAll(spec, pageable);
}
}Nếu tất cả filter null, spec là where(null).and(null).and(null) — Hibernate sinh SELECT ... WHERE 1=1 (hoặc bỏ WHERE hoàn toàn tùy version). Kết hợp với Pageable ở bài 04: findAll(spec, pageable) sinh cả WHERE lẫn LIMIT/OFFSET trong một query.
Bài tiếp theo: Pageable, Sort & generated SQL
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