【ActiveRecord】 ポリモーフィック関連の「~type」の値がどこで決定されるか
こんにちは、st-1985 です。
最近、ActiveRecord
で継承を使用したモデルの実装をしていた際に、~_type
に意図しないクラス名が設定される問題に遭遇しました。
この記事では、その経験と、ソースコードを追いかけて分かったことを共有したいと思います。
この記事の対象
-
ActiveRecord
でdelegated_type
やpolymorphic: 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
の定義を見てみます。
指定されたrole
(ここでは:entryable
)に対してbelongs_to
を設定しています。
polymorphic: true
が指定されていることを覚えておいてください。
次に以下のように
entry = Entry.create
entry.entryable = ModelC.new
entryable
属性を設定しようとした場合の流れを見ていきます。
belongs_to
関連付けがされているので、上記の上書きする処理がよばれます。
また、polymorphic: true
オプションが指定されていため、104行目ではこのクラスではなく以下のActiveRecord::Associations::BelongsToPolymorphicAssociation
の replace_keys
が呼び出されます。
ここで owner
は Entry
のインスタンス(entry
)です。
reflection.foreign_type
によって "entryable_type"
が取得され
entry["entryable_type"]
に設定されます。
設定される値は record.class.polymorphic_name
の結果です。
polymorphic_name
の定義は以下にあります。
base_class
の名前を元にしているようなので、base_class
を決めているコードを見てみます。
- 自身が
ActiveRecord::Base
を継承していなければエラー - 親が
ActiveRecord::Base
継承していてかつ抽象クラスなら自身を base_class に設定 - そうでなければ親のbase_class を base_class に設定
つまりActiveRecord::Base
を継承している最初の具象クラスの名前が設定されていそうです。
これによって ModelB
を継承している ModelC
の entryable_type
は ModelB
の名前が設定されているようでした。
なお、この動作は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
の値が決まる流れは以下の通り-
~_type
の値を設定する時に関連付けするモデルのpolymorphic_name
が呼び出される -
polymorphic_name
はbase_class
の名前を使用 -
base_class
は継承チェーンを遡ってActiveRecord::Base
を継承している最初の具象クラスを返す
-
- 同じような振る舞いを共有したい場合は、継承ではなくConcernや抽象クラス等を使用することで対応できる
Discussion