【JPA徹底入門】QueryDSLで実現する型安全な動的クエリ
JPQL は JPA でクエリを実装するための標準的な手法ですが、
といったデメリットがあります。これらの課題に対して JPA は標準機能として Criteria API と Metamodel API を提供していますが、可読性が低く常人がおいそれと手を出すことはできないです。そこで本記事では、QueryDSL というライブラリを利用して型安全に・動的なクエリを実装する手法について紹介したいと思います。
なお、本記事は以下の記事の続編で、データモデル・エンティティ・サンプルのクエリは同じものを利用しています。JPQL に関しても本記事で簡単に解説していますが、不明点があればこちらの記事も参照して下さい。
前提
この記事で解説するコードとサンプルデータは GitHub に掲載してあります。サンプルコードのパッケージはこちら
データモデル
spring-petclinic のデータモデルを一部カスタマイズしたものを利用します。
セットアップ
QueryDSL は JPA エンティティをもとに、Q{エンティティ名}
というクラス名の型定義クラス(使い方は後述)をアノテーションプロセッサーで生成します。Gradle の場合、build.gradle
で以下の依存性を追加します。
dependencies {
annotationProcessor 'jakarta.persistence:jakarta.persistence-api:2.2.3'
annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jpa'
implementation 'com.querydsl:querydsl-core:5.0.0'
implementation 'com.querydsl:querydsl-jpa:5.0.0'
}
QueryDSL のアノテーションプロセッサーから @Entity
などの JPA のアノテーションが参照できる必要があるので jakarta.persistence:jakarta.persistence-api
を追加しています。Maven 向けの設定は公式ドキュメントを参照して下さい。
単一エンティティに対するクエリ
まずは Pet エンティティを題材に、1 エンティティ(=1 テーブル)に対する基本的なクエリの実行方法を解説します。
Pet エンティティ
@Getter
@Setter
@Entity
@Table(name = "pets")
public class Pet {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "type_id")
private Type type;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "owner_id")
private Owner owner;
}
基本的な JPQL
JPQL の基本構文は以下の形式でした。
SELECT エイリアス FROM エンティティ名 エイリアス [WHERE ...] [ORDER BY ...]
「name が "L" で始まる Pet エンティティを name の昇順で取得する」という JPQL を QueryDSL で実装してみましょう。
JPQLQueryFactory query = new JPAQueryFactory(em); // <1>
QPet p = QPet.pet; // <2>
// SELECT p
// FROM Pet p
// WHERE p.name LIKE 'L%'
// ORDER BY p.name ASC
List<Pet> pets = query.select(p) // <3>
.from(p)
.where(p.name.startsWith("L")) // <4>
.orderBy(p.name.asc()) // <5>
.fetch(); // <6>
-
EntityManager
のインスタンスem
を利用してJPAQueryFactory
を初期化します。[3] - Pet エンティティに対するエイリアスを変数
p
で定義します。QPet
は QueryDSL のアノテーションプロセッサーが自動生成した型定義クラスです。 - JPQL の SELECT, FROM, WHERE, ORDER BY といった式に対応する
select
,from
,where
,orderBy
メソッドが用意されているので、これらをメソッドチェーンで呼び出すことによって JPQL を表現します。コメントに対応する JPQL を書いてあるので、対応関係に注目してみて下さい。 -
where
メソッドではエイリアス.プロパティ名.演算子
の形式で条件式を表現しています。JPQL で標準的な演算子 (=, <>, <, >, <=, >=, LIKE, BETWEEN, IN
) や 関数 (UPPER, LOWER, TRIM
など) はそのままの名前で用意されています。LIKE 検索に関してはstartsWith
(前方一致)、likeIgnoreCase
(大文字小文字を無視して部分一致)などの専用メソッドがあるのでこちらを利用するといいでしょう。 -
orderBy
メソッドでは 昇順の場合エイリアス.プロパティ名.asc()
, 降順の場合エイリアス.プロパティ名.desc()
としてソート条件を指定します。 - 最後に
fetch
メソッドを呼び出すことにより、結果を List で取得することができます。
このサンプルコードから分かる通り、型定義クラス QPet
はエンティティが持つプロパティをフィールドとして持ち(例. name
フィールド)、さらに各プロパティはその型に応じて利用可能な演算子をメソッドとして実装しています(例. startsWith
メソッド)。そのため、IDE の入力補完を受けながら流れるように・型安全にクエリを書けるのが QueryDSL の強みです。[4]
また、プロパティの参照を型定義クラスのメソッド経由で行うため、メソッドの参照箇所を調べることによってエンティティ修正(=テーブル定義の変更)時の影響調査を容易に行うことができます。
AND / OR 条件
where
メソッドの引数は可変長配列になっていて、条件式を複数渡すと AND で繋がれます。
JPQLQueryFactory query = new JPAQueryFactory(em);
QPet p = QPet.pet;
// SELECT p
// FROM Pet p
// WHERE p.name = 'Lucky'
// AND p.type.id = 2
List<Pet> pets = query.selectFrom(p)
.where(
p.name.eq("Lucky"),
p.type.id.eq(2)
)
.fetch();
and
メソッドで条件式同士を繋いでも同じことが可能です(サンプルは省略しますが、OR 条件は or
メソッドで表現します)。
JPQLQueryFactory query = new JPAQueryFactory(em);
QPet p = QPet.pet;
// SELECT p
// FROM Pet p
// WHERE p.name = 'Lucky'
// AND p.type.id = 2
List<Pet> pets = query.selectFrom(p)
.where(p.name.eq("Lucky").and(p.type.id.eq(2)))
.fetch();
where
メソッドを複数回実行しても条件式が AND で繋がれます。
JPQLQueryFactory query = new JPAQueryFactory(em);
QPet p = QPet.pet;
// SELECT p
// FROM Pet p
// WHERE p.name = 'Lucky'
// AND p.type.id = 2
List<Pet> pets = query.selectFrom(p)
.where(p.name.eq("Lucky"))
.where(p.type.id.eq(2))
.fetch();
この性質を活用すると、条件に応じて WHERE 句が変化する動的クエリを実装することができます。先ほどの例を少し修正すると「引数の name が指定されていた場合のみ、name での絞り込みを行う」findByName
メソッドを以下のように実装できます。
List<Pet> findByName(String name) {
JPQLQueryFactory query = new JPAQueryFactory(em);
QPet p = QPet.pet;
JPQLQuery<Pet> petQuery = query.selectFrom(p).where(p.type.id.eq(2));
if (name != null && !name.isEmpty()) {
petQuery.where(p.name.eq(name));
}
return petQuery.fetch();
}
結果件数の制御
ページネーションを行うため、limit
メソッド と offset
メソッドが用意されています。内部的には JPA の setMaxResults
と setFirstResult
に対応しています。
JPQLQueryFactory query = new JPAQueryFactory(em);
QPet p = QPet.pet;
List<Pet> pets = query.selectFrom(p)
.limit(5)
.offset(0)
.fetch();
fetchOne
メソッドを利用すると結果が List ではなくスカラーで取得できます。
JPQLQueryFactory query = new JPAQueryFactory(em);
QPet p = QPet.pet;
// SELECT p
// FROM Pet p
// WHERE p.name = 'Leo'
Pet pet = query.selectFrom(p)
.where(p.name.eq("Leo"))
.fetchOne();
fetchOne
メソッドと fetchFirst
メソッドは、結果が存在しない場合は null
を返します。
まとめ
- QueryDSL では、型定義クラス (
Q{エンティティ名}
) を活用して型安全に JPQL を表現する。 - 動的クエリは
where
メソッドの呼び出しを条件付きで行うことにより実現する。 - 結果の件数によって
fetch
,fetchOne
,fetchFirst
を使い分ける。
ManyToOne の関連を伴うクエリ
前節の例で見た Pet エンティティから見て @ManyToOne
の関連にある Owner エンティティを導入します。
Owner エンティティ
@Getter
@Setter
@Entity
@Table(name = "owners")
public class Owner {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "first_name")
private String firstName;
@Column(name = "last_name")
private String lastName;
private String address;
private String city;
private String telephone;
@OneToMany(mappedBy = "owner", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Pet> pets = new ArrayList<>();
}
JPQL の JOIN には JOIN FETCH / JOIN / JOIN ON という 3 種類の構文が存在しましたが、まずはそれぞれの用途を復習しておきましょう。
構文 | 用途 |
---|---|
JOIN FETCH | 関連エンティティを結合して一括で取得する。 |
JOIN | WHERE 句のみで参照するエンティティを結合する。 |
JOIN ON | 関連(@ManyToOne など)が定義されていないエンティティを結合する。 |
以上の 3 種類の JOIN を QueryDSL でどのように実装するかを解説していきます。
JOIN FETCH
Pet エンティティに対して Owner エンティティを JOIN FETCH して、Owner エンティティの取得を一括で行う例を考えます。QueryDSL では innerJoin(結合するプロパティ名).fetchJoin()
とすることによって JOIN FETCH が行えます(外部結合の場合は leftJoin
メソッドを利用します)。
JPQLQueryFactory query = new JPAQueryFactory(em);
QPet p = QPet.pet;
// SELECT p
// FROM Pet p
// INNER JOIN FETCH p.owner
List<Pet> pets = query.selectFrom(p)
.innerJoin(p.owner).fetchJoin()
.fetch();
SELECT p.*, o.* // owners の項目が含まれる
FROM pets p
INNER JOIN owners o ON p.owner_id = o.id
次に、Pet エンティティに対して @ManyToOne
の関連を持つ Visit エンティティを定義し、Visit, Pet, Owner の 3 エンティティを同時に取得するクエリを考えます。
Visit エンティティ
@Getter
@Setter
@Entity
@Table(name = "visits")
public class Visit {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "pet_id")
private Pet pet;
@Column(name = "visit_date")
private LocalDate visitDate;
private String description;
}
Hibernate では 中間エンティティである Pet エンティティに対してエイリアスを付与し、そのエイリアスに対して JOIN FETCH を指定することでネストした JOIN FETCH を表現することができます。実際のコードを見てみましょう。
JPQLQueryFactory query = new JPAQueryFactory(em);
QVisit v = QVisit.visit;
QPet p = QPet.pet;
// SELECT v
// FROM Visit v
// INNER JOIN FETCH v.pet p
// INNER JOIN FETCH p.owner
List<Visit> visits = query.selectFrom(v)
.innerJoin(v.pet, p).fetchJoin() // ここがポイント
.innerJoin(p.owner).fetchJoin()
.fetch();
innerJoin(v.pet, p)
の箇所がポイントで、Visit エンティティの pet プロパティに対して 第二引数で p
というエイリアスを与えています。
JOIN
「Owner の firstName が "George" である Pet を取得する。ただし、Owner の情報は不要」という例を考えます。このように、関連エンティティを WHERE 句のみで利用する場合は単に innerJoin
または leftJoin
メソッドで結合を行えばよく、fetchJoin
を実行する必要はありません。
JPQLQueryFactory query = new JPAQueryFactory(em);
QPet p = QPet.pet;
// SELECT p
// FROM Pet p
// INNER JOIN p.owner
// WHERE p.owner.firstName = 'George'
List<Pet> pets = query.selectFrom(p)
.innerJoin(p.owner)
.where(p.owner.firstName.eq("George"))
.fetch();
SELECT p.*
FROM pets p
INNER JOIN owners o ON p.owner_id = o.id
WHERE o.first_name = 'George'
JOIN ON
Pet エンティティから Owner エンティティに対する @ManyToOne
の関連を削除した PetNoRelation エンティティを定義します。
PetNoRelation エンティティ
@Getter
@Setter
@Entity
@Table(name = "pets")
public class PetNoRelation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String name;
@Column(name = "type_id")
private Integer typeId;
@Column(name = "owner_id")
private Integer ownerId;
}
PetNoRelation エンティティと Owner エンティティを同時に取得するクエリは、 innerJoin(結合するエンティティのエイリアス).on(結合条件)
とう構文で実装可能です。
JPQLQueryFactory query = new JPAQueryFactory(em);
QPetNoRelation p = QPetNoRelation.petNoRelation;
QOwner o = QOwner.owner;
// SELECT p, o
// FROM PetNoRelation p
// INNER JOIN Owner o ON o.id = p.ownerId
List<Tuple> tuples = query.select(p, o) // <1>
.from(p)
.innerJoin(o).on(o.id.eq(p.ownerId)) // <2>
.fetch();
-
select
メソッドで取得対象エンティティのエイリアスをそれぞれ指定します。それに伴い、結果がエンティティで受け取れなくなるので戻りの型をcom.querydsl.core.Tuple
のリストとしています(クラス名が同じで紛らわしいのですがjavax.persistence.Tuple
とは別インターフェースなので注意して下さい)。 - JOIN FETCH, JOIN の場合と異なり
on
メソッドで結合条件を明示的に指定します。
まとめ
JPQL | QueryDSL |
---|---|
INNER JOIN FETCH | innerJoin(結合するプロパティ名).fetchJoin() |
INNER JOIN | innerJoin(結合するプロパティ名) |
INNER JOIN ON | innerJoin(結合するエンティティのエイリアス).on(結合条件) |
OneToMany / ManyToMany の関連を伴うクエリ
@ManyToOne
の例では「Pet エンティティを駆動表にして、Owner エンティティを結合する」というパターンのクエリを見てきました。この節では、逆に Owner エンティティを駆動表とするクエリについて解説していきます。
OneToMany に対する JOIN FETCH
@OneToMany
の関連にあるエンティティを JOIN すると、子エンティティの多重度だけレコードが重複します。この重複を除外するには distinct
メソッドを利用します。
JPQLQueryFactory query = new JPAQueryFactory(em);
QOwner o = QOwner.owner;
// SELECT DISTINCT o
// FROM Owner o
// INNER JOIN FETCH o.pets
List<Owner> owners = query.selectFrom(o)
.distinct()
.innerJoin(o.pets).fetchJoin()
.fetch();
selectDistinct
というメソッドも用意されており、query.selectFrom(o).distinct()
の箇所は query.selectDistinct(o).from(o)
と書くこともできます。
OneToMany の関連エンティティを検索条件にする
@OneToMany
の関連エンティティを検索条件にする例として「Pet としてネコ(typeId = 1)を飼っている Owner を取得する」クエリを考えます。まずは EXISTS 句を使うパターンです。
JPQLQueryFactory query = new JPAQueryFactory(em);
QOwner o = QOwner.owner;
QPet p = QPet.pet;
// SELECT o
// FROM Owner o
// WHERE EXISTS (
// SELECT 1
// FROM Pet p
// WHERE p.owner.id = o.id
// AND p.type.id = 1
// )
List<Owner> owners = query.selectFrom(o)
.where(
JPAExpressions.selectOne()
.from(p)
.where(
p.owner.id.eq(o.id),
p.type.id.eq(1)
)
.exists()
)
.fetch();
JPAExpressions.selectOne
でサブクエリを開始するのがポイントで、サブクエリの最後で exists
メソッドを実行して条件式を生成しています。
IN 句 を利用しても同じ処理が実現可能です。JPAExpressions.select
で IN 句に含める値を抽出するサブクエリを実装します。
JPQLQueryFactory query = new JPAQueryFactory(em);
QOwner o = QOwner.owner;
QPet p = QPet.pet;
// SELECT o
// FROM Owner o
// WHERE o.id IN (
// SELECT p.owner.id
// FROM Pet p
// WHERE p.type.id = 1
// )
List<Owner> owners = query.selectFrom(o)
.where(o.id.in(
JPAExpressions.select(p.owner.id)
.from(p)
.where(p.type.id.eq(1))
))
.fetch();
ManyToMany の関連エンティティを検索条件にする
@ManyToMany
の 関連にあるエンティティとして Vet (獣医) と Specialty (専門) を定義します。
Vet エンティティと Specialty エンティティ
@Getter
@Setter
@Entity
@Table(name = "vets")
public class Vet {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "first_name")
private String firstName;
@Column(name = "last_name")
private String lastName;
@ManyToMany
@JoinTable(name = "vet_specialties",
joinColumns = @JoinColumn(name = "vet_id", referencedColumnName = "id"),
inverseJoinColumns = @JoinColumn(name = "specialty_id", referencedColumnName = "id")
)
private List<Specialty> specialities;
}
@Getter
@Setter
@Entity
@Table(name = "specialties")
public class Specialty {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String name;
@ManyToMany
@JoinTable(name = "vet_specialties",
joinColumns = @JoinColumn(name = "specialty_id", referencedColumnName = "id"),
inverseJoinColumns = @JoinColumn(name = "vet_id", referencedColumnName = "id")
)
private List<Vet> vets;
}
「id = 2 の Specialty (専門) を持つ Vet (獣医) を検索する」クエリを考えます。JPQL では MEMBER OF という JPQL 独自の式がありましたが、QueryDSL では コレクション型のプロパティが持つ contains
メソッドで同様の式を表現することができます。
JPQLQueryFactory query = new JPAQueryFactory(em);
QVet v = QVet.vet;
QSpecialty s = QSpecialty.specialty;
// SELECT v
// FROM Vet v
// WHERE EXISTS (
// SELECT 1
// FROM Specialty s
// WHERE s MEMBER OF v.specialities
// AND s.id = 2
// )
List<Vet> vets = query.selectFrom(v)
.where(
JPAExpressions.selectOne()
.from(s)
.where(
v.specialities.contains(s),
s.id.eq(2)
)
.exists()
)
.fetch();
まとめ
-
@OneToMany
の関連エンティティを結合する場合、distinct
メソッドで重複を除外する。 - サブクエリは
JPAExpressions.selectOne
,JPAExpressions.select
等で生成する。
集計を伴うクエリ
最後に集計です。QueryDSL を使ったとしても、JPA は集計処理が苦手という事実は変わりないのでさらりと解説します。
GROUP BY
集計を伴うクエリでは、groupBy
メソッドでグルーピング条件を指定します。以下の例は「Owner エンティティに加え、その Owner が飼っている Pet の件数を取得する」クエリです。
JPQLQueryFactory query = new JPAQueryFactory(em);
QOwner o = QOwner.owner;
QPet p = QPet.pet;
// SELECT o, COUNT(p)
// FROM Owner o
// LEFT JOIN Pet p ON p.owner.id = o.id
// GROUP BY o
List<Tuple> results = query.select(o, p.count())
.from(o)
.leftJoin(p).on(p.owner.id.eq(o.id))
.groupBy(o)
.fetch();
集計関数は JPA 標準の COUNT, MIN, MAX, SUM, AVG が利用可能です。
コンストラクタ式
結果を Tuple で受け取ると、QueryDSL の強みである型安全性を最大限に生かすことができないので 結果を DTO (Data Transfer Object) で受け取ることを考えます。JPQL のコンストラクタ式では、取得したプロパティをコンストラクタで受け取る DTO を定義し、SELECT new DTO(...)
とすることによって結果を DTO で受け取ることができました。Owner エンティティと Pet の件数をコンストラクタで受け取る OwnerAndCountDTO
を定義します。
OwnerAndCountDTO
public class OwnerAndCountDTO {
private final Owner owner;
private final Long count;
public OwnerAndCountDTO(Owner owner, Long count) {
this.owner = owner;
this.count = count;
}
// getter省略
}
QueryDSL では、select
メソッドの中で Projections.constructor(DTOのクラスオブジェクト, 取得するプロパティ)
とすることによってコンストラクタ式の記述が可能です。
JPQLQueryFactory query = new JPAQueryFactory(em);
QOwner o = QOwner.owner;
QPet p = QPet.pet;
// SELECT new com.github.sooogle.jpademo.entitysub.OwnerAndCountDTO(o, COUNT(p))
// FROM Owner o
// LEFT JOIN Pet p ON p.owner.id = o.id
// GROUP BY o
List<OwnerAndCountDTO> dtos = query.select(Projections.constructor(OwnerAndCountDTO.class, o, p.count()))
.from(o)
.leftJoin(p).on(p.owner.id.eq(o.id))
.groupBy(o)
.fetch();
JPQL のコンストラクタ式はパッケージ名を含む完全修飾クラス名を文字列で指定する必要があり、冗長でリファクタリングにも弱かったのですが、QueryDSL で書くといい感じですね。
-
IntelliJ IDEA を使うと JPQL の構文チェックと補完が効きますが、QueryDSL のようにプロパティの参照箇所を検索することはできません。 ↩︎
-
検索系の処理で必要になる、WHERE 句が動的に変化するクエリなどを指します。 ↩︎
-
ここは
JPQLQuery<Pet> query = new JPAQuery(em);
と書くこともできます。 ↩︎ -
「Java で型安全にクエリを実装する」というコンセプトで作られたライブラリとして、他には jOOQ が有名です。Query DSL は SQL, JPA, Mongo DB と多様なプラットフォームに対応していますが、jOOQ は SQL に特化しています。 ↩︎
Discussion