検索フォームって、条件が入力されたりされなかったりしますよね。名前だけ指定してカテゴリは空、という場合もあれば、価格範囲も全部埋める場合もある。そういう可変な条件を @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の連鎖が消えます。
ページネーションと組み合わせる
JOINを使った関連エンティティでの絞り込み
Product が Category エンティティを参照していて、カテゴリ名で絞り込みたいケースを考えます。root.join("category") で結合し、結合先のカラムを参照します。
public static Specification<Product> categoryNameEquals(String categoryName) {
return (root, query, cb) -> {
if (categoryName == null || categoryName.isEmpty()) {
return cb.conjunction();
}
Join<Product, Category> category = root.join("category", JoinType.LEFT);
return cb.equal(category.get("name"), categoryName);
};
}
LEFT JOIN にすることでカテゴリ未設定の Product も対象に残せます。コレクション結合の場合は重複が発生しやすいので、後述の distinct を併用しましょう。
N+1の発生を抑える fetch join を併用したい場合は「Spring Data JPAのN+1問題を解決する方法」も参照してください。
distinctで結合による重複行を除去する
1対多のJOINでは同じ親レコードが複数返ることがあります。query.distinct(true) を toPredicate 内で指定すると SELECT DISTINCT が付与されます。
public static Specification<Product> hasTag(String tag) {
return (root, query, cb) -> {
if (tag == null || tag.isEmpty()) return cb.conjunction();
query.distinct(true);
Join<Product, Tag> tags = root.join("tags");
return cb.equal(tags.get("name"), tag);
};
}
in句で複数値を絞り込む
チェックボックスのような複数選択フォームには root.get(...).in(values) を使います。
public static Specification<Product> categoryIn(List<String> categories) {
return (root, query, cb) -> {
if (categories == null || categories.isEmpty()) {
return cb.conjunction();
}
return root.get("category").in(categories);
};
}
not条件・否定形を表現する
Specification.not(spec) または cb.not(predicate) で否定を作れます。除外フィルター(例: 廃番カテゴリを外す)に便利です。
Specification<Product> spec = Specification
.where(ProductSpecifications.categoryEquals("book"))
.and(Specification.not(ProductSpecifications.nameContains("中古")));
動的ソートとSpecificationを組み合わせる
ソート条件は Pageable の Sort で渡すのが基本ですが、Specification 側で query.orderBy(...) を指定することもできます。
Pageable pageable = PageRequest.of(0, 20, Sort.by("price").descending());
Page<Product> page = productRepository.findAll(ProductSpecifications.from(form), pageable);
リクエストパラメータ ?sort=price,desc で動的にソート列を切り替えられるため、Specification 内で orderBy を書く必要はほとんどありません。
Specificationの単体テストを書く
Specification は Repository を介さずとも、toPredicate 単体での検証が難しいため、@DataJpaTest でH2に対して findAll(spec) を実行するのが現実的です。
@DataJpaTest
class ProductSpecificationsTest {
@Autowired ProductRepository repository;
@Test
void nameContains_部分一致でヒットする() {
repository.save(new Product(null, "Spring Boot入門", BigDecimal.valueOf(2000), "book"));
repository.save(new Product(null, "Java実践", BigDecimal.valueOf(2500), "book"));
List<Product> result = repository.findAll(ProductSpecifications.nameContains("Spring"));
assertThat(result).hasSize(1);
}
}
クエリビルダ層を直接テストするより、SQLが期待通り発行されるかも合わせて確認できるためおすすめです。
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エンティティのリレーション設計」も合わせて参照してみてください。
よくある質問(FAQ)
Q. Specification と @Query はどう使い分けますか?
A. 検索条件が固定で複雑なSQLを書きたい場合は @Query が読みやすいです。一方、フォーム入力のように条件の有無が動的に変わるケースでは Specification が向いています。両者は同じRepositoryに併存できます。
Q. Specification と QueryDSL の違いは何ですか?
A. Specification は Spring Data JPA に標準で含まれ、追加ライブラリ不要で使えます。QueryDSL はコード生成によりカラム名を文字列ではなく型として参照でき、補完が効くのが強みですが、ビルド設定が必要です。シンプルな検索フォームなら Specification、結合や集計が多い管理画面では QueryDSL が選ばれることが多いです。
Q. Specification が発行するSQLを確認するには?
A. application.yml に spring.jpa.show-sql: true と spring.jpa.properties.hibernate.format_sql: true を設定すると、Hibernate が組み立てたSQLが整形されて出力されます。本番では org.hibernate.SQL ロガーを DEBUG にしてアプリログに流すのが一般的です。
Q. なぜ null チェックで cb.conjunction() を返すのですか?
A. Specification チェーンの途中で null を返すと、後続の .and() で NullPointerException や予期しない挙動を起こします。cb.conjunction() は常に true のPredicateなので、AND結合した際に実質的に「その条件を無視する」効果になり、安全に条件をスキップできます。
Q. JpaSpecificationExecutor を継承すると既存のクエリメソッドに影響しますか?
A. ありません。JpaRepository と並列に継承するだけで、既存の findByName などのクエリメソッドはそのまま利用できます。Specification を使う検索だけ追加で書ける形になります。