Search code examples
ruby-on-railsrubyactiverecordeager-loading

Extend custom Rails preloader with includes


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'

Solution

  • 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]