Add delegated type to Active Record (#39341)
This commit is contained in:
parent
277d637277
commit
fd8fd4ae76
|
@ -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.
|
||||
|
|
|
@ -43,6 +43,7 @@ module ActiveRecord
|
|||
autoload :ConnectionHandling
|
||||
autoload :CounterCache
|
||||
autoload :DynamicMatchers
|
||||
autoload :DelegatedType
|
||||
autoload :Enum
|
||||
autoload :InternalMetadata
|
||||
autoload :Explain
|
||||
|
|
|
@ -274,6 +274,7 @@ module ActiveRecord #:nodoc:
|
|||
extend Querying
|
||||
extend Translation
|
||||
extend DynamicMatchers
|
||||
extend DelegatedType
|
||||
extend Explain
|
||||
extend Enum
|
||||
extend Delegation::DelegateCache
|
||||
|
|
|
@ -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
|
||||
# <div class="message">
|
||||
# Posted on <%= entry.created_at %> by <%= entry.creator.name %>: <%= entry.message.content %>
|
||||
# </div>
|
||||
#
|
||||
# # entries/entryables/_comment.html.erb
|
||||
# <div class="comment">
|
||||
# <%= entry.creator.name %> said: <%= entry.comment.content %>
|
||||
# </div>
|
||||
#
|
||||
# == 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
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Entry < ActiveRecord::Base
|
||||
delegated_type :entryable, types: %w[ Message Comment ]
|
||||
end
|
|
@ -0,0 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Message < ActiveRecord::Base
|
||||
has_one :entry, as: :entryable
|
||||
end
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue