spring-data-jpa + Hibernateで子Entityをフィルタする方法

## はじめに
タイトルにあることを実現するためには何種類か方法があります。

- children側を`org.hibernate.annotations.Where`で修飾する
- `join on` + EntityGraph
- `org.hibernate.annotations.Filter` (これは難しいのでパス)

どの方法が自分にとって良いか検証した結果を以下に記載します。

### コード
https://github.com/sis-yoshiday/spring-data-jpa-filtering-children

## `org.hibernate.annotations.Where`を使う

### Article-Commentsモデル+リポジトリ

典型的な`OneToMany`なパターンです

```java
@Getter
@Entity @Table(name = "articles")
public class Article {

  @Id @GeneratedValue
  @Column(name = "id")
  private Integer id;

  @Where(clause = "active = true")// ここ
  @OneToMany(mappedBy = "article")
  private Set<Comment> comments;
}

@Getter
@Entity @Table(name = "comments")
public class Comment {

  @Id @GeneratedValue
  @Column(name = "id")
  private Integer id;

  @Column(name = "active", nullable = false)
  private Boolean active;

  @ManyToOne(fetch = FetchType.LAZY, optional = false)
  @JoinColumn(name = "article_id", nullable = false)
  private Article article;
}

public interface ArticleRepository extends JpaRepository<Article, Integer> {

  @Query("select a from Article a left join fetch a.comments where a.id = ?1")
  Optional<Article> findById_JoinFetch(Integer id);

  @Query("select a from Article a where a.id = ?1")
  @EntityGraph(attributePaths = "comments")
  Optional<Article> findById_EntityGraph(Integer id);

  @Query("select a from Article a join a.comments c on c.active = false where a.id = ?1")
  @EntityGraph(attributePaths = "comments")
  Optional<Article> findById_Inactive(Integer id);
}
```

### 何もしない場合

```java
@Sql(statements = {
  "INSERT INTO articles(id) VALUES (1);",
  "INSERT INTO comments(id, article_id, active) VALUES (11, 1, 1), (12, 1, 1), (13, 1, 0);"
})
@Transactional(readOnly = true)
@Test
public void test_plain() {

  Optional<Article> actual = target.findById(1);

  assertThat(actual.get().getComments(), hasSize(2));
}
```

この時に実行されるSQL文は以下になります。

```
Hibernate:
    select
        article0_.id as id1_0_0_
    from
        articles article0_
    where
        article0_.id=?
Hibernate:
    select
        comments0_.article_id as article_3_1_0_,
        comments0_.id as id1_1_0_,
        comments0_.id as id1_1_1_,
        comments0_.active as active2_1_1_,
        comments0_.article_id as article_3_1_1_
    from
        comments comments0_
    where
        (
            comments0_.active = 1
        )
        and comments0_.article_id=?
```

当然ですがSQL文は複数回実行されます。
しかし「記事+アクティブなコメントのみを取得したい」という要求は満たしています。

### `join fetch`を使う場合

```java
@Sql(statements = {
  "INSERT INTO articles(id) VALUES (1);",
  "INSERT INTO comments(id, article_id, active) VALUES (11, 1, 1), (12, 1, 1), (13, 1, 0);"
})
@Transactional(readOnly = true)
@Test
public void test_join_fetch() {

  Optional<Article> actual = target.findById_JoinFetch(1);

  assertThat(actual.get().getComments(), hasSize(2));
}
```

この時に実行されるSQL文は以下になります。

```
Hibernate:
    select
        article0_.id as id1_0_0_,
        comments1_.id as id1_1_1_,
        comments1_.active as active2_1_1_,
        comments1_.article_id as article_3_1_1_,
        comments1_.article_id as article_3_1_0__,
        comments1_.id as id1_1_0__
    from
        articles article0_
    left outer join
        comments comments1_
            on article0_.id=comments1_.article_id
            and (
                comments1_.active = 1
            )
    where
        article0_.id=?
```

SQL文は1本にまとまり、結果も想定どおりです。
前者よりは良さそうです。

### EntityGraphを使う場合

```java
@Sql(statements = {
  "INSERT INTO articles(id) VALUES (1);",
  "INSERT INTO comments(id, article_id, active) VALUES (11, 1, 1), (12, 1, 1), (13, 1, 0);"
})
@Transactional(readOnly = true)
@Test
public void test_entity_graph() {

  Optional<Article> actual = target.findById_EntityGraph(1);

  assertThat(actual.get().getComments(), hasSize(2));
}
```

この時に実行されるSQL文は以下になります。

