torque-postgresql 4.0.1 → 4.1.1
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 +52 -0
- data/lib/generators/torque/skill_generator.rb +17 -0
- data/lib/generators/torque/templates/trigger.sql.erb +3 -0
- data/lib/generators/torque/trigger_generator.rb +13 -0
- data/lib/torque/postgresql/adapter/database_statements.rb +98 -7
- data/lib/torque/postgresql/adapter/inheritance_statements.rb +297 -0
- data/lib/torque/postgresql/adapter/oid/array.rb +13 -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 +117 -0
- data/lib/torque/postgresql/adapter/oid/enum.rb +1 -1
- data/lib/torque/postgresql/adapter/oid/enum_set.rb +2 -2
- data/lib/torque/postgresql/adapter/oid/interval.rb +1 -1
- data/lib/torque/postgresql/adapter/oid/line.rb +1 -1
- data/lib/torque/postgresql/adapter/oid/lquery.rb +70 -0
- data/lib/torque/postgresql/adapter/oid/ltree.rb +111 -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/oid.rb +13 -0
- data/lib/torque/postgresql/adapter/quoting.rb +11 -2
- data/lib/torque/postgresql/adapter/schema_definitions.rb +51 -0
- data/lib/torque/postgresql/adapter/schema_dumper.rb +27 -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 +114 -1
- data/lib/torque/postgresql/arel/visitors.rb +65 -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 +97 -0
- data/lib/torque/postgresql/attributes/composite.rb +98 -0
- data/lib/torque/postgresql/attributes/enum.rb +13 -7
- data/lib/torque/postgresql/attributes/lquery.rb +120 -0
- data/lib/torque/postgresql/attributes/ltree.rb +121 -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 +26 -15
- data/lib/torque/postgresql/config.rb +100 -15
- data/lib/torque/postgresql/geometry_builder.rb +4 -10
- data/lib/torque/postgresql/inheritance/expander.rb +66 -0
- data/lib/torque/postgresql/inheritance/record.rb +52 -0
- data/lib/torque/postgresql/inheritance.rb +141 -68
- data/lib/torque/postgresql/migration/command_recorder.rb +76 -0
- data/lib/torque/postgresql/predicate_builder/arel_attribute_handler.rb +1 -1
- data/lib/torque/postgresql/predicate_builder/array_handler.rb +16 -8
- data/lib/torque/postgresql/predicate_builder/composite_handler.rb +122 -0
- data/lib/torque/postgresql/predicate_builder/ltree_handler.rb +112 -0
- data/lib/torque/postgresql/predicate_builder/struct_handler.rb +88 -0
- data/lib/torque/postgresql/predicate_builder.rb +53 -0
- data/lib/torque/postgresql/predicate_table.rb +61 -0
- data/lib/torque/postgresql/railtie.rb +52 -0
- data/lib/torque/postgresql/relation/auxiliary_statement.rb +7 -3
- data/lib/torque/postgresql/relation/buckets.rb +2 -1
- data/lib/torque/postgresql/relation/cross_join.rb +193 -0
- data/lib/torque/postgresql/relation/inheritance.rb +145 -28
- data/lib/torque/postgresql/relation/join_series.rb +1 -0
- data/lib/torque/postgresql/relation/merger.rb +30 -5
- data/lib/torque/postgresql/relation/segments.rb +140 -0
- data/lib/torque/postgresql/relation.rb +32 -12
- 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 +24 -1
- data/lib/torque/postgresql/versioned_commands.rb +39 -0
- data/lib/torque/postgresql.rb +4 -0
- data/sig/generators/torque/function_generator.rbs +9 -0
- data/sig/generators/torque/skill_generator.rbs +7 -0
- data/sig/generators/torque/trigger_generator.rbs +9 -0
- data/sig/generators/torque/type_generator.rbs +9 -0
- data/sig/generators/torque/view_generator.rbs +9 -0
- data/sig/manifest.yaml +7 -0
- data/sig/torque/postgresql/adapter/database_statements.rbs +42 -0
- data/sig/torque/postgresql/adapter/inheritance_statements.rbs +36 -0
- data/sig/torque/postgresql/adapter/oid/array.rbs +20 -0
- data/sig/torque/postgresql/adapter/oid/box.rbs +28 -0
- data/sig/torque/postgresql/adapter/oid/circle.rbs +33 -0
- data/sig/torque/postgresql/adapter/oid/composite.rbs +35 -0
- data/sig/torque/postgresql/adapter/oid/enum.rbs +28 -0
- data/sig/torque/postgresql/adapter/oid/enum_set.rbs +19 -0
- data/sig/torque/postgresql/adapter/oid/interval.rbs +20 -0
- data/sig/torque/postgresql/adapter/oid/line.rbs +33 -0
- data/sig/torque/postgresql/adapter/oid/lquery.rbs +21 -0
- data/sig/torque/postgresql/adapter/oid/ltree.rbs +32 -0
- data/sig/torque/postgresql/adapter/oid/range.rbs +28 -0
- data/sig/torque/postgresql/adapter/oid/segment.rbs +38 -0
- data/sig/torque/postgresql/adapter/oid/struct.rbs +41 -0
- data/sig/torque/postgresql/adapter/oid/struct_list.rbs +20 -0
- data/sig/torque/postgresql/adapter/oid/struct_set.rbs +18 -0
- data/sig/torque/postgresql/adapter/oid.rbs +9 -0
- data/sig/torque/postgresql/adapter/quoting.rbs +20 -0
- data/sig/torque/postgresql/adapter/schema_creation.rbs +13 -0
- data/sig/torque/postgresql/adapter/schema_definitions.rbs +55 -0
- data/sig/torque/postgresql/adapter/schema_dumper.rbs +37 -0
- data/sig/torque/postgresql/adapter/schema_overrides.rbs +46 -0
- data/sig/torque/postgresql/adapter/schema_statements.rbs +50 -0
- data/sig/torque/postgresql/adapter.rbs +24 -0
- data/sig/torque/postgresql/arel/infix_operation.rbs +79 -0
- data/sig/torque/postgresql/arel/join_source.rbs +19 -0
- data/sig/torque/postgresql/arel/nodes.rbs +83 -0
- data/sig/torque/postgresql/arel/operations.rbs +28 -0
- data/sig/torque/postgresql/arel/select_manager.rbs +15 -0
- data/sig/torque/postgresql/arel/visitors.rbs +30 -0
- data/sig/torque/postgresql/arel.rbs +6 -0
- data/sig/torque/postgresql/associations/association_scope.rbs +28 -0
- data/sig/torque/postgresql/associations/belongs_to_many_association.rbs +51 -0
- data/sig/torque/postgresql/associations/builder/belongs_to_many.rbs +24 -0
- data/sig/torque/postgresql/associations/builder/has_many.rbs +15 -0
- data/sig/torque/postgresql/associations/builder.rbs +8 -0
- data/sig/torque/postgresql/associations/foreign_association.rbs +16 -0
- data/sig/torque/postgresql/associations/join_dependency.rbs +16 -0
- data/sig/torque/postgresql/associations/preloader/association.rbs +32 -0
- data/sig/torque/postgresql/associations/preloader/loader_query.rbs +20 -0
- data/sig/torque/postgresql/associations/preloader.rbs +8 -0
- data/sig/torque/postgresql/associations.rbs +17 -0
- data/sig/torque/postgresql/attributes/base.rbs +37 -0
- data/sig/torque/postgresql/attributes/builder/enum.rbs +38 -0
- data/sig/torque/postgresql/attributes/builder/full_text_search.rbs +27 -0
- data/sig/torque/postgresql/attributes/builder/period.rbs +106 -0
- data/sig/torque/postgresql/attributes/builder.rbs +19 -0
- data/sig/torque/postgresql/attributes/composite.rbs +28 -0
- data/sig/torque/postgresql/attributes/enum.rbs +69 -0
- data/sig/torque/postgresql/attributes/enum_set.rbs +75 -0
- data/sig/torque/postgresql/attributes/full_text_search.rbs +13 -0
- data/sig/torque/postgresql/attributes/lazy.rbs +17 -0
- data/sig/torque/postgresql/attributes/lquery.rbs +48 -0
- data/sig/torque/postgresql/attributes/ltree.rbs +46 -0
- data/sig/torque/postgresql/attributes/period.rbs +13 -0
- data/sig/torque/postgresql/attributes/simple_enum.rbs +39 -0
- data/sig/torque/postgresql/attributes/struct.rbs +38 -0
- data/sig/torque/postgresql/attributes.rbs +6 -0
- data/sig/torque/postgresql/autosave_association.rbs +16 -0
- data/sig/torque/postgresql/auxiliary_statement/recursive.rbs +22 -0
- data/sig/torque/postgresql/auxiliary_statement/settings.rbs +63 -0
- data/sig/torque/postgresql/auxiliary_statement.rbs +57 -0
- data/sig/torque/postgresql/base.rbs +41 -0
- data/sig/torque/postgresql/collector.rbs +7 -0
- data/sig/torque/postgresql/config.rbs +9 -0
- data/sig/torque/postgresql/function.rbs +21 -0
- data/sig/torque/postgresql/geometry_builder.rbs +20 -0
- data/sig/torque/postgresql/i18n.rbs +15 -0
- data/sig/torque/postgresql/inheritance/expander.rbs +23 -0
- data/sig/torque/postgresql/inheritance/record.rbs +20 -0
- data/sig/torque/postgresql/inheritance.rbs +47 -0
- data/sig/torque/postgresql/insert_all.rbs +22 -0
- data/sig/torque/postgresql/migration/command_recorder.rbs +30 -0
- data/sig/torque/postgresql/migration.rbs +6 -0
- data/sig/torque/postgresql/predicate_builder/arel_attribute_handler.rbs +15 -0
- data/sig/torque/postgresql/predicate_builder/array_handler.rbs +26 -0
- data/sig/torque/postgresql/predicate_builder/composite_handler.rbs +26 -0
- data/sig/torque/postgresql/predicate_builder/enumerator_lazy_handler.rbs +18 -0
- data/sig/torque/postgresql/predicate_builder/ltree_handler.rbs +36 -0
- data/sig/torque/postgresql/predicate_builder/regexp_handler.rbs +16 -0
- data/sig/torque/postgresql/predicate_builder/struct_handler.rbs +24 -0
- data/sig/torque/postgresql/predicate_builder.rbs +23 -0
- data/sig/torque/postgresql/predicate_table.rbs +24 -0
- data/sig/torque/postgresql/railtie.rbs +10 -0
- data/sig/torque/postgresql/reflection/abstract_reflection.rbs +22 -0
- data/sig/torque/postgresql/reflection/association_reflection.rbs +18 -0
- data/sig/torque/postgresql/reflection/belongs_to_many_reflection.rbs +30 -0
- data/sig/torque/postgresql/reflection/has_many_reflection.rbs +14 -0
- data/sig/torque/postgresql/reflection/runtime_reflection.rbs +18 -0
- data/sig/torque/postgresql/reflection/through_reflection.rbs +14 -0
- data/sig/torque/postgresql/reflection.rbs +11 -0
- data/sig/torque/postgresql/relation/auxiliary_statement.rbs +29 -0
- data/sig/torque/postgresql/relation/buckets.rbs +35 -0
- data/sig/torque/postgresql/relation/cross_join.rbs +63 -0
- data/sig/torque/postgresql/relation/distinct_on.rbs +16 -0
- data/sig/torque/postgresql/relation/inheritance.rbs +48 -0
- data/sig/torque/postgresql/relation/join_series.rbs +28 -0
- data/sig/torque/postgresql/relation/merger.rbs +23 -0
- data/sig/torque/postgresql/relation/segments.rbs +27 -0
- data/sig/torque/postgresql/relation.rbs +48 -0
- data/sig/torque/postgresql/schema_cache/bound_schema_reflection.rbs +14 -0
- data/sig/torque/postgresql/schema_cache/inheritance.rbs +12 -0
- data/sig/torque/postgresql/schema_cache/schema_reflection.rbs +14 -0
- data/sig/torque/postgresql/schema_cache.rbs +43 -0
- data/sig/torque/postgresql/table_name.rbs +20 -0
- data/sig/torque/postgresql/validations.rbs +13 -0
- data/sig/torque/postgresql/version.rbs +5 -0
- data/sig/torque/postgresql/versioned_commands/command_migration.rbs +54 -0
- data/sig/torque/postgresql/versioned_commands/generator.rbs +17 -0
- data/sig/torque/postgresql/versioned_commands/migration_context.rbs +30 -0
- data/sig/torque/postgresql/versioned_commands/migrator.rbs +19 -0
- data/sig/torque/postgresql/versioned_commands/schema_table.rbs +23 -0
- data/sig/torque/postgresql/versioned_commands.rbs +25 -0
- data/sig/torque/postgresql.rbs +26 -0
- data/skills/torque-postgresql/SKILL.md +70 -0
- data/spec/fixtures/migrations/20250101000008_create_trigger_audit_users_v1.sql +3 -0
- data/spec/initialize.rb +29 -2
- data/spec/mocks/cache_query.rb +13 -1
- 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/rbs_helper.rb +8 -0
- data/spec/schema.rb +35 -4
- data/spec/spec_helper.rb +1 -0
- data/spec/tests/arel_spec.rb +3 -3
- data/spec/tests/auxiliary_statement_spec.rb +12 -0
- data/spec/tests/buckets_spec.rb +122 -0
- data/spec/tests/composite_spec.rb +867 -0
- data/spec/tests/cross_join_spec.rb +185 -0
- data/spec/tests/distinct_on_spec.rb +5 -0
- data/spec/tests/enum_set_spec.rb +4 -0
- data/spec/tests/enum_spec.rb +20 -4
- data/spec/tests/geometric_builder_spec.rb +16 -5
- data/spec/tests/has_many_spec.rb +2 -1
- data/spec/tests/interval_spec.rb +6 -0
- data/spec/tests/ltree_spec.rb +886 -0
- data/spec/tests/predicate_builder_spec.rb +120 -0
- data/spec/tests/relation_spec.rb +25 -69
- data/spec/tests/segments_spec.rb +264 -0
- data/spec/tests/struct_spec.rb +1170 -0
- data/spec/tests/table_inheritance_spec.rb +1037 -56
- data/spec/tests/versioned_commands_spec.rb +145 -0
- metadata +198 -2
|
@@ -0,0 +1,886 @@
|
|
|
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
|
+
let(:ltree_type) { Torque::PostgreSQL::Adapter::OID::Ltree.new }
|
|
11
|
+
let(:lquery_type) { Torque::PostgreSQL::Adapter::OID::Lquery.new }
|
|
12
|
+
|
|
13
|
+
context 'on table definition' do
|
|
14
|
+
subject { table_definition.new(connection, 'articles') }
|
|
15
|
+
|
|
16
|
+
it 'has the ltree method' do
|
|
17
|
+
expect(subject).to respond_to(:ltree)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'has the lquery method' do
|
|
21
|
+
expect(subject).to respond_to(:lquery)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'can define an ltree column' do
|
|
25
|
+
subject.ltree('path')
|
|
26
|
+
expect(subject['path'].name).to eql('path')
|
|
27
|
+
expect(subject['path'].type).to eql(:ltree)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'can define an lquery column' do
|
|
31
|
+
subject.lquery('pattern')
|
|
32
|
+
expect(subject['pattern'].name).to eql('pattern')
|
|
33
|
+
expect(subject['pattern'].type).to eql(:lquery)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'can define an array of paths' do
|
|
37
|
+
subject.ltree('permissions', array: true)
|
|
38
|
+
expect(subject['permissions'].type).to eql(:ltree)
|
|
39
|
+
expect(subject['permissions'].options[:array]).to be_truthy
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'requires a column name' do
|
|
43
|
+
expect { subject.ltree }.to raise_error(ArgumentError, /Missing column name/)
|
|
44
|
+
expect { subject.lquery }.to raise_error(ArgumentError, /Missing column name/)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
context 'on schema' do
|
|
49
|
+
it 'dumps the column as an ltree' do
|
|
50
|
+
dump_io = StringIO.new
|
|
51
|
+
ActiveRecord::SchemaDumper.dump(source, dump_io)
|
|
52
|
+
expect(dump_io.string).to match(/t\.ltree +"path"/)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'dumps the column as an lquery' do
|
|
56
|
+
dump_io = StringIO.new
|
|
57
|
+
ActiveRecord::SchemaDumper.dump(source, dump_io)
|
|
58
|
+
expect(dump_io.string).to match(/t\.lquery +"pattern"/)
|
|
59
|
+
expect(dump_io.string).to match(/t\.lquery +"patterns", array: true/)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'dumps an array of paths' do
|
|
63
|
+
dump_io = StringIO.new
|
|
64
|
+
ActiveRecord::SchemaDumper.dump(source, dump_io)
|
|
65
|
+
expect(dump_io.string).to match(/t\.ltree +"permissions", array: true/)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'is a valid type' do
|
|
69
|
+
expect(connection.valid_type?(:ltree)).to be_truthy
|
|
70
|
+
expect(connection.valid_type?(:lquery)).to be_truthy
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
context 'on the path' do
|
|
75
|
+
it 'enumerates its labels' do
|
|
76
|
+
subject = ltree.new('Top.Science')
|
|
77
|
+
expect(subject).to be_a(Enumerable)
|
|
78
|
+
expect(subject.items).to be_eql(%w[Top Science])
|
|
79
|
+
expect(subject.to_a).to be_eql(%w[Top Science])
|
|
80
|
+
expect(subject.map(&:downcase)).to be_eql(%w[top science])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'compares with a plain array' do
|
|
84
|
+
expect(ltree.new('Top.Science')).to be_eql(%w[Top Science])
|
|
85
|
+
expect(ltree.new('Top.Science')).to be_eql(ltree[:Top, :Science])
|
|
86
|
+
expect(ltree.new('Top.Science')).not_to be_eql(%w[Top])
|
|
87
|
+
expect(ltree.new('Top.Science').hash).to be_eql(ltree[:Top, :Science].hash)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it 'accepts a string, an array or symbols' do
|
|
91
|
+
expect(ltree.new('Top.Science').to_s).to be_eql('Top.Science')
|
|
92
|
+
expect(ltree.new(%w[Top Science]).to_s).to be_eql('Top.Science')
|
|
93
|
+
expect(ltree[:Top, :Science].to_s).to be_eql('Top.Science')
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it 'flattens nested arrays into labels' do
|
|
97
|
+
expect(ltree.new(['Top', %w[Science Astro]]).to_s).to be_eql('Top.Science.Astro')
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'knows its depth' do
|
|
101
|
+
expect(ltree.new('a.b.c').depth).to be_eql(3)
|
|
102
|
+
expect(ltree.new('').depth).to be_eql(0)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'knows its root' do
|
|
106
|
+
expect(ltree.new('a.b.c').root.to_s).to be_eql('a')
|
|
107
|
+
expect(ltree.new('a.b.c').root?).to be_falsey
|
|
108
|
+
expect(ltree.new('a').root?).to be_truthy
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'has no parent when it is a root' do
|
|
112
|
+
expect(ltree.new('a.b.c').parent.to_s).to be_eql('a.b')
|
|
113
|
+
expect(ltree.new('a').parent).to be_nil
|
|
114
|
+
expect(ltree.new('').parent).to be_nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it 'can be extended' do
|
|
118
|
+
expect((ltree.new('a.b') / 'c').to_s).to be_eql('a.b.c')
|
|
119
|
+
expect((ltree.new('a.b') + %w[c d]).to_s).to be_eql('a.b.c.d')
|
|
120
|
+
expect(ltree.new('a.b') / 'c').to be_a(ltree)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it 'checks ancestors and descendants, including itself' do
|
|
124
|
+
expect(ltree.new('a.b').ancestor_of?('a.b.c')).to be_truthy
|
|
125
|
+
expect(ltree.new('a.b').ancestor_of?('a.b')).to be_truthy
|
|
126
|
+
expect(ltree.new('a.b').ancestor_of?('a.c')).to be_falsey
|
|
127
|
+
expect(ltree.new('a.b.c').descendant_of?('a.b')).to be_truthy
|
|
128
|
+
expect(ltree.new('a.b').descendant_of?('a.b.c')).to be_falsey
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it 'aliases the checks as covering' do
|
|
132
|
+
expect(ltree.new('a.b').covers?('a.b.c')).to be_truthy
|
|
133
|
+
expect(ltree.new('a.b.c').covered_by?('a.b')).to be_truthy
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it 'finds the longest common ancestor' do
|
|
137
|
+
expect(ltree.new('1.2.3').lca('1.2.3.4.5.6').to_s).to be_eql('1.2')
|
|
138
|
+
expect(ltree.new('1.2.2.3').lca('1.2.3.4.5.6').to_s).to be_eql('1.2')
|
|
139
|
+
expect(ltree.new('a.b').lca('a.b').to_s).to be_eql('a')
|
|
140
|
+
expect(ltree.new('a.b').lca('c.d').to_s).to be_eql('')
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it 'finds the position of a subpath' do
|
|
144
|
+
subject = ltree.new('0.1.2.3.5.4.5.6.8.5.6.8')
|
|
145
|
+
expect(subject.index_of('5.6')).to be_eql(6)
|
|
146
|
+
expect(subject.index_of('5.6', -4)).to be_eql(9)
|
|
147
|
+
expect(subject.index_of('9.9')).to be_eql(-1)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
context 'on the pattern' do
|
|
152
|
+
samples = {
|
|
153
|
+
'a label' => [%w[Top], 'Top'],
|
|
154
|
+
'a symbol label' => [[:Top], 'Top'],
|
|
155
|
+
'a star' => [['Top', :any], 'Top.*'],
|
|
156
|
+
'an exact quantifier' => [['Top', 2..2], 'Top.*{2}'],
|
|
157
|
+
'a range quantifier' => [['Top', 0..2], 'Top.*{0,2}'],
|
|
158
|
+
'an open quantifier' => [['Top', 1..], 'Top.*{1,}'],
|
|
159
|
+
'a closed quantifier' => [['Top', ..3], 'Top.*{,3}'],
|
|
160
|
+
'an exclusive range' => [['Top', 2...4], 'Top.*{2,3}'],
|
|
161
|
+
'a prefix modifier' => [['sport*'], 'sport*'],
|
|
162
|
+
'a case modifier' => [['sport@'], 'sport@'],
|
|
163
|
+
'a word modifier' => [['sport%'], 'sport%'],
|
|
164
|
+
'combined modifiers' => [['sport*@'], 'sport*@'],
|
|
165
|
+
'alternatives' => [[%w[football tennis]], 'football|tennis'],
|
|
166
|
+
'a negated group' => [['!football|tennis'], '!football|tennis'],
|
|
167
|
+
'a quantified item' => [['football{1,}'], 'football{1,}'],
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
samples.each do |title, (input, expected)|
|
|
171
|
+
it "compiles #{title}" do
|
|
172
|
+
expect(Torque::PostgreSQL::LQuery.new(input).to_s).to be_eql(expected)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it 'compiles the example from the PostgreSQL manual' do
|
|
177
|
+
subject = lquery['Top', 0..2, 'sport*@', '!football|tennis{1,}', %w[Russ* Spain]]
|
|
178
|
+
expect(subject.to_s).to be_eql('Top.*{0,2}.sport*@.!football|tennis{1,}.Russ*|Spain')
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it 'holds the pure items' do
|
|
182
|
+
expect(lquery['Top', :any].items).to be_eql(['Top', :any])
|
|
183
|
+
expect(lquery.new('Top.*{1,2}.a|b').items).to be_eql(['Top', 1..2, %w[a b]])
|
|
184
|
+
expect(lquery.new('Top.*').to_a).to be_eql(['Top', :any])
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it 'enumerates its items' do
|
|
188
|
+
expect(lquery['Top', :any]).to be_a(Enumerable)
|
|
189
|
+
expect(lquery['Top', :any].map(&:to_s)).to be_eql(%w[Top any])
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it 'accepts its own text form' do
|
|
193
|
+
expect(lquery.new('Top.*.sport*@').items).to be_eql(['Top', :any, 'sport*@'])
|
|
194
|
+
expect(lquery.new('Top.*.sport*@').to_s).to be_eql('Top.*.sport*@')
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
it 'compares by its items' do
|
|
198
|
+
expect(lquery['Top', :any]).to be_eql(lquery.new('Top.*'))
|
|
199
|
+
expect(lquery['Top', :any]).to be_eql(['Top', :any])
|
|
200
|
+
expect(lquery['Top', :any].hash).to be_eql(lquery.new('Top.*').hash)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it 'leaves the validation to the database' do
|
|
204
|
+
expect(lquery['a b'].to_s).to be_eql('a b')
|
|
205
|
+
expect(lquery[2..0].to_s).to be_eql('*{2,0}')
|
|
206
|
+
expect { Category.where(path: ['a b']).count }.to raise_error(ActiveRecord::StatementInvalid)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
context 'on matching' do
|
|
211
|
+
let(:root) { Category.create!(title: 'Top') }
|
|
212
|
+
|
|
213
|
+
after { Torque::PostgreSQL.config.ltree.sanitize = nil }
|
|
214
|
+
|
|
215
|
+
it 'compiles into a regular expression over the text of a path' do
|
|
216
|
+
subject = lquery.new('Top.*{1,}.sport*@')
|
|
217
|
+
expect(subject.pattern).to be_a(Regexp)
|
|
218
|
+
expect(subject.pattern).to be_equal(subject.pattern)
|
|
219
|
+
expect(subject.pattern).to match('Top.Science.Sports')
|
|
220
|
+
expect(subject.pattern).not_to match('Top.Sports')
|
|
221
|
+
expect(subject.pattern).not_to match('Other.Top.Science.Sports')
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
it 'matches a string, an array or a path' do
|
|
225
|
+
subject = lquery['Top', :any]
|
|
226
|
+
expect(subject.match?('Top.Science')).to be_truthy
|
|
227
|
+
expect(subject.match?(%w[Top Science])).to be_truthy
|
|
228
|
+
expect(subject.match?(ltree['Top'])).to be_truthy
|
|
229
|
+
expect(subject.match?('Other')).to be_falsey
|
|
230
|
+
expect(subject.match?(nil)).to be_falsey
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
it 'works like a regular expression on the match operator' do
|
|
234
|
+
subject = lquery['Top', :any]
|
|
235
|
+
expect(subject =~ 'Top.Science').to be_eql(0)
|
|
236
|
+
expect(subject =~ 'Other').to be_nil
|
|
237
|
+
expect('Top.Science' =~ subject).to be_eql(0)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
it 'matches a record by its primary key' do
|
|
241
|
+
expect(lquery[root, :any].match?([root, 'Science'])).to be_truthy
|
|
242
|
+
expect(lquery[root, :any].match?(root)).to be_truthy
|
|
243
|
+
expect(lquery['Top', :any].match?(root)).to be_falsey
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
it 'sanitizes the path like a condition would' do
|
|
247
|
+
Torque::PostgreSQL.config.ltree.sanitize = { '-' => '_' }
|
|
248
|
+
expect(lquery.new('top.my-slug').match?('top.my_slug')).to be_truthy
|
|
249
|
+
expect(lquery.new('top.my_slug').match?('top.my-slug')).to be_truthy
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
samples = [
|
|
253
|
+
['a.b.c.d.e', 'a.b.c.d.e', true],
|
|
254
|
+
['a.b.c.d.e', 'A.b.c.d.e', false],
|
|
255
|
+
['a.b.c.d.e', 'A@.b.c.d.e', true],
|
|
256
|
+
['aa.b.c.d.e', 'A@.b.c.d.e', false],
|
|
257
|
+
['aa.b.c.d.e', 'A*.b.c.d.e', false],
|
|
258
|
+
['aa.b.c.d.e', 'A*@.b.c.d.e', true],
|
|
259
|
+
['aa.b.c.d.e', 'A*@|g.b.c.d.e', true],
|
|
260
|
+
['g.b.c.d.e', 'A*@|g.b.c.d.e', true],
|
|
261
|
+
['a.b.c.d.e', 'a.*.e', true],
|
|
262
|
+
['a.b.c.d.e', 'a.*{3}.e', true],
|
|
263
|
+
['a.b.c.d.e', 'a.*{2}.e', false],
|
|
264
|
+
['a.b.c.d.e', 'a.*{4}.e', false],
|
|
265
|
+
['a.b.c.d.e', 'a.*{,4}.e', true],
|
|
266
|
+
['a.b.c.d.e', 'a.*{2,}.e', true],
|
|
267
|
+
['a.b.c.d.e', 'a.*{2,4}.e', true],
|
|
268
|
+
['a.b.c.d.e', 'a.*{2,3}.e', true],
|
|
269
|
+
['a.b.c.d.e', 'a.*{2,3}', false],
|
|
270
|
+
['a.b.c.d.e', 'a.*{2,4}', true],
|
|
271
|
+
['a.b.c.d.e', 'a.*{2,5}', true],
|
|
272
|
+
['a.b.c.d.e', '*{2,3}.e', false],
|
|
273
|
+
['a.b.c.d.e', '*{2,4}.e', true],
|
|
274
|
+
['a.b.c.d.e', '*{2,5}.e', true],
|
|
275
|
+
['a.b.c.d.e', '*.e', true],
|
|
276
|
+
['a.b.c.d.e', '*.e.*', true],
|
|
277
|
+
['a.b.c.d.e', '*.d.*', true],
|
|
278
|
+
['a.b.c.d.e', '*.a.*.d.*', true],
|
|
279
|
+
['a.b.c.d.e', '*.!d.*', true],
|
|
280
|
+
['a.b.c.d.e', '*.!d', true],
|
|
281
|
+
['a.b.c.d.e', '!d.*', true],
|
|
282
|
+
['a.b.c.d.e', '!a.*', false],
|
|
283
|
+
['a.b.c.d.e', '*.!e', false],
|
|
284
|
+
['a.b.c.d.e', '*.!e.*', true],
|
|
285
|
+
['a.b.c.d.e', 'a.*.!e', false],
|
|
286
|
+
['a.b.c.d.e', 'a.*.!d', true],
|
|
287
|
+
['a.b.c.d.e', 'a.*.!d.*', true],
|
|
288
|
+
['a.b.c.d.e', 'a.*.!f.*', true],
|
|
289
|
+
['a.b.c.d.e', '*.a.*.!f.*', true],
|
|
290
|
+
['a.b.c.d.e', '*.a.*.!d.*', true],
|
|
291
|
+
['a.b.c.d.e', '*.a.!d.*', true],
|
|
292
|
+
['a.b.c.d.e', '*.a.!d', false],
|
|
293
|
+
['a.b.c.d.e', 'a.!d.*', true],
|
|
294
|
+
['a.b.c.d.e', '*.!b.*', true],
|
|
295
|
+
['a.b.c.d.e', '*.!b.c.*', false],
|
|
296
|
+
['a.b.c.d.e', '*.!b.*.c.*', true],
|
|
297
|
+
['a.b.c.d.e', '!b.*.c.*', true],
|
|
298
|
+
['a.b.c.d.e', '!b.b.*', true],
|
|
299
|
+
['a.b.c.d.e', '!b.*.e', true],
|
|
300
|
+
['a.b.c.d.e', '!b.!c.*.e', true],
|
|
301
|
+
['a.b.c.d.e', '!b.*.!c.*.e', true],
|
|
302
|
+
['a.b.c.d.e', '*{2}.!b.*.!c.*.e', true],
|
|
303
|
+
['a.b.c.d.e', '*{1}.!b.*.!c.*.e', false],
|
|
304
|
+
['a.b.c.d.e', '*{1}.!b.*{1}.!c.*.e', false],
|
|
305
|
+
['a.b.c.d.e', 'a.!b.*{1}.!c.*.e', false],
|
|
306
|
+
['a.b.c.d.e', '!b.*{1}.!c.*.e', false],
|
|
307
|
+
['a.b.c.d.e', '*.!b.*{1}.!c.*.e', false],
|
|
308
|
+
['a.b.c.d.e', '*.!b.*.!c.*.e', true],
|
|
309
|
+
['a.b.c.d.e', '!b.!c.*', true],
|
|
310
|
+
['a.b.c.d.e', '!b.*.!c.*', true],
|
|
311
|
+
['a.b.c.d.e', '*{2}.!b.*.!c.*', true],
|
|
312
|
+
['a.b.c.d.e', '*{1}.!b.*.!c.*', false],
|
|
313
|
+
['a.b.c.d.e', '*{1}.!b.*{1}.!c.*', false],
|
|
314
|
+
['a.b.c.d.e', 'a.!b.*{1}.!c.*', false],
|
|
315
|
+
['a.b.c.d.e', '!b.*{1}.!c.*', false],
|
|
316
|
+
['a.b.c.d.e', '*.!b.*{1}.!c.*', true],
|
|
317
|
+
['a.b.c.d.e', '*.!b.*.!c.*', true],
|
|
318
|
+
['a.b.c.d.e', 'a.*{2}.*{2}', true],
|
|
319
|
+
['a.b.c.d.e', 'a.*{1}.*{2}.e', true],
|
|
320
|
+
['a.b.c.d.e', 'a.*{1}.*{4}', false],
|
|
321
|
+
['a.b.c.d.e', 'a.*{5}.*', false],
|
|
322
|
+
['5.0.1.0', '5.!0.!0.0', false],
|
|
323
|
+
['a.b', '!a.!a', false],
|
|
324
|
+
['a.b.c.d.e', 'a{,}', false],
|
|
325
|
+
['a.b.c.d.e', 'a{1,}.*', true],
|
|
326
|
+
['a.b.c.d.e', 'a{,}.!a{,}', true],
|
|
327
|
+
['a.b.c.d.a', 'a{,}.!a{,}', false],
|
|
328
|
+
['a.b.c.d.a', 'a{,2}.!a{1,}', false],
|
|
329
|
+
['a.b.c.d.e', 'a{,2}.!a{1,}', true],
|
|
330
|
+
['a.b.c.d.e', '!x{,}', true],
|
|
331
|
+
['a.b.c.d.e', '!c{,}', false],
|
|
332
|
+
['a.b.c.d.e', '!c{0,3}.!a{2,}', true],
|
|
333
|
+
['a.b.c.d.e', '!c{0,3}.!d{2,}.*', true],
|
|
334
|
+
['QWER_TY', 'q%@*', true],
|
|
335
|
+
['QWER_TY', 'q%@*%@*', true],
|
|
336
|
+
['QWER_TY', 'Q_t%@*', true],
|
|
337
|
+
['QWER_GY', 'q_t%@*', false],
|
|
338
|
+
]
|
|
339
|
+
|
|
340
|
+
samples.each do |path, query, expected|
|
|
341
|
+
it "agrees with PostgreSQL that '#{path}' ~ '#{query}' is #{expected}" do
|
|
342
|
+
expect(lquery.new(query).match?(path)).to be(expected)
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
it 'holds the same outcomes as the database for every sample' do
|
|
347
|
+
rows = samples.map { |path, query, _| "('#{path}', '#{query}')" }
|
|
348
|
+
result = connection.select_values(<<~SQL)
|
|
349
|
+
SELECT path::ltree ~ query::lquery
|
|
350
|
+
FROM (VALUES #{rows.join(', ')}) AS samples(path, query)
|
|
351
|
+
SQL
|
|
352
|
+
|
|
353
|
+
expect(result).to be_eql(samples.map(&:last))
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
context 'on records' do
|
|
358
|
+
let(:root) { Category.create!(title: 'Top') }
|
|
359
|
+
let(:child) { Category.create!(title: 'Science') }
|
|
360
|
+
|
|
361
|
+
it 'uses the primary key as the label' do
|
|
362
|
+
expect(ltree.new(root).to_s).to be_eql(root.id.to_s)
|
|
363
|
+
expect(ltree[root, child].to_s).to be_eql("#{root.id}.#{child.id}")
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
it 'mixes records with plain labels' do
|
|
367
|
+
expect(ltree.new(['app', root]).to_s).to be_eql("app.#{root.id}")
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
it 'extends a path with a record' do
|
|
371
|
+
expect((ltree.new('app') / child).to_s).to be_eql("app.#{child.id}")
|
|
372
|
+
expect((ltree.new('app') + [root, child]).to_s).to be_eql("app.#{root.id}.#{child.id}")
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
it 'compares paths given as records' do
|
|
376
|
+
subject = ltree[root, child]
|
|
377
|
+
expect(ltree.new(root).ancestor_of?(subject)).to be_truthy
|
|
378
|
+
expect(subject.descendant_of?(root)).to be_truthy
|
|
379
|
+
expect(subject.covered_by?(ltree.new(root))).to be_truthy
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
it 'accepts records on the remaining path operations' do
|
|
383
|
+
subject = ltree[root, child]
|
|
384
|
+
expect(subject.lca(ltree[root, child]).to_s).to be_eql(root.id.to_s)
|
|
385
|
+
expect(subject.index_of(child)).to be_eql(1)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
it 'uses the primary key on a pattern' do
|
|
389
|
+
expect(lquery[root, :any].to_s).to be_eql("#{root.id}.*")
|
|
390
|
+
expect(lquery[[root, child]].to_s).to be_eql("#{root.id}|#{child.id}")
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
it 'follows a custom primary key' do
|
|
394
|
+
klass = Class.new(Category) do
|
|
395
|
+
self.table_name = 'categories'
|
|
396
|
+
self.primary_key = 'title'
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
expect(ltree.new(klass.find(root.title)).to_s).to be_eql('Top')
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
it 'refuses a record that was never saved' do
|
|
403
|
+
expect { ltree.new(Category.new) }
|
|
404
|
+
.to raise_error(ArgumentError, /its id is still empty/)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
it 'writes a record-based path to the database' do
|
|
408
|
+
record = Category.create!(title: 'Astronomy', path: [root, child])
|
|
409
|
+
expect(Category.find(record.id).path).to be_eql([root.id.to_s, child.id.to_s])
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
it 'queries with a record-based path' do
|
|
413
|
+
Category.create!(title: 'Astronomy', path: [root, child])
|
|
414
|
+
|
|
415
|
+
expect(Category.where(path: [root, child]).count).to be_eql(1)
|
|
416
|
+
expect(Category.where(path: [root, :any]).count).to be_eql(1)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
it 'takes a lone record as a single-label path' do
|
|
420
|
+
Category.create!(title: 'Only', path: [root])
|
|
421
|
+
|
|
422
|
+
expect(Category.where(path: root).to_sql)
|
|
423
|
+
.to include(%[WHERE "categories"."path" = '#{root.id}'])
|
|
424
|
+
expect(Category.where(path: root).count).to be_eql(1)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
it 'reaches the entries of an array column' do
|
|
428
|
+
user = User.create!(name: 'Rick', permissions: [root, [root, child]])
|
|
429
|
+
expect(User.find(user.id).permissions)
|
|
430
|
+
.to be_eql([[root.id.to_s], [root.id.to_s, child.id.to_s]])
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
context 'on compatible objects' do
|
|
435
|
+
let(:path_like) { ::Struct.new(:to_tree_path) }
|
|
436
|
+
|
|
437
|
+
after { Torque::PostgreSQL.config.ltree.compatible_method = :to_tree_path }
|
|
438
|
+
|
|
439
|
+
it 'takes over as a path' do
|
|
440
|
+
expect(ltree.new(path_like.new('app.users')).to_s).to be_eql('app.users')
|
|
441
|
+
expect(ltree.new(path_like.new(%w[app users])).to_s).to be_eql('app.users')
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
it 'can be one entry among labels' do
|
|
445
|
+
subject = ltree.new(['top', path_like.new('app.users')])
|
|
446
|
+
expect(subject.to_s).to be_eql('top.app.users')
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
it 'is accepted while comparing two paths' do
|
|
450
|
+
other = path_like.new('app.users')
|
|
451
|
+
expect(ltree.new('app').ancestor_of?(other)).to be_truthy
|
|
452
|
+
expect(ltree.new('app.users.write').descendant_of?(other)).to be_truthy
|
|
453
|
+
expect(ltree.new('app.users.write').index_of(other)).to be_eql(0)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
it 'is accepted while extending a path' do
|
|
457
|
+
expect((ltree.new('top') / path_like.new('app.users')).to_s).to be_eql('top.app.users')
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
it 'contributes every item of a pattern' do
|
|
461
|
+
expect(lquery[path_like.new('app.users'), :any].to_s).to be_eql('app.users.*')
|
|
462
|
+
expect(lquery[path_like.new(['app', :any])].items).to be_eql(['app', :any])
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
it 'turns the condition into a match when it describes a pattern' do
|
|
466
|
+
expect(Category.where(path: path_like.new('app.*')).to_sql)
|
|
467
|
+
.to include(%[WHERE "categories"."path" ~ 'app.*'::lquery])
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
it 'keeps the condition an equality when it describes a path' do
|
|
471
|
+
expect(Category.where(path: path_like.new('app.users')).to_sql)
|
|
472
|
+
.to include(%[WHERE "categories"."path" = 'app.users'])
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
it 'wins over the primary key of a record' do
|
|
476
|
+
klass = Class.new(Category)
|
|
477
|
+
klass.define_method(:to_tree_path) { "cat.#{title}" }
|
|
478
|
+
record = klass.create!(title: 'Science')
|
|
479
|
+
|
|
480
|
+
expect(ltree.new(record).to_s).to be_eql('cat.Science')
|
|
481
|
+
expect(Category.where(path: record).to_sql)
|
|
482
|
+
.to include(%[WHERE "categories"."path" = 'cat.Science'])
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
it 'is ignored when the config has no method' do
|
|
486
|
+
Torque::PostgreSQL.config.ltree.compatible_method = nil
|
|
487
|
+
expect(ltree.new(path_like.new('app.users'))).not_to be_eql(%w[app users])
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
context 'on markers' do
|
|
492
|
+
it 'treats a plain path as a path' do
|
|
493
|
+
expect(lquery.new('Top.Science')).not_to be_pattern
|
|
494
|
+
expect(lquery.new(%w[Top Science])).not_to be_pattern
|
|
495
|
+
expect(lquery.new(%i[Top Science])).not_to be_pattern
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
it 'treats anything with a query feature as a pattern' do
|
|
499
|
+
expect(lquery.new(['Top', :any])).to be_pattern
|
|
500
|
+
expect(lquery.new(['Top', 1..])).to be_pattern
|
|
501
|
+
expect(lquery.new(['Top', %w[a b]])).to be_pattern
|
|
502
|
+
expect(lquery.new('Top.*')).to be_pattern
|
|
503
|
+
expect(lquery.new('!Top')).to be_pattern
|
|
504
|
+
expect(lquery.new('Top@')).to be_pattern
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
context 'on sanitize' do
|
|
509
|
+
after { Torque::PostgreSQL.config.ltree.sanitize = nil }
|
|
510
|
+
|
|
511
|
+
it 'does nothing by default' do
|
|
512
|
+
expect(ltree.new('a-b').to_s).to be_eql('a-b')
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
it 'replaces the configured characters on a path' do
|
|
516
|
+
Torque::PostgreSQL.config.ltree.sanitize = { '-' => '_' }
|
|
517
|
+
expect(ltree.new('a-b.c-d').to_s).to be_eql('a_b.c_d')
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
it 'can drop characters entirely' do
|
|
521
|
+
Torque::PostgreSQL.config.ltree.sanitize = { '-' => '' }
|
|
522
|
+
expect(ltree.new('a-b').to_s).to be_eql('ab')
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
it 'reaches the labels of a pattern' do
|
|
526
|
+
Torque::PostgreSQL.config.ltree.sanitize = { '-' => '_' }
|
|
527
|
+
expect(lquery['a-b*', %w[c-d e]].to_s).to be_eql('a_b*.c_d|e')
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
it 'does not apply to values read from the database' do
|
|
531
|
+
Torque::PostgreSQL.config.ltree.sanitize = { 'a' => 'z' }
|
|
532
|
+
expect(ltree_type.deserialize('a.b').to_s).to be_eql('a.b')
|
|
533
|
+
expect(lquery_type.deserialize('a.*').to_s).to be_eql('a.*')
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
context 'on OID' do
|
|
538
|
+
it 'has the right types' do
|
|
539
|
+
expect(ltree_type.type).to be_eql(:ltree)
|
|
540
|
+
expect(lquery_type.type).to be_eql(:lquery)
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
it 'deserializes into a path' do
|
|
544
|
+
expect(ltree_type.deserialize('Top.Science')).to be_a(ltree)
|
|
545
|
+
expect(ltree_type.deserialize('Top.Science')).to be_eql(%w[Top Science])
|
|
546
|
+
expect(ltree_type.deserialize(nil)).to be_nil
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
it 'serializes a path back into text' do
|
|
550
|
+
expect(ltree_type.serialize(%w[Top Science])).to be_eql('Top.Science')
|
|
551
|
+
expect(ltree_type.serialize('Top.Science')).to be_eql('Top.Science')
|
|
552
|
+
expect(ltree_type.serialize(nil)).to be_nil
|
|
553
|
+
expect(ltree_type.serialize('')).to be_nil
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
it 'deserializes a pattern into its items' do
|
|
557
|
+
expect(lquery_type.deserialize('users.*')).to be_a(lquery)
|
|
558
|
+
expect(lquery_type.deserialize('users.*')).to be_eql(['users', :any])
|
|
559
|
+
expect(lquery_type.deserialize('a.*{2}')).to be_eql(['a', 2..2])
|
|
560
|
+
expect(lquery_type.deserialize('a.*{1,}')).to be_eql(['a', 1..])
|
|
561
|
+
expect(lquery_type.deserialize('a.*{,3}')).to be_eql(['a', ..3])
|
|
562
|
+
expect(lquery_type.deserialize('a.*{0,2}')).to be_eql(['a', 0..2])
|
|
563
|
+
expect(lquery_type.deserialize('a|b.c')).to be_eql([%w[a b], 'c'])
|
|
564
|
+
expect(lquery_type.deserialize(nil)).to be_nil
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
it 'keeps negated and quantified groups as text' do
|
|
568
|
+
expect(lquery_type.deserialize('!a|b.c{1,}')).to be_eql(['!a|b', 'c{1,}'])
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
it 'round trips the example from the PostgreSQL manual' do
|
|
572
|
+
text = 'Top.*{0,2}.sport*@.!football|tennis{1,}.Russ*|Spain'
|
|
573
|
+
items = ['Top', 0..2, 'sport*@', '!football|tennis{1,}', %w[Russ* Spain]]
|
|
574
|
+
|
|
575
|
+
expect(lquery_type.deserialize(text)).to be_eql(items)
|
|
576
|
+
expect(lquery_type.serialize(lquery_type.deserialize(text))).to be_eql(text)
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
it 'serializes a pattern back into text' do
|
|
580
|
+
expect(lquery_type.serialize(['Top', :any])).to be_eql('Top.*')
|
|
581
|
+
expect(lquery_type.serialize(lquery['Top', 1..])).to be_eql('Top.*{1,}')
|
|
582
|
+
expect(lquery_type.serialize(nil)).to be_nil
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
context 'on the model' do
|
|
587
|
+
subject { Category.new }
|
|
588
|
+
|
|
589
|
+
it 'reads and writes a path' do
|
|
590
|
+
subject.path = 'Top.Science'
|
|
591
|
+
expect(subject.path).to be_a(ltree)
|
|
592
|
+
expect(subject.path).to be_eql(%w[Top Science])
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
it 'accepts an array of labels' do
|
|
596
|
+
subject.path = %w[Top Science]
|
|
597
|
+
expect(subject.path.to_s).to be_eql('Top.Science')
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
it 'round trips through the database' do
|
|
601
|
+
subject.update!(title: 'Science', path: 'Top.Science')
|
|
602
|
+
expect(Category.find(subject.id).path).to be_eql(%w[Top Science])
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
it 'round trips an array of paths' do
|
|
606
|
+
user = User.create!(name: 'Rick', permissions: ['app.users.write', %w[app posts]])
|
|
607
|
+
reloaded = User.find(user.id)
|
|
608
|
+
|
|
609
|
+
expect(reloaded.permissions).to be_eql([%w[app users write], %w[app posts]])
|
|
610
|
+
expect(reloaded.permissions.first).to be_a(ltree)
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
it 'reads and writes a pattern' do
|
|
614
|
+
subject.pattern = ['users', :any]
|
|
615
|
+
expect(subject.pattern).to be_a(lquery)
|
|
616
|
+
expect(subject.pattern.items).to be_eql(['users', :any])
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
it 'round trips a pattern as its items' do
|
|
620
|
+
subject.update!(title: 'Rule', pattern: ['users', :any, 1..2])
|
|
621
|
+
expect(Category.find(subject.id).pattern.items).to be_eql(['users', :any, 1..2])
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
it 'round trips an array of patterns' do
|
|
625
|
+
subject.update!(title: 'Rule', patterns: [['users', :any], 'posts.*{1,}'])
|
|
626
|
+
reloaded = Category.find(subject.id)
|
|
627
|
+
|
|
628
|
+
expect(reloaded.patterns).to be_eql([['users', :any], ['posts', 1..]])
|
|
629
|
+
expect(reloaded.patterns.first).to be_a(lquery)
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
it 'tracks changes by the compiled form' do
|
|
633
|
+
subject.update!(title: 'Rule', path: 'Top.Science', pattern: ['Top', :any])
|
|
634
|
+
subject.path = %w[Top Science]
|
|
635
|
+
subject.pattern = 'Top.*'
|
|
636
|
+
expect(subject).not_to be_changed
|
|
637
|
+
|
|
638
|
+
subject.pattern = ['Top', 1..]
|
|
639
|
+
expect(subject).to be_changed
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
it 'leaves the validation to the database' do
|
|
643
|
+
subject.path = 'Top.*'
|
|
644
|
+
expect(subject.path).to be_eql(['Top', '*'])
|
|
645
|
+
expect { subject.save! }.to raise_error(ActiveRecord::StatementInvalid)
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
context 'on predicate builder' do
|
|
650
|
+
subject { Category.all }
|
|
651
|
+
|
|
652
|
+
it 'uses equality for a plain path' do
|
|
653
|
+
expect(subject.where(path: 'Top.Science').to_sql)
|
|
654
|
+
.to include(%[WHERE "categories"."path" = 'Top.Science'])
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
it 'uses equality for a plain array of labels' do
|
|
658
|
+
expect(subject.where(path: %w[Top Science]).to_sql)
|
|
659
|
+
.to include(%[WHERE "categories"."path" = 'Top.Science'])
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
it 'matches a pattern when the value has a star' do
|
|
663
|
+
expect(subject.where(path: ['Top', :any]).to_sql)
|
|
664
|
+
.to include(%[WHERE "categories"."path" ~ 'Top.*'::lquery])
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
it 'matches a pattern when the value has a quantifier' do
|
|
668
|
+
expect(subject.where(path: ['Top', 1..]).to_sql)
|
|
669
|
+
.to include(%[WHERE "categories"."path" ~ 'Top.*{1,}'::lquery])
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
it 'matches a pattern when the value has alternatives' do
|
|
673
|
+
expect(subject.where(path: ['Top', %w[a b]]).to_sql)
|
|
674
|
+
.to include(%[WHERE "categories"."path" ~ 'Top.a|b'::lquery])
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
it 'matches a pattern written as a string' do
|
|
678
|
+
expect(subject.where(path: 'Top.*.sport*@').to_sql)
|
|
679
|
+
.to include(%[WHERE "categories"."path" ~ 'Top.*.sport*@'::lquery])
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
it 'accepts a pattern object' do
|
|
683
|
+
expect(subject.where(path: lquery['Top', :any]).to_sql)
|
|
684
|
+
.to include(%[WHERE "categories"."path" ~ 'Top.*'::lquery])
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
it 'takes a plain array as one path' do
|
|
688
|
+
expect(subject.where(path: %w[Top Science]).to_sql).not_to include('IN')
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
it 'lists paths when the first entry is a whole one' do
|
|
692
|
+
expect(subject.where(path: [%w[Top a], %w[Top b]]).to_sql)
|
|
693
|
+
.to include(%[WHERE "categories"."path" IN ('Top.a', 'Top.b')])
|
|
694
|
+
expect(subject.where(path: [ltree['Top'], 'Other']).to_sql)
|
|
695
|
+
.to include(%[WHERE "categories"."path" IN ('Top', 'Other')])
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
it 'tests a list of patterns at once' do
|
|
699
|
+
expect(subject.where(path: [['Top', :any], lquery['Other', 1..]]).to_sql)
|
|
700
|
+
.to include(%[WHERE "categories"."path" ? '{Top.*,"Other.*{1,}"}'::lquery[]])
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
it 'handles nil and an empty list' do
|
|
704
|
+
expect(subject.where(path: nil).to_sql).to include(%[WHERE "categories"."path" IS NULL])
|
|
705
|
+
expect(subject.where(path: []).to_sql).to include('WHERE 1=0')
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
it 'finds the records that match' do
|
|
709
|
+
Category.create!(title: 'Science', path: 'Top.Science')
|
|
710
|
+
Category.create!(title: 'Astronomy', path: 'Top.Science.Astronomy')
|
|
711
|
+
Category.create!(title: 'Hobbies', path: 'Top.Hobbies')
|
|
712
|
+
|
|
713
|
+
expect(Category.where(path: ['Top', :any]).count).to be_eql(3)
|
|
714
|
+
expect(Category.where(path: %w[Top Science]).count).to be_eql(1)
|
|
715
|
+
expect(Category.where(path: ['Top', 'Science', :any]).count).to be_eql(2)
|
|
716
|
+
expect(Category.where(path: [%w[Top Science], %w[Top Hobbies]]).count).to be_eql(2)
|
|
717
|
+
expect(Category.where(path: [['Top', 'Sci*'], ['Nope', :any]]).count).to be_eql(1)
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
context 'on an array of paths' do
|
|
721
|
+
subject { User.all }
|
|
722
|
+
|
|
723
|
+
it 'asks whether any entry is the path' do
|
|
724
|
+
expect(subject.where(permissions: 'app.users').to_sql)
|
|
725
|
+
.to include(%[WHERE 'app.users' = ANY("users"."permissions")])
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
it 'matches the entries against a pattern' do
|
|
729
|
+
expect(subject.where(permissions: ['app', :any]).to_sql)
|
|
730
|
+
.to include(%[WHERE "users"."permissions" ~ 'app.*'::lquery])
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
it 'overlaps with a list of paths' do
|
|
734
|
+
expect(subject.where(permissions: [%w[app users], %w[app posts]]).to_sql)
|
|
735
|
+
.to include(%[WHERE "users"."permissions" && '{app.users,app.posts}'])
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
it 'tests the entries against a list of patterns' do
|
|
739
|
+
expect(subject.where(permissions: [['app', :any], ['other', :any]]).to_sql)
|
|
740
|
+
.to include(%[WHERE "users"."permissions" ? '{app.*,other.*}'::lquery[]])
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
it 'handles nil and an empty list' do
|
|
744
|
+
expect(subject.where(permissions: nil).to_sql)
|
|
745
|
+
.to include(%[WHERE "users"."permissions" IS NULL])
|
|
746
|
+
expect(subject.where(permissions: []).to_sql)
|
|
747
|
+
.to include(%[WHERE CARDINALITY("users"."permissions") = 0])
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
it 'finds the records that match' do
|
|
751
|
+
User.create!(name: 'Editor', permissions: ['app.posts', 'app.users'])
|
|
752
|
+
User.create!(name: 'Guest', permissions: ['app.posts'])
|
|
753
|
+
|
|
754
|
+
expect(User.where(permissions: 'app.users').pluck(:name)).to be_eql(%w[Editor])
|
|
755
|
+
expect(User.where(permissions: ['app', 'u*']).pluck(:name)).to be_eql(%w[Editor])
|
|
756
|
+
expect(User.where(permissions: [%w[app users], %w[nope]]).pluck(:name)).to be_eql(%w[Editor])
|
|
757
|
+
expect(User.where(permissions: [['app', :any]]).count).to be_eql(2)
|
|
758
|
+
end
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
context 'on a pattern column' do
|
|
762
|
+
it 'compares a pattern by its text' do
|
|
763
|
+
expect(subject.where(pattern: ['users', :any]).to_sql)
|
|
764
|
+
.to include(%[WHERE "categories"."pattern"::text = 'users.*'])
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
it 'matches a path against the stored pattern' do
|
|
768
|
+
expect(subject.where(pattern: 'users.admin').to_sql)
|
|
769
|
+
.to include(%[WHERE "categories"."pattern" ~ 'users.admin'::ltree])
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
it 'lists patterns by their text' do
|
|
773
|
+
expect(subject.where(pattern: [['users', :any], ['posts', :any]]).to_sql)
|
|
774
|
+
.to include(%[WHERE "categories"."pattern"::text IN ('users.*', 'posts.*')])
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
it 'matches any of the paths' do
|
|
778
|
+
expect(subject.where(pattern: [%w[users admin], %w[posts new]]).to_sql)
|
|
779
|
+
.to include(%[WHERE "categories"."pattern" ~ ANY('{users.admin,posts.new}'::ltree[])])
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
it 'finds the patterns that match' do
|
|
783
|
+
Category.create!(title: 'Users', pattern: ['users', :any])
|
|
784
|
+
Category.create!(title: 'Posts', pattern: ['posts', 1..])
|
|
785
|
+
|
|
786
|
+
expect(Category.where(pattern: 'users.admin').pluck(:title)).to be_eql(%w[Users])
|
|
787
|
+
expect(Category.where(pattern: ['users', :any]).pluck(:title)).to be_eql(%w[Users])
|
|
788
|
+
expect(Category.where(pattern: [%w[users admin], %w[posts new]]).pluck(:title))
|
|
789
|
+
.to match_array(%w[Users Posts])
|
|
790
|
+
end
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
context 'on an array of patterns' do
|
|
794
|
+
it 'asks whether any entry is the pattern' do
|
|
795
|
+
expect(subject.where(patterns: ['users', :any]).to_sql)
|
|
796
|
+
.to include(%[WHERE 'users.*' = ANY("categories"."patterns"::text[])])
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
it 'matches a path against any entry' do
|
|
800
|
+
expect(subject.where(patterns: 'users.admin').to_sql)
|
|
801
|
+
.to include(%[WHERE 'users.admin'::ltree ~ ANY("categories"."patterns")])
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
it 'overlaps with a list of patterns by their text' do
|
|
805
|
+
expect(subject.where(patterns: [['users', :any], ['posts', :any]]).to_sql)
|
|
806
|
+
.to include(%[WHERE "categories"."patterns"::text[] && '{users.*,posts.*}'])
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
it 'tests the entries against a list of paths' do
|
|
810
|
+
expect(subject.where(patterns: [%w[users admin], %w[posts new]]).to_sql)
|
|
811
|
+
.to include(%[WHERE "categories"."patterns" ? '{users.admin,posts.new}'::ltree[]])
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
it 'finds the records whose patterns match' do
|
|
815
|
+
Category.create!(title: 'Rule', patterns: [['users', :any], ['posts', 1..]])
|
|
816
|
+
|
|
817
|
+
expect(Category.where(patterns: 'users.admin').count).to be_eql(1)
|
|
818
|
+
expect(Category.where(patterns: 'nope.x').count).to be_eql(0)
|
|
819
|
+
expect(Category.where(patterns: ['users', :any]).count).to be_eql(1)
|
|
820
|
+
expect(Category.where(patterns: [%w[posts new], %w[nope]]).count).to be_eql(1)
|
|
821
|
+
expect(Category.where(patterns: [['users', :any], ['x', :any]]).count).to be_eql(1)
|
|
822
|
+
end
|
|
823
|
+
end
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
context 'on arel' do
|
|
827
|
+
let(:attribute) { Category.arel_table['path'] }
|
|
828
|
+
|
|
829
|
+
it 'has the tree operators' do
|
|
830
|
+
expect(attribute).to respond_to(:contains)
|
|
831
|
+
expect(attribute).to respond_to(:contained_by)
|
|
832
|
+
expect(attribute).to respond_to(:matches_lquery)
|
|
833
|
+
expect(attribute).to respond_to(:matches_any_lquery)
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
it 'builds an ancestor condition' do
|
|
837
|
+
sql = Category.where(attribute.contains(::Arel.sql("'Top.Science'"))).to_sql
|
|
838
|
+
expect(sql).to include(%["categories"."path" @> 'Top.Science'])
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
it 'builds a pattern condition' do
|
|
842
|
+
value = ::Arel.sql("'Top.*'").pg_cast('lquery')
|
|
843
|
+
sql = Category.where(attribute.matches_lquery(value)).to_sql
|
|
844
|
+
expect(sql).to include(%["categories"."path" ~ 'Top.*'::lquery])
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
it 'builds a condition against many patterns' do
|
|
848
|
+
value = ::Arel.array(['Top.*', 'Other.*'], cast: 'lquery')
|
|
849
|
+
sql = Category.where(attribute.matches_any_lquery(value)).to_sql
|
|
850
|
+
expect(sql).to include(%["categories"."path" ? ARRAY['Top.*', 'Other.*']::lquery[]])
|
|
851
|
+
end
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
context 'on authorization' do
|
|
855
|
+
let(:required) { Torque::PostgreSQL::LTree.new('app.users.write') }
|
|
856
|
+
let(:grants) { User.arel_table['permissions'] }
|
|
857
|
+
|
|
858
|
+
before do
|
|
859
|
+
User.create!(name: 'Admin', permissions: ['app'])
|
|
860
|
+
User.create!(name: 'Editor', permissions: ['app.posts', 'app.users'])
|
|
861
|
+
User.create!(name: 'Guest', permissions: ['app.posts'])
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
it 'finds the users a grant covers, in SQL' do
|
|
865
|
+
condition = grants.contains(Torque::PostgreSQL::FN.bind_type(required.to_s, cast: 'ltree'))
|
|
866
|
+
names = User.where(condition).pluck(:name)
|
|
867
|
+
|
|
868
|
+
expect(names).to match_array(%w[Admin Editor])
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
it 'agrees with the same check performed in Ruby' do
|
|
872
|
+
condition = grants.contains(Torque::PostgreSQL::FN.bind_type(required.to_s, cast: 'ltree'))
|
|
873
|
+
from_sql = User.where(condition).pluck(:name)
|
|
874
|
+
|
|
875
|
+
in_memory = User.all.select do |user|
|
|
876
|
+
user.permissions.any? { |grant| grant.covers?(required) }
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
expect(from_sql).to match_array(in_memory.map(&:name))
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
it 'matches grants against a pattern' do
|
|
883
|
+
expect(User.where(permissions: ['app', 'users', :any]).pluck(:name)).to match_array(%w[Editor])
|
|
884
|
+
end
|
|
885
|
+
end
|
|
886
|
+
end
|