модуль ActiveRecord::DelegatedType
Делегированные типы
Class иерархии могут отображаться в таблицах реляционной базы данных множеством способов. Например, Active Record предлагает чисто абстрактные классы, где родительский класс не сохраняет никаких атрибутов, и наследование по одной таблице, где все атрибуты со всех уровней иерархии представлены в одной таблице. У обоих подходов есть свои преимущества, но и недостатки.
Проблема с чисто абстрактными классами заключается в том, что все конкретные подклассы должны сохранять все общие атрибуты в своих таблицах (также известное как наследование по классам). Это затрудняет выполнение запросов по всей иерархии. Например, представьте следующую иерархию:
Entry < ApplicationRecord Message < Entry Comment < Entry
Как отобразить ленту, содержащую как Message , так и Comment записи, которые можно легко постраничивать? Нельзя! Сообщения хранятся в таблице сообщений, а комментарии — в таблице комментариев. Вы не можете получить данные из обеих таблиц одновременно и использовать согласованную схему OFFSET/LIMIT.
Можно обойти проблему постраничности, используя наследование по одной таблице, но теперь вы вынуждены использовать одну огромную таблицу со всеми атрибутами всех подклассов. Независимо от того, насколько они отличаются. Если у сообщения есть тема, а у комментария нет, то у комментария она теперь есть! Поэтому наследование по одной таблице лучше всего работает, когда различия между подклассами и их атрибутами невелики.
Но есть третий способ: делегированные типы. В этом подходе «родительский класс» — это конкретный класс, который представлен собственной таблицей, где хранятся все атрибуты родительского класса, общие для всех «подклассов». Затем каждый из подклассов имеет свои отдельные таблицы для дополнительных атрибутов, специфичных для их реализации. Это похоже на то, что в 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.merge(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 ], будет использовать «entryable_id» в качестве значения по умолчанию:foreign_key. - :foreign_type
-
Укажите столбец, используемый для хранения типа связанного объекта. По умолчанию он определяется по переданному
roleс суффиксом «_type». Класс, который определяет ассоциациюdelegated_type :entryable, types: %w[ Message Comment ], будет использовать «entryable_type» в качестве значения по умолчанию:foreign_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.