Skip to content

Commit 332e760

Browse files
committed
Fix circular autosave: true
Use a variable local to the `save_collection_association` method in `activerecord/lib/active_record/autosave_association.rb`, instead of an instance variable. Prior to this PR, when there was a circular series of `autosave: true` associations, the callback for a `has_many` association was run while another instance of the same callback on the same association hadn't finished running. When control returned to the first instance of the callback, the instance variable had changed, and subsequent associated records weren't saved correctly. Specifically, the ID field for the `belongs_to` corresponding to the `has_many` was `nil`. Remove unnecessary test and comments. Fixes rails#28080.
1 parent bd139a5 commit 332e760

File tree

7 files changed

+75
-2
lines changed

7 files changed

+75
-2
lines changed

activerecord/CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
1+
* Fix circular `autosave: true`
2+
3+
Use a variable local to the `save_collection_association` method in
4+
`activerecord/lib/active_record/autosave_association.rb`, instead of an
5+
instance variable.
6+
7+
Prior to this PR, when there was a circular series of `autosave: true`
8+
associations, the callback for a `has_many` association was run while
9+
another instance of the same callback on the same association hadn't
10+
finished running. When control returned to the first instance of the
11+
callback, the instance variable had changed, and subsequent associated
12+
records weren't saved correctly. Specifically, the ID field for the
13+
`belongs_to` corresponding to the `has_many` was `nil`.
14+
15+
Fixes #28080.
16+
17+
*Larry Reid*
118
* Don't impose primary key order if limit() has already been supplied.
219

320
Fixes #23607

activerecord/lib/active_record/autosave_association.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -382,10 +382,14 @@ def save_collection_association(reflection)
382382
if association = association_instance_get(reflection.name)
383383
autosave = reflection.options[:autosave]
384384

385+
# By saving the instance variable in a local variable, we make the
386+
# whole callback re-entrant, fixing issue #28080
387+
new_record_before_save = @new_record_before_save
388+
385389
# reconstruct the scope now that we know the owner's id
386390
association.reset_scope
387391

388-
if records = associated_records_to_validate_or_save(association, @new_record_before_save, autosave)
392+
if records = associated_records_to_validate_or_save(association, new_record_before_save, autosave)
389393
if autosave
390394
records_to_destroy = records.select(&:marked_for_destruction?)
391395
records_to_destroy.each { |record| association.destroy(record) }
@@ -397,7 +401,7 @@ def save_collection_association(reflection)
397401

398402
saved = true
399403

400-
if autosave != false && (@new_record_before_save || record.new_record?)
404+
if autosave != false && (new_record_before_save || record.new_record?)
401405
if autosave
402406
saved = association.insert_record(record, false)
403407
elsif !reflection.nested?

activerecord/test/cases/associations/has_many_through_associations_test.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
require "models/user"
3434
require "models/family"
3535
require "models/family_tree"
36+
require "models/section"
37+
require "models/seminar"
38+
require "models/session"
3639

3740
class HasManyThroughAssociationsTest < ActiveRecord::TestCase
3841
fixtures :posts, :readers, :people, :comments, :authors, :categories, :taggings, :tags,
@@ -1403,6 +1406,19 @@ def self.name; "PostWithNestedHasManyThrough"; end
14031406
assert_equal [subscription2], post.subscriptions.to_a
14041407
end
14051408

1409+
def test_circular_autosave_association_correctly_saves_multiple_records
1410+
cs180 = Seminar.new(name: "CS180")
1411+
fall = Session.new(name: "Fall")
1412+
sections = [
1413+
cs180.sections.build(short_name: "A"),
1414+
cs180.sections.build(short_name: "B"),
1415+
]
1416+
fall.sections << sections
1417+
fall.save!
1418+
fall.reload
1419+
assert_equal sections, fall.sections.sort_by(&:id)
1420+
end
1421+
14061422
private
14071423
def make_model(name)
14081424
Class.new(ActiveRecord::Base) { define_singleton_method(:name) { name } }

activerecord/test/models/section.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# frozen_string_literal: true
2+
3+
class Section < ActiveRecord::Base
4+
belongs_to :session, inverse_of: :sections, autosave: true
5+
belongs_to :seminar, inverse_of: :sections, autosave: true
6+
end

activerecord/test/models/seminar.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# frozen_string_literal: true
2+
3+
class Seminar < ActiveRecord::Base
4+
has_many :sections, inverse_of: :seminar, autosave: true, dependent: :destroy
5+
has_many :sessions, through: :sections
6+
end

activerecord/test/models/session.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# frozen_string_literal: true
2+
3+
class Session < ActiveRecord::Base
4+
has_many :sections, inverse_of: :session, autosave: true, dependent: :destroy
5+
has_many :seminars, through: :sections
6+
end

activerecord/test/schema/schema.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,24 @@
772772
t.integer :lock_version, default: 0
773773
end
774774

775+
disable_referential_integrity do
776+
create_table :seminars, force: :cascade do |t|
777+
t.string :name
778+
end
779+
780+
create_table :sessions, force: :cascade do |t|
781+
t.date :start_date
782+
t.date :end_date
783+
t.string :name
784+
end
785+
786+
create_table :sections, force: :cascade do |t|
787+
t.string :short_name
788+
t.belongs_to :session, foreign_key: true
789+
t.belongs_to :seminar, foreign_key: true
790+
end
791+
end
792+
775793
create_table :shape_expressions, force: true do |t|
776794
t.string :paint_type
777795
t.integer :paint_id

0 commit comments

Comments
 (0)