Spring Data JPA Specification API

最終更新: 2022-11-09 01:13:09 +0800 +0800
tags: Java | Spring | Jpa
原文は英語で書かれています。あなたはAI翻訳版を読んでいます。

Spring Data JPA リポジトリで、任意指定可能で、合成可能で、実行時に決定される WHERE 句が必要な場合、通常使用する API は org.springframework.data.jpa.repository.JpaSpecificationExecutor<T> です。

これは次の要素をつなぐ橋渡しとなります。

  1. findAll(...)、count(...)、exists(...) などの Spring Data リポジトリメソッド
  2. Spring の org.springframework.data.jpa.domain.Specification<T>
  3. jakarta.persistence.criteria.CriteriaBuilder を中心とする Jakarta Persistence Criteria API

JpaSpecificationExecutor<T> #

エンティティがあるとします。

java
@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;
}

そして、リポジトリは次のようになります。

java
public interface PurchaseOrderRepository
        extends JpaRepository<PurchaseOrder, Long>,
                JpaSpecificationExecutor<PurchaseOrder> {
}

JpaRepository は CRUD とページングの API を提供します。JpaSpecificationExecutor は、フィルタリングロジックを Specification<PurchaseOrder> で記述するメソッドを追加します。

代表的なメソッドは次のとおりです。

java
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> は関数型インターフェースです。

java
public interface Specification<T> extends Serializable {
    Predicate toPredicate(
        Root<T> root,
        CriteriaQuery<?> query,
        CriteriaBuilder criteriaBuilder
    );
}

つまり、Specification<T> は、エンティティ型 T に対する Predicate の生成に寄与する方法を知っている、再利用可能な関数にすぎません。

3 つのパラメーターにはそれぞれ意味があります。

  1. Root<T> は、クエリ対象となるルートテーブルまたはエンティティを表します
  2. CriteriaQuery<?> は、構築中のクエリ全体です
  3. CriteriaBuilder は、equal、like、between、and、or、not、lower、count などの式や述語を生成するファクトリです

実際には、アプリケーションコードの大半は root と criteriaBuilder だけを使用します。distinct、groupBy、カスタム順序付け、フェッチ結合などが必要になると、query が重要になります。

実際に Specification を取得する方法 #

最も一般的な方法は、小さな static ファクトリメソッドから返すことです。

java
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);
    }
}

次に、これらを合成します。

java
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 に変換することです。

もう 1 つの一般的なスタイルは、リクエストオブジェクトを保持し、1 つのメソッドで仕様全体を構築する方法です。

java
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 に変換する式オブジェクトのツリーを生成します。

メソッドは、一般に次のカテゴリに分類できます。

  1. 比較述語: equal、notEqual、greaterThan、lessThan、between
  2. 文字列操作: like、lower、upper、trim、concat
  3. null/boolean チェック: isNull、isNotNull、isTrue、isFalse
  4. boolean の合成: and、or、not、conjunction、disjunction
  5. 集約またはクエリ式: count、avg、sum、exists

例を示します。

java
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 はそのテーブルまたはエンティティに対応します。

例を示します。

java
root.get("status");
root.get("createdAt");
root.get("customer").get("id");

Path<X> #

root.get(...) の戻り値はすべて、属性へのナビゲーションを表す Path<?> です。これが、プロパティアクセスを連鎖できる理由です。

java
Path<String> email = root.get("customerEmail");
Path<Long> customerId = root.get("customer").get("id");

Predicate #

Predicate は toPredicate(...) が返す boolean 式です。生成された WHERE 句の一部になります。

CriteriaQuery<?> #

CriteriaQuery<?> はクエリ全体の定義です。必要に応じて、仕様からこれを変更できます。

java
return (root, query, cb) -> {
    query.distinct(true);
    return cb.equal(root.get("status"), "PAID");
};

ここでは注意が必要です。仕様がクエリの形状を変更し始めると、単なる小さなフィルター断片ではなくなります。強力である一方、合成の予測可能性も低下します。

Join<Z, X> #

関連エンティティでフィルタリングする場合、get(...) の連鎖より明示的な結合のほうが明確になることがよくあります。

java
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 で処理するほうが適切です。ただし、仕様から query.orderBy(...) を通じて順序付けに影響を与える必要がある場合は、CriteriaBuilder.asc(...) / desc(...) を使用できます。

Specification が存在する理由 #

Specification がなければ、通常は次のいずれかの問題に行き着きます。

  1. findByStatusAndCustomerEmailContainingAndCreatedAtAfter(...) のようなリポジトリメソッドが増える
  2. null の可能性がある条件を大量に含む手書きの JPQL 文字列を使う

Specification は、各フィルターを .and(...) や .or(...) で結合できるオブジェクトまたは関数にすることで、「動的な WHERE 句」の問題を解決します。

次の用途で特に有用です。

  1. 多数の任意項目を持つ検索画面
  2. ページング可能なフィルタリングを備えた管理 API
  3. 「テナントから見える」、「論理削除されていない」、「現在のユーザーが作成した」などの再利用可能なビジネスフィルター

型安全なアクセスと文字列プロパティ名 #

例では次のようなコードをよく使用します。

java
root.get("status")

これは便利ですが、文字列ベースの型付けです。タイプミスは実行時まで検出されません。

ビルドで JPA 静的メタモデルを生成する場合は、より安全な次の形式を使用できます。

java
root.get(PurchaseOrder_.status)

Specification を使用するために必須ではありませんが、リファクタリングに強い Criteria コードを求める場合に重要な関連 API です。

Spring Data が内部で行っていること #

通常のアプリケーションコードで、CriteriaBuilder を自分で生成することはありません。Spring Data の JPA リポジトリ実装が EntityManager を取得し、そこから CriteriaBuilder を取得し、CriteriaQuery を生成し、Specification#toPredicate(...) を呼び出し、最終的なクエリを JPA プロバイダー経由で実行します。

処理の流れは、おおむね次のとおりです。

  1. アプリケーションが Specification<T> を作成または合成する
  2. アプリケーションが repository.findAll(spec, pageable) を呼び出す
  3. Spring Data が EntityManager のインフラストラクチャを取得する
  4. Spring Data が Criteria クエリを構築する
  5. Specification が述語ツリーを提供する
  6. Hibernate がその Criteria ツリーを SQL に変換する
  7. データベースが SQL を実行する

Specification が小さく見えるのはこのためです。Specification はフィルター断片にすぎず、リポジトリ実装全体ではありません。

実践的な目安 #

クエリが「1 つのエンティティルートと動的な述語ツリー」の範囲に収まる場合は、JpaSpecificationExecutor を使用します。投影、集約、複雑なグループ化、ベンダー固有の SQL、または扱いの難しいフェッチプランを伴うレポートになると、Specification API は使いにくくなるため、カスタムリポジトリ実装や別のクエリ方式のほうが明確になることがよくあります。

ただし、通常の検索・フィルタリングのユースケースでは、次の構成が適しています。

  1. JpaSpecificationExecutor<T> はリポジトリのエントリーポイントです
  2. Specification<T> は再利用可能なフィルター抽象化です
  3. CriteriaBuilder は述語・式ツリーを生成するファクトリです
  4. Root<T>、Path<?>、Join<?, ?>、Predicate、CriteriaQuery<?> は主要な補助型です

このメンタルモデルが明確になれば、API は大幅に理解しやすくなります。

コメント

GitHub で閲覧・コメントする(投稿にはログインが必要です)