ファーストビュー画像
logo
homenextバックエンドnext
【Rails】eager_loadの挙動を理解する
バックエンド

【Rails】eager_loadの挙動を理解する

作成日2025/06/20
更新日2025/06/20
【Rails】eager_loadの挙動を理解する

今回は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が呼ばれた時の内部処理

  1. 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として実行されることになります。

  1. recordsメソッドの実行

recordsactiverecord/lib/active_record/relation.rbで定義されています。

該当箇所はこちらです。

def records # :nodoc:
  load
  @records
end

ここで、実際にクエリを実行する担当であるloadメソッドを実行した後、取得結果の@recordsを返します。

  1. loadメソッドでクエリ実行

loadメソッドはrecordsと同じくactiverecord/lib/active_record/relation.rbで定義されています。

該当箇所はこちらです。

def load(&block)
  if !loaded? || scheduled?
    @records = exec_queries(&block)
    @loaded = true
  end

  self
end

  1. 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のコードを実際に追ってみることでより理解が深まったので、他のクエリインターフェースの挙動も確認してみようと思います。

share on
xアイコンfacebookアイコンlineアイコン
アバター

Shota Nagato

Webエンジニア

株式会社HAB&Co.|RailsとかNext.jsとか

GitHubrss
カテゴリ

カテゴリ

タグ

タグ