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