【Rails】eager_loadの挙動を理解する
.png&w=640&q=75)
今回はRailsのeager_loadメソッドを使っている中で挙動が気になったので実際のコードを見ながらどのような処理を裏側で行なっているのか理解していこうと思います。
eager_loadとは
Active Recordで提供されているクエリインターフェースのうちデータベースからオブジェクトを取り出すためのメソッドの一つです。
主となるモデルに対して、関連先を引数に渡して使用します。
ユーザー(User)が複数の投稿(Post)を持っている場合、以下のように書きます。
# モデル.eager_load(関連先)
User.eager_load(:posts)
内部的にはLEFT OUTER JOINで結合を行なっているため、以下のような特徴があります。
関連テーブルでの絞り込みができる
タイトル(title)が「タイトル1」の投稿(post)を持つユーザー(user)を取得
というように結合先のデータで条件を指定して絞り込むことができます。
関連テーブルの情報を保持できる
以下のように主となるモデルのオブジェクトに紐づく関連先のオブジェクトも同時に保持しておくことができるようになり、N+1問題を回避できます。
- ユーザー1
- 投稿1
- 投稿2
- ユーザー2
- 投稿1
データの準備
実際にRailsでeager_loadの挙動を確認するためのデータを作成してきます。
Railsプロジェクトとを作成している前提で進めていきます。
今回は以下のバージョンで動作確認をしました。
$ ruby -v
ruby 3.4.3
$ rails -v
Rails 8.0.2
以下の流れでUserモデルとPostモデルを作成します。
Userモデル
マイグレーションファイル
class CreateUsers < ActiveRecord::Migration[8.0]
def change
create_table :users do |t|
t.string :name
t.timestamps
end
end
end
モデルファイル
class User < ApplicationRecord
has_many :posts
end
Postモデル
マイグレーションファイル
class CreatePosts < ActiveRecord::Migration[8.0]
def change
create_table :posts do |t|
t.references :user, null: false, foreign_key: true
t.string :title
t.timestamps
end
end
end
モデルファイル
class User < ApplicationRecord
has_many :posts
end
テストデータ作成
以下の内容でseeds.rbファイルを更新します。
alice = User.create(name: "Alice")
bob = User.create(name: "Bob")
charlie = User.create(name: "Charlie")
Post.create(user: alice, title: "Alice's First Post")
Post.create(user: alice, title: "Alice's Second Post")
Post.create(user: bob, title: "Bob's Post")
Post.create(user: alice, title: "Alice's Third Post")
テストデータを作成します。
$ rails db:seed
挙動の確認
表示
ホーム画面でeager_loadを使って取得し、各ユーザーの名前と投稿の個数を表示してみます。
コントローラー・ビューファイル生成
$ rails g controller home index
コントローラー
class DashboardController < ApplicationController
def index
@users = User.eager_load(:posts)
end
end
ルーティング
Rails.application.routes.draw do
root "dashboard#index"
end
ビューファイル
<% @users.each do |user| %>
<%= user.name %>
<%= user.post.size %>
<% end %>
この状態でルートページにアクセスすると、投稿の有無に関わらず全てのユーザーとその投稿数が表示されました。

