module Versionary

from https://github.com/buzzware/buzztools/blob/master/lib/buzztools/versionary.rb
Versionary is a mixin for implementing versioning of models with ActiveRecord in Rails 3.2+
It attempts to be better than other solutions eg.

* no shadow tables
* does not abuse ActiveRecord, no magic
* no serialization at all, so queries work with all versions
* associations work like normal ActiveRecord. The id column identifies a single version of an instance. You can associate to an old version
* its easy to read and write old versions
* if you use and keep updated the ver_current column, it is extremely fast
* this file is really all there is to it
* supports future versions that become current when the time comes eg. future price changes

This is achieved by using the id column to identify each version of each instance, unlike some solutions that use the id per instance, then have to do trickery to provide versions.
This means associations can simply attach to any version of any instance using the id column as normal
Instances are identified by the iid column

id    iid  version   name  price
1     3         1         eggs  2.99
2     3         2         eggs  3.10
3     4         1         bread 3.50

In the above table, eggs (iid = 3) has 2 versions and bread (iid = 4) has 1 version.
An order can simply attach to any product/version using the id column

The eggs product would be created as follows :

eggs = Product.create!(name: 'eggs', price: 2.99)
eggs2 = eggs.create_version!(price: 3.10)

current_10_dollar_products = Product.live_current_versions.where(price: 10)

Migration

class CreateThings < ActiveRecord::Migration

def change
  create_table :things do |t|
    t.integer :iid         # instance id - versions of an instance will have different ids but the same iid
    t.integer :version
    t.integer :current_from, limit: 8    # timestamp in milliseconds since 1970
                      t.boolean :ver_current, null: false, default: false    # optional, for performance. true indicates this is the current version

    t.integer :size
    t.string :colour
    t.string :shape
  end
             add_index(:things, [:iid, :version], :unique => true)
end

end

Model

class Thing < ActiveRecord::Base

include Versionary

end

Public Class Methods

included(aClass) click to toggle source
# File lib/buzztools/extras/versionary.rb, line 62
      def self.included(aClass)
  aClass.class_eval do

          def self.next_version_id(aIid)
                     where(iid: aIid).maximum(:version).to_i + 1
             end

          after_create do
                     updates = {}
                     updates[:iid] = id if !iid
                  if !version
                       updates[:version] = self.class.next_version_id(self.iid)
                  end
                     update_attributes!(updates) unless updates.empty?
                     true
             end

                      # should be able to do eg. : TaxRate.where(owner_id: 1,dealership_id: 2).latest_versions.where(state: 'WA')
                      scope :live_latest_versions, -> {
                              live_current_versions(nil)
                      }

          # Scopes to the current version for all iids at the given timestamp
          # This and other methods beginning with "live" do not use the ver_current column
          # if the timestamp is nil, it will return the highest available version regardless of current_from
                      scope :live_current_versions, ->(aTimestamp) {
                              aTimestamp = aTimestamp.to_ms if aTimestamp && aTimestamp.is_a?(Time)
                              inner = clone.select("iid, max(version) as version")
                              inner = inner.where(["current_from <= ?",aTimestamp]) if aTimestamp
                              inner = inner.group(:iid).to_sql
                              ids = ActiveRecord::Base.connection.execute("select id from (#{inner}) as v inner join #{table_name} as t on t.iid = v.iid and t.version = v.version").to_a
                              if (adapter = ActiveRecord::Base.configurations[Rails.env]['adapter'])=='postgresql'
                                      ids = ids.map{|i| i['id']}.join(',')
                              elsif adapter.begins_with? 'mysql'
                                      ids = ids.flatten.join(',')
                              else
                                      raise "Adapter #{adapter} not supported"
                              end
                              if ids.to_nil
                                      where "id IN (#{ids})"
                              else
                                      where("1=0")              # relation that matches nothing
                              end
                      }

          # Scopes to the current version of a given iid. Can only return 0 or 1 records
          # This and other methods beginning with "live" do not use the ver_current column
          scope :live_current_version, ->(aIid,aTimestamp=nil) {
                  aTimestamp ||= KojacUtils.timestamp
                  where(iid: aIid).where(["current_from <= ?",aTimestamp]).order('version DESC').limit(1)
          }

          # Scopes to current version for all iids using the ver_current column. The ver_current must be updated regularly using update_all_ver_current.
          # This method is much faster than live_current_versions
          scope :current_versions, -> {
                  where(ver_current: true)
          }

          # Updates the ver_current column, which enables simpler and much faster queries on current versions eg. using current_versions instead of live_current_versions.
          # Must be run periodically eg. 4am daily
          # !!! probably should do something like this after every create_version!
          def self.update_all_ver_current
                  self.update_all(ver_current: false)
                  self.live_current_versions(Time.now.to_ms).update_all(ver_current: true)
          end
  end
end
next_version_id(aIid) click to toggle source
# File lib/buzztools/extras/versionary.rb, line 65
def self.next_version_id(aIid)
           where(iid: aIid).maximum(:version).to_i + 1
   end
update_all_ver_current() click to toggle source

Updates the ver_current column, which enables simpler and much faster queries on current versions eg. using current_versions instead of live_current_versions. Must be run periodically eg. 4am daily !!! probably should do something like this after every create_version!

# File lib/buzztools/extras/versionary.rb, line 123
def self.update_all_ver_current
        self.update_all(ver_current: false)
        self.live_current_versions(Time.now.to_ms).update_all(ver_current: true)
end

Public Instance Methods

copyable_attributes() click to toggle source
# File lib/buzztools/extras/versionary.rb, line 130
def copyable_attributes
        result = {}
        self.class.columns.each do |c|
                next if ['id', 'version'].include? c.name
                result[c.name.to_sym] = self.send(c.name)
        end
        result
end
create_version!(aValues) click to toggle source
# File lib/buzztools/extras/versionary.rb, line 147
def create_version!(aValues)
        raise "iid must be set before calling new_version" unless self.iid
        attrs = copyable_attributes
        attrs[:version] = self.class.next_version_id(self.iid)
        attrs.merge!(aValues.symbolize_keys)
        self.class.create!(attrs)
end
current_version() click to toggle source
# File lib/buzztools/extras/versionary.rb, line 155
def current_version
        self.class.live_current_version(self.iid,KojacUtils.timestamp).first
end
new_version(aValues) click to toggle source
# File lib/buzztools/extras/versionary.rb, line 139
def new_version(aValues)
        raise "iid must be set before calling new_version" unless self.iid
        attrs = copyable_attributes
        attrs[:version] = self.class.next_version_id(self.iid)
        ver = self.class.new(attrs)
        ver
end
versions() click to toggle source
# File lib/buzztools/extras/versionary.rb, line 159
def versions
        self.class.where(iid: iid).order(:version)
end