sequel_bitemporal 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,4 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source "http://rubygems.org"
2
+ gemspec
data/README.md ADDED
@@ -0,0 +1,29 @@
1
+ sequel_bitemporal
2
+ =================
3
+
4
+ Bitemporal versioning for sequel.
5
+
6
+ Dependencies
7
+ ------------
8
+
9
+ * Ruby >= 1.9.2
10
+ * gem "sequel"
11
+
12
+ Usage
13
+ -----
14
+
15
+ * Declare bitemporality inside your model:
16
+
17
+ class HotelPrice < Sequel::Model
18
+ plugin :bitemporal
19
+ end
20
+
21
+ Build Status
22
+ ------------
23
+
24
+ [![Build Status](http://travis-ci.org/TalentBox/sequel_bitemporal.png)](http://travis-ci.org/TalentBox/sequel_bitemporal)
25
+
26
+ License
27
+ -------
28
+
29
+ sequel_bitemporal is Copyright © 2011 TalentBox SA. It is free software, and may be redistributed under the terms specified in the LICENSE file.
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+ RSpec::Core::RakeTask.new("spec").tap do |config|
4
+ config.rspec_opts = "--color"
5
+ end
6
+ task :default => :spec
@@ -0,0 +1,121 @@
1
+ module Sequel
2
+ module Plugins
3
+ module Bitemporal
4
+ def self.configure(master, opts = {})
5
+ version = opts[:version_class]
6
+ raise Error, "please specify version class to use for bitemporal plugin" unless version
7
+ required = [:master_id, :valid_from, :valid_to, :created_at, :expired_at]
8
+ missing = required - version.columns
9
+ raise Error, "bitemporal plugin requires the following missing column#{"s" if missing.size>1} on version class: #{missing.join(", ")}" unless missing.empty?
10
+ master.one_to_many :versions, class: version, key: :master_id
11
+ master.one_to_one :current_version, class: version, key: :master_id, conditions: ["created_at<=:now AND (expired_at IS NULL OR expired_at>:now) AND valid_from<=:now AND valid_to>:now", now: Time.now]
12
+ version.many_to_one :master, class: master, key: :master_id
13
+ version.class_eval do
14
+ def current?(now = Time.now)
15
+ !new? &&
16
+ created_at.to_time<=now &&
17
+ (expired_at.nil? || expired_at.to_time>now) &&
18
+ valid_from.to_time<=now &&
19
+ valid_to.to_time>now
20
+ end
21
+ end
22
+ master.instance_eval do
23
+ @version_class = version
24
+ end
25
+ end
26
+ module ClassMethods
27
+ attr_reader :version_class
28
+ end
29
+ module DatasetMethods
30
+ end
31
+ module InstanceMethods
32
+ attr_reader :pending_version
33
+
34
+ def validate
35
+ super
36
+ pending_version.errors.each do |key, key_errors|
37
+ key_errors.each{|error| errors.add key, error}
38
+ end if pending_version && !pending_version.valid?
39
+ end
40
+
41
+ def attributes
42
+ pending_version ? pending_version.values : {}
43
+ end
44
+
45
+ def attributes=(attributes)
46
+ @pending_version ||= model.version_class.new
47
+ pending_version.set attributes
48
+ end
49
+
50
+ def update_attributes(attributes={})
51
+ if attributes.delete(:partial_update) && current_version
52
+ current_attributes = current_version.values.dup
53
+ current_attributes.delete :id
54
+ attributes = current_attributes.merge attributes
55
+ end
56
+ self.attributes = attributes
57
+ save raise_on_failure: false
58
+ end
59
+
60
+ def after_create
61
+ super
62
+ if pending_version
63
+ prepare_pending_version
64
+ return false unless save_pending_version
65
+ end
66
+ end
67
+
68
+ def before_update
69
+ if pending_version
70
+ lock!
71
+ prepare_pending_version
72
+ expire_previous_versions
73
+ return false unless save_pending_version
74
+ end
75
+ super
76
+ end
77
+
78
+ private
79
+
80
+ def prepare_pending_version
81
+ point_in_time = Time.now
82
+ pending_version.created_at = point_in_time
83
+ pending_version.valid_from = point_in_time if !pending_version.valid_from || pending_version.valid_from.to_time<point_in_time
84
+ end
85
+
86
+ def save_pending_version
87
+ pending_version.valid_to ||= Time.utc 9999
88
+ success = add_version pending_version
89
+ @pending_version = nil if success
90
+ success
91
+ end
92
+
93
+ def expire_previous_versions
94
+ expired = versions_dataset.where expired_at: nil
95
+ expired = expired.exclude "valid_from=valid_to"
96
+ expired = expired.exclude "valid_to<=?", pending_version.valid_from
97
+ pending_version.valid_to ||= expired.where("valid_from>?", pending_version.valid_from).select("MIN(valid_from)").first
98
+ pending_version.valid_to ||= Time.utc 9999
99
+ expired = expired.exclude "valid_from>=?", pending_version.valid_to
100
+ expired = expired.all
101
+ expired.each do |expired_version|
102
+ if expired_version.valid_from<pending_version.valid_from && expired_version.valid_to>pending_version.valid_from
103
+ return false unless save_fossil expired_version, created_at: pending_version.created_at, valid_to: pending_version.valid_from
104
+ elsif expired_version.valid_from<pending_version.valid_to && expired_version.valid_to>pending_version.valid_to
105
+ return false unless save_fossil expired_version, created_at: pending_version.created_at, valid_from: pending_version.valid_to
106
+ end
107
+ end
108
+ versions_dataset.where(id: expired.collect(&:id)).update expired_at: pending_version.created_at
109
+ end
110
+
111
+ def save_fossil(expired, attributes={})
112
+ fossil = model.version_class.new
113
+ expired_attributes = expired.values.dup
114
+ expired_attributes.delete :id
115
+ fossil.send :set_values, expired_attributes.merge(attributes)
116
+ fossil.save validate: false
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1 @@
1
+ require "sequel/plugins/bitemporal"
@@ -0,0 +1,22 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = "sequel_bitemporal"
6
+ s.version = "0.1.0"
7
+ s.authors = ["Joseph HALTER"]
8
+ s.email = ["joseph.halter@thetalentbox.com"]
9
+ s.homepage = "https://github.com/TalentBox/sequel_bitemporal"
10
+ s.summary = "Bitemporal versioning for sequel."
11
+ s.description = "Bitemporal versioning for sequel, fully tested."
12
+
13
+ s.files = `git ls-files`.split("\n")
14
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
15
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
16
+ s.require_paths = ["lib"]
17
+
18
+ s.add_development_dependency "sqlite3"
19
+ s.add_development_dependency "rspec"
20
+ s.add_development_dependency "timecop"
21
+ s.add_runtime_dependency "sequel"
22
+ end
@@ -0,0 +1,183 @@
1
+ require "spec_helper"
2
+
3
+ describe "Sequel::Plugins::Bitemporal" do
4
+ before :all do
5
+ DB.create_table! :rooms do
6
+ primary_key :id
7
+ end
8
+ DB.create_table! :room_versions do
9
+ primary_key :id
10
+ foreign_key :master_id, :rooms
11
+ String :name
12
+ Fixnum :price
13
+ Date :created_at
14
+ Date :expired_at
15
+ Date :valid_from
16
+ Date :valid_to
17
+ end
18
+ @version_class = Class.new Sequel::Model do
19
+ set_dataset :room_versions
20
+ def validate
21
+ super
22
+ errors.add(:name, "is required") unless name
23
+ errors.add(:price, "is required") unless price
24
+ end
25
+ end
26
+ closure = @version_class
27
+ @master_class = Class.new Sequel::Model do
28
+ set_dataset :rooms
29
+ plugin :bitemporal, version_class: closure
30
+ end
31
+ end
32
+ before do
33
+ Timecop.freeze 2009, 11, 28
34
+ end
35
+ after do
36
+ Timecop.return
37
+ @master_class.truncate
38
+ @version_class.truncate
39
+ end
40
+ it "checks version class is given" do
41
+ lambda{
42
+ @version_class.plugin :bitemporal
43
+ }.should raise_error Sequel::Error, "please specify version class to use for bitemporal plugin"
44
+ end
45
+ it "checks required columns are present" do
46
+ lambda{
47
+ @version_class.plugin :bitemporal, :version_class => @master_class
48
+ }.should raise_error Sequel::Error, "bitemporal plugin requires the following missing columns on version class: master_id, valid_from, valid_to, created_at, expired_at"
49
+ end
50
+ it "propagates errors from version to master" do
51
+ master = @master_class.new
52
+ master.should be_valid
53
+ master.attributes = {name: "Single Standard"}
54
+ master.should_not be_valid
55
+ master.errors.should == {price: ["is required"]}
56
+ end
57
+ it "#update_attributes returns false instead of raising errors" do
58
+ master = @master_class.new
59
+ master.update_attributes(name: "Single Standard").should be_false
60
+ master.should be_new
61
+ master.errors.should == {price: ["is required"]}
62
+ master.update_attributes(price: 98).should be_true
63
+ end
64
+ it "allows creating a master and its first version in one step" do
65
+ master = @master_class.new
66
+ master.update_attributes(name: "Single Standard", price: 98).should be_true
67
+ master.should_not be_new
68
+ master.should have_versions %Q{
69
+ | name | price | created_at | expired_at | valid_from | valid_to | current |
70
+ | Single Standard | 98 | 2009-11-28 | | 2009-11-28 | | true |
71
+ }
72
+ end
73
+ it "prevents creating a new version in the past" do
74
+ master = @master_class.new
75
+ master.update_attributes name: "Single Standard", price: 98, valid_from: Date.today-1
76
+ master.should have_versions %Q{
77
+ | name | price | created_at | expired_at | valid_from | valid_to | current |
78
+ | Single Standard | 98 | 2009-11-28 | | 2009-11-28 | | true |
79
+ }
80
+ end
81
+ it "allows creating a new version in the future" do
82
+ master = @master_class.new
83
+ master.update_attributes name: "Single Standard", price: 98, valid_from: Date.today+1
84
+ master.should have_versions %Q{
85
+ | name | price | created_at | expired_at | valid_from | valid_to | current |
86
+ | Single Standard | 98 | 2009-11-28 | | 2009-11-29 | | |
87
+ }
88
+ end
89
+ it "doesn't loose previous version in same-day update" do
90
+ master = @master_class.new
91
+ master.update_attributes name: "Single Standard", price: 98
92
+ master.update_attributes name: "Single Standard", price: 94
93
+ master.should have_versions %Q{
94
+ | name | price | created_at | expired_at | valid_from | valid_to | current |
95
+ | Single Standard | 98 | 2009-11-28 | 2009-11-28 | 2009-11-28 | | |
96
+ | Single Standard | 94 | 2009-11-28 | | 2009-11-28 | | true |
97
+ }
98
+ end
99
+ it "allows partial updating based on current version" do
100
+ master = @master_class.new
101
+ master.update_attributes name: "Single Standard", price: 98
102
+ master.update_attributes price: 94, partial_update: true
103
+ master.update_attributes name: "King Size", partial_update: true
104
+ master.should have_versions %Q{
105
+ | name | price | created_at | expired_at | valid_from | valid_to | current |
106
+ | Single Standard | 98 | 2009-11-28 | 2009-11-28 | 2009-11-28 | | |
107
+ | Single Standard | 94 | 2009-11-28 | 2009-11-28 | 2009-11-28 | | |
108
+ | King Size | 94 | 2009-11-28 | | 2009-11-28 | | true |
109
+ }
110
+ end
111
+ it "expires previous version but keep it in history" do
112
+ master = @master_class.new
113
+ master.update_attributes name: "Single Standard", price: 98
114
+ Timecop.freeze Date.today+1
115
+ master.update_attributes price: 94, partial_update: true
116
+ master.should have_versions %Q{
117
+ | name | price | created_at | expired_at | valid_from | valid_to | current |
118
+ | Single Standard | 98 | 2009-11-28 | 2009-11-29 | 2009-11-28 | | |
119
+ | Single Standard | 98 | 2009-11-29 | | 2009-11-28 | 2009-11-29 | |
120
+ | Single Standard | 94 | 2009-11-29 | | 2009-11-29 | | true |
121
+ }
122
+ end
123
+ it "doesn't expire no longer valid versions" do
124
+ master = @master_class.new
125
+ master.update_attributes name: "Single Standard", price: 98, valid_to: Date.today+1
126
+ Timecop.freeze Date.today+1
127
+ master.update_attributes(price: 94, partial_update: true).should be_false
128
+ master.update_attributes name: "Single Standard", price: 94
129
+ master.should have_versions %Q{
130
+ | name | price | created_at | expired_at | valid_from | valid_to | current |
131
+ | Single Standard | 98 | 2009-11-28 | | 2009-11-28 | 2009-11-29 | |
132
+ | Single Standard | 94 | 2009-11-29 | | 2009-11-29 | | true |
133
+ }
134
+ end
135
+ it "allows shortening validity (COULD BE IMPROVED!)" do
136
+ master = @master_class.new
137
+ master.update_attributes name: "Single Standard", price: 98
138
+ Timecop.freeze Date.today+1
139
+ master.update_attributes valid_to: Date.today+10, partial_update: true
140
+ master.should have_versions %Q{
141
+ | name | price | created_at | expired_at | valid_from | valid_to | current |
142
+ | Single Standard | 98 | 2009-11-28 | 2009-11-29 | 2009-11-28 | | |
143
+ | Single Standard | 98 | 2009-11-29 | | 2009-11-28 | 2009-11-29 | |
144
+ | Single Standard | 98 | 2009-11-29 | | 2009-11-29 | 2009-12-09 | true |
145
+ }
146
+ end
147
+ # Timecop.freeze Date.today+1
148
+ # version.update_attributes valid_to: "2009-12-05"
149
+ # version.master.check_versions %Q{
150
+ # | name | price | created_at | expired_at | valid_from | valid_to | current |
151
+ # | Single Standard | 98.00 | 2009-11-28 | 2009-11-29 | 2009-11-28 | | |
152
+ # | Single Standard | 98.00 | 2009-11-29 | | 2009-11-28 | 2009-11-29 | |
153
+ # | Single Standard | 94.00 | 2009-11-29 | 2009-11-30 | 2009-11-29 | | |
154
+ # | Single Standard | 94.00 | 2009-11-30 | | 2009-11-29 | 2009-12-05 | true |
155
+ # }
156
+ # Timecop.freeze Date.today+1
157
+ # version.update_attributes valid_from: "2009-12-02", valid_to: nil, price: 95
158
+ # version.master.check_versions %Q{
159
+ # | name | price | created_at | expired_at | valid_from | valid_to | current |
160
+ # | Single Standard | 98.00 | 2009-11-28 | 2009-11-29 | 2009-11-28 | | |
161
+ # | Single Standard | 98.00 | 2009-11-29 | | 2009-11-28 | 2009-11-29 | |
162
+ # | Single Standard | 94.00 | 2009-11-29 | 2009-11-30 | 2009-11-29 | | |
163
+ # | Single Standard | 94.00 | 2009-11-30 | 2009-12-01 | 2009-11-29 | 2009-12-05 | |
164
+ # | Single Standard | 94.00 | 2009-12-01 | | 2009-11-29 | 2009-12-02 | true |
165
+ # | Single Standard | 95.00 | 2009-12-01 | | 2009-11-02 | | |
166
+ # }
167
+ # Timecop.freeze Date.today+1
168
+ # version.master.check_versions %Q{
169
+ # | name | price | created_at | expired_at | valid_from | valid_to | current |
170
+ # | Single Standard | 98.00 | 2009-11-28 | 2009-11-29 | 2009-11-28 | | |
171
+ # | Single Standard | 98.00 | 2009-11-29 | | 2009-11-28 | 2009-11-29 | |
172
+ # | Single Standard | 94.00 | 2009-11-29 | 2009-11-30 | 2009-11-29 | | |
173
+ # | Single Standard | 94.00 | 2009-11-30 | 2009-12-01 | 2009-11-29 | 2009-12-05 | |
174
+ # | Single Standard | 94.00 | 2009-12-01 | | 2009-11-29 | 2009-12-02 | |
175
+ # | Single Standard | 95.00 | 2009-12-01 | | 2009-11-02 | | true |
176
+ # }
177
+ # missing scenarios:
178
+ # - same date update
179
+ # - save unchanged
180
+ # - delete scheduled version
181
+ # - delete all versions
182
+ # - simultaneous updates
183
+ end
@@ -0,0 +1,4 @@
1
+ require "sequel"
2
+ require "timecop"
3
+ DB = Sequel.sqlite
4
+ Dir[File.expand_path("../support/*.rb", __FILE__)].each{|f| require f}
@@ -0,0 +1,41 @@
1
+ RSpec::Matchers.define :have_versions do |versions_str|
2
+ @table = have_versions_parse_table versions_str
3
+ @last_index = nil
4
+ @last_version = nil
5
+ match do |master|
6
+ versions = master.versions_dataset.order(:id).all
7
+ versions.size == @table.size && @table.each.with_index.all? do |version, index|
8
+ @last_index = index
9
+ @last_version = version
10
+ master_version = versions[index]
11
+ [:name, :price, :valid_from, :valid_to, :created_at, :expired_at, :current].all? do |column|
12
+ expected = version[column.to_s]
13
+ case column
14
+ when :valid_to
15
+ expected = "9999-01-01"
16
+ when :current
17
+ expected = "false"
18
+ end if expected==""
19
+ found = master_version.send(column == :current ? "current?" : column).to_s
20
+ equal = found == expected
21
+ puts "#{column}: #{found} != #{expected}" unless equal
22
+ equal
23
+ end
24
+ end
25
+ end
26
+ failure_message_for_should do |master|
27
+ versions = master.versions_dataset.order(:id).all
28
+ if versions.size != @table.size
29
+ "Expected #{master.class} to have #{@table.size} versions but found #{versions.size}"
30
+ else
31
+ "Expected row #{@last_index+1} to match #{@last_version.inspect} but found #{versions[@last_index].inspect}"
32
+ end
33
+ end
34
+ end
35
+
36
+ def have_versions_parse_table(str)
37
+ rows = str.strip.split("\n")
38
+ rows.collect!{|row| row[/^\s*\|(.+)\|\s*$/, 1].split("|").collect(&:strip)}
39
+ headers = rows.shift
40
+ rows.collect{|row| Hash[headers.zip row]}
41
+ end
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sequel_bitemporal
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Joseph HALTER
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2011-11-01 00:00:00.000000000Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: sqlite3
16
+ requirement: &2162303500 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: *2162303500
25
+ - !ruby/object:Gem::Dependency
26
+ name: rspec
27
+ requirement: &2162303060 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ! '>='
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: *2162303060
36
+ - !ruby/object:Gem::Dependency
37
+ name: timecop
38
+ requirement: &2162330280 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ! '>='
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ type: :development
45
+ prerelease: false
46
+ version_requirements: *2162330280
47
+ - !ruby/object:Gem::Dependency
48
+ name: sequel
49
+ requirement: &2162329860 !ruby/object:Gem::Requirement
50
+ none: false
51
+ requirements:
52
+ - - ! '>='
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ type: :runtime
56
+ prerelease: false
57
+ version_requirements: *2162329860
58
+ description: Bitemporal versioning for sequel, fully tested.
59
+ email:
60
+ - joseph.halter@thetalentbox.com
61
+ executables: []
62
+ extensions: []
63
+ extra_rdoc_files: []
64
+ files:
65
+ - .gitignore
66
+ - Gemfile
67
+ - README.md
68
+ - Rakefile
69
+ - lib/sequel/plugins/bitemporal.rb
70
+ - lib/sequel_bitemporal.rb
71
+ - sequel_bitemporal.gemspec
72
+ - spec/bitemporal_spec.rb
73
+ - spec/spec_helper.rb
74
+ - spec/support/bitemporal_matchers.rb
75
+ homepage: https://github.com/TalentBox/sequel_bitemporal
76
+ licenses: []
77
+ post_install_message:
78
+ rdoc_options: []
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ none: false
83
+ requirements:
84
+ - - ! '>='
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ none: false
89
+ requirements:
90
+ - - ! '>='
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubyforge_project:
95
+ rubygems_version: 1.8.6
96
+ signing_key:
97
+ specification_version: 3
98
+ summary: Bitemporal versioning for sequel.
99
+ test_files:
100
+ - spec/bitemporal_spec.rb
101
+ - spec/spec_helper.rb
102
+ - spec/support/bitemporal_matchers.rb