mirror of
https://github.com/paper-trail-gem/paper_trail.git
synced 2022-11-09 11:33:19 -05:00
c10a8573f1
* Make paper_trail work with Rails 7.0 * from class_methods do back to module ClassMethods * add spec for PostgresArraySerializer to boost coverage * lint the spec for PostgresArraySerializer * lint the spec for PostgresArraySerializer again * and now make that linted spec pass again * test object change scopes a bit * round out json and jsonb testing of object scopes * test some other code paths to increase coverage * linting * linting * mess with yaml loading in test * oddball cop for double quotes * use Rails public API for compatibility rather than instance_variable_set Co-authored-by: dfurber <dfurber@truecar.com>
397 lines
13 KiB
Ruby
397 lines
13 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "paper_trail/attribute_serializers/object_changes_attribute"
|
|
require "paper_trail/queries/versions/where_attribute_changes"
|
|
require "paper_trail/queries/versions/where_object"
|
|
require "paper_trail/queries/versions/where_object_changes"
|
|
require "paper_trail/queries/versions/where_object_changes_from"
|
|
require "paper_trail/queries/versions/where_object_changes_to"
|
|
|
|
module PaperTrail
|
|
# Originally, PaperTrail did not provide this module, and all of this
|
|
# functionality was in `PaperTrail::Version`. That model still exists (and is
|
|
# used by most apps) but by moving the functionality to this module, people
|
|
# can include this concern instead of sub-classing the `Version` model.
|
|
module VersionConcern
|
|
extend ::ActiveSupport::Concern
|
|
|
|
included do
|
|
belongs_to :item, polymorphic: true, optional: true, inverse_of: false
|
|
validates_presence_of :event
|
|
after_create :enforce_version_limit!
|
|
end
|
|
|
|
# :nodoc:
|
|
module ClassMethods
|
|
def with_item_keys(item_type, item_id)
|
|
where item_type: item_type, item_id: item_id
|
|
end
|
|
|
|
def creates
|
|
where event: "create"
|
|
end
|
|
|
|
def updates
|
|
where event: "update"
|
|
end
|
|
|
|
def destroys
|
|
where event: "destroy"
|
|
end
|
|
|
|
def not_creates
|
|
where.not(event: "create")
|
|
end
|
|
|
|
def between(start_time, end_time)
|
|
where(
|
|
arel_table[:created_at].gt(start_time).
|
|
and(arel_table[:created_at].lt(end_time))
|
|
).order(timestamp_sort_order)
|
|
end
|
|
|
|
# Defaults to using the primary key as the secondary sort order if
|
|
# possible.
|
|
def timestamp_sort_order(direction = "asc")
|
|
[arel_table[:created_at].send(direction.downcase)].tap do |array|
|
|
array << arel_table[primary_key].send(direction.downcase) if primary_key_is_int?
|
|
end
|
|
end
|
|
|
|
# Given an attribute like `"name"`, query the `versions.object_changes`
|
|
# column for any changes that modified the provided attribute.
|
|
#
|
|
# @api public
|
|
def where_attribute_changes(attribute)
|
|
unless attribute.is_a?(String) || attribute.is_a?(Symbol)
|
|
raise ArgumentError, "expected to receive a String or Symbol"
|
|
end
|
|
|
|
Queries::Versions::WhereAttributeChanges.new(self, attribute).execute
|
|
end
|
|
|
|
# Given a hash of attributes like `name: 'Joan'`, query the
|
|
# `versions.objects` column.
|
|
#
|
|
# ```
|
|
# SELECT "versions".*
|
|
# FROM "versions"
|
|
# WHERE ("versions"."object" LIKE '%
|
|
# name: Joan
|
|
# %')
|
|
# ```
|
|
#
|
|
# This is useful for finding versions where a given attribute had a given
|
|
# value. Imagine, in the example above, that Joan had changed her name
|
|
# and we wanted to find the versions before that change.
|
|
#
|
|
# Based on the data type of the `object` column, the appropriate SQL
|
|
# operator is used. For example, a text column will use `like`, and a
|
|
# jsonb column will use `@>`.
|
|
#
|
|
# @api public
|
|
def where_object(args = {})
|
|
raise ArgumentError, "expected to receive a Hash" unless args.is_a?(Hash)
|
|
Queries::Versions::WhereObject.new(self, args).execute
|
|
end
|
|
|
|
# Given a hash of attributes like `name: 'Joan'`, query the
|
|
# `versions.objects_changes` column.
|
|
#
|
|
# ```
|
|
# SELECT "versions".*
|
|
# FROM "versions"
|
|
# WHERE .. ("versions"."object_changes" LIKE '%
|
|
# name:
|
|
# - Joan
|
|
# %' OR "versions"."object_changes" LIKE '%
|
|
# name:
|
|
# -%
|
|
# - Joan
|
|
# %')
|
|
# ```
|
|
#
|
|
# This is useful for finding versions immediately before and after a given
|
|
# attribute had a given value. Imagine, in the example above, that someone
|
|
# changed their name to Joan and we wanted to find the versions
|
|
# immediately before and after that change.
|
|
#
|
|
# Based on the data type of the `object` column, the appropriate SQL
|
|
# operator is used. For example, a text column will use `like`, and a
|
|
# jsonb column will use `@>`.
|
|
#
|
|
# @api public
|
|
def where_object_changes(args = {})
|
|
raise ArgumentError, "expected to receive a Hash" unless args.is_a?(Hash)
|
|
Queries::Versions::WhereObjectChanges.new(self, args).execute
|
|
end
|
|
|
|
# Given a hash of attributes like `name: 'Joan'`, query the
|
|
# `versions.objects_changes` column for changes where the version changed
|
|
# from the hash of attributes to other values.
|
|
#
|
|
# This is useful for finding versions where the attribute started with a
|
|
# known value and changed to something else. This is in comparison to
|
|
# `where_object_changes` which will find both the changes before and
|
|
# after.
|
|
#
|
|
# @api public
|
|
def where_object_changes_from(args = {})
|
|
raise ArgumentError, "expected to receive a Hash" unless args.is_a?(Hash)
|
|
Queries::Versions::WhereObjectChangesFrom.new(self, args).execute
|
|
end
|
|
|
|
# Given a hash of attributes like `name: 'Joan'`, query the
|
|
# `versions.objects_changes` column for changes where the version changed
|
|
# to the hash of attributes from other values.
|
|
#
|
|
# This is useful for finding versions where the attribute started with an
|
|
# unknown value and changed to a known value. This is in comparison to
|
|
# `where_object_changes` which will find both the changes before and
|
|
# after.
|
|
#
|
|
# @api public
|
|
def where_object_changes_to(args = {})
|
|
raise ArgumentError, "expected to receive a Hash" unless args.is_a?(Hash)
|
|
Queries::Versions::WhereObjectChangesTo.new(self, args).execute
|
|
end
|
|
|
|
def primary_key_is_int?
|
|
@primary_key_is_int ||= columns_hash[primary_key].type == :integer
|
|
rescue StandardError # TODO: Rescue something more specific
|
|
true
|
|
end
|
|
|
|
# Returns whether the `object` column is using the `json` type supported
|
|
# by PostgreSQL.
|
|
def object_col_is_json?
|
|
%i[json jsonb].include?(columns_hash["object"].type)
|
|
end
|
|
|
|
# Returns whether the `object_changes` column is using the `json` type
|
|
# supported by PostgreSQL.
|
|
def object_changes_col_is_json?
|
|
%i[json jsonb].include?(columns_hash["object_changes"].try(:type))
|
|
end
|
|
|
|
# Returns versions before `obj`.
|
|
#
|
|
# @param obj - a `Version` or a timestamp
|
|
# @param timestamp_arg - boolean - When true, `obj` is a timestamp.
|
|
# Default: false.
|
|
# @return `ActiveRecord::Relation`
|
|
# @api public
|
|
# rubocop:disable Style/OptionalBooleanParameter
|
|
def preceding(obj, timestamp_arg = false)
|
|
if timestamp_arg != true && primary_key_is_int?
|
|
preceding_by_id(obj)
|
|
else
|
|
preceding_by_timestamp(obj)
|
|
end
|
|
end
|
|
# rubocop:enable Style/OptionalBooleanParameter
|
|
|
|
# Returns versions after `obj`.
|
|
#
|
|
# @param obj - a `Version` or a timestamp
|
|
# @param timestamp_arg - boolean - When true, `obj` is a timestamp.
|
|
# Default: false.
|
|
# @return `ActiveRecord::Relation`
|
|
# @api public
|
|
# rubocop:disable Style/OptionalBooleanParameter
|
|
def subsequent(obj, timestamp_arg = false)
|
|
if timestamp_arg != true && primary_key_is_int?
|
|
subsequent_by_id(obj)
|
|
else
|
|
subsequent_by_timestamp(obj)
|
|
end
|
|
end
|
|
# rubocop:enable Style/OptionalBooleanParameter
|
|
|
|
private
|
|
|
|
# @api private
|
|
def preceding_by_id(obj)
|
|
where(arel_table[primary_key].lt(obj.id)).order(arel_table[primary_key].desc)
|
|
end
|
|
|
|
# @api private
|
|
def preceding_by_timestamp(obj)
|
|
obj = obj.send(:created_at) if obj.is_a?(self)
|
|
where(arel_table[:created_at].lt(obj)).
|
|
order(timestamp_sort_order("desc"))
|
|
end
|
|
|
|
# @api private
|
|
def subsequent_by_id(version)
|
|
where(arel_table[primary_key].gt(version.id)).order(arel_table[primary_key].asc)
|
|
end
|
|
|
|
# @api private
|
|
def subsequent_by_timestamp(obj)
|
|
obj = obj.send(:created_at) if obj.is_a?(self)
|
|
where(arel_table[:created_at].gt(obj)).order(timestamp_sort_order)
|
|
end
|
|
end
|
|
|
|
# @api private
|
|
def object_deserialized
|
|
if self.class.object_col_is_json?
|
|
object
|
|
else
|
|
PaperTrail.serializer.load(object)
|
|
end
|
|
end
|
|
|
|
# Restore the item from this version.
|
|
#
|
|
# Options:
|
|
#
|
|
# - :mark_for_destruction
|
|
# - `true` - Mark the has_one/has_many associations that did not exist in
|
|
# the reified version for destruction, instead of removing them.
|
|
# - `false` - Default. Useful for persisting the reified version.
|
|
# - :dup
|
|
# - `false` - Default.
|
|
# - `true` - Always create a new object instance. Useful for
|
|
# comparing two versions of the same object.
|
|
# - :unversioned_attributes
|
|
# - `:nil` - Default. Attributes undefined in version record are set to
|
|
# nil in reified record.
|
|
# - `:preserve` - Attributes undefined in version record are not modified.
|
|
#
|
|
def reify(options = {})
|
|
unless self.class.column_names.include? "object"
|
|
raise Error, "reify requires an object column"
|
|
end
|
|
return nil if object.nil?
|
|
::PaperTrail::Reifier.reify(self, options)
|
|
end
|
|
|
|
# Returns what changed in this version of the item.
|
|
# `ActiveModel::Dirty#changes`. returns `nil` if your `versions` table does
|
|
# not have an `object_changes` text column.
|
|
def changeset
|
|
return nil unless self.class.column_names.include? "object_changes"
|
|
@changeset ||= load_changeset
|
|
end
|
|
|
|
# Returns who put the item into the state stored in this version.
|
|
def paper_trail_originator
|
|
@paper_trail_originator ||= previous.try(:whodunnit)
|
|
end
|
|
|
|
# Returns who changed the item from the state it had in this version. This
|
|
# is an alias for `whodunnit`.
|
|
def terminator
|
|
@terminator ||= whodunnit
|
|
end
|
|
alias version_author terminator
|
|
|
|
def next
|
|
@next ||= sibling_versions.subsequent(self).first
|
|
end
|
|
|
|
def previous
|
|
@previous ||= sibling_versions.preceding(self).first
|
|
end
|
|
|
|
# Returns an integer representing the chronological position of the
|
|
# version among its siblings. The "create" event, for example, has an index
|
|
# of 0.
|
|
#
|
|
# @api public
|
|
def index
|
|
@index ||= RecordHistory.new(sibling_versions, self.class).index(self)
|
|
end
|
|
|
|
private
|
|
|
|
# @api private
|
|
def load_changeset
|
|
if PaperTrail.config.object_changes_adapter.respond_to?(:load_changeset)
|
|
return PaperTrail.config.object_changes_adapter.load_changeset(self)
|
|
end
|
|
|
|
# First, deserialize the `object_changes` column.
|
|
changes = HashWithIndifferentAccess.new(object_changes_deserialized)
|
|
|
|
# The next step is, perhaps unfortunately, called "de-serialization",
|
|
# and appears to be responsible for custom attribute serializers. For an
|
|
# example of a custom attribute serializer, see
|
|
# `Person::TimeZoneSerializer` in the test suite.
|
|
#
|
|
# Is `item.class` good enough? Does it handle `inheritance_column`
|
|
# as well as `Reifier#version_reification_class`? We were using
|
|
# `item_type.constantize`, but that is problematic when the STI parent
|
|
# is not versioned. (See `Vehicle` and `Car` in the test suite).
|
|
#
|
|
# Note: `item` returns nil if `event` is "destroy".
|
|
unless item.nil?
|
|
AttributeSerializers::ObjectChangesAttribute.
|
|
new(item.class).
|
|
deserialize(changes)
|
|
end
|
|
|
|
# Finally, return a Hash mapping each attribute name to
|
|
# a two-element array representing before and after.
|
|
changes
|
|
end
|
|
|
|
# If the `object_changes` column is a Postgres JSON column, then
|
|
# ActiveRecord will deserialize it for us. Otherwise, it's a string column
|
|
# and we must deserialize it ourselves.
|
|
# @api private
|
|
def object_changes_deserialized
|
|
if self.class.object_changes_col_is_json?
|
|
object_changes
|
|
else
|
|
begin
|
|
PaperTrail.serializer.load(object_changes)
|
|
rescue StandardError # TODO: Rescue something more specific
|
|
{}
|
|
end
|
|
end
|
|
end
|
|
|
|
# Enforces the `version_limit`, if set. Default: no limit.
|
|
# @api private
|
|
def enforce_version_limit!
|
|
limit = version_limit
|
|
return unless limit.is_a? Numeric
|
|
previous_versions = sibling_versions.not_creates.
|
|
order(self.class.timestamp_sort_order("asc"))
|
|
return unless previous_versions.size > limit
|
|
excess_versions = previous_versions - previous_versions.last(limit)
|
|
excess_versions.map(&:destroy)
|
|
end
|
|
|
|
# @api private
|
|
def sibling_versions
|
|
@sibling_versions ||= self.class.with_item_keys(item_type, item_id)
|
|
end
|
|
|
|
# See docs section 2.e. Limiting the Number of Versions Created.
|
|
# The version limit can be global or per-model.
|
|
#
|
|
# @api private
|
|
def version_limit
|
|
klass = item.class
|
|
if limit_option?(klass)
|
|
klass.paper_trail_options[:limit]
|
|
elsif base_class_limit_option?(klass)
|
|
klass.base_class.paper_trail_options[:limit]
|
|
else
|
|
PaperTrail.config.version_limit
|
|
end
|
|
end
|
|
|
|
def limit_option?(klass)
|
|
klass.respond_to?(:paper_trail_options) && klass.paper_trail_options.key?(:limit)
|
|
end
|
|
|
|
def base_class_limit_option?(klass)
|
|
klass.respond_to?(:base_class) && limit_option?(klass.base_class)
|
|
end
|
|
end
|
|
end
|