Chuyển tới nội dung chính

Truy vấn JPA nâng cao

Bộ mở rộng Spring Data JPA: truy vấn động bằng Specification, base repository/service, annotation điều kiện trên DTO, JOIN/SELECT động, ánh xạ kết quả query về VO và JPA auditing tự động. Dùng khi service thao tác dữ liệu quan hệ qua JPA mà không muốn viết tay Criteria/Specification cho từng form tìm kiếm.

Khi nào sử dụng

  • Dựng truy vấn động có nhiều điều kiện tùy chọn (form tìm kiếm) mà không phải viết tay Criteria/Specification.
  • Dùng base repository với thao tác batch, upsert chỉ cập nhật trường non-null và truy vấn theo Specification.
  • Cần JPQL/native query trả thẳng về VO/DTO mà không cần interface projection hay constructor expression.
  • Muốn tự động điền createdBy, createdAt, updatedBy, updatedAt cho entity.
  • Cần cách ly điều kiện tìm kiếm trong DTO bằng annotation thay vì viết logic if/else.
  • Cần JOIN động không phụ thuộc quan hệ @ManyToOne/@OneToMany, chọn cột động, subquery EXISTS hoặc so sánh cột-với-cột.

Cài đặt

<dependency>
<groupId>vn.govex.cloud</groupId>
<artifactId>govex-cloud-data-jpa</artifactId>
</dependency>

Version quản lý qua BOM/parent — xem Cài đặt. Khi dùng govex-cloud-parent, khai báo <relativePath/> để Maven luôn resolve parent từ Maven repository thay vì tìm trong thư mục cục bộ.

Module kéo sẵn các dependency cần thiết, ứng dụng không phải khai báo thêm:

DependencyVai trò
govex-cloud-data-commonPath, Paths, SFunc, TypedField, @Limit — bộ mô tả trường và biểu thức dùng chung
govex-cloud-coreTiện ích nền tảng (String/Collection/Json/lambda...)
spring-boot-starter-data-jpaSpring Data JPA + Hibernate
jsqlparser, guavaParser SQL cho cơ chế viết lại câu lệnh native

Yêu cầu JDK 17 trở lên (khuyến nghị JDK 21) và Spring Boot 3.x theo BOM của workspace.

Cấu hình

PropertyMô tảMặc định
govex.jpa.auditing.enabledBật JPA Auditing để tự động điền thông tin audit cho entity; chỉ tắt khi đặt falsetrue

Tắt JPA auditing:

govex:
jpa:
auditing:
enabled: false

Dependency ghi đè Hibernate property hibernate.session_factory.statement_inspector để viết lại câu lệnh native phục vụ SQLExpression. Khi ứng dụng đã tự cấu hình statement inspector, khai báo bean tên govexJpaHibernatePropertiesCustomizer để tự quyết định property này.

Auto-configuration JpaAutoConfiguration đăng ký sẵn các thành phần:

  • AuditorAware lấy người thao tác từ SecurityContext (chỉ khi có govex-cloud-security trên classpath).
  • RewriteStatementInspector viết lại câu lệnh SQL theo SQLExpression.
  • MapToProjectionConverter chuyển Map<String, Object> sang class implements Projection.
  • TableMetadata quét metadata entity/column khi EntityManagerFactory khởi tạo.
  • QuerySingleResultAspect xử lý kết quả đơn cho @QuerySingleResult.
  • Cấu hình @EnableJpaAuditing khi govex.jpa.auditing.enabled khác false.

Bật repository mở rộng

Dùng @EnableExtendJpaRepositories thay cho @EnableJpaRepositories để repository dùng base class và query lookup của Govex:

@Configuration
@EnableExtendJpaRepositories(basePackages = "vn.govex.cloud.demo")
public class JpaConfig {
}

Annotation này là alias của @EnableJpaRepositories với các default:

  • repositoryFactoryBeanClass = ExtendJpaRepositoryFactoryBean
  • repositoryBaseClass = SimpleBaseJpaRepository

Nhờ đó mọi repository được tạo sẽ hỗ trợ base repository mở rộng, projection converter và các tiện ích truy vấn của module.

Base repository

public interface OrderRepository extends BaseRepository<Order, String> {
}

BaseRepository mở rộng JpaRepository + JpaSpecificationExecutor và bổ sung:

