👩‍👦‍👦

【ActiveRecord】 ポリモーフィック関連の「~type」の値がどこで決定されるか

2025/02/04に公開

こんにちは、st-1985 です。
最近、ActiveRecord で継承を使用したモデルの実装をしていた際に、~_typeに意図しないクラス名が設定される問題に遭遇しました。
この記事では、その経験と、ソースコードを追いかけて分かったことを共有したいと思います。

この記事の対象

  • ActiveRecorddelegated_typepolymorphic: trueを使用している

~_typeに継承先のクラス名が設定されない

delegated_typeを使用した以下のような実装があるとします:

class Entry < ApplicationRecord
  delegated_type :entryable, types: %w[ ModelA ModelB ]
end

class ModelA < ApplicationRecord
  has_one :entry, as: :entryable
end

class ModelB < ApplicationRecord
  has_one :entry, as: :entryable
end

ここにModelBとほぼ同じ振る舞いを持つModelCを追加することになりました。

当初、以下のように実装すれば良いと考えていました:


class Entry < ApplicationRecord
  delegated_type :entryable, types: %w[ ModelA ModelB ModelC ]
end

class ModelC < ModelB
  # 追加の実装
end

しかし、実際に動かしてみると、entryable_typeにはModelBが設定され、ModelCとして区別することができませんでした。

entry_b = Entry.create(entryable: ModelB.new)
entry_b.entryable_type # => "ModelB"

entry_c = Entry.create(entryable: ModelC.new)
entry_c.entryable_type # => "ModelB"

ソースコードを追ってみる

まずdelegated_typeの定義を見てみます。

https://github.com/rails/rails/blob/cf6ff17e9a3c6c1139040b519a341f55f0be16cf/activerecord/lib/active_record/delegated_type.rb#L231-L234

指定されたrole(ここでは:entryable)に対してbelongs_toを設定しています。
polymorphic: trueが指定されていることを覚えておいてください。

次に以下のように

entry = Entry.create
entry.entryable = ModelC.new

entryable属性を設定しようとした場合の流れを見ていきます。

https://github.com/rails/rails/blob/3a5cb828882cb3e5553ed2a279ea2e9168c3f77a/activerecord/lib/active_record/associations/belongs_to_association.rb#L95-L107

belongs_to関連付けがされているので、上記の上書きする処理がよばれます。
また、polymorphic: trueオプションが指定されていため、104行目ではこのクラスではなく以下のActiveRecord::Associations::BelongsToPolymorphicAssociationreplace_keysが呼び出されます。

https://github.com/rails/rails/blob/3a5cb828882cb3e5553ed2a279ea2e9168c3f77a/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb#L25-L33

ここで ownerEntry のインスタンス(entry)です。
reflection.foreign_type によって "entryable_type" が取得され
entry["entryable_type"] に設定されます。

設定される値は record.class.polymorphic_name の結果です。

polymorphic_name の定義は以下にあります。

https://github.com/rails/rails/blob/3a5cb828882cb3e5553ed2a279ea2e9168c3f77a/activerecord/lib/active_record/inheritance.rb#L211-L213

base_class の名前を元にしているようなので、base_class を決めているコードを見てみます。

https://github.com/rails/rails/blob/3a5cb828882cb3e5553ed2a279ea2e9168c3f77a/activerecord/lib/active_record/inheritance.rb#L270-L284

  • 自身がActiveRecord::Base を継承していなければエラー
  • 親がActiveRecord::Base 継承していてかつ抽象クラスなら自身を base_class に設定
  • そうでなければ親のbase_class を base_class に設定

つまりActiveRecord::Baseを継承している最初の具象クラスの名前が設定されていそうです。

これによって ModelB を継承している ModelCentryable_typeModelB の名前が設定されているようでした。

なお、この動作はpolymorphic: trueによる影響なのでdelegated_typeに限定されるものではなさそうです。

継承の代わり

同じような振る舞いをする共有する方法については、継承の代わりにConcernを使用したり、抽象クラスを作成したりする方法が考えられると思います。

Concernを使用する

module CommonBehavior
  extend ActiveSupport::Concern

  included do
    has_one :entry, as: :entryable
    # 共通の振る舞いをここに定義
  end
end

class ModelB < ApplicationRecord
  include CommonBehavior
end

class ModelC < ApplicationRecord
  include CommonBehavior
end

機能毎に分けることができるので、必要に応じてモデル側で選択して取り込むことができそうです。
一方取り込む種類が多くなると把握が難しくなりそうです。

抽象クラスを使用する

class BaseModel < ApplicationRecord
  self.abstract_class = true

  has_one :entry, as: :entryable
  # 共通の振る舞いをここに定義
end

class ModelB < BaseModel
end

class ModelC < BaseModel
end

共通化の処理が抽象クラスに集約されるので、振る舞いがほとんど同じであれば見通しが良くなりそうです。
一方で1つのクラスに集約してしまう関係で、新しく別のモデルを追加する必要が発生した場合や、抽象モデルが肥大化した場合等は変更が辛くなりそうです。

上記以外にもモデル自体がごく小さなものであれば無理に共通化せずにそれぞれ個別に定義することも選択肢になるかと思います。

それぞれの比較

方法 メリット デメリット
Concern 機能を柔軟に追加できる 多用すると複雑になりやすい
抽象クラス 共通部分が一箇所にまとまる 肥大化すると変更が辛くなる
直接個別に定義 シンプルで分かりやすい 重複が発生しやすい

まとめ

  • 継承を使用しても~_typeの値は意図したものにならないため、注意が必要
  • ~_type の値が決まる流れは以下の通り
    1. ~_type の値を設定する時に関連付けするモデルのpolymorphic_nameが呼び出される
    2. polymorphic_namebase_classの名前を使用
    3. base_classは継承チェーンを遡ってActiveRecord::Base を継承している最初の具象クラスを返す
  • 同じような振る舞いを共有したい場合は、継承ではなくConcernや抽象クラス等を使用することで対応できる
SocialPLUS Tech Blog

Discussion