brianjlandau-vestal_versions 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (47) hide show
  1. data/.gitignore +27 -0
  2. data/Gemfile +3 -0
  3. data/LICENSE +20 -0
  4. data/README.rdoc +196 -0
  5. data/Rakefile +50 -0
  6. data/VERSION +1 -0
  7. data/generators/vestal_versions/templates/initializer.rb +9 -0
  8. data/generators/vestal_versions/templates/migration.rb +28 -0
  9. data/generators/vestal_versions/vestal_versions_generator.rb +10 -0
  10. data/init.rb +1 -0
  11. data/lib/vestal_versions.rb +104 -0
  12. data/lib/vestal_versions/associations.rb +67 -0
  13. data/lib/vestal_versions/changes.rb +125 -0
  14. data/lib/vestal_versions/conditions.rb +69 -0
  15. data/lib/vestal_versions/configuration.rb +40 -0
  16. data/lib/vestal_versions/control.rb +175 -0
  17. data/lib/vestal_versions/creation.rb +85 -0
  18. data/lib/vestal_versions/deletion.rb +46 -0
  19. data/lib/vestal_versions/options.rb +45 -0
  20. data/lib/vestal_versions/reload.rb +22 -0
  21. data/lib/vestal_versions/reset.rb +28 -0
  22. data/lib/vestal_versions/reversion.rb +92 -0
  23. data/lib/vestal_versions/tagging.rb +54 -0
  24. data/lib/vestal_versions/users.rb +56 -0
  25. data/lib/vestal_versions/version.rb +76 -0
  26. data/lib/vestal_versions/versioned.rb +30 -0
  27. data/lib/vestal_versions/versions.rb +74 -0
  28. data/test/associations_test.rb +49 -0
  29. data/test/changes_test.rb +169 -0
  30. data/test/conditions_test.rb +137 -0
  31. data/test/configuration_test.rb +39 -0
  32. data/test/control_test.rb +152 -0
  33. data/test/creation_test.rb +110 -0
  34. data/test/deletion_test.rb +121 -0
  35. data/test/options_test.rb +52 -0
  36. data/test/reload_test.rb +19 -0
  37. data/test/reset_test.rb +112 -0
  38. data/test/reversion_test.rb +99 -0
  39. data/test/schema.rb +62 -0
  40. data/test/tagging_test.rb +39 -0
  41. data/test/test_helper.rb +12 -0
  42. data/test/users_test.rb +25 -0
  43. data/test/version_test.rb +61 -0
  44. data/test/versioned_test.rb +18 -0
  45. data/test/versions_test.rb +172 -0
  46. data/vestal_versions.gemspec +124 -0
  47. metadata +245 -0