MethodMô tả
getOne(spec) / getOne(spec, sort)Trả về entity đầu tiên thỏa điều kiện; ném NoResultException nếu không có kết quả
findOne(spec) / findOne(spec, sort)Như trên nhưng trả Optional
findAll(spec) / findAll(spec, sort)Danh sách entity thỏa điều kiện
findAll(spec, pageable)Trang dữ liệu (Page) theo điều kiện, phân trang và sắp xếp
findAll(spec, limit) / findAll(spec, sort, limit)Giới hạn số bản ghi bằng Limit của Spring Data
saveBatch(entities) / saveBatch(entities, batchSize)Thêm mới theo lô (mặc định 1000), flush từng lô; chỉ dùng cho insert
updateBatch(entities) / updateBatch(entities, batchSize)Cập nhật theo lô bằng merge; chỉ dùng cho update
saveOrUpdateByNotNullProperties(entity)Upsert một entity: kiểm tra tồn tại rồi chỉ ghi các trường non-null
saveOrUpdateAllByNotNullProperties(entities)Upsert theo lô, kiểm tra tồn tại từng bản ghi
deleteByIds(ids)Xóa từng ID (loop deleteById), hiệu năng thấp
deleteBatchByIds(ids) / deleteBatchByIds(ids, batchSize)Xóa theo lô bằng điều kiện in, mặc định 1000

Các overload nhận SQLExpression (truy vấn native động) được trình bày trong Native query & SQLExpression.

Ví dụ thao tác batch/upsert:

orderRepository.saveBatch(orders); // insert theo lô 1000
orderRepository.updateBatch(orders); // update theo lô 1000
orderRepository.saveOrUpdateAllByNotNullProperties(orders); // upsert, chỉ ghi field non-null
orderRepository.deleteBatchByIds(List.of("1", "2")); // xóa theo lô bằng IN

Base service

IService<T, ID> cung cấp sẵn các thao tác CRUD/batch và ủy quyền xuống BaseRepository: save, saveAll, saveBatch, deleteById, delete(Specification), findById, findAll, findAll(spec), findAll(pageable), findAll(spec, pageable), count, existsById, getOne(spec)...

ServiceImpl<R, T, ID> là lớp triển khai abstract, tự inject @Autowired R repository (kiểu BaseRepository) và tự resolve entityClass từ generic type:

public interface OrderRepository extends BaseRepository<Order, String> {
}

@Service
@RequiredArgsConstructor
public class OrderService extends ServiceImpl<OrderRepository, Order, String> {

private final OrderMapper mapper;

public void importOrders(List<Order> orders) {
saveBatch(orders);
}

public Page<OrderVO> search(OrderSearchDTO request, Pageable pageable) {
TypedJPASpecification<Order> spec = Specifications.ofBeanTyped(request)
.equal(Order::getDeleted, false);

return repository.findAll(spec, pageable)
.map(mapper::toVO);
}
}

Entity base class & converter

Module cung cấp sẵn các lớp cơ sở cho entity:

ClassMô tả
IdEntityKhóa chính id kiểu String sinh tự động bằng UUID
BaseEntityIdEntity + thông tin audit (createdBy, createdAt, updatedBy, updatedAt)
AbstractEntityChỉ implements DataEntity, không có field nào
AbstractAuditEntityCó thông tin audit nhưng không có khóa chính

Các AttributeConverter dùng chung:

  • SoftDeleteConverter: BooleanInteger (1/0) cho cờ xóa mềm.
  • JsonSetConverter: Set<String> ↔ chuỗi JSON (không phụ thuộc kiểu cột JSON của từng DB).
  • Ngoài ra còn JsonListConverter, JsonMapConverter, JsonObjectConverter, StringListConverter, StringSetConverter.

Truy vấn động với Specification

Khởi tạo

import vn.govex.cloud.data.jpa.specification.Specifications;

// Điều kiện theo tên cột dạng chuỗi
JPASpecification<Order> spec = Specifications.where();

// Điều kiện theo method reference (type-safe)
TypedJPASpecification<Order> typedSpec = Specifications.whereTyped();
TypedJPASpecification<Order> typedSpecWithClass = Specifications.whereTyped(Order.class);

