ActiveRecordのjoins, eager_load, preloadで一生迷うのでフローチャートにした

# はじめに
メリークリスマス！ZOZOの@sasamukuです。

昨年は開発未経験の SRE がバックエンドエンジニアに転籍した話を書きました。

https://qiita.com/sasamuku/items/3f92d7f4edd74ca1b71f

転籍から1年経過し Ruby on Rails での開発にも慣れてきました。

しかし未だに迷うことがあります。それは `joins (left_joins)`, `eager_load`, `preload` の使い分けです。忘れてはググる日々に終止符を打つべく YES or NO 形式のフローチャートにしました。

本記事ではフローチャートの設問に基づきながら、各メソッドを利用すべきケースとその特性について説明していきます。

# フローチャート
フローチャートはこちらになります。

<img width="700" alt="active_record_flowchart.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/619762/63388f14-0105-8c3c-c94e-fc172272e059.jpeg">

設問は全部で5項目です。

* 関連付けをキャッシュするか
* 関連先テーブルのカラムで絞り込むか
* 複数モデルの関連付けをキャッシュするか
* `has_many` による関連付けであるか
* 関連先テーブルのレコードが存在するデータのみ必要か

# 想定するモデル
下記のモデルとその関連付けを用いて実装とクエリを確認していきます。

```models/**.rb
class User < ApplicationRecord
  has_many :posts
  has_many :photos
  has_many :movies
end

class Post < ApplicationRecord
  belongs_to :user
end

class Photo < ApplicationRecord
  belongs_to :user
end

class Movie < ApplicationRecord
  belongs_to :user
end
```

# joins
`joins` は下記を満たすときに適しています。

* 関連付けをキャッシュするか: `NO`
* 関連先テーブルのレコードが存在するデータのみ必要か: `YES`

下例では id = 1 である Post を持つ User を取得します。ここでは User だけが必要であり、Post は絞り込みのためだけに使われます。

```ruby:実装
User.joins(:posts).merge(Post.where(id: 1))
```

```sql:クエリ
SELECT "users".* FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE "posts"."id" = 1
```

関連付けをキャッシュしないので次のようなコードでは N+1 が発生します。

```ruby:実装
users = User.joins(:posts).merge(Post.where(id: [1, 2, 3]))
users.map(&:posts)
```

```sql:クエリ
SELECT "users".* FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE "posts"."id" IN (1, 2, 3)
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 1
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 2
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 3
```

# left_joins
`left_joins` は下記を満たすときに適しています。

* 関連付けをキャッシュするか: `NO`
* 関連先テーブルのレコードが存在するデータのみ必要か: `NO` 

下例では Post を1件も持たない User を取得します。

```ruby:実装
User.left_joins(:posts).merge(Post.where(id: nil))
```

```sql:クエリ
SELECT "users".* FROM "users" LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE "posts"."id" IS NULL
```

LEFT OUTER JOIN するので結合するレコードがなくても、左側テーブルのレコードが残ります。そのため、右側テーブルの要素が NULL であるレコードを取得できます。

`joins` は INNER JOIN ですので Post を1件も持たない User を取得することはできません。

```ruby
users = User.left_joins(:posts).merge(Post.where(id: nil))
users.map(&:id).uniq
#=> [4]
```

```ruby
users = User.joins(:posts).merge(Post.where(id: nil))
users.map(&:id).uniq
#=> []
```

# eager_load
`eager_load` は下記を満たすときに適しています。

* 関連付けをキャッシュするか: `YES`
* 関連先テーブルのカラムで絞り込むか: `YES`

`eager_load` は内部的には LEFT OUTER JOIN で結合しています。

下例では id = 1 である Post を持つ User を取得します。

```ruby:実装
User.eager_load(:posts).merge(Post.where(id: 1))
```

```sql:クエリ
SELECT DISTINCT "users"."id" FROM "users" LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE "posts"."id" = 1
SELECT "users"."id" AS t0_r0, "users"."name" AS t0_r1, "users"."created_at" AS t0_r2, "users"."updated_at" AS t0_r3, "posts"."id" AS t1_r0, "posts"."name" AS t1_r1, "posts"."user_id" AS t1_r2, "posts"."created_at" AS t1_r3, "posts"."updated_at" AS t1_r4 FROM "users" LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE "posts"."id" = 1 AND "users"."id" = 1
```

`eager_load` は関連付けをキャッシュするため N+1 は発生しません。

```ruby:実装
users = User.eager_load(:posts).merge(Post.where(id: [1, 2, 3]))
users.map(&:posts)
```

```sql:クエリ
SELECT "users"."id" AS t0_r0, "users"."name" AS t0_r1, "users"."created_at" AS t0_r2, "users"."updated_at" AS t0_r3, "posts"."id" AS t1_r0, "posts"."name" AS t1_r1, "posts"."user_id" AS t1_r2, "posts"."created_at" AS t1_r3, "posts"."updated_at" AS t1_r4 FROM "users" LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE "posts"."id" IN (1, 2, 3)
```

関連先テーブルのカラムで絞り込みしなくても、下記を満たすときも利用できます。

* 関連付けをキャッシュするか: `YES`
* 関連先テーブルのカラムで絞り込むか: `NO`
* 複数モデルの関連付けをキャッシュするか: `NO`
* `has_many` による関連付けであるか: `NO`
    * => `has_one` or `belongs_to` による関連付けである

これらの項目を満たすのは次のような実装です。

```ruby:実装
posts = Post.eager_load(:user)
posts.map { |post| post.user.name }
#=> ["User1", "User2", "User3"]
```

```sql:クエリ
SELECT "posts"."id" AS t0_r0, "posts"."name" AS t0_r1, "posts"."user_id" AS t0_r2, "posts"."created_at" AS t0_r3, "posts"."updated_at" AS t0_r4, "users"."id" AS t1_r0, "users"."name" AS t1_r1, "users"."created_at" AS t1_r2, "users"."updated_at" AS t1_r3 FROM "posts" LEFT OUTER JOIN "users" ON "users"."id" = "posts"."user_id"
```

上例では JOIN するテーブルは1つだけです。また関連先テーブルのレコードは必ず1つになるので、JOIN してもデータ量はそれほど増大しません。そのため `preload` よりパフォーマンス面で優位になる可能性があります。

# preload
`preload` は下記を満たすときに適しています。

* 関連付けをキャッシュするか: `YES`
* 関連先テーブルのカラムで絞り込むか: `NO`
* 複数モデルの関連付けをキャッシュするか: `YES`

`preload` は内部的には複数のクエリに分けて関連付けを取得します。

下例では User とその全ての Post, Photo, Movie を取得しています。

```ruby:実装
User.preload(:posts, :photos, :movies)
```

```sql:クエリ
SELECT "users".* FROM "users"
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN (1, 2, 3)
SELECT "photos".* FROM "photos" WHERE "photos"."user_id" IN (1, 2, 3)
SELECT "movies".* FROM "movies" WHERE "movies"."user_id" IN (1, 2, 3)
```

`preload` も関連付けをキャッシュするため N+1 は発生しません。

```ruby:実装
users = User.preload(:posts, :photos, :movies)
users.map { |u| [u.posts.map(&:name), u.photos.map(&:name), u.movies.map(&:name)].flatten }
#=> [["Post1", "Photo1", "Movie1"], ["Post2", "Photo2", "Movie2"], ["Post3", "Photo3", "Movie3"]]
```

```sql:クエリ
SELECT "users".* FROM "users"
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN (1, 2, 3)
SELECT "photos".* FROM "photos" WHERE "photos"."user_id" IN (1, 2, 3)
SELECT "movies".* FROM "movies" WHERE "movies"."user_id" IN (1, 2, 3)
```

`eager_load` も同様に複数モデルの関連付けをキャッシュできますが推奨されません。関連付けが多くなるほど JOIN するテーブルも増えます。そのため一般的に `preload` の方がパフォーマンス面で優位です。

```ruby:実装
users = User.eager_load(:posts, :photos, :movies)
users.map { |u| [u.posts.map(&:name), u.photos.map(&:name), u.movies.map(&:name)].flatten }
##=> [["Post1", "Photo1", "Movie1"], ["Post2", "Photo2", "Movie2"], ["Post3", "Photo3", "Movie3"]]
```

