torque-postgresql 4.1.0 → 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 (194) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +28 -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 +9 -0
  7. data/lib/torque/postgresql/adapter/oid/array.rb +0 -4
  8. data/lib/torque/postgresql/adapter/oid/composite.rb +2 -1
  9. data/lib/torque/postgresql/adapter/oid/enum.rb +1 -1
  10. data/lib/torque/postgresql/adapter/oid/enum_set.rb +2 -2
  11. data/lib/torque/postgresql/adapter/oid/interval.rb +1 -1
  12. data/lib/torque/postgresql/adapter/oid/lquery.rb +45 -28
  13. data/lib/torque/postgresql/adapter/oid/ltree.rb +67 -9
  14. data/lib/torque/postgresql/adapter/oid/struct.rb +1 -1
  15. data/lib/torque/postgresql/adapter/oid.rb +13 -0
  16. data/lib/torque/postgresql/adapter/schema_definitions.rb +5 -0
  17. data/lib/torque/postgresql/adapter/schema_dumper.rb +1 -0
  18. data/lib/torque/postgresql/arel/nodes.rb +51 -0
  19. data/lib/torque/postgresql/arel/visitors.rb +46 -0
  20. data/lib/torque/postgresql/attributes/base.rb +10 -7
  21. data/lib/torque/postgresql/attributes/composite.rb +1 -5
  22. data/lib/torque/postgresql/attributes/enum.rb +1 -6
  23. data/lib/torque/postgresql/attributes/lquery.rb +67 -127
  24. data/lib/torque/postgresql/attributes/ltree.rb +35 -83
  25. data/lib/torque/postgresql/base.rb +9 -1
  26. data/lib/torque/postgresql/config.rb +21 -3
  27. data/lib/torque/postgresql/geometry_builder.rb +4 -10
  28. data/lib/torque/postgresql/inheritance.rb +5 -5
  29. data/lib/torque/postgresql/predicate_builder/arel_attribute_handler.rb +1 -1
  30. data/lib/torque/postgresql/predicate_builder/array_handler.rb +16 -8
  31. data/lib/torque/postgresql/predicate_builder/composite_handler.rb +18 -5
  32. data/lib/torque/postgresql/predicate_builder/ltree_handler.rb +86 -18
  33. data/lib/torque/postgresql/predicate_builder/struct_handler.rb +29 -9
  34. data/lib/torque/postgresql/predicate_builder.rb +38 -11
  35. data/lib/torque/postgresql/railtie.rb +17 -0
  36. data/lib/torque/postgresql/relation/auxiliary_statement.rb +7 -3
  37. data/lib/torque/postgresql/relation/buckets.rb +2 -1
  38. data/lib/torque/postgresql/relation/cross_join.rb +193 -0
  39. data/lib/torque/postgresql/relation/inheritance.rb +1 -2
  40. data/lib/torque/postgresql/relation/join_series.rb +1 -0
  41. data/lib/torque/postgresql/relation/merger.rb +9 -0
  42. data/lib/torque/postgresql/relation/segments.rb +140 -0
  43. data/lib/torque/postgresql/relation.rb +23 -4
  44. data/lib/torque/postgresql/version.rb +1 -1
  45. data/lib/torque/postgresql/versioned_commands/command_migration.rb +23 -0
  46. data/lib/torque/postgresql/versioned_commands.rb +39 -0
  47. data/lib/torque/postgresql.rb +0 -2
  48. data/sig/generators/torque/function_generator.rbs +9 -0
  49. data/sig/generators/torque/skill_generator.rbs +7 -0
  50. data/sig/generators/torque/trigger_generator.rbs +9 -0
  51. data/sig/generators/torque/type_generator.rbs +9 -0
  52. data/sig/generators/torque/view_generator.rbs +9 -0
  53. data/sig/manifest.yaml +7 -0
  54. data/sig/torque/postgresql/adapter/database_statements.rbs +42 -0
  55. data/sig/torque/postgresql/adapter/inheritance_statements.rbs +36 -0
  56. data/sig/torque/postgresql/adapter/oid/array.rbs +20 -0
  57. data/sig/torque/postgresql/adapter/oid/box.rbs +28 -0
  58. data/sig/torque/postgresql/adapter/oid/circle.rbs +33 -0
  59. data/sig/torque/postgresql/adapter/oid/composite.rbs +35 -0
  60. data/sig/torque/postgresql/adapter/oid/enum.rbs +28 -0
  61. data/sig/torque/postgresql/adapter/oid/enum_set.rbs +19 -0
  62. data/sig/torque/postgresql/adapter/oid/interval.rbs +20 -0
  63. data/sig/torque/postgresql/adapter/oid/line.rbs +33 -0
  64. data/sig/torque/postgresql/adapter/oid/lquery.rbs +21 -0
  65. data/sig/torque/postgresql/adapter/oid/ltree.rbs +32 -0
  66. data/sig/torque/postgresql/adapter/oid/range.rbs +28 -0
  67. data/sig/torque/postgresql/adapter/oid/segment.rbs +38 -0
  68. data/sig/torque/postgresql/adapter/oid/struct.rbs +41 -0
  69. data/sig/torque/postgresql/adapter/oid/struct_list.rbs +20 -0
  70. data/sig/torque/postgresql/adapter/oid/struct_set.rbs +18 -0
  71. data/sig/torque/postgresql/adapter/oid.rbs +9 -0
  72. data/sig/torque/postgresql/adapter/quoting.rbs +20 -0
  73. data/sig/torque/postgresql/adapter/schema_creation.rbs +13 -0
  74. data/sig/torque/postgresql/adapter/schema_definitions.rbs +55 -0
  75. data/sig/torque/postgresql/adapter/schema_dumper.rbs +37 -0
  76. data/sig/torque/postgresql/adapter/schema_overrides.rbs +46 -0
  77. data/sig/torque/postgresql/adapter/schema_statements.rbs +50 -0
  78. data/sig/torque/postgresql/adapter.rbs +24 -0
  79. data/sig/torque/postgresql/arel/infix_operation.rbs +79 -0
  80. data/sig/torque/postgresql/arel/join_source.rbs +19 -0
  81. data/sig/torque/postgresql/arel/nodes.rbs +83 -0
  82. data/sig/torque/postgresql/arel/operations.rbs +28 -0
  83. data/sig/torque/postgresql/arel/select_manager.rbs +15 -0
  84. data/sig/torque/postgresql/arel/visitors.rbs +30 -0
  85. data/sig/torque/postgresql/arel.rbs +6 -0
  86. data/sig/torque/postgresql/associations/association_scope.rbs +28 -0
  87. data/sig/torque/postgresql/associations/belongs_to_many_association.rbs +51 -0
  88. data/sig/torque/postgresql/associations/builder/belongs_to_many.rbs +24 -0
  89. data/sig/torque/postgresql/associations/builder/has_many.rbs +15 -0
  90. data/sig/torque/postgresql/associations/builder.rbs +8 -0
  91. data/sig/torque/postgresql/associations/foreign_association.rbs +16 -0
  92. data/sig/torque/postgresql/associations/join_dependency.rbs +16 -0
  93. data/sig/torque/postgresql/associations/preloader/association.rbs +32 -0
  94. data/sig/torque/postgresql/associations/preloader/loader_query.rbs +20 -0
  95. data/sig/torque/postgresql/associations/preloader.rbs +8 -0
  96. data/sig/torque/postgresql/associations.rbs +17 -0
  97. data/sig/torque/postgresql/attributes/base.rbs +37 -0
  98. data/sig/torque/postgresql/attributes/builder/enum.rbs +38 -0
  99. data/sig/torque/postgresql/attributes/builder/full_text_search.rbs +27 -0
  100. data/sig/torque/postgresql/attributes/builder/period.rbs +106 -0
  101. data/sig/torque/postgresql/attributes/builder.rbs +19 -0
  102. data/sig/torque/postgresql/attributes/composite.rbs +28 -0
  103. data/sig/torque/postgresql/attributes/enum.rbs +69 -0
  104. data/sig/torque/postgresql/attributes/enum_set.rbs +75 -0
  105. data/sig/torque/postgresql/attributes/full_text_search.rbs +13 -0
  106. data/sig/torque/postgresql/attributes/lazy.rbs +17 -0
  107. data/sig/torque/postgresql/attributes/lquery.rbs +48 -0
  108. data/sig/torque/postgresql/attributes/ltree.rbs +46 -0
  109. data/sig/torque/postgresql/attributes/period.rbs +13 -0
  110. data/sig/torque/postgresql/attributes/simple_enum.rbs +39 -0
  111. data/sig/torque/postgresql/attributes/struct.rbs +38 -0
  112. data/sig/torque/postgresql/attributes.rbs +6 -0
  113. data/sig/torque/postgresql/autosave_association.rbs +16 -0
  114. data/sig/torque/postgresql/auxiliary_statement/recursive.rbs +22 -0
  115. data/sig/torque/postgresql/auxiliary_statement/settings.rbs +63 -0
  116. data/sig/torque/postgresql/auxiliary_statement.rbs +57 -0
  117. data/sig/torque/postgresql/base.rbs +41 -0
  118. data/sig/torque/postgresql/collector.rbs +7 -0
  119. data/sig/torque/postgresql/config.rbs +9 -0
  120. data/sig/torque/postgresql/function.rbs +21 -0
  121. data/sig/torque/postgresql/geometry_builder.rbs +20 -0
  122. data/sig/torque/postgresql/i18n.rbs +15 -0
  123. data/sig/torque/postgresql/inheritance/expander.rbs +23 -0
  124. data/sig/torque/postgresql/inheritance/record.rbs +20 -0
  125. data/sig/torque/postgresql/inheritance.rbs +47 -0
  126. data/sig/torque/postgresql/insert_all.rbs +22 -0
  127. data/sig/torque/postgresql/migration/command_recorder.rbs +30 -0
  128. data/sig/torque/postgresql/migration.rbs +6 -0
  129. data/sig/torque/postgresql/predicate_builder/arel_attribute_handler.rbs +15 -0
  130. data/sig/torque/postgresql/predicate_builder/array_handler.rbs +26 -0
  131. data/sig/torque/postgresql/predicate_builder/composite_handler.rbs +26 -0
  132. data/sig/torque/postgresql/predicate_builder/enumerator_lazy_handler.rbs +18 -0
  133. data/sig/torque/postgresql/predicate_builder/ltree_handler.rbs +36 -0
  134. data/sig/torque/postgresql/predicate_builder/regexp_handler.rbs +16 -0
  135. data/sig/torque/postgresql/predicate_builder/struct_handler.rbs +24 -0
  136. data/sig/torque/postgresql/predicate_builder.rbs +23 -0
  137. data/sig/torque/postgresql/predicate_table.rbs +24 -0
  138. data/sig/torque/postgresql/railtie.rbs +10 -0
  139. data/sig/torque/postgresql/reflection/abstract_reflection.rbs +22 -0
  140. data/sig/torque/postgresql/reflection/association_reflection.rbs +18 -0
  141. data/sig/torque/postgresql/reflection/belongs_to_many_reflection.rbs +30 -0
  142. data/sig/torque/postgresql/reflection/has_many_reflection.rbs +14 -0
  143. data/sig/torque/postgresql/reflection/runtime_reflection.rbs +18 -0
  144. data/sig/torque/postgresql/reflection/through_reflection.rbs +14 -0
  145. data/sig/torque/postgresql/reflection.rbs +11 -0
  146. data/sig/torque/postgresql/relation/auxiliary_statement.rbs +29 -0
  147. data/sig/torque/postgresql/relation/buckets.rbs +35 -0
  148. data/sig/torque/postgresql/relation/cross_join.rbs +63 -0
  149. data/sig/torque/postgresql/relation/distinct_on.rbs +16 -0
  150. data/sig/torque/postgresql/relation/inheritance.rbs +48 -0
  151. data/sig/torque/postgresql/relation/join_series.rbs +28 -0
  152. data/sig/torque/postgresql/relation/merger.rbs +23 -0
  153. data/sig/torque/postgresql/relation/segments.rbs +27 -0
  154. data/sig/torque/postgresql/relation.rbs +48 -0
  155. data/sig/torque/postgresql/schema_cache/bound_schema_reflection.rbs +14 -0
  156. data/sig/torque/postgresql/schema_cache/inheritance.rbs +12 -0
  157. data/sig/torque/postgresql/schema_cache/schema_reflection.rbs +14 -0
  158. data/sig/torque/postgresql/schema_cache.rbs +43 -0
  159. data/sig/torque/postgresql/table_name.rbs +20 -0
  160. data/sig/torque/postgresql/validations.rbs +13 -0
  161. data/sig/torque/postgresql/version.rbs +5 -0
  162. data/sig/torque/postgresql/versioned_commands/command_migration.rbs +54 -0
  163. data/sig/torque/postgresql/versioned_commands/generator.rbs +17 -0
  164. data/sig/torque/postgresql/versioned_commands/migration_context.rbs +30 -0
  165. data/sig/torque/postgresql/versioned_commands/migrator.rbs +19 -0
  166. data/sig/torque/postgresql/versioned_commands/schema_table.rbs +23 -0
  167. data/sig/torque/postgresql/versioned_commands.rbs +25 -0
  168. data/sig/torque/postgresql.rbs +26 -0
  169. data/skills/torque-postgresql/SKILL.md +70 -0
  170. data/spec/fixtures/migrations/20250101000008_create_trigger_audit_users_v1.sql +3 -0
  171. data/spec/initialize.rb +9 -0
  172. data/spec/mocks/cache_query.rb +1 -1
  173. data/spec/rbs_helper.rb +8 -0
  174. data/spec/schema.rb +2 -0
  175. data/spec/spec_helper.rb +1 -0
  176. data/spec/tests/arel_spec.rb +1 -3
  177. data/spec/tests/auxiliary_statement_spec.rb +12 -0
  178. data/spec/tests/buckets_spec.rb +122 -0
  179. data/spec/tests/composite_spec.rb +67 -0
  180. data/spec/tests/cross_join_spec.rb +185 -0
  181. data/spec/tests/distinct_on_spec.rb +5 -0
  182. data/spec/tests/enum_set_spec.rb +4 -0
  183. data/spec/tests/enum_spec.rb +20 -4
  184. data/spec/tests/geometric_builder_spec.rb +16 -5
  185. data/spec/tests/has_many_spec.rb +2 -1
  186. data/spec/tests/interval_spec.rb +6 -0
  187. data/spec/tests/ltree_spec.rb +398 -64
  188. data/spec/tests/predicate_builder_spec.rb +120 -0
  189. data/spec/tests/relation_spec.rb +25 -69
  190. data/spec/tests/segments_spec.rb +264 -0
  191. data/spec/tests/struct_spec.rb +202 -0
  192. data/spec/tests/table_inheritance_spec.rb +10 -0
  193. data/spec/tests/versioned_commands_spec.rb +145 -0
  194. metadata +153 -2
