Help us understand the problem. What is going on with this article?

JPA Specificationで複数キーワードによる絞り込み検索

More than 1 year has passed since last update.

本記事はJPA Specificationで複数キーワードによる絞り込み検索を実装するのに、利用される環境・設定を含め、なるべく詳細に記述した記事です。

この記事を理解するのに、Spring BootとJPAの基礎知識が必要です。

JPA自動生成メソッドを使えない

単一キーワードによる絞り込みはJPAの自動生成したメソッド1(ex. findByNameContaining)を使えば簡単に実装できますが、
Googleなどの検索エンジンのように「spring jpa entity」複数キーワードで検索する機能を実装するのに、メソッドの自動生成はどうも力が足りないようです。

なにが問題なの?

例えば、itemテーブルの構成が

CREATE TABLE items (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(255) NOT NULL,
    price FLOAT NOT NULL
);

の時、nameの中身に「spring」含むレコードを探すクエリ文はこうです。

SELECT * FROM items WHERE name LIKE '%spring%';

対応するJPAの自動生成メソッドは

findByNameContaining(String name);

です。簡単ですね。

そして、nameの中身に「spring」と「jpa」の両方を含むレコードを探すクエリ文はこうです(いろいろ書き方があると思いますが、あくまで一例です)。

SELECT * FROM items WHERE name LIKE '%spring%' AND name LIKE '%jpa%';

対応するJPAの自動生成メソッドはおそらく

findByNameContainingAndNameContaining(String word1, String word2);

こうです(検証していません)。

気づきましたか?JPAの自動生成メソッドは検索するキーワードの数が違うと、別のメソッドになります。キーワードの数に応じて動的にクエリ文を生成することができません。

JPA Specification

任意のクエリ文を動的に組み立てるのに、Criteria APIを使えばできないことはないですが、Criteria APIのドキュメントが少ないのと、書き方にちょっとクセがあることから、直接Criteria APIを使うことはオススメしません。

そんなCriteria APIを使わなくても、JPA Specification2を使えば、ほぼ同じ効果が得られます。
JPA Specificationは検索条件を表すインターフェースで、JpaSpecificationExecutorを継承すれば、

findAll(Specification<T> spec);
findAll(Specification<T> spec, Pageable pageable);

などのメソッドが使えるようになります。

今回の「複数キーワードによる絞り込み」は、このJPA Specificationで簡単に実装します。

環境

JVM 1.8.0_92
Gradle 4.7
Windows 7 64bit

複数キーワードによる絞り込みの実装

実装するにあたって、
Spring Data JPA の Specificationでらくらく動的クエリー
を参考させていただきました。

まず、JPA Specificationを利用するため、ItemRepositoryにJpaSpecificationExecutor<Item>を継承させます。

ItemRepository.java
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;

public interface ItemRepository extends
    JpaRepository<Item, Integer>, JpaSpecificationExecutor<Item> {
}

次に、単一キーワードによる絞り込み条件を表すSpecificationを返すためのヘルパー関数をItemServiceに作ります。
分割された単一キーワードからSpecificationを生成して、後述のSpecification<T>::and(Specification<T>)で結合させたら、複数キーワードの絞り込みになる寸法です。

ItemService.java
private Specification<Item> nameContains(String name) {
   return new Specification<Item>() {
       @Override
       public Predicate toPredicate(Root<Item> root, CriteriaQuery<?> query, CriteriaBuilder cb) {
           return cb.like(root.get(Item_.name), "%" + name + "%");
       }
   };
}

このroot.get(Item_.name)の部分をroot.get("name")と書いても、問題ないです。ただしItem_.nameのように、Itemのメタモデルクラスからカラム名を取得するやり方のほうが、より変更に強く、堅牢な作りになると思います。
このメタモデルクラスに関して、最後で説明します。

次に、"word1 word2 word3"のようなクエリ文をばらして、["word1", "word2", "word3"]にするのに、以下のヘルパー関数を作ります。

