Hamed Asghari

home

ActiveRecord: Enhanced Query Objects

15 Sep 2013

Your ActiveRecord models are usually the first place in your application where the unwieldy code begs for refactoring.

In an excellent post by Bryan Helmkamp on the Code Climate Blog, he outlined 7 Patterns to Refactor Fat ActiveRecord Models. One of the patterns from this blog post that I want to focus on is Extract Query Objects.

We have been using this pattern for a while but I missed the convenience of chainable and reusable scopes. Here's an example:

 1 class Product < ActiveRecord::Base
 2   has_many :reviews
 3 end
 4 
 5 class PopularProductQuery
 6   def initialize(relation = Product.scoped)
 7     @relation = relation
 8   end
 9 
10   def popular(time)
11     @relation.joins(:reviews).where(reviews: { created_at: time..Time.now,
12                                                available: true })
13   end
14 
15   def with_recent_activity(time)
16     @relation.joins(:reviews).where(reviews: { created_at: time..Time.now })
17   end
18 
19   def with_available_reviews
20     @relation.joins(:reviews).where(reviews: { available: true })
21   end
22 end

The query object above defines three utility methods to return records of Product with certain properties. However, you will notice that PopularProductQuery#popular is combining the logic of #with_recent_activity and #with_available_reviews. The trivial solution to keeping this DRY is defining scopes on the Product model:

 1 class Product < ActiveRecord::Base
 2   has_many :reviews
 3 
 4   scope :popular, ->(time) {
 5     with_recent_activity(time).with_available_reviews
 6   }
 7 
 8   scope :with_recent_activity, ->(time) {
 9     joins(:reviews).where(reviews: { created_at: time..Time.now })
10   }
11 
12   scope :with_available_reviews, ->(time) {
13     joins(:reviews).where(reviews: { available: true })
14   }
15 end

Ideally we would like to define these scopes on our query objects to prevent our models from growing "fat" over time. If these scopes were so common that they would be used across many different contexts in our application, we would probably want to keep them on the model but for the purpose of this post, let's assume that these are very specific scopes that we would like to isolate to the query object.

An existing but rarely advertised feature of ActiveRecord is that you have the ability to extend any ActiveRecord::Relation object with your custom scopes:

 1 class PopularProductQuery
 2   def initialize(relation = Product.scoped)
 3     @relation = relation.extending(Scopes)
 4   end
 5 
 6   def popular(time)
 7     @relation.with_recent_activity(time).with_available_reviews
 8   end
 9 
10   module Scopes
11     def with_recent_activity(time)
12       joins(:reviews).where(reviews: { created_at: time..Time.now })
13     end
14 
15     def with_available_reviews
16       joins(:reviews).where(reviews: { available: true })
17     end
18   end
19 end

Here we are taking advantage of the ActiveRecord::QueryMethods#extending method to add custom scopes to our query object without polluting the model space. In other words, Product.with_available_reviews is not valid. To put it all together, you would use the enhanced query object like so:

PopularProductQuery.new.popular(2.weeks.ago)

I've come to really like this pattern to adhere to the Single Responsibility Principle and keep my models manageable.


comments powered by Disqus