Acts_as_multi_versioned

Hi, I’ve been playing around with something I call
acts_as_multi_versioned. The difference from acts_as_versioned is that
you can work with different versions simultaneously. For example if
one session sees one version, and another session sees another. It’s
designed like this:

All tables need to have two primary-keys, id (autoincrement) and
version (not autoincrement). There is no extra table for older
versions, everything is in the same table.

The variable ActiveRecord::Base.current_version is used to define what
version use currently use. That version will be used for all tables.
When an object, for example with id 3, don’t exists for version 4, the
system will search for the closest version available (which means
max(version < current_version). So, a new version only takes extra
space for the modified objects.

Currently it looks like obj.destroy is like doing revert on a revision
system :). Needs to be fixed. Currently it’s just a proof of concept,
but it would be nice with comments and suggestions.

Have a nice day!
Tobias Nurmiranta

Minimal example (the function ver sets the current version):

Loading development environment.

n = Node.create :name => “aaa”
=> #<Node:0xb6fe42d0 @errors=#<ActiveRecord::Errors:0xb6fb5a20
@errors={}, @base=#<Node:0xb6fe42d0 …>>, @new_record=false,
@attributes={“name”=>“aaa”, “id”=>22, “version”=>0}>

n.name
=> “aaa”

ver 1
=> 1

n.name = “bbb”
=> “bbb”

n.save
=> true

ver 0
=> 0

Node.find(22).name
=> “aaa”

ver 1
=> 1

Node.find(22).name
=> “bbb”

ver 25
=> 25

Node.find(22)
=> #<Node:0xb7378a2c @attributes={“name”=>“bbb”, “id”=>“22”,
“version”=>“1”}>


Monkey-patch:

require ‘composite_primary_keys’

class ActiveRecord::Base
class << self
attr_accessor :current_version
end
self.current_version = 0

def self.acts_as_multi_versioned
class_eval %q{
set_primary_keys :id, :version
class << self
def find_with_version(obj, *args)
case obj
when :first, :all
find_without_version(obj, *args)
else
begin
find_without_version(obj,
ActiveRecord::Base.current_version, *args)
rescue => e
find_without_version(:all, (args[0] || {}).merge(
{:order => ‘version desc’, :limit => 1, :conditions
=> [‘id = ? AND version < ?’, obj,
ActiveRecord::Base.current_version]})).first || (raise e)
end
end
end
alias_method_chain :find, :version
end
def before_create
self[:version] = ActiveRecord::Base.current_version
end

  def update_without_callbacks_with_version
    if self.version != ActiveRecord::Base.current_version
      self[:version] = ActiveRecord::Base.current_version
      # must create a new object when storing a new version?
      # but the statement above makes the old record store into

the new row?
o = self.clone
o[:id] = self[:id]
o.save!
else
update_without_callbacks_without_version
end
end
alias_method_chain :update_without_callbacks, :version

  # Note: needed monkey-patch to be able to use id as one of the

primary keys.
def id
self[:id]
end
def id=(val)
self[:id] = val
end
# Creates a new record with values matching those of the
instance attributes.
# Note: call self.ids instead of self.ids
def create_without_callbacks
unless self.ids; raise CompositeKeyError, “Composite keys do
not generated ids from sequences, you must provide id values”; end
attributes_minus_pks = attributes_with_quotes(false)
cols = quoted_column_names(attributes_minus_pks) <<
self.class.primary_key
vals = attributes_minus_pks.values << quoted_id

    self[:id] = connection.insert(
      "INSERT INTO #{self.class.table_name} " +
      "(#{cols.join(', ')}) " +
      "VALUES(#{vals.join(', ')})",
      "#{self.class.name} Create"
    )
    @new_record = false
    return true
  end
}

end
end

setting current version…

def ver(i)
ActiveRecord::Base.current_version = i
end

class Node < ActiveRecord::Base
acts_as_multi_versioned
end

Hi. I’ve updated the code mentioned below, if anyone is interested or
want to comment. Tables with version-control now have the extra
columns version (primary key) and version_deleted (boolean indicating
if the object is deleted in this version). I needed some complicated
SQL to get it working with find(:all) and find(:first) :). More
details in the old mail.

Have a nice day!
Tobias

– code –

acts_as_multi_versioned

class ActiveRecord::Base
class << self
attr_accessor :current_version
end
self.current_version = 0

def self.acts_as_multi_versioned
class_eval %q{
class << self
def find_version(obj, version, args = {})
# Adding a join-statement which selects the rows with
highest
# version =< current_version for every unique value of id.
args[:joins] = “INNER JOIN (SELECT
#{primary_key},MAX(version) as version FROM #{table_name} WHERE
version < #{version+1} GROUP BY #{primary_key}) #{table_name}_versions
ON #{table_name}_versions.#{primary_key} =
#{table_name}.#{primary_key} AND #{table_name}_versions.version =
#{table_name}.version AND #{table_name}.version_deleted = 0
#{args[:joins]}”
args[:readonly] ||= false
find_without_version(obj, args)
end
def find_with_version(obj, args = {})
find_version(obj, ActiveRecord::Base.current_version, args)
end
alias_method_chain :find, :version
end

  def before_create
    self[:version] = ActiveRecord::Base.current_version
  end

  def update_without_callbacks
    if self.version != ActiveRecord::Base.current_version
      self[:version] = ActiveRecord::Base.current_version
      # Must create a new object when storing a new version-

number. But the
# old record will work correctly for the next update.
o = self.clone
o[self.class.primary_key] = self[self.class.primary_key] #
why is this needed?
o.save!
else
# Modified update_without_callbacks from
composite_primary_keys.
where_class = “(#{self.class.primary_key} = #{quoted_id})
AND (version = #{self[:version]})”

      connection.update(

        "UPDATE #{self.class.table_name} " +

        "SET #{quoted_comma_pair_list(connection,

attributes_with_quotes_versioned)} " +

        "WHERE #{where_class}",

        "#{self.class.name} Update"

      )

      return true
    end
  end

  def destroy_without_callbacks
    begin
      self.class.find_version(self.id, self.version - 1) # older

version exists?
self.version_deleted = true
self.save! # (this will clone and create a deleted record if
only readaccess was made earlier)

    rescue ActiveRecord::RecordNotFound # older record doesn't

exist, remove it from database.
where_class = “(#{self.class.primary_key} = #{quoted_id})
AND (version = #{self[:version]})”
unless new_record?

        connection.delete(

          "DELETE FROM #{self.class.table_name} " +

          "WHERE #{where_class}",

          "#{self.class.name} Destroy"

        )

      end
    end
    freeze
  end

  def attributes_with_quotes_versioned # All attributes except

primary-key and ‘version’.
x = attributes_with_quotes(false)
x.delete “version”
x
end
}
end
end

ActiveRecord::Base.current_version = 3