Querying Across Associations with Named Scopes and Merge

Using ActiveRecord::Relations to Your Advantage

Dave Frame
3 min readJun 25, 2020

Introduction

You have data, and you want insights. ActiveRecord queries give Ruby developers the power to work with databases without sifting through the complex syntax of SQL statements. However, even with ActiveRecord, a complex query can get out of hand fast. Just like SQL, ActiveRecord will allow us to chain methods to our heart’s content, but, at some point, this starts to affect readability, and we can wind up lost in our own logic.

Luckily ActiveRecord also provides helper methods that allow us to abstract our logic and chart even incredibly complex relationships between many, even seemingly unrelated, tables. Two such methods I’ve found incredibly powerful and interesting are scope and .merge. On their own, each is helpful. But we really start to see their power when we combine them. Before we get there, let’s look at what they’re doing.

Scope

A scope consists of the scope call, a name, written as a symbol, a lambda (->) and curly braces containing the logic of our query:

scope :name -> { your logic here }

At its base, a scope is just a class method. It allows us to retrieve and query objects. The power of a scope lies in the fact that, unlike a normal class method, it will always return an ActiveRecord::Relation, not a simple array. This means we can continue to chain query methods like .where and .order after our scope method, and even chain scopes on top of each other.

It also means that we never have to worry about getting a nil value. Worst case scenario, our ActiveRecord::Relation contains an empty array. This means chained scopes or methods, while they might fail to return anything very useful, won’t raise an “Undefined method for nil:NilClass” error.

Scopes allow us to create simple, repeatable filters we can apply to our queries and methods, like so:

class Shirt < ActiveRecord::Base
scope :red, -> { where(color: 'red') }
scope :dry_clean_only, -> { joins(:washing_instructions).where('washing_instructions.dry_clean_only = ?', true) }
end

Source: https://api.rubyonrails.org/classes/ActiveRecord/Scoping/Named/ClassMethods.html#method-i-scope

This keeps our code DRY and highly readable. The above code can be easily reused wherever we need it by calling Shirt.red, Shirt.dry_clean_only, or even Shirt.red.dry_clean_only.

Merge

Merge is another querying method that allows us to combine two ActiveRecord::Relation objects. We can use it where there are common conditions between multiple associations. It will return the intersection of the two conditions like so:

first_relation = User.where(:first_name => ‘Bojack’) 
second_relation = User.where(:last_name => ‘Horseman’)
first_relation.merge(second_relation) #=> object for Bojack Horseman

In this case, we’re working with a single class to find the intersection of two conditions, a first name and a last name. Pretty straight forward. Again, we can see how this can be helpful, but it becomes really powerful if we use it with joins. This allows us to filter results between classes:

Post.where(published: true).joins(:comments).merge( Comment.where(spam: false) )

Source: https://api.rubyonrails.org/classes/ActiveRecord/SpawnMethods.html#method-i-merge

Combining Scopes and Merges in Action

Imagine we have a library application that has the following models: Author, Book, and Tag. We can assume an author has many books, and a book will belong to an author. A tag will also have many books, but a book could also have many tags (e.g. A Fault in Our Stars might be #fiction, #YA, and #bildungsroman with a theme of #death or #terminal-illness). This many to many relationship means we’ll need a join table, BookTag. Now we have four models and no association between tags and authors. But what if we want to use an ActiveRecord query to find all the authors in our library who write horror?

With merging and scopes, this is no problem. Let’s start with the Tag class:

class Tag < ActiveRecord::Base
has_many :book_tags
has_many :books, through: :book_tags

scope :with_name, -> (name) { where(name: name) }

end

Then in our Book class, we can merge our book query with the pre-filtered Tag query:

class Book < ActiveRecord::Base
has_many :book_tags
has_many :tags, through: :book_tags

scope :with_tag, -> (name) { joins(:tags).merge(Tag.with_name(name)) }

end

And finally, we can bring it all together in our Author class:

class Author <ActiveRecord::Base
has_many :books

scope :with_books_with_tag, -> (name) { joins(:books).merge(Book.with_tag(name)).uniq }

end

Now, we can do all that work with one simple, easy-to-read line:

Author.with_books_with_tag(“horror”)

And, done!

Conclusion

Readability is important. Can you imagine the SQL statement to achieve the above example? Me neither. Luckily we don’t have to, thanks to the power of ActiveRecord::Relations, named scopes, and .merge. Using these methods, we can sort through massive troves of data with relative ease, and get the insights we need to make our apps useful. Try it out next time to keep things DRY and save future collaborators’ precious time.

--

--

Dave Frame
Dave Frame

Written by Dave Frame

Full Stack Web Developer//MFA in Creative Nonfiction

No responses yet