diff --git a/.rubocop.yml b/.rubocop.yml index 33e216c97..c1a35106a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -17,10 +17,6 @@ Metrics/AbcSize: Exclude: - 'test/dummy/db/migrate/*' -Metrics/ClassLength: - Exclude: - - test/**/* - # The Ruby Style Guide recommends to "Limit lines to 80 characters." # (/~https://github.com/bbatsov/ruby-style-guide#80-character-limits) # but 100 is also reasonable. diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 384273fee..d85809307 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -4,11 +4,16 @@ Metrics/AbcSize: Max: 30 # Goal: 15 +Metrics/ClassLength: + Max: 300 + Exclude: + - test/**/* + Metrics/CyclomaticComplexity: Max: 13 # Goal: 6 Metrics/ModuleLength: - Max: 313 + Max: 317 Metrics/PerceivedComplexity: Max: 16 # Goal: 7 diff --git a/lib/paper_trail.rb b/lib/paper_trail.rb index 3b789cb97..831aaf6de 100644 --- a/lib/paper_trail.rb +++ b/lib/paper_trail.rb @@ -16,6 +16,11 @@ module PaperTrail extend PaperTrail::Cleaner class << self + # @api private + def clear_transaction_id + self.transaction_id = nil + end + # Switches PaperTrail on or off. # @api public def enabled=(value) diff --git a/lib/paper_trail/attribute_serializers/object_attribute.rb b/lib/paper_trail/attribute_serializers/object_attribute.rb index 2157bbda1..d682588bf 100644 --- a/lib/paper_trail/attribute_serializers/object_attribute.rb +++ b/lib/paper_trail/attribute_serializers/object_attribute.rb @@ -32,7 +32,7 @@ def alter(attributes, serialization_method) end def object_col_is_json? - @model_class.paper_trail_version_class.object_col_is_json? + @model_class.paper_trail.version_class.object_col_is_json? end end end diff --git a/lib/paper_trail/attribute_serializers/object_changes_attribute.rb b/lib/paper_trail/attribute_serializers/object_changes_attribute.rb index 682c7b06a..9195a4b62 100644 --- a/lib/paper_trail/attribute_serializers/object_changes_attribute.rb +++ b/lib/paper_trail/attribute_serializers/object_changes_attribute.rb @@ -35,7 +35,7 @@ def alter(changes, serialization_method) end def object_changes_col_is_json? - @item_class.paper_trail_version_class.object_changes_col_is_json? + @item_class.paper_trail.version_class.object_changes_col_is_json? end end end diff --git a/lib/paper_trail/has_paper_trail.rb b/lib/paper_trail/has_paper_trail.rb index e31502960..05bc527ea 100644 --- a/lib/paper_trail/has_paper_trail.rb +++ b/lib/paper_trail/has_paper_trail.rb @@ -2,13 +2,17 @@ require "paper_trail/attribute_serializers/legacy_active_record_shim" require "paper_trail/attribute_serializers/object_attribute" require "paper_trail/attribute_serializers/object_changes_attribute" +require "paper_trail/model_config" +require "paper_trail/record_trail" module PaperTrail # Extensions to `ActiveRecord::Base`. See `frameworks/active_record.rb`. + # It is our goal to have the smallest possible footprint here, because + # `ActiveRecord::Base` is a very crowded namespace! That is why we introduced + # `.paper_trail` and `#paper_trail`. module Model def self.included(base) base.send :extend, ClassMethods - base.send :attr_accessor, :paper_trail_habtm end # :nodoc: @@ -58,534 +62,274 @@ module ClassMethods # # @api public def has_paper_trail(options = {}) - options[:on] ||= [:create, :update, :destroy] - - # Wrap the :on option in an array if necessary. This allows a single - # symbol to be passed in. - options[:on] = Array(options[:on]) - - setup_model_for_paper_trail(options) - - setup_callbacks_from_options options[:on] - - setup_callbacks_for_habtm options[:join_tables] - end - - def update_for_callback(name, callback, model, assoc) - model.paper_trail_habtm ||= {} - model.paper_trail_habtm.reverse_merge!(name => { removed: [], added: [] }) - case callback - when :before_add - model.paper_trail_habtm[name][:added] |= [assoc.id] - model.paper_trail_habtm[name][:removed] -= [assoc.id] - when :before_remove - model.paper_trail_habtm[name][:removed] |= [assoc.id] - model.paper_trail_habtm[name][:added] -= [assoc.id] - end + paper_trail.setup(options) end - attr_reader :paper_trail_save_join_tables - - def setup_callbacks_for_habtm(join_tables) - @paper_trail_save_join_tables = Array.wrap(join_tables) - # Adds callbacks to record changes to habtm associations such that on - # save the previous version of the association (if changed) can be - # interpreted - reflect_on_all_associations(:has_and_belongs_to_many). - reject { |a| paper_trail_options[:skip].include?(a.name.to_s) }. - each do |a| - added_callback = lambda do |*args| - update_for_callback(a.name, :before_add, args[-2], args.last) - end - removed_callback = lambda do |*args| - update_for_callback(a.name, :before_remove, args[-2], args.last) - end - send(:"before_add_for_#{a.name}").send(:<<, added_callback) - send(:"before_remove_for_#{a.name}").send(:<<, removed_callback) - end - end - - # Installs callbacks, associations, "class attributes", and more. - # For details of how "class attributes" work, see the activesupport docs. - # @api private - def setup_model_for_paper_trail(options = {}) - # Lazily include the instance methods so we don't clutter up - # any more ActiveRecord models than we have to. - send :include, InstanceMethods - - if ::ActiveRecord::VERSION::STRING < "4.2" - send :extend, AttributeSerializers::LegacyActiveRecordShim - end - - class_attribute :version_association_name - self.version_association_name = options[:version] || :version - - # The version this instance was reified from. - attr_accessor version_association_name - - class_attribute :version_class_name - self.version_class_name = options[:class_name] || "PaperTrail::Version" - - setup_paper_trail_options(options) - - class_attribute :versions_association_name - self.versions_association_name = options[:versions] || :versions - - attr_accessor :paper_trail_event - - # `has_many` syntax for specifying order uses a lambda in Rails 4 - if ::ActiveRecord::VERSION::MAJOR >= 4 - has_many versions_association_name, - -> { order(model.timestamp_sort_order) }, - class_name: version_class_name, as: :item - else - has_many versions_association_name, - class_name: version_class_name, - as: :item, - order: paper_trail_version_class.timestamp_sort_order - end - - # Reset the transaction id when the transaction is closed. - after_commit :reset_transaction_id - after_rollback :reset_transaction_id - after_rollback :clear_rolled_back_versions + # @api public + def paper_trail + ::PaperTrail::ModelConfig.new(self) end - # Given `options`, populates `paper_trail_options`. # @api private - def setup_paper_trail_options(options) - class_attribute :paper_trail_options - self.paper_trail_options = options.dup - [:ignore, :skip, :only].each do |k| - paper_trail_options[k] = [paper_trail_options[k]].flatten.compact.map { |attr| - attr.is_a?(Hash) ? attr.stringify_keys : attr.to_s - } - end - paper_trail_options[:meta] ||= {} - if paper_trail_options[:save_changes].nil? - paper_trail_options[:save_changes] = true - end + def paper_trail_deprecate(new_method, old_method = nil) + old = old_method.nil? ? new_method : old_method + msg = format("Use paper_trail.%s instead of %s", new_method, old) + ::ActiveSupport::Deprecation.warn(msg, caller(2)) end - def setup_callbacks_from_options(options_on = []) - options_on.each do |option| - send "paper_trail_on_#{option}" - end - end - - # Record version before or after "destroy" event - def paper_trail_on_destroy(recording_order = "before") - unless %w(after before).include?(recording_order.to_s) - raise ArgumentError, 'recording order can only be "after" or "before"' - end - - if recording_order.to_s == "after" && - Gem::Version.new(ActiveRecord::VERSION::STRING).release >= Gem::Version.new("5") - if ::ActiveRecord::Base.belongs_to_required_by_default - ::ActiveSupport::Deprecation.warn( - "paper_trail_on_destroy(:after) is incompatible with ActiveRecord " + - "belongs_to_required_by_default and has no effect. Please use :before " + - "or disable belongs_to_required_by_default." - ) - end - end - - send "#{recording_order}_destroy", :record_destroy, if: :save_version? - - return if paper_trail_options[:on].include?(:destroy) - paper_trail_options[:on] << :destroy + # @deprecated + def paper_trail_on_destroy(*args) + paper_trail_deprecate "on_destroy", "paper_trail_on_destroy" + paper_trail.on_destroy(*args) end - # Record version after "update" event + # @deprecated def paper_trail_on_update - before_save :reset_timestamp_attrs_for_update_if_needed!, on: :update - after_update :record_update, if: :save_version? - after_update :clear_version_instance! - - return if paper_trail_options[:on].include?(:update) - paper_trail_options[:on] << :update + paper_trail_deprecate "on_update", "paper_trail_on_update" + paper_trail.on_update end - # Record version after "create" event + # @deprecated def paper_trail_on_create - after_create :record_create, if: :save_version? - - return if paper_trail_options[:on].include?(:create) - paper_trail_options[:on] << :create + paper_trail_deprecate "on_create", "paper_trail_on_create" + paper_trail.on_create end - # Switches PaperTrail off for this class. + # @deprecated def paper_trail_off! - PaperTrail.enabled_for_model(self, false) + paper_trail_deprecate "disable", "paper_trail_off!" + paper_trail.disable end - # Switches PaperTrail on for this class. + # @deprecated def paper_trail_on! - PaperTrail.enabled_for_model(self, true) + paper_trail_deprecate "enable", "paper_trail_on!" + paper_trail.enable end + # @deprecated def paper_trail_enabled_for_model? - return false unless include?(PaperTrail::Model::InstanceMethods) - PaperTrail.enabled_for_model?(self) + paper_trail_deprecate "enabled?", "paper_trail_enabled_for_model?" + paper_trail.enabled? end + # @deprecated def paper_trail_version_class - @paper_trail_version_class ||= version_class_name.constantize + paper_trail_deprecate "version_class", "paper_trail_version_class" + paper_trail.version_class end end # Wrap the following methods in a module so we can include them only in the # ActiveRecord models that declare `has_paper_trail`. module InstanceMethods - # Returns true if this instance is the current, live one; - # returns false if this instance came from a previous version. + def paper_trail + ::PaperTrail::RecordTrail.new(self) + end + + # @deprecated def live? - source_version.nil? + self.class.paper_trail_deprecate "live?" + paper_trail.live? end - # Returns who put the object into its current state. + # @deprecated def paper_trail_originator - (source_version || send(self.class.versions_association_name).last).try(:whodunnit) + self.class.paper_trail_deprecate "originator", "paper_trail_originator" + paper_trail.originator end + # @deprecated def originator - ::ActiveSupport::Deprecation.warn "Use paper_trail_originator instead of originator." - paper_trail_originator + self.class.paper_trail_deprecate "originator" + paper_trail.originator 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. + # @deprecated def clear_rolled_back_versions - send(self.class.versions_association_name).reset + self.class.paper_trail_deprecate "clear_rolled_back_versions" + paper_trail.clear_rolled_back_versions 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 = send(self.class.versions_association_name).subsequent(timestamp, true).first - return v.reify(reify_options) if v - self unless destroyed? + # @deprecated + def source_version + self.class.paper_trail_deprecate "source_version" + paper_trail.source_version end - # Returns the objects (not Versions) as they were between the given times. - # TODO: Either add support for the third argument, `_reify_options`, or - # add a deprecation warning if someone tries to use it. + # @deprecated + def version_at(*args) + self.class.paper_trail_deprecate "version_at" + paper_trail.version_at(*args) + end + + # @deprecated def versions_between(start_time, end_time, _reify_options = {}) - versions = send(self.class.versions_association_name).between(start_time, end_time) - versions.collect { |version| version_at(version.send(PaperTrail.timestamp_field)) } + self.class.paper_trail_deprecate "versions_between" + paper_trail.versions_between(start_time, end_time) end - # Returns the object (not a Version) as it was most recently. + # @deprecated def previous_version - previous = - if source_version - source_version.previous - else - send(self.class.versions_association_name).last - end - previous.try(:reify) - 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? + self.class.paper_trail_deprecate "previous_version" + paper_trail.previous_version + end + + # @deprecated def next_version - subsequent_version = source_version.next - subsequent_version ? subsequent_version.reify : self.class.find(id) - rescue - nil + self.class.paper_trail_deprecate "next_version" + paper_trail.next_version end + # @deprecated def paper_trail_enabled_for_model? - self.class.paper_trail_enabled_for_model? - end - - # Executes the given method or block without creating a new version. - def without_versioning(method = nil) - paper_trail_was_enabled = paper_trail_enabled_for_model? - self.class.paper_trail_off! - method ? method.to_proc.call(self) : yield(self) - ensure - self.class.paper_trail_on! if paper_trail_was_enabled - end - - # Utility method for reifying. Anything executed inside the block will - # appear like a new record. - def appear_as_new_record - instance_eval { - alias :old_new_record? :new_record? - alias :new_record? :present? - } - yield - instance_eval { alias :new_record? :old_new_record? } - end - - # Temporarily overwrites the value of whodunnit and then executes the - # provided block. - def whodunnit(value) - raise ArgumentError, "expected to receive a block" unless block_given? - current_whodunnit = PaperTrail.whodunnit - PaperTrail.whodunnit = value - yield self - ensure - PaperTrail.whodunnit = current_whodunnit - end - - # Mimics the `touch` method from `ActiveRecord::Persistence`, but also - # creates a version. A version is created regardless of options such as - # `:on`, `:if`, or `:unless`. - # - # TODO: look into leveraging the `after_touch` callback from - # `ActiveRecord` to allow the regular `touch` method to generate a version - # as normal. May make sense to switch the `record_update` method to - # leverage an `after_update` callback anyways (likely for v4.0.0) - def touch_with_version(name = nil) - raise ActiveRecordError, "can not touch on a new record object" unless persisted? - - attributes = timestamp_attributes_for_update_in_model - attributes << name if name - current_time = current_time_from_proper_timezone - - attributes.each { |column| write_attribute(column, current_time) } + self.class.paper_trail_deprecate "enabled_for_model?", "paper_trail_enabled_for_model?" + paper_trail.enabled_for_model? + end - record_update(true) unless will_record_after_update? - save!(validate: false) + # @deprecated + def without_versioning(method = nil, &block) + self.class.paper_trail_deprecate "without_versioning" + paper_trail.without_versioning(method, &block) end - private + # @deprecated + def appear_as_new_record(&block) + self.class.paper_trail_deprecate "appear_as_new_record" + paper_trail.appear_as_new_record(&block) + end - # Returns true if `save` will cause `record_update` - # to be called via the `after_update` callback. - def will_record_after_update? - on = paper_trail_options[:on] - on.nil? || on.include?(:update) + # @deprecated + def whodunnit(value, &block) + self.class.paper_trail_deprecate "whodunnit" + paper_trail.whodunnit(value, &block) end - def source_version - send self.class.version_association_name + # @deprecated + def touch_with_version(name = nil) + self.class.paper_trail_deprecate "touch_with_version" + paper_trail.touch_with_version(name) end + # `record_create` is deprecated in favor of `paper_trail.record_create`, + # but does not yet print a deprecation warning. When the `after_create` + # callback is registered (by ModelConfig#on_create) we still refer to this + # method by name, e.g. + # + # @model_class.after_create :record_create, if: :save_version? + # + # instead of using the preferred method `paper_trail.record_create`, e.g. + # + # @model_class.after_create { |r| r.paper_trail.record_create if r.save_version?} + # + # We still register the callback by name so that, if someone calls + # `has_paper_trail` twice, the callback will *not* be registered twice. + # Our own test suite calls `has_paper_trail` many times for the same + # class. + # + # In the future, perhaps we should require that users only set up + # PT once per class. + # + # @deprecated def record_create - if paper_trail_switched_on? - data = { - event: paper_trail_event || "create", - whodunnit: PaperTrail.whodunnit - } - if respond_to?(:updated_at) - data[PaperTrail.timestamp_field] = updated_at - end - if pt_record_object_changes? && changed_notably? - data[:object_changes] = pt_recordable_object_changes - end - add_transaction_id_to(data) - version = send(self.class.versions_association_name).create! merge_metadata(data) - update_transaction_id(version) - save_associations(version) - end + paper_trail.record_create end + # See deprecation comment for `record_create` + # @deprecated def record_update(force = nil) - if paper_trail_switched_on? && (force || changed_notably?) - data = { - event: paper_trail_event || "update", - object: pt_recordable_object, - whodunnit: PaperTrail.whodunnit - } - if respond_to?(:updated_at) - data[PaperTrail.timestamp_field] = updated_at - end - if pt_record_object_changes? - data[:object_changes] = pt_recordable_object_changes - end - add_transaction_id_to(data) - version = send(self.class.versions_association_name).create merge_metadata(data) - if version.errors.any? - log_version_errors(version, :update) - else - update_transaction_id(version) - save_associations(version) - end - end + paper_trail.record_update(force) end - # Returns a boolean indicating whether to store serialized version diffs - # in the `object_changes` column of the version record. - # @api private + # @deprecated def pt_record_object_changes? - paper_trail_options[:save_changes] && - self.class.paper_trail_version_class.column_names.include?("object_changes") + self.class.paper_trail_deprecate "record_object_changes?", "pt_record_object_changes?" + paper_trail.record_object_changes? end - # Returns an object which can be assigned to the `object` attribute of a - # nascent version record. If the `object` column is a postgres `json` - # column, then a hash can be used in the assignment, otherwise the column - # is a `text` column, and we must perform the serialization here, using - # `PaperTrail.serializer`. - # @api private + # @deprecated def pt_recordable_object - if self.class.paper_trail_version_class.object_col_is_json? - object_attrs_for_paper_trail - else - PaperTrail.serializer.dump(object_attrs_for_paper_trail) - end + self.class.paper_trail_deprecate "recordable_object", "pt_recordable_object" + paper_trail.recordable_object end - # Returns an object which can be assigned to the `object_changes` - # attribute of a nascent version record. If the `object_changes` column is - # a postgres `json` column, then a hash can be used in the assignment, - # otherwise the column is a `text` column, and we must perform the - # serialization here, using `PaperTrail.serializer`. - # @api private + # @deprecated def pt_recordable_object_changes - if self.class.paper_trail_version_class.object_changes_col_is_json? - changes_for_paper_trail - else - PaperTrail.serializer.dump(changes_for_paper_trail) - end + self.class.paper_trail_deprecate "recordable_object_changes", "pt_recordable_object_changes" + paper_trail.recordable_object_changes end + # @deprecated def changes_for_paper_trail - notable_changes = changes.delete_if { |k, _v| !notably_changed.include?(k) } - AttributeSerializers::ObjectChangesAttribute. - new(self.class). - serialize(notable_changes) - notable_changes.to_hash + self.class.paper_trail_deprecate "changes", "changes_for_paper_trail" + paper_trail.changes end - # Invoked via`after_update` callback for when a previous version is - # reified and then saved. + # See deprecation comment for `record_create` + # @deprecated def clear_version_instance! - send("#{self.class.version_association_name}=", nil) + paper_trail.clear_version_instance end - # Invoked via callback when a user attempts to persist a reified - # `Version`. + # See deprecation comment for `record_create` + # @deprecated def reset_timestamp_attrs_for_update_if_needed! - return if live? - timestamp_attributes_for_update_in_model.each do |column| - # ActiveRecord 4.2 deprecated `reset_column!` in favor of - # `restore_column!`. - if respond_to?("restore_#{column}!") - send("restore_#{column}!") - else - send("reset_#{column}!") - end - end + paper_trail.reset_timestamp_attrs_for_update_if_needed end + # See deprecation comment for `record_create` + # @deprecated def record_destroy - if paper_trail_switched_on? && !new_record? - data = { - item_id: id, - item_type: self.class.base_class.name, - event: paper_trail_event || "destroy", - object: pt_recordable_object, - whodunnit: PaperTrail.whodunnit - } - add_transaction_id_to(data) - version = self.class.paper_trail_version_class.create(merge_metadata(data)) - if version.errors.any? - log_version_errors(version, :destroy) - else - send("#{self.class.version_association_name}=", version) - send(self.class.versions_association_name).reset - update_transaction_id(version) - save_associations(version) - end - end + paper_trail.record_destroy end - # Saves associations if the join table for `VersionAssociation` exists. + # @deprecated def save_associations(version) - return unless PaperTrail.config.track_associations? - save_associations_belongs_to(version) - save_associations_has_and_belongs_to_many(version) + self.class.paper_trail_deprecate "save_associations" + paper_trail.save_associations(version) end + # @deprecated def save_associations_belongs_to(version) - self.class.reflect_on_all_associations(:belongs_to).each do |assoc| - assoc_version_args = { - version_id: version.id, - foreign_key_name: assoc.foreign_key - } - - if assoc.options[:polymorphic] - associated_record = send(assoc.name) if send(assoc.foreign_type) - if associated_record && associated_record.class.paper_trail_enabled_for_model? - assoc_version_args[:foreign_key_id] = associated_record.id - end - elsif assoc.klass.paper_trail_enabled_for_model? - assoc_version_args[:foreign_key_id] = send(assoc.foreign_key) - end - - if assoc_version_args.key?(:foreign_key_id) - PaperTrail::VersionAssociation.create(assoc_version_args) - end - end + self.class.paper_trail_deprecate "save_associations_belongs_to" + paper_trail.save_associations_belongs_to(version) end + # @deprecated def save_associations_has_and_belongs_to_many(version) - # Use the :added and :removed keys to extrapolate the HABTM associations - # to before any changes were made - self.class.reflect_on_all_associations(:has_and_belongs_to_many).each do |a| - next unless - self.class.paper_trail_save_join_tables.include?(a.name) || - a.klass.paper_trail_enabled_for_model? - assoc_version_args = { - version_id: version.transaction_id, - foreign_key_name: a.name - } - assoc_ids = - send(a.name).to_a.map(&:id) + - (@paper_trail_habtm.try(:[], a.name).try(:[], :removed) || []) - - (@paper_trail_habtm.try(:[], a.name).try(:[], :added) || []) - assoc_ids.each do |id| - PaperTrail::VersionAssociation.create(assoc_version_args.merge(foreign_key_id: id)) - end - end + self.class.paper_trail_deprecate( + "save_associations_habtm", + "save_associations_has_and_belongs_to_many" + ) + paper_trail.save_associations_habtm(version) end + # @deprecated + # @api private def reset_transaction_id - PaperTrail.transaction_id = nil + ::ActiveSupport::Deprecation.warn( + "reset_transaction_id is deprecated, use PaperTrail.clear_transaction_id" + ) + PaperTrail.clear_transaction_id end + # @deprecated + # @api private def merge_metadata(data) - # First we merge the model-level metadata in `meta`. - paper_trail_options[:meta].each do |k, v| - data[k] = - if v.respond_to?(:call) - v.call(self) - elsif v.is_a?(Symbol) && respond_to?(v, true) - # If it is an attribute that is changing in an existing object, - # be sure to grab the current version. - if has_attribute?(v) && send("#{v}_changed?".to_sym) && data[:event] != "create" - send("#{v}_was".to_sym) - else - send(v) - end - else - v - end - end - - # Second we merge any extra data from the controller (if available). - data.merge(PaperTrail.controller_info || {}) + self.class.paper_trail_deprecate "merge_metadata" + paper_trail.merge_metadata(data) end + # @deprecated def attributes_before_change - changed = changed_attributes.select { |k, _v| self.class.column_names.include?(k) } - attributes.merge(changed) + self.class.paper_trail_deprecate "attributes_before_change" + paper_trail.attributes_before_change end - # Returns hash of attributes (with appropriate attributes serialized), - # ommitting attributes to be skipped. + # @deprecated def object_attrs_for_paper_trail - attrs = attributes_before_change.except(*paper_trail_options[:skip]) - AttributeSerializers::ObjectAttribute.new(self.class).serialize(attrs) - attrs + self.class.paper_trail_deprecate "object_attrs_for_paper_trail" + paper_trail.object_attrs_for_paper_trail end # Determines whether it is appropriate to generate a new version @@ -638,7 +382,7 @@ def changed_and_not_ignored def paper_trail_switched_on? PaperTrail.enabled? && PaperTrail.enabled_for_controller? && - paper_trail_enabled_for_model? + paper_trail.enabled_for_model? end def save_version? @@ -648,13 +392,13 @@ def save_version? end def add_transaction_id_to(data) - return unless self.class.paper_trail_version_class.column_names.include?("transaction_id") + return unless self.class.paper_trail.version_class.column_names.include?("transaction_id") data[:transaction_id] = PaperTrail.transaction_id end # @api private def update_transaction_id(version) - return unless self.class.paper_trail_version_class.column_names.include?("transaction_id") + return unless self.class.paper_trail.version_class.column_names.include?("transaction_id") if PaperTrail.transaction? && PaperTrail.transaction_id.nil? PaperTrail.transaction_id = version.id version.transaction_id = version.id diff --git a/lib/paper_trail/model_config.rb b/lib/paper_trail/model_config.rb new file mode 100644 index 000000000..f61df3a5c --- /dev/null +++ b/lib/paper_trail/model_config.rb @@ -0,0 +1,194 @@ +require "active_support/core_ext" + +module PaperTrail + # Configures an ActiveRecord model, mostly at application boot time, but also + # sometimes mid-request, with methods like enable/disable. + class ModelConfig + E_CANNOT_RECORD_AFTER_DESTROY = <<-STR.strip_heredoc.freeze + paper_trail.on_destroy(:after) is incompatible with ActiveRecord's + belongs_to_required_by_default and has no effect. Please use :before + or disable belongs_to_required_by_default. + STR + + def initialize(model_class) + @model_class = model_class + end + + # Switches PaperTrail off for this class. + def disable + ::PaperTrail.enabled_for_model(@model_class, false) + end + + # Switches PaperTrail on for this class. + def enable + ::PaperTrail.enabled_for_model(@model_class, true) + end + + def enabled? + return false unless @model_class.include?(::PaperTrail::Model::InstanceMethods) + ::PaperTrail.enabled_for_model?(@model_class) + end + + # Adds a callback that records a version after a "create" event. + def on_create + @model_class.after_create :record_create, if: :save_version? + return if @model_class.paper_trail_options[:on].include?(:create) + @model_class.paper_trail_options[:on] << :create + end + + # Adds a callback that records a version before or after a "destroy" event. + def on_destroy(recording_order = "before") + unless %w(after before).include?(recording_order.to_s) + raise ArgumentError, 'recording order can only be "after" or "before"' + end + + if recording_order.to_s == "after" && cannot_record_after_destroy? + ::ActiveSupport::Deprecation.warn(E_CANNOT_RECORD_AFTER_DESTROY) + end + + @model_class.send "#{recording_order}_destroy", :record_destroy, if: :save_version? + + return if @model_class.paper_trail_options[:on].include?(:destroy) + @model_class.paper_trail_options[:on] << :destroy + end + + # Adds a callback that records a version after an "update" event. + def on_update + @model_class.before_save :reset_timestamp_attrs_for_update_if_needed!, on: :update + @model_class.after_update :record_update, if: :save_version? + @model_class.after_update :clear_version_instance! + return if @model_class.paper_trail_options[:on].include?(:update) + @model_class.paper_trail_options[:on] << :update + end + + # Set up `@model_class` for PaperTrail. Installs callbacks, associations, + # "class attributes", instance methods, and more. + # @api private + def setup(options = {}) + options[:on] ||= [:create, :update, :destroy] + options[:on] = Array(options[:on]) # Support single symbol + @model_class.send :include, ::PaperTrail::Model::InstanceMethods + if ::ActiveRecord::VERSION::STRING < "4.2" + @model_class.send :extend, AttributeSerializers::LegacyActiveRecordShim + end + setup_options(options) + setup_associations(options) + setup_transaction_callbacks + setup_callbacks_from_options options[:on] + setup_callbacks_for_habtm options[:join_tables] + end + + def version_class + @_version_class ||= @model_class.version_class_name.constantize + end + + private + + def active_record_gem_version + Gem::Version.new(ActiveRecord::VERSION::STRING) + end + + def cannot_record_after_destroy? + Gem::Version.new(ActiveRecord::VERSION::STRING).release >= Gem::Version.new("5") && + ::ActiveRecord::Base.belongs_to_required_by_default + end + + def setup_associations(options) + @model_class.class_attribute :version_association_name + @model_class.version_association_name = options[:version] || :version + + # The version this instance was reified from. + @model_class.send :attr_accessor, @model_class.version_association_name + + @model_class.class_attribute :version_class_name + @model_class.version_class_name = options[:class_name] || "PaperTrail::Version" + + @model_class.class_attribute :versions_association_name + @model_class.versions_association_name = options[:versions] || :versions + + @model_class.send :attr_accessor, :paper_trail_event + + # In rails 4, the `has_many` syntax for specifying order uses a lambda. + if ::ActiveRecord::VERSION::MAJOR >= 4 + @model_class.has_many( + @model_class.versions_association_name, + -> { order(model.timestamp_sort_order) }, + class_name: @model_class.version_class_name, + as: :item + ) + else + @model_class.has_many( + @model_class.versions_association_name, + class_name: @model_class.version_class_name, + as: :item, + order: @model_class.paper_trail_version_class.timestamp_sort_order + ) + end + end + + # Adds callbacks to record changes to habtm associations such that on save + # the previous version of the association (if changed) can be interpreted. + def setup_callbacks_for_habtm(join_tables) + @model_class.send :attr_accessor, :paper_trail_habtm + @model_class.class_attribute :paper_trail_save_join_tables + @model_class.paper_trail_save_join_tables = Array.wrap(join_tables) + @model_class.reflect_on_all_associations(:has_and_belongs_to_many). + reject { |a| @model_class.paper_trail_options[:skip].include?(a.name.to_s) }. + each { |a| + added_callback = lambda do |*args| + update_habtm_state(a.name, :before_add, args[-2], args.last) + end + removed_callback = lambda do |*args| + update_habtm_state(a.name, :before_remove, args[-2], args.last) + end + @model_class.send(:"before_add_for_#{a.name}").send(:<<, added_callback) + @model_class.send(:"before_remove_for_#{a.name}").send(:<<, removed_callback) + } + end + + def setup_callbacks_from_options(options_on = []) + options_on.each do |event| + public_send("on_#{event}") + end + end + + def setup_options(options) + @model_class.class_attribute :paper_trail_options + @model_class.paper_trail_options = options.dup + + [:ignore, :skip, :only].each do |k| + @model_class.paper_trail_options[k] = [@model_class.paper_trail_options[k]]. + flatten. + compact. + map { |attr| attr.is_a?(Hash) ? attr.stringify_keys : attr.to_s } + end + + @model_class.paper_trail_options[:meta] ||= {} + if @model_class.paper_trail_options[:save_changes].nil? + @model_class.paper_trail_options[:save_changes] = true + end + end + + # Reset the transaction id when the transaction is closed. + def setup_transaction_callbacks + @model_class.after_commit { PaperTrail.clear_transaction_id } + @model_class.after_rollback { PaperTrail.clear_transaction_id } + @model_class.after_rollback { paper_trail.clear_rolled_back_versions } + end + + def update_habtm_state(name, callback, model, assoc) + model.paper_trail_habtm ||= {} + model.paper_trail_habtm.reverse_merge!(name => { removed: [], added: [] }) + case callback + when :before_add + model.paper_trail_habtm[name][:added] |= [assoc.id] + model.paper_trail_habtm[name][:removed] -= [assoc.id] + when :before_remove + model.paper_trail_habtm[name][:removed] |= [assoc.id] + model.paper_trail_habtm[name][:added] -= [assoc.id] + else + raise "Invalid callback: #{callback}" + end + end + end +end diff --git a/lib/paper_trail/record_trail.rb b/lib/paper_trail/record_trail.rb new file mode 100644 index 000000000..85f767802 --- /dev/null +++ b/lib/paper_trail/record_trail.rb @@ -0,0 +1,370 @@ +module PaperTrail + # Represents the "paper trail" for a single record. + class RecordTrail + def initialize(record) + @record = record + end + + # Utility method for reifying. Anything executed inside the block will + # appear like a new record. + def appear_as_new_record + @record.instance_eval { + alias :old_new_record? :new_record? + alias :new_record? :present? + } + yield + @record.instance_eval { alias :new_record? :old_new_record? } + end + + def attributes_before_change + changed = @record.changed_attributes.select { |k, _v| + @record.class.column_names.include?(k) + } + @record.attributes.merge(changed) + 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 + + # @api private + def changes + notable_changes = @record.changes.delete_if { |k, _v| + !@record.notably_changed.include?(k) + } + AttributeSerializers::ObjectChangesAttribute. + new(@record.class). + serialize(notable_changes) + notable_changes.to_hash + end + + def enabled_for_model? + @record.class.paper_trail.enabled? + 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 + + # @api private + def merge_metadata(data) + # First we merge the model-level metadata in `meta`. + @record.paper_trail_options[:meta].each do |k, v| + data[k] = + if v.respond_to?(:call) + v.call(@record) + elsif v.is_a?(Symbol) && @record.respond_to?(v, true) + # If it is an attribute that is changing in an existing object, + # be sure to grab the current version. + if @record.has_attribute?(v) && + @record.send("#{v}_changed?".to_sym) && + data[:event] != "create" + @record.send("#{v}_was".to_sym) + else + @record.send(v) + end + else + v + end + end + + # Second we merge any extra data from the controller (if available). + data.merge(PaperTrail.controller_info || {}) + 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 # TODO: Rescue something more specific + nil + end + + # Returns hash of attributes (with appropriate attributes serialized), + # omitting attributes to be skipped. + def object_attrs_for_paper_trail + attrs = attributes_before_change.except(*@record.paper_trail_options[:skip]) + AttributeSerializers::ObjectAttribute.new(@record.class).serialize(attrs) + attrs + end + + # Returns who put `@record` into its current state. + def originator + (source_version || versions.last).try(:whodunnit) + end + + # Returns the object (not a Version) as it was most recently. + def previous_version + (source_version ? source_version.previous : versions.last).try(:reify) + end + + def record_create + return unless @record.paper_trail_switched_on? + data = { + event: @record.paper_trail_event || "create", + whodunnit: PaperTrail.whodunnit + } + if @record.respond_to?(:updated_at) + data[PaperTrail.timestamp_field] = @record.updated_at + end + if record_object_changes? && @record.changed_notably? + data[:object_changes] = recordable_object_changes + end + @record.add_transaction_id_to(data) + versions_assoc = @record.send(@record.class.versions_association_name) + version = versions_assoc.create! merge_metadata(data) + @record.update_transaction_id(version) + save_associations(version) + end + + def record_destroy + if @record.paper_trail_switched_on? && !@record.new_record? + data = { + item_id: @record.id, + item_type: @record.class.base_class.name, + event: @record.paper_trail_event || "destroy", + object: recordable_object, + whodunnit: PaperTrail.whodunnit + } + @record.add_transaction_id_to(data) + version = @record.class.paper_trail.version_class.create(merge_metadata(data)) + if version.errors.any? + @record.log_version_errors(version, :destroy) + else + @record.send("#{@record.class.version_association_name}=", version) + @record.send(@record.class.versions_association_name).reset + @record.update_transaction_id(version) + save_associations(version) + end + end + end + + # Returns a boolean indicating whether to store serialized version diffs + # in the `object_changes` column of the version record. + # @api private + def record_object_changes? + @record.paper_trail_options[:save_changes] && + @record.class.paper_trail.version_class.column_names.include?("object_changes") + end + + def record_update(force) + if @record.paper_trail_switched_on? && (force || @record.changed_notably?) + data = { + event: @record.paper_trail_event || "update", + object: recordable_object, + whodunnit: PaperTrail.whodunnit + } + if @record.respond_to?(:updated_at) + data[PaperTrail.timestamp_field] = @record.updated_at + end + if record_object_changes? + data[:object_changes] = recordable_object_changes + end + @record.add_transaction_id_to(data) + versions_assoc = @record.send(@record.class.versions_association_name) + version = versions_assoc.create(merge_metadata(data)) + if version.errors.any? + @record.log_version_errors(version, :update) + else + @record.update_transaction_id(version) + save_associations(version) + end + end + end + + # Returns an object which can be assigned to the `object` attribute of a + # nascent version record. If the `object` column is a postgres `json` + # column, then a hash can be used in the assignment, otherwise the column + # is a `text` column, and we must perform the serialization here, using + # `PaperTrail.serializer`. + # @api private + def recordable_object + if @record.class.paper_trail.version_class.object_col_is_json? + object_attrs_for_paper_trail + else + PaperTrail.serializer.dump(object_attrs_for_paper_trail) + end + end + + # Returns an object which can be assigned to the `object_changes` + # attribute of a nascent version record. If the `object_changes` column is + # a postgres `json` column, then a hash can be used in the assignment, + # otherwise the column is a `text` column, and we must perform the + # serialization here, using `PaperTrail.serializer`. + # @api private + def recordable_object_changes + if @record.class.paper_trail.version_class.object_changes_col_is_json? + changes + else + PaperTrail.serializer.dump(changes) + end + 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| + # ActiveRecord 4.2 deprecated `reset_column!` in favor of + # `restore_column!`. + if @record.respond_to?("restore_#{column}!") + @record.send("restore_#{column}!") + else + @record.send("reset_#{column}!") + end + end + end + + # Saves associations if the join table for `VersionAssociation` exists. + def save_associations(version) + return unless PaperTrail.config.track_associations? + save_associations_belongs_to(version) + save_associations_habtm(version) + end + + def save_associations_belongs_to(version) + @record.class.reflect_on_all_associations(:belongs_to).each do |assoc| + assoc_version_args = { + version_id: version.id, + foreign_key_name: assoc.foreign_key + } + + if assoc.options[:polymorphic] + associated_record = @record.send(assoc.name) if @record.send(assoc.foreign_type) + if associated_record && associated_record.class.paper_trail.enabled? + assoc_version_args[:foreign_key_id] = associated_record.id + end + elsif assoc.klass.paper_trail.enabled? + assoc_version_args[:foreign_key_id] = @record.send(assoc.foreign_key) + end + + if assoc_version_args.key?(:foreign_key_id) + PaperTrail::VersionAssociation.create(assoc_version_args) + end + end + end + + def save_associations_habtm(version) + # Use the :added and :removed keys to extrapolate the HABTM associations + # to before any changes were made + @record.class.reflect_on_all_associations(:has_and_belongs_to_many).each do |a| + next unless + @record.class.paper_trail_save_join_tables.include?(a.name) || + a.klass.paper_trail.enabled? + assoc_version_args = { + version_id: version.transaction_id, + foreign_key_name: a.name + } + assoc_ids = + @record.send(a.name).to_a.map(&:id) + + (@record.paper_trail_habtm.try(:[], a.name).try(:[], :removed) || []) - + (@record.paper_trail_habtm.try(:[], a.name).try(:[], :added) || []) + assoc_ids.each do |id| + PaperTrail::VersionAssociation.create(assoc_version_args.merge(foreign_key_id: id)) + end + end + end + + def source_version + version + end + + # Mimics the `touch` method from `ActiveRecord::Persistence`, but also + # creates a version. A version is created regardless of options such as + # `:on`, `:if`, or `:unless`. + # + # TODO: look into leveraging the `after_touch` callback from + # `ActiveRecord` to allow the regular `touch` method to generate a version + # as normal. May make sense to switch the `record_update` method to + # leverage an `after_update` callback anyways (likely for v4.0.0) + def touch_with_version(name = nil) + unless @record.persisted? + raise ActiveRecordError, "can not touch on a new record object" + end + attributes = @record.send :timestamp_attributes_for_update_in_model + attributes << name if name + current_time = @record.send :current_time_from_proper_timezone + attributes.each { |column| + @record.send(:write_attribute, column, current_time) + } + @record.record_update(true) unless will_record_after_update? + @record.save!(validate: false) + 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.send(PaperTrail.timestamp_field)) + } + end + + # Executes the given method or block without creating a new version. + def without_versioning(method = nil) + paper_trail_was_enabled = enabled_for_model? + @record.class.paper_trail.disable + if method + if respond_to?(method) + public_send(method) + else + @record.send(method) + end + else + yield @record + end + ensure + @record.class.paper_trail.enable if paper_trail_was_enabled + end + + # Temporarily overwrites the value of whodunnit and then executes the + # provided block. + def whodunnit(value) + raise ArgumentError, "expected to receive a block" unless block_given? + current_whodunnit = PaperTrail.whodunnit + PaperTrail.whodunnit = value + yield @record + ensure + PaperTrail.whodunnit = current_whodunnit + end + + private + + # Returns true if `save` will cause `record_update` + # to be called via the `after_update` callback. + def will_record_after_update? + on = @record.paper_trail_options[:on] + on.nil? || on.include?(:update) + 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 diff --git a/lib/paper_trail/reifier.rb b/lib/paper_trail/reifier.rb index 9fc339fc6..be23d86cd 100644 --- a/lib/paper_trail/reifier.rb +++ b/lib/paper_trail/reifier.rb @@ -65,7 +65,7 @@ def apply_defaults_to(options, version) # @api private def each_enabled_association(associations) associations.each do |assoc| - next unless assoc.klass.paper_trail_enabled_for_model? + next unless assoc.klass.paper_trail.enabled? yield assoc end end @@ -112,7 +112,7 @@ def hmt_collection_through_belongs_to(through_collection, assoc, options, transa collection_keys = through_collection.map { |through_model| through_model.send(assoc.source_reflection.foreign_key) } - version_id_subquery = assoc.klass.paper_trail_version_class. + version_id_subquery = assoc.klass.paper_trail.version_class. select("MIN(id)"). where("item_type = ?", assoc.class_name). where("item_id IN (?)", collection_keys). @@ -140,7 +140,7 @@ def init_unversioned_attrs(attrs, model) # from the point in time identified by `transaction_id` or `version_at`. # @api private def load_version_for_habtm(assoc, id, transaction_id, version_at) - assoc.klass.paper_trail_version_class. + assoc.klass.paper_trail.version_class. where("item_type = ?", assoc.klass.name). where("item_id = ?", id). where("created_at >= ? OR transaction_id = ?", version_at, transaction_id). @@ -153,8 +153,8 @@ def load_version_for_habtm(assoc, id, transaction_id, version_at) # record from the point in time identified by `transaction_id` or `version_at`. # @api private def load_version_for_has_one(assoc, model, transaction_id, version_at) - version_table_name = model.class.paper_trail_version_class.table_name - model.class.paper_trail_version_class.joins(:version_associations). + version_table_name = model.class.paper_trail.version_class.table_name + model.class.paper_trail.version_class.joins(:version_associations). where("version_associations.foreign_key_name = ?", assoc.foreign_key). where("version_associations.foreign_key_id = ?", model.id). where("#{version_table_name}.item_type = ?", assoc.class_name). @@ -263,7 +263,7 @@ def reify_has_ones(transaction_id, model, options = {}) if options[:mark_for_destruction] model.send(assoc.name).mark_for_destruction if model.send(assoc.name, true) else - model.appear_as_new_record do + model.paper_trail.appear_as_new_record do model.send "#{assoc.name}=", nil end end @@ -276,7 +276,7 @@ def reify_has_ones(transaction_id, model, options = {}) has_and_belongs_to_many: false ) ) - model.appear_as_new_record do + model.paper_trail.appear_as_new_record do without_persisting(child) do model.send "#{assoc.name}=", child end @@ -289,7 +289,7 @@ def reify_belongs_tos(transaction_id, model, options = {}) associations = model.class.reflect_on_all_associations(:belongs_to) each_enabled_association(associations) do |assoc| collection_key = model.send(assoc.association_foreign_key) - version = assoc.klass.paper_trail_version_class. + version = assoc.klass.paper_trail.version_class. where("item_type = ?", assoc.class_name). where("item_id = ?", collection_key). where("created_at >= ? OR transaction_id = ?", options[:version_at], transaction_id). @@ -326,7 +326,7 @@ def reify_has_manys(transaction_id, model, options = {}) # Restore the `model`'s has_many associations not associated through # another association. def reify_has_many_directly(transaction_id, 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 each_enabled_association(associations) do |assoc| version_id_subquery = PaperTrail::VersionAssociation. joins(model.class.version_association_name). @@ -366,7 +366,7 @@ def reify_has_many_through(transaction_id, associations, model, options = {}) def reify_has_and_belongs_to_many(transaction_id, model, options = {}) model.class.reflect_on_all_associations(:has_and_belongs_to_many).each do |assoc| - papertrail_enabled = assoc.klass.paper_trail_enabled_for_model? + papertrail_enabled = assoc.klass.paper_trail.enabled? next unless model.class.paper_trail_save_join_tables.include?(assoc.name) || papertrail_enabled @@ -429,7 +429,7 @@ def version_reification_class(version, attrs) # def versions_by_id(klass, version_id_subquery) klass. - paper_trail_version_class. + paper_trail.version_class. where("id IN (#{version_id_subquery})"). inject({}) { |a, e| a.merge!(e.item_id => e) } end diff --git a/spec/models/boolit_spec.rb b/spec/models/boolit_spec.rb index 478a98c7f..543d48dd0 100644 --- a/spec/models/boolit_spec.rb +++ b/spec/models/boolit_spec.rb @@ -28,7 +28,7 @@ end it "should still be able to be reified and persisted" do - expect { subject.previous_version.save! }.to_not raise_error + expect { subject.paper_trail.previous_version.save! }.to_not raise_error end context "with `nil` attributes on the live instance" do @@ -40,7 +40,7 @@ after { PaperTrail.serializer = PaperTrail::Serializers::YAML } it "should not overwrite that attribute during the reification process" do - expect(subject.previous_version.name).to be_nil + expect(subject.paper_trail.previous_version.name).to be_nil end end end diff --git a/spec/models/fluxor_spec.rb b/spec/models/fluxor_spec.rb index 7cd5669bd..7dc0e3f88 100644 --- a/spec/models/fluxor_spec.rb +++ b/spec/models/fluxor_spec.rb @@ -7,12 +7,10 @@ describe "Methods" do describe "Class" do - subject { Fluxor } - - describe "#paper_trail_enabled_for_model?" do - it { is_expected.to respond_to(:paper_trail_enabled_for_model?) } - - it { expect(subject.paper_trail_enabled_for_model?).to be false } + describe ".paper_trail.enabled?" do + it "returns false" do + expect(Fluxor.paper_trail.enabled?).to eq(false) + end end end end diff --git a/spec/models/gadget_spec.rb b/spec/models/gadget_spec.rb index 9d73021bf..c2d89fd48 100644 --- a/spec/models/gadget_spec.rb +++ b/spec/models/gadget_spec.rb @@ -28,8 +28,6 @@ describe '#changed_notably?' do subject { Gadget.new(created_at: Time.now) } - it { expect(subject.private_methods).to include(:changed_notably?) } - context "create events" do it { expect(subject.send(:changed_notably?)).to be true } end diff --git a/spec/models/not_on_update_spec.rb b/spec/models/not_on_update_spec.rb index 26ba41a9f..95304a1e0 100644 --- a/spec/models/not_on_update_spec.rb +++ b/spec/models/not_on_update_spec.rb @@ -5,7 +5,7 @@ let!(:record) { described_class.create! } it "should create a version, regardless" do - expect { record.touch_with_version }.to change { + expect { record.paper_trail.touch_with_version }.to change { PaperTrail::Version.count }.by(+1) end @@ -14,7 +14,7 @@ before = record.updated_at # Travel 1 second because MySQL lacks sub-second resolution Timecop.travel(1) do - record.touch_with_version + record.paper_trail.touch_with_version end expect(record.updated_at).to be > before end diff --git a/spec/models/post_with_status_spec.rb b/spec/models/post_with_status_spec.rb index 094629daa..8c9c9299f 100644 --- a/spec/models/post_with_status_spec.rb +++ b/spec/models/post_with_status_spec.rb @@ -10,7 +10,7 @@ it "should stash the enum value properly in versions" do post.published! post.archived! - expect(post.previous_version.published?).to be true + expect(post.paper_trail.previous_version.published?).to be true end context "storing enum object_changes" do diff --git a/spec/models/widget_spec.rb b/spec/models/widget_spec.rb index 9f60aa985..06a22b947 100644 --- a/spec/models/widget_spec.rb +++ b/spec/models/widget_spec.rb @@ -64,11 +64,11 @@ subject { widget.versions.last.reify } - it { expect(subject).not_to be_live } + it { expect(subject.paper_trail).not_to be_live } it "should clear the `versions_association_name` virtual attribute" do subject.save! - expect(subject).to be_live + expect(subject.paper_trail).to be_live end it "corresponding version should use the widget updated_at" do @@ -140,21 +140,21 @@ describe "Methods" do describe "Instance", versioning: true do - describe '#paper_trail_originator' do - it { is_expected.to respond_to(:paper_trail_originator) } - + describe '#paper_trail.originator' do describe "return value" do let(:orig_name) { FFaker::Name.name } let(:new_name) { FFaker::Name.name } before { PaperTrail.whodunnit = orig_name } context "accessed from live model instance" do - specify { expect(widget).to be_live } + specify { expect(widget.paper_trail).to be_live } it "should return the originator for the model at a given state" do - expect(widget.paper_trail_originator).to eq(orig_name) - widget.whodunnit(new_name) { |w| w.update_attributes(name: "Elizabeth") } - expect(widget.paper_trail_originator).to eq(new_name) + expect(widget.paper_trail.originator).to eq(orig_name) + widget.paper_trail.whodunnit(new_name) { |w| + w.update_attributes(name: "Elizabeth") + } + expect(widget.paper_trail.originator).to eq(new_name) end end @@ -169,7 +169,7 @@ let(:reified_widget) { widget.versions[1].reify } it "should return the appropriate originator" do - expect(reified_widget.paper_trail_originator).to eq(orig_name) + expect(reified_widget.paper_trail.originator).to eq(orig_name) end it "should not create a new model instance" do @@ -181,7 +181,7 @@ let(:reified_widget) { widget.versions[1].reify(dup: true) } it "should return the appropriate originator" do - expect(reified_widget.paper_trail_originator).to eq(orig_name) + expect(reified_widget.paper_trail.originator).to eq(orig_name) end it "should not create a new model instance" do @@ -192,35 +192,12 @@ end end - describe "#originator" do - subject { widget } - - it { is_expected.to respond_to(:originator) } - - it "should set the invoke `paper_trail_originator`" do - allow(::ActiveSupport::Deprecation).to receive(:warn) - is_expected.to receive(:paper_trail_originator) - subject.originator - end - - it "should display a deprecation warning" do - expect(::ActiveSupport::Deprecation).to receive(:warn). - with(/Use paper_trail_originator instead of originator/) - subject.originator - end - end - describe '#version_at' do - it { is_expected.to respond_to(:version_at) } - context "Timestamp argument is AFTER object has been destroyed" do - before do + it "should return `nil`" do widget.update_attribute(:name, "foobar") widget.destroy - end - - it "should return `nil`" do - expect(widget.version_at(Time.now)).to be_nil + expect(widget.paper_trail.version_at(Time.now)).to be_nil end end end @@ -231,7 +208,7 @@ context "no block given" do it "should raise an error" do expect { - widget.whodunnit("Ben") + widget.paper_trail.whodunnit("Ben") }.to raise_error(ArgumentError, "expected to receive a block") end end @@ -246,7 +223,7 @@ end it "should modify value of `PaperTrail.whodunnit` while executing the block" do - widget.whodunnit(new_name) do + widget.paper_trail.whodunnit(new_name) do expect(PaperTrail.whodunnit).to eq(new_name) widget.update_attributes(name: "Elizabeth") end @@ -255,7 +232,9 @@ context "after executing the block" do it "reverts value of whodunnit to previous value" do - widget.whodunnit(new_name) { |w| w.update_attributes(name: "Elizabeth") } + widget.paper_trail.whodunnit(new_name) { |w| + w.update_attributes(name: "Elizabeth") + } expect(PaperTrail.whodunnit).to eq(orig_name) end end @@ -263,7 +242,7 @@ context "error within block" do it "still reverts the whodunnit value to previous value" do expect { - widget.whodunnit(new_name) { raise } + widget.paper_trail.whodunnit(new_name) { raise } }.to raise_error(RuntimeError) expect(PaperTrail.whodunnit).to eq(orig_name) end @@ -272,13 +251,11 @@ end describe '#touch_with_version' do - it { is_expected.to respond_to(:touch_with_version) } - it "creates a version" do count = widget.versions.size # Travel 1 second because MySQL lacks sub-second resolution Timecop.travel(1) do - widget.touch_with_version + widget.paper_trail.touch_with_version end expect(widget.versions.size).to eq(count + 1) end @@ -287,7 +264,7 @@ time_was = widget.updated_at # Travel 1 second because MySQL lacks sub-second resolution Timecop.travel(1) do - widget.touch_with_version + widget.paper_trail.touch_with_version end expect(widget.updated_at).to be > time_was end @@ -295,33 +272,26 @@ end describe "Class" do - subject { Widget } - - describe "#paper_trail_enabled_for_model?" do - it { is_expected.to respond_to(:paper_trail_enabled_for_model?) } - - it { expect(subject.paper_trail_enabled_for_model?).to be true } + describe ".paper_trail.enabled?" do + it "returns true" do + expect(Widget.paper_trail.enabled?).to eq(true) + end end - describe '#paper_trail_off!' do - it { is_expected.to respond_to(:paper_trail_off!) } - - it "should set the `paper_trail_enabled_for_model?` to `false`" do - expect(subject.paper_trail_enabled_for_model?).to be true - subject.paper_trail_off! - expect(subject.paper_trail_enabled_for_model?).to be false + describe ".disable" do + it "should set the `paper_trail.enabled?` to `false`" do + expect(Widget.paper_trail.enabled?).to eq(true) + Widget.paper_trail.disable + expect(Widget.paper_trail.enabled?).to eq(false) end end - describe '#paper_trail_on!' do - before { subject.paper_trail_off! } - - it { is_expected.to respond_to(:paper_trail_on!) } - - it "should set the `paper_trail_enabled_for_model?` to `true`" do - expect(subject.paper_trail_enabled_for_model?).to be false - subject.paper_trail_on! - expect(subject.paper_trail_enabled_for_model?).to be true + describe ".enable" do + it "should set the `paper_trail.enabled?` to `true`" do + Widget.paper_trail.disable + expect(Widget.paper_trail.enabled?).to eq(false) + Widget.paper_trail.enable + expect(Widget.paper_trail.enabled?).to eq(true) end end end diff --git a/test/dummy/app/models/callback_modifier.rb b/test/dummy/app/models/callback_modifier.rb index dafc8130f..6b3f96ed8 100644 --- a/test/dummy/app/models/callback_modifier.rb +++ b/test/dummy/app/models/callback_modifier.rb @@ -17,27 +17,27 @@ def flagged_deleted? class BeforeDestroyModifier < CallbackModifier has_paper_trail on: [] - paper_trail_on_destroy :before + paper_trail.on_destroy :before end class AfterDestroyModifier < CallbackModifier has_paper_trail on: [] - paper_trail_on_destroy :after + paper_trail.on_destroy :after end class NoArgDestroyModifier < CallbackModifier has_paper_trail on: [] - paper_trail_on_destroy + paper_trail.on_destroy end class UpdateModifier < CallbackModifier has_paper_trail on: [] - paper_trail_on_update + paper_trail.on_update end class CreateModifier < CallbackModifier has_paper_trail on: [] - paper_trail_on_create + paper_trail.on_create end class DefaultModifier < CallbackModifier diff --git a/test/dummy/app/models/elephant.rb b/test/dummy/app/models/elephant.rb index 6e46a9ee8..a5821fc0c 100644 --- a/test/dummy/app/models/elephant.rb +++ b/test/dummy/app/models/elephant.rb @@ -1,3 +1,3 @@ class Elephant < Animal - paper_trail_off! + paper_trail.disable end diff --git a/test/functional/thread_safety_test.rb b/test/functional/thread_safety_test.rb index 6cc8d6729..15ed27ce8 100644 --- a/test/functional/thread_safety_test.rb +++ b/test/functional/thread_safety_test.rb @@ -26,9 +26,9 @@ class ThreadSafetyTest < ActionController::TestCase enabled = nil slow_thread = Thread.new do - Widget.new.without_versioning do + Widget.new.paper_trail.without_versioning do sleep(0.01) - enabled = Widget.paper_trail_enabled_for_model? + enabled = Widget.paper_trail.enabled? sleep(0.01) end enabled @@ -36,11 +36,11 @@ class ThreadSafetyTest < ActionController::TestCase fast_thread = Thread.new do sleep(0.005) - Widget.paper_trail_enabled_for_model? + Widget.paper_trail.enabled? end assert_not_equal slow_thread.value, fast_thread.value - assert Widget.paper_trail_enabled_for_model? + assert Widget.paper_trail.enabled? assert PaperTrail.enabled_for_model?(Widget) end end diff --git a/test/unit/cleaner_test.rb b/test/unit/cleaner_test.rb index cfb73e2c1..a16bb695d 100644 --- a/test/unit/cleaner_test.rb +++ b/test/unit/cleaner_test.rb @@ -53,7 +53,7 @@ def populate_db! should "restrict the versions destroyed to those that were created on the date provided" do assert_equal 10, PaperTrail::Version.count assert_equal 4, @animal.versions.size - assert_equal 3, @animal.versions_between(@date, @date + 1.day).size + assert_equal 3, @animal.paper_trail.versions_between(@date, @date + 1.day).size PaperTrail.clean_versions!(date: @date) assert_equal 8, PaperTrail::Version.count assert_equal 2, @animal.versions.reload.size diff --git a/test/unit/model_test.rb b/test/unit/model_test.rb index c586aeb30..776a43cc1 100644 --- a/test/unit/model_test.rb +++ b/test/unit/model_test.rb @@ -242,7 +242,7 @@ class HasPaperTrailModelTest < ActiveSupport::TestCase end should "be live" do - assert @widget.live? + assert @widget.paper_trail.live? end context "which is then created" do @@ -262,7 +262,7 @@ class HasPaperTrailModelTest < ActiveSupport::TestCase end should "be live" do - assert @widget.live? + assert @widget.paper_trail.live? end should "use the widget `updated_at` as the version's `created_at`" do @@ -317,7 +317,9 @@ class HasPaperTrailModelTest < ActiveSupport::TestCase end should "have versions that are not live" do - assert @widget.versions.map(&:reify).compact.all? { |w| !w.live? } + assert @widget.versions.map(&:reify).compact.all? { |w| + !w.paper_trail.live? + } end should "have stored changes" do @@ -566,11 +568,11 @@ class HasPaperTrailModelTest < ActiveSupport::TestCase context "with its paper trail turned off" do setup do - Widget.paper_trail_off! + Widget.paper_trail.disable @count = @widget.versions.length end - teardown { Widget.paper_trail_on! } + teardown { Widget.paper_trail.enable } context "when updated" do setup { @widget.update_attributes name: "Beeblebrox" } @@ -582,13 +584,13 @@ class HasPaperTrailModelTest < ActiveSupport::TestCase context 'when destroyed "without versioning"' do should "leave paper trail off after call" do - @widget.without_versioning :destroy - assert !Widget.paper_trail_enabled_for_model? + @widget.paper_trail.without_versioning :destroy + assert_equal false, Widget.paper_trail.enabled? end end context "and then its paper trail turned on" do - setup { Widget.paper_trail_on! } + setup { Widget.paper_trail.enable } context "when updated" do setup { @widget.update_attributes name: "Ford" } @@ -600,11 +602,11 @@ class HasPaperTrailModelTest < ActiveSupport::TestCase context 'when updated "without versioning"' do setup do - @widget.without_versioning do + @widget.paper_trail.without_versioning do @widget.update_attributes name: "Ford" end # The model instance should yield itself for convenience purposes - @widget.without_versioning { |w| w.update_attributes name: "Nixon" } + @widget.paper_trail.without_versioning { |w| w.update_attributes name: "Nixon" } end should "not create new version" do @@ -612,19 +614,19 @@ class HasPaperTrailModelTest < ActiveSupport::TestCase end should "enable paper trail after call" do - assert Widget.paper_trail_enabled_for_model? + assert Widget.paper_trail.enabled? end end context "when receiving a method name as an argument" do - setup { @widget.without_versioning(:touch_with_version) } + setup { @widget.paper_trail.without_versioning(:touch_with_version) } should "not create new version" do assert_equal @count, @widget.versions.length end should "enable paper trail after call" do - assert Widget.paper_trail_enabled_for_model? + assert Widget.paper_trail.enabled? end end end @@ -645,9 +647,9 @@ class HasPaperTrailModelTest < ActiveSupport::TestCase should "track who made the change" do assert_equal "Alice", @version.whodunnit - assert_nil @version.paper_trail_originator + assert_nil @version.paper_trail_originator assert_equal "Alice", @version.terminator - assert_equal "Alice", @widget.paper_trail_originator + assert_equal "Alice", @widget.paper_trail.originator end context "when a record is updated" do @@ -658,10 +660,10 @@ class HasPaperTrailModelTest < ActiveSupport::TestCase end should "track who made the change" do - assert_equal "Bob", @version.whodunnit + assert_equal "Bob", @version.whodunnit assert_equal "Alice", @version.paper_trail_originator - assert_equal "Bob", @version.terminator - assert_equal "Bob", @widget.paper_trail_originator + assert_equal "Bob", @version.terminator + assert_equal "Bob", @widget.paper_trail.originator end context "when a record is destroyed" do @@ -673,9 +675,9 @@ class HasPaperTrailModelTest < ActiveSupport::TestCase should "track who made the change" do assert_equal "Charlie", @version.whodunnit - assert_equal "Bob", @version.paper_trail_originator + assert_equal "Bob", @version.paper_trail_originator assert_equal "Charlie", @version.terminator - assert_equal "Charlie", @widget.paper_trail_originator + assert_equal "Charlie", @widget.paper_trail.originator end end end @@ -735,7 +737,7 @@ class HasPaperTrailModelTest < ActiveSupport::TestCase should "should return the correct originator" do PaperTrail.whodunnit = "Ben" @foo.update_attribute(:name, "Geoffrey") - assert_equal PaperTrail.whodunnit, @foo.paper_trail_originator + assert_equal PaperTrail.whodunnit, @foo.paper_trail.originator end context "when destroyed" do @@ -768,40 +770,43 @@ class HasPaperTrailModelTest < ActiveSupport::TestCase end should "return nil for version_at before it was created" do - assert_nil @widget.version_at(@created - 1) + assert_nil @widget.paper_trail.version_at(@created - 1) end should "return how it looked when created for version_at its creation" do - assert_equal "Widget", @widget.version_at(@created).name + assert_equal "Widget", @widget.paper_trail.version_at(@created).name end should "return how it looked before its first update" do - assert_equal "Widget", @widget.version_at(@first_update - 1).name + assert_equal "Widget", @widget.paper_trail.version_at(@first_update - 1).name end should "return how it looked after its first update" do - assert_equal "Fidget", @widget.version_at(@first_update).name + assert_equal "Fidget", @widget.paper_trail.version_at(@first_update).name end should "return how it looked before its second update" do - assert_equal "Fidget", @widget.version_at(@second_update - 1).name + assert_equal "Fidget", @widget.paper_trail.version_at(@second_update - 1).name end should "return how it looked after its second update" do - assert_equal "Digit", @widget.version_at(@second_update).name + assert_equal "Digit", @widget.paper_trail.version_at(@second_update).name end should "return the current object for version_at after latest update" do - assert_equal "Digit", @widget.version_at(1.day.from_now).name + assert_equal "Digit", @widget.paper_trail.version_at(1.day.from_now).name end context "passing in a string representation of a timestamp" do should "still return a widget when appropriate" do # need to add 1 second onto the timestamps before casting to a string, # since casting a Time to a string drops the microseconds - assert_equal "Widget", @widget.version_at((@created + 1.second).to_s).name - assert_equal "Fidget", @widget.version_at((@first_update + 1.second).to_s).name - assert_equal "Digit", @widget.version_at((@second_update + 1.second).to_s).name + assert_equal "Widget", + @widget.paper_trail.version_at((@created + 1.second).to_s).name + assert_equal "Fidget", + @widget.paper_trail.version_at((@first_update + 1.second).to_s).name + assert_equal "Digit", + @widget.paper_trail.version_at((@second_update + 1.second).to_s).name end end end @@ -819,13 +824,13 @@ class HasPaperTrailModelTest < ActiveSupport::TestCase should "return versions in the time period" do assert_equal ["Fidget"], - @widget.versions_between(20.days.ago, 10.days.ago).map(&:name) + @widget.paper_trail.versions_between(20.days.ago, 10.days.ago).map(&:name) assert_equal %w(Widget Fidget), - @widget.versions_between(45.days.ago, 10.days.ago).map(&:name) + @widget.paper_trail.versions_between(45.days.ago, 10.days.ago).map(&:name) assert_equal %w(Fidget Digit Digit), - @widget.versions_between(16.days.ago, 1.minute.ago).map(&:name) + @widget.paper_trail.versions_between(16.days.ago, 1.minute.ago).map(&:name) assert_equal [], - @widget.versions_between(60.days.ago, 45.days.ago).map(&:name) + @widget.paper_trail.versions_between(60.days.ago, 45.days.ago).map(&:name) end end @@ -948,7 +953,8 @@ class HasPaperTrailModelTest < ActiveSupport::TestCase end should "return its previous self" do - assert_equal @widget.versions[-2].reify, @widget.previous_version + assert_equal @widget.versions[-2].reify, + @widget.paper_trail.previous_version end end @@ -956,11 +962,11 @@ class HasPaperTrailModelTest < ActiveSupport::TestCase setup { @widget = Widget.new } should "not have a previous version" do - assert_nil @widget.previous_version + assert_nil @widget.paper_trail.previous_version end should "not have a next version" do - assert_nil @widget.next_version + assert_nil @widget.paper_trail.next_version end context "with versions" do @@ -970,11 +976,12 @@ class HasPaperTrailModelTest < ActiveSupport::TestCase end should "have a previous version" do - assert_equal @widget.versions.last.reify.name, @widget.previous_version.name + assert_equal @widget.versions.last.reify.name, + @widget.paper_trail.previous_version.name end should "not have a next version" do - assert_nil @widget.next_version + assert_nil @widget.paper_trail.next_version end end end @@ -988,13 +995,15 @@ class HasPaperTrailModelTest < ActiveSupport::TestCase end should "have a previous version" do - assert_nil @second_widget.previous_version # `create` events return `nil` for `reify` - assert_equal @widget.versions[-2].reify.name, @last_widget.previous_version.name + # `create` events return `nil` for `reify` + assert_nil @second_widget.paper_trail.previous_version + assert_equal @widget.versions[-2].reify.name, + @last_widget.paper_trail.previous_version.name end should "have a next version" do - assert_equal @widget.versions[2].reify.name, @second_widget.next_version.name - assert_equal @last_widget.next_version.name, @widget.name + assert_equal @widget.versions[2].reify.name, @second_widget.paper_trail.next_version.name + assert_equal @last_widget.paper_trail.next_version.name, @widget.name end end @@ -1270,13 +1279,14 @@ class HasPaperTrailModelTest < ActiveSupport::TestCase end should "respond to `next_version` as normal" do - assert_equal @doc.paper_trail_versions.last.reify.next_version.name, @doc.name + reified = @doc.paper_trail_versions.last.reify + assert_equal reified.paper_trail.next_version.name, @doc.name end should "respond to `previous_version` as normal" do @doc.update_attributes name: "Doc 2" assert_equal 3, @doc.paper_trail_versions.length - assert_equal "Doc 1", @doc.previous_version.name + assert_equal "Doc 1", @doc.paper_trail.previous_version.name end end @@ -1350,7 +1360,7 @@ class HasPaperTrailModelTest < ActiveSupport::TestCase end should "still respond to touch_with_version" do - @fluxor.touch_with_version + @fluxor.paper_trail.touch_with_version assert_equal 1, @fluxor.versions.length end end @@ -1405,7 +1415,7 @@ class HasPaperTrailModelTest < ActiveSupport::TestCase end should "return its previous self" do - assert_equal @widget.versions[-2].reify, @widget.previous_version + assert_equal @widget.versions[-2].reify, @widget.paper_trail.previous_version end end diff --git a/test/unit/protected_attrs_test.rb b/test/unit/protected_attrs_test.rb index d95cf6caa..bac477126 100644 --- a/test/unit/protected_attrs_test.rb +++ b/test/unit/protected_attrs_test.rb @@ -25,7 +25,7 @@ class ProtectedAttrsTest < ActiveSupport::TestCase end should "be `nil` in its previous version" do - assert_nil @widget.previous_version + assert_nil @widget.paper_trail.previous_version end context "which is then updated" do @@ -36,14 +36,15 @@ class ProtectedAttrsTest < ActiveSupport::TestCase end should "not be `nil` in its previous version" do - assert_not_nil @widget.previous_version + assert_not_nil @widget.paper_trail.previous_version end should "the previous version should contain right attributes" do # For some reason this test seems to be broken in JRuby 1.9 mode in the # test env even though it works in the console. WTF? unless ActiveRecord::VERSION::MAJOR >= 4 && defined?(JRUBY_VERSION) - assert_attributes_equal @widget.previous_version.attributes, @initial_attributes + previous_attributes = @widget.paper_trail.previous_version.attributes + assert_attributes_equal previous_attributes, @initial_attributes end end end diff --git a/test/unit/serializer_test.rb b/test/unit/serializer_test.rb index ec9ca51e1..49e767d2f 100644 --- a/test/unit/serializer_test.rb +++ b/test/unit/serializer_test.rb @@ -11,7 +11,7 @@ class SerializerTest < ActiveSupport::TestCase @fluxor = Fluxor.create name: "Some text." # this is exactly what PaperTrail serializes - @original_fluxor_attributes = @fluxor.send(:attributes_before_change) + @original_fluxor_attributes = @fluxor.paper_trail.attributes_before_change @fluxor.update_attributes name: "Some more text." end @@ -41,7 +41,7 @@ class SerializerTest < ActiveSupport::TestCase @fluxor = Fluxor.create name: "Some text." # this is exactly what PaperTrail serializes - @original_fluxor_attributes = @fluxor.send(:attributes_before_change) + @original_fluxor_attributes = @fluxor.paper_trail.attributes_before_change @fluxor.update_attributes name: "Some more text." end @@ -85,7 +85,8 @@ class SerializerTest < ActiveSupport::TestCase # this is exactly what PaperTrail serializes @original_fluxor_attributes = @fluxor. - send(:attributes_before_change). + paper_trail. + attributes_before_change. reject { |_k, v| v.nil? } @fluxor.update_attributes name: "Some more text."