require 'active_record' require 'active_support' module ActsAsRevisionable autoload :RevisionRecord, File.expand_path('../acts_as_revisionable/revision_record', __FILE__) def self.included(base) base.extend(ActsMethods) end module ActsMethods # Calling acts_as_revisionable will inject the revisionable behavior into the class. Specifying a :limit option # will limit the number of revisions that are kept per record. Specifying :minimum_age will ensure that revisions are # kept for at least a certain amount of time (i.e. 2.weeks). Associations to be revisioned can be specified with # the :associations option as an array of association names. To specify associations of associations, use a hash # for that association with the association name as the key and the value as an array of sub associations. # For instance, this declaration will revision :tags, :comments, as well as the # :ratings association on :comments: # # :associations => [:tags, {:comments => [:ratings]}] # # You can also pass an options of :on_update => true to automatically enable revisioning on every update. # Otherwise you will need to perform your updates in a store_revision block. The reason for this is so that # revisions for complex models with associations can be better controlled. # # You can keep a revisions of deleted records by passing :dependent => :keep. When a record is destroyed, # an additional revision will be created and marked as trash. Trash records can be deleted by calling the # empty_trash method. You can set :on_destroy => true to automatically create the trash revision # whenever a record is destroyed. It is recommended that you turn both of these features on. # # A has_many :revision_records will also be added to the model for accessing the revisions. def acts_as_revisionable(options = {}) class_attribute :acts_as_revisionable_options self.acts_as_revisionable_options = options.clone extend ClassMethods include InstanceMethods has_many_options = {:as => :revisionable, :order => 'revision DESC', :class_name => "ActsAsRevisionable::RevisionRecord"} has_many_options[:dependent] = :destroy unless options[:dependent] == :keep has_many :revision_records, has_many_options alias_method_chain :update, :revision if options[:on_update] alias_method_chain :destroy, :revision if options[:on_destroy] end end module ClassMethods # Get a revision for a specified id. def revision(id, revision_number) RevisionRecord.find_revision(self, id, revision_number) end # Get the last revision for a specified id. def last_revision(id) RevisionRecord.last_revision(self, id) end # Load a revision for a record with a particular id. Associations added since the revision # was created will still be in the restored record. # If you want to save a revision with associations properly, use restore_revision! def restore_revision(id, revision_number) revision_record = revision(id, revision_number) return revision_record.restore if revision_record end # Load a revision for a record with a particular id and save it to the database. You should # always use this method to save a revision if it has associations. def restore_revision!(id, revision_number) record = restore_revision(id, revision_number) if record record.store_revision do save_restorable_associations(record, revisionable_associations) end end return record end # Load the last revision for a record with the specified id. Associations added since the revision # was created will still be in the restored record. # If you want to save a revision with associations properly, use restore_last_revision! def restore_last_revision(id) revision_record = last_revision(id) return revision_record.restore if revision_record end # Load the last revision for a record with the specified id and save it to the database. You should # always use this method to save a revision if it has associations. def restore_last_revision!(id) record = restore_last_revision(id) if record record.store_revision do save_restorable_associations(record, revisionable_associations) end end return record end # Returns a hash structure used to identify the revisioned associations. def revisionable_associations(options = acts_as_revisionable_options[:associations]) return nil unless options options = [options] unless options.kind_of?(Array) associations = {} options.each do |association| if association.kind_of?(Symbol) associations[association] = true elsif association.kind_of?(Hash) association.each_pair do |key, value| associations[key] = revisionable_associations(value) end end end return associations end # Delete all revision records for deleted items that are older than the specified maximum age in seconds. def empty_trash(max_age) RevisionRecord.empty_trash(self, max_age) end private def save_restorable_associations(record, associations) record.class.transaction do if associations.kind_of?(Hash) associations.each_pair do |association, sub_associations| associated_records = record.send(association) reflection = record.class.reflections[association].macro if reflection == :has_and_belongs_to_many associated_records = associated_records.collect{|r| r} record.send(association, true).clear associated_records.each do |assoc_record| record.send(association) << assoc_record end else if reflection == :has_many existing = associated_records.all existing.each do |existing_association| associated_records.delete(existing_association) unless associated_records.include?(existing_association) end end associated_records = [associated_records] unless associated_records.kind_of?(Array) associated_records.each do |associated_record| save_restorable_associations(associated_record, sub_associations) if associated_record end end end end record.save! unless record.new_record? end end end module InstanceMethods # Restore a revision of the record and return it. The record is not saved to the database. If there # is a problem restoring values, errors will be added to the record. def restore_revision(revision_number) self.class.restore_revision(self.id, revision_number) end # Restore a revision of the record and save it along with restored associations. def restore_revision!(revision_number) self.class.restore_revision!(self.id, revision_number) end # Get a specified revision record def revision(revision_number) self.class.revision(id, revision_number) end # Get the last revision record def last_revision self.class.last_revision(id) end # Call this method to implement revisioning. The object changes should happen inside the block. def store_revision if new_record? || @revisions_disabled return yield else retval = nil revision = nil begin RevisionRecord.transaction do begin read_only = self.class.first(:conditions => {self.class.primary_key => self.id}, :readonly => true) if read_only revision = read_only.create_revision! truncate_revisions! end rescue => e puts e logger.warn(e) if logger end disable_revisioning do retval = yield end raise ActiveRecord::Rollback unless errors.empty? revision.trash! if destroyed? end rescue => e # In case the database doesn't support transactions if revision begin revision.destroy rescue => e puts e logger.warn(e) if logger end end raise e end return retval end end # Create a revision record based on this record and save it to the database. def create_revision! revision = RevisionRecord.new(self, acts_as_revisionable_options[:encoding]) revision.save! return revision end # Truncate the number of revisions kept for this record. Available options are :limit and :minimum_age. def truncate_revisions!(options = nil) options = {:limit => acts_as_revisionable_options[:limit], :minimum_age => acts_as_revisionable_options[:minimum_age]} unless options RevisionRecord.truncate_revisions(self.class, self.id, options) end # Disable the revisioning behavior inside of a block passed to the method. def disable_revisioning save_val = @revisions_disabled retval = nil begin @revisions_disabled = true retval = yield if block_given? ensure @revisions_disabled = save_val end return retval end # Destroy the record while recording the revision. def destroy_with_revision store_revision do destroy_without_revision end end private # Update the record while recording the revision. def update_with_revision store_revision do update_without_revision end end end end ActiveRecord::Base.send(:include, ActsAsRevisionable)