検索フォームって、条件が入力されたりされなかったりしますよね。名前だけ指定してカテゴリは空、という場合もあれば、価格範囲も全部埋める場合もある。そういう可変な条件を @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の連鎖が消えます。

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

JOINを使った関連エンティティでの絞り込み

ProductCategory エンティティを参照していて、カテゴリ名で絞り込みたいケースを考えます。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を組み合わせる

ソート条件は PageableSort で渡すのが基本ですが、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.ymlspring.jpa.show-sql: truespring.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 を使う検索だけ追加で書ける形になります。