Preserving with_scope on eager-loaded Associations

(I’m just gonna start posting things now. Please excuse the suddenness of this article. –brendan)

As web developers, one of the first places we turn to when tuning performance of pages that include a lot of data is to minimize the number of queries to the database needed to render a page. In Rails, we use the “eager loading” facility provided by the :include option as in Article.find(:all, :include => :author), the upshot being that the object represented by the author association is retrieved by the same query that retrieves all of the Article objects.

One of the other niceties of Rails is the ability to apply a scope to restrict the result set for operations conducted on a model within a block, such as (contrived example, just go with me here:)

Article.with_scope :find => {:conditions => ['published = ?', true]} do
   @author = User.find(user_id)
   @author.articles.each do |article|
     ... show an article or something ...
   end
end

The problem comes when we combine eager loading with expectations that the scope defined using with_scope is being applied properly:

Article.with_scope :find => {:conditions => ['published = ?', true]} do
   @author = User.find(user_id, :include => :articles)
   @author.articles.each do |article|
     ... show an article or something ...
   end
end

In the above example, the :include => :articles bit eager-loads ALL of the articles into the resulting User object @author and we wind up showing non-published articles in the ensuing loop. The reason for this is that the sql generated by ActiveRecord does not honor scopes for eager loading.

Here is some code that changes this.

ActiveRecord::Associations::ClassMethods::JoinDependency::JoinAssociation.class_eval do

  def association_join_with_scoped_eager_loading
    # Don't bother to create the subquery unless we have a finder scope to apply.
    return association_join_without_scoped_eager_loading unless reflection.klass.class_eval{scope :find}
    "#{association_join_without_scoped_eager_loading} AND " <<
    "#{aliased_table_name}.#{reflection.klass.connection.quote_column_name(reflection.klass.primary_key)} IN " <<
    "(#{reflection.klass.__send__(:construct_finder_sql,{:select => 
      "#{reflection.klass.table_name}.#{reflection.klass.connection.quote_column_name(reflection.klass.primary_key)
    }"})})"
  end

  alias_method_chain :association_join, :scoped_eager_loading

end

So what’s going on here? What we’re basically doing is creating a subquery that returns all of the ids for the class referenced by the association, to match against the id for the records we are eager-loading, ensuring that we only eager-load associated records that are properly scoped. The negative performance impact of this approach depends on your database engine and the complexity of the scope you are applying.

Post a Comment

Your email is never published nor shared. Required fields are marked *

*
*