chrono_model 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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