"Look closer" | Rails association scope


Setting up relationships between models is an important aspect of domain representation. Ruby on Rails gives us a powerful tool to build such relationships through associations.

class User < ApplicationRecord
  has_many :images
end

Even being unfamiliar with Rails, we can guess from this piece of code that a user in the given domain owns some images. From this tiny declaration of a one-to-many relationship, Rails will derive an extensive set of tools to operate with such kind of relationship, substantially increasing developer's productivity. Here is the excellent documentation on what is included.

The typical way of declaring associations is by mirroring relations between database tables, relying on foreign keys directly or through a separate join table. Like, from the previous example we can also say that the images table has the user_id column to hold references to records in the users table.

However, there is often a need to set up relationships that do not strictly follow this convention or tweak the behavior of produced objects. Such configuration can be done using a scope block, passed as the second argument to an association. Let's further look at some cases where this might be useful.

Read-only associations

Here is one type of relationship between models which pops up very often. Suppose a part of our domain operates with buildings, residents, and locations. Each building has its location and can contain residents. A resident got registered in a particular building thus can be reached by the address of that building.

We can model such a scenario in the following way.

class Building < ApplicationRecord
  has_one :location
  has_many :residents
end
class Location < ApplicationRecord
  belongs_to :building
end
class Resident < ApplicationRecord
  belongs_to :building
  has_one :location, through: :building
end

This is a well-known way of declaring one relationship through another in Ruby on Rails. A noticeable thing here is that resident’s indirect relation with the location comes with an implicit access policy.

Because the building fully determines resident’s location, the only way to change the location is to register the resident in a different building. If you try to assign a new location directly to the resident Rails will throw an error.

Under normal circumstances, accessing a location through a resident should never lead to changes in the location itself. To ensure this, we can make the location record read-only using an association scope.

class Resident < ApplicationRecord
  belongs_to :building
  has_one :location, -> { readonly }, through: :building
end

Eager loading

In one-to-many relationships, it is usually enough for a belonging association to represent a collection of related records. Yet, sometimes it is also useful to have a separate association to one record from that collection.

For example, a user in your system might be able to create a gallery of images and pick one as a profile avatar. Associations between the user and images might look like this.

class User < ApplicationRecord
  has_many :images, class_name: User::Image.name
end
class User::Image < ApplicationRecord
  belongs_to :user
  # avatar:boolean
end

Any image can be set as the avatar image through the boolean field avatar.

Thanks to the has_many association, we have access to user images, but we also need a way to get the avatar image.

We might try to implement access to the avatar by defining a simple getter method on the User model.

class User < ApplicationRecord
  has_many :images, class_name: User::Image.name

  def avatar
    images.where(avatar: true).first
  end
end

But this approach lacks the possibility to perform eager loading of related records. If we try to display a list of users with avatars, we will face the N+1 queries problem.

Instead of defining the relationship manually through the getter method, let's use the active record association with a custom scope.

class User < ApplicationRecord
  has_many :images, class_name: User::Image.name
  has_one :avatar,
          -> { where(avatar: true) },
          class_name: User::Image.name
end

Now, we do not only have the accessor to the avatar image but also can efficiently load avatars along with users like this User.includes(:avatar).

State reflection

It is common for a model to have a variable state. Having a single column representing model’s state is often not enough. Processes, which affect the model, may be quite complex and require a separate model for representation.

As an example, suppose there is the Car model represefnting an actual vehicle in a car rental company domain. Each vehicle is constantly involved in business processes like being in use by a client, being on maintenance, etc. Some processes may affect vehicle’s state within the domain, for example, if a car is currently on maintenance, it cannot be given to a client.

You would like to define specific rules for each such process and collect valuable information, which is better to do through dedicated models.

class Car < ApplicationRecord
end
class Car::ClientUsage < ApplicationRecord
  belongs_to :car

  # state:string ['active', 'done']
end
class Car::Maintenance < ApplicationRecord
  belongs_to :car

  # state:string ['active', 'done']
end

Here we have set associations for Car::ClientUsage and Car::Maintenance models, so we are able to collect records about each process a vehicle went through.

Usually, a process may be described as a series of changing states. For simplicity, let's say our processes can only be in one of two states active or done.

We can give a car to only one client at a time, thus there can only be one record of the Car::ClientUsage with the active state per car. Same is also true for the Car::Maintenance records.

Given all that, let's reflect those states in the Car model using association scopes.

class Car < ApplicationRecord
  has_one :active_client_usage,
          -> { where(state: 'active') },
          class_name: Car::ClientUsage.name

  has_one :active_maintenance,
          -> { where(state: 'active') },
          class_name: Car::Maintenance.name
end

With this setup, we now can:

  • determine car’s current state;
  • easily access the state record through a car;
  • query cars scoped by the state (e.g., Car.joins(:active_maintenance) - all cars currently on maintenance);
  • perform eager loading of states we are interested in.

Reusing scopes

Auxiliary associations may also help with reusing scopes in some cases.

Let's say you are collecting data about participants taking part in competitions with the following models.

class Participant < ApplicationRecord
  has_many :records, class_name: Participant::Record.name
end
class Participant::Record < ApplicationRecord
  belongs_to :participant

  scope :with_avg_score, -> {
    group(:participant_id).
      select("#{table_name}.*, AVG(score) as avg_score")
  }
end

The Participant::RecordsController index action renders a view with a table of all collected results. At some point, there was a requirement also to display an average score for each participant in this table. So you have added the with_avg_score scope to the Participant::Record model and changed the controller like this.

class Participant::RecordsController < ApplicationController
  def index
    @records = Participant::Record.with_avg_score
  end
end

Now there is a requirement to display collected records, including average scores, along with a list of participants rendered by the ParticipantsController index action.

class ParticipantsController < ApplicationController
  def index
    @participants = Participant.all
  end
end

You cannot simply loop through participants and get scoped records using participant.records.with_avg_score, because it is the subject to N+1 queries.

To avoid excess queries, we might try to preload records manually with:

Participant::Record
  .with_avg_score
  .where(participant_id: @participants.map(&:id))

But then we would have to figure out an efficient way to access these records while iterating through items in @participants later in the view.

It may seem like we have to write the average score calculation query again, but this time as a part of the participants retrieval query.

However, in this case, we actually can reuse the scope defined in the Participant::Record through a scope block argument for a new auxiliary association in the Participant model.

class Participant < ApplicationRecord
  has_many :records, class_name: Participant::Record.name
  has_many :records_with_avg_score,
           -> { with_avg_score },
           class_name: Participant::Record.name
end

Now we can use this association as an argument for the includes method like this:

Participant.includes(:records_with_avg_score).

There is a caveat, though!. Scopes with parameters (e.g., scope :foo, -> (param) { }) will not work with joins, eager loading, and preloading.

Feb 04, 2022