Merge pull request #582 from airblade/formatting_comments

Format comments
This commit is contained in:
Jared Beck 2015-08-03 19:12:53 -04:00
commit b5c2bce93d
7 changed files with 222 additions and 147 deletions

View File

@ -25,7 +25,7 @@ module PaperTrail
end end
# ActiveRecord 5 drops support for serialized attributes; for previous # ActiveRecord 5 drops support for serialized attributes; for previous
# versions of ActiveRecord it is supported, we have a config option # versions of ActiveRecord it is supported, we have a config option
# to enable it within PaperTrail. # to enable it within PaperTrail.
def self.serialized_attributes? def self.serialized_attributes?
!!PaperTrail.config.serialized_attributes && ::ActiveRecord::VERSION::MAJOR < 5 !!PaperTrail.config.serialized_attributes && ::ActiveRecord::VERSION::MAJOR < 5
@ -43,12 +43,14 @@ module PaperTrail
!!paper_trail_store[:request_enabled_for_controller] !!paper_trail_store[:request_enabled_for_controller]
end end
# Sets whether PaperTrail is enabled or disabled for this model in the current request. # Sets whether PaperTrail is enabled or disabled for this model in the
# current request.
def self.enabled_for_model(model, value) def self.enabled_for_model(model, value)
paper_trail_store[:"enabled_for_#{model}"] = value paper_trail_store[:"enabled_for_#{model}"] = value
end end
# Returns `true` if PaperTrail is enabled for this model in the current request, `false` otherwise. # Returns `true` if PaperTrail is enabled for this model in the current
# request, `false` otherwise.
def self.enabled_for_model?(model) def self.enabled_for_model?(model)
!!paper_trail_store.fetch(:"enabled_for_#{model}", true) !!paper_trail_store.fetch(:"enabled_for_#{model}", true)
end end
@ -63,10 +65,9 @@ module PaperTrail
PaperTrail.config.timestamp_field PaperTrail.config.timestamp_field
end end
# Sets who is responsible for any changes that occur. # Sets who is responsible for any changes that occur. You would normally use
# You would normally use this in a migration or on the console, # this in a migration or on the console, when working with models directly.
# when working with models directly. In a controller it is set # In a controller it is set automatically to the `current_user`.
# automatically to the `current_user`.
def self.whodunnit=(value) def self.whodunnit=(value)
paper_trail_store[:whodunnit] = value paper_trail_store[:whodunnit] = value
end end
@ -76,8 +77,8 @@ module PaperTrail
paper_trail_store[:whodunnit] paper_trail_store[:whodunnit]
end end
# Sets any information from the controller that you want PaperTrail # Sets any information from the controller that you want PaperTrail to
# to store. By default this is set automatically by a before filter. # store. By default this is set automatically by a before filter.
def self.controller_info=(value) def self.controller_info=(value)
paper_trail_store[:controller_info] = value paper_trail_store[:controller_info] = value
end end
@ -117,8 +118,8 @@ module PaperTrail
private private
# Thread-safe hash to hold PaperTrail's data. # Thread-safe hash to hold PaperTrail's data. Initializing with needed
# Initializing with needed default values. # default values.
def self.paper_trail_store def self.paper_trail_store
RequestStore.store[:paper_trail] ||= { :request_enabled_for_controller => true } RequestStore.store[:paper_trail] ||= { :request_enabled_for_controller => true }
end end
@ -135,12 +136,15 @@ module PaperTrail
end end
end end
# Ensure `ProtectedAttributes` gem gets required if it is available before the `Version` class gets loaded in # Ensure `ProtectedAttributes` gem gets required if it is available before the
# `Version` class gets loaded in.
unless PaperTrail.active_record_protected_attributes? unless PaperTrail.active_record_protected_attributes?
PaperTrail.send(:remove_instance_variable, :@active_record_protected_attributes) PaperTrail.send(:remove_instance_variable, :@active_record_protected_attributes)
begin begin
require 'protected_attributes' require 'protected_attributes'
rescue LoadError; end # will rescue if `ProtectedAttributes` gem is not available rescue LoadError
# In case `ProtectedAttributes` gem is not available.
end
end end
ActiveSupport.on_load(:active_record) do ActiveSupport.on_load(:active_record) do

View File

