Rails 7 ensures has_one autosave association callbacks get called once


ActiveRecord AutosaveAssociation is a module that takes care of automatically saving associated records when their parent is saved. In addition to saving, it also destroys any associated records that were marked for destruction.

Association with autosave option defines several callbacks on your model (around_save, before_save, after_create, after_update). When saving a record, autosave adds callbacks to save its associations.

Since the associations can have similar callbacks for the inverse, endless loops could occur. To prevent these endless loops, the callbacks for has_many and belongs_to are made non-cyclic (methods that only execute once). This is implemented in the Ruby define_non_cyclic_method method and the same wasn’t used for the has_one callbacks.

Before

While has_one association callbacks didn’t result in endless loops, they could execute multiple times. For a bidirectional has_one with autosave enabled, the save_has_one_association gets called twice creating inconsistency.

class Supplier < ActiveRecord::Base
  has_one :account, autosave: true

  def save_has_one_association(reflection)
    @count ||= 0
    @count += 1 if reflection.name == :account
    super
  end
end

class Account < ActiveRecord::Base
  belongs_to :supplier, autosave: true
end

supplier = Supplier.new(name: "ACME")
supplier.build_account(account_number: "ACCN007")
supplier.save!
# this returns 2 instead of 1.
puts supplier.instance_variable_get(:@count)

After

Starting with Rails 7, these changes are added for the has_one autosave callbacks to be non-cyclic. By doing this the autosave callbacks are made more consistent for all 3 cases: has_many, has_one and belongs_to.

class Supplier < ActiveRecord::Base
  has_one :account, autosave: true

  def save_has_one_association(reflection)
    @count ||= 0
    @count += 1 if reflection.name == :account
    super
  end
end

class Account < ActiveRecord::Base
  belongs_to :supplier, autosave: true
end

supplier = Supplier.new(name: "ACME")
supplier.build_account(account_number: "ACCN007")
supplier.save!
# this returns 1
puts supplier.instance_variable_get(:@count)

How Rails fixed it?

In the previous versions of Rails, the callbacks were defined as follows:

if reflection.collection?
  define_non_cyclic_method(save_method) { save_collection_association(reflection) }
  after_create save_method
  after_update save_method
elsif reflection.has_one?
  define_method(save_method) { save_has_one_association(reflection) } unless method_defined?(save_method)
  after_create save_method
  after_update save_method
else
  define_non_cyclic_method(save_method) { throw(:abort) if save_belongs_to_association(reflection) == false }
  before_save save_method
end

The Rails team added define_non_cyclic_method under reflection.has_one? condition.

  if reflection.collection?
    around_save :around_save_collection_association
    define_non_cyclic_method(save_method) { save_collection_association(reflection) }
    after_create save_method
    after_update save_method
  elsif reflection.has_one?
    define_non_cyclic_method(save_method) { save_has_one_association(reflection) }
    after_create save_method
    after_update save_method
  else
    define_non_cyclic_method(save_method) { throw(:abort) if save_belongs_to_association(reflection) == false }
    before_save save_method
  end