@@ -88,6 +88,34 @@ RSpec.describe 'PredicateBuilder' do
88
88
  expect(sql).to include("WHERE CARDINALITY(\"items\".\"tag_ids\") = 0")
89
89
  end
90
90
 
91
+ it 'keeps nil as a null check' do
92
+ sql = subject.where(tag_ids: nil).to_sql
93
+ expect(sql).to include("WHERE \"items\".\"tag_ids\" IS NULL")
94
+
95
+ sql = subject.where.not(tag_ids: nil).to_sql
96
+ expect(sql).to include("WHERE \"items\".\"tag_ids\" IS NOT NULL")
97
+ end
98
+
99
+ it 'keeps a nil among the values as a null check' do
100
+ sql = subject.where(tag_ids: [1, nil]).to_sql
101
+ expect(sql).to include("WHERE (\"items\".\"tag_ids\" && '{1}' OR \"items\".\"tag_ids\" IS NULL)")
102
+ end
103
+
104
+ it 'resolves records to their ids' do
105
+ tag = Tag.create!(name: 'A')
106
+
107
+ sql = subject.where(tag_ids: tag).to_sql
108
+ expect(sql).to include("WHERE #{tag.id} = ANY(\"items\".\"tag_ids\")")
109
+
110
+ sql = subject.where(tag_ids: [tag]).to_sql
111
+ expect(sql).to include("WHERE \"items\".\"tag_ids\" && '{#{tag.id}}'")
112
+ end
113
+
114
+ it 'accepts a set of values' do
115
+ sql = subject.where(tag_ids: Set[1, 2]).to_sql
116
+ expect(sql).to include("WHERE \"items\".\"tag_ids\" && '{1,2}'")
117
+ end
118
+
91
119
  it 'properly binds the provided values' do
