ActiveRecordでカラムの値を偽装しながらPolymorphic associationとSTIを併用する

  • Article (has-many notifications)
  • Comment (has-many notifications)
  • Mention (has-many notifications)
  • Notification (belongs-to something)

のようなモデルとそれらの関連性があるとき、notificationsテーブルに例えば以下のようなカラムがあれば、Notification.belongs_to :source, polymorphic: true のようなコードでPolymorphic associationが実現できるようになると思います。

  • source_id
  • source_type

しかしNotificationの要件が大きくなるにつれ、関連するモデルの種類によって条件分岐が増えてくる可能性があります。例えば、Articleに関連するNotificationではこのValidationが必要だが、Commentに関連するNotificationでは不要、などです。そうした場合、Notificationクラス自体にtypeカラムがあれば、以下のようにSingle-Table-Inheritanceを利用しながら別々のクラスを用意することで、実装の定義場所を綺麗に分けられて便利です。

  • Notification
  • ArticleNotification < Notification
  • CommentNotification < Notification
  • MentionNotification < Notification

さて、こうなると source_type の内容と type の内容が重複してしまうことになるので、source_type カラムを削除することを考えます。ActiveRecordが source_type の値を参照しようとしたとき、以下のように2つのメソッドをOverrideすることで、動的に値を組み立てることができます。#[] の変更だけで良いように思われがちですが、#_read_attribute の値がActiveRecord内部で関連レコードのキャッシュ用に利用されているので、これも変更する必要があります。

class Notification < ActiveRecord::Base
  # @note Override
  def [](attribute_name)
    if attribute_name.to_s == "source_type"
      type.gsub(/Notification\z/, "")
    else
      super
    end
  end

  # @note Override
  def _read_attribute(attribute_name)
    if attribute_name == "source_type"
      self[attribute_name]
    else
      super
    end
  end
end

上記のコードで、notificationsテーブルにtypeカラムだけを用意しながら、STIとPolymorphic associationを併用できるようになりました。まあ子クラスごとに belongs_to を書けばPolymorphic associationとかやる必要なさそうですが、一応こういうやり方もあるよということでここはどうか…。上例の場合、受け取った通知一覧などでのN+1クエリが問題になることがあると思うので、良ければ ActiveRecordでPolymorphicにPreloadする - Qiita も参考にどうぞ。