historical 0.2.1 → 0.2.2
Sign up to get free protection for your applications and to get access to all the features.
- 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
|