jobready/versioned_record

View on GitHub
lib/versioned_record.rb

Summary

Maintainability
A
0 mins
Test Coverage

require 'active_record'
require 'active_record/connection_adapters/postgresql_adapter'

require 'composite_primary_keys'
require 'versioned_record/attribute_builder'
require 'versioned_record/class_methods'
require 'versioned_record/connection_adapters/postgresql'
require 'versioned_record/attribute_methods/write'
require 'versioned_record/version'
require 'versioned_record/composite_predicates'
require 'versioned_record/active_record_versioning'

ActiveRecord::Associations::AssociationScope.include(VersionedRecord::CompositePredicates)

module VersionedRecord
  def self.included(model_class)
    model_class.primary_keys = :id, :version
    model_class.after_save :ensure_version_deprecation!, on: :create
    model_class.extend ClassMethods
    model_class.include InstanceMethods
  end

  module InstanceMethods
    # @return just the ID integer value (not the composite id, version key)
    def _id
      id[0]
    end

    # Create a new version of the existing record
    # A new version can only be created once for a given record and subsequent
    # versions must be created by calling create_version! on the latest version
    #
    # Attributes that are not specified here will be copied to the new version from
    # the previous version
    #
    # This method will still fire ActiveRecord callbacks for save/create etc as per
    # normal record creation
    #
    # @example
    #
    #     person_v1 = Person.create(name: 'Dan')
    #     person_v2 = person_v1.create_version!(name: 'Daniel')
    #
    def create_version!(new_attrs = {})
      create_operation do
        self.class.create!(new_version_attrs(new_attrs))
      end
    end

    # Same as #create_version! but will not raise if the record is invalid
    # @see VersionedRecord#create_version!
    #
    def create_version(new_attrs = {})
      create_operation do
        self.class.create(new_version_attrs(new_attrs))
      end
    end

    # Build (but do not save) a new version of the record
    # This allows you to use the object in forms etc
    # After the record is saved, all previous versions will be deprecated
    # and this record will be marked as current
    #
    # @example
    #
    #     new_version = first_version.build_version
    #     new_version.save
    #
    def build_version(new_attrs = {})
      new_version = self.class.new(new_version_attrs(new_attrs)).tap do |built|
        built.deprecate_old_versions_after_create!
        preserve_has_one_associations_to(built) 
     end
    end

    # Retrieve all versions of this record
    # Can be chained with other scopes
    #
    # @example Versions ordered by version number
    #
    #     person.versions.order(:version)
    #
    def versions
      self.class.where(id: self._id)
    end

    # Retrieve the current version of an object
    # (May be itself)
    #
    def current_version
      versions.current_versions.first
    end

    # Ensure that old versions are deprecated when we save
    # (only applies on create)
    def deprecate_old_versions_after_create!
      @deprecate_old_versions_after_create = true
    end

    private
      def new_version_attrs(new_attrs)
        attr_builder = AttributeBuilder.new(self)
        attr_builder.attributes(new_attrs)
      end

      def deprecate_old_versions(current_version)
        versions.exclude(current_version).update_all(is_current_version: false)
      end

      def create_operation
        self.class.transaction do
          yield.tap do |created|
            deprecate_old_versions(created) if created.persisted?
          end
        end
      end

      def deprecate_old_versions_after_create?
        @deprecate_old_versions_after_create
      end

      def ensure_version_deprecation!
        if deprecate_old_versions_after_create?
          deprecate_old_versions(self)
        end
      end

      # This is required because a new version which has not been persisted
      # to the database breaks the normal ActiveRecord paradigm.
      # Because normally when a record has not yet been persisted
      # it can have no persisted has_one associations because there is no foriegn key.
      # In our case we have a foreign key because it was determined from the
      # previous version.
      #
      # This doesn't apply to composite has_one associations because they will
      # use a different foreign key to the parent version.
      #
      def preserve_has_one_associations_to(new_version)
        # Preserve simple has_one reflections
        self.class.reflections.select { |_, reflection|
          reflection.macro == :has_one
        }.each do |key, reflection|
          if !reflection.foreign_key.kind_of?(Array)
            new_version.send("#{key}=", self.send(key))
          end
        end
      end
  end
end