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 CHANGED
@@ -2,4 +2,6 @@
2
2
  *.db
3
3
  *.log
4
4
  previous_failures.txt
5
- pkg
5
+ /pkg
6
+ /.yardoc
7
+ /doc
@@ -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 'rake/rdoctask'
38
- Rake::RDocTask.new do |rdoc|
39
- version = File.exist?('VERSION') ? File.read('VERSION') : ""
40
-
41
- rdoc.rdoc_dir = 'rdoc'
42
- rdoc.title = "historical #{version}"
43
- rdoc.rdoc_files.include('README*')
44
- rdoc.rdoc_files.include('lib/**/*.rb')
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
1
+ 0.2.2
@@ -5,30 +5,33 @@
5
5
 
6
6
  Gem::Specification.new do |s|
7
7
  s.name = %q{historical}
8
- s.version = "0.2.1"
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-26}
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.rdoc"
17
+ "README.markdown"
18
18
  ]
19
19
  s.files = [
20
20
  ".gitignore",
21
21
  "LICENSE",
22
- "README.rdoc",
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",
@@ -1,35 +1,50 @@
1
- require 'historical/railtie' if defined?(Rails::Railtie)
1
+ require 'historical/railtie'
2
+ require 'active_support'
2
3
 
4
+ # Main module for the Historical gem
3
5
  module Historical
4
- IGNORED_ATTRIBUTES = [:id, :created_at, :updated_at]
5
-
6
- autoload :ModelHistory, "historical/model_history"
7
- autoload :ActiveRecord, "historical/active_record"
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
- module Pool
12
- # cached classes are stored here
13
-
14
- def self.pooled(name)
15
- @@class_pool ||= {}
16
- return @@class_pool[name] if @@class_pool[name]
17
-
18
- cls = yield
19
-
20
- Historical::Models::Pool::const_set(name, cls)
21
- @@class_pool[name] = cls
22
-
23
- cls
24
- end
25
-
26
- def self.pooled_name(specialized_for, parent)
27
- "#{specialized_for.name.demodulize}#{parent.name.demodulize}"
28
- end
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
- autoload :AttributeDiff, 'historical/models/attribute_diff'
32
- autoload :ModelDiff, 'historical/models/model_diff'
33
- autoload :ModelVersion, 'historical/models/model_version'
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
- def is_historical(&block)
15
- class_eval do
16
- cattr_accessor :historical_customizations, :historical_installed
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
- # dirty attributes a removed after save, so we need to check it here
20
- before_update do |record|
21
- record.historical_differences = !record.changes.empty?
22
- true
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
- # new_record flag is removed after save, so we need to check it here
26
- before_save do |record|
27
- record.historical_creation = record.new_record?
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
- after_save do |record|
32
- next unless record.historical_creation or record.historical_differences
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(record.class).new.tap do |v|
35
- v._record_id = record.id
36
- v._record_type = record.class.name
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
- record.attribute_names.each do |attr_name|
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}=", record[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
- # generate pooled classes
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