SysMO-DB/seek

View on GitHub
lib/explicit_versioning.rb

Summary

Maintainability
D
1 day
Test Coverage
module Jits
  module Acts
    # Based heavily on the acts_as_versioned plugin
    module ExplicitVersioning
      CALLBACKS = [:sync_latest_version]
      def self.included(mod) # :nodoc:
        mod.extend(ClassMethods)
      end

      module ClassMethods
        def explicit_versioning(options = {}, &extension)
          # don't allow multiple calls
          return if self.included_modules.include?(Jits::Acts::ExplicitVersioning::ActMethods)

          send :include, Jits::Acts::ExplicitVersioning::ActMethods

          cattr_accessor :versioned_class_name, :versioned_foreign_key, :versioned_table_name, :versioned_inheritance_column,
            :version_column, :version_sequence_name, :non_versioned_columns, :file_columns, :white_list_columns, :revision_comments_column,
            :version_association_options, :timestamp_columns, :sync_ignore_columns

          self.versioned_class_name         = options[:class_name]  || "Version"
          self.versioned_foreign_key        = options[:foreign_key] || self.to_s.foreign_key
          self.versioned_table_name         = options[:table_name]  || "#{table_name_prefix}#{base_class.name.demodulize.underscore}_versions#{table_name_suffix}"
          self.versioned_inheritance_column = options[:inheritance_column] || "versioned_#{inheritance_column}"
          self.version_column               = options[:version_column]     || 'version'
          self.non_versioned_columns        = [self.primary_key, inheritance_column, 'version', 'lock_version', versioned_inheritance_column, version_column]
          self.file_columns                 = options[:file_columns] || []
          self.white_list_columns           = options[:white_list_columns] || []
          self.revision_comments_column     = options[:revision_comments_column]  || "revision_comments"
          self.version_association_options  = {
                                                :class_name  => "#{self.to_s}::#{versioned_class_name}",
                                                :foreign_key => "#{versioned_foreign_key}",
                                                :order       => 'version',
                                                :dependent   => :destroy
                                              }.merge(options[:association_options] || {})
          self.timestamp_columns            = options[:timestamp_columns]  || [ "created_at", "updated_at" ]
          self.sync_ignore_columns          = options[:sync_ignore_columns]  || []

          class_eval do
            has_many :versions, version_association_options

            before_create :set_new_version
            after_create :save_version_on_create
            after_update :sync_latest_version
          end

          # create the dynamic versioned model
          const_set(versioned_class_name, Class.new(ActiveRecord::Base)).class_eval do
            def self.reloadable? ; false ; end          
            def latest_version
              parent.latest_version
            end
            
            def versions
              parent.versions
            end

            def latest_version?
              parent.latest_version == self
            end
          end

          versioned_class.table_name = versioned_table_name
          versioned_class.belongs_to self.to_s.demodulize.underscore.to_sym,
            :class_name  => "::#{self.to_s}",
            :foreign_key => versioned_foreign_key

          #add a generic method, independent of the model, that gets the thing being versioned.
          versioned_class.belongs_to :parent,
            :class_name  => "::#{self.to_s}",
            :foreign_key => versioned_foreign_key


          if block_given?
            versioned_class.class_eval(&extension)
          end

        end
      end

      module ActMethods
        def self.included(base) # :nodoc:
          base.extend ClassMethods
        end

        # Finds a specific version of this model.
        def find_version(version)
          return version if version.is_a?(self.class.versioned_class)
          return nil if version.is_a?(ActiveRecord::Base)
          find_versions(:conditions => ['version = ?', version], :limit => 1).first
        end

        # Returns the most recent version
        def latest_version
          versions.last
        end

        # Finds versions of this model.  Takes an options hash like <tt>find</tt>
        def find_versions(options = {})
          versions.find(:all, options)
        end

        # Saves the object as a new version and also saves the original object as the new version.
        # Make sure to create (and thus save) any inner associations beforehand as these won't be saved here.
        def save_as_new_version(revision_comment=nil)
          return false unless self.valid?
          without_update_callbacks do
            set_new_version
            save_version_on_create(revision_comment)
            self.save
          end
        end

        def update_version(version_number_to_update, attributes)
          return false if version_number_to_update.nil? or version_number_to_update.to_i < 1
          return false if attributes.nil? or attributes.empty?
          return false unless (ver = find_version(version_number_to_update))

          rtn = ver.update_attributes(attributes)

          if rtn
            # if the latest version has been updated then update the main table as well
            if version_number_to_update.to_i == eval("#{self.class.version_column}")
              return update_main_to_version(version_number_to_update, true)
            else
              return true
            end
          else
            return false
          end
        end

        def destroy_version(version_number)
          if (ver = find_version(version_number))
            without_update_callbacks do
              # For fault tolerance (ie: to prevent data loss through premature deletion), first...
              # Check to see if the current (aka latest) version has to be deleted,
              # and if so update the main table with the data from the version that will become the latest
              if version_number.to_i == eval("#{self.class.version_column}")
                if versions.count > 1
                  to_be_latest_version = versions[versions.count-2].version
                else
                  return false
                end
                success = update_main_to_version(to_be_latest_version)
              end

              # Then... delete the version
              if success || Seek::Config.delete_asset_version_enabled
                return ver.destroy
              else
                return false
              end
            end
          end
        end

        def describe_version(version_number)
          return "" if versions.count < 2
          return "(earliest)" if version_number == versions.first.version
          return "(latest)" if version_number == versions.last.version
          return ""
        end

        def without_update_callbacks(&block)
          self.class.without_update_callbacks(&block)
        end

        def empty_callback() end #:nodoc:

        protected

          def set_new_version
            self.send("#{self.class.version_column}=", self.next_version)
          end

          # Saves a version of the model in the versioned table. This is called in the after_create callback by default
          def save_version_on_create(revision_comment=nil)
            rev = self.class.versioned_class.new
            self.clone_versioned_model(self, rev)
            rev.version = send(self.class.version_column)
            rev.send("#{self.class.revision_comments_column}=", revision_comment)
            rev.send("#{self.class.versioned_foreign_key}=", self.id)
            rev.projects = self.projects
            saved = rev.save

            if saved
              # Now update timestamp columns on main model.
              # Note: main model doesnt get saved yet.
              update_timestamps(rev, self)
            end

            return saved
          end

          def update_timestamps(from, to)
            begin
              ["updated_at"].each do |key|
                if to.has_attribute?(key)
                  logger.debug("explicit_versioning - update_timestamps method - setting timestamp_column '#{key}'")
                  if eval("from.#{key}.nil?")
                    to.send("#{key}=", nil)
                  else
                    to.send("#{key}=", eval("from.#{key}"))
                  end
                end
              end
            rescue => err
              logger.error("ERROR: An error occurred in the explicit_versioning plugin during the update_timestamps method (setting timestamp columns).")
              logger.error("ERROR DETAILS: #{err}")
            end
          end

          # This method updates the latest version entry in the versioned table with the data
          # from the main table (since those two entries should always have the same data).
          def sync_latest_version
            ver = versions.last
            if (ver.nil?)
              save_as_new_version
            else
              clone_versioned_model(self, ver)
              ver.save
            end            
          end

          # This method updates the entry in the main table with the data from the version specified,
          # and also updates the corresponding version column in the main table to reflect this.
          # Note: this method on its own should not be used to revert to previous versions as it doesn't actualy delete any versions.
          def update_main_to_version(version_number, process_file_columns=true)
            if (ver = find_version(version_number))
              clone_versioned_model(ver, self, process_file_columns)
              self.send("#{self.class.version_column}=", version_number)

              # Now update timestamp columns on main model.
              update_timestamps(ver, self)

              return self.save
            else
              return false
            end
          end

          # Clones a model.
          def clone_versioned_model(orig_model, new_model, process_file_columns=true)
            self.versioned_attributes.each do |key|
              # Make sure to ignore file columns, white list columns, timestamp columns and any other ignore columns
              unless self.file_columns.include?(key) ||
                     self.white_list_columns.include?(key) ||
                     self.timestamp_columns.include?(key) ||
                     self.sync_ignore_columns.include?(key)
                new_model.send("#{key}=", eval("orig_model.#{key}")) if orig_model.respond_to?(key)
              end
            end

            if process_file_columns
              # Now copy over file columns
              begin
                self.file_columns.each do |key|
                  if orig_model.has_attribute?(key)
                    if eval("orig_model.#{key}.nil?")
                      logger.debug("DEBUG: file column is nil")
                      new_model.send("#{key}=", nil)
                    else
                      logger.debug("DEBUG: file column is not nil")
                      new_model.send("#{key}=", File.open(eval("orig_model.#{key}")))
                      FileUtils.cp(eval("orig_model.#{key}"), eval("new_model.#{key}"))
                    end
                  end
                end
              rescue => err
                logger.error("ERROR: An error occurred in the explicit_versioning plugin during the clone_versioned_model method (copying file columns).")
                logger.error("ERROR DETAILS: #{err}")
              end
            end

            # Now set white list columns
            begin
              self.white_list_columns.each do |key|
                if orig_model.has_attribute?(key)
                  if eval("orig_model.#{key}.nil?")
                    new_model.send("#{key}=", nil)
                  else
                    new_model.send("#{key}=", eval("orig_model.#{key}"))
                  end
                end
              end
            rescue => err
              logger.error("ERROR: An error occurred in the explicit_versioning plugin during the clone_versioned_model method (setting white list columns).")
              logger.error("ERROR DETAILS: #{err}")
            end

            # Set version column accordingly.
            #if orig_model.is_a?(self.class.versioned_class)
            #  new_model[new_model.class.inheritance_column] = orig_model[self.class.versioned_inheritance_column]
            #elsif new_model.is_a?(self.class.versioned_class)
            #  new_model[self.class.versioned_inheritance_column] = orig_model[orig_model.class.inheritance_column]
            #end
          end

          # Gets the next available version for the current record, or 1 for a new record
          def next_version
            return 1 if new_record? || versions.empty?
            (versions.maximum(:version) || 0) + 1
          end

          # Returns an array of attribute keys that are versioned.  See non_versioned_columns
          def versioned_attributes
            self.attributes.keys.select { |k| !self.class.non_versioned_columns.include?(k) }
          end

        module ClassMethods
          # Finds a specific version of a specific row of this model
          def find_version(id, version)
            find_versions(id,
              :conditions => ["#{versioned_foreign_key} = ? AND version = ?", id, version],
              :limit => 1).first
          end

          # Finds versions of a specific model.  Takes an options hash like <tt>find</tt>
          def find_versions(id, options = {})
            versioned_class.find :all, {
              :conditions => ["#{versioned_foreign_key} = ?", id],
              :order      => 'version' }.merge(options)
          end

          # Returns an array of columns that are versioned.  See non_versioned_columns
          def versioned_columns
            self.columns.select { |c| !non_versioned_columns.include?(c.name) }
          end

          # Returns an instance of the dynamic versioned model
          def versioned_class
            const_get versioned_class_name
          end

          # Rake migration task to create the versioned table using options passed
          def create_versioned_table(create_table_options = {})
            # create version column in main table if it does not exist
            if !self.content_columns.find { |c| %w(version lock_version).include? c.name }
              self.connection.add_column table_name, :version, :integer, :default=>1
            end

            self.connection.create_table(versioned_table_name, create_table_options) do |t|
              t.column versioned_foreign_key, :integer
              t.column :version, :integer
              t.column revision_comments_column, :text
            end

            updated_col = nil
            self.versioned_columns.each do |col|
              updated_col = col if !updated_col && %(updated_at updated_on).include?(col.name)
              self.connection.add_column versioned_table_name, col.name, col.type,
                :limit => col.limit,
                :default => col.default
            end

            if type_col = self.columns_hash[inheritance_column]
              self.connection.add_column versioned_table_name, versioned_inheritance_column, type_col.type,
                :limit => type_col.limit,
                :default => type_col.default
            end

            if updated_col.nil?
              self.connection.add_column versioned_table_name, :updated_at, :timestamp
            end
          end

          # Rake migration task to drop the versioned table
          def drop_versioned_table
            self.connection.drop_table versioned_table_name
          end

          # Executes the block with the update callbacks disabled.
          #
          #   Foo.without_update_callbacks do
          #     @foo.save
          #   end
          #
          def without_update_callbacks(&block)
            class_eval do
              CALLBACKS.each do |attr_name|
                alias_method "orig_#{attr_name}".to_sym, attr_name
                alias_method attr_name, :empty_callback
              end
            end
            block.call
          ensure
            class_eval do
              CALLBACKS.each do |attr_name|
                alias_method attr_name, "orig_#{attr_name}".to_sym
              end
            end
          end
        end
      end
    end
  end
end

ActiveRecord::Base.class_eval do
  include Jits::Acts::ExplicitVersioning
end