// Dựng từ DTO có annotation điều kiện
JPASpecification<Order> beanSpec = Specifications.ofBean(searchDTO);
TypedJPASpecification<Order> typedBeanSpec = Specifications.ofBeanTyped(searchDTO);
APITrả vềDùng khi
Specifications.where()JPASpecification<T>Tham chiếu trường bằng tên cột dạng chuỗi ("status", "customer.name")
Specifications.whereTyped()TypedJPASpecification<T>Bắt buộc cho method reference (Order::getStatus)
Specifications.whereTyped(Class<T>)TypedJPASpecification<T>Như trên, kèm kiểu entity đích
Specifications.ofBean(bean)JPASpecification<T>Dựng từ DTO có annotation điều kiện
Specifications.ofBean(bean, paths)JPASpecification<T>Như trên, override alias/entityAlias/ignore bằng Paths
Specifications.ofBeanTyped(bean)TypedJPASpecification<T>Dựng từ DTO annotation theo hướng type-safe
Specifications.ofBeanTyped(bean, paths)TypedJPASpecification<T>Kết hợp DTO annotation với cấu hình Paths

Chuyển đổi giữa hai dạng:

TypedJPASpecification<Order> typedSpec = Specifications.where()
.equal("status", "ACTIVE")
.like("customer.name", keyword)
.typed();

Tự động bỏ qua giá trị rỗng

Khi không truyền tham số match, phương thức tự kiểm tra giá trị và chỉ thêm điều kiện khi giá trị khác null và không rỗng:

spec.equal("username", username); // chỉ thêm khi username có giá trị
spec.like("email", email); // chỉ thêm khi email có giá trị
spec.in("status", statuses); // chỉ thêm khi statuses khác null và không rỗng

Tương đương cách viết tường minh:

spec.equal(username != null && !username.isEmpty(), "username", username);
spec.like(email != null && !email.isEmpty(), "email", email);
spec.in(statuses != null && !statuses.isEmpty(), "status", statuses);

Với logic phức tạp, dùng phiên bản có tham số match để kiểm soát chính xác điều kiện.

Các toán tử

Mỗi toán tử đều có phiên bản or* tương ứng, dùng để ghép điều kiện bằng OR:

NhómMethod
Bằng/khácequal, notEqual, orEqual, orNotEqual (có ignoreCase cho chuỗi)
Chuỗilike, notLike, orLike, orNotLike, startsWith, endsWith, orStartsWith, orEndsWith
So sánhgreatThan, greatThanOrEqualTo, lessThan, lessThanOrEqualTo và biến thể or*
Khoảngbetween, notBetween, orBetween, orNotBetween (nhận BetweenValue)
Tập hợpin, notIn, orIn, orNotIn (nhận Collection hoặc varargs)
NULLisNull, isNotNull, orIsNull, orIsNotNull

Ví dụ với TypedJPASpecification:

TypedJPASpecification<Order> spec = Specifications.whereTyped()
.like(Order::getOrderNumber, request.getOrderNumber())
.between(Order::getOrderDate, request.getStartDate(), request.getEndDate())
.in(Order::getStatus, request.getStatuses())
.greatThanOrEqualTo(Order::getTotalAmount, request.getMinAmount())
.lessThanOrEqualTo(Order::getTotalAmount, request.getMaxAmount());

List<Order> orders = orderRepository.findAll(spec);

Ví dụ với tên cột dạng chuỗi:

JPASpecification<Order> spec = Specifications.where()
.equal("status", "ACTIVE")
.like("customer.name", keyword);

Kết hợp AND/OR theo nhóm

// (status = 'ACTIVE' OR status = 'PENDING') AND (age >= 18 AND age <= 60)
Specifications.where()
.equal("status", "ACTIVE")
.orEqual("status", "PENDING")
.and(subSpec -> subSpec
.greatThanOrEqualTo("age", 18)
.lessThanOrEqualTo("age", 60));

Sắp xếp ngay trên Specification

orderByAsc/orderByDesc nhận method reference (cả hai dạng specification) hoặc SingularAttribute của static metamodel:

Specifications.whereTyped()
.equal(Order::getStatus, "ACTIVE")
.orderByDesc(Order::getOrderDate);

Dựng điều kiện từ DTO annotation

Đánh dấu annotation điều kiện trên thuộc tính DTO rồi dựng Specification bằng ofBean/ofBeanTyped. Các annotation nằm trong package vn.govex.cloud.api:

