Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: ActiveRecord::DelegatedType APIドキュメント(翻訳)

概要

MITライセンスに基づいて翻訳・公開いたします。

ActiveRecord::DelegatedTypeはRails 6.1以降で利用できます。delegated typeは英ママとしました。

週刊Railsウォッチ20200601 新機能: Active Recordにdelegated_typeが追加もどうぞ。

また、Rails 7ではaccepts_nested_attributes_forもサポートされています。

週刊Railsウォッチ20211115前編 delegated_typeaccepts_nested_attributes_forをサポート

  • 2021/11/19: 初版公開
  • 2022/04/12: accepts_nested_attributes_forの追加を反映

Rails: ActiveRecord::DelegatedType APIドキュメント(翻訳)

Delegated typeについて

Class階層は、さまざまな方法でリレーショナルデータベースのテーブルとマッピングできます。たとえば、スーパークラスが属性を持たない純粋抽象クラスや、階層のあらゆるレベルの属性を1個のテーブルで表現するSTI(Single Table Inheritance: 単一テーブル継承)がActive Recordで提供されています。どちらの手法にも使い所がありますが、どちらにも欠点があります。

純粋抽象クラスの問題は、すべての具象サブクラスが、そのサブクラス自身が持つテーブル内で共有される属性をすべて永続化しなければならないことです(これはクラステーブル継承とも呼ばれます)。そのため、クラス階層にまたがるクエリが難しくなります。たとえば、以下のクラス階層があるとします。

Entry < ApplicationRecord
Message < Entry
Comment < Entry

このとき、MessageモデルのレコードとCommentモデルのレコードを両方使うフィードを、ページネーションしやすい形で表示するにはどうすればよいでしょうか?これはできません。メッセージにはmessagesテーブルがあり、コメントにはcommentsテーブルがあります。両方のテーブルから一度にデータを取り出して、しかも一貫したOFFSET/LIMITスキームを使うのは無理です。

ページネーションの問題はSTIを使えば回避できますが、その代わりすべてのサブクラスにあるすべての属性を巨大な1個のテーブルに保存することを強いられます。属性がテーブルごとにどれほど異なっていようと、そうするしかありません。メッセージにsubject属性があり、コメントにはsubject属性がなくても、結局コメントにもsubject属性が入ってきてしまいます。つまり、STIはサブクラス同士やサブクラスの属性同士のばらつきがほとんどない場合にベストです。

しかし、第3の「delegated type」という方法があります。このアプローチでは、「スーパークラス」が独自のテーブルで表現する具象クラスになっていて、すべての「サブクラス」で共有される属性はすべてスーパークラス独自のテーブルに保存されます。そして、各サブクラスはその実装特有の属性を保存するテーブルを個別に持ちます。これはDjangoフレームワークでマルチテーブル継承と呼ばれているものと似ていますが、このアプローチでは実際の継承ではなく、委譲を用いて階層を形成しつつ責務を共有します。

delegated typeを用いたEntry、Message、Commentのコード例を見てみましょう。

# 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, body ]
class Message < ApplicationRecord
  include Entryable
end

# Schema: comments[ id, content ]
class Comment < ApplicationRecord
  include Entryable
end

ご覧のように、MessageモデルもCommentモデルも単独では成立しません。両方のクラスにある重要なメタデータは、「スーパークラス」であるEntryモデルに存在します。しかしEntryモデルは、特にクエリ機能の面では絶対的に独立しています。これで、以下のような操作を簡単に行なえるようになります。

Account.find(1).entries.order(created_at: :desc).limit(50)

これはまさに、コメントとメッセージをまとめて表示したいときに欲しくなるものです。エントリそのものはdelegated typeとして以下のように簡単にレンダリングできます。

# 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>

concernとコントローラで振る舞いを共有する

「スーパークラス」であるEntryモデルは、メッセージとコメントの両方に適用するすべての共有ロジックを置く場所としても申し分ありません。以下を想像してみてください。

class Entry < ApplicationRecord
  include Eventable, Forwardable, Redeliverable
end

これで、ForwardsControllerRedeliverableControllerのような、どちらもエントリ上で動かすものに使うコントローラを持てるようになり、メッセージとコメントの両方で機能を共有できるようになります。

新規レコードを作成する

delegated typeを用いる新規レコードを作成する場合は、以下のようにdelegator(委任側)とdelegatee(受任側)を同時に作成してください。

Entry.create! entryable: Comment.new(content: "Hello!"), creator: Current.user

さらに複雑なコンポジションが必要な場合や、依存のバリデーションを実行する必要がある場合は、factoryメソッドかfactoryクラスを構築して複雑なニーズを扱います。これは以下のようにシンプルにできます。

class Entry < ApplicationRecord
  def self.create_with_comment(content, creator: Current.user)
    create! entryable: Comment.new(content: content), creator: creator
  end
end

さらに委譲を追加する

このdelegated typeは、背後のクラスが何と呼ばれるかという質問に答えるだけの存在であってはいけません。これはほとんどの場合アンチパターンになります。この階層を構築する理由は、ポリモーフィズムを利用するためです。以下に簡単なコード例を示します。

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'

publicインスタンスメソッド

delegated_type(role, types:, **options)

渡されたroleの型をtypes内のクラス参照に委譲するクラスとして定義します。これにより、そのroleへのポリモーフィックなbelongs_toリレーションシップが作成され、delegated typeの便利メソッドがそこにすべて追加されます。

class Entry < ApplicationRecord
  delegated_type :entryable, types: %w[ Message Comment ], dependent: :destroy
end

Entry#entryable_class # => +Message+ または +Comment+
Entry#entryable_name  # => "message" または "comment"
Entry.messages        # => Entry.where(entryable_type: "Message")
Entry#message?        # => entryable_type == "Message"の場合はtrue
Entry#message         # => entryable_type == "Message"の場合はmessageレコードを返し、それ以外はnilを返す
Entry#message_id      # => entryable_type == "Message"の場合はentryable_id,を返し、それ以外はnilを返す
Entry.comments        # => Entry.where(entryable_type: "Comment")
Entry#comment?        # => entryable_type == "Comment"の場合はtrue
Entry#comment         # => entryable_type == "Comment"の場合はcommentレコードを返し、それ以外はnilを返す
Entry#comment_id      # => entryable_type == "Comment"の場合はentryable_idを返し、それ以外は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などはここで宣言します。以下のオプションを含めることで、delegated typeの便利なメソッドの振る舞いを特殊化できます。

  • :foreign_key

便利メソッドで用いる外部キーを指定します。デフォルトでは、_idをサフィックスしたものがroleに渡されると推測します。つまり、delegated_type :entryable, types: %w[ Message Comment ]という関連付けを定義しているクラスでは、デフォルトで:foreign_keyに"entryable_id"が使われます。

  • :primary_key

便利メソッドで用いられる、関連付けされたオブジェクトの主キーを返すメソッドを指定します。これはデフォルトではidになります。

オプションの利用例は以下のとおりです。

class Entry < ApplicationRecord
  delegated_type :entryable, types: %w[ Message Comment ], primary_key: :uuid, foreign_key: :entryable_uuid
end

Entry#message_uuid      # => entryable_type == "Message"の場合はentryable_uuidを返し、それ以外はnilを返す
Entry#comment_uuid      # => entryable_type == "Comment"の場合はentryable_uuidを返し、それ以外はnilを返す

関連記事

Rails: ActiveRecord標準のattributes APIドキュメント(翻訳)

Rails 5.1〜7.0: ‘form_with’ APIドキュメント(翻訳)

Rails APIドキュメント: Active Recordのトランザクション(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。