Introducing has_subtypes: a new Ruby on Rails association

systems development

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_one associations.
  • 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!