Gem: acts_as_versioned

master
Alinson S. Xavier 18 years ago
parent 427c33aee1
commit 100d6bd4c6

@ -0,0 +1,39 @@
*0.2.3*
* (12 Nov 2005) fixed bug with old behavior of #blank? [Michael Schuerig]
* (12 Nov 2005) updated tests to use ActiveRecord Schema
*0.2.2*
* (3 Nov 2005) added documentation note to #acts_as_versioned [Martin Jul]
*0.2.1*
* (6 Oct 2005) renamed dirty? to changed? to keep it uniform. it was aliased to keep it backwards compatible.
*0.2*
* (6 Oct 2005) added find_versions and find_version class methods.
* (6 Oct 2005) removed transaction from create_versioned_table().
this way you can specify your own transaction around a group of operations.
* (30 Sep 2005) fixed bug where find_versions() would order by 'version' twice. (found by Joe Clark)
* (26 Sep 2005) added :sequence_name option to acts_as_versioned to set the sequence name on the versioned model
*0.1.3* (18 Sep 2005)
* First RubyForge release
*0.1.2*
* check if module is already included when acts_as_versioned is called
*0.1.1*
* Adding tests and rdocs
*0.1*
* Initial transfer from Rails ticket: http://dev.rubyonrails.com/ticket/1974

@ -0,0 +1,20 @@
Copyright (c) 2005 Rick Olson
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

@ -0,0 +1,28 @@
= acts_as_versioned
This library adds simple versioning to an ActiveRecord module. ActiveRecord is required.
== Resources
Install
* gem install acts_as_versioned
Rubyforge project
* http://rubyforge.org/projects/ar-versioned
RDocs
* http://ar-versioned.rubyforge.org
Subversion
* http://techno-weenie.net/svn/projects/acts_as_versioned
Collaboa
* http://collaboa.techno-weenie.net/repository/browse/acts_as_versioned
Special thanks to Dreamer on ##rubyonrails for help in early testing. His ServerSideWiki (http://serversidewiki.com)
was the first project to use acts_as_versioned <em>in the wild</em>.

