Allow foreign_type on a non-polymorphic belongs_to

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.

The ID part of a polymorphic association is only meant to be used along with its corresponding *_type column. It’s not meant to additionally be used in a separate belongs_to. If you need this kind of thing then although it might feel like duplication, add an additional foreign key column (in this case comment_id) instead of trying to rely on using just one part of the polymorphic key, that is, the part found in the item_id column.

My goal is the opposite of a second column: store only item_type + item_id, but expose them as if the model had separate comment_id, task_id, … columns. Today it has a real comment_id and I want to move it to the polymorphic pair, so it can point at more types, without an API change. Serializers, includes and filters are built on associations, so each type has to stay an association:

belongs_to :item, polymorphic: true
belongs_to :comment, foreign_key: :item_id, foreign_type: :item_type, optional: true
belongs_to :task, foreign_key: :item_id, foreign_type: :item_type, optional: true

Keeping comment_id next to the polymorphic pair means two references that have to be written together and kept in sync, which is what the migration is meant to remove. With foreign_type, comment keeps preloading, eager loading, joining and querying while reading only from the polymorphic columns.