Spring Data JPA を利用した環境において、QueryDSL にてサブクエリを用いたテーブル結合を実現する

More than 1 year has passed since last update.


業務要件

例えば、Spring Data JPA にて購買システムを実装していると想定します。購買システムには、購買履歴が存在します。

購買システムの "購買" エンティティでは、下記のような属性があるとします。


  • 購買履歴


    • id

    • 購入品名

    • 購入店

    • 購入店区分

    • 購入日

    • 購入者ID



業務要件で、直近一年以内で購入した商品と、購入店区分を取得し、別エンティティの値オブジェクトとする必要が生じました。

SQL はこんな感じになるかと思います。


example.sql

select

a.purchase_user_id,
b.purchase_date,
a.shop_type,
a.name
from
purchase_history a
inner join
(select
purchase_user_id,
max(purchase_date)) as purchase_date
from
purchase_history
where
purchase_date >= now() - INTERVAL 1 YEAR
and purchase_user_id in ?
and shop_type = 'BOOK'
group by
purchase_user_id
) b
on a.purchase_user_id = b.purchase_user_id
where
shop_type = 'BOOK'
;

エンティティについてはこんな感じでしょうか。


PurchaseHistory.java

@Entity

@Getter
@NoArgsConstructor
public class PurchaseHistory {

@GeneratedValue(strategy = GenerationType.AUTO)
@Id
private int id;

@Column(nullable = false)
private String name;

@Column(nullable = false)
private String purchaseShop;

@Column(nullable = false)
@Enumerated(EnumType.STRING)
private ShopType shopType;

@Column(nullable = false)
private LocalDate purchaseDate;

@Column(nullable = false)
private int purchaseUserId;

}



実現方法

JDBC で実現することも可能ですが、O/R マップが面倒です。

JPQL での実現の場合、公式ページ によると From 句のサブクエリは、JPQL にてサポートされていないので、データの取得を別々に取得して、プログラム上での結合が必要となり、多くの実装が要求されます。


Subqueries are restricted to the WHERE and HAVING clauses in this release. Support for subqueries in the FROM clause will be considered in a later release of the specification.


このため、QueryDSL の SqlQueryFactory クラスにて通常のクエリを組み立てます。

SqlQueryFactory は、JPAQueryFactory とは異なり、JPQL を生成せず、ネイティブの SQL を生成します。

また、ついでに必要なカラムのみ取得したいので、射影用のクラスを作成し、値オブジェクトとして DTO のようなクラスを作成します。


参考文献


SQL (SqlQueryFactory) 用

http://www.querydsl.com/static/querydsl/4.0.9/reference/html/ch02s03.html#d0e1439


JPQL (JPAQueryFactory)用

http://www.querydsl.com/static/querydsl/latest/reference/html/ch02.html

http://www.baeldung.com/querydsl-with-jpa-tutorial

http://www.baeldung.com/intro-to-querydsl


0. QueryDSL の導入

こちらの記事 の方法にて導入できます。


1. 射影用のクラスの作成

JPQL では、不要なカラムも含めて全てのフィールドが DB より取得されます。SqlQueryFactory では、必要なカラムのみ射影することが可能です。

システム的な必要性の他に、そもそもエンティティの用途と異なるオブジェクトを加工して、エンティティに付属した一意に決まらないようなオブジェクトにする訳ですから、値オブジェクトとして別途クラスを作成することは有効であると判断できます。

ドメインの関心事を中心に置いた結果、必要なカラムだけ取得できるようになります。


NewestPurchase.java


@MappedSuperclass // テーブルがあるわけではないので、Entity にしない。Embeddedable にすると join が使えない。
@Getter
@NoArgsConstructor
public class NewestPurchase {

private ShopType shopType;

private int purchaseUserId;

private LocalDate purchaseDate;

private String name;

/**
* 必要な情報を全て格納するためのコンストラクタです。
* JPA とは異なり、Enum の自動変換ができないので、文字列を手動で enum に返還します。
*/

public NewestPurchase(int purchaseUserId, LocalDate purchaseDate, String shopType, String name) {
this.shopType = ShopType.convert(shopType);
this.purchaseDate = purchaseDate;
this.purchaseUserId = purchaseUserId;
this.name = name;
}

/**
* Group By にて最低限必要なフィールドをインジェクとするためのコンストラクタです。
*/

public NewestPurchase(int purchaseUserId, LocalDate purchaseDate) {
this.purchaseDate = purchaseDate;
this.purchaseUserId = purchaseUserId;
}
}



2. スネークケースへの対応

JPA のデフォルトの NamingStrategy では、スネークケースが採用されます。

そのため、Entity のデフォルトのテーブル名は、table_name.column_name の形式となります。

