Skip to content

Commit 16f1222

Browse files
authored
Merge pull request rails#49847 from joshuay03/duplicate-callbacks-when-child-autosaves-parent
[Fix rails#48688] Duplicate callback execution when child autosaves parent with `has_one` and `belongs_to`
2 parents 78ce299 + 154f4a4 commit 16f1222

File tree

4 files changed

+138
-47
lines changed

4 files changed

+138
-47
lines changed

activerecord/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
* Fix duplicate callback execution when child autosaves parent with `has_one` and `belongs_to`.
2+
3+
Before, persisting a new child record with a new associated parent record would run `before_validation`,
4+
`after_validation`, `before_save` and `after_save` callbacks twice.
5+
6+
Now, these callbacks are only executed once as expected.
7+
8+
*Joshua Young*
9+
110
* `ActiveRecord::Encryption::Encryptor` now supports a `:compressor` option to customize the compression algorithm used.
211

312
```ruby

activerecord/lib/active_record/autosave_association.rb

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,12 @@ def nested_records_changed_for_autosave?
317317
def validate_single_association(reflection)
318318
association = association_instance_get(reflection.name)
319319
record = association && association.reader
320-
association_valid?(association, record) if record && (record.changed_for_autosave? || custom_validation_context?)
320+
return unless record && (record.changed_for_autosave? || custom_validation_context?)
321+
322+
inverse_belongs_to_association = inverse_belongs_to_association_for(reflection, record)
323+
return if inverse_belongs_to_association && inverse_belongs_to_association.updated?
324+
325+
association_valid?(association, record)
321326
end
322327

323328
# Validate the associated records if <tt>:validate</tt> or
@@ -429,36 +434,44 @@ def save_collection_association(reflection)
429434
def save_has_one_association(reflection)
430435
association = association_instance_get(reflection.name)
431436
record = association && association.load_target
437+
return unless record && !record.destroyed?
432438

433-
if record && !record.destroyed?
434-
autosave = reflection.options[:autosave]
439+
inverse_belongs_to_association = inverse_belongs_to_association_for(reflection, record)
440+
return if inverse_belongs_to_association && inverse_belongs_to_association.updated?
435441

436-
if autosave && record.marked_for_destruction?
437-
record.destroy
438-
elsif autosave != false
439-
primary_key = Array(compute_primary_key(reflection, self)).map(&:to_s)
440-
primary_key_value = primary_key.map { |key| _read_attribute(key) }
442+
autosave = reflection.options[:autosave]
441443

442-
if (autosave && record.changed_for_autosave?) || _record_changed?(reflection, record, primary_key_value)
443-
unless reflection.through_reflection
444-
foreign_key = Array(reflection.foreign_key)
445-
primary_key_foreign_key_pairs = primary_key.zip(foreign_key)
444+
if autosave && record.marked_for_destruction?
445+
record.destroy
446+
elsif autosave != false
447+
primary_key = Array(compute_primary_key(reflection, self)).map(&:to_s)
448+
primary_key_value = primary_key.map { |key| _read_attribute(key) }
446449

447-
primary_key_foreign_key_pairs.each do |primary_key, foreign_key|
448-
association_id = _read_attribute(primary_key)
449-
record[foreign_key] = association_id unless record[foreign_key] == association_id
450-
end
451-
association.set_inverse_instance(record)
452-
end
450+
if (autosave && record.changed_for_autosave?) || _record_changed?(reflection, record, primary_key_value)
451+
unless reflection.through_reflection
452+
foreign_key = Array(reflection.foreign_key)
453+
primary_key_foreign_key_pairs = primary_key.zip(foreign_key)
453454

454-
saved = record.save(validate: !autosave)
455-
raise ActiveRecord::Rollback if !saved && autosave
456-
saved
455+
primary_key_foreign_key_pairs.each do |primary_key, foreign_key|
456+
association_id = _read_attribute(primary_key)
457+
record[foreign_key] = association_id unless record[foreign_key] == association_id
458+
end
459+
association.set_inverse_instance(record)
457460
end
461+
462+
saved = record.save(validate: !autosave)
463+
raise ActiveRecord::Rollback if !saved && autosave
464+
saved
458465
end
459466
end
460467
end
461468

469+
def inverse_belongs_to_association_for(reflection, record)
470+
reflection.inverse_of &&
471+
reflection.inverse_of.belongs_to? &&
472+
record.association(reflection.inverse_of.name)
473+
end
474+
462475
# If the record is new or it has changed, returns true.
463476
def _record_changed?(reflection, record, key)
464477
record.new_record? ||
@@ -480,7 +493,6 @@ def inverse_polymorphic_association_changed?(reflection, record)
480493
return false unless reflection.inverse_of&.polymorphic?
481494

482495
class_name = record._read_attribute(reflection.inverse_of.foreign_type)
483-
484496
reflection.active_record != record.class.polymorphic_class_for(class_name)
485497
end
486498

activerecord/test/cases/autosave_association_test.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,26 @@ def test_callbacks_firing_order_on_save
304304
assert_equal [false, false, false, false], eye.after_save_callbacks_stack
305305
end
306306

307+
def test_callbacks_on_child_when_parent_autosaves_child
308+
eye = Eye.create(iris: Iris.new)
309+
assert_equal 1, eye.iris.before_validation_callbacks_counter
310+
assert_equal 1, eye.iris.before_create_callbacks_counter
311+
assert_equal 1, eye.iris.before_save_callbacks_counter
312+
assert_equal 1, eye.iris.after_validation_callbacks_counter
313+
assert_equal 1, eye.iris.after_create_callbacks_counter
314+
assert_equal 1, eye.iris.after_save_callbacks_counter
315+
end
316+
317+
def test_callbacks_on_child_when_child_autosaves_parent
318+
iris = Iris.create(eye: Eye.new)
319+
assert_equal 1, iris.before_validation_callbacks_counter
320+
assert_equal 1, iris.before_create_callbacks_counter
321+
assert_equal 1, iris.before_save_callbacks_counter
322+
assert_equal 1, iris.after_validation_callbacks_counter
323+
assert_equal 1, iris.after_create_callbacks_counter
324+
assert_equal 1, iris.after_save_callbacks_counter
325+
end
326+
307327
def test_foreign_key_attribute_is_not_set_unless_changed
308328
eye = Eye.create!(iris_with_read_only_foreign_key_attributes: { color: "honey" })
309329
assert_nothing_raised do

activerecord/test/models/eye.rb

Lines changed: 75 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,35 +19,85 @@ class Eye < ActiveRecord::Base
1919
after_update :trace_after_update2, if: :iris
2020
after_save :trace_after_save2, if: :iris
2121

22-
def trace_after_create
23-
(@after_create_callbacks_stack ||= []) << !iris.persisted?
24-
end
25-
alias trace_after_create2 trace_after_create
26-
27-
def trace_after_update
28-
(@after_update_callbacks_stack ||= []) << iris.has_changes_to_save?
29-
end
30-
alias trace_after_update2 trace_after_update
31-
32-
def trace_after_save
33-
(@after_save_callbacks_stack ||= []) << iris.has_changes_to_save?
34-
end
35-
alias trace_after_save2 trace_after_save
36-
37-
has_one :iris_with_read_only_foreign_key, class_name: "IrisWithReadOnlyForeignKey", foreign_key: :eye_id
38-
accepts_nested_attributes_for :iris_with_read_only_foreign_key
39-
40-
before_save :set_iris_with_read_only_foreign_key_color_to_blue, if: -> {
41-
iris_with_read_only_foreign_key && @override_iris_with_read_only_foreign_key_color
42-
}
43-
44-
def set_iris_with_read_only_foreign_key_color_to_blue
45-
iris_with_read_only_foreign_key.color = "blue"
46-
end
22+
private
23+
def trace_after_create
24+
(@after_create_callbacks_stack ||= []) << !iris.persisted?
25+
end
26+
alias trace_after_create2 trace_after_create
27+
28+
def trace_after_update
29+
(@after_update_callbacks_stack ||= []) << iris.has_changes_to_save?
30+
end
31+
alias trace_after_update2 trace_after_update
32+
33+
def trace_after_save
34+
(@after_save_callbacks_stack ||= []) << iris.has_changes_to_save?
35+
end
36+
alias trace_after_save2 trace_after_save
37+
38+
public
39+
has_one :iris_with_read_only_foreign_key, class_name: "IrisWithReadOnlyForeignKey", foreign_key: :eye_id
40+
accepts_nested_attributes_for :iris_with_read_only_foreign_key
41+
42+
before_save :set_iris_with_read_only_foreign_key_color_to_blue, if: -> {
43+
iris_with_read_only_foreign_key && @override_iris_with_read_only_foreign_key_color
44+
}
45+
46+
private
47+
def set_iris_with_read_only_foreign_key_color_to_blue
48+
iris_with_read_only_foreign_key.color = "blue"
49+
end
4750
end
4851

4952
class Iris < ActiveRecord::Base
53+
attr_reader :before_validation_callbacks_counter
54+
attr_reader :before_create_callbacks_counter
55+
attr_reader :before_save_callbacks_counter
56+
57+
attr_reader :after_validation_callbacks_counter
58+
attr_reader :after_create_callbacks_counter
59+
attr_reader :after_save_callbacks_counter
60+
5061
belongs_to :eye
62+
63+
before_validation :update_before_validation_counter
64+
before_create :update_before_create_counter
65+
before_save :update_before_save_counter
66+
67+
after_validation :update_after_validation_counter
68+
after_create :update_after_create_counter
69+
after_save :update_after_save_counter
70+
71+
private
72+
def update_before_validation_counter
73+
@before_validation_callbacks_counter ||= 0
74+
@before_validation_callbacks_counter += 1
75+
end
76+
77+
def update_before_create_counter
78+
@before_create_callbacks_counter ||= 0
79+
@before_create_callbacks_counter += 1
80+
end
81+
82+
def update_before_save_counter
83+
@before_save_callbacks_counter ||= 0
84+
@before_save_callbacks_counter += 1
85+
end
86+
87+
def update_after_validation_counter
88+
@after_validation_callbacks_counter ||= 0
89+
@after_validation_callbacks_counter += 1
90+
end
91+
92+
def update_after_create_counter
93+
@after_create_callbacks_counter ||= 0
94+
@after_create_callbacks_counter += 1
95+
end
96+
97+
def update_after_save_counter
98+
@after_save_callbacks_counter ||= 0
99+
@after_save_callbacks_counter += 1
100+
end
51101
end
52102

53103
class IrisWithReadOnlyForeignKey < Iris

0 commit comments

Comments
 (0)