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
これで、ForwardsController
やRedeliverableController
のような、どちらもエントリ上で動かすものに使うコントローラを持てるようになり、メッセージとコメントの両方で機能を共有できるようになります。
新規レコードを作成する
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を返す
概要
MITライセンスに基づいて翻訳・公開いたします。
ActiveRecord::DelegatedType
ActiveRecord::DelegatedType
はRails 6.1以降で利用できます。delegated typeは英ママとしました。週刊Railsウォッチ20200601 新機能: Active Recordに
delegated_type
が追加もどうぞ。また、Rails 7では
accepts_nested_attributes_for
もサポートされています。週刊Railsウォッチ20211115前編
delegated_type
でaccepts_nested_attributes_for
をサポートaccepts_nested_attributes_for
の追加を反映