historical 0.2.1 → 0.2.2

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.
@@ -0,0 +1,60 @@
1
+ module Historical
2
+ # Builds the customized classes for a record.
3
+ class ClassBuilder
4
+ # @return [Array<Proc>] A list of callbacks to be evaluated on save.
5
+ # @private
6
+ attr_accessor :callbacks
7
+
8
+ # @return [Class] A customized subclass of {Models::ModelVersion::Meta}
9
+ # @private
10
+ attr_accessor :meta_class
11
+
12
+ # @return [Class] A customized subclass of {Models::ModelVersion::Diff}
13
+ # @private
14
+ attr_accessor :diff_class
15
+
16
+ # @return [Class] A customized subclass of {Models::ModelVersion}
17
+ # @private
18
+ attr_accessor :version_class
19
+
20
+ # @param base The record on which the customized classes should be based on
21
+ def initialize(base)
22
+ self.callbacks = []
23
+
24
+ self.version_class = Historical::Models::ModelVersion.for_class(base)
25
+ self.diff_class = Historical::Models::ModelVersion::Diff.for_class(base)
26
+ self.meta_class = Historical::Models::ModelVersion::Meta.for_class(base)
27
+
28
+ base.historical_customizations.each do |customization|
29
+ instance_eval(&customization)
30
+ end
31
+ end
32
+
33
+ # @group Builder Methods
34
+
35
+ # Evaluated within class scope of the custom {Models::ModelVersion::Meta} for this record.
36
+ # The Meta-class includes `MongoMapper::EmbeddedDocument` and {MongoMapperEnhancements}.
37
+ # @example Usage within is_historical
38
+ # is_historical do
39
+ # meta do
40
+ # key :some_key, String
41
+ # end
42
+ # end
43
+ def meta(&block)
44
+ meta_class.instance_eval(&block)
45
+ end
46
+
47
+ # A callback to be called when a new version is spawned (i.e. model was saved with changes)
48
+ # @yield [version] Your callback
49
+ # @yieldparam [Models::ModelVersion] version The new version to be saved
50
+ # @example Usage within is_historical
51
+ # is_historical do
52
+ # callback do |version|
53
+ # version.meta.some_key = "foo"
54
+ # end
55
+ # end
56
+ def callback(&block)
57
+ self.callbacks << block
58
+ end
59
+ end
60
+ end
@@ -1,22 +1,41 @@
1
1
  module Historical
2
2
  class ModelHistory
3
- attr_reader :record
3
+ attr_reader :record, :base_version
4
4
 
5
5
  def initialize(record)
6
6
  @record = record
7
- @base_version = record.historical_version
8
- @base_version ||= -1 if record.new_record?
9
- @base_version ||= versions.count - 1
7
+
8
+ if record.historical_version
9
+ @base_version = record.historical_version
10
+ elsif record.new_record?
11
+ @base_version = -1
12
+ else
13
+ version_count = versions.count
14
+
15
+ if version_count.zero?
16
+ spawn_creation!
17
+ version_count = 1
18
+ end
19
+
20
+ @base_version = version_count - 1
21
+ end
22
+ end
23
+
24
+ def destroy
25
+ versions.remove
26
+ record.invalidate_history! if record.history == self
10
27
  end
11
28
 
12
29
  def versions
13
- Models::ModelVersion.for_record(record).sort(:created_at.asc, :id.asc)
30
+ Models::ModelVersion.for_record(record).sort(:"meta.created_at".asc, :_id.asc)
14
31
  end
15
32
 
33
+ delegate :version_index, :to => :own_version
34
+
16
35
  def own_version
17
- own = versions.skip(@base_version).limit(1).first
18
- raise "couldn't find myself (base_version: #{@base_version}, versions: #{versions.count})" unless own
19
- own
36
+ versions.skip(@base_version).limit(1).first.tap do |own|
37
+ raise "couldn't find myself (base_version: #{@base_version}, versions: #{versions.count})" unless own
38
+ end
20
39
  end
21
40
 
22
41
  def previous_version
@@ -57,36 +76,23 @@ module Historical
57
76
  end
58
77
  end
59
78
 
