Delegated Types in Rails, handling models with overlapping attributes
September 05, 2020
*This article was originally posted on the Blackbox Engineering blog
Let’s say we have a blog application that manages different types of posts, ex: Videos
and Texts
. The posts are
similar enough that they will share certain attributes (like a title), but also different enough that they will
require unique columns (a url column for video). In addition, we want to have a newsfeed feature where we display
all posts by most recently created. This means we need to have a way to query across the entire Posts
architecture.
This problem is generally solved in one of two ways in Rails: single table inheritance (STI) or abstract classes. Since ActiveRecord models do not have to be backed by a database table, these solutions leverage Plain Old Ruby Objects in order to represent this inheritance hierarchy. Polymorphic associations also often get thrown into the mix but don’t quite fit our use case (we’ll briefly touch on this). We’ll discuss the pros and cons of each of these approaches and shed light on a new Rails feature that combines the best of both worlds.
Single Table Inheritance (STI)
In an STI implementation, attributes are stored in a single parent class table and a type
column holds the name of the
subclass for the given row. Subclasses inherit from the parent class which allows us to write Text specific methods on
the Text class without polluting the Video class. Generating a newsfeed is also very straightforward since all the
data is in a single table, i.e. we can do things like Posts.order(created_at: :desc)
.
# == Schema
# Table name: posts
# id :integer
# author :string
# title :string
# url :string
# content :string
# type :string #['Video', 'Text]
class Post < ApplicationRecord; end
class Video < Post; end
class Text < Post
def preview
content.truncate(20)
end
end
STI is simple to implement but can quickly get unwieldy as slight differences in models introduce new fields. This leads to wide tables that are full of null values storing information for a collection of now distinct models that are forced to share the same table.
Abstract Classes
Abstract Classes solve the problem of wide tables by giving each model its own table and connecting them via an Abstract Base Model - the parent class cannot be instantiated and must be subclassed to be utilized. Where the entire hierarchy was represented in a single table in STI, the opposite is true using abstract classes.
This gives us the ability to separate unique attributes to distinct tables, but now we have the opposite problem -
repetitive columns. The author
and title
columns will have to be included in all subclasses. We have also lost the
ability to query across the entire hierarchy (i.e. Posts.all
), making our newsfeed unnecessarily cumbersome.
class Post < ApplicationRecord
self.abstract_class = true
end
# == Schema
# Table name: videos
# id :integer
# author :string
# title :string
# url :string
class Video < Post; end
# == Schema
# Table name: texts
# id :integer
# author :string
# title :string
# content :string
class Text < Post; end
We’ve established that both methods have their strengths but are not without significant drawbacks. So what’s the solution?
Delegated Types
At the time of writing, this feature is only included in edge Rails and is expected to be included in the next minor release (6.1.0). However, the code can be pulled from the Rails codebase and included in ApplicationRecord to be used right away
Delegated types take the best of STI and abstract classes and merge them into a single solution. Both the superclass and subclasses are backed by database tables, allowing for common attributes to live in the parent class table and unique attributes to live in the subclass tables.
# == Schema
# Table name: posts
# id :integer
# author :string
# title :string
# postable_type :string
# postable_id :string
class Post < ApplicationRecord
delegated_type :postable, type: %w[Video Text]
end
# == Schema
# Table name: videos
# id :integer
# url :string
class Video < ApplicationRecord
has_one :post, as: :postable
end
# == Schema
# Table name: texts
# id :integer
# content :string
class Text < ApplicationRecord
has_one :post, as: :postable
end
So what’s happening here? By including delegated_type :postable
on the Post
model, it adds a polymorphic belongs_to
relationship to postable
under the hood. So far, this is the same as setting up a regular polymorphic association minus
the syntactical difference. However, where polymorphic relationships define an interface for a “has many” relationship,
delegated types define an interface for “has one”. That distinction is what allows us to isolate shared attributes in a
separate table, but still be able to retrieve values specific to a delegated type row.
NB: Postable is an interface and not an actual table
Polymorphism has its role in the opposite use case - ex: “X and Y models can be commented on” vs “X and Y models are types of comments”.
Going back to delegated types, creating a new Video record looks something like this:
Post.create(
postable: Video.create(url: 'www.video.com'),
title: 'Sample Video',
author: 'Jane Kim'
)
There is some fantastic Rails magic up there as the title and author attributes will be stored in the parent Post
table! Videos and Texts are always created in the context of Posts so that they always retain common post attributes.
Now, if we need to ask questions to the subclass models, the :postable
role will delegate to the class defined in the
postable_type
column.
As for our newsfeed feature, we can still call Posts.all
to retrieve all the blog posts that a user has written. To get
the specific post type, we call .postable
on the record. To get the common post attribute, we call .post
on the
subclass model.
Post.first.postable
# <Video id: 1, url: 'www.video.com'>
Video.first.post
# <Post id: 1, postable_type: 'Video', postable_id: 1,
# title: 'Sample Video', author: 'Jane Kim'>
We don’t have any repeated or nil values, we can still query across records, and can confidently scale the parent and subclasses’ attributes and behavior 🎉