[Rails]DelegatedTypeことはじめ
はじめに
※表現がやや誤っている箇所もあるかもしれません。明らかな間違いがあればコメントでご指摘いただけると幸いです。
以前書いた以下記事でも触れましたが、代表的なクラス継承パターンとしてSTI・CCI・CTIなどがあります。
STIについては、Railsに標準で備わっており、非常に便利に使用することができます。
一方で、サブクラスのカラムが増える度に、他のサブクラスではそのカラムを使わないとしても、該当のテーブルにそのカラムを含めなければなりません。
そのため、テーブルが肥大化してしまったり、使わないカラムはNULLになってしまう、などのデメリットがあります。
(オレンジの部分は擬似的なテーブルであり、実際に作成されるテーブルではない)
DelegatedTypeとは
ではDelegatedTypeとは何なのか?
- what
- 共通で使用するカラムは親テーブルへ、個別で使用するカラムは子テーブルへという、CTIっぽいことができるRailsの機能
- when
- Rail6.1から導入
元々STIではテーブルを継承するため、各モデルごとに別のカラムを作りたい時には、共有されているテーブルに全てのカラムを追加する必要がありました。
一方、DelegatedTypだと、共通部分を統一的に管理するテーブルを作り、その下に個々のサブテーブルを作ることで、個々のモデルで必要になったカラムはそれぞれのテーブルに加えれば良くなります。
必要なテーブルに必要なカラムだけを追加すれば良いのです。
そのため、先ほどSTIの箇所で挙げた
- テーブルが肥大化してしまう
- NOT NULL制約を貼れない
- 共通のカラムとサブテーブル特有のカラムの区別がつかない
などのデメリットを解消することができます。
それため、STIの問題点を解消した後継機能と言われているのかもしれません。
DelegatedTypeを使ってみる
ではここから実際にコードを見ながら理解していきましょう。
実際に書いたコードは私のリポジトリにあります。
設計
実際のコードを書く前に、どのようなテーブル構造にするかを決めます。
今回は分かりやすく以下画像のようにします。
ContactActivityという「連絡先への行動」的なテーブルがトップにあり、その下に3つほどテーブルを準備します。
- CallEvent
- 電話履歴
- MailEvent
- メール履歴
- AssigneeChangeEvent
- 担当者変更履歴
お問い合わせのあったクライアントへ、どのように連絡したか、何を連絡したか、担当者は途中で変わったか、などを記録するようなイメージを持っていただけると良いかなと思います。
Railsでスキーマを作成する
共通部分となる抽象テーブルの作成が一番重要です。
- db/migrate/2024xxxxxxxxxx_create_contact_activities.rb
class CreateContactActivities < ActiveRecord::Migration[7.1]
def change
create_table :contact_activities do |t|
t.references :activityable, polymorphic: true, null: false
t.timestamps
end
end
end
上記のように、ポリモーフィックのオプションをつけることで、contact_activitiesテーブルには、activityable_type
とactivityable_id
の2つのカラムが追加されます。
- db/schema.rb
create_table "contact_activities", force: :cascade do |t|
t.string "activityable_type", null: false
t.integer "activityable_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["activityable_type", "activityable_id"], name: "index_contact_activities_on_activityable"
end
この2つのカラムを用いることでDelegatedTypeを使用することができます。
残りのサブテーブルは通常のテーブルと変わらないので、migrationをそれぞれ作成してテーブルを作成してください。
その他のマイグレーション
- db/migrate/2024xxxxxxxxxx_create_call_events.rb
class CreateCallEvents < ActiveRecord::Migration[7.1]
def change
create_table :call_events do |t|
t.string :phone_number, null: false
t.timestamps
end
end
end
- db/migrate/2024xxxxxxxxxx_create_mail_events.rb
class CreateMailEvents < ActiveRecord::Migration[7.1]
def change
create_table :mail_events do |t|
t.text :to, null: false
t.text :body, null: false
t.timestamps
end
end
end
- db/migrate/2024xxxxxxxxxx_create_assignee_change_events.rb
class CreateAssigneeChangeEvents < ActiveRecord::Migration[7.1]
def change
create_table :assignee_change_events do |t|
t.string :before_assignee
t.string :after_assignee, null: false
t.timestamps
end
end
end
モデルでアソシエーションを定義する
抽象的なモデルのContactActivity
にはdelegated_typeメソッドを用いてDelegatedTypeを使うということを明記します。
第一引数にはschemaに登録したhogehoge_id
, hogehoge_type
のhogehoge
の部分を、第二引数には各モデル名をそれぞれ記載してください。
(delegated_type
メソッドの詳細については以下参照)
- app/models/contact_activity.rb
class ContactActivity < ApplicationRecord
delegated_type :activityable, types: %w[CallEvent MailEvent AssigneeChangeEvent]
end
各サブモデルにはhas_oneメソッドでアソシエーションを定義します。
has_one :contact_activity, as: :activityable
もちろん一つ一つのサブモデルに上記を記載するのも良いですが、ここは共通なのでconcernモジュールとしておくのが良いかもしれません。
(APIリファレンスでもモジュールとして切り出すようにコードが書かれています)
- app/models/concerns/activityable.rb
module Activityable
extend ActiveSupport::Concern
included do
has_one :contact_activity, as: :activityable
end
end
- app/models/call_event.rb
class CallEvent < ApplicationRecord
include Activityable
end
これで準備完了です。
レコードを作成・呼び出す
レコードを作成するときは、抽象テーブルとサブテーブルのレコードを同時に作ります。
それぞれ以下のように作成できます。
ContactActivity.create(activityable: CallEvent.new(phone_number: "08012345678"))
ContactActivity.create(activityable: MailEvent.new(to: "hoge@example.com", body: "私はコロナに感染中です"))
ContactActivity.create(activityable: AssigneeChangeEvent.new(before_assignee: "一郎", after_assignee: "二郎"))
呼び出すときは抽象テーブルに値するモデルを操作することで取得できます。
ContactActivity.all
ContactActivity Load (0.1ms) SELECT "contact_activities".* FROM "contact_activities" /* loading for pp */ LIMIT ? [["LIMIT", 11]]
=>
[#<ContactActivity:0x0000000113e2bf58
id: 1,
activityable_type: "CallEvent",
activityable_id: 1,
>,
#<ContactActivity:0x0000000113e2be18
id: 2,
activityable_type: "MailEvent",
activityable_id: 1,
>,
#<ContactActivity:0x0000000113e2bcd8
id: 3,
activityable_type: "AssigneeChangeEvent",
activityable_id: 1,
>]
そのほかにも、例えばCallEvent
だけ取り出したい場合にはContactActivity.call_events
、AssigneeChangeEvent
だけを取り出したい場合にはContactActivity.assignee_change_events
で取り出すことができます。
(めちゃくちゃ便利)
このようにDelegatedTypeを使用すると、それぞれ違うモデル、違うテーブルにレコードが入っていたとしても、DelegatedTypeを使ってContactActivity
モデルにも登録しているため、まとめて呼び出すことができます。
もちろん任意の順番に並べ替えることも可能です。
最後に
いかがでしたでしょうか?
実は私自身、プロダクションコードで初めてこのDelegatedTypeを使った実装を見た時は、「なんだこれ、全く理解できないや、、、」と思っていましたw
ただ実際に触ってみると想像以上にスッと理解できた且つ、非常に便利だなと感じることが多いです。
(そして改めてRailsが自動生成してくれるメソッドの多さには驚きましたw)
「少し苦手意識あるかもな〜」という方は一度触ってみることをオススメします。
最後までお読みいただき、ありがとうございました!
この記事がDelegatedTypeをこれから使用しようとしている方のお役に立てれば幸いです。
Discussion