60
- def restore(version_query)
61
- version = version_by_query(version_query)
62
-
63
- raise ::ActiveRecord::RecordNotFound, "version does not exist" unless version
64
-
65
- record.clone.tap do |r|
66
- r.id = record.id
67
-
68
- r.class.columns.each do |c|
69
- attr = c.name.to_sym
70
- next if Historical::IGNORED_ATTRIBUTES.include? attr
71
-
72
- r[attr] = version.send(attr)
73
- end
74
-
75
- r.historical_version = version.version_index
76
- r.clear_association_cache
77
- end
79
+ def restore(query)
80
+ version = version_by_query(query)
81
+ raise ::ActiveRecord::RecordNotFound, "version (base_version: #{base_version}, query: #{query}) does not exist" unless version
82
+ version.restore(record)
78
83
  end
79
84
 
80
- def restore_with_protection(*args)
81
- restore_without_protection(*args).tap do |r|
82
- r.readonly!
83
- end
85
+ %w(original latest previous next).each do |k|
86
+ alias_method k, "#{k}_version"
84
87
  end
85
88
 
86
- alias_method_chain :restore, :protection
89
+ protected
87
90
 
88
- %w(original latest previous next).each do |k|
89
- alias_method k, "#{k}_version"
91
+ def spawn_creation!
92
+ record.send(:spawn_version, :create).tap do |e|
93
+ e.created_at = record.created_at
94
+ e.save!
95
+ end
90
96
  end
91
97
  end
92
98
  end
@@ -1,16 +1,31 @@
1
1
  module Historical::Models
2
+ # The diff of an attribute (specialized by a type qualifier)
2
3
  class AttributeDiff
4
+ # MongoMappers supported native Ruby types
5
+ SUPPORTED_NATIVE_RUBY_TYPES = %w{Date String Time Boolean Integer Float Binary}
6
+
3
7
  include MongoMapper::EmbeddedDocument
4
8
  extend Historical::MongoMapperEnhancements
5
9
 
10
+ # class identifier
11
+ # @private
6
12
  key :_type, String
7
13
 
14
+ # @return [String] The attribute name
8
15
  key :attribute, String, :required => true
16
+
17
+ # @return [String] The attribute type (string, integer, float)
9
18
  key :attribute_type, String, :required => true
10
19
 
20
+ # @return The old value
21
+ # @private
11
22
  key :_old_value, Object
23
+
24
+ # @return The new value
25
+ # @private
12
26
  key :_new_value, Object
13
27
 
28
+ # @return [ModelVersion::Diff] The parent diff model.
14
29
  attr_accessor :parent
15
30
 
16
31
  alias_method :old_value, :_old_value
@@ -18,28 +33,22 @@ module Historical::Models
18
33
 
19
34
  protected :_old_value=, :_new_value=, :_old_value, :_new_value
20
35
 
36
+ # @return [Class] The specialized {AttributeDiff} class for a attribute type
37
+ # @private
21
38
  def self.specialized_for(parent, attribute)
22
39
  type = detect_attribute_type(parent, attribute)
23
40
  Historical::Models.const_get(specialized_class_name(type))
24
41
  end
25
-
26
- def self.specialized_class_name(type)
27
- ruby_type = Historical::ActiveRecord.sql_to_type(type)
28
- "#{ruby_type}#{simple_name}"
29
- end
30
-
31
- def self.simple_name
32
- name.to_s.demodulize
33
- end
34
42
 
35
43
  def old_value=(value)
36
- self._old_value = cast_value(value)
44
+ self._old_value = value
37
45
  end
38
46
 
39
47
  def new_value=(value)
40
- self._new_value = cast_value(value)
48
+ self._new_value = value
41
49
  end
42
50
 
51
+ # Get attribute type for an attribute
43
52
  def self.detect_attribute_type(parent, attribute)
44
53
  model_class = parent.record.class
45
54
 
@@ -50,21 +59,42 @@ module Historical::Models
50
59
  column ? column.type.to_s : nil
51
60
  end
52
61
 
62
+ # Generates subclasses of {AttributeDiff} for each type in {SUPPORTED_NATIVE_RUBY_TYPES}
63
+ # @private
64
+ def self.generate_subclasses!
65
+ SUPPORTED_NATIVE_RUBY_TYPES.each do |type|
66
+ diff_class = Class.new(self)
67
+ type_class = type.constantize
68
+
69
+ diff_class.send :key, :_old_value, type_class
70
+ diff_class.send :key, :_new_value, type_class
71
+
72
+ const_name = specialized_class_name(type_class)
73
+ namespace = Historical::Models
74
+
75
+ namespace.send(:remove_const, const_name) if namespace.const_defined?(const_name)
76
+ namespace.const_set(const_name, diff_class)
77
+ end
78
+ end
79
+
53
80
  protected
54
81
 
