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,42 @@
1
+ module VestalVersions
2
+ # Provides +versioned+ options conversion and cleanup.
3
+ module Options
4
+ def self.included(base) # :nodoc:
5
+ base.class_eval do
6
+ extend ClassMethods
7
+ end
8
+ end
9
+
10
+ # Class methods that provide preparation of options passed to the +versioned+ method.
11
+ module ClassMethods
12
+ # The +prepare_versioned_options+ method has three purposes:
13
+ # 1. Populate the provided options with default values where needed
14
+ # 2. Prepare options for use with the +has_many+ association
15
+ # 3. Save user-configurable options in a class-level variable
16
+ #
17
+ # Options are given priority in the following order:
18
+ # 1. Those passed directly to the +versioned+ method
19
+ # 2. Those specified in an initializer +configure+ block
20
+ # 3. Default values specified in +prepare_versioned_options+
21
+ #
22
+ # The method is overridden in feature modules that require specific options outside the
23
+ # standard +has_many+ associations.
24
+ def prepare_versioned_options(options)
25
+ options.symbolize_keys!
26
+ options.reverse_merge!(Configuration.options)
27
+ options.reverse_merge!(
28
+ :class_name => 'VestalVersions::Version',
29
+ :dependent => :delete_all
30
+ )
31
+
32
+ class_inheritable_accessor :vestal_versions_options
33
+ self.vestal_versions_options = options.dup
34
+
35
+ options.merge!(
36
+ :as => :versioned,
37
+ :extend => Array(options[:extend]).unshift(Versions)
38
+ )
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,23 @@
1
+ module VestalVersions
2
+ # Ties into the existing ActiveRecord::Base#reload method to ensure that version information
3
+ # is properly reset.
4
+ module Reload
5
+ def self.included(base) # :nodoc:
6
+ base.class_eval do
7
+ include InstanceMethods
8
+
9
+ alias_method_chain :reload, :versions
10
+ end
11
+ end
12
+
13
+ # Adds instance methods into ActiveRecord::Base to tap into the +reload+ method.
14
+ module InstanceMethods
15
+ # Overrides ActiveRecord::Base#reload, resetting the instance-variable-cached version number
16
+ # before performing the original +reload+ method.
17
+ def reload_with_versions(*args)
18
+ reset_version
19
+ reload_without_versions(*args)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,56 @@
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
+ Version.send(:include, VersionMethods)
6
+
7
+ base.class_eval do
8
+ include InstanceMethods
9
+ end
10
+ end
11
+
12
+ # Adds the instance methods required to reset an object to a previous version.
13
+ module InstanceMethods
14
+ # Similar to +revert_to!+, the +reset_to!+ method reverts an object to a previous version,
15
+ # only instead of creating a new record in the version history, +reset_to!+ deletes all of
16
+ # the version history that occurs after the version reverted to.
17
+ #
18
+ # The action taken on each version record after the point of reversion is determined by the
19
+ # <tt>:dependent</tt> option given to the +versioned+ method. See the +versioned+ method
20
+ # documentation for more details.
21
+ def reset_to!(value)
22
+ if saved = skip_version{ revert_to!(value) }
23
+ versions.after(value).each(&version_reset_method)
24
+ reset_version
25
+ end
26
+ saved
27
+ end
28
+
29
+ private
30
+ # The method used to individually remove versions from the version history by way of the
31
+ # +reset_to!+ method. There are three options for the <tt>:dependent</tt> option given
32
+ # to the +versioned+ method: <tt>:delete_all</tt>, <tt>:destroy</tt> and <tt>:nullify</tt>.
33
+ # If none is given, <tt>:delete_all</tt> is the default.
34
+ #
35
+ # If <tt>:delete_all</tt> is given, each version will be deleted from the database,
36
+ # triggering no callbacks. If <tt>:destroy</tt> is given, each version will likewise be
37
+ # deleted from the database, but any callbacks associated with version destruction will be
38
+ # triggered. If <tt>:nullify</tt> is specified, the version records will simply be
39
+ # dissociated from the versioned parent record by setting its foreign key to nil.
40
+ def version_reset_method
41
+ vestal_versions_options[:dependent].to_s.sub(/_all$/, '').to_sym
42
+ end
43
+ end
44
+
45
+ # Instance methods added to the VestalVersions::Version model to accomodate resetting the
46
+ # parent ActiveRecord::Base instance.
47
+ module VersionMethods
48
+ # The +nullify+ method is meant to mimic the behavior of ActiveRecord when the parent of a
49
+ # +has_many+ association (with <tt>:dependent => :nullify</tt>) is destroyed and the child
50
+ # records are dissociated from the parent's primary key.
51
+ def nullify
52
+ update_attribute(:versioned_id, nil)
53
+ end
54
+ end
55
+ end
56
+ 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
+ # Order versions by number, ascending by default.
10
+ default_scope :order => "#{table_name}.number ASC"
11
+
12
+ # ActiveRecord::Base#changes is an existing method, so before serializing the +changes+ column,
13
+ # the existing +changes+ method is undefined. The overridden +changes+ method pertained to
14
+ # dirty attributes, but will not affect the partial updates functionality as that's based on
15
+ # an underlying +changed_attributes+ method, not +changes+ itself.
16
+ undef_method :changes
17
+ serialize :changes, 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 at(value).try(:number) || 1
68
+ when Numeric then value.floor
69
+ when String, Symbol then at(value).try(:number)
70
+ when Version then value.number
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1 @@
1
+ require File.join(File.dirname(__FILE__), '..', 'lib', 'vestal_versions')