@ -1,19 +1,24 @@
module PaperTrail module PaperTrail
module Cleaner module Cleaner
# Destroys all but the most recent version(s) for items on a given date (or on all dates). Useful for deleting drafts. # Destroys all but the most recent version(s) for items on a given date
# (or on all dates). Useful for deleting drafts.
# #
# Options: # Options:
# :keeping An `integer` indicating the number of versions to be kept for each item per date. #
# Defaults to `1`. # - :keeping - An `integer` indicating the number of versions to be kept for
# :date Should either be a `Date` object specifying which date to destroy versions for or `:all`, # each item per date. Defaults to `1`.
# which will specify that all dates should be cleaned. Defaults to `:all`. # - :date - Should either be a `Date` object specifying which date to
# :item_id The `id` for the item to be cleaned on, or `nil`, which causes all items to be cleaned. # destroy versions for or `:all`, which will specify that all dates
# Defaults to `nil`. # should be cleaned. Defaults to `:all`.
# - :item_id - The `id` for the item to be cleaned on, or `nil`, which
# causes all items to be cleaned. Defaults to `nil`.
#
def clean_versions!(options = {}) def clean_versions!(options = {})
options = {:keeping => 1, :date => :all}.merge(options) options = {:keeping => 1, :date => :all}.merge(options)
gather_versions(options[:item_id], options[:date]).each do |item_id, versions| gather_versions(options[:item_id], options[:date]).each do |item_id, versions|
versions.group_by { |v| v.send(PaperTrail.timestamp_field).to_date }.each do |date, _versions| versions.group_by { |v| v.send(PaperTrail.timestamp_field).to_date }.each do |date, _versions|
# remove the number of versions we wish to keep from the collection of versions prior to destruction # Remove the number of versions we wish to keep from the collection
# of versions prior to destruction.
_versions.pop(options[:keeping]) _versions.pop(options[:keeping])
_versions.map(&:destroy) _versions.map(&:destroy)
end end
@ -22,13 +27,18 @@ module PaperTrail
private private
# Returns a hash of versions grouped by the `item_id` attribute formatted like this: {:item_id => PaperTrail::Version}. # Returns a hash of versions grouped by the `item_id` attribute formatted
# If `item_id` or `date` is set, versions will be narrowed to those pointing at items with those ids that were created on specified date. # like this: {:item_id => PaperTrail::Version}. If `item_id` or `date` is
# set, versions will be narrowed to those pointing at items with those ids
# that were created on specified date.
def gather_versions(item_id = nil, date = :all) def gather_versions(item_id = nil, date = :all)
raise ArgumentError.new("`date` argument must receive a Timestamp or `:all`") unless date == :all || date.respond_to?(:to_date) raise ArgumentError.new("`date` argument must receive a Timestamp or `:all`") unless date == :all || date.respond_to?(:to_date)
versions = item_id ? PaperTrail::Version.where(:item_id => item_id) : PaperTrail::Version versions = item_id ? PaperTrail::Version.where(:item_id => item_id) : PaperTrail::Version
versions = versions.between(date.to_date, date.to_date + 1.day) unless date == :all versions = versions.between(date.to_date, date.to_date + 1.day) unless date == :all
versions = PaperTrail::Version.all if versions == PaperTrail::Version # if versions has not been converted to an ActiveRecord::Relation yet, do so now
# If `versions` has not been converted to an ActiveRecord::Relation yet,
# do so now.
versions = PaperTrail::Version.all if versions == PaperTrail::Version
versions.group_by(&:item_id) versions.group_by(&:item_id)
end end
end end

View File

@ -1,4 +1,4 @@
# This file only needs to be loaded if the gem is being used outside of Rails, since otherwise # This file only needs to be loaded if the gem is being used outside of Rails,
# the model(s) will get loaded in via the `Rails::Engine` # since otherwise the model(s) will get loaded in via the `Rails::Engine`.
require "paper_trail/frameworks/active_record/models/paper_trail/version_association" require "paper_trail/frameworks/active_record/models/paper_trail/version_association"
require "paper_trail/frameworks/active_record/models/paper_trail/version" require "paper_trail/frameworks/active_record/models/paper_trail/version"

View File

@ -36,24 +36,26 @@ module PaperTrail
# #
# The columns `ip` and `user_agent` must exist in your `versions` # table. # The columns `ip` and `user_agent` must exist in your `versions` # table.
# #
# Use the `:meta` option to `PaperTrail::Model::ClassMethods.has_paper_trail` # Use the `:meta` option to
# to store any extra model-level data you need. # `PaperTrail::Model::ClassMethods.has_paper_trail` to store any extra
# model-level data you need.
def info_for_paper_trail def info_for_paper_trail
{} {}
end end
# Returns `true` (default) or `false` depending on whether PaperTrail should # Returns `true` (default) or `false` depending on whether PaperTrail
# be active for the current request. # should be active for the current request.
# #
# Override this method in your controller to specify when PaperTrail should # Override this method in your controller to specify when PaperTrail
# be off. # should be off.
def paper_trail_enabled_for_controller def paper_trail_enabled_for_controller
::PaperTrail.enabled? ::PaperTrail.enabled?
end end
private private
# Tells PaperTrail whether versions should be saved in the current request. # Tells PaperTrail whether versions should be saved in the current
# request.
def set_paper_trail_enabled_for_controller def set_paper_trail_enabled_for_controller
::PaperTrail.enabled_for_controller = paper_trail_enabled_for_controller ::PaperTrail.enabled_for_controller = paper_trail_enabled_for_controller
end end
@ -63,8 +65,8 @@ module PaperTrail
::PaperTrail.whodunnit = user_for_paper_trail if ::PaperTrail.enabled_for_controller? ::PaperTrail.whodunnit = user_for_paper_trail if ::PaperTrail.enabled_for_controller?
end end
# Tells PaperTrail any information from the controller you want # Tells PaperTrail any information from the controller you want to store
# to store alongside any changes that occur. # alongside any changes that occur.
def set_paper_trail_controller_info def set_paper_trail_controller_info
::PaperTrail.controller_info = info_for_paper_trail if ::PaperTrail.enabled_for_controller? ::PaperTrail.controller_info = info_for_paper_trail if ::PaperTrail.enabled_for_controller?
end end

