torque-postgresql 4.0.0 → 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 (65) 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 +28 -2
  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/join_series.rb +8 -6
  46. data/lib/torque/postgresql/relation/merger.rb +23 -7
  47. data/lib/torque/postgresql/relation.rb +12 -11
  48. data/lib/torque/postgresql/schema_cache.rb +1 -0
  49. data/lib/torque/postgresql/validations.rb +29 -0
  50. data/lib/torque/postgresql/version.rb +1 -1
  51. data/lib/torque/postgresql/versioned_commands/command_migration.rb +1 -1
  52. data/lib/torque/postgresql.rb +6 -0
  53. data/spec/initialize.rb +20 -2
  54. data/spec/mocks/cache_query.rb +12 -0
  55. data/spec/models/author.rb +1 -1
  56. data/spec/models/comment.rb +2 -0
  57. data/spec/models/place.rb +2 -0
  58. data/spec/models/profile.rb +31 -0
  59. data/spec/schema.rb +34 -4
  60. data/spec/tests/arel_spec.rb +5 -3
  61. data/spec/tests/composite_spec.rb +800 -0
  62. data/spec/tests/ltree_spec.rb +552 -0
  63. data/spec/tests/struct_spec.rb +968 -0
  64. data/spec/tests/table_inheritance_spec.rb +1027 -56
  65. metadata +49 -7
