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
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
Or 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)
- Get record’s
In our case:
- Get all Records
- Map through records
- Record 1
“MountineBicycle”.constantize.new(attributes)
- Record 2
”RoadBicycle”.constantize.new(attributes)
- Record 1
- 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!