(Ab)Using Single Table Inheritance to Refactor Fat Models
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.
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 🤗