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
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
|