🌊

【JPA徹底入門】QueryDSLで実現する型安全な動的クエリ

19 min read

JPQL は JPA でクエリを実装するための標準的な手法ですが、

  • クエリを文字列としてハードコードするためタイプミスに気づきにくい・エンティティの変更に対して弱い[1]
  • 動的なクエリ[2]を実装する手段が提供されていない

といったデメリットがあります。これらの課題に対して JPA は標準機能として Criteria API と Metamodel API を提供していますが、可読性が低く常人がおいそれと手を出すことはできないです。そこで本記事では、QueryDSL というライブラリを利用して型安全に・動的なクエリを実装する手法について紹介したいと思います。

なお、本記事は以下の記事の続編で、データモデル・エンティティ・サンプルのクエリは同じものを利用しています。JPQL に関しても本記事で簡単に解説していますが、不明点があればこちらの記事も参照して下さい。

https://zenn.dev/sooogle/articles/1a13f48acd1a02

前提

この記事で解説するコードとサンプルデータは GitHub に掲載してあります。サンプルコードのパッケージはこちら

https://github.com/sooogle/jpademo

データモデル

spring-petclinic のデータモデルを一部カスタマイズしたものを利用します。

ERD

セットアップ

QueryDSL は JPA エンティティをもとに、Q{エンティティ名} というクラス名の型定義クラス(使い方は後述)をアノテーションプロセッサーで生成します。Gradle の場合、build.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>
  1. EntityManager のインスタンス em を利用して JPAQueryFactory を初期化します。[3]
  2. Pet エンティティに対するエイリアスを変数 p で定義します。QPet は QueryDSL のアノテーションプロセッサーが自動生成した型定義クラスです。
  3. JPQL の SELECT, FROM, WHERE, ORDER BY といった式に対応する select, from, where, orderBy メソッドが用意されているので、これらをメソッドチェーンで呼び出すことによって JPQL を表現します。コメントに対応する JPQL を書いてあるので、対応関係に注目してみて下さい。
  4. where メソッドでは エイリアス.プロパティ名.演算子 の形式で条件式を表現しています。JPQL で標準的な演算子 (=, <>, <, >, <=, >=, LIKE, BETWEEN, IN) や 関数 (UPPER, LOWER, TRIM など) はそのままの名前で用意されています。LIKE 検索に関しては startsWith(前方一致)、likeIgnoreCase(大文字小文字を無視して部分一致)などの専用メソッドがあるのでこちらを利用するといいでしょう。
  5. orderBy メソッドでは 昇順の場合 エイリアス.プロパティ名.asc(), 降順の場合 エイリアス.プロパティ名.desc() としてソート条件を指定します。
  6. 最後に fetch メソッドを呼び出すことにより、結果を List で取得することができます。

このサンプルコードから分かる通り、型定義クラス QPet はエンティティが持つプロパティをフィールドとして持ち(例. name フィールド)、さらに各プロパティはその型に応じて利用可能な演算子をメソッドとして実装しています(例. startsWith メソッド)。そのため、IDE の入力補完を受けながら流れるように・型安全にクエリを書けるのが QueryDSL の強みです。[4]

IntelliJ

また、プロパティの参照を型定義クラスのメソッド経由で行うため、メソッドの参照箇所を調べることによってエンティティ修正(=テーブル定義の変更)時の影響調査を容易に行うことができます。

AND / OR 条件

where メソッドの引数は可変長配列になっていて、条件式を複数渡すと AND で繋がれます。

AND (1)
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();

query.selectFrom(p)query.select(p).from(p) のショートカットです。これ以降のサンプルでは可能な場合こちらの表記を利用します。

and メソッドで条件式同士を繋いでも同じことが可能です(サンプルは省略しますが、OR 条件は or メソッドで表現します)。

AND (2)
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 で繋がれます。

AND (3)
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 の setMaxResultssetFirstResult に対応しています。

limitとoffset
JPQLQueryFactory query = new JPAQueryFactory(em);
QPet p = QPet.pet;
List<Pet> pets = query.selectFrom(p)
    .limit(5)
    .offset(0)
    .fetch();

fetchOne メソッドを利用すると結果が List ではなくスカラーで取得できます。

fetchOne
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 メソッドを実行すると、com.querydsl.core.NonUniqueResultException がスローされます。先頭の 1 件を取得したい場合、ソート条件を指定した上で fetchFirst メソッドを利用しましょう。fetchFirstlimit(1).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 メソッドを利用します)。

JOIN FETCH
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();
発行されるSQLのイメージ
SELECT p.*, o.* // owners の項目が含まれる
FROM pets p
INNER JOIN owners o ON p.owner_id = o.id

fetchJoin をつけ忘れると、後述する(無印の)JOIN と同じ動作になり、結合したエンティティの項目が SQL の SELECT 句に含まれなくなります(SELECT 句が SELECT p.* になります)。動作が全く異なるので注意しましょう。

次に、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 を表現することができます。実際のコードを見てみましょう。

ネストした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 を実行する必要はありません。

JOIN
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();
発行されるSQLのイメージ
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(結合条件) とう構文で実装可能です。

JOIN 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();
  1. select メソッドで取得対象エンティティのエイリアスをそれぞれ指定します。それに伴い、結果がエンティティで受け取れなくなるので戻りの型を com.querydsl.core.Tuple のリストとしています(クラス名が同じで紛らわしいのですが javax.persistence.Tuple とは別インターフェースなので注意して下さい)。
  2. 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 メソッドを利用します。

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 句を使うパターンです。

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 句に含める値を抽出するサブクエリを実装します。

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 メソッドで同様の式を表現することができます。

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 の件数を取得する」クエリです。

GROUP BY
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のクラスオブジェクト, 取得するプロパティ) とすることによってコンストラクタ式の記述が可能です。

Projections
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 で書くといい感じですね。

脚注
  1. IntelliJ IDEA を使うと JPQL の構文チェックと補完が効きますが、QueryDSL のようにプロパティの参照箇所を検索することはできません。 ↩︎

  2. 検索系の処理で必要になる、WHERE 句が動的に変化するクエリなどを指します。 ↩︎

  3. ここは JPQLQuery<Pet> query = new JPAQuery(em); と書くこともできます。 ↩︎

  4. 「Java で型安全にクエリを実装する」というコンセプトで作られたライブラリとして、他には jOOQ が有名です。Query DSL は SQL, JPA, Mongo DB と多様なプラットフォームに対応していますが、jOOQ は SQL に特化しています。 ↩︎

Discussion

ログインするとコメントできます