ActiveRecord Scopes: Setting your sights on the common pitfalls

Sam Jenkins
Florence Product blog

--

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

--

--

Sam Jenkins
Florence Product blog

Full-stack Ruby/Rails engineer | Staff Engineer @ Florence | Formerly @ Relished, Smart Pension, Cookpad, and Hero Health