This post will show you a new advanced association in Rails: has_subtypes
Rails has 6 basic types of associations: belongs_to, has_one, has_many, has_many :through, has_one :through, and has_and_belongs_to_many.
There are also advanced associations: the main ones are polymorphic and delegated types, where a model can belong to more than one other model via a type and an ID. These are essentially special variations of the belongs_to association. For example, a delegated type is still a belongs_to, but with an extra layer of semantics and conventions.
My purpose here is to show you an alternative to delegated types using a new advanced association: has_subtypes. Instead of focusing on belongs_to, this new method specializes the has_one association.
This new approach to solving the problem of multi-table inheritance also allows greater control of the database. Unlike delegated types, it is now possible to guarantee referential integrity between each child table (subtype) and the parent table.
Implementation
rails new app
cd app
rails generate model Entry
rails generate model Message entry:references content:text
rails generate model Comment entry:references body:text
Now, open the Project and insert the following method inside the ApplicationRecord class:
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
def self.has_subtypes(*types, dependent: :destroy)
types.each { has_one(_1, dependent:) }
scope :with_subtype, -> { includes(*types) }
class_eval <<~RUBY, __FILE__, __LINE__ + 1
def subtype = #{types.join(" || ")} # message || comment
before_save do
subtypes = [#{types.join(", ")}].compact # message, comment
raise "must have exactly one subtype" unless subtypes.one?
end
RUBY
end
end
It would be awesome if, in the future, this method became part of Rails itself. For now, you can start using this specialization of the has_one association today.
Using the new association has_subtypes on Models
class Entry < ApplicationRecord
has_subtypes :message, :comment
end
class Message < ApplicationRecord
belongs_to :entry
end
class Comment < ApplicationRecord
belongs_to :entry
end
Each Entry can now be either a Message or a Comment.
Step by Step with Rails Console
# Start the Rails console
rails console
# 1. Create an Entry with a Message
entry1 = Entry.create!(message: Message.new(content: "Hello world"))
# 2. Create an Entry with a Comment
entry2 = Entry.create!(comment: Comment.new(body: "First comment"))
# 3. Check subtype for each entry
entry1.subtype
# => #<Message id:1, content:"Hello world">
entry2.subtype
# => #<Comment id:1, body:"First comment">
# 4. Query all entries with their subtypes
Entry.with_subtype.each do |entry|
puts "Entry ##{entry.id} subtype: #{entry.subtype.class.name}"
end
# Output:
# Entry #1 subtype: Message
# Entry #2 subtype: Comment
# 5. Attempt to create an Entry with both subtypes (will raise error)
Entry.create!(message: Message.new(content: "Test"), comment: Comment.new(body: "Invalid"))
# => RuntimeError: must have exactly one subtype
Conclusion
With this new assotiation has_subtypes, you get:
- A clear concept of subtypes by extend
has_oneassociations. - Referential integrity between parent and child tables.
- A simple way to query and paginate entries including their subtypes in a single command, wich is similar to delegated types, but with stronger database guarantees.
Aknowledgements
Thank you, Matz, for creating such a fun language -> Ruby.
Thank you, David Heinemeier Hansson, for clarifying the Rails doctrine: Push up a big tent: “We need disagreement. We need dialects. We need diversity of thought and people.” This post is only possible thanks to that “big tent” philosophy.
Finally, this result wouldn’t have been possible without the refactoring and suggestions from Kasper Timm Hansen. Thank you very much, Kasper!