mil_vestal_versions 1.2.6

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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +19 -0
  3. data/.travis.yml +9 -0
  4. data/CHANGELOG +25 -0
  5. data/Gemfile +8 -0
  6. data/LICENSE +20 -0
  7. data/README.rdoc +200 -0
  8. data/Rakefile +6 -0
  9. data/gemfiles/activerecord_3_0.gemfile +9 -0
  10. data/gemfiles/activerecord_3_1.gemfile +9 -0
  11. data/gemfiles/activerecord_3_2.gemfile +9 -0
  12. data/lib/generators/vestal_versions.rb +11 -0
  13. data/lib/generators/vestal_versions/migration/migration_generator.rb +17 -0
  14. data/lib/generators/vestal_versions/migration/templates/initializer.rb +9 -0
  15. data/lib/generators/vestal_versions/migration/templates/migration.rb +28 -0
  16. data/lib/vestal_versions.rb +126 -0
  17. data/lib/vestal_versions/changes.rb +121 -0
  18. data/lib/vestal_versions/conditions.rb +57 -0
  19. data/lib/vestal_versions/control.rb +199 -0
  20. data/lib/vestal_versions/creation.rb +93 -0
  21. data/lib/vestal_versions/deletion.rb +37 -0
  22. data/lib/vestal_versions/options.rb +41 -0
  23. data/lib/vestal_versions/reload.rb +16 -0
  24. data/lib/vestal_versions/reset.rb +24 -0
  25. data/lib/vestal_versions/reversion.rb +81 -0
  26. data/lib/vestal_versions/users.rb +54 -0
  27. data/lib/vestal_versions/version.rb +81 -0
  28. data/lib/vestal_versions/version_tagging.rb +49 -0
  29. data/lib/vestal_versions/versioned.rb +27 -0
  30. data/lib/vestal_versions/versions.rb +74 -0
  31. data/mil_vestal_versions.gemspec +19 -0
  32. data/spec/spec_helper.rb +20 -0
  33. data/spec/support/models.rb +19 -0
  34. data/spec/support/schema.rb +25 -0
  35. data/spec/vestal_versions/changes_spec.rb +134 -0
  36. data/spec/vestal_versions/conditions_spec.rb +103 -0
  37. data/spec/vestal_versions/control_spec.rb +120 -0
  38. data/spec/vestal_versions/creation_spec.rb +90 -0
  39. data/spec/vestal_versions/deletion_spec.rb +86 -0
  40. data/spec/vestal_versions/options_spec.rb +45 -0
  41. data/spec/vestal_versions/reload_spec.rb +18 -0
  42. data/spec/vestal_versions/reset_spec.rb +111 -0
  43. data/spec/vestal_versions/reversion_spec.rb +103 -0
  44. data/spec/vestal_versions/users_spec.rb +21 -0
  45. data/spec/vestal_versions/version_spec.rb +61 -0
  46. data/spec/vestal_versions/version_tagging_spec.rb +39 -0
  47. data/spec/vestal_versions/versioned_spec.rb +16 -0
  48. data/spec/vestal_versions/versions_spec.rb +176 -0
  49. data/vestal_versions.gemspec +19 -0
  50. metadata +139 -0
