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.
- checksums.yaml +7 -0
- data/.rspec +1 -1
- data/.travis.yml +7 -0
- data/Gemfile +10 -1
- data/LICENSE +3 -1
- data/README.md +239 -136
- data/README.sql +108 -94
- data/chrono_model.gemspec +5 -4
- data/lib/active_record/connection_adapters/chronomodel_adapter.rb +42 -0
- data/lib/chrono_model.rb +0 -8
- data/lib/chrono_model/adapter.rb +346 -212
- data/lib/chrono_model/patches.rb +21 -8
- data/lib/chrono_model/railtie.rb +1 -13
- data/lib/chrono_model/time_gate.rb +2 -2
- data/lib/chrono_model/time_machine.rb +153 -87
- data/lib/chrono_model/utils.rb +35 -8
- data/lib/chrono_model/version.rb +1 -1
- data/spec/adapter_spec.rb +154 -14
- data/spec/config.yml.example +1 -0
- data/spec/json_ops_spec.rb +48 -0
- data/spec/support/connection.rb +4 -9
- data/spec/support/helpers.rb +27 -2
- data/spec/support/matchers/column.rb +5 -2
- data/spec/support/matchers/index.rb +4 -0
- data/spec/support/matchers/schema.rb +4 -0
- data/spec/support/matchers/table.rb +94 -21
- data/spec/time_machine_spec.rb +62 -28
- data/spec/time_query_spec.rb +227 -0
- data/sql/json_ops.sql +56 -0
- data/sql/uninstall-json_ops.sql +24 -0
- metadata +44 -18
- data/lib/chrono_model/compatibility.rb +0 -31
data/lib/chrono_model/utils.rb
CHANGED
@@ -17,28 +17,55 @@ module ChronoModel
|
|
17
17
|
end
|
18
18
|
end
|
19
19
|
|
20
|
+
module Json
|
21
|
+
extend self
|
22
|
+
|
23
|
+
def create
|
24
|
+
adapter.execute 'CREATE OR REPLACE LANGUAGE plpythonu'
|
25
|
+
adapter.execute File.read(sql 'json_ops.sql')
|
26
|
+
end
|
27
|
+
|
28
|
+
def drop
|
29
|
+
adapter.execute File.read(sql 'uninstall-json_ops.sql')
|
30
|
+
adapter.execute 'DROP LANGUAGE IF EXISTS plpythonu'
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
def sql(file)
|
35
|
+
File.dirname(__FILE__) + '/../../sql/' + file
|
36
|
+
end
|
37
|
+
|
38
|
+
def adapter
|
39
|
+
ActiveRecord::Base.connection
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
20
43
|
module Utilities
|
21
44
|
# Amends the given history item setting a different period.
|
22
|
-
# Useful when migrating from legacy systems
|
23
|
-
# as this is not a proper API.
|
24
|
-
#
|
25
|
-
# Extend your model with the Utilities model if you want to
|
26
|
-
# use it.
|
45
|
+
# Useful when migrating from legacy systems.
|
27
46
|
#
|
28
47
|
def amend_period!(hid, from, to)
|
29
|
-
unless [from, to].
|
48
|
+
unless [from, to].any? {|ts| ts.respond_to?(:zone) && ts.zone == 'UTC'}
|
30
49
|
raise 'Can amend history only with UTC timestamps'
|
31
50
|
end
|
32
51
|
|
33
52
|
connection.execute %[
|
34
53
|
UPDATE #{quoted_table_name}
|
35
|
-
SET "
|
36
|
-
"valid_to" = #{connection.quote(to )}
|
54
|
+
SET "validity" = tsrange(#{connection.quote(from)}, #{connection.quote(to)})
|
37
55
|
WHERE "hid" = #{hid.to_i}
|
38
56
|
]
|
39
57
|
end
|
58
|
+
|
59
|
+
# Returns true if this model is backed by a temporal table,
|
60
|
+
# false otherwise.
|
61
|
+
#
|
62
|
+
def chrono?
|
63
|
+
connection.is_chrono?(table_name)
|
64
|
+
end
|
40
65
|
end
|
41
66
|
|
67
|
+
ActiveRecord::Base.extend Utilities
|
68
|
+
|
42
69
|
module Migrate
|
43
70
|
extend self
|
44
71
|
|
data/lib/chrono_model/version.rb
CHANGED
data/spec/adapter_spec.rb
CHANGED
@@ -34,14 +34,15 @@ describe ChronoModel::Adapter do
|
|
34
34
|
context do
|
35
35
|
subject { adapter }
|
36
36
|
it { should be_a_kind_of(ChronoModel::Adapter) }
|
37
|
+
its(:adapter_name) { should == 'PostgreSQL' }
|
37
38
|
|
38
39
|
context do
|
39
|
-
before { adapter.stub(:postgresql_version =>
|
40
|
+
before { adapter.stub(:postgresql_version => 90300) }
|
40
41
|
it { should be_chrono_supported }
|
41
42
|
end
|
42
43
|
|
43
44
|
context do
|
44
|
-
before { adapter.stub(:postgresql_version =>
|
45
|
+
before { adapter.stub(:postgresql_version => 90000) }
|
45
46
|
it { should_not be_chrono_supported }
|
46
47
|
end
|
47
48
|
end
|
@@ -59,10 +60,12 @@ describe ChronoModel::Adapter do
|
|
59
60
|
|
60
61
|
def native.to_proc
|
61
62
|
proc {|t|
|
62
|
-
t.string :test, :null => false
|
63
|
+
t.string :test, :null => false, :default => 'default-value'
|
63
64
|
t.integer :foo
|
64
65
|
t.float :bar
|
65
66
|
t.text :baz
|
67
|
+
t.integer :ary, :array => true, :null => false, :default => []
|
68
|
+
t.boolean :bool, :null => false, :default => false
|
66
69
|
}
|
67
70
|
end
|
68
71
|
|
@@ -117,10 +120,27 @@ describe ChronoModel::Adapter do
|
|
117
120
|
|
118
121
|
with_plain_table do
|
119
122
|
before :all do
|
123
|
+
adapter.add_index table, :foo
|
124
|
+
adapter.add_index table, :bar, :unique => true
|
125
|
+
|
120
126
|
adapter.change_table table, :temporal => true
|
121
127
|
end
|
122
128
|
|
123
129
|
it_should_behave_like 'temporal table'
|
130
|
+
|
131
|
+
let(:history_indexes) do
|
132
|
+
adapter.on_schema(ChronoModel::Adapter::HISTORY_SCHEMA) do
|
133
|
+
adapter.indexes(table)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
it "copies plain index to history" do
|
138
|
+
history_indexes.find {|i| i.columns == ['foo']}.should be_present
|
139
|
+
end
|
140
|
+
|
141
|
+
it "copies unique index to history without uniqueness constraint" do
|
142
|
+
history_indexes.find {|i| i.columns == ['bar'] && i.unique == false}.should be_present
|
143
|
+
end
|
124
144
|
end
|
125
145
|
end
|
126
146
|
|
@@ -395,6 +415,17 @@ describe ChronoModel::Adapter do
|
|
395
415
|
end
|
396
416
|
|
397
417
|
|
418
|
+
let(:current) { [ChronoModel::Adapter::TEMPORAL_SCHEMA, table].join('.') }
|
419
|
+
let(:history) { [ChronoModel::Adapter::HISTORY_SCHEMA, table].join('.') }
|
420
|
+
|
421
|
+
def count(table)
|
422
|
+
adapter.select_value("SELECT COUNT(*) FROM ONLY #{table}").to_i
|
423
|
+
end
|
424
|
+
|
425
|
+
def ids(table)
|
426
|
+
adapter.select_values("SELECT id FROM ONLY #{table} ORDER BY id")
|
427
|
+
end
|
428
|
+
|
398
429
|
context 'INSERT multiple values' do
|
399
430
|
before :all do
|
400
431
|
adapter.create_table table, :temporal => true, &columns
|
@@ -404,17 +435,6 @@ describe ChronoModel::Adapter do
|
|
404
435
|
adapter.drop_table table
|
405
436
|
end
|
406
437
|
|
407
|
-
let(:current) { [ChronoModel::Adapter::TEMPORAL_SCHEMA, table].join('.') }
|
408
|
-
let(:history) { [ChronoModel::Adapter::HISTORY_SCHEMA, table].join('.') }
|
409
|
-
|
410
|
-
def count(table)
|
411
|
-
adapter.select_value("SELECT COUNT(*) FROM ONLY #{table}").to_i
|
412
|
-
end
|
413
|
-
|
414
|
-
def ids(table)
|
415
|
-
adapter.select_values("SELECT id FROM ONLY #{table} ORDER BY id")
|
416
|
-
end
|
417
|
-
|
418
438
|
context 'when succeeding' do
|
419
439
|
def insert
|
420
440
|
adapter.execute <<-SQL
|
@@ -461,4 +481,124 @@ describe ChronoModel::Adapter do
|
|
461
481
|
end
|
462
482
|
end
|
463
483
|
|
484
|
+
context 'INSERT on NOT NULL columns but with a DEFAULT value' do
|
485
|
+
before :all do
|
486
|
+
adapter.create_table table, :temporal => true, &columns
|
487
|
+
end
|
488
|
+
|
489
|
+
after :all do
|
490
|
+
adapter.drop_table table
|
491
|
+
end
|
492
|
+
|
493
|
+
def insert
|
494
|
+
adapter.execute <<-SQL
|
495
|
+
INSERT INTO #{table} DEFAULT VALUES
|
496
|
+
SQL
|
497
|
+
end
|
498
|
+
|
499
|
+
def select
|
500
|
+
adapter.select_values <<-SQL
|
501
|
+
SELECT test FROM #{table}
|
502
|
+
SQL
|
503
|
+
end
|
504
|
+
|
505
|
+
it { expect { insert }.to_not raise_error }
|
506
|
+
it { insert; select.uniq.should == ['default-value'] }
|
507
|
+
end
|
508
|
+
|
509
|
+
context 'redundant UPDATEs' do
|
510
|
+
|
511
|
+
before :all do
|
512
|
+
adapter.create_table table, :temporal => true, &columns
|
513
|
+
|
514
|
+
adapter.execute <<-SQL
|
515
|
+
INSERT INTO #{table} (test, foo) VALUES ('test1', 1);
|
516
|
+
SQL
|
517
|
+
|
518
|
+
adapter.execute <<-SQL
|
519
|
+
UPDATE #{table} SET test = 'test2';
|
520
|
+
SQL
|
521
|
+
|
522
|
+
adapter.execute <<-SQL
|
523
|
+
UPDATE #{table} SET test = 'test2';
|
524
|
+
SQL
|
525
|
+
end
|
526
|
+
|
527
|
+
after :all do
|
528
|
+
adapter.drop_table table
|
529
|
+
end
|
530
|
+
|
531
|
+
it { count(current).should == 1 }
|
532
|
+
it { count(history).should == 2 }
|
533
|
+
|
534
|
+
end
|
535
|
+
|
536
|
+
context 'updates on non-journaled fields' do
|
537
|
+
before :all do
|
538
|
+
adapter.create_table table, :temporal => true do |t|
|
539
|
+
t.string 'test'
|
540
|
+
t.timestamps
|
541
|
+
end
|
542
|
+
|
543
|
+
adapter.execute <<-SQL
|
544
|
+
INSERT INTO #{table} (test, created_at, updated_at) VALUES ('test', now(), now());
|
545
|
+
SQL
|
546
|
+
|
547
|
+
adapter.execute <<-SQL
|
548
|
+
UPDATE #{table} SET test = 'test2', updated_at = now();
|
549
|
+
SQL
|
550
|
+
|
551
|
+
2.times do
|
552
|
+
adapter.execute <<-SQL # Redundant update with only updated_at change
|
553
|
+
UPDATE #{table} SET test = 'test2', updated_at = now();
|
554
|
+
SQL
|
555
|
+
|
556
|
+
adapter.execute <<-SQL
|
557
|
+
UPDATE #{table} SET updated_at = now();
|
558
|
+
SQL
|
559
|
+
end
|
560
|
+
end
|
561
|
+
|
562
|
+
after :all do
|
563
|
+
adapter.drop_table table
|
564
|
+
end
|
565
|
+
|
566
|
+
it { count(current).should == 1 }
|
567
|
+
it { count(history).should == 2 }
|
568
|
+
end
|
569
|
+
|
570
|
+
context 'selective journaled fields' do
|
571
|
+
before :all do
|
572
|
+
adapter.create_table table, :temporal => true, :journal => %w( foo ) do |t|
|
573
|
+
t.string 'foo'
|
574
|
+
t.string 'bar'
|
575
|
+
end
|
576
|
+
|
577
|
+
adapter.execute <<-SQL
|
578
|
+
INSERT INTO #{table} (foo, bar) VALUES ('test foo', 'test bar');
|
579
|
+
SQL
|
580
|
+
|
581
|
+
adapter.execute <<-SQL
|
582
|
+
UPDATE #{table} SET foo = 'test foo', bar = 'no history';
|
583
|
+
SQL
|
584
|
+
|
585
|
+
2.times do
|
586
|
+
adapter.execute <<-SQL
|
587
|
+
UPDATE #{table} SET bar = 'really no history';
|
588
|
+
SQL
|
589
|
+
end
|
590
|
+
end
|
591
|
+
|
592
|
+
after :all do
|
593
|
+
adapter.drop_table table
|
594
|
+
end
|
595
|
+
|
596
|
+
it { count(current).should == 1 }
|
597
|
+
it { count(history).should == 1 }
|
598
|
+
|
599
|
+
it 'preserves options upon column change'
|
600
|
+
it 'changes option upon table change'
|
601
|
+
|
602
|
+
end
|
603
|
+
|
464
604
|
end
|
data/spec/config.yml.example
CHANGED
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'support/helpers'
|
3
|
+
|
4
|
+
describe 'JSON equality operator' do
|
5
|
+
include ChronoTest::Helpers::Adapter
|
6
|
+
|
7
|
+
table 'json_test'
|
8
|
+
|
9
|
+
before :all do
|
10
|
+
ChronoModel::Json.create
|
11
|
+
end
|
12
|
+
|
13
|
+
after :all do
|
14
|
+
ChronoModel::Json.drop
|
15
|
+
end
|
16
|
+
|
17
|
+
it { adapter.select_value(%[ SELECT '{"a":1}'::json = '{"a":1}'::json ]).should == 't' }
|
18
|
+
it { adapter.select_value(%[ SELECT '{"a":1}'::json = '{"a" : 1}'::json ]).should == 't' }
|
19
|
+
it { adapter.select_value(%[ SELECT '{"a":1}'::json = '{"a":2}'::json ]).should == 'f' }
|
20
|
+
it { adapter.select_value(%[ SELECT '{"a":1,"b":2}'::json = '{"b":2,"a":1}'::json ]).should == 't' }
|
21
|
+
it { adapter.select_value(%[ SELECT '{"a":1,"b":2,"x":{"c":4,"d":5}}'::json = '{"b":2, "x": { "d": 5, "c": 4}, "a":1}'::json ]).should == 't' }
|
22
|
+
|
23
|
+
context 'on a temporal table' do
|
24
|
+
before :all do
|
25
|
+
adapter.create_table table, :temporal => true do |t|
|
26
|
+
t.json 'data'
|
27
|
+
end
|
28
|
+
|
29
|
+
adapter.execute %[
|
30
|
+
INSERT INTO #{table} ( data ) VALUES ( '{"a":1,"b":2}' )
|
31
|
+
]
|
32
|
+
end
|
33
|
+
|
34
|
+
after :all do
|
35
|
+
adapter.drop_table table
|
36
|
+
end
|
37
|
+
|
38
|
+
it { expect {
|
39
|
+
adapter.execute "UPDATE #{table} SET data = NULL"
|
40
|
+
}.to_not raise_error }
|
41
|
+
|
42
|
+
it { expect {
|
43
|
+
adapter.execute %[UPDATE #{table} SET data = '{"x":1,"y":2}']
|
44
|
+
|
45
|
+
}.to_not raise_error }
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
data/spec/support/connection.rb
CHANGED
@@ -1,6 +1,5 @@
|
|
1
1
|
require 'pathname'
|
2
2
|
require 'active_record'
|
3
|
-
require 'active_support/core_ext/logger'
|
4
3
|
|
5
4
|
module ChronoTest
|
6
5
|
extend self
|
@@ -29,24 +28,20 @@ module ChronoTest
|
|
29
28
|
|
30
29
|
def recreate_database!
|
31
30
|
database = config.fetch(:database)
|
32
|
-
connect! config.merge(:database =>
|
33
|
-
|
34
|
-
unless AR.supports_chrono?
|
35
|
-
raise 'Your postgresql version is not supported. >= 9.0 is required.'
|
36
|
-
end
|
31
|
+
connect! config.merge(:database => :postgres)
|
37
32
|
|
38
33
|
connection.drop_database database
|
39
34
|
connection.create_database database
|
35
|
+
connect! config
|
40
36
|
|
41
|
-
|
42
|
-
connect!
|
37
|
+
connection.execute 'CREATE EXTENSION btree_gist'
|
43
38
|
logger.info "Connected to #{config}"
|
44
39
|
end
|
45
40
|
|
46
41
|
def config
|
47
42
|
@config ||= YAML.load(config_file.read).tap do |conf|
|
48
43
|
conf.symbolize_keys!
|
49
|
-
conf.update(:adapter => '
|
44
|
+
conf.update(:adapter => 'chronomodel')
|
50
45
|
|
51
46
|
def conf.to_s
|
52
47
|
'pgsql://%s:%s@%s/%s' % [
|
data/spec/support/helpers.rb
CHANGED
@@ -48,7 +48,12 @@ module ChronoTest::Helpers
|
|
48
48
|
end
|
49
49
|
|
50
50
|
module DSL
|
51
|
+
@@schema_setup = false
|
52
|
+
|
51
53
|
def setup_schema!
|
54
|
+
return if @@schema_setup
|
55
|
+
@@schema_setup = true
|
56
|
+
|
52
57
|
# Set up database structure
|
53
58
|
#
|
54
59
|
adapter.create_table 'foos', :temporal => true do |t|
|
@@ -75,6 +80,15 @@ module ChronoTest::Helpers
|
|
75
80
|
t.string :title
|
76
81
|
t.string :type
|
77
82
|
end
|
83
|
+
|
84
|
+
adapter.create_table 'plains' do |t|
|
85
|
+
t.string :foo
|
86
|
+
end
|
87
|
+
|
88
|
+
adapter.create_table 'events' do |t|
|
89
|
+
t.string :name
|
90
|
+
t.daterange :interval
|
91
|
+
end
|
78
92
|
end
|
79
93
|
|
80
94
|
Models = lambda {
|
@@ -104,7 +118,7 @@ module ChronoTest::Helpers
|
|
104
118
|
class ::Defoo < ActiveRecord::Base
|
105
119
|
include ChronoModel::TimeMachine
|
106
120
|
|
107
|
-
default_scope where(:active => true)
|
121
|
+
default_scope proc { where(:active => true) }
|
108
122
|
end
|
109
123
|
|
110
124
|
# STI case (https://github.com/ifad/chronomodel/issues/5)
|
@@ -114,9 +128,20 @@ module ChronoTest::Helpers
|
|
114
128
|
|
115
129
|
class ::Publication < Element
|
116
130
|
end
|
131
|
+
|
132
|
+
class ::Plain < ActiveRecord::Base
|
133
|
+
end
|
134
|
+
|
135
|
+
class ::Event < ActiveRecord::Base
|
136
|
+
extend ChronoModel::TimeMachine::TimeQuery
|
137
|
+
end
|
117
138
|
}
|
118
139
|
|
140
|
+
@@models_defined = false
|
119
141
|
def define_models!
|
142
|
+
return if @@models_defined
|
143
|
+
@@models_defined = true
|
144
|
+
|
120
145
|
Models.call
|
121
146
|
end
|
122
147
|
|
@@ -141,7 +166,7 @@ module ChronoTest::Helpers
|
|
141
166
|
define_method(:ts) { @_ts ||= [] }
|
142
167
|
end unless obj.methods.include?(:ts)
|
143
168
|
|
144
|
-
now = ChronoTest.connection.select_value('select now()::timestamp')
|
169
|
+
now = ChronoTest.connection.select_value('select now()::timestamp') + 'Z'
|
145
170
|
obj.ts.push(Time.parse(now))
|
146
171
|
end
|
147
172
|
end
|
@@ -7,6 +7,10 @@ module ChronoTest::Matchers
|
|
7
7
|
@schema = schema
|
8
8
|
end
|
9
9
|
|
10
|
+
def description
|
11
|
+
'have columns'
|
12
|
+
end
|
13
|
+
|
10
14
|
def matches?(table)
|
11
15
|
super(table)
|
12
16
|
|
@@ -68,8 +72,7 @@ module ChronoTest::Matchers
|
|
68
72
|
class HaveHistoryExtraColumns < HaveColumns
|
69
73
|
def initialize
|
70
74
|
super([
|
71
|
-
['
|
72
|
-
['valid_to', 'timestamp without time zone'],
|
75
|
+
['validity', 'tsrange'],
|
73
76
|
['recorded_at', 'timestamp without time zone'],
|
74
77
|
['hid', 'integer']
|
75
78
|
], history_schema)
|