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.
- data/.gitignore +3 -1
- data/README.markdown +121 -0
- data/Rakefile +27 -8
- data/VERSION +1 -1
- data/historical.gemspec +8 -5
- data/lib/historical.rb +42 -27
- data/lib/historical/active_record.rb +109 -29
- data/lib/historical/class_builder.rb +60 -0
- data/lib/historical/model_history.rb +39 -33
- data/lib/historical/models/attribute_diff.rb +53 -23
- data/lib/historical/models/model_version.rb +74 -6
- data/lib/historical/models/model_version/diff.rb +90 -0
- data/lib/historical/models/model_version/meta.rb +19 -0
- data/lib/historical/models/pool.rb +31 -0
- data/lib/historical/mongo_mapper_enhancements.rb +11 -0
- data/lib/historical/railtie.rb +4 -2
- data/spec/historical_spec.rb +49 -15
- data/spec/spec_helper.rb +9 -2
- metadata +10 -7
- data/README.rdoc +0 -17
- data/lib/historical/models/model_diff.rb +0 -90
@@ -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
|
-
|
8
|
-
|
9
|
-
|
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, :
|
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
|
-
|
18
|
-
|
19
|
-
|
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(
|
61
|
-
version = version_by_query(
|
62
|
-
|
63
|
-
|
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
|
-
|
81
|
-
|
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
|
-
|
89
|
+
protected
|
87
90
|
|
88
|
-
|
89
|
-
|
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 =
|
44
|
+
self._old_value = value
|
37
45
|
end
|
38
46
|
|
39
47
|
def new_value=(value)
|
40
|
-
self._new_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
|
-
|
56
|
-
|
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
|
-
|
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
|
-
|
13
|
-
|
14
|
-
one :
|
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
|