AnnotationĐiều kiện sinh ra
@Equalsfield = value
@NotEqualsfield <> value
@Likefield LIKE '%value%'
@NotLikefield NOT LIKE '%value%'
@StartsWithfield LIKE 'value%'
@NotStartsWithfield NOT LIKE 'value%'
@EndsWithfield LIKE '%value'
@NotEndsWithfield NOT LIKE '%value'
@LikePatternfield LIKE 'pattern' (pattern do người gọi truyền, không escape)
@NotLikePatternfield NOT LIKE 'pattern'
@GreaterThanfield > value
@GreaterThanEqualfield >= value
@LessThanfield < value
@LessThanEqualfield <= value
@Betweenfield BETWEEN start AND end
@NotBetweenfield NOT BETWEEN start AND end
@Infield IN (values)
@NotInfield NOT IN (values)
@LikeInfield LIKE '%value1%' OR field LIKE '%value2%' cho từng giá trị trong collection
@LikeOrLikeLIKE trên nhiều field cùng lúc (danh sách field phân tách dấu phẩy trong value)
@IsNullfield IS NULL
@IsNotNullfield IS NOT NULL
@LengthSo sánh độ dài chuỗi, chọn phép so sánh qua thuộc tính comparison (ComparisonType)

Thuộc tính chung:

Thuộc tínhÝ nghĩa
valueTên field tương ứng trong entity; để trống thì dùng tên thuộc tính DTO
notKhai báo điều kiện phủ định (tương ứng annotation @Not... là biến thể tường minh)
operatorCách ghép điều kiện: Operator.And (mặc định), Operator.Or, Operator.AndNot, Operator.OrNot
fieldTypeChỉ có trên @In — gợi ý kiểu dữ liệu của field
comparisonChỉ có trên @Length — chọn phép so sánh độ dài

Field có giá trị null/rỗng sẽ tự động bị bỏ qua khi dựng Specification.

public class OrderSearchDTO {

@Like("orderNumber")
private String orderNumber;

@Between("orderDate")
private DateRange orderDate; // extends BetweenValue<LocalDate>

@In("status")
private List<String> statuses;

@Equals("customer.id")
private Long customerId;

@GreaterThanEqual("totalAmount")
private BigDecimal minAmount;

@LessThanEqual("totalAmount")
private BigDecimal maxAmount;

// getters và setters
}
TypedJPASpecification<Order> spec = Specifications.ofBeanTyped(searchDTO);
List<Order> orders = orderRepository.findAll(spec);

Kết hợp DTO annotation với fluent API cho các điều kiện bổ sung:

TypedJPASpecification<User> spec = Specifications.ofBeanTyped(searchDTO)
.like(User::getAddress, addressKeyword)
.orEqual(User::getReferralCode, referralCode)
.and(subSpec -> subSpec
.greatThanOrEqualTo(User::getRegistrationDate, startDate)
.lessThanOrEqualTo(User::getRegistrationDate, endDate));

Bỏ qua hoặc đổi metadata một field bằng Paths

ofBean(bean, paths) cho phép override trường trước khi sinh điều kiện — ví dụ bỏ qua một field đang có annotation:

Paths paths = Paths.field("username")
.ignore(true)
.build();

JPASpecification<User> spec = Specifications.ofBean(searchDTO, paths);

Chỉ định alias/entityAlias cho trường (hữu ích khi ghép với JPQL JOIN nhiều entity):

Paths paths = Paths.field("email")
.entityAlias("u")
.field("department.name")
.entityAlias("d")
.build();

JPASpecification<User> spec = Specifications.ofBean(searchDTO, paths);

@JoinPath cho DTO

Khi field tìm kiếm nằm ở entity không xuất hiện trong JPQL của @Query, khai báo join ngay trên field DTO:

public class DonSearchParam {

@Like("soBanAn")
@JoinPath(path = "vuAn.banAn", type = BanAn.class)
private String soBanAnQD;
}

@JoinPath hỗ trợ path (đường dẫn join, có thể nested), joinType (mặc định LEFT), type (entity đích), aliason (điều kiện ON khi entity không có quan hệ JPA).

Field & biểu thức truy vấn

