Rails と テーブル結合

  • 321
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

テーブル結合おさらい

テーブル結合とは、

2つ以上のテーブルを何らかのキーを元に結合し、1つのテーブルにすること

inner join(内部結合)

結合する両方のテーブルどちらにも同じキーが存在するレコードのみ残し、それ以外は切り捨てる。

SELECT *
  FROM テーブルA INNER JOIN 結合したいテーブルB
    ON テーブルA.キー = 結合したいテーブルB.キー

inner join(内部結合)では、メインテーブルと結合したいテーブルを入れ替えても、両方のテーブルに存在するキーのレコードだけを表示するので、取得結果は変わらない。

outer join(外部結合)

結合する両方のテーブルどちらにしか存在しないキーがあっても切り捨てずに取得する。どちらのテーブルのレコードを取得するかで2通りの書き方がある。

種類 説明
left outer join(左外部結合) メインテーブルに存在するキーのレコードは、結合したいテーブルになくても表示する
right outer join(右外部結合) 結合したテーブルに存在するキーのレコードは、メインテーブルになくても表示する

left outer join(左外部結合)

SELECT *
  FROM テーブルA LEFT OUTER JOIN 結合したいテーブルB
    ON テーブルA.キー = 結合したいテーブルB.キー

right outer join(右外部結合)

SELECT *
  FROM テーブルA RIGHT OUTER JOIN 結合したいテーブルB
    ON テーブルA.キー = 結合したいテーブルB.キー

Railsでテーブル結合

以下、Railsでテーブルを結合する時のメソッドを紹介。使うのはUserとAvatarモデル。Avatarでポリモフィック使ってますがお気になさらず。

users avatars
id id
email resource_id
name resource_type

joins

inner join を行う。

User.joins(:avatar)
=>
SELECT users.* 
  FROM users INNER JOIN avatars 
    ON avatars.resource_id = users.id AND avatars.resource_type = 'User'

association(= ActiveRecordのオブジェクト)をキャッシュしないので、メモリ消費が少ないが、それによりN+1問題が起こる。

※ 注意
ActiveRecordでINNER JOINすると、ReadOnlyRecordとなってsaveができなくなります。

user = User.joins(:avatar).find_by(id: 1)
user.email = "taro@example.com"
user.save # 失敗する

もし更新したい場合は、readonly(false)をつけてあげます。

user = User.joins(:avatar).readonly(false).find_by(id: 1)
user.email = "taro@example.com"
user.save # 成功

preload

テーブルごとに取得するクエリを分けてassociationをキャッシュする。

User.preload(:avatar)
=>
SELECT users.* FROM users
SELECT avatars.* FROM avatars WHERE avatars.resource_type = 'User' AND avatars.resource_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

テーブルごとにそれぞれ異なるSQLを実行するため、例えば User が preloadした Avatar のテーブル のカラムを指定して where で絞り込むことはできない。検索ではなくキャッシュ目的で使用する。

 User.preload(:avatar).where(avatars: {id: 1})

eager_load

left outer join を行い、associationをキャッシュする。

User.eager_load(:avatar)
=>
SELECT users.id AS t0_r0, 
       users.email AS t0_r1, 
       users.name AS t0_r2, 
       avatars.id AS t1_r0, 
       avatars.resource_id AS t1_r1, 
       avatars.resource_type AS t1_r2
  FROM users LEFT OUTER JOIN avatars 
    ON avatars.resource_id = users.id AND avatars.resource_type = 'User'

includes

条件句のあり、なしで挙動が違う。また、referencesを使うと条件句ありの時と同じ挙動になる。

# 条件句なしだと、pleloadと同じ
User.includes(:avatar) 
=>
SELECT users.* FROM users
SELECT avatars.* FROM avatars WHERE avatars.resource_type = 'User' AND avatars.resource_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

# 条件句を指定すると、left outer join となる。
User.includes(:avatar).where(avatars: {id: 1})
=>
SELECT users.id AS t0_r0, 
       users.email AS t0_r1,
       users.name AS t0_r2,
       avatars.id AS t1_r0, 
       avatars.resource_id AS t1_r1, 
       avatars.resource_type AS t1_r2, 
  FROM users LEFT OUTER JOIN avatars 
    ON avatars.resource_id = users.id AND avatars.resource_type = 'User' 
 WHERE avatars.id = 1

# references
User.includes(:avatar).references(:avatar)
=>
SELECT users.id AS t0_r0, 
       users.email AS t0_r1,
       users.name AS t0_r2,
       avatars.id AS t1_r0, 
       avatars.resource_id AS t1_r1, 
       avatars.resource_type AS t1_r2, 
  FROM users LEFT OUTER JOIN avatars 
    ON avatars.resource_id = users.id AND avatars.resource_type = 'User' 

left outer join なので、もしavatarに存在しないレコードがある場合でも、usersが全部ロードされてしまう。また、以下のようにループ中に関連モデルのプロパティを参照していると、結合したテーブルにレコードがない場合にエラーになってしまう。

users = User.includes(:avatar)
users.each {|user| p user.avatar.id} # user_idが9に紐づくavatarがない場合、エラーになる

なので、なるべくなら下記のようにinner joinでとってきたものをキャッシュするようにしたほうが良い。

joinsをキャッシュする

最後にpreloadまたはeager_loadする。違いはクエリを複数回投げるか1回で済ますか。

User.joins(:avatar).preload(:avatar)
=>
SELECT users.* FROM users INNER JOIN avatars ON avatars.resource_id = users.id AND avatars.resource_type = 'User'
SELECT avatars.* FROM avatars  WHERE avatars.resource_type = 'User' AND avatars.resource_id IN (1, 2, 3, 4, 5, 6, 7, 8)

User.joins(:avatar).eager_load(:avatar)
=>
SELECT users.id AS t0_r0, 
       users.email AS t0_r1, 
       users.name AS t0_r2, 
       avatars.id AS t1_r0, 
       avatars.resource_id AS t1_r1, 
       avatars.resource_type AS t1_r2
  FROM users INNER JOIN avatars 
    ON avatars.resource_id = users.id AND avatars.resource_type = 'User'

merge

joins で結合したテーブルの条件式に、ActiveRecord::Relationを使用することができる。

User.joins(:avatar).merge(Avatar.where(id: 1))
=>
SELECT users.* FROM users INNER JOIN avatars ON avatars.resource_id = users.id AND avatars.resource_type = 'User' WHERE avatars.id = 1

# 普通に書いた場合
User.joins(:avatar).where('avatars.id = 1')
or
User.joins(:avatar).where(avatars: {id: 1})

Modelで定義されたscopeを指定することも可能。