STI, Polymorphism and Abstract Classes — Rails
There are times where certain models need to share some behavior but their identities differ. Rails provides some baked-in functionality to handle these situations, we’d be looking at 3 of them.
Abstract Base Classes
An abstract base class in Rails Model is simply a model that is not persistent, i.e not backed by a table. It would look like:
# app/models/citizen.rbclass Citizen < ApplicationRecord
self.abstract_class = true
end
Declaring the model as abstract tells Rails to see it as one that’s not persistent and would be used to share functionality with its subclasses via Inheritance.
Say we have two other models that represent different categories of citizens: electorate
and candidate
. These models can share some properties like fullname
and eligible
.
Assuming that in our country of choice the minimum age to engage in electoral activities is 18years. Then the models could look like:
# app/models/citizen.rbclass Citizen < ApplicationRecord
self.abstract_class = true def fullname
"#{first_name} #{last_name}"
end def eligible?
age >= 18
end
end# app/models/electorate.rbclass Electorate < Citizen
...
end# app/models/candidate.rbclass Candidate < Citizen
...
end
As you rightly enthused, the candidate and electorate models both have age
, first_name
and last_name
fields. Now, they both can make use of the methods defined in the Citizen
model. Class & instance methods, constants and other class members that might be brought in through module inclusion are passed down to the subclasses in this inheritance hierarchy, however, it’s advisable to not turn the abstract base class into a dumping ground in the guise of shared functionality.
It’s pertinent to note that in this setup, citizen
has no underlying table but electorate
and candidate
do have underlying tables.
Single Table Inheritance (STI)
Sometimes, you have models that share some common attributes but also have a few different ones. STI is one of Rails’ provisions for such situations.
In an STI setup, you have a model that is parent(or super
) to other models. This parent model must contain a field named type
with no default value needed. The type field automatically stores the name of the child model(subclass) to which the record belongs. Taking our citizen
example:
# migration file to add field type to citizendef change
add_column :citizens, :type, :string
end# app/models/citizen.rbclass Citizen < ApplicationRecord
self.abstract_class = true # remove this line for STI
...
end# app/models/electorate.rbclass Electorate < Citizen
...
end# app/models/candidate.rbclass Candidate < Citizen
...
endExample>> c = Candidate.create
>> c.type
=> "Candidate">> Citizen.first
=> #<Candidate:0x231456...>
In this case, the electorate
and candidate
models do not need to have underlying tables. The only table needed is the citizens
table. As seen in the example above, for all subclasses, Rails automatically figures out the model to which a record belongs.
Since all subclasses share the same table, you cannot have the same attribute on two subclasses with different datatypes. As the STI table gets bigger and bigger, it might have too many null
fields. Fields that exist only on a subclass would be null for other subclasses. There are other pros and cons of STIs but that could be a discussion for another time.
Polymorphic Associations
There are situations where you have a model that belongs_to
more than one other model. Rails provides polymorphic associations for this use case, where the belonging model has an association name that by convention has an able
postfix. This model should have two fields that describe the id and type(class) of the associating record— ending in _id
and _type
. The association can be seen as an interface that this model exposes to other models. Let’s take a look at a Vote
model for our citizen example.
# migration file for votesdef change
create_table :votes do |t|
...
t.references :votable, polymorphic: true, index: true
...
end
end# app/models/vote.rbclass Vote < ApplicationRecord
belongs_to :votable, polymorphic: true
...
end# app/models/electorate.rbclass Electorate < ApplicationRecord
has_many :votes, as: :votable
...
end# app/models/candidate.rbclass Candidate < ApplicationRecord
has_many :votes, as: :votable
...
end
Example>> c = Candidate.create
>> c.votes
>> [#<Vote:0x0003437fdd79a91d0 id:1, votable_type: "Candidate", votable_id: 1...>]>> v = Vote.first
>> v.votable
>> #<Candidate:0x02332fed3903e id:1, ...>
The association works on both — the belongs_to
and the has_many
— sides, taking advantage of the id and the type columns.
Rails and Active Record provide some security by ensuring that the type
and id
of the record saved on a polymorphic model represent an actual record that belongs to this relationship chain. However, if someone has access to your database, they can create orphan records because polymorphic associations don’t have the foreign key constraint of a typical belongs_to
association.
>> Candidate.find(50)
>> ActiveRecord::RecordNotFound: Couldn't find Candidate with 'id'=50...>> Vote.create!(votable_id: 50, votable_type: "Candidate")
>> # Candidate Load (0.3ms) SELECT "candidates".* FROM "candidates" WHERE "candidates"."id" = $1 LIMIT $2 [["id", 50], ["LIMIT", 1]]
>> ActiveRecord::RecordInvalid: Validation failed: Votable must existSQL Shell
>> INSERT INTO votes (votable_id, votable_type, created_at, updated_at) VALUES (50, 'Candidate', '02-05-2019', '02-05-19');# The above query succeeds even though a candidate with that id doesn't exist.
Sidenote: It’s possible to have polymorphic associations on STIs but I decided to keep the example focused on simple polymorphic associations. Feel free to look into that if you’re curious 😉
While designing your data models, you’ll probably figure out when it is appropriate to use each of these. Eventually, choosing the right strategy could be as important as solving the problem.