chrono_model 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
|
2
|
+
#
|
3
|
+
require 'chrono_model'
|
4
|
+
|
5
|
+
require 'support/connection'
|
6
|
+
require 'support/matchers/schema'
|
7
|
+
require 'support/matchers/table'
|
8
|
+
require 'support/matchers/column'
|
9
|
+
require 'support/matchers/index'
|
10
|
+
|
11
|
+
RSpec.configure do |config|
|
12
|
+
config.treat_symbols_as_metadata_keys_with_true_values = true
|
13
|
+
|
14
|
+
config.include(ChronoTest::Matchers::Schema)
|
15
|
+
config.include(ChronoTest::Matchers::Table)
|
16
|
+
config.include(ChronoTest::Matchers::Column)
|
17
|
+
config.include(ChronoTest::Matchers::Index)
|
18
|
+
|
19
|
+
config.before :suite do
|
20
|
+
ChronoTest.recreate_database!
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
require 'pathname'
|
2
|
+
require 'active_record'
|
3
|
+
require 'active_support/core_ext/logger'
|
4
|
+
|
5
|
+
module ChronoTest
|
6
|
+
extend self
|
7
|
+
|
8
|
+
AR = ActiveRecord::Base
|
9
|
+
log = ENV['VERBOSE'].present? ? $stderr : 'spec/debug.log'.tap{|f| File.truncate(f, 0)}
|
10
|
+
AR.logger = ::Logger.new(log).tap do |l|
|
11
|
+
l.level = 0
|
12
|
+
end
|
13
|
+
|
14
|
+
def connect!(spec = self.config)
|
15
|
+
unless ENV['VERBOSE'].present?
|
16
|
+
spec = spec.merge(:min_messages => 'WARNING')
|
17
|
+
end
|
18
|
+
AR.establish_connection spec
|
19
|
+
end
|
20
|
+
|
21
|
+
def logger
|
22
|
+
AR.logger
|
23
|
+
end
|
24
|
+
|
25
|
+
def connection
|
26
|
+
AR.connection
|
27
|
+
end
|
28
|
+
|
29
|
+
def recreate_database!
|
30
|
+
database = config.fetch(:database)
|
31
|
+
connect! config.merge(:database => 'postgres')
|
32
|
+
|
33
|
+
unless AR.supports_chrono?
|
34
|
+
raise 'Your postgresql version is not supported. >= 9.0 is required.'
|
35
|
+
end
|
36
|
+
|
37
|
+
connection.drop_database database
|
38
|
+
connection.create_database database
|
39
|
+
|
40
|
+
ensure
|
41
|
+
connect!
|
42
|
+
logger.info "Connected to #{config}"
|
43
|
+
end
|
44
|
+
|
45
|
+
def config
|
46
|
+
@config ||= YAML.load(config_file.read).tap do |conf|
|
47
|
+
conf.symbolize_keys!
|
48
|
+
conf.update(:adapter => 'postgresql')
|
49
|
+
|
50
|
+
def conf.to_s
|
51
|
+
'pgsql://%s:%s@%s/%s' % [
|
52
|
+
self[:username], self[:password], self[:hostname], self[:database]
|
53
|
+
]
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
rescue Errno::ENOENT
|
58
|
+
$stderr.puts <<EOM
|
59
|
+
|
60
|
+
Please define your AR database configuration
|
61
|
+
in spec/config.yml or reference your own configuration
|
62
|
+
file using the TEST_CONFIG environment variable
|
63
|
+
EOM
|
64
|
+
|
65
|
+
abort
|
66
|
+
end
|
67
|
+
|
68
|
+
def config_file
|
69
|
+
Pathname(ENV['TEST_CONFIG'] ||
|
70
|
+
File.join(File.dirname(__FILE__), '..', 'config.yml')
|
71
|
+
)
|
72
|
+
end
|
73
|
+
|
74
|
+
end
|
@@ -0,0 +1,123 @@
|
|
1
|
+
module ChronoTest::Helpers
|
2
|
+
|
3
|
+
module Adapter
|
4
|
+
def self.included(base)
|
5
|
+
base.let!(:adapter) { ChronoTest.connection }
|
6
|
+
base.extend DSL
|
7
|
+
end
|
8
|
+
|
9
|
+
def columns
|
10
|
+
DSL.columns
|
11
|
+
end
|
12
|
+
|
13
|
+
module DSL
|
14
|
+
def with_temporal_table(&block)
|
15
|
+
context ':temporal => true' do
|
16
|
+
before(:all) { adapter.create_table(table, :temporal => true, &columns) }
|
17
|
+
after(:all) { adapter.drop_table table }
|
18
|
+
|
19
|
+
instance_eval(&block)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def with_plain_table(&block)
|
24
|
+
context ':temporal => false' do
|
25
|
+
before(:all) { adapter.create_table(table, :temporal => false, &columns) }
|
26
|
+
after(:all) { adapter.drop_table table }
|
27
|
+
|
28
|
+
instance_eval(&block)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def columns(&block)
|
33
|
+
DSL.columns = block.call
|
34
|
+
end
|
35
|
+
|
36
|
+
class << self
|
37
|
+
attr_accessor :columns
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
module TimeMachine
|
43
|
+
def self.included(base)
|
44
|
+
base.let!(:adapter) { ChronoTest.connection }
|
45
|
+
base.extend(DSL)
|
46
|
+
end
|
47
|
+
|
48
|
+
module DSL
|
49
|
+
def setup_schema!
|
50
|
+
# Set up database structure
|
51
|
+
#
|
52
|
+
before(:all) do
|
53
|
+
adapter.create_table 'foos', :temporal => true do |t|
|
54
|
+
t.string :name
|
55
|
+
t.integer :fooity
|
56
|
+
end
|
57
|
+
|
58
|
+
adapter.create_table 'bars', :temporal => true do |t|
|
59
|
+
t.string :name
|
60
|
+
t.references :foo
|
61
|
+
end
|
62
|
+
|
63
|
+
adapter.create_table 'bazs' do |t|
|
64
|
+
t.string :name
|
65
|
+
t.references :bar
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
after(:all) do
|
70
|
+
adapter.drop_table 'foos'
|
71
|
+
adapter.drop_table 'bars'
|
72
|
+
adapter.drop_table 'bazs'
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
Models = lambda {
|
77
|
+
class ::Foo < ActiveRecord::Base
|
78
|
+
include ChronoModel::TimeMachine
|
79
|
+
|
80
|
+
has_many :bars
|
81
|
+
end
|
82
|
+
|
83
|
+
class ::Bar < ActiveRecord::Base
|
84
|
+
include ChronoModel::TimeMachine
|
85
|
+
|
86
|
+
belongs_to :foo
|
87
|
+
has_one :baz
|
88
|
+
end
|
89
|
+
|
90
|
+
class ::Baz < ActiveRecord::Base
|
91
|
+
belongs_to :baz
|
92
|
+
end
|
93
|
+
}
|
94
|
+
|
95
|
+
def define_models!
|
96
|
+
before(:all) { Models.call }
|
97
|
+
end
|
98
|
+
|
99
|
+
end
|
100
|
+
|
101
|
+
# If a context object is given, evaluates the given
|
102
|
+
# block in its instance context, then defines a `ts`
|
103
|
+
# on it, backed by an Array, and adds the current
|
104
|
+
# database timestamp to it.
|
105
|
+
#
|
106
|
+
# If a context object is not given, the block is
|
107
|
+
# evaluated in the current context and the above
|
108
|
+
# mangling is done on the blocks' return value.
|
109
|
+
#
|
110
|
+
def ts_eval(ctx = nil, &block)
|
111
|
+
ret = (ctx || self).instance_eval(&block)
|
112
|
+
(ctx || ret).tap do |obj|
|
113
|
+
obj.singleton_class.instance_eval do
|
114
|
+
define_method(:ts) { @_ts ||= [] }
|
115
|
+
end unless obj.methods.include?(:ts)
|
116
|
+
|
117
|
+
now = adapter.select_value('select now()::timestamp')
|
118
|
+
obj.ts.push(Time.parse(now))
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require 'ostruct'
|
2
|
+
|
3
|
+
module ChronoTest::Matchers
|
4
|
+
class Base
|
5
|
+
attr_reader :table
|
6
|
+
|
7
|
+
def matches?(table)
|
8
|
+
table = table.table_name if table.respond_to?(:table_name)
|
9
|
+
@table = table
|
10
|
+
end
|
11
|
+
private :matches? # This is an abstract class
|
12
|
+
|
13
|
+
def failure_message_for_should_not
|
14
|
+
failure_message_for_should.gsub(/to /, 'to not ')
|
15
|
+
end
|
16
|
+
|
17
|
+
protected
|
18
|
+
def connection
|
19
|
+
ChronoTest.connection
|
20
|
+
end
|
21
|
+
|
22
|
+
def temporal_schema
|
23
|
+
ChronoModel::Adapter::TEMPORAL_SCHEMA
|
24
|
+
end
|
25
|
+
|
26
|
+
def history_schema
|
27
|
+
ChronoModel::Adapter::HISTORY_SCHEMA
|
28
|
+
end
|
29
|
+
|
30
|
+
def public_schema
|
31
|
+
'public'
|
32
|
+
end
|
33
|
+
|
34
|
+
def select_value(sql, binds, name = nil)
|
35
|
+
result = exec_query(sql, binds, name || 'select_value')
|
36
|
+
result.rows.first.try(:[], 0)
|
37
|
+
end
|
38
|
+
|
39
|
+
def select_values(sql, binds, name = nil)
|
40
|
+
result = exec_query(sql, binds, name || 'select_values')
|
41
|
+
result.rows.map(&:first)
|
42
|
+
end
|
43
|
+
|
44
|
+
def select_rows(sql, binds, name = nil)
|
45
|
+
exec_query(sql, binds, name || 'select_rows').rows
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
FooColumn = OpenStruct.new(:name => '')
|
50
|
+
|
51
|
+
def exec_query(sql, binds, name)
|
52
|
+
binds.map! do |col, val|
|
53
|
+
val ? [col, val] : [FooColumn, col]
|
54
|
+
end
|
55
|
+
connection.exec_query(sql, name, binds)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
module ChronoTest::Matchers
|
2
|
+
|
3
|
+
module Column
|
4
|
+
class HaveColumns < ChronoTest::Matchers::Base
|
5
|
+
def initialize(columns, schema = 'public')
|
6
|
+
@columns = columns
|
7
|
+
@schema = schema
|
8
|
+
end
|
9
|
+
|
10
|
+
def matches?(table)
|
11
|
+
super(table)
|
12
|
+
|
13
|
+
@matches = @columns.inject({}) do |h, (name, type)|
|
14
|
+
h.update([name, type] => has_column?(name, type))
|
15
|
+
end
|
16
|
+
|
17
|
+
@matches.values.all?
|
18
|
+
end
|
19
|
+
|
20
|
+
def failure_message_for_should
|
21
|
+
"expected #{@schema}.#{table} to have ".tap do |message|
|
22
|
+
message << @matches.map do |(name, type), match|
|
23
|
+
"a #{name}(#{type}) column" unless match
|
24
|
+
end.compact.to_sentence
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
protected
|
29
|
+
def has_column?(name, type)
|
30
|
+
table = "#{@schema}.#{self.table}"
|
31
|
+
|
32
|
+
select_rows(<<-SQL, [table, name], 'Check column').first == [name, type]
|
33
|
+
SELECT attname, FORMAT_TYPE(atttypid, atttypmod)
|
34
|
+
FROM pg_attribute
|
35
|
+
WHERE attrelid = $1::regclass::oid
|
36
|
+
AND attname = $2
|
37
|
+
SQL
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def have_columns(*args)
|
42
|
+
HaveColumns.new(*args)
|
43
|
+
end
|
44
|
+
|
45
|
+
|
46
|
+
class HaveTemporalColumns < HaveColumns
|
47
|
+
def initialize(columns)
|
48
|
+
super(columns, temporal_schema)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def have_temporal_columns(*args)
|
53
|
+
HaveTemporalColumns.new(*args)
|
54
|
+
end
|
55
|
+
|
56
|
+
|
57
|
+
class HaveHistoryColumns < HaveColumns
|
58
|
+
def initialize(columns)
|
59
|
+
super(columns, history_schema)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def have_history_columns(*args)
|
64
|
+
HaveHistoryColumns.new(*args)
|
65
|
+
end
|
66
|
+
|
67
|
+
|
68
|
+
class HaveHistoryExtraColumns < HaveColumns
|
69
|
+
def initialize
|
70
|
+
super([
|
71
|
+
['valid_from', 'timestamp without time zone'],
|
72
|
+
['valid_to', 'timestamp without time zone'],
|
73
|
+
['recorded_at', 'timestamp without time zone'],
|
74
|
+
['hid', 'integer']
|
75
|
+
], history_schema)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def have_history_extra_columns
|
80
|
+
HaveHistoryExtraColumns.new
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module ChronoTest::Matchers
|
2
|
+
|
3
|
+
module Index
|
4
|
+
class HaveIndex < ChronoTest::Matchers::Base
|
5
|
+
attr_reader :name, :columns, :schema
|
6
|
+
|
7
|
+
def initialize(name, columns, schema = 'public')
|
8
|
+
@name = name
|
9
|
+
@columns = columns.sort
|
10
|
+
@schema = schema
|
11
|
+
end
|
12
|
+
|
13
|
+
def matches?(table)
|
14
|
+
super(table)
|
15
|
+
|
16
|
+
select_values(<<-SQL, [ table, name, schema ], 'Check index') == columns
|
17
|
+
SELECT a.attname
|
18
|
+
FROM pg_class t
|
19
|
+
JOIN pg_index d ON t.oid = d.indrelid
|
20
|
+
JOIN pg_class i ON i.oid = d.indexrelid
|
21
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(d.indkey)
|
22
|
+
WHERE i.relkind = 'i'
|
23
|
+
AND t.relname = $1
|
24
|
+
AND i.relname = $2
|
25
|
+
AND i.relnamespace = (
|
26
|
+
SELECT oid FROM pg_namespace WHERE nspname = $3
|
27
|
+
)
|
28
|
+
ORDER BY a.attname
|
29
|
+
SQL
|
30
|
+
end
|
31
|
+
|
32
|
+
def failure_message_for_should
|
33
|
+
"expected #{schema}.#{table} to have a #{name} index on #{columns}"
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def have_index(*args)
|
38
|
+
HaveIndex.new(*args)
|
39
|
+
end
|
40
|
+
|
41
|
+
class HaveTemporalIndex < HaveIndex
|
42
|
+
def initialize(name, columns)
|
43
|
+
super(name, columns, temporal_schema)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def have_temporal_index(*args)
|
48
|
+
HaveTemporalIndex.new(*args)
|
49
|
+
end
|
50
|
+
|
51
|
+
class HaveHistoryIndex < HaveIndex
|
52
|
+
def initialize(name, columns)
|
53
|
+
super(name, columns, history_schema)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def have_history_index(*args)
|
58
|
+
HaveHistoryIndex.new(*args)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'support/matchers/base'
|
2
|
+
|
3
|
+
module ChronoTest::Matchers
|
4
|
+
|
5
|
+
module Schema
|
6
|
+
class BeInSchema < ChronoTest::Matchers::Base
|
7
|
+
|
8
|
+
def initialize(expected)
|
9
|
+
@expected = expected
|
10
|
+
@expected = '"$user", public' if @expected == :default
|
11
|
+
end
|
12
|
+
|
13
|
+
def failure_message_for_should
|
14
|
+
"expected to be in schema #@expected, but was in #@current"
|
15
|
+
end
|
16
|
+
|
17
|
+
def matches?(*)
|
18
|
+
@current = select_value(<<-SQL, [], 'Current schema')
|
19
|
+
SHOW search_path
|
20
|
+
SQL
|
21
|
+
|
22
|
+
@current == @expected
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def be_in_schema(schema)
|
27
|
+
BeInSchema.new(schema)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|