Polymorphic Models in Rails
Let's peel back the layers of a Polymorphic model in Rails. Let's see why'd you consider using it in the first place, how you can use it and how we could implement it ourselves.
For the unfamiliar, Ruby on Rails has several associations you can decorate your models with, such as has_many
, belongs_to
, and so on. These types of associations drive the Rails’ ability to simplify querying related models. More about associations can be found here. The following example shows how to use the has_many
association via a blogging system with a User
having many Posts
:
# Migrations
## Users
class CreateUsers < ActiveRecord::Migration[7.1]
def change
create_table :users do |t|
t.string :name
t.string :email
t.timestamps
end
end
end
## Posts
class CreatePosts < ActiveRecord::Migration[7.1]
def change
create_table :posts do |t|
t.string :title
t.text :content
# Creates a reference to users with an index
t.references :user, null: false, foreign_key: true
t.timestamps
end
end
end
# Classes
class User < ApplicationRecord
has_many :posts, dependent: :destroy
end
class Post < ApplicationRecord
belongs_to :user
end
# ...
User.first.posts # a list of posts
However, a polymorphic association is a slightly more advanced one we can use to model our application.
What are we solving for?
The problem we’re solving is when we have behaviour that can belong to multiple models rather than a relationship. Let’s imagine we are building a blog with Rails (ha), and Users can have images (in the form of profile images), and Posts can have images as well (in the form of hero images at the top of the post).
We could create tables called PostImages
and ProfilePictures
and model the storage and relationship of these pictures separately. Let’s break down why this might not be the best approach.
# ProfilePictures migration
class CreateProfilePictures < ActiveRecord::Migration[7.1]
def change
create_table :profile_pictures do |t|
t.string :location
t.references :user
t.timestamps
end
end
end
# PostImages migration
class CreatePostImages < ActiveRecord::Migration[7.1]
def change
create_table :post_images do |t|
t.string :location
t.references :post
t.timestamps
end
end
end
# User Class
class User < ApplicationRecord
has_many :posts, dependent: :destroy # Correct spelling of 'dependent'
has_one :profile_picture, dependent: :destroy # Correct spelling of 'dependent' and 'destroy'
end
# Post Class
class Post < ApplicationRecord
belongs_to :user # Singular form for 'belongs_to' association
has_one :post_image, dependent: :destroy # Correct spelling of 'dependent'
end
# PostImage class
class PostImage < ApplicationRecord
belongs_to :post
end
#ProfilePicture class
class ProfilePicture < ApplicationRecord
belongs_to :user
end
Notice how both migrations create practically the same table? They store the photo’s location (a link to a cloud storage bucket), some updated_at
and created_at
timestamps, and the relationship to their respective owner. This creates extra mental and technical baggage as we build our system: two separate tables for image storage, two sets of foreign keys, indexes, and so on.
Further, if any other model in our system needs image storage, we must create another table and model.
Surely we can simplify this, create something that can scale, and reduce the weight of the solution?
Enter Polymorphic Associations
A polymorphic association is a Rails association that understands that different models can own it.
Put differently
a model can belong to more than one other model, on a single association.
Outside the world of tech, polymorphic means
occurring in several different forms, in particular with reference to species or genetic variatio
If you’re familiar with Interfaces, a polymorphic association is setting up an interface that any other model can use. Let’s simulate our implementation using a polymorphic association, and then we can break down how Rails is achieving this.
# Migrations
## Users
class CreateUsers < ActiveRecord::Migration[7.1]
def change
create_table :users do |t|
t.string :name
t.string :email
t.timestamps
end
end
end
## Posts
class CreatePosts < ActiveRecord::Migration[7.1]
def change
create_table :posts do |t|
t.string :title
t.text :content
# Creates a reference to users with an index
t.references :user, null: false, foreign_key: true
t.timestamps
end
end
end
## Polymorphic Images Tables
class CreateImages < ActiveRecord::Migration[7.1]
def change
create_table :images do |t|
t.string :location # path to the image
# Polymorphic references
t.references :imageable, polymorphic: true, null: false
t.timestamps
end
end
end
class User < ApplicationRecord
has_many :posts, dependent: :destroy
has_one :image, as: :imageable
end
class Post < ApplicationRecord
belongs_to :user
has_one :image, as: :imageable
end
class Image < ApplicationRecord
belongs_to :imageable, polymorphic: true
end
## Usage
User.first.image # the profile picture
Post.first.image # the hero image
Image.first.imageable # The User or Post that owns this photo
We now have a single table that models our data’s behaviour of having an image. Further, Rails abstracts this behind a call to .images
. We can call this method on any model that includes an association with :imageable
.
Instead of creating a separate table for each model with images, we have one. Now we’re allowing Rails to manage the lookup and association between an instance of the Image
model and the model it belongs to. This means that we can easily find the owner of an image via the .imageable
method that Rails sets up, and we can also get the image by using the .image
method on any owning model. Nice.
How Does Rails Implement This?
Let’s peel back the layers of abstraction to fully understand what’s going on here. Let’s understand how we’d get ourselves if we had to. First, let’s re-write our migrations and implement the polymorphic relationship.
class CreateImages < ActiveRecord::Migration[7.1]
def change
create_table :images do |t|
t.string :location
t.bigint :imageable_id # ID of the model that owns this image
# a string name of the model that owns this image.
# "User" or "Post" for now
t.string :imageable_type
t.timestamps
end
add_index :images, [:imageable_type, :imageable_id]
end
end
class Image < ApplicationRecord
def imageable
case imageable_type
when 'User'
User.find(imageable_id)
when 'Post'
Post.find(imageable_id)
end
end
end
class Post < ApplicationRecord
def image
Image.find_by(imageable_id: id, imageable_type; 'Post')
end
def image=(img)
Image.create(imageable_id: id, imageable_type: 'User', location: img.location)
end
end
class User < ApplicationRecord
def image
Image.find_by(imageable_id: id, imageable_type; 'User')
end
def image=(img)
Image.create(imageable_id: id, imageable_type: 'User', location: img.location)
end
end
User.first.image = Image.create(location: s3://...)
img = User.first.image # the image instance
img.imageable # the User that owns this image
As we can see, a bit is going on here. At the data level, we have a table that tracks the imageable_type
and imageable_id
. The imageable_type
tracks the type (User
, Post
etc.) if the entity that owns the current image. Next, we have the imageable_id
, which tracks the primary key ID of the owning entity, too.
Starting with the image.imageable_type
call, we can see that the reverse end of the association is managed. This method looks at the imageable_type
of an instance of Image
; when it’s a User
, we look up that specific user via the imageable_id
. It's similar to the post, too.
Next, on each model, we need to implement the .image
and .image=
methods to maintain this end of the relationship and write the correct values to imageable_type
and imageable_id
.
Phew 😮💨, that’s a lot of back-and-forth between all the models. Now, what if we wanted to add a new entity that can have images? We’d have to do this all over again, of course.
Thankfully, Rails abstracts these relationships away via their has_many
, belongs_to
associations. Internally, they’d use metaprogramming to ensure the method calls were in the right place.
Tying This up
We know now that polymorphic associations are classes that represent a behaviour in your application. This behaviour is backed by a database table and an ActiveRecord model that manages the abstraction for us.
We also got a rough idea of how we’d implement this ourselves without a framework like Rails to abstract it away for us.
Hopefully, you found this useful, and it’s helped you grasp what polymorphic models can solve for and why you might use them in the future.
Until next time, 👋