sequel_bitemporal 0.1.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.
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