"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