# 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