chrono_model 0.5.3 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -10,6 +10,10 @@ module ChronoTest::Matchers
10
10
  @schema = schema
11
11
  end
12
12
 
13
+ def description
14
+ 'have index'
15
+ end
16
+
13
17
  def matches?(table)
14
18
  super(table)
15
19
 
@@ -10,6 +10,10 @@ module ChronoTest::Matchers
10
10
  @expected = '"$user", public' if @expected == :default
11
11
  end
12
12
 
13
+ def description
14
+ 'be in schema'
15
+ end
16
+
13
17
  def failure_message_for_should
14
18
  "expected to be in schema #@expected, but was in #@current"
15
19
  end
@@ -35,6 +35,10 @@ module ChronoTest::Matchers
35
35
  relation_exists? :in => public_schema
36
36
  end
37
37
 
38
+ def description
39
+ 'be in the public schema'
40
+ end
41
+
38
42
  def failure_message_for_should
39
43
  "expected #{table} to exist in the #{public_schema} schema"
40
44
  end
@@ -55,6 +59,10 @@ module ChronoTest::Matchers
55
59
  relation_exists? :in => temporal_schema
56
60
  end
57
61
 
62
+ def description
63
+ 'be in the temporal schema'
64
+ end
65
+
58
66
  def failure_message_for_should
59
67
  "expected #{table} to exist in the #{temporal_schema} schema"
60
68
  end
@@ -73,14 +81,23 @@ module ChronoTest::Matchers
73
81
  def matches?(table)
74
82
  super(table)
75
83
 
76
- table_exists? && inherits_from_temporal?
84
+ table_exists? &&
85
+ inherits_from_temporal? &&
86
+ has_consistency_constraint? &&
87
+ has_history_indexes?
88
+ end
89
+
90
+ def description
91
+ 'be in history schema'
77
92
  end
78
93
 
79
94
  def failure_message_for_should
80
95
  "expected #{table} ".tap do |message|
81
96
  message << [
82
97
  ("to exist in the #{history_schema} schema" unless @existance),
83
- ("to inherit from #{temporal_schema}.#{table}" unless @inheritance)
98
+ ("to inherit from #{temporal_schema}.#{table}" unless @inheritance),
99
+ ("to have a timeline consistency constraint" unless @constraint),
100
+ ("to have history indexes" unless @indexes)
84
101
  ].compact.to_sentence
85
102
  end
86
103
  end
@@ -101,6 +118,58 @@ module ChronoTest::Matchers
101
118
  )
102
119
  SQL
103
120
  end
121
+
122
+ def has_history_indexes?
123
+ binds = [ history_schema, table ]
124
+
125
+ indexes = select_values(<<-SQL, binds, 'Check history indexes')
126
+ SELECT indexdef FROM pg_indexes
127
+ WHERE schemaname = $1
128
+ AND tablename = $2
129
+ SQL
130
+
131
+ fqtn = [history_schema, table].join('.')
132
+
133
+ expected = [
134
+ "CREATE INDEX index_#{table}_temporal_on_lower_validity ON #{fqtn} USING btree (lower(validity))",
135
+ "CREATE INDEX index_#{table}_temporal_on_upper_validity ON #{fqtn} USING btree (upper(validity))",
136
+ "CREATE INDEX index_#{table}_temporal_on_validity ON #{fqtn} USING gist (validity)",
137
+
138
+ "CREATE INDEX #{table}_inherit_pkey ON #{fqtn} USING btree (id)",
139
+ "CREATE INDEX #{table}_instance_history ON #{fqtn} USING btree (id, recorded_at)",
140
+ "CREATE UNIQUE INDEX #{table}_pkey ON #{fqtn} USING btree (hid)",
141
+ "CREATE INDEX #{table}_recorded_at ON #{fqtn} USING btree (recorded_at)",
142
+ "CREATE INDEX #{table}_timeline_consistency ON #{fqtn} USING gist (id, validity)"
143
+ ]
144
+
145
+ @indexes = (expected - indexes).empty?
146
+ end
147
+
148
+ def has_consistency_constraint?
149
+ binds = [
150
+ connection.timeline_consistency_constraint_name(table), # conname
151
+ history_schema, # connamespace
152
+ [history_schema, table].join('.'), # conrelid, attrelid
153
+ connection.primary_key(table) # attnum
154
+ ]
155
+
156
+ @constraint = select_value(<<-SQL, binds, 'Check Consistency Constraint') == 't'
157
+ SELECT EXISTS (
158
+ SELECT 1 FROM pg_catalog.pg_constraint
159
+ WHERE conname = $1
160
+ AND contype = 'x'
161
+ AND conrelid = $3::regclass
162
+ AND connamespace = (
163
+ SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = $2
164
+ )
165
+ AND conkey = (
166
+ SELECT array_agg(attnum) FROM pg_catalog.pg_attribute
167
+ WHERE attname IN ($4, 'validity')
168
+ AND attrelid = $3::regclass
169
+ )
170
+ )
171
+ SQL
172
+ end
104
173
  end
105
174
 
106
175
  def have_history_backing
@@ -110,23 +179,27 @@ module ChronoTest::Matchers
110
179
 
111
180
  # ##################################################################
112
181
  # Checks that a table exists in the Public schema, is an updatable
113
- # view and has an INSERT, UPDATE and DELETE rule.
182
+ # view and has an INSERT, UPDATE and DELETE triggers.
114
183
  #
115
184
  class HavePublicInterface < Base
116
185
  def matches?(table)
117
186
  super(table)
118
187
 
119
- view_exists? && [ is_updatable?, has_rules? ].all?
188
+ view_exists? && [ is_updatable?, has_triggers? ].all?
189
+ end
190
+
191
+ def description
192
+ 'be an updatable view'
120
193
  end
121
194
 
122
195
  def failure_message_for_should
123
196
  "expected #{table} ".tap do |message|
124
197
  message << [
125
- ("to exist in the #{public_schema} schema" unless @existance ),
126
- ('to be an updatable view' unless @updatable ),
127
- ('to have an INSERT rule' unless @insert_rule),
128
- ('to have an UPDATE rule' unless @update_rule),
129
- ('to have a DELETE rule' unless @delete_rule)
198
+ ("to exist in the #{public_schema} schema" unless @existance ),
199
+ ('to be an updatable view' unless @updatable ),
200
+ ('to have an INSERT trigger' unless @insert_trigger),
201
+ ('to have an UPDATE trigger' unless @update_trigger),
202
+ ('to have a DELETE trigger' unless @delete_trigger)
130
203
  ].compact.to_sentence
131
204
  end
132
205
  end
@@ -145,21 +218,21 @@ module ChronoTest::Matchers
145
218
  SQL
146
219
  end
147
220
 
148
- def has_rules?
149
- rules = select_values(<<-SQL, [ public_schema, table ], 'Check rules')
150
- SELECT UNNEST(REGEXP_MATCHES(
151
- definition, 'ON (INSERT|UPDATE|DELETE) TO #{table} DO INSTEAD'
152
- ))
153
- FROM pg_catalog.pg_rules
154
- WHERE schemaname = $1
155
- AND tablename = $2
221
+ def has_triggers?
222
+ triggers = select_values(<<-SQL, [ public_schema, table ], 'Check triggers')
223
+ SELECT t.tgname
224
+ FROM pg_catalog.pg_trigger t, pg_catalog.pg_class c, pg_catalog.pg_namespace n
225
+ WHERE t.tgrelid = c.relfilenode
226
+ AND n.oid = c.relnamespace
227
+ AND n.nspname = $1
228
+ AND c.relname = $2;
156
229
  SQL
157
230
 
158
- @insert_rule = rules.include? 'INSERT'
159
- @update_rule = rules.include? 'UPDATE'
160
- @delete_rule = rules.include? 'DELETE'
231
+ @insert_trigger = triggers.include? 'chronomodel_insert'
232
+ @update_trigger = triggers.include? 'chronomodel_update'
233
+ @delete_trigger = triggers.include? 'chronomodel_delete'
161
234
 
162
- @insert_rule && @update_rule && @delete_rule
235
+ @insert_trigger && @update_trigger && @delete_trigger
163
236
  end
164
237
  end
165
238
 
@@ -24,6 +24,18 @@ describe ChronoModel::TimeMachine do
24
24
  #
25
25
  baz = Baz.create :name => 'baz', :bar => bar
26
26
 
27
+ describe '.chrono?' do
28
+ context 'on a temporal model' do
29
+ subject { Foo }
30
+ it { should be_chrono }
31
+ end
32
+
33
+ context 'on a plain model' do
34
+ subject { Plain }
35
+ it { should_not be_chrono }
36
+ end
37
+ end
38
+
27
39
  # Specs start here
28
40
  #
29
41
  describe '.chrono_models' do
@@ -164,12 +176,15 @@ describe ChronoModel::TimeMachine do
164
176
  end
165
177
 
166
178
  describe 'does not add as_of_time when there are aggregates' do
167
- it { foo.history.select('max (id)').to_sql.should_not =~ /as_of_time/ }
168
- it { foo.history.select('max (id) as foo, min(id) as bar').first.attributes.keys.should == %w( foo bar ) }
179
+ it { foo.history.select('max (id)').to_sql.should_not =~ /as_of_time/ } # The id is automatically added by ActiveRecord
180
+ it { foo.history.select('max(id) as foo, min(id) as bar').order(nil).first.attributes.keys.should == %w( foo bar id ) }
169
181
  end
170
182
 
171
- describe 'orders by recorded_at, hid by default' do
172
- it { foo.history.to_sql.should =~ /order by.*recorded_at,.*hid/i }
183
+ context 'with STI models' do
184
+ pub = ts_eval { Publication.create! :title => 'wrong title' }
185
+ ts_eval(pub) { update_attributes! :title => 'correct title' }
186
+
187
+ it { pub.history.map(&:title).should == ['wrong title', 'correct title'] }
173
188
  end
174
189
 
175
190
  describe 'allows a custom order list' do
@@ -177,12 +192,12 @@ describe ChronoModel::TimeMachine do
177
192
  it { foo.history.order('id').to_sql.should =~ /order by id/i }
178
193
  end
179
194
 
180
- context 'with STI models' do
181
- pub = ts_eval { Publication.create! :title => 'wrong title' }
182
- ts_eval(pub) { update_attributes! :title => 'correct title' }
183
-
184
- it { pub.history.map(&:title).should == ['wrong title', 'correct title'] }
195
+ context '.sorted' do
196
+ describe 'orders by recorded_at, hid' do
197
+ it { foo.history.sorted.to_sql.should =~ /order by .+"recorded_at", .+"hid"/i }
198
+ end
185
199
  end
200
+
186
201
  end
187
202
 
188
203
  describe '#pred' do
@@ -303,11 +318,10 @@ describe ChronoModel::TimeMachine do
303
318
 
304
319
  timestamps_from = lambda {|*records|
305
320
  ts = records.map(&:history).flatten!.inject([]) {|ret, rec|
306
- ret.concat [
307
- [rec.valid_from.to_i, rec.valid_from.usec + 2],
308
- [rec.valid_to.to_i, rec.valid_to.usec + 2]
309
- ]
310
- }.sort.uniq[0..-2]
321
+ ret.push [rec.valid_from.to_i, rec.valid_from.usec] if rec.try(:valid_from)
322
+ ret.push [rec.valid_to .to_i, rec.valid_to .usec] if rec.try(:valid_to)
323
+ ret
324
+ }.sort.uniq
311
325
  }
312
326
 
313
327
  describe 'on records having an :has_many relationship' do
@@ -425,31 +439,44 @@ describe ChronoModel::TimeMachine do
425
439
  end
426
440
  end
427
441
 
428
- context do
429
- let!(:history) { foo.history.first }
430
- let!(:current) { foo }
442
+ describe 'timestamp methods' do
443
+ history_methods = %w( valid_from valid_to recorded_at )
444
+ current_methods = %w( as_of_time )
431
445
 
432
- spec = lambda {|attr|
433
- return lambda {|*|
434
- describe 'on history records' do
435
- subject { history.public_send(attr) }
446
+ context 'on history records' do
447
+ let(:record) { foo.history.first }
448
+
449
+ (history_methods + current_methods).each do |attr|
450
+ describe ['#', attr].join do
451
+ subject { record.public_send(attr) }
436
452
 
437
453
  it { should be_present }
438
454
  it { should be_a(Time) }
439
455
  it { should be_utc }
440
456
  end
457
+ end
458
+ end
441
459
 
442
- describe 'on current records' do
443
- subject { current.public_send(attr) }
460
+ context 'on current records' do
461
+ let(:record) { foo }
444
462
 
445
- it { should be_nil }
463
+ history_methods.each do |attr|
464
+ describe ['#', attr].join do
465
+ subject { record.public_send(attr) }
466
+
467
+ it { expect { subject }.to raise_error(NoMethodError) }
446
468
  end
447
- }
448
- }
469
+ end
449
470
 
450
- %w( valid_from valid_to recorded_at as_of_time ).each do |attr|
451
- describe ['#', attr].join, &spec.call(attr)
471
+ current_methods.each do |attr|
472
+ describe ['#', attr].join do
473
+ subject { record.public_send(attr) }
474
+
475
+ it { should be_nil }
476
+ end
477
+ end
452
478
  end
479
+
453
480
  end
454
481
 
455
482
  # Class methods
@@ -507,6 +534,13 @@ describe ChronoModel::TimeMachine do
507
534
  it { Foo.history.all.map(&:name).should == foo_history }
508
535
  it { Bar.history.all.map(&:name).should == bar_history }
509
536
  end
537
+
538
+ describe '.time_query' do
539
+ it { Foo.history.time_query(:after, :now).count.should == 3 }
540
+ it { Foo.history.time_query(:before, :now).count.should == 5 }
541
+ it { Foo.history.past.size.should == 2 }
542
+ end
543
+
510
544
  end
511
545
 
512
546
  # Transactions
@@ -0,0 +1,227 @@
1
+ require 'spec_helper'
2
+ require 'support/helpers'
3
+
4
+ describe ChronoModel::TimeMachine::TimeQuery do
5
+ include ChronoTest::Helpers::TimeMachine
6
+
7
+ setup_schema!
8
+ define_models!
9
+
10
+ # Create a set of events
11
+ #
12
+ think = Event.create! name: 'think', interval: (15.days.ago.to_date...13.days.ago.to_date)
13
+ plan = Event.create! name: 'plan', interval: (14.days.ago.to_date...12.days.ago.to_date)
14
+ collect = Event.create! name: 'collect', interval: (12.days.ago.to_date...10.days.ago.to_date)
15
+ start = Event.create! name: 'start', interval: (8.days.ago.to_date...7.days.ago.to_date)
16
+ build = Event.create! name: 'build', interval: (7.days.ago.to_date...Date.yesterday)
17
+ profit = Event.create! name: 'profit', interval: (Date.tomorrow...1.year.from_now.to_date)
18
+
19
+ describe :at do
20
+ describe 'with a single timestamp' do
21
+ subject { Event.time_query(:at, time.try(:to_date) || time, on: :interval).to_a }
22
+
23
+ context 'no records' do
24
+ let(:time) { 16.days.ago }
25
+ it { should be_empty }
26
+ end
27
+
28
+ context 'single record' do
29
+ let(:time) { 15.days.ago }
30
+ it { should == [think] }
31
+ end
32
+
33
+ context 'multiple overlapping records' do
34
+ let(:time) { 14.days.ago }
35
+ it { should =~ [think, plan] }
36
+ end
37
+
38
+ context 'on an edge of an open interval' do
39
+ let(:time) { 10.days.ago }
40
+ it { should be_empty }
41
+ end
42
+
43
+ context 'in an hole' do
44
+ let(:time) { 9.days.ago }
45
+ it { should be_empty }
46
+ end
47
+
48
+ context 'today' do
49
+ let(:time) { Date.today }
50
+ it { should be_empty }
51
+ end
52
+
53
+ context 'server-side :today' do
54
+ let(:time) { :today }
55
+ it { should be_empty }
56
+ end
57
+ end
58
+
59
+ describe 'with a range' do
60
+ subject { Event.time_query(:at, times.map!(&:to_date), on: :interval, type: :daterange).to_a }
61
+
62
+ context 'that is empty' do
63
+ let(:times) { [ 14.days.ago, 14.days.ago ] }
64
+ it { should be_empty }
65
+ end
66
+
67
+ context 'overlapping no records' do
68
+ let(:times) { [ 20.days.ago, 16.days.ago ] }
69
+ it { should be_empty }
70
+ end
71
+
72
+ context 'overlapping a single record' do
73
+ let(:times) { [ 16.days.ago, 14.days.ago ] }
74
+ it { should == [think] }
75
+ end
76
+
77
+ context 'overlapping more records' do
78
+ let(:times) { [ 16.days.ago, 11.days.ago ] }
79
+ it { should =~ [think, plan, collect] }
80
+ end
81
+
82
+ context 'on the edge of an open interval and an hole' do
83
+ let(:times) { [ 10.days.ago, 9.days.ago ] }
84
+ it { should be_empty }
85
+ end
86
+ end
87
+ end
88
+
89
+ describe :before do
90
+ subject { Event.time_query(:before, time.try(:to_date) || time, on: :interval, type: :daterange).to_a }
91
+
92
+ context '16 days ago' do
93
+ let(:time) { 16.days.ago }
94
+ it { should be_empty }
95
+ end
96
+
97
+ context '14 days ago' do
98
+ let(:time) { 14.days.ago }
99
+ it { should == [think] }
100
+ end
101
+
102
+ context '11 days ago' do
103
+ let(:time) { 11.days.ago }
104
+ it { should =~ [think, plan, collect] }
105
+ end
106
+
107
+ context '10 days ago' do
108
+ let(:time) { 10.days.ago }
109
+ it { should =~ [think, plan, collect] }
110
+ end
111
+
112
+ context '8 days ago' do
113
+ let(:time) { 8.days.ago }
114
+ it { should =~ [think, plan, collect] }
115
+ end
116
+
117
+ context 'today' do
118
+ let(:time) { Date.today }
119
+ it { should =~ [think, plan, collect, start, build] }
120
+ end
121
+
122
+ context ':today' do
123
+ let(:time) { :today }
124
+ it { should =~ [think, plan, collect, start, build] }
125
+ end
126
+ end
127
+
128
+ describe :after do
129
+ subject { Event.time_query(:after, time.try(:to_date) || time, on: :interval, type: :daterange).to_a }
130
+
131
+ context 'one month ago' do
132
+ let(:time) { 1.month.ago }
133
+ it { should =~ [think, plan, collect, start, build, profit] }
134
+ end
135
+
136
+ context '10 days ago' do
137
+ let(:time) { 10.days.ago }
138
+ it { should =~ [start, build, profit] }
139
+ end
140
+
141
+ context 'yesterday' do
142
+ let(:time) { Date.yesterday }
143
+ it { should == [profit] }
144
+ end
145
+
146
+ context 'today' do
147
+ let(:time) { Date.today }
148
+ it { should == [profit] }
149
+ end
150
+
151
+ context 'server-side :today' do
152
+ let(:time) { :today }
153
+ it { should == [profit] }
154
+ end
155
+
156
+ context 'tomorrow' do
157
+ let(:time) { Date.tomorrow }
158
+ it { should == [profit] }
159
+ end
160
+
161
+ context 'one month from now' do
162
+ let(:time) { 1.month.from_now }
163
+ it { should == [profit] }
164
+ end
165
+
166
+ context 'far future' do
167
+ let(:time) { 1.year.from_now }
168
+ it { should be_empty }
169
+ end
170
+ end
171
+
172
+ describe :not do
173
+ context 'with a single timestamp' do
174
+ subject { Event.time_query(:not, time.try(:to_date) || time, on: :interval, type: :daterange).to_a }
175
+
176
+ context '14 days ago' do
177
+ let(:time) { 14.days.ago }
178
+ it { should =~ [collect, start, build, profit] }
179
+ end
180
+
181
+ context '9 days ago' do
182
+ let(:time) { 9.days.ago }
183
+ it { should =~ [think, plan, collect, start, build, profit] }
184
+ end
185
+
186
+ context '8 days ago' do
187
+ let(:time) { 8.days.ago }
188
+ it { should =~ [think, plan, collect, build, profit] }
189
+ end
190
+
191
+ context 'today' do
192
+ let(:time) { Date.today }
193
+ it { should =~ [think, plan, collect, start, build, profit] }
194
+ end
195
+
196
+ context ':today' do
197
+ let(:time) { :today }
198
+ it { should =~ [think, plan, collect, start, build, profit] }
199
+ end
200
+
201
+ context '1 month from now' do
202
+ let(:time) { 1.month.from_now }
203
+ it { should =~ [think, plan, collect, start, build] }
204
+ end
205
+ end
206
+
207
+ context 'with a range' do
208
+ subject { Event.time_query(:not, time.map(&:to_date), on: :interval, type: :daterange).to_a }
209
+
210
+ context 'eliminating a single record' do
211
+ let(:time) { [1.month.ago, 14.days.ago] }
212
+ it { should =~ [plan, collect, start, build, profit] }
213
+ end
214
+
215
+ context 'eliminating multiple records' do
216
+ let(:time) { [1.month.ago, Date.today] }
217
+ it { should == [profit] }
218
+ end
219
+
220
+ context 'from an edge' do
221
+ let(:time) { [14.days.ago, 10.days.ago] }
222
+ it { should == [start, build, profit] }
223
+ end
224
+ end
225
+ end
226
+
227
+ end