diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md
index b216597417..0118ddaca5 100644
--- a/activerecord/CHANGELOG.md
+++ b/activerecord/CHANGELOG.md
@@ -1,3 +1,8 @@
+* Added delegated type as an alternative to single-table inheritance for representing class hierarchies.
+ See ActiveRecord::DelegatedType for the full description.
+
+ *DHH*
+
* Deprecate aggregations with group by duplicated fields.
To migrate to Rails 6.2's behavior, use `uniq!(:group)` to deduplicate group fields.
diff --git a/activerecord/lib/active_record.rb b/activerecord/lib/active_record.rb
index 3442ada6ad..c5c9b9e21a 100644
--- a/activerecord/lib/active_record.rb
+++ b/activerecord/lib/active_record.rb
@@ -43,6 +43,7 @@ module ActiveRecord
autoload :ConnectionHandling
autoload :CounterCache
autoload :DynamicMatchers
+ autoload :DelegatedType
autoload :Enum
autoload :InternalMetadata
autoload :Explain
diff --git a/activerecord/lib/active_record/base.rb b/activerecord/lib/active_record/base.rb
index f09437b51a..612e031d1d 100644
--- a/activerecord/lib/active_record/base.rb
+++ b/activerecord/lib/active_record/base.rb
@@ -274,6 +274,7 @@ module ActiveRecord #:nodoc:
extend Querying
extend Translation
extend DynamicMatchers
+ extend DelegatedType
extend Explain
extend Enum
extend Delegation::DelegateCache
diff --git a/activerecord/lib/active_record/delegated_type.rb b/activerecord/lib/active_record/delegated_type.rb
new file mode 100644
index 0000000000..fb108f1958
--- /dev/null
+++ b/activerecord/lib/active_record/delegated_type.rb
@@ -0,0 +1,209 @@
+# frozen_string_literal: true
+
+require "active_support/core_ext/string/inquiry"
+
+module ActiveRecord
+ # == Delegated types
+ #
+ # Class hierarchies can map to relational database tables in many ways. Active Record, for example, offers
+ # purely abstract classes, where the superclass doesn't persist any attributes, and single-table inheritance,
+ # where all attributes from all levels of the hierarchy are represented in a single table. Both have their
+ # places, but neither are without their drawbacks.
+ #
+ # The problem with purely abstract classes is that all concrete subclasses must persist all the shared
+ # attributes themselves in their own tables (also known as class-table inheritance). This makes it hard to
+ # do queries across the hierarchy. For example, imagine you have the following hierarchy:
+ #
+ # Entry < ApplicationRecord
+ # Message < Entry
+ # Comment < Entry
+ #
+ # How do you show a feed that has both +Message+ and +Comment+ records, which can be easily paginated?
+ # Well, you can't! Messages are backed by a messages table and comments by a comments table. You can't
+ # pull from both tables at once and use a consistent OFFSET/LIMIT scheme.
+ #
+ # You can get around the pagination problem by using single-table inheritance, but now you're forced into
+ # a single mega table with all the attributes from all subclasses. No matter how divergent. If a Message
+ # has a subject, but the comment does not, well, now the comment does anyway! So STI works best when there's
+ # little divergence between the subclasses and their attributes.
+ #
+ # But there's a third way: Delegated types. With this approach, the "superclass" is a concrete class
+ # that is represented by its own table, where all the superclass attributes that are shared amongst all the
+ # "subclasses" are stored. And then each of the subclasses have their own individual tables for additional
+ # attributes that are particular to their implementation. This is similar to what's called multi-table
+ # inheritance in Django, but instead of actual inheritance, this approach uses delegation to form the
+ # hierarchy and share responsibilities.
+ #
+ # Let's look at that entry/message/comment example using delegated types:
+ #
+ # # Schema: entries[ id, account_id, creator_id, created_at, updated_at, entryable_type, entryable_id ]
+ # class Entry < ApplicationRecord
+ # belongs_to :account
+ # belongs_to :creator
+ # delegated_type :entryable, types: %w[ Message Comment ]
+ # end
+ #
+ # module Entryable
+ # extend ActiveSupport::Concern
+ #
+ # included do
+ # has_one :entry, as: :entryable, touch: true
+ # end
+ # end
+ #
+ # # Schema: messages[ id, subject ]
+ # class Message < ApplicationRecord
+ # include Entryable
+ # has_rich_text :content
+ # end
+ #
+ # # Schema: comments[ id, content ]
+ # class Comment < ApplicationRecord
+ # include Entryable
+ # end
+ #
+ # As you can see, neither +Message+ nor +Comment+ are meant to stand alone. Crucial metadata for both classes
+ # resides in the +Entry+ "superclass". But the +Entry+ absolutely can stand alone in terms of querying capacity
+ # in particular. You can now easily do things like:
+ #
+ # Account.entries.order(created_at: :desc).limit(50)
+ #
+ # Which is exactly what you want when displaying both comments and messages together. The entry itself can
+ # be rendered as its delegated type easily, like so:
+ #
+ # # entries/_entry.html.erb
+ # <%= render "entries/entryables/#{entry.entryable_name}", entry: entry %>
+ #
+ # # entries/entryables/_message.html.erb
+ #
+ # Posted on <%= entry.created_at %> by <%= entry.creator.name %>: <%= entry.message.content %>
+ #
+ #
+ # # entries/entryables/_comment.html.erb
+ #
+ #
+ # == Sharing behavior with concerns and controllers
+ #
+ # The entry "superclass" also serves as a perfect place to put all that shared logic that applies to both
+ # messages and comments, and which acts primarily on the shared attributes. Imagine:
+ #
+ # class Entry < ApplicationRecord
+ # include Eventable, Forwardable, Redeliverable
+ # end
+ #
+ # Which allows you to have controllers for things like +ForwardsController+ and +RedeliverableController+
+ # that both act on entries, and thus provide the shared functionality to both messages and comments.
+ #
+ # == Creating new records
+ #
+ # You create a new record that uses delegated typing by creating the delegator and delegatee at the same time,
+ # like so:
+ #
+ # Entry.create! message: Comment.new(content: "Hello!"), creator: Current.user
+ #
+ # If you need more complicated composition, or you need to perform dependent validation, you should build a factory
+ # method or class to take care of the complicated needs. This could be as simple as:
+ #
+ # class Entry < ApplicationRecord
+ # def self.create_with_comment(content, creator: Current.user)
+ # create! message: Comment.new(content: content), creator: creator
+ # end
+ # end
+ #
+ # == Adding further delegation
+ #
+ # The delegated type shouldn't just answer the question of what the underlying class is called. In fact, that's
+ # an anti-pattern most of the time. The reason you're building this hierarchy is to take advantage of polymorphism.
+ # So here's a simple example of that:
+ #
+ # class Entry < ApplicationRecord
+ # delegated_type :entryable, types: %w[ Message Comment ]
+ # delegates :title, to: :entryable
+ # end
+ #
+ # class Message < ApplicationRecord
+ # def title
+ # subject
+ # end
+ # end
+ #
+ # class Comment < ApplicationRecord
+ # def title
+ # content.truncate(20)
+ # end
+ # end
+ #
+ # Now you can list a bunch of entries, call +Entry#title+, and polymorphism will provide you with the answer.
+ module DelegatedType
+ # Defines this as a class that'll delegate its type for the passed +role+ to the class references in +types+.
+ # That'll create a polymorphic +belongs_to+ relationship to that +role+, and it'll add all the delegated
+ # type convenience methods:
+ #
+ # class Entry < ApplicationRecord
+ # delegated_type :entryable, types: %w[ Message Comment ], dependent: :destroy
+ # end
+ #
+ # Entry#entryable_class # => +Message+ or +Comment+
+ # Entry#entryable_name # => "message" or "comment"
+ # Entry.messages # => Entry.where(entryable_type: "Message")
+ # Entry#message? # => true when entryable_type == "Message"
+ # Entry#message # => returns the message record, when entryable_type == "Message", otherwise nil
+ # Entry#message_id # => returns entryable_id, when entryable_type == "Message", otherwise nil
+ # Entry.comments # => Entry.where(entryable_type: "Comment")
+ # Entry#comment? # => true when entryable_type == "Comment"
+ # Entry#comment # => returns the comment record, when entryable_type == "Comment", otherwise nil
+ # Entry#comment_id # => returns entryable_id, when entryable_type == "Comment", otherwise nil
+ #
+ # The +options+ are passed directly to the +belongs_to+ call, so this is where you declare +dependent+ etc.
+ #
+ # You can also declare namespaced types:
+ #
+ # class Entry < ApplicationRecord
+ # delegated_type :entryable, types: %w[ Message Comment Access::NoticeMessage ], dependent: :destroy
+ # end
+ #
+ # Entry.access_notice_messages
+ # entry.access_notice_message
+ # entry.access_notice_message?
+ def delegated_type(role, types:, **options)
+ belongs_to role, options.delete(:scope), **options.merge(polymorphic: true)
+ define_delegated_type_methods role, types: types
+ end
+
+ private
+ def define_delegated_type_methods(role, types:)
+ role_type = "#{role}_type"
+ role_id = "#{role}_id"
+
+ define_method "#{role}_class" do
+ public_send("#{role}_type").constantize
+ end
+
+ define_method "#{role}_name" do
+ public_send("#{role}_class").model_name.singular.inquiry
+ end
+
+ types.each do |type|
+ scope_name = type.tableize.gsub("/", "_")
+ singular = scope_name.singularize
+ query = "#{singular}?"
+
+ scope scope_name, -> { where(role_type => type) }
+
+ define_method query do
+ public_send(role_type) == type
+ end
+
+ define_method singular do
+ public_send(role) if public_send(query)
+ end
+
+ define_method "#{singular}_id" do
+ public_send(role_id) if public_send(query)
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/delegated_type_test.rb b/activerecord/test/cases/delegated_type_test.rb
new file mode 100644
index 0000000000..b6c0680649
--- /dev/null
+++ b/activerecord/test/cases/delegated_type_test.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/entry"
+require "models/message"
+require "models/comment"
+
+class DelegatedTypeTest < ActiveRecord::TestCase
+ fixtures :comments
+
+ setup do
+ @entry_with_message = Entry.create! entryable: Message.new(subject: "Hello world!")
+ @entry_with_comment = Entry.create! entryable: comments(:greetings)
+ end
+
+ test "delegated class" do
+ assert_equal Message, @entry_with_message.entryable_class
+ assert_equal Comment, @entry_with_comment.entryable_class
+ end
+
+ test "delegated type name" do
+ assert_equal "message", @entry_with_message.entryable_name
+ assert @entry_with_message.entryable_name.message?
+
+ assert_equal "comment", @entry_with_comment.entryable_name
+ assert @entry_with_comment.entryable_name.comment?
+ end
+
+ test "delegated type predicates" do
+ assert @entry_with_message.message?
+ assert_not @entry_with_message.comment?
+
+ assert @entry_with_comment.comment?
+ assert_not @entry_with_comment.message?
+ end
+
+ test "scope" do
+ assert Entry.messages.first.message?
+ assert Entry.comments.first.comment?
+ end
+
+ test "accessor" do
+ assert @entry_with_message.message.is_a?(Message)
+ assert_nil @entry_with_message.comment
+
+ assert @entry_with_comment.comment.is_a?(Comment)
+ assert_nil @entry_with_comment.message
+ end
+
+ test "association id" do
+ assert_equal @entry_with_message.entryable_id, @entry_with_message.message_id
+ assert_nil @entry_with_message.comment_id
+
+ assert_equal @entry_with_comment.entryable_id, @entry_with_comment.comment_id
+ assert_nil @entry_with_comment.message_id
+ end
+end
diff --git a/activerecord/test/models/entry.rb b/activerecord/test/models/entry.rb
new file mode 100644
index 0000000000..fb100d7657
--- /dev/null
+++ b/activerecord/test/models/entry.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class Entry < ActiveRecord::Base
+ delegated_type :entryable, types: %w[ Message Comment ]
+end
diff --git a/activerecord/test/models/message.rb b/activerecord/test/models/message.rb
new file mode 100644
index 0000000000..931499532a
--- /dev/null
+++ b/activerecord/test/models/message.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class Message < ActiveRecord::Base
+ has_one :entry, as: :entryable
+end
diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb
index f919f109ae..1285881083 100644
--- a/activerecord/test/schema/schema.rb
+++ b/activerecord/test/schema/schema.rb
@@ -347,6 +347,11 @@ ActiveRecord::Schema.define do
t.integer :course_id, null: false
end
+ create_table :entries, force: true do |t|
+ t.string :entryable_type, null: false
+ t.integer :entryable_id, null: false
+ end
+
create_table :essays, force: true do |t|
t.string :name, **case_sensitive_options
t.string :writer_id
@@ -542,6 +547,10 @@ ActiveRecord::Schema.define do
t.string :name
end
+ create_table :messages, force: true do |t|
+ t.string :subject
+ end
+
create_table :minivans, force: true, id: false do |t|
t.string :minivan_id
t.string :name