torque-postgresql 4.0.1 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Rakefile +24 -0
- data/lib/torque/postgresql/adapter/database_statements.rb +89 -7
- data/lib/torque/postgresql/adapter/inheritance_statements.rb +297 -0
- data/lib/torque/postgresql/adapter/oid/array.rb +17 -0
- data/lib/torque/postgresql/adapter/oid/box.rb +1 -1
- data/lib/torque/postgresql/adapter/oid/circle.rb +1 -1
- data/lib/torque/postgresql/adapter/oid/composite.rb +116 -0
- data/lib/torque/postgresql/adapter/oid/line.rb +1 -1
- data/lib/torque/postgresql/adapter/oid/lquery.rb +53 -0
- data/lib/torque/postgresql/adapter/oid/ltree.rb +53 -0
- data/lib/torque/postgresql/adapter/oid/segment.rb +1 -1
- data/lib/torque/postgresql/adapter/oid/struct.rb +150 -0
- data/lib/torque/postgresql/adapter/oid/struct_list.rb +52 -0
- data/lib/torque/postgresql/adapter/oid/struct_set.rb +38 -0
- data/lib/torque/postgresql/adapter/quoting.rb +11 -2
- data/lib/torque/postgresql/adapter/schema_definitions.rb +46 -0
- data/lib/torque/postgresql/adapter/schema_dumper.rb +26 -0
- data/lib/torque/postgresql/adapter/schema_statements.rb +207 -0
- data/lib/torque/postgresql/adapter.rb +2 -5
- data/lib/torque/postgresql/arel/nodes.rb +63 -1
- data/lib/torque/postgresql/arel/visitors.rb +19 -8
- data/lib/torque/postgresql/associations/join_dependency.rb +55 -0
- data/lib/torque/postgresql/associations.rb +3 -0
- data/lib/torque/postgresql/attributes/base.rb +94 -0
- data/lib/torque/postgresql/attributes/composite.rb +102 -0
- data/lib/torque/postgresql/attributes/enum.rb +13 -2
- data/lib/torque/postgresql/attributes/lquery.rb +180 -0
- data/lib/torque/postgresql/attributes/ltree.rb +169 -0
- data/lib/torque/postgresql/attributes/simple_enum.rb +72 -0
- data/lib/torque/postgresql/attributes/struct.rb +137 -0
- data/lib/torque/postgresql/base.rb +17 -14
- data/lib/torque/postgresql/config.rb +81 -14
- data/lib/torque/postgresql/inheritance/expander.rb +66 -0
- data/lib/torque/postgresql/inheritance/record.rb +52 -0
- data/lib/torque/postgresql/inheritance.rb +138 -65
- data/lib/torque/postgresql/migration/command_recorder.rb +76 -0
- data/lib/torque/postgresql/predicate_builder/composite_handler.rb +109 -0
- data/lib/torque/postgresql/predicate_builder/ltree_handler.rb +44 -0
- data/lib/torque/postgresql/predicate_builder/struct_handler.rb +68 -0
- data/lib/torque/postgresql/predicate_builder.rb +26 -0
- data/lib/torque/postgresql/predicate_table.rb +61 -0
- data/lib/torque/postgresql/railtie.rb +35 -0
- data/lib/torque/postgresql/relation/inheritance.rb +146 -28
- data/lib/torque/postgresql/relation/merger.rb +21 -5
- data/lib/torque/postgresql/relation.rb +12 -11
- data/lib/torque/postgresql/schema_cache.rb +1 -0
- data/lib/torque/postgresql/validations.rb +29 -0
- data/lib/torque/postgresql/version.rb +1 -1
- data/lib/torque/postgresql/versioned_commands/command_migration.rb +1 -1
- data/lib/torque/postgresql.rb +6 -0
- data/spec/initialize.rb +20 -2
- data/spec/mocks/cache_query.rb +12 -0
- data/spec/models/author.rb +1 -1
- data/spec/models/comment.rb +2 -0
- data/spec/models/place.rb +2 -0
- data/spec/models/profile.rb +31 -0
- data/spec/schema.rb +33 -4
- data/spec/tests/arel_spec.rb +5 -3
- data/spec/tests/composite_spec.rb +800 -0
- data/spec/tests/ltree_spec.rb +552 -0
- data/spec/tests/struct_spec.rb +968 -0
- data/spec/tests/table_inheritance_spec.rb +1027 -56
- metadata +47 -2
|
@@ -0,0 +1,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
|