ファーストビュー画像
logo
homenextバックエンドnext
【Rails】テーブル結合・関連オブジェクトの一括読み込みの挙動を理解する
バックエンド

【Rails】テーブル結合・関連オブジェクトの一括読み込みの挙動を理解する

作成日2025/07/09
更新日2025/07/09
【Rails】テーブル結合・関連オブジェクトの一括読み込みの挙動を理解する

Active RecordではSQLを直に記述することなく、様々な操作を簡潔に行うことができます。

非常に便利な反面、裏側で発行されるSQLを理解しておかないと、思わぬパフォーマンス問題に遭遇することがあります。

今回はActiveRecordが提供するクエリインターフェースで発行されるSQLや挙動について解説していきます。

事前準備

実際の動作確認には前回の記事の「データの準備」の箇所で作成したデータを使っていきます。

データベースに存在するテーブル・レコードは以下です。

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

SQL - INNER JOIN

ユーザーが複数の投稿を持つような構造のusersテーブルとpostsテーブルがある場合、これらのテーブルをINNER JOIN(内部)で結合する場合の挙動を考えます。

usersを結合元とする場合

usersを結合元とする場合、SQLは以下のように書けます。

SELECT *
FROM users
INNER JOIN posts
ON users.id = posts.user_id

取得できるテーブルは以下のようになります。
created_atupdated_atは省略

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

postsを結合元とする場合

SELECT *
FROM posts
INNER JOIN users
ON posts.user_id = users.id

postsを結合元とする場合、SQLは以下のように書けます。

結果は以下のようにusersを結合元としたときと同じく、postsテーブルのレコード数分が取得できます。

posts.id

posts.user_id

posts.title

user.id

users.name

1

1

Alice's First Post

1

Alice

2

1

Alice's Second Post

1

Alice

3

2

Bob's Post

2

Bob

4

1

Alice's Third Post

1

Alice

このようにpostsテーブルのuser_id、usersテーブルに存在するレコードのidが入るという制約(外部キー制約)がある場合、INNER JOINではどちらを結合元としてもpostsテーブルのレコード数分のテーブルが取得できます。

Rails - joins

INNER JOINをActive Recordではjoinsを使って行うことができます。

usersを結合元とする場合

usersを結合元とする場合は以下のように記述できます。

User.joins(:posts)

ログを確認するとINNER JOINで結合されていることがわかります。

SELECT "users".* FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id"

取得できる値としてはUserのActiveRecord::Relationオブジェクトになります。
INNER JOINなので、以下のように重複したオブジェクトが返されることもあります。

[#<User:0x0000000128f353d0
  id: 1,
  name: "Alice",
  created_at: "2025-06-16 03:10:48.557071000 +0000",
  updated_at: "2025-06-16 03:10:48.557071000 +0000">,
 #<User:0x0000000128f35290
  id: 1,
  name: "Alice",
  created_at: "2025-06-16 03:10:48.557071000 +0000",
  updated_at: "2025-06-16 03:10:48.557071000 +0000">,
 #<User:0x0000000128f35010
  id: 1,
  name: "Alice",
  created_at: "2025-06-16 03:10:48.557071000 +0000",
  updated_at: "2025-06-16 03:10:48.557071000 +0000">,
 #<User:0x0000000128f34ed0
  id: 2,
  name: "Bob",
  created_at: "2025-06-16 03:10:48.562348000 +0000",
  updated_at: "2025-06-16 03:10:48.562348000 +0000">]

重複を除きたい場合は、distinctを使うことで実現できます。

$ User.joins(:posts).distinct
User Load (5.9ms)  SELECT DISTINCT "users".* FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" /* loading for pp */ LIMIT 11 /*application='ActiveRecord'*/
=> 
[#<User:0x0000000128f312d0
  id: 1,
  name: "Alice",
  created_at: "2025-06-16 03:10:48.557071000 +0000",
  updated_at: "2025-06-16 03:10:48.557071000 +0000">,
 #<User:0x0000000128f30dd0
  id: 2,
  name: "Bob",
  created_at: "2025-06-16 03:10:48.562348000 +0000",
  updated_at: "2025-06-16 03:10:48.562348000 +0000">]