@@ -0,0 +1,67 @@
1
+ module VestalVersions
2
+ # Allows associations to be automatically reverted_to a given instance method.
3
+ module Associations
4
+ def self.extended(base) # :nodoc:
5
+ base.class_eval do
6
+ valid_keys_for_belongs_to_association << :versioned
7
+ valid_keys_for_has_and_belongs_to_many_association << :versioned
8
+ valid_keys_for_has_many_association << :versioned
9
+ valid_keys_for_has_one_association << :versioned
10
+ end
11
+
12
+ ActiveRecord::Associations::AssociationProxy.send(:include, AssociationProxy::Reversion)
13
+ ActiveRecord::Associations::AssociationProxy.send(:include, AssociationProxy)
14
+ ActiveRecord::Associations::AssociationCollection.send(:include, AssociationProxy)
15
+ ActiveRecord::Associations::AssociationCollection.send(:include, AssociationCollection)
16
+ end
17
+
18
+ module AssociationCollection
19
+ def self.included(base) # :nodoc:
20
+ base.class_eval do
21
+ alias_method_chain :first, :reversion
22
+ alias_method_chain :last, :reversion
23
+ end
24
+ end
25
+
26
+ def first_with_reversion(*args)
27
+ record = first_without_reversion(*args)
28
+ revert_record(record) if record
29
+ record
30
+ end
31
+
32
+ def last_with_reversion(*args)
33
+ record = last_without_reversion(*args)
34
+ revert_record(record) if record
35
+ record
36
+ end
37
+ end
38
+
39
+ module AssociationProxy
40
+ def self.included(base) # :nodoc:
41
+ base.class_eval do
42
+ alias_method_chain :load_target, :reversion
43
+ end
44
+ end
45
+
46
+ def load_target_with_reversion
47
+ @target = load_target_without_reversion
48
+ revert_target if @reflection.options[:versioned]
49
+ @target
50
+ end
51
+
52
+ module Reversion
53
+ private
54
+ def revert_target
55
+ case @target
56
+ when ActiveRecord::Base then revert_record(@target)
57
+ when Array then @target.each{|r| revert_record(r) }
58
+ end
59
+ end
60
+
61
+ def revert_record(record)
62
+ record.revert_to(@owner.version) if record.class.versioned?
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,125 @@
1
+ module VestalVersions
2
+ # Provides the ability to manipulate hashes in the specific format that ActiveRecord gives to
3
+ # dirty attribute changes: string keys and unique, two-element array values.
4
+ module Changes
5
+ def self.included(base) # :nodoc:
6
+ Hash.send(:include, HashMethods)
7
+
8
+ base.class_eval do
9
+ include InstanceMethods
10
+
11
+ after_update :merge_version_changes
12
+ end
13
+ end
14
+
15
+ # Methods available to versioned ActiveRecord::Base instances in order to manage changes used
16
+ # for version creation.
17
+ module InstanceMethods
18
+ # Collects an array of changes from a record's versions between the given range and compiles
19
+ # them into one summary hash of changes. The +from+ and +to+ arguments can each be either a
20
+ # version number, a symbol representing an association proxy method, a string representing a
21
+ # version tag or a version object itself.
22
+ def changes_between(from, to)
23
+ from_number, to_number = versions.number_at(from), versions.number_at(to)
24
+ return {} if from_number == to_number
25
+ chain = versions.between(from_number, to_number).reject(&:initial?)
26
+ return {} if chain.empty?
27
+
28
+ backward = from_number > to_number
29
+ backward ? chain.pop : chain.shift unless from_number == 1 || to_number == 1
30
+
31
+ chain.inject({}) do |changes, version|
32
+ changes.append_changes!(backward ? version.changes.reverse_changes : version.changes)
33
+ end
34
+ end
35
+
36
+ private
37
+ # Before a new version is created, the newly-changed attributes are appended onto a hash
38
+ # of previously-changed attributes. Typically the previous changes will be empty, except in
39
+ # the case that a control block is used where versions are to be merged. See
40
+ # VestalVersions::Control for more information.
41
+ def merge_version_changes
42
+ version_changes.append_changes!(incremental_version_changes)
43
+ end
44
+
45
+ # Stores the cumulative changes that are eventually used for version creation.
46
+ def version_changes
47
+ @version_changes ||= {}
48
+ end
49
+
50
+ # Stores the incremental changes that are appended to the cumulative changes before version
51
+ # creation. Incremental changes are reset when the record is saved because they represent
52
+ # a subset of the dirty attribute changes, which are reset upon save.
53
+ def incremental_version_changes
54
+ changes.slice(*versioned_columns)
55
+ end
56
+
57
+ # Simply resets the cumulative changes after version creation.
58
+ def reset_version_changes
59
+ @version_changes = nil
60
+ end
61
+ end
62
+
63
+ # Instance methods included into Hash for dealing with manipulation of hashes in the specific
64
+ # format of ActiveRecord::Base#changes.
65
+ module HashMethods
66
+ # When called on a hash of changes and given a second hash of changes as an argument,
67
+ # +append_changes+ will run the second hash on top of the first, updating the last element
68
+ # of each array value with its own, or creating its own key/value pair for missing keys.
69
+ # Resulting non-unique array values are removed.
70
+ #
71
+ # == Example
72
+ #
73
+ # first = {
74
+ # "first_name" => ["Steve", "Stephen"],
75
+ # "age" => [25, 26]
76
+ # }
77
+ # second = {
78
+ # "first_name" => ["Stephen", "Steve"],
79
+ # "last_name" => ["Richert", "Jobs"],
80
+ # "age" => [26, 54]
81
+ # }
82
+ # first.append_changes(second)
83
+ # # => {
84
+ # "last_name" => ["Richert", "Jobs"],
85
+ # "age" => [25, 54]
86
+ # }
87
+ def append_changes(changes)
88
+ changes.inject(self) do |new_changes, (attribute, change)|
89
+ new_change = [new_changes.fetch(attribute, change).first, change.last]
90
+ new_changes.merge(attribute => new_change)
91
+ end.reject do |attribute, change|
92
+ change.first == change.last
93
+ end
94
+ end
95
+
96
+ # Destructively appends a given hash of changes onto an existing hash of changes.
97
+ def append_changes!(changes)
98
+ replace(append_changes(changes))
99
+ end
100
+
101
+ # Appends the existing hash of changes onto a given hash of changes. Relates to the
102
+ # +append_changes+ method in the same way that Hash#reverse_merge relates to
103
+ # Hash#merge.
104
+ def prepend_changes(changes)
105
+ changes.append_changes(self)
106
+ end
107
+
108
+ # Destructively prepends a given hash of changes onto an existing hash of changes.
109
+ def prepend_changes!(changes)
110
+ replace(prepend_changes(changes))
111
+ end
112
+
113
+ # Reverses the array values of a hash of changes. Useful for reversion both backward and
114
+ # forward through a record's history of changes.
115
+ def reverse_changes
116
+ inject({}){|nc,(a,c)| nc.merge!(a => c.reverse) }
117
+ end
118
+
119
+ # Destructively reverses the array values of a hash of changes.
120
+ def reverse_changes!
121
+ replace(reverse_changes)
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,69 @@
1
+ module VestalVersions
2
+ # Allows version creation to occur conditionally based on given <tt>:if</tt> and/or
3
+ # <tt>:unless</tt> options.
4
+ module Conditions
5
+ def self.included(base) # :nodoc:
6
+ base.class_eval do
7
+ extend ClassMethods
8
+ include InstanceMethods
9
+
10
+ alias_method_chain :create_version?, :conditions
11
+ alias_method_chain :update_version?, :conditions
12
+
13
+ class << self
14
+ alias_method_chain :prepare_versioned_options, :conditions
15
+ end
16
+ end
17
+ end
18
+
19
+ # Class methods on ActiveRecord::Base to prepare the <tt>:if</tt> and <tt>:unless</tt> options.
20
+ module ClassMethods
21
+ # After the original +prepare_versioned_options+ method cleans the given options, this alias
22
+ # also extracts the <tt>:if</tt> and <tt>:unless</tt> options, chaning them into arrays
23
+ # and converting any symbols to procs. Procs are called with the ActiveRecord model instance
24
+ # as the sole argument.
25
+ #
26
+ # If all of the <tt>:if</tt> conditions are met and none of the <tt>:unless</tt> conditions
27
+ # are unmet, than version creation will proceed, assuming all other conditions are also met.
28
+ def prepare_versioned_options_with_conditions(options)
29
+ result = prepare_versioned_options_without_conditions(options)
30
+
31
+ self.vestal_versions_options[:if] = Array(options.delete(:if)).map(&:to_proc)
32
+ self.vestal_versions_options[:unless] = Array(options.delete(:unless)).map(&:to_proc)
33
+
34
+ result
35
+ end
36
+ end
37
+
38
+ # Instance methods that determine based on the <tt>:if</tt> and <tt>:unless</tt> conditions,
39
+ # whether a version is to be create or updated.
40
+ module InstanceMethods
41
+ private
42
+ # After first determining whether the <tt>:if</tt> and <tt>:unless</tt> conditions are
43
+ # satisfied, the original, unaliased +create_version?+ method is called to determine
44
+ # whether a new version should be created upon update of the ActiveRecord::Base instance.
45
+ def create_version_with_conditions?
46
+ version_conditions_met? && create_version_without_conditions?
47
+ end
48
+
49
+ # After first determining whether the <tt>:if</tt> and <tt>:unless</tt> conditions are
50
+ # satisfied, the original, unaliased +update_version?+ method is called to determine
51
+ # whther the last version should be updated to include changes merged from the current
52
+ # ActiveRecord::Base instance update.
53
+ #
54
+ # The overridden +update_version?+ method simply returns false, effectively delegating
55
+ # the decision to whether the <tt>:if</tt> and <tt>:unless</tt> conditions are met.
56
+ def update_version_with_conditions?
57
+ version_conditions_met? && update_version_without_conditions?
58
+ end
59
+
60
+ # Simply checks whether the <tt>:if</tt> and <tt>:unless</tt> conditions given in the
61
+ # +versioned+ options are met: meaning that all procs in the <tt>:if</tt> array must
62
+ # evaluate to a non-false, non-nil value and that all procs in the <tt>:unless</tt> array
63
+ # must all evaluate to either false or nil.
64
+ def version_conditions_met?
65
+ vestal_versions_options[:if].all?{|p| p.call(self) } && !vestal_versions_options[:unless].any?{|p| p.call(self) }
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,40 @@
1
+ module VestalVersions
2
+ # Allows for easy application-wide configuration of options passed into the +versioned+ method.
3
+ module Configuration
4
+ # The VestalVersions module is extended by VestalVersions::Configuration, allowing the
5
+ # +configure method+ to be used as follows in a Rails initializer:
6
+ #
7
+ # VestalVersions.configure do |config|
8
+ # config.class_name = "MyCustomVersion"
9
+ # config.dependent = :destroy
10
+ # end
11
+ #
12
+ # Each variable assignment in the +configure+ block corresponds directly with the options
13
+ # available to the +versioned+ method. Assigning common options in an initializer can keep your
14
+ # models tidy.
15
+ #
16
+ # If an option is given in both an initializer and in the options passed to +versioned+, the
17
+ # value given in the model itself will take precedence.
18
+ def configure
19
+ yield Configuration
20
+ end
21
+
22
+ class << self
23
+ # Simply stores a hash of options given to the +configure+ block.
24
+ def options
25
+ @options ||= {}
26
+ end
27
+
28
+ # If given a setter method name, will assign the first argument to the +options+ hash with
29
+ # the method name (sans "=") as the key. If given a getter method name, will attempt to
30
+ # a value from the +options+ hash for that key. If the key doesn't exist, defers to +super+.
31
+ def method_missing(symbol, *args)
32
+ if (method = symbol.to_s).sub!(/\=$/, '')
33
+ options[method.to_sym] = args.first
34
+ else
35
+ options.fetch(method.to_sym, super)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,175 @@
1
+ module VestalVersions
2
+ # The control feature allows use of several code blocks that provide finer control over whether
3
+ # a new version is created, or a previous version is updated.
4
+ module Control
5
+ def self.included(base) # :nodoc:
6
+ base.class_eval do
7
+ include InstanceMethods
8
+
9
+ alias_method_chain :create_version?, :control
10
+ alias_method_chain :update_version?, :control
11
+ end
12
+ end
13
+
14
+ # Control blocks are called on ActiveRecord::Base instances as to not cause any conflict with
15
+ # other instances of the versioned class whose behavior could be inadvertently altered within
16
+ # a control block.
17
+ module InstanceMethods
18
+ # The +skip_version+ block simply allows for updates to be made to an instance of a versioned
19
+ # ActiveRecord model while ignoring all new version creation. The <tt>:if</tt> and
20
+ # <tt>:unless</tt> conditions (if given) will not be evaulated inside a +skip_version+ block.
21
+ #
22
+ # When the block closes, the instance is automatically saved, so explicitly saving the
23
+ # object within the block is unnecessary.
24
+ #
25
+ # == Example
26
+ #
27
+ # user = User.find_by_first_name("Steve")
28
+ # user.version # => 1
29
+ # user.skip_version do
30
+ # user.first_name = "Stephen"
31
+ # end
32
+ # user.version # => 1
33
+ def skip_version
34
+ with_version_flag(:skip_version) do
35
+ yield if block_given?
36
+ save
37
+ end
38
+ end
39
+
40
+ # Behaving almost identically to the +skip_version+ block, the only difference with the
41
+ # +skip_version!+ block is that the save automatically performed at the close of the block
42
+ # is a +save!+, meaning that an exception will be raised if the object cannot be saved.
43
+ def skip_version!
44
+ with_version_flag(:skip_version) do
45
+ yield if block_given?
46
+ save!
47
+ end
48
+ end
49
+
50
+ # A convenience method for determining whether a versioned instance is set to skip its next
51
+ # version creation.
52
+ def skip_version?
53
+ !!@skip_version
54
+ end
55
+
56
+ # Merging versions with the +merge_version+ block will take all of the versions that would
57
+ # be created within the block and merge them into one version and pushing that single version
58
+ # onto the ActiveRecord::Base instance's version history. A new version will be created and
59
+ # the instance's version number will be incremented.
60
+ #
61
+ # == Example
62
+ #
63
+ # user = User.find_by_first_name("Steve")
64
+ # user.version # => 1
65
+ # user.merge_version do
66
+ # user.update_attributes(:first_name => "Steven", :last_name => "Tyler")
67
+ # user.update_attributes(:first_name => "Stephen")
68
+ # user.update_attributes(:last_name =>"Richert")
69
+ # end
70
+ # user.version # => 2
71
+ # user.versions.last.changes
72
+ # # => {"first_name" => ["Steve", "Stephen"], "last_name" => ["Jobs", "Richert"]}
73
+ #
74
+ # See VestalVersions::Changes for an explanation on how changes are appended.
75
+ def merge_version
76
+ with_version_flag(:merge_version) do
77
+ yield if block_given?
78
+ end
79
+ save
80
+ end
81
+
82
+ # Behaving almost identically to the +merge_version+ block, the only difference with the
83
+ # +merge_version!+ block is that the save automatically performed at the close of the block
84
+ # is a +save!+, meaning that an exception will be raised if the object cannot be saved.
85
+ def merge_version!
86
+ with_version_flag(:merge_version) do
87
+ yield if block_given?
88
+ end
89
+ save!
90
+ end
91
+
92
+ # A convenience method for determining whether a versioned instance is set to merge its next
93
+ # versions into one before version creation.
94
+ def merge_version?
95
+ !!@merge_version
96
+ end
97
+
98
+ # Appending versions with the +append_version+ block acts similarly to the +merge_version+
99
+ # block in that all would-be version creations within the block are defered until the block
100
+ # closes. The major difference is that with +append_version+, a new version is not created.
101
+ # Rather, the cumulative changes are appended to the serialized changes of the instance's
102
+ # last version. A new version is not created, so the version number is not incremented.
103
+ #
104
+ # == Example
105
+ #
106
+ # user = User.find_by_first_name("Steve")
107
+ # user.version # => 2
108
+ # user.versions.last.changes
109
+ # # => {"first_name" => ["Stephen", "Steve"]}
110
+ # user.append_version do
111
+ # user.last_name = "Jobs"
112
+ # end
113
+ # user.versions.last.changes
114
+ # # => {"first_name" => ["Stephen", "Steve"], "last_name" => ["Richert", "Jobs"]}
115
+ # user.version # => 2
116
+ #
117
+ # See VestalVersions::Changes for an explanation on how changes are appended.
118
+ def append_version
119
+ with_version_flag(:merge_version) do
120
+ yield if block_given?
121
+ end
122
+
123
+ with_version_flag(:append_version) do
124
+ save
125
+ end
126
+ end
127
+
128
+ # Behaving almost identically to the +append_version+ block, the only difference with the
129
+ # +append_version!+ block is that the save automatically performed at the close of the block
130
+ # is a +save!+, meaning that an exception will be raised if the object cannot be saved.
131
+ def append_version!
132
+ with_version_flag(:merge_version) do
133
+ yield if block_given?
134
+ end
135
+
136
+ with_version_flag(:append_version) do
137
+ save!
138
+ end
139
+ end
140
+
141
+ # A convenience method for determining whether a versioned instance is set to append its next
142
+ # version's changes into the last version changes.
143
+ def append_version?
144
+ !!@append_version
145
+ end
146
+
147
+ private
148
+ # Used for each control block, the +with_version_flag+ method sets a given variable to
149
+ # true and then executes the given block, ensuring that the variable is returned to a nil
150
+ # value before returning. This is useful to be certain that one of the control flag
151
+ # instance variables isn't inadvertently left in the "on" position by execution within the
152
+ # block raising an exception.
153
+ def with_version_flag(flag)
154
+ begin
155
+ instance_variable_set("@#{flag}", true)
156
+ yield
157
+ ensure
158
+ instance_variable_set("@#{flag}", nil)
159
+ end
160
+ end
161
+
162
+ # Overrides the basal +create_version?+ method to make sure that new versions are not
163
+ # created when inside any of the control blocks (until the block terminates).
164
+ def create_version_with_control?
165
+ !skip_version? && !merge_version? && !append_version? && create_version_without_control?
166
+ end
167
+
168
+ # Overrides the basal +update_version?+ method to allow the last version of an versioned
169
+ # ActiveRecord::Base instance to be updated at the end of an +append_version+ block.
170
+ def update_version_with_control?
171
+ append_version?
172
+ end
173
+ end
174
+ end
175
+ end