```
Hibernate:
    select
        article0_.id as id1_0_0_,
        comments1_.id as id1_1_1_,
        comments1_.active as active2_1_1_,
        comments1_.article_id as article_3_1_1_,
        comments1_.article_id as article_3_1_0__,
        comments1_.id as id1_1_0__
    from
        articles article0_
    left outer join
        comments comments1_
            on article0_.id=comments1_.article_id
            and (
                comments1_.active = 1
            )
    where
        article0_.id=?
```

`join fetch`の場合と同じです。

### 突然「記事+非アクティブなコメント」を取得しなければならないことになった

Q : 以下のテストは通るでしょうか？

```java
@Sql(statements = {
  "INSERT INTO articles(id) VALUES (1);",
  "INSERT INTO comments(id, article_id, active) VALUES (11, 1, 1), (12, 1, 1), (13, 1, 0);"
})
@Transactional(readOnly = true)
@Test
public void test_inactive() {

  // @Query("select a from Article a join a.comments c on c.active = false where a.id = ?1")
  // @EntityGraph(attributePaths = "comments")
  Optional<Article> actual = target.findById_Inactive(1);

  assertThat(actual.get().getComments(), hasSize(1));// 非アクティブなコメントだけ取れていて欲しい
}
```

A : 通りません

実行されるSQL文は以下です。

```
Hibernate:
    select
        article0_.id as id1_0_0_,
        comments1_.id as id1_1_1_,
        comments1_.active as active2_1_1_,
        comments1_.article_id as article_3_1_1_,
        comments1_.article_id as article_3_1_0__,
        comments1_.id as id1_1_0__
    from
        articles article0_
    inner join
        comments comments1_
            on article0_.id=comments1_.article_id
            and (
                comments1_.active = 1
            )  
            and (
                comments1_.active=0
            )
    where
        article0_.id=?
```

明らかに通らないので何か他の方法を考える必要がありそうです。

### 所感
- メリット
 - 暗黙的にフィルタ(`where`, `join on`)してくれる
 - 選択肢が多い（join fetchなし, join fetch, EntityGraphいずれでも実現可能）
- デメリット
 - クエリの見通しは落ちる
     - @Queryの中身から実際のクエリが想像しにくい、Entityの中も見る必要がある
     - ※ @QueryはEntity側にも定義可能だけどそれはそれでクエリの数によってはキツイ
 - Parent -> Childへのクエリ全てが影響を受けるのでクエリが増えてくると制御に苦しむ可能性がある
 - 逆のフィルタ条件を使いたい時に厳しい
 - Hibernate独自の機能なのでプロバイダに依存する

というわけで「便利な時もあるけど融通の効かないヤツ」といった印象です。

## `join on` + EntityGraph

### Department-Usersモデル+リポジトリ

これも典型的な`OneToMany`パターンです

```java
@Getter
@Entity
@Table(name = "departments")
public class Department {

  @Id
  @GeneratedValue
  @Column(name = "id")
  private Integer id;

  // @Whereは使わない
  @OneToMany(mappedBy = "department")
  private Set<User> users;
}

@Getter
@Entity
@Table(name = "users")
public class User {

  @Id
  @GeneratedValue
  @Column(name = "id")
  private Integer id;

  @Column(name = "active", nullable = false)
  private Boolean active;

  @ManyToOne(fetch = FetchType.LAZY, optional = false)
  @JoinColumn(name = "department_id", nullable = false)
  private Department department;
}

public interface DepartmentRepository extends JpaRepository<Department, Integer> {

  @Query("select d from Department d left join d.users u on u.active = true")
  Optional<Department> findById_Join(Integer id);

  @Query("select d from Department d left join d.users u on u.active = true")
  @EntityGraph(attributePaths = "users")
  Optional<Department> findById_EntityGraph(Integer id);

  @Query("select d from Department d left join d.users u on u.active = false")
  @EntityGraph(attributePaths = "users")
  Optional<Department> findById_Inactive(Integer id);
}
```

### `join on`のみの場合

Q : 以下のテストは通ると思いますか？

```java
@Sql(statements = {
  "INSERT INTO departments(id) VALUES (1);",
  "INSERT INTO users(id, department_id, active) VALUES (11, 1, 1), (12, 1, 1), (13, 1, 0);"
})
@Transactional(readOnly = true)
@Test
public void test_join() {

  // @Query("select d from Department d left join d.users u on u.active = true")
  Optional<Department> actual = target.findById_Join(1);

  assertThat(actual.get().getUsers(), hasSize(2));
}
```

A : 通りません

実行されるSQL文は以下です

