Save (restore deleted) AR object without triggering Rails' AR lifecycle callbacks?

59 Views Asked by At

Although this question is caused by the PaperTrail gem, it's also applicable to the Rails' ActiveRecord in general.

TL;DR:

How do I save/create the new ActiveRecord object without triggering the ActiveRecord lifecycle callbacks?

NTL;R:

When I reify the deleted object from PaperTrail::Version backup, it, essentially, initialises a new new AR object and assigns it the attribute values from the version data.

When I then want to restore (persist) that object in database, Rails runs all the normal callbacks it would run when creating a new record, and, specifically, after_create. If any of the callbacks change the object's state we end up with an object, that is not the same as it was in the Version.

For a very simplified example (a lot of other things are happening in these callbacks), there is a Payout model with a serialised protocol field. Every change to the record's state is logged in that field:

  • after_create adds "%{timestamp} - Payout requested"
  • before_update adds "%{timestamp} - Payout status changed from 'requested' to 'processing'"
  • another before_update adds "%{timestamp} - Payout status changed from 'processing' to 'complete'"
  • before_destroy finally adds "%{timestamp} - Payout deleted by user 'whodunnit'"

Later, when I restore the Payout record from Version, it contains all the protocol entries, AND then adds to the end of the protocol one more entry, saying "%{timestamp} - Payout requested".

...you can imagine the level of WTFs when the accountant sees this...

Now, one of the ways to mitigate this is to add an instance variable flag, and/or a bunch of checks in the callbacks, and so on... But when you need to do that in a few dozens of models, it quickly becomes a fugly mess.

Therefore, my question is: how do I save/restore the record from PaperTrail in exactly the same state that it was in when the PaperTrail version was created, without triggering the ActiveRecord lifecycle callbacks? (and without hacking the Rails' internals too much). Ideally, this will be a concern that is included in all necessary models by a single line of code, hence I'm looking for a universal-ish solution.

UPDATE: save's functionality is needed to save the associated nested records, thus "update_columns" and plain insert of raw values is not really an option. As a last resort - maybe, but only if there is absolutely no other way.

1

There are 1 best solutions below

1
mechnicov On

Why don't use raw SQL to insert recovered data

Let's assume some Payout with payout_id was deleted

# Somehow find needed version
version =
  PaperTrail::Version.where(item_type: "Payout", item_id: payout_id).last

# Instantiate new Payout with all attributes of deleted one
deleted_payout = version.reify

# Use single SQL query without callbacks and instantiating model
Payout.insert(deleted_payout.attributes)

It's also possible to skip or even reset callbacks, in this case instead of insert, use usual save

Payout.skip_callback(:create, :after, :save_protocol_callback_name)
deleted_payout.save

That's not exactly the answer to the question, but probably it's not good idea to use callbacks in general

Rails callbacks seem like a cool feature until it turns into spaghetti, especially if you have different flows for different business operations (for example, record update by admin, by moderator and by user can be different)

To avoid such things, it is worth using some wrappers for operations where you do things similar to callbacks. It can be some gems like dry-rb or trailblazer, it can be PORO

For example (pseudocode)

class UpdatePayout
  def self.call(payout_id, **attributes)
    new(payout_id, **attributes).call
  end

  def initialize(payout_id, **attributes)
    @payout = Payout.find(payout_id)
    @attributes = attributes
  end

  def call
    @payout.with_lock do
      old_status = @payout.status
      new_status = @attributes[:status]
      protocol = @payout.protocol

      if old_status != new_status
        protocol << "#{Time.now} status changed from #{old_status} to #{new_status}"
      end

      @protocol.update!(@attributes, protocol:)
    end
  end
end

In this case, you will not pollute the models with callbacks and run your operations where needed

There will also be no problems when trying to recover data from papertrail