Search code examples
ruby-on-railsrubyactiverecord

saved_changes is empty after persisting changes on a Rails model


We have a Rails 6.1 application running an after_commit callback to look into saved_changes value to run some background workers. But the issue is saved_changes returns empty if a model has accepts_nested_attributes_for. It is not only after_commit other callbacks like before_save, and around_save also return empty saved_changes value.

For example.

on models/profile.rb

after_commit :run_worker, on: :update

belongs_to :user
accepts_nested_attributes_for :user

In this case saved_changes is {} but if I remove the accepts_nested_attributes_for it works fine and gives me the changed value.

It is a known issue caused by double saves as mentioned on this GH issue. The workaround for 7.1 is adding config on the application.rb

config.active_record.run_commit_callbacks_on_first_saved_instances_in_transaction = true

This default is for Rails 7.1 and does not work for 6.1. Has someone else run through this issue and has a workaround?


Solution

  • It is not only after_commit other callbacks like before_save, and around_save also return empty saved_changes value.

    saved_change is only present after a record is saved, so it is expected that it's empty in before_save callback, and it should be empty in around_save callback as well until the record is saved there.

    Before a record is saved the dirty state is stored in changes object (not saved_changes).


    To address your problem I would suggest to save changes in a custom attribute to use later in after_save/after_commit callbacks. Something like this:

    attr_accessor :my_saved_changes
    
    before_save :save_dirty_state
    
    def save_dirty_state
      self.my_saved_changes = changes
    end
    

    UPDATED SUGGESTION GIVEN THE COMMENT BELOW:

    If I understand correctly those 2 saves happen inside 1 transaction, so after_commit callback will be called just once. You can probably rely on that and only set my_saved_changes attribute from my proposal only if it's not set yet (and then reset it in after_commit callback):

    attr_accessor :my_saved_changes
    
    before_save :save_dirty_state
    # Have this callback to execute the last:
    after_commit :reset_dirty_state
    
    def save_dirty_state
      # Note the change of assignment here ('||=' operator instead of '=')
      self.my_saved_changes ||= changes
    end
    
    def reset_dirty_state
      self.my_saved_changes = nil
    end