Spec-Zone.ru › Ruby on Rails 8.1

модуль 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'

Открытые методы экземпляра

delegated_type (role, types:, **options) Показать исходный код
# 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.

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API