модуль ActiveRecord::DelegatedType
Делегируемые типы
Иерархии Class можно отображать на реляционные таблицы базы данных разными способами. Например, Active Record предлагает чисто абстрактные классы, в которых суперкласс не сохраняет никаких атрибутов, и наследование с одной таблицей, при котором все атрибуты всех уровней иерархии представлены в одной таблице. Оба подхода имеют свои области применения, но у каждого есть недостатки.
Проблема чисто абстрактных классов заключается в том, что все конкретные подклассы должны самостоятельно сохранять общие атрибуты в собственных таблицах (это также называется наследованием с таблицей для каждого класса). Из-за этого сложно выполнять запросы по всей иерархии. Например, представьте следующую иерархию:
Entry < ApplicationRecord Message < Entry Comment < Entry
Как вывести ленту, содержащую записи Message и Comment, которую можно легко разбивать на страницы? Никак! Сообщения хранятся в таблице messages, а комментарии — в таблице comments. Нельзя одновременно выбирать данные из обеих таблиц и использовать согласованную схему OFFSET/LIMIT.
Проблему разбиения на страницы можно обойти с помощью наследования с одной таблицей, но тогда приходится использовать одну огромную таблицу со всеми атрибутами всех подклассов, независимо от того, насколько сильно они различаются. Если у сообщения есть тема, а у комментария нет, то теперь тема будет и у комментария! Поэтому STI лучше всего подходит для случаев, когда подклассы и их атрибуты мало различаются.
Но есть и третий способ: делегируемые типы. В этом подходе «суперкласс» — это конкретный класс, представленный собственной таблицей, в которой хранятся все общие атрибуты суперкласса, используемые всеми «подклассами». У каждого подкласса при этом есть собственная таблица для дополнительных атрибутов, специфичных для его реализации. Это похоже на так называемое многотабличное наследование в Django, но вместо настоящего наследования в этом подходе для построения иерархии и распределения обязанностей используется делегирование.
Рассмотрим пример с записями, сообщениями и комментариями, используя делегируемые типы:
# Schema: entries[ id, account_id, creator_id, entryable_type, entryable_id, created_at, updated_at ]
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, body, created_at, updated_at ]
class Message < ApplicationRecord
include Entryable
end
# Schema: comments[ id, content, created_at, updated_at ]
class Comment < ApplicationRecord
include Entryable
end
Как видите, ни Message, ни Comment не предназначены для самостоятельного использования. Важные метаданные обоих классов находятся в «суперклассе» Entry. Однако Entry вполне может использоваться самостоятельно, особенно с точки зрения возможностей выполнения запросов. Теперь можно легко выполнять такие действия:
Account.find(1).entries.order(created_at: :desc).limit(50)
Именно это и нужно при совместном отображении комментариев и сообщений. Саму запись можно легко отобразить как её делегируемый тип, например так:
# entries/_entry.html.erb
<%= render "entries/entryables/#{entry.entryable_name}", entry: entry %>
# entries/entryables/_message.html.erb
<div class="message">
<div class="subject"><%= entry.message.subject %></div>
<p><%= entry.message.body %></p>
<i>Posted on <%= entry.created_at %> by <%= entry.creator.name %></i>
</div>
# entries/entryables/_comment.html.erb
<div class="comment">
<%= entry.creator.name %> said: <%= entry.comment.content %>
</div> Общий функционал в модулях и контроллерах
«Суперкласс» записи также отлично подходит для размещения общей логики, которая применяется и к сообщениям, и к комментариям и в основном работает с общими атрибутами. Например:
class Entry < ApplicationRecord include Eventable, Forwardable, Redeliverable end
Это позволяет создавать контроллеры для таких сущностей, как ForwardsController и RedeliverableController, которые оба работают с записями и, таким образом, предоставляют общую функциональность для сообщений и комментариев.
Создание новых записей
Чтобы создать запись с делегируемым типом, создайте делегирующий и делегируемый объекты одновременно, например так:
Entry.create! entryable: Comment.new(content: "Hello!"), creator: Current.user, account: Current.account
Если требуется более сложная композиция или проверка зависимых данных, следует создать фабричный метод или класс, который будет обрабатывать эти сложные требования. Например, это может быть так просто:
class Entry < ApplicationRecord
def self.create_with_comment(content, creator: Current.user, account: Current.account)
create! entryable: Comment.new(content: content), creator: creator, account: account
end
end
Querying по нескольким записям
Одно из следствий использования делегируемых типов — запросы к атрибутам, распределённым между несколькими классами, становятся немного сложнее, но остаются возможными.
Самый простой способ — соединить «суперкласс» с «подклассом» и применить параметры запроса (то есть where) в соответствующих местах:
Comment.joins(:entry).where(comments: { content: 'Hello!' }, entry: { creator: Current.user } )
Для удобства добавьте область видимости в модуль. Теперь все классы, подключающие этот модуль, автоматически получат этот метод:
# app/models/concerns/entryable.rb
scope :with_entry, ->(attrs) { joins(:entry).where(entry: attrs) }
Теперь запрос можно значительно сократить:
Comment.where(content: 'Hello!').with_entry(creator: Current.user)
Добавление дополнительного делегирования
Делегируемый тип не должен лишь отвечать на вопрос о названии базового класса. На самом деле, чаще всего это антипаттерн. Иерархия создаётся ради использования полиморфизма. Вот простой пример:
class Entry < ApplicationRecord
delegated_type :entryable, types: %w[ Message Comment ]
delegate :title, to: :entryable
end
class Message < ApplicationRecord
def title
subject
end
end
class Comment < ApplicationRecord
def title
content.truncate(20)
end
end
Теперь можно перечислить несколько записей, вызвать Entry#title, и полиморфизм предоставит ответ.
Вложенные атрибуты
Включение вложенных атрибутов для ассоциации delegated_type позволяет создать запись и сообщение за один раз:
class Entry < ApplicationRecord
delegated_type :entryable, types: %w[ Message Comment ]
accepts_nested_attributes_for :entryable
end
params = { entry: { entryable_type: 'Message', entryable_attributes: { subject: 'Smiling' } } }
entry = Entry.create(params[:entry])
entry.entryable.id # => 2
entry.entryable.subject # => 'Smiling'
Открытые методы экземпляра
# File activerecord/lib/active_record/delegated_type.rb, line 231 def delegated_type(role, types:, **options) belongs_to role, options.delete(:scope), **options, polymorphic: true define_delegated_type_methods role, types: types, options: options end
Объявляет этот класс классом, который будет делегировать тип для переданного role ссылкам на классы в types. Это создаст полиморфную связь belongs_to с этим role и добавит все вспомогательные методы делегируемого типа:
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
Можно также объявлять типы с пространствами имён:
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?
Параметры
Параметры options напрямую передаются вызову belongs_to, поэтому именно здесь объявляются dependent и т. д. Для настройки поведения вспомогательных методов делегируемого типа можно указать следующие параметры.
:foreign_key-
Указывает внешний ключ, используемый вспомогательными методами. По умолчанию его имя формируется из переданного
roleс суффиксом «_id». Поэтому для ассоциацииdelegated_type :entryable, types: %w[ Message Comment ]в качестве:foreign_keyпо умолчанию будет использоваться «entryable_id». :foreign_type-
Указывает столбец, в котором хранится тип связанного объекта. По умолчанию его имя формируется из переданного
roleс суффиксом «_type». Для ассоциацииdelegated_type :entryable, types: %w[ Message Comment ]в качестве:foreign_typeпо умолчанию будет использоваться «entryable_type». :primary_key-
Указывает метод, возвращающий первичный ключ связанного объекта, который используется вспомогательными методами. По умолчанию это
id.
Примеры параметров:
class Entry < ApplicationRecord delegated_type :entryable, types: %w[ Message Comment ], primary_key: :uuid, foreign_key: :entryable_uuid end @entry.message_uuid # => returns entryable_uuid, when entryable_type == "Message", otherwise nil @entry.comment_uuid # => returns entryable_uuid, when entryable_type == "Comment", otherwise nil
© 2004–2021 David Heinemeier Hansson
Licensed under the MIT License.