```sql:クエリ
SELECT "users"."id" AS t0_r0, "users"."name" AS t0_r1, "users"."created_at" AS t0_r2, "users"."updated_at" AS t0_r3, "posts"."id" AS t1_r0, "posts"."name" AS t1_r1, "posts"."user_id" AS t1_r2, "posts"."created_at" AS t1_r3, "posts"."updated_at" AS t1_r4, "photos"."id" AS t2_r0, "photos"."name" AS t2_r1, "photos"."user_id" AS t2_r2, "photos"."created_at" AS t2_r3, "photos"."updated_at" AS t2_r4, "movies"."id" AS t3_r0, "movies"."name" AS t3_r1, "movies"."user_id" AS t3_r2, "movies"."created_at" AS t3_r3, "movies"."updated_at" AS t3_r4 FROM "users" LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id" LEFT OUTER JOIN "photos" ON "photos"."user_id" = "users"."id" LEFT OUTER JOIN "movies" ON "movies"."user_id" = "users"."id"
```

また `preload` は複数モデルの関連付けをキャッシュせずとも、下記を満たすときも利用できます。

* 関連付けをキャッシュするか: `YES`
* 関連先テーブルのカラムで絞り込むか: `NO`
* 複数モデルの関連付けをキャッシュするか: `NO`
* `has_many` による関連付けであるか: `YES`

`has_many` による関連付けをキャッシュするなら  `eager_load` で JOIN するより `preload` でクエリを分けて取得した方が高速になるケースが多いです。

:::note warn
大量の値を IN 句に含めるとデータベースによっては問題が起きる[^sql_server_in]場合があるのでしっかり検証する必要がありそうです。
:::

# 合せ技
フローチャートでは表現しきれませんでしたが以下のようなパターンもあるかと思います。

* 関連付けをキャッシュするか: `YES`
* 関連先テーブルのカラムで絞り込むか: `YES`
* 複数モデルの関連付けをキャッシュするか: `YES`
* `has_many` による関連付けであるか: `YES`

そのようなケースでは `joins (left_joins)` と `preload` を併用して対応できます。

下例では id = 1 の Post を持つ User の関連付けを取得しています。

```ruby:実装
User.joins(:posts).preload(:posts, :photos, :movies).merge(Post.where(id: 1))
```

```sql:クエリ
SELECT "users".* FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE "posts"."id" = 1
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 1
SELECT "photos".* FROM "photos" WHERE "photos"."user_id" = 1
SELECT "movies".* FROM "movies" WHERE "movies"."user_id" = 1
```

下例では id = 2 の Photo あるいは id = 3 の Movie を持つ User の関連付けを取得しています。

```ruby:実装
User.left_joins(:photos, :movies).preload(:posts, :photos, :movies).merge(Photo.where(id: 2).or(Movie.where(id: 3)))
```

```sql:クエリ
SELECT "users".* FROM "users" LEFT OUTER JOIN "photos" ON "photos"."user_id" = "users"."id" LEFT OUTER JOIN "movies" ON "movies"."user_id" = "users"."id" WHERE ("photos"."id" = 2 OR "movies"."id" = 3)
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN (2, 3)
SELECT "photos".* FROM "photos" WHERE "photos"."user_id" IN (2, 3)
SELECT "movies".* FROM "movies" WHERE "movies"."user_id" IN (2, 3)
```

# おわりに
フローチャートを書くことで脳内がかなり整理されました。これで気持ちよく新年を迎えることができそうです。それではよいお年を！

# 参考記事
https://tech.stmn.co.jp/entry/2020/11/30/145159

https://techracho.bpsinc.jp/hachi8833/2023_09_27/89510

https://moneyforward-dev.jp/entry/2019/04/02/activerecord-includes-preload-eagerload/

https://tadhao.medium.com/joins-vs-preload-vs-includes-vs-eager-load-in-rails-5f721c44b3a9

[^sql_server_in]:参考までに SQL Server では数千規模の値を IN 句に含めるとこのようなエラーが発生することがあります。
https://learn.microsoft.com/ja-jp/sql/t-sql/language-elements/in-transact-sql?view=sql-server-ver16#remarks