Introduction
On a client project recently, we ran into a domain problem that didn’t fit into the ActiveRecord standard conventions. The following is the thought process taken to get to our solution, so it gets detailed in some areas.
ActiveRecord has a great feature called Single Table Inheritance. It allows a model to have multiple types while using a single database table for the storage. Those type abstractions can each have their own validations, override base functionality, and specific abstraction functionality.
If your model has ever been littered with case
statements checking if a User
is a guest, admin, etc., you should take a look at STI.
Models
The project had a based model that had many types and each abstraction had a scope of .active
that defined what it meant to be active for that type.
class Person < ActiveRecord::Base; end
class FireFighter < Person
def self.active
where(has_helmet: true)
end
end
class PoliceOfficer < Person
def self.active
where(has_squad_car: true)
end
end
Problem
We needed to create an API endpoint that returned all active Person
instances. This would require us to iterate through each child of Person
and get all its current active members. Since we have Person
model let’s give it a concept of .active
that incorporates every active member of society in our domain.
Solution
We can extend Person
to return an array of each active FireFighter
and PoliceOfficer
.
class Person < ActiveRecord::Base
def self.active
FireFighter.active.all + PoliceOfficer.active.all
end
end
One problem we have with this implementation is every time we add a new abstraction of Person
we have to add to .active
. Luckily, ActiveRecord STI comes with support for looking up a parent’s .descendants
.
class Person < ActiveRecord::Base
def self.active
active_people = descendants.map do |descendant|
d.active.all
end.flatten
end
end
This is pretty powerful. We can add Astronaut
and any active astronauts will automatically be in Person.active
array. This implementation will help satisfy our API endpoint requirements, but it does break useful ActiveRecord patterns.
Advance Solution
WARNING: Continue at your own risk. If you are content with the solution above stop, but if you want to see what can be done with Arel continue.
What if we want to chain scopes or extend the .active
with pagination for our API? We cannot do this because easily because we are currently returning a Ruby array instead of an ActiveRecord::Relation. How can we modify .active
to be an actual scope?
You might be thinking, ActiveRecord comes with the ability to merge scopes between models. Unfortunately, it does not work very well when merging scopes with STI models.
We ended using Arel (known for not being well documented) within our model. Each ActiveRecord::Relation is actually just an object with holding on to Arel values for different parts of an SQL statement — joins, froms, selects, etc. We are able to get the conditions for WHERE
clause by looking at the ActiveRecord::Relation where_values
.
class Person < ActiveRecord::Base
def self.active
conditions = descendants.map do |d|
d.active.where_values.reduce(:and)
end.reduce(:or)
where(conditions)
end
end
Our implementation takes the where_values
from the .active
scope from each descendant and does an SQL OR
on them. ActiveRecord::Relation can take
# somewhere in a Rails console
> Person.active.to_sql
=> SELECT "people".* FROM "people" WHERE (
("people"."has_helmet" = 't' AND "people"."type" = "FireFighter")
OR
("people"."has_squad_car" = 't' AND "people"."type" = "PoliceOfficer")
)
What does give us? We can now use Person.active
as a normal scope, which allows us to append any conditions on to it.
> Person.active.where(created_at: 2.days.ago..1.day.ago).order(:created_at)
=> []
> Person.active.limit(10)
=> []
About the Author