paper-trail-gem--paper_trail/lib/paper_trail/record_trail.rb

301 lines
9.8 KiB
Ruby

# frozen_string_literal: true
require "paper_trail/events/create"
require "paper_trail/events/destroy"
require "paper_trail/events/update"
module PaperTrail
# Represents the "paper trail" for a single record.
class RecordTrail
def initialize(record)
@record = record
end
# Invoked after rollbacks to ensure versions records are not created for
# changes that never actually took place. Optimization: Use lazy `reset`
# instead of eager `reload` because, in many use cases, the association will
# not be used.
def clear_rolled_back_versions
versions.reset
end
# Invoked via`after_update` callback for when a previous version is
# reified and then saved.
def clear_version_instance
@record.send("#{@record.class.version_association_name}=", nil)
end
# Is PT enabled for this particular record?
# @api private
def enabled?
PaperTrail.enabled? &&
PaperTrail.request.enabled? &&
PaperTrail.request.enabled_for_model?(@record.class)
end
# Returns true if this instance is the current, live one;
# returns false if this instance came from a previous version.
def live?
source_version.nil?
end
# Returns the object (not a Version) as it became next.
# NOTE: if self (the item) was not reified from a version, i.e. it is the
# "live" item, we return nil. Perhaps we should return self instead?
def next_version
subsequent_version = source_version.next
subsequent_version ? subsequent_version.reify : @record.class.find(@record.id)
rescue StandardError # TODO: Rescue something more specific
nil
end
# Returns who put `@record` into its current state.
#
# @api public
def originator
(source_version || versions.last).try(:whodunnit)
end
# Returns the object (not a Version) as it was most recently.
#
# @api public
def previous_version
(source_version ? source_version.previous : versions.last).try(:reify)
end
def record_create
return unless enabled?
build_version_on_create(in_after_callback: true).tap do |version|
version.save!
# Because the version object was created using version_class.new instead
# of versions_assoc.build?, the association cache is unaware. So, we
# invalidate the `versions` association cache with `reset`.
versions.reset
end
end
# PT-AT extends this method to add its transaction id.
#
# @api private
def data_for_create
{}
end
# `recording_order` is "after" or "before". See ModelConfig#on_destroy.
#
# @api private
# @return - The created version object, so that plugins can use it, e.g.
# paper_trail-association_tracking
def record_destroy(recording_order)
return unless enabled? && !@record.new_record?
in_after_callback = recording_order == "after"
event = Events::Destroy.new(@record, in_after_callback)
# Merge data from `Event` with data from PT-AT. We no longer use
# `data_for_destroy` but PT-AT still does.
data = event.data.merge(data_for_destroy)
version = @record.class.paper_trail.version_class.create(data)
if version.errors.any?
log_version_errors(version, :destroy)
else
assign_and_reset_version_association(version)
version
end
end
# PT-AT extends this method to add its transaction id.
#
# @api private
def data_for_destroy
{}
end
# @api private
# @return - The created version object, so that plugins can use it, e.g.
# paper_trail-association_tracking
def record_update(force:, in_after_callback:, is_touch:)
return unless enabled?
version = build_version_on_update(
force: force,
in_after_callback: in_after_callback,
is_touch: is_touch
)
return unless version
if version.save
# Because the version object was created using version_class.new instead
# of versions_assoc.build?, the association cache is unaware. So, we
# invalidate the `versions` association cache with `reset`.
versions.reset
version
else
log_version_errors(version, :update)
end
end
# PT-AT extends this method to add its transaction id.
#
# @api private
def data_for_update
{}
end
# @api private
# @return - The created version object, so that plugins can use it, e.g.
# paper_trail-association_tracking
def record_update_columns(changes)
return unless enabled?
event = Events::Update.new(@record, false, false, changes)
# Merge data from `Event` with data from PT-AT. We no longer use
# `data_for_update_columns` but PT-AT still does.
data = event.data.merge(data_for_update_columns)
versions_assoc = @record.send(@record.class.versions_association_name)
version = versions_assoc.create(data)
if version.errors.any?
log_version_errors(version, :update)
else
version
end
end
# PT-AT extends this method to add its transaction id.
#
# @api private
def data_for_update_columns
{}
end
# Invoked via callback when a user attempts to persist a reified
# `Version`.
def reset_timestamp_attrs_for_update_if_needed
return if live?
@record.send(:timestamp_attributes_for_update_in_model).each do |column|
@record.send("restore_#{column}!")
end
end
# AR callback.
# @api private
def save_version?
if_condition = @record.paper_trail_options[:if]
unless_condition = @record.paper_trail_options[:unless]
(if_condition.blank? || if_condition.call(@record)) && !unless_condition.try(:call, @record)
end
def source_version
version
end
# Save, and create a version record regardless of options such as `:on`,
# `:if`, or `:unless`.
#
# `in_after_callback`: Indicates if this method is being called within an
# `after` callback. Defaults to `false`.
# `options`: Optional arguments passed to `save`.
#
# This is an "update" event. That is, we record the same data we would in
# the case of a normal AR `update`.
def save_with_version(in_after_callback: false, **options)
::PaperTrail.request(enabled: false) do
@record.save(**options)
end
record_update(force: true, in_after_callback: in_after_callback, is_touch: false)
end
# Like the `update_column` method from `ActiveRecord::Persistence`, but also
# creates a version to record those changes.
# @api public
def update_column(name, value)
update_columns(name => value)
end
# Like the `update_columns` method from `ActiveRecord::Persistence`, but also
# creates a version to record those changes.
# @api public
def update_columns(attributes)
# `@record.update_columns` skips dirty-tracking, so we can't just use
# `@record.changes` or @record.saved_changes` from `ActiveModel::Dirty`.
# We need to build our own hash with the changes that will be made
# directly to the database.
changes = {}
attributes.each do |k, v|
changes[k] = [@record[k], v]
end
@record.update_columns(attributes)
record_update_columns(changes)
end
# Returns the object (not a Version) as it was at the given timestamp.
def version_at(timestamp, reify_options = {})
# Because a version stores how its object looked *before* the change,
# we need to look for the first version created *after* the timestamp.
v = versions.subsequent(timestamp, true).first
return v.reify(reify_options) if v
@record unless @record.destroyed?
end
# Returns the objects (not Versions) as they were between the given times.
def versions_between(start_time, end_time)
versions = send(@record.class.versions_association_name).between(start_time, end_time)
versions.collect { |version| version_at(version.created_at) }
end
private
# @api private
def assign_and_reset_version_association(version)
@record.send("#{@record.class.version_association_name}=", version)
@record.send(@record.class.versions_association_name).reset
end
# @api private
def build_version_on_create(in_after_callback:)
event = Events::Create.new(@record, in_after_callback)
# Merge data from `Event` with data from PT-AT. We no longer use
# `data_for_create` but PT-AT still does.
data = event.data.merge!(data_for_create)
# Pure `version_class.new` reduces memory usage compared to `versions_assoc.build`
@record.class.paper_trail.version_class.new(data)
end
# @api private
def build_version_on_update(force:, in_after_callback:, is_touch:)
event = Events::Update.new(@record, in_after_callback, is_touch, nil)
return unless force || event.changed_notably?
# Merge data from `Event` with data from PT-AT. We no longer use
# `data_for_update` but PT-AT still does. To save memory, we use `merge!`
# instead of `merge`.
data = event.data.merge!(data_for_update)
# Using `version_class.new` reduces memory usage compared to
# `versions_assoc.build`. It's a trade-off though. We have to clear
# the association cache (see `versions.reset`) and that could cause an
# additional query in certain applications.
@record.class.paper_trail.version_class.new(data)
end
def log_version_errors(version, action)
version.logger&.warn(
"Unable to create version for #{action} of #{@record.class.name}" \
"##{@record.id}: " + version.errors.full_messages.join(", ")
)
end
def version
@record.public_send(@record.class.version_association_name)
end
def versions
@record.public_send(@record.class.versions_association_name)
end
end
end