ItemService.java
private List<String> splitQuery(String query) {
    final String space = " ";
    // 半角スペースと全角スペースの組み合わせのパターンを表す
    final String spacesPattern = "[\\s ]+";
    // 以上のパターンにマッチした部分を単一の半角スペースに変換する
    final String monoSpaceQuery = query.replaceAll(spacesPattern, space);
    // splitするとき、余分な空要素が生成されるのを防ぐため、先頭と末尾のスペースを削除する
    final String trimmedMonoSpaceQuery = monoSpaceQuery.trim();
    // 半角スペースでクエリをsplitする
    return Arrays.asList(trimmedMonoSpaceQuery.split("\\s"));
}

以上の2つのヘルパー関数を利用すれば、"word1 word2 word3"のようなクエリ文を引き取って、nameカラムにword1word2word3を含むItemを検索する関数を簡単に作れました。

ItemService.java
public Page<Item> findAll(String nameQuery, Pageable pageable) {
    // クエリを複数キーワードに分割する
    final List<String> keywords = splitQuery(nameQuery);
    // 何もしないSpecificationを生成する。reduceの初期値として利用する
    // Specification.where()にnullを渡せば、何もしないSpecificationが生成される
    final Specification<Item> zero = Specification.where((Specification<Item>)null);
    // キーワードのリストをそれぞれSpecificationにマッピングして、andで結合する
    final Specification<Item> spec = keywords
        .stream()
        .map(this::nameContains)
        .reduce(zero, Specification<Item>::and);

    return itemRepository.findAll(spec, pageable);
}

SpecificationのAND条件の結合は、Specification<T>::and(Specification<T>)を使います。例えば条件が3つの場合は

Specification<Item> spec = Specification.where(nameContains(word1))
    .and(nameContains(word2))
    .and(nameContains(word2));

で作れますが、今回は不特定多数のキーワードに対応するため、Stream APIを使って書きました。

メタモデルクラス

メタモデルクラスの生成は、たぶん今回の一番わかりづらい部分です。メタモデルクラスはEntityのフィールドを表すためのクラスらしいです。
このメタモデルクラスの名前は慣習的に、対応するEntityの名前の後ろに、アンダーバーをつけます。
例えば、今回のItem.java

Item.java
@Entity
@Table(name = "items")
public class Item {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    public Integer id;

    @Column(nullable = false)
    public String name;

    @Column(nullable = false)
    public Double price;
}

に対して、メタモデルクラスは

Item_.java
@StaticMetamodel(Item.class)
public abstract class Item_ {
    public static volatile SingularAttribute<Item, Double> price;
    public static volatile SingularAttribute<Item, String> name;
    public static volatile SingularAttribute<Item, Integer> id;

    public static final String PRICE = "price";
    public static final String NAME = "name";
    public static final String ID = "id";
}

になります。

このItem_.javaを手動生成しても大丈夫ですが、自動生成する方法もあります。Gradleの場合、build.gradleに以下のように記述すれば、コンパイル時に自動的にbuild/classes/java/mainディレクトリの下にItem_.javaを作ります。

build.gradle
dependencies {
    compileOnly group: 'org.hibernate', name: 'hibernate-jpamodelgen', version: '5.3.1.Final'
}

Mavenの場合はこちら3をご参照ください。

メタモデルクラスが自動生成されるようにしましたが、ただし、このメタモデルクラスはソースフォルダにないので、これだけではIDEなどによる自動補完が効きません。なので、Gradleの場合は、手動でbuild/classes/java/mainをパスに追加する必要があります。

build.gradle
sourceSets {
    main {
        java {
            srcDirs 'build/classes/java/main/'
        }
    }
}

Mavenの場合はこちら4をご参照ください。

ソースファイル

今回の実装のソースファイルはGithubに上げてあります。
こちらをご参照ください

参照

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした