Get started with ActiveRecord scoped associations
Active Record Associations are a great feature of Ruby on Rails. Associations allow you to declare - well - associations between your models. AR Associations also allow you to write operations when several models are linked (e.g., Author.first.books.create(title: 'I love Rails!')
).
But I had never thought of using them as scopes until last week! So let me show you a neat little trick that’ll make your code much much more expressive (and keep your N+1 queries in check).
Defining basic associations
I won’t dig into the basics of Active Record Associations. If you don’t know your way around them, go and read the doc first.
Let’s define a couple of models with their associations.
class Author < ApplicationRecord
has_many :books
end
class Book < ApplicationRecord
belongs_to :author
end
Okay, so now we have a one-to-many relationship between an author and its books. In my console, I can query all the books from one author like this:
author = Author.first
author.books
It will return a collection of books for my first author.
Filtering things out with scopes
Now, let’s say some books can either be available or not available. What I’d like is to get a collection of all the books that are currently available in my application.
How can I filter out unavailable books?
I could write something like this:
available_books = author.books.where(available: true)
But what if I write this line above in multiple places in my code? Well, if I decided to change the condition of the collection, I would need to edit my code several times. This would be time-consuming, and error-prone.
To DRY things up, I can write a scope in my Book
model to centralize the where(available: true)
bit.
class Book < ApplicationRecord
belongs_to :author
scope :available, -> { where(available: true) }
end
The available
scope now lets me do this:
author.books.available
It looks already more readable, right?
One caveat with scopes is that they are not preloaded. This’ll eventually results in N+1 queries.
authors = Author.first(2)
authors.map do |author|
"#{author.first_name} has #{author.books.available.length} books available"
end
Author Load (0.3ms) SELECT "authors".* FROM "authors" ORDER BY "authors"."id" ASC LIMIT ? [["LIMIT", 2]]
Book Load (0.3ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? AND "books"."available" = ? [["author_id", 1], ["available", 1]]
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? AND "books"."available" = ? [["author_id", 2], ["available", 1]]
=> ["Bob has 2 books available", "Starhawk has 0 books available"]
What you see above are N+1 queries: one query for the author, one query for each book. Not something you want to keep around in your code.
Let’s make our code more expressive and robust!
Adding scopes to your has_one or has_many associations
Remember our available
scope?
class Book < ApplicationRecord
belongs_to :author
scope :available, -> { where(available: true) }
end
I can define a new association with the available
scope as a parameter.
class Author < ApplicationRecord
has_many :books
has_many :available_books, -> { available }, class_name: 'Book'
end
Please note that I’m passing:
- the name of the scope -
available
- in the association lambda - the name of the class the association points to, with the key
class_name
Now, I can query available books like this:
author.available_books
It reads like plain English. Beautiful, isn’t it?
I also like that the model Author
doesn’t get to know about the internal logic of books’ availability. This logic is encapsulated in the Book
model because it only concerns books. Neat!
Many-to-many associations with scope
What if our books have several authors? How do we scope through the has_many
association?
Let’s edit our models.
class Author < ApplicationRecord
has_many :books, through: :authors_books
end
class Book < ApplicationRecord
has_many :authors, though: :authors_books
scope :available, -> { where(available: true) }
end
class AuthorsBook < ApplicationRecord
belongs_to :author
belongs_to :book
end
We changed the nature of the association in Book
and added a third table to handle the relationship: AuthorsBooks
. Let’s re-create the has_many :available_books
association.
class Author < ApplicationRecord
has_many :books, through: :authors_books
has_many :available_books, -> { available }, through: :authors_books, source: :book
end
Like for the belongs_to
association, we passed the name of the scope in a lambda. Only this time, we send it through the authors_books
table. Note that we’re not using class_name
anymore. We’re using source
instead as per Rails documentation.
And what about preloading?
authors = Author.includes(:available_books).first(2)
authors.map do |author|
"#{author.first_name} has #{author.available_books.length} books available"
end
Author Load (0.2ms) SELECT "authors".* FROM "authors" ORDER BY "authors"."id" ASC LIMIT ? [["LIMIT", 2]]
Book Load (0.5ms) SELECT "books".* FROM "books" WHERE "books"."available" = ? AND "books"."author_id" IN (?, ?) [["available", 1], ["author_id", 1], ["author_id", 2]]
=> ["Bob has 2 books available", "Starhawk has 0 books available"]
Thanks to includes
, I can preload my scoped association. No more N+1 queries: one query for the author and one query for all the books. Note that, in my loop, I can replace author.books.available
with author.available_books
for more readability.
I hope it’ll help next time you need to access a scope through a has_many
association!
Did I miss something? Submit an edit on GitHub.
Cheers,
Rémi - @remi@ruby.social