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 + #
+ # <%= entry.creator.name %> said: <%= entry.comment.content %> + #
+ # + # == 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