調べたけど意外に出てこなかったので書いてみます。
ActiveRecord の join, includes, preload, eager_load の違いについては、この記事では言及しません。
ユースケース
N + 1 問題回避のために includes を使用したいけれど、絞り込みには where 句を使いたくない。
例えば includes で取得した値に対して、同じ処理内で異なった条件での絞り込みをしたいとき。
結論
ActiveRecord::QueryMethods#where じゃなくて、 Array#select を使用して絞り込む。
[追記]
別のやり方として、association を応用する方法を教えていただきました。詳細はコメント参照
サンプルコード
本題
例えば以下のようなテーブル構造に対して、
次のようなレコードが用意されているとする。
author_one = Author.find_or_create_by!(name: 'author_one')
author_two = Author.find_or_create_by!(name: 'author_two')
book_one = Book.find_or_create_by!(name: 'book_one', author_id: author_one.id, book_type: 'magazine')
book_two = Book.find_or_create_by!(name: 'book_two', author_id: author_one.id, book_type: 'comic')
book_three = Book.find_or_create_by!(name: 'book_three', author_id: author_two.id, book_type: 'magazine')
book_four = Book.find_or_create_by!(name: 'book_four', author_id: author_two.id, book_type: 'comic')
eager_load(includes)などを使わない場合(N + 1 問題が発生する場合)
eager_load(includes)などを使わずに、下記のコードを実行すると N + 1 問題が発生する。
authors = Author.all
authors.each do |author|
author.books.each_with_index do |book, index|
pp "--book_#{index}--", book
end
end
# 出力結果
"--book_0--"
#<Book:0x00007f83b6adf9a8
id: 1,
name: "book_one",
author_id: 1,
book_type: "magazine">
"--book_1--"
#<Book:0x00007f83ba996fc0
id: 2,
name: "book_two",
author_id: 1,
book_type: "comic">
"--book_0--"
#<Book:0x00007f83b7544628
id: 3,
name: "book_three",
author_id: 2,
book_type: "magazine">
"--book_1--"
#<Book:0x00007f83b7544538
id: 4,
name: "book_four",
author_id: 2,
book_type: "comic">
実行ログを見て発行される SQL を確認すると、以下のように N + 1 問題が発生している。
Author Load (0.6ms) SELECT "authors".* FROM "authors"
Book Load (0.5ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? [["author_id", 1]]
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? [["author_id", 2]]
eager_load(includes) + where を使って絞り込みを行う
eager_load(includes) + where を使って絞り込みを行うと、N + 1 問題を回避した上で次のように book_type が magazine のものだけが取得できる。
authors = Author.all.eager_load(:books).where(books: { book_type: 'magazine' })
authors.each do |author|
author.books.each_with_index do |book, index|
pp "--magazine_#{index}--", book
end
end
# 出力結果
"--magazine_0--"
#<Book:0x00007f83b74b9a78
id: 1,
name: "book_one",
author_id: 1,
book_type: "magazine">
"--magazine_0--"
#<Book:0x00007f83ba97d2f0
id: 3,
name: "book_three",
author_id: 2,
book_type: "magazine">
SQL を確認すると、以下のように N + 1 問題が回避されたものとなる。
SQL (0.1ms) SELECT "authors"."id" AS t0_r0, "authors"."name" AS t0_r1, "books"."id" AS t1_r0, "books"."name" AS t1_r1, "books"."author_id" AS t1_r2, "books"."book_type" AS t1_r3 FROM "authors" LEFT OUTER JOIN "books" ON "books"."author_id" = "authors"."id" WHERE "books"."book_type" = ? [["book_type", "magazine"]]
しかし、追加で同じ処理内で異なる book_type のものを取得しようした場合、
authors = Author.all.eager_load(:books).where(books: { book_type: 'magazine' })
authors.each do |author|
author.books.each_with_index do |book, index|
pp "--magazine_#{index}--", book
end
end
+ authors.each do |author|
+ author.books.where(book_type: 'comic').each_with_index do |book, index|
+ pp "--comic_#{index}--", book
+ end
+ end
# 出力結果
"--magazine_0--"
#<Book:0x00007f83b74b9a78
id: 1,
name: "book_one",
author_id: 1,
book_type: "magazine">
"--magazine_0--"
#<Book:0x00007f83ba97d2f0
id: 3,
name: "book_three",
author_id: 2,
book_type: "magazine">
"--comic_0--"
#<Book:0x00007f83b74f4010
id: 2,
name: "book_two",
author_id: 1,
book_type: "comic">
"--comic_0--"
#<Book:0x00007f83b74e7bd0
id: 4,
name: "book_four",
author_id: 2,
book_type: "comic">
追加で SQL が発行されてしまう。
SQL (0.8ms) SELECT "authors"."id" AS t0_r0, "authors"."name" AS t0_r1, "books"."id" AS t1_r0, "books"."name" AS t1_r1, "books"."author_id" AS t1_r2, "books"."book_type" AS t1_r3 FROM "authors" LEFT OUTER JOIN "books" ON "books"."author_id" = "authors"."id" WHERE "books"."book_type" = ? [["book_type", "magazine"]]
Book Load (0.2ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? AND "books"."book_type" = ? [["author_id", 1], ["book_type", "comic"]]
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? AND "books"."book_type" = ? [["author_id", 2], ["book_type", "comic"]]
eager_load(includes) + select を使って絞り込みを行う
eager_load(includes) + select を使って絞り込みを行うと、同じ処理内で異なる book_type のものを取得しようした場合でも、N + 1 問題を回避することができる。
authors = Author.all.eager_load(:books)
authors.each do |author|
magazines = author.books.select{ |book| book.book_type == 'magazine' }
magazines.each_with_index do |magazine, index|
pp "--magazine_#{index}--", magazine
end
end
authors.each do |author|
comics = author.books.select{ |book| book.book_type == 'comic' }
comics.each_with_index do |comic, index|
pp "--comic_#{index}--", comic
end
end
# 実行結果
"--magazine_0--"
#<Book:0x00007f83b74dd428
id: 1,
name: "book_one",
author_id: 1,
book_type: "magazine">
"--magazine_0--"
#<Book:0x00007f83b74f49c0
id: 3,
name: "book_three",
author_id: 2,
book_type: "magazine">
"--comic_0--"
#<Book:0x00007f83b74f50a0
id: 2,
name: "book_two",
author_id: 1,
book_type: "comic">
"--comic_0--"
#<Book:0x00007f83b74f4c40
id: 4,
name: "book_four",
author_id: 2,
book_type: "comic">
発行される SQL は一つだけとなっている。
SQL (0.2ms) SELECT "authors"."id" AS t0_r0, "authors"."name" AS t0_r1, "books"."id" AS t1_r0, "books"."name" AS t1_r1, "books"."author_id" AS t1_r2, "books"."book_type" AS t1_r3 FROM "authors" LEFT OUTER JOIN "books" ON "books"."author_id" = "authors"."id"
おまけ
N + 1 問題を回避するために includes などを使うと LEFT OUTER JOIN が発生するので、 JOIN するテーブルの規模がとってもとっても大きかったりすると、実は N + 1 を発生させたままにしておいた方が早いってことがあったりするらしい。
実際に色々試して計測して、一番早くなる方法を採用するのが良いですね。おわり。