Customising Single Table Inheritance mapping in Active Record
A few weeks ago I was working on modelling set of relationships between very similar concepts so I evaluated the different alternatives that Active Records provides for this; Abstract classes, Single Table Inheritance (STI) and Delegated Types. I ended up going with STI (I might post about this thought process later) and in doing so, I learned that there’s a way to customise how Rails maps the relationships between parent and child classes.
How STI works by default
For the purpose of this article, let’s assume we’re modelling an app where there are bicycles of different types (e.g. Road, Mountain, Gravel) and that we’ve already decided that using STI is the right approach.
To make STI work in Rails you first need to create the parent model and its associated table with a column named type
.
rails generate model bicycle type sku
class Bicycle < ApplicationRecord
end
type
column in ActiveRecord is reserved to use it exclusively for STI. You can disable this by using self.inheritance_column = nil
in the model.Now you can create subtypes of Bicycle type by creating new classes that inherit from Bicycle
class MountainBicycle < Bicycle
end
class RoadBicycle < Bicycle
end
class GravelBicycle < Bicycle
end
Now you can create Bicycles by using the Bicycle model and providing the subclass class name as the type
:
mountain_bicycle = Bicycle.create!(
type: "MountainBicycle", # the subclass class name
sku: 123456
)
mountain_bicycle #=> Returns an instance of MountainBicycle
Bicycle
, it’s of type MountainBicycle
! Rails casts the type automatically for youOr you can also use the subtype directly:
road_bicycle = RoadBicycle.create!(sku: 987654)
road_bicycle #=> Returns an instance of RoadBicycle
Now if you were to query for all bicycles, Active Record will return a collection of subtypes, not a collection of bicycles.
Bicycle.all #=> ActiveRecord Collection
# [
# MountainBicycle:0x000333222 id: 1,
# RoadBicycle:0x0004448883203 id: 2
# ]
This is very cool and very useful! Rails does this by querying the database for all Bicycles and then initialising a new instance of the class that’s stored in the database. Something like:
Get all records
Map through records
Get record’s
type
(which is returned as a string)initialise an instance based on the type using something like
type. contantize.new(attributes)
In our case:
Get all Records
Map through records
Record 1
“MountineBicycle”.constantize.new(attributes)
Record 2
”RoadBicycle”.constantize.new(attributes)
return
[MountainBicycle, RoadBicycle]
Essentially we are mapping the type stored in the database to the class (subtype/child) it corresponds to. But what if you need to change how that mapping behaves?
Customising the mapping
The default way Active Record works is very much in line with Rails’ core values: convention over configuration. In this case, the convention is that the subtype’s class name is stored as the type
in the parent class’ table.
There are some scenarios though in which you need to tweak the configuration to fit your use case; you might need to migrate from one model to another or you might not want to tie up your domain modelling with your data (which I learned is quite a healthy thing to do, specially when you’re still figuring things out).
To be more concise, you might want to use just the word “mountain” or “road” as the types stored in the type
column in the bicycles
table but let Active Record still return instances of MountainBicycle
and RoadBicycle
.
To do so, ActiveRecord::Inheritance
provides a couple of methods you can override to teach your classes to use a different mapping between the value stored in the database and the class used to initialise records.
How to customise the mapping of types when using Single Table Inheritance
On the parent class, you’ll need to override the sti_class_for
(docs, code) method to teach it how interpret the type_name
stored in the database:
class Bicycle < ApplicationRecord
def self.sti_class_for(type_name)
case type_name
when "road" then RoadBicycle
when "mountain" then MountainBicycle
else
raise SubclassNotFound
end
end
end
Now when you query for all bicycles using Bicycle.all
Active Record will now know that if the type
returns the string “road”, it should use RoadBicycle
to initialise a new instance of the record.
On the subtype/child class, you’ll need to teach it which “type name” it responds to:
class MountainBicycle < Bicycle
def self.sti_name = "mountain"
end
class RoadBicycle < Bicycle
def self.sti_name = "road"
end
This is so when you initialise and create a new instance of the subtype it know which value to store in the database.
mountain_bicycle = MountainBicycle.new
mountain_bicycle.type #=> "mountain"
Support for this was added fairly recently in a PR from 2019 https://github.com/rails/rails/pull/37500 by Rafael França.
Recently I’ve started interacting and sharing more stuff over on Bluesky and it’s been great. Old-school Twitter vibes, no ads, very genuine interactions and a promise of a future of more control over your data in social media. I hope it stays that way for long. Here’s my profile if you wanna connect and a Starter Pack with Ruby, Rails and Web-related people to get you up and running!