55
- def cast_value(value)
56
- value
82
+ # A unique subclass name for the type diffs.
83
+ # @private
84
+ def self.specialized_class_name(type)
85
+ ruby_type = case type
86
+ when Class
87
+ type.name
88
+ else
89
+ Historical::ActiveRecord.sql_to_type(type)
90
+ end
91
+ "#{ruby_type}#{simple_name}"
57
92
  end
58
- end
59
-
60
- %w{Date String Time Boolean Integer Float Binary}.each do |type|
61
- superclass = AttributeDiff
62
- diff_class = Class.new(superclass)
63
- type_class = type.constantize
64
-
65
- diff_class.send :key, :_old_value, type_class
66
- diff_class.send :key, :_new_value, type_class
67
93
 
68
- self.const_set "#{type}#{superclass.simple_name}", diff_class
94
+ # @return [String] Pure classname without modules
95
+ # @private
96
+ def self.simple_name
97
+ name.to_s.demodulize
98
+ end
69
99
  end
70
100
  end
@@ -1,44 +1,95 @@
1
- module Historical::Models
1
+ module Historical::Models
2
+ # A complete snapshot of a model.
2
3
  class ModelVersion
4
+ autoload :Diff, 'historical/models/model_version/diff'
5
+ autoload :Meta, 'historical/models/model_version/meta'
6
+
3
7
  include MongoMapper::Document
4
8
  extend Historical::MongoMapperEnhancements
5
9
 
6
10
  validate :validate_diff
11
+ validate :validate_meta
12
+
13
+ validates_presence_of :meta
7
14
 
8
15
  key :_type, String
9
16
 
10
17
  belongs_to_active_record :_record, :polymorphic => true, :required => true
18
+
19
+ # The diff between the current and the previous version (if exists)
20
+ # @return [Diff]
21
+ one :diff, :class_name => "Historical::Models::ModelVersion::Diff"
11
22
 
12
- timestamps!
13
-
14
- one :diff, :class_name => "Historical::Models::ModelDiff"
23
+ # The meta-data associated with the diff (i.e. this version)
24
+ # @return [Meta]
25
+ one :meta, :class_name => "Historical::Models::ModelVersion::Meta"
26
+
27
+ before_save :update_timestamps
15
28
 
16
29
  alias_method :record, :_record
17
30
 
31
+ # All other versions of the associated record
18
32
  def siblings
19
33
  self.class.for_record(_record_id, _record_type)
20
34
  end
21
35
 
36
+ # All the previous versions (immediate predecessor first)
22
37
  def previous_versions
23
- (new? ? siblings : siblings.where(:created_at.lte => created_at, :_id.lt => _id)).sort(:created_at.desc)
38
+ (new? ? siblings : siblings.where(:"meta.created_at".lte => created_at, :_id.lt => _id)).sort(:"meta.created_at".desc, :_id.desc)
24
39
  end
25
40
 
41
+ # The immediate predecessor version
26
42
  def previous
27
43
  previous_versions.first
28
44
  end
29
45
 
46
+ # All the next versions (immediate successor first)
30
47
  def next_versions
31
- siblings.where(:created_at.gte => created_at, :_id.gt => _id).sort(:created_at.asc)
48
+ siblings.where(:"meta.created_at".gte => created_at, :_id.gt => _id).sort(:"meta.created_at".asc, :_id.asc)
32
49
  end
33
50
 
51
+ # The immediate successor version
34
52
  def next
35
53
  next_versions.first
36
54
  end
37
55
 
56
+ # The current version index (zero-based)
38
57
  def version_index
39
58
  previous_versions.count
40
59
  end
41
60
 
61
+ # Restores the current version.
62
+ # @param base A reference to the record (to reduce DB queries)
63
+ def restore(base = nil)
64
+ base ||= record
65
+
66
+ base.clone.tap do |r|
67
+ r.class.columns.each do |c|
68
+ attr = c.name.to_sym
69
+ next if Historical::IGNORED_ATTRIBUTES.include? attr
70
+
71
+ r[attr] = send(attr)
72
+ end
73
+
74
+ r.id = base.id
75
+ r.historical_version = version_index
76
+ r.clear_association_cache
77
+ r.invalidate_history!
78
+ end
79
+ end
80
+
81
+ # Prevents the restored version from being saved or modifed.
82
+ def restore_with_protection(*args)
83
+ restore_without_protection(*args).tap do |r|
84
+ r.readonly!
85
+ end
86
+ end
87
+
88
+ alias_method_chain :restore, :protection
89
+
90
+ # Return all versions for the provided record
91
+ # @param record_or_id [Record,Integer] The id of the record (or a record - then type can be left nil)
92
+ # @param type [String] The type of the record
42
93
  def self.for_record(record_or_id, type = nil)
