andoq-vestal_versions 0.6.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +4 -0
- data/LICENSE +20 -0
- data/README.rdoc +77 -0
- data/Rakefile +38 -0
- data/VERSION +1 -0
- data/generators/vestal_versions_migration/templates/migration.rb +20 -0
- data/generators/vestal_versions_migration/vestal_versions_migration_generator.rb +11 -0
- data/init.rb +1 -0
- data/lib/version.rb +13 -0
- data/lib/vestal_versions.rb +167 -0
- data/test/associations_test.rb +36 -0
- data/test/between_test.rb +58 -0
- data/test/changes_test.rb +35 -0
- data/test/comparable_test.rb +35 -0
- data/test/creation_test.rb +70 -0
- data/test/latest_changes_test.rb +42 -0
- data/test/revert_test.rb +80 -0
- data/test/schema.rb +71 -0
- data/test/test_helper.rb +10 -0
- data/vestal_versions.gemspec +71 -0
- metadata +91 -0
data/.gitignore
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2009 Steve Richert
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.rdoc
ADDED
@@ -0,0 +1,77 @@
|
|
1
|
+
= vestal_versions
|
2
|
+
|
3
|
+
Finally, DRY ActiveRecord versioning!
|
4
|
+
|
5
|
+
<tt>acts_as_versioned</tt>[http://github.com/technoweenie/acts_as_versioned] by technoweenie[http://github.com/technoweenie] was a great start, but it failed to keep up with ActiveRecord's introduction of dirty objects in version 2.1. Additionally, each versioned model needs its own versions table that duplicates most of the original table's columns. The versions table is then populated with records that often duplicate most of the original record's attributes. All in all, not very DRY.
|
6
|
+
|
7
|
+
<tt>simply_versioned</tt>[http://github.com/mmower/simply_versioned] by mmower[http://github.com/mmower] started to move in the right direction by removing a great deal of the duplication of acts_as_versioned. It requires only one versions table and no changes whatsoever to existing models. Its versions table stores all of the model attributes as a YAML hash in a single text column. But we could be DRYer!
|
8
|
+
|
9
|
+
<tt>vestal_versions</tt> keeps in the spirit of consolidating to one versions table, polymorphically associated with its parent models. But it goes one step further by storing a serialized hash of only the models' changes. Think modern version control systems. By traversing the record of changes, the models can be reverted to any point in time.
|
10
|
+
|
11
|
+
And that's just what <tt>vestal_versions</tt> does. Not only can a model be reverted to a previous version number but also to a date or time!
|
12
|
+
|
13
|
+
== Installation
|
14
|
+
|
15
|
+
In <tt>environment.rb</tt>:
|
16
|
+
|
17
|
+
Rails::Initializer.run do |config|
|
18
|
+
config.gem 'laserlemon-vestal_versions', :lib => 'vestal_versions', :source => 'http://gems.github.com'
|
19
|
+
end
|
20
|
+
|
21
|
+
At your application root, run:
|
22
|
+
|
23
|
+
$ sudo rake gems:install
|
24
|
+
|
25
|
+
Next, generate and run the first and last versioning migration you'll ever need:
|
26
|
+
|
27
|
+
$ script/generate vestal_versions_migration
|
28
|
+
$ rake db:migrate
|
29
|
+
|
30
|
+
== Example
|
31
|
+
|
32
|
+
To version an ActiveRecord model, simply add <tt>versioned</tt> to your class like so:
|
33
|
+
|
34
|
+
class User < ActiveRecord::Base
|
35
|
+
versioned
|
36
|
+
|
37
|
+
validates_presence_of :first_name, :last_name
|
38
|
+
|
39
|
+
def name
|
40
|
+
"#{first_name} #{last_name}"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
It's that easy! Now watch it in action...
|
45
|
+
|
46
|
+
>> u = User.create(:first_name => 'Steve', :last_name => 'Richert')
|
47
|
+
=> #<User first_name: "Steve", last_name: "Richert">
|
48
|
+
>> u.version
|
49
|
+
=> 1
|
50
|
+
>> u.update_attribute(:first_name, 'Stephen')
|
51
|
+
=> true
|
52
|
+
>> u.name
|
53
|
+
=> "Stephen Richert"
|
54
|
+
>> u.version
|
55
|
+
=> 2
|
56
|
+
>> u.revert_to(:first)
|
57
|
+
=> 1
|
58
|
+
>> u.name
|
59
|
+
=> "Steve Richert"
|
60
|
+
>> u.version
|
61
|
+
=> 1
|
62
|
+
>> u.save
|
63
|
+
=> true
|
64
|
+
>> u.version
|
65
|
+
=> 3
|
66
|
+
>> u.update_attribute(:last_name, 'Jobs')
|
67
|
+
=> true
|
68
|
+
>> u.name
|
69
|
+
=> "Steve Jobs"
|
70
|
+
>> u.version
|
71
|
+
=> 4
|
72
|
+
>> u.revert_to!(2)
|
73
|
+
=> true
|
74
|
+
>> u.name
|
75
|
+
=> "Stephen Richert"
|
76
|
+
>> u.version
|
77
|
+
=> 5
|
data/Rakefile
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
require 'rake/testtask'
|
4
|
+
require 'rake/rdoctask'
|
5
|
+
|
6
|
+
begin
|
7
|
+
require 'jeweler'
|
8
|
+
Jeweler::Tasks.new do |g|
|
9
|
+
g.name = 'vestal_versions'
|
10
|
+
g.summary = %(Keep a DRY history of your ActiveRecord models' changes)
|
11
|
+
g.description = %(Keep a DRY history of your ActiveRecord models' changes)
|
12
|
+
g.email = 'steve@laserlemon.com'
|
13
|
+
g.homepage = 'http://github.com/laserlemon/vestal_versions'
|
14
|
+
g.authors = %w(laserlemon)
|
15
|
+
g.add_development_dependency 'thoughtbot-shoulda'
|
16
|
+
g.rubyforge_project = 'laser-lemon'
|
17
|
+
end
|
18
|
+
Jeweler::RubyforgeTasks.new do |r|
|
19
|
+
r.doc_task = 'rdoc'
|
20
|
+
end
|
21
|
+
rescue LoadError
|
22
|
+
puts 'Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com'
|
23
|
+
end
|
24
|
+
|
25
|
+
Rake::TestTask.new do |t|
|
26
|
+
t.libs = %w(test)
|
27
|
+
t.pattern = 'test/**/*_test.rb'
|
28
|
+
end
|
29
|
+
|
30
|
+
task :default => :test
|
31
|
+
|
32
|
+
Rake::RDocTask.new do |r|
|
33
|
+
version = File.exist?('VERSION') ? File.read('VERSION') : nil
|
34
|
+
r.rdoc_dir = 'rdoc'
|
35
|
+
r.title = ['vestal_versions', version].compact.join(' ')
|
36
|
+
r.rdoc_files.include('README*')
|
37
|
+
r.rdoc_files.include('lib/**/*.rb')
|
38
|
+
end
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.6.1
|
@@ -0,0 +1,20 @@
|
|
1
|
+
class CreateVestalVersions < ActiveRecord::Migration
|
2
|
+
def self.up
|
3
|
+
create_table :versions do |t|
|
4
|
+
t.belongs_to :versioned, :polymorphic => true
|
5
|
+
t.text :changes
|
6
|
+
t.integer :number
|
7
|
+
t.datetime :created_at
|
8
|
+
end
|
9
|
+
|
10
|
+
change_table :versions do |t|
|
11
|
+
t.index [:versioned_type, :versioned_id]
|
12
|
+
t.index :number
|
13
|
+
t.index :created_at
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.down
|
18
|
+
drop_table :versions
|
19
|
+
end
|
20
|
+
end
|
data/init.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'vestal_versions'
|
data/lib/version.rb
ADDED
@@ -0,0 +1,167 @@
|
|
1
|
+
require 'version'
|
2
|
+
|
3
|
+
module LaserLemon
|
4
|
+
module VestalVersions
|
5
|
+
def self.included(base)
|
6
|
+
base.extend ClassMethods
|
7
|
+
end
|
8
|
+
|
9
|
+
module ClassMethods
|
10
|
+
|
11
|
+
def has_many_versioned(association_id, options = {}, &extension)
|
12
|
+
|
13
|
+
if options[:through]
|
14
|
+
raise 'Versioning through relation ships is not supported. Version the join relationship instead.'
|
15
|
+
end
|
16
|
+
options[:after_remove] ||= []
|
17
|
+
options[:after_remove] << :remove_association
|
18
|
+
has_many association_id, options, &extension
|
19
|
+
|
20
|
+
#We can't use the after_add callback, because the object may not be saved, and then we don't have an ID to record.
|
21
|
+
#So we need to put the recording of the change on the associated model's after_save callback.s
|
22
|
+
#TODO: figure out how to get the change into the calling objects's changes
|
23
|
+
versioned_class = self
|
24
|
+
|
25
|
+
self.reflections[association_id].klass.send(:define_method, "vestal_version_#{self.reflections[association_id].name}_after_save_callback", Proc.new {
|
26
|
+
self.send((versioned_class.name.downcase).to_sym).send(:add_association, self)
|
27
|
+
self.send((versioned_class.name.downcase).to_sym).send(:save)
|
28
|
+
})
|
29
|
+
self.reflections[association_id].klass.send(:after_save, "vestal_version_#{self.reflections[association_id].name}_after_save_callback".to_sym)
|
30
|
+
|
31
|
+
end
|
32
|
+
|
33
|
+
def versioned
|
34
|
+
has_many :versions, :as => :versioned, :order => 'versions.number ASC', :dependent => :delete_all do
|
35
|
+
def between(from_value, to_value)
|
36
|
+
from, to = number_at(from_value), number_at(to_value)
|
37
|
+
return [] if from.nil? || to.nil?
|
38
|
+
condition = (from == to) ? to : Range.new(*[from, to].sort)
|
39
|
+
all(
|
40
|
+
:conditions => {:number => condition},
|
41
|
+
:order => "versions.number #{(from > to) ? 'DESC' : 'ASC'}"
|
42
|
+
)
|
43
|
+
end
|
44
|
+
|
45
|
+
def at(value)
|
46
|
+
case value
|
47
|
+
when Version then value
|
48
|
+
when Numeric then find_by_number(value.floor)
|
49
|
+
when Symbol then respond_to?(value) ? send(value) : nil
|
50
|
+
when Date, Time then last(:conditions => ['versions.created_at <= ?', value.to_time])
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def number_at(value)
|
55
|
+
case value
|
56
|
+
when Version then value.number
|
57
|
+
when Numeric then value.floor
|
58
|
+
when Symbol, Date, Time then at(value).try(:number)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
after_create :create_initial_version
|
64
|
+
after_update :create_initial_version, :if => :needs_initial_version?
|
65
|
+
after_update :create_version, :if => :needs_version?
|
66
|
+
|
67
|
+
include InstanceMethods
|
68
|
+
alias_method_chain :reload, :versions
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
module InstanceMethods
|
73
|
+
private
|
74
|
+
def needs_initial_version?
|
75
|
+
versions.empty?
|
76
|
+
end
|
77
|
+
|
78
|
+
def needs_version?
|
79
|
+
!revisable_changes.empty?
|
80
|
+
end
|
81
|
+
|
82
|
+
def reset_version(new_version = nil)
|
83
|
+
@last_version = nil if new_version.nil?
|
84
|
+
@version = new_version
|
85
|
+
end
|
86
|
+
|
87
|
+
def create_initial_version
|
88
|
+
versions.create(:changes => nil, :number => 1)
|
89
|
+
end
|
90
|
+
|
91
|
+
def create_version
|
92
|
+
versions.create(:changes => revisable_changes, :number => (last_version + 1))
|
93
|
+
reset_version
|
94
|
+
end
|
95
|
+
|
96
|
+
def add_association(association_object)
|
97
|
+
association_changes.merge!('association' => {:action => 'add', :name => association_object.class.name, :id => association_object.id})
|
98
|
+
end
|
99
|
+
|
100
|
+
def remove_association(association_object)
|
101
|
+
association_changes.merge!('association' => {:action => 'remove', :name => association_object.class.name, :id => association_object.id})
|
102
|
+
save #save here so that the version is recorded. This keeps it consistent w/ adding a association. If this is ever fixed on add, remove this save call
|
103
|
+
end
|
104
|
+
|
105
|
+
public
|
106
|
+
def version
|
107
|
+
@version ||= last_version
|
108
|
+
end
|
109
|
+
|
110
|
+
def last_version
|
111
|
+
@last_version ||= versions.maximum(:number)
|
112
|
+
end
|
113
|
+
|
114
|
+
def association_changes
|
115
|
+
@association_changes ||= {}
|
116
|
+
end
|
117
|
+
|
118
|
+
def revisable_changes
|
119
|
+
changes.merge!(association_changes)
|
120
|
+
end
|
121
|
+
|
122
|
+
def reverted?
|
123
|
+
version != last_version
|
124
|
+
end
|
125
|
+
|
126
|
+
def reload_with_versions(*args)
|
127
|
+
reset_version
|
128
|
+
reload_without_versions(*args)
|
129
|
+
end
|
130
|
+
|
131
|
+
def revert_to(value)
|
132
|
+
to_value = versions.number_at(value)
|
133
|
+
return version if to_value == version
|
134
|
+
chain = versions.between(version, to_value)
|
135
|
+
return version if chain.empty?
|
136
|
+
|
137
|
+
new_version = chain.last.number
|
138
|
+
backward = chain.first > chain.last
|
139
|
+
backward ? chain.pop : chain.shift
|
140
|
+
|
141
|
+
unrevertable_changes = %w(created_at created_on updated_at updated_on association)
|
142
|
+
|
143
|
+
chain.each do |version|
|
144
|
+
version.changes.except(*unrevertable_changes).each do |attribute, change|
|
145
|
+
new_value = backward ? change.first : change.last
|
146
|
+
write_attribute(attribute, new_value)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
reset_version(new_version)
|
151
|
+
end
|
152
|
+
|
153
|
+
def revert_to!(value)
|
154
|
+
revert_to(value)
|
155
|
+
reset_version if saved = save
|
156
|
+
saved
|
157
|
+
end
|
158
|
+
|
159
|
+
def latest_changes
|
160
|
+
return {} if version.nil? || version == 1
|
161
|
+
versions.at(version).changes
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
ActiveRecord::Base.send(:include, LaserLemon::VestalVersions)
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class AssociationsTest < Test::Unit::TestCase
|
4
|
+
context "A model's change" do
|
5
|
+
setup do
|
6
|
+
@user = User.create(:name => 'Steve Richert')
|
7
|
+
end
|
8
|
+
|
9
|
+
should 'add a version when an has_many associations is added' do
|
10
|
+
project = Project.create(:name => 'Versioned Associations')
|
11
|
+
|
12
|
+
old_version_count = @user.versions.size
|
13
|
+
@user.user_projects.create!(:project => project)
|
14
|
+
@user.reload #needed for now, not sure how to get this object to reload it's versions after the after_save callback on the associated object
|
15
|
+
assert_equal(old_version_count + 1, @user.versions.size)
|
16
|
+
end
|
17
|
+
|
18
|
+
should 'add a version when an has_many associations is removed' do
|
19
|
+
project = Project.create(:name => 'Versioned Associations')
|
20
|
+
|
21
|
+
user_project = @user.user_projects.create!(:project => project)
|
22
|
+
@user.reload
|
23
|
+
old_version_count = @user.versions.size
|
24
|
+
@user.user_projects.delete(user_project)
|
25
|
+
@user.reload
|
26
|
+
assert_equal(old_version_count + 1, @user.versions.size)
|
27
|
+
end
|
28
|
+
|
29
|
+
should 'add a version when an has_many_through association is added and the :through relationship is versioned' do
|
30
|
+
old_version_count = @user.versions.size
|
31
|
+
@user.projects.create!(:name => 'Versioned Associations')
|
32
|
+
@user.reload
|
33
|
+
assert_equal(old_version_count + 1, @user.versions.size)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class BetweenTest < Test::Unit::TestCase
|
4
|
+
context 'The number of versions between' do
|
5
|
+
setup do
|
6
|
+
@user = User.create(:name => 'Steve Richert')
|
7
|
+
@version = @user.version
|
8
|
+
@valid = [@version, 0, 1_000_000, :first, :last, 1.day.since(@user.created_at), @user.versions.first]
|
9
|
+
@invalid = [nil, :bogus, 'bogus', Date.parse('0001-12-25')]
|
10
|
+
end
|
11
|
+
|
12
|
+
context 'the current version and the current version' do
|
13
|
+
should 'equal one' do
|
14
|
+
assert_equal 1, @user.versions.between(@version, @version).size
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
context 'the current version and a valid value' do
|
19
|
+
should 'not equal zero' do
|
20
|
+
@valid.each do |valid|
|
21
|
+
assert_not_equal 0, @user.versions.between(@version, valid).size
|
22
|
+
assert_not_equal 0, @user.versions.between(valid, @version).size
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
context 'the current version and an invalid value' do
|
28
|
+
should 'equal zero' do
|
29
|
+
@invalid.each do |invalid|
|
30
|
+
assert_equal 0, @user.versions.between(@version, invalid).size
|
31
|
+
assert_equal 0, @user.versions.between(invalid, @version).size
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
context 'two invalid values' do
|
37
|
+
should 'equal zero' do
|
38
|
+
@invalid.each do |first|
|
39
|
+
@invalid.each do |second|
|
40
|
+
assert_equal 0, @user.versions.between(first, second).size
|
41
|
+
assert_equal 0, @user.versions.between(second, first).size
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
context 'a valid value and an invalid value' do
|
48
|
+
should 'equal zero' do
|
49
|
+
@valid.each do |valid|
|
50
|
+
@invalid.each do |invalid|
|
51
|
+
assert_equal 0, @user.versions.between(valid, invalid).size
|
52
|
+
assert_equal 0, @user.versions.between(invalid, valid).size
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class ChangesTest < Test::Unit::TestCase
|
4
|
+
context "A version's changes" do
|
5
|
+
setup do
|
6
|
+
@user = User.create(:name => 'Steve Richert')
|
7
|
+
end
|
8
|
+
|
9
|
+
should "initially be blank" do
|
10
|
+
assert @user.versions.first.changes.blank?
|
11
|
+
end
|
12
|
+
|
13
|
+
should 'contain all changed attributes' do
|
14
|
+
@user.name = 'Steve Jobs'
|
15
|
+
changes = @user.changes
|
16
|
+
@user.save
|
17
|
+
assert_equal changes, @user.versions.last.changes.slice(*changes.keys)
|
18
|
+
end
|
19
|
+
|
20
|
+
should 'contain timestamp changes when applicable' do
|
21
|
+
timestamp = 'updated_at'
|
22
|
+
@user.update_attribute(:name, 'Steve Jobs')
|
23
|
+
assert @user.class.content_columns.map(&:name).include?(timestamp)
|
24
|
+
assert_contains @user.versions.last.changes.keys, timestamp
|
25
|
+
end
|
26
|
+
|
27
|
+
should 'contain no more than the changed attributes and not timestamps' do
|
28
|
+
timestamps = %w(created_at created_on updated_at updated_on)
|
29
|
+
@user.name = 'Steve Jobs'
|
30
|
+
changes = @user.changes
|
31
|
+
@user.save
|
32
|
+
assert_equal changes, @user.versions.last.changes.except(*timestamps)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class ComparableTest < Test::Unit::TestCase
|
4
|
+
context 'A comparable version' do
|
5
|
+
setup do
|
6
|
+
@version_1 = Version.new(:number => 1)
|
7
|
+
@version_2 = Version.new(:number => 2)
|
8
|
+
end
|
9
|
+
|
10
|
+
should 'equal itself' do
|
11
|
+
assert @version_1 == @version_1
|
12
|
+
assert @version_2 == @version_2
|
13
|
+
end
|
14
|
+
|
15
|
+
context 'with version number 1' do
|
16
|
+
should 'not equal a version with version number 2' do
|
17
|
+
assert @version_1 != @version_2
|
18
|
+
end
|
19
|
+
|
20
|
+
should 'be less than a version with version number 2' do
|
21
|
+
assert @version_1 < @version_2
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
context 'with version number 2' do
|
26
|
+
should 'not equal a version with version number 1' do
|
27
|
+
assert @version_2 != @version_1
|
28
|
+
end
|
29
|
+
|
30
|
+
should 'be greater than a version with version number 1' do
|
31
|
+
assert @version_2 > @version_1
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class CreationTest < Test::Unit::TestCase
|
4
|
+
context 'The number of versions' do
|
5
|
+
setup do
|
6
|
+
@name = 'Steve Richert'
|
7
|
+
@user = User.create(:name => @name)
|
8
|
+
@count = @user.versions.count
|
9
|
+
end
|
10
|
+
|
11
|
+
should 'initially equal one' do
|
12
|
+
assert_equal 1, @count
|
13
|
+
end
|
14
|
+
|
15
|
+
should 'not increase when no changes are made in an update' do
|
16
|
+
@user.update_attribute(:name, @name)
|
17
|
+
assert_equal @count, @user.versions.count
|
18
|
+
end
|
19
|
+
|
20
|
+
should 'not increase when no changes are made before a save' do
|
21
|
+
@user.save
|
22
|
+
assert_equal @count, @user.versions.count
|
23
|
+
end
|
24
|
+
|
25
|
+
should 'not increase when reverting to the current version' do
|
26
|
+
@user.revert_to!(@user.version)
|
27
|
+
assert_equal @count, @user.versions.count
|
28
|
+
end
|
29
|
+
|
30
|
+
context 'after an update' do
|
31
|
+
setup do
|
32
|
+
@initial_count = @count
|
33
|
+
@name = 'Steve Jobs'
|
34
|
+
@user.update_attribute(:name, @name)
|
35
|
+
@count = @user.versions.count
|
36
|
+
end
|
37
|
+
|
38
|
+
should 'increase by one' do
|
39
|
+
assert_equal @initial_count + 1, @count
|
40
|
+
end
|
41
|
+
|
42
|
+
should 'increase by one when reverted' do
|
43
|
+
@user.revert_to!(:first)
|
44
|
+
assert_equal @count + 1, @user.versions.count
|
45
|
+
end
|
46
|
+
|
47
|
+
should 'not increase until a revert is saved' do
|
48
|
+
@user.revert_to(:first)
|
49
|
+
assert_equal @count, @user.versions.count
|
50
|
+
@user.save
|
51
|
+
assert_not_equal @count, @user.versions.count
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
context 'after multiple updates' do
|
56
|
+
setup do
|
57
|
+
@initial_count = @count
|
58
|
+
@new_name = 'Steve Jobs'
|
59
|
+
@user.update_attribute(:name, @new_name)
|
60
|
+
@user.update_attribute(:name, @name)
|
61
|
+
@count = @user.versions.count
|
62
|
+
end
|
63
|
+
|
64
|
+
should 'not increase when reverting to an identical version' do
|
65
|
+
@user.revert_to!(:first)
|
66
|
+
assert_equal @count, @user.versions.count
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class LatestChangesTest < Test::Unit::TestCase
|
4
|
+
context "A created model's last changes" do
|
5
|
+
setup do
|
6
|
+
@user = User.create(:name => 'Steve Richert')
|
7
|
+
end
|
8
|
+
|
9
|
+
should 'be blank' do
|
10
|
+
assert @user.latest_changes.blank?
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
context "An updated model's last changes" do
|
15
|
+
setup do
|
16
|
+
@user = User.create(:name => 'Steve Richert')
|
17
|
+
@previous_attributes = @user.attributes
|
18
|
+
@user.update_attribute(:name, 'Steve Jobs')
|
19
|
+
@current_attributes = @user.attributes
|
20
|
+
end
|
21
|
+
|
22
|
+
should 'values of two-element arrays with unique values' do
|
23
|
+
@user.latest_changes.values.each do |value|
|
24
|
+
assert_kind_of Array, value
|
25
|
+
assert_equal 2, value.size
|
26
|
+
assert_equal value, value.uniq
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
should 'begin with the previous attribute values' do
|
31
|
+
changes = @user.latest_changes.inject({}){|h,(k,v)| h.update(k => v.first) }
|
32
|
+
previous = @previous_attributes.slice(*@user.latest_changes.keys)
|
33
|
+
assert_equal previous, changes
|
34
|
+
end
|
35
|
+
|
36
|
+
should 'end with the current attribute values' do
|
37
|
+
changes = @user.latest_changes.inject({}){|h,(k,v)| h.update(k => v.last) }
|
38
|
+
current = @current_attributes.slice(*@user.latest_changes.keys)
|
39
|
+
assert_equal current, changes
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
data/test/revert_test.rb
ADDED
@@ -0,0 +1,80 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class RevertTest < Test::Unit::TestCase
|
4
|
+
context 'A model reversion' do
|
5
|
+
setup do
|
6
|
+
@user, @attributes, @times = User.new, {}, {}
|
7
|
+
names = ['Steve Richert', 'Stephen Richert', 'Stephen Jobs', 'Steve Jobs']
|
8
|
+
time = names.size.hours.ago
|
9
|
+
names.each do |name|
|
10
|
+
@user.update_attribute(:name, name)
|
11
|
+
@attributes[@user.version] = @user.attributes
|
12
|
+
time += 1.hour
|
13
|
+
@user.versions.last.update_attribute(:created_at, time)
|
14
|
+
@times[@user.version] = time
|
15
|
+
end
|
16
|
+
@user.reload.versions.reload
|
17
|
+
@first_version, @last_version = @attributes.keys.min, @attributes.keys.max
|
18
|
+
end
|
19
|
+
|
20
|
+
should 'do nothing for a non-existent version' do
|
21
|
+
attributes = @user.attributes
|
22
|
+
@user.revert_to!(nil)
|
23
|
+
assert_equal attributes, @user.attributes
|
24
|
+
end
|
25
|
+
|
26
|
+
should 'return the new version number' do
|
27
|
+
new_version = @user.revert_to(@first_version)
|
28
|
+
assert_equal @first_version, new_version
|
29
|
+
end
|
30
|
+
|
31
|
+
should 'change the version number when saved' do
|
32
|
+
current_version = @user.version
|
33
|
+
@user.revert_to!(@first_version)
|
34
|
+
assert_not_equal current_version, @user.version
|
35
|
+
end
|
36
|
+
|
37
|
+
should 'be able to target the first version' do
|
38
|
+
@user.revert_to(:first)
|
39
|
+
assert_equal @first_version, @user.version
|
40
|
+
end
|
41
|
+
|
42
|
+
should 'be able to target the last version' do
|
43
|
+
@user.revert_to(:last)
|
44
|
+
assert_equal @last_version, @user.version
|
45
|
+
end
|
46
|
+
|
47
|
+
should 'do nothing for a non-existent method name' do
|
48
|
+
current_version = @user.version
|
49
|
+
@user.revert_to(:bogus)
|
50
|
+
assert_equal current_version, @user.version
|
51
|
+
end
|
52
|
+
|
53
|
+
should 'be able to target a version number' do
|
54
|
+
@user.revert_to(1)
|
55
|
+
assert 1, @user.version
|
56
|
+
end
|
57
|
+
|
58
|
+
should 'be able to target a date and time' do
|
59
|
+
@times.each do |version, time|
|
60
|
+
@user.revert_to(time + 1.second)
|
61
|
+
assert_equal version, @user.version
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
should 'be able to target a version object' do
|
66
|
+
@user.versions.each do |version|
|
67
|
+
@user.revert_to(version)
|
68
|
+
assert_equal version.number, @user.version
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
should "correctly roll back the model's attributes" do
|
73
|
+
timestamps = %w(created_at created_on updated_at updated_on)
|
74
|
+
@attributes.each do |version, attributes|
|
75
|
+
@user.revert_to!(version)
|
76
|
+
assert_equal attributes.except(*timestamps), @user.attributes.except(*timestamps)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
data/test/schema.rb
ADDED
@@ -0,0 +1,71 @@
|
|
1
|
+
ActiveRecord::Base.establish_connection(
|
2
|
+
:adapter => defined?(RUBY_ENGINE) && RUBY_ENGINE == 'jruby' ? 'jdbcsqlite3' : 'sqlite3',
|
3
|
+
:database => File.join(File.dirname(__FILE__), 'test.db')
|
4
|
+
)
|
5
|
+
|
6
|
+
class CreateSchema < ActiveRecord::Migration
|
7
|
+
def self.up
|
8
|
+
create_table :users, :force => true do |t|
|
9
|
+
t.string :first_name
|
10
|
+
t.string :last_name
|
11
|
+
t.timestamps
|
12
|
+
end
|
13
|
+
|
14
|
+
create_table :projects, :force => true do |t|
|
15
|
+
t.string :name
|
16
|
+
t.datetime :due_date, :default => 1.day.ago
|
17
|
+
t.timestamps
|
18
|
+
end
|
19
|
+
|
20
|
+
create_table :user_projects, :force => true do |t|
|
21
|
+
t.references :user
|
22
|
+
t.references :project
|
23
|
+
t.timestamps
|
24
|
+
end
|
25
|
+
|
26
|
+
create_table :versions, :force => true do |t|
|
27
|
+
t.belongs_to :versioned, :polymorphic => true
|
28
|
+
t.text :changes
|
29
|
+
t.integer :number
|
30
|
+
t.datetime :created_at
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
CreateSchema.suppress_messages do
|
36
|
+
CreateSchema.migrate(:up)
|
37
|
+
end
|
38
|
+
|
39
|
+
class UserProject < ActiveRecord::Base
|
40
|
+
belongs_to :user
|
41
|
+
belongs_to :project
|
42
|
+
|
43
|
+
def alert
|
44
|
+
raise 'UserProject'
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
class Project < ActiveRecord::Base
|
49
|
+
has_many :user_projects
|
50
|
+
has_many :users, :through => :user_projects
|
51
|
+
|
52
|
+
versioned
|
53
|
+
end
|
54
|
+
|
55
|
+
|
56
|
+
class User < ActiveRecord::Base
|
57
|
+
versioned
|
58
|
+
|
59
|
+
has_many_versioned :user_projects
|
60
|
+
has_many :projects, :through => :user_projects
|
61
|
+
|
62
|
+
def name
|
63
|
+
[first_name, last_name].compact.join(' ')
|
64
|
+
end
|
65
|
+
|
66
|
+
def name=(names)
|
67
|
+
self[:first_name], self[:last_name] = names.split(' ', 2)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,10 @@
|
|
1
|
+
$: << File.join(File.dirname(__FILE__), '..', 'lib')
|
2
|
+
$: << File.dirname(__FILE__)
|
3
|
+
|
4
|
+
require 'rubygems'
|
5
|
+
require 'test/unit'
|
6
|
+
require 'activerecord'
|
7
|
+
require 'shoulda'
|
8
|
+
require 'vestal_versions'
|
9
|
+
require 'schema'
|
10
|
+
begin; require 'redgreen'; rescue LoadError; end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# Generated by jeweler
|
2
|
+
# DO NOT EDIT THIS FILE
|
3
|
+
# Instead, edit Jeweler::Tasks in Rakefile, and run `rake gemspec`
|
4
|
+
# -*- encoding: utf-8 -*-
|
5
|
+
|
6
|
+
Gem::Specification.new do |s|
|
7
|
+
s.name = %q{vestal_versions}
|
8
|
+
s.version = "0.6.1"
|
9
|
+
|
10
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
|
+
s.authors = ["laserlemon"]
|
12
|
+
s.date = %q{2009-09-05}
|
13
|
+
s.description = %q{Keep a DRY history of your ActiveRecord models' changes}
|
14
|
+
s.email = %q{steve@laserlemon.com}
|
15
|
+
s.extra_rdoc_files = [
|
16
|
+
"LICENSE",
|
17
|
+
"README.rdoc"
|
18
|
+
]
|
19
|
+
s.files = [
|
20
|
+
".gitignore",
|
21
|
+
"LICENSE",
|
22
|
+
"README.rdoc",
|
23
|
+
"Rakefile",
|
24
|
+
"VERSION",
|
25
|
+
"generators/vestal_versions_migration/templates/migration.rb",
|
26
|
+
"generators/vestal_versions_migration/vestal_versions_migration_generator.rb",
|
27
|
+
"init.rb",
|
28
|
+
"lib/version.rb",
|
29
|
+
"lib/vestal_versions.rb",
|
30
|
+
"test/associations_test.rb",
|
31
|
+
"test/between_test.rb",
|
32
|
+
"test/changes_test.rb",
|
33
|
+
"test/comparable_test.rb",
|
34
|
+
"test/creation_test.rb",
|
35
|
+
"test/latest_changes_test.rb",
|
36
|
+
"test/revert_test.rb",
|
37
|
+
"test/schema.rb",
|
38
|
+
"test/test_helper.rb",
|
39
|
+
"vestal_versions.gemspec"
|
40
|
+
]
|
41
|
+
s.homepage = %q{http://github.com/laserlemon/vestal_versions}
|
42
|
+
s.rdoc_options = ["--charset=UTF-8"]
|
43
|
+
s.require_paths = ["lib"]
|
44
|
+
s.rubyforge_project = %q{laser-lemon}
|
45
|
+
s.rubygems_version = %q{1.3.5}
|
46
|
+
s.summary = %q{Keep a DRY history of your ActiveRecord models' changes}
|
47
|
+
s.test_files = [
|
48
|
+
"test/associations_test.rb",
|
49
|
+
"test/between_test.rb",
|
50
|
+
"test/changes_test.rb",
|
51
|
+
"test/comparable_test.rb",
|
52
|
+
"test/creation_test.rb",
|
53
|
+
"test/latest_changes_test.rb",
|
54
|
+
"test/revert_test.rb",
|
55
|
+
"test/schema.rb",
|
56
|
+
"test/test_helper.rb"
|
57
|
+
]
|
58
|
+
|
59
|
+
if s.respond_to? :specification_version then
|
60
|
+
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
|
61
|
+
s.specification_version = 3
|
62
|
+
|
63
|
+
if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
|
64
|
+
s.add_development_dependency(%q<thoughtbot-shoulda>, [">= 0"])
|
65
|
+
else
|
66
|
+
s.add_dependency(%q<thoughtbot-shoulda>, [">= 0"])
|
67
|
+
end
|
68
|
+
else
|
69
|
+
s.add_dependency(%q<thoughtbot-shoulda>, [">= 0"])
|
70
|
+
end
|
71
|
+
end
|
metadata
ADDED
@@ -0,0 +1,91 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: andoq-vestal_versions
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.6.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- laserlemon
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2009-09-05 00:00:00 -07:00
|
13
|
+
default_executable:
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: thoughtbot-shoulda
|
17
|
+
type: :development
|
18
|
+
version_requirement:
|
19
|
+
version_requirements: !ruby/object:Gem::Requirement
|
20
|
+
requirements:
|
21
|
+
- - ">="
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: "0"
|
24
|
+
version:
|
25
|
+
description: Keep a DRY history of your ActiveRecord models' changes
|
26
|
+
email: steve@laserlemon.com
|
27
|
+
executables: []
|
28
|
+
|
29
|
+
extensions: []
|
30
|
+
|
31
|
+
extra_rdoc_files:
|
32
|
+
- LICENSE
|
33
|
+
- README.rdoc
|
34
|
+
files:
|
35
|
+
- .gitignore
|
36
|
+
- LICENSE
|
37
|
+
- README.rdoc
|
38
|
+
- Rakefile
|
39
|
+
- VERSION
|
40
|
+
- generators/vestal_versions_migration/templates/migration.rb
|
41
|
+
- generators/vestal_versions_migration/vestal_versions_migration_generator.rb
|
42
|
+
- init.rb
|
43
|
+
- lib/version.rb
|
44
|
+
- lib/vestal_versions.rb
|
45
|
+
- test/associations_test.rb
|
46
|
+
- test/between_test.rb
|
47
|
+
- test/changes_test.rb
|
48
|
+
- test/comparable_test.rb
|
49
|
+
- test/creation_test.rb
|
50
|
+
- test/latest_changes_test.rb
|
51
|
+
- test/revert_test.rb
|
52
|
+
- test/schema.rb
|
53
|
+
- test/test_helper.rb
|
54
|
+
- vestal_versions.gemspec
|
55
|
+
has_rdoc: false
|
56
|
+
homepage: http://github.com/laserlemon/vestal_versions
|
57
|
+
licenses:
|
58
|
+
post_install_message:
|
59
|
+
rdoc_options:
|
60
|
+
- --charset=UTF-8
|
61
|
+
require_paths:
|
62
|
+
- lib
|
63
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - ">="
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: "0"
|
68
|
+
version:
|
69
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
70
|
+
requirements:
|
71
|
+
- - ">="
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
version: "0"
|
74
|
+
version:
|
75
|
+
requirements: []
|
76
|
+
|
77
|
+
rubyforge_project: laser-lemon
|
78
|
+
rubygems_version: 1.3.5
|
79
|
+
signing_key:
|
80
|
+
specification_version: 3
|
81
|
+
summary: Keep a DRY history of your ActiveRecord models' changes
|
82
|
+
test_files:
|
83
|
+
- test/associations_test.rb
|
84
|
+
- test/between_test.rb
|
85
|
+
- test/changes_test.rb
|
86
|
+
- test/comparable_test.rb
|
87
|
+
- test/creation_test.rb
|
88
|
+
- test/latest_changes_test.rb
|
89
|
+
- test/revert_test.rb
|
90
|
+
- test/schema.rb
|
91
|
+
- test/test_helper.rb
|