When you want one type of a polymorphic reference as its own association, the obvious approach silently ignores the type column:
class Activity < ActiveRecord::Base
belongs_to :item, polymorphic: true
belongs_to :comment, foreign_key: :item_id
end
comment reads item_id and ignores item_type, so it returns a comment even when the activity points at something else:
task = Task.find(5)
activity = Activity.create!(item: task) # item_type: "Task", item_id: 5
activity.comment # => Comment.find(5), an unrelated comment that just has the same id as the task
Joins have the same problem. Activity.joins(:comment) doesn’t check the type, so every activity whose item_id happens to match a comment id is joined to that comment:
INNER JOIN "comments" ON "comments"."id" = "activities"."item_id"
preload and eager_load attach the wrong comments the same way.
foreign_type can’t fix it because it’s only allowed with polymorphic: true. (Support for non-polymorphic joins on polymorphic belongs_to · Issue #50213 · rails/rails · GitHub asked for the same thing and went stale.)
Proposal: allow it.
belongs_to :comment, foreign_key: :item_id, foreign_type: :item_type, optional: true
comment then only resolves when item_type is Comment.polymorphic_name. The reader, preloading, eager loading, joins and where(comment: ...) all check it. Assigning a comment writes both columns, and counter caches and touch ignore other types. foreign_type keeps its meaning: the column that holds the type.
Implementation with tests: Allow foreign_type on a belongs_to that isn't polymorphic by sucicfilip · Pull Request #59720 · rails/rails · GitHub
where(comment: nil) matches item_id IS NULL only, not rows of another type, the same as where(item: nil) on the polymorphic belongs_to. where.missing(:comment) covers the other types.
Like any belongs_to, it’s required unless you pass optional: true, which you’ll want here since it’s nil for every other type.