43
94
  if type
44
95
  ModelVersion.where(:_record_id => record_or_id, :_record_type => type)
@@ -47,6 +98,7 @@ module Historical::Models
47
98
  end
48
99
  end
49
100
 
101
+ # Retrieve customized class definition for a record class (e.g. TopicVersion, MessageVersion)
50
102
  def self.for_class(source_class)
51
103
  Historical::Models::Pool.pooled(Historical::Models::Pool.pooled_name(source_class, self)) do
52
104
  Class.new(self).tap do |cls|
@@ -61,10 +113,26 @@ module Historical::Models
61
113
 
62
114
  protected
63
115
 
116
+ # Sets the created_at timestamp in the meta model
117
+ def update_timestamps
118
+ if new? and !meta.created_at?
119
+ now = Time.now.utc
120
+ meta[:created_at] = now
121
+ end
122
+ end
123
+
124
+ # Cascading validation for `diff`
64
125
  def validate_diff
65
126
  if diff.present? and !diff.valid?
66
127
  errors.add(:diff, "not valid")
67
128
  end
68
129
  end
130
+
131
+ # Cascading validation for `meta`
132
+ def validate_meta
133
+ if meta.present? and !meta.valid?
134
+ errors.add(:meta, "not valid")
135
+ end
136
+ end
69
137
  end
70
138
  end
@@ -0,0 +1,90 @@
1
+ module Historical::Models
2
+ class ModelVersion
3
+ # Contains the differences between the current and the previous version.
4
+ class Diff
5
+ include MongoMapper::EmbeddedDocument
6
+ extend Historical::MongoMapperEnhancements
7
+
8
+ validates_associated :changes
9
+
10
+ key :_type, String
11
+ key :diff_type, String, :required => true
12
+
13
+ # @return [Array<Historical::Models::AttributeDiff>]
14
+ many :changes, :class_name => "Historical::Models::AttributeDiff"
15
+
16
+ delegate :creation?, :update?, :to => :diff_type_inquirer
17
+
18
+ # The record the diff was applied on
19
+ def record
20
+ new_version.record
21
+ end
22
+
23
+ # The version after the diff was applied
24
+ def new_version
25
+ @parent || _parent_document
26
+ end
27
+
28
+ # The version before the diff was applied
29
+ def old_version
30
+ new_version.previous
31
+ end
32
+
33
+ # Generates a diff from two versions.
34
+ # @private
35
+ def self.from_versions(from, to)
36
+ return from_creation(to) if !from
37
+
38
+ generate_from_version(from, 'update').tap do |d|
39
+ from.record.attribute_names.each do |attr_name|
40
+ attr = attr_name.to_sym
41
+ next if Historical::IGNORED_ATTRIBUTES.include? attr
42
+
43
+ old_value, new_value = from[attr], to[attr]
44
+
45
+ Historical::Models::AttributeDiff.specialized_for(d, attr).new.tap do |ad|
46
+ ad.attribute_type = Historical::Models::AttributeDiff.detect_attribute_type(d, attr)
47
+ ad.parent = d
48
+ ad.old_value = old_value
49
+ ad.new_value = new_value
50
+ ad.attribute = attr.to_s
51
+ d.changes << ad
52
+ end if old_value != new_value
53
+ end
54
+ end
55
+ end
56
+
57
+ # Generates a creation diff
58
+ # @private
59
+ def self.from_creation(to)
60
+ generate_from_version(to)
61
+ end
62
+
63
+ protected
64
+
65
+ # Helper to allow diff_type.create? and diff_type.update?
66
+ # @private
67
+ def diff_type_inquirer
68
+ ActiveSupport::StringInquirer.new(diff_type)
69
+ end
70
+
71
+ # Generates a basic Diff instance
72
+ # @private
73
+ def self.generate_from_version(version, type = 'creation')
74
+ for_class(version.record.class).new.tap do |d|
75
+ d.diff_type = type
76
+ d.instance_variable_set :@parent, version
77
+ end
78
+ end
79
+
80
+ # Retrieve customized class definition for a record class (e.g. TopicDiff, MessageDiff)
81
+ # @return [Class]
82
+ # @private
83
+ def self.for_class(source_class)
84
+ Historical::Models::Pool.pooled(Historical::Models::Pool.pooled_name(source_class, self)) do
85
+ Class.new(self)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end