require 'active_record'
require 'active_support'
module ActsAsRevisionable
autoload :RevisionRecord, File.expand_path('../acts_as_revisionable/revision_record', __FILE__)
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.
#
# Revision records can be extended to include other fields as needed and set with the :meta option.
# In order to extend a revision record, you must add columns to the database table. The values of the :meta
# option hash will be provided to the newly created revision record.
#
# acts_as_revisionable :meta => {
# :updated_by => :last_updated_by,
# :label => lambda{|record| "Updated by #{record.updated_by} at #{record.updated_at}"},
# :version => 1
# }
#
# As a shortcut, you can can also just pass an attribute name or array of attribute names to copy to the revision
# record.
#
# acts_as_revisionable :meta => :updated_by
#
# The values to the :meta hash can be either symbols or Procs. If it is a symbol, the method
# so named will be called on the record being revisioned. If it is a Proc, it will be called with the
# record as the argument. Any other class will be sent directly to the revision record.
#
# You can also use a subclass of RevisionRecord if desired so that you can add your own model logic as
# necessary. To specify a different class to use for revision records, simply subclass RevisionRecord and
# provide the class name to the :class_name option.
#
# acts_as_revisionable :class_name => "MyRevisionRecord"
#
# 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, :instance_writer => false, :instance_reader => false
defaults = {:class_name => "ActsAsRevisionable::RevisionRecord"}
self.acts_as_revisionable_options = defaults.merge(options)
acts_as_revisionable_options[:class_name] = acts_as_revisionable_options[:class_name].name if acts_as_revisionable_options[:class_name].is_a?(Class)
extend ClassMethods
include InstanceMethods
class_name = acts_as_revisionable_options[:class_name].to_s if acts_as_revisionable_options[:class_name]
has_many_options = {:as => :revisionable, :order => 'revision DESC', :class_name => class_name}
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)
revision_record_class.find_revision(self, id, revision_number)
end
# Get the last revision for a specified id.
def last_revision(id)
revision_record_class.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)
revision_record_class.empty_trash(self, max_age)
end
def revision_record_class
acts_as_revisionable_options[:class_name].constantize
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
revision_record_class.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
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
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_options = self.class.acts_as_revisionable_options
revision = revision_record_class.new(self, revision_options[:encoding])
if revision_options[:meta].is_a?(Hash)
revision_options[:meta].each do |attribute, value|
set_revision_meta_attribute(revision, attribute, value)
end
elsif revision_options[:meta].is_a?(Array)
revision_options[:meta].each do |attribute|
set_revision_meta_attribute(revision, attribute, attribute.to_sym)
end
elsif revision_options[:meta]
set_revision_meta_attribute(revision, revision_options[:meta], revision_options[:meta].to_sym)
end
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 => self.class.acts_as_revisionable_options[:limit], :minimum_age => self.class.acts_as_revisionable_options[:minimum_age]} unless options
revision_record_class.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
def revision_record_class
self.class.revision_record_class
end
private
# Update the record while recording the revision.
def update_with_revision
store_revision do
update_without_revision
end
end
# Set an attribute based on a meta argument
def set_revision_meta_attribute(revision, attribute, value)
case value
when Symbol
value = self.send(value)
when Proc
value = value.call(self)
end
revision.send("#{attribute}=", value)
end
end
end
ActiveRecord::Base.extend(ActsAsRevisionable::ActsMethods)