一方、QueryDSL にて自動で生成されるソースコード (@Entity, @MappedSuperclass などのアノテーションのついたクラスに対して生成されます)では、TableName.columnName のパスカル + キャメルが採用されます。

これは、該当のエンティティのパスを構築する際に、コンストラクタの引数に文字列を渡すことで解消します。


  • テーブル名


    • コンストラクタにテーブル名を引数として渡します。



private QPurchaseHistory qPurchaseHistory = new QPurchaseHistory("purchase_history");


  • カラム名


    • コンストラクタに下記を設定します。


      • パス (列) の型 (int ならば Integer.class)

      • 対応するテーブルのパスオブジェクト (上記のテーブル名で生成した qPurchaseHistory インスタンス)

      • 列名の文字列





private NumberPath<Integer> pathPurchaseUserId = Expressions.numberPath(Integer.class, qPurchaseHistory, "purchase_user_id");


3. サブクエリの構築

SQLQueryFactory にてサブクエリを構築します。現在これを実践している資料を確認できませんでしたが、本家のテストコード にて確認できます。


SubQueryExpression<?> subQuery = select(employee.id, Expressions.constant("XXX"), employee.firstname).from(employee);


ポイントは、


  • com.querydsl.core.types.dsl.Expressions#select にて SubQueryExpression を実装したインスタンスを生成

  • innerJoin() メソッドの引数にエイリアスのパスを設定する。

です。特に後者についての情報がないので、困りましたが、innerJoin() メソッドのソースコード上からパスが必要であることがわかります。

こんなコードになるかと思います。


PurchaseHistoryRepositoryImpl.java

@Repository

@RequiredArgsConstructor
public class PurchaseHistoryRepositoryImpl implements PurchaseHistoryRepository {

private final SQLQueryFactory sqlQueryFactory;

private QPurchaseHistory qPurchaseHistory = new QPurchaseHistory("purchase_history");
private QNewestPurchase maxDate = new QNewestPurchase("max_date"); // エイリアス設定

public List<NewestPurchase> findChargePeriodByIds(List<Integer> Ids, PurchasedDate purchaseDate) {

NumberPath<Integer> pathPurchaseUserId = Expressions.numberPath(Integer.class, qPurchaseHistory, "purchase_user_id");
DatePath<LocalDate> pathPurchasedDate = Expressions.datePath(LocalDate.class, qPurchaseHistory, "purchase_date");
StringPath pathPurchaseShopType = Expressions.stringPath(qPurchaseHistory, "shop_type");

DatePath<LocalDate> selectPurchasedDate = Expressions.datePath(LocalDate.class, maxDate, "purchase_date");
NumberPath<Integer> selectedPurchaseUserId = Expressions.numberPath(Integer.class, maxDate, "purchase_user_id");

SubQueryExpression<NewestPurchase> subQuery = SQLExpressions.select(Projections.constructor(NewestPurchase.class, // 引数が2つのコンストラクタにてインジェクション
pathPurchaseUserId,
pathPurchasedDate.max().as(selectPurchasedDate))
)
.from(qPurchaseHistory)
.where(
pathPurchaseUserId.in(Ids)
.and(pathPurchaseHistoryType.eq(PurchaseHistoryType.BOOK_STORE.name()))
.and(pathPurchasedDate.goe(purchaseDate.day().minusYears(1)))
)
.groupBy(pathPurchaseUserId);

return sqlQueryFactory
.select(Projections.constructor(NewestPurchase.class, // 引数が 4 つのコンストラクタにてマップ
pathPurchaseUserId,
selectPurchasedDate,
pathPurchaseShopType,
QPurchaseHistory.purchaseHistory.name // スネークケースで解釈されても問題ない
))
.from(qPurchaseHistory)
.innerJoin(subQuery, maxDate)
.on(pathPurchaseUserId.eq(selectedPurchaseUserId)
.and(pathPurchasedDate.eq(selectPurchasedDate)))
.where(pathPurchaseHistoryType.eq(PurchaseHistoryType.BOOK_STORE.name()))
.fetch();
}
}



補足 : Projections.constructor について

QueryDSLでは Projections.bean で setter にて値オブジェクトにマップすることができますが、Immutable にするため、コンストラクタにてマップしてます。

コンストラクタが 2 つ必要な理由については、上述の通り、2 フィールドだけ、Projections する場合と、全フィールド Projections する場合があるためです。


感想

Criteria API よりもマシか、という印象です。

Criteria API は文字列を使用することを強要しますし、MetaModel を使った場合、ドメイン層にインフラストラクチャである、@StaticMetaModel のクラスを配置しないと NullPointerException になるので、ノイズになります。

ついでに言うと、MetaModel はエンティティ定義が変わるたびに手動で手を入れるハメになりますが、QueryDSL のソースコードは自動で生成されます。