```
Hibernate: 
    select
        department0_.id as id1_2_ 
    from
        departments department0_ 
    left outer join
        users users1_ 
            on department0_.id=users1_.department_id 
            and (
                users1_.active=1
            )
Hibernate: 
    select
        users0_.department_id as departme3_3_0_,
        users0_.id as id1_3_0_,
        users0_.id as id1_3_1_,
        users0_.active as active2_3_1_,
        users0_.department_id as departme3_3_1_ 
    from
        users users0_ 
    where
        users0_.department_id=?
```

1クエリ目はともかく2クエリ目で普通に全部取得しちゃっています。
一見動きそうに見える（自分的には）だけにこれはショックです。

### `join on` + EntityGraph

前者にグラフの指定を追加しただけのものです。

```java
@Sql(statements = {
  "INSERT INTO departments(id) VALUES (1);",
  "INSERT INTO users(id, department_id, active) VALUES (11, 1, 1), (12, 1, 1), (13, 1, 0);"
})
@Transactional(readOnly = true)
@Test
public void test_join_entity_graph() {

  Optional<Department> actual = target.findById_EntityGraph(1);

  assertThat(actual.get().getUsers(), hasSize(2));
}
```

テストは通ります。
グラフの指定の有無がポイントになり、これを忘れてしまうと怖いことが分かりました。

### 突然「部署+非アクティブなユーザ」を取得しなければならないことになった（2回目）

```java
@Sql(statements = {
  "INSERT INTO departments(id) VALUES (1);",
  "INSERT INTO users(id, department_id, active) VALUES (11, 1, 1), (12, 1, 1), (13, 1, 0);"
})
@Transactional(readOnly = true)
@Test
public void test_inactive() {

  Optional<Department> actual = target.findById_Inactive(1);

  assertThat(actual.get().getUsers(), hasSize(1));
}
```

今回は上記のテストは普通に通ります、SQL文は以下。

```
Hibernate:
    select
        department0_.id as id1_2_0_,
        users1_.id as id1_3_1_,
        users1_.active as active2_3_1_,
        users1_.department_id as departme3_3_1_,
        users1_.department_id as departme3_3_0__,
        users1_.id as id1_3_0__
    from
        departments department0_
    inner join
        users users1_
            on department0_.id=users1_.department_id
            and (
                users1_.active=0
            )
```

### `join fetch on`の場合

以下のクエリは実行できそうに見えます

```java
@Query("select d from Department d left join fetch d.users u on 
u.active=true where d.id = :id")
```

が、現在のところ`join fetch on`という仕様はないので↑の様なクエリは定義できず起動時にエラーで落ちます。
（他にありえないJPQLクエリ書いてた場合も同様に落としてくれるので助かる）

### 所感

- メリット
  - フィルタ条件を`@Where`よりは柔軟に制御できる
  - クエリ内で完結するという安心感、見通しもいい
  - プロバイダに依存しない（表面上は）
- デメリット
  - fetchできていない場合に思わぬバグを生み出す危険性がある
      - けどテスト書いてれば回避できる
  - 選択肢が狭い（`join on` + EntityGraphのみ、他にもあるかも？）
      - グラフが適用されないバグがあった場合とかに辛い

## まとめ

どちらにも長所・短所があるのでそれぞれの要件に応じて使い分けるのが良さそう。

### `@Where`が良さそうなケース
- 今後要件は絶対にブレないし、絶対にフィルタ条件を固定するという強い信念がある
- `@Where`が与える（意図しない）影響を自分やチームメンバーが制御できる自信がある

### `join on`+EntityGraphが良さそうなケース
- 色々な条件でフィルタする可能性がある
- プロバイダに絶対依存したくないマン
- 静的クエリだけどフィルタ条件を引数に取る必要がある場合
  - `join p.children c on c.foo = ?1` みたいな 
- 動的クエリでフィルタ条件が変わる様な場合

```java
// あまりいい例じゃないけどこんなイメージ
static Specification<Parent> spec(Integer id, @Nullable String foo) {
  return (root, query, cb) -> {
    Join<Parent, Child> jChildren = root.join(Parent_.children);

    if (foo != null) { 
      jChildren.on(cb.equal(jChildren.get(Child_.foo), foo);
    } else {
      jChildren.on(cb.equal(jChildren.get(Child_.foo), "bar");
    }

    // ...

    return cb.equal(root.get(Parent_.id), id);
  };
}
```

### 最後に
自分には`join on`+EntityGraphが合ってると思ったのでメインに採用しました。（他にいい方法あったら知りたい）

ちなみにシンプルなモデルだと大丈夫ですが

- `@Where`がなぜか効かない
- EntityGraphがなぜか適用されない

なんていうこともあったりするので即座にSQLログ確認できる状態にしておくのはどの方法を選択するにしても絶対に必須です。