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 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