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.
@@ -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,3 @@
1
+ module ChronoModel
2
+ VERSION = "0.3.0"
3
+ 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