(Ab)Using Single Table Inheritance to Refactor Fat Models

Posted on · 1 minute read

How to deal with a model that tries to do too much? Consider something like this:

class Vegetable < ActiveRecord::Base
  validates :name, presence: true

  validates :color, inclusion: { in: ['green'] }, if: -> { name == 'AVOCADO'}
  validates :color, inclusion: { in: ['yellow'] }, if: -> { name == 'POTATO'}
end

It makes sense to split this up into three classes: Vegetable, Avocado and Potato. Alright, this particular class isn’t so bad, but imagine Vegetable being hundreds of lines long and containing dozens of validations like that. Yikes.

A big Potato
Quite the enormous potato!

Cutting Up our Fat Model

Rails gives us a straightforward way to refactor Vegetable: Single Table Inheritance. We can create some submodels to split up Vegetable and improve our code’s cohesion.

class Vegetable < ApplicationRecord
  validates :name, presence: true
end

class Avocado < Vegetable
  validates :color, inclusion: { in: ['green'] }
end

class Potato < Vegetable
  validates :color, inclusion: { in: ['yellow'] }
end

If you create a new model from scratch, this approach will just work™. But if you are working with existing code, as in our case, things tend not to be so simple. Rails makes two assumptions when you use single table inheritance:

  • The subtype of your model is designated by a column type.
  • The type column contains the literal name of your subtypes, e.g. Avocado, Potato.

Our models don’t adhere to these requirements. Our database looks like this:

id name color
1 VEGETABLE nil
2 AVOCADO green
3 POTATO yellow

The value that distinguishes the types of vegetables lives in the name column rather than the type column. Also, the names themselves are uppercase versions of our subclass-names: AVOCADO rather than Avocado and so on. To solve these issues you could migrate your data - and if you can, you definitely should! But sometimes that just isn’t an option.

Luckily, there are ways to shoehorn single table inheritance into models like these.

Adapting Single Table Inheritance

After splitting up your models, you may try to run and run a query to get all potatoes:

Potato.all

Surprise. Instead of returning only a single record, all vegetables are returned. This is not at all what we wanted! We need to tell Rails about our non-standard inheritance column name. To do so, we can update the parent model Vegetable:

class Vegetable < ApplicationRecord
  self.inheritance_column='name'

  ...
end

Unfortunately, doing so will not only make our queries still return nonsense - Potato.all now returns no records at all - but also break a bunch of other things. Even creating new vegetables now fails:

# Raises ActiveRecord::SubclassNotFound (The single-table inheritance mechanism failed to locate the subclass: 'POTATO'...
Vegetable.create(name: 'POTATO')

Rails expects the inheritance column to contain the class name of the specific sub-type, Potato rather than POTATO. Under the hood, it executes POTATO.constantize, which of course doesn’t work. We have to change how Rails locates the types used to instantiate STI records. But how?

Enter sti_class_for. By overwriting this method, we can customize which types are used for instantiation:

class Vegetable < ActiveRecord::Base
  self.inheritance_column = "name"

  class << self
    def sti_class_for(type_name)
      super(type_name.dowcase.camelize)
    end
  end
end

Warning: sti_class_for was added in Rails 6.1. If you are stuck with an earlier version of Rails, you can use find_sti_class instead. It does pretty much the same thing but is private. You can still overwrite it all the same of course, just be careful.

That fixes querying for vegetables. However, querying our subclasses and creating new sub-records still does not work like we want it to:

Potato.all
=> #<ActiveRecord::Relation []>
Potato.new(color: 'yellow')
=> #<Potato id: 6, name: "Potato", color: "yellow",

Although we overwrite sti_class_for, Rails uses the wrong name values. We have to ask ourselves: How does Rails know which values to put into the inheritance column when instantiating child records? It uses sti_name:

def sti_name
 store_full_sti_class ? name : name.demodulize
end

You probably know where this is going. Let’s overwrite sti_name as well:

class Vegetable < ActiveRecord::Base
  self.inheritance_column = "name"

  class << self
    def sti_class_for(type_name)
      super(type_name.lower.camelize)
    end

    def sti_name
      name.upcase
    end
  end
end

Success! We have refactored the chonky Vegetable, and we can work with our subclasses just like we would expect:

Potato.all
=> #<ActiveRecord::Relation [#<Potato id: 3, name: "POTATO", color: "yellow", created_at: "2021-07-25 14:41:00.032041000 +0000", updated_at: "2021-07-25 14:41:00.032041000 +0000">]>
Potato.new(color: 'yellow')
=> #<Potato id: nil, name: "POTATO", color: "yellow", created_at: nil, updated_at: nil>

Conclusion

Single Table Inheritance can be useful to re-organize existing models that have grown too large. Ideally, you’d never have to reach for this approach, but when are things ever try ideal? If you got any use out of this short guide let me know on twitter 🤗