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
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
|
3
|
+
module ChronoModel
|
4
|
+
|
5
|
+
# Utility methods added to every ActiveRecord::Base class instance
|
6
|
+
# to check whether ChronoModel is supported and whether a model is
|
7
|
+
# backed by temporal tables or not.
|
8
|
+
#
|
9
|
+
module Compatibility
|
10
|
+
extend ActiveSupport::Concern
|
11
|
+
|
12
|
+
# Returns true if this model is backed by a temporal table,
|
13
|
+
# false otherwise.
|
14
|
+
#
|
15
|
+
def chrono?
|
16
|
+
supports_chrono? && connection.is_chrono?(table_name)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Returns true whether the connection adapter supports our
|
20
|
+
# implementation of temporal tables. Currently, only the
|
21
|
+
# PostgreSQL adapter is supported.
|
22
|
+
#
|
23
|
+
def supports_chrono?
|
24
|
+
connection.respond_to?(:chrono_supported?) &&
|
25
|
+
connection.chrono_supported?
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
30
|
+
|
31
|
+
ActiveRecord::Base.extend ChronoModel::Compatibility
|
@@ -0,0 +1,104 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
|
3
|
+
module ChronoModel
|
4
|
+
module Patches
|
5
|
+
|
6
|
+
# Patches ActiveRecord::Associations::Association to add support for
|
7
|
+
# temporal associations.
|
8
|
+
#
|
9
|
+
# Each record fetched from the +as_of+ scope on the owner class will have
|
10
|
+
# an additional "as_of_time" field yielding the UTC time of the request,
|
11
|
+
# then the as_of scope is called on either this association's class or
|
12
|
+
# on the join model's (:through association) one.
|
13
|
+
#
|
14
|
+
class Association < ActiveRecord::Associations::Association
|
15
|
+
|
16
|
+
# Add temporal Common Table Expressions (WITH queries) to the resulting
|
17
|
+
# scope, checking whether either the association class or the through
|
18
|
+
# association one are ChronoModels.
|
19
|
+
def scoped
|
20
|
+
return super unless _chrono_record?
|
21
|
+
|
22
|
+
ctes = {}
|
23
|
+
|
24
|
+
if reflection.klass.chrono?
|
25
|
+
ctes.update _chrono_ctes_for(reflection.klass)
|
26
|
+
end
|
27
|
+
|
28
|
+
if respond_to?(:through_reflection) && through_reflection.klass.chrono?
|
29
|
+
ctes.update _chrono_ctes_for(through_reflection.klass)
|
30
|
+
end
|
31
|
+
|
32
|
+
scoped = super
|
33
|
+
ctes.each {|table, cte| scoped = scoped.with(table, cte) }
|
34
|
+
return scoped.readonly
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
def _chrono_ctes_for(klass)
|
39
|
+
klass.as_of(owner.as_of_time).with_values
|
40
|
+
end
|
41
|
+
|
42
|
+
def _chrono_record?
|
43
|
+
owner.respond_to?(:as_of_time) && owner.as_of_time.present?
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# Adds the WITH queries (Common Table Expressions) support to
|
48
|
+
# ActiveRecord::Relation.
|
49
|
+
#
|
50
|
+
# \name is the CTE you want
|
51
|
+
# \value can be a plain SQL query or another AR::Relation
|
52
|
+
#
|
53
|
+
# Example:
|
54
|
+
#
|
55
|
+
# Post.with('posts',
|
56
|
+
# Post.from('history.posts').
|
57
|
+
# where('? BETWEEN valid_from AND valid_to', 1.month.ago)
|
58
|
+
# ).where(:author_id => 1)
|
59
|
+
#
|
60
|
+
# yields:
|
61
|
+
#
|
62
|
+
# WITH posts AS (
|
63
|
+
# SELECT * FROM history.posts WHERE ... BETWEEN valid_from AND valid_to
|
64
|
+
# ) SELECT * FROM posts
|
65
|
+
#
|
66
|
+
# PG Documentation:
|
67
|
+
# http://www.postgresql.org/docs/9.0/static/queries-with.html
|
68
|
+
#
|
69
|
+
module QueryMethods
|
70
|
+
attr_accessor :with_values
|
71
|
+
|
72
|
+
def with(name, value)
|
73
|
+
clone.tap do |relation|
|
74
|
+
relation.with_values ||= {}
|
75
|
+
value = value.to_sql if value.respond_to? :to_sql
|
76
|
+
relation.with_values[name] = value
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def build_arel
|
81
|
+
super.tap {|arel| arel.with with_values if with_values.present? }
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
module Querying
|
86
|
+
delegate :with, :to => :scoped
|
87
|
+
end
|
88
|
+
|
89
|
+
# Fixes ARel's WITH visitor method with the correct SQL syntax
|
90
|
+
#
|
91
|
+
# FIXME: the .children.first is messy. This should be properly
|
92
|
+
# fixed in ARel.
|
93
|
+
#
|
94
|
+
class Visitor < Arel::Visitors::PostgreSQL
|
95
|
+
def visit_Arel_Nodes_With o
|
96
|
+
values = o.children.first.map do |name, value|
|
97
|
+
[name, ' AS (', value.is_a?(String) ? value : visit(value), ')'].join
|
98
|
+
end
|
99
|
+
"WITH #{values.join ', '}"
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module ChronoModel
|
2
|
+
class Railtie < ::Rails::Railtie
|
3
|
+
rake_tasks do
|
4
|
+
|
5
|
+
namespace :db do
|
6
|
+
namespace :chrono do
|
7
|
+
task :create_schemas do
|
8
|
+
ActiveRecord::Base.connection.chrono_create_schemas!
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
task 'db:schema:load' => 'db:chrono:create_schemas'
|
14
|
+
end
|
15
|
+
|
16
|
+
class SchemaDumper < ::ActiveRecord::SchemaDumper
|
17
|
+
def tables(*)
|
18
|
+
super
|
19
|
+
@connection.send(:_on_temporal_schema) { super }
|
20
|
+
end
|
21
|
+
|
22
|
+
def indexes(table, stream)
|
23
|
+
super
|
24
|
+
if @connection.is_chrono?(table)
|
25
|
+
stream.rewind
|
26
|
+
t = stream.read.sub(':force => true', '\&, :temporal => true') # HACK
|
27
|
+
stream.seek(0)
|
28
|
+
stream.truncate(0)
|
29
|
+
stream.write(t)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
|
35
|
+
# I'm getting (too) used to this (dirty) override scheme.
|
36
|
+
#
|
37
|
+
silence_warnings do
|
38
|
+
::ActiveRecord::SchemaDumper = SchemaDumper
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,214 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
|
3
|
+
module ChronoModel
|
4
|
+
|
5
|
+
module TimeMachine
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
included do
|
9
|
+
unless supports_chrono?
|
10
|
+
raise Error, "Your database server is not supported by ChronoModel. "\
|
11
|
+
"Currently, only PostgreSQL >= 9.0 is supported."
|
12
|
+
end
|
13
|
+
|
14
|
+
unless chrono?
|
15
|
+
raise Error, "#{table_name} is not a temporal table. " \
|
16
|
+
"Please use change_table :#{table_name}, :temporal => true"
|
17
|
+
end
|
18
|
+
|
19
|
+
TimeMachine.chrono_models[table_name] = self
|
20
|
+
end
|
21
|
+
|
22
|
+
# Returns an Hash keyed by table name of models that included
|
23
|
+
# ChronoModel::TimeMachine
|
24
|
+
#
|
25
|
+
def self.chrono_models
|
26
|
+
(@chrono_models ||= {})
|
27
|
+
end
|
28
|
+
|
29
|
+
# Returns a read-only representation of this record as it was +time+ ago.
|
30
|
+
#
|
31
|
+
def as_of(time)
|
32
|
+
self.class.as_of(time).find(self)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Return the complete read-only history of this instance.
|
36
|
+
#
|
37
|
+
def history
|
38
|
+
self.class.history_of(self)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Aborts the destroy if this is an historical record
|
42
|
+
#
|
43
|
+
def destroy
|
44
|
+
if historical?
|
45
|
+
raise ActiveRecord::ReadOnlyRecord, 'Cannot delete historical records'
|
46
|
+
else
|
47
|
+
super
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Returns true if this record was fetched from history
|
52
|
+
#
|
53
|
+
def historical?
|
54
|
+
attributes.key?('hid')
|
55
|
+
end
|
56
|
+
|
57
|
+
HISTORY_ATTRIBUTES = %w( valid_from valid_to recorded_at as_of_time ).each do |attr|
|
58
|
+
define_method(attr) { Conversions.string_to_utc_time(attributes[attr]) }
|
59
|
+
end
|
60
|
+
|
61
|
+
# Strips the history timestamps when duplicating history records
|
62
|
+
#
|
63
|
+
def initialize_dup(other)
|
64
|
+
super
|
65
|
+
|
66
|
+
if historical?
|
67
|
+
HISTORY_ATTRIBUTES.each {|attr| @attributes.delete(attr)}
|
68
|
+
@attributes.delete 'hid'
|
69
|
+
@readonly = false
|
70
|
+
@new_record = true
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# Returns an Array of timestamps for which this instance has an history
|
75
|
+
# record. Takes temporal associations into account.
|
76
|
+
#
|
77
|
+
def history_timestamps
|
78
|
+
self.class.history_timestamps do |query|
|
79
|
+
query.where(:id => self)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
module ClassMethods
|
84
|
+
# Fetches as of +time+ records.
|
85
|
+
#
|
86
|
+
def as_of(time)
|
87
|
+
time = Conversions.time_to_utc_string(time.utc)
|
88
|
+
|
89
|
+
readonly.with(table_name, on_history(time)).tap do |relation|
|
90
|
+
relation.instance_variable_set(:@temporal, time)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def on_history(time)
|
95
|
+
unscoped.from(history_table_name).
|
96
|
+
select("#{history_table_name}.*, '#{time}' AS as_of_time").
|
97
|
+
where("'#{time}' >= valid_from AND '#{time}' < valid_to")
|
98
|
+
end
|
99
|
+
|
100
|
+
# Returns the whole history as read only.
|
101
|
+
#
|
102
|
+
def history
|
103
|
+
readonly.from(history_table_name).order("#{history_table_name}.recorded_at")
|
104
|
+
end
|
105
|
+
|
106
|
+
# Fetches the given +object+ history, sorted by history record time.
|
107
|
+
#
|
108
|
+
def history_of(object)
|
109
|
+
history.
|
110
|
+
select("#{history_table_name}.*").
|
111
|
+
select('LEAST(valid_to, now()::timestamp) AS as_of_time').
|
112
|
+
where(:id => object)
|
113
|
+
end
|
114
|
+
|
115
|
+
# Returns this table name in the +Adapter::HISTORY_SCHEMA+
|
116
|
+
#
|
117
|
+
def history_table_name
|
118
|
+
[Adapter::HISTORY_SCHEMA, table_name].join('.')
|
119
|
+
end
|
120
|
+
|
121
|
+
# Returns an Array of unique UTC timestamps for which at least an
|
122
|
+
# history record exists. Takes temporal associations into account.
|
123
|
+
#
|
124
|
+
def history_timestamps
|
125
|
+
assocs = reflect_on_all_associations.select {|a|
|
126
|
+
[:has_one, :has_many].include?(a.macro) && a.klass.chrono?
|
127
|
+
}
|
128
|
+
|
129
|
+
models = [self].concat(assocs.map(&:klass))
|
130
|
+
fields = models.inject([]) {|a,m| a.concat m.quoted_history_fields}
|
131
|
+
|
132
|
+
relation = self.
|
133
|
+
joins(*assocs.map(&:name)).
|
134
|
+
select("DISTINCT UNNEST(ARRAY[#{fields.join(',')}]) AS ts").
|
135
|
+
order('ts')
|
136
|
+
|
137
|
+
relation = yield relation if block_given?
|
138
|
+
|
139
|
+
sql = "SELECT ts FROM ( #{relation.to_sql} ) foo WHERE ts IS NOT NULL AND ts < NOW()"
|
140
|
+
sql.gsub! 'INNER JOIN', 'LEFT OUTER JOIN'
|
141
|
+
|
142
|
+
connection.on_schema(Adapter::HISTORY_SCHEMA) do
|
143
|
+
connection.select_values(sql, "#{self.name} history periods").map! do |ts|
|
144
|
+
Conversions.string_to_utc_time ts
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
def quoted_history_fields
|
150
|
+
[:valid_from, :valid_to].map do |field|
|
151
|
+
[connection.quote_table_name(table_name),
|
152
|
+
connection.quote_column_name(field)
|
153
|
+
].join('.')
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
module QueryMethods
|
159
|
+
def build_arel
|
160
|
+
super.tap do |arel|
|
161
|
+
|
162
|
+
# Extract joined tables and add temporal WITH if appropriate
|
163
|
+
arel.join_sources.map {|j| j.to_sql =~ /JOIN "(\w+)" ON/ && $1}.compact.each do |table|
|
164
|
+
next unless (model = TimeMachine.chrono_models[table])
|
165
|
+
with(table, model.on_history(@temporal))
|
166
|
+
end if @temporal
|
167
|
+
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
171
|
+
ActiveRecord::Relation.instance_eval { include QueryMethods }
|
172
|
+
|
173
|
+
module Conversions
|
174
|
+
extend self
|
175
|
+
|
176
|
+
ISO_DATETIME = /\A(\d{4})-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)(\.\d+)?\z/
|
177
|
+
|
178
|
+
def string_to_utc_time(string)
|
179
|
+
if string =~ ISO_DATETIME
|
180
|
+
microsec = ($7.to_f * 1_000_000).to_i
|
181
|
+
Time.utc $1.to_i, $2.to_i, $3.to_i, $4.to_i, $5.to_i, $6.to_i, microsec
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
def time_to_utc_string(time)
|
186
|
+
[time.to_s(:db), sprintf('%06d', time.usec)].join '.'
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
module Utilities
|
191
|
+
# Amends the given history item setting a different period.
|
192
|
+
# Useful when migrating from legacy systems, but it is here
|
193
|
+
# as this is not a proper API.
|
194
|
+
#
|
195
|
+
# Extend your model with the Utilities model if you want to
|
196
|
+
# use it.
|
197
|
+
#
|
198
|
+
def amend_history_period!(hid, from, to)
|
199
|
+
unless [from, to].all? {|ts| ts.respond_to?(:zone) && ts.zone == 'UTC'}
|
200
|
+
raise 'Can amend history only with UTC timestamps'
|
201
|
+
end
|
202
|
+
|
203
|
+
connection.execute %[
|
204
|
+
UPDATE #{history_table_name}
|
205
|
+
SET valid_from = #{connection.quote(from)},
|
206
|
+
valid_to = #{connection.quote(to )}
|
207
|
+
WHERE hid = #{hid.to_i}
|
208
|
+
]
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
end
|
213
|
+
|
214
|
+
end
|
@@ -0,0 +1,398 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'support/helpers'
|
3
|
+
|
4
|
+
shared_examples_for 'temporal table' do
|
5
|
+
it { adapter.is_chrono?(subject).should be_true }
|
6
|
+
|
7
|
+
it { should_not have_public_backing }
|
8
|
+
|
9
|
+
it { should have_temporal_backing }
|
10
|
+
it { should have_history_backing }
|
11
|
+
it { should have_history_extra_columns }
|
12
|
+
it { should have_public_interface }
|
13
|
+
|
14
|
+
it { should have_columns(columns) }
|
15
|
+
it { should have_temporal_columns(columns) }
|
16
|
+
it { should have_history_columns(columns) }
|
17
|
+
end
|
18
|
+
|
19
|
+
shared_examples_for 'plain table' do
|
20
|
+
it { adapter.is_chrono?(subject).should be_false }
|
21
|
+
|
22
|
+
it { should have_public_backing }
|
23
|
+
|
24
|
+
it { should_not have_temporal_backing }
|
25
|
+
it { should_not have_history_backing }
|
26
|
+
it { should_not have_public_interface }
|
27
|
+
|
28
|
+
it { should have_columns(columns) }
|
29
|
+
end
|
30
|
+
|
31
|
+
describe ChronoModel::Adapter do
|
32
|
+
include ChronoTest::Helpers::Adapter
|
33
|
+
|
34
|
+
context do
|
35
|
+
subject { adapter }
|
36
|
+
it { should be_a_kind_of(ChronoModel::Adapter) }
|
37
|
+
|
38
|
+
context do
|
39
|
+
before { adapter.stub(:postgresql_version => 90000) }
|
40
|
+
it { should be_chrono_supported }
|
41
|
+
end
|
42
|
+
|
43
|
+
context do
|
44
|
+
before { adapter.stub(:postgresql_version => 80400) }
|
45
|
+
it { should_not be_chrono_supported }
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
let(:table) { 'test_table' }
|
50
|
+
subject { table }
|
51
|
+
|
52
|
+
columns do
|
53
|
+
native = [
|
54
|
+
['test', 'character varying(255)'],
|
55
|
+
['foo', 'integer'],
|
56
|
+
['bar', 'double precision'],
|
57
|
+
['baz', 'text']
|
58
|
+
]
|
59
|
+
|
60
|
+
def native.to_proc
|
61
|
+
proc {|t|
|
62
|
+
t.string :test
|
63
|
+
t.integer :foo
|
64
|
+
t.float :bar
|
65
|
+
t.text :baz
|
66
|
+
}
|
67
|
+
end
|
68
|
+
|
69
|
+
native
|
70
|
+
end
|
71
|
+
|
72
|
+
describe '.create_table' do
|
73
|
+
with_temporal_table do
|
74
|
+
it_should_behave_like 'temporal table'
|
75
|
+
end
|
76
|
+
|
77
|
+
with_plain_table do
|
78
|
+
it_should_behave_like 'plain table'
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
describe '.rename_table' do
|
83
|
+
let(:table) { 'test_table' }
|
84
|
+
let(:renamed) { 'foo_table' }
|
85
|
+
subject { renamed }
|
86
|
+
|
87
|
+
context ':temporal => true' do
|
88
|
+
before :all do
|
89
|
+
adapter.create_table table, :temporal => true, &columns
|
90
|
+
|
91
|
+
adapter.rename_table table, renamed
|
92
|
+
end
|
93
|
+
after(:all) { adapter.drop_table(renamed) }
|
94
|
+
|
95
|
+
it_should_behave_like 'temporal table'
|
96
|
+
end
|
97
|
+
|
98
|
+
context ':temporal => false' do
|
99
|
+
before :all do
|
100
|
+
adapter.create_table table, :temporal => false, &columns
|
101
|
+
|
102
|
+
adapter.rename_table table, renamed
|
103
|
+
end
|
104
|
+
after(:all) { adapter.drop_table(renamed) }
|
105
|
+
|
106
|
+
it_should_behave_like 'plain table'
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
describe '.change_table' do
|
111
|
+
with_temporal_table do
|
112
|
+
before :all do
|
113
|
+
adapter.change_table table, :temporal => false
|
114
|
+
end
|
115
|
+
|
116
|
+
it_should_behave_like 'plain table'
|
117
|
+
end
|
118
|
+
|
119
|
+
with_plain_table do
|
120
|
+
before :all do
|
121
|
+
adapter.change_table table, :temporal => true
|
122
|
+
end
|
123
|
+
|
124
|
+
it_should_behave_like 'temporal table'
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
describe '.drop_table' do
|
129
|
+
before :all do
|
130
|
+
adapter.create_table table, :temporal => true, &columns
|
131
|
+
|
132
|
+
adapter.drop_table table
|
133
|
+
end
|
134
|
+
|
135
|
+
it { should_not have_public_backing }
|
136
|
+
it { should_not have_temporal_backing }
|
137
|
+
it { should_not have_history_backing }
|
138
|
+
it { should_not have_public_interface }
|
139
|
+
end
|
140
|
+
|
141
|
+
describe '.add_index' do
|
142
|
+
with_temporal_table do
|
143
|
+
before :all do
|
144
|
+
adapter.add_index table, [:foo, :bar], :name => 'foobar_index'
|
145
|
+
adapter.add_index table, [:test], :name => 'test_index'
|
146
|
+
end
|
147
|
+
|
148
|
+
it { should have_temporal_index 'foobar_index', %w( foo bar ) }
|
149
|
+
it { should have_history_index 'foobar_index', %w( foo bar ) }
|
150
|
+
it { should have_temporal_index 'test_index', %w( test ) }
|
151
|
+
it { should have_history_index 'test_index', %w( test ) }
|
152
|
+
|
153
|
+
it { should_not have_index 'foobar_index', %w( foo bar ) }
|
154
|
+
it { should_not have_index 'test_index', %w( test ) }
|
155
|
+
end
|
156
|
+
|
157
|
+
with_plain_table do
|
158
|
+
before :all do
|
159
|
+
adapter.add_index table, [:foo, :bar], :name => 'foobar_index'
|
160
|
+
adapter.add_index table, [:test], :name => 'test_index'
|
161
|
+
end
|
162
|
+
|
163
|
+
it { should_not have_temporal_index 'foobar_index', %w( foo bar ) }
|
164
|
+
it { should_not have_history_index 'foobar_index', %w( foo bar ) }
|
165
|
+
it { should_not have_temporal_index 'test_index', %w( test ) }
|
166
|
+
it { should_not have_history_index 'test_index', %w( test ) }
|
167
|
+
|
168
|
+
it { should have_index 'foobar_index', %w( foo bar ) }
|
169
|
+
it { should have_index 'test_index', %w( test ) }
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
describe '.remove_index' do
|
174
|
+
with_temporal_table do
|
175
|
+
before :all do
|
176
|
+
adapter.add_index table, [:foo, :bar], :name => 'foobar_index'
|
177
|
+
adapter.add_index table, [:test], :name => 'test_index'
|
178
|
+
|
179
|
+
adapter.remove_index table, :name => 'test_index'
|
180
|
+
end
|
181
|
+
|
182
|
+
it { should_not have_temporal_index 'test_index', %w( test ) }
|
183
|
+
it { should_not have_history_index 'test_index', %w( test ) }
|
184
|
+
it { should_not have_index 'test_index', %w( test ) }
|
185
|
+
end
|
186
|
+
|
187
|
+
with_plain_table do
|
188
|
+
before :all do
|
189
|
+
adapter.add_index table, [:foo, :bar], :name => 'foobar_index'
|
190
|
+
adapter.add_index table, [:test], :name => 'test_index'
|
191
|
+
|
192
|
+
adapter.remove_index table, :name => 'test_index'
|
193
|
+
end
|
194
|
+
|
195
|
+
it { should_not have_temporal_index 'test_index', %w( test ) }
|
196
|
+
it { should_not have_history_index 'test_index', %w( test ) }
|
197
|
+
it { should_not have_index 'test_index', %w( test ) }
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
describe '.add_column' do
|
202
|
+
let(:extra_columns) { [['foobarbaz', 'integer']] }
|
203
|
+
|
204
|
+
with_temporal_table do
|
205
|
+
before :all do
|
206
|
+
adapter.add_column table, :foobarbaz, :integer
|
207
|
+
end
|
208
|
+
|
209
|
+
it { should have_columns(extra_columns) }
|
210
|
+
it { should have_temporal_columns(extra_columns) }
|
211
|
+
it { should have_history_columns(extra_columns) }
|
212
|
+
end
|
213
|
+
|
214
|
+
with_plain_table do
|
215
|
+
before :all do
|
216
|
+
adapter.add_column table, :foobarbaz, :integer
|
217
|
+
end
|
218
|
+
|
219
|
+
it { should have_columns(extra_columns) }
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
describe '.remove_column' do
|
224
|
+
let(:resulting_columns) { columns.reject {|c,_| c == 'foo'} }
|
225
|
+
|
226
|
+
with_temporal_table do
|
227
|
+
before :all do
|
228
|
+
adapter.remove_column table, :foo
|
229
|
+
end
|
230
|
+
|
231
|
+
it { should have_columns(resulting_columns) }
|
232
|
+
it { should have_temporal_columns(resulting_columns) }
|
233
|
+
it { should have_history_columns(resulting_columns) }
|
234
|
+
|
235
|
+
it { should_not have_columns([['foo', 'integer']]) }
|
236
|
+
it { should_not have_temporal_columns([['foo', 'integer']]) }
|
237
|
+
it { should_not have_history_columns([['foo', 'integer']]) }
|
238
|
+
end
|
239
|
+
|
240
|
+
with_plain_table do
|
241
|
+
before :all do
|
242
|
+
adapter.remove_column table, :foo
|
243
|
+
end
|
244
|
+
|
245
|
+
it { should have_columns(resulting_columns) }
|
246
|
+
it { should_not have_columns([['foo', 'integer']]) }
|
247
|
+
end
|
248
|
+
end
|
249
|
+
|
250
|
+
describe '.rename_column' do
|
251
|
+
with_temporal_table do
|
252
|
+
before :all do
|
253
|
+
adapter.rename_column table, :foo, :taratapiatapioca
|
254
|
+
end
|
255
|
+
|
256
|
+
it { should_not have_columns([['foo', 'integer']]) }
|
257
|
+
it { should_not have_temporal_columns([['foo', 'integer']]) }
|
258
|
+
it { should_not have_history_columns([['foo', 'integer']]) }
|
259
|
+
|
260
|
+
it { should have_columns([['taratapiatapioca', 'integer']]) }
|
261
|
+
it { should have_temporal_columns([['taratapiatapioca', 'integer']]) }
|
262
|
+
it { should have_history_columns([['taratapiatapioca', 'integer']]) }
|
263
|
+
end
|
264
|
+
|
265
|
+
with_plain_table do
|
266
|
+
before :all do
|
267
|
+
adapter.rename_column table, :foo, :taratapiatapioca
|
268
|
+
end
|
269
|
+
|
270
|
+
it { should_not have_columns([['foo', 'integer']]) }
|
271
|
+
it { should have_columns([['taratapiatapioca', 'integer']]) }
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
describe '.change_column' do
|
276
|
+
with_temporal_table do
|
277
|
+
before :all do
|
278
|
+
adapter.change_column table, :foo, :float
|
279
|
+
end
|
280
|
+
|
281
|
+
it { should_not have_columns([['foo', 'integer']]) }
|
282
|
+
it { should_not have_temporal_columns([['foo', 'integer']]) }
|
283
|
+
it { should_not have_history_columns([['foo', 'integer']]) }
|
284
|
+
|
285
|
+
it { should have_columns([['foo', 'double precision']]) }
|
286
|
+
it { should have_temporal_columns([['foo', 'double precision']]) }
|
287
|
+
it { should have_history_columns([['foo', 'double precision']]) }
|
288
|
+
end
|
289
|
+
|
290
|
+
with_plain_table do
|
291
|
+
before(:all) do
|
292
|
+
adapter.change_column table, :foo, :float
|
293
|
+
end
|
294
|
+
|
295
|
+
it { should_not have_columns([['foo', 'integer']]) }
|
296
|
+
it { should have_columns([['foo', 'double precision']]) }
|
297
|
+
end
|
298
|
+
end
|
299
|
+
|
300
|
+
describe '.remove_column' do
|
301
|
+
with_temporal_table do
|
302
|
+
before :all do
|
303
|
+
adapter.remove_column table, :foo
|
304
|
+
end
|
305
|
+
|
306
|
+
it { should_not have_columns([['foo', 'integer']]) }
|
307
|
+
it { should_not have_temporal_columns([['foo', 'integer']]) }
|
308
|
+
it { should_not have_history_columns([['foo', 'integer']]) }
|
309
|
+
end
|
310
|
+
|
311
|
+
with_plain_table do
|
312
|
+
before :all do
|
313
|
+
adapter.remove_column table, :foo
|
314
|
+
end
|
315
|
+
|
316
|
+
it { should_not have_columns([['foo', 'integer']]) }
|
317
|
+
end
|
318
|
+
end
|
319
|
+
|
320
|
+
describe '.column_definitions' do
|
321
|
+
subject { adapter.column_definitions(table).map {|d| d.take(2)} }
|
322
|
+
|
323
|
+
assert = proc do
|
324
|
+
it { (subject & columns).should == columns }
|
325
|
+
it { should include(['id', 'integer']) }
|
326
|
+
end
|
327
|
+
|
328
|
+
with_temporal_table &assert
|
329
|
+
with_plain_table &assert
|
330
|
+
end
|
331
|
+
|
332
|
+
describe '.primary_key' do
|
333
|
+
subject { adapter.primary_key(table) }
|
334
|
+
|
335
|
+
assert = proc do
|
336
|
+
it { should == 'id' }
|
337
|
+
end
|
338
|
+
|
339
|
+
with_temporal_table &assert
|
340
|
+
with_plain_table &assert
|
341
|
+
end
|
342
|
+
|
343
|
+
describe '.indexes' do
|
344
|
+
subject { adapter.indexes(table) }
|
345
|
+
|
346
|
+
assert = proc do
|
347
|
+
before(:all) do
|
348
|
+
adapter.add_index table, :foo, :name => 'foo_index'
|
349
|
+
adapter.add_index table, [:bar, :baz], :name => 'bar_index'
|
350
|
+
end
|
351
|
+
|
352
|
+
it { subject.map(&:name).should =~ %w( foo_index bar_index ) }
|
353
|
+
it { subject.map(&:columns).should =~ [['foo'], ['bar', 'baz']] }
|
354
|
+
end
|
355
|
+
|
356
|
+
with_temporal_table &assert
|
357
|
+
with_plain_table &assert
|
358
|
+
end
|
359
|
+
|
360
|
+
describe '.on_schema' do
|
361
|
+
before(:all) do
|
362
|
+
5.times {|i| adapter.execute "CREATE SCHEMA test_#{i}"}
|
363
|
+
end
|
364
|
+
|
365
|
+
context 'with nesting' do
|
366
|
+
|
367
|
+
it 'saves the schema at each recursion' do
|
368
|
+
should be_in_schema(:default)
|
369
|
+
|
370
|
+
adapter.on_schema('test_1') { should be_in_schema('test_1')
|
371
|
+
adapter.on_schema('test_2') { should be_in_schema('test_2')
|
372
|
+
adapter.on_schema('test_3') { should be_in_schema('test_3')
|
373
|
+
}
|
374
|
+
should be_in_schema('test_2')
|
375
|
+
}
|
376
|
+
should be_in_schema('test_1')
|
377
|
+
}
|
378
|
+
|
379
|
+
should be_in_schema(:default)
|
380
|
+
end
|
381
|
+
|
382
|
+
end
|
383
|
+
|
384
|
+
context 'without nesting' do
|
385
|
+
it 'ignores recursive calls' do
|
386
|
+
should be_in_schema(:default)
|
387
|
+
|
388
|
+
adapter.on_schema('test_1', false) { should be_in_schema('test_1')
|
389
|
+
adapter.on_schema('test_2', false) { should be_in_schema('test_1')
|
390
|
+
adapter.on_schema('test_3', false) { should be_in_schema('test_1')
|
391
|
+
} } }
|
392
|
+
|
393
|
+
should be_in_schema(:default)
|
394
|
+
end
|
395
|
+
end
|
396
|
+
end
|
397
|
+
|
398
|
+
end
|