[Rails] eager_loadでN+1問題を解消し関連レコードを含んで一覧表示

## はじめに
ポートフォリオ作成中の学びを記載します。
記事を読んでいただくことで次の2点をお伝えできると思います。
- eager_loadによるN+1の解決
- scopeによるクエリ文の可読性向上

## やりたいこと
複数の開催日をもったイベント情報一覧を表示したい
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1754248/1b9bb117-8162-eb2a-5a67-2b015fcecfb4.png)

### 動作環境
Ruby: 3.1.2
Ruby on Rails: 6.1.7


### 関連の確認
アソシエーションは次の通りです。
```plantuml
@startuml yonde
hide circle

skinparam linetype ortho

entity "events" as events {
  id
  --
  title
  discription
  is_published
  created_at
  updated_at
}

entity "hosted_dates" as hosted_dates {
  id
  --
  event_id<<FK>>
  date_at
  created_at
  updated_at
}

events ||--|{ hosted_dates

@enduml

```
## 実装してみる
泣けなしの知識で早速実装します。動作しますが、問題はController（Modelも）にあります。お分かりでしょうか。

### Model
```ruby

class Event < ApplicationRecord
  has_many :hosted_dates
end

class HostedDate < ApplicationRecord
  belongs_to :event
end
```
### Controller
`joins`と`select`によって関連先レコードを取得できそうだぞ、と鼻息が荒くなっています。
注意：このコードは危険です。絶対に真似しないでください。
```ruby
class EventController < ApplicationController
  def index
    @events = Event.joins(:hosted_dates).select('hosted_dates.*, events_title, events_discription').where(is_published: true).order(:date_at)
  end
end
```

### View
Controllerで取得した`@events`内から`date_at`を取り出し表示します。
```html
  <% @events.each do |event| %>
    <%= link_to event do %>
      <h1><%= event.title %></h1>
      <p><%= event.discription %></p>
      <ul>
        <% event.hosted_dates.each do |date| %>
          <li><%= l.(date.date_at, format: :long) %></li>
        <% end %>
      </ul>
    <% end %>
  <% end %>
```

## 何が問題なのか
上記コードでも動作する分に問題ありませんが次の問題があります。
- SQLが悲惨（N+1問題）
- Contollerにロジックを書いており、可読性が悪い
### SQLが悲惨（N+1問題）
サーバーを走らせ、一覧を取得したときのSQLログは次の通りです。見事に**N+1回**のクエリが発生しています。
<details><summary>サーバーログからみるSQL</summary>




```bash
Processing by EventController#index as HTML
  Rendering layout layouts/application.html.erb
  Rendering events/index.html.erb within layouts/application
  Event Load (1.6ms)  SELECT "events".* FROM "events" WHERE "events"."is_published" = $1 ORDER BY "events"."updated_at" DESC  [["is_published", true]]
  ↳ app/views/events/index.html.erb:4
  HostedDate Load (3.6ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 15], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (1.5ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 14], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  CACHE HostedDate Load (0.0ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 14], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (1.3ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 13], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  CACHE HostedDate Load (0.0ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 13], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (1.0ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 12], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  CACHE HostedDate Load (0.0ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 12], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (0.9ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 11], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  CACHE HostedDate Load (0.0ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 11], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (1.0ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 10], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  CACHE HostedDate Load (0.0ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 10], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (1.0ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 9], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (1.1ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 8], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  CACHE HostedDate Load (0.0ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 8], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (1.0ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 7], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (0.8ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 4], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (0.7ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 3], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (0.7ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 2], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (0.7ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 1], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  Rendered events/index.html.erb within layouts/application (Duration: 47.6ms | Allocations: 19073)
略
Completed 200 OK in 69ms (Views: 48.0ms | ActiveRecord: 17.9ms | Allocations: 30484)
```
</details>

どうやらView内の次のeach文で繰り返したN回分のクエリが発生しています。今回はレコード件数が少ないこともあり処理時間は69msで済んでいますが、レコードが増えてアクセス数も増えるとサーバーはダウンしてしまうでしょう。
```html
<% event.hosted_dates.each do |date| %>
  <li><%= l(date.date_at, format: :long) %></li>
<% end %>
```
### N+1問題の解決策としてeager_loadする
ActiveRecordが提供しているメソッドとして、 `preload`, `eager_load`, `includes`があります。それぞれ微妙に挙動が違いますが、アソシエーションを結合してテーブルを作成しActiveRecordのキャッシュメモリに情報を保持することにより、クエリ発行数を1, 2回に収めて高速通信を実現するしくみです。 

最初の`joins`と`select`も結合しアソシエーションで条件を絞ることができますが、関連先レコードを保持することができません。

