ActiveRecord

ActiveRecord ~ 複数テーブルにまたがる検索(preload, eager_load, include, joins)

説明に使用している各テーブルは blogs ← entries ← comments という感じにFK参照してる。DDLは最後の方に記載

いきなりまとめ

preload
SELECT複数回(親子で1回ずつ)
子テーブルでの絞り込みは不可
eager_load
LEFT OUTER JOIN
子/孫テーブルでの絞り込みも可能
include
preloadとeager_loadのハイブリッド
絞り込み条件なし OR 親テーブルへの絞り込みのみの場合 => preload
子/孫テーブルへの絞り込み条件が指定されている場合 => eager_load

joins ※これだけ少し毛色が異なる
INNER JOIN
結果がベタに(=1行1インスタンスで)返ってくる

preload: 2段階SELECT(子テーブルの先読み)

  • 親テーブル/子テーブルで1回ずつSELECTが走る
  • 子テーブル側では絞り込めない
# 子テーブル(?)側の先読み
# SELECT * FROM blogs WHERE id IN (1, 2)
# SELECT * FROM entries WHERE blog_id IN (1, 2)
r = Blog.preload(:entries).where(id: [1, 2])

r.each do |b|
  p b
  p b.entries
end

# 子テーブル側では絞り込めない
r = Blog.preload(:entries).where(entrires: { id: [1, 2] } )
# => ActiveRecord::StatementInvalid

eager_load: LEFT OUTER JOIN

# SELECT *
#   FROM entries E LEFT OUTER JOIN comments C ON C.entry_id = E.id
#  WHERE E.id IN (3, 4, 5)
r = Entry.eager_load(:comments).where(id: [*3..5])

r.each do |b|
  p b
  b.comments.each do |c|
    puts "======="
    p c
  end
end

2段階に(孫テーブルも)LEFT OUTER JOIN + 子テーブルでの絞り込み

ruby
r = Blog.eager_load(entries: :comments).where(entries: {id: [1, 2]})
SQL
SELECT * 
  FROM blogs B 
       LEFT OUTER JOIN entries E ON E.blog_id = B.id 
       LEFT OUTER JOIN comments C ON C.entry_id = E.id 
 WHERE E.id IN (1, 2)

子/孫テーブルの絞り込み条件をハッシュで渡せない場合でも references をかまさなくて良い(includesとは違う点)

ruby
r = Blog.eager_load(entries: :comments).where("comments.id <= ?", 2)

includes: preloadeager_load を自動切り替え

絞り込みなし or 親テーブルでの絞り込みのみ の場合は preload(親/子テーブルで1回ずつSELECTが走る)

子テーブル/孫テーブルで絞り込んだ場合は eager_load(LEFT OUTER JOIN)

ではまず、普通に子テーブルまで先読みした例。絞り込みは親テーブルに対してのみ。

# 以下2つのSELECTが実行される
# SELECT * FROM entries WHERE id = 1
# SELECT * FROM comments WHERE entry_id IN (1)
r = Entry.includes(:comments)
         .where(id: 1)

r.each do |e|
  # 親テーブル側のインスタンス
  p e
  # コレクションとして保持されている
  p e.comments
end

2段階に(孫テーブルまで)先読みする例。
こちらも絞り込みは親テーブルのみ。

# SELECTが3回走る
# SELECT * FROM blogs WHERE (2 <= id)
# SELECT * FROM entries WHERE blog_id IN (2, 3)
# SELECT * FROM comments WHERE entry_id IN (1, 2)
r = Blog.includes(entries: :comments).where("? <= id", 2)

r.each do |b|
  puts "###### Blog #{b.id}"
  p b
  b.entries.each do |e|
    puts "  ====== Entry #{e.id}"
    p e

    e.comments.each do |c|
      puts "    -------"
      p c
    end
  end
end

子テーブル側で絞り込むと LEFT OUTER JOIN に。

ruby
r = Blog.includes(entries: :comments).where(entries: { id: 1 })
SQL
SELECT *
  FROM blogs B
       LEFT OUTER JOIN entries E ON E.blog_id = B.id -- ←OUTERを使っているが、ここはINNERでも結果は同じ
       LEFT OUTER JOIN comments C ON C.entry_id = E.id
 WHERE E.id = 1 --←子テーブルentriesのidで絞り込んでいる

絞り込み条件をハッシュで渡せないときは references をかます必要があるっぽい。生成されるSQLはWHERE句以外は上のと同じ。

ruby
r = Blog.includes(entries: :comments).references(:entries).where("entries.id < ?", 2)

孫テーブルで絞り込む ⇒やっぱりLEFT OUTER JOIN

ruby
r = Blog.includes(entries: :comments).where(comments: { id: [*1..3] })
SQL
SELECT *
  FROM blogs B
       LEFT OUTER JOIN entries E ON E.blog_id = B.id
       LEFT OUTER JOIN comments C ON C.entry_id = E.id
 WHERE C.id IN (1, 2, 3) -- 孫テーブルcommentsのidで絞り込んでいる

joins: 単なる INNER JOIN

こいつだけ他のメソッドとは毛色がちょっと違う。

  • 先読みではなく、結果がベタに(=1レコード1インスタンスで)返ってくる
  • JOINしたテーブルのカラムは select で明示的に指定しないと見えない
# SELECT blogs.*, entries.title AS entry_title, entries.body
#   FROM blogs
#        INNER JOIN entries
#        ON entries.blog_id = blogs.id
#  WHERE blogs.id = 1
#
r = Blog.joins(:entries)
        .where(id: 1)
        .select("blogs.*, entries.title AS entry_title, entries.body")

r.each do |e|
  p e             # Blogの属性しか見えない
  p e.attributes  # Blog以外の属性が格納されていることが分かる

  # blogsテーブルの属性も、entriesテーブルの属性も読める
  puts "blog title: #{e.title}, entry title: #{e.entry_title}"
end

2段階(子/孫テーブルへの)INNER JOINもいける。

ruby
r = Blog.joins(entries: :comments)
        .where(id: 1)
        .select("blogs.*, entries.title AS entry_title, entries.body, comments.body AS comment_body")

参考: 説明で使用しているテーブルの構成

DDL
-- ブログテーブル
CREATE TABLE blogs
(
    id INT(11) PRIMARY KEY NOT NULL AUTO_INCREMENT,
    title VARCHAR(255),
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL
);

-- エントリーテーブル
CREATE TABLE entries
(
    id INT(11) PRIMARY KEY NOT NULL AUTO_INCREMENT,
    title VARCHAR(255),
    body TEXT,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    blog_id INT(11),
    CONSTRAINT fk_rails_1c7d1d2722 FOREIGN KEY (blog_id) REFERENCES blogs (id)
);
CREATE INDEX index_entries_on_blog_id ON entries (blog_id);

-- コメントテーブル
CREATE TABLE comments
(
    id INT(11) PRIMARY KEY NOT NULL AUTO_INCREMENT,
    body TEXT,
    status VARCHAR(255),
    entry_id INT(11),
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    CONSTRAINT fk_rails_24508b7aa5 FOREIGN KEY (entry_id) REFERENCES entries (id)
);
CREATE INDEX index_comments_on_entry_id ON comments (entry_id);

環境

Version
OS Windows7 x64
ruby 2.2
ActiveRecord 4.2.6

参考サイト

ActiveRecordのjoinsとpreloadとincludesとeager_loadの違い - Qiita
似ているようで全然違う!?Activerecordにおけるincludesとjoinsの振る舞いまとめ - Qiita
Rails Guides - Active Record クエリインターフェイス