diff --git a/.gitignore b/.gitignore index 2ee48aa24..32aa891ed 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ Gemfile.lock vendor/* .idea .rvmrc +.tags +.tags_sorted_by_file \ No newline at end of file diff --git a/README.md b/README.md index aa0c10bf6..560d0d6c7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# PaperTrail [![Build Status](https://img.shields.io/travis/airblade/paper_trail.svg)](https://travis-ci.org/airblade/paper_trail) [![Dependency Status](https://img.shields.io/gemnasium/airblade/paper_trail.svg)](https://gemnasium.com/airblade/paper_trail) +# PaperTrail [![Build Status](https://img.shields.io/travis/airblade/paper_trail/master.svg)](https://travis-ci.org/airblade/paper_trail) [![Dependency Status](https://img.shields.io/gemnasium/airblade/paper_trail.svg)](https://gemnasium.com/airblade/paper_trail) PaperTrail lets you track changes to your models' data. It's good for auditing or versioning. You can see how a model looked at any stage in its lifecycle, revert it to any version, and even undelete it after it's been destroyed. diff --git a/lib/generators/paper_trail/install_generator.rb b/lib/generators/paper_trail/install_generator.rb index 2b78b97bd..67cde9540 100644 --- a/lib/generators/paper_trail/install_generator.rb +++ b/lib/generators/paper_trail/install_generator.rb @@ -12,12 +12,23 @@ class InstallGenerator < ::Rails::Generators::Base desc 'Generates (but does not run) a migration to add a versions table.' def create_migration_file - migration_template 'create_versions.rb', 'db/migrate/create_versions.rb' - migration_template 'add_object_changes_column_to_versions.rb', 'db/migrate/add_object_changes_column_to_versions.rb' if options.with_changes? + add_paper_trail_migration('create_versions') + add_paper_trail_migration('add_object_changes_column_to_versions') if options.with_changes? + add_paper_trail_migration('create_version_associations') + add_paper_trail_migration('add_transaction_id_column_to_versions') end def self.next_migration_number(dirname) ::ActiveRecord::Generators::Base.next_migration_number(dirname) end + + protected + def add_paper_trail_migration(template) + migration_dir = File.expand_path('db/migrate') + + if !self.class.migration_exists?(migration_dir, template) + migration_template "#{template}.rb", "db/migrate/#{template}.rb" + end + end end end diff --git a/lib/generators/paper_trail/templates/add_transaction_id_column_to_versions.rb b/lib/generators/paper_trail/templates/add_transaction_id_column_to_versions.rb new file mode 100644 index 000000000..646c56c0c --- /dev/null +++ b/lib/generators/paper_trail/templates/add_transaction_id_column_to_versions.rb @@ -0,0 +1,11 @@ +class AddTransactionIdColumnToVersions < ActiveRecord::Migration + def self.up + add_column :versions, :transaction_id, :integer + add_index :versions, [:transaction_id] + end + + def self.down + remove_column :versions, :transaction_id + remove_index :versions, [:transaction_id] + end +end diff --git a/lib/generators/paper_trail/templates/create_version_associations.rb b/lib/generators/paper_trail/templates/create_version_associations.rb new file mode 100644 index 000000000..57c1d109b --- /dev/null +++ b/lib/generators/paper_trail/templates/create_version_associations.rb @@ -0,0 +1,17 @@ +class CreateVersionAssociations < ActiveRecord::Migration + def self.up + create_table :version_associations do |t| + t.integer :version_id + t.string :foreign_key_name, :null => false + t.integer :foreign_key_id + end + add_index :version_associations, [:version_id] + add_index :version_associations, [:foreign_key_name, :foreign_key_id], :name => 'index_on_foreign_key_name_and foreign_key_id' + end + + def self.down + remove_index :version_associations, [:version_id] + remove_index :version_associations, [:foreign_key_name, :foreign_key_id], :name => 'index_on_foreign_key_name_and foreign_key_id' + drop_table :version_associations + end +end \ No newline at end of file diff --git a/lib/paper_trail.rb b/lib/paper_trail.rb index 8f0e78aac..49be73b38 100644 --- a/lib/paper_trail.rb +++ b/lib/paper_trail.rb @@ -92,6 +92,28 @@ def self.active_record_protected_attributes? @active_record_protected_attributes ||= ::ActiveRecord::VERSION::MAJOR < 4 || !!defined?(ProtectedAttributes) end + def self.transaction? + ActiveRecord::Base.connection.open_transactions > 0 || paper_trail_store[:transaction_open] + end + + def self.start_transaction + paper_trail_store[:transaction_open] = true + self.transaction_id = nil + end + + def self.end_transaction + paper_trail_store[:transaction_open] = false + self.transaction_id = nil + end + + def self.transaction_id + paper_trail_store[:transaction_id] + end + + def self.transaction_id=(id) + paper_trail_store[:transaction_id] = id + end + private # Thread-safe hash to hold PaperTrail's data. @@ -119,6 +141,7 @@ def self.configure end require 'paper_trail/version' +require 'paper_trail/version_association' # Require frameworks require 'paper_trail/frameworks/rails' diff --git a/lib/paper_trail/has_paper_trail.rb b/lib/paper_trail/has_paper_trail.rb index e1103c731..c2607974f 100644 --- a/lib/paper_trail/has_paper_trail.rb +++ b/lib/paper_trail/has_paper_trail.rb @@ -14,11 +14,11 @@ module ClassMethods # `:create`, `:update`, `:destroy` as desired. # :class_name the name of a custom Version class. This class should inherit from `PaperTrail::Version`. # :ignore an array of attributes for which a new `Version` will not be created if only they change. - # it can also aceept a Hash as an argument where the key is the attribute to ignore (a `String` or `Symbol`), + # it can also aceept a Hash as an argument where the key is the attribute to ignore (a `String` or `Symbol`), # which will only be ignored if the value is a `Proc` which returns truthily. # :if, :unless Procs that allow to specify conditions when to save versions for an object # :only inverse of `ignore` - a new `Version` will be created only for these attributes if supplied - # it can also aceept a Hash as an argument where the key is the attribute to track (a `String` or `Symbol`), + # it can also aceept a Hash as an argument where the key is the attribute to track (a `String` or `Symbol`), # which will only be counted if the value is a `Proc` which returns truthily. # :skip fields to ignore completely. As with `ignore`, updates to these fields will not create # a new `Version`. In addition, these fields will not be included in the serialized versions @@ -74,6 +74,10 @@ def has_paper_trail(options = {}) after_create :record_create, :if => :save_version? if options_on.empty? || options_on.include?(:create) before_update :record_update, :if => :save_version? if options_on.empty? || options_on.include?(:update) after_destroy :record_destroy, :if => :save_version? if options_on.empty? || options_on.include?(:destroy) + + # Reset the transaction id when the transaction is closed + after_commit :reset_transaction_id + after_rollback :reset_transaction_id end # Switches PaperTrail off for this class. @@ -91,7 +95,7 @@ def paper_trail_on! PaperTrail.enabled_for_model(self, true) end - def paper_trail_on + def paper_trail_on warn "DEPRECATED: use `paper_trail_on!` instead of `paper_trail_on`. Support for `paper_trail_on` will be removed in PaperTrail 3.1" self.paper_trail_on! end @@ -216,6 +220,16 @@ def without_versioning(method = nil) 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 + # Mimicks behavior of `touch` method from `ActiveRecord::Persistence`, but generates a version # # TODO: lookinto leveraging the `after_touch` callback from `ActiveRecord` to allow the @@ -241,15 +255,18 @@ def source_version def record_create if paper_trail_switched_on? data = { - :event => paper_trail_event || 'create', - :whodunnit => PaperTrail.whodunnit + :event => paper_trail_event || 'create', + :whodunnit => PaperTrail.whodunnit, + :transaction_id => PaperTrail.transaction_id } if changed_notably? and self.class.paper_trail_version_class.column_names.include?('object_changes') data[:object_changes] = self.class.paper_trail_version_class.object_changes_col_is_json? ? changes_for_paper_trail : PaperTrail.serializer.dump(changes_for_paper_trail) end - send(self.class.versions_association_name).create! merge_metadata(data) + version = send(self.class.versions_association_name).create! merge_metadata(data) + set_transaction_id(version) + save_associations(version) end end @@ -257,15 +274,18 @@ def record_update if paper_trail_switched_on? && changed_notably? object_attrs = object_attrs_for_paper_trail(item_before_change) data = { - :event => paper_trail_event || 'update', - :object => self.class.paper_trail_version_class.object_col_is_json? ? object_attrs : PaperTrail.serializer.dump(object_attrs), - :whodunnit => PaperTrail.whodunnit + :event => paper_trail_event || 'update', + :object => self.class.paper_trail_version_class.object_col_is_json? ? object_attrs : PaperTrail.serializer.dump(object_attrs), + :whodunnit => PaperTrail.whodunnit, + :transaction_id => PaperTrail.transaction_id } if self.class.paper_trail_version_class.column_names.include?('object_changes') data[:object_changes] = self.class.paper_trail_version_class.object_changes_col_is_json? ? changes_for_paper_trail : PaperTrail.serializer.dump(changes_for_paper_trail) end - send(self.class.versions_association_name).build merge_metadata(data) + version = send(self.class.versions_association_name).create merge_metadata(data) + set_transaction_id(version) + save_associations(version) end end @@ -279,17 +299,42 @@ def record_destroy if paper_trail_switched_on? and not new_record? object_attrs = object_attrs_for_paper_trail(item_before_change) data = { - :item_id => self.id, - :item_type => self.class.base_class.name, - :event => paper_trail_event || 'destroy', - :object => self.class.paper_trail_version_class.object_col_is_json? ? object_attrs : PaperTrail.serializer.dump(object_attrs), - :whodunnit => PaperTrail.whodunnit + :item_id => self.id, + :item_type => self.class.base_class.name, + :event => paper_trail_event || 'destroy', + :object => self.class.paper_trail_version_class.object_col_is_json? ? object_attrs : PaperTrail.serializer.dump(object_attrs), + :whodunnit => PaperTrail.whodunnit, + :transaction_id => PaperTrail.transaction_id } - self.class.paper_trail_version_class.create merge_metadata(data) + version = self.class.paper_trail_version_class.create merge_metadata(data) send(self.class.versions_association_name).send :load_target + set_transaction_id(version) + save_associations(version) + end + end + + def save_associations(version) + self.class.reflect_on_all_associations(:belongs_to).each do |assoc| + PaperTrail::VersionAssociation.create( + :version_id => version.id, + :foreign_key_name => assoc.foreign_key, + :foreign_key_id => self.send(assoc.foreign_key) + ) end end + def set_transaction_id(version) + if PaperTrail.transaction? && PaperTrail.transaction_id.nil? + PaperTrail.transaction_id = version.id + version.transaction_id = version.id + version.save + end + end + + def reset_transaction_id + PaperTrail.transaction_id = nil + end + def merge_metadata(data) # First we merge the model-level metadata in `meta`. paper_trail_options[:meta].each do |k,v| diff --git a/lib/paper_trail/version_association.rb b/lib/paper_trail/version_association.rb new file mode 100644 index 000000000..c592f51e4 --- /dev/null +++ b/lib/paper_trail/version_association.rb @@ -0,0 +1,7 @@ +module PaperTrail + class VersionAssociation < ActiveRecord::Base + belongs_to :version + + attr_accessible :version_id, :foreign_key_name, :foreign_key_id if PaperTrail.active_record_protected_attributes? + end +end \ No newline at end of file diff --git a/lib/paper_trail/version_concern.rb b/lib/paper_trail/version_concern.rb index d5a62e05e..3cbc757bb 100644 --- a/lib/paper_trail/version_concern.rb +++ b/lib/paper_trail/version_concern.rb @@ -6,10 +6,14 @@ module VersionConcern included do belongs_to :item, :polymorphic => true + has_many :version_associations, :dependent => :destroy + validates_presence_of :event - attr_accessible :item_type, :item_id, :event, :whodunnit, :object, :object_changes if PaperTrail.active_record_protected_attributes? + attr_accessible :item_type, :item_id, :event, :whodunnit, :object, :object_changes, :transaction_id if PaperTrail.active_record_protected_attributes? after_create :enforce_version_limit! + + scope :within_transaction, lambda { |id| where :transaction_id => id } end module ClassMethods @@ -77,8 +81,11 @@ def reify(options = {}) return nil if object.nil? without_identity_map do - options[:has_one] = 3 if options[:has_one] == true - options.reverse_merge! :has_one => false + options.reverse_merge!( + :version_at => created_at, + :has_one => false, + :has_many => false + ) attrs = self.class.object_col_is_json? ? object : PaperTrail.serializer.load(object) @@ -119,8 +126,16 @@ def reify(options = {}) model.send "#{model.class.version_association_name}=", self + # unless options[:has_one] == false + # reify_has_ones model, options[:has_one] + # end + unless options[:has_one] == false - reify_has_ones model, options[:has_one] + reify_has_ones model, options + end + + unless options[:has_many] == false + reify_has_manys model, options end model @@ -140,6 +155,15 @@ def changeset {} end + # Rollback all changes within a transaction + def rollback + transaction do + self.class.within_transaction(transaction_id).reverse_each do |version| + version.reify.save! + end + end + end + # Returns who put the item into the state stored in this version. def originator @originator ||= previous.whodunnit rescue nil @@ -187,28 +211,68 @@ def without_identity_map(&block) # Restore the `model`'s has_one associations as they were when this version was # superseded by the next (because that's what the user was looking at when they # made the change). - # - # The `lookback` sets how many seconds before the model's change we go. - def reify_has_ones(model, lookback) + def reify_has_ones(model, options = {}) + version_table_name = model.class.paper_trail_version_class.table_name model.class.reflect_on_all_associations(:has_one).each do |assoc| - child = model.send assoc.name - if child.respond_to? :version_at - # N.B. we use version of the child as it was `lookback` seconds before the parent was updated. - # Ideally we want the version of the child as it was just before the parent was updated... - # but until PaperTrail knows which updates are "together" (e.g. parent and child being - # updated on the same form), it's impossible to tell when the overall update started; - # and therefore impossible to know when "just before" was. - if (child_as_it_was = child.version_at(send(PaperTrail.timestamp_field) - lookback.seconds)) - child_as_it_was.attributes.each do |k,v| - model.send(assoc.name).send :write_attribute, k.to_sym, v rescue nil + version = 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). + where("created_at >= ? OR transaction_id = ?", options[:version_at], transaction_id). + order("#{version_table_name}.id ASC").first + if version + if version.event == 'create' + if child = version.item + child.mark_for_destruction + model.send "#{assoc.name}=", nil end else - model.send "#{assoc.name}=", nil + child = version.reify options + logger.info "Reify #{child}" + model.appear_as_new_record do + model.send "#{assoc.name}=", child + end end end end end + # Restore the `model`'s has_many associations as they were at version_at timestamp + # We lookup the first child versions after version_at timestamp or in same transaction. + def reify_has_manys(model, options = {}) + version_table_name = model.class.paper_trail_version_class.table_name + model.class.reflect_on_all_associations(:has_many).each do |assoc| + next if assoc.name == model.class.versions_association_name + version_id_subquery = PaperTrail::VersionAssociation.joins(model.class.version_association_name). + select("MIN(version_id)"). + where("foreign_key_name = ?", assoc.foreign_key). + where("foreign_key_id = ?", model.id). + where("#{version_table_name}.item_type = ?", assoc.class_name). + where("created_at >= ? OR transaction_id = ?", options[:version_at], transaction_id). + group("item_id").to_sql + versions = model.class.paper_trail_version_class.where("id IN (#{version_id_subquery})") + + # Pass true to force the model to load + collection = Array.new model.send(assoc.name, true) + + # Iterate all the child records to replace them with the previous values + versions.each do |version| + collection << version.reify(options) if version.event == 'destroy' + collection.map! do |c| + if version.event == 'create' + c.mark_for_destruction if version.item && version.item.id == c.id + c + else + child = version.reify(options) + c.id == child.id ? child : c + end + end + end + + model.send(assoc.name).proxy_association.target = collection + end + end + # checks to see if a value has been set for the `version_limit` config option, and if so enforces it def enforce_version_limit! return unless PaperTrail.config.version_limit.is_a? Numeric