historical 0.2.0

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.
@@ -0,0 +1,5 @@
1
+
2
+ *.db
3
+ *.log
4
+ previous_failures.txt
5
+ pkg
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Marcel Jackwerth
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,17 @@
1
+ = historical2
2
+
3
+ Description goes here.
4
+
5
+ == Note on Patches/Pull Requests
6
+
7
+ * Fork the project.
8
+ * Make your feature addition or bug fix.
9
+ * Add tests for it. This is important so I don't break it in a
10
+ future version unintentionally.
11
+ * Commit, do not mess with rakefile, version, or history.
12
+ (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
13
+ * Send me a pull request. Bonus points for topic branches.
14
+
15
+ == Copyright
16
+
17
+ Copyright (c) 2010 Marcel Jackwerth. See LICENSE for details.
@@ -0,0 +1,45 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "historical"
8
+ gem.summary = %Q{DRY and serialization-free versioning for ActiveRecord}
9
+ gem.description = %Q{Rewrite of the original historical-plugin using MongoDB}
10
+ gem.email = "marcel@northdocks.com"
11
+ gem.homepage = "http://github.com/sirlantis/historical"
12
+ gem.authors = ["Marcel Jackwerth"]
13
+ gem.add_development_dependency "rspec", ">= 1.2.9"
14
+ # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
15
+ end
16
+ Jeweler::GemcutterTasks.new
17
+ rescue LoadError
18
+ puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
19
+ end
20
+
21
+ require 'spec/rake/spectask'
22
+ Spec::Rake::SpecTask.new(:spec) do |spec|
23
+ spec.libs << 'lib' << 'spec'
24
+ spec.spec_files = FileList['spec/**/*_spec.rb']
25
+ end
26
+
27
+ Spec::Rake::SpecTask.new(:rcov) do |spec|
28
+ spec.libs << 'lib' << 'spec'
29
+ spec.pattern = 'spec/**/*_spec.rb'
30
+ spec.rcov = true
31
+ end
32
+
33
+ task :spec => :check_dependencies
34
+
35
+ task :default => :spec
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')
45
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.2.0
@@ -0,0 +1,61 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = %q{historical}
8
+ s.version = "0.2.0"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Marcel Jackwerth"]
12
+ s.date = %q{2010-08-16}
13
+ s.description = %q{Rewrite of the original historical-plugin using MongoDB}
14
+ s.email = %q{marcel@northdocks.com}
15
+ s.extra_rdoc_files = [
16
+ "LICENSE",
17
+ "README.rdoc"
18
+ ]
19
+ s.files = [
20
+ ".gitignore",
21
+ "LICENSE",
22
+ "README.rdoc",
23
+ "Rakefile",
24
+ "VERSION",
25
+ "historical.gemspec",
26
+ "lib/historical.rb",
27
+ "lib/historical/active_record.rb",
28
+ "lib/historical/model_history.rb",
29
+ "lib/historical/models/attribute_diff.rb",
30
+ "lib/historical/models/model_diff.rb",
31
+ "lib/historical/models/model_version.rb",
32
+ "lib/historical/mongo_mapper_enhancements.rb",
33
+ "rails/init.rb",
34
+ "spec/historical_spec.rb",
35
+ "spec/spec.opts",
36
+ "spec/spec_helper.rb"
37
+ ]
38
+ s.homepage = %q{http://github.com/sirlantis/historical}
39
+ s.rdoc_options = ["--charset=UTF-8"]
40
+ s.require_paths = ["lib"]
41
+ s.rubygems_version = %q{1.3.7}
42
+ s.summary = %q{DRY and serialization-free versioning for ActiveRecord}
43
+ s.test_files = [
44
+ "spec/historical_spec.rb",
45
+ "spec/spec_helper.rb"
46
+ ]
47
+
48
+ if s.respond_to? :specification_version then
49
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
50
+ s.specification_version = 3
51
+
52
+ if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
53
+ s.add_development_dependency(%q<rspec>, [">= 1.2.9"])
54
+ else
55
+ s.add_dependency(%q<rspec>, [">= 1.2.9"])
56
+ end
57
+ else
58
+ s.add_dependency(%q<rspec>, [">= 1.2.9"])
59
+ end
60
+ end
61
+
@@ -0,0 +1,33 @@
1
+ module Historical
2
+ IGNORED_ATTRIBUTES = [:id, :created_at, :updated_at]
3
+
4
+ autoload :ModelHistory, "historical/model_history"
5
+ autoload :ActiveRecord, "historical/active_record"
6
+ autoload :MongoMapperEnhancements, "historical/mongo_mapper_enhancements"
7
+
8
+ module Models
9
+ module Pool
10
+ # cached classes are stored here
11
+
12
+ def self.pooled(name)
13
+ @@class_pool ||= {}
14
+ return @@class_pool[name] if @@class_pool[name]
15
+
16
+ cls = yield
17
+
18
+ Historical::Models::Pool::const_set(name, cls)
19
+ @@class_pool[name] = cls
20
+
21
+ cls
22
+ end
23
+
24
+ def self.pooled_name(specialized_for, parent)
25
+ "#{specialized_for.name.demodulize}#{parent.name.demodulize}"
26
+ end
27
+ end
28
+
29
+ autoload :AttributeDiff, 'historical/models/attribute_diff'
30
+ autoload :ModelDiff, 'historical/models/model_diff'
31
+ autoload :ModelVersion, 'historical/models/model_version'
32
+ end
33
+ end
@@ -0,0 +1,67 @@
1
+ module Historical
2
+ module ActiveRecord
3
+ def self.sql_to_type(type)
4
+ case type.to_sym
5
+ when :datetime then "Time"
6
+ when :text then "String"
7
+ when :decimal then "Float"
8
+ when :timestamp then "Time"
9
+ else
10
+ type.to_s.classify
11
+ end
12
+ end
13
+
14
+ def is_historical(&block)
15
+ class_eval do
16
+ cattr_accessor :historical_customizations, :historical_installed
17
+ attr_accessor :historical_differences, :historical_creation, :historical_version
18
+
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
23
+ end
24
+
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?
28
+ true
29
+ end
30
+
31
+ after_save do |record|
32
+ next unless record.historical_creation or record.historical_differences
33
+
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
37
+
38
+ record.attribute_names.each do |attr_name|
39
+ attr = attr_name.to_sym
40
+ next if Historical::IGNORED_ATTRIBUTES.include? attr
41
+ v.send("#{attr}=", record[attr])
42
+ end
43
+
44
+ v.diff = Historical::Models::ModelDiff.from_versions(v.previous, v)
45
+ v.save!
46
+ end
47
+ 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
+ end
57
+
58
+ self.historical_installed = true
59
+ self.historical_customizations ||= []
60
+ self.historical_customizations << block if block_given?
61
+
62
+ # generate pooled classes
63
+ Historical::Models::ModelDiff.for_class(self)
64
+ Historical::Models::ModelVersion.for_class(self)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,92 @@
1
+ module Historical
2
+ class ModelHistory
3
+ attr_reader :record
4
+
5
+ def initialize(record)
6
+ @record = record
7
+ @base_version = record.historical_version
8
+ @base_version ||= -1 if record.new_record?
9
+ @base_version ||= versions.count - 1
10
+ end
11
+
12
+ def versions
13
+ Models::ModelVersion.for_record(record).sort(:created_at.asc, :id.asc)
14
+ end
15
+
16
+ def own_version
17
+ own = versions.skip(@base_version).limit(1).first
18
+ raise "couldn't find myself (base_version: #{@base_version}, versions: #{versions.count})" unless own
19
+ own
20
+ end
21
+
22
+ def previous_version
23
+ own_version.previous
24
+ end
25
+
26
+ def next_version
27
+ own_version.next
28
+ end
29
+
30
+ def latest_version
31
+ versions.last
32
+ end
33
+
34
+ def original_version
35
+ versions.first
36
+ end
37
+
38
+ def creation
39
+ versions.where("diff.diff_type" => "creation").first
40
+ end
41
+
42
+ def updates
43
+ versions.where("diff.diff_type" => "update")
44
+ end
45
+
46
+ def find_version(position)
47
+ versions.skip(position).limit(1).first
48
+ end
49
+
50
+ def version_by_query(query)
51
+ case query
52
+ when Numeric then find_version(query)
53
+ when Symbol then send(query)
54
+ when Models::ModelVersion then query
55
+ else
56
+ nil
57
+ end
58
+ end
59
+
60
+ def restore(version_query)
61
+ version = version_by_query(version_query)
62
+
63
+ raise ::ActiveRecord::RecordNotFound, "version does not exist" unless version
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
78
+ end
79
+
80
+ def restore_with_protection(*args)
81
+ restore_without_protection(*args).tap do |r|
82
+ r.readonly!
83
+ end
84
+ end
85
+
86
+ alias_method_chain :restore, :protection
87
+
88
+ %w(original latest previous next).each do |k|
89
+ alias_method k, "#{k}_version"
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,70 @@
1
+ module Historical::Models
2
+ class AttributeDiff
3
+ include MongoMapper::EmbeddedDocument
4
+ extend Historical::MongoMapperEnhancements
5
+
6
+ key :_type, String
7
+
8
+ key :attribute, String, :required => true
9
+ key :attribute_type, String, :required => true
10
+
11
+ key :_old_value, Object
12
+ key :_new_value, Object
13
+
14
+ attr_accessor :parent
15
+
16
+ alias_method :old_value, :_old_value
17
+ alias_method :new_value, :_new_value
18
+
19
+ protected :_old_value=, :_new_value=, :_old_value, :_new_value
20
+
21
+ def self.specialized_for(parent, attribute)
22
+ type = detect_attribute_type(parent, attribute)
23
+ Historical::Models.const_get(specialized_class_name(type))
24
+ 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
+
35
+ def old_value=(value)
36
+ self._old_value = cast_value(value)
37
+ end
38
+
39
+ def new_value=(value)
40
+ self._new_value = cast_value(value)
41
+ end
42
+
43
+ def self.detect_attribute_type(parent, attribute)
44
+ model_class = parent.record.class
45
+
46
+ column = model_class.columns.select do |c|
47
+ c.name.to_s == attribute.to_s
48
+ end.first
49
+
50
+ column ? column.type.to_s : nil
51
+ end
52
+
53
+ protected
54
+
55
+ def cast_value(value)
56
+ value
57
+ 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
+
68
+ self.const_set "#{type}#{superclass.simple_name}", diff_class
69
+ end
70
+ end
@@ -0,0 +1,90 @@
1
+ module Historical::Models
2
+ class ModelDiff
3
+ include MongoMapper::EmbeddedDocument
4
+ extend Historical::MongoMapperEnhancements
5
+ class_inheritable_accessor :historical_callbacks
6
+
7
+ validates_associated :changes
8
+
9
+ key :_type, String
10
+ key :diff_type, String, :required => true
11
+ many :changes, :class_name => "Historical::Models::AttributeDiff"
12
+
13
+ delegate :creation?, :update?, :to => :diff_type_inquirer
14
+
15
+ def record
16
+ new_version.record
17
+ end
18
+
19
+ def new_version
20
+ @parent || _parent_document
21
+ end
22
+
23
+ def old_version
24
+ new_version.previous
25
+ end
26
+
27
+ def self.from_versions(from, to)
28
+ return from_creation(to) if from.nil?
29
+
30
+ generate_from_version(from, 'update').tap do |d|
31
+ from.record.attribute_names.each do |attr_name|
32
+ attr = attr_name.to_sym
33
+ next if Historical::IGNORED_ATTRIBUTES.include? attr
34
+
35
+ old_value, new_value = from[attr], to[attr]
36
+
37
+ Historical::Models::AttributeDiff.specialized_for(d, attr).new.tap do |ad|
38
+ ad.attribute_type = Historical::Models::AttributeDiff.detect_attribute_type(d, attr)
39
+ ad.parent = d
40
+ ad.old_value = old_value
41
+ ad.new_value = new_value
42
+ ad.attribute = attr.to_s
43
+ d.changes << ad
44
+ end if old_value != new_value
45
+ end
46
+ end
47
+ end
48
+
49
+ def self.from_creation(to)
50
+ generate_from_version(to)
51
+ end
52
+
53
+ protected
54
+
55
+ def diff_type_inquirer
56
+ ActiveSupport::StringInquirer.new(diff_type)
57
+ end
58
+
59
+ def self.historical_callback(&block)
60
+ raise "no block given" unless block_given?
61
+
62
+ self.historical_callbacks ||= []
63
+ self.historical_callbacks << block
64
+ end
65
+
66
+
67
+ def self.generate_from_version(version, type = 'creation')
68
+ for_class(version.record.class).new.tap do |d|
69
+ d.diff_type = type
70
+ d.instance_variable_set :@parent, version
71
+
72
+ if cbs = d.class.historical_callbacks
73
+ cbs.each do |c|
74
+ c.call(d)
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ def self.for_class(source_class)
81
+ Historical::Models::Pool.pooled(Historical::Models::Pool.pooled_name(source_class, self)) do
82
+ Class.new(self).tap do |cls|
83
+ source_class.historical_customizations.each do |customization|
84
+ cls.instance_eval(&customization)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,70 @@
1
+ module Historical::Models
2
+ class ModelVersion
3
+ include MongoMapper::Document
4
+ extend Historical::MongoMapperEnhancements
5
+
6
+ validate :validate_diff
7
+
8
+ key :_type, String
9
+
10
+ belongs_to_active_record :_record, :polymorphic => true, :required => true
11
+
12
+ timestamps!
13
+
14
+ one :diff, :class_name => "Historical::Models::ModelDiff"
15
+
16
+ alias_method :record, :_record
17
+
18
+ def siblings
19
+ self.class.for_record(_record_id, _record_type)
20
+ end
21
+
22
+ def previous_versions
23
+ (new? ? siblings : siblings.where(:created_at.lte => created_at, :_id.lt => _id)).sort(:created_at.desc)
24
+ end
25
+
26
+ def previous
27
+ previous_versions.first
28
+ end
29
+
30
+ def next_versions
31
+ siblings.where(:created_at.gte => created_at, :_id.gt => _id).sort(:created_at.asc)
32
+ end
33
+
34
+ def next
35
+ next_versions.first
36
+ end
37
+
38
+ def version_index
39
+ previous_versions.count
40
+ end
41
+
42
+ def self.for_record(record_or_id, type = nil)
43
+ if type
44
+ ModelVersion.where(:_record_id => record_or_id, :_record_type => type)
45
+ else
46
+ ModelVersion.where(:_record_id => record_or_id.id, :_record_type => record_or_id.class.name)
47
+ end
48
+ end
49
+
50
+ def self.for_class(source_class)
51
+ Historical::Models::Pool.pooled(Historical::Models::Pool.pooled_name(source_class, self)) do
52
+ Class.new(self).tap do |cls|
53
+ source_class.columns.each do |col|
54
+ next if Historical::IGNORED_ATTRIBUTES.include? col.name.to_sym
55
+ type = Historical::ActiveRecord.sql_to_type(col.type)
56
+ cls.send :key, col.name, type.constantize
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ protected
63
+
64
+ def validate_diff
65
+ if diff.present? and !diff.valid?
66
+ errors.add(:diff, "not valid")
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,49 @@
1
+ module Historical
2
+ module MongoMapperEnhancements
3
+ def belongs_to_active_record(name, options = {})
4
+ ar_id_field = "#{name}_id"
5
+ ar_type_field = "#{name}_type"
6
+
7
+ polymorphic = options.delete(:polymorphic)
8
+ class_name = options.delete(:class_name)
9
+ model_class = nil
10
+
11
+ unless polymorphic
12
+ if class_name
13
+ model_class = class_name.is_a?(Class) ? class_name.name : class_name.classify
14
+ else
15
+ model_class = name.to_s.classify
16
+ end
17
+ end
18
+
19
+ key ar_id_field, Integer, options
20
+ key ar_type_field, String, options if polymorphic
21
+
22
+ define_method name do
23
+ if id = send(ar_id_field)
24
+ if polymorphic
25
+ type_class = send(ar_type_field)
26
+ type_class ? type_class.constantize.find(id) : nil
27
+ else
28
+ model_class.constantize.find(id)
29
+ end
30
+ else
31
+ nil
32
+ end
33
+ end
34
+
35
+ define_method "#{name}=" do |val|
36
+ id = type = nil
37
+
38
+ if val
39
+ raise "expected an instace of ActiveRecord::Base, got: #{val.class.name}" unless val.is_a?(::ActiveRecord::Base)
40
+ type = val.class.name
41
+ id = val.id
42
+ end
43
+
44
+ send("#{ar_id_field}=", id)
45
+ send("#{ar_type_field}=", type) if polymorphic
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,3 @@
1
+ require File.join(File.dirname(__FILE__), '..', 'lib', 'historical')
2
+
3
+ ActiveRecord::Base.send(:extend, Historical::ActiveRecord)
@@ -0,0 +1,289 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
2
+
3
+ describe "A historical model" do
4
+ class User < ActiveRecord::Base
5
+ set_table_name "users"
6
+ end
7
+
8
+ class Message < ActiveRecord::Base
9
+ extend Historical::ActiveRecord
10
+ is_historical
11
+ end
12
+
13
+ context "when created" do
14
+ it "should persist" do
15
+ msg = Message.new(:title => "Hello")
16
+ msg.save.should be_true
17
+ end
18
+
19
+ it "should create a creation-diff" do
20
+ msg = Message.create(:title => "Hello")
21
+
22
+ msg.history.tap do |h|
23
+ h.versions.count.should be(1)
24
+ h.updates.should be_empty
25
+
26
+ h.creation.should_not be_nil
27
+ h.creation.diff.diff_type.should == "creation"
28
+ h.creation.diff.should be_creation
29
+ end
30
+ end
31
+ end
32
+
33
+ context "when not modified" do
34
+ before :each do
35
+ @msg = Message.create(:title => "Hello")
36
+ @msg.title += "Different"
37
+ @msg.save!
38
+ end
39
+
40
+ it "should not create new versions" do
41
+ version_count = lambda { @msg.history.versions.count }
42
+
43
+ lambda do
44
+ @msg.save!
45
+ end.should_not change(version_count, :call)
46
+ end
47
+ end
48
+
49
+ context "when modified" do
50
+ it "should keep the correct value-types" do
51
+ m = Message.create
52
+
53
+ time = Time.now.utc
54
+ date = Date.today.advance(:days => 5)
55
+
56
+ m.title = "Hello there!"
57
+ m.body = "I am no spambot."
58
+ m.votes = 42
59
+ m.read = true
60
+ m.donated = 13.37
61
+ m.published_at = time
62
+ m.stamped_on = date
63
+ m.save!
64
+
65
+ m.history.updates.count.should == 1
66
+ change = m.history.updates.last.diff
67
+ changes = change.changes
68
+ grouped = {}
69
+
70
+ changes.each do |c|
71
+ grouped[c.attribute.to_sym] = c
72
+ end
73
+
74
+ grouped[:title].new_value.should == "Hello there!"
75
+ grouped[:title].old_value.should == nil
76
+
77
+ grouped[:body].new_value.should == "I am no spambot."
78
+ grouped[:body].old_value.should == nil
79
+
80
+ grouped[:votes].new_value.should == 42
81
+ grouped[:votes].old_value.should == nil
82
+
83
+ grouped[:read].new_value.should == true
84
+ grouped[:read].old_value.should == false
85
+
86
+ grouped[:published_at].new_value.class.should == Time
87
+ grouped[:published_at].new_value.should == Time.utc(time.year, time.month, time.day, time.hour, time.min, time.sec)
88
+ grouped[:published_at].old_value.should == nil
89
+
90
+ grouped[:stamped_on].new_value.class.should == Date
91
+ grouped[:stamped_on].new_value.should == date
92
+ grouped[:stamped_on].old_value.should == nil
93
+ end
94
+
95
+ context "on a single field" do
96
+ before :each do
97
+ @first_title = "Hello"
98
+ @new_title = "Hello World"
99
+ @msg = Message.create(:title => @first_title)
100
+ @msg.title = @new_title
101
+ @msg.save!
102
+ end
103
+
104
+ it "should create new versions" do
105
+ @msg.history.tap do |h|
106
+ h.versions.count.should == 2
107
+ h.original_version.title.should == @first_title
108
+ h.previous.title.should == @first_title
109
+ h.latest_version.title.should == @new_title
110
+ end
111
+ end
112
+
113
+ it "should create an update-diff" do
114
+ @msg.history.tap do |h|
115
+ h.updates.count.should == 1
116
+ h.updates.first.diff.diff_type.should == "update"
117
+ h.updates.first.diff.should be_update
118
+
119
+ h.creation.diff.should == h.versions.first.diff
120
+ h.updates.first.diff.should == h.versions.last.diff
121
+ end
122
+ end
123
+
124
+ it "should create attribute-diffs in update-diff" do
125
+ @msg.history.tap do |h|
126
+ model_diff = h.updates.first.diff
127
+ model_diff.changes.count.should == 1
128
+
129
+ model_diff.changes.first.tap do |diff|
130
+ diff.attribute.should == "title"
131
+ diff.old_value.should == @first_title
132
+ diff.new_value.should == @new_title
133
+ end
134
+ end
135
+ end
136
+ end
137
+
138
+ context "on multiple fields" do
139
+ it "should create multiple attribute-diffs in update-diff" do
140
+ @msg = Message.create
141
+
142
+ @msg.title = "Foo"
143
+ @msg.read = true
144
+ @msg.save!
145
+
146
+ @msg.history.versions.count.should == 2
147
+ @msg.history.updates.count.should == 1
148
+
149
+ @msg.history.tap do |h|
150
+ changes = h.updates.first.diff.changes
151
+ changes.count.should == 2
152
+
153
+ attributes_changed = changes.collect do |change|
154
+ change.attribute.to_s
155
+ end
156
+
157
+ Set.new(attributes_changed).should == Set.new(%w(title read))
158
+ end
159
+ end
160
+ end
161
+ end
162
+
163
+ context "when restored" do
164
+ before :each do
165
+ @message = Message.create(:title => "one")
166
+ @message.title = "two"
167
+ @message.save!
168
+
169
+ @restored = @message.history.restore(@message.history.original_version)
170
+ end
171
+
172
+ it "should contain the previous values" do
173
+ r = @restored
174
+ r.should_not be_nil
175
+ r.should be_kind_of(Message)
176
+ r.title.should == "one"
177
+ end
178
+
179
+ it "should not modifiy the original" do
180
+ @message.title.should == "two"
181
+ end
182
+
183
+ it "should be read-only" do
184
+ lambda { @restored.save! }.should raise_exception(ActiveRecord::ReadOnlyRecord)
185
+ end
186
+ end
187
+
188
+ it "should also accept a version number for restoration" do
189
+ @message = Message.create(:title => "one")
190
+ @message.update_attributes(:title => "two").should be_true
191
+ @message.update_attributes(:title => "three").should be_true
192
+
193
+ @restored = @message.history.restore(0)
194
+ @restored.title.should == "one"
195
+
196
+ @restored = @message.history.restore(1)
197
+ @restored.title.should == "two"
198
+
199
+ @restored = @message.history.restore(2)
200
+ @restored.title.should == "three"
201
+ end
202
+
203
+ it "should raise an exception for invalid version numbers" do
204
+ @message = Message.create(:title => "one")
205
+ @message.history.versions.count.should == 1
206
+
207
+ lambda{ @message.history.restore(-1) }.should raise_exception
208
+ lambda{ @message.history.restore(1) }.should raise_exception(ActiveRecord::RecordNotFound)
209
+ end
210
+
211
+ it "should allow handy queryies for restoration" do
212
+ @message = Message.create(:title => "one")
213
+ @message.update_attributes(:title => "two").should be_true
214
+
215
+ @message.history.restore(:original).title.should == "one"
216
+ end
217
+
218
+ it "should find version-neighbors" do
219
+ @message = Message.create(:title => "one")
220
+ @message.update_attributes(:title => "two").should be_true
221
+ @message.update_attributes(:title => "three").should be_true
222
+
223
+ mid = @message.history.versions.skip(1).limit(1).first
224
+ mid.title.should == "two"
225
+
226
+ mid.previous_versions.count.should == 1
227
+ mid.next_versions.count.should == 1
228
+ mid.previous.should == @message.history.versions.skip(0).limit(1).first
229
+ mid.next.should == @message.history.versions.skip(2).limit(1).first
230
+ end
231
+
232
+ it "should not break handy queries for chained restorations" do
233
+ @message = Message.create(:title => "one")
234
+ @message.update_attributes(:title => "two").should be_true
235
+ @message.history.own_version.version_index.should == 1
236
+ @message.version.should == 1
237
+
238
+ previous = @message.history.restore(:previous)
239
+ previous.history.own_version.version_index.should == 0
240
+ previous.version.should == 0
241
+
242
+ identity = previous.history.restore(:next)
243
+ identity.history.own_version.version_index.should == 1
244
+ identity.version.should == 1
245
+
246
+ identity.should_not be_nil
247
+ identity.title.should == "two"
248
+ end
249
+
250
+ context "with customization" do
251
+ class AuditedMessage < ActiveRecord::Base
252
+ set_table_name "messages"
253
+
254
+ cattr_accessor :current_user
255
+
256
+ extend Historical::ActiveRecord
257
+ is_historical do
258
+ belongs_to_active_record :author, :required => true, :class_name => "User"
259
+
260
+ historical_callback do |diff|
261
+ diff.author = AuditedMessage.current_user
262
+ end
263
+ end
264
+ end
265
+
266
+ before :each do
267
+ AuditedMessage.current_user = nil
268
+ end
269
+
270
+ it "should create custom keys on ModelDiffs" do
271
+ user = User.create(:name => "Jane Doe")
272
+ AuditedMessage.current_user = user
273
+
274
+ msg = AuditedMessage.create(:title => "one")
275
+
276
+ author = msg.history.versions.first.diff.author
277
+ author.should == user
278
+ author.id.should == user.id
279
+ end
280
+
281
+ it "should validate requirements" do
282
+ # no author is set
283
+
284
+ lambda do
285
+ AuditedMessage.create(:title => "one")
286
+ end.should raise_exception(MongoMapper::DocumentNotValid)
287
+ end
288
+ end
289
+ end
@@ -0,0 +1,9 @@
1
+ --color
2
+ --format
3
+ nested
4
+ --loadby
5
+ mtime
6
+ --reverse
7
+ --timeout
8
+ 20
9
+ --diff
@@ -0,0 +1,47 @@
1
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
2
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
3
+ require 'historical'
4
+ require 'spec'
5
+ require 'spec/autorun'
6
+
7
+ require 'rubygems'
8
+
9
+ gem "rails", "= 2.3.8"
10
+
11
+ require 'active_support'
12
+ require 'active_support/test_case'
13
+ require 'active_record'
14
+ require 'mongo_mapper'
15
+
16
+ ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :database => ":memory:")
17
+ ActiveRecord::Base.logger ||= Logger.new(STDOUT)
18
+ ActiveRecord::Base.logger.level = Logger::WARN
19
+
20
+ ActiveRecord::Base.silence do
21
+ ActiveRecord::Schema.define(:version => 1) do
22
+ self.verbose = false
23
+
24
+ create_table :messages do |t|
25
+ t.string :title
26
+ t.text :body
27
+ t.integer :votes
28
+ t.datetime :published_at
29
+ t.date :stamped_on
30
+ t.decimal :donated, :precision => 10, :scale => 2
31
+ t.boolean :read, :null => false, :default => false
32
+ t.timestamps
33
+ end
34
+
35
+ create_table :users do |t|
36
+ t.string :name
37
+ t.timestamps
38
+ end
39
+ end
40
+ end
41
+
42
+ MongoMapper.database = "historical-test"
43
+ MongoMapper.database.collections.each(&:remove)
44
+
45
+ Spec::Runner.configure do |config|
46
+
47
+ end
metadata ADDED
@@ -0,0 +1,100 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: historical
3
+ version: !ruby/object:Gem::Version
4
+ hash: 23
5
+ prerelease: false
6
+ segments:
7
+ - 0
8
+ - 2
9
+ - 0
10
+ version: 0.2.0
11
+ platform: ruby
12
+ authors:
13
+ - Marcel Jackwerth
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2010-08-16 00:00:00 +02:00
19
+ default_executable:
20
+ dependencies:
21
+ - !ruby/object:Gem::Dependency
22
+ name: rspec
23
+ prerelease: false
24
+ requirement: &id001 !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ hash: 13
30
+ segments:
31
+ - 1
32
+ - 2
33
+ - 9
34
+ version: 1.2.9
35
+ type: :development
36
+ version_requirements: *id001
37
+ description: Rewrite of the original historical-plugin using MongoDB
38
+ email: marcel@northdocks.com
39
+ executables: []
40
+
41
+ extensions: []
42
+
43
+ extra_rdoc_files:
44
+ - LICENSE
45
+ - README.rdoc
46
+ files:
47
+ - .gitignore
48
+ - LICENSE
49
+ - README.rdoc
50
+ - Rakefile
51
+ - VERSION
52
+ - historical.gemspec
53
+ - lib/historical.rb
54
+ - lib/historical/active_record.rb
55
+ - lib/historical/model_history.rb
56
+ - lib/historical/models/attribute_diff.rb
57
+ - lib/historical/models/model_diff.rb
58
+ - lib/historical/models/model_version.rb
59
+ - lib/historical/mongo_mapper_enhancements.rb
60
+ - rails/init.rb
61
+ - spec/historical_spec.rb
62
+ - spec/spec.opts
63
+ - spec/spec_helper.rb
64
+ has_rdoc: true
65
+ homepage: http://github.com/sirlantis/historical
66
+ licenses: []
67
+
68
+ post_install_message:
69
+ rdoc_options:
70
+ - --charset=UTF-8
71
+ require_paths:
72
+ - lib
73
+ required_ruby_version: !ruby/object:Gem::Requirement
74
+ none: false
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ hash: 3
79
+ segments:
80
+ - 0
81
+ version: "0"
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ none: false
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ hash: 3
88
+ segments:
89
+ - 0
90
+ version: "0"
91
+ requirements: []
92
+
93
+ rubyforge_project:
94
+ rubygems_version: 1.3.7
95
+ signing_key:
96
+ specification_version: 3
97
+ summary: DRY and serialization-free versioning for ActiveRecord
98
+ test_files:
99
+ - spec/historical_spec.rb
100
+ - spec/spec_helper.rb