Single table inheritance in Rails, and the day a class gets renamed
STI is the Rails feature people are warned off without being told what actually goes wrong. Two things do, and only one of them is the schema argument everyone repeats. The other is a renamed class, and it fails in a way that keeps most of your app working.
All of this ran on Rails 8.1.4 against a vehicles table with a type column.
What's actually stored
class Vehicle < ApplicationRecord; end
class Car < Vehicle; end
class Truck < Vehicle; end
That's the setup. No self.table_name, no configuration. Rails sees a model inheriting from
another model, finds a type column, and takes over.
Vehicle.distinct.pluck(:type) # => ["Car", "Truck"]
Vehicle.first.class # => Car
Vehicle.all.to_sql # ... FROM "vehicles"
Car.all.to_sql # ... WHERE "vehicles"."type" = 'Car'
The column holds the class name as a string. The base class queries everything, each subclass
scopes itself, and loading through the base gives you instances of the right subclass. Counts came
back 3, 2 and 1 for Vehicle, Car and Truck, which is the behaviour that makes STI feel free.
inheritance_column is type by default and it's a setting, not a rule. If your table already has
a type column meaning something else, point Rails at another name. Setting it to nil turns STI
off and makes the column ordinary again.
The rename that half breaks everything
Here's the failure that isn't in the usual warnings.
# a row whose type column says "Spaceship", with no such class in the app
Vehicle.where(name: "t1").first # ActiveRecord::SubclassNotFound
Vehicle.count # => 3
Your class name is in production data. Rename Truck to Lorry, deploy, and every existing row
still says Truck. Delete a subclass you stopped using and its rows are now unloadable. The
exception is clear enough when you see it, and the problem is where you see it.
count still works. So does pluck, so does exists?, so does anything that never builds an
object. Your admin dashboard shows the right totals, your monitoring is quiet, and one page
raises for the subset of users whose row holds the dead name.
Which means the migration is the deploy. Renaming an STI subclass is a data change and a code change together:
class RenameTruckToLorry < ActiveRecord::Migration[8.1]
def up = execute("UPDATE vehicles SET type = 'Lorry' WHERE type = 'Truck'")
def down = execute("UPDATE vehicles SET type = 'Truck' WHERE type = 'Lorry'")
end
Ship the migration and the class rename in the same release, and have the new class exist before the rows claim to be it.
becomes is not a conversion
The method everyone reaches for when they want to change a record's subclass does not do that.
truck = car.becomes(Truck)
truck.class # => Truck
truck.type # => "Car"
Vehicle.find(car.id).type # => "Car"
A new object of the target class, sharing the original's attributes, with the type attribute
untouched. Save it and you've saved a Car. The database never moved.
becomes exists for rendering: you have a Car and you want the Truck form partial, or you
want validations from a sibling class for one request. For actually moving a record between
subclasses, write the column:
car.update_column(:type, "Truck")
update_column rather than update, because update would route through the Car class's
callbacks on its way to making the row stop being a Car, and you'll want to reload afterwards to
get an object of the new class.
The useful surprise
Vehicle.new(type: "Car").class # => Car
The base class honours a type you pass at build time and hands back the subclass. That's why
Vehicle.new(vehicle_params) works straight out of a form with a type select in it, with no
case statement and no factory method.
It's also an injection point worth being careful about, because the string comes from params. Rails will only instantiate descendants of the base class, so it isn't arbitrary code execution, but a user can still pick which subclass their record becomes. If that matters, permit a list rather than the raw value.
When to use it at all
The schema argument is the familiar one and it's correct: every column any subclass needs lives on one table, so a column used by one subclass out of five is nullable and meaningless for the other four. Five subclasses with three unique columns each is a table with fifteen mostly empty columns.
So the line is about columns, not about taste. STI fits when subclasses share nearly all their data
and differ in behaviour: Notification and its half dozen kinds, all holding the same recipient
and body and differing in how they render. It stops fitting the moment each kind wants its own
fields, and that's when a kind column with no inheritance, or separate tables with a
polymorphic association, start being the cheaper answer.
What this page doesn't cover
No index advice. Every subclass query filters on type, so that column's selectivity and how it
combines with your other indexes is the performance story of an STI table, and there's no
measurement of it here.
Nothing above touches store_full_sti_class, which controls whether namespaced classes write
their full path into the column, and that setting is exactly where a module rename goes wrong in
the same way described above. Nor default_scope on a subclass, which composes with the automatic
type condition in ways worth testing before you rely on them.
Comments
No comments yet. Be the first.