先日、Spring Data JPAでSpecificationという機能を使って動的クエリーを生成する機能を利用したので、その内容をまとめておこうと思います。
解決したい問題
このページでは以下の問題を解決する方法を紹介します。
ウェブの検索画面に7つの入力項目がある。
ユーザーはこれらのうちの最低一つでも入力すればよいが、組み合わせは自由で、全部入力してもいい。
入力があった項目のみで検索を行い、一致したデータを表示する
さらに掘り下げると、検索条件もいろいろあり、単に「○○に等しい」というだけでなく
- 処理日時が指定された日時以前または以降である
- 電話番号が一致する
- 処理結果が失敗か成功か(ウェブ上ではチェックボックス)
- 複数入力されたIDを含むレコードを選択
- あるIDからあるIDまでの範囲
といった感じで、Timestampカラムの比較、IN、BETWEENもあり、練習問題としても適しているなという感じでした。
Query By Exampleでは対応できないケース
Spring Data JPAにはQuery By Exampleという機能があり、これを使うと動的クエリーを生成できます。
-
Spring Data JPAで動的にクエリを生成するQuery by Example機能
先日、同僚からのある質問をきっかけにSpring Data JPAのQuery by Example機能を知ったので、その内容をまとめておきます。 Query by Exampleが解決する問題 問題 ...
続きを見る
この機能も非常に便利ですが、クエリー条件の指定に制約があり、単純な文字列の一致などにしか使えません(2019年5月現在)。
今回の要件では文字列の完全一致だけでなく、Dateの比較やIN、BETWEENも行わないといけないので、今回はQuery By Exampleは除外されました。
Specificationの使用方法
実際のコードを公開することはできないので、ここからはサンプルを使ってコードを紹介していこうと思います。
エンティティ
今回のサンプルでは以下のエンティティを使用します。
package hello;
import java.util.Date;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String firstName;
private String lastName;
private String emailAddress;
private int age;
private String gender;
private Date registrationDate;
}
レポジトリインターフェイス
続いてレポジトリインターフェイスです。
注目点は、JpaRepositoryのほかにJpaSpecificationExecutorというクラスも継承している点です。
package hello;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface CustomerRepository extends JpaRepository<Customer, Long>, JpaSpecificationExecutor<Customer> {
}
JpaSpecificationExecutor を継承したことで、Specification<Customer> をパラメータとして受け取るfindAll とfindOne メソッドが利用可能になり、JPA Specificationを使った検索をする場合はこれらのメソッドを使います。
Specificationを生成するクラス
エンティティとレポジトリインターフェイスを作成したら、次はSpecificationを生成するクラスを作ります。
クラスの名前やメソッド名などに「お作法」や「コンベンション」があるのかわからなかったので、ここではSpring Data JPAのサンプルを参考にして以下のようなクラスを作成しました。
package hello;
import org.springframework.data.jpa.domain.Specification;
import java.util.Date;
import java.util.List;
public class CustomerSpecs {
private CustomerSpecs() {
}
public static Specification<Customer> firstNameEquals(String firstName) {
return firstName == null ? null : (root, query, builder) ->
builder.equal(root.get("firstName"), firstName);
}
public static Specification<Customer> lastNameEquals(String lastName) {
return lastName == null ? null : (root, query, builder) ->
builder.equal(root.get("lastName"), lastName);
}
public static Specification<Customer> emailAddressEquals(String emailAddress) {
return emailAddress == null ? null : (root, query, builder) ->
builder.equal(root.get("emailAddress"), emailAddress);
}
public static Specification<Customer> registrationDateAfter(Date registrationDate) {
return registrationDate == null ? null : (root, query, builder) ->
builder.greaterThan(root.get("registrationDate"), registrationDate);
}
public static Specification<Customer> registrationDateBefore(Date registrationDate) {
return registrationDate == null ? null : (root, query, builder) ->
builder.lessThanOrEqualTo(root.get("registrationDate"), registrationDate);
}
public static Specification<Customer> ageBetween(Integer minAge, Integer maxAge) {
return minAge == null || maxAge == null ? null : (root, query, builder) ->
builder.between(root.get("age"), minAge, maxAge);
}
public static Specification<Customer> genderIn(List<String> genderList) {
return genderList == null || genderList.isEmpty() ? null : (root, query, builder) ->
root.get("gender").in(genderList);
}
}
public staticなメソッドをいくつか定義していますが、それぞれのメソッドが一つの検索条件になっています。
例えば、firstNameEquals メソッドは、firstName を受け取り、それをequal で検索するようにCriteriaBuilder を設定しています。
上の構文は簡素化されたラムダ式ですが、実際にはこうなるようです。
public static Specification<Customer> firstNameEquals(String firstName) {
if (firstName == null) {
return null;
} else {
return new Specification<Customer>() {
@Override
public Predicate toPredicate(Root<Customer> root, CriteriaQuery<?> criteriaQuery, CriteriaBuilder criteriaBuilder) {
return criteriaBuilder.equal(root.get("firstName"), firstName);
}
};
}
}
僕は省略されまくった構文もわかりづらいと思ってしまうのですが、かつての冗長なスタイルもやはりわかりづらいですね(笑)
実際に検索してみる
下準備が長くなりましたが、これで必要なものは全て揃ったので検索をしてみましょう。
ここでは以下のようなテストクラスを作ってクエリを試してみます。
あと、Hibernateがどんなクエリを生成したか確認するために、org.hibernateのログレベルをDEBUGに上げています。
テストクラス
package hello;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.test.context.junit4.SpringRunner;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.List;
@RunWith(SpringRunner.class)
@DataJpaTest
public class CustomerRepositoryTests {
@Autowired
private TestEntityManager entityManager;
@Autowired
private CustomerRepository customerRepository;
@Test
public void test() {
Specification<Customer> spec = Specification.where(CustomerSpecs.firstNameEquals("Taro"));
List<Customer> customerList = customerRepository.findAll(spec);
}
}
これを実行すると以下のログメッセージが確認されました。
Hibernateが生成するカラムのエイリアス名が長くてみづらいので簡素化していますが、
Specification<Customer> spec = Specification.where(CustomerSpecs.firstNameEquals("Taro"));
List<Customer> customerList = customerRepository.findAll(spec);
というコードに対して、以下のようなクエリが生成されたのがわかります。
[DEBUG] [o.h.SQL.logStatement] - select (省略) from customer customer0_ where customer0_.first_name=?
次は複数のカラムを組み合わせてみましょう。
Specification<Customer> spec = Specification.where(CustomerSpecs.firstNameEquals("Taro"))
.and(CustomerSpecs.registrationDateAfter(new Date()));
List<Customer> customerList = customerRepository.findAll(spec);
今回はregistrationDateAfterという条件も追加しました。
すると、以下のように生成されるクエリが変わっているのがわかりますね。
[DEBUG] [o.h.SQL.logStatement] - select (省略) from customer customer0_ where customer0_.first_name=? and customer0_.registration_date>?
最後に、BETWEENとIN句を使った条件もテストしてみましょうか。
Specification<Customer> spec = Specification.where(CustomerSpecs.ageBetween(20, 29))
.and(CustomerSpecs.genderIn(Arrays.asList("M", "F")));
List<Customer> customerList = customerRepository.findAll(spec);
生成されたクエリは以下の通りです。
[DEBUG] [o.h.SQL.logStatement] - select (省略) from customer customer0_ where (customer0_.age between 20 and 29) and (customer0_.gender in (? , ?))
期待通りにできましたね。
といいたいところですが、ちょっとだけ気になるのは、BETWEEN区のところです。本来ならば
between ? and ?
とパラメータになるはずなのに、20と29という値がクエリの中に直接埋め込まれていますね。
昔言われたことがあるのは、データベースエンジン(その時はオラクルでした)によっては、クエリ内の値が変わるごとに別のPreparedStatementを生成してキャッシュするため、データベースの無用な負荷をかけることになるそうです。
この辺りはこのメソッドの使用パターンにもよりますし、もっとも最近のデータベースはそんな問題ないのかもしれませんが・・・
気になる方は、ファンシーなBETWEENメソッドを避けて、greaterThanOrEqualとlessThanOrEqualを組み合わせた方が無難かもしれません。
情報の検索
以上、JPA Specificationを使って、Spring Data JPAで動的にクエリーを生成する方法を紹介しました。
ここで紹介しきれていない機能もたくさんあると思いますが、JPA SpecificationやQueryCriteriaなどをキーワードに含めて検索するとさまざまな情報が見つかると思うので、ぜひ検索してみてください。
また、本家のドキュメントはこちらから参照できます。
https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#specifications
参考:JPAメタモデルについて
サンプル内では、検索対象のカラム名をハードコードした文字列で直接していて、これでも問題ないといえばないのですが、カラム名が変更された時など、問題を発見するのが遅れる可能性がありますし、この手のバグは探すのに苦労するので、できればハードコードするのは避けたいところです。
この対処策としては、JPAアノテーションをスキャンしてメタモデルというクラスを自動生成し、メタモデルのプロパティを参照するようにすることです。
その方法については、http://docs.jboss.org/hibernate/jpamodelgen/1.0/reference/en-US/html_single/#whatisitで説明されていますが、僕は以下のプラグインをMavenに設定してメタモデルを自動生成されています。
<plugin>
<groupId>org.bsc.maven</groupId>
<artifactId>maven-processor-plugin</artifactId>
<version>3.3.3</version>
<executions>
<execution>
<id>process</id>
<goals>
<goal>process</goal>
</goals>
<phase>generate-sources</phase>
<configuration>
<outputDirectory>${project.build.directory}/metamodel</outputDirectory>
<processors>
<processor>org.hibernate.jpamodelgen.JPAMetaModelEntityProcessor</processor>
</processors>
</configuration>
</execution>
</executions>
</plugin>
僕の場合、エンティティばかりを集めたデータモデル専用のモジュールがあり、その中でメタモデルを生成してjarに含めていますので、アプリ本体のモジュールからこのjarを参照すればメタモデルが自動的に見れる状態になっています。
同一モジュール内でメタモデルを生成する場合、生成されたクラスをクラスパスに含めるように変更する必要がありそうですね。