Search code examples
ruby-on-railsactiverecordrails-activerecordruby-on-rails-7rails7

Getting "original" object during a before_add callback in ActiveRecord (Rails 7)


I'm in the process of updating a project to use Ruby 3 and Rails 7. I'm running into a problem with some code that was working before, but isn't now. Here's (I think) the relevant parts of the code.

class Dataset < ActiveRecord::Base
  has_and_belongs_to_many :tags, :autosave => true,
    :before_add => ->(owner, change){ owner.send(:on_flag_changes, :before_add, change) }

  before_save :summarize_changes

  def on_flag_changes(method, tag)
    before = tags.map(&:id)
    after = before + [tag.id]

    record_change('tags', before, after)
  end

  def record_change(field, before_val, after_val)
    reset_changes

    before_val = @change_hash[field][0] if @change_hash[field]
    if before_val.class_method_defined? :sort
      before_val = before_val.sort unless before_val.blank?
      after_val = after_val.sort unless after_val.blank?
    end
    
    @change_hash[field] = [before_val, after_val]
  end

  reset_changes
    if @change_hash.nil?
      @change_notes = {}
      @change_hash = {
        tags: [tags.map(&:id), :undefined]
      }
    end
  end

  def has_changes_to_save?
    super || !change_hash.reject { |_, v| v[1] == :undefined }.blank?
  end

  def changes_to_save
    super.merge(change_hash.reject { |_, v| v[0] == v[1] || v[1] == :undefined })
  end

  def summarize_changes
    critical_fields = %w[ tags ]

    @change_notes = changes_to_save.keep_if { |key, _value| critical_fields.include? key } if has_changes_to_save?
    
    self.critical_change = true unless @change_notes.blank?
end

There are more fields for this class, and some attr_accessors but the reason I'm doing it this way is because the tags list can change, which may not necessarily trigger a change in the default "changes_to_save" list. This will allow us to track if the tags have changed, and set the "critical_change" flag (also part of Dataset) if they do.

In previous Rails instances, this worked fine. But since the upgrade, it's failing. What I'm finding is that the owner passed into the :before_add callback is NOT the same object as the one being passed into the before_save callback. This means that in the summarize_changes method, it's not seeing the changes to the @change_hash, so it's never setting the critical_change flag like it should.

I'm not sure what changed between Rails 6 and 7 to cause this, but I'm trying to find a way to get this to work properly; IE, if something says dataset.tags = [tag1, tag2], when tag1 was previously the only association, then dataset.save should result in the critical_change flag being set.

I hope that makes sense. I'm hoping this is something that is an easy fix, but so far my looking through the Rails 7 documentations has not given me the information I need. (it may go without saying that @change_notes and @change_hash are NOT persisted in the database; they are there just to track changes prior to saving to know if the critical_change flag should be set.

Thanks!


Solution

  • Turns out in my case there was some weird caching going on; I'd forgotten to mention an "after_initialize" callback that was calling the reset method, but for some reason at the time it makes this call, it wasn't the same object as actually got loaded, but some association caching was going on with tags (it was loading the tags association with the "initialized" record, and it was being cached with the "final" record, so it was confusing some of the code).

    Removing the tags bit from the reset method, and having it initialize the tag state the first time it tries to modify tags solved the problem. Not particularly fond of the solution, but it works, and that's what I needed for now.