ActiveRecord Scopes: Setting your sights on the common pitfalls
There are endless resources out there describing how to use Active Record scopes. Today we’re going to explore when and why to use scopes…and when to steer clear!
Encapsulating a query
Imagine we use the following query many times within our code:
Article.where(number_of_words: 0...500)
We could DRY this code by encapsulating the query in a method:
class Article
def self.quick_reads
where(number_of_words: 0...500)
end
end
Whenever we need to fetch articles with a low word count, we can use our new method:
Article.quick_reads
This method DRYs our code and means we have a single source of truth for the definition of a “quick read”. In the future, if we decide that a quick read is any article with fewer than 750 words, we only need to change the method definition. If we hadn’t defined the method and instead had written Article.where(number_of_words: 0...500)
in multiple places across our app, we would have to find and change each one individually.
We can also encapsulate this query as a scope:
class Article
scope :quick_reads, -> { where(number_of_words: 0...500) }
end
The scope is called in the same way as the class method and generates the same SQL query:
Article.quick_reads
Why use scopes instead of class methods?
Intention
When a Rails engineer sees a method definition, they have to read the method name and body before they understand what the method does. When a Rails engineer sees a scope, they immediately know that it encapsulates an SQL query, even before they have read the body of the scope itself. The scope syntax reveals something about the intention of the code. This is a principle known as intentional programming.
Safety
Let’s enhance our example and say that sometimes we won’t allow searching by word count:
class Article
belongs_to :user
scope :with_words_fewer_than, lambda { |number_of_words, allowed_to_search|
return unless allowed_to_search
where(number_of_words: 0...number_of_words)
}
end
Article.with_words_fewer_than(500, false)
SELECT "articles".* FROM "articles"
This is a surprising but useful result of Active Record scopes: they are designed to consistently return ActiveRecord::Relation
objects. Instead of returning nil
(or false
), no scope will be applied and we fetch all Article records from the articles table. This ensures that the scope will always be chain-able and we won’t ever try to call a chained scope on a nil
object.
The problem with scopes
Readability
There is an issue with our quick_reads
scope. If a new developer sees the query Article.quick_reads
somewhere in the code, it is not obvious what SQL query we are making. What is a “quick read”? Without checking the scope definition, it is impossible to know that this scope returns records with fewer than 500 words. This is a common issue with scopes; their brevity can lead to opaque code unless they are named and implemented sensibly.
Perhaps a more sensible definition for this scope would take an argument:
class Article
QUICK_READ_MAX_PAGE_COUNT = 500
scope :with_words_fewer_than, lambda { |number_of_words|
where(number_of_words: 0...number_of_words)
}
end
Whenever we want to fetch quick reads we can now call
Article.with_words_fewer_than(Article::QUICK_READ_MAX_PAGE_COUNT)
This implementation gives the developer a clearer idea of the query we are making without having to refer to the scope definition and retains a single source of truth for the definition of a quick read.
This now begs the question: why not do away with this scope and just implement the following?
Article.where(number_of_words: 0...Article::QUICK_READ_MAX_PAGE_COUNT)
My answer: I would not use a scope in this scenario. The line above retains our reusable single source of truth without it. Scopes can be helpful, but often they offer no meaningful utility and introduce a layer of unnecessary complexity.
Beware of default_scope
Imagine we only want to show published articles on our website:
class Article
default_scope { where(published: true) }
end
Implementing this default scope on the Article
model now means we don’t have to worry about hiding unpublished articles across our application.
If we want a particular page to show unpublished (draft) articles we can override the default scope:
Article.all.unscoped
This seems like a helpful tool. However, in practice, it should be used with extreme caution.
Readability
Applying default_scope to a model adds a layer of hidden behaviour to every query we make to the articles
table and can confuse engineers working on the code. Calling Article.all
will not do what it appears to do at first glance.
Unexpected behaviour: Building new records
When a model has a default scope, building new records can result in unexpected or unintuitive behaviour.
class Article
default_scope { where(published: true) }
end
Article.new.published
# => true
Above, the scope is applied to the new record. This is likely to surprise developers. Intuitively we would expect a new article to be in a “draft” state and to be unpublished.
Unexpected behaviour: unscoped
Let’s add a one-to-many association between users and articles and query all the articles associated with a particular user, published and unpublished:
class Article
default_scope { where(published: true) }
end
class User
has_many :articles
end
# list all articles (published and unpublished) for a user
user = User.find(123)
user.articles.unscoped
The line above will not return all articles associated with that user.
Instead, it will return every article for every user:
SELECT "articles".* FROM "articles"
This is because #unscoped
removes all scopes on your query, not just the default scope; it will remove the scope on the published
column but it will also remove the scope on the user_id
column. This is another aspect of default_scope
’s behaviour that might surprise developers.
Summary
- When considering whether to create a new scope, give thought to whether the scope is actually improving the quality of your code. A badly written scope can make your code less readable.
- Avoid using
default_scope
without very good reason. It almost always causes more difficulty and confusion than the benefits it offers.
Get in touch with your thoughts!
Until next time,
Sam