mirror of
https://github.com/paper-trail-gem/paper_trail.git
synced 2022-11-09 11:33:19 -05:00
23ffbdc7e1
Fixes NoMethodError when an STI parent class is unversioned and thus does not respond to `unserialize_attribute_changes_for_paper_trail!` [Fixes #738]
341 lines
11 KiB
Ruby
341 lines
11 KiB
Ruby
require "active_support/concern"
|
|
|
|
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
|
|
|
|
# Since the test suite has test coverage for this, we want to declare
|
|
# the association when the test suite is running. This makes it pass when
|
|
# DB is not initialized prior to test runs such as when we run on Travis
|
|
# CI (there won't be a db in `test/dummy/db/`).
|
|
if PaperTrail.config.track_associations?
|
|
has_many :version_associations, dependent: :destroy
|
|
end
|
|
|
|
validates_presence_of :event
|
|
|
|
if PaperTrail.active_record_protected_attributes?
|
|
attr_accessible(
|
|
:item_type,
|
|
:item_id,
|
|
:event,
|
|
:whodunnit,
|
|
:object,
|
|
:object_changes,
|
|
:transaction_id,
|
|
:created_at
|
|
)
|
|
end
|
|
|
|
after_create :enforce_version_limit!
|
|
|
|
scope :within_transaction, ->(id) { where transaction_id: id }
|
|
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 "event <> ?", "create"
|
|
end
|
|
|
|
# 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
|
|
def subsequent(obj, timestamp_arg = false)
|
|
if timestamp_arg != true && primary_key_is_int?
|
|
return where(arel_table[primary_key].gt(obj.id)).order(arel_table[primary_key].asc)
|
|
end
|
|
|
|
obj = obj.send(PaperTrail.timestamp_field) if obj.is_a?(self)
|
|
where(arel_table[PaperTrail.timestamp_field].gt(obj)).order(timestamp_sort_order)
|
|
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
|
|
def preceding(obj, timestamp_arg = false)
|
|
if timestamp_arg != true && primary_key_is_int?
|
|
return where(arel_table[primary_key].lt(obj.id)).order(arel_table[primary_key].desc)
|
|
end
|
|
|
|
obj = obj.send(PaperTrail.timestamp_field) if obj.is_a?(self)
|
|
where(arel_table[PaperTrail.timestamp_field].lt(obj)).
|
|
order(timestamp_sort_order("desc"))
|
|
end
|
|
|
|
def between(start_time, end_time)
|
|
where(
|
|
arel_table[PaperTrail.timestamp_field].gt(start_time).
|
|
and(arel_table[PaperTrail.timestamp_field].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[PaperTrail.timestamp_field].send(direction.downcase)].tap do |array|
|
|
array << arel_table[primary_key].send(direction.downcase) if primary_key_is_int?
|
|
end
|
|
end
|
|
|
|
# Performs an attribute search on the serialized object by invoking the
|
|
# identically-named method in the serializer being used.
|
|
def where_object(args = {})
|
|
raise ArgumentError, "expected to receive a Hash" unless args.is_a?(Hash)
|
|
|
|
if columns_hash["object"].type == :jsonb
|
|
where("object @> ?", args.to_json)
|
|
elsif columns_hash["object"].type == :json
|
|
predicates = []
|
|
values = []
|
|
args.each do |field, value|
|
|
predicates.push "object->>? = ?"
|
|
values.concat([field, value.to_s])
|
|
end
|
|
sql = predicates.join(" and ")
|
|
where(sql, *values)
|
|
else
|
|
arel_field = arel_table[:object]
|
|
where_conditions = args.map { |field, value|
|
|
PaperTrail.serializer.where_object_condition(arel_field, field, value)
|
|
}
|
|
where_conditions = where_conditions.reduce { |a, e| a.and(e) }
|
|
where(where_conditions)
|
|
end
|
|
end
|
|
|
|
def where_object_changes(args = {})
|
|
raise ArgumentError, "expected to receive a Hash" unless args.is_a?(Hash)
|
|
|
|
if columns_hash["object_changes"].type == :jsonb
|
|
args.each { |field, value| args[field] = [value] }
|
|
where("object_changes @> ?", args.to_json)
|
|
elsif columns_hash["object"].type == :json
|
|
predicates = []
|
|
values = []
|
|
args.each do |field, value|
|
|
predicates.push(
|
|
"((object_changes->>? ILIKE ?) OR (object_changes->>? ILIKE ?))"
|
|
)
|
|
values.concat([field, "[#{value.to_json},%", field, "[%,#{value.to_json}]%"])
|
|
end
|
|
sql = predicates.join(" and ")
|
|
where(sql, *values)
|
|
else
|
|
arel_field = arel_table[:object_changes]
|
|
where_conditions = args.map { |field, value|
|
|
PaperTrail.serializer.where_object_changes_condition(arel_field, field, value)
|
|
}
|
|
where_conditions = where_conditions.reduce { |a, e| a.and(e) }
|
|
where(where_conditions)
|
|
end
|
|
end
|
|
|
|
def primary_key_is_int?
|
|
@primary_key_is_int ||= columns_hash[primary_key].type == :integer
|
|
rescue
|
|
true
|
|
end
|
|
|
|
# Returns whether the `object` column is using the `json` type supported
|
|
# by PostgreSQL.
|
|
def object_col_is_json?
|
|
[: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?
|
|
[:json, :jsonb].include?(columns_hash["object_changes"].try(:type))
|
|
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.
|
|
#
|
|
# Optionally this can also restore all :has_one and :has_many (including
|
|
# has_many :through) associations as they were "at the time", if they are
|
|
# also being versioned by PaperTrail.
|
|
#
|
|
# Options:
|
|
#
|
|
# - :has_one
|
|
# - `true` - Also reify has_one associations.
|
|
# - `false - Default.
|
|
# - :has_many
|
|
# - `true` - Also reify has_many and has_many :through associations.
|
|
# - `false` - Default.
|
|
# - :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 = {})
|
|
return nil if object.nil?
|
|
without_identity_map do
|
|
::PaperTrail::Reifier.reify(self, options)
|
|
end
|
|
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
|
|
|
|
def originator
|
|
::ActiveSupport::Deprecation.warn "Use paper_trail_originator instead of originator."
|
|
paper_trail_originator
|
|
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 sibling_versions(reload = false)
|
|
if reload || @sibling_versions.nil?
|
|
@sibling_versions = self.class.with_item_keys(item_type, item_id)
|
|
end
|
|
@sibling_versions
|
|
end
|
|
|
|
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 (see `sibling_versions`). 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
|
|
# First, deserialize the `object_changes` column.
|
|
changes = HashWithIndifferentAccess.new(object_changes_deserialized)
|
|
|
|
# The next step is, perhaps unfortunately, called "un-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?
|
|
item.class.unserialize_attribute_changes_for_paper_trail!(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 # TODO: Rescue something specific
|
|
{}
|
|
end
|
|
end
|
|
end
|
|
|
|
# In Rails 3.1+, calling reify on a previous version confuses the
|
|
# IdentityMap, if enabled. This prevents insertion into the map.
|
|
# @api private
|
|
def without_identity_map(&block)
|
|
if defined?(::ActiveRecord::IdentityMap) && ::ActiveRecord::IdentityMap.respond_to?(:without)
|
|
::ActiveRecord::IdentityMap.without(&block)
|
|
else
|
|
yield
|
|
end
|
|
end
|
|
|
|
# Checks that a value has been set for the `version_limit` config
|
|
# option, and if so enforces it.
|
|
# @api private
|
|
def enforce_version_limit!
|
|
limit = PaperTrail.config.version_limit
|
|
return unless limit.is_a? Numeric
|
|
previous_versions = sibling_versions.not_creates
|
|
return unless previous_versions.size > limit
|
|
excess_versions = previous_versions - previous_versions.last(limit)
|
|
excess_versions.map(&:destroy)
|
|
end
|
|
end
|
|
end
|