home articles sponsorship

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:

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