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

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

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

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

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

# なにが問題なの？

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

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

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

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

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

```java
findByNameContaining(String name);
```

です。簡単ですね。

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

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

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

```java
findByNameContainingAndNameContaining(String word1, String word2);
```

こうです（検証していません）。

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

# JPA Specification

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

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

```java
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でらくらく動的クエリー](https://qiita.com/tag1216/items/3a408d2751a6310e2948)
を参考させていただきました。

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

```java: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>)`で結合させたら、複数キーワードの絞り込みになる寸法です。

```java: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"]`にするのに、以下のヘルパー関数を作ります。

```java: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`カラムに`word1`と`word2`と`word3`を含むItemを検索する関数を簡単に作れました。

```java: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つの場合は

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

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

# メタモデルクラス

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

```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;
}
```

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

```java: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`を作ります。

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

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

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

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

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

# ソースファイル

今回の実装のソースファイルはGithubに上げてあります。
[こちら](https://github.com/ibara1454/practice/tree/master/spring/JPASpecification)をご参照ください



# 参照
[^1]: JPA query method creating https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.query-methods.query-creation
[^2]: Advanced Spring Data JPA - Specifications and Querydsl https://spring.io/blog/2011/04/26/advanced-spring-data-jpa-specifications-and-querydsl/
[^3]: Create type-safe queries with the JPA static metamodel https://www.thoughts-on-java.org/static-metamodel/
[^4]: Hibernate Tips: How to automatically add Metamodel classes to your project https://www.thoughts-on-java.org/hibernate-tips-automatically-add-metamodel-classes-project/