joshbuddy-has_many_versions 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Joshua Hull
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,124 @@
1
+ = has_many_versions
2
+
3
+ This attempts to provide versioning for 'has many' relationships within ActiveRecord.
4
+
5
+ == Installation
6
+
7
+ script/plugin install git://github.com/joshbuddy/has_many_versions.git
8
+
9
+ == Usage
10
+
11
+ Here is a pretty typical relationship.
12
+
13
+ class Book < ActiveRecord::Base
14
+ belongs_to :author
15
+ validates_associated :author
16
+ end
17
+
18
+ class Author < ActiveRecord::Base
19
+ has_many :books
20
+ end
21
+
22
+ Lets say we want to include versioning now to modifications made to the collection of books. We can modify the +has_many+ relationship the following way:
23
+
24
+ has_many :books, :extend => HasManyVersions
25
+
26
+ Now changes made to the relationship with the normal <tt><<</tt> and +delete+ methods will be tracked. There are some modifications needed for both tables for this to work. The owner of the relationship (in this case +Author+) needs an integer column named +version+ (with a default value of one). The collection (in this case +Book+) needs two columns, +initial_version+ and +version+.
27
+
28
+ Assuming we've done all that, lets try out some magical versioning to see how it all works.
29
+
30
+ We'll start by defining some books.
31
+ the_eyre_affair = Book.new(:name => 'The Eyre Affair')
32
+ => #<Book id: nil, initial_version: nil, version: nil, author_id: nil, name: "The Eyre Affair">
33
+ lost_in_a_good_book = Book.new(:name => 'Lost in a Good Book')
34
+ => #<Book id: nil, initial_version: nil, version: nil, author_id: nil, name: "Lost in a Good Book">
35
+ the_well_of_lost_plots = Book.new(:name => 'The Well of Lost Plots')
36
+ => #<Book id: nil, initial_version: nil, version: nil, author_id: nil, name: "The Well of Lost Plots">
37
+ something_rotten = Book.new(:name => 'Something Rotten')
38
+ => #<Book id: nil, initial_version: nil, version: nil, author_id: nil, name: "Something Rotten">
39
+
40
+ And an author to take them.
41
+ jasper = Author.new(:name => 'Jasper Fforde')
42
+ => #<Author id: nil, version: 1, name: "Jasper Fforde">
43
+
44
+ That sounds good. Just so we're all on the same page, how does Jasper look at the moment?
45
+ jasper.version
46
+ => 1
47
+ jasper.books
48
+ => []
49
+
50
+ Version 1 is where all versioned objects initially start.
51
+
52
+ Now, lets start adding some books.
53
+ jasper.books.push(the_eyre_affair, something_rotten)
54
+
55
+ And our new version?
56
+ jasper.version
57
+ => 2
58
+
59
+ And our books?
60
+ jasper.books
61
+ => [#<Book id: 1, initial_version: 2, version: 2, author_id: 1, name: "The Eyre Affair">,
62
+ #<Book id: 2, initial_version: 2, version: 2, author_id: 1, name: "Something Rotten">]
63
+
64
+ Yeah! Everything is good and green.
65
+
66
+ Lets add on some more books.
67
+ jasper.books.push(lost_in_a_good_book)
68
+ jasper.version
69
+ => 3
70
+ jasper.books.push(the_well_of_lost_plots)
71
+ jasper.version
72
+ => 4
73
+
74
+ Because we did them one at a time, the version number incremented for each one.
75
+
76
+ Now, Jasper has a *lot* of books.
77
+ jasper.books
78
+ => [#<Book id: 1, initial_version: 2, version: 4, author_id: 1, name: "The Eyre Affair">,
79
+ #<Book id: 2, initial_version: 2, version: 4, author_id: 1, name: "Something Rotten">,
80
+ #<Book id: 3, initial_version: 3, version: 4, author_id: 1, name: "Lost in a Good Book">,
81
+ #<Book id: 4, initial_version: 4, version: 4, author_id: 1, name: "The Well of Lost Plots">]
82
+
83
+ Lets take one away...
84
+ jasper.books.delete(lost_in_a_good_book)
85
+
86
+ And the version has incremented
87
+ jasper.version
88
+ => 5
89
+ jasper.books
90
+ => [#<Book id: 1, initial_version: 2, version: 5, author_id: 1, name: "The Eyre Affair">,
91
+ #<Book id: 2, initial_version: 2, version: 5, author_id: 1, name: "Something Rotten">,
92
+ #<Book id: 4, initial_version: 4, version: 5, author_id: 1, name: "The Well of Lost Plots">]
93
+
94
+ But thats not right, he *did* write that book. Lets go back in time.
95
+ jasper.books.rollback
96
+
97
+ And the version get incremented...
98
+ jasper.version
99
+ => 6
100
+
101
+ And the books...
102
+ jasper.books
103
+ => [#<Book id: 5, initial_version: 6, version: 6, author_id: 1, name: "The Eyre Affair">,
104
+ #<Book id: 6, initial_version: 6, version: 6, author_id: 1, name: "Something Rotten">,
105
+ #<Book id: 7, initial_version: 6, version: 6, author_id: 1, name: "Lost in a Good Book">,
106
+ #<Book id: 8, initial_version: 6, version: 6, author_id: 1, name: "The Well of Lost Plots">]
107
+
108
+ Are right where we left them!
109
+
110
+ In fact, lets roll it right back to version 2.
111
+
112
+ jasper.books.rollback(2)
113
+ jasper.version
114
+ => 7
115
+ jasper.books
116
+ => [#<Book id: 9, initial_version: 7, version: 7, author_id: 1, name: "The Eyre Affair">,
117
+ #<Book id: 10, initial_version: 7, version: 7, author_id: 1, name: "Something Rotten">]
118
+
119
+ And what would the world look like if Jasper Fforde hadn't written anything?
120
+ jasper.books.rollback(1) #back to version one
121
+ jasper.version
122
+ => 8
123
+ jasper.books
124
+ => []
data/VERSION.yml ADDED
@@ -0,0 +1,4 @@
1
+ ---
2
+ :patch: 4
3
+ :major: 0
4
+ :minor: 0
@@ -0,0 +1,98 @@
1
+ module ActiveRecord
2
+ module Associations
3
+ class AssociationCollection
4
+ alias_method :__concat__, :<<
5
+ end
6
+ end
7
+ end
8
+
9
+ module HasManyVersions
10
+
11
+ def upgrade_proxy_object
12
+ proxy_owner.transaction do
13
+ new_version = proxy_owner.version + 1
14
+ proxy_owner.version = new_version
15
+ proxy_owner.class.update_all ['version = ?', new_version], ["#{proxy_reflection.klass.primary_key} = ?", proxy_owner.id]
16
+ proxy_owner.save!
17
+ yield new_version
18
+ proxy_owner.reload
19
+ end
20
+ end
21
+
22
+ def replace(other_array)
23
+ other_array.each { |val| raise_on_type_mismatch(val) }
24
+
25
+ load_target
26
+ other = other_array.size < 100 ? other_array : other_array.to_set
27
+ current = @target.size < 100 ? @target : @target.to_set
28
+
29
+ upgrade_proxy_object do |new_version|
30
+ delete_records_without_versioning_transaction(new_version, @target.select { |v| !other.include?(v) })
31
+ add_records_without_versioning_transaction(new_version, *other_array.select { |v| !current.include?(v) })
32
+ end
33
+ end
34
+
35
+ def add_records_without_versioning_transaction(new_version, *records)
36
+ changing_records = flatten_deeper(records).select{|r| !r.new_record? && r.changed?}
37
+ changing_records.empty? ? proxy_reflection.klass.update_all(
38
+ ['version = ?', new_version],
39
+ ["#{proxy_reflection.primary_key_name} = ? and version = ?", proxy_owner.id, new_version - 1]
40
+ ) : proxy_reflection.klass.update_all(
41
+ ['version = ?', new_version],
42
+ ["#{proxy_reflection.primary_key_name} = ? and version = ? and #{proxy_reflection.klass.primary_key} not in (?)", proxy_owner.id, new_version - 1, changing_records.collect(&:id)]
43
+ )
44
+ records = flatten_deeper(records).collect do |r|
45
+ if !r.new_record? && r.changed?
46
+ new_r = r.clone
47
+ new_r.from_version = r.id if new_r.respond_to?(:from_version=)
48
+ new_r
49
+ else
50
+ r
51
+ end
52
+ end
53
+ flatten_deeper(records).each do |record|
54
+ record.initial_version = proxy_owner.version if record.new_record?
55
+ record.version = proxy_owner.version
56
+ end
57
+ __concat__(*records)
58
+ end
59
+
60
+
61
+ def <<(*records)
62
+ upgrade_proxy_object do |new_version|
63
+ add_records_without_versioning_transaction(new_version, *records)
64
+ end
65
+ end
66
+
67
+ alias_method :push, :<<
68
+ alias_method :concat, :<<
69
+
70
+ def delete_records_without_versioning_transaction(new_version, records)
71
+ proxy_reflection.klass.update_all ['version = ?', new_version], ["#{proxy_reflection.primary_key_name} = ? and version = ? and #{proxy_reflection.klass.primary_key} not in (?)", proxy_owner.id, new_version - 1, records.collect(&proxy_reflection.klass.primary_key.to_sym)]
72
+ end
73
+
74
+ def delete_records(records)
75
+ upgrade_proxy_object do |new_version|
76
+ delete_records_without_versioning_transaction(new_version, records)
77
+ end
78
+ end
79
+
80
+ def conditions
81
+ interpolate_sql(@reflection.sanitized_conditions ?
82
+ '%s AND %s.version = #{version}' % [@reflection.sanitized_conditions, proxy_reflection.quoted_table_name] :
83
+ '%s.version = #{version}' % [proxy_reflection.quoted_table_name])
84
+ end
85
+
86
+ def rollback(target_version = proxy_owner.version - 1)
87
+ upgrade_proxy_object do |new_version|
88
+ proxy_reflection.klass.find(:all, :conditions => ['initial_version <= ? and version >= ?', target_version, target_version]).each do |new_record|
89
+ new_record = new_record.clone
90
+ new_record.initial_version = new_version
91
+ new_record.version = new_version
92
+ new_record.save!
93
+ end
94
+ end
95
+
96
+ end
97
+
98
+ end
data/spec/add_spec.rb ADDED
@@ -0,0 +1,36 @@
1
+ require File.join(File.dirname(__FILE__), 'spec_helper')
2
+
3
+ describe "HasManyVersions adding" do
4
+
5
+ before(:each) do
6
+ Database.reset!
7
+ end
8
+
9
+ it "should start with a version of 1" do
10
+ jasper = Author.new(:name => 'Jasper Fforde')
11
+ jasper.save!
12
+ jasper.version.should == 1
13
+ end
14
+
15
+ it "should increment the version and the associated object should match it" do
16
+ jasper = Author.new(:name => 'Jasper Fforde')
17
+ eyre_affair = Book.new(:name => "The Eyre Affair")
18
+ jasper.books << eyre_affair
19
+ jasper.save!
20
+ jasper.version.should == eyre_affair.version
21
+ eyre_affair.initial_version.should == eyre_affair.version
22
+ end
23
+
24
+ it "should be able to get two books and return them" do
25
+ jasper = Author.new(:name => 'Jasper Fforde')
26
+ eyre_affair = Book.new(:name => "The Eyre Affair")
27
+ shades_of_grey = Book.new(:name => "Shades of Grey")
28
+ jasper.save!
29
+ old_version = jasper.version
30
+ jasper.books << eyre_affair
31
+ jasper.books << shades_of_grey
32
+ jasper.books.should == [eyre_affair, shades_of_grey]
33
+ jasper.books.each {|b| b.version.should == (old_version + 2) }
34
+ end
35
+
36
+ end
data/spec/database.yml ADDED
@@ -0,0 +1,2 @@
1
+ adapter: sqlite3
2
+ database: test.db
@@ -0,0 +1,20 @@
1
+ require File.join(File.dirname(__FILE__), 'spec_helper')
2
+
3
+ describe "HasManyVersions deleting" do
4
+
5
+ before(:each) do
6
+ Database.reset!
7
+ end
8
+
9
+ it "should delete from the associations and increment the version" do
10
+ jasper = Author.new(:name => 'Jasper Fforde')
11
+ eyre_affair = Book.new(:name => "The Eyre Affair")
12
+ shades_of_grey = Book.new(:name => "Shades of Grey")
13
+ jasper.save!
14
+ jasper.books.push(eyre_affair, shades_of_grey)
15
+ initial_version = jasper.version
16
+ jasper.books.delete(eyre_affair)
17
+ jasper.books.should == [shades_of_grey]
18
+ jasper.books.first.version.should == 3
19
+ end
20
+ end
@@ -0,0 +1,61 @@
1
+ require File.join(File.dirname(__FILE__), 'spec_helper')
2
+
3
+ describe "HasManyVersions rollbacks" do
4
+
5
+ before(:each) do
6
+ Database.reset!
7
+ end
8
+
9
+ it "should rollback one step" do
10
+ jasper = Author.new(:name => 'Jasper Fforde')
11
+ eyre_affair = Book.new(:name => "The Eyre Affair")
12
+ shades_of_grey = Book.new(:name => "Shades of Grey")
13
+ jasper.save!
14
+ jasper.books.push(eyre_affair, shades_of_grey)
15
+ eyre_affair2 = Book.new(:name => "The Eyre Affair 2")
16
+ shades_of_grey2 = Book.new(:name => "Shades of Grey 2")
17
+ jasper.books.push(eyre_affair2, shades_of_grey2)
18
+ eyre_affair3 = Book.new(:name => "The Eyre Affair 3")
19
+ jasper.books.push(eyre_affair3)
20
+ jasper.books.should == [eyre_affair, shades_of_grey, eyre_affair2, shades_of_grey2, eyre_affair3]
21
+ jasper.books.rollback
22
+ jasper.books.collect(&:name).should == [eyre_affair.name, shades_of_grey.name, eyre_affair2.name, shades_of_grey2.name]
23
+ end
24
+
25
+ it "should any number of steps" do
26
+ jasper = Author.new(:name => 'Jasper Fforde')
27
+ eyre_affair = Book.new(:name => "The Eyre Affair")
28
+ shades_of_grey = Book.new(:name => "Shades of Grey")
29
+ jasper.save!
30
+ jasper.books.push(eyre_affair, shades_of_grey)
31
+ eyre_affair2 = Book.new(:name => "The Eyre Affair 2")
32
+ shades_of_grey2 = Book.new(:name => "Shades of Grey 2")
33
+ jasper.books.push(eyre_affair2, shades_of_grey2)
34
+ eyre_affair3 = Book.new(:name => "The Eyre Affair 3")
35
+ jasper.books.push(eyre_affair3)
36
+ jasper.books.should == [eyre_affair, shades_of_grey, eyre_affair2, shades_of_grey2, eyre_affair3]
37
+ jasper.books.rollback(2)
38
+ jasper.books.collect(&:name).should == [eyre_affair.name, shades_of_grey.name]
39
+ end
40
+
41
+ it "should rollback multiple times" do
42
+ jasper = Author.new(:name => 'Jasper Fforde')
43
+ eyre_affair = Book.new(:name => "The Eyre Affair")
44
+ shades_of_grey = Book.new(:name => "Shades of Grey")
45
+ jasper.save!
46
+ jasper.books.push(eyre_affair, shades_of_grey)
47
+ eyre_affair2 = Book.new(:name => "The Eyre Affair 2")
48
+ shades_of_grey2 = Book.new(:name => "Shades of Grey 2")
49
+ jasper.books.push(eyre_affair2, shades_of_grey2)
50
+ eyre_affair3 = Book.new(:name => "The Eyre Affair 3")
51
+ jasper.books.push(eyre_affair3)
52
+ jasper.books.collect(&:name).should == [eyre_affair.name, shades_of_grey.name, eyre_affair2.name, shades_of_grey2.name, eyre_affair3.name]
53
+ jasper.books.rollback(2)
54
+ jasper.books.collect(&:name).should == [eyre_affair.name, shades_of_grey.name]
55
+ jasper.books.rollback
56
+ jasper.books.collect(&:name).should == [eyre_affair.name, shades_of_grey.name, eyre_affair2.name, shades_of_grey2.name, eyre_affair3.name]
57
+ jasper.books.rollback(3)
58
+ jasper.books.collect(&:name).should == [eyre_affair.name, shades_of_grey.name, eyre_affair2.name, shades_of_grey2.name]
59
+ end
60
+
61
+ end
data/spec/spec.opts ADDED
@@ -0,0 +1,7 @@
1
+ --colour
2
+ --format
3
+ specdoc
4
+ --loadby
5
+ mtime
6
+ --reverse
7
+ --backtrace
@@ -0,0 +1,45 @@
1
+ require 'rubygems'
2
+ require 'spec'
3
+ require 'activerecord'
4
+ require 'rails/init'
5
+
6
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
7
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
8
+
9
+ Spec::Runner.configure do |config|
10
+
11
+ end
12
+
13
+ ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + '/debug.log')
14
+ ActiveRecord::Base.establish_connection(YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml')))
15
+
16
+ class Database
17
+ def self.reset!(with_from_version = false)
18
+ ActiveRecord::Schema.define :version => 0 do
19
+ create_table :books, :force => true do |t|
20
+ t.integer :author_id
21
+ t.integer :initial_version
22
+ t.integer :from_version if with_from_version
23
+ t.integer :version
24
+ t.string :name
25
+ end
26
+
27
+ create_table :authors, :force => true do |t|
28
+ t.integer :version, :default => 1
29
+ t.string :name
30
+ end
31
+
32
+ Book.reset_column_information
33
+ end
34
+ end
35
+
36
+ end
37
+
38
+ class Book < ActiveRecord::Base
39
+ belongs_to :author
40
+ validates_associated :author
41
+ end
42
+
43
+ class Author < ActiveRecord::Base
44
+ has_many :books, :extend => HasManyVersions
45
+ end
@@ -0,0 +1,60 @@
1
+ require File.join(File.dirname(__FILE__), 'spec_helper')
2
+
3
+ describe "HasManyVersions updating" do
4
+
5
+ before(:each) do
6
+ Database.reset!
7
+ end
8
+
9
+ it "interpret changed? objects as new and allow rolling back to that state" do
10
+ jasper = Author.new(:name => 'Jasper Fforde')
11
+ eyre_affair = Book.new(:name => "The Eyre Affair")
12
+ jasper.books << eyre_affair
13
+ jasper.save!
14
+ jasper.version.should == eyre_affair.version
15
+ eyre_affair.initial_version.should == eyre_affair.version
16
+ eyre_affair.name = 'The EYRE affair'
17
+ jasper.books << eyre_affair
18
+ jasper.books.first.name.should == 'The EYRE affair'
19
+ jasper.books.rollback
20
+ jasper.books.first.name.should == 'The Eyre Affair'
21
+ end
22
+
23
+ it "should record the 'from version' if that column exists" do
24
+ Database.reset!(true)
25
+ jasper = Author.new(:name => 'Jasper Fforde')
26
+ eyre_affair = Book.new(:name => "The Eyre Affair")
27
+ jasper.books << eyre_affair
28
+ jasper.save!
29
+ jasper.version.should == eyre_affair.version
30
+ eyre_affair.initial_version.should == eyre_affair.version
31
+ eyre_affair.name = 'The EYRE affair'
32
+ jasper.books << eyre_affair
33
+ jasper.books.first.from_version.should == eyre_affair.id
34
+ jasper.books.first.id.should == 2
35
+ jasper.books.first.name.should == 'The EYRE affair'
36
+ jasper.books.rollback
37
+ jasper.books.first.name.should == 'The Eyre Affair'
38
+ end
39
+
40
+ it "should record add, delete and update events all at the same time" do
41
+ Database.reset!(true)
42
+ jasper = Author.new(:name => 'Jasper Fforde')
43
+ eyre_affair = Book.new(:name => "The Eyre Affair")
44
+ shades_of_grey = Book.new(:name => "Shades of Grey")
45
+ eyre_affair2 = Book.new(:name => "The Eyre Affair 2")
46
+ shades_of_grey2 = Book.new(:name => "Shades of Grey 2")
47
+
48
+ jasper.version.should == 1
49
+ jasper.books = [eyre_affair, shades_of_grey]
50
+ jasper.version.should == 2
51
+
52
+ jasper.books = [shades_of_grey2, eyre_affair]
53
+ jasper.version.should == 3
54
+
55
+ jasper.books.rollback
56
+ jasper.books.collect(&:name).should == ['The Eyre Affair', 'Shades of Grey']
57
+
58
+ end
59
+
60
+ end
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: joshbuddy-has_many_versions
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.4
5
+ platform: ruby
6
+ authors:
7
+ - Joshua Hull
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-04-15 00:00:00 -07:00
13
+ default_executable:
14
+ dependencies: []
15
+
16
+ description:
17
+ email: joshbuddy@gmail.com
18
+ executables: []
19
+
20
+ extensions: []
21
+
22
+ extra_rdoc_files:
23
+ - README.rdoc
24
+ - LICENSE
25
+ files:
26
+ - README.rdoc
27
+ - VERSION.yml
28
+ - lib/has_many_versions.rb
29
+ - spec/add_spec.rb
30
+ - spec/database.yml
31
+ - spec/delete_spec.rb
32
+ - spec/rollback_spec.rb
33
+ - spec/spec.opts
34
+ - spec/spec_helper.rb
35
+ - spec/update_spec.rb
36
+ - LICENSE
37
+ has_rdoc: true
38
+ homepage: http://github.com/joshbuddy/has_many_versions
39
+ post_install_message:
40
+ rdoc_options:
41
+ - --inline-source
42
+ - --charset=UTF-8
43
+ require_paths:
44
+ - lib
45
+ required_ruby_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: "0"
50
+ version:
51
+ required_rubygems_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: "0"
56
+ version:
57
+ requirements: []
58
+
59
+ rubyforge_project:
60
+ rubygems_version: 1.2.0
61
+ signing_key:
62
+ specification_version: 2
63
+ summary: Versioning for has_many relationships
64
+ test_files: []
65
+