simple_query 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,17 +12,18 @@ module SimpleQuery
12
12
  @arel_table = @model.arel_table
13
13
 
14
14
  @selects = []
15
- @wheres = WhereClause.new(@arel_table)
15
+ @wheres = WhereClause.new(@arel_table, @model)
16
16
  @joins = JoinClause.new
17
- @group_having = GroupHavingClause.new(@arel_table)
17
+ @group_having = GroupHavingClause.new(@arel_table, @model)
18
18
  @orders = OrderClause.new(@arel_table)
19
19
  @limits = LimitOffsetClause.new
20
20
  @distinct_flag = DistinctClause.new
21
+ @aggregations = AggregationClause.new(@arel_table, @model)
21
22
 
22
23
  @query_cache = {}
23
24
  @query_built = false
24
25
  @read_model_class = nil
25
- @result_struct = nil
26
+ @result_structs = {}
26
27
  end
27
28
 
28
29
  def select(*fields)
@@ -91,6 +92,99 @@ module SimpleQuery
91
92
  self
92
93
  end
93
94
 
95
+ # Aggregation methods
96
+ def count(column = nil, alias_name: nil)
97
+ @aggregations.count(column, alias_name: alias_name)
98
+ reset_query
99
+ self
100
+ end
101
+
102
+ def sum(column, alias_name: nil)
103
+ @aggregations.sum(column, alias_name: alias_name)
104
+ reset_query
105
+ self
106
+ end
107
+
108
+ def avg(column, alias_name: nil)
109
+ @aggregations.avg(column, alias_name: alias_name)
110
+ reset_query
111
+ self
112
+ end
113
+
114
+ def min(column, alias_name: nil)
115
+ @aggregations.min(column, alias_name: alias_name)
116
+ reset_query
117
+ self
118
+ end
119
+
120
+ def max(column, alias_name: nil)
121
+ @aggregations.max(column, alias_name: alias_name)
122
+ reset_query
123
+ self
124
+ end
125
+
126
+ def variance(column, alias_name: nil)
127
+ @aggregations.variance(column, alias_name: alias_name)
128
+ reset_query
129
+ self
130
+ end
131
+
132
+ def stddev(column, alias_name: nil)
133
+ @aggregations.stddev(column, alias_name: alias_name)
134
+ reset_query
135
+ self
136
+ end
137
+
138
+ def group_concat(column, separator: ",", alias_name: nil)
139
+ @aggregations.group_concat(column, separator: separator, alias_name: alias_name)
140
+ reset_query
141
+ self
142
+ end
143
+
144
+ def custom_aggregation(expression, alias_name)
145
+ @aggregations.custom(expression, alias_name)
146
+ reset_query
147
+ self
148
+ end
149
+
150
+ # Convenience methods for common aggregation patterns
151
+ def total_count(alias_name: "total")
152
+ count(alias_name: alias_name)
153
+ end
154
+
155
+ def stats(column, alias_prefix: nil)
156
+ prefix = alias_prefix || column.to_s
157
+ count(alias_name: "#{prefix}_count")
158
+ sum(column, alias_name: "#{prefix}_sum")
159
+ avg(column, alias_name: "#{prefix}_avg")
160
+ min(column, alias_name: "#{prefix}_min")
161
+ max(column, alias_name: "#{prefix}_max")
162
+ self
163
+ end
164
+
165
+ # Method to get first/top record by column
166
+ def first_by(column, alias_name: nil)
167
+ alias_name ||= "first_#{column}"
168
+ custom_aggregation("FIRST_VALUE(#{resolve_column_name(column)}) OVER (ORDER BY #{resolve_column_name(column)})",
169
+ alias_name)
170
+ end
171
+
172
+ # Method to get last/bottom record by column
173
+ def last_by(column, alias_name: nil)
174
+ alias_name ||= "last_#{column}"
175
+ custom_aggregation("LAST_VALUE(#{resolve_column_name(column)}) OVER (ORDER BY #{resolve_column_name(column)} " \
176
+ "ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)",
177
+ alias_name)
178
+ end
179
+
180
+ # Percentage calculations
181
+ def percentage_of_total(column, alias_name: nil)
182
+ alias_name ||= "#{column}_percentage"
183
+ column_expr = resolve_column_name(column)
184
+ expression = "ROUND((#{column_expr} * 100.0 / SUM(#{column_expr}) OVER ()), 2)"
185
+ custom_aggregation(expression, alias_name)
186
+ end
187
+
94
188
  def map_to(klass)
95
189
  @read_model_class = klass
96
190
  reset_query
@@ -98,37 +192,44 @@ module SimpleQuery
98
192
  end
99
193
 
100
194
  def bulk_update(set:)
101
- table_name = @arel_table.name
102
- set_sql = SetClause.new(set).to_sql
195
+ validate_bulk_update_scope!
103
196
 
197
+ set_sql = SetClause.new(set, @model).to_sql
104
198
  raise ArgumentError, "No columns to update" if set_sql.empty?
105
199
 
106
- where_sql = build_where_sql
107
- sql = "UPDATE #{table_name} SET #{set_sql}"
108
- sql += " WHERE #{where_sql}" unless where_sql.nil? || where_sql.empty?
200
+ update = Arel::UpdateManager.new
201
+ update.table(@arel_table)
202
+ update.set(Arel.sql(set_sql))
203
+ condition = @wheres.to_arel
204
+ update.where(condition) if condition
109
205
 
110
- ActiveRecord::Base.connection.execute(sql)
206
+ connection.execute(compile_sql(update))
111
207
  end
112
208
 
113
209
  def stream_each(batch_size: 1000, &block)
114
- adapter = ActiveRecord::Base.connection.adapter_name.downcase
210
+ validate_stream_batch_size!(batch_size)
211
+
212
+ adapter_name = connection.adapter_name
213
+ adapter = adapter_name.downcase
115
214
  if adapter.include?("postgres")
116
215
  stream_each_postgres(batch_size, &block)
117
216
  elsif adapter.include?("mysql")
118
217
  stream_each_mysql(&block)
119
218
  else
120
- raise "stream_each is only implemented for Postgres and MySQL."
219
+ message = "stream_each is only implemented for PostgreSQL and MySQL adapters " \
220
+ "(current adapter: #{adapter_name})"
221
+ raise UnsupportedAdapterError, message
121
222
  end
122
223
  end
123
224
 
124
225
  def execute
125
- records = ActiveRecord::Base.connection.select_all(cached_sql)
226
+ records = connection.select_all(cached_sql)
126
227
  build_result_objects_from_rows(records)
127
228
  end
128
229
 
129
230
  def lazy_execute
130
231
  Enumerator.new do |yielder|
131
- records = ActiveRecord::Base.connection.select_all(cached_sql)
232
+ records = connection.select_all(cached_sql)
132
233
  if @read_model_class
133
234
  build_read_models_enumerator(records, yielder)
134
235
  else
@@ -145,7 +246,10 @@ module SimpleQuery
145
246
 
146
247
  @query = Arel::SelectManager.new(Arel::Table.engine)
147
248
  @query.from(@arel_table)
148
- @query.project(*(@selects.empty? ? [@arel_table[Arel.star]] : @selects))
249
+
250
+ # Combine regular selects with aggregations
251
+ all_selects = build_select_expressions
252
+ @query.project(*all_selects)
149
253
 
150
254
  apply_distinct
151
255
  apply_where_conditions
@@ -160,11 +264,46 @@ module SimpleQuery
160
264
 
161
265
  private
162
266
 
163
- def build_where_sql
164
- condition = @wheres.to_arel
165
- return "" unless condition
267
+ def validate_stream_batch_size!(batch_size)
268
+ return if batch_size.is_a?(Integer) && batch_size.positive?
269
+
270
+ raise ArgumentError, "stream_each batch_size must be a positive Integer"
271
+ end
272
+
273
+ def validate_bulk_update_scope!
274
+ unsupported = []
275
+ unsupported << "joins" if @joins.joins.any?
276
+ unsupported << "limit/offset" if @limits.limit_value || @limits.offset_value
277
+ unsupported << "group/having" if @group_having.group_fields.any? || @group_having.having_conditions.any?
278
+ return if unsupported.empty?
279
+
280
+ raise ArgumentError, "bulk_update only supports where conditions (found #{unsupported.join(", ")})"
281
+ end
282
+
283
+ def build_select_expressions
284
+ expressions = []
285
+
286
+ # Add regular selects
287
+ if @selects.any?
288
+ expressions.concat(@selects)
289
+ elsif @aggregations.empty?
290
+ # Only use * if no aggregations and no explicit selects
291
+ expressions << @arel_table[Arel.star]
292
+ end
293
+
294
+ # Add aggregation expressions
295
+ expressions.concat(@aggregations.to_arel_expressions)
296
+
297
+ expressions
298
+ end
299
+
300
+ def connection
301
+ @model.connection
302
+ end
166
303
 
167
- condition.to_sql
304
+ # Compiles with the model's own connection, inlining any bind values.
305
+ def compile_sql(arel)
306
+ connection.unprepared_statement { connection.to_sql(arel) }
168
307
  end
169
308
 
170
309
  def reset_query
@@ -182,10 +321,11 @@ module SimpleQuery
182
321
  @orders.orders,
183
322
  @limits.limit_value,
184
323
  @limits.offset_value,
185
- @distinct_flag.use_distinct?
324
+ @distinct_flag.use_distinct?,
325
+ @aggregations.aggregations
186
326
  ]
187
327
 
188
- @query_cache[key] ||= build_query.to_sql
328
+ @query_cache[key] ||= compile_sql(build_query)
189
329
  end
190
330
 
191
331
  def build_result_objects_from_rows(records)
@@ -226,7 +366,8 @@ module SimpleQuery
226
366
  end
227
367
 
228
368
  def result_struct(columns)
229
- @result_struct ||= Struct.new(*columns.map(&:to_sym))
369
+ struct_key = columns.map(&:to_s)
370
+ @result_structs[struct_key] ||= Struct.new(*struct_key.map(&:to_sym))
230
371
  end
231
372
 
232
373
  def apply_distinct
@@ -267,9 +408,21 @@ module SimpleQuery
267
408
  end
268
409
  end
269
410
 
270
- def method_missing(method_name, *args, &block)
411
+ def resolve_column_name(column)
412
+ case column
413
+ when Symbol
414
+ "#{@arel_table.name}.#{column}"
415
+ when String
416
+ column.include?(".") ? column : "#{@arel_table.name}.#{column}"
417
+ else
418
+ column.to_s
419
+ end
420
+ end
421
+
422
+ def method_missing(method_name, *args, **kwargs, &block)
271
423
  if (scope_block = find_scope(method_name))
272
- instance_exec(*args, &scope_block)
424
+ validate_scope_arity!(method_name, scope_block, scope_arguments_for_validation(scope_block, args, kwargs))
425
+ instance_exec(*args, **kwargs, &scope_block)
273
426
  self
274
427
  else
275
428
  super
@@ -285,5 +438,69 @@ module SimpleQuery
285
438
 
286
439
  model._simple_scopes[method_name.to_sym]
287
440
  end
441
+
442
+ def validate_scope_arity!(scope_name, scope_block, args)
443
+ range = scope_argument_range(scope_block)
444
+ provided = args.length
445
+ return if scope_arity_matches?(range, provided)
446
+
447
+ raise ArgumentError,
448
+ "simple_scope :#{scope_name} expected #{scope_arity_description(range)}, provided #{provided}"
449
+ end
450
+
451
+ def scope_arguments_for_validation(scope_block, args, kwargs)
452
+ return args if kwargs.empty? || scope_uses_keyword_arguments?(scope_block)
453
+
454
+ args + [kwargs]
455
+ end
456
+
457
+ def scope_argument_range(scope_block)
458
+ # Keyword argument arity has Ruby-version-specific edge cases; let Ruby raise its native error.
459
+ return nil if scope_uses_keyword_arguments?(scope_block)
460
+
461
+ minimum = minimum_scope_arguments(scope_block)
462
+ maximum = maximum_scope_arguments(scope_block)
463
+
464
+ [minimum, maximum]
465
+ end
466
+
467
+ def scope_uses_keyword_arguments?(scope_block)
468
+ scope_block.parameters.any? { |kind, _name| [:key, :keyreq, :keyrest].include?(kind) }
469
+ end
470
+
471
+ def minimum_scope_arguments(scope_block)
472
+ arity = scope_block.arity
473
+ return arity if arity >= 0
474
+
475
+ -arity - 1
476
+ end
477
+
478
+ def maximum_scope_arguments(scope_block)
479
+ parameters = scope_block.parameters
480
+ return nil if parameters.any? { |kind, _name| kind == :rest }
481
+
482
+ parameters.count { |kind, _name| [:req, :opt].include?(kind) }
483
+ end
484
+
485
+ def scope_arity_matches?(range, provided)
486
+ return true if range.nil?
487
+
488
+ minimum, maximum = range
489
+ return provided >= minimum if maximum.nil?
490
+
491
+ provided.between?(minimum, maximum)
492
+ end
493
+
494
+ def scope_arity_description(range)
495
+ minimum, maximum = range
496
+ return "at least #{argument_count_description(minimum)}" if maximum.nil?
497
+ return "exactly #{argument_count_description(minimum)}" if minimum == maximum
498
+
499
+ "#{minimum} to #{argument_count_description(maximum)}"
500
+ end
501
+
502
+ def argument_count_description(count)
503
+ "#{count} #{count == 1 ? "argument" : "arguments"}"
504
+ end
288
505
  end
289
506
  end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleQuery
4
+ class AggregationClause
5
+ attr_reader :aggregations
6
+
7
+ def initialize(table, model = ActiveRecord::Base)
8
+ @table = table
9
+ @model = model
10
+ @aggregations = []
11
+ end
12
+
13
+ def count(column = nil, alias_name: nil)
14
+ column_expr = column ? resolve_column(column) : "*"
15
+ alias_name ||= column ? "count_#{sanitize_alias(column)}" : "count"
16
+
17
+ add_aggregation("COUNT", column_expr, alias_name)
18
+ end
19
+
20
+ def sum(column, alias_name: nil)
21
+ raise ArgumentError, "Column is required for SUM aggregation" if column.nil?
22
+
23
+ column_expr = resolve_column(column)
24
+ alias_name ||= "sum_#{sanitize_alias(column)}"
25
+
26
+ add_aggregation("SUM", column_expr, alias_name)
27
+ end
28
+
29
+ def avg(column, alias_name: nil)
30
+ raise ArgumentError, "Column is required for AVG aggregation" if column.nil?
31
+
32
+ column_expr = resolve_column(column)
33
+ alias_name ||= "avg_#{sanitize_alias(column)}"
34
+
35
+ add_aggregation("AVG", column_expr, alias_name)
36
+ end
37
+
38
+ def min(column, alias_name: nil)
39
+ raise ArgumentError, "Column is required for MIN aggregation" if column.nil?
40
+
41
+ column_expr = resolve_column(column)
42
+ alias_name ||= "min_#{sanitize_alias(column)}"
43
+
44
+ add_aggregation("MIN", column_expr, alias_name)
45
+ end
46
+
47
+ def max(column, alias_name: nil)
48
+ raise ArgumentError, "Column is required for MAX aggregation" if column.nil?
49
+
50
+ column_expr = resolve_column(column)
51
+ alias_name ||= "max_#{sanitize_alias(column)}"
52
+
53
+ add_aggregation("MAX", column_expr, alias_name)
54
+ end
55
+
56
+ def custom(expression, alias_name)
57
+ if expression.nil? || alias_name.nil?
58
+ raise ArgumentError,
59
+ "Expression and alias are required for custom aggregation"
60
+ end
61
+
62
+ @aggregations << {
63
+ expression: expression,
64
+ alias: alias_name
65
+ }
66
+ end
67
+
68
+ # Statistical functions
69
+ def variance(column, alias_name: nil)
70
+ raise ArgumentError, "Column is required for VARIANCE aggregation" if column.nil?
71
+
72
+ column_expr = resolve_column(column)
73
+ alias_name ||= "variance_#{sanitize_alias(column)}"
74
+
75
+ add_aggregation("VARIANCE", column_expr, alias_name)
76
+ end
77
+
78
+ def stddev(column, alias_name: nil)
79
+ raise ArgumentError, "Column is required for STDDEV aggregation" if column.nil?
80
+
81
+ column_expr = resolve_column(column)
82
+ alias_name ||= "stddev_#{sanitize_alias(column)}"
83
+
84
+ add_aggregation("STDDEV", column_expr, alias_name)
85
+ end
86
+
87
+ # Group concatenation (database-specific)
88
+ def group_concat(column, separator: ",", alias_name: nil)
89
+ raise ArgumentError, "Column is required for GROUP_CONCAT aggregation" if column.nil?
90
+
91
+ column_expr = resolve_column(column)
92
+ alias_name ||= "group_concat_#{sanitize_alias(column)}"
93
+
94
+ # Use database-specific group concatenation
95
+ connection = @model.connection
96
+ adapter = connection.adapter_name.downcase
97
+ quoted_separator = connection.quote(separator.to_s)
98
+
99
+ expression = case adapter
100
+ when /mysql/
101
+ "GROUP_CONCAT(#{column_expr} SEPARATOR #{quoted_separator})"
102
+ when /postgres/
103
+ "STRING_AGG(#{column_expr}::text, #{quoted_separator})"
104
+ when /sqlite/
105
+ "GROUP_CONCAT(#{column_expr}, #{quoted_separator})"
106
+ else
107
+ # Fallback for other databases
108
+ "GROUP_CONCAT(#{column_expr})"
109
+ end
110
+
111
+ @aggregations << {
112
+ expression: expression,
113
+ alias: alias_name
114
+ }
115
+ end
116
+
117
+ def to_arel_expressions
118
+ @aggregations.map do |agg|
119
+ Arel.sql("#{agg[:expression]} AS #{agg[:alias]}")
120
+ end
121
+ end
122
+
123
+ def empty?
124
+ @aggregations.empty?
125
+ end
126
+
127
+ def clear
128
+ @aggregations.clear
129
+ end
130
+
131
+ private
132
+
133
+ def add_aggregation(function, column_expr, alias_name)
134
+ @aggregations << {
135
+ expression: "#{function}(#{column_expr})",
136
+ alias: alias_name
137
+ }
138
+ end
139
+
140
+ def resolve_column(column)
141
+ case column
142
+ when Symbol
143
+ "#{@table.name}.#{column}"
144
+ when String
145
+ # Allow table.column format or just column name
146
+ column.include?(".") ? column : "#{@table.name}.#{column}"
147
+ when Arel::Attributes::Attribute
148
+ column.to_sql
149
+ else
150
+ column.to_s
151
+ end
152
+ end
153
+
154
+ def sanitize_alias(column)
155
+ column.to_s.gsub(".", "_")
156
+ end
157
+ end
158
+ end
@@ -4,8 +4,9 @@ module SimpleQuery
4
4
  class GroupHavingClause
5
5
  attr_reader :group_fields, :having_conditions
6
6
 
7
- def initialize(table)
7
+ def initialize(table, model = ActiveRecord::Base)
8
8
  @table = table
9
+ @model = model
9
10
  @group_fields = []
10
11
  @having_conditions = []
11
12
  end
@@ -15,15 +16,14 @@ module SimpleQuery
15
16
  end
16
17
 
17
18
  def add_having(condition)
18
- @having_conditions << condition
19
+ clause = WhereClause.new(@table, @model)
20
+ clause.add(condition)
21
+ @having_conditions.concat(clause.conditions)
19
22
  end
20
23
 
21
24
  def apply_to(query)
22
25
  @group_fields.each { |g| query.group(g) }
23
- if @having_conditions.any?
24
- combined = @having_conditions.inject { |c, a| c.and(a) }
25
- query.having(combined)
26
- end
26
+ query.having(WhereClause.combine(@having_conditions)) if @having_conditions.any?
27
27
  query
28
28
  end
29
29
  end
@@ -2,8 +2,9 @@
2
2
 
3
3
  module SimpleQuery
4
4
  class SetClause
5
- def initialize(set_hash)
5
+ def initialize(set_hash, model = ActiveRecord::Base)
6
6
  @set_hash = set_hash
7
+ @model = model
7
8
  end
8
9
 
9
10
  def to_sql
@@ -15,11 +16,11 @@ module SimpleQuery
15
16
  private
16
17
 
17
18
  def quote_column(col)
18
- ActiveRecord::Base.connection.quote_column_name(col)
19
+ @model.connection.quote_column_name(col)
19
20
  end
20
21
 
21
22
  def quote_value(val)
22
- ActiveRecord::Base.connection.quote(val)
23
+ @model.connection.quote(val)
23
24
  end
24
25
  end
25
26
  end
@@ -4,8 +4,16 @@ module SimpleQuery
4
4
  class WhereClause
5
5
  attr_reader :conditions
6
6
 
7
- def initialize(table)
7
+ def self.combine(conditions)
8
+ return nil if conditions.empty?
9
+ return conditions.first if conditions.one?
10
+
11
+ Arel::Nodes::And.new(conditions.map { |c| c.is_a?(Arel::Nodes::Grouping) ? c : Arel::Nodes::Grouping.new(c) })
12
+ end
13
+
14
+ def initialize(table, model = ActiveRecord::Base)
8
15
  @table = table
16
+ @model = model
9
17
  @conditions = []
10
18
  end
11
19
 
@@ -15,11 +23,7 @@ module SimpleQuery
15
23
  end
16
24
 
17
25
  def to_arel
18
- return nil if @conditions.empty?
19
-
20
- @conditions.inject do |combined, current|
21
- combined.and(current)
22
- end
26
+ self.class.combine(@conditions)
23
27
  end
24
28
 
25
29
  private
@@ -27,15 +31,36 @@ module SimpleQuery
27
31
  def parse_condition(condition)
28
32
  case condition
29
33
  when Hash
30
- condition.map { |field, value| @table[field].eq(value) }
31
- when Arel::Nodes::Node
34
+ condition.map { |field, value| hash_predicate(field, value) }
35
+ when Arel::Nodes::Node, Arel::Attributes::Attribute
32
36
  [condition]
33
37
  when Array
34
- sanitized_sql = ActiveRecord::Base.send(:sanitize_sql_array, condition)
38
+ sanitized_sql = @model.send(:sanitize_sql_array, condition)
35
39
  [Arel.sql(sanitized_sql)]
36
40
  else
37
41
  [Arel.sql(condition.to_s)]
38
42
  end
39
43
  end
44
+
45
+ def hash_predicate(field, value)
46
+ attribute = @table[field]
47
+ return attribute.eq(value) if force_equality?(field, value)
48
+
49
+ case value
50
+ when Array
51
+ values = value.compact
52
+ predicate = attribute.in(values)
53
+ values.size == value.size ? predicate : predicate.or(attribute.eq(nil))
54
+ when Range
55
+ attribute.between(value)
56
+ else
57
+ attribute.eq(value)
58
+ end
59
+ end
60
+
61
+ # PostgreSQL array/range columns compare arrays and ranges by equality, like ActiveRecord does.
62
+ def force_equality?(field, value)
63
+ @table.able_to_type_cast? && @table.type_for_attribute(field.to_s).force_equality?(value)
64
+ end
40
65
  end
41
66
  end
@@ -6,12 +6,17 @@ module SimpleQuery
6
6
  def stream_each_mysql(&block)
7
7
  select_sql = cached_sql
8
8
 
9
- raw_conn = ActiveRecord::Base.connection.raw_connection
9
+ raw_conn = connection.raw_connection
10
10
 
11
11
  result = raw_conn.query(select_sql, stream: true, cache_rows: false, as: :hash)
12
- result.each do |mysql_row|
13
- record = build_row_object_mysql(mysql_row)
14
- block.call(record)
12
+ begin
13
+ result.each do |mysql_row|
14
+ record = build_row_object_mysql(mysql_row)
15
+ block.call(record)
16
+ end
17
+ ensure
18
+ # Discards unread rows so the connection can run the next query after an error or break.
19
+ result.free
15
20
  end
16
21
  end
17
22