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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +24 -0
  3. data/lib/torque/postgresql/adapter/database_statements.rb +89 -7
  4. data/lib/torque/postgresql/adapter/inheritance_statements.rb +297 -0
  5. data/lib/torque/postgresql/adapter/oid/array.rb +17 -0
  6. data/lib/torque/postgresql/adapter/oid/box.rb +1 -1
  7. data/lib/torque/postgresql/adapter/oid/circle.rb +1 -1
  8. data/lib/torque/postgresql/adapter/oid/composite.rb +116 -0
  9. data/lib/torque/postgresql/adapter/oid/line.rb +1 -1
  10. data/lib/torque/postgresql/adapter/oid/lquery.rb +53 -0
  11. data/lib/torque/postgresql/adapter/oid/ltree.rb +53 -0
  12. data/lib/torque/postgresql/adapter/oid/segment.rb +1 -1
  13. data/lib/torque/postgresql/adapter/oid/struct.rb +150 -0
  14. data/lib/torque/postgresql/adapter/oid/struct_list.rb +52 -0
  15. data/lib/torque/postgresql/adapter/oid/struct_set.rb +38 -0
  16. data/lib/torque/postgresql/adapter/quoting.rb +11 -2
  17. data/lib/torque/postgresql/adapter/schema_definitions.rb +46 -0
  18. data/lib/torque/postgresql/adapter/schema_dumper.rb +26 -0
  19. data/lib/torque/postgresql/adapter/schema_statements.rb +207 -0
  20. data/lib/torque/postgresql/adapter.rb +2 -5
  21. data/lib/torque/postgresql/arel/nodes.rb +63 -1
  22. data/lib/torque/postgresql/arel/visitors.rb +19 -8
  23. data/lib/torque/postgresql/associations/join_dependency.rb +55 -0
  24. data/lib/torque/postgresql/associations.rb +3 -0
  25. data/lib/torque/postgresql/attributes/base.rb +94 -0
  26. data/lib/torque/postgresql/attributes/composite.rb +102 -0
  27. data/lib/torque/postgresql/attributes/enum.rb +13 -2
  28. data/lib/torque/postgresql/attributes/lquery.rb +180 -0
  29. data/lib/torque/postgresql/attributes/ltree.rb +169 -0
  30. data/lib/torque/postgresql/attributes/simple_enum.rb +72 -0
  31. data/lib/torque/postgresql/attributes/struct.rb +137 -0
  32. data/lib/torque/postgresql/base.rb +17 -14
  33. data/lib/torque/postgresql/config.rb +81 -14
  34. data/lib/torque/postgresql/inheritance/expander.rb +66 -0
  35. data/lib/torque/postgresql/inheritance/record.rb +52 -0
  36. data/lib/torque/postgresql/inheritance.rb +138 -65
  37. data/lib/torque/postgresql/migration/command_recorder.rb +76 -0
  38. data/lib/torque/postgresql/predicate_builder/composite_handler.rb +109 -0
  39. data/lib/torque/postgresql/predicate_builder/ltree_handler.rb +44 -0
  40. data/lib/torque/postgresql/predicate_builder/struct_handler.rb +68 -0
  41. data/lib/torque/postgresql/predicate_builder.rb +26 -0
  42. data/lib/torque/postgresql/predicate_table.rb +61 -0
  43. data/lib/torque/postgresql/railtie.rb +35 -0
  44. data/lib/torque/postgresql/relation/inheritance.rb +146 -28
  45. data/lib/torque/postgresql/relation/merger.rb +21 -5
  46. data/lib/torque/postgresql/relation.rb +12 -11
  47. data/lib/torque/postgresql/schema_cache.rb +1 -0
  48. data/lib/torque/postgresql/validations.rb +29 -0
  49. data/lib/torque/postgresql/version.rb +1 -1
  50. data/lib/torque/postgresql/versioned_commands/command_migration.rb +1 -1
  51. data/lib/torque/postgresql.rb +6 -0
  52. data/spec/initialize.rb +20 -2
  53. data/spec/mocks/cache_query.rb +12 -0
  54. data/spec/models/author.rb +1 -1
  55. data/spec/models/comment.rb +2 -0
  56. data/spec/models/place.rb +2 -0
  57. data/spec/models/profile.rb +31 -0
  58. data/spec/schema.rb +33 -4
  59. data/spec/tests/arel_spec.rb +5 -3
  60. data/spec/tests/composite_spec.rb +800 -0
  61. data/spec/tests/ltree_spec.rb +552 -0
  62. data/spec/tests/struct_spec.rb +968 -0
  63. data/spec/tests/table_inheritance_spec.rb +1027 -56
  64. 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