sequel_temporal 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.
@@ -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