I'd like to trigger eager loading on a relation with an #includes
call, that was modified with a custom preloader using ActiveRecord::Associations::Preloader
on a Rails 7 app, Ruby 3.2:
class Product < ApplicationRecord
belongs_to :vendor
has_many :taxons
end
class Vendor < ApplicationRecord
has_many :products
end
records = Product.where(id: [1,2,3,4])
scope = Vendor.where.not(id: [5,6])
preloader = ActiveRecord::Associations::Preloader.new(records:, associations: :vendor, scope:).tap(&:call)
puts records.first.association_cached?(:vendor)
=> true
puts records.includes(:taxons).first.association_cached?(:vendor)
=> false
I need the scope:
argument because some users aren't allowed to access certain resources.
Without the includes(:taxons)
call, the association :vendor
is correctly eager loaded and cached. But in the second case, it seems like the whole eager loading of Preloader
is somehow dropped.
Is there a way to include a custom preloader as a chain argument along with other includes
? I get an error when I try to include the preloader itself like this:
records.includes(preloader).to_a
ActiveRecord::AssociationNotFoundError: Association named '#<ActiveRecord::Associations::Preloader:0x00007f573bc31ab0>' was not found on Product; perhaps you misspelled it?
from /usr/local/bundle/gems/activerecord-7.0.4.2/lib/active_record/associations.rb:302:in `association'
As Jan Vítek pointed out, any queries called on a relation will reload all objects, and therefore loose all already eager loaded associations.
Due to the structure of the code, I can't guarantee to always call the custom ActiveRecord::Associations::Preloader
, so I came up with a little monkey patch for ActiveRecord::Relation#preload_associations
:
raise "ActiveRecord::Relation#preload_associations is no longer available, check patch!" unless ActiveRecord::Relation.method_defined? :preload_associations
raise "ActiveRecord::Relation#preload_associations arity != 1, check patch!" unless ActiveRecord::Relation.instance_method(:preload_associations).arity == 1
module PreloadWithScopePatch
def preload_with_scope(association, scope)
scope = model.reflect_on_association(association).klass.where(scope) if scope.is_a?(Hash)
(@preload_with_scope ||= []).push([association, scope])
self
end
def preload_associations(records)
super.tap do
Array(@preload_with_scope).each do |associations, scope|
ActiveRecord::Associations::Preloader.new(records:, associations:, scope:).call
end
end
end
end
ActiveRecord::Relation.prepend(PreloadWithScopePatch)
ActiveRecord::Base.singleton_class.delegate(:preload_with_scope, to: :all)
Now you can use it like this:
records = Product.where(id: [1,2,3,4]).preload_with_scope(:vendor, Vendor.where.not(id: [5,6])).includes(:taxons)
records.map { |r| r.association_cached?(:vendor) }
=> [true, true, true, true]
records.map { |r| r.association_cached?(:taxons) }
=> [true, true, true, true]