Module: Versionary

Defined in:
lib/buzztools/extras/versionary.rb

Overview

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

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.included(aClass) ⇒ Object



62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# 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, -> {
		inner = clone.select("iid, max(version) as version").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

		#ids = ActiveRecord::Base.connection.execute("select id from (SELECT iid, max(version) as version FROM `things` GROUP BY iid) as v inner join things as t on t.iid = v.iid and t.version = v.version").to_a.flatten.join(',')
		where "id IN (#{ids})"
	}

   # 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
	scope :live_current_versions, ->(aTimestamp) {
		inner = clone.select("iid, max(version) as version").where(["current_from <= ?",aTimestamp]).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
   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

Instance Method Details

#copyable_attributesObject



136
137
138
139
140
141
142
143
# File 'lib/buzztools/extras/versionary.rb', line 136

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) ⇒ Object



153
154
155
156
157
158
159
# File 'lib/buzztools/extras/versionary.rb', line 153

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_versionObject



161
162
163
# File 'lib/buzztools/extras/versionary.rb', line 161

def current_version
	self.class.live_current_version(self.iid,KojacUtils.timestamp).first
end

#new_version(aValues) ⇒ Object



145
146
147
148
149
150
151
# File 'lib/buzztools/extras/versionary.rb', line 145

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