Spring Data JPAのSpecificationでページング時にJOIN FETCHすると例外が発生する

@OneToMany でリレーションを設定したJPA Entityに対し、Spring Data JPAのSpecificationで動的クエリを発行している。

N+1問題の回避のため、 JOIN FETCH して findAll(Specification) で検索していたが、ページングすることとなり、 findAll(Specification, Pageable) に切り替えたところ、実行時に例外が発生するようになった。

原因と対応方法を調べたのでメモ。

問題

@OneToMany でリレーションを設定したJPA Entityに対し、Spring Data JPASpecificationで動的クエリを発行している。

JpaRepository#findAll した場合、デフォルトでは @OneToMany を付与したフィールドを参照したタイミングでリレーション先のエンティティ取得のSQLが実行され、N+1問題が発生する。

いくつか対応方法はあるが、 JOIN FETCH することで1クエリで関連エンティティも取得できるため、 Specification#toPredicate 内で Root#fetch し、 JpaSpecificationExecutor#findAll(Specification) で検索していた。

// Specificationをラムダ式でインスタンス化
return (root, query, cb) -> {
    root.fetch(EntityClass_.relatedEntities, JoinType.LEFT);

    // 検索条件を設定
}

同様の検索条件でページングすることとなり、単純に List を返す findAll から Page を返す JpaSpecificationExecutor#findAll(Specification, Pageable) に切り替えたところ、実行時に以下の例外が発生するようになった。

java.lang.IllegalArgumentException: org.hibernate.QueryException: query specified join fetching, but the owner of the fetched association was not present in the select list [...] [select count(generatedAlias0) from EntityClass as generatedAlias0 inner join fetch generatedAlias0.relatedEntities as generatedAlias1 ...]

対応

エラーメッセージは「フェッチしているのに、SELECTで使ってない」的な内容。JPQLが出力されているが、そこにも select count(関連元エンティティ) が出力されている。

Page#getTotalElements で使用する件数取得のために、SELECT句を COUNT に変換したクエリが実行されるが、それにより例外が発生している模様。

エラーメッセージで検索すると spring data jpa specification join fetch is not working - Stack Overflow が出てきた。

その中のリンクから飛んだ、 java - Eager fetching in a Spring Specification - Stack Overflow に対応方法があった。

Specification#toPredicate の第2引数の CriteriaQuery#getResultType で戻り値の型を取得できる。

これが Long であればカウントクエリとみなし、その場合はフェッチしないようにすればいい。

return (root, query, cb) -> {
    if (Long.class != query.getResultType()) {
        root.fetch(EntityClass_.relatedEntities, JoinType.LEFT);
    }

    // 検索条件を設定
}

この変更で、例外なく実行できるようになった。

振り返り

spring data specification join fetch でWeb検索したら、そのものズバリなStack Overflowの質問も出てきた。

stackoverflow.com

ここでは Long.class 以外に long.class とも比較しているが、 Long.class だけで動いている。

うまいこと解消しなければ、検索とカウントでクエリを分けようかと思っていたので、シンプルに対応できてよかった。

参考

JPAでのN+1問題と対応方法

meetup-jp.toast.com