sequel_temporal 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,4 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
@@ -0,0 +1,5 @@
1
+ rvm:
2
+ - 1.9.2
3
+ - 1.9.3
4
+ notifications:
5
+ disabled: true
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source "http://rubygems.org"
2
+ gemspec
@@ -0,0 +1,47 @@
1
+ sequel_temporal
2
+ =================
3
+
4
+ Temporal versioning for sequel.
5
+
6
+ Dependencies
7
+ ------------
8
+
9
+ * Ruby >= 1.9.2
10
+ * gem "sequel", "~> 3.30.0"
11
+
12
+ Usage
13
+ -----
14
+
15
+ * Declare temporality inside your model:
16
+
17
+ class HotelPriceVersion < Sequel::Model
18
+ end
19
+
20
+ class HotelPrice < Sequel::Model
21
+ plugin :temporal, version_class: HotelPriceVersion
22
+ end
23
+
24
+ * You can now create a hotel price with versions:
25
+
26
+ price = HotelPrice.new
27
+ price.update_attributes price: 18
28
+
29
+ * To show all versions:
30
+
31
+ price.versions
32
+
33
+ * To get current version:
34
+
35
+ price.current_version
36
+
37
+ * Look at the specs for more usage patterns.
38
+
39
+ Build Status
40
+ ------------
41
+
42
+ [![Build Status](http://travis-ci.org/TalentBox/sequel_bitemporal.png)](http://travis-ci.org/TalentBox/sequel_bitemporal)
43
+
44
+ License
45
+ -------
46
+
47
+ sequel_temporal is Copyright © 2011 TalentBox SA. It is free software, and may be redistributed under the terms specified in the LICENSE file.
@@ -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,151 @@
1
+ module Sequel
2
+ module Plugins
3
+ module Temporal
4
+ def self.at(time)
5
+ raise ArgumentError, "requires a block" unless block_given?
6
+ key = :sequel_plugins_temporal_now
7
+ previous, Thread.current[key] = Thread.current[key], time.to_time
8
+ yield
9
+ Thread.current[key] = previous
10
+ end
11
+
12
+ def self.now
13
+ Thread.current[:sequel_plugins_temporal_now] || Time.now
14
+ end
15
+
16
+ def self.configure(master, opts = {})
17
+ version = opts[:version_class]
18
+ raise Error, "please specify version class to use for temporal plugin" unless version
19
+ required = [:master_id, :created_at, :expired_at]
20
+ missing = required - version.columns
21
+ raise Error, "temporal plugin requires the following missing column#{"s" if missing.size>1} on version class: #{missing.join(", ")}" unless missing.empty?
22
+ master.instance_eval do
23
+ @version_class = version
24
+ base_alias = name ? underscore(demodulize(name)) : table_name
25
+ @versions_alias = "#{base_alias}_versions".to_sym
26
+ @current_version_alias = "#{base_alias}_current_version".to_sym
27
+ end
28
+ master.one_to_many :versions, class: version, key: :master_id, graph_alias_base: master.versions_alias
29
+ master.one_to_one :current_version, class: version, key: :master_id, graph_alias_base: master.current_version_alias, :graph_block=>(proc do |j, lj, js|
30
+ n = ::Sequel::Plugins::Temporal.now
31
+ e = :expired_at.qualify(j)
32
+ (:created_at.qualify(j) <= n) & ({e=>nil} | (e > n))
33
+ end) do |ds|
34
+ n = ::Sequel::Plugins::Temporal.now
35
+ ds.where{(created_at <= n) & ({expired_at=>nil} | (expired_at > n))}
36
+ end
37
+ master.def_dataset_method :with_current_version do
38
+ eager_graph(:current_version).where({:id.qualify(model.current_version_alias) => nil}.sql_negate)
39
+ end
40
+ version.many_to_one :master, class: master, key: :master_id
41
+ version.class_eval do
42
+ def current?
43
+ n = ::Sequel::Plugins::Temporal.now
44
+ !new? &&
45
+ created_at.to_time<=n &&
46
+ (expired_at.nil? || expired_at.to_time>n)
47
+ end
48
+ end
49
+ unless opts[:delegate]==false
50
+ (version.columns-required-[:id]).each do |column|
51
+ master.class_eval <<-EOS
52
+ def #{column}
53
+ pending_or_current_version.#{column} if pending_or_current_version
54
+ end
55
+ EOS
56
+ end
57
+ end
58
+ end
59
+ module ClassMethods
60
+ attr_reader :version_class, :versions_alias, :current_version_alias
61
+ end
62
+ module DatasetMethods
63
+ end
64
+ module InstanceMethods
65
+ attr_reader :pending_version
66
+
67
+ def before_validation
68
+ prepare_pending_version
69
+ super
70
+ end
71
+
72
+ def validate
73
+ super
74
+ pending_version.errors.each do |key, key_errors|
75
+ key_errors.each{|error| errors.add key, error}
76
+ end if pending_version && !pending_version.valid?
77
+ end
78
+
79
+ def pending_or_current_version
80
+ pending_version || current_version
81
+ end
82
+
83
+ def attributes
84
+ if pending_version
85
+ pending_version.values
86
+ elsif current_version
87
+ current_version.values
88
+ else
89
+ {}
90
+ end
91
+ end
92
+
93
+ def attributes=(attributes)
94
+ if !new? && attributes.delete(:partial_update) && current_version
95
+ current_attributes = current_version.keys.inject({}) do |hash, key|
96
+ hash[key] = current_version.send key
97
+ hash
98
+ end
99
+ attributes = current_attributes.merge attributes
100
+ end
101
+ attributes.delete :id
102
+ @pending_version ||= model.version_class.new
103
+ pending_version.set attributes
104
+ pending_version.master_id = id unless new?
105
+ end
106
+
107
+ def update_attributes(attributes={})
108
+ self.attributes = attributes
109
+ save raise_on_failure: false
110
+ end
111
+
112
+ def after_create
113
+ super
114
+ if pending_version
115
+ return false unless save_pending_version
116
+ end
117
+ end
118
+
119
+ def before_update
120
+ if pending_version
121
+ expire_previous_version
122
+ return false unless save_pending_version
123
+ end
124
+ super
125
+ end
126
+
127
+ def destroy
128
+ versions_dataset.where(expired_at: nil).update expired_at: Time.now
129
+ end
130
+
131
+ private
132
+
133
+ def prepare_pending_version
134
+ return unless pending_version
135
+ pending_version.created_at = Time.now
136
+ end
137
+
138
+ def expire_previous_version
139
+ lock!
140
+ versions_dataset.where(expired_at: nil).update expired_at: pending_version.created_at
141
+ end
142
+
143
+ def save_pending_version
144
+ success = add_version pending_version
145
+ @pending_version = nil if success
146
+ success
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1 @@
1
+ require "sequel/plugins/temporal"
@@ -0,0 +1,24 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = "sequel_temporal"
6
+ s.version = "0.1.0"
7
+ s.authors = ["Joseph HALTER", "Jonathan TRON"]
8
+ s.email = ["joseph.halter@thetalentbox.com", "jonathan.tron@thetalentbox.com"]
9
+ s.homepage = "https://github.com/TalentBox/sequel_temporal"
10
+ s.summary = "Temporal versioning for sequel."
11
+ s.description = "Temporal 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_runtime_dependency "sequel", "~> 3.30.0"
19
+
20
+ s.add_development_dependency "sqlite3"
21
+ s.add_development_dependency "rspec", "~> 2.8.0.rc1"
22
+ s.add_development_dependency "timecop"
23
+ s.add_development_dependency "rake"
24
+ 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,40 @@
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, :created_at, :expired_at, :current].all? do |column|
12
+ if column==:current
13
+ found = master_version.current?
14
+ expected = (version[column.to_s]=="true").to_s
15
+ else
16
+ found = master_version.send column
17
+ expected = version[column.to_s]
18
+ end
19
+ equal = found.to_s == expected
20
+ puts "#{column}: #{found} != #{expected}" unless equal
21
+ equal
22
+ end
23
+ end
24
+ end
25
+ failure_message_for_should do |master|
26
+ versions = master.versions_dataset.order(:id).all
27
+ if versions.size != @table.size
28
+ "Expected #{master.class} to have #{@table.size} versions but found #{versions.size}"
29
+ else
30
+ "Expected row #{@last_index+1} to match #{@last_version.inspect} but found #{versions[@last_index].inspect}"
31
+ end
32
+ end
33
+ end
34
+
35
+ def have_versions_parse_table(str)
36
+ rows = str.strip.split("\n")
37
+ rows.collect!{|row| row[/^\s*\|(.+)\|\s*$/, 1].split("|").collect(&:strip)}
38
+ headers = rows.shift
39
+ rows.collect{|row| Hash[headers.zip row]}
40
+ end
@@ -0,0 +1,222 @@
1
+ require "spec_helper"
2
+
3
+ describe "Sequel::Plugins::Temporal" do
4
+ before :all do
5
+ DB.drop_table(:room_versions) if DB.table_exists?(:room_versions)
6
+ DB.drop_table(:rooms) if DB.table_exists?(:rooms)
7
+ DB.create_table! :rooms do
8
+ primary_key :id
9
+ end
10
+ DB.create_table! :room_versions do
11
+ primary_key :id
12
+ foreign_key :master_id, :rooms
13
+ String :name
14
+ Fixnum :price
15
+ Date :created_at
16
+ Date :expired_at
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 :temporal, version_class: closure
30
+ end
31
+ end
32
+ before do
33
+ Timecop.freeze 2009, 11, 28
34
+ @version_class.truncate
35
+ @master_class.truncate
36
+ end
37
+ after do
38
+ Timecop.return
39
+ end
40
+ it "checks version class is given" do
41
+ lambda{
42
+ @version_class.plugin :temporal
43
+ }.should raise_error Sequel::Error, "please specify version class to use for temporal plugin"
44
+ end
45
+ it "checks required columns are present" do
46
+ lambda{
47
+ @version_class.plugin :temporal, version_class: @master_class
48
+ }.should raise_error Sequel::Error, "temporal plugin requires the following missing columns on version class: master_id, 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 | current |
70
+ | Single Standard | 98 | 2009-11-28 | | true |
71
+ }
72
+ end
73
+ it "doesn't loose previous version in same-day update" do
74
+ master = @master_class.new
75
+ master.update_attributes name: "Single Standard", price: 98
76
+ master.update_attributes name: "Single Standard", price: 94
77
+ master.should have_versions %Q{
78
+ | name | price | created_at | expired_at | current |
79
+ | Single Standard | 98 | 2009-11-28 | 2009-11-28 | |
80
+ | Single Standard | 94 | 2009-11-28 | | true |
81
+ }
82
+ end
83
+ it "allows partial updating based on current version" do
84
+ master = @master_class.new
85
+ master.update_attributes name: "Single Standard", price: 98
86
+ master.update_attributes price: 94, partial_update: true
87
+ master.update_attributes name: "King Size", partial_update: true
88
+ master.should have_versions %Q{
89
+ | name | price | created_at | expired_at | current |
90
+ | Single Standard | 98 | 2009-11-28 | 2009-11-28 | |
91
+ | Single Standard | 94 | 2009-11-28 | 2009-11-28 | |
92
+ | King Size | 94 | 2009-11-28 | | true |
93
+ }
94
+ end
95
+ it "expires previous version but keep it in history" do
96
+ master = @master_class.new
97
+ master.update_attributes name: "Single Standard", price: 98
98
+ Timecop.freeze Date.today+1
99
+ master.update_attributes price: 94, partial_update: true
100
+ master.should have_versions %Q{
101
+ | name | price | created_at | expired_at | current |
102
+ | Single Standard | 98 | 2009-11-28 | 2009-11-29 | |
103
+ | Single Standard | 94 | 2009-11-29 | | true |
104
+ }
105
+ end
106
+ xit "doesn't do anything if unchanged" do
107
+ end
108
+ it "allows deleting current version" do
109
+ master = @master_class.new
110
+ master.update_attributes name: "Single Standard", price: 98
111
+ Timecop.freeze Date.today+1
112
+ master.destroy.should be_true
113
+ master.should have_versions %Q{
114
+ | name | price | created_at | expired_at | current |
115
+ | Single Standard | 98 | 2009-11-28 | 2009-11-29 | |
116
+ }
117
+ end
118
+ it "allows simultaneous updates without information loss" do
119
+ master = @master_class.new
120
+ master.update_attributes name: "Single Standard", price: 98
121
+ Timecop.freeze Date.today+1
122
+ master2 = @master_class.find id: master.id
123
+ master.update_attributes name: "Single Standard", price: 94
124
+ master2.update_attributes name: "Single Standard", price: 95
125
+ master.should have_versions %Q{
126
+ | name | price | created_at | expired_at | current |
127
+ | Single Standard | 98 | 2009-11-28 | 2009-11-29 | |
128
+ | Single Standard | 94 | 2009-11-29 | 2009-11-29 | |
129
+ | Single Standard | 95 | 2009-11-29 | | true |
130
+ }
131
+ end
132
+ it "allows simultaneous cumulative updates" do
133
+ master = @master_class.new
134
+ master.update_attributes name: "Single Standard", price: 98
135
+ Timecop.freeze Date.today+1
136
+ master2 = @master_class.find id: master.id
137
+ master.update_attributes price: 94, partial_update: true
138
+ master2.update_attributes name: "King Size", partial_update: true
139
+ master.should have_versions %Q{
140
+ | name | price | created_at | expired_at | current |
141
+ | Single Standard | 98 | 2009-11-28 | 2009-11-29 | |
142
+ | Single Standard | 94 | 2009-11-29 | 2009-11-29 | |
143
+ | King Size | 94 | 2009-11-29 | | true |
144
+ }
145
+ end
146
+ it "allows eager loading with conditions on current version" do
147
+ master = @master_class.new
148
+ master.update_attributes name: "Single Standard", price: 98
149
+ @master_class.eager_graph(:current_version).where("rooms_current_version.id IS NOT NULL").first.should be
150
+ Timecop.freeze Date.today+1
151
+ master.destroy
152
+ @master_class.eager_graph(:current_version).where("rooms_current_version.id IS NOT NULL").first.should be_nil
153
+ end
154
+ it "allows loading masters with a current version" do
155
+ master_destroyed = @master_class.new
156
+ master_destroyed.update_attributes name: "Single Standard", price: 98
157
+ master_destroyed.destroy
158
+ master_with_current = @master_class.new
159
+ master_with_current.update_attributes name: "Single Standard", price: 94
160
+ @master_class.with_current_version.all.should have(1).item
161
+ end
162
+ it "gets pending or current version attributes" do
163
+ master = @master_class.new
164
+ master.attributes.should == {}
165
+ master.pending_version.should be_nil
166
+ master.pending_or_current_version.should be_nil
167
+ master.update_attributes name: "Single Standard", price: 98
168
+ master.attributes[:name].should == "Single Standard"
169
+ master.pending_version.should be_nil
170
+ master.pending_or_current_version.name.should == "Single Standard"
171
+ master.attributes = {name: "King Size"}
172
+ master.attributes[:name].should == "King Size"
173
+ master.pending_version.should be
174
+ master.pending_or_current_version.name.should == "King Size"
175
+ end
176
+ it "allows to go back in time" do
177
+ master = @master_class.new
178
+ master.update_attributes name: "Single Standard", price: 98
179
+ Timecop.freeze Date.today+1
180
+ master.update_attributes price: 94, partial_update: true
181
+ master.should have_versions %Q{
182
+ | name | price | created_at | expired_at | current |
183
+ | Single Standard | 98 | 2009-11-28 | 2009-11-29 | |
184
+ | Single Standard | 94 | 2009-11-29 | | true |
185
+ }
186
+ master.current_version.price.should == 94
187
+ Sequel::Plugins::Temporal.at(Date.today-1) do
188
+ master.current_version(true).price.should == 98
189
+ end
190
+ end
191
+ it "delegates attributes from master to pending_or_current_version" do
192
+ master = @master_class.new
193
+ master.name.should be_nil
194
+ master.update_attributes name: "Single Standard", price: 98
195
+ master.name.should == "Single Standard"
196
+ master.attributes = {name: "King Size", partial_update: true}
197
+ master.name.should == "King Size"
198
+ end
199
+ it "avoids delegation with option delegate: false" do
200
+ closure = @version_class
201
+ without_delegation_class = Class.new Sequel::Model do
202
+ set_dataset :rooms
203
+ plugin :temporal, version_class: closure, delegate: false
204
+ end
205
+ master = without_delegation_class.new
206
+ expect{ master.name }.to raise_error NoMethodError
207
+ end
208
+ it "get current_version association name from class name" do
209
+ class MyNameVersion < Sequel::Model
210
+ set_dataset :room_versions
211
+ end
212
+ class MyName < Sequel::Model
213
+ set_dataset :rooms
214
+ plugin :temporal, version_class: MyNameVersion
215
+ end
216
+ expect do
217
+ MyName.eager_graph(:current_version).where("my_name_current_version.id IS NOT NULL").first
218
+ end.not_to raise_error
219
+ Object.send :remove_const, :MyName
220
+ Object.send :remove_const, :MyNameVersion
221
+ end
222
+ end
metadata ADDED
@@ -0,0 +1,122 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sequel_temporal
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Joseph HALTER
9
+ - Jonathan TRON
10
+ autorequire:
11
+ bindir: bin
12
+ cert_chain: []
13
+ date: 2011-12-08 00:00:00.000000000 Z
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: sequel
17
+ requirement: &2151890200 !ruby/object:Gem::Requirement
18
+ none: false
19
+ requirements:
20
+ - - ~>
21
+ - !ruby/object:Gem::Version
22
+ version: 3.30.0
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: *2151890200
26
+ - !ruby/object:Gem::Dependency
27
+ name: sqlite3
28
+ requirement: &2151889020 !ruby/object:Gem::Requirement
29
+ none: false
30
+ requirements:
31
+ - - ! '>='
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: *2151889020
37
+ - !ruby/object:Gem::Dependency
38
+ name: rspec
39
+ requirement: &2151887200 !ruby/object:Gem::Requirement
40
+ none: false
41
+ requirements:
42
+ - - ~>
43
+ - !ruby/object:Gem::Version
44
+ version: 2.8.0.rc1
45
+ type: :development
46
+ prerelease: false
47
+ version_requirements: *2151887200
48
+ - !ruby/object:Gem::Dependency
49
+ name: timecop
50
+ requirement: &2151902600 !ruby/object:Gem::Requirement
51
+ none: false
52
+ requirements:
53
+ - - ! '>='
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ type: :development
57
+ prerelease: false
58
+ version_requirements: *2151902600
59
+ - !ruby/object:Gem::Dependency
60
+ name: rake
61
+ requirement: &2151901800 !ruby/object:Gem::Requirement
62
+ none: false
63
+ requirements:
64
+ - - ! '>='
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: *2151901800
70
+ description: Temporal versioning for sequel, fully tested.
71
+ email:
72
+ - joseph.halter@thetalentbox.com
73
+ - jonathan.tron@thetalentbox.com
74
+ executables: []
75
+ extensions: []
76
+ extra_rdoc_files: []
77
+ files:
78
+ - .gitignore
79
+ - .travis.yml
80
+ - Gemfile
81
+ - README.md
82
+ - Rakefile
83
+ - lib/sequel/plugins/temporal.rb
84
+ - lib/sequel_temporal.rb
85
+ - sequel_temporal.gemspec
86
+ - spec/spec_helper.rb
87
+ - spec/support/temporal_matchers.rb
88
+ - spec/temporal_spec.rb
89
+ homepage: https://github.com/TalentBox/sequel_temporal
90
+ licenses: []
91
+ post_install_message:
92
+ rdoc_options: []
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ none: false
97
+ requirements:
98
+ - - ! '>='
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ segments:
102
+ - 0
103
+ hash: -1369786071214787930
104
+ required_rubygems_version: !ruby/object:Gem::Requirement
105
+ none: false
106
+ requirements:
107
+ - - ! '>='
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ segments:
111
+ - 0
112
+ hash: -1369786071214787930
113
+ requirements: []
114
+ rubyforge_project:
115
+ rubygems_version: 1.8.10
116
+ signing_key:
117
+ specification_version: 3
118
+ summary: Temporal versioning for sequel.
119
+ test_files:
120
+ - spec/spec_helper.rb
121
+ - spec/support/temporal_matchers.rb
122
+ - spec/temporal_spec.rb