269 lines
8.5 KiB
Ruby
269 lines
8.5 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
|
|
RAILS_GTE_5_1 = ::ActiveRecord.gem_version >= ::Gem::Version.new("5.1.0.beta1")
|
|
|
|
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?
|
|
event = Events::Create.new(@record, true)
|
|
|
|
# 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)
|
|
|
|
versions_assoc = @record.send(@record.class.versions_association_name)
|
|
versions_assoc.create!(data)
|
|
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?
|
|
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.
|
|
data = event.data.merge(data_for_update)
|
|
|
|
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
|
|
{}
|
|
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`.
|
|
#
|
|
# Arguments are 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(*args)
|
|
::PaperTrail.request(enabled: false) do
|
|
@record.save(*args)
|
|
end
|
|
record_update(force: true, in_after_callback: false, 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
|
|
|
|
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
|