Điều kiện typed dùng bộ mô tả trường chung của govex-cloud-data-common; dependency này được govex-cloud-data-jpa kéo sẵn qua transitive dependency nên không cần khai báo riêng:

  • Path mô tả một trường: tên trường (name), alias hiển thị (alias), alias entity/join chứa trường (entityAlias), kiểu dữ liệu (type), kiểu entity (entityType), cờ ignore và tập thuộc tính mở rộng. Chuỗi khởi tạo chỉ nhận dạng field hoặc alias.field; chuỗi có từ hai dấu chấm trở lên bị từ chối.
  • Paths là tập Path đánh chỉ mục theo tên trường, khai báo fluent bằng Paths.field(...) và phải gọi build() để chốt tập trường. Truyền Paths vào Specifications.ofBean(bean, paths)/ofBeanTyped(bean, paths) khi cần cấu hình thêm alias/entityAlias/ignore cho trường trong DTO tìm kiếm.
  • SFunc/TypedField là tham chiếu getter an toàn kiểu (method reference) thay cho tên trường dạng chuỗi. Specifications.whereTyped() nhận SFunc; Specifications.where() chỉ nhận tên trường dạng chuỗi.
  • @Limit đặt trên query method để giới hạn số kết quả; module đọc giá trị này khi thực thi câu truy vấn, yêu cầu value > 0.
Path byField = Path.of("status");
Path byAlias = Path.of("o.status"); // alias entity/join đứng trước tên trường
Path byGetter = Path.of(Order::getStatus); // từ method reference, kèm kiểu entity

Paths paths = Paths.field("orderNumber")
.alias("soDon") // alias hiển thị của trường
.name(Order::getStatus) // khai báo trường kế tiếp từ getter
.build(); // chốt tập trường

TypedJPASpecification<Order> spec = Specifications.ofBeanTyped(searchDTO, paths)
.like(Order::getOrderNumber, searchDTO.getOrderNumber())
.in(Order::getStatus, searchDTO.getStatuses());

Với tham chiếu trường, luôn dùng API typed — Specifications.whereTyped() hoặc ofBeanTyped():

TypedJPASpecification<Order> spec = Specifications.whereTyped()
.like(Order::getOrderNumber, request.getOrderNumber())
.greatThanOrEqualTo(Order::getTotalAmount, request.getMinAmount());

@Limit dùng cho query method có @Query:

public interface OrderRepository extends BaseRepository<Order, String> {

@Limit(1)
@Query("select o from Order o where o.status = 'ACTIVE' order by o.orderDate desc")
List<Order> findFirstActive();
}

JOIN động, chọn cột và EXISTS

AbstractJPASpecification hỗ trợ JOIN động mà không cần quan hệ JPA trong entity.

JOIN theo entity class (cross join + ON condition khi không có relationship):

Specifications.where()
.leftJoin(Department.class).as("d")
.on(s -> s.equal("id", someValue));

JOIN giữa hai bảng không phải root (A → B → C):

Specifications.where()
.from(EntityB.class).as("b")
.leftJoin(EntityC.class).as("c")
.on(s -> s.equal("status", 1));

Điều kiện ON có 3 dạng: on(Specification), on(Function<JPASpecification<S>, JPASpecification<S>>)onTyped(Function<TypedJPASpecification<S>, TypedJPASpecification<S>>).

Chọn cột động (thay cho SELECT *) và literal:

spec.select("id").select("name", "tenDonVi"); // theo tên path
spec.select(User_.id, User_.name); // theo static metamodel
spec.select(User::getId, User::getName); // theo method reference
spec.select(Department::getName, "tenPhong"); // kèm alias
spec.selectLiteral("ACTIVE", "trangThai"); // literal

Subquery EXISTS/NOT EXISTS theo hướng type-safe:

Specifications.whereTyped(Menu.class)
.existsTyped(Permission.class, s -> s
.equal(Permission::getUserId, currentUserId)
.equal(Permission::getDeleted, false));

Tương tự có notExists, orExists, orNotExists và biến thể *Typed.

So sánh cột-với-cột (không phải cột-với-giá trị), dùng SingularAttribute của static metamodel:

spec.equal(Order_.customerId, Customer_.id)
.notEqual(Order_.startDate, Order_.endDate)
.greatThan(Order_.actualAmount, Order_.estimatedAmount);

JPQL/native query trả về VO

AnnotationLoại queryresultTransformer mặc định
@JpaQueryJPQL — meta-annotation của @QueryPropertyTupleTransformer khớp alias theo tên property, không phân biệt hoa thường
@JpaNativeQueryNative SQL — meta-annotation của @NativeQueryUnderscoreTransformer chuyển alias snake_case sang camelCase

