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
💡
The 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
👀
Note how the instance returned is not of type Bicycle, it’s of type MountainBicycle! Rails casts the type automatically for you

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:

  1. Get all records

  2. Map through records

    1. Get record’s type (which is returned as a string)

    2. initialise an instance based on the type using something like type. contantize.new(attributes)

In our case:

  1. Get all Records

  2. Map through records

    1. Record 1

      1. “MountineBicycle”.constantize.new(attributes)
    2. Record 2

      1. ”RoadBicycle”.constantize.new(attributes)
  3. 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!

0
Subscribe to my newsletter

Read articles from Julián Pinzón Eslava directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Julián Pinzón Eslava
Julián Pinzón Eslava