1

I'm trying to use ActiveModel::Dirty/ActiveRecord::AttributeMethods::Dirty in views (e.g. in update.js.erb) this way:

<% if @product.saved_change_to_attribute?(:name) %>
  alert("Name changed!")
<% end %>

In my controller I have:

class ProductsController < ApplicationController
  def update
    @product = Product.find(params[:id])

    respond_to do |format|
      if @product.update(product_params)
        format.js { render(:action => :show) }
      else
        format.js { render(:action => :update) }
      end
    end
  end

  private

  def product_params
    params.require(:product).permit(:price)
  end
end

In my model I have:

class Product < ApplicationRecord
  after_update :something_that_clears_changed_attributes

  private

  def something_that_clears_changed_attributes
    ...
    self.reload
  end
end

In the above example, the alert("Name changed!") would be never fired.

In fact, it seems that if during the "update flow" (within the controller) there are "after callbacks" (at model level) that reload the object or further update or save it, then you cannot rely anymore on Dirty. That is, Dirty methods can return "unexpected" values because the object is "manipulated" during the flow.

This would be the case for models that use gems that reload, update or save the object multiple times within callbacks, and so "invalidate" the Dirty model during the flow (even the attribute_before_last_save method in Rails 5 would return "unexpected" values).

To solve the issue, you could use the first example in this post (by keeping – before update – the changing attributes within a variable for later use) but, perhaps, there is a better way to use Dirty models.

Any idea on how to rely on Dirty models when during the flow there are multiple reloads, updates, saves or others that clear changed attributes?

Backo
  • 18,291
  • 27
  • 103
  • 170

1 Answers1

0

If you're looking to reference previous changes to ActiveRecord objects (after the changes have persisted and the object is reloaded), I recommend creating a separate table (e.g. product_dirties) to track said changes. Let's use your Product model as an example:

First, you'll want to create a product_dirties table

create_table :product_dirties do |t|
  t.references :product,      index: true, foreign_key: true
  t.jsonb      :modifications
end

Then, add the ProductDirty model

class ProductDirty < ApplicationRecord
  belongs_to :product, inverse_of: :product_dirties

  validates_presence_of :product, :modifications
end

And update your Product model to include the new association and a callback to create dirty records when changes are made:

class Product < ApplicationRecord
  has_many :product_dirties, inverse_of: :product

  before_save :create_dirty_record, if: -> { changed? }

  private

  def create_dirty_record
    # modifications will be saved in this format: {"name"=>"new name here!"}
    attribute_changes = ActiveSupport::HashWithIndifferentAccess[self.changed.map{ |attr| [attr, self.method(attr).call] }]

    if attribute_changes.present?
      ProductDirty.find_or_create_by(product: self).update_attribute(:modifications, attribute_changes)
    end

    self.restore_attributes # <- add this if you'd like to revert the changes to the product and keep them separate in the `product_dirties` table
  end
end

You could then add a method to your Product model that does a lookup for changes. We added an apply_dirty_record method (see below) to our parent model (e.g. Product) since we don't actually persist the changes (see the note next to self.restore_attributes above).

def apply_dirty_record
  dirty_record = ProductDirty.find_by(product: self)
  self.assign_attributes(dirty_record.modifications) if dirty_record
end
vich
  • 11,836
  • 13
  • 49
  • 66
  • Thanks for your reply, but creating a dedicated database table seems a too extreme/hacky solution to solve the issue. – Backo Jan 18 '19 at 09:33
  • No problem! It really depends on your requirements. Disagree in terms of it being hacky - it's a common enough use case (e.g. [Papertrail](https://github.com/paper-trail-gem/paper_trail)). – vich Jan 18 '19 at 18:31
  • The fact is that I'm not looking to reference previous changes to ActiveRecord objects (after the changes have persisted and the object is reloaded). I'm looking to reference changes in a "update flow" (within/during `@product.update(product_params)`). – Backo Jan 21 '19 at 00:36