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.
- 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 +28 -2
- 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/join_series.rb +8 -6
- data/lib/torque/postgresql/relation/merger.rb +23 -7
- 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 +34 -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 +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
|