@@ -0,0 +1,121 @@
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
+ extend ActiveSupport::Concern
6
+ included do
7
+ Hash.class_eval{ include HashMethods }
8
+
9
+ after_update :merge_version_changes
10
+ end
11
+
12
+ # Methods available to versioned ActiveRecord::Base instances in order to manage changes used
13
+ # for version creation.
14
+
15
+ # Collects an array of changes from a record's versions between the given range and compiles
16
+ # them into one summary hash of changes. The +from+ and +to+ arguments can each be either a
17
+ # version number, a symbol representing an association proxy method, a string representing a
18
+ # version tag or a version object itself.
19
+ def changes_between(from, to)
20
+ from_number, to_number = versions.number_at(from), versions.number_at(to)
21
+ return {} if from_number == to_number
22
+ chain = versions.between(from_number, to_number).reject(&:initial?)
23
+ return {} if chain.empty?
24
+
25
+ backward = from_number > to_number
26
+ backward ? chain.pop : chain.shift unless from_number == 1 || to_number == 1
27
+
28
+ chain.inject({}) do |changes, version|
29
+ changes.append_changes!(backward ? version.changes.reverse_changes : version.changes)
30
+ end
31
+ end
32
+
33
+ private
34
+ # Before a new version is created, the newly-changed attributes are appended onto a hash
35
+ # of previously-changed attributes. Typically the previous changes will be empty, except in
36
+ # the case that a control block is used where versions are to be merged. See
37
+ # VestalVersions::Control for more information.
38
+ def merge_version_changes
39
+ version_changes.append_changes!(incremental_version_changes)
40
+ end
41
+
42
+ # Stores the cumulative changes that are eventually used for version creation.
43
+ def version_changes
44
+ @version_changes ||= {}
45
+ end
46
+
47
+ # Stores the incremental changes that are appended to the cumulative changes before version
48
+ # creation. Incremental changes are reset when the record is saved because they represent
49
+ # a subset of the dirty attribute changes, which are reset upon save.
50
+ def incremental_version_changes
51
+ changes.slice(*versioned_columns)
52
+ end
53
+
54
+ # Simply resets the cumulative changes after version creation.
55
+ def reset_version_changes
56
+ @version_changes = nil
57
+ end
58
+
59
+ # Instance methods included into Hash for dealing with manipulation of hashes in the specific
60
+ # format of ActiveRecord::Base#changes.
61
+ module HashMethods
62
+ # When called on a hash of changes and given a second hash of changes as an argument,
63
+ # +append_changes+ will run the second hash on top of the first, updating the last element
64
+ # of each array value with its own, or creating its own key/value pair for missing keys.
65
+ # Resulting non-unique array values are removed.
66
+ #
67
+ # == Example
68
+ #
69
+ # first = {
70
+ # "first_name" => ["Steve", "Stephen"],
71
+ # "age" => [25, 26]
72
+ # }
73
+ # second = {
74
+ # "first_name" => ["Stephen", "Steve"],
75
+ # "last_name" => ["Richert", "Jobs"],
76
+ # "age" => [26, 54]
77
+ # }
78
+ # first.append_changes(second)
79
+ # # => {
80
+ # "last_name" => ["Richert", "Jobs"],
81
+ # "age" => [25, 54]
82
+ # }
83
+ def append_changes(changes)
84
+ changes.inject(self) do |new_changes, (attribute, change)|
85
+ new_change = [new_changes.fetch(attribute, change).first, change.last]
86
+ new_changes.merge(attribute => new_change)
87
+ end.reject do |attribute, change|
88
+ change.first == change.last
89
+ end
90
+ end
91
+
92
+ # Destructively appends a given hash of changes onto an existing hash of changes.
93
+ def append_changes!(changes)
94
+ replace(append_changes(changes))
95
+ end
96
+
97
+ # Appends the existing hash of changes onto a given hash of changes. Relates to the
98
+ # +append_changes+ method in the same way that Hash#reverse_merge relates to
99
+ # Hash#merge.
100
+ def prepend_changes(changes)
101
+ changes.append_changes(self)
102
+ end
103
+
104
+ # Destructively prepends a given hash of changes onto an existing hash of changes.
105
+ def prepend_changes!(changes)
106
+ replace(prepend_changes(changes))
107
+ end
108
+
109
+ # Reverses the array values of a hash of changes. Useful for reversion both backward and
110
+ # forward through a record's history of changes.
111
+ def reverse_changes
112
+ inject({}){|nc,(a,c)| nc.merge!(a => c.reverse) }
113
+ end
114
+
115
+ # Destructively reverses the array values of a hash of changes.
116
+ def reverse_changes!
117
+ replace(reverse_changes)
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,57 @@
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
+ extend ActiveSupport::Concern
6
+
7
+ # Class methods on ActiveRecord::Base to prepare the <tt>:if</tt> and <tt>:unless</tt> options.
8
+ module ClassMethods
9
+ # After the original +prepare_versioned_options+ method cleans the given options, this alias
10
+ # also extracts the <tt>:if</tt> and <tt>:unless</tt> options, chaning them into arrays
11
+ # and converting any symbols to procs. Procs are called with the ActiveRecord model instance
12
+ # as the sole argument.
13
+ #
14
+ # If all of the <tt>:if</tt> conditions are met and none of the <tt>:unless</tt> conditions
15
+ # are unmet, than version creation will proceed, assuming all other conditions are also met.
16
+ def prepare_versioned_options(options)
17
+ result = super(options)
18
+
19
+ vestal_versions_options[:if] = Array(options.delete(:if)).map(&:to_proc)
20
+ vestal_versions_options[:unless] = Array(options.delete(:unless)).map(&:to_proc)
21
+
22
+ result
23
+ end
24
+ end
25
+
26
+ # Instance methods that determine based on the <tt>:if</tt> and <tt>:unless</tt> conditions,
27
+ # whether a version is to be create or updated.
28
+
29
+
30
+ private
31
+ # After first determining whether the <tt>:if</tt> and <tt>:unless</tt> conditions are
32
+ # satisfied, the original, unaliased +create_version?+ method is called to determine
33
+ # whether a new version should be created upon update of the ActiveRecord::Base instance.
34
+ def create_version?
35
+ version_conditions_met? && super
36
+ end
37
+
38
+ # After first determining whether the <tt>:if</tt> and <tt>:unless</tt> conditions are
39
+ # satisfied, the original, unaliased +update_version?+ method is called to determine
40
+ # whther the last version should be updated to include changes merged from the current
41
+ # ActiveRecord::Base instance update.
42
+ #
43
+ # The overridden +update_version?+ method simply returns false, effectively delegating
44
+ # the decision to whether the <tt>:if</tt> and <tt>:unless</tt> conditions are met.
45
+ def update_version?
46
+ version_conditions_met? && super
47
+ end
48
+
49
+ # Simply checks whether the <tt>:if</tt> and <tt>:unless</tt> conditions given in the
50
+ # +versioned+ options are met: meaning that all procs in the <tt>:if</tt> array must
51
+ # evaluate to a non-false, non-nil value and that all procs in the <tt>:unless</tt> array
52
+ # must all evaluate to either false or nil.
53
+ def version_conditions_met?
54
+ vestal_versions_options[:if].all?{|p| p.call(self) } && !vestal_versions_options[:unless].any?{|p| p.call(self) }
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,199 @@
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
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ class_attribute :_skip_version, :instance_writer => false
9
+ end
10
+
11
+
12
+ # Control blocks are called on ActiveRecord::Base instances as to not cause any conflict with
13
+ # other instances of the versioned class whose behavior could be inadvertently altered within
14
+ # a control block.
15
+
16
+ # The +skip_version+ block simply allows for updates to be made to an instance of a versioned
17
+ # ActiveRecord model while ignoring all new version creation. The <tt>:if</tt> and
18
+ # <tt>:unless</tt> conditions (if given) will not be evaulated inside a +skip_version+ block.
19
+ #
20
+ # When the block closes, the instance is automatically saved, so explicitly saving the
21
+ # object within the block is unnecessary.
22
+ #
23
+ # == Example
24
+ #
25
+ # user = User.find_by_first_name("Steve")
26
+ # user.version # => 1
27
+ # user.skip_version do
28
+ # user.first_name = "Stephen"
29
+ # end
30
+ # user.version # => 1
31
+ def skip_version
32
+ _with_version_flag(:_skip_version) do
33
+ yield if block_given?
34
+ save
35
+ end
36
+ end
37
+
38
+ # Behaving almost identically to the +skip_version+ block, the only difference with the
39
+ # +skip_version!+ block is that the save automatically performed at the close of the block
40
+ # is a +save!+, meaning that an exception will be raised if the object cannot be saved.
41
+ def skip_version!
42
+ _with_version_flag(:_skip_version) do
43
+ yield if block_given?
44
+ save!
45
+ end
46
+ end
47
+
48
+ # Merging versions with the +merge_version+ block will take all of the versions that would
49
+ # be created within the block and merge them into one version and pushing that single version
50
+ # onto the ActiveRecord::Base instance's version history. A new version will be created and
51
+ # the instance's version number will be incremented.
52
+ #
53
+ # == Example
54
+ #
55
+ # user = User.find_by_first_name("Steve")
56
+ # user.version # => 1
57
+ # user.merge_version do
58
+ # user.update_attributes(:first_name => "Steven", :last_name => "Tyler")
59
+ # user.update_attribute(:first_name, "Stephen")
60
+ # user.update_attribute(:last_name, "Richert")
61
+ # end
62
+ # user.version # => 2
63
+ # user.versions.last.changes
64
+ # # => {"first_name" => ["Steve", "Stephen"], "last_name" => ["Jobs", "Richert"]}
65
+ #
66
+ # See VestalVersions::Changes for an explanation on how changes are appended.
67
+ def merge_version
68
+ _with_version_flag(:merge_version) do
69
+ yield if block_given?
70
+ end
71
+ save
72
+ end
73
+
74
+ # Behaving almost identically to the +merge_version+ block, the only difference with the
75
+ # +merge_version!+ block is that the save automatically performed at the close of the block
76
+ # is a +save!+, meaning that an exception will be raised if the object cannot be saved.
77
+ def merge_version!
78
+ _with_version_flag(:merge_version) do
79
+ yield if block_given?
80
+ end
81
+ save!
82
+ end
83
+
84
+ # A convenience method for determining whether a versioned instance is set to merge its next
85
+ # versions into one before version creation.
86
+ def merge_version?
87
+ !!@merge_version
88
+ end
89
+
90
+ # Appending versions with the +append_version+ block acts similarly to the +merge_version+
91
+ # block in that all would-be version creations within the block are defered until the block
92
+ # closes. The major difference is that with +append_version+, a new version is not created.
93
+ # Rather, the cumulative changes are appended to the serialized changes of the instance's
94
+ # last version. A new version is not created, so the version number is not incremented.
95
+ #
96
+ # == Example
97
+ #
98
+ # user = User.find_by_first_name("Steve")
99
+ # user.version # => 2
100
+ # user.versions.last.changes
101
+ # # => {"first_name" => ["Stephen", "Steve"]}
102
+ # user.append_version do
103
+ # user.last_name = "Jobs"
104
+ # end
105
+ # user.versions.last.changes
106
+ # # => {"first_name" => ["Stephen", "Steve"], "last_name" => ["Richert", "Jobs"]}
107
+ # user.version # => 2
108
+ #
109
+ # See VestalVersions::Changes for an explanation on how changes are appended.
110
+ def append_version
111
+ _with_version_flag(:merge_version) do
112
+ yield if block_given?
113
+ end
114
+
115
+ _with_version_flag(:append_version) do
116
+ save
117
+ end
118
+ end
119
+
120
+ # Behaving almost identically to the +append_version+ block, the only difference with the
121
+ # +append_version!+ block is that the save automatically performed at the close of the block
122
+ # is a +save!+, meaning that an exception will be raised if the object cannot be saved.
123
+ def append_version!
124
+ _with_version_flag(:merge_version) do
125
+ yield if block_given?
126
+ end
127
+
128
+ _with_version_flag(:append_version) do
129
+ save!
130
+ end
131
+ end
132
+
133
+ # A convenience method for determining whether a versioned instance is set to append its next
134
+ # version's changes into the last version changes.
135
+ def append_version?
136
+ !!@append_version
137
+ end
138
+
139
+ # Used for each control block, the +_with_version_flag+ method sets a given variable to
140
+ # true and then executes the given block, ensuring that the variable is returned to a nil
141
+ # value before returning. This is useful to be certain that one of the control flag
142
+ # instance variables isn't inadvertently left in the "on" position by execution within the
143
+ # block raising an exception.
144
+ def _with_version_flag(flag)
145
+ instance_variable_set("@#{flag}", true)
146
+ yield
147
+ ensure
148
+ remove_instance_variable("@#{flag}")
149
+ end
150
+
151
+ # Overrides the basal +create_version?+ method to make sure that new versions are not
152
+ # created when inside any of the control blocks (until the block terminates).
153
+ def create_version?
154
+ !_skip_version? && !merge_version? && !append_version? && super
155
+ end
156
+
157
+ # Overrides the basal +update_version?+ method to allow the last version of an versioned
158
+ # ActiveRecord::Base instance to be updated at the end of an +append_version+ block.
159
+ def update_version?
160
+ append_version?
161
+ end
162
+
163
+ module ClassMethods
164
+ # The +skip_version+ block simply allows for updates to be made to an instance of a versioned
165
+ # ActiveRecord model while ignoring all new version creation. The <tt>:if</tt> and
166
+ # <tt>:unless</tt> conditions (if given) will not be evaulated inside a +skip_version+ block.
167
+ #
168
+ # When the block closes, the instance is automatically saved, so explicitly saving the
169
+ # object within the block is unnecessary.
170
+ #
171
+ # == Example
172
+ #
173
+ # user = User.find_by_first_name("Steve")
174
+ # user.version # => 1
175
+ # user.skip_version do
176
+ # user.first_name = "Stephen"
177
+ # end
178
+ # user.version # => 1
179
+ def skip_version
180
+ _with_version_flag(:_skip_version) do
181
+ yield if block_given?
182
+ end
183
+ end
184
+
185
+ # Used for each control block, the +with_version_flag+ method sets a given variable to
186
+ # true and then executes the given block, ensuring that the variable is returned to a nil
187
+ # value before returning. This is useful to be certain that one of the control flag
188
+ # instance variables isn't inadvertently left in the "on" position by execution within the
189
+ # block raising an exception.
190
+ def _with_version_flag(flag)
191
+ self.send("#{flag}=", true)
192
+ yield
193
+ ensure
194
+ self.send("#{flag}=", nil)
195
+ end
196
+
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,93 @@
1
+ module VestalVersions
2
+ # Adds the functionality necessary to control version creation on a versioned instance of
3
+ # ActiveRecord::Base.
4
+ module Creation
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ after_create :create_initial_version, :if => :create_initial_version?
9
+ after_update :create_version, :if => :create_version?
10
+ after_update :update_version, :if => :update_version?
11
+ end
12
+
13
+ # Class methods added to ActiveRecord::Base to facilitate the creation of new versions.
14
+ module ClassMethods
15
+ # Overrides the basal +prepare_versioned_options+ method defined in VestalVersions::Options
16
+ # to extract the <tt>:only</tt>, <tt>:except</tt> and <tt>:initial_version</tt> options
17
+ # into +vestal_versions_options+.
18
+ def prepare_versioned_options(options)
19
+ result = super(options)
20
+
21
+ self.vestal_versions_options[:only] = Array(options.delete(:only)).map(&:to_s).uniq if options[:only]
22
+ self.vestal_versions_options[:except] = Array(options.delete(:except)).map(&:to_s).uniq if options[:except]
23
+ self.vestal_versions_options[:initial_version] = options.delete(:initial_version)
24
+
25
+ result
26
+ end
27
+ end
28
+
29
+ # Instance methods that determine whether to save a version and actually perform the save.
30
+
31
+ private
32
+
33
+ # Returns whether an initial version should be created upon creation of the parent record.
34
+ def create_initial_version?
35
+ vestal_versions_options[:initial_version] == true
36
+ end
37
+
38
+ # Creates an initial version upon creation of the parent record.
39
+ def create_initial_version
40
+ versions.create(version_attributes.merge(:number => 1))
41
+ reset_version_changes
42
+ reset_version
43
+ end
44
+
45
+ # Returns whether a new version should be created upon updating the parent record.
46
+ def create_version?
47
+ !version_changes.blank?
48
+ end
49
+
50
+ # Creates a new version upon updating the parent record.
51
+ def create_version(attributes = nil)
52
+ versions.create(attributes || version_attributes)
53
+ reset_version_changes
54
+ reset_version
55
+ end
56
+
57
+ # Returns whether the last version should be updated upon updating the parent record.
58
+ # This method is overridden in VestalVersions::Control to account for a control block that
59
+ # merges changes onto the previous version.
60
+ def update_version?
61
+ false
62
+ end
63
+
64
+ # Updates the last version's changes by appending the current version changes.
65
+ def update_version
66
+ return create_version unless v = versions.last
67
+ v.modifications_will_change!
68
+ v.update_attribute(:modifications, v.changes.append_changes(version_changes))
69
+ reset_version_changes
70
+ reset_version
71
+ end
72
+
73
+ # Returns an array of column names that should be included in the changes of created
74
+ # versions. If <tt>vestal_versions_options[:only]</tt> is specified, only those columns
75
+ # will be versioned. Otherwise, if <tt>vestal_versions_options[:except]</tt> is specified,
76
+ # all columns will be versioned other than those specified. Without either option, the
77
+ # default is to version all columns. At any rate, the four "automagic" timestamp columns
78
+ # maintained by Rails are never versioned.
79
+ def versioned_columns
80
+ case
81
+ when vestal_versions_options[:only] then self.class.column_names & vestal_versions_options[:only]
82
+ when vestal_versions_options[:except] then self.class.column_names - vestal_versions_options[:except]
83
+ else self.class.column_names
84
+ end - %w(created_at created_on updated_at updated_on)
85
+ end
86
+
87
+ # Specifies the attributes used during version creation. This is separated into its own
88
+ # method so that it can be overridden by the VestalVersions::Users feature.
89
+ def version_attributes
90
+ {:modifications => version_changes, :number => last_version + 1}
91
+ end
92
+ end
93
+ end