Last Updated: February 25, 2016
·
10.27K
· alexvpopov

Preloading scoped has_many :through associations

Recently, I read Justin Weiss' very nice article on How to Preload Rails Scopes. The gist of it is this - if you have:

class Product < ActiveRecord::Base
  has_many :ratings
end

and

class Rating < ActiveRecord::Base
  belongs_to :product
end

instead of having a scope inside Rating like this:

scope :positive, -> { where 'score > 3.0'}

you can have a scoped associaton inside Product

has_many :positive_ratings, -> { where 'score > 3.0'}, class_name: 'Rating'

The rational for this is that in the first case you would call the positive ratings for a product like this:

product.ratings.positive

and in the second case like this:

product.positive_ratings

This also allows you to eager load them like this:

Product.includes(:positive_ratings).

which is not possible in the first case.

An even more interesting twist: what if you have a has_many :through association between two models and the through model also happen to use the Single Table Inheritance pattern?

Let's see this in practice:

class User < ActiveRecord::Base
  has_many :activities, inverse_of: :user
  has_many :products, through: :activities
end

class Activity < ActiveRecord::Base
  belongs_to :user, inverse_of: :activities
  belongs_to :product, inverse_of: :activities
end

class View < Activity; end

class Save < Activity; end

class Product < ActiveRecord::Base
  has_many :activities, inverse_of: :product
  has_many :users, through: :activities
end

What if you want to get only the saved products for a user? You could add the following method to User:

def saved_products
 products.includes(:activities).where(activities: {type: 'Save'})
end

However, you can't preload the saved products and you would have the N+1 problem, when you load a couple of users and display their saved products. Let's fix this.

First, let's add the scoped association between User and Activity to User:

has_many :saves, -> { where type: 'Save' }, class_name: 'Activity'

This will allow us to eager load our saved products like this:

User.includes(saves: :product).first

To have it even more convenient, you need to take the next step and add the scoped association between User and Product:

has_many :saved_products, through: :saves, source: :product

The key step here is to add the source option. It:

Specifies the source association name used by hasmany :through queries. Only use it if the name cannot be inferred from the association. hasmany :subscribers, through: :subscriptions will look for either :subscribers or :subscriber on Subscription, unless a :source is given.

Since Activity does not have an association, named 'ViewedProduct', you need to specify the right name using source.

Now you can just call:

User.includes(:saved_products).first

Happy preloading!