@@ -0,0 +1,968 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe 'Struct' do
4
+ let(:connection) { ActiveRecord::Base.connection }
5
+ let(:settings_klass) { Profile::Settings }
6
+
7
+ context 'on setup' do
8
+ it 'exposes the base method on models' do
9
+ expect(ActiveRecord::Base).to respond_to(:struct_for)
10
+ end
11
+
12
+ it 'accepts a configurable method name' do
13
+ expect(Torque::PostgreSQL.config.struct.base_method).to be_eql(:struct_for)
14
+ end
15
+
16
+ it 'raises when the column is encrypted on the model level' do
17
+ expect do
18
+ Class.new(ActiveRecord::Base) do
19
+ self.table_name = 'profiles'
20
+ encrypts :settings
21
+ struct_for :settings, Profile::Settings
22
+ end
23
+ end.to raise_error(ArgumentError, /encrypted/)
24
+ end
25
+
26
+ it 'raises when the column cannot hold a document' do
27
+ expect do
28
+ Class.new(ActiveRecord::Base) do
29
+ self.table_name = 'profiles'
30
+ struct_for :name, Profile::Bio
31
+ end
32
+ end.to raise_error(ArgumentError, /document/)
33
+ end
34
+
35
+ it 'supports any ActiveModel class with attributes' do
36
+ plain_klass = Class.new do
37
+ include ActiveModel::Model
38
+ include ActiveModel::Attributes
39
+
40
+ attribute :value, :integer
41
+ end
42
+
43
+ model = Class.new(ActiveRecord::Base) do
44
+ self.table_name = 'profiles'
45
+ struct_for :settings, plain_klass
46
+ end
47
+
48
+ instance = model.new(settings: { value: '10' })
49
+ expect(instance.settings).to be_a(plain_klass)
50
+ expect(instance.settings.value).to be_eql(10)
51
+ end
52
+ end
53
+
54
+ context 'on column types' do
55
+ subject { Profile.new }
56
+
57
+ it 'keeps the jsonb type of the column' do
58
+ expect(Profile.type_for_attribute(:settings).type).to be_eql(:jsonb)
59
+ end
60
+
61
+ it 'keeps the json type of the column' do
62
+ expect(Profile.type_for_attribute(:bio).type).to be_eql(:json)
63
+ end
64
+
65
+ it 'round-trips a json column' do
66
+ subject.bio.headline = 'hello'
67
+ subject.save!
68
+
69
+ raw = connection.select_value("SELECT bio::text FROM profiles WHERE id = #{subject.id}")
70
+ expect(raw).to be_present
71
+ expect(subject.reload.bio.headline).to be_eql('hello')
72
+ end
73
+ end
74
+
75
+ context 'on casting' do
76
+ subject { Profile.new }
77
+
78
+ it 'returns an instance when the column is null' do
79
+ expect(subject.settings).to be_a(settings_klass)
80
+ expect(subject.settings.theme).to be_eql('light')
81
+ expect(subject.settings.notifications).to be_eql(true)
82
+ end
83
+
84
+ it 'accepts a hash' do
85
+ subject.settings = { theme: 'dark' }
86
+ expect(subject.settings).to be_a(settings_klass)
87
+ expect(subject.settings.theme).to be_eql('dark')
88
+ end
89
+
90
+ it 'accepts an instance' do
91
+ subject.settings = settings_klass.new(theme: 'dark')
92
+ expect(subject.settings.theme).to be_eql('dark')
93
+ end
94
+
95
+ it 'accepts nil' do
96
+ subject.settings = nil
97
+ expect(subject.settings).to be_nil
98
+ end
99
+
100
+ it 'accepts an instance loaded from another record' do
101
+ origin = Profile.create!(settings: { theme: 'dark' }).reload
102
+ subject.settings = origin.settings
103
+ subject.save!
104
+ expect(subject.reload.settings.theme).to be_eql('dark')
105
+ end
106
+
107
+ it 'casts attribute values through their types' do
108
+ subject.settings = { notifications: '0' }
109
+ expect(subject.settings.notifications).to be_eql(false)
110
+ end
111
+ end
112
+
113
+ context 'on documents' do
114
+ subject { Profile.create!(name: 'a') }
115
+
116
+ let(:document) do
117
+ raw = connection.select_value("SELECT settings::text FROM profiles WHERE id = #{subject.id}")
118
+ ActiveSupport::JSON.decode(raw)
119
+ end
120
+
121
+ it 'stores the properties that have a default' do
122
+ expect(document).to be_eql('theme' => 'light', 'notifications' => true)
123
+ end
124
+
125
+ it 'marks a new record as changed because of the defaults' do
126
+ expect(Profile.new.changed).to include('settings')
127
+ end
128
+
129
+ it 'does not store properties that have no default' do
130
+ expect(document).not_to have_key('tags')
131
+ end
132
+
133
+ it 'adds properties as they are written' do
134
+ subject.settings.tags = %w[a]
135
+ subject.save!
136
+ expect(document).to have_key('tags')
137
+ end
138
+
139
+ it 'does not add properties that are missing from the document' do
140
+ connection.execute(<<~SQL)
141
+ UPDATE profiles SET settings = '{"tags": ["a"]}' WHERE id = #{subject.id}
142
+ SQL
143
+
144
+ subject.reload
145
+ expect(subject.settings.theme).to be_nil
146
+ expect(subject.changed?).to be_falsey
147
+
148
+ subject.settings.tags << 'b'
149
+ subject.save!
150
+ expect(document).to be_eql('tags' => %w[a b])
151
+ end
152
+ end
153
+
154
+ context 'on defaults' do
155
+ let(:model) do
156
+ options = default_options
157
+ Class.new(ActiveRecord::Base) do
158
+ self.table_name = 'profiles'
159
+
160
+ struct_for :settings, Profile::Settings, default: options
161
+
162
+ def default_settings
163
+ { theme: 'dark', tags: [name].compact }
164
+ end
165
+ end
166
+ end
167
+
168
+ context 'with a hash' do
169
+ let(:default_options) { { theme: 'dark' } }
170
+
171
+ it 'overrides the class defaults' do
172
+ expect(model.new.settings.theme).to be_eql('dark')
173
+ expect(model.new.settings.notifications).to be_eql(true)
174
+ end
175
+
176
+ it 'saves the defaults and marks the record as changed' do
177
+ record = model.new
178
+ expect(record.changed).to include('settings')
179
+
180
+ record.save!
181
+ raw = connection.select_value("SELECT settings::text FROM profiles WHERE id = #{record.id}")
182
+ expect(ActiveSupport::JSON.decode(raw)['theme']).to be_eql('dark')
183
+ end
184
+
185
+ it 'does not share the default between records' do
186
+ model.new.settings.theme << '!'
187
+ expect(model.new.settings.theme).to be_eql('dark')
188
+ end
189
+ end
190
+
191
+ context 'with a proc' do
192
+ let(:default_options) { -> { { theme: 'dark', tags: [name].compact } } }
193
+
194
+ it 'overrides the class defaults' do
195
+ expect(model.new.settings.theme).to be_eql('dark')
196
+ end
197
+
198
+ it 'is evaluated on the record' do
199
+ expect(model.new(name: 'a').settings.tags).to be_eql(%w[a])
200
+ end
201
+ end
202
+
203
+ context 'with a symbol' do
204
+ let(:default_options) { :default_settings }
205
+
206
+ it 'calls the method on the record and overrides the class defaults' do
207
+ expect(model.new.settings.theme).to be_eql('dark')
208
+ end
209
+
210
+ it 'composes the default from the record' do
211
+ expect(model.new(name: 'a').settings.tags).to be_eql(%w[a])
212
+ end
213
+ end
214
+ end
215
+
216
+ context 'on backfill' do
217
+ let(:model) do
218
+ options = backfill_options
219
+ Class.new(ActiveRecord::Base) do
220
+ self.table_name = 'profiles'
221
+ struct_for :settings, Profile::Settings, backfill: options
222
+ end
223
+ end
224
+
225
+ subject { model.create!(name: 'a') }
226
+
227
+ let(:document) do
228
+ raw = connection.select_value("SELECT settings::text FROM profiles WHERE id = #{subject.id}")
229
+ ActiveSupport::JSON.decode(raw)
230
+ end
231
+
232
+ before do
233
+ connection.execute(<<~SQL)
234
+ UPDATE profiles SET settings = '{"tags": ["a"]}' WHERE id = #{subject.id}
235
+ SQL
236
+
237
+ subject.reload
238
+ end
239
+
240
+ context 'with true' do
241
+ let(:backfill_options) { true }
242
+
243
+ it 'applies the class defaults to stored documents' do
244
+ expect(subject.settings.theme).to be_eql('light')
245
+ expect(subject.settings.notifications).to be_eql(true)
246
+ end
247
+
248
+ it 'marks the record as changed once the value is read' do
249
+ expect(subject.settings.theme).to be_present
250
+ expect(subject.changed?).to be_truthy
251
+
252
+ subject.save!
253
+ expect(document['theme']).to be_eql('light')
254
+ expect(document['tags']).to be_eql(%w[a])
255
+
256
+ expect(subject.reload.changed?).to be_falsey
257
+ end
258
+ end
259
+
260
+ context 'with a list of properties' do
261
+ let(:backfill_options) { %i[theme] }
262
+
263
+ it 'applies only the listed defaults' do
264
+ expect(subject.settings.theme).to be_eql('light')
265
+ expect(subject.settings.notifications).to be_nil
266
+ end
267
+
268
+ it 'marks the record as changed once the value is read' do
269
+ expect(subject.settings.theme).to be_present
270
+ expect(subject.changed?).to be_truthy
271
+
272
+ subject.save!
273
+ expect(document).to be_eql('theme' => 'light', 'tags' => %w[a])
274
+ end
275
+ end
276
+ end
277
+
278
+ context 'on null semantics' do
279
+ subject { Profile.create!(name: 'a').reload }
280
+
281
+ let(:raw_bio) do
282
+ connection.select_value("SELECT bio::text FROM profiles WHERE id = #{subject.id}")
283
+ end
284
+
285
+ it 'keeps the column null when there are no defaults' do
286
+ expect(raw_bio).to be_nil
287
+ end
288
+
289
+ it 'returns an instance when the column is null' do
290
+ expect(subject.bio).to be_a(Profile::Bio)
291
+ expect(subject.bio.headline).to be_nil
292
+ end
293
+
294
+ it 'reads the instance of a null column as blank' do
295
+ expect(subject.bio).to be_empty
296
+ expect(subject.bio).to be_blank
297
+ end
298
+
299
+ it 'keeps the column null when the instance is never changed' do
300
+ subject.bio.headline
301
+ subject.update!(name: 'b')
302
+ expect(raw_bio).to be_nil
303
+ end
304
+
305
+ it 'does not mark the record as dirty when reading' do
306
+ subject.bio.headline
307
+ expect(subject.changed?).to be_falsey
308
+ end
309
+
310
+ it 'stores the value once the instance is changed' do
311
+ subject.bio.headline = 'hi'
312
+ expect(subject.changed?).to be_truthy
313
+
314
+ subject.save!
315
+ expect(subject.reload.changed?).to be_falsey
316
+ expect(subject.bio.headline).to be_eql('hi')
317
+ end
318
+
319
+ it 'goes back to pristine when a change is reverted' do
320
+ subject.settings.theme = 'dark'
321
+ expect(subject.changed?).to be_truthy
322
+
323
+ subject.settings.theme = 'light'
324
+ expect(subject.changed?).to be_falsey
325
+ end
326
+
327
+ it 'keeps the raw value byte-identical while untouched' do
328
+ connection.execute(<<~SQL)
329
+ UPDATE profiles SET settings = '{"zeta": 1, "theme": "dark"}' WHERE id = #{subject.id}
330
+ SQL
331
+
332
+ subject.reload
333
+ query = "SELECT settings::text FROM profiles WHERE id = #{subject.id}"
334
+ before = connection.select_value(query)
335
+
336
+ subject.settings.theme
337
+ subject.update!(name: 'c')
338
+ expect(connection.select_value(query)).to be_eql(before)
339
+ end
340
+
341
+ it 'detects in-place mutation of nested values' do
342
+ connection.execute(<<~SQL)
343
+ UPDATE profiles SET settings = '{"tags": ["a"]}' WHERE id = #{subject.id}
344
+ SQL
345
+
346
+ subject.reload
347
+
348
+ subject.settings.tags << 'b'
349
+ expect(subject.changed?).to be_truthy
350
+
351
+ subject.save!
352
+ expect(subject.reload.settings.tags).to be_eql(%w[a b])
353
+ end
354
+ end
355
+
356
+ context 'on unknown keys' do
357
+ subject { Profile.create!(name: 'a') }
358
+
359
+ let(:document) do
360
+ raw = connection.select_value("SELECT settings::text FROM profiles WHERE id = #{subject.id}")
361
+ ActiveSupport::JSON.decode(raw)
362
+ end
363
+
364
+ before do
365
+ connection.execute(<<~SQL)
366
+ UPDATE profiles SET settings = '{"theme": "dark", "legacy": 123}' WHERE id = #{subject.id}
367
+ SQL
368
+
369
+ subject.reload
370
+ end
371
+
372
+ it 'gives plain access to unknown keys' do
373
+ expect(subject.settings[:legacy]).to be_eql(123)
374
+ end
375
+
376
+ it 'routes declared attributes through the accessors' do
377
+ expect(subject.settings[:theme]).to be_eql('dark')
378
+
379
+ subject.settings[:notifications] = 'false'
380
+ expect(subject.settings.notifications).to be_eql(false)
381
+ end
382
+
383
+ it 'preserves unknown keys when the instance is stored' do
384
+ subject.settings.theme = 'light'
385
+ subject.save!
386
+
387
+ expect(document['legacy']).to be_eql(123)
388
+ expect(document['theme']).to be_eql('light')
389
+ end
390
+
391
+ context 'when strict' do
392
+ it 'is the configured default' do
393
+ expect(Torque::PostgreSQL.config.struct.default_strict).to be_truthy
394
+ expect(settings_klass.strict?).to be_truthy
395
+ end
396
+
397
+ it 'raises when writing an unknown key' do
398
+ expect do
399
+ subject.settings[:other] = 'x'
400
+ end.to raise_error(ActiveModel::UnknownAttributeError, /other/)
401
+ end
402
+
403
+ it 'raises when building an instance with an unknown key' do
404
+ expect do
405
+ settings_klass.new(other: 'x')
406
+ end.to raise_error(ActiveModel::UnknownAttributeError, /other/)
407
+ end
408
+
409
+ it 'raises before storing a hash with an unknown key' do
410
+ subject.settings = { other: 'x' }
411
+ expect { subject.save! }.to raise_error(ActiveModel::UnknownAttributeError, /other/)
412
+ end
413
+ end
414
+
415
+ context 'when not strict' do
416
+ let(:struct_klass) do
417
+ Class.new(Torque::PostgreSQL::Attributes::Struct) do
418
+ self.strict = false
419
+
420
+ attribute :theme, :string
421
+ end
422
+ end
423
+
424
+ let(:model) do
425
+ klass = struct_klass
426
+ Class.new(ActiveRecord::Base) do
427
+ self.table_name = 'profiles'
428
+ struct_for :settings, klass
429
+ end
430
+ end
431
+
432
+ subject { model.find(super().id) }
433
+
434
+ it 'accepts the option on the struct method' do
435
+ klass = Class.new(Torque::PostgreSQL::Attributes::Struct) { attribute :theme, :string }
436
+ Class.new(ActiveRecord::Base) do
437
+ self.table_name = 'profiles'
438
+ struct_for :settings, klass, strict: false
439
+ end
440
+
441
+ expect(klass.strict?).to be_falsey
442
+ end
443
+
444
+ it 'stores unknown keys' do
445
+ subject.settings[:legacy] = 321
446
+ expect(subject.settings[:legacy]).to be_eql(321)
447
+ expect(subject.settings['legacy']).to be_eql(321)
448
+
449
+ subject.settings['legacy'] = 123
450
+ expect(subject.settings[:legacy]).to be_eql(123)
451
+ expect(subject.settings['legacy']).to be_eql(123)
452
+ end
453
+
454
+ it 'marks the instance as changed when writing unknown keys' do
455
+ subject.settings[:other] = 'x'
456
+ expect(subject.changed?).to be_truthy
457
+
458
+ subject.save!
459
+ expect(document['other']).to be_eql('x')
460
+ end
461
+ end
462
+ end
463
+
464
+ context 'on emptiness' do
465
+ subject { Profile.new(name: 'a') }
466
+
467
+ it 'is empty while none of its properties were written to' do
468
+ expect(subject.bio).to be_empty
469
+ expect(subject.bio).to be_blank
470
+
471
+ subject.bio.headline = 'a'
472
+ expect(subject.bio).not_to be_empty
473
+ expect(subject.bio).to be_present
474
+ end
475
+
476
+ it 'is not empty when the class declares defaults' do
477
+ expect(subject.settings).not_to be_empty
478
+ expect(subject.settings).to be_present
479
+ end
480
+ end
481
+
482
+ context 'on validation' do
483
+ subject { Profile.new(name: 'a') }
484
+
485
+ it 'skips validation while nothing is stored' do
486
+ expect(subject.bio).to be_a(Profile::Bio)
487
+ expect(subject.bio).to be_empty
488
+ expect(subject).to be_valid
489
+ end
490
+
491
+ it 'adds a plain invalid error when the instance is invalid' do
492
+ subject.settings.theme = 'bogus'
493
+ expect(subject).to be_invalid
494
+ expect(subject.errors.added?(:settings, :invalid)).to be_truthy
495
+ end
496
+
497
+ it 'runs the validation once the value is present on the database' do
498
+ subject.save!
499
+ connection.execute(<<~SQL)
500
+ UPDATE profiles SET settings = '{"theme": "bogus"}' WHERE id = #{subject.id}
501
+ SQL
502
+
503
+ subject.reload
504
+ expect(subject).to be_invalid
505
+ end
506
+
507
+ it 'validates every entry of an array' do
508
+ subject.previews = [{ label: 'a', url: 'http://x' }, { label: 'b' }]
509
+ expect(subject).to be_invalid
510
+ expect(subject.errors.added?(:previews, :invalid)).to be_truthy
511
+ end
512
+ end
513
+
514
+ context 'on delegation' do
515
+ subject { Profile.new }
516
+
517
+ it 'delegates readers and writers to the struct' do
518
+ subject.theme = 'dark'
519
+ expect(subject.theme).to be_eql('dark')
520
+ expect(subject.settings.theme).to be_eql('dark')
521
+ end
522
+ end
523
+
524
+ context 'on json array columns' do
525
+ subject { Profile.create!(name: 'a').reload }
526
+
527
+ it 'reads null as an empty list' do
528
+ expect(subject.previews).to be_empty
529
+ end
530
+
531
+ it 'keeps the column null when the list is never changed' do
532
+ subject.previews.size
533
+ subject.update!(name: 'b')
534
+
535
+ raw = connection.select_value("SELECT previews::text FROM profiles WHERE id = #{subject.id}")
536
+ expect(raw).to be_nil
537
+ end
538
+
539
+ it 'casts every entry to an instance' do
540
+ subject.update!(previews: [{ label: 'a', url: 'x' }, Profile::Preview.new(label: 'b', url: 'y')])
541
+ subject.reload
542
+
543
+ expect(subject.previews.size).to be_eql(2)
544
+ expect(subject.previews.map(&:class).uniq).to be_eql([Profile::Preview])
545
+ expect(subject.previews.first.label).to be_eql('a')
546
+ end
547
+
548
+ it 'detects additions and mutations' do
549
+ subject.update!(previews: [{ label: 'a', url: 'x' }])
550
+ subject.reload
551
+
552
+ subject.previews << Profile::Preview.new(label: 'b', url: 'y')
553
+ expect(subject.changed?).to be_truthy
554
+
555
+ subject.save!
556
+ subject.reload
557
+
558
+ subject.previews.first.label = 'c'
559
+ expect(subject.changed?).to be_truthy
560
+
561
+ subject.save!
562
+ expect(subject.reload.previews.map(&:label)).to be_eql(%w[c b])
563
+ end
564
+ end
565
+
566
+ context 'on native array columns' do
567
+ subject { Profile.create!(name: 'a').reload }
568
+
569
+ it 'reads null as an empty list' do
570
+ expect(subject.snippets).to be_empty
571
+ end
572
+
573
+ it 'keeps the column null when the list is never changed' do
574
+ subject.snippets.size
575
+ subject.update!(name: 'b')
576
+
577
+ raw = connection.select_value("SELECT snippets::text FROM profiles WHERE id = #{subject.id}")
578
+ expect(raw).to be_nil
579
+ end
580
+
581
+ it 'casts every entry to an instance and round-trips' do
582
+ subject.update!(snippets: [{ title: 't1', body: 'b1' }, { title: 't2', body: 'b2' }])
583
+ subject.reload
584
+
585
+ expect(subject.snippets.size).to be_eql(2)
586
+ expect(subject.snippets.first).to be_a(Profile::Snippet)
587
+ expect(subject.snippets.first.title).to be_eql('t1')
588
+ end
589
+
590
+ it 'detects mutations on entries' do
591
+ subject.update!(snippets: [{ title: 't1', body: 'b1' }])
592
+ subject.reload
593
+
594
+ subject.snippets.first.body = 'b2'
595
+ expect(subject.changed?).to be_truthy
596
+
597
+ subject.save!
598
+ expect(subject.reload.snippets.first.body).to be_eql('b2')
599
+ end
600
+ end
601
+
602
+ context 'on encryption' do
603
+ before do
604
+ ActiveRecord::Encryption.configure(
605
+ primary_key: 'test master key',
606
+ deterministic_key: 'test deterministic key',
607
+ key_derivation_salt: 'testing salt',
608
+ )
609
+ end
610
+
611
+ let(:struct_klass) do
612
+ Class.new(Torque::PostgreSQL::Attributes::Struct) do
613
+ attribute :label, :string
614
+ attribute :token, :string
615
+
616
+ encrypts :token
617
+ end
618
+ end
619
+
620
+ let(:model) do
621
+ klass = struct_klass
622
+ Class.new(ActiveRecord::Base) do
623
+ self.table_name = 'profiles'
624
+ struct_for :settings, klass
625
+ end
626
+ end
627
+
628
+ subject { model.create!(settings: { label: 'a', token: 'secret' }) }
629
+
630
+ it 'raises when encrypting an undeclared attribute' do
631
+ expect do
632
+ Class.new(Torque::PostgreSQL::Attributes::Struct) { encrypts :missing }
633
+ end.to raise_error(ArgumentError, /declared attribute/)
634
+ end
635
+
636
+ it 'stores the attribute encrypted inside the document' do
637
+ raw = connection.select_value("SELECT settings::text FROM profiles WHERE id = #{subject.id}")
638
+ expect(raw).not_to include('secret')
639
+ expect(ActiveSupport::JSON.decode(raw)['label']).to be_eql('a')
640
+ end
641
+
642
+ it 'decrypts the attribute when loading' do
643
+ expect(subject.reload.settings.token).to be_eql('secret')
644
+ end
645
+
646
+ it 'does not mark unchanged records as dirty' do
647
+ expect(subject.changed?).to be_falsey
648
+
649
+ subject.reload
650
+ subject.settings.label
651
+ expect(subject.changed?).to be_falsey
652
+ end
653
+
654
+ it 'persists changes to the encrypted attribute' do
655
+ subject.reload
656
+ subject.settings.token = 'other'
657
+ expect(subject.changed?).to be_truthy
658
+
659
+ subject.save!
660
+ expect(subject.reload.settings.token).to be_eql('other')
661
+ end
662
+ end
663
+
664
+ context 'on instances' do
665
+ it 'compares by class and attributes' do
666
+ one = settings_klass.new(theme: 'dark')
667
+ two = settings_klass.new(theme: 'dark')
668
+ expect(one).to be_eql(two)
669
+
670
+ two.notifications = false
671
+ expect(one).not_to be_eql(two)
672
+ end
673
+
674
+ it 'only exposes the attributes that were written' do
675
+ instance = settings_klass.new
676
+ expect(instance.attributes).to be_eql('theme' => 'light', 'notifications' => true)
677
+
678
+ instance.tags = %w[a]
679
+ expect(instance.attributes).to have_key('tags')
680
+ end
681
+
682
+ it 'exposes the dirty methods' do
683
+ instance = settings_klass.new
684
+ instance.theme = 'dark'
685
+ expect(instance.theme_changed?).to be_truthy
686
+ expect(instance.changes).to include('theme')
687
+ end
688
+
689
+ it 'reports a property that was never written as changed from nil' do
690
+ instance = settings_klass.new
691
+ instance.tags = %w[a]
692
+
693
+ expect(instance.changes['tags']).to be_eql([nil, %w[a]])
694
+ expect(instance.tags_was).to be_nil
695
+ end
696
+ end
697
+
698
+ context 'on json serialization' do
699
+ it 'serializes the properties, and not the internals' do
700
+ instance = settings_klass.new(theme: 'dark')
701
+
702
+ expect(instance.as_json).to be_eql('theme' => 'dark', 'notifications' => true)
703
+ expect(instance.to_json).to be_eql(%({"theme":"dark","notifications":true}))
704
+ end
705
+
706
+ it 'leaves out the properties that were never written' do
707
+ expect(Profile::Bio.new.as_json).to be_eql({})
708
+ end
709
+ end
710
+
711
+ context 'on enum' do
712
+ let(:enum_klass) do
713
+ Class.new(Torque::PostgreSQL::Attributes::Struct) do
714
+ attribute :status, :string
715
+ enum :status, { active: 'a', off: 'o' }
716
+ end
717
+ end
718
+
719
+ it 'casts the value both ways' do
720
+ instance = enum_klass.new(status: 'active')
721
+
722
+ expect(instance.status).to be_eql('active')
723
+ expect(instance.read_attribute_for_database(:status)).to be_eql('a')
724
+ end
725
+
726
+ it 'defines only the predicates' do
727
+ instance = enum_klass.new(status: 'active')
728
+
729
+ expect(instance.active?).to be_truthy
730
+ expect(instance.off?).to be_falsey
731
+ expect(instance).not_to respond_to(:active!)
732
+ expect(enum_klass).not_to respond_to(:active)
733
+ expect(enum_klass).not_to respond_to(:not_active)
734
+ end
735
+
736
+ it 'exposes the values and the definition' do
737
+ expect(enum_klass.statuses.to_h).to be_eql('active' => 'a', 'off' => 'o')
738
+ expect(enum_klass.defined_enums).to be_eql('status' => { 'active' => 'a', 'off' => 'o' })
739
+ end
740
+
741
+ it 'raises on an invalid value' do
742
+ expect { enum_klass.new(status: 'bogus') }.to raise_error(ArgumentError, /not a valid status/)
743
+ end
744
+
745
+ it 'supports the prefix option' do
746
+ klass = Class.new(Torque::PostgreSQL::Attributes::Struct) do
747
+ attribute :state, :string
748
+ enum :state, { active: 'a' }, prefix: true
749
+ end
750
+
751
+ expect(klass.new(state: 'active').state_active?).to be_truthy
752
+ end
753
+
754
+ it 'validates instead of raising when asked to' do
755
+ klass = stub_const('SampleEnumStruct', Class.new(Torque::PostgreSQL::Attributes::Struct))
756
+ klass.attribute(:kind, :string)
757
+ klass.enum(:kind, { a: 'a' }, validate: true)
758
+
759
+ instance = klass.new(kind: 'bogus')
760
+ expect(instance).to be_invalid
761
+ expect(instance.errors[:kind]).to be_present
762
+ end
763
+
764
+ it 'refuses a value that would shadow an existing method' do
765
+ expect do
766
+ Class.new(Torque::PostgreSQL::Attributes::Struct) do
767
+ attribute :kind, :string
768
+ enum :kind, { empty: 'e' }
769
+ end
770
+ end.to raise_error(ArgumentError, /already defined/)
771
+ end
772
+ end
773
+
774
+ context 'on normalization' do
775
+ let(:normalized_klass) do
776
+ Class.new(Torque::PostgreSQL::Attributes::Struct) do
777
+ attribute :email, :string
778
+ normalizes :email, with: ->(value) { value.strip.downcase }
779
+ end
780
+ end
781
+
782
+ it 'normalizes on assignment' do
783
+ expect(normalized_klass.new(email: ' A@B.C ').email).to be_eql('a@b.c')
784
+ end
785
+
786
+ it 'leaves nil alone' do
787
+ expect(normalized_klass.new.email).to be_nil
788
+ end
789
+ end
790
+
791
+ context 'on store accessor' do
792
+ let(:stored_klass) do
793
+ Class.new(Torque::PostgreSQL::Attributes::Struct) do
794
+ attribute :prefs, ActiveRecord::Type::Json.new
795
+ store_accessor :prefs, :theme, :locale
796
+ end
797
+ end
798
+
799
+ it 'expands the keys of a json property' do
800
+ instance = stored_klass.new
801
+ instance.theme = 'dark'
802
+
803
+ expect(instance.theme).to be_eql('dark')
804
+ expect(instance.prefs).to be_eql('theme' => 'dark')
805
+ end
806
+
807
+ it 'tracks the changes of each key' do
808
+ instance = stored_klass.new
809
+ instance.theme = 'dark'
810
+
811
+ expect(instance.theme_changed?).to be_truthy
812
+ expect(instance.theme_was).to be_nil
813
+ expect(instance.locale_changed?).to be_falsey
814
+ end
815
+
816
+ it 'exposes the stored attributes' do
817
+ expect(stored_klass.stored_attributes[:prefs]).to be_eql(%i[theme locale])
818
+ end
819
+ end
820
+
821
+ context 'on nested documents' do
822
+ let(:inner_klass) do
823
+ stub_const('SampleInner', Class.new(Torque::PostgreSQL::Attributes::Struct))
824
+ SampleInner.attribute(:city, :string)
825
+ SampleInner.attribute(:zip, :integer)
826
+ SampleInner
827
+ end
828
+
829
+ let(:outer_klass) do
830
+ inner = inner_klass
831
+ stub_const('SampleOuter', Class.new(Torque::PostgreSQL::Attributes::Struct))
832
+ SampleOuter.attribute(:label, :string)
833
+ SampleOuter.attribute(:address, Torque::PostgreSQL::Adapter::OID::Struct.new(inner))
834
+ SampleOuter
835
+ end
836
+
837
+ let(:model) do
838
+ klass = outer_klass
839
+ stub_const('SampleNestedProfile', Class.new(ActiveRecord::Base))
840
+ SampleNestedProfile.table_name = 'profiles'
841
+ SampleNestedProfile.struct_for(:settings, klass)
842
+ SampleNestedProfile
843
+ end
844
+
845
+ it 'casts a hash into the nested class' do
846
+ instance = outer_klass.new(label: 'home', address: { city: 'SP', zip: 1 })
847
+
848
+ expect(instance.address).to be_a(inner_klass)
849
+ expect(instance.address.zip).to be_eql(1)
850
+ end
851
+
852
+ it 'stores the nested value as a document, and not as a string' do
853
+ type = Torque::PostgreSQL::Adapter::OID::Struct.new(outer_klass)
854
+ document = type.serialize(outer_klass.new(label: 'h', address: { city: 'SP', zip: 1 }))
855
+
856
+ expect(document).to be_eql(%({"label":"h","address":{"city":"SP","zip":1}}))
857
+ end
858
+
859
+ it 'round-trips through the database' do
860
+ record = model.create!(name: 'n', settings: { label: 'h', address: { city: 'SP', zip: 1 } })
861
+
862
+ expect(record.reload.settings.address).to be_a(inner_klass)
863
+ expect(record.settings.address.city).to be_eql('SP')
864
+ end
865
+ end
866
+
867
+ context 'on where clauses' do
868
+ let(:dark) { Profile.create!(name: 'a', settings: { theme: 'dark', notifications: true }) }
869
+ let(:light) { Profile.create!(name: 'b', settings: { theme: 'light', notifications: false }) }
870
+
871
+ it 'breaks a hash into conditions over each property' do
872
+ expect(Profile.where(settings: { theme: 'dark' }).to_sql)
873
+ .to include(%{("profiles"."settings" #>> ARRAY['theme']) = 'dark'})
874
+
875
+ dark && light
876
+ expect(Profile.where(settings: { theme: 'dark' }).pluck(:name)).to be_eql(%w[a])
877
+ end
878
+
879
+ it 'casts the property to what the class declares for it' do
880
+ expect(Profile.where(settings: { notifications: true }).to_sql)
881
+ .to include(%{("profiles"."settings" #>> ARRAY['notifications'])::boolean = TRUE})
882
+
883
+ dark && light
884
+ expect(Profile.where(settings: { notifications: true }).pluck(:name)).to be_eql(%w[a])
885
+ end
886
+
887
+ it 'hands each property back to the predicate builder' do
888
+ expect(Profile.where(settings: { theme: %w[light dark] }).to_sql)
889
+ .to include(%{("profiles"."settings" #>> ARRAY['theme']) IN ('light', 'dark')})
890
+
891
+ expect(Profile.where(settings: { theme: 'a'..'z' }).to_sql)
892
+ .to include(%{("profiles"."settings" #>> ARRAY['theme']) BETWEEN 'a' AND 'z'})
893
+
894
+ dark && light
895
+ expect(Profile.where(settings: { theme: %w[light] }).pluck(:name)).to be_eql(%w[b])
896
+ end
897
+
898
+ it 'keeps comparing a whole document as a document' do
899
+ expect(Profile.where(settings: Profile::Settings.new(theme: 'dark')).to_sql)
900
+ .to include(%{"profiles"."settings" = })
901
+ end
902
+
903
+ it 'reaches the properties of a nested document' do
904
+ inner = stub_const('WhereInner', Class.new(Torque::PostgreSQL::Attributes::Struct))
905
+ inner.attribute(:city, :string)
906
+ outer = stub_const('WhereOuter', Class.new(Torque::PostgreSQL::Attributes::Struct))
907
+ outer.attribute(:address, Torque::PostgreSQL::Adapter::OID::Struct.new(inner))
908
+
909
+ model = stub_const('WhereProfile', Class.new(ActiveRecord::Base))
910
+ model.table_name = 'profiles'
911
+ model.struct_for(:settings, outer)
912
+
913
+ expect(model.where(settings: { address: { city: 'SP' } }).to_sql)
914
+ .to include(%{("profiles"."settings" #>> ARRAY['address', 'city']) = 'SP'})
915
+
916
+ model.create!(name: 'n', settings: { address: { city: 'SP' } })
917
+ expect(model.where(settings: { address: { city: 'SP' } }).pluck(:name)).to be_eql(%w[n])
918
+ end
919
+
920
+ it 'refuses a json column' do
921
+ expect { Profile.where(bio: { headline: 'x' }).to_sql }
922
+ .to raise_error(ArgumentError, /json columns cannot be queried/)
923
+ end
924
+
925
+ it 'refuses an undeclared property of a strict class' do
926
+ expect { Profile.where(settings: { nope: 1 }).to_sql }
927
+ .to raise_error(ArgumentError, /not a declared property/)
928
+ end
929
+ end
930
+
931
+ context 'on encryption options' do
932
+ before do
933
+ ActiveRecord::Encryption.configure(
934
+ primary_key: 'test master key',
935
+ deterministic_key: 'test deterministic key',
936
+ key_derivation_salt: 'testing salt',
937
+ )
938
+ end
939
+
940
+ let(:encrypted_klass) do
941
+ Class.new(Torque::PostgreSQL::Attributes::Struct) do
942
+ attribute :token, :string
943
+ attribute :label, :string
944
+
945
+ encrypts :token
946
+ encrypts :label, deterministic: true
947
+ end
948
+ end
949
+
950
+ it 'registers every encrypted property' do
951
+ expect(encrypted_klass.encrypted_attributes).to be_eql(Set[:token, :label])
952
+ end
953
+
954
+ it 'produces the same ciphertext when deterministic' do
955
+ one = encrypted_klass.new(label: 'same').read_attribute_for_database(:label)
956
+ two = encrypted_klass.new(label: 'same').read_attribute_for_database(:label)
957
+
958
+ expect(one).to be_eql(two)
959
+ end
960
+
961
+ it 'produces a different ciphertext when not deterministic' do
962
+ one = encrypted_klass.new(token: 'same').read_attribute_for_database(:token)
963
+ two = encrypted_klass.new(token: 'same').read_attribute_for_database(:token)
964
+
965
+ expect(one).not_to be_eql(two)
966
+ end
967
+ end
968
+ end