chrono_model 0.5.3 → 0.8.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.
@@ -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