@ -0,0 +1,41 @@
== Creating the test database
The default name for the test databases is "activerecord_versioned". If you
want to use another database name then be sure to update the connection
adapter setups you want to test with in test/connections/<your database>/connection.rb.
When you have the database online, you can import the fixture tables with
the test/fixtures/db_definitions/*.sql files.
Make sure that you create database objects with the same user that you specified in i
connection.rb otherwise (on Postgres, at least) tests for default values will fail.
== Running with Rake
The easiest way to run the unit tests is through Rake. The default task runs
the entire test suite for all the adapters. You can also run the suite on just
one adapter by using the tasks test_mysql_ruby, test_ruby_mysql, test_sqlite,
or test_postresql. For more information, checkout the full array of rake tasks with "rake -T"
Rake can be found at http://rake.rubyforge.org
== Running by hand
Unit tests are located in test directory. If you only want to run a single test suite,
or don't want to bother with Rake, you can do so with something like:
cd test; ruby -I "connections/native_mysql" base_test.rb
That'll run the base suite using the MySQL-Ruby adapter. Change the adapter
and test suite name as needed.
== Faster tests
If you are using a database that supports transactions, you can set the
"AR_TX_FIXTURES" environment variable to "yes" to use transactional fixtures.
This gives a very large speed boost. With rake:
rake AR_TX_FIXTURES=yes
Or, by hand:
AR_TX_FIXTURES=yes ruby -I connections/native_sqlite3 base_test.rb

@ -0,0 +1,3 @@
require File.join(File.dirname(__FILE__), 'lib', 'acts_as_versioned')

@ -0,0 +1,397 @@
# Copyright (c) 2005 Rick Olson
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
module ActiveRecord #:nodoc:
module Acts #:nodoc:
# Specify this act if you want to save a copy of the row in a versioned table. This assumes there is a
# versioned table ready and that your model has a version field. This works with optimisic locking if the lock_version
# column is present as well.
#
# The class for the versioned model is derived the first time it is seen. Therefore, if you change your database schema you have to restart
# your container for the changes to be reflected. In development mode this usually means restarting WEBrick.
#
# class Page < ActiveRecord::Base
# # assumes pages_versions table
# acts_as_versioned
# end
#
# Example:
#
# page = Page.create(:title => 'hello world!')
# page.version # => 1
#
# page.title = 'hello world'
# page.save
# page.version # => 2
# page.versions.size # => 2
#
# page.revert_to(1) # using version number
# page.title # => 'hello world!'
#
# page.revert_to(page.versions.last) # using versioned instance
# page.title # => 'hello world'
#
# See ActiveRecord::Acts::Versioned::ClassMethods#acts_as_versioned for configuration options
module Versioned
def self.included(base) # :nodoc:
base.extend ClassMethods
end
module ClassMethods
# == Configuration options
#
# * <tt>class_name</tt> - versioned model class name (default: PageVersion in the above example)
# * <tt>table_name</tt> - versioned model table name (default: page_versions in the above example)
# * <tt>foreign_key</tt> - foreign key used to relate the versioned model to the original model (default: page_id in the above example)
# * <tt>inheritance_column</tt> - name of the column to save the model's inheritance_column value for STI. (default: versioned_type)
# * <tt>version_column</tt> - name of the column in the model that keeps the version number (default: version)
# * <tt>sequence_name</tt> - name of the custom sequence to be used by the versioned model.
# * <tt>limit</tt> - number of revisions to keep, defaults to unlimited
# * <tt>if</tt> - symbol of method to check before saving a new version. If this method returns false, a new version is not saved.
# For finer control, pass either a Proc or modify Model#version_condition_met?
#
# acts_as_versioned :if => Proc.new { |auction| !auction.expired? }
#
# or...
#
# class Auction
# def version_condition_met? # totally bypasses the <tt>:if</tt> option
# !expired?
# end
# end
#
# * <tt>if_changed</tt> - Simple way of specifying attributes that are required to be changed before saving a model. This takes
# either a symbol or array of symbols.
#
# == Database Schema
#
# The model that you're versioning needs to have a 'version' attribute. The model is versioned
# into a table called #{model}_versions where the model name is singlular. The _versions table should
# contain all the fields you want versioned, the same version column, and a #{model}_id foreign key field.
#
# A lock_version field is also accepted if your model uses Optimistic Locking. If your table uses Single Table inheritance,
# then that field is reflected in the versioned model as 'versioned_type' by default.
#
# Acts_as_versioned comes prepared with the ActiveRecord::Acts::Versioned::ActMethods::ClassMethods#create_versioned_table
# method, perfect for a migration. It will also create the version column if the main model does not already have it.
#
# class AddVersions < ActiveRecord::Migration
# def self.up
# # create_versioned_table takes the same options hash
# # that create_table does
# Post.create_versioned_table
# end
#
# def self.down
# Post.drop_versioned_table
# end
# end
def acts_as_versioned(options = {})
# don't allow multiple calls
return if self.included_modules.include?(ActiveRecord::Acts::Versioned::ActMethods)
class_eval do
include ActiveRecord::Acts::Versioned::ActMethods
cattr_accessor :versioned_class_name, :versioned_foreign_key, :versioned_table_name, :versioned_inheritance_column,
:version_column, :max_version_limit, :track_changed_attributes, :version_condition, :version_sequence_name
attr_accessor :changed_attributes
end
self.versioned_class_name = options[:class_name] || "#{self.to_s.demodulize}Version"
self.versioned_foreign_key = options[:foreign_key] || self.to_s.foreign_key
self.versioned_table_name = options[:table_name] || "#{table_name_prefix}#{Inflector.underscore(Inflector.demodulize(class_name_of_active_record_descendant(self)))}_versions#{table_name_suffix}"
self.versioned_inheritance_column = options[:inheritance_column] || "versioned_#{inheritance_column}"
self.version_column = options[:version_column] || 'version'
self.version_sequence_name = options[:sequence_name]
self.max_version_limit = options[:limit].to_i
self.version_condition = options[:if] || true
class_eval do
has_many :versions,
:class_name => "ActiveRecord::Acts::Versioned::#{versioned_class_name}",
:foreign_key => "#{versioned_foreign_key}",
:order => 'version'
before_save :set_new_version
after_create :save_version_on_create
after_update :save_version
after_save :clear_old_versions
after_save :clear_changed_attributes
unless options[:if_changed].nil?
self.track_changed_attributes = true
options[:if_changed] = [options[:if_changed]] unless options[:if_changed].is_a?(Array)
options[:if_changed].each do |attr_name|
define_method("#{attr_name}=") do |value|
(self.changed_attributes ||= []) << attr_name.to_s unless self.changed?(attr_name) or self.send(attr_name) == value
write_attribute(attr_name.to_s, value)
end
end
end
end
# create the dynamic versioned model
# maybe if i sit down long enough i can think up a better way to do this.
dynamic_model = <<-EOV
class ActiveRecord::Acts::Versioned::#{versioned_class_name} < ActiveRecord::Base
set_table_name "#{versioned_table_name}"
belongs_to :#{self.to_s.demodulize.underscore}, :class_name => "#{self.to_s}"
EOV
dynamic_model += %Q{set_sequence_name "#{version_sequence_name}"\n} if version_sequence_name
eval dynamic_model + 'end'
end
end
module ActMethods
def self.included(base) # :nodoc:
base.extend ClassMethods
end
# Saves a version of the model if applicable
def save_version
save_version_on_create if save_version?
end
# Saves a version of the model in the versioned table. This is called in the after_save callback by default
def save_version_on_create
rev = self.class.versioned_class.new
self.clone_versioned_model(self, rev)
rev.version = send(self.class.version_column)
rev.send("#{self.class.versioned_foreign_key}=", self.id)
rev.save
end
# Clears old revisions if a limit is set with the :limit option in <tt>acts_as_versioned</tt>.
# Override this method to set your own criteria for clearing old versions.
def clear_old_versions
return if self.class.max_version_limit == 0
excess_baggage = send(self.class.version_column).to_i - self.class.max_version_limit
if excess_baggage > 0
sql = "DELETE FROM #{self.class.versioned_table_name} WHERE version <= #{excess_baggage} AND #{self.class.versioned_foreign_key} = #{self.id}"
self.class.versioned_class.connection.execute sql
end
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
# Finds versions of this model. Takes an options hash like <tt>find</tt>
def find_versions(options = {})
versions.find(:all, options)
end
# Reverts a model to a given version. Takes either a version number or an instance of the versioned model
def revert_to(version)
if version.is_a?(self.class.versioned_class)
return false unless version.send(self.class.versioned_foreign_key) == self.id and !version.new_record?
else
return false unless version = find_version(version)
end
self.clone_versioned_model(version, self)
self.send("#{self.class.version_column}=", version.version)
true
end
# Reverts a model to a given version and saves the model.
# Takes either a version number or an instance of the versioned model
def revert_to!(version)
revert_to(version) ? save_without_revision : false
end
# Temporarily turns off Optimistic Locking while saving. Used when reverting so that a new version is not created.
def save_without_revision
old_lock_value = ActiveRecord::Base.lock_optimistically
ActiveRecord::Base.lock_optimistically = false if old_lock_value
disable_acts_as_versioned_callbacks
save_result = self.save
enable_acts_as_versioned_callbacks
ActiveRecord::Base.lock_optimistically = true if old_lock_value
save_result
end
# Returns an array of attribute keys that are versioned. See non_versioned_fields
def versioned_attributes
self.attributes.keys.select { |k| !self.class.non_versioned_fields.include?(k) }
end
# If called with no parameters, gets whether the current model has changed and needs to be versioned.
# If called with a single parameter, gets whether the parameter has changed.
def changed?(attr_name = nil)
attr_name.nil? ?
(!self.class.track_changed_attributes or (changed_attributes and changed_attributes.length > 0)) :
(changed_attributes and changed_attributes.include?(attr_name.to_s))
end
# keep old dirty? method
alias_method :dirty?, :changed?
# Clones a model. Used when saving a new version or reverting a model's version.
def clone_versioned_model(orig_model, new_model)
self.versioned_attributes.each do |key|
new_model.send("#{key}=", orig_model.attributes[key]) if orig_model.attribute_present?(key)
end
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
# Checks whether a new version shall be saved or not. Calls <tt>version_condition_met?</tt> and <tt>changed?</tt>.
def save_version?
version_condition_met? and changed?
end
# Checks condition set in the :if option to check whether a revision should be created or not. Override this for
# custom version condition checking.
def version_condition_met?
case
when version_condition.is_a?(Symbol)
send(version_condition)
when version_condition.respond_to?(:call) && (version_condition.arity == 1 || version_condition.arity == -1)
version_condition.call(self)
else
version_condition
end
end
protected
# sets the new version before saving, unless you're using optimistic locking. In that case, let it take care of the version.
def set_new_version
self.send("#{self.class.version_column}=", self.next_version) if new_record? or (!locking_enabled? and save_version?)
end
# Gets the next available version for the current record, or 1 for a new record
def next_version
return 1 if new_record?
connection.select_one("SELECT MAX(version)+1 AS next_version FROM #{self.class.versioned_table_name} WHERE #{self.class.versioned_foreign_key} = #{self.id}")['next_version'] || 1
end
# clears current changed attributes. Called after save.
def clear_changed_attributes
self.changed_attributes = []
end
private
unless defined?(ACTS_AS_VERSIONED_CALLBACKS)
ACTS_AS_VERSIONED_CALLBACKS = [:set_new_version, :save_version_on_create, :save_version, :clear_changed_attributes]
end
ACTS_AS_VERSIONED_CALLBACKS.each do |attr_name|
alias_method "orig_#{attr_name}".to_sym, attr_name
end
def empty_callback() end #:nodoc:
def enable_acts_as_versioned_callbacks
self.class.class_eval do
ACTS_AS_VERSIONED_CALLBACKS.each do |attr_name|
alias_method attr_name, "orig_#{attr_name}".to_sym
end
end
end
def disable_acts_as_versioned_callbacks
self.class.class_eval do
ACTS_AS_VERSIONED_CALLBACKS.each do |attr_name|
alias_method attr_name, :empty_callback
end
end
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_fields
def versioned_columns
self.columns.select { |c| !non_versioned_fields.include?(c.name) }
end
# Returns an instance of the dynamic versioned model
def versioned_class
"ActiveRecord::Acts::Versioned::#{versioned_class_name}".constantize
end
# An array of fields that are not saved in the versioned table
def non_versioned_fields
[self.primary_key, inheritance_column, 'version', 'lock_version', versioned_inheritance_column]
end
# Rake migration task to create the versioned table using options passed to acts_as_versioned
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
end
self.connection.create_table(versioned_table_name, create_table_options) do |t|
t.column versioned_foreign_key, :integer
t.column :version, :integer
end
updated_col = nil
self.versioned_columns.each do |col|
updated_col = col if !updated_col and %(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
end
end
end
end
end
ActiveRecord::Base.class_eval { include ActiveRecord::Acts::Versioned }

@ -0,0 +1,44 @@
$:.unshift(File.dirname(__FILE__) + '/../lib')
require 'rubygems'
require 'test/unit'
require 'active_record'
require 'active_record/fixtures'
require 'active_support/binding_of_caller'
require 'active_support/breakpoint'
require "#{File.dirname(__FILE__)}/../init"
config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml'))
ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + "/debug.log")
ActiveRecord::Base.establish_connection(config[ENV['DB'] || 'sqlite'])
load(File.dirname(__FILE__) + "/schema.rb")
# set up custom sequence on widget_versions for DBs that support sequences
if ENV['DB'] == 'postgresql'
ActiveRecord::Base.connection.execute "DROP SEQUENCE widgets_seq;" rescue nil
ActiveRecord::Base.connection.remove_column :widget_versions, :id
ActiveRecord::Base.connection.execute "CREATE SEQUENCE widgets_seq START 101;"
ActiveRecord::Base.connection.execute "ALTER TABLE widget_versions ADD COLUMN id INTEGER PRIMARY KEY DEFAULT nextval('widgets_seq');"
end
Test::Unit::TestCase.fixture_path = File.dirname(__FILE__) + "/fixtures/"
$LOAD_PATH.unshift(Test::Unit::TestCase.fixture_path)
class Test::Unit::TestCase #:nodoc:
def create_fixtures(*table_names)
if block_given?
Fixtures.create_fixtures(Test::Unit::TestCase.fixture_path, table_names) { yield }
else
Fixtures.create_fixtures(Test::Unit::TestCase.fixture_path, table_names)
end
end
# Turn off transactional fixtures if you're working with MyISAM tables in MySQL
self.use_transactional_fixtures = true
# Instantiated fixtures are slow, but give you @david where you otherwise would need people(:david)
self.use_instantiated_fixtures = false
# Add more helper methods to be used by all tests here...
end

@ -0,0 +1,18 @@
sqlite:
:adapter: sqlite
:dbfile: acts_as_versioned_plugin.sqlite.db
sqlite3:
:adapter: sqlite3
:dbfile: acts_as_versioned_plugin.sqlite3.db
postgresql:
:adapter: postgresql
:username: postgres
:password: postgres
:database: acts_as_versioned_plugin_test
:min_messages: ERROR
mysql:
:adapter: mysql
:host: localhost
:username: rails
:password:
:database: acts_as_versioned_plugin_test

@ -0,0 +1,10 @@
welcome:
id: 1
title: Welcome to the weblog
lock_version: 24
type: LockedPage
thinking:
id: 2
title: So I was thinking
lock_version: 24
type: SpecialLockedPage

@ -0,0 +1,27 @@
welcome_1:
id: 1
page_id: 1
title: Welcome to the weblg
version: 23
version_type: LockedPage
welcome_2:
id: 2
page_id: 1
title: Welcome to the weblog
version: 24
version_type: LockedPage
thinking_1:
id: 3
page_id: 2
title: So I was thinking!!!
version: 23
version_type: SpecialLockedPage
thinking_2:
id: 4
page_id: 2
title: So I was thinking
version: 24
version_type: SpecialLockedPage

@ -0,0 +1,13 @@
class AddVersionedTables < ActiveRecord::Migration
def self.up
create_table("things") do |t|
t.column :title, :text
end
Thing.create_versioned_table
end
def self.down
Thing.drop_versioned_table
drop_table "things" rescue nil
end
end

@ -0,0 +1,24 @@
class Page < ActiveRecord::Base
cattr_accessor :feeling_good
@@feeling_good = true
acts_as_versioned :if => :feeling_good?
def feeling_good?
@@feeling_good == true
end
end
class LockedPage < ActiveRecord::Base
acts_as_versioned \
:inheritance_column => :version_type,
:foreign_key => :page_id,
:table_name => :locked_pages_revisions,
:class_name => 'LockedPageRevision',
:version_column => :lock_version,
:limit => 2,
:if_changed => :title
end
class SpecialLockedPage < LockedPage
end

@ -0,0 +1,12 @@
welcome_2:
id: 1
page_id: 1
title: Welcome to the weblog
body: Such a lovely day
version: 24
welcome_1:
id: 2
page_id: 1
title: Welcome to the weblg
body: Such a lovely day
version: 23

@ -0,0 +1,5 @@
welcome:
id: 1
title: Welcome to the weblog
body: Such a lovely day
version: 24

@ -0,0 +1,3 @@
class Widget < ActiveRecord::Base
acts_as_versioned :sequence_name => 'widgets_seq'
end

@ -0,0 +1,32 @@
require File.join(File.dirname(__FILE__), 'abstract_unit')
if ActiveRecord::Base.connection.supports_migrations?
class Thing < ActiveRecord::Base
attr_accessor :version
acts_as_versioned
end
class MigrationTest < Test::Unit::TestCase
self.use_transactional_fixtures = false
def teardown
ActiveRecord::Base.connection.initialize_schema_information
ActiveRecord::Base.connection.update "UPDATE schema_info SET version = 0"
Thing.connection.drop_table "things" rescue nil
Thing.connection.drop_table "thing_versions" rescue nil
Thing.reset_column_information
end
def test_versioned_migration
assert_raises(ActiveRecord::StatementInvalid) { Thing.create :title => 'blah blah' }
# take 'er up
ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/')
t = Thing.create :title => 'blah blah'
assert_equal 1, t.versions.size
# now lets take 'er back down
ActiveRecord::Migrator.down(File.dirname(__FILE__) + '/fixtures/migrations/')
assert_raises(ActiveRecord::StatementInvalid) { Thing.create :title => 'blah blah' }
end
end
end

@ -0,0 +1,43 @@
ActiveRecord::Schema.define(:version => 0) do
create_table :pages, :force => true do |t|
t.column :version, :integer
t.column :title, :string, :limit => 255
t.column :body, :text
t.column :updated_on, :datetime
end
create_table :page_versions, :force => true do |t|
t.column :page_id, :integer
t.column :version, :integer
t.column :title, :string, :limit => 255
t.column :body, :text
t.column :updated_on, :datetime
end
create_table :locked_pages, :force => true do |t|
t.column :lock_version, :integer
t.column :title, :string, :limit => 255
t.column :type, :string, :limit => 255
end
create_table :locked_pages_revisions, :force => true do |t|
t.column :page_id, :integer
t.column :version, :integer
t.column :title, :string, :limit => 255
t.column :version_type, :string, :limit => 255
t.column :updated_at, :datetime
end
create_table :widgets, :force => true do |t|
t.column :name, :string, :limit => 50
t.column :version, :integer
t.column :updated_at, :datetime
end
create_table :widget_versions, :force => true do |t|
t.column :widget_id, :integer
t.column :name, :string, :limit => 50
t.column :version, :integer
t.column :updated_at, :datetime
end
end

@ -0,0 +1,238 @@
require File.join(File.dirname(__FILE__), 'abstract_unit')
require File.join(File.dirname(__FILE__), 'fixtures/page')
require File.join(File.dirname(__FILE__), 'fixtures/widget')
class VersionedTest < Test::Unit::TestCase
fixtures :pages, :page_versions, :locked_pages, :locked_pages_revisions
def test_saves_versioned_copy
p = Page.create :title => 'first title', :body => 'first body'
assert !p.new_record?
assert_equal 1, p.versions.size
assert_equal 1, p.version
assert_instance_of Page.versioned_class, p.versions.first
end
def test_rollback_with_version_number
p = pages(:welcome)
assert_equal 24, p.version
assert_equal 'Welcome to the weblog', p.title
assert p.revert_to!(p.versions.first.version), "Couldn't revert to 23"
assert_equal 23, p.version
assert_equal 'Welcome to the weblg', p.title
end
def test_versioned_class_name
assert_equal 'PageVersion', Page.versioned_class_name
assert_equal 'LockedPageRevision', LockedPage.versioned_class_name
end
def test_rollback_with_version_class
p = pages(:welcome)
assert_equal 24, p.version
assert_equal 'Welcome to the weblog', p.title
assert p.revert_to!(p.versions.first), "Couldn't revert to 23"
assert_equal 23, p.version
assert_equal 'Welcome to the weblg', p.title
end
def test_rollback_fails_with_invalid_revision
p = locked_pages(:welcome)
assert !p.revert_to!(locked_pages(:thinking))
end
def test_saves_versioned_copy_with_options
p = LockedPage.create :title => 'first title'
assert !p.new_record?
assert_equal 1, p.versions.size
assert_instance_of LockedPage.versioned_class, p.versions.first
end
def test_rollback_with_version_number_with_options
p = locked_pages(:welcome)
assert_equal 'Welcome to the weblog', p.title
assert_equal 'LockedPage', p.versions.first.version_type
assert p.revert_to!(p.versions.first.version), "Couldn't revert to 23"
assert_equal 'Welcome to the weblg', p.title
assert_equal 'LockedPage', p.versions.first.version_type
end
def test_rollback_with_version_class_with_options
p = locked_pages(:welcome)
assert_equal 'Welcome to the weblog', p.title
assert_equal 'LockedPage', p.versions.first.version_type
assert p.revert_to!(p.versions.first), "Couldn't revert to 1"
assert_equal 'Welcome to the weblg', p.title
assert_equal 'LockedPage', p.versions.first.version_type
end
def test_saves_versioned_copy_with_sti
p = SpecialLockedPage.create :title => 'first title'
assert !p.new_record?
assert_equal 1, p.versions.size
assert_instance_of LockedPage.versioned_class, p.versions.first
assert_equal 'SpecialLockedPage', p.versions.first.version_type
end
def test_rollback_with_version_number_with_sti
p = locked_pages(:thinking)
assert_equal 'So I was thinking', p.title
assert p.revert_to!(p.versions.first.version), "Couldn't revert to 1"
assert_equal 'So I was thinking!!!', p.title
assert_equal 'SpecialLockedPage', p.versions.first.version_type
end
def test_lock_version_works_with_versioning
p = locked_pages(:thinking)
p2 = LockedPage.find(p.id)
p.title = 'fresh title'
p.save
assert_equal 2, p.versions.size # limit!
assert_raises(ActiveRecord::StaleObjectError) do
p2.title = 'stale title'
p2.save
end
end
def test_version_if_condition
p = Page.create :title => "title"
assert_equal 1, p.version
Page.feeling_good = false
p.save
assert_equal 1, p.version
Page.feeling_good = true
end
def test_version_if_condition2
# set new if condition
Page.class_eval do
def new_feeling_good() title[0..0] == 'a'; end
alias_method :old_feeling_good, :feeling_good?
alias_method :feeling_good?, :new_feeling_good
end
p = Page.create :title => "title"
assert_equal 1, p.version # version does not increment
assert_equal 1, p.versions(true).size
p.update_attributes(:title => 'new title')
assert_equal 1, p.version # version does not increment
assert_equal 1, p.versions(true).size
p.update_attributes(:title => 'a title')
assert_equal 2, p.version
assert_equal 2, p.versions(true).size
# reset original if condition
Page.class_eval { alias_method :feeling_good?, :old_feeling_good }
end
def test_version_if_condition_with_block
# set new if condition
old_condition = Page.version_condition
Page.version_condition = Proc.new { |page| page.title[0..0] == 'b' }
p = Page.create :title => "title"
assert_equal 1, p.version # version does not increment
assert_equal 1, p.versions(true).size
p.update_attributes(:title => 'a title')
assert_equal 1, p.version # version does not increment
assert_equal 1, p.versions(true).size
p.update_attributes(:title => 'b title')
assert_equal 2, p.version
assert_equal 2, p.versions(true).size
# reset original if condition
Page.version_condition = old_condition
end
def test_version_no_limit
p = Page.create :title => "title", :body => 'first body'
p.save
p.save
5.times do |i|
assert_page_title p, i
end
end
def test_version_max_limit
p = LockedPage.create :title => "title"
p.update_attributes(:title => "title1")
p.update_attributes(:title => "title2")
5.times do |i|
assert_page_title p, i, :lock_version
assert p.versions(true).size <= 2, "locked version can only store 2 versions"
end
end
def test_track_changed_attributes_default_value
assert !Page.track_changed_attributes
assert LockedPage.track_changed_attributes
assert SpecialLockedPage.track_changed_attributes
end
def test_version_order
assert_equal 23, pages(:welcome).versions.first.version
assert_equal 24, pages(:welcome).versions.last.version
assert_equal 23, pages(:welcome).find_versions.first.version
assert_equal 24, pages(:welcome).find_versions.last.version
end
def test_track_changed_attributes
p = LockedPage.create :title => "title"
assert_equal 1, p.lock_version
assert_equal 1, p.versions(true).size
p.title = 'title'
assert !p.save_version?
p.save
assert_equal 2, p.lock_version # still increments version because of optimistic locking
assert_equal 1, p.versions(true).size
p.title = 'updated title'
assert p.save_version?
p.save
assert_equal 3, p.lock_version
assert_equal 1, p.versions(true).size # version 1 deleted
p.title = 'updated title!'
assert p.save_version?
p.save
assert_equal 4, p.lock_version
assert_equal 2, p.versions(true).size # version 1 deleted
end
def assert_page_title(p, i, version_field = :version)
p.title = "title#{i}"
p.save
assert_equal "title#{i}", p.title
assert_equal (i+4), p.send(version_field)
end
def test_find_versions
assert_equal 2, locked_pages(:welcome).versions.size
assert_equal 1, locked_pages(:welcome).find_versions(:conditions => ['title LIKE ?', '%weblog%']).length
assert_equal 2, locked_pages(:welcome).find_versions(:conditions => ['title LIKE ?', '%web%']).length
assert_equal 0, locked_pages(:thinking).find_versions(:conditions => ['title LIKE ?', '%web%']).length
assert_equal 2, locked_pages(:welcome).find_versions.length
end
def test_with_sequence
assert_equal 'widgets_seq', Widget.versioned_class.sequence_name
Widget.create :name => 'new widget'
Widget.create :name => 'new widget'
Widget.create :name => 'new widget'
assert_equal 3, Widget.count
assert_equal 3, Widget.versioned_class.count
end
end