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.
Files changed (218) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +52 -0
  3. data/lib/generators/torque/skill_generator.rb +17 -0
  4. data/lib/generators/torque/templates/trigger.sql.erb +3 -0
  5. data/lib/generators/torque/trigger_generator.rb +13 -0
  6. data/lib/torque/postgresql/adapter/database_statements.rb +98 -7
  7. data/lib/torque/postgresql/adapter/inheritance_statements.rb +297 -0
  8. data/lib/torque/postgresql/adapter/oid/array.rb +13 -0
  9. data/lib/torque/postgresql/adapter/oid/box.rb +1 -1
  10. data/lib/torque/postgresql/adapter/oid/circle.rb +1 -1
  11. data/lib/torque/postgresql/adapter/oid/composite.rb +117 -0
  12. data/lib/torque/postgresql/adapter/oid/enum.rb +1 -1
  13. data/lib/torque/postgresql/adapter/oid/enum_set.rb +2 -2
  14. data/lib/torque/postgresql/adapter/oid/interval.rb +1 -1
  15. data/lib/torque/postgresql/adapter/oid/line.rb +1 -1
  16. data/lib/torque/postgresql/adapter/oid/lquery.rb +70 -0
  17. data/lib/torque/postgresql/adapter/oid/ltree.rb +111 -0
  18. data/lib/torque/postgresql/adapter/oid/segment.rb +1 -1
  19. data/lib/torque/postgresql/adapter/oid/struct.rb +150 -0
  20. data/lib/torque/postgresql/adapter/oid/struct_list.rb +52 -0
  21. data/lib/torque/postgresql/adapter/oid/struct_set.rb +38 -0
  22. data/lib/torque/postgresql/adapter/oid.rb +13 -0
  23. data/lib/torque/postgresql/adapter/quoting.rb +11 -2
  24. data/lib/torque/postgresql/adapter/schema_definitions.rb +51 -0
  25. data/lib/torque/postgresql/adapter/schema_dumper.rb +27 -0
  26. data/lib/torque/postgresql/adapter/schema_statements.rb +207 -0
  27. data/lib/torque/postgresql/adapter.rb +2 -5
  28. data/lib/torque/postgresql/arel/nodes.rb +114 -1
  29. data/lib/torque/postgresql/arel/visitors.rb +65 -8
  30. data/lib/torque/postgresql/associations/join_dependency.rb +55 -0
  31. data/lib/torque/postgresql/associations.rb +3 -0
  32. data/lib/torque/postgresql/attributes/base.rb +97 -0
  33. data/lib/torque/postgresql/attributes/composite.rb +98 -0
  34. data/lib/torque/postgresql/attributes/enum.rb +13 -7
  35. data/lib/torque/postgresql/attributes/lquery.rb +120 -0
  36. data/lib/torque/postgresql/attributes/ltree.rb +121 -0
  37. data/lib/torque/postgresql/attributes/simple_enum.rb +72 -0
  38. data/lib/torque/postgresql/attributes/struct.rb +137 -0
  39. data/lib/torque/postgresql/base.rb +26 -15
  40. data/lib/torque/postgresql/config.rb +100 -15
  41. data/lib/torque/postgresql/geometry_builder.rb +4 -10
  42. data/lib/torque/postgresql/inheritance/expander.rb +66 -0
  43. data/lib/torque/postgresql/inheritance/record.rb +52 -0
  44. data/lib/torque/postgresql/inheritance.rb +141 -68
  45. data/lib/torque/postgresql/migration/command_recorder.rb +76 -0
  46. data/lib/torque/postgresql/predicate_builder/arel_attribute_handler.rb +1 -1
  47. data/lib/torque/postgresql/predicate_builder/array_handler.rb +16 -8
  48. data/lib/torque/postgresql/predicate_builder/composite_handler.rb +122 -0
  49. data/lib/torque/postgresql/predicate_builder/ltree_handler.rb +112 -0
  50. data/lib/torque/postgresql/predicate_builder/struct_handler.rb +88 -0
  51. data/lib/torque/postgresql/predicate_builder.rb +53 -0
  52. data/lib/torque/postgresql/predicate_table.rb +61 -0
  53. data/lib/torque/postgresql/railtie.rb +52 -0
  54. data/lib/torque/postgresql/relation/auxiliary_statement.rb +7 -3
  55. data/lib/torque/postgresql/relation/buckets.rb +2 -1
  56. data/lib/torque/postgresql/relation/cross_join.rb +193 -0
  57. data/lib/torque/postgresql/relation/inheritance.rb +145 -28
  58. data/lib/torque/postgresql/relation/join_series.rb +1 -0
  59. data/lib/torque/postgresql/relation/merger.rb +30 -5
  60. data/lib/torque/postgresql/relation/segments.rb +140 -0
  61. data/lib/torque/postgresql/relation.rb +32 -12
  62. data/lib/torque/postgresql/schema_cache.rb +1 -0
  63. data/lib/torque/postgresql/validations.rb +29 -0
  64. data/lib/torque/postgresql/version.rb +1 -1
  65. data/lib/torque/postgresql/versioned_commands/command_migration.rb +24 -1
  66. data/lib/torque/postgresql/versioned_commands.rb +39 -0
  67. data/lib/torque/postgresql.rb +4 -0
  68. data/sig/generators/torque/function_generator.rbs +9 -0
  69. data/sig/generators/torque/skill_generator.rbs +7 -0
  70. data/sig/generators/torque/trigger_generator.rbs +9 -0
  71. data/sig/generators/torque/type_generator.rbs +9 -0
  72. data/sig/generators/torque/view_generator.rbs +9 -0
  73. data/sig/manifest.yaml +7 -0
  74. data/sig/torque/postgresql/adapter/database_statements.rbs +42 -0
  75. data/sig/torque/postgresql/adapter/inheritance_statements.rbs +36 -0
  76. data/sig/torque/postgresql/adapter/oid/array.rbs +20 -0
  77. data/sig/torque/postgresql/adapter/oid/box.rbs +28 -0
  78. data/sig/torque/postgresql/adapter/oid/circle.rbs +33 -0
  79. data/sig/torque/postgresql/adapter/oid/composite.rbs +35 -0
  80. data/sig/torque/postgresql/adapter/oid/enum.rbs +28 -0
  81. data/sig/torque/postgresql/adapter/oid/enum_set.rbs +19 -0
  82. data/sig/torque/postgresql/adapter/oid/interval.rbs +20 -0
  83. data/sig/torque/postgresql/adapter/oid/line.rbs +33 -0
  84. data/sig/torque/postgresql/adapter/oid/lquery.rbs +21 -0
  85. data/sig/torque/postgresql/adapter/oid/ltree.rbs +32 -0
  86. data/sig/torque/postgresql/adapter/oid/range.rbs +28 -0
  87. data/sig/torque/postgresql/adapter/oid/segment.rbs +38 -0
  88. data/sig/torque/postgresql/adapter/oid/struct.rbs +41 -0
  89. data/sig/torque/postgresql/adapter/oid/struct_list.rbs +20 -0
  90. data/sig/torque/postgresql/adapter/oid/struct_set.rbs +18 -0
  91. data/sig/torque/postgresql/adapter/oid.rbs +9 -0
  92. data/sig/torque/postgresql/adapter/quoting.rbs +20 -0
  93. data/sig/torque/postgresql/adapter/schema_creation.rbs +13 -0
  94. data/sig/torque/postgresql/adapter/schema_definitions.rbs +55 -0
  95. data/sig/torque/postgresql/adapter/schema_dumper.rbs +37 -0
  96. data/sig/torque/postgresql/adapter/schema_overrides.rbs +46 -0
  97. data/sig/torque/postgresql/adapter/schema_statements.rbs +50 -0
  98. data/sig/torque/postgresql/adapter.rbs +24 -0
  99. data/sig/torque/postgresql/arel/infix_operation.rbs +79 -0
  100. data/sig/torque/postgresql/arel/join_source.rbs +19 -0
  101. data/sig/torque/postgresql/arel/nodes.rbs +83 -0
  102. data/sig/torque/postgresql/arel/operations.rbs +28 -0
  103. data/sig/torque/postgresql/arel/select_manager.rbs +15 -0
  104. data/sig/torque/postgresql/arel/visitors.rbs +30 -0
  105. data/sig/torque/postgresql/arel.rbs +6 -0
  106. data/sig/torque/postgresql/associations/association_scope.rbs +28 -0
  107. data/sig/torque/postgresql/associations/belongs_to_many_association.rbs +51 -0
  108. data/sig/torque/postgresql/associations/builder/belongs_to_many.rbs +24 -0
  109. data/sig/torque/postgresql/associations/builder/has_many.rbs +15 -0
  110. data/sig/torque/postgresql/associations/builder.rbs +8 -0
  111. data/sig/torque/postgresql/associations/foreign_association.rbs +16 -0
  112. data/sig/torque/postgresql/associations/join_dependency.rbs +16 -0
  113. data/sig/torque/postgresql/associations/preloader/association.rbs +32 -0
  114. data/sig/torque/postgresql/associations/preloader/loader_query.rbs +20 -0
  115. data/sig/torque/postgresql/associations/preloader.rbs +8 -0
  116. data/sig/torque/postgresql/associations.rbs +17 -0
  117. data/sig/torque/postgresql/attributes/base.rbs +37 -0
  118. data/sig/torque/postgresql/attributes/builder/enum.rbs +38 -0
  119. data/sig/torque/postgresql/attributes/builder/full_text_search.rbs +27 -0
  120. data/sig/torque/postgresql/attributes/builder/period.rbs +106 -0
  121. data/sig/torque/postgresql/attributes/builder.rbs +19 -0
  122. data/sig/torque/postgresql/attributes/composite.rbs +28 -0
  123. data/sig/torque/postgresql/attributes/enum.rbs +69 -0
  124. data/sig/torque/postgresql/attributes/enum_set.rbs +75 -0
  125. data/sig/torque/postgresql/attributes/full_text_search.rbs +13 -0
  126. data/sig/torque/postgresql/attributes/lazy.rbs +17 -0
  127. data/sig/torque/postgresql/attributes/lquery.rbs +48 -0
  128. data/sig/torque/postgresql/attributes/ltree.rbs +46 -0
  129. data/sig/torque/postgresql/attributes/period.rbs +13 -0
  130. data/sig/torque/postgresql/attributes/simple_enum.rbs +39 -0
  131. data/sig/torque/postgresql/attributes/struct.rbs +38 -0
  132. data/sig/torque/postgresql/attributes.rbs +6 -0
  133. data/sig/torque/postgresql/autosave_association.rbs +16 -0
  134. data/sig/torque/postgresql/auxiliary_statement/recursive.rbs +22 -0
  135. data/sig/torque/postgresql/auxiliary_statement/settings.rbs +63 -0
  136. data/sig/torque/postgresql/auxiliary_statement.rbs +57 -0
  137. data/sig/torque/postgresql/base.rbs +41 -0
  138. data/sig/torque/postgresql/collector.rbs +7 -0
  139. data/sig/torque/postgresql/config.rbs +9 -0
  140. data/sig/torque/postgresql/function.rbs +21 -0
  141. data/sig/torque/postgresql/geometry_builder.rbs +20 -0
  142. data/sig/torque/postgresql/i18n.rbs +15 -0
  143. data/sig/torque/postgresql/inheritance/expander.rbs +23 -0
  144. data/sig/torque/postgresql/inheritance/record.rbs +20 -0
  145. data/sig/torque/postgresql/inheritance.rbs +47 -0
  146. data/sig/torque/postgresql/insert_all.rbs +22 -0
  147. data/sig/torque/postgresql/migration/command_recorder.rbs +30 -0
  148. data/sig/torque/postgresql/migration.rbs +6 -0
  149. data/sig/torque/postgresql/predicate_builder/arel_attribute_handler.rbs +15 -0
  150. data/sig/torque/postgresql/predicate_builder/array_handler.rbs +26 -0
  151. data/sig/torque/postgresql/predicate_builder/composite_handler.rbs +26 -0
  152. data/sig/torque/postgresql/predicate_builder/enumerator_lazy_handler.rbs +18 -0
  153. data/sig/torque/postgresql/predicate_builder/ltree_handler.rbs +36 -0
  154. data/sig/torque/postgresql/predicate_builder/regexp_handler.rbs +16 -0
  155. data/sig/torque/postgresql/predicate_builder/struct_handler.rbs +24 -0
  156. data/sig/torque/postgresql/predicate_builder.rbs +23 -0
  157. data/sig/torque/postgresql/predicate_table.rbs +24 -0
  158. data/sig/torque/postgresql/railtie.rbs +10 -0
  159. data/sig/torque/postgresql/reflection/abstract_reflection.rbs +22 -0
  160. data/sig/torque/postgresql/reflection/association_reflection.rbs +18 -0
  161. data/sig/torque/postgresql/reflection/belongs_to_many_reflection.rbs +30 -0
  162. data/sig/torque/postgresql/reflection/has_many_reflection.rbs +14 -0
  163. data/sig/torque/postgresql/reflection/runtime_reflection.rbs +18 -0
  164. data/sig/torque/postgresql/reflection/through_reflection.rbs +14 -0
  165. data/sig/torque/postgresql/reflection.rbs +11 -0
  166. data/sig/torque/postgresql/relation/auxiliary_statement.rbs +29 -0
  167. data/sig/torque/postgresql/relation/buckets.rbs +35 -0
  168. data/sig/torque/postgresql/relation/cross_join.rbs +63 -0
  169. data/sig/torque/postgresql/relation/distinct_on.rbs +16 -0
  170. data/sig/torque/postgresql/relation/inheritance.rbs +48 -0
  171. data/sig/torque/postgresql/relation/join_series.rbs +28 -0
  172. data/sig/torque/postgresql/relation/merger.rbs +23 -0
  173. data/sig/torque/postgresql/relation/segments.rbs +27 -0
  174. data/sig/torque/postgresql/relation.rbs +48 -0
  175. data/sig/torque/postgresql/schema_cache/bound_schema_reflection.rbs +14 -0
  176. data/sig/torque/postgresql/schema_cache/inheritance.rbs +12 -0
  177. data/sig/torque/postgresql/schema_cache/schema_reflection.rbs +14 -0
  178. data/sig/torque/postgresql/schema_cache.rbs +43 -0
  179. data/sig/torque/postgresql/table_name.rbs +20 -0
  180. data/sig/torque/postgresql/validations.rbs +13 -0
  181. data/sig/torque/postgresql/version.rbs +5 -0
  182. data/sig/torque/postgresql/versioned_commands/command_migration.rbs +54 -0
  183. data/sig/torque/postgresql/versioned_commands/generator.rbs +17 -0
  184. data/sig/torque/postgresql/versioned_commands/migration_context.rbs +30 -0
  185. data/sig/torque/postgresql/versioned_commands/migrator.rbs +19 -0
  186. data/sig/torque/postgresql/versioned_commands/schema_table.rbs +23 -0
  187. data/sig/torque/postgresql/versioned_commands.rbs +25 -0
  188. data/sig/torque/postgresql.rbs +26 -0
  189. data/skills/torque-postgresql/SKILL.md +70 -0
  190. data/spec/fixtures/migrations/20250101000008_create_trigger_audit_users_v1.sql +3 -0
  191. data/spec/initialize.rb +29 -2
  192. data/spec/mocks/cache_query.rb +13 -1
  193. data/spec/models/author.rb +1 -1
  194. data/spec/models/comment.rb +2 -0
  195. data/spec/models/place.rb +2 -0
  196. data/spec/models/profile.rb +31 -0
  197. data/spec/rbs_helper.rb +8 -0
  198. data/spec/schema.rb +35 -4
  199. data/spec/spec_helper.rb +1 -0
  200. data/spec/tests/arel_spec.rb +3 -3
  201. data/spec/tests/auxiliary_statement_spec.rb +12 -0
  202. data/spec/tests/buckets_spec.rb +122 -0
  203. data/spec/tests/composite_spec.rb +867 -0
  204. data/spec/tests/cross_join_spec.rb +185 -0
  205. data/spec/tests/distinct_on_spec.rb +5 -0
  206. data/spec/tests/enum_set_spec.rb +4 -0
  207. data/spec/tests/enum_spec.rb +20 -4
  208. data/spec/tests/geometric_builder_spec.rb +16 -5
  209. data/spec/tests/has_many_spec.rb +2 -1
  210. data/spec/tests/interval_spec.rb +6 -0
  211. data/spec/tests/ltree_spec.rb +886 -0
  212. data/spec/tests/predicate_builder_spec.rb +120 -0
  213. data/spec/tests/relation_spec.rb +25 -69
  214. data/spec/tests/segments_spec.rb +264 -0
  215. data/spec/tests/struct_spec.rb +1170 -0
  216. data/spec/tests/table_inheritance_spec.rb +1037 -56
  217. data/spec/tests/versioned_commands_spec.rb +145 -0
  218. 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