Skip to content

has_many association does not work with Rails 7.1+ #69

Description

@JanC89

We encountered below problem in our production app. I have had Claude help summarize and write a report.

Summary

When records are added to a has_many association via .new + .save! (without the
association ever being fully loaded), and something later triggers a reload of that
association while the caller still holds references to the objects it already built,
Rails 7.1 merges the freshly-queried rows into those same in-memory objects using
_write_attribute instead of []=.

polymorphic_integer_type overrides []/[]=
and the generated *_type accessors to translate between the integer stored in the
column and the class name, but never overrides _read_attribute/_write_attribute.
The read side of the merge goes through the gem's [] (returns the class name
String); the write side bypasses it (_write_attribute), storing that String raw in
an integer column, which ActiveRecord then casts to 0.

The database is written correctly. Only the in-memory copy the caller already had a
reference to is corrupted.

Confirmed:

  • Rails 7.1.6 + polymorphic_integer_type 3.5.0
  • Rails 7.1.6 + polymorphic_integer_type 3.3.0
  • Rails 7.0.10 + polymorphic_integer_type 3.2.2: does not reproduce
  • Rails 6.1.7.10 + polymorphic_integer_type 3.2.2: does not reproduce

Minimal reproduction

Gemfile:

source "https://rubygems.org"
gem "rails", "7.1.6"
gem "polymorphic_integer_type", "3.5.0"
gem "sqlite3"

bug_report.rb:

require "active_record"
require "polymorphic_integer_type"

ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")

ActiveRecord::Schema.define do
  create_table :posts, force: true

  create_table :comments, force: true do |t|
    t.integer :post_id
    t.integer :commentable_type
    t.integer :commentable_id
  end
end

class Post < ActiveRecord::Base
  has_many :comments
end

class Comment < ActiveRecord::Base
  include PolymorphicIntegerType::Extensions

  belongs_to :post
  belongs_to :commentable, polymorphic: { 0 => "Foo", 1 => "Bar" }, optional: true
end

post = Post.create!

c1 = post.comments.new
c1.commentable_type = 0 # "Foo"
c1.commentable_id = 1
c1.save!

c2 = post.comments.new
c2.commentable_type = 1 # "Bar"
c2.commentable_id = 1
c2.save!

puts "post.comments.loaded? before touching it again: #{post.comments.loaded?}"

# `post.comments.new` appends to the in-memory target but does NOT mark the
# association loaded, so the next call that needs the collection triggers a
# real reload (CollectionAssociation#find_target -> merge_target_lists),
# even though the caller (this script) still holds references to c1/c2.
post.comments.length

reloaded_c1, reloaded_c2 = post.comments.first(2)

puts
puts "reloaded_c2.equal?(c2)                          -> #{reloaded_c2.equal?(c2)} (same in-memory object)"
puts "reloaded_c2._read_attribute('commentable_type')  -> #{reloaded_c2._read_attribute('commentable_type').inspect} (expected 1)"
puts "reloaded_c2.commentable_type                     -> #{reloaded_c2.commentable_type.inspect} (expected \"Bar\")"
puts
puts "Comment.find(c2.id).commentable_type             -> #{Comment.find(c2.id).commentable_type.inspect} (DB is correct either way)"
puts

if reloaded_c2._read_attribute("commentable_type") == 1
  puts "PASS -- attribute survived the reload correctly"
else
  puts "FAIL -- BUG REPRODUCED: in-memory commentable_type was corrupted by the reload"
  exit 1
end

Run with:

bundle install
bundle exec ruby bug_report.rb

Actual output (Rails 7.1.6, polymorphic_integer_type 3.5.0)

post.comments.loaded? before touching it again: false

reloaded_c2.equal?(c2)                          -> true (same in-memory object)
reloaded_c2._read_attribute('commentable_type')  -> 0 (expected 1)
reloaded_c2.commentable_type                     -> "Foo" (expected "Bar")

Comment.find(c2.id).commentable_type             -> "Bar" (DB is correct either way)

FAIL -- BUG REPRODUCED: in-memory commentable_type was corrupted by the reload

Expected output (and actual output on Rails 7.0.10 / 6.1.7.10, unmodified script)

post.comments.loaded? before touching it again: false

reloaded_c2.equal?(c2)                          -> true (same in-memory object)
reloaded_c2._read_attribute('commentable_type')  -> 1 (expected 1)
reloaded_c2.commentable_type                     -> "Bar" (expected "Bar")

Comment.find(c2.id).commentable_type             -> "Bar" (DB is correct either way)

PASS -- attribute survived the reload correctly

Root cause

ActiveRecord::Associations::CollectionAssociation#merge_target_lists — which runs
when a has_many is reloaded while the caller still holds references to records
already built into it — changed how it copies persisted values onto the in-memory
records:

# activerecord 6.1.7.10 and 7.0.10 -- lib/active_record/associations/collection_association.rb
def merge_target_lists(persisted, memory)
  return persisted if memory.empty?

  persisted.map! do |record|
    if mem_record = memory.delete(record)
      ((record.attribute_names & mem_record.attribute_names) - mem_record.changed_attribute_names_to_save).each do |name|
        mem_record[name] = record[name]
      end
      mem_record
    else
      record
    end
  end

  persisted + memory.reject(&:persisted?)
end
# activerecord 7.1.6 -- same method, same file
def merge_target_lists(persisted, memory)
  return persisted if memory.empty?

  persisted.map! do |record|
    if mem_record = memory.delete(record)
      ((record.attribute_names & mem_record.attribute_names) - mem_record.changed_attribute_names_to_save - mem_record.class._attr_readonly).each do |name|
        mem_record._write_attribute(name, record[name])
      end
      mem_record
    else
      record
    end
  end

  persisted + memory.reject(&:persisted?)
end

The read side (record[name]) is unchanged — it still goes through
PolymorphicIntegerType::Extensions#[], which returns the mapped class name String.
The write side changed from mem_record[name] = ... (routes through the gem's
[]=, which maps the String back to the correct Integer) to
mem_record._write_attribute(name, ...), which bypasses the gem's override entirely
and writes the String directly into what ActiveRecord's schema believes is an
Integer column. "Bar".to_i #=> 0, and 0 happens to be commentable_type's
mapping for a different class ("Foo" in the reproduction, whatever the mapping's
zero-key is in a real app) — so the association silently repoints at the wrong
record type instead of raising.

Environment

  • Ruby 3.0.7
  • Rails / ActiveRecord 7.1.6 (bug present), 7.0.10 and 6.1.7.10 (bug absent)
  • polymorphic_integer_type 3.5.0 and 3.3.0 (bug present in both)
  • sqlite3 adapter (bug is adapter-independent — it's pure Ruby/ActiveRecord attribute
    handling, not SQL)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions