検索フォームって、条件が入力されたりされなかったりしますよね。名前だけ指定してカテゴリは空、という場合もあれば、価格範囲も全部埋める場合もある。そういう可変な条件を @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に継承させる
既存の JpaRepository に JpaSpecificationExecutor を追加するだけです。
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エンティティのリレーション設計」も合わせて参照してみてください。