torque-postgresql 4.0.1 → 4.1.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.
- checksums.yaml +4 -4
- data/Rakefile +24 -0
- data/lib/torque/postgresql/adapter/database_statements.rb +89 -7
- data/lib/torque/postgresql/adapter/inheritance_statements.rb +297 -0
- data/lib/torque/postgresql/adapter/oid/array.rb +17 -0
- data/lib/torque/postgresql/adapter/oid/box.rb +1 -1
- data/lib/torque/postgresql/adapter/oid/circle.rb +1 -1
- data/lib/torque/postgresql/adapter/oid/composite.rb +116 -0
- data/lib/torque/postgresql/adapter/oid/line.rb +1 -1
- data/lib/torque/postgresql/adapter/oid/lquery.rb +53 -0
- data/lib/torque/postgresql/adapter/oid/ltree.rb +53 -0
- data/lib/torque/postgresql/adapter/oid/segment.rb +1 -1
- data/lib/torque/postgresql/adapter/oid/struct.rb +150 -0
- data/lib/torque/postgresql/adapter/oid/struct_list.rb +52 -0
- data/lib/torque/postgresql/adapter/oid/struct_set.rb +38 -0
- data/lib/torque/postgresql/adapter/quoting.rb +11 -2
- data/lib/torque/postgresql/adapter/schema_definitions.rb +46 -0
- data/lib/torque/postgresql/adapter/schema_dumper.rb +26 -0
- data/lib/torque/postgresql/adapter/schema_statements.rb +207 -0
- data/lib/torque/postgresql/adapter.rb +2 -5
- data/lib/torque/postgresql/arel/nodes.rb +63 -1
- data/lib/torque/postgresql/arel/visitors.rb +19 -8
- data/lib/torque/postgresql/associations/join_dependency.rb +55 -0
- data/lib/torque/postgresql/associations.rb +3 -0
- data/lib/torque/postgresql/attributes/base.rb +94 -0
- data/lib/torque/postgresql/attributes/composite.rb +102 -0
- data/lib/torque/postgresql/attributes/enum.rb +13 -2
- data/lib/torque/postgresql/attributes/lquery.rb +180 -0
- data/lib/torque/postgresql/attributes/ltree.rb +169 -0
- data/lib/torque/postgresql/attributes/simple_enum.rb +72 -0
- data/lib/torque/postgresql/attributes/struct.rb +137 -0
- data/lib/torque/postgresql/base.rb +17 -14
- data/lib/torque/postgresql/config.rb +81 -14
- data/lib/torque/postgresql/inheritance/expander.rb +66 -0
- data/lib/torque/postgresql/inheritance/record.rb +52 -0
- data/lib/torque/postgresql/inheritance.rb +138 -65
- data/lib/torque/postgresql/migration/command_recorder.rb +76 -0
- data/lib/torque/postgresql/predicate_builder/composite_handler.rb +109 -0
- data/lib/torque/postgresql/predicate_builder/ltree_handler.rb +44 -0
- data/lib/torque/postgresql/predicate_builder/struct_handler.rb +68 -0
- data/lib/torque/postgresql/predicate_builder.rb +26 -0
- data/lib/torque/postgresql/predicate_table.rb +61 -0
- data/lib/torque/postgresql/railtie.rb +35 -0
- data/lib/torque/postgresql/relation/inheritance.rb +146 -28
- data/lib/torque/postgresql/relation/merger.rb +21 -5
- data/lib/torque/postgresql/relation.rb +12 -11
- data/lib/torque/postgresql/schema_cache.rb +1 -0
- data/lib/torque/postgresql/validations.rb +29 -0
- data/lib/torque/postgresql/version.rb +1 -1
- data/lib/torque/postgresql/versioned_commands/command_migration.rb +1 -1
- data/lib/torque/postgresql.rb +6 -0
- data/spec/initialize.rb +20 -2
- data/spec/mocks/cache_query.rb +12 -0
- data/spec/models/author.rb +1 -1
- data/spec/models/comment.rb +2 -0
- data/spec/models/place.rb +2 -0
- data/spec/models/profile.rb +31 -0
- data/spec/schema.rb +33 -4
- data/spec/tests/arel_spec.rb +5 -3
- data/spec/tests/composite_spec.rb +800 -0
- data/spec/tests/ltree_spec.rb +552 -0
- data/spec/tests/struct_spec.rb +968 -0
- data/spec/tests/table_inheritance_spec.rb +1027 -56
- metadata +47 -2
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe 'Composite' do
|
|
4
|
+
let(:connection) { ActiveRecord::Base.connection }
|
|
5
|
+
let(:source) { ActiveRecord::Base.connection_pool }
|
|
6
|
+
|
|
7
|
+
context 'on migration' do
|
|
8
|
+
it 'creates a composite type' do
|
|
9
|
+
connection.create_composite_type(:sample_type) do |t|
|
|
10
|
+
t.string 'label'
|
|
11
|
+
t.integer 'amount'
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
expect(connection.type_exists?(:sample_type)).to be_truthy
|
|
15
|
+
|
|
16
|
+
columns = connection.composite_column_types('sample_type')
|
|
17
|
+
expect(columns.keys).to be_eql(%w[label amount])
|
|
18
|
+
expect(columns['amount'].type).to be_eql(:integer)
|
|
19
|
+
ensure
|
|
20
|
+
connection.drop_type(:sample_type)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'accepts size-related and type-related options' do
|
|
24
|
+
connection.create_composite_type(:sample_type) do |t|
|
|
25
|
+
t.string 'label', limit: 10
|
|
26
|
+
t.enum 'category', enum_type: :types
|
|
27
|
+
t.composite 'place', composite_type: :address
|
|
28
|
+
t.string 'tags', array: true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
columns = connection.composite_column_types('sample_type')
|
|
32
|
+
expect(columns['category']).to be_a(Torque::PostgreSQL::Adapter::OID::Enum)
|
|
33
|
+
expect(columns['place']).to be_a(Torque::PostgreSQL::Adapter::OID::Composite)
|
|
34
|
+
ensure
|
|
35
|
+
connection.drop_type(:sample_type)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'raises on unsupported column options' do
|
|
39
|
+
expect do
|
|
40
|
+
connection.create_composite_type(:sample_type) do |t|
|
|
41
|
+
t.string 'label', null: false
|
|
42
|
+
end
|
|
43
|
+
end.to raise_error(ArgumentError, /unsupported options/)
|
|
44
|
+
|
|
45
|
+
expect do
|
|
46
|
+
connection.create_composite_type(:sample_type) do |t|
|
|
47
|
+
t.string 'label', default: 'x'
|
|
48
|
+
end
|
|
49
|
+
end.to raise_error(ArgumentError, /unsupported options/)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'raises on indexes' do
|
|
53
|
+
expect do
|
|
54
|
+
connection.create_composite_type(:sample_type) do |t|
|
|
55
|
+
t.string 'label', index: true
|
|
56
|
+
end
|
|
57
|
+
end.to raise_error(ArgumentError)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'raises when composite columns do not provide the type' do
|
|
61
|
+
expect { connection.type_to_sql(:composite) }
|
|
62
|
+
.to raise_error(ArgumentError, /composite_type is required/)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'raises when the type already exists' do
|
|
66
|
+
expect do
|
|
67
|
+
connection.create_composite_type(:address) { |t| t.string 'other' }
|
|
68
|
+
end.to raise_error(ActiveRecord::StatementInvalid, /already exists/)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it 'changes the columns of a composite type' do
|
|
72
|
+
connection.create_composite_type(:sample_type) do |t|
|
|
73
|
+
t.string 'label'
|
|
74
|
+
t.integer 'amount'
|
|
75
|
+
t.string 'gone'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
connection.change_composite_type(:sample_type) do |t|
|
|
79
|
+
t.date 'issued_at'
|
|
80
|
+
t.change 'amount', :bigint
|
|
81
|
+
t.remove 'gone'
|
|
82
|
+
t.rename 'label', 'title'
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
columns = connection.composite_column_types('sample_type')
|
|
86
|
+
expect(columns.keys).to be_eql(%w[title amount issued_at])
|
|
87
|
+
expect(columns['amount'].type).to be_eql(:integer)
|
|
88
|
+
expect(columns['amount'].limit).to be_eql(8)
|
|
89
|
+
expect(columns['issued_at'].type).to be_eql(:date)
|
|
90
|
+
ensure
|
|
91
|
+
connection.drop_type(:sample_type, check: true)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'keeps the schema out of the columns while changing a type' do
|
|
95
|
+
connection.create_composite_type(:sample_type, schema: 'internal') { |t| t.string 'label' }
|
|
96
|
+
connection.add_composite_column(:sample_type, 'amount', :integer, schema: 'internal')
|
|
97
|
+
|
|
98
|
+
columns = connection.composite_column_types('internal.sample_type')
|
|
99
|
+
expect(columns.keys).to be_eql(%w[label amount])
|
|
100
|
+
ensure
|
|
101
|
+
connection.drop_type(:sample_type, schema: 'internal', check: true)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'raises on unsupported options when changing a composite type' do
|
|
105
|
+
expect do
|
|
106
|
+
connection.change_composite_type(:sample_type) { |t| t.string 'label', null: false }
|
|
107
|
+
end.to raise_error(ArgumentError, /unsupported options/)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it 'changes a composite type one column at a time' do
|
|
111
|
+
connection.create_composite_type(:sample_type) { |t| t.string 'label' }
|
|
112
|
+
|
|
113
|
+
connection.add_composite_column(:sample_type, 'amount', :integer)
|
|
114
|
+
connection.change_composite_column(:sample_type, 'amount', :bigint)
|
|
115
|
+
connection.rename_composite_column(:sample_type, 'label', 'title')
|
|
116
|
+
connection.add_composite_column(:sample_type, 'gone', :string)
|
|
117
|
+
connection.remove_composite_column(:sample_type, 'gone')
|
|
118
|
+
|
|
119
|
+
columns = connection.composite_column_types('sample_type')
|
|
120
|
+
expect(columns.keys).to be_eql(%w[title amount])
|
|
121
|
+
expect(columns['amount'].limit).to be_eql(8)
|
|
122
|
+
ensure
|
|
123
|
+
connection.drop_type(:sample_type, check: true)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it 'recreates the type with force' do
|
|
127
|
+
connection.create_composite_type(:sample_type) { |t| t.string 'a' }
|
|
128
|
+
connection.create_composite_type(:sample_type, force: :cascade) { |t| t.string 'b' }
|
|
129
|
+
|
|
130
|
+
expect(connection.composite_column_types('sample_type').keys).to be_eql(%w[b])
|
|
131
|
+
ensure
|
|
132
|
+
connection.drop_type(:sample_type)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
context 'reverting' do
|
|
136
|
+
let(:migration) { ActiveRecord::Migration::Current.new('Testing') }
|
|
137
|
+
|
|
138
|
+
before do
|
|
139
|
+
allow_any_instance_of(ActiveRecord::Migration).to receive(:puts)
|
|
140
|
+
connection.create_composite_type(:sample_type) { |t| t.string 'label' }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it 'reverts the creation of a composite type' do
|
|
144
|
+
expect(connection.type_exists?(:sample_type)).to be_truthy
|
|
145
|
+
|
|
146
|
+
migration.revert do
|
|
147
|
+
migration.connection.create_composite_type(:sample_type) { |t| t.string 'label' }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
expect(connection.type_exists?(:sample_type)).to be_falsey
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it 'reverts a column being added to a composite type' do
|
|
154
|
+
migration.connection.add_composite_column(:sample_type, 'amount', :integer)
|
|
155
|
+
expect(connection.composite_column_types('sample_type').keys).to include('amount')
|
|
156
|
+
|
|
157
|
+
migration.revert do
|
|
158
|
+
migration.connection.add_composite_column(:sample_type, 'amount', :integer)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
expect(connection.composite_column_types('sample_type').keys).not_to include('amount')
|
|
162
|
+
ensure
|
|
163
|
+
connection.drop_type(:sample_type, check: true)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it 'reverts a column being renamed' do
|
|
167
|
+
migration.revert do
|
|
168
|
+
migration.connection.rename_composite_column(:sample_type, 'title', 'label')
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
expect(connection.composite_column_types('sample_type').keys).to be_eql(%w[title])
|
|
172
|
+
ensure
|
|
173
|
+
connection.drop_type(:sample_type, check: true)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it 'does not revert a composite type being changed' do
|
|
177
|
+
expect do
|
|
178
|
+
migration.revert do
|
|
179
|
+
migration.connection.change_composite_type(:sample_type) { |t| t.string 'other' }
|
|
180
|
+
end
|
|
181
|
+
end.to raise_error(ActiveRecord::IrreversibleMigration)
|
|
182
|
+
ensure
|
|
183
|
+
connection.drop_type(:sample_type, check: true)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it 'does not revert a column being removed without a type' do
|
|
187
|
+
expect do
|
|
188
|
+
migration.revert do
|
|
189
|
+
migration.connection.remove_composite_column(:sample_type, 'gone')
|
|
190
|
+
end
|
|
191
|
+
end.to raise_error(ActiveRecord::IrreversibleMigration)
|
|
192
|
+
ensure
|
|
193
|
+
connection.drop_type(:sample_type, check: true)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it 'reverts a column being removed when given a type' do
|
|
197
|
+
migration.revert do
|
|
198
|
+
migration.connection.remove_composite_column(:sample_type, 'gone', :string)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
expect(connection.composite_column_types('sample_type').keys).to include('gone')
|
|
202
|
+
ensure
|
|
203
|
+
connection.drop_type(:sample_type, check: true)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
context 'with tables' do
|
|
208
|
+
before(:context) { ActiveRecord::Base.connection.max_identifier_length }
|
|
209
|
+
|
|
210
|
+
mock_create_table
|
|
211
|
+
|
|
212
|
+
it 'adds composite columns through the helper' do
|
|
213
|
+
sql = connection.create_table(:sample, id: false) do |t|
|
|
214
|
+
t.composite 'home', composite_type: :address
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
expect(sql).to include('"home" address')
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
it 'supports arrays of composite columns' do
|
|
221
|
+
sql = connection.create_table(:sample, id: false) do |t|
|
|
222
|
+
t.composite 'homes', composite_type: :address, array: true
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
expect(sql).to include('"homes" address[]')
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
it 'supports the type name as the column type' do
|
|
229
|
+
sql = connection.create_table(:sample, id: false) do |t|
|
|
230
|
+
t.column 'home', :address
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
expect(sql).to include('"home" address')
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
context 'on discovery' do
|
|
239
|
+
it 'lists user defined composite types' do
|
|
240
|
+
expect(connection.composite_types).to include('address', 'full_address')
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
it 'sorts the list by dependencies' do
|
|
244
|
+
list = connection.composite_types
|
|
245
|
+
expect(list.index('address')).to be < list.index('full_address')
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
it 'does not include table row types' do
|
|
249
|
+
expect(connection.composite_types & connection.tables).to be_empty
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
it 'identifies composite columns' do
|
|
253
|
+
column = Place.columns_hash['home']
|
|
254
|
+
expect(column.type).to be_eql(:composite)
|
|
255
|
+
expect(column.sql_type).to be_eql('address')
|
|
256
|
+
|
|
257
|
+
column = Place.columns_hash['offices']
|
|
258
|
+
expect(column.type).to be_eql(:composite)
|
|
259
|
+
expect(column.array?).to be_truthy
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
context 'on classes' do
|
|
264
|
+
it 'spins up classes on demand' do
|
|
265
|
+
klass = Composite::Address
|
|
266
|
+
expect(klass.superclass).to be_eql(Torque::PostgreSQL::Attributes::Composite)
|
|
267
|
+
expect(klass.type_name).to be_eql('address')
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
it 'defines the attributes from the type columns' do
|
|
271
|
+
instance = Composite::Address.new(street: 'Main', number: '42')
|
|
272
|
+
expect(instance.street).to be_eql('Main')
|
|
273
|
+
expect(instance.number).to be_eql(42)
|
|
274
|
+
expect(Composite::Address.attribute_names).to include('street', 'city', 'number', 'category')
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
it 'compares instances by class and attributes' do
|
|
278
|
+
one = Composite::Address.new(street: 'X', number: 1)
|
|
279
|
+
two = Composite::Address.new(street: 'X', number: 1)
|
|
280
|
+
|
|
281
|
+
expect(one).to be_eql(two)
|
|
282
|
+
|
|
283
|
+
two.number = 2
|
|
284
|
+
expect(one).not_to be_eql(two)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
it 'exposes the attributes as a hash' do
|
|
288
|
+
instance = Composite::Address.new(street: 'X')
|
|
289
|
+
expect(instance.to_h).to include(street: 'X', number: nil)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
it 'keeps the attributes that the class declares on its own' do
|
|
293
|
+
klass = Class.new(Torque::PostgreSQL::Attributes::Composite)
|
|
294
|
+
klass.type_name = 'address'
|
|
295
|
+
klass.attribute(:number, :string)
|
|
296
|
+
|
|
297
|
+
expect(klass.attribute_types['number']).to be_a(ActiveModel::Type::String)
|
|
298
|
+
expect(klass.new(number: 42).number).to be_eql('42')
|
|
299
|
+
expect(klass.columns['number'].type).to be_eql(:integer)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
it 'supports irregular types mapping' do
|
|
303
|
+
stub_klass = Class.new(Torque::PostgreSQL::Attributes::Composite)
|
|
304
|
+
Object.const_set('SpecialAddress', stub_klass)
|
|
305
|
+
Torque::PostgreSQL.config.composite.irregular_types = { address: 'SpecialAddress' }
|
|
306
|
+
|
|
307
|
+
expect(Torque::PostgreSQL::Attributes::Composite.lookup('address')).to be_eql(stub_klass)
|
|
308
|
+
ensure
|
|
309
|
+
Torque::PostgreSQL.config.composite.irregular_types = {}
|
|
310
|
+
Object.send(:remove_const, 'SpecialAddress')
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
context 'on OID' do
|
|
315
|
+
subject { Torque::PostgreSQL::Adapter::OID::Composite.new('address') }
|
|
316
|
+
|
|
317
|
+
it 'deserializes literals with quoting edge cases' do
|
|
318
|
+
instance = subject.deserialize(%q{("say ""hi"", ok",,"1",A)})
|
|
319
|
+
expect(instance.street).to be_eql('say "hi", ok')
|
|
320
|
+
expect(instance.city).to be_nil
|
|
321
|
+
expect(instance.number).to be_eql(1)
|
|
322
|
+
expect(instance.category).to be_eql('A')
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
it 'differentiates nil and empty string columns' do
|
|
326
|
+
instance = subject.deserialize(%q{("",,,)})
|
|
327
|
+
expect(instance.street).to be_eql('')
|
|
328
|
+
expect(instance.city).to be_nil
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
it 'casts hashes, arrays, instances, and nil' do
|
|
332
|
+
expect(subject.cast(nil)).to be_nil
|
|
333
|
+
expect(subject.cast(street: 'X').street).to be_eql('X')
|
|
334
|
+
expect(subject.cast(['X', 'Y', 2, nil]).city).to be_eql('Y')
|
|
335
|
+
|
|
336
|
+
instance = Composite::Address.new(street: 'X')
|
|
337
|
+
expect(subject.cast(instance)).to be_eql(instance)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
it 'serializes into an encoder and its values' do
|
|
341
|
+
data = subject.serialize(Composite::Address.new(street: 'X', number: 1))
|
|
342
|
+
expect(data).to be_a(ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array::Data)
|
|
343
|
+
expect(data.encoder).to be_a(PG::TextEncoder::Record)
|
|
344
|
+
expect(data.values).to be_eql(['X', nil, 1, nil])
|
|
345
|
+
expect(subject.serialize(nil)).to be_nil
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
it 'deserializes back from what it serialized' do
|
|
349
|
+
instance = Composite::Address.new(street: 'X', number: 1)
|
|
350
|
+
expect(subject.deserialize(subject.serialize(instance))).to be_eql(instance)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
it 'detects in-place changes' do
|
|
354
|
+
raw = %q{(Main,,1,)}
|
|
355
|
+
expect(subject.changed_in_place?(raw, subject.deserialize(raw))).to be_falsey
|
|
356
|
+
expect(subject.changed_in_place?(raw, Composite::Address.new(street: 'Other'))).to be_truthy
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
context 'on records' do
|
|
361
|
+
it 'round-trips composite values' do
|
|
362
|
+
place = Place.create!(name: 'HQ', home: { street: 'Main, St', number: 1, category: 'A' })
|
|
363
|
+
place.reload
|
|
364
|
+
|
|
365
|
+
expect(place.home).to be_a(Composite::Address)
|
|
366
|
+
expect(place.home.street).to be_eql('Main, St')
|
|
367
|
+
expect(place.home.number).to be_eql(1)
|
|
368
|
+
expect(place.home.category).to be_eql('A')
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
it 'round-trips tricky quoting values' do
|
|
372
|
+
tricky = { street: %q{say "hi", ok\maybe}, city: '', number: nil }
|
|
373
|
+
place = Place.create!(name: 'Edge', home: tricky)
|
|
374
|
+
place.reload
|
|
375
|
+
|
|
376
|
+
expect(place.home.street).to be_eql(%q{say "hi", ok\maybe})
|
|
377
|
+
expect(place.home.city).to be_eql('')
|
|
378
|
+
expect(place.home.number).to be_nil
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
it 'round-trips arrays of composite values' do
|
|
382
|
+
offices = [{ street: 'A' }, Composite::Address.new(street: 'B, "C"')]
|
|
383
|
+
place = Place.create!(name: 'Multi', offices: offices)
|
|
384
|
+
place.reload
|
|
385
|
+
|
|
386
|
+
expect(place.offices.size).to be_eql(2)
|
|
387
|
+
expect(place.offices.map(&:class).uniq).to be_eql([Composite::Address])
|
|
388
|
+
expect(place.offices.last.street).to be_eql('B, "C"')
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
it 'round-trips nested composite values' do
|
|
392
|
+
location = { base: { street: 'Deep', number: 7 }, country: 'BR' }
|
|
393
|
+
place = Place.create!(name: 'Nested', location: location)
|
|
394
|
+
place.reload
|
|
395
|
+
|
|
396
|
+
expect(place.location).to be_a(Composite::FullAddress)
|
|
397
|
+
expect(place.location.country).to be_eql('BR')
|
|
398
|
+
expect(place.location.base).to be_a(Composite::Address)
|
|
399
|
+
expect(place.location.base.number).to be_eql(7)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
it 'round-trips columns that are not plain strings' do
|
|
403
|
+
location = { country: 'BR', since: Date.new(2020, 3, 1), rate: BigDecimal('12.34') }
|
|
404
|
+
place = Place.create!(name: 'Typed', location: location)
|
|
405
|
+
place.reload
|
|
406
|
+
|
|
407
|
+
expect(place.location.since).to be_eql(Date.new(2020, 3, 1))
|
|
408
|
+
expect(place.location.rate).to be_eql(BigDecimal('12.34'))
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
it 'does not mark untouched records as dirty' do
|
|
412
|
+
place = Place.create!(name: 'Clean', home: { street: 'S' })
|
|
413
|
+
place.reload
|
|
414
|
+
|
|
415
|
+
place.home.street
|
|
416
|
+
expect(place.changed?).to be_falsey
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
it 'marks assignment changes as dirty' do
|
|
420
|
+
place = Place.create!(name: 'Dirty', home: { street: 'S' })
|
|
421
|
+
place.reload
|
|
422
|
+
|
|
423
|
+
place.home = { street: 'Other' }
|
|
424
|
+
expect(place.changed?).to be_truthy
|
|
425
|
+
|
|
426
|
+
place.save!
|
|
427
|
+
expect(place.reload.home.street).to be_eql('Other')
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
it 'supports composite values on where clauses' do
|
|
431
|
+
place = Place.create!(name: 'Find', home: { street: 'Unique St', number: 9 })
|
|
432
|
+
|
|
433
|
+
found = Place.where(home: place.reload.home).first
|
|
434
|
+
expect(found).to be_eql(place)
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
context 'on null semantics' do
|
|
439
|
+
subject { Place.create!(name: 'Null').reload }
|
|
440
|
+
|
|
441
|
+
let(:raw_home) do
|
|
442
|
+
connection.select_value("SELECT home::text FROM places WHERE id = #{subject.id}")
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
it 'reads a null column as nil, which is blank' do
|
|
446
|
+
expect(subject.home).to be_nil
|
|
447
|
+
expect(subject.home).to be_blank
|
|
448
|
+
expect(subject.offices).to be_nil
|
|
449
|
+
expect(subject.offices).to be_blank
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
it 'keeps the column null when the record is saved again' do
|
|
453
|
+
subject.update!(name: 'Still null')
|
|
454
|
+
expect(raw_home).to be_nil
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
it 'does not validate a null value' do
|
|
458
|
+
expect(Place.new(name: 'Null')).to be_valid
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# A composite always carries every one of its columns, so a row of nulls is
|
|
462
|
+
# a value on its own, which PostgreSQL keeps apart from a null column
|
|
463
|
+
it 'is never blank once there is a value' do
|
|
464
|
+
expect(Composite::Address.new).not_to be_empty
|
|
465
|
+
expect(Composite::Address.new).to be_present
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
it 'stores a value with no columns set as a row of nulls' do
|
|
469
|
+
subject.update!(home: {})
|
|
470
|
+
|
|
471
|
+
expect(raw_home).to be_eql('(,,,)')
|
|
472
|
+
expect(subject.reload.home).to be_a(Composite::Address)
|
|
473
|
+
expect(subject.home).to be_present
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
context 'on enum' do
|
|
478
|
+
let(:enum_klass) do
|
|
479
|
+
klass = Class.new(Torque::PostgreSQL::Attributes::Composite)
|
|
480
|
+
klass.type_name = 'address'
|
|
481
|
+
klass.enum(:category, { alpha: 'A', beta: 'B' })
|
|
482
|
+
klass
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
it 'declares over the type loaded from the database' do
|
|
486
|
+
expect(enum_klass.attribute_types['category']).to be_a(ActiveRecord::Enum::EnumType)
|
|
487
|
+
expect(enum_klass.attribute_types['number'].type).to be_eql(:integer)
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
it 'casts the value both ways' do
|
|
491
|
+
instance = enum_klass.new(category: 'alpha')
|
|
492
|
+
|
|
493
|
+
expect(instance.category).to be_eql('alpha')
|
|
494
|
+
expect(instance.alpha?).to be_truthy
|
|
495
|
+
expect(instance.beta?).to be_falsey
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
it 'serializes through the composite type' do
|
|
499
|
+
type = Torque::PostgreSQL::Adapter::OID::Composite.new('address')
|
|
500
|
+
allow(type).to receive(:klass).and_return(enum_klass)
|
|
501
|
+
|
|
502
|
+
data = type.serialize(enum_klass.new(street: 'M', category: 'alpha'))
|
|
503
|
+
expect(data.values).to be_eql(['M', nil, nil, 'A'])
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
it 'does not define anything that needs a relation' do
|
|
507
|
+
expect(enum_klass.new).not_to respond_to(:alpha!)
|
|
508
|
+
expect(enum_klass).not_to respond_to(:alpha)
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
context 'on normalization' do
|
|
513
|
+
let(:extended_klass) do
|
|
514
|
+
klass = Class.new(Torque::PostgreSQL::Attributes::Composite)
|
|
515
|
+
klass.type_name = 'address'
|
|
516
|
+
klass.normalizes(:street, with: ->(value) { value.strip.upcase })
|
|
517
|
+
klass
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
it 'normalizes a column on assignment' do
|
|
521
|
+
expect(extended_klass.new(street: ' main ').street).to be_eql('MAIN')
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
context 'on store accessor' do
|
|
526
|
+
let(:extended_klass) do
|
|
527
|
+
klass = Class.new(Torque::PostgreSQL::Attributes::Composite)
|
|
528
|
+
klass.type_name = 'address'
|
|
529
|
+
klass.attribute(:extras, ActiveRecord::Type::Json.new)
|
|
530
|
+
klass.store_accessor(:extras, :locale)
|
|
531
|
+
klass
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
it 'expands the keys of a json attribute' do
|
|
535
|
+
instance = extended_klass.new
|
|
536
|
+
instance.locale = 'pt-BR'
|
|
537
|
+
|
|
538
|
+
expect(instance.locale).to be_eql('pt-BR')
|
|
539
|
+
expect(instance.extras).to be_eql('locale' => 'pt-BR')
|
|
540
|
+
expect(instance.locale_changed?).to be_truthy
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
context 'on json serialization' do
|
|
545
|
+
it 'serializes the columns, and not the internals' do
|
|
546
|
+
instance = Composite::Address.new(street: 'Main', number: 1)
|
|
547
|
+
|
|
548
|
+
expect(instance.as_json).to be_eql(
|
|
549
|
+
'street' => 'Main',
|
|
550
|
+
'city' => nil,
|
|
551
|
+
'number' => 1,
|
|
552
|
+
'category' => nil,
|
|
553
|
+
)
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
context 'on encryption' do
|
|
558
|
+
before do
|
|
559
|
+
ActiveRecord::Encryption.configure(
|
|
560
|
+
primary_key: 'test master key',
|
|
561
|
+
deterministic_key: 'test deterministic key',
|
|
562
|
+
key_derivation_salt: 'testing salt',
|
|
563
|
+
)
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
let(:composite_klass) do
|
|
567
|
+
klass = Class.new(Torque::PostgreSQL::Attributes::Composite)
|
|
568
|
+
klass.type_name = 'address'
|
|
569
|
+
klass.encrypts :street
|
|
570
|
+
klass
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
let(:type) do
|
|
574
|
+
oid = Torque::PostgreSQL::Adapter::OID::Composite.new('address')
|
|
575
|
+
allow(oid).to receive(:klass).and_return(composite_klass)
|
|
576
|
+
oid
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
it 'raises when encrypting an undeclared attribute' do
|
|
580
|
+
klass = Class.new(Torque::PostgreSQL::Attributes::Composite)
|
|
581
|
+
klass.type_name = 'address'
|
|
582
|
+
|
|
583
|
+
expect { klass.encrypts :missing }.to raise_error(ArgumentError, /declared attribute/)
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
it 'stores the column encrypted inside the record' do
|
|
587
|
+
data = type.serialize(composite_klass.new(street: 'secret', number: 1))
|
|
588
|
+
|
|
589
|
+
expect(data.values.first).not_to include('secret')
|
|
590
|
+
expect(data.values.first).to include('"p"')
|
|
591
|
+
expect(data.values.third).to be_eql(1)
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
it 'reads the column back decrypted' do
|
|
595
|
+
data = type.serialize(composite_klass.new(street: 'secret', number: 1))
|
|
596
|
+
|
|
597
|
+
expect(type.deserialize(data).street).to be_eql('secret')
|
|
598
|
+
expect(type.deserialize(data).number).to be_eql(1)
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
it 'round-trips through the record literal' do
|
|
602
|
+
data = type.serialize(composite_klass.new(street: 'secret'))
|
|
603
|
+
literal = connection.quote(data)[1..-2].gsub("''", "'")
|
|
604
|
+
|
|
605
|
+
expect(type.deserialize(literal).street).to be_eql('secret')
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
it 'registers the encrypted columns' do
|
|
609
|
+
expect(composite_klass.encrypted_attributes).to include(:street)
|
|
610
|
+
expect(composite_klass.new(street: 'secret').encrypted_attribute?(:street)).to be_falsey
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
it 'exposes the ciphertext of a column read from the database' do
|
|
614
|
+
data = type.serialize(composite_klass.new(street: 'secret'))
|
|
615
|
+
instance = type.deserialize(data)
|
|
616
|
+
|
|
617
|
+
expect(instance.ciphertext_for(:street)).to include('"p"')
|
|
618
|
+
expect(instance.encrypted_attribute?(:street)).to be_truthy
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
it 'supports deterministic encryption' do
|
|
622
|
+
klass = Class.new(Torque::PostgreSQL::Attributes::Composite)
|
|
623
|
+
klass.type_name = 'address'
|
|
624
|
+
klass.encrypts :street, deterministic: true
|
|
625
|
+
|
|
626
|
+
other = Torque::PostgreSQL::Adapter::OID::Composite.new('address')
|
|
627
|
+
allow(other).to receive(:klass).and_return(klass)
|
|
628
|
+
|
|
629
|
+
one = other.serialize(klass.new(street: 'same')).values.first
|
|
630
|
+
two = other.serialize(klass.new(street: 'same')).values.first
|
|
631
|
+
expect(one).to be_eql(two)
|
|
632
|
+
end
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
context 'on where clauses' do
|
|
636
|
+
let(:address) { Composite::Address.new(street: 'Main', number: 9) }
|
|
637
|
+
let!(:place) { Place.create!(name: 'Find', home: { street: 'Main', number: 9 }) }
|
|
638
|
+
let!(:many) { Place.create!(name: 'Many', offices: [{ street: 'A' }, { street: 'B' }]) }
|
|
639
|
+
|
|
640
|
+
it 'casts whole values to the type they belong to' do
|
|
641
|
+
expect(Place.where(home: address).to_sql)
|
|
642
|
+
.to include(%{"places"."home" = '("Main",,"9",)'::address})
|
|
643
|
+
|
|
644
|
+
expect(Place.where(home: place.reload.home).first).to be_eql(place)
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
it 'breaks a hash into conditions over each column' do
|
|
648
|
+
expect(Place.where(home: { street: 'Main' }).to_sql)
|
|
649
|
+
.to include(%{(("places"."home")."street" = 'Main')})
|
|
650
|
+
|
|
651
|
+
expect(Place.where(home: { street: 'Main', number: 9 }).first).to be_eql(place)
|
|
652
|
+
expect(Place.where(home: { street: 'Other' }).first).to be_nil
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
it 'hands each column back to the predicate builder' do
|
|
656
|
+
expect(Place.where(home: { number: 1..15 }).to_sql)
|
|
657
|
+
.to include(%{(("places"."home")."number" BETWEEN 1 AND 15)})
|
|
658
|
+
|
|
659
|
+
expect(Place.where(home: { street: %w[Main Other] }).to_sql)
|
|
660
|
+
.to include(%{(("places"."home")."street" IN ('Main', 'Other'))})
|
|
661
|
+
|
|
662
|
+
expect(Place.where(home: { number: 1..15 }).first).to be_eql(place)
|
|
663
|
+
expect(Place.where(home: { street: %w[Main Other] }).first).to be_eql(place)
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
it 'reaches columns of nested composite types' do
|
|
667
|
+
expect(Place.where(location: { base: { street: 'X' } }).to_sql)
|
|
668
|
+
.to include(%{((("places"."location")."base")."street" = 'X')})
|
|
669
|
+
|
|
670
|
+
nested = Place.create!(name: 'Nested', location: { base: { street: 'X' } })
|
|
671
|
+
expect(Place.where(location: { base: { street: 'X' } }).first).to be_eql(nested)
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
it 'checks if any entry of an array matches a hash' do
|
|
675
|
+
expect(Place.where(offices: { street: 'B' }).to_sql).to include(<<~SQL.squish)
|
|
676
|
+
EXISTS (SELECT 1 FROM UNNEST("places"."offices") "address"
|
|
677
|
+
WHERE (("address")."street" = 'B'))
|
|
678
|
+
SQL
|
|
679
|
+
|
|
680
|
+
expect(Place.where(offices: { street: 'B' }).first).to be_eql(many)
|
|
681
|
+
expect(Place.where(offices: { street: 'C' }).first).to be_nil
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
it 'compares whole values against the entries of an array' do
|
|
685
|
+
expect(Place.where(offices: address).to_sql)
|
|
686
|
+
.to include(%{'("Main",,"9",)'::address = ANY("places"."offices")})
|
|
687
|
+
|
|
688
|
+
expect(Place.where(offices: [address]).to_sql)
|
|
689
|
+
.to include(%{"places"."offices" && '{"(\\"Main\\",,\\"9\\",)"}'::address[]})
|
|
690
|
+
|
|
691
|
+
expect(Place.where(offices: many.reload.offices.first).first).to be_eql(many)
|
|
692
|
+
expect(Place.where(offices: many.offices).first).to be_eql(many)
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
it 'checks each entry of a list of hashes' do
|
|
696
|
+
expect(Place.where(offices: [{ street: 'A' }, { street: 'B' }]).to_sql)
|
|
697
|
+
.to include('EXISTS', 'OR')
|
|
698
|
+
|
|
699
|
+
expect(Place.where(offices: [{ street: 'B' }]).first).to be_eql(many)
|
|
700
|
+
expect(Place.where(offices: [{ street: 'C' }]).first).to be_nil
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
it 'raises when a key is not a column of the composite type' do
|
|
704
|
+
expect { Place.where(home: { nope: 1 }).to_sql }
|
|
705
|
+
.to raise_error(ArgumentError, /not a column of the "address"/)
|
|
706
|
+
|
|
707
|
+
expect { Place.where(home: { nope: { deep: 1 } }).to_sql }
|
|
708
|
+
.to raise_error(ArgumentError, /not a column of the "address"/)
|
|
709
|
+
end
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
context 'on validation' do
|
|
713
|
+
let(:validator) { Torque::PostgreSQL::Validations::NestedValidator }
|
|
714
|
+
|
|
715
|
+
let(:invalid_address) do
|
|
716
|
+
Composite::Address.new(street: 'Main').tap do |address|
|
|
717
|
+
allow(address).to receive(:invalid?).and_return(true)
|
|
718
|
+
end
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
it 'invalidates the record that holds an invalid composite value' do
|
|
722
|
+
place = Place.new(name: 'Broken', home: invalid_address)
|
|
723
|
+
|
|
724
|
+
expect(place).to be_invalid
|
|
725
|
+
expect(place.errors[:home]).to be_present
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
it 'invalidates the record when an entry of an array is invalid' do
|
|
729
|
+
place = Place.new(name: 'Broken', offices: [invalid_address])
|
|
730
|
+
|
|
731
|
+
expect(place).to be_invalid
|
|
732
|
+
expect(place.errors[:offices]).to be_present
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
it 'keeps records with valid composite values valid' do
|
|
736
|
+
expect(Place.new(name: 'Fine', home: { street: 'Main' })).to be_valid
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
it 'only validates the attributes backed by a composite type' do
|
|
740
|
+
Place.new
|
|
741
|
+
|
|
742
|
+
attributes = Place.validators.grep(validator).flat_map(&:attributes)
|
|
743
|
+
expect(attributes).to be_eql(%w[home offices location])
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
it 'leaves models without composite columns alone' do
|
|
747
|
+
Author.new
|
|
748
|
+
Comment.new
|
|
749
|
+
|
|
750
|
+
expect(Author.validators).to be_none(validator)
|
|
751
|
+
expect(Comment.validators).to be_none(validator)
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
it 'does not add the validation again when the schema is reloaded' do
|
|
755
|
+
Place.reset_column_information
|
|
756
|
+
Place.new
|
|
757
|
+
|
|
758
|
+
attributes = Place.validators.grep(validator).flat_map(&:attributes)
|
|
759
|
+
expect(attributes).to be_eql(%w[home offices location])
|
|
760
|
+
end
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
context 'on schema' do
|
|
764
|
+
let(:dump_result) do
|
|
765
|
+
ActiveRecord::SchemaDumper.dump(source, (dump_result = StringIO.new))
|
|
766
|
+
dump_result.string
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
it 'dumps composite types after enums' do
|
|
770
|
+
enum_pos = dump_result.index('create_enum "types"')
|
|
771
|
+
type_pos = dump_result.index('create_composite_type "address", force: :cascade do |t|')
|
|
772
|
+
table_pos = dump_result.index('create_table')
|
|
773
|
+
|
|
774
|
+
expect(enum_pos).to be < type_pos
|
|
775
|
+
expect(type_pos).to be < table_pos
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
it 'dumps composite types sorted by dependencies' do
|
|
779
|
+
address_pos = dump_result.index('create_composite_type "address", force: :cascade do |t|')
|
|
780
|
+
full_pos = dump_result.index('create_composite_type "full_address", force: :cascade do |t|')
|
|
781
|
+
|
|
782
|
+
expect(address_pos).to be < full_pos
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
it 'dumps the columns of composite types' do
|
|
786
|
+
expect(dump_result).to include('t.enum "category", enum_type: "types"')
|
|
787
|
+
expect(dump_result).to include('t.composite "base", composite_type: "address"')
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
it 'does not dump table row types' do
|
|
791
|
+
expect(dump_result).not_to match(/create_composite_type "(places|users|authors)"/)
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
it 'dumps composite columns on tables' do
|
|
795
|
+
expect(dump_result).to include('t.composite "home", composite_type: "address"')
|
|
796
|
+
expect(dump_result).to match(/t\.composite "offices", (?:composite_type: "address", array: true|array: true, composite_type: "address")/)
|
|
797
|
+
expect(dump_result).to include('t.composite "location", composite_type: "full_address"')
|
|
798
|
+
end
|
|
799
|
+
end
|
|
800
|
+
end
|