本記事は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>
を継承させます。
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>)
で結合させたら、複数キーワードの絞り込みになる寸法です。
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"]
にするのに、以下のヘルパー関数を作ります。
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を検索する関数を簡単に作れました。
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
@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;
}
に対して、メタモデルクラスは
@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
を作ります。
dependencies {
compileOnly group: 'org.hibernate', name: 'hibernate-jpamodelgen', version: '5.3.1.Final'
}
Mavenの場合はこちら3をご参照ください。
メタモデルクラスが自動生成されるようにしましたが、ただし、このメタモデルクラスはソースフォルダにないので、これだけではIDEなどによる自動補完が効きません。なので、Gradleの場合は、手動でbuild/classes/java/main
をパスに追加する必要があります。
sourceSets {
main {
java {
srcDirs 'build/classes/java/main/'
}
}
}
Mavenの場合はこちら4をご参照ください。
ソースファイル
今回の実装のソースファイルはGithubに上げてあります。
こちらをご参照ください
参照
-
JPA query method creating https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.query-methods.query-creation ↩
-
Advanced Spring Data JPA - Specifications and Querydsl https://spring.io/blog/2011/04/26/advanced-spring-data-jpa-specifications-and-querydsl/ ↩
-
Create type-safe queries with the JPA static metamodel https://www.thoughts-on-java.org/static-metamodel/ ↩
-
Hibernate Tips: How to automatically add Metamodel classes to your project https://www.thoughts-on-java.org/hibernate-tips-automatically-add-metamodel-classes-project/ ↩