検索フォームって、条件が入力されたりされなかったりしますよね。名前だけ指定してカテゴリは空、という場合もあれば、価格範囲も全部埋める場合もある。そういう可変な条件を @Query の静的JPQLで書こうとすると、すぐ詰まります。

かといってサービス層にif文を積み上げていくと、こうなりがちです。

if (name != null && category != null) {
    return repo.findByNameAndCategory(name, category);
} else if (name != null) {
    return repo.findByName(name);
} else if (category != null) {
    return repo.findByCategory(category);
} else {
    return repo.findAll();
}

条件が3つになると分岐は8通り。これを Specification パターンで解決しましょう。

JpaSpecificationExecutor をRepositoryに継承させる

既存の JpaRepositoryJpaSpecificationExecutor を追加するだけです。

public interface ProductRepository
    extends JpaRepository<Product, Long>,
            JpaSpecificationExecutor<Product> {
}

これで findAll(Specification<T> spec)findAll(Specification<T> spec, Pageable pageable) が使えるようになります。エンティティはこんな形を想定します。

@Entity
public class Product {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private BigDecimal price;
    private String category;
    // getter/setter省略
}

Criteria APIの3要素を押さえる

Specification の toPredicate を実装するには3つのオブジェクトが登場します。

  • Root<T> :FROM句のエンティティ。root.get("name") でカラムを参照します
  • CriteriaBuilder :WHERE条件(Predicate)を生成するファクトリです
  • Predicate :実際の条件式。cb.equal(...)cb.like(...) で作ります

この3つを使って toPredicate の中に条件を書いていきます。

単一条件のSpecificationを書く

ラムダ式で実装するのが最もシンプルです。

public class ProductSpecifications {

    public static Specification<Product> nameContains(String name) {
        return (root, query, cb) ->
            cb.like(root.get("name"), "%" + name + "%");
    }

    public static Specification<Product> categoryEquals(String category) {
        return (root, query, cb) ->
            cb.equal(root.get("category"), category);
    }
}

静的メソッドとしてユーティリティクラスにまとめておくと、呼び出し側がすっきりします。

null・空文字を安全にスキップする

検索条件が入力されていない場合、cb.conjunction() を返すようにします。「常にtrue」なPredicateなので、実質的にそのWHERE条件をスキップした扱いになります。

public static Specification<Product> nameContains(String name) {
    return (root, query, cb) -> {
        if (name == null || name.isEmpty()) {
            return cb.conjunction();
        }
        return cb.like(root.get("name"), "%" + name + "%");
    };
}

nullのまま cb.like(...) に渡すとNullPointerExceptionになるので、各メソッドの先頭でこのチェックを入れるのを徹底しましょう。

複数条件をAND/ORでつなぐ

複数の Specification は Specification.where().and().or() でチェーンします。

Specification<Product> spec = Specification
    .where(ProductSpecifications.nameContains(form.getName()))
    .and(ProductSpecifications.categoryEquals(form.getCategory()))
    .and(ProductSpecifications.priceBetween(form.getMinPrice(), form.getMaxPrice()));

where() は起点を作るだけなので、最初に渡すSpecificationがnullを返しても問題ありません。OR結合したいときは .or(spec) を使います。

フォームBeanからSpecificationを生成するユーティリティクラス

実際の現場では、検索条件をまとめたBeanを受け取ってSpecificationに変換するパターンがよく使われます。

@Getter @Setter
public class ProductSearchForm {
    private String name;
    private String category;
    private BigDecimal minPrice;
    private BigDecimal maxPrice;
}
public class ProductSpecifications {

    public static Specification<Product> from(ProductSearchForm form) {
        return Specification
            .where(nameContains(form.getName()))
            .and(categoryEquals(form.getCategory()))
            .and(priceBetween(form.getMinPrice(), form.getMaxPrice()));
    }

    private static Specification<Product> priceBetween(BigDecimal min, BigDecimal max) {
        return (root, query, cb) -> {
            if (min == null && max == null) return cb.conjunction();
            if (min == null) return cb.lessThanOrEqualTo(root.<BigDecimal>get("price"), max);
            if (max == null) return cb.greaterThanOrEqualTo(root.<BigDecimal>get("price"), min);
            return cb.between(root.<BigDecimal>get("price"), min, max);
        };
    }
    // nameContains, categoryEquals は前述のとおり
}

サービス層では from(form) を呼ぶだけになり、ifの連鎖が消えます。

ページネーションと組み合わせる

findAll(spec, pageable) を使えばSpecificationとページネーションをそのまま組み合わせられます。

@Service
@RequiredArgsConstructor
public class ProductService {
    private final ProductRepository productRepository;

    public Page<Product> search(ProductSearchForm form, Pageable pageable) {
        return productRepository.findAll(ProductSpecifications.from(form), pageable);
    }
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/products")
public class ProductController {
    private final ProductService productService;

    @GetMapping("/search")
    public Page<Product> search(ProductSearchForm form, Pageable pageable) {
        return productService.search(form, pageable);
    }
}

Pageable はSpringが ?page=0&size=10&sort=name,asc のようなクエリパラメータを自動バインドしてくれます。ページネーション全般については「Spring BootのREST APIでページネーションを実装する方法」も参考にしてみてください。

QueryDSLとの使い分け

Specificationは追加ライブラリなしで使えるので、シンプルな検索フォームには十分です。結合が複雑になってきたり、コード補完で型安全に書きたい場合はQueryDSLが有利ですが、コード生成の設定コストが伴います。「まずは動的クエリを整理したい」という段階ならSpecificationから始めるのが無難です。

まとめ

Specificationパターンを導入すると、サービス層のif地獄から解放されます。

  • JpaSpecificationExecutor を継承するだけで有効化できる
  • cb.conjunction() でnull条件を安全にスキップ
  • where().and().or() でチェーンして複数条件を表現
  • フォームBeanからSpecificationを生成するユーティリティクラスにまとめると呼び出しがシンプル
  • findAll(spec, pageable) でページネーションもそのまま使える

クエリメソッドの基本については「Spring Data JPAのクエリメソッド、どう書けばいいか迷ったことありませんか?」を、エンティティのリレーション設計については「Spring Boot JPAエンティティのリレーション設計」も合わせて参照してみてください。