🫠

[Rails]DelegatedTypeことはじめ

2024/01/26に公開

はじめに

※表現がやや誤っている箇所もあるかもしれません。明らかな間違いがあればコメントでご指摘いただけると幸いです。

以前書いた以下記事でも触れましたが、代表的なクラス継承パターンとしてSTI・CCI・CTIなどがあります。
https://zenn.dev/aion/articles/39f756e58dcf47

STIについては、Railsに標準で備わっており、非常に便利に使用することができます。

一方で、サブクラスのカラムが増える度に、他のサブクラスではそのカラムを使わないとしても、該当のテーブルにそのカラムを含めなければなりません。

そのため、テーブルが肥大化してしまったり、使わないカラムはNULLになってしまう、などのデメリットがあります。
(オレンジの部分は擬似的なテーブルであり、実際に作成されるテーブルではない)

https://api.rubyonrails.org/classes/ActiveRecord/Inheritance.html

DelegatedTypeとは

ではDelegatedTypeとは何なのか?

  • what
    • 共通で使用するカラムは親テーブルへ、個別で使用するカラムは子テーブルへという、CTIっぽいことができるRailsの機能
  • when
    • Rail6.1から導入

元々STIではテーブルを継承するため、各モデルごとに別のカラムを作りたい時には、共有されているテーブルに全てのカラムを追加する必要がありました。
一方、DelegatedTypだと、共通部分を統一的に管理するテーブルを作り、その下に個々のサブテーブルを作ることで、個々のモデルで必要になったカラムはそれぞれのテーブルに加えれば良くなります。

必要なテーブルに必要なカラムだけを追加すれば良いのです。

そのため、先ほどSTIの箇所で挙げた

  • テーブルが肥大化してしまう
  • NOT NULL制約を貼れない
  • 共通のカラムとサブテーブル特有のカラムの区別がつかない

などのデメリットを解消することができます。

それため、STIの問題点を解消した後継機能と言われているのかもしれません。
https://api.rubyonrails.org/classes/ActiveRecord/DelegatedType.html

DelegatedTypeを使ってみる

ではここから実際にコードを見ながら理解していきましょう。
実際に書いたコードは私のリポジトリにあります。
https://github.com/issei-yoshi/sample-app-for-delegated_type

設計

実際のコードを書く前に、どのようなテーブル構造にするかを決めます。
今回は分かりやすく以下画像のようにします。

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_typeactivityable_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_typehogehogeの部分を、第二引数には各モデル名をそれぞれ記載してください。
delegated_typeメソッドの詳細については以下参照)
https://github.com/rails/rails/blob/80bce4aad3b288a92745abb7c3d15e5bb8aa2c82/activerecord/lib/active_record/delegated_type.rb#L211

  • 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_eventsAssigneeChangeEventだけを取り出したい場合にはContactActivity.assignee_change_eventsで取り出すことができます。
(めちゃくちゃ便利)

このようにDelegatedTypeを使用すると、それぞれ違うモデル、違うテーブルにレコードが入っていたとしても、DelegatedTypeを使ってContactActivityモデルにも登録しているため、まとめて呼び出すことができます。
もちろん任意の順番に並べ替えることも可能です。

最後に

いかがでしたでしょうか?

実は私自身、プロダクションコードで初めてこのDelegatedTypeを使った実装を見た時は、「なんだこれ、全く理解できないや、、、」と思っていましたw

ただ実際に触ってみると想像以上にスッと理解できた且つ、非常に便利だなと感じることが多いです。
(そして改めてRailsが自動生成してくれるメソッドの多さには驚きましたw)

「少し苦手意識あるかもな〜」という方は一度触ってみることをオススメします。

最後までお読みいただき、ありがとうございました!
この記事がDelegatedTypeをこれから使用しようとしている方のお役に立てれば幸いです。

参考

Discussion