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)
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_manyassociation via.new+.save!(without theassociation 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_attributeinstead of[]=.polymorphic_integer_typeoverrides[]/[]=and the generated
*_typeaccessors to translate between the integer stored in thecolumn 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 nameString); the write side bypasses it (
_write_attribute), storing that String raw inan 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:
Minimal reproduction
Gemfile:bug_report.rb:Run with:
Actual output (Rails 7.1.6, polymorphic_integer_type 3.5.0)
Expected output (and actual output on Rails 7.0.10 / 6.1.7.10, unmodified script)
Root cause
ActiveRecord::Associations::CollectionAssociation#merge_target_lists— which runswhen a
has_manyis reloaded while the caller still holds references to recordsalready built into it — changed how it copies persisted values onto the in-memory
records:
The read side (
record[name]) is unchanged — it still goes throughPolymorphicIntegerType::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) tomem_record._write_attribute(name, ...), which bypasses the gem's override entirelyand writes the String directly into what ActiveRecord's schema believes is an
Integer column.
"Bar".to_i #=> 0, and0happens to becommentable_type'smapping for a different class (
"Foo"in the reproduction, whatever the mapping'szero-key is in a real app) — so the association silently repoints at the wrong
record type instead of raising.
Environment
handling, not SQL)