batchagg 0.1.0 → 0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2b6918b95a0a1d2b8989a596c2f1bcdc5652bf82e8f20fa39ff247ed3d61f52e
4
- data.tar.gz: 58df5d2bac16759d9803855dcfaab1c41c952887b0a00a4058bf5ce83af4b6be
3
+ metadata.gz: 7974966a61d4458721e6c61956c9dda9554a2e4e8d05240e8cb3ef6599a5f16c
4
+ data.tar.gz: e6da51c30121042ecaf42b9862dd910c2fce26db88e5e9b8a54f56faeefa0b2d
5
5
  SHA512:
6
- metadata.gz: 49a3bea438ae260b9331381bf566726133cfa3b8e2c9a081d5bd3e6afaa62bee9a7893bc0722d18b1c4b7f00cc0590a7c1fdd5c8d0f7fc1ec7c98c8ae480aee8
7
- data.tar.gz: b6fecaf5afde6c779b7cc20d37ed73b0e0ff03900fa01e52fff14b3317a53c88913596ad7f5f876cfbba3686bb52cd1f42b3b9cfb42c166f5a8e99f824c0a078
6
+ metadata.gz: 9b8f8167c5de9611402559fa2c79bad9a10ad6c39fc81478e3d8f1c1e446844297a3dcde6f92826dbd68de628d0c7c2f2860a8b38446363c783e14423efc4a70
7
+ data.tar.gz: 32d5a18e5750fcd3a33cf1bea2d17f67b823827dd69f40d307e1d8df47adb2f3290b686dd7024d278e4396e4c5d3bceea362bdbbb8c243accf6d17846aa118e5
data/README.md CHANGED
@@ -12,6 +12,11 @@ collection of records without making repeated database queries. It uses
12
12
  correlated subqueries to fetch all aggregations in a single efficient database
13
13
  call, improving application performance.
14
14
 
15
+ ### Unsupported
16
+
17
+ Because of the composing of SQL statements, there will never be support for `group` or `GROUP BY`.
18
+ Please don't open an issue about this, unless you have an idea to solve it.
19
+
15
20
  ## Installation
16
21
 
17
22
  ```ruby
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BatchAgg
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
data/lib/batchagg.rb CHANGED
@@ -3,734 +3,253 @@
3
3
  require "active_record"
4
4
 
5
5
  module BatchAgg
6
- # Represents a single aggregate function definition
7
- class AggregateDefinition
8
- attr_reader :name, :type, :block, :column, :expression, :options
9
-
10
- def initialize(name, type, block, column = nil, expression = nil, options = nil)
11
- @name = name
12
- @type = type
13
- @block = block
14
- @column = column
15
- @expression = expression
16
- @options = options
17
- end
18
-
19
- def column_based?
20
- type == :column
21
- end
22
-
23
- def computed?
24
- type == :computed
25
- end
26
-
27
- def block?
28
- !block.nil?
29
- end
6
+ AggregateDef = Struct.new(:name, :type, :block, :column, :expression, :options, keyword_init: true) do
7
+ def column_based? = type == :column
8
+ def computed? = type == :computed
9
+ def block? = !block.nil?
30
10
  end
31
11
 
32
- # Handles association traversal for correlated subqueries
33
- class AssociationResolver
34
- def initialize(base_model_class, outer_table_alias)
35
- @base_model_class = base_model_class
36
- @outer_table_alias = outer_table_alias
37
- end
38
-
39
- def resolve_association(association_name)
40
- reflection = find_association_reflection(association_name)
41
- target_class = reflection.klass
42
- correlation_condition = build_correlation_condition(reflection)
43
-
44
- target_class.where(correlation_condition)
45
- end
46
-
47
- def association?(association_name)
48
- @base_model_class.reflect_on_association(association_name).present?
49
- end
50
-
51
- private
52
-
53
- def find_association_reflection(association_name)
54
- reflection = @base_model_class.reflect_on_association(association_name)
55
- raise NoMethodError, "Association '#{association_name}' not found" unless reflection
56
-
57
- reflection
58
- end
59
-
60
- def build_correlation_condition(reflection)
61
- if reflection.through_reflection
62
- build_has_many_through_condition(reflection)
63
- elsif belongs_to_association?(reflection)
64
- build_belongs_to_condition(reflection)
65
- else # has_many or has_one (direct)
66
- build_has_many_condition(reflection)
12
+ class AssocMagic
13
+ def initialize(model, outer_table)
14
+ @model = model
15
+ @outer_table = outer_table
16
+ end
17
+
18
+ def method_missing(name, *args, &)
19
+ reflection = @model.reflect_on_association(name)
20
+ if reflection
21
+ target = reflection.klass
22
+ if reflection.through_reflection
23
+ # has_many :through
24
+ through = reflection.through_reflection
25
+ source = reflection.source_reflection
26
+ final_target = reflection.klass.arel_table
27
+ through_table = through.klass.arel_table
28
+
29
+ cond1 = if source.macro == :belongs_to
30
+ through_table[source.foreign_key].eq(final_target[source.association_primary_key])
31
+ else
32
+ final_target[source.foreign_key].eq(through_table[source.active_record_primary_key])
33
+ end
34
+
35
+ cond2 = if through.macro == :belongs_to
36
+ @outer_table[through.foreign_key].eq(through_table[through.association_primary_key])
37
+ else
38
+ through_table[through.foreign_key].eq(@outer_table[through.active_record_primary_key])
39
+ end
40
+
41
+ subquery = through_table.project(Arel.sql("1")).where(cond1.and(cond2))
42
+ target.where(subquery.exists)
43
+ elsif reflection.macro == :belongs_to
44
+ target.where(target.arel_table[reflection.association_primary_key].eq(@outer_table[reflection.foreign_key]))
45
+ else
46
+ target.where(target.arel_table[reflection.foreign_key].eq(@outer_table[reflection.active_record_primary_key]))
47
+ end
48
+ else
49
+ super
67
50
  end
68
51
  end
69
52
 
70
- def belongs_to_association?(reflection)
71
- reflection.macro == :belongs_to
72
- end
73
-
74
- def build_belongs_to_condition(reflection)
75
- target_table = reflection.klass.arel_table
76
- foreign_key_column = @outer_table_alias[reflection.foreign_key]
77
- primary_key_column = target_table[reflection.association_primary_key]
78
-
79
- primary_key_column.eq(foreign_key_column)
80
- end
81
-
82
- def build_has_many_condition(reflection)
83
- target_table = reflection.klass.arel_table
84
- foreign_key_column = target_table[reflection.foreign_key]
85
- primary_key_column = @outer_table_alias[reflection.active_record_primary_key]
86
-
87
- foreign_key_column.eq(primary_key_column)
88
- end
89
-
90
- def build_has_many_through_condition(reflection)
91
- through_reflection = reflection.through_reflection
92
- source_reflection = reflection.source_reflection
93
-
94
- final_target_table = reflection.klass.arel_table
95
- through_table = through_reflection.klass.arel_table
96
-
97
- # Condition 1: Links the through_table to the final_target_table
98
- # Based on source_reflection (e.g., Appointment.belongs_to :patient)
99
- cond1_arel = if source_reflection.macro == :belongs_to
100
- through_table[source_reflection.foreign_key].eq(final_target_table[source_reflection.association_primary_key])
101
- else # :has_many, :has_one
102
- final_target_table[source_reflection.foreign_key].eq(through_table[source_reflection.active_record_primary_key])
103
- end
104
-
105
- # Condition 2: Links the through_table to the @outer_table_alias (base model)
106
- # Based on through_reflection (e.g., Physician.has_many :appointments)
107
- cond2_arel = if through_reflection.macro == :belongs_to
108
- @outer_table_alias[through_reflection.foreign_key].eq(through_table[through_reflection.association_primary_key])
109
- else # :has_many, :has_one
110
- through_table[through_reflection.foreign_key].eq(@outer_table_alias[through_reflection.active_record_primary_key])
111
- end
112
-
113
- subquery = through_table.project(Arel.sql("1"))
114
- .where(cond1_arel.and(cond2_arel))
115
- subquery.exists
116
- end
117
- end
118
-
119
- # Provides method_missing interface for association access in aggregate blocks
120
- class CorrelatedRelationBuilder
121
- def initialize(base_model_class, outer_table_alias)
122
- @association_resolver = AssociationResolver.new(base_model_class, outer_table_alias)
123
- end
124
-
125
- def method_missing(association_name, *args, &block_arg)
126
- validate_method_call(association_name, args, block_arg)
127
- @association_resolver.resolve_association(association_name)
128
- end
129
-
130
- def respond_to_missing?(method_name, include_private = false)
131
- @association_resolver.association?(method_name) || super
132
- end
133
-
134
- private
135
-
136
- def validate_method_call(association_name, args, block_arg)
137
- return unless args.any? || block_arg
138
-
139
- raise ArgumentError,
140
- "Unexpected arguments or block for association '#{association_name}' in aggregate definition."
53
+ def respond_to_missing?(name, *)
54
+ @model.reflect_on_association(name) || super
141
55
  end
142
56
  end
143
57
 
144
- # Provides access to outer table attributes in aggregate blocks
145
- class OuterTableAttributeAccessor
146
- def initialize(outer_table_alias, base_model_class)
147
- @outer_table_alias = outer_table_alias
148
- @base_model_class = base_model_class
58
+ class AttrMagic
59
+ def initialize(model, outer_table)
60
+ @model = model
61
+ @outer_table = outer_table
149
62
  end
150
63
 
151
- def method_missing(method_name, *args, &block_arg)
152
- return super unless valid_attribute_access?(method_name, args, block_arg)
153
-
154
- @outer_table_alias[method_name]
155
- end
156
-
157
- def respond_to_missing?(method_name, include_private = false)
158
- column?(method_name) || super
159
- end
160
-
161
- private
162
-
163
- def valid_attribute_access?(method_name, args, block_arg)
164
- args.empty? && block_arg.nil? && column?(method_name)
64
+ def method_missing(name, *args, &)
65
+ if @model.columns_hash.key?(name.to_s)
66
+ @outer_table[name]
67
+ else
68
+ super
69
+ end
165
70
  end
166
71
 
167
- def column?(method_name)
168
- @base_model_class.columns_hash.key?(method_name.to_s)
72
+ def respond_to_missing?(name, *)
73
+ @model.columns_hash.key?(name.to_s) || super
169
74
  end
170
75
  end
171
76
 
172
- # Handles building SQL projections for column aggregates
173
- class ColumnProjectionBuilder
174
- def initialize(outer_table_alias, base_model_class)
175
- @outer_table_alias = outer_table_alias
176
- @attribute_accessor = OuterTableAttributeAccessor.new(outer_table_alias, base_model_class)
77
+ class ColumnProj
78
+ def initialize(outer_table, model)
79
+ @outer_table = outer_table
80
+ @attr_magic = AttrMagic.new(model, outer_table)
177
81
  end
178
82
 
179
- def build_projection(aggregate_def, correlation_builder)
180
- if aggregate_def.block?
181
- build_block_based_projection(aggregate_def, correlation_builder)
182
- else
183
- build_direct_attribute_projection(aggregate_def)
184
- end
185
- end
186
-
187
- private
188
-
189
- def build_direct_attribute_projection(aggregate_def)
190
- @outer_table_alias[aggregate_def.column].as(aggregate_def.name.to_s)
191
- end
83
+ def build(defn, corr)
84
+ if defn.block?
85
+ val = begin
86
+ defn.block.call(@attr_magic)
87
+ rescue StandardError
88
+ nil
89
+ end
90
+ return val.as(defn.name.to_s) if val.is_a?(Arel::Attributes::Attribute) || val.is_a?(Arel::Nodes::SqlLiteral)
192
91
 
193
- def build_block_based_projection(aggregate_def, correlation_builder)
194
- if (aliased_attribute_projection = try_aliased_attribute(aggregate_def))
195
- aliased_attribute_projection
92
+ rel = defn.block.call(corr)
93
+ Arel.sql("(#{rel.to_sql})").as(defn.name.to_s)
196
94
  else
197
- build_subquery_projection(aggregate_def, correlation_builder)
95
+ @outer_table[defn.column].as(defn.name.to_s)
198
96
  end
199
97
  end
200
-
201
- def try_aliased_attribute(aggregate_def)
202
- value = aggregate_def.block.call(@attribute_accessor)
203
- return nil unless arel_attribute_or_literal?(value)
204
-
205
- value.as(aggregate_def.name.to_s)
206
- rescue NoMethodError, ArgumentError
207
- nil
208
- end
209
-
210
- def arel_attribute_or_literal?(value)
211
- value.is_a?(Arel::Attributes::Attribute) || value.is_a?(Arel::Nodes::SqlLiteral)
212
- end
213
-
214
- def build_subquery_projection(aggregate_def, correlation_builder)
215
- relation = aggregate_def.block.call(correlation_builder)
216
- validate_subquery_relation(relation, aggregate_def.name)
217
-
218
- subquery_sql = relation.to_sql
219
- Arel.sql("(#{subquery_sql})").as(aggregate_def.name.to_s)
220
- rescue StandardError => e
221
- raise ArgumentError,
222
- "Block for column aggregate '#{aggregate_def.name}' failed. " \
223
- "Not a valid aliased attribute or subquery. Error: #{e.message}"
224
- end
225
-
226
- def validate_subquery_relation(relation, aggregate_name)
227
- return if relation.is_a?(ActiveRecord::Relation)
228
-
229
- raise ArgumentError,
230
- "Block for column subquery '#{aggregate_name}' must return an ActiveRecord::Relation. " \
231
- "Got: #{relation.class}"
232
- end
233
98
  end
234
99
 
235
- # Builds SQL for aggregate functions (count, sum, etc.)
236
- class AggregateSubqueryBuilder
237
- def build_subquery_sql(relation, aggregate_def)
238
- base_query = relation.except(:select)
239
-
240
- case aggregate_def.type
100
+ module AggSQL
101
+ def self.sql(relation, defn)
102
+ q = relation.except(:select)
103
+ t = q.model.arel_table
104
+ case defn.type
241
105
  when :count
242
- build_count_query(base_query)
106
+ q.select(Arel.star.count).to_sql
243
107
  when :count_expression
244
- build_count_expression_query(base_query, aggregate_def.expression)
108
+ q.select(Arel.sql("COUNT(#{defn.expression})")).to_sql
245
109
  when :count_distinct
246
- build_count_distinct_query(base_query, aggregate_def.column)
110
+ q.select(t[defn.column].count(true)).to_sql
247
111
  when :count_distinct_expression
248
- build_count_distinct_expression_query(base_query, aggregate_def.expression)
112
+ q.select(Arel.sql("COUNT(DISTINCT #{defn.expression})")).to_sql
249
113
  when :sum
250
- build_sum_query(base_query, aggregate_def.column)
114
+ q.select(Arel::Nodes::NamedFunction.new("COALESCE", [t[defn.column].sum, Arel::Nodes.build_quoted(0)])).to_sql
251
115
  when :sum_expression
252
- build_sum_expression_query(base_query, aggregate_def.expression)
116
+ q.select(Arel.sql("COALESCE(SUM(#{defn.expression}), 0)")).to_sql
253
117
  when :avg
254
- build_avg_query(base_query, aggregate_def.column)
118
+ q.select(Arel::Nodes::NamedFunction.new("COALESCE", [t[defn.column].average, Arel::Nodes.build_quoted(0.0)])).to_sql
255
119
  when :avg_expression
256
- build_avg_expression_query(base_query, aggregate_def.expression)
120
+ q.select(Arel.sql("COALESCE(AVG(#{defn.expression}), 0.0)")).to_sql
257
121
  when :min
258
- build_min_query(base_query, aggregate_def.column)
122
+ q.select(Arel::Nodes::NamedFunction.new("COALESCE", [t[defn.column].minimum, Arel::Nodes.build_quoted(0)])).to_sql
259
123
  when :min_expression
260
- build_min_expression_query(base_query, aggregate_def.expression)
124
+ q.select(Arel.sql("COALESCE(MIN(#{defn.expression}), 0)")).to_sql
261
125
  when :max
262
- build_max_query(base_query, aggregate_def.column)
126
+ q.select(Arel::Nodes::NamedFunction.new("COALESCE", [t[defn.column].maximum, Arel::Nodes.build_quoted(0)])).to_sql
263
127
  when :max_expression
264
- build_max_expression_query(base_query, aggregate_def.expression)
128
+ q.select(Arel.sql("COALESCE(MAX(#{defn.expression}), 0)")).to_sql
265
129
  when :string_agg
266
- build_string_agg_query(base_query, aggregate_def.column, aggregate_def.options)
130
+ delim = defn.options&.dig(:delimiter)
131
+ args = [t[defn.column]]
132
+ args << Arel::Nodes.build_quoted(delim) if delim
133
+ q.select(Arel::Nodes::NamedFunction.new("GROUP_CONCAT", args)).to_sql
267
134
  when :string_agg_expression
268
- build_string_agg_expression_query(base_query, aggregate_def.expression, aggregate_def.options)
135
+ delim = defn.options&.dig(:delimiter)
136
+ args = [Arel.sql(defn.expression)]
137
+ args << Arel::Nodes.build_quoted(delim) if delim
138
+ q.select(Arel::Nodes::NamedFunction.new("GROUP_CONCAT", args)).to_sql
269
139
  else
270
- raise ArgumentError, "Unsupported aggregate type: #{aggregate_def.type}"
140
+ raise "Unknown aggregate type: #{defn.type}"
271
141
  end
272
142
  end
273
-
274
- private
275
-
276
- def build_count_query(base_query)
277
- base_query.select(Arel.star.count).to_sql
278
- end
279
-
280
- def build_count_expression_query(base_query, expression)
281
- base_query.select(Arel.sql("COUNT(#{expression})")).to_sql
282
- end
283
-
284
- def build_count_distinct_query(base_query, column)
285
- table = base_query.model.arel_table
286
- base_query.select(table[column].count(true)).to_sql
287
- end
288
-
289
- def build_count_distinct_expression_query(base_query, expression)
290
- base_query.select(Arel.sql("COUNT(DISTINCT #{expression})")).to_sql
291
- end
292
-
293
- def build_sum_query(base_query, column)
294
- table = base_query.model.arel_table
295
- sum_agg = table[column].sum
296
- coalesced_sum = Arel::Nodes::NamedFunction.new("COALESCE", [sum_agg, Arel::Nodes.build_quoted(0)])
297
- base_query.select(coalesced_sum).to_sql
298
- end
299
-
300
- def build_sum_expression_query(base_query, expression)
301
- base_query.select(Arel.sql("COALESCE(SUM(#{expression}), 0)")).to_sql
302
- end
303
-
304
- def build_avg_query(base_query, column)
305
- table = base_query.model.arel_table
306
- avg_agg = table[column].average
307
- coalesced_avg = Arel::Nodes::NamedFunction.new("COALESCE", [avg_agg, Arel::Nodes.build_quoted(0.0)])
308
- base_query.select(coalesced_avg).to_sql
309
- end
310
-
311
- def build_avg_expression_query(base_query, expression)
312
- base_query.select(Arel.sql("COALESCE(AVG(#{expression}), 0.0)")).to_sql
313
- end
314
-
315
- def build_min_query(base_query, column)
316
- table = base_query.model.arel_table
317
- min_agg = table[column].minimum
318
- coalesced_min = Arel::Nodes::NamedFunction.new("COALESCE", [min_agg, Arel::Nodes.build_quoted(0)])
319
- base_query.select(coalesced_min).to_sql
320
- end
321
-
322
- def build_min_expression_query(base_query, expression)
323
- base_query.select(Arel.sql("COALESCE(MIN(#{expression}), 0)")).to_sql
324
- end
325
-
326
- def build_max_query(base_query, column)
327
- table = base_query.model.arel_table
328
- max_agg = table[column].maximum
329
- coalesced_max = Arel::Nodes::NamedFunction.new("COALESCE", [max_agg, Arel::Nodes.build_quoted(0)])
330
- base_query.select(coalesced_max).to_sql
331
- end
332
-
333
- def build_max_expression_query(base_query, expression)
334
- base_query.select(Arel.sql("COALESCE(MAX(#{expression}), 0)")).to_sql
335
- end
336
-
337
- def build_string_agg_query(base_query, column, options)
338
- table = base_query.model.arel_table
339
- delimiter = options&.dig(:delimiter)
340
-
341
- args = [table[column]]
342
- args << Arel::Nodes.build_quoted(delimiter) if delimiter
343
-
344
- base_query.select(Arel::Nodes::NamedFunction.new("GROUP_CONCAT", args)).to_sql
345
- end
346
-
347
- def build_string_agg_expression_query(base_query, expression, options)
348
- delimiter = options&.dig(:delimiter)
349
-
350
- args = [Arel.sql(expression)]
351
- args << Arel::Nodes.build_quoted(delimiter) if delimiter
352
-
353
- base_query.select(Arel::Nodes::NamedFunction.new("GROUP_CONCAT", args)).to_sql
354
- end
355
143
  end
356
144
 
357
- # Handles applying WHERE conditions from scope to outer table alias
358
- class ScopeConditionApplier
359
- def initialize(base_model, outer_table_alias)
360
- @base_model = base_model
361
- @outer_table_alias = outer_table_alias
362
- end
363
-
364
- def apply_conditions_to_query(arel_query, scope)
365
- processed_columns = apply_simple_where_conditions(arel_query, scope)
366
- apply_arel_where_conditions(arel_query, scope, processed_columns)
367
- end
368
-
369
- private
370
-
371
- def apply_simple_where_conditions(arel_query, scope)
372
- processed_columns = []
373
-
374
- return processed_columns unless scope.respond_to?(:where_values_hash)
375
- return processed_columns unless scope.where_values_hash.is_a?(Hash)
376
-
377
- scope.where_values_hash.each do |column_name, value|
378
- column_name_str = column_name.to_s
379
-
380
- if model_column?(column_name_str)
381
- arel_query.where(@outer_table_alias[column_name.to_sym].eq(value))
382
- processed_columns << column_name_str
383
- end
384
- end
385
-
386
- processed_columns
387
- end
388
-
389
- def apply_arel_where_conditions(arel_query, scope, processed_columns)
390
- where_clauses = extract_where_clauses(scope)
391
-
392
- where_clauses.each do |constraint|
393
- next unless simple_table_constraint?(constraint, processed_columns)
394
-
395
- apply_constraint_to_query(arel_query, constraint)
396
- end
397
- end
398
-
399
- def extract_where_clauses(scope)
400
- scope.arel.ast.cores.flat_map(&:wheres)
401
- end
402
-
403
- def simple_table_constraint?(constraint, processed_columns)
404
- return false unless constraint.left.is_a?(Arel::Attributes::Attribute)
405
- return false unless constraint.left.relation.name == @base_model.table_name
406
- return false if processed_columns.include?(constraint.left.name.to_s)
407
-
408
- true
409
- end
410
-
411
- def apply_constraint_to_query(arel_query, constraint)
412
- if constraint.is_a?(Arel::Nodes::Equality)
413
- apply_equality_constraint(arel_query, constraint)
414
- elsif constraint.is_a?(Arel::Nodes::In)
415
- apply_in_constraint(arel_query, constraint)
145
+ class Query
146
+ def initialize(model, aggs)
147
+ @model = model
148
+ @aggs = aggs
149
+ end
150
+
151
+ def build(scope)
152
+ outer = @model.arel_table.alias("batchagg_outer_#{@model.table_name}")
153
+ corr = AssocMagic.new(@model, outer)
154
+ colproj = ColumnProj.new(outer, @model)
155
+ arel = Arel::SelectManager.new(scope.klass.connection)
156
+ arel.from(outer)
157
+ arel.project(outer[@model.primary_key].as(@model.primary_key.to_s))
158
+ @aggs.reject(&:computed?).each do |agg|
159
+ proj = agg.column_based? ? colproj.build(agg, corr) : Arel.sql("(#{AggSQL.sql(agg.block.call(corr), agg)})").as(agg.name.to_s)
160
+ arel.project(proj)
416
161
  end
417
- end
418
-
419
- def apply_equality_constraint(arel_query, constraint)
420
- column_name = constraint.left.name
421
- value = constraint.right
422
- arel_query.where(@outer_table_alias[column_name].eq(value))
423
- end
424
-
425
- def apply_in_constraint(arel_query, constraint)
426
- column_name = constraint.left.name
427
- values = extract_in_clause_values(constraint.right)
428
- arel_query.where(@outer_table_alias[column_name].in(values))
429
- end
430
-
431
- def extract_in_clause_values(right_operand)
432
- return right_operand unless right_operand.is_a?(Array)
433
-
434
- right_operand.map do |value|
435
- value.is_a?(Arel::Nodes::BindParam) ? value.value.value_before_type_cast : value
436
- end
437
- end
438
-
439
- def model_column?(column_name)
440
- @base_model.columns_hash.key?(column_name)
162
+ arel.where(
163
+ outer[@model.primary_key].in(
164
+ Arel.sql("(#{scope.select(@model.primary_key).to_sql})")
165
+ )
166
+ )
167
+ arel
441
168
  end
442
169
  end
443
170
 
444
- # Main query builder that orchestrates the SQL generation
445
- class QueryBuilder
446
- def initialize(base_model)
447
- @base_model = base_model
448
- @subquery_builder = AggregateSubqueryBuilder.new
449
- end
450
-
451
- def build_query_for_scope(scope, aggregates)
452
- outer_table_alias = create_outer_table_alias
453
- builders = create_helper_builders(outer_table_alias)
454
-
455
- arel_query = create_base_query(scope, outer_table_alias)
456
- # Filter out computed aggregates before building SQL projections
457
- sql_aggregates = aggregates.reject(&:computed?)
458
- add_projections_to_query(arel_query, sql_aggregates, builders, outer_table_alias)
459
- apply_scope_conditions(arel_query, scope, outer_table_alias)
460
-
461
- arel_query
462
- end
463
-
464
- private
465
-
466
- def create_outer_table_alias
467
- @base_model.arel_table.alias("batchagg_outer_#{@base_model.table_name}")
468
- end
469
-
470
- def create_helper_builders(outer_table_alias)
471
- {
472
- correlation: CorrelatedRelationBuilder.new(@base_model, outer_table_alias),
473
- column_projection: ColumnProjectionBuilder.new(outer_table_alias, @base_model)
474
- }
475
- end
476
-
477
- def create_base_query(scope, outer_table_alias)
478
- arel_query = Arel::SelectManager.new(scope.klass.connection)
479
- arel_query.from(outer_table_alias)
480
-
481
- primary_key_projection = outer_table_alias[@base_model.primary_key].as(@base_model.primary_key.to_s)
482
- arel_query.project(primary_key_projection)
483
-
484
- arel_query
171
+ class Runner
172
+ def initialize(aggs, model)
173
+ @aggs = aggs
174
+ @model = model
175
+ @query = Query.new(model, aggs)
176
+ @result_class = build_result_class(aggs)
485
177
  end
486
178
 
487
- def add_projections_to_query(arel_query, aggregates, builders, _outer_table_alias)
488
- aggregates.each do |aggregate_def|
489
- # At this point, aggregate_def should not be a computed? one due to filtering above
490
- projection = build_projection_for_aggregate(aggregate_def, builders)
491
- arel_query.project(projection)
492
- end
179
+ def only(record)
180
+ from(@model.where(@model.primary_key => record.id))
493
181
  end
494
182
 
495
- def build_projection_for_aggregate(aggregate_def, builders)
496
- if aggregate_def.column_based?
497
- builders[:column_projection].build_projection(aggregate_def, builders[:correlation])
498
- else
499
- build_aggregate_function_projection(aggregate_def, builders[:correlation])
183
+ def from(scope)
184
+ rows = scope.klass.connection.select_all(@query.build(scope).to_sql).to_a
185
+ rows.each_with_object({}) do |row, h|
186
+ id = row[@model.primary_key.to_s]
187
+ h[@model.primary_key ? id : row.keys.first] = @result_class.new(row)
500
188
  end
501
189
  end
502
190
 
503
- def build_aggregate_function_projection(aggregate_def, correlation_builder)
504
- correlated_relation = aggregate_def.block.call(correlation_builder)
505
- subquery_sql = @subquery_builder.build_subquery_sql(correlated_relation, aggregate_def)
506
- Arel.sql("(#{subquery_sql})").as(aggregate_def.name.to_s)
507
- end
508
-
509
- def apply_scope_conditions(arel_query, scope, outer_table_alias)
510
- condition_applier = ScopeConditionApplier.new(@base_model, outer_table_alias)
511
- condition_applier.apply_conditions_to_query(arel_query, scope)
512
- end
513
- end
514
-
515
- # Handles type casting of primary key values for hash keys
516
- class PrimaryKeyTypeConverter
517
- def self.cast_for_hash_key(id_value, model_class)
518
- primary_key_column = find_primary_key_column(model_class)
519
- type_converter = create_type_converter(primary_key_column)
520
- type_converter.deserialize(id_value)
521
- end
522
-
523
- def self.find_primary_key_column(model_class)
524
- model_class.columns_hash[model_class.primary_key]
525
- end
526
-
527
- def self.create_type_converter(primary_key_column)
528
- ActiveRecord::Type.lookup(
529
- primary_key_column.type,
530
- limit: primary_key_column.limit,
531
- precision: primary_key_column.precision,
532
- scale: primary_key_column.scale
533
- )
534
- end
535
- end
536
-
537
- # Represents aggregate results for a single record
538
- class AggregateResultForRecord
539
- def initialize(row_data, all_aggregate_definitions)
540
- @data = symbolize_keys(row_data)
541
- @all_aggregate_definitions = all_aggregate_definitions
542
- @computed_cache = {}
543
-
544
- sql_aggregate_definitions = @all_aggregate_definitions.reject(&:computed?)
545
- define_sql_aggregate_methods(sql_aggregate_definitions)
546
-
547
- computed_aggregate_definitions = @all_aggregate_definitions.select(&:computed?)
548
- define_computed_aggregate_methods(computed_aggregate_definitions)
549
- end
550
-
551
191
  private
552
192
 
553
- def symbolize_keys(hash)
554
- hash.transform_keys(&:to_sym)
555
- end
556
-
557
- def define_sql_aggregate_methods(sql_aggregate_definitions)
558
- sql_aggregate_definitions.each do |aggregate_def|
559
- define_singleton_method(aggregate_def.name) do
560
- @data[aggregate_def.name]
193
+ def build_result_class(aggs)
194
+ klass = Class.new do
195
+ define_method(:initialize) do |row|
196
+ @data = row.transform_keys(&:to_sym)
197
+ @cache = {}
561
198
  end
562
199
  end
563
- end
564
-
565
- def define_computed_aggregate_methods(computed_aggregate_definitions)
566
- computed_aggregate_definitions.each do |aggregate_def|
567
- define_singleton_method(aggregate_def.name) do
568
- return @computed_cache[aggregate_def.name] if @computed_cache.key?(aggregate_def.name)
569
-
570
- value = instance_exec(self, &aggregate_def.block)
571
- @computed_cache[aggregate_def.name] = value
572
- value
573
- end
200
+ aggs.reject(&:computed?).each do |agg|
201
+ klass.define_method(agg.name) { @data[agg.name] }
574
202
  end
575
- end
576
- end
577
-
578
- # Orchestrates query execution and result processing
579
- class AggregateResultProcessor
580
- def initialize(aggregates, base_model)
581
- @aggregates = aggregates
582
- @base_model = base_model
583
- @query_builder = QueryBuilder.new(base_model)
584
- end
585
-
586
- def process_single_record(record)
587
- scope = create_single_record_scope(record)
588
- process_scope(scope)
589
- end
590
-
591
- def process_scope(scope)
592
- query_results = execute_query(scope)
593
- convert_results_to_hash(query_results, scope.klass)
594
- end
595
-
596
- private
597
-
598
- def create_single_record_scope(record)
599
- @base_model.where(@base_model.primary_key => record.id)
600
- end
601
-
602
- def execute_query(scope)
603
- query_arel = @query_builder.build_query_for_scope(scope, @aggregates)
604
- scope.klass.connection.select_all(query_arel.to_sql).to_a
605
- end
606
-
607
- def convert_results_to_hash(query_results, model_class)
608
- query_results.each_with_object({}) do |row_hash, result_hash|
609
- record_id = extract_and_cast_record_id(row_hash, model_class)
610
- result_hash[record_id] = AggregateResultForRecord.new(row_hash, @aggregates)
203
+ aggs.select(&:computed?).each do |agg|
204
+ klass.define_method(agg.name) do
205
+ @cache[agg.name] ||= instance_exec(self, &agg.block)
206
+ end
611
207
  end
612
- end
613
-
614
- def extract_and_cast_record_id(row_hash, model_class)
615
- raw_id = row_hash[@base_model.primary_key.to_s]
616
- PrimaryKeyTypeConverter.cast_for_hash_key(raw_id, model_class)
208
+ klass
617
209
  end
618
210
  end
619
211
 
620
- # Main class that provides the public API for aggregate results
621
- class AggregateResultClass
622
- def initialize(aggregates, base_model)
623
- @processor = AggregateResultProcessor.new(aggregates, base_model)
624
- end
625
-
626
- def only(record)
627
- @processor.process_single_record(record)
628
- end
629
-
630
- def from(scope)
631
- @processor.process_scope(scope)
632
- end
633
- end
634
-
635
- # Builder for creating aggregate definitions using DSL methods
636
- class AggregateDefinitionCollector
637
- def initialize
638
- @aggregates = []
639
- end
640
-
641
- def column(name, &block)
642
- add_aggregate(:column, name, block, column: name)
643
- end
644
-
645
- def count(name, &block)
646
- add_aggregate(:count, name, block)
647
- end
648
-
649
- def count_distinct(name, column, &block)
650
- add_aggregate(:count_distinct, name, block, column: column)
651
- end
652
-
653
- def count_expression(name, expression, &block)
654
- add_aggregate(:count_expression, name, block, expression: expression)
655
- end
656
-
657
- def count_distinct_expression(name, expression, &block)
658
- add_aggregate(:count_distinct_expression, name, block, expression: expression)
659
- end
660
-
661
- def sum(name, column, &block)
662
- add_aggregate(:sum, name, block, column: column)
663
- end
664
-
665
- def sum_expression(name, expression, &block)
666
- add_aggregate(:sum_expression, name, block, expression: expression)
667
- end
668
-
669
- def avg(name, column, &block)
670
- add_aggregate(:avg, name, block, column: column)
671
- end
672
-
673
- def avg_expression(name, expression, &block)
674
- add_aggregate(:avg_expression, name, block, expression: expression)
675
- end
676
-
677
- def min(name, column, &block)
678
- add_aggregate(:min, name, block, column: column)
679
- end
680
-
681
- def min_expression(name, expression, &block)
682
- add_aggregate(:min_expression, name, block, expression: expression)
683
- end
684
-
685
- def max(name, column, &block)
686
- add_aggregate(:max, name, block, column: column)
687
- end
688
-
689
- def max_expression(name, expression, &block)
690
- add_aggregate(:max_expression, name, block, expression: expression)
691
- end
692
-
693
- def string_agg(name, column, delimiter: nil, &block)
694
- add_aggregate(:string_agg, name, block, column: column, options: { delimiter: delimiter })
695
- end
696
-
697
- def string_agg_expression(name, expression, delimiter: nil, &block)
698
- add_aggregate(:string_agg_expression, name, block, expression: expression, options: { delimiter: delimiter })
699
- end
700
-
701
- def computed(name, &block)
702
- add_aggregate(:computed, name, block)
703
- end
704
-
705
- attr_reader :aggregates
212
+ class Collector
213
+ attr_reader :aggs
214
+
215
+ def initialize = @aggs = []
216
+ def column(name, &block) = add(:column, name, block, column: name)
217
+ def count(name, &block) = add(:count, name, block)
218
+ def count_distinct(name, column, &block) = add(:count_distinct, name, block, column: column)
219
+ def count_expression(name, expr, &block) = add(:count_expression, name, block, expression: expr)
220
+ def count_distinct_expression(name, expr, &block) = add(:count_distinct_expression, name, block, expression: expr)
221
+ def sum(name, column, &block) = add(:sum, name, block, column: column)
222
+ def sum_expression(name, expr, &block) = add(:sum_expression, name, block, expression: expr)
223
+ def avg(name, column, &block) = add(:avg, name, block, column: column)
224
+ def avg_expression(name, expr, &block) = add(:avg_expression, name, block, expression: expr)
225
+ def min(name, column, &block) = add(:min, name, block, column: column)
226
+ def min_expression(name, expr, &block) = add(:min_expression, name, block, expression: expr)
227
+ def max(name, column, &block) = add(:max, name, block, column: column)
228
+ def max_expression(name, expr, &block) = add(:max_expression, name, block, expression: expr)
229
+ def string_agg(name, column, delimiter: nil, &block) = add(:string_agg, name, block, column: column, options: { delimiter: delimiter })
230
+ def string_agg_expression(name, expr, delimiter: nil, &block) = add(:string_agg_expression, name, block, expression: expr, options: { delimiter: delimiter })
231
+ def computed(name, &block) = add(:computed, name, block)
706
232
 
707
233
  private
708
234
 
709
- def add_aggregate(type, name, block, column: nil, expression: nil, options: nil)
710
- @aggregates << AggregateDefinition.new(name, type, block, column, expression, options)
235
+ def add(type, name, block, column: nil, expression: nil, options: nil)
236
+ @aggs << AggregateDef.new(name: name, type: type, block: block, column: column, expression: expression, options: options)
711
237
  end
712
238
  end
713
239
 
714
- # Main builder that coordinates the DSL and creates the result class
715
- class AggregateBuilder
716
- def initialize(base_model)
717
- @base_model = base_model
718
- end
240
+ class Builder
241
+ def initialize(model) = @model = model
719
242
 
720
243
  def build_class(&)
721
- collector = AggregateDefinitionCollector.new
722
- collector.instance_eval(&)
723
- aggregates = collector.aggregates
724
-
725
- AggregateResultClass.new(aggregates, @base_model)
244
+ col = Collector.new
245
+ col.instance_eval(&)
246
+ Runner.new(col.aggs, @model)
726
247
  end
727
248
  end
728
249
 
729
- # Public DSL module
730
250
  module DSL
731
- def aggregate(base_model, &)
732
- builder = BatchAgg::AggregateBuilder.new(base_model)
733
- builder.build_class(&)
251
+ def aggregate(model, &)
252
+ Builder.new(model).build_class(&)
734
253
  end
735
254
  end
736
255
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: batchagg
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - JT Archie