ログの確認
ログを見てみると以下のSQLが発行されています。
usersとpostsをLEFT OUTER JOINで結合して両テーブルの全てのカラムを取得しています。
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"."user_id" AS t1_r1,
"posts"."title" 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"
現状のデータは
users
id | name |
---|---|
1 | Alice |
2 | Bob |
3 | Charlie |
posts
id | user_id | title |
---|---|---|
1 | 1 | Alice's First Post |
2 | 1 | Alice's Second Post |
3 | 2 | Bob's Post |
4 | 1 | Alice's Third Post |
のようになっているので、これらをLEFT OUTER JOIN(左外部結合)で結合すると、
users.id | users.name | posts.id | posts.user_id | posts.title |
---|---|---|---|---|
1 | Alice | 1 | 1 | Alice's First Post |
1 | Alice | 2 | 1 | Alice's Second Post |
2 | Bob | 3 | 2 | Bob's Post |
1 | Alice | 4 | 1 | Alice's Third Post |
3 | Charlie | NULL | NULL | NULL |
このようになります。
この結果を見るとAliceが3回表示されそうですが、実際に表示されているのは各ユーザー1回ずつです。
次からrailsのソースコードを確認して処理を追っていきます。
Railsのソースコードを確認
eager_loadメソッド
eager_load
メソッドはactiverecord/lib/active_record/relation/query_methods.rb
で定義されています。
該当箇所はこちらです。
def eager_load(*args)
check_if_method_has_arguments!(__callee__, args)
spawn.eager_load!(*args)
end
eager_load!で値を設定
def eager_load!(*args) # :nodoc:
self.eager_load_values |= args
self
end
eager_load!
メソッドでeager_load_values
に今回は:posts
が渡されます。
ここまでがcontroller
での@users = User.eager_load(:posts)
の箇所で行われる処理です。
この時点ではまだSQLは発行されていません。
クエリの発行タイミング
view側の@users.each
の箇所で初めてクエリが発行されます。
<% @users.each do |user| %>
<%= user.name %>
<%= user.post.size %>
<% end %>
次からここで行われる処理について詳しく見ていきます。
.eachが呼ばれた時の内部処理
- delegateによるeachの呼び出し
activerecord/lib/active_record/relation/delegation.rb
では以下のようにdelegateが定義されています。
該当箇所はこちらです
delegate :to_xml, :encode_with, :length, :each, :join, :intersect?,
:[], :&, :|, :+, :-, :sample, :reverse, :rotate, :compact, :in_groups, :in_groups_of,
:to_sentence, :to_fs, :to_formatted_s, :as_json,
:shuffle, :split, :slice, :index, :rindex, to: :records
ここで、each
メソッドが:records
にdelegateされており、@users.each
は内部的には@users.records.each
として実行されることになります。
- recordsメソッドの実行
recordsはactiverecord/lib/active_record/relation.rb
で定義されています。
該当箇所はこちらです。
def records # :nodoc:
load
@records
end
ここで、実際にクエリを実行する担当であるload
メソッドを実行した後、取得結果の@records
を返します。
- loadメソッドでクエリ実行
load
メソッドはrecords
と同じくactiverecord/lib/active_record/relation.rb
で定義されています。
該当箇所はこちらです。
def load(&block)
if !loaded? || scheduled?
@records = exec_queries(&block)
@loaded = true
end
self
end
- exec_queries内で結果の集約処理
ここが今回のように、SQLの結果が同じユーザーを複数返している場合でも重複されずに表示を行うように処理をしている箇所です。
主に、
- SQLクエリ(LEFT OUTER JOIN)の実行
- 主テーブルのオブジェクトをユニークに抽出
- 関連レコードのデータを主テーブルのオブジェクトに設定
のような処理を行っています。
SQLの実行は該当メソッドのexec_main_query
で、関連オブジェクトを含む重複しないオブジェクトの取得はinstantiate_records
で行われています。
データの取得イメージとしては以下のような感じです。
SQLクエリの実行結果
[
{ user_id: 1, user_name: "Alice", post_id: 1, post_title: "Alice's First Post" },
{ user_id: 1, user_name: "Alice", post_id: 2, post_title: "Alice's Second Post" },
{ user_id: 1, user_name: "Alice", post_id: 4, post_title: "Alice's Third Post" },
{ user_id: 2, user_name: "Bob", post_id: 3, post_title: "Bob's Post" },
{ user_id: 3, user_name: "Charlie", post_id: nil, post_title: nil }
]
内部処理によるオブジェクト構造
[
#<User id: 1, name: "Alice", posts: [
#<Post id: 1, title: "Alice's First Post", user_id: 1>,
#<Post id: 2, title: "Alice's Second Post", user_id: 1>,
#<Post id: 4, title: "Alice's Third Post", user_id: 1>,
]>,
#<User id: 2, name: "Bob", posts: [
#<Post id: 3, title: "Bob's Post", user_id: 2>
]>,
#<User id: 3, name: "Charlie", posts: []>
]
まとめ
eager_loadの内部処理をまとめると以下のようになります:
- クエリ構築段階:
eager_load(:posts)
でeager_load_valuesに:postsを設定 - 遅延実行: 実際のSQLは
.each
が呼ばれるまで実行されない - delegate:
.each
は:records
にdelegateされ、records.each
が実行される - クエリ実行: LEFT OUTER JOINのSQLが実行される
- 結果の集約: JOINの結果から主となるオブジェクトをユニークに抽出し、関連データを適切に紐付け
この仕組みにより、1回のSQLでN+1問題を回避しながら、直感的なオブジェクト構造を保持できてることがわかりました。
Railsのコードを実際に追ってみることでより理解が深まったので、他のクエリインターフェースの挙動も確認してみようと思います。