ひとつのUserが複数のItemを持っているという構造で、Item.nameを出したい。
class User < ActiveRecord::Base
has_many :items
end
class Item < ActiveRecord::Base
end
@users = User.where(foo_bar_condition: 1)
@users.each do |user|
user.items.each do |item|
item.name
end
end
このままではUserの数だけItemに対するクエリが出てしまい、パフォーマンスがあまりよろしくない。これはpreload(またはincludes)を使うことで対策ができる。
class User < ActiveRecord::Base
has_many :items
end
class Item < ActiveRecord::Base
end
@users = User.where(foo_bar_condition: 1).preload(:items)
@users.each do |user|
user.items.each do |item|
item.name
end
end
このとき、Userと同時にItemを WHERE item.user_id IN (?)
のようなクエリで取得してくれる。
ここに加えて、Itemをis_activeで絞りたい。便利に使えるようscopeを付けたとする。
class User < ActiveRecord::Base
has_many :items
end
class Item < ActiveRecord::Base
scope :active, -> { where(is_active: true) }
end
@users = User.where(foo_bar_condition: 1).preload(:items)
@users.each do |user|
user.items.active.each do |item|
item.name
end
end
するとN+1…N+2になってしまう。Userへのクエリ、Itemをuser_id IN (?)するクエリ、UserごとのItemへのクエリ。
scopeで記述する部分はActiveRecordのクエリになっていて、preloadする時点では知る由がないため、絞り込むことができない。これを解決する方法はいくつか方法あるけど、アソシエーションを設定するのが一番お手軽だった。
class User < ActiveRecord::Base
has_many :items
has_many :items_active, -> { active }, class_name: 'Item'
end
class Item < ActiveRecord::Base
scope :active, -> { where(is_active: true) }
end
@users = User.where(foo_bar_condition: 1).preload(:items_active)
@users.each do |user|
user.items_active.each do |item|
item.name
end
end
アソシエーションの定義で、対象のモデルのscopeを利用できる、というもの。モデルとは異なる名前にした場合はclass_nameの値でどこにマッピングするかを決められる。
ActiveRecord::Associations::ClassMethods, has_many label options
scopeのpreloadどうやるんだろうな〜と思ってActiveRecordの説明みていたら、scopeで作ったものを見ていそうなものがあったので、なるほど〜とやったら動いた。
他にも ActiveRecord::Associations::Preloader
を使うという方法があるみたい。
ActiveRecordでScoped preloading - Qiita
これは埋め込み先をわかっていないとできないので、複雑に入りくんだデータをガッとpreloadするのは大変。かつ、使う箇所で scopeの情報(=activeであるitem)が失われるので、もしかしたら理解が難しくなるかもしれない。
@users = User.where(foo_bar_condition: 1)
ActiveRecord::Associations::Preloader.new.preload(
@users,
:items,
Item.active
)
@users.each do |user|
user.items.each do |item|
item.name
end
end
※これはRails6までで、Rails7では記述の仕方が違う。
Remove deprecated private API in ActiveRecord::Associations::Preloader · rails/rails@e3b9779
ActiveRecord::Associations::Preloader.new(
records: @users,
associations: :items,
scope: Item.active
)
なのだけれど、手元で試すとクエリのログがいくつも流れてしまうのでちょっと違うような気がする。どうやるんだろう。。
preloadじゃなくてeager_loadとmergeを使う方法もある。mergeは別のActiveRecordのクエリをくっつけることができる。
ActiveRecord::SpawnMethods#merge
eager_loadの場合、クエリを分けずにJOINしてまとめてデータを取ることになるので、たくさんくっつけるとクエリが大変になる。その上、条件次第、具体的にはeager_loadした先のフィールドで絞るので結果が違うケースが出てくる。これはuser.nameも出すとわかる。
@users = User.where(foo_bar_condition: 1).eager_load(:items).merge(Item.active)
# User LEFT OUTER JOIN Item ON User.id = Item.user_id WHERE User.foo_bar_condition = 1 AND Item.is_active = true みたいなクエリになる。
@users.each do |user|
user.name # Item.active を持っていないUserが出てこない
user.items.each do |item|
item.name
end
end