今回はアソシエーションで条件を絞りたかったので`eager_load`を選択しました。次のように使用することで関連先のレコードを保持したままインスタンスを作成することができます。これより先ほどのViews内のeach文においても追加でSQLが発生することはありません。
```ruby
@events = Event.eager_load(:hosted_dates).where(is_published: true).order(date_at: :desc)
```

### Contollerにロジックを書いており、可読性も悪い

`eager_load`により一件落着。と言いたいところですが、まだ残っている問題としては**Controllerにロジックを持たせている**ところです。
ControllerはユーザーとMode, Viewを繋ぐ指令等のため、できるだけ薄くシンプルにし、今回のような**ロジックはModelに持たせるべき**と言えます。

また、今回の例では`where`や`order`などのクエリメソッドは読みやすい方ではありますが、条件が複雑になってくると可読性に乏しくなることでしょう。


### scopeによりクエリ条件のエイリアスを作る
Model内に`scope`メソッドを定義し、第一引数にシンボルを使ったエイリアス名、第二引数にアロー記法を使ってクエリ文を渡します。
```ruby
class Event < ApplicationRecord
  scope :published, -> { where(is_published: true) }
  scope :sorted, -> { order(updated_at: :desc) }
End
```
次のようなコマンドでクエリを出すことができます。とても直感的でわかりやすいですよね。
```ruby
# Event.where(is_published: true)と等価
Event.published

# Event.order(updated_at: :desc)と等価
Event.sorted
```
また定義したscopeを再利用することも可能です。
```ruby
略
scope :recent, -> { pulished.sorted }

# Event.where(is_published: true).order(updated_at: :desc)と等価
Event.recent
```

注意点として`scope`による検索結果が存在しない場合あっても、必ず`ActiveRecord::Relation`を返します。クラスメソッドでも同じような実装が可能ですが存在しない場合は`nil`が返されます。要件によって使い分ける必要がありそうですね。

## 改善後
修正したコードは次の通りになります。(Viewに変化はありません）
Modelにロジックを持たせたことで、Controllerの見通しも良く直感的になりました。

### Model
```ruby
class Event < ApplicationRecord
  has_many :hosted_dates

  scope :with_dates, -> { eager_load(:hosted_dates) }
  scope :published, -> { where(is_published: true) }
  scope :sorted, -> { order(date_at: :desc) }
  scope :with_recent_dates, -> { with_dates.published.sorted }
end

```
### Controller
```ruby
class EventController < ApplicationController
  def index
    @events = Event.with_recent_dates
  end
end
```


### SQL削減結果
SQL発行数も1件となりスッキリ解決です。処理時間も69msから39msと**43%削減**することができました！
```bash
Processing by EventController#index as HTML
  Rendering layout layouts/application.html.erb
  Rendering events/index.html.erb within layouts/application
  SQL (2.4ms)  SELECT "events"."id" AS t0_r0, "events"."owner_id" AS t0_r1, "events"."name" AS t0_r2, "events"."place" AS t0_r3, "events"."title" AS t0_r4, "events"."discription" AS t0_r5, "events"."thumbnail_url" AS t0_r6, "events"."price" AS t0_r7, "events"."required_time" AS t0_r8, "events"."is_published" AS t0_r9, "events"."capacitiy" AS t0_r10, "events"."created_at" AS t0_r11, "events"."updated_at" AS t0_r12, "hosted_dates"."id" AS t1_r0, "hosted_dates"."event_id" AS t1_r1, "hosted_dates"."start_at" AS t1_r2, "hosted_dates"."end_at" AS t1_r3, "hosted_dates"."created_at" AS t1_r4, "hosted_dates"."updated_at" AS t1_r5 FROM "events" LEFT OUTER JOIN "hosted_dates" ON "hosted_dates"."event_id" = "events"."id" WHERE "events"."is_published" = $1 ORDER BY "events"."updated_at" DESC  [["is_published", true]]
  ↳ app/views/events/index.html.erb:4
  Rendered events/index.html.erb within layouts/application (Duration: 14.8ms | Allocations: 4094)
略
Completed 200 OK in 39ms (Views: 32.1ms | ActiveRecord: 3.8ms | Allocations: 15336)
```
## 最後に
ポートフォリオは実際にユーザーに使用いただく予定のためパフォーマンスは重要です。
また、改善を続けていけるように可読性・保守性も考慮する必要があります。
引き続きこれらを心がけて実装を進めていきます。

## 参考
[Railsガイド Active Record クエリインターフェイス](https://railsguides.jp/active_record_querying.html)
[ActiveRecordのincludesは使わずにpreloadとeager_loadを使い分ける理由](https://qiita.com/ryosuketter/items/097556841ec8e1b2940f)
[[Rails]モデルのscopeメソッド](https://zenn.dev/yusuke_docha/articles/ca0637ccc8d01f)