View File

@ -3,7 +3,8 @@ require 'active_support/core_ext/object' # provides the `try` method
module PaperTrail module PaperTrail
module Sinatra module Sinatra
# Register this module inside your Sinatra application to gain access to controller-level methods used by PaperTrail # Register this module inside your Sinatra application to gain access to
# controller-level methods used by PaperTrail.
def self.registered(app) def self.registered(app)
app.use RequestStore::Middleware app.use RequestStore::Middleware
app.helpers self app.helpers self

View File

@ -8,31 +8,42 @@ module PaperTrail
end end
module ClassMethods module ClassMethods
# Declare this in your model to track every create, update, and destroy. Each version of # Declare this in your model to track every create, update, and destroy.
# the model is available in the `versions` association. # Each version of the model is available in the `versions` association.
# #
# Options: # Options:
# :on the events to track (optional; defaults to all of them). Set to an array of #
# `:create`, `:update`, `:destroy` as desired. # - :on - The events to track (optional; defaults to all of them). Set
# :class_name the name of a custom Version class. This class should inherit from `PaperTrail::Version`. # to an array of `:create`, `:update`, `:destroy` as desired.
# :ignore an array of attributes for which a new `Version` will not be created if only they change. # - :class_name - The name of a custom Version class. This class should
# it can also aceept a Hash as an argument where the key is the attribute to ignore (a `String` or `Symbol`), # inherit from `PaperTrail::Version`.
# which will only be ignored if the value is a `Proc` which returns truthily. # - :ignore - An array of attributes for which a new `Version` will not be
# :if, :unless Procs that allow to specify conditions when to save versions for an object # created if only they change. It can also aceept a Hash as an
# :only inverse of `ignore` - a new `Version` will be created only for these attributes if supplied # argument where the key is the attribute to ignore (a `String` or
# it can also aceept a Hash as an argument where the key is the attribute to track (a `String` or `Symbol`), # `Symbol`), which will only be ignored if the value is a `Proc` which
# which will only be counted if the value is a `Proc` which returns truthily. # returns truthily.
# :skip fields to ignore completely. As with `ignore`, updates to these fields will not create # - :if, :unless - Procs that allow to specify conditions when to save
# a new `Version`. In addition, these fields will not be included in the serialized versions # versions for an object.
# of the object whenever a new `Version` is created. # - :only - Inverse of `ignore`. A new `Version` will be created only
# :meta a hash of extra data to store. You must add a column to the `versions` table for each key. # for these attributes if supplied it can also aceept a Hash as an
# Values are objects or procs (which are called with `self`, i.e. the model with the paper # argument where the key is the attribute to track (a `String` or
# trail). See `PaperTrail::Controller.info_for_paper_trail` for how to store data from # `Symbol`), which will only be counted if the value is a `Proc` which
# the controller. # returns truthily.
# :versions the name to use for the versions association. Default is `:versions`. # - :skip - Fields to ignore completely. As with `ignore`, updates to
# :version the name to use for the method which returns the version the instance was reified from. # these fields will not create a new `Version`. In addition, these
# Default is `:version`. # fields will not be included in the serialized versions of the object
# :save_changes whether or not to save changes to the object_changes column if it exists. Default is true # whenever a new `Version` is created.
# - :meta - A hash of extra data to store. You must add a column to the
# `versions` table for each key. Values are objects or procs (which
# are called with `self`, i.e. the model with the paper trail). See
# `PaperTrail::Controller.info_for_paper_trail` for how to store data
# from the controller.
# - :versions - The name to use for the versions association. Default
# is `:versions`.
# - :version - The name to use for the method which returns the version
# the instance was reified from. Default is `:version`.
# - :save_changes - Whether or not to save changes to the object_changes
# column if it exists. Default is true
# #
def has_paper_trail(options = {}) def has_paper_trail(options = {})
# Lazily include the instance methods so we don't clutter up # Lazily include the instance methods so we don't clutter up
@ -64,7 +75,8 @@ module PaperTrail
attr_accessor :paper_trail_event attr_accessor :paper_trail_event
if ::ActiveRecord::VERSION::MAJOR >= 4 # `has_many` syntax for specifying order uses a lambda in Rails 4 # `has_many` syntax for specifying order uses a lambda in Rails 4
if ::ActiveRecord::VERSION::MAJOR >= 4
has_many self.versions_association_name, has_many self.versions_association_name,
lambda { order(model.timestamp_sort_order) }, lambda { order(model.timestamp_sort_order) },
:class_name => self.version_class_name, :as => :item :class_name => self.version_class_name, :as => :item
@ -76,7 +88,11 @@ module PaperTrail
end end
options[:on] ||= [:create, :update, :destroy] options[:on] ||= [:create, :update, :destroy]
options_on = Array(options[:on]) # so that a single symbol can be passed in without wrapping it in an `Array`
# Wrap the :on option in an array if necessary. This allows a single
# symbol to be passed in.
options_on = Array(options[:on])
after_create :record_create, :if => :save_version? if options_on.include?(:create) after_create :record_create, :if => :save_version? if options_on.include?(:create)
if options_on.include?(:update) if options_on.include?(:update)
before_save :reset_timestamp_attrs_for_update_if_needed!, :on => :update before_save :reset_timestamp_attrs_for_update_if_needed!, :on => :update
@ -85,7 +101,7 @@ module PaperTrail
end end
after_destroy :record_destroy, :if => :save_version? if options_on.include?(:destroy) after_destroy :record_destroy, :if => :save_version? if options_on.include?(:destroy)
# Reset the transaction id when the transaction is closed # Reset the transaction id when the transaction is closed.
after_commit :reset_transaction_id after_commit :reset_transaction_id
after_rollback :reset_transaction_id after_rollback :reset_transaction_id
after_rollback :clear_rolled_back_versions after_rollback :clear_rolled_back_versions
@ -110,40 +126,47 @@ module PaperTrail
@paper_trail_version_class ||= version_class_name.constantize @paper_trail_version_class ||= version_class_name.constantize
end end
# Used for Version#object attribute # Used for `Version#object` attribute.
def serialize_attributes_for_paper_trail!(attributes) def serialize_attributes_for_paper_trail!(attributes)
# don't serialize before values before inserting into columns of type `JSON` on `PostgreSQL` databases # Don't serialize before values before inserting into columns of type
# `JSON` on `PostgreSQL` databases.
return attributes if self.paper_trail_version_class.object_col_is_json? return attributes if self.paper_trail_version_class.object_col_is_json?
serialized_attributes.each do |key, coder| serialized_attributes.each do |key, coder|
if attributes.key?(key) if attributes.key?(key)
# Fall back to current serializer if `coder` has no `dump` method # Fall back to current serializer if `coder` has no `dump` method.
coder = PaperTrail.serializer unless coder.respond_to?(:dump) coder = PaperTrail.serializer unless coder.respond_to?(:dump)
attributes[key] = coder.dump(attributes[key]) attributes[key] = coder.dump(attributes[key])
end end
end end
end end
# TODO: There is a lot of duplication between this and
# `serialize_attributes_for_paper_trail!`.
def unserialize_attributes_for_paper_trail!(attributes) def unserialize_attributes_for_paper_trail!(attributes)
# don't serialize before values before inserting into columns of type `JSON` on `PostgreSQL` databases # Don't serialize before values before inserting into columns of type
# `JSON` on `PostgreSQL` databases.
return attributes if self.paper_trail_version_class.object_col_is_json? return attributes if self.paper_trail_version_class.object_col_is_json?
serialized_attributes.each do |key, coder| serialized_attributes.each do |key, coder|
if attributes.key?(key) if attributes.key?(key)
# Fall back to current serializer if `coder` has no `dump` method.
# TODO: Shouldn't this be `:load`?
coder = PaperTrail.serializer unless coder.respond_to?(:dump) coder = PaperTrail.serializer unless coder.respond_to?(:dump)
attributes[key] = coder.load(attributes[key]) attributes[key] = coder.load(attributes[key])
end end
end end
end end
# Used for Version#object_changes attribute # Used for Version#object_changes attribute.
def serialize_attribute_changes_for_paper_trail!(changes) def serialize_attribute_changes_for_paper_trail!(changes)
# don't serialize before values before inserting into columns of type `JSON` on `PostgreSQL` databases # Don't serialize before values before inserting into columns of type `JSON`
# on `PostgreSQL` databases.
return changes if self.paper_trail_version_class.object_changes_col_is_json? return changes if self.paper_trail_version_class.object_changes_col_is_json?
serialized_attributes.each do |key, coder| serialized_attributes.each do |key, coder|
if changes.key?(key) if changes.key?(key)
# Fall back to current serializer if `coder` has no `dump` method # Fall back to current serializer if `coder` has no `dump` method.
coder = PaperTrail.serializer unless coder.respond_to?(:dump) coder = PaperTrail.serializer unless coder.respond_to?(:dump)
old_value, new_value = changes[key] old_value, new_value = changes[key]
changes[key] = [coder.dump(old_value), changes[key] = [coder.dump(old_value),
@ -152,12 +175,17 @@ module PaperTrail
end end
end end
# TODO: There is a lot of duplication between this and
# `serialize_attribute_changes_for_paper_trail!`.
def unserialize_attribute_changes_for_paper_trail!(changes) def unserialize_attribute_changes_for_paper_trail!(changes)
# don't serialize before values before inserting into columns of type `JSON` on `PostgreSQL` databases # Don't serialize before values before inserting into columns of type
# `JSON` on `PostgreSQL` databases.
return changes if self.paper_trail_version_class.object_changes_col_is_json? return changes if self.paper_trail_version_class.object_changes_col_is_json?
serialized_attributes.each do |key, coder| serialized_attributes.each do |key, coder|
if changes.key?(key) if changes.key?(key)
# Fall back to current serializer if `coder` has no `dump` method.
# TODO: Shouldn't this be `:load`?
coder = PaperTrail.serializer unless coder.respond_to?(:dump) coder = PaperTrail.serializer unless coder.respond_to?(:dump)
old_value, new_value = changes[key] old_value, new_value = changes[key]
changes[key] = [coder.load(old_value), changes[key] = [coder.load(old_value),
@ -236,7 +264,8 @@ module PaperTrail
self.class.paper_trail_on! if paper_trail_was_enabled self.class.paper_trail_on! if paper_trail_was_enabled
end end
# Utility method for reifying. Anything executed inside the block will appear like a new record # Utility method for reifying. Anything executed inside the block will
# appear like a new record.
def appear_as_new_record def appear_as_new_record
instance_eval { instance_eval {
alias :old_new_record? :new_record? alias :old_new_record? :new_record?
@ -246,7 +275,8 @@ module PaperTrail
instance_eval { alias :new_record? :old_new_record? } instance_eval { alias :new_record? :old_new_record? }
end end
# Temporarily overwrites the value of whodunnit and then executes the provided block. # Temporarily overwrites the value of whodunnit and then executes the
# provided block.
def whodunnit(value) def whodunnit(value)
raise ArgumentError, 'expected to receive a block' unless block_given? raise ArgumentError, 'expected to receive a block' unless block_given?
current_whodunnit = PaperTrail.whodunnit current_whodunnit = PaperTrail.whodunnit
@ -344,15 +374,19 @@ module PaperTrail
_changes.to_hash _changes.to_hash
end end
# Invoked via`after_update` callback for when a previous version is reified and then saved # Invoked via`after_update` callback for when a previous version is
# reified and then saved.
def clear_version_instance! def clear_version_instance!
send("#{self.class.version_association_name}=", nil) send("#{self.class.version_association_name}=", nil)
end end
# Invoked via callback when a user attempts to persist a reified
# `Version`.
def reset_timestamp_attrs_for_update_if_needed! def reset_timestamp_attrs_for_update_if_needed!
return if self.live? # invoked via callback when a user attempts to persist a reified `Version` return if self.live?
timestamp_attributes_for_update_in_model.each do |column| timestamp_attributes_for_update_in_model.each do |column|
# ActiveRecord 4.2 deprecated `reset_column!` in favor of `restore_column!` # ActiveRecord 4.2 deprecated `reset_column!` in favor of
# `restore_column!`.
if respond_to?("restore_#{column}!") if respond_to?("restore_#{column}!")
send("restore_#{column}!") send("restore_#{column}!")
else else
@ -382,7 +416,7 @@ module PaperTrail
end end
end end
# saves associations if the join table for `VersionAssociation` exists # Saves associations if the join table for `VersionAssociation` exists.
def save_associations(version) def save_associations(version)
return unless PaperTrail.config.track_associations? return unless PaperTrail.config.track_associations?
self.class.reflect_on_all_associations(:belongs_to).each do |assoc| self.class.reflect_on_all_associations(:belongs_to).each do |assoc|
@ -424,8 +458,8 @@ module PaperTrail
if v.respond_to?(:call) if v.respond_to?(:call)
v.call(self) v.call(self)
elsif v.is_a?(Symbol) && respond_to?(v) elsif v.is_a?(Symbol) && respond_to?(v)
# if it is an attribute that is changing in an existing object, # If it is an attribute that is changing in an existing object,
# be sure to grab the current version # be sure to grab the current version.
if has_attribute?(v) && send("#{v}_changed?".to_sym) && data[:event] != 'create' if has_attribute?(v) && send("#{v}_changed?".to_sym) && data[:event] != 'create'
send("#{v}_was".to_sym) send("#{v}_was".to_sym)
else else
@ -435,6 +469,7 @@ module PaperTrail
v v
end end
end end
# Second we merge any extra data from the controller (if available). # Second we merge any extra data from the controller (if available).
data.merge(PaperTrail.controller_info || {}) data.merge(PaperTrail.controller_info || {})
end end
@ -449,8 +484,8 @@ module PaperTrail
end end
end end
# returns hash of attributes (with appropriate attributes serialized), # Returns hash of attributes (with appropriate attributes serialized),
# ommitting attributes to be skipped # ommitting attributes to be skipped.
def object_attrs_for_paper_trail(attributes_hash) def object_attrs_for_paper_trail(attributes_hash)
attrs = attributes_hash.except(*self.paper_trail_options[:skip]) attrs = attributes_hash.except(*self.paper_trail_options[:skip])
if PaperTrail.serialized_attributes? if PaperTrail.serialized_attributes?
@ -459,10 +494,9 @@ module PaperTrail
attrs attrs
end end
# This method determines whether it is appropriate to generate a new # Determines whether it is appropriate to generate a new version
# version instance. A timestamp-only update (e.g. only `updated_at` # instance. A timestamp-only update (e.g. only `updated_at` changed) is
# changed) is considered notable unless an ignored attribute was also # considered notable unless an ignored attribute was also changed.
# changed.
def changed_notably? def changed_notably?
if ignored_attr_has_changed? if ignored_attr_has_changed?
timestamps = timestamp_attributes_for_update_in_model.map(&:to_s) timestamps = timestamp_attributes_for_update_in_model.map(&:to_s)
@ -473,8 +507,8 @@ module PaperTrail
end end
# An attributed is "ignored" if it is listed in the `:ignore` option # An attributed is "ignored" if it is listed in the `:ignore` option
# and/or the `:skip` option. Returns true if an ignored attribute # and/or the `:skip` option. Returns true if an ignored attribute has
# has changed. # changed.
def ignored_attr_has_changed? def ignored_attr_has_changed?
ignored = self.paper_trail_options[:ignore] + self.paper_trail_options[:skip] ignored = self.paper_trail_options[:ignore] + self.paper_trail_options[:skip]
ignored.any? && (changed & ignored).any? ignored.any? && (changed & ignored).any?
@ -482,7 +516,8 @@ module PaperTrail
def notably_changed def notably_changed
only = self.paper_trail_options[:only].dup only = self.paper_trail_options[:only].dup
# remove Hash arguments and then evaluate whether the attributes (the keys of the hash) should also get pushed into the collection # Remove Hash arguments and then evaluate whether the attributes (the
# keys of the hash) should also get pushed into the collection.
only.delete_if do |obj| only.delete_if do |obj|
obj.is_a?(Hash) && obj.each { |attr, condition| only << attr if condition.respond_to?(:call) && condition.call(self) } obj.is_a?(Hash) && obj.each { |attr, condition| only << attr if condition.respond_to?(:call) && condition.call(self) }
end end
@ -491,7 +526,8 @@ module PaperTrail
def changed_and_not_ignored def changed_and_not_ignored
ignore = self.paper_trail_options[:ignore].dup ignore = self.paper_trail_options[:ignore].dup
# remove Hash arguments and then evaluate whether the attributes (the keys of the hash) should also get pushed into the collection # Remove Hash arguments and then evaluate whether the attributes (the
# keys of the hash) should also get pushed into the collection.
ignore.delete_if do |obj| ignore.delete_if do |obj|
obj.is_a?(Hash) && obj.each { |attr, condition| ignore << attr if condition.respond_to?(:call) && condition.call(self) } obj.is_a?(Hash) && obj.each { |attr, condition| ignore << attr if condition.respond_to?(:call) && condition.call(self) }
end end

View File

@ -7,10 +7,10 @@ module PaperTrail
included do included do
belongs_to :item, :polymorphic => true belongs_to :item, :polymorphic => true
# Since the test suite has test coverage for this, we want to declare the # Since the test suite has test coverage for this, we want to declare
# association when the test suite is running. This makes it pass # the association when the test suite is running. This makes it pass when
# when DB is not initialized prior to test runs such as when we run on # DB is not initialized prior to test runs such as when we run on Travis
# Travis CI (there won't be a db in `test/dummy/db/`) # CI (there won't be a db in `test/dummy/db/`).
if PaperTrail.config.track_associations? if PaperTrail.config.track_associations?
has_many :version_associations, :dependent => :destroy has_many :version_associations, :dependent => :destroy
end end
@ -47,8 +47,8 @@ module PaperTrail
where 'event <> ?', 'create' where 'event <> ?', 'create'
end end
# Expects `obj` to be an instance of `PaperTrail::Version` by default, but can accept a timestamp if # Expects `obj` to be an instance of `PaperTrail::Version` by default,
# `timestamp_arg` receives `true` # but can accept a timestamp if `timestamp_arg` receives `true`
def subsequent(obj, timestamp_arg = false) def subsequent(obj, timestamp_arg = false)
if timestamp_arg != true && self.primary_key_is_int? if timestamp_arg != true && self.primary_key_is_int?
return where(arel_table[primary_key].gt(obj.id)).order(arel_table[primary_key].asc) return where(arel_table[primary_key].gt(obj.id)).order(arel_table[primary_key].asc)
@ -75,7 +75,8 @@ module PaperTrail
).order(self.timestamp_sort_order) ).order(self.timestamp_sort_order)
end end
# defaults to using the primary key as the secondary sort order if possible # Defaults to using the primary key as the secondary sort order if
# possible.
def timestamp_sort_order(direction = 'asc') def timestamp_sort_order(direction = 'asc')
[arel_table[PaperTrail.timestamp_field].send(direction.downcase)].tap do |array| [arel_table[PaperTrail.timestamp_field].send(direction.downcase)].tap do |array|
array << arel_table[primary_key].send(direction.downcase) if self.primary_key_is_int? array << arel_table[primary_key].send(direction.downcase) if self.primary_key_is_int?
@ -137,12 +138,14 @@ module PaperTrail
true true
end end
# Returns whether the `object` column is using the `json` type supported by PostgreSQL # Returns whether the `object` column is using the `json` type supported
# by PostgreSQL.
def object_col_is_json? def object_col_is_json?
[:json, :jsonb].include?(columns_hash['object'].type) [:json, :jsonb].include?(columns_hash['object'].type)
end end
# Returns whether the `object_changes` column is using the `json` type supported by PostgreSQL # Returns whether the `object_changes` column is using the `json` type
# supported by PostgreSQL.
def object_changes_col_is_json? def object_changes_col_is_json?
[:json, :jsonb].include?(columns_hash['object_changes'].try(:type)) [:json, :jsonb].include?(columns_hash['object_changes'].try(:type))
end end
@ -150,22 +153,31 @@ module PaperTrail
# Restore the item from this version. # Restore the item from this version.
# #
# Optionally this can also restore all :has_one and :has_many (including has_many :through) associations as # Optionally this can also restore all :has_one and :has_many (including
# they were "at the time", if they are also being versioned by PaperTrail. # has_many :through) associations as they were "at the time", if they are
# also being versioned by PaperTrail.
# #
# Options: # Options:
# :has_one set to `true` to also reify has_one associations. Default is `false`. #
# :has_many set to `true` to also reify has_many and has_many :through associations. # - :has_one
# Default is `false`. # - `true` - Also reify has_one associations.
# :mark_for_destruction set to `true` to mark the has_one/has_many associations that did not exist in the # - `false - Default.
# reified version for destruction, instead of remove them. Default is `false`. # - :has_many
# This option is handy for people who want to persist the reified version. # - `true` - Also reify has_many and has_many :through associations.
# :dup `false` default behavior # - `false` - Default.
# `true` it always create a new object instance. It is useful for comparing two versions of the same object # - :mark_for_destruction
# :unversioned_attributes `:nil` - (default) attributes undefined in version record # - `true` - Mark the has_one/has_many associations that did not exist in
# are set to nil in reified record # the reified version for destruction, instead of removing them.
# `:preserve` - attributes undefined in version record are # - `false` - Default. Useful for persisting the reified version.
# not modified # - :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 = {}) def reify(options = {})
return nil if object.nil? return nil if object.nil?
@ -180,22 +192,22 @@ module PaperTrail
attrs = self.class.object_col_is_json? ? object : PaperTrail.serializer.load(object) attrs = self.class.object_col_is_json? ? object : PaperTrail.serializer.load(object)
# Normally a polymorphic belongs_to relationship allows us # Normally a polymorphic belongs_to relationship allows us to get the
# to get the object we belong to by calling, in this case, # object we belong to by calling, in this case, `item`. However this
# `item`. However this returns nil if `item` has been # returns nil if `item` has been destroyed, and we need to be able to
# destroyed, and we need to be able to retrieve destroyed # retrieve destroyed objects.
# objects.
# #
# In this situation we constantize the `item_type` to get hold of # In this situation we constantize the `item_type` to get hold of the
# the class...except when the stored object's attributes # class...except when the stored object's attributes include a `type`
# include a `type` key. If this is the case, the object # key. If this is the case, the object we belong to is using single
# we belong to is using single table inheritance and the # table inheritance and the `item_type` will be the base class, not the
# `item_type` will be the base class, not the actual subclass. # actual subclass. If `type` is present but empty, the class is the base
# If `type` is present but empty, the class is the base class. # class.
if options[:dup] != true && item if options[:dup] != true && item
model = item model = item
# Look for attributes that exist in the model and not in this version. These attributes should be set to nil. # Look for attributes that exist in the model and not in this
# version. These attributes should be set to nil.
if options[:unversioned_attributes] == :nil if options[:unversioned_attributes] == :nil
(model.attribute_names - attrs.keys).each { |k| attrs[k] = nil } (model.attribute_names - attrs.keys).each { |k| attrs[k] = nil }
end end
@ -203,13 +215,14 @@ module PaperTrail
inheritance_column_name = item_type.constantize.inheritance_column inheritance_column_name = item_type.constantize.inheritance_column
class_name = attrs[inheritance_column_name].blank? ? item_type : attrs[inheritance_column_name] class_name = attrs[inheritance_column_name].blank? ? item_type : attrs[inheritance_column_name]
klass = class_name.constantize klass = class_name.constantize
# the `dup` option always returns a new object, otherwise we should attempt # The `dup` option always returns a new object, otherwise we should
# to look for the item outside of default scope(s) # attempt to look for the item outside of default scope(s).
if options[:dup] || (_item = klass.unscoped.find_by_id(item_id)).nil? if options[:dup] || (_item = klass.unscoped.find_by_id(item_id)).nil?
model = klass.new model = klass.new
elsif options[:unversioned_attributes] == :nil elsif options[:unversioned_attributes] == :nil
model = _item model = _item
# Look for attributes that exist in the model and not in this version. These attributes should be set to nil. # Look for attributes that exist in the model and not in this
# version. These attributes should be set to nil.
(model.attribute_names - attrs.keys).each { |k| attrs[k] = nil } (model.attribute_names - attrs.keys).each { |k| attrs[k] = nil }
end end
end end
@ -218,7 +231,7 @@ module PaperTrail
model.class.unserialize_attributes_for_paper_trail! attrs model.class.unserialize_attributes_for_paper_trail! attrs
end end
# Set all the attributes in this version on the model # Set all the attributes in this version on the model.
attrs.each do |k, v| attrs.each do |k, v|
if model.has_attribute?(k) if model.has_attribute?(k)
model[k.to_sym] = v model[k.to_sym] = v
@ -243,8 +256,9 @@ module PaperTrail
end end
end end
# Returns what changed in this version of the item. `ActiveModel::Dirty#changes`. # Returns what changed in this version of the item.
# returns `nil` if your `versions` table does not have an `object_changes` text column. # `ActiveModel::Dirty#changes`. returns `nil` if your `versions` table does
# not have an `object_changes` text column.
def changeset def changeset
return nil unless self.class.column_names.include? 'object_changes' return nil unless self.class.column_names.include? 'object_changes'
@ -268,8 +282,8 @@ module PaperTrail
self.paper_trail_originator self.paper_trail_originator
end end
# Returns who changed the item from the state it had in this version. # Returns who changed the item from the state it had in this version. This
# This is an alias for `whodunnit`. # is an alias for `whodunnit`.
def terminator def terminator
@terminator ||= whodunnit @terminator ||= whodunnit
end end
@ -311,9 +325,9 @@ module PaperTrail
end end
end end
# Restore the `model`'s has_one associations as they were when this version was # Restore the `model`'s has_one associations as they were when this
# superseded by the next (because that's what the user was looking at when they # version was superseded by the next (because that's what the user was
# made the change). # looking at when they made the change).
def reify_has_ones(model, options = {}) def reify_has_ones(model, options = {})
version_table_name = model.class.paper_trail_version_class.table_name version_table_name = model.class.paper_trail_version_class.table_name
model.class.reflect_on_all_associations(:has_one).each do |assoc| model.class.reflect_on_all_associations(:has_one).each do |assoc|
@ -344,8 +358,9 @@ module PaperTrail
end end
end end
# Restore the `model`'s has_many associations as they were at version_at timestamp # Restore the `model`'s has_many associations as they were at version_at
# We lookup the first child versions after version_at timestamp or in same transaction. # timestamp We lookup the first child versions after version_at timestamp or
# in same transaction.
def reify_has_manys(model, options = {}) def reify_has_manys(model, options = {})
assoc_has_many_through, assoc_has_many_directly = assoc_has_many_through, assoc_has_many_directly =
model.class.reflect_on_all_associations(:has_many). model.class.reflect_on_all_associations(:has_many).
@ -354,7 +369,8 @@ module PaperTrail
reify_has_many_through(assoc_has_many_through, model, options) reify_has_many_through(assoc_has_many_through, model, options)
end end
# Restore the `model`'s has_many associations not associated through another association # Restore the `model`'s has_many associations not associated through
# another association.
def reify_has_many_directly(associations, model, options = {}) def reify_has_many_directly(associations, model, options = {})
version_table_name = model.class.paper_trail_version_class.table_name version_table_name = model.class.paper_trail_version_class.table_name
associations.each do |assoc| associations.each do |assoc|
@ -370,10 +386,11 @@ module PaperTrail
acc.merge!(v.item_id => v) acc.merge!(v.item_id => v)
end end
# Pass true to force the model to load # Pass true to force the model to load.
collection = Array.new model.send(assoc.name, true) collection = Array.new model.send(assoc.name, true)
# Iterate each child to replace it with the previous value if there is a version after the timestamp # Iterate each child to replace it with the previous value if there is
# a version after the timestamp.
collection.map! do |c| collection.map! do |c|
if (version = versions.delete(c.id)).nil? if (version = versions.delete(c.id)).nil?
c c
@ -384,16 +401,18 @@ module PaperTrail
end end
end end
# Reify the rest of the versions and add them to the collection, these versions are for those that # Reify the rest of the versions and add them to the collection, these
# have been removed from the live associations # versions are for those that have been removed from the live
# associations.
collection += versions.values.map { |version| version.reify(options.merge(:has_many => false, :has_one => false)) } collection += versions.values.map { |version| version.reify(options.merge(:has_many => false, :has_one => false)) }
model.send(assoc.name).proxy_association.target = collection.compact model.send(assoc.name).proxy_association.target = collection.compact
end end
end end
# Restore the `model`'s has_many associations through another association # Restore the `model`'s has_many associations through another association.
# This must be called after the direct has_manys have been reified (reify_has_many_directly) # This must be called after the direct has_manys have been reified
# (reify_has_many_directly).
def reify_has_many_through(associations, model, options = {}) def reify_has_many_through(associations, model, options = {})
associations.each do |assoc| associations.each do |assoc|
next unless assoc.klass.paper_trail_enabled_for_model? next unless assoc.klass.paper_trail_enabled_for_model?
@ -412,7 +431,8 @@ module PaperTrail
collection = Array.new assoc.klass.where(assoc.klass.primary_key => collection_keys) collection = Array.new assoc.klass.where(assoc.klass.primary_key => collection_keys)
# Iterate each child to replace it with the previous value if there is a version after the timestamp # Iterate each child to replace it with the previous value if there is
# a version after the timestamp.
collection.map! do |c| collection.map! do |c|
if (version = versions.delete(c.id)).nil? if (version = versions.delete(c.id)).nil?
c c
@ -423,15 +443,17 @@ module PaperTrail
end end
end end
# Reify the rest of the versions and add them to the collection, these versions are for those that # Reify the rest of the versions and add them to the collection, these
# have been removed from the live associations # versions are for those that have been removed from the live
# associations.
collection += versions.values.map { |version| version.reify(options.merge(:has_many => false, :has_one => false)) } collection += versions.values.map { |version| version.reify(options.merge(:has_many => false, :has_one => false)) }
model.send(assoc.name).proxy_association.target = collection.compact model.send(assoc.name).proxy_association.target = collection.compact
end end
end end
# checks to see if a value has been set for the `version_limit` config option, and if so enforces it # Checks that a value has been set for the `version_limit` config
# option, and if so enforces it.
def enforce_version_limit! def enforce_version_limit!
return unless PaperTrail.config.version_limit.is_a? Numeric return unless PaperTrail.config.version_limit.is_a? Numeric
previous_versions = sibling_versions.not_creates previous_versions = sibling_versions.not_creates