What are polymorphic associations? When do we need to create join tables? When do we use single table inheritence?
We will cover some more advanced topics with Active Record Associations in this post.
Note: This post assumes that you are already familiar with common Active Record associations. You can see this post for Active Record associations basics for a refresher.
Data modeling is a skill that is an art and a science. In order to get it right, we need to think deeply about the natural relationship between our models in the real world. Active Record associations provide many options to model our business domain. If our models do not neatly fit into the common associations, we can use some of the concepts below and manually define our relationships.
What are Polymorphic Associations
If we need a model to belong to more than one other model on a single association, we'd use polymorphic associations.
Let's see this with an example. With the models below, we have Employee
and Product
. Both of those can have many Picture
. So we make polymorphic association called imageable
.
class Picture < ApplicationRecord
belongs_to :imageable, polymorphic: true
end
class Employee < ApplicationRecord
has_many :pictures, as: :imageable
end
class Product < ApplicationRecord
has_many :pictures, as: :imageable
end
The interesting part here is the belongs_to :imageable, Polymorphic: true
. This implies that we will have an imagable_id
on Picture
that will a foreign key to either Employee
or Product
. In order to know which one we will also have an imagable_type
on Picture
.
The migration for Picture
looks like this
class CreatePictures < ActiveRecord::Migration[7.0]
def change
create_table :pictures do |t|
t.string :name
t.bigint :imageable_id
t.string :imageable_type
t.timestamps
end
add_index :pictures, [:imageable_type, :imageable_id]
end
end
Another way to create the imageable_id
and imageable_type
is with t.references :imageable, polymorphic: true
. I like the first one as it's more explicit and makes it clear that we will have two fields id and type.
Creating Join Tables
For has_and_belongs_to_many
association, we need to create a join table in our migration. This join table should not have a primary key. This is important for things to work correctly. This join table does not represent a model. (Note: if we want a join table that can hold other fields, we use has_many :through
association).
There are two ways to create a table without primary key id
. We can use create_join_table
in the migration or set id
to false
if using create_table
like this
ActiveRecord::Migration[7.0]
def change
create_table :authors_books, id: false do |t|
t.bigint :author_id
t.bigint :book_id
end
Bi-directional Associations and inverse_of
Active Record will automatically identify most bi-directional associations with standard names. By bi-directional association we mean having belong_to
on one model and has_many
on the "opposite" model, for example.
In case we decide to use non standard names though, we need to manually specify the "opposite" model. We can do this with inverse_of
option.
With the below model, we are using writer
instead of author
so now @book.writer
works to refer to the Author
object, but @author
doesn't 'know' about the relationship if we leave out the inserve_of
on the has_many
on Author
below
class Author < ApplicationRecord
has_many :books, inverse_of: 'writer'
end
class Book < ApplicationRecord
belongs_to :writer, class_name: 'Author', foreign_key: 'author_id'
end
Association Callbacks
There are four callbacks that can specify methods to be called when an object is added for removed from a collection through the association. before_add
, after_add
, before_remove
, after_remove
.
We specify the callback as an option to the association declaration.
class LibraryPatron < ApplicationRecord
has_many :books, before_add: :checkout_limit
def checkout_limit(book)
throw(:abort) if limit_reached?
end
end
With that in place, a library patron will only be allowed to add books if their checkout limit is not reached.
These are similar to before_save
callback that causes something to happen before an object is saved. The association callbacks only happen when an object is added or removed through the association collection.
Single Table Inheritance
Sometimes we want to share fields and behavior between different models but also have specific behavior for each and separate controllers too. With Single Table Inheritance (STI), we do this by storing the related models in a single table but have a type
column in that table that differentiates them.
For a concrete example, let's say we want to represent different types of vehicles that all have color
and price
fields in common. We can create a vehicle
model
$ rails generate model vehicle type:string color:string price:decimal
Then we can generate a car
which is a type of vehicle
like this
$ generate model car --parent=Vehicle
That will generate a model that look like this
class Car < Vehicle
end
And when we create a Car
object, it will be stored in the vehicles
table with the type
column set to "Car"
Car.create(color: 'red' price: 10000)
with this SQL
INSERT INTO "vehicles" ("type", "color", "price") VALUES ('Car', 'Red', 10000)
The above explanation and examples give us a better idea of all the ways in which we can define Active Record associations in our applications.