postsテーブルとの結合を行うので、postsテーブルのカラムの値を条件に使用して取得するusersを絞ることができます。

$ User.joins(:posts).where("posts.title LIKE '%First%'")
User Load (3.3ms)  SELECT "users".* FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE (posts.title LIKE '%First%') /* loading for pp */ LIMIT 11 /*application='ActiveRecord'*/
=> 
[#<User:0x0000000128f32a90
  id: 1,
  name: "Alice",
  created_at: "2025-06-16 03:10:48.557071000 +0000",
  updated_at: "2025-06-16 03:10:48.557071000 +0000">]

また、UserのActiveRecord::Relationオブジェクトを返し、Postのオブジェクトは含まれないため、joinsで取得したActiveRecord::Relationオブジェクトのループ内でpostsを参照すると、その都度SQLが発行されN+1問題が発生します。

users = User.joins(:posts)

users.each do |user|
  user.posts.each do |post| # 都度SQLが発行される
    puts post.title
  end
end

SQL - LEFT OUTER JOIN

INNER JOINでは結合条件(今回ではposts.user_id = users.id)に一致するレコードのみ取得します。
今回では、postsを1つ以上保有しているuserのみを取得するので、id: 3のユーザーは取得ができません。

結合条件に一致するレコードがないものも含め結合元のデータを全て取得したい場合、SQLではLEFT OUTER JOINを使用します。

usersを結合元とする場合

usersを結合元とする場合、SQLは以下のように書けます。

SELECT *
FROM users
LEFT OUTER JOIN posts
ON users.id = posts.user_id;

取得できるテーブルは以下のようになります。

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

結合元のusersのデータを全て取得できるので、以下のようにpostsの保有数が0のusersを取得するようなことが可能になります。

SELECT users.id
FROM users
LEFT OUTER JOIN posts
ON users.id = posts.user_id
GROUP BY users.id
HAVING COUNT(posts.id) = 0;

実行結果

users.id

3

postsを結合元とする場合

postsを結合元とする場合、SQLは以下のように書けます。

SELECT *
FROM posts
LEFT OUTER JOIN users
ON posts.user_id = users.id;

取得できるテーブルは以下のようになります。

posts.id

posts.user_id

posts.title

users.id

users.name

1

1

Alice's First Post

1

Alice

2

1

Alice's Second Post

1

Alice

3

2

Bob's Post

2

Bob

4

1

Alice's Third Post

1

Alice

今回のように、userとpostsが1対多の関係になっていて、postsのuser_idにNOT NULL制約、外部キー制約がある場合、postsを結合元としたINNER JOINとLEFT OUTER JOINの結果は同じになります。

Rails - left_joins

LEFT OUTER JOINをActive Recordではjoinsを使って行うことができます。

usersを結合元とする場合

usersを結合元とする場合は以下のように記述できます。

User.left_joins(:posts)

ログを確認するとLEFT OUTER JOINで結合されていることが分かります。

SELECT "users".* FROM "users" LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id"

postsを持っていないusersを取得する場合は以下のように記述できます。

User.left_joins(:posts).group(:id).having("COUNT(posts.id) = 0")
User Load (1.8ms)  SELECT "users".* FROM "users" LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id" GROUP BY "users"."id" HAVING (COUNT(posts.id) = 0) /* loading for pp */ LIMIT 11 /*application='ActiveRecord'*/
=> 
[#<User:0x0000000128f38c10
  id: 3,
  name: "Charlie",
  created_at: "2025-06-16 03:10:48.567224000 +0000",
  updated_at: "2025-06-16 03:10:48.567224000 +0000">]

joinsと同じく、UserのActiveRecord::Relationオブジェクトを返し、Postのオブジェクトは含まれないため、left_joinsで取得したActiveRecord::Relationオブジェクトのループ内でpostsを参照すると、その都度SQLが発行されN+1問題が発生します。

users = User.left_joins(:posts)

users.each do |user|
  user.posts.each do |post| # 都度SQLが発行される
    puts post.title
  end
end

rails - eager_load

left_joinsと同じく内部的にLEFT OUTER JOINを実行するメソッドとしてeager_loadがあります。

eager_loadは以下のように記述できます。

users = User.eager_load(:users)

left_joinsと違う点としては結合元のモデル(今回はUser)のユニークなオブジェクトごとに関連オブジェクト(今回はPost)との紐付けを行い、一括で取得するような処理を行います。

以下のようなイメージです。

[
  #<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メソッドでは重複することなく、関連オブジェクトを紐付けた状態で取得ができるので、ループ内でpostsを参照したとしてもN+1問題は発生しません。

また、postsテーブルとの結合を行うので、postsテーブルのカラムの値を条件に使用して取得するusersを絞ることもできます。

users = User.eager_load(:posts)

users.each do |user|
  user.posts.each do |post| # SQLが発行されない
    puts post.title
  end
end

ログを見るとそれぞれのテーブルの各レコードを取得していることが分かります。

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"

Rails - preload

eager_loadと同じく関連オブジェクトの一括読み込みを行うメソッドとしてpreloadがあります。

usersを結合元とする場合

usersを結合元とする場合は以下のように記述できます。

User.preload(:posts)

ログを確認すると、LEFT OUTER JOINを発行するeager_loadとは異なり、まずusersテーブルから全てのレコードを取得し、その後、postsテーブルからIN句でusersのIDを指定して関連するレコードを取得しています。

SELECT "users".* FROM "users"
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN (1, 2, 3)

テーブル結合は行なっていないため、結合先のpostsテーブルのレコードの値で条件を指定することはできません。

# これはエラーになる
User.preload(:posts).where("posts.title LIKE '%First%'")
An error occurred when inspecting the object: #<ActiveRecord::StatementInvalid:"PG::UndefinedTable: ERROR:  missing FROM-clause entry for table \"posts\"\nLINE 1: SELECT \"users\".* FROM \"users\" WHERE (posts.title LIKE '%Firs...

関連オブジェクトはeager_loadと同じく一括で読み込んでいるため、ループ内で参照してもN+1問題は起きません。

users = User.preload(:posts)

users.each do |user|
  user.posts.each do |post| # SQLが発行されない
    puts post.title
  end
end

まとめ

joins

  • 内部的にINNER JOINを行う
  • レコード数が変動する(結合先が複数あれば重複する、結合先を持たなければ取得できない)
  • 結合先テーブルの条件で絞り込みができる
  • 結合先のオブジェクトは含まれないため、ループ内で関連オブジェクトを参照するとN+1問題が発生する

left_joins

  • 内部的にLEFT OUTER JOINを行う
  • 結合元の全てのレコードを取得する
  • 結合先テーブルの条件で絞り込みができる
  • 結合先のオブジェクトは含まれないため、ループ内で関連オブジェクトを参照するとN+1問題が発生する

eager_load

  • 内部的にLEFT OUTER JOINを実行
  • 結合元のモデルのユニークなオブジェクトごとに関連オブジェクトとの紐付けを行い、一括で取得
  • 結合先テーブルの条件で絞り込みができる
  • 関連オブジェクトが事前に読み込まれるため、N+1問題が発生しない

preload

  • 最初に結合元のテーブルから全レコードを取得し、次にIN句で関連レコードを取得
  • テーブル結合を行わないため、結合先テーブルの条件での絞り込みはできない
  • 関連オブジェクトが事前に読み込まれるため、N+1問題が発生しない

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

Shota Nagato

Webエンジニア

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

GitHubrss
カテゴリ

カテゴリ

タグ

タグ