bitfluent-vestal_versions 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. data/.gitignore +23 -0
  2. data/LICENSE +20 -0
  3. data/README.rdoc +175 -0
  4. data/Rakefile +45 -0
  5. data/VERSION +1 -0
  6. data/generators/vestal_versions/templates/initializer.rb +9 -0
  7. data/generators/vestal_versions/templates/migration.rb +27 -0
  8. data/generators/vestal_versions/vestal_versions_generator.rb +24 -0
  9. data/init.rb +1 -0
  10. data/lib/vestal_versions.rb +103 -0
  11. data/lib/vestal_versions/changes.rb +125 -0
  12. data/lib/vestal_versions/conditions.rb +69 -0
  13. data/lib/vestal_versions/configuration.rb +40 -0
  14. data/lib/vestal_versions/control.rb +175 -0
  15. data/lib/vestal_versions/creation.rb +100 -0
  16. data/lib/vestal_versions/options.rb +45 -0
  17. data/lib/vestal_versions/reload.rb +23 -0
  18. data/lib/vestal_versions/reset.rb +28 -0
  19. data/lib/vestal_versions/reversion.rb +69 -0
  20. data/lib/vestal_versions/tagging.rb +50 -0
  21. data/lib/vestal_versions/users.rb +57 -0
  22. data/lib/vestal_versions/version.rb +32 -0
  23. data/lib/vestal_versions/versioned.rb +30 -0
  24. data/lib/vestal_versions/versions.rb +74 -0
  25. data/test/changes_test.rb +169 -0
  26. data/test/conditions_test.rb +137 -0
  27. data/test/configuration_test.rb +39 -0
  28. data/test/control_test.rb +152 -0
  29. data/test/creation_test.rb +148 -0
  30. data/test/options_test.rb +52 -0
  31. data/test/reload_test.rb +19 -0
  32. data/test/reset_test.rb +112 -0
  33. data/test/reversion_test.rb +68 -0
  34. data/test/schema.rb +43 -0
  35. data/test/tagging_test.rb +39 -0
  36. data/test/test_helper.rb +12 -0
  37. data/test/users_test.rb +25 -0
  38. data/test/version_test.rb +43 -0
  39. data/test/versioned_test.rb +18 -0
  40. data/test/versions_test.rb +172 -0
  41. data/vestal_versions.gemspec +105 -0
  42. metadata +167 -0
