RailsのDelegated Typesで実現する柔軟なモデル設計

この記事は、 PLEX Advent Calendar 2024 の7日目の記事です。

はじめに

こんにちは、株式会社プレックスのコーポレートチームの金山です。

この記事では、Railsアプリケーションの設計に役立つ「Delegated Types」について解説したいと思います。

「Delegated Types」がどのような課題を解決するのか、使い方やメリット・デメリット、実際に使ってみた感想をシェアしていきます。

背景と課題

とあるプロジェクトの開発で、通話履歴を管理する機能を実装することになりました。

通話には「企業への通話」と「個人への通話」の2つがあります。

これら2つの異なるモデルを画面にリスト表示し、ページネーション、ソート、検索機能を提供する必要がありました。

Railsでは異なるモデルを一緒に扱うことが難しいため、この課題を解決する方法として「単一テーブル継承」と「Delegated Types」を検討しました。

単一テーブル継承(STI)とは

STIは、複数のモデルを単一のテーブルで管理する方法です。

モデルの種類を識別するための型情報「type」カラムを持たせます。

今回の通話履歴で例えると、企業への通話履歴ならtypeが「CompanyCall」、個人への通話履歴ならtypeが「UserCall」となります。

STIを利用すると、異なるモデルのデータを1つのテーブルで管理できるため、検索や操作が簡単になります。

考慮すべき点

ひとつのテーブルにサブクラス固有の属性も含まれるため、未使用カラムが多くなりテーブルが肥大化する可能性があります。

また、モデルごとに異なるカラムにNOT NULL制約を適用できないため、データ整合性のチェックが難しくなります。

Delegated Typesとは

Delegated Typesは、STIの問題を解決するためのアプローチです。

共通の属性を親テーブルに保存し、固有の属性は別の子テーブルに分けて管理します。

例えば、今回の通話履歴の場合、通話日時などの共有属性はCallテーブルで持ち、モデルごとに異なる属性はサブテーブルで保持します。

親子の関連付けは◯◯able_typeと◯◯able_idで紐付けます。

これにより、単一のテーブルで、すべてのサブクラス間で不必要に共有される属性を定義する必要がなくなります。

考慮すべき点

テーブルを分けることで、複数テーブル間の結合が必要になりクエリのパフォーマンスが低下する場合があります。

また、レコード作成や削除処理では親子テーブルで同時に行う必要があり、パフォーマンスに影響します。

テーブルが分かれることから実装の複雑になるため、各テーブルの構造や関連付けを明確に設計する必要があります。

Delegated Typesの使い方

まずはマイグレーションファイルを作成します。

callableというポリモーフィック関連を定義します。

ポリモーフィック関連を定義する場合、◯◯ableが慣例的な命名規則です。

これを書くことでcallable_typeとcallable_idカラムが自動的に追加されます。

class CreateCalls < ActiveRecord::Migration[7.1]
  def change
    create_table(:calls, comment: '通話テーブル') do |t|
      t.references(:callable, polymorphic: true, null: false)
      t.datetime(:called_at, null: false, comment: '通話日時')
      t.integer(:duration, comment: '通話時間')

      t.timestamps
    end
  end
end

次にモデルの設定です。

class Call < ApplicationRecord
  delegated_type :callable, types: ['UserCall', 'CompanyCall'], dependent: :destroy
  accepts_nested_attributes_for :callable
end
class UserCall < ApplicationRecord
  has_one :call, as: :callable, touch: true, dependent: :destroy
  belongs_to :user
end
class CompanyCall < ApplicationRecord
  has_one :call, as: :callable, touch: true, dependent: :destroy
  belongs_to :company
end

マイグレーションとモデルを設定したら準備完了です。

ここからはどのように使用するのかを見ていきます。

作成方法

新しいCallオブジェクトを作成する際に、Callableサブクラスを同時に指定できます。

以下のようにするとCallsとUserCallsテーブルにそれぞれレコードが作成されます。

  Call.create!(
    assignee_user_id: User.ids.sample,
    called_at: Faker::Time.backward(days: 30),
    duration: Faker::Number.between(from: 1, to: 100),
    callable: UserCall.new(user_id: User.ids.sample)
  )

取得方法

Callモデルを使用して通話一覧を取得できます。

Call.user_callsとすれば、UserCallのCallのみ取得可能です。

注意点として親から子の属性を参照することはできません。

委任先の情報を取得するにはcallableを呼び出す必要があります。

Call.order(called_at: :desc).limit(10)

Call.user_calls

call = Call.find(1)
callable = call.callable

更新方法

Callモデルにaccepts_nested_attributes_for :callableを定義することで、Callableサブクラスを同時に更新できます。

親と子のレコードが同じトランザクションで更新されます。

class Call < ApplicationRecord
  delegated_type :callable, types: ['UserCall', 'CompanyCall'], dependent: :destroy
  accepts_nested_attributes_for :callable
end
call = Call.find(1)

call.update(
  called_at: Time.now,
  duration: Faker::Number.between(from: 1, to: 100),
  callable_attributes: { user_id: User.ids.sample }
)

削除方法

削除はシンプルにdestroyを呼び出します。

親と子のレコードが同じトランザクションで削除されます。

call = Call.find(1)
call.destroy

結果と感想

今回のプロジェクトでは、各通話ごとに異なる属性を持つことを考慮し、Delegated Typesを採用しました。

その結果、共通部分を明確にしつつ、通話ごとに異なる外部キーにNOT NULL制約をつけることで、データの整合性を保つことができました。

しかし一方で、ActiveRecordでの操作がやや煩雑になり、STIと比べてシンプルな操作がしづらい場面がありました。

どちらの方法を採用するかはプロジェクトの要件次第です。それぞれのメリットとデメリットを考慮した上で選択することが大切だと感じました。