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.
|
* Deprecate aggregations with group by duplicated fields.
|
||||||
|
|
||||||
To migrate to Rails 6.2's behavior, use `uniq!(:group)` to deduplicate group 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 :ConnectionHandling
|
||||||
autoload :CounterCache
|
autoload :CounterCache
|
||||||
autoload :DynamicMatchers
|
autoload :DynamicMatchers
|
||||||
|
autoload :DelegatedType
|
||||||
autoload :Enum
|
autoload :Enum
|
||||||
autoload :InternalMetadata
|
autoload :InternalMetadata
|
||||||
autoload :Explain
|
autoload :Explain
|
||||||
|
|
|
@ -274,6 +274,7 @@ module ActiveRecord #:nodoc:
|
||||||
extend Querying
|
extend Querying
|
||||||
extend Translation
|
extend Translation
|
||||||
extend DynamicMatchers
|
extend DynamicMatchers
|
||||||
|
extend DelegatedType
|
||||||
extend Explain
|
extend Explain
|
||||||
extend Enum
|
extend Enum
|
||||||
extend Delegation::DelegateCache
|
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
|
t.integer :course_id, null: false
|
||||||
end
|
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|
|
create_table :essays, force: true do |t|
|
||||||
t.string :name, **case_sensitive_options
|
t.string :name, **case_sensitive_options
|
||||||
t.string :writer_id
|
t.string :writer_id
|
||||||
|
@ -542,6 +547,10 @@ ActiveRecord::Schema.define do
|
||||||
t.string :name
|
t.string :name
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table :messages, force: true do |t|
|
||||||
|
t.string :subject
|
||||||
|
end
|
||||||
|
|
||||||
create_table :minivans, force: true, id: false do |t|
|
create_table :minivans, force: true, id: false do |t|
|
||||||
t.string :minivan_id
|
t.string :minivan_id
|
||||||
t.string :name
|
t.string :name
|
||||||
|
|
Loading…
Reference in New Issue