chrono_model 0.3.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 +19 -0
- data/.rspec +2 -0
- data/Gemfile +10 -0
- data/LICENSE +22 -0
- data/README.md +173 -0
- data/README.sql +101 -0
- data/Rakefile +7 -0
- data/chrono_model.gemspec +20 -0
- data/lib/chrono_model.rb +34 -0
- data/lib/chrono_model/adapter.rb +423 -0
- data/lib/chrono_model/compatibility.rb +31 -0
- data/lib/chrono_model/patches.rb +104 -0
- data/lib/chrono_model/railtie.rb +41 -0
- data/lib/chrono_model/time_machine.rb +214 -0
- data/lib/chrono_model/version.rb +3 -0
- data/spec/adapter_spec.rb +398 -0
- data/spec/config.yml.example +7 -0
- data/spec/spec_helper.rb +22 -0
- data/spec/support/connection.rb +74 -0
- data/spec/support/helpers.rb +123 -0
- data/spec/support/matchers/base.rb +59 -0
- data/spec/support/matchers/column.rb +83 -0
- data/spec/support/matchers/index.rb +61 -0
- data/spec/support/matchers/schema.rb +31 -0
- data/spec/support/matchers/table.rb +171 -0
- data/spec/time_machine_spec.rb +299 -0
- metadata +105 -0
@@ -0,0 +1,171 @@
|
|
1
|
+
require 'support/matchers/base'
|
2
|
+
|
3
|
+
module ChronoTest::Matchers
|
4
|
+
|
5
|
+
module Table
|
6
|
+
class Base < ChronoTest::Matchers::Base
|
7
|
+
|
8
|
+
protected
|
9
|
+
# Database statements
|
10
|
+
#
|
11
|
+
def relation_exists?(options)
|
12
|
+
schema = options[:in]
|
13
|
+
kind = options[:kind] == :view ? 'v' : 'r'
|
14
|
+
|
15
|
+
select_value(<<-SQL, [ table, schema ], 'Check table exists') == 't'
|
16
|
+
SELECT EXISTS (
|
17
|
+
SELECT 1
|
18
|
+
FROM pg_class c
|
19
|
+
LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
|
20
|
+
WHERE c.relkind = '#{kind}'
|
21
|
+
AND c.relname = $1
|
22
|
+
AND n.nspname = $2
|
23
|
+
)
|
24
|
+
SQL
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# ##################################################################
|
29
|
+
# Checks that a table exists in the Public schema
|
30
|
+
#
|
31
|
+
class HavePublicBacking < Base
|
32
|
+
def matches?(table)
|
33
|
+
super(table)
|
34
|
+
|
35
|
+
relation_exists? :in => public_schema
|
36
|
+
end
|
37
|
+
|
38
|
+
def failure_message_for_should
|
39
|
+
"expected #{table} to exist in the #{public_schema} schema"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def have_public_backing
|
44
|
+
HavePublicBacking.new
|
45
|
+
end
|
46
|
+
|
47
|
+
|
48
|
+
# ##################################################################
|
49
|
+
# Checks that a table exists in the Temporal schema
|
50
|
+
#
|
51
|
+
class HaveTemporalBacking < Base
|
52
|
+
def matches?(table)
|
53
|
+
super(table)
|
54
|
+
|
55
|
+
relation_exists? :in => temporal_schema
|
56
|
+
end
|
57
|
+
|
58
|
+
def failure_message_for_should
|
59
|
+
"expected #{table} to exist in the #{temporal_schema} schema"
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def have_temporal_backing
|
64
|
+
HaveTemporalBacking.new
|
65
|
+
end
|
66
|
+
|
67
|
+
|
68
|
+
# ##################################################################
|
69
|
+
# Checks that a table exists in the History schema and inherits from
|
70
|
+
# the one in the Temporal schema
|
71
|
+
#
|
72
|
+
class HaveHistoryBacking < Base
|
73
|
+
def matches?(table)
|
74
|
+
super(table)
|
75
|
+
|
76
|
+
table_exists? && inherits_from_temporal?
|
77
|
+
end
|
78
|
+
|
79
|
+
def failure_message_for_should
|
80
|
+
"expected #{table} ".tap do |message|
|
81
|
+
message << [
|
82
|
+
("to exist in the #{history_schema} schema" unless @existance),
|
83
|
+
("to inherit from #{temporal_schema}.#{table}" unless @inheritance)
|
84
|
+
].compact.to_sentence
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
private
|
89
|
+
def table_exists?
|
90
|
+
@existance = relation_exists? :in => history_schema
|
91
|
+
end
|
92
|
+
|
93
|
+
def inherits_from_temporal?
|
94
|
+
binds = ["#{history_schema}.#{table}", "#{temporal_schema}.#{table}"]
|
95
|
+
|
96
|
+
@inheritance = select_value(<<-SQL, binds, 'Check inheritance') == 't'
|
97
|
+
SELECT EXISTS (
|
98
|
+
SELECT 1 FROM pg_catalog.pg_inherits
|
99
|
+
WHERE inhrelid = $1::regclass::oid
|
100
|
+
AND inhparent = $2::regclass::oid
|
101
|
+
)
|
102
|
+
SQL
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def have_history_backing
|
107
|
+
HaveHistoryBacking.new
|
108
|
+
end
|
109
|
+
|
110
|
+
|
111
|
+
# ##################################################################
|
112
|
+
# Checks that a table exists in the Public schema, is an updatable
|
113
|
+
# view and has an INSERT, UPDATE and DELETE rule.
|
114
|
+
#
|
115
|
+
class HavePublicInterface < Base
|
116
|
+
def matches?(table)
|
117
|
+
super(table)
|
118
|
+
|
119
|
+
view_exists? && [ is_updatable?, has_rules? ].all?
|
120
|
+
end
|
121
|
+
|
122
|
+
def failure_message_for_should
|
123
|
+
"expected #{table} ".tap do |message|
|
124
|
+
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)
|
130
|
+
].compact.to_sentence
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
private
|
135
|
+
def view_exists?
|
136
|
+
@existance = relation_exists? :in => public_schema, :kind => :view
|
137
|
+
end
|
138
|
+
|
139
|
+
def is_updatable?
|
140
|
+
binds = [ public_schema, table ]
|
141
|
+
|
142
|
+
@updatable = select_value(<<-SQL, binds, 'Check updatable') == 'YES'
|
143
|
+
SELECT is_updatable FROM information_schema.views
|
144
|
+
WHERE table_schema = $1 AND table_name = $2
|
145
|
+
SQL
|
146
|
+
end
|
147
|
+
|
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
|
156
|
+
SQL
|
157
|
+
|
158
|
+
@insert_rule = rules.include? 'INSERT'
|
159
|
+
@update_rule = rules.include? 'UPDATE'
|
160
|
+
@delete_rule = rules.include? 'DELETE'
|
161
|
+
|
162
|
+
@insert_rule && @update_rule && @delete_rule
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
def have_public_interface
|
167
|
+
HavePublicInterface.new
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
end
|
@@ -0,0 +1,299 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'support/helpers'
|
3
|
+
|
4
|
+
describe ChronoModel::TimeMachine do
|
5
|
+
include ChronoTest::Helpers::TimeMachine
|
6
|
+
|
7
|
+
setup_schema!
|
8
|
+
define_models!
|
9
|
+
|
10
|
+
describe '.chrono_models' do
|
11
|
+
subject { ChronoModel::TimeMachine.chrono_models }
|
12
|
+
|
13
|
+
it { should == {'foos' => Foo, 'bars' => Bar} }
|
14
|
+
end
|
15
|
+
|
16
|
+
|
17
|
+
# Set up two associated records, with intertwined updates
|
18
|
+
#
|
19
|
+
let!(:foo) {
|
20
|
+
foo = ts_eval { Foo.create! :name => 'foo', :fooity => 1 }
|
21
|
+
ts_eval(foo) { update_attributes! :name => 'foo bar' }
|
22
|
+
}
|
23
|
+
|
24
|
+
let!(:bar) {
|
25
|
+
bar = ts_eval { Bar.create! :name => 'bar', :foo => foo }
|
26
|
+
ts_eval(bar) { update_attributes! :name => 'foo bar' }
|
27
|
+
|
28
|
+
ts_eval(foo) { update_attributes! :name => 'new foo' }
|
29
|
+
|
30
|
+
ts_eval(bar) { update_attributes! :name => 'bar bar' }
|
31
|
+
ts_eval(bar) { update_attributes! :name => 'new bar' }
|
32
|
+
}
|
33
|
+
|
34
|
+
# Specs start here
|
35
|
+
#
|
36
|
+
describe '#as_of' do
|
37
|
+
describe 'accepts a Time instance' do
|
38
|
+
it { foo.as_of(Time.now).name.should == 'new foo' }
|
39
|
+
it { bar.as_of(Time.now).name.should == 'new bar' }
|
40
|
+
end
|
41
|
+
|
42
|
+
describe 'ignores time zones' do
|
43
|
+
it { foo.as_of(Time.now.in_time_zone('America/Havana')).name.should == 'new foo' }
|
44
|
+
it { bar.as_of(Time.now.in_time_zone('America/Havana')).name.should == 'new bar' }
|
45
|
+
end
|
46
|
+
|
47
|
+
describe 'returns records as they were before' do
|
48
|
+
it { foo.as_of(foo.ts[0]).name.should == 'foo' }
|
49
|
+
it { foo.as_of(foo.ts[1]).name.should == 'foo bar' }
|
50
|
+
it { foo.as_of(foo.ts[2]).name.should == 'new foo' }
|
51
|
+
|
52
|
+
it { bar.as_of(bar.ts[0]).name.should == 'bar' }
|
53
|
+
it { bar.as_of(bar.ts[1]).name.should == 'foo bar' }
|
54
|
+
it { bar.as_of(bar.ts[2]).name.should == 'bar bar' }
|
55
|
+
it { bar.as_of(bar.ts[3]).name.should == 'new bar' }
|
56
|
+
end
|
57
|
+
|
58
|
+
describe 'takes care of associated records' do
|
59
|
+
it { foo.as_of(foo.ts[0]).bars.should == [] }
|
60
|
+
it { foo.as_of(foo.ts[1]).bars.should == [] }
|
61
|
+
it { foo.as_of(foo.ts[2]).bars.should == [bar] }
|
62
|
+
|
63
|
+
it { foo.as_of(foo.ts[2]).bars.first.name.should == 'foo bar' }
|
64
|
+
|
65
|
+
|
66
|
+
it { foo.as_of(bar.ts[0]).bars.should == [bar] }
|
67
|
+
it { foo.as_of(bar.ts[1]).bars.should == [bar] }
|
68
|
+
it { foo.as_of(bar.ts[2]).bars.should == [bar] }
|
69
|
+
it { foo.as_of(bar.ts[3]).bars.should == [bar] }
|
70
|
+
|
71
|
+
it { foo.as_of(bar.ts[0]).bars.first.name.should == 'bar' }
|
72
|
+
it { foo.as_of(bar.ts[1]).bars.first.name.should == 'foo bar' }
|
73
|
+
it { foo.as_of(bar.ts[2]).bars.first.name.should == 'bar bar' }
|
74
|
+
it { foo.as_of(bar.ts[3]).bars.first.name.should == 'new bar' }
|
75
|
+
|
76
|
+
|
77
|
+
it { bar.as_of(bar.ts[0]).foo.should == foo }
|
78
|
+
it { bar.as_of(bar.ts[1]).foo.should == foo }
|
79
|
+
it { bar.as_of(bar.ts[2]).foo.should == foo }
|
80
|
+
it { bar.as_of(bar.ts[3]).foo.should == foo }
|
81
|
+
|
82
|
+
it { bar.as_of(bar.ts[0]).foo.name.should == 'foo bar' }
|
83
|
+
it { bar.as_of(bar.ts[1]).foo.name.should == 'foo bar' }
|
84
|
+
it { bar.as_of(bar.ts[2]).foo.name.should == 'new foo' }
|
85
|
+
it { bar.as_of(bar.ts[3]).foo.name.should == 'new foo' }
|
86
|
+
end
|
87
|
+
|
88
|
+
it 'raises RecordNotFound when no history records are found' do
|
89
|
+
expect { foo.as_of(1.minute.ago) }.to raise_error
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
describe '#history' do
|
94
|
+
describe 'returns historical instances' do
|
95
|
+
it { foo.history.should have(3).entries }
|
96
|
+
it { foo.history.map(&:name).should == ['foo', 'foo bar', 'new foo'] }
|
97
|
+
|
98
|
+
it { bar.history.should have(4).entries }
|
99
|
+
it { bar.history.map(&:name).should == ['bar', 'foo bar', 'bar bar', 'new bar'] }
|
100
|
+
end
|
101
|
+
|
102
|
+
describe 'returns read only records' do
|
103
|
+
it { foo.history.all?(&:readonly?).should be_true }
|
104
|
+
it { bar.history.all?(&:readonly?).should be_true }
|
105
|
+
end
|
106
|
+
|
107
|
+
describe 'takes care of associated records' do
|
108
|
+
subject { foo.history.map {|f| f.bars.first.try(:name)} }
|
109
|
+
it { should == [nil, 'foo bar', 'new bar'] }
|
110
|
+
end
|
111
|
+
|
112
|
+
describe 'returns read only associated records' do
|
113
|
+
it { foo.history[2].bars.all?(&:readonly?).should be_true }
|
114
|
+
it { bar.history.all? {|b| b.foo.readonly?}.should be_true }
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
describe '#historical?' do
|
119
|
+
describe 'on plain records' do
|
120
|
+
subject { foo.historical? }
|
121
|
+
it { should be_false }
|
122
|
+
end
|
123
|
+
|
124
|
+
describe 'on historical records' do
|
125
|
+
describe 'from #history' do
|
126
|
+
subject { foo.history.first }
|
127
|
+
it { should be_true }
|
128
|
+
end
|
129
|
+
|
130
|
+
describe 'from #as_of' do
|
131
|
+
subject { foo.as_of(Time.now) }
|
132
|
+
it { should be_true }
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
describe '#destroy' do
|
138
|
+
describe 'on historical records' do
|
139
|
+
subject { foo.history.first.destroy }
|
140
|
+
it { expect { subject }.to raise_error(ActiveRecord::ReadOnlyRecord) }
|
141
|
+
end
|
142
|
+
|
143
|
+
describe 'on current records' do
|
144
|
+
let!(:rec) {
|
145
|
+
rec = ts_eval { Foo.create!(:name => 'alive foo', :fooity => 42) }
|
146
|
+
ts_eval(rec) { update_attributes!(:name => 'dying foo') }
|
147
|
+
}
|
148
|
+
|
149
|
+
subject { rec.destroy }
|
150
|
+
|
151
|
+
it { expect { subject }.to_not raise_error }
|
152
|
+
it { expect { rec.reload }.to raise_error(ActiveRecord::RecordNotFound) }
|
153
|
+
|
154
|
+
describe 'does not delete its history' do
|
155
|
+
context do
|
156
|
+
subject { rec.as_of(rec.ts.first) }
|
157
|
+
its(:name) { should == 'alive foo' }
|
158
|
+
end
|
159
|
+
|
160
|
+
context do
|
161
|
+
subject { rec.as_of(rec.ts.last) }
|
162
|
+
its(:name) { should == 'dying foo' }
|
163
|
+
end
|
164
|
+
|
165
|
+
context do
|
166
|
+
subject { Foo.as_of(rec.ts.first).where(:fooity => 42).first }
|
167
|
+
its(:name) { should == 'alive foo' }
|
168
|
+
end
|
169
|
+
|
170
|
+
context do
|
171
|
+
subject { Foo.history.where(:fooity => 42).map(&:name) }
|
172
|
+
it { should == ['alive foo', 'dying foo'] }
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
describe '#history_timestamps' do
|
179
|
+
timestamps_from = lambda {|*records|
|
180
|
+
records.map(&:history).flatten!.inject([]) {|ret, rec|
|
181
|
+
ret.concat [rec.valid_from, rec.valid_to]
|
182
|
+
}.sort.uniq[0..-2]
|
183
|
+
}
|
184
|
+
|
185
|
+
describe 'on records having an :has_many relationship' do
|
186
|
+
subject { foo.history_timestamps }
|
187
|
+
|
188
|
+
describe 'returns timestamps of the record and its associations' do
|
189
|
+
its(:size) { should == foo.ts.size + bar.ts.size }
|
190
|
+
it { should == timestamps_from.call(foo, bar) }
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
describe 'on records having a :belongs_to relationship' do
|
195
|
+
subject { bar.history_timestamps }
|
196
|
+
|
197
|
+
describe 'returns timestamps of the record only' do
|
198
|
+
its(:size) { should == bar.ts.size }
|
199
|
+
it { should == timestamps_from.call(bar) }
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
context do
|
205
|
+
history_attrs = ChronoModel::TimeMachine::HISTORY_ATTRIBUTES
|
206
|
+
|
207
|
+
let!(:history) { foo.history.first }
|
208
|
+
let!(:current) { foo }
|
209
|
+
|
210
|
+
history_attrs.each do |attr|
|
211
|
+
describe ['#', attr].join do
|
212
|
+
describe 'on history records' do
|
213
|
+
subject { history.public_send(attr) }
|
214
|
+
|
215
|
+
it { should be_present }
|
216
|
+
it { should be_a(Time) }
|
217
|
+
it { should be_utc }
|
218
|
+
end
|
219
|
+
|
220
|
+
describe 'on current records' do
|
221
|
+
subject { current.public_send(attr) }
|
222
|
+
it { should be_nil }
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
describe '#initialize_dup' do
|
228
|
+
describe 'on history records' do
|
229
|
+
subject { history.dup }
|
230
|
+
|
231
|
+
history_attrs.each do |attr|
|
232
|
+
its(attr) { should be_nil }
|
233
|
+
end
|
234
|
+
|
235
|
+
it { should_not be_readonly }
|
236
|
+
it { should be_new_record }
|
237
|
+
|
238
|
+
end
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
# Class methods
|
243
|
+
context do
|
244
|
+
let!(:foos) { Array.new(2) {|i| ts_eval { Foo.create! :name => "foo #{i}" } } }
|
245
|
+
let!(:bars) { Array.new(2) {|i| ts_eval { Bar.create! :name => "bar #{i}", :foo => foos[i] } } }
|
246
|
+
|
247
|
+
after(:all) { foos.each(&:destroy); bars.each(&:destroy) }
|
248
|
+
|
249
|
+
describe '.as_of' do
|
250
|
+
it { Foo.as_of(1.month.ago).should == [] }
|
251
|
+
|
252
|
+
it { Foo.as_of(foos[0].ts[0]).should == [foo, foos[0]] }
|
253
|
+
it { Foo.as_of(foos[1].ts[0]).should == [foo, foos[0], foos[1]] }
|
254
|
+
it { Foo.as_of(Time.now ).should == [foo, foos[0], foos[1]] }
|
255
|
+
|
256
|
+
it { Bar.as_of(foos[1].ts[0]).should == [bar] }
|
257
|
+
|
258
|
+
it { Bar.as_of(bars[0].ts[0]).should == [bar, bars[0]] }
|
259
|
+
it { Bar.as_of(bars[1].ts[0]).should == [bar, bars[0], bars[1]] }
|
260
|
+
it { Bar.as_of(Time.now ).should == [bar, bars[0], bars[1]] }
|
261
|
+
|
262
|
+
# Associations
|
263
|
+
context do
|
264
|
+
subject { foos[0] }
|
265
|
+
|
266
|
+
it { Foo.as_of(foos[0].ts[0]).find(subject).bars.should == [] }
|
267
|
+
it { Foo.as_of(foos[1].ts[0]).find(subject).bars.should == [] }
|
268
|
+
it { Foo.as_of(bars[0].ts[0]).find(subject).bars.should == [bars[0]] }
|
269
|
+
it { Foo.as_of(bars[1].ts[0]).find(subject).bars.should == [bars[0]] }
|
270
|
+
it { Foo.as_of(Time.now ).find(subject).bars.should == [bars[0]] }
|
271
|
+
end
|
272
|
+
|
273
|
+
context do
|
274
|
+
subject { foos[1] }
|
275
|
+
|
276
|
+
it { expect { Foo.as_of(foos[0].ts[0]).find(subject) }.to raise_error(ActiveRecord::RecordNotFound) }
|
277
|
+
it { expect { Foo.as_of(foos[1].ts[0]).find(subject) }.to_not raise_error }
|
278
|
+
|
279
|
+
it { Foo.as_of(bars[0].ts[0]).find(subject).bars.should == [] }
|
280
|
+
it { Foo.as_of(bars[1].ts[0]).find(subject).bars.should == [bars[1]] }
|
281
|
+
it { Foo.as_of(Time.now ).find(subject).bars.should == [bars[1]] }
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
describe '.history' do
|
286
|
+
let(:foo_history) {
|
287
|
+
['foo', 'foo bar', 'new foo', 'alive foo', 'dying foo', 'foo 0', 'foo 1']
|
288
|
+
}
|
289
|
+
|
290
|
+
let(:bar_history) {
|
291
|
+
['bar', 'foo bar', 'bar bar', 'new bar', 'bar 0', 'bar 1']
|
292
|
+
}
|
293
|
+
|
294
|
+
it { Foo.history.map(&:name).should == foo_history }
|
295
|
+
it { Bar.history.map(&:name).should == bar_history }
|
296
|
+
end
|
297
|
+
end
|
298
|
+
|
299
|
+
end
|