92
120
  sql, binds = get_query_with_binds { subject.where(tag_ids: 1).load }
93
121
  expect(sql).to include("WHERE $1 = ANY(\"items\".\"tag_ids\")")
@@ -129,4 +157,96 @@ RSpec.describe 'PredicateBuilder' do
129
157
  expect(binds.first.value).to eq('(a|b)')
130
158
  end
131
159
  end
160
+
161
+ describe 'on normalized attributes' do
162
+ before { Torque::PostgreSQL.config.predicate_builder.handle_array_attributes = true }
163
+ after { Torque::PostgreSQL.config.predicate_builder.handle_array_attributes = false }
164
+
165
+ let(:item_klass) do
166
+ Class.new(Item) do
167
+ normalizes :tag_ids, with: ->(value) { value.is_a?(::Array) ? value.uniq : value }
168
+ end
169
+ end
170
+
171
+ let(:place_klass) do
172
+ Class.new(Place) do
173
+ normalizes :home, with: ->(value) { value }
174
+ normalizes :offices, with: ->(value) { value }
175
+ end
176
+ end
177
+
178
+ let(:profile_klass) do
179
+ Class.new(Profile) do
180
+ normalizes :settings, with: ->(value) { value }
181
+ end
182
+ end
183
+
184
+ let(:category_klass) do
185
+ Class.new(Category) do
186
+ normalizes :path, with: ->(value) { value }
187
+ normalizes :patterns, with: ->(value) { value }
188
+ end
189
+ end
190
+
191
+ it 'still sees an array column as an array' do
192
+ sql = item_klass.where(tag_ids: 1).to_sql
193
+ expect(sql).to include(%[WHERE 1 = ANY("items"."tag_ids")])
194
+
195
+ sql = item_klass.where(tag_ids: [1, 2]).to_sql
196
+ expect(sql).to include(%[WHERE "items"."tag_ids" && '{1,2}'])
197
+
198
+ sql = item_klass.where(tag_ids: []).to_sql
199
+ expect(sql).to include(%[WHERE CARDINALITY("items"."tag_ids") = 0])
200
+ end
201
+
202
+ it 'applies the normalization to the values of an array condition' do
203
+ sql = item_klass.where(tag_ids: [1, 1, 2]).to_sql
204
+ expect(sql).to include(%[WHERE "items"."tag_ids" && '{1,2}'])
205
+ end
206
+
207
+ it 'still sees an array column on either side of an arel attribute' do
208
+ sql = item_klass.where(tag_ids: Item.arel_table[:id]).to_sql
209
+ expect(sql).to include(%[WHERE "items"."id" = ANY("items"."tag_ids")])
210
+
211
+ sql = Item.where(id: item_klass.arel_table[:tag_ids]).to_sql
212
+ expect(sql).to include(%[WHERE "items"."id" = ANY("items"."tag_ids")])
213
+ end
214
+
215
+ it 'still breaks a hash into conditions over a composite column' do
216
+ sql = place_klass.where(home: { street: 'Main' }).to_sql
217
+ expect(sql).to include(%[(("places"."home")."street" = 'Main')])
218
+
219
+ sql = place_klass.where(offices: { street: 'A' }).to_sql
220
+ expect(sql).to include(%[FROM UNNEST("places"."offices") "address"])
221
+ end
222
+
223
+ it 'still casts a whole composite value to its type' do
224
+ sql = place_klass.where(home: Composite::Address.new(street: 'Main')).to_sql
225
+ expect(sql).to include(%[WHERE "places"."home" = '("Main",,,)'::address])
226
+ end
227
+
228
+ it 'still breaks a hash into conditions over a struct column' do
229
+ sql = profile_klass.where(settings: { theme: 'dark' }).to_sql
230
+ expect(sql).to include(%[("profiles"."settings" #>> ARRAY['theme']) = 'dark'])
231
+ end
232
+
233
+ it 'still tells a pattern from a path on an ltree column' do
234
+ sql = category_klass.where(path: ['Top', :any]).to_sql
235
+ expect(sql).to include(%[WHERE "categories"."path" ~ 'Top.*'::lquery])
236
+
237
+ sql = category_klass.where(path: %w[Top Science]).to_sql
238
+ expect(sql).to include(%[WHERE "categories"."path" = 'Top.Science'])
239
+
240
+ sql = category_klass.where(patterns: 'Top.Science').to_sql
241
+ expect(sql).to include(%[WHERE 'Top.Science'::ltree ~ ANY("categories"."patterns")])
242
+ end
243
+
244
+ it 'still resolves the parts of a column on order' do
245
+ sql = place_klass.order(home: { street: :asc }).to_sql
246
+ expect(sql).to include(%[ORDER BY ("places"."home")."street" ASC])
247
+
248
+ sql = profile_klass.order(settings: { theme: :asc }).to_sql
249
+ expect(sql).to include(%[ORDER BY ("profiles"."settings" #>> ARRAY['theme']) ASC])
250
+ end
251
+ end
132
252
  end
@@ -43,6 +43,20 @@ RSpec.describe 'Relation', type: :helper do
43
43
  expect(subject.call(check)).to be_attributes_as(result)
44
44
  end
45
45
 
46
+ it 'resolves a struct column into its properties' do
47
+ Profile.create!(name: 'a', settings: { theme: 'dark' })
48
+ Profile.create!(name: 'b', settings: { theme: 'light' })
49
+
50
+ expect(Profile.unscoped.resolve_column([settings: :theme]).first)
51
+ .to be_a(Torque::PostgreSQL::Arel::Nodes::Property)
52
+ expect(Profile.maximum(settings: :theme)).to be_eql('light')
53
+ end
54
+
55
+ it 'resolves a composite column into its columns' do
56
+ expect(Place.unscoped.resolve_column([home: :street]).first)
57
+ .to be_a(Torque::PostgreSQL::Arel::Nodes::Column)
58
+ end
59
+
46
60
  it 'raises on relation not present' do
47
61
  check = [supervisors: :name]
48
62
  expect{ subject.call(check) }.to raise_error(ArgumentError, /Relation for/)
@@ -71,6 +85,13 @@ RSpec.describe 'Relation', type: :helper do
71
85
  expect(source.join_series(1..10, with: :id).to_sql).to eq(sql)
72
86
  end
73
87
 
88
+ it 'accepts a part of a column' do
89
+ sql = 'SELECT "places".* FROM "places"'
90
+ sql += ' INNER JOIN GENERATE_SERIES(1::integer, 10::integer)'
91
+ sql += ' AS series ON "series" = ("places"."home")."number"'
92
+ expect(Place.join_series(1..10, with: { home: :number }).to_sql).to eq(sql)
93
+ end
94
+
74
95
  it 'can be renamed' do
75
96
  sql = 'SELECT "videos".* FROM "videos"'
76
97
  sql += ' INNER JOIN GENERATE_SERIES(1::integer, 10::integer)'
@@ -212,75 +233,10 @@ RSpec.describe 'Relation', type: :helper do
212
233
  end
213
234
  end
214
235
 
215
- context 'on buckets' do
216
- let(:source) { User.all }
217
-
218
- it 'produces the right query' do
219
- query = source.buckets(:age, 0..50, count: 5)
220
- sql, binds = get_query_with_binds { query.load }
221
-
222
- expect(sql).to include(<<~SQL.squish)
223
- WIDTH_BUCKET("users"."age", $1::numeric, $2::numeric, $3::integer) AS bucket
224
- SQL
225
- expect(binds.map(&:value)).to eq([0, 50, 5])
226
- end
227
-
228
- it 'can query records by buckets' do
229
- list = [create(:user, age: 5), create(:user, age: 5), create(:user, age: 15)]
230
- query = source.buckets(:age, 0..50, count: 5).records
231
-
232
- expect(query).to be_a(Hash)
233
- expect(query.keys).to match_array([0...10, 10...20])
234
- expect(query[0...10]).to match_array([list[0], list[1]])
235
- expect(query[10...20]).to match_array([list[2]])
236
- end
237
-
238
- it 'can query buckets of roles' do
239
- list = [create(:user, role: :visitor)]
240
- list << create(:user, role: :assistant)
241
- list << create(:user, role: :manager)
242
- query = source.buckets(:role, %w[assistant manager], cast: :roles).records
243
-
244
- expect(query).to be_a(Hash)
245
- expect(query.keys).to match_array([nil, 'assistant', 'manager'])
246
- expect(query[nil]).to eq([list[0]])
247
- expect(query['assistant']).to eq([list[1]])
248
- expect(query['manager']).to eq([list[2]])
249
- end
250
-
251
- it 'works with calculations' do
252
- list = [create(:user, age: 5), create(:user, age: 5), create(:user, age: 15)]
253
- query = source.buckets(:age, 0..50, count: 5).count
254
-
255
- expect(query).to be_a(Hash)
256
- expect(query.keys).to match_array([0...10, 10...20])
257
- expect(query[0...10]).to eq(2)
258
- expect(query[10...20]).to eq(1)
259
- end
260
-
261
- it 'works with other types of calculations' do
262
- list = [create(:user, age: 5), create(:user, age: 5), create(:user, age: 15)]
263
- query = source.buckets(:age, 0..50, count: 5).sum(:age)
264
-
265
- expect(query).to be_a(Hash)
266
- expect(query.keys).to match_array([0...10, 10...20])
267
- expect(query[0...10]).to eq(10)
268
- expect(query[10...20]).to eq(15)
269
- end
270
-
271
- it 'work with joins and merge' do
272
- list = [create(:user, age: 5), create(:user, age: 5), create(:user, age: 15)]
273
- records = [create(:comment, user: list[0], content: 'Hello')]
274
- records << create(:comment, user: list[1], content: 'World')
275
- records << create(:comment, user: list[2], content: 'Test')
276
-
277
- query = Comment.joins(:user).merge(source.buckets(:age, 0..50, count: 5)).records
278
-
279
- expect(query).to be_a(Hash)
280
- expect(query.keys).to match_array([0...10, 10...20])
281
- expect(query[0...10]).to match_array([records[0], records[1]])
282
- expect(query[10...20]).to match_array([records[2]])
236
+ context 'on connection' do
237
+ it 'casts a condition value without a permanent connection' do
238
+ expect(ActiveRecord::Base).not_to receive(:connection)
239
+ expect(User.all.cast_for_condition(:name, 'Rick')).to be_eql('Rick')
283
240
  end
284
241
  end
285
-
286
242
  end
@@ -0,0 +1,264 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe 'Segments', type: :helper do
4
+ let(:source) { User.all }
5
+ let(:ages) { { adults: { age: 18.. }, minors: { age: ...18 } } }
6
+
7
+ context 'on relation' do
8
+ it 'produces one filtered aggregate per segment' do
9
+ sql, binds = get_query_with_binds { source.segments(**ages).count }
10
+
11
+ expect(sql).to include(<<~SQL.squish)
12
+ SELECT COUNT(*) FILTER (WHERE "users"."age" >= $1) AS "adults",
13
+ COUNT(*) FILTER (WHERE "users"."age" < $2) AS "minors" FROM "users"
14
+ SQL
15
+ expect(binds.map(&:value)).to eq([18, 18])
16
+ end
17
+
18
+ it 'counts each segment' do
19
+ create(:user, age: 5)
20
+ create(:user, age: 15)
21
+ create(:user, age: 25)
22
+
23
+ expect(source.segments(**ages).count).to eq(adults: 1, minors: 2)
24
+ end
25
+
26
+ it 'leaves the aggregate unfiltered for nil and blank conditions' do
27
+ create(:user, age: 5)
28
+ create(:user, age: 25)
29
+
30
+ query = source.segments(all: nil, none: {}, empty: '', list: [], adults: { age: 18.. })
31
+ sql, = get_query_with_binds { query.count }
32
+
33
+ expect(sql).to include(<<~SQL.squish)
34
+ SELECT COUNT(*) AS "all", COUNT(*) AS "none", COUNT(*) AS "empty", COUNT(*) AS "list",
35
+ COUNT(*) FILTER (WHERE "users"."age" >= $1) AS "adults"
36
+ SQL
37
+ expect(query.count).to eq(all: 2, none: 2, empty: 2, list: 2, adults: 1)
38
+ end
39
+
40
+ it 'applies the filters to other aggregates' do
41
+ create(:user, age: 5)
42
+ create(:user, age: 15)
43
+ create(:user, age: 25)
44
+
45
+ query = source.segments(**ages)
46
+ sql, = get_query_with_binds { query.sum(:age) }
47
+
48
+ expect(sql).to include('SUM("users"."age") FILTER (WHERE "users"."age" >= $1) AS "adults"')
49
+ expect(query.sum(:age)).to eq(adults: 25, minors: 20)
50
+ expect(query.average(:age)).to eq(adults: 25, minors: 10)
51
+ expect(query.maximum(:age)).to eq(adults: 25, minors: 15)
52
+ end
53
+
54
+ it 'returns zero for sums over empty segments' do
55
+ create(:user, age: 5)
56
+
57
+ expect(source.segments(**ages).sum(:age)).to eq(adults: 0, minors: 5)
58
+ end
59
+
60
+ it 'drops the order when there is no grouping' do
61
+ sql, = get_query_with_binds { source.order(:name).segments(**ages).count }
62
+ expect(sql).not_to include('ORDER BY')
63
+ end
64
+
65
+ it 'does not change how records are loaded' do
66
+ user = create(:user, age: 5)
67
+ expect(source.segments(**ages).to_a).to eq([user])
68
+ end
69
+
70
+ it 'is available on the model class' do
71
+ expect(User.segments(**ages)).to be_a(ActiveRecord::Relation)
72
+ end
73
+ end
74
+
75
+ context 'on grouping' do
76
+ it 'nests the segments under each group key' do
77
+ create(:user, age: 5, role: :visitor)
78
+ create(:user, age: 25, role: :visitor)
79
+ create(:user, age: 25, role: :admin)
80
+
81
+ result = source.group(:role).segments(**ages).count
82
+ expect(result.keys).to match_array(source.group(:role).count.keys)
83
+ expect(result['visitor']).to eq(adults: 1, minors: 1)
84
+ expect(result['admin']).to eq(adults: 1, minors: 0)
85
+ end
86
+
87
+ it 'uses array keys for multiple groups' do
88
+ create(:user, age: 5, role: :visitor, name: 'a')
89
+ create(:user, age: 25, role: :visitor, name: 'a')
90
+
91
+ result = source.group(:role, :name).segments(**ages).count
92
+ expect(result).to eq(['visitor', 'a'] => { adults: 1, minors: 1 })
93
+ end
94
+
95
+ it 'accepts raw expressions as groups' do
96
+ create(:user, age: 5)
97
+ create(:user, age: 25)
98
+
99
+ result = source.group('"users"."age" >= 18').segments(**ages).count
100
+ expect(result).to eq(false => { adults: 0, minors: 1 }, true => { adults: 1, minors: 0 })
101
+ end
102
+
103
+ it 'composes with buckets' do
104
+ create(:user, age: 5, role: :visitor)
105
+ create(:user, age: 15, role: :admin)
106
+ create(:user, age: 25, role: :admin)
107
+
108
+ query = source.buckets(:age, 0..50, count: 5).segments(admins: { role: :admin }, visitors: { role: :visitor })
109
+ expect(query.count).to eq(
110
+ 0...10 => { admins: 0, visitors: 1 },
111
+ 10...20 => { admins: 1, visitors: 0 },
112
+ 20...30 => { admins: 1, visitors: 0 },
113
+ )
114
+ end
115
+ end
116
+
117
+ context 'on conditions' do
118
+ let(:source) { Post.all }
119
+
120
+ it 'accepts where arguments' do
121
+ query = source.segments(text: 'title IS NOT NULL', bound: [['title = ?', 'a']])
122
+ sql, binds = get_query_with_binds { query.count }
123
+
124
+ expect(sql).to include('FILTER (WHERE (title IS NOT NULL)) AS "text"')
125
+ expect(sql).to include('FILTER (WHERE (title = $1)) AS "bound"')
126
+ expect(binds).to eq(['a'])
127
+ end
128
+
129
+ it 'applies each item of an array in order' do
130
+ condition = [:test_scope, { status: :draft }, -> { where.not(title: nil) }, Post.where(author_id: 1)]
131
+ sql, = get_query_with_binds { source.segments(mixed: condition).count }
132
+
133
+ expect(sql).to include(<<~SQL.squish)
134
+ FILTER (WHERE (1=1) AND "posts"."status" = $1 AND "posts"."title" IS NOT NULL
135
+ AND "posts"."author_id" = $2) AS "mixed"
136
+ SQL
137
+ end
138
+
139
+ it 'skips blank items of an array' do
140
+ sql, = get_query_with_binds { source.segments(drafts: [nil, {}, { status: :draft }]).count }
141
+ expect(sql).to include(%{FILTER (WHERE "posts"."status" = $1) AS "drafts"})
142
+ end
143
+
144
+ it 'accepts a scope name' do
145
+ sql, = get_query_with_binds { source.segments(tested: :test_scope).count }
146
+ expect(sql).to include('FILTER (WHERE (1=1)) AS "tested"')
147
+ end
148
+
149
+ it 'accepts a relation' do
150
+ sql, = get_query_with_binds { source.segments(drafts: Post.where(status: :draft)).count }
151
+ expect(sql).to include(%{FILTER (WHERE "posts"."status" = $1) AS "drafts"})
152
+ end
153
+
154
+ it 'accepts a proc evaluated on the model' do
155
+ sql, = get_query_with_binds { source.segments(drafts: -> { where(status: :draft) }).count }
156
+ expect(sql).to include(%{FILTER (WHERE "posts"."status" = $1) AS "drafts"})
157
+ end
158
+
159
+ it 'accepts a proc receiving the model relation' do
160
+ sql, = get_query_with_binds { source.segments(drafts: ->(posts) { posts.where(status: :draft) }).count }
161
+ expect(sql).to include(%{FILTER (WHERE "posts"."status" = $1) AS "drafts"})
162
+ end
163
+
164
+ it 'accepts an arel predicate' do
165
+ sql, = get_query_with_binds { source.segments(titled: Post.arel_table[:title].not_eq(nil)).count }
166
+ expect(sql).to include(%{FILTER (WHERE "posts"."title" IS NOT NULL) AS "titled"})
167
+ end
168
+
169
+ it 'raises when a condition does not filter anything' do
170
+ expect { source.segments(all: Post.all) }.to raise_error(ArgumentError, /does not filter/)
171
+ expect { source.segments(all: -> { order(:title) }) }.to raise_error(ArgumentError, /does not filter/)
172
+ expect { source.segments(all: [:test_scope, nil]) }.not_to raise_error
173
+ end
174
+ end
175
+
176
+ context 'on sanity' do
177
+ before do
178
+ create(:user, age: 5, role: :visitor)
179
+ create(:user, age: 25, role: :visitor)
180
+ create(:user, age: 25, role: :admin)
181
+ end
182
+
183
+ it 'keeps the conditions of the query' do
184
+ query = source.where(role: :visitor).segments(**ages)
185
+ sql, = get_query_with_binds { query.count }
186
+
187
+ expect(sql).to include(%{FROM "users" WHERE "users"."role" = $3})
188
+ expect(query.count).to eq(adults: 1, minors: 1)
189
+ expect(query.where(age: 25).count).to eq(adults: 1, minors: 0)
190
+ end
191
+
192
+ it 'keeps joins and accepts conditions on joined tables' do
193
+ create(:comment, user: User.find_by(age: 5))
194
+ create(:comment, user: User.find_by(role: :admin))
195
+
196
+ query = Comment.joins(:user).segments(adults: { users: { age: 18.. } }, minors: { users: { age: ...18 } })
197
+ sql, = get_query_with_binds { query.count }
198
+
199
+ expect(sql).to include(%{INNER JOIN "users" ON "users"."id" = "comments"."user_id"})
200
+ expect(query.count).to eq(adults: 1, minors: 1)
201
+ end
202
+
203
+ it 'goes through eager loading' do
204
+ create(:comment, user: User.find_by(age: 5))
205
+ create(:comment, user: User.find_by(role: :admin))
206
+
207
+ query = Comment.includes(:user).where(users: { role: :visitor })
208
+ query = query.segments(adults: { users: { age: 18.. } }, minors: { users: { age: ...18 } })
209
+ sql, = get_query_with_binds { query.count }
210
+
211
+ expect(sql).to include('LEFT OUTER JOIN "users"')
212
+ expect(query.count).to eq(adults: 0, minors: 1)
213
+ end
214
+
215
+ it 'counts a column and distinct values' do
216
+ query = source.segments(**ages)
217
+
218
+ sql, = get_query_with_binds { query.count(:name) }
219
+ expect(sql).to include('COUNT("users"."name") FILTER')
220
+
221
+ sql, = get_query_with_binds { query.distinct.count }
222
+ expect(sql).to include('COUNT(DISTINCT "users"."id") FILTER')
223
+ expect(query.distinct.count).to eq(adults: 2, minors: 1)
224
+ end
225
+
226
+ it 'works asynchronously' do
227
+ expect(source.segments(**ages).async_count.value).to eq(adults: 2, minors: 1)
228
+ end
229
+
230
+ it 'does not affect operations other than calculations' do
231
+ query = source.segments(**ages)
232
+
233
+ expect(query.to_sql).to eq('SELECT "users".* FROM "users"')
234
+ expect(query.size).to eq(adults: 2, minors: 1)
235
+ expect(query.exists?).to be(true)
236
+ expect(query.pluck(:age)).to match_array([5, 25, 25])
237
+ expect(query.order(:age).first.age).to eq(5)
238
+ expect(query.update_all(name: 'x')).to eq(3)
239
+ expect(query.where(age: 5).delete_all).to eq(1)
240
+ end
241
+
242
+ it 'does not affect calculations without segments' do
243
+ expect(source.count).to eq(3)
244
+ expect(source.group(:role).count).to eq('visitor' => 2, 'admin' => 1)
245
+ end
246
+ end
247
+
248
+ context 'on composing' do
249
+ it 'merges chained calls' do
250
+ query = source.segments(adults: ages[:adults]).segments(minors: ages[:minors])
251
+ expect(query.segments_value.keys).to eq(%i[adults minors])
252
+ end
253
+
254
+ it 'merges segments from another relation' do
255
+ query = source.segments(adults: ages[:adults]).merge(User.segments(minors: ages[:minors]))
256
+ expect(query.segments_value.keys).to eq(%i[adults minors])
257
+ end
258
+
259
+ it 'can be unscoped' do
260
+ create(:user, age: 5)
261
+ expect(source.segments(**ages).unscope(:segments).count).to eq(1)
262
+ end
263
+ end
264
+ end