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
data/.gitignore
CHANGED
data/README.markdown
ADDED
@@ -0,0 +1,121 @@
|
|
1
|
+
# Historical: Versioning and Auditing
|
2
|
+
|
3
|
+
There are several plugins available for versioning (e.g. `acts_as_versioned`, `simply_versioned`, `vestal_versions`). Since they try to solve versioning using a relational database they require that you setup table for each model being versioned, clutter your main table or serialize your data into a single `TEXT` or `BLOB` field.
|
4
|
+
|
5
|
+
Historical doesn't need to look for workarounds since it uses MongoDB as the backend, a document-database which does not require a fixed schema or table structure.
|
6
|
+
|
7
|
+
|
8
|
+
## Rails Version
|
9
|
+
|
10
|
+
Developed with/for Rails 3.0, installable using Bundler.
|
11
|
+
|
12
|
+
|
13
|
+
## Usage Example
|
14
|
+
|
15
|
+
# models/message.rb
|
16
|
+
|
17
|
+
class Message < ActiveRecord::Base
|
18
|
+
# string :title
|
19
|
+
# text :body
|
20
|
+
# datetime :published_at
|
21
|
+
# integer :author_id
|
22
|
+
|
23
|
+
# This is unnecessary if you use Rails (will be installed by default on boot)
|
24
|
+
extend Historical::ActiveRecord
|
25
|
+
|
26
|
+
is_historical
|
27
|
+
end
|
28
|
+
|
29
|
+
# app.rb
|
30
|
+
|
31
|
+
m = Message.create(:title => "foo", :author_id => 1)
|
32
|
+
m.author = Person.find(2)
|
33
|
+
m.title = "bar"
|
34
|
+
m.save!
|
35
|
+
|
36
|
+
versions = m.history.versions.all
|
37
|
+
|
38
|
+
# get old values
|
39
|
+
versions[0].title #=> "foo"
|
40
|
+
versions[0].author_id #=> 1
|
41
|
+
|
42
|
+
# access an old relation
|
43
|
+
old = versions[0].restore #=> <#Message>
|
44
|
+
old.author #=> User(id:1)
|
45
|
+
|
46
|
+
# what changed?
|
47
|
+
versions[1].diff.to_hash #=> { :author_id => [1, 2], :title => ["foo", "bar"] }
|
48
|
+
versions[1].diff.changes #=> [<#AttributeDiff>, <#AttributeDiff>]
|
49
|
+
versions[1].meta.created_at #=> 2010-01-23 18:56:52 (date when model was saved)
|
50
|
+
|
51
|
+
|
52
|
+
## Audits (and other Meta-Data)
|
53
|
+
|
54
|
+
As you have seen above each version contains a meta-object. You can write custom data to that meta object.
|
55
|
+
|
56
|
+
# YourApp.current_user could be set by a before_filter
|
57
|
+
|
58
|
+
class AuditedMessage < ActiveRecord::Base
|
59
|
+
|
60
|
+
# This is unnecessary if you use Rails (will be installed by default on boot)
|
61
|
+
extend Historical::ActiveRecord
|
62
|
+
|
63
|
+
is_historical do
|
64
|
+
|
65
|
+
meta do
|
66
|
+
# extend that object with MongoMapper helpers
|
67
|
+
key :reason, String
|
68
|
+
|
69
|
+
belongs_to_active_record :author, :required => true, :class_name => "Person"
|
70
|
+
end
|
71
|
+
|
72
|
+
callback do |version|
|
73
|
+
version.meta.author = YourApp.current_user
|
74
|
+
version.meta.reason = "some reason"
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
Historical::Models::ModelVersion.where(:"meta.author_id" => 1).all
|
80
|
+
|
81
|
+
### `belongs_to_active_record`
|
82
|
+
|
83
|
+
The MongoMapper extension `belongs_to_active_record` creates `belongs_to` (also polymorphic) relations and will
|
84
|
+
handle key generation.
|
85
|
+
|
86
|
+
|
87
|
+
## History Model (Quick Overview)
|
88
|
+
|
89
|
+
When calling `model.history` you will get a object that contains several methods to operate with the history of a model.
|
90
|
+
|
91
|
+
- `model.history.versions` will return a PQ containing all versions for that model. It's sorted ascending by creation date.
|
92
|
+
- `model.history.previous_version`, `model.history.next_version`, `model.history.latest_version`, `model.history.original_version` - you get it.
|
93
|
+
- `model.history.find_version(2)` like in `model.history.versions.all[2]` only handled by the database (less db-app traffic).
|
94
|
+
|
95
|
+
### UseCase for `history.next_version`
|
96
|
+
|
97
|
+
old_message = message.history.original_version.restore
|
98
|
+
not_so_old_message = old_message.history.next_version.restore
|
99
|
+
|
100
|
+
### Note: Plucky Query (PQ)
|
101
|
+
|
102
|
+
MongoMapper - which is used by Historical - uses Plucky, a query generator. To perform the query you must call `.all` on it (similar to ActiveRecord).
|
103
|
+
|
104
|
+
|
105
|
+
## Interaction with 3rd Party Plugins
|
106
|
+
|
107
|
+
Historical won't prevent your model from being destroyed. Should your model be destroyed all versions
|
108
|
+
will be destroyed as well. However you might consider to use [`is_paranoid`](http://github.com/semanticart/is_paranoid)
|
109
|
+
by [semanticart](http://github.com/semanticart) for that. Historical will then detect updates on the `deleted_at` column
|
110
|
+
and store a new version.
|
111
|
+
|
112
|
+
**Note:** `is_paranoid` was discontinued by **semanticart** in October 2009. I recommend to
|
113
|
+
[read about the whys](http://blog.semanticart.com/killing_is_paranoid/). These are also the reasons
|
114
|
+
why such feature isn't implemented in Historical by itself. You might want to use the
|
115
|
+
[less hacky `is_paranoid`](http://github.com/mislav/is_paranoid) by [mislav](http://github.com/mislav),
|
116
|
+
who developed `will_paginate`.
|
117
|
+
|
118
|
+
|
119
|
+
## Intellectual Property
|
120
|
+
|
121
|
+
Copyright (c) 2010 Marcel Jackwerth (marcel@northdocks.com). Released under the MIT licence.
|
data/Rakefile
CHANGED
@@ -34,12 +34,31 @@ task :spec => :check_dependencies
|
|
34
34
|
|
35
35
|
task :default => :spec
|
36
36
|
|
37
|
-
require '
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
37
|
+
require 'yard'
|
38
|
+
class YARD::Handlers::Ruby::Legacy::MongoHandler < YARD::Handlers::Ruby::Legacy::Base
|
39
|
+
handles /^(one|many|key)/
|
40
|
+
|
41
|
+
def process
|
42
|
+
match = statement.tokens.to_s.match(/^(one|many|key)\s+:([a-zA-Z0-9_]+)/)
|
43
|
+
return unless match
|
44
|
+
|
45
|
+
type, method = match[1], match[2]
|
46
|
+
|
47
|
+
case type
|
48
|
+
when "many"
|
49
|
+
register(MethodObject.new(namespace, method) do |obj|
|
50
|
+
if obj.tag(:return) && (obj.tag(:return).types || []).empty?
|
51
|
+
obj.tag(:return).types = ['Array']
|
52
|
+
elsif obj.tag(:return).nil?
|
53
|
+
obj.docstring.add_tag(YARD::Tags::Tag.new(:return, "", "Array"))
|
54
|
+
end
|
55
|
+
end)
|
56
|
+
else
|
57
|
+
register MethodObject.new(namespace, method)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
YARD::Rake::YardocTask.new do |t|
|
63
|
+
t.files = FileList['lib/**/*.rb']
|
45
64
|
end
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.2.
|
1
|
+
0.2.2
|
data/historical.gemspec
CHANGED
@@ -5,30 +5,33 @@
|
|
5
5
|
|
6
6
|
Gem::Specification.new do |s|
|
7
7
|
s.name = %q{historical}
|
8
|
-
s.version = "0.2.
|
8
|
+
s.version = "0.2.2"
|
9
9
|
|
10
10
|
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
11
|
s.authors = ["Marcel Jackwerth"]
|
12
|
-
s.date = %q{2010-08
|
12
|
+
s.date = %q{2010-10-08}
|
13
13
|
s.description = %q{Rewrite of the original historical-plugin using MongoDB}
|
14
14
|
s.email = %q{marcel@northdocks.com}
|
15
15
|
s.extra_rdoc_files = [
|
16
16
|
"LICENSE",
|
17
|
-
"README.
|
17
|
+
"README.markdown"
|
18
18
|
]
|
19
19
|
s.files = [
|
20
20
|
".gitignore",
|
21
21
|
"LICENSE",
|
22
|
-
"README.
|
22
|
+
"README.markdown",
|
23
23
|
"Rakefile",
|
24
24
|
"VERSION",
|
25
25
|
"historical.gemspec",
|
26
26
|
"lib/historical.rb",
|
27
27
|
"lib/historical/active_record.rb",
|
28
|
+
"lib/historical/class_builder.rb",
|
28
29
|
"lib/historical/model_history.rb",
|
29
30
|
"lib/historical/models/attribute_diff.rb",
|
30
|
-
"lib/historical/models/model_diff.rb",
|
31
31
|
"lib/historical/models/model_version.rb",
|
32
|
+
"lib/historical/models/model_version/diff.rb",
|
33
|
+
"lib/historical/models/model_version/meta.rb",
|
34
|
+
"lib/historical/models/pool.rb",
|
32
35
|
"lib/historical/mongo_mapper_enhancements.rb",
|
33
36
|
"lib/historical/railtie.rb",
|
34
37
|
"rails/init.rb",
|
data/lib/historical.rb
CHANGED
@@ -1,35 +1,50 @@
|
|
1
|
-
require 'historical/railtie'
|
1
|
+
require 'historical/railtie'
|
2
|
+
require 'active_support'
|
2
3
|
|
4
|
+
# Main module for the Historical gem
|
3
5
|
module Historical
|
4
|
-
|
5
|
-
|
6
|
-
autoload :
|
7
|
-
autoload :
|
8
|
-
autoload :MongoMapperEnhancements, "historical/mongo_mapper_enhancements"
|
6
|
+
autoload :ModelHistory, 'historical/model_history'
|
7
|
+
autoload :ActiveRecord, 'historical/active_record'
|
8
|
+
autoload :ClassBuilder, 'historical/class_builder'
|
9
|
+
autoload :MongoMapperEnhancements, 'historical/mongo_mapper_enhancements'
|
9
10
|
|
11
|
+
# MongoDB models used by Historical are stored here
|
10
12
|
module Models
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
13
|
+
autoload :Pool, 'historical/models/pool'
|
14
|
+
autoload :AttributeDiff, 'historical/models/attribute_diff'
|
15
|
+
autoload :ModelVersion, 'historical/models/model_version'
|
16
|
+
end
|
17
|
+
|
18
|
+
IGNORED_ATTRIBUTES = [:id]
|
19
|
+
|
20
|
+
@@historical_models = []
|
21
|
+
@@booted = false
|
22
|
+
|
23
|
+
mattr_reader :historical_models
|
24
|
+
def self.booted?; @@booted; end
|
25
|
+
|
26
|
+
# Generates all customized models.
|
27
|
+
def self.boot!
|
28
|
+
return if booted?
|
29
|
+
|
30
|
+
Historical::Models::Pool.clear!
|
31
|
+
Historical::Models::AttributeDiff.generate_subclasses!
|
32
|
+
|
33
|
+
historical_models.each do |model|
|
34
|
+
model.generate_historical_models!
|
35
|
+
end
|
36
|
+
|
37
|
+
@@booted = true
|
38
|
+
@@historical_models = ImmediateLoader.new
|
39
|
+
end
|
40
|
+
|
41
|
+
class ImmediateLoader
|
42
|
+
def <<(model)
|
43
|
+
model.generate_historical_models!
|
29
44
|
end
|
30
45
|
|
31
|
-
|
32
|
-
|
33
|
-
|
46
|
+
def each
|
47
|
+
false
|
48
|
+
end
|
34
49
|
end
|
35
50
|
end
|
@@ -1,5 +1,6 @@
|
|
1
1
|
module Historical
|
2
2
|
module ActiveRecord
|
3
|
+
# converts database fieldtypes to Ruby types
|
3
4
|
def self.sql_to_type(type)
|
4
5
|
case type.to_sym
|
5
6
|
when :datetime then "Time"
|
@@ -11,57 +12,136 @@ module Historical
|
|
11
12
|
end
|
12
13
|
end
|
13
14
|
|
14
|
-
|
15
|
-
|
16
|
-
|
15
|
+
# Extensions for a model that is flagged as `is_historical`.
|
16
|
+
module Extensions
|
17
|
+
extend ActiveSupport::Concern
|
18
|
+
|
19
|
+
included do
|
20
|
+
cattr_accessor :historical_customizations, :historical_callbacks, :historical_installed
|
21
|
+
cattr_accessor :historical_meta_class, :historical_version_class, :historical_diff_class
|
22
|
+
|
17
23
|
attr_accessor :historical_differences, :historical_creation, :historical_version
|
18
24
|
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
25
|
+
before_save :detect_version_spawn
|
26
|
+
after_save :invalidate_history!
|
27
|
+
after_save :spawn_version, :if => :spawn_version?
|
28
|
+
|
29
|
+
attr_writer :history
|
30
|
+
end
|
31
|
+
|
32
|
+
module ClassMethods
|
33
|
+
# Generates the customized classes ({Models::ModelVersion}, {Models::ModelVersion::Meta}, {Models::ModelVersion::Diff}) for this model.
|
34
|
+
def generate_historical_models!
|
35
|
+
builder = Historical::ClassBuilder.new(self)
|
36
|
+
|
37
|
+
self.historical_callbacks ||= []
|
38
|
+
self.historical_callbacks += builder.callbacks
|
39
|
+
|
40
|
+
self.historical_version_class = builder.version_class
|
41
|
+
self.historical_meta_class = builder.meta_class
|
42
|
+
self.historical_diff_class = builder.diff_class
|
23
43
|
end
|
44
|
+
end
|
45
|
+
|
46
|
+
module InstanceMethods
|
47
|
+
# @return [ModelHistory] The history of this model
|
48
|
+
def history
|
49
|
+
@history ||= Historical::ModelHistory.new(self)
|
50
|
+
end
|
51
|
+
|
52
|
+
# @return [Integer] The version number of this model
|
53
|
+
def version
|
54
|
+
historical_version || history.own_version.version_index
|
55
|
+
end
|
56
|
+
|
57
|
+
# Invalidates the history of that model
|
58
|
+
# @private
|
59
|
+
def invalidate_history!
|
60
|
+
@history = nil
|
61
|
+
end
|
62
|
+
|
63
|
+
protected
|
24
64
|
|
25
|
-
#
|
26
|
-
|
27
|
-
|
65
|
+
# Set some flags before saving the model (so that after_save-callbacks can be run)
|
66
|
+
# @private
|
67
|
+
def detect_version_spawn
|
68
|
+
if new_record?
|
69
|
+
self.historical_creation = true
|
70
|
+
self.historical_differences = []
|
71
|
+
else
|
72
|
+
self.historical_creation = false
|
73
|
+
self.historical_differences = (changes.empty? ? nil : changes)
|
74
|
+
end
|
75
|
+
|
28
76
|
true
|
29
77
|
end
|
30
78
|
|
31
|
-
|
32
|
-
|
79
|
+
# Check whether a new version should be spawned (also see {detect_version_spawn})
|
80
|
+
# @private
|
81
|
+
def spawn_version?
|
82
|
+
historical_creation or historical_differences
|
83
|
+
end
|
84
|
+
|
85
|
+
# Spawns a new version
|
86
|
+
# @private
|
87
|
+
def spawn_version(mode = :update)
|
88
|
+
mode = :create if historical_creation
|
33
89
|
|
34
|
-
Historical::Models::ModelVersion.for_class(
|
35
|
-
v._record_id =
|
36
|
-
v._record_type =
|
90
|
+
Historical::Models::ModelVersion.for_class(self.class).new.tap do |v|
|
91
|
+
v._record_id = id
|
92
|
+
v._record_type = self.class.name
|
93
|
+
|
37
94
|
|
38
|
-
|
95
|
+
attribute_names.each do |attr_name|
|
39
96
|
attr = attr_name.to_sym
|
40
97
|
next if Historical::IGNORED_ATTRIBUTES.include? attr
|
41
|
-
v.send("#{attr}=",
|
98
|
+
v.send("#{attr}=", self[attr])
|
99
|
+
end
|
100
|
+
|
101
|
+
previous = (mode != :create ? v.previous : nil)
|
102
|
+
|
103
|
+
v.diff = Historical::Models::ModelVersion::Diff.from_versions(previous, v)
|
104
|
+
v.meta = self.class.historical_meta_class.new
|
105
|
+
|
106
|
+
(self.class.historical_callbacks || []).each do |callback|
|
107
|
+
callback.call(v)
|
42
108
|
end
|
43
109
|
|
44
|
-
v.diff = Historical::Models::ModelDiff.from_versions(v.previous, v)
|
45
110
|
v.save!
|
46
111
|
end
|
47
112
|
end
|
48
|
-
|
49
|
-
def history
|
50
|
-
@history ||= Historical::ModelHistory.new(self)
|
51
|
-
end
|
52
|
-
|
53
|
-
def version
|
54
|
-
historical_version || history.own_version.version_index
|
55
|
-
end
|
56
113
|
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# Enables Historical in this model.
|
117
|
+
#
|
118
|
+
# @example simple
|
119
|
+
# class Message < ActiveRecord::Base
|
120
|
+
# is_historical
|
121
|
+
# end
|
122
|
+
#
|
123
|
+
# @example advanced, with custom meta-data (auditing)
|
124
|
+
# class Message < ActiveRecord::Base
|
125
|
+
# is_historical do
|
126
|
+
# meta do
|
127
|
+
# belongs_to_active_record :user
|
128
|
+
# key :cause, String
|
129
|
+
# end
|
130
|
+
#
|
131
|
+
# callback do |version|
|
132
|
+
# version.meta.cause = "just because"
|
133
|
+
# version.meta.user = App.current_user
|
134
|
+
# end
|
135
|
+
# end
|
136
|
+
# end
|
137
|
+
def is_historical(&block)
|
138
|
+
include Historical::ActiveRecord::Extensions
|
57
139
|
|
58
140
|
self.historical_installed = true
|
59
141
|
self.historical_customizations ||= []
|
60
142
|
self.historical_customizations << block if block_given?
|
61
143
|
|
62
|
-
|
63
|
-
Historical::Models::ModelDiff.for_class(self)
|
64
|
-
Historical::Models::ModelVersion.for_class(self)
|
144
|
+
Historical.historical_models << self
|
65
145
|
end
|
66
146
|
end
|
67
147
|
end
|