【Rails】テーブル結合

テーブル結合については MySQL で学ぶテーブル結合 でご紹介しましたが、今回はこれらを Ruby on Rails で扱う方法をご紹介します。

サンプルデータは上記の記事と同じものを用意し、以下2つのモデルがある状態で始めます。

user.rb

class User < ApplicationRecord
  belongs_to :group
end

group.rb

class Group < ApplicationRecord
  has_many :users
end

users テーブル

+----+--------+------+----------+
| id | name   | age  | group_id |
+----+--------+------+----------+
|  1 | 田中   |   15 |        1 |
|  2 | 鈴木   |   22 |        2 |
|  3 | 佐藤   |   38 |        2 |
|  4 | 中田   |   38 |        4 |
+----+--------+------+----------+

groups テーブル

+----+---------------+
| id | name          |
+----+---------------+
|  1 | Aグループ      |
|  2 | Bグループ      |
|  3 | Cグループ      |
+----+---------------+
※ 動作確認は Rails 5.1.3 で行っています

内部結合

joins

User.joins(:group)

SELECT `users`.* FROM `users` INNER JOIN `groups` ON `groups`.`user_id` = `users`.`id`
=> [#<User:0x007fcbb05e8458 id: 1, name: "田中", age: 15, group_id: 1>,
 #<User:0x007fcbb05e8110 id: 2, name: "鈴木", age: 22, group_id: 2>,
 #<User:0x007fcbb1d57be8 id: 3, name: "佐藤", age: 38, group_id: 2>]

SQL で INNER JOIN が使われているのがわかります。

また、merge を使用することで、結合したモデルに関連する where句を ActiveRecord::Relation で書くことができます。

User.joins(:group).merge(Group.where(id: 1))

SELECT `users`.* FROM `users` INNER JOIN `groups` ON `groups`.`id` = `users`.`group_id` WHERE `groups`.`id` = 1
=> [#<User:0x007fcbb1c19380 id: 1, name: "田中", age: 15, group_id: 1>]

joins は結合するモデルをキャッシュしないのでメモリ消費が少なくて済みますが、それにより N+1 問題が起こるので、結合したモデルを参照するときは注意が必要です。

以下のスクリプトをコンソールで実行してみてください。

User.joins(:group).each{|user| puts user.group.name}

=> SELECT `users`.* FROM `users` INNER JOIN `groups` ON `groups`.`id` = `users`.`group_id`
Group Load (0.3ms)  SELECT  `groups`.* FROM `groups` WHERE `groups`.`id` = 1 LIMIT 1
Aグループ
Group Load (0.3ms)  SELECT  `groups`.* FROM `groups` WHERE `groups`.`id` = 2 LIMIT 1
Bグループ
Group Load (0.3ms)  SELECT  `groups`.* FROM `groups` WHERE `groups`.`id` = 2 LIMIT 1
Bグループ

ループ内で group の name を参照する度に select しているのがわかります。今回はデータが3つだけですが、これが数百、数千とループする時はパフォーマンスに多大な影響を与えてしまうので、関連モデルを参照する時は内部結合を使わないか、後述の「組み合わせによる解決」をしましょう。

外部結合

left_outer_joins

左外部結合をします。

User.left_outer_joins(:group)

SELECT `users`.* FROM `users` LEFT OUTER JOIN `groups` ON `groups`.`id` = `users`.`group_id`
=> [##<User:0x007fcbb05e8458 id: 1, name: "田中", age: 15, group_id: 1>,
 #<User:0x007fcbb05e8110 id: 2, name: "鈴木", age: 22, group_id: 2>,
 #<User:0x007fcbb1d57be8 id: 3, name: "佐藤", age: 38, group_id: 2>,
 #<User:0x007fcbba193718 id: 4, name: "中田", age: 38, group_id: 4>]

こちらも join と同様、結合するモデルをキャッシュしないので、ループする時に N+1 問題が起こります。

User.left_outer_joins(:group).each{|user| puts user.group&.name}

=> SELECT `users`.* FROM `users` LEFT OUTER JOIN `groups` ON `groups`.`id` = `users`.`group_id`
Group Load (0.2ms)  SELECT  `groups`.* FROM `groups` WHERE `groups`.`id` = 1 LIMIT 1
Aグループ
Group Load (0.2ms)  SELECT  `groups`.* FROM `groups` WHERE `groups`.`id` = 2 LIMIT 1
Bグループ
Group Load (0.2ms)  SELECT  `groups`.* FROM `groups` WHERE `groups`.`id` = 2 LIMIT 1
Bグループ
Group Load (0.2ms)  SELECT  `groups`.* FROM `groups` WHERE `groups`.`id` = 4 LIMIT 1
group&.name となっていますが、これは group_id=4 の user データが groups テーブルを参照してもデータがなく、 user.group.name とやると group が nil になってエラーになってしまうため、&でエラーになるのを防いでいます。これは Ruby 2.3 で実装された新機能です。

eager_load

left_outer_joins と同じく左外部結合をします。

User.eager_load(:group)

SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`age` AS t0_r2, `users`.`group_id` AS t0_r3, `groups`.`id` AS t1_r0, `groups`.`name` AS t1_r1 FROM `users` LEFT OUTER JOIN `groups` ON `groups`.`id` = `users`.`group_id
=> [##<User:0x007fcbb05e8458 id: 1, name: "田中", age: 15, group_id: 1>,
 #<User:0x007fcbb05e8110 id: 2, name: "鈴木", age: 22, group_id: 2>,
 #<User:0x007fcbb1d57be8 id: 3, name: "佐藤", age: 38, group_id: 2>,
 #<User:0x007fcbba193718 id: 4, name: "中田", age: 38, group_id: 4>]

ただし、こちらは結合するモデルをキャッシュするので、ループする時に N+1 問題は起こりません。(ちなみに eager load とは「一括読み込み」を意味します)

User.eager_load(:group).each{|user| puts user.group&.name}

SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`age` AS t0_r2, `users`.`group_id` AS t0_r3, `groups`.`id` AS t1_r0, `groups`.`name` AS t1_r1 FROM `users` LEFT OUTER JOIN `groups` ON `groups`.`id` = `users`.`group_id`
Aグループ
Bグループ
Bグループ

クエリ分割

Rails にはテーブルを結合せずにクエリを分割して発行することで、左外部結合を実現する方法があります。

preload

User.preload(:group)

User Load (0.3ms)  SELECT `users`.* FROM `users`
Group Load (0.2ms)  SELECT `groups`.* FROM `groups` WHERE `groups`.`id` IN (1, 2, 4)
=> [##<User:0x007fcbb05e8458 id: 1, name: "田中", age: 15, group_id: 1>,
 #<User:0x007fcbb05e8110 id: 2, name: "鈴木", age: 22, group_id: 2>,
 #<User:0x007fcbb1d57be8 id: 3, name: "佐藤", age: 38, group_id: 2>,
 #<User:0x007fcbba193718 id: 4, name: "中田", age: 38, group_id: 4>]

users と groups でそれぞれクエリを発行しています。最初に users テーブルからデータを取得して、取得したデータの外部キー(group_id) を使って groups テーブルを select しているのがわかります。

結合対象のモデルをキャッシュしているので、N+1 問題は起こりません。

User.preload(:group).each{|user| puts user.group&.name}

User Load (0.3ms)  SELECT `users`.* FROM `users`
Group Load (0.2ms)  SELECT `groups`.* FROM `groups` WHERE `groups`.`id` IN (1, 2, 4)
Aグループ
Bグループ
Bグループ

注意点として、実際にテーブルを結合しているわけではないので、たとえば groups テーブル の列を指定して where で絞り込むことはできません。

✕ User.preload(:group).where(groups: { id: 1 })

includes

条件によって挙動が違います。

where句なしだと preload と同じ挙動をします。

User.includes(:group)

User Load (0.3ms)  SELECT `users`.* FROM `users`
Group Load (0.2ms)  SELECT `groups`.* FROM `groups` WHERE `groups`.`id` IN (1, 2, 4)
=> [##<User:0x007fcbb05e8458 id: 1, name: "田中", age: 15, group_id: 1>,
 #<User:0x007fcbb05e8110 id: 2, name: "鈴木", age: 22, group_id: 2>,
 #<User:0x007fcbb1d57be8 id: 3, name: "佐藤", age: 38, group_id: 2>,
 #<User:0x007fcbba193718 id: 4, name: "中田", age: 38, group_id: 4>]

ハッシュによる where 句があると eager_load と同じ挙動をするので、クエリは分割されません。よって、関連モデルの列を使って検索することも可能です。

User.includes(:group).where(groups: { id: 1 })

SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`age` AS t0_r2, `users`.`group_id` AS t0_r3, `groups`.`id` AS t1_r0, `groups`.`name` AS t1_r1 FROM `users` LEFT OUTER JOIN `groups` ON `groups`.`id` = `users`.`group_id` WHERE `groups`.`id` = 1
=> [#<User:0x007fcbba496820 id: 1, name: "田中", age: 15, group_id: 1>]

ちなみに文字列による where 句は references とセットで使います。

User.includes(:group).references(:group).where('groups.id = 1')

SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`age` AS t0_r2, `users`.`group_id` AS t0_r3, `groups`.`id` AS t1_r0, `groups`.`name` AS t1_r1 FROM `users` LEFT OUTER JOIN `groups` ON `groups`.`id` = `users`.`group_id` WHERE `groups`.`id` = 1
=> [#<User:0x007fcbba496820 id: 1, name: "田中", age: 15, group_id: 1>]

組み合わせによる解決

joins + preload/eager_load

内部結合したいけど、関連モデルのキャッシュをして N+1 問題をなくしたい、という時に使用します。preload も eager_load も使えます。違いはクエリが分割されるかどうかです。

preload

User.joins(:group).preload(:group).each{|user| puts user.group.name}

SELECT `users`.* FROM `users` INNER JOIN `groups` ON `groups`.`id` = `users`.`group_id`
SELECT `groups`.* FROM `groups` WHERE `groups`.`id` IN (1, 2)
Aグループ
Bグループ
Bグループ

eager_load

User.joins(:group).eager_load(:group).each{|user| puts user.group.name}

SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`age` AS t0_r2, `users`.`group_id` AS t0_r3, `groups`.`id` AS t1_r0, `groups`.`name` AS t1_r1 FROM `users` INNER JOIN `groups` ON `groups`.`id` = `users`.`group_id`
Aグループ
Bグループ
Bグループ