vestal_versions 0.8.3 → 1.0.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 (47) hide show
  1. data/.gitignore +20 -2
  2. data/README.rdoc +110 -8
  3. data/Rakefile +14 -3
  4. data/VERSION +1 -1
  5. data/lib/vestal_versions.rb +93 -143
  6. data/lib/vestal_versions/changes.rb +125 -0
  7. data/lib/vestal_versions/conditions.rb +69 -0
  8. data/lib/vestal_versions/configuration.rb +40 -0
  9. data/lib/vestal_versions/control.rb +175 -0
  10. data/lib/vestal_versions/creation.rb +85 -0
  11. data/lib/vestal_versions/options.rb +42 -0
  12. data/lib/vestal_versions/reload.rb +23 -0
  13. data/lib/vestal_versions/reset.rb +56 -0
  14. data/lib/vestal_versions/reversion.rb +69 -0
  15. data/lib/vestal_versions/tagging.rb +50 -0
  16. data/lib/vestal_versions/users.rb +57 -0
  17. data/lib/vestal_versions/version.rb +32 -0
  18. data/lib/vestal_versions/versioned.rb +30 -0
  19. data/lib/vestal_versions/versions.rb +74 -0
  20. data/rails/init.rb +1 -0
  21. data/rails_generators/vestal_versions/templates/initializer.rb +9 -0
  22. data/{generators/vestal_versions_migration → rails_generators/vestal_versions}/templates/migration.rb +9 -2
  23. data/rails_generators/vestal_versions/vestal_versions_generator.rb +10 -0
  24. data/test/changes_test.rb +154 -13
  25. data/test/conditions_test.rb +137 -0
  26. data/test/configuration_test.rb +39 -0
  27. data/test/control_test.rb +152 -0
  28. data/test/creation_test.rb +70 -30
  29. data/test/options_test.rb +52 -0
  30. data/test/reload_test.rb +19 -0
  31. data/test/reset_test.rb +107 -0
  32. data/test/{revert_test.rb → reversion_test.rb} +8 -22
  33. data/test/schema.rb +4 -1
  34. data/test/tagging_test.rb +38 -0
  35. data/test/test_helper.rb +2 -1
  36. data/test/users_test.rb +25 -0
  37. data/test/version_test.rb +43 -0
  38. data/test/versioned_test.rb +18 -0
  39. data/test/versions_test.rb +172 -0
  40. data/vestal_versions.gemspec +61 -21
  41. metadata +75 -15
  42. data/generators/vestal_versions_migration/vestal_versions_migration_generator.rb +0 -11
  43. data/init.rb +0 -1
  44. data/lib/version.rb +0 -14
  45. data/test/between_test.rb +0 -58
  46. data/test/comparable_test.rb +0 -35
  47. data/test/latest_changes_test.rb +0 -42
@@ -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_attribute(:first_name, "Stephen")
68
+ # user.update_attribute(: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
@@ -0,0 +1,85 @@
1
+ module VestalVersions
2
+ # Adds the functionality necessary to control version creation on a versioned instance of
3
+ # ActiveRecord::Base.
4
+ module Creation
5
+ def self.included(base) # :nodoc:
6
+ base.class_eval do
7
+ extend ClassMethods
8
+ include InstanceMethods
9
+
10
+ after_update :create_version, :if => :create_version?
11
+ after_update :update_version, :if => :update_version?
12
+
13
+ class << self
14
+ alias_method_chain :prepare_versioned_options, :creation
15
+ end
16
+ end
17
+ end
18
+
19
+ # Class methods added to ActiveRecord::Base to facilitate the creation of new versions.
20
+ module ClassMethods
21
+ # Overrides the basal +prepare_versioned_options+ method defined in VestalVersions::Options
22
+ # to extract the <tt>:only</tt> and <tt>:except</tt> options into +vestal_versions_options+.
23
+ def prepare_versioned_options_with_creation(options)
24
+ result = prepare_versioned_options_without_creation(options)
25
+
26
+ self.vestal_versions_options[:only] = Array(options.delete(:only)).map(&:to_s).uniq if options[:only]
27
+ self.vestal_versions_options[:except] = Array(options.delete(:except)).map(&:to_s).uniq if options[:except]
28
+
29
+ result
30
+ end
31
+ end
32
+
33
+ # Instance methods that determine whether to save a version and actually perform the save.
34
+ module InstanceMethods
35
+ private
36
+ # Returns whether a new version should be created upon updating the parent record.
37
+ def create_version?
38
+ version_changes.present?
39
+ end
40
+
41
+ # Creates a new version upon updating the parent record.
42
+ def create_version
43
+ versions.create(version_attributes)
44
+ reset_version_changes
45
+ reset_version
46
+ end
47
+
48
+ # Returns whether the last version should be updated upon updating the parent record.
49
+ # This method is overridden in VestalVersions::Control to account for a control block that
50
+ # merges changes onto the previous version.
51
+ def update_version?
52
+ false
53
+ end
54
+
55
+ # Updates the last version's changes by appending the current version changes.
56
+ def update_version
57
+ return create_version unless v = versions.last
58
+ v.changes_will_change!
59
+ v.update_attribute(:changes, v.changes.append_changes(version_changes))
60
+ reset_version_changes
61
+ reset_version
62
+ end
63
+
64
+ # Returns an array of column names that should be included in the changes of created
65
+ # versions. If <tt>vestal_versions_options[:only]</tt> is specified, only those columns
66
+ # will be versioned. Otherwise, if <tt>vestal_versions_options[:except]</tt> is specified,
67
+ # all columns will be versioned other than those specified. Without either option, the
68
+ # default is to version all columns. At any rate, the four "automagic" timestamp columns
69
+ # maintained by Rails are never versioned.
70
+ def versioned_columns
71
+ case
72
+ when vestal_versions_options[:only] then self.class.column_names & vestal_versions_options[:only]
73
+ when vestal_versions_options[:except] then self.class.column_names - vestal_versions_options[:except]
74
+ else self.class.column_names
75
+ end - %w(created_at created_on updated_at updated_on)
76
+ end
77
+
78
+ # Specifies the attributes used during version creation. This is separated into its own
79
+ # method so that it can be overridden by the VestalVersions::Users feature.
80
+ def version_attributes
81
+ {:changes => version_changes, :number => last_version + 1}
82
+ end
83
+ end
84
+ end
85
+ end