🍒

RailsのActiveRecordでポリモーフィック関連を別のモデルからhas_manyする

2022/12/26に公開

まえおき

  • Article
  • ArticleSnapshot
    • article_id
  • Like
    • target_type
    • target_id

ArticleとLikeであれば話は単純だ。

class Article < ActiveRecord::Base
  has_many :likes, as: :target
end
class Like < ActiveRecord::Base
  belongs_to :target, polymorphic: true
end

ただ、諸般の事情によりArticleはレコード削除してしまったが、そのスナップショットであるArticleSnapshotからどうしてもLikeをたどりたい。

article_snapshot: ArticleSnapshot(id: 1111, article_id: 123) があったときに、article_snapshot.likes すると

SELECT * FROM likes
WHERE target_type = 'Article' AND target_id = 123

こういうクエリが叩かれるようにしたい。

Railsのソースななめよみ

まずは、当然うまくいかないのだが、以下のようなアソシエーションを定義して動作を見てみる。

class ArticleSnapshot < ActiceRecord::Base
  has_many :likes, as: :target
end

has_one, has_manyなどは、builderでメソッドが定義される。

https://github.com/rails/rails/blob/8015c2c2cf5c8718449677570f372ceb01318a32/activerecord/lib/active_record/associations/builder/has_one.rb

article_snapshot.association(:likes) のようにRailsコンソールで叩くと、Associationクラスの子クラスが見える。

ownerにarticle_snapshot, reflectionにHasManyReflection のようなクラスがある。

article_snapshot.association(:likes).reader と叩くと、article_snapshot.likesを叩いたときと同じ結果が返ってくるので、Associationクラスの #reader メソッドを叩いている際にクエリが作られるっぽい。

どうも find_target というメソッドがクエリを作る際のスコープを指定してる場所のようだ。

https://github.com/rails/rails/blob/8015c2c2cf5c8718449677570f372ceb01318a32/activerecord/lib/active_record/associations/association.rb#L218

binds = AssociationScope.get_bind_values(owner, reflection.chain)

ここで、ポリモーフィック関連の [1111, 'ArticleSnapshot'] が作られており、それがそのまま

SELECT * FROM likes
WHERE target_type = 'ArticleSnapshot' AND target_id = 1111

というクエリを発行するもととなっている。

1111 は、Reflectionクラスの #join_id_for これは primary_keyオプションが指定されていたらそれが使われるようだ。試しに

class ArticleSnapshot < ActiceRecord::Base
  has_many :likes, as: :target, primary_key: :article_id
end

こうしてみると、get_bind_valuesの返り値は [123, 'ArticleSnapshot'] になる。

問題はtarget_typeの方だが、これは owner.class.polymorphic_name を参照している。つまり、ArticleSnapshot.polymorphic_name がそのまま使われている。

小規模で影響範囲が小さいプロダクトであれば、polymorphic_nameをオーバーライドしてしまうということも考えうるが、has_many1個のためだけにクラス名定義を変えてしまうのはちょっとやりすぎだろう。

結論。

ポリモーフィック関連をたどるasは使えない。愚直にやるしかない。

class ArticleSnapshot < ActiceRecord::Base
  has_many :likes,
    -> { where(target_type: 'Article') },
    foreign_key: :target_id,
    primary_key: :article_id
end

ここまでやれば、ベースのスコープで WHERE target_type = 'Article' AND target_id = ? 相当のものが作られ、get_bind_valuesではarticle_idの123が使われる。

Discussion