Có thể chỉ định resultTransformer khác (class kế thừa AbstractTupleTransformer). VO chỉ cần field trùng tên alias, có constructor không tham số; framework tự tạo instance và set property qua BeanWrapper — không cần interface projection hay constructor expression:

@JpaQuery("SELECT u.id as id, u.name as ten, d.name as tenDonVi " +
"FROM User u LEFT JOIN DonVi d ON u.maDonVi = d.ma")
List<UserVO> search(Specification<User> spec);

UserVO chỉ cần các field id, ten, tenDonVi. Chi tiết native query và SQLExpression xem Native query & SQLExpression.

Kết hợp @Query với Specification

Repository khai báo method với @Query (phần cố định) và tham số Specification (phần động):

public interface UserRepository extends BaseRepository<User, String> {

@Query("select u from User u where u.active = true")
List<User> findActiveUsers(Specification<User> spec);

@Query("select u from User u where u.registrationDate >= :startDate")
List<User> findByRegistrationDate(@Param("startDate") LocalDate startDate, Specification<User> spec);
}

Quy tắc kết hợp:

  • Specification null hoặc rỗng: câu JPQL chạy nguyên trạng.
  • Điều kiện từ Specification luôn được nối vào sau điều kiện cố định bằng AND.
  • Tham số named parameter của @Query vẫn hoạt động bình thường.

Cơ chế nội bộ: SpecificationUtils.applySpecification() copy SQM tree của Hibernate, resolve root tương ứng, merge predicate vào WHERE hiện có (kể cả derived root/subquery trong FROM), merge selection nếu Specification có chọn cột động và xử lý tương tự cho câu DELETE/UPDATE.

Phân trang & sắp xếp

  • Truyền Pageable vào findAll(spec, pageable) để nhận Page; điều kiện Specification được áp dụng cho cả câu query và câu count.
  • Sắp xếp có thể khai báo trực tiếp trên Specification (orderByAsc/orderByDesc) hoặc trong Pageable/Sort.
  • Giới hạn số bản ghi bằng Limit của Spring Data qua findAll(spec, limit)/findAll(spec, sort, limit).
Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "orderDate"));

TypedJPASpecification<Order> spec = Specifications.ofBeanTyped(request);
Page<OrderVO> page = orderRepository.findAll(spec, pageable)
.map(mapper::toVO);

Kết quả đơn và projection

@QuerySingleResult biến kết quả rỗng thành null (kiểu tham chiếu) hoặc Optional.empty() thay vì ném EmptyResultDataAccessException/NoResultException:

@QuerySingleResult
@Query("select u from User u where u.username = :username")
User findByUsernameNullable(@Param("username") String username);

MapToProjectionConverter tự chuyển Map<String, Object> sang class implements vn.govex.cloud.api.Projection theo tên field.

EntitySchemaUpdater

Tự động migrate schema cho một số entity cụ thể khi application startup (Hibernate hbm2ddl update), chỉ dùng cho module cần tự quản lý schema riêng:

@Bean
EntitySchemaUpdater messageSchemaUpdater(DataSource dataSource) {
return new EntitySchemaUpdater(dataSource, Message.class, MessageTracking.class);
}

Luồng hoạt động

Sơ đồ dưới đây mô tả luồng truy vấn động: từ DTO annotation hoặc @Query tới Specification, câu JPQL/SQL thực thi và ánh xạ kết quả về VO.

Lưu ý

  • @EnableExtendJpaRepositories là điều kiện để các tính năng mở rộng hoạt động đầy đủ (base repository, statement inspector, projection converter, @QuerySingleResult).
  • where() nhận tên cột dạng chuỗi; whereTyped()/ofBeanTyped() mới nhận method reference.
  • Instance Specification giữ trạng thái trong lúc dựng truy vấn — không chia sẻ giữa nhiều luồng.
  • saveBatch chỉ dùng cho insert, updateBatch chỉ dùng cho update; saveOrUpdateAllByNotNullProperties kiểm tra tồn tại từng bản ghi nên chậm hơn batch thuần túy.
  • JPA auditing lấy người thao tác từ SecurityContext; khi không có thông tin xác thực, createdBy/updatedBy để trống và module chỉ ghi cảnh báo một lần.
  • Dependency chứa lớp override trong package gốc của Spring Data JPA/Hibernate; khi nâng cấp hai thư viện này cần kiểm tra lại tương thích.
  • Static metamodel (Order_) chỉ có khi cấu hình hibernate-jpamodelgen trong build.