@@ -0,0 +1,28 @@
1
+ module VestalVersions
2
+ # Adds the ability to "reset" (or hard revert) a versioned ActiveRecord::Base instance.
3
+ module Reset
4
+ def self.included(base) # :nodoc:
5
+ base.class_eval do
6
+ include InstanceMethods
7
+ end
8
+ end
9
+
10
+ # Adds the instance methods required to reset an object to a previous version.
11
+ module InstanceMethods
12
+ # Similar to +revert_to!+, the +reset_to!+ method reverts an object to a previous version,
13
+ # only instead of creating a new record in the version history, +reset_to!+ deletes all of
14
+ # the version history that occurs after the version reverted to.
15
+ #
16
+ # The action taken on each version record after the point of reversion is determined by the
17
+ # <tt>:dependent</tt> option given to the +versioned+ method. See the +versioned+ method
18
+ # documentation for more details.
19
+ def reset_to!(value)
20
+ if saved = skip_version{ revert_to!(value) }
21
+ versions.send(:delete_records, versions.after(value))
22
+ reset_version
23
+ end
24
+ saved
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,69 @@
1
+ module VestalVersions
2
+ # Enables versioned ActiveRecord::Base instances to revert to a previously saved version.
3
+ module Reversion
4
+ def self.included(base) # :nodoc:
5
+ base.class_eval do
6
+ include InstanceMethods
7
+ end
8
+ end
9
+
10
+ # Provides the base instance methods required to revert a versioned instance.
11
+ module InstanceMethods
12
+ # Returns the current version number for the versioned object.
13
+ def version
14
+ @version ||= last_version
15
+ end
16
+
17
+ # Accepts a value corresponding to a specific version record, builds a history of changes
18
+ # between that version and the current version, and then iterates over that history updating
19
+ # the object's attributes until the it's reverted to its prior state.
20
+ #
21
+ # The single argument should adhere to one of the formats as documented in the +at+ method of
22
+ # VestalVersions::Versions.
23
+ #
24
+ # After the object is reverted to the target version, it is not saved. In order to save the
25
+ # object after the reversion, use the +revert_to!+ method.
26
+ #
27
+ # The version number of the object will reflect whatever version has been reverted to, and
28
+ # the return value of the +revert_to+ method is also the target version number.
29
+ def revert_to(value)
30
+ to_number = versions.number_at(value)
31
+
32
+ changes_between(version, to_number).each do |attribute, change|
33
+ write_attribute(attribute, change.last)
34
+ end
35
+
36
+ reset_version(to_number)
37
+ end
38
+
39
+ # Behaves similarly to the +revert_to+ method except that it automatically saves the record
40
+ # after the reversion. The return value is the success of the save.
41
+ def revert_to!(value)
42
+ revert_to(value)
43
+ reset_version if saved = save
44
+ saved
45
+ end
46
+
47
+ # Returns a boolean specifying whether the object has been reverted to a previous version or
48
+ # if the object represents the latest version in the version history.
49
+ def reverted?
50
+ version != last_version
51
+ end
52
+
53
+ private
54
+ # Returns the number of the last created version in the object's version history.
55
+ #
56
+ # If no associated versions exist, the object is considered at version 1.
57
+ def last_version
58
+ @last_version ||= versions.maximum(:number) || 1
59
+ end
60
+
61
+ # Clears the cached version number instance variables so that they can be recalculated.
62
+ # Useful after a new version is created.
63
+ def reset_version(version = nil)
64
+ @last_version = nil if version.nil?
65
+ @version = version
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,50 @@
1
+ module VestalVersions
2
+ # Allows specific versions to be tagged with a custom string. Useful for assigning a more
3
+ # meaningful value to a version for the purpose of reversion.
4
+ module Tagging
5
+ def self.included(base) # :nodoc:
6
+ Version.send(:include, VersionMethods)
7
+
8
+ base.class_eval do
9
+ include InstanceMethods
10
+ end
11
+ end
12
+
13
+ # Adds an instance method which allows version tagging through the parent object.
14
+ module InstanceMethods
15
+ # Accepts a single string argument which is attached to the version record associated with
16
+ # the current version number of the parent object.
17
+ #
18
+ # Returns the given tag if successful, nil if not. Tags must be unique within the scope of
19
+ # the parent object. Tag creation will fail if non-unique.
20
+ #
21
+ # Version records corresponding to version number 1 are not typically created, but one will
22
+ # be built to house the given tag if the parent object's current version number is 1.
23
+ def tag_version(tag)
24
+ v = versions.at(version) || versions.build(:number => 1)
25
+ v.tag!(tag)
26
+ end
27
+ end
28
+
29
+ # Instance methods included into VestalVersions::Version to enable version tagging.
30
+ module VersionMethods
31
+ def self.included(base) # :nodoc:
32
+ base.class_eval do
33
+ validates_uniqueness_of :tag, :scope => [:versioned_id, :versioned_type], :if => :tagged?
34
+ end
35
+ end
36
+
37
+ # Attaches the given string to the version tag column. If the uniqueness validation fails,
38
+ # nil is returned. Otherwise, the given string is returned.
39
+ def tag!(tag)
40
+ write_attribute(:tag, tag)
41
+ save ? tag : nil
42
+ end
43
+
44
+ # Simply returns a boolean signifying whether the version instance has a tag value attached.
45
+ def tagged?
46
+ !tag.nil?
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,57 @@
1
+ module VestalVersions
2
+ # Provides a way for information to be associated with specific versions as to who was
3
+ # responsible for the associated update to the parent.
4
+ module Users
5
+ def self.included(base) # :nodoc:
6
+ Version.send(:include, VersionMethods)
7
+
8
+ base.class_eval do
9
+ include InstanceMethods
10
+
11
+ attr_accessor :updated_by
12
+ alias_method_chain :version_attributes, :user
13
+ end
14
+ end
15
+
16
+ # Methods added to versioned ActiveRecord::Base instances to enable versioning with additional
17
+ # user information.
18
+ module InstanceMethods
19
+ private
20
+ # Overrides the +version_attributes+ method to include user information passed into the
21
+ # parent object, by way of a +updated_by+ attr_accessor.
22
+ def version_attributes_with_user
23
+ version_attributes_without_user.merge(:user => updated_by)
24
+ end
25
+ end
26
+
27
+ # Instance methods added to VestalVersions::Version to accomodate incoming user information.
28
+ module VersionMethods
29
+ def self.included(base) # :nodoc:
30
+ base.class_eval do
31
+ belongs_to :user, :polymorphic => true
32
+
33
+ alias_method_chain :user, :name
34
+ alias_method_chain :user=, :name
35
+ end
36
+ end
37
+
38
+ # Overrides the +user+ method created by the polymorphic +belongs_to+ user association. If
39
+ # the association is absent, defaults to the +user_name+ string column. This allows
40
+ # VestalVersions::Version#user to either return an ActiveRecord::Base object or a string,
41
+ # depending on what is sent to the +user_with_name=+ method.
42
+ def user_with_name
43
+ user_without_name || user_name
44
+ end
45
+
46
+ # Overrides the +user=+ method created by the polymorphic +belongs_to+ user association.
47
+ # Based on the class of the object given, either the +user+ association columns or the
48
+ # +user_name+ string column is populated.
49
+ def user_with_name=(value)
50
+ case value
51
+ when ActiveRecord::Base then self.user_without_name = value
52
+ else self.user_name = value
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,32 @@
1
+ module VestalVersions
2
+ # The ActiveRecord model representing versions.
3
+ class Version < ActiveRecord::Base
4
+ include Comparable
5
+
6
+ # Associate polymorphically with the parent record.
7
+ belongs_to :versioned, :polymorphic => true
8
+
9
+ # ActiveRecord::Base#changes is an existing method, so before serializing the +changes+ column,
10
+ # the existing +changes+ method is undefined. The overridden +changes+ method pertained to
11
+ # dirty attributes, but will not affect the partial updates functionality as that's based on
12
+ # an underlying +changed_attributes+ method, not +changes+ itself.
13
+ undef_method :changes
14
+ def changes
15
+ self[:modifications]
16
+ end
17
+ serialize :modifications, Hash
18
+
19
+ # In conjunction with the included Comparable module, allows comparison of version records
20
+ # based on their corresponding version numbers, creation timestamps and IDs.
21
+ def <=>(other)
22
+ [number, created_at, id].map(&:to_i) <=> [other.number, other.created_at, other.id].map(&:to_i)
23
+ end
24
+
25
+ # Returns whether the version has a version number of 1. Useful when deciding whether to ignore
26
+ # the version during reversion, as initial versions have no serialized changes attached. Helps
27
+ # maintain backwards compatibility.
28
+ def initial?
29
+ number == 1
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,30 @@
1
+ module VestalVersions
2
+ # Simply adds a flag to determine whether a model class if versioned.
3
+ module Versioned
4
+ def self.extended(base) # :nodoc:
5
+ base.class_eval do
6
+ class << self
7
+ alias_method_chain :versioned, :flag
8
+ end
9
+ end
10
+ end
11
+
12
+ # Overrides the +versioned+ method to first define the +versioned?+ class method before
13
+ # deferring to the original +versioned+.
14
+ def versioned_with_flag(*args)
15
+ versioned_without_flag(*args)
16
+
17
+ class << self
18
+ def versioned?
19
+ true
20
+ end
21
+ end
22
+ end
23
+
24
+ # For all ActiveRecord::Base models that do not call the +versioned+ method, the +versioned?+
25
+ # method will return false.
26
+ def versioned?
27
+ false
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,74 @@
1
+ module VestalVersions
2
+ # An extension module for the +has_many+ association with versions.
3
+ module Versions
4
+ # Returns all versions between (and including) the two given arguments. See documentation for
5
+ # the +at+ extension method for what arguments are valid. If either of the given arguments is
6
+ # invalid, an empty array is returned.
7
+ #
8
+ # The +between+ method preserves returns an array of version records, preserving the order
9
+ # given by the arguments. If the +from+ value represents a version before that of the +to+
10
+ # value, the array will be ordered from earliest to latest. The reverse is also true.
11
+ def between(from, to)
12
+ from_number, to_number = number_at(from), number_at(to)
13
+ return [] if from_number.nil? || to_number.nil?
14
+
15
+ condition = (from_number == to_number) ? to_number : Range.new(*[from_number, to_number].sort)
16
+ all(
17
+ :conditions => {:number => condition},
18
+ :order => "#{aliased_table_name}.number #{(from_number > to_number) ? 'DESC' : 'ASC'}"
19
+ )
20
+ end
21
+
22
+ # Returns all version records created before the version associated with the given value.
23
+ def before(value)
24
+ return [] if (number = number_at(value)).nil?
25
+ all(:conditions => "#{aliased_table_name}.number < #{number}")
26
+ end
27
+
28
+ # Returns all version records created after the version associated with the given value.
29
+ #
30
+ # This is useful for dissociating records during use of the +reset_to!+ method.
31
+ def after(value)
32
+ return [] if (number = number_at(value)).nil?
33
+ all(:conditions => "#{aliased_table_name}.number > #{number}")
34
+ end
35
+
36
+ # Returns a single version associated with the given value. The following formats are valid:
37
+ # * A Date or Time object: When given, +to_time+ is called on the value and the last version
38
+ # record in the history created before (or at) that time is returned.
39
+ # * A Numeric object: Typically a positive integer, these values correspond to version numbers
40
+ # and the associated version record is found by a version number equal to the given value
41
+ # rounded down to the nearest integer.
42
+ # * A String: A string value represents a version tag and the associated version is searched
43
+ # for by a matching tag value. *Note:* Be careful with string representations of numbers.
44
+ # * A Symbol: Symbols represent association class methods on the +has_many+ versions
45
+ # association. While all of the built-in association methods require arguments, additional
46
+ # extension modules can be defined using the <tt>:extend</tt> option on the +versioned+
47
+ # method. See the +versioned+ documentation for more information.
48
+ # * A Version object: If a version object is passed to the +at+ method, it is simply returned
49
+ # untouched.
50
+ def at(value)
51
+ case value
52
+ when Date, Time then last(:conditions => ["#{aliased_table_name}.created_at <= ?", value.to_time])
53
+ when Numeric then find_by_number(value.floor)
54
+ when String then find_by_tag(value)
55
+ when Symbol then respond_to?(value) ? send(value) : nil
56
+ when Version then value
57
+ end
58
+ end
59
+
60
+ # Returns the version number associated with the given value. In many cases, this involves
61
+ # simply passing the value to the +at+ method and then returning the subsequent version number.
62
+ # Hoever, for Numeric values, the version number can be returned directly and for Date/Time
63
+ # values, a default value of 1 is given to ensure that times prior to the first version
64
+ # still return a valid version number (useful for reversion).
65
+ def number_at(value)
66
+ case value
67
+ when Date, Time then (v = at(value)) ? v.number : 1
68
+ when Numeric then value.floor
69
+ when String, Symbol then (v = at(value)) ? v.number : nil
70
+ when Version then value.number
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,169 @@
1
+ require File.expand_path(File.join(File.dirname(__FILE__), '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
+ @user.update_attribute(:last_name, 'Jobs')
8
+ @changes = @user.versions.last.changes
9
+ end
10
+
11
+ should 'be a hash' do
12
+ assert_kind_of Hash, @changes
13
+ end
14
+
15
+ should 'not be empty' do
16
+ assert !@changes.empty?
17
+ end
18
+
19
+ should 'have string keys' do
20
+ @changes.keys.each do |key|
21
+ assert_kind_of String, key
22
+ end
23
+ end
24
+
25
+ should 'have array values' do
26
+ @changes.values.each do |value|
27
+ assert_kind_of Array, value
28
+ end
29
+ end
30
+
31
+ should 'have two-element values' do
32
+ @changes.values.each do |value|
33
+ assert_equal 2, value.size
34
+ end
35
+ end
36
+
37
+ should 'have unique-element values' do
38
+ @changes.values.each do |value|
39
+ assert_equal value.uniq, value
40
+ end
41
+ end
42
+
43
+ should "equal the model's changes" do
44
+ @user.first_name = 'Stephen'
45
+ model_changes = @user.changes
46
+ @user.save
47
+ changes = @user.versions.last.changes
48
+ assert_equal model_changes, changes
49
+ end
50
+ end
51
+
52
+ context 'A hash of changes' do
53
+ setup do
54
+ @changes = {'first_name' => ['Steve', 'Stephen']}
55
+ @other = {'first_name' => ['Catie', 'Catherine']}
56
+ end
57
+
58
+ should 'properly append other changes' do
59
+ expected = {'first_name' => ['Steve', 'Catherine']}
60
+ changes = @changes.append_changes(@other)
61
+ assert_equal expected, changes
62
+ @changes.append_changes!(@other)
63
+ assert_equal expected, @changes
64
+ end
65
+
66
+ should 'properly prepend other changes' do
67
+ expected = {'first_name' => ['Catie', 'Stephen']}
68
+ changes = @changes.prepend_changes(@other)
69
+ assert_equal expected, changes
70
+ @changes.prepend_changes!(@other)
71
+ assert_equal expected, @changes
72
+ end
73
+
74
+ should 'be reversible' do
75
+ expected = {'first_name' => ['Stephen', 'Steve']}
76
+ changes = @changes.reverse_changes
77
+ assert_equal expected, changes
78
+ @changes.reverse_changes!
79
+ assert_equal expected, @changes
80
+ end
81
+ end
82
+
83
+ context 'The changes between two versions' do
84
+ setup do
85
+ name = 'Steve Richert'
86
+ @user = User.create(:name => name) # 1
87
+ @user.update_attribute(:last_name, 'Jobs') # 2
88
+ @user.update_attribute(:first_name, 'Stephen') # 3
89
+ @user.update_attribute(:last_name, 'Richert') # 4
90
+ @user.update_attribute(:name, name) # 5
91
+ @version = @user.version
92
+ end
93
+
94
+ should 'be a hash' do
95
+ 1.upto(@version) do |i|
96
+ 1.upto(@version) do |j|
97
+ changes = @user.changes_between(i, j)
98
+ assert_kind_of Hash, changes
99
+ end
100
+ end
101
+ end
102
+
103
+ should 'have string keys' do
104
+ 1.upto(@version) do |i|
105
+ 1.upto(@version) do |j|
106
+ changes = @user.changes_between(i, j)
107
+ changes.keys.each do |key|
108
+ assert_kind_of String, key
109
+ end
110
+ end
111
+ end
112
+ end
113
+
114
+ should 'have array values' do
115
+ 1.upto(@version) do |i|
116
+ 1.upto(@version) do |j|
117
+ changes = @user.changes_between(i, j)
118
+ changes.values.each do |value|
119
+ assert_kind_of Array, value
120
+ end
121
+ end
122
+ end
123
+ end
124
+
125
+ should 'have two-element values' do
126
+ 1.upto(@version) do |i|
127
+ 1.upto(@version) do |j|
128
+ changes = @user.changes_between(i, j)
129
+ changes.values.each do |value|
130
+ assert_equal 2, value.size
131
+ end
132
+ end
133
+ end
134
+ end
135
+
136
+ should 'have unique-element values' do
137
+ 1.upto(@version) do |i|
138
+ 1.upto(@version) do |j|
139
+ changes = @user.changes_between(i, j)
140
+ changes.values.each do |value|
141
+ assert_equal value.uniq, value
142
+ end
143
+ end
144
+ end
145
+ end
146
+
147
+ should 'be empty between identical versions' do
148
+ assert @user.changes_between(1, @version).empty?
149
+ assert @user.changes_between(@version, 1).empty?
150
+ end
151
+
152
+ should 'be should reverse with direction' do
153
+ 1.upto(@version) do |i|
154
+ i.upto(@version) do |j|
155
+ up = @user.changes_between(i, j)
156
+ down = @user.changes_between(j, i)
157
+ assert_equal up, down.reverse_changes
158
+ end
159
+ end
160
+ end
161
+
162
+ should 'be empty with invalid arguments' do
163
+ 1.upto(@version) do |i|
164
+ assert @user.changes_between(i, nil)
165
+ assert @user.changes_between(nil, i)
166
+ end
167
+ end
168
+ end
169
+ end