【Rails】テーブル結合・関連オブジェクトの一括読み込みの挙動を理解する
.png&w=640&q=75)
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_at
とupdated_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問題が発生しない