當 Spring Data JPA 儲存庫需要一個可選、可組合,且在執行時決定的 WHERE 子句時,通常使用的 API 是 org.springframework.data.jpa.repository.JpaSpecificationExecutor<T>。
它連接了以下元件:
- Spring Data 儲存庫方法,例如
findAll(...)、count(...)和exists(...) - Spring 的
org.springframework.data.jpa.domain.Specification<T> - 以
jakarta.persistence.criteria.CriteriaBuilder為核心的 Jakarta Persistence Criteria API
JpaSpecificationExecutor<T>
#
假設有一個實體:
@Entity
@Table(name = "orders")
public class PurchaseOrder {
@Id
private Long id;
@Column(nullable = false)
private String status;
@Column(nullable = false)
private String customerEmail;
@Column(nullable = false)
private Instant createdAt;
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
}以及一個儲存庫:
public interface PurchaseOrderRepository
extends JpaRepository<PurchaseOrder, Long>,
JpaSpecificationExecutor<PurchaseOrder> {
}JpaRepository 提供 CRUD 與分頁 API。JpaSpecificationExecutor 則新增一組方法,其篩選邏輯由 Specification<PurchaseOrder> 描述。
典型方法如下:
List<PurchaseOrder> findAll(Specification<PurchaseOrder> spec);
Page<PurchaseOrder> findAll(Specification<PurchaseOrder> spec, Pageable pageable);
List<PurchaseOrder> findAll(Specification<PurchaseOrder> spec, Sort sort);
Optional<PurchaseOrder> findOne(Specification<PurchaseOrder> spec);
long count(Specification<PurchaseOrder> spec);
boolean exists(Specification<PurchaseOrder> spec);核心概念很簡單:不需要手動組合 JPQL 字串。將 Specification<T> 交給 Spring Data 後,Spring Data 會替你建立 Criteria 查詢。
Specification<T> 的本質
#
org.springframework.data.jpa.domain.Specification<T> 是函式介面:
public interface Specification<T> extends Serializable {
Predicate toPredicate(
Root<T> root,
CriteriaQuery<?> query,
CriteriaBuilder criteriaBuilder
);
}換句話說,Specification<T> 只是一個可重複使用的函式,知道如何為實體型別 T 貢獻一個 Predicate。
三個參數都很重要:
Root<T>代表正在查詢的根資料表或根實體CriteriaQuery<?>是正在建立的完整查詢CriteriaBuilder是建立運算式與述詞的工廠,例如equal、like、between、and、or、not、lower、count等
實務上,大部分應用程式碼只會關心 root 與 criteriaBuilder。當需要 distinct、groupBy、自訂排序或 fetch join 時,query 才會變得重要。
實務上如何取得 Specification
#
最常見的方式,是從小型靜態工廠方法中回傳它:
public final class PurchaseOrderSpecs {
private PurchaseOrderSpecs() {}
public static Specification<PurchaseOrder> hasStatus(String status) {
return (root, query, cb) -> cb.equal(root.get("status"), status);
}
public static Specification<PurchaseOrder> customerEmailContains(String token) {
return (root, query, cb) ->
cb.like(
cb.lower(root.get("customerEmail")),
"%" + token.toLowerCase(Locale.ROOT) + "%"
);
}
public static Specification<PurchaseOrder> createdAtGte(Instant lowerBound) {
return (root, query, cb) -> cb.greaterThanOrEqualTo(root.get("createdAt"), lowerBound);
}
public static Specification<PurchaseOrder> customerIdEquals(Long customerId) {
return (root, query, cb) -> cb.equal(root.get("customer").get("id"), customerId);
}
}接著將它們組合起來:
Specification<PurchaseOrder> spec = Specification.where(null);
if (status != null) {
spec = spec.and(PurchaseOrderSpecs.hasStatus(status));
}
if (emailToken != null && !emailToken.isBlank()) {
spec = spec.and(PurchaseOrderSpecs.customerEmailContains(emailToken));
}
if (createdAfter != null) {
spec = spec.and(PurchaseOrderSpecs.createdAtGte(createdAfter));
}
if (customerId != null) {
spec = spec.and(PurchaseOrderSpecs.customerIdEquals(customerId));
}
Page<PurchaseOrder> page =
purchaseOrderRepository.findAll(spec, PageRequest.of(0, 20, Sort.by("createdAt").descending()));在大多數程式碼庫中,這個 API 的實際形式是:將請求參數轉換成一連串可選的 Specification,再將它們串接起來。
另一種常見風格,是保留請求物件,並讓一個方法建立完整的 specification:
public record PurchaseOrderFilter(
String status,
String emailToken,
Instant createdAfter,
Long customerId
) {}
public static Specification<PurchaseOrder> fromFilter(PurchaseOrderFilter filter) {
Specification<PurchaseOrder> spec = Specification.where(null);
if (filter.status() != null) {
spec = spec.and(hasStatus(filter.status()));
}
if (filter.emailToken() != null && !filter.emailToken().isBlank()) {
spec = spec.and(customerEmailContains(filter.emailToken()));
}
if (filter.createdAfter() != null) {
spec = spec.and(createdAtGte(filter.createdAfter()));
}
if (filter.customerId() != null) {
spec = spec.and(customerIdEquals(filter.customerId()));
}
return spec;
}CriteriaBuilder 的運作方式
#
jakarta.persistence.criteria.CriteriaBuilder 是一個工廠 API。它本身不會執行 SQL,而是建立一棵運算式物件樹,之後由 JPA 提供者(例如 Hibernate)將它轉換成 SQL。
方法通常可分為以下類別:
- 比較述詞:
equal、notEqual、greaterThan、lessThan、between - 字串操作:
like、lower、upper、trim、concat - null/boolean 檢查:
isNull、isNotNull、isTrue、isFalse - 布林組合:
and、or、not、conjunction、disjunction - 彙總或查詢運算式:
count、avg、sum、exists
範例:
cb.equal(root.get("status"), "PAID");
cb.like(cb.lower(root.get("customerEmail")), "%gmail.com");
cb.between(root.get("createdAt"), start, end);
cb.and(
cb.equal(root.get("status"), "PAID"),
cb.isNotNull(root.get("customerEmail"))
);因此,Criteria API 基本上是用來建立類似 SQL 查詢的型別化 AST 建構器。
相關的重要 Criteria API 型別 #
若要理解 Specification,以下介面值得認識:
Root<T>
#
Root<T> 是 FROM 子句中的根實體。如果儲存庫查詢的是 PurchaseOrder,那麼 root 就對應到該資料表或實體。
範例:
root.get("status");
root.get("createdAt");
root.get("customer").get("id");Path<X>
#
每個 root.get(...) 都會回傳一個 Path<?>,代表對屬性的導覽。這就是為什麼屬性存取可以鏈式呼叫。
Path<String> email = root.get("customerEmail");
Path<Long> customerId = root.get("customer").get("id");Predicate
#
Predicate 是 toPredicate(...) 回傳的布林運算式,並會成為產生之 WHERE 子句的一部分。
CriteriaQuery<?>
#
CriteriaQuery<?> 是完整的查詢定義。必要時,specification 可以修改它:
return (root, query, cb) -> {
query.distinct(true);
return cb.equal(root.get("status"), "PAID");
};需要注意的是:一旦 specification 開始變更查詢形狀,它就不再只是小型的篩選片段。它雖然很強大,但也會使組合行為變得較難預測。
Join<Z, X>
#
依據關聯實體進行篩選時,明確的 join 通常比鏈式呼叫 get(...) 更清楚:
public static Specification<PurchaseOrder> customerRegionEquals(String region) {
return (root, query, cb) -> {
Join<PurchaseOrder, Customer> customer = root.join("customer");
return cb.equal(customer.get("region"), region);
};
}Order
#
排序通常應該交由 Spring Data 的 Sort 或 Pageable 處理,而不是硬編碼在 specification 中。不過,如果 specification 必須透過 query.orderBy(...) 影響排序,可以使用 CriteriaBuilder.asc(...) / desc(...)。
為什麼存在 Specification
#
沒有 Specification 時,通常會遇到以下其中一個問題:
- 出現許多像
findByStatusAndCustomerEmailContainingAndCreatedAtAfter(...)的儲存庫方法 - 使用包含大量可為 null 條件的手寫 JPQL 字串
Specification 讓每個篩選器都成為可以透過 .and(...) 和 .or(...) 組合的物件或函式,藉此解決「動態 WHERE 子句」問題。
它特別適合以下用途:
- 包含許多可選欄位的搜尋畫面
- 支援可分頁篩選的管理 API
- 可重複使用的業務篩選器,例如「對租戶可見」、「未被軟刪除」或「由目前使用者建立」
型別安全的存取與字串屬性名稱 #
範例通常會使用:
root.get("status")這種方式很方便,但屬於字串型別化。拼寫錯誤只有在執行時才會失敗。
如果建置過程會產生 JPA 靜態中繼模型,可以使用更安全的形式:
root.get(PurchaseOrder_.status)使用 Specification 並不要求採用這種方式,但如果希望 Criteria 程式碼能安全地配合重構,它就是重要的配套 API。
Spring Data 在幕後替你做了什麼 #
在一般應用程式碼中,不需要自行建立 CriteriaBuilder。Spring Data 的 JPA 儲存庫實作會取得 EntityManager,向它要求 CriteriaBuilder,建立 CriteriaQuery,呼叫你的 Specification#toPredicate(...),然後透過 JPA 提供者執行最終查詢。
因此,控制流程大致如下:
- 應用程式建立或組合
Specification<T> - 應用程式呼叫
repository.findAll(spec, pageable) - Spring Data 取得
EntityManager基礎設施 - Spring Data 建立 Criteria 查詢
Specification貢獻述詞樹- Hibernate 將 Criteria 樹轉換成 SQL
- 資料庫執行 SQL
這說明了為什麼 Specification 看起來很小:它只是篩選片段,而不是完整的儲存庫實作。
實務上的判斷原則 #
當查詢仍屬於「一個實體根加上一棵動態述詞樹」的範圍時,使用 JpaSpecificationExecutor。一旦查詢變成包含投影、彙總、複雜分組、供應商特定 SQL 或棘手 fetch 計畫的報表,Specification API 就會變得不太適用;此時,自訂儲存庫實作或其他查詢方式通常更清楚。
不過,對於一般的搜尋與篩選使用案例,這個架構很簡潔:
JpaSpecificationExecutor<T>是儲存庫的入口點Specification<T>是可重複使用的篩選器抽象化CriteriaBuilder是建立述詞/運算式樹的工廠Root<T>、Path<?>、Join<?, ?>、Predicate和CriteriaQuery<?>是主要的支援型別
一旦這個心智模型清楚,這個 API 就不再神秘。
評論
在 GitHub 上閱讀或評論(發表評論需要登入)