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,552 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe 'LTree' do
4
+ let(:table_definition) { ActiveRecord::ConnectionAdapters::PostgreSQL::TableDefinition }
5
+ let(:connection) { ActiveRecord::Base.connection }
6
+ let(:source) { ActiveRecord::Base.connection_pool }
7
+
8
+ let(:ltree) { Torque::PostgreSQL::LTree }
9
+ let(:lquery) { Torque::PostgreSQL::LQuery }
10
+
11
+ context 'on table definition' do
12
+ subject { table_definition.new(connection, 'articles') }
13
+
14
+ it 'has the ltree method' do
15
+ expect(subject).to respond_to(:ltree)
16
+ end
17
+
18
+ it 'has the lquery method' do
19
+ expect(subject).to respond_to(:lquery)
20
+ end
21
+
22
+ it 'can define an ltree column' do
23
+ subject.ltree('path')
24
+ expect(subject['path'].name).to eql('path')
25
+ expect(subject['path'].type).to eql(:ltree)
26
+ end
27
+
28
+ it 'can define an lquery column' do
29
+ subject.lquery('pattern')
30
+ expect(subject['pattern'].name).to eql('pattern')
31
+ expect(subject['pattern'].type).to eql(:lquery)
32
+ end
33
+
34
+ it 'can define an array of paths' do
35
+ subject.ltree('permissions', array: true)
36
+ expect(subject['permissions'].type).to eql(:ltree)
37
+ expect(subject['permissions'].options[:array]).to be_truthy
38
+ end
39
+
40
+ it 'requires a column name' do
41
+ expect { subject.ltree }.to raise_error(ArgumentError, /Missing column name/)
42
+ expect { subject.lquery }.to raise_error(ArgumentError, /Missing column name/)
43
+ end
44
+ end
45
+
46
+ context 'on schema' do
47
+ it 'dumps the column as an ltree' do
48
+ dump_io = StringIO.new
49
+ ActiveRecord::SchemaDumper.dump(source, dump_io)
50
+ expect(dump_io.string).to match(/t\.ltree +"path"/)
51
+ end
52
+
53
+ it 'dumps an array of paths' do
54
+ dump_io = StringIO.new
55
+ ActiveRecord::SchemaDumper.dump(source, dump_io)
56
+ expect(dump_io.string).to match(/t\.ltree +"permissions", array: true/)
57
+ end
58
+
59
+ it 'is a valid type' do
60
+ expect(connection.valid_type?(:ltree)).to be_truthy
61
+ expect(connection.valid_type?(:lquery)).to be_truthy
62
+ end
63
+ end
64
+
65
+ context 'on the path' do
66
+ it 'is an array of labels' do
67
+ expect(ltree.new('Top.Science')).to be_a(Array)
68
+ expect(ltree.new('Top.Science')).to be_eql(%w[Top Science])
69
+ end
70
+
71
+ it 'accepts a string, an array or symbols' do
72
+ expect(ltree.new('Top.Science').to_s).to be_eql('Top.Science')
73
+ expect(ltree.new(%w[Top Science]).to_s).to be_eql('Top.Science')
74
+ expect(ltree[:Top, :Science].to_s).to be_eql('Top.Science')
75
+ end
76
+
77
+ it 'knows its depth' do
78
+ expect(ltree.new('a.b.c').depth).to be_eql(3)
79
+ expect(ltree.new('').depth).to be_eql(0)
80
+ end
81
+
82
+ it 'knows its root' do
83
+ expect(ltree.new('a.b.c').root.to_s).to be_eql('a')
84
+ expect(ltree.new('a.b.c').root?).to be_falsey
85
+ expect(ltree.new('a').root?).to be_truthy
86
+ end
87
+
88
+ it 'has no parent when it is a root' do
89
+ expect(ltree.new('a.b.c').parent.to_s).to be_eql('a.b')
90
+ expect(ltree.new('a').parent).to be_nil
91
+ expect(ltree.new('').parent).to be_nil
92
+ end
93
+
94
+ it 'can be extended' do
95
+ expect((ltree.new('a.b') / 'c').to_s).to be_eql('a.b.c')
96
+ expect((ltree.new('a.b') + %w[c d]).to_s).to be_eql('a.b.c.d')
97
+ expect(ltree.new('a.b') / 'c').to be_a(ltree)
98
+ end
99
+
100
+ it 'checks ancestors and descendants, including itself' do
101
+ expect(ltree.new('a.b').ancestor_of?('a.b.c')).to be_truthy
102
+ expect(ltree.new('a.b').ancestor_of?('a.b')).to be_truthy
103
+ expect(ltree.new('a.b').ancestor_of?('a.c')).to be_falsey
104
+ expect(ltree.new('a.b.c').descendant_of?('a.b')).to be_truthy
105
+ expect(ltree.new('a.b').descendant_of?('a.b.c')).to be_falsey
106
+ end
107
+
108
+ it 'aliases the checks as covering' do
109
+ expect(ltree.new('a.b').covers?('a.b.c')).to be_truthy
110
+ expect(ltree.new('a.b.c').covered_by?('a.b')).to be_truthy
111
+ end
112
+
113
+ it 'finds the longest common ancestor' do
114
+ expect(ltree.new('1.2.3').lca('1.2.3.4.5.6').to_s).to be_eql('1.2')
115
+ expect(ltree.new('1.2.2.3').lca('1.2.3.4.5.6').to_s).to be_eql('1.2')
116
+ expect(ltree.new('a.b').lca('a.b').to_s).to be_eql('a')
117
+ expect(ltree.new('a.b').lca('c.d').to_s).to be_eql('')
118
+ end
119
+
120
+ it 'finds the position of a subpath' do
121
+ subject = ltree.new('0.1.2.3.5.4.5.6.8.5.6.8')
122
+ expect(subject.index_of('5.6')).to be_eql(6)
123
+ expect(subject.index_of('5.6', -4)).to be_eql(9)
124
+ expect(subject.index_of('9.9')).to be_eql(-1)
125
+ end
126
+
127
+ it 'rejects anything that is not a plain sequence of labels' do
128
+ expect { ltree.new('a.*') }.to raise_error(ArgumentError, /not a valid ltree label/)
129
+ expect { ltree.new('a b') }.to raise_error(ArgumentError, /not a valid ltree label/)
130
+ expect { ltree.new(['a', %w[b c]]) }.to raise_error(ArgumentError, /only make sense on an lquery/)
131
+ expect { ltree.new(['a', 1..2]) }.to raise_error(ArgumentError, /only make sense on an lquery/)
132
+ end
133
+ end
134
+
135
+ context 'on the pattern' do
136
+ samples = {
137
+ 'a label' => [%w[Top], 'Top'],
138
+ 'a symbol label' => [[:Top], 'Top'],
139
+ 'a star' => [['Top', :any], 'Top.*'],
140
+ 'an exact quantifier' => [['Top', 2..2], 'Top.*{2}'],
141
+ 'a range quantifier' => [['Top', 0..2], 'Top.*{0,2}'],
142
+ 'an open quantifier' => [['Top', 1..], 'Top.*{1,}'],
143
+ 'a closed quantifier' => [['Top', ..3], 'Top.*{,3}'],
144
+ 'an exclusive range' => [['Top', 2...4], 'Top.*{2,3}'],
145
+ 'a prefix modifier' => [['sport*'], 'sport*'],
146
+ 'a case modifier' => [['sport@'], 'sport@'],
147
+ 'a word modifier' => [['sport%'], 'sport%'],
148
+ 'combined modifiers' => [['sport*@'], 'sport*@'],
149
+ 'alternatives' => [[%w[football tennis]], 'football|tennis'],
150
+ 'a negated group' => [['!football|tennis'], '!football|tennis'],
151
+ 'a quantified item' => [['football{1,}'], 'football{1,}'],
152
+ }
153
+
154
+ samples.each do |title, (input, expected)|
155
+ it "compiles #{title}" do
156
+ expect(Torque::PostgreSQL::LQuery.new(input).to_s).to be_eql(expected)
157
+ end
158
+ end
159
+
160
+ it 'compiles the example from the PostgreSQL manual' do
161
+ subject = lquery['Top', 0..2, 'sport*@', '!football|tennis{1,}', %w[Russ* Spain]]
162
+ expect(subject.to_s).to be_eql('Top.*{0,2}.sport*@.!football|tennis{1,}.Russ*|Spain')
163
+ end
164
+
165
+ it 'accepts its own text form' do
166
+ expect(lquery.new('Top.*.sport*@').to_s).to be_eql('Top.*.sport*@')
167
+ end
168
+
169
+ it 'compares by its text form' do
170
+ expect(lquery['Top', :any]).to be_eql(lquery.new('Top.*'))
171
+ expect(lquery['Top', :any].hash).to be_eql(lquery.new('Top.*').hash)
172
+ end
173
+
174
+ it 'rejects items that are not valid' do
175
+ expect { lquery['a.b'] }.to raise_error(ArgumentError, /not a valid lquery item/)
176
+ expect { lquery['foo{'] }.to raise_error(ArgumentError, /not a valid lquery item/)
177
+ expect { lquery['a b'] }.to raise_error(ArgumentError, /not a valid lquery item/)
178
+ expect { lquery[{ a: 1 }] }.to raise_error(ArgumentError, /Unable to use/)
179
+ end
180
+
181
+ it 'rejects quantifiers that make no sense' do
182
+ expect { lquery[2..0] }.to raise_error(ArgumentError, /not a valid quantifier/)
183
+ expect { lquery[-1..2] }.to raise_error(ArgumentError, /not a valid quantifier/)
184
+ end
185
+
186
+ it 'rejects an empty alternation' do
187
+ expect { lquery[[]] }.to raise_error(ArgumentError, /at least one label/)
188
+ end
189
+ end
190
+
191
+ context 'on records' do
192
+ let(:root) { Category.create!(title: 'Top') }
193
+ let(:child) { Category.create!(title: 'Science') }
194
+
195
+ it 'uses the primary key as the label' do
196
+ expect(ltree.new(root).to_s).to be_eql(root.id.to_s)
197
+ expect(ltree[root, child].to_s).to be_eql("#{root.id}.#{child.id}")
198
+ end
199
+
200
+ it 'mixes records with plain labels' do
201
+ expect(ltree.new(['app', root]).to_s).to be_eql("app.#{root.id}")
202
+ end
203
+
204
+ it 'extends a path with a record' do
205
+ expect((ltree.new('app') / child).to_s).to be_eql("app.#{child.id}")
206
+ expect((ltree.new('app') + [root, child]).to_s).to be_eql("app.#{root.id}.#{child.id}")
207
+ end
208
+
209
+ it 'compares paths given as records' do
210
+ subject = ltree[root, child]
211
+ expect(ltree.new(root).ancestor_of?(subject)).to be_truthy
212
+ expect(subject.descendant_of?(root)).to be_truthy
213
+ expect(subject.covered_by?(ltree.new(root))).to be_truthy
214
+ end
215
+
216
+ it 'accepts records on the remaining path operations' do
217
+ subject = ltree[root, child]
218
+ expect(subject.lca(ltree[root, child]).to_s).to be_eql(root.id.to_s)
219
+ expect(subject.index_of(child)).to be_eql(1)
220
+ end
221
+
222
+ it 'uses the primary key on a pattern' do
223
+ expect(lquery[root, :any].to_s).to be_eql("#{root.id}.*")
224
+ expect(lquery[[root, child]].to_s).to be_eql("#{root.id}|#{child.id}")
225
+ end
226
+
227
+ it 'follows a custom primary key' do
228
+ klass = Class.new(Category) do
229
+ self.table_name = 'categories'
230
+ self.primary_key = 'title'
231
+ end
232
+
233
+ expect(ltree.new(klass.find(root.title)).to_s).to be_eql('Top')
234
+ end
235
+
236
+ it 'refuses a record that was never saved' do
237
+ expect { ltree.new(Category.new) }
238
+ .to raise_error(ArgumentError, /its id is still empty/)
239
+ end
240
+
241
+ it 'writes a record-based path to the database' do
242
+ record = Category.create!(title: 'Astronomy', path: [root, child])
243
+ expect(Category.find(record.id).path).to be_eql([root.id.to_s, child.id.to_s])
244
+ end
245
+
246
+ it 'queries with a record-based path' do
247
+ Category.create!(title: 'Astronomy', path: [root, child])
248
+
249
+ expect(Category.where(path: [root, child]).count).to be_eql(1)
250
+ expect(Category.where(path: [root, :any]).count).to be_eql(1)
251
+ end
252
+
253
+ it 'takes a lone record as a single-label path' do
254
+ Category.create!(title: 'Only', path: [root])
255
+
256
+ expect(Category.where(path: root).to_sql)
257
+ .to include(%[WHERE "categories"."path" = '#{root.id}'])
258
+ expect(Category.where(path: root).count).to be_eql(1)
259
+ end
260
+
261
+ it 'reaches the entries of an array column' do
262
+ user = User.create!(name: 'Rick', permissions: [root, [root, child]])
263
+ expect(User.find(user.id).permissions)
264
+ .to be_eql([[root.id.to_s], [root.id.to_s, child.id.to_s]])
265
+ end
266
+ end
267
+
268
+ context 'on compatible objects' do
269
+ let(:path_like) { ::Struct.new(:to_tree_path) }
270
+
271
+ after { Torque::PostgreSQL.config.ltree.compatible_method = :to_tree_path }
272
+
273
+ it 'takes over as a path' do
274
+ expect(ltree.new(path_like.new('app.users')).to_s).to be_eql('app.users')
275
+ expect(ltree.new(path_like.new(%w[app users])).to_s).to be_eql('app.users')
276
+ end
277
+
278
+ it 'can be one entry among labels' do
279
+ subject = ltree.new(['top', path_like.new('app.users')])
280
+ expect(subject.to_s).to be_eql('top.app.users')
281
+ end
282
+
283
+ it 'is accepted while comparing two paths' do
284
+ other = path_like.new('app.users')
285
+ expect(ltree.new('app').ancestor_of?(other)).to be_truthy
286
+ expect(ltree.new('app.users.write').descendant_of?(other)).to be_truthy
287
+ expect(ltree.new('app.users.write').index_of(other)).to be_eql(0)
288
+ end
289
+
290
+ it 'is accepted while extending a path' do
291
+ expect((ltree.new('top') / path_like.new('app.users')).to_s).to be_eql('top.app.users')
292
+ end
293
+
294
+ it 'contributes every label of a pattern' do
295
+ expect(lquery[path_like.new('app.users'), :any].to_s).to be_eql('app.users.*')
296
+ end
297
+
298
+ it 'turns the condition into a match when it describes a pattern' do
299
+ expect(Category.where(path: path_like.new('app.*')).to_sql)
300
+ .to include(%[WHERE "categories"."path" ~ 'app.*'::lquery])
301
+ end
302
+
303
+ it 'keeps the condition an equality when it describes a path' do
304
+ expect(Category.where(path: path_like.new('app.users')).to_sql)
305
+ .to include(%[WHERE "categories"."path" = 'app.users'])
306
+ end
307
+
308
+ it 'wins over the primary key of a record' do
309
+ klass = Class.new(Category)
310
+ klass.define_method(:to_tree_path) { "cat.#{title}" }
311
+ record = klass.create!(title: 'Science')
312
+
313
+ expect(ltree.new(record).to_s).to be_eql('cat.Science')
314
+ expect(Category.where(path: record).to_sql)
315
+ .to include(%[WHERE "categories"."path" = 'cat.Science'])
316
+ end
317
+
318
+ it 'is ignored when the config has no method' do
319
+ Torque::PostgreSQL.config.ltree.compatible_method = nil
320
+ expect { ltree.new(path_like.new('app.users')) }
321
+ .to raise_error(ArgumentError, /only make sense on an lquery/)
322
+ end
323
+ end
324
+
325
+ context 'on markers' do
326
+ it 'treats a plain path as a path' do
327
+ expect(lquery.marker?('Top.Science')).to be_falsey
328
+ expect(lquery.marker?(%w[Top Science])).to be_falsey
329
+ expect(lquery.marker?(%i[Top Science])).to be_falsey
330
+ end
331
+
332
+ it 'treats anything with a query feature as a pattern' do
333
+ expect(lquery.marker?(['Top', :any])).to be_truthy
334
+ expect(lquery.marker?(['Top', 1..])).to be_truthy
335
+ expect(lquery.marker?(['Top', %w[a b]])).to be_truthy
336
+ expect(lquery.marker?('Top.*')).to be_truthy
337
+ expect(lquery.marker?('!Top')).to be_truthy
338
+ expect(lquery.marker?('Top@')).to be_truthy
339
+ end
340
+ end
341
+
342
+ context 'on sanitize' do
343
+ after { Torque::PostgreSQL.config.ltree.sanitize = nil }
344
+
345
+ it 'does nothing by default' do
346
+ expect(ltree.new('a-b').to_s).to be_eql('a-b')
347
+ end
348
+
349
+ it 'replaces the configured characters on a path' do
350
+ Torque::PostgreSQL.config.ltree.sanitize = { '-' => '_' }
351
+ expect(ltree.new('a-b.c-d').to_s).to be_eql('a_b.c_d')
352
+ end
353
+
354
+ it 'can drop characters entirely' do
355
+ Torque::PostgreSQL.config.ltree.sanitize = { '-' => '' }
356
+ expect(ltree.new('a-b').to_s).to be_eql('ab')
357
+ end
358
+
359
+ it 'reaches the labels of a pattern' do
360
+ Torque::PostgreSQL.config.ltree.sanitize = { '-' => '_' }
361
+ expect(lquery['a-b*', %w[c-d e]].to_s).to be_eql('a_b*.c_d|e')
362
+ end
363
+
364
+ it 'never rewrites a quantifier' do
365
+ Torque::PostgreSQL.config.ltree.sanitize = { '1' => '9' }
366
+ expect(lquery['foo{1,2}'].to_s).to be_eql('foo{1,2}')
367
+ end
368
+
369
+ it 'does not apply to values read from the database' do
370
+ Torque::PostgreSQL.config.ltree.sanitize = { 'a' => 'z' }
371
+ expect(ltree.load('a.b').to_s).to be_eql('a.b')
372
+ end
373
+ end
374
+
375
+ context 'on OID' do
376
+ subject { Torque::PostgreSQL::Adapter::OID::Ltree.new }
377
+
378
+ it 'has the right type' do
379
+ expect(subject.type).to be_eql(:ltree)
380
+ end
381
+
382
+ it 'deserializes into a path' do
383
+ expect(subject.deserialize('Top.Science')).to be_a(Torque::PostgreSQL::LTree)
384
+ expect(subject.deserialize('Top.Science')).to be_eql(%w[Top Science])
385
+ expect(subject.deserialize(nil)).to be_nil
386
+ end
387
+
388
+ it 'serializes back into text' do
389
+ expect(subject.serialize(%w[Top Science])).to be_eql('Top.Science')
390
+ expect(subject.serialize('Top.Science')).to be_eql('Top.Science')
391
+ expect(subject.serialize(nil)).to be_nil
392
+ expect(subject.serialize('')).to be_nil
393
+ end
394
+
395
+ it 'validates on the way in' do
396
+ expect { subject.serialize('Top.*') }.to raise_error(ArgumentError)
397
+ end
398
+ end
399
+
400
+ context 'on the model' do
401
+ subject { Category.new }
402
+
403
+ it 'reads and writes a path' do
404
+ subject.path = 'Top.Science'
405
+ expect(subject.path).to be_a(Torque::PostgreSQL::LTree)
406
+ expect(subject.path).to be_eql(%w[Top Science])
407
+ end
408
+
409
+ it 'accepts an array of labels' do
410
+ subject.path = %w[Top Science]
411
+ expect(subject.path.to_s).to be_eql('Top.Science')
412
+ end
413
+
414
+ it 'round trips through the database' do
415
+ subject.update!(title: 'Science', path: 'Top.Science')
416
+ expect(Category.find(subject.id).path).to be_eql(%w[Top Science])
417
+ end
418
+
419
+ it 'round trips an array of paths as an array of arrays' do
420
+ user = User.create!(name: 'Rick', permissions: ['app.users.write', %w[app posts]])
421
+ reloaded = User.find(user.id)
422
+
423
+ expect(reloaded.permissions).to be_eql([%w[app users write], %w[app posts]])
424
+ expect(reloaded.permissions.first).to be_a(Torque::PostgreSQL::LTree)
425
+ end
426
+
427
+ it 'refuses a pattern where a path is expected' do
428
+ subject.path = 'Top.*'
429
+ expect { subject.path }.to raise_error(ArgumentError, /not a valid ltree label/)
430
+ end
431
+ end
432
+
433
+ context 'on predicate builder' do
434
+ subject { Category.all }
435
+
436
+ it 'uses equality for a plain path' do
437
+ expect(subject.where(path: 'Top.Science').to_sql)
438
+ .to include(%[WHERE "categories"."path" = 'Top.Science'])
439
+ end
440
+
441
+ it 'uses equality for a plain array of labels' do
442
+ expect(subject.where(path: %w[Top Science]).to_sql)
443
+ .to include(%[WHERE "categories"."path" = 'Top.Science'])
444
+ end
445
+
446
+ it 'matches a pattern when the value has a star' do
447
+ expect(subject.where(path: ['Top', :any]).to_sql)
448
+ .to include(%[WHERE "categories"."path" ~ 'Top.*'::lquery])
449
+ end
450
+
451
+ it 'matches a pattern when the value has a quantifier' do
452
+ expect(subject.where(path: ['Top', 1..]).to_sql)
453
+ .to include(%[WHERE "categories"."path" ~ 'Top.*{1,}'::lquery])
454
+ end
455
+
456
+ it 'matches a pattern when the value has alternatives' do
457
+ expect(subject.where(path: ['Top', %w[a b]]).to_sql)
458
+ .to include(%[WHERE "categories"."path" ~ 'Top.a|b'::lquery])
459
+ end
460
+
461
+ it 'matches a pattern written as a string' do
462
+ expect(subject.where(path: 'Top.*.sport*@').to_sql)
463
+ .to include(%[WHERE "categories"."path" ~ 'Top.*.sport*@'::lquery])
464
+ end
465
+
466
+ it 'accepts a pattern object' do
467
+ expect(subject.where(path: lquery['Top', :any]).to_sql)
468
+ .to include(%[WHERE "categories"."path" ~ 'Top.*'::lquery])
469
+ end
470
+
471
+ it 'finds the records that match' do
472
+ Category.create!(title: 'Science', path: 'Top.Science')
473
+ Category.create!(title: 'Astronomy', path: 'Top.Science.Astronomy')
474
+ Category.create!(title: 'Hobbies', path: 'Top.Hobbies')
475
+
476
+ expect(Category.where(path: ['Top', :any]).count).to be_eql(3)
477
+ expect(Category.where(path: %w[Top Science]).count).to be_eql(1)
478
+ expect(Category.where(path: ['Top', 'Science', :any]).count).to be_eql(2)
479
+ end
480
+
481
+ it 'leaves an array column to the default behavior' do
482
+ expect(User.where(permissions: ['app.users']).to_sql)
483
+ .to include(%[WHERE "users"."permissions" = ])
484
+ end
485
+
486
+ it 'never takes an array as a list of values' do
487
+ expect(subject.where(path: %w[Top Science]).to_sql).not_to include('IN')
488
+ end
489
+ end
490
+
491
+ context 'on arel' do
492
+ let(:attribute) { Category.arel_table['path'] }
493
+
494
+ it 'has the tree operators' do
495
+ expect(attribute).to respond_to(:contains)
496
+ expect(attribute).to respond_to(:contained_by)
497
+ expect(attribute).to respond_to(:matches_lquery)
498
+ expect(attribute).to respond_to(:matches_any_lquery)
499
+ end
500
+
501
+ it 'builds an ancestor condition' do
502
+ sql = Category.where(attribute.contains(::Arel.sql("'Top.Science'"))).to_sql
503
+ expect(sql).to include(%["categories"."path" @> 'Top.Science'])
504
+ end
505
+
506
+ it 'builds a pattern condition' do
507
+ value = ::Arel.sql("'Top.*'").pg_cast('lquery')
508
+ sql = Category.where(attribute.matches_lquery(value)).to_sql
509
+ expect(sql).to include(%["categories"."path" ~ 'Top.*'::lquery])
510
+ end
511
+
512
+ it 'builds a condition against many patterns' do
513
+ value = ::Arel.array(['Top.*', 'Other.*'], cast: 'lquery')
514
+ sql = Category.where(attribute.matches_any_lquery(value)).to_sql
515
+ expect(sql).to include(%["categories"."path" ? ARRAY['Top.*', 'Other.*']::lquery[]])
516
+ end
517
+ end
518
+
519
+ context 'on authorization' do
520
+ let(:required) { Torque::PostgreSQL::LTree.new('app.users.write') }
521
+ let(:grants) { User.arel_table['permissions'] }
522
+
523
+ before do
524
+ User.create!(name: 'Admin', permissions: ['app'])
525
+ User.create!(name: 'Editor', permissions: ['app.posts', 'app.users'])
526
+ User.create!(name: 'Guest', permissions: ['app.posts'])
527
+ end
528
+
529
+ it 'finds the users a grant covers, in SQL' do
530
+ condition = grants.contains(Torque::PostgreSQL::FN.bind_type(required.to_s, cast: 'ltree'))
531
+ names = User.where(condition).pluck(:name)
532
+
533
+ expect(names).to match_array(%w[Admin Editor])
534
+ end
535
+
536
+ it 'agrees with the same check performed in Ruby' do
537
+ condition = grants.contains(Torque::PostgreSQL::FN.bind_type(required.to_s, cast: 'ltree'))
538
+ from_sql = User.where(condition).pluck(:name)
539
+
540
+ in_memory = User.all.select do |user|
541
+ user.permissions.any? { |grant| grant.covers?(required) }
542
+ end
543
+
544
+ expect(from_sql).to match_array(in_memory.map(&:name))
545
+ end
546
+
547
+ it 'matches grants against a pattern' do
548
+ condition = grants.matches_lquery(::Arel.sql("'app.users.*'").pg_cast('lquery'))
549
+ expect(User.where(condition).pluck(:name)).to match_array(%w[Editor])
550
+ end
551
+ end
552
+ end