Skip to content

Commit 15921d7

Browse files
committed
fix SQL generation issues and add comprehensive tests
- Add GROUP BY support in string builder compilation path - Implement HAVING clause support for both Arel and string builder - Add validation to prevent empty array IN clauses (syntax error) - Add comprehensive tests for compiler, validator, runner, explain_gate - Fix query_spec tests for safe? and binds methods
1 parent 1e6681c commit 15921d7

File tree

6 files changed

+1584
-16
lines changed

6 files changed

+1584
-16
lines changed

lib/code_to_query/compiler.rb

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
end
1010

1111
module CodeToQuery
12+
# rubocop:disable Metrics/ClassLength
1213
class Compiler
1314
def initialize(config)
1415
@config = config
@@ -182,6 +183,18 @@ def compile_with_arel(intent)
182183
end
183184
end
184185

186+
if (having_filters = intent['having']).present?
187+
having_filters.each do |h|
188+
agg_node = build_arel_aggregate(table, h)
189+
next unless agg_node
190+
191+
key = h['param'] || "having_#{h['column']}"
192+
bind_spec << { key: key, column: h['column'], cast: nil }
193+
condition = build_arel_having_condition(agg_node, h['op'], key)
194+
query = query.having(condition) if condition
195+
end
196+
end
197+
185198
if (limit = determine_appropriate_limit(intent))
186199
query = query.take(limit)
187200
end
@@ -197,7 +210,7 @@ def compile_with_arel(intent)
197210
compile_with_string_building(intent)
198211
end
199212

200-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/BlockLength
213+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/BlockLength, Metrics/CyclomaticComplexity
201214
# NOTE: This method is intentionally monolithic for clarity and to avoid regressions in SQL assembly.
202215
# TODO: Extract EXISTS/NOT EXISTS handling and simple predicate building into small helpers.
203216
def compile_with_string_building(intent)
@@ -300,6 +313,8 @@ def compile_with_string_building(intent)
300313
sub_where << "#{rcol} BETWEEN #{placeholder1} AND #{placeholder2}"
301314
when 'in'
302315
key = rf['param'] || rf['column']
316+
values = params_hash[key] || params_hash[key.to_s] || params_hash[key.to_sym]
317+
validate_in_clause_values!(values, rf['column'])
303318
placeholder = placeholder_for_adapter(placeholder_index)
304319
bind_spec << ({ key: key, column: rf['column'], cast: :array })
305320
placeholder_index += 1
@@ -363,6 +378,8 @@ def compile_with_string_building(intent)
363378
sub_where << "#{rcol} BETWEEN #{placeholder1} AND #{placeholder2}"
364379
when 'in'
365380
key = rf['param'] || rf['column']
381+
values = params_hash[key] || params_hash[key.to_s] || params_hash[key.to_sym]
382+
validate_in_clause_values!(values, rf['column'])
366383
placeholder = placeholder_for_adapter(placeholder_index)
367384
bind_spec << { key: key, column: rf['column'], cast: :array }
368385
placeholder_index += 1
@@ -394,7 +411,11 @@ def compile_with_string_building(intent)
394411
"#{col} BETWEEN #{placeholder1} AND #{placeholder2}"
395412
when 'in'
396413
key = f['param'] || f['column']
397-
# For IN clauses, we'll need to handle arrays specially
414+
values = params_hash[key] || params_hash[key.to_s] || params_hash[key.to_sym]
415+
if values.is_a?(Array) && values.empty?
416+
raise ArgumentError, "IN clause requires non-empty array for column '#{f['column']}'"
417+
end
418+
398419
placeholder = placeholder_for_adapter(placeholder_index)
399420
bind_spec << { key: key, column: f['column'], cast: :array }
400421
placeholder_index += 1
@@ -412,6 +433,22 @@ def compile_with_string_building(intent)
412433
sql_parts << "WHERE #{where_fragments.join(' AND ')}" if where_fragments.any?
413434
end
414435

436+
if (group_columns = intent['group_by']).present?
437+
group_fragments = group_columns.map { |col| quote_ident(col) }
438+
sql_parts << "GROUP BY #{group_fragments.join(', ')}"
439+
end
440+
441+
if (having_filters = intent['having']).present?
442+
having_fragments = having_filters.map do |h|
443+
agg_expr = build_aggregate_expression(h)
444+
placeholder = placeholder_for_adapter(placeholder_index)
445+
bind_spec << { key: h['param'] || "having_#{h['column']}", column: h['column'], cast: nil }
446+
placeholder_index += 1
447+
"#{agg_expr} #{h['op']} #{placeholder}"
448+
end
449+
sql_parts << "HAVING #{having_fragments.join(' AND ')}"
450+
end
451+
415452
if (orders = intent['order']).present?
416453
order_fragments = orders.map do |o|
417454
dir = o['dir'].to_s.downcase == 'desc' ? 'DESC' : 'ASC'
@@ -426,7 +463,7 @@ def compile_with_string_building(intent)
426463

427464
{ sql: sql_parts.join(' '), params: params_hash, bind_spec: bind_spec }
428465
end
429-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/BlockLength
466+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/BlockLength, Metrics/CyclomaticComplexity
430467

431468
def apply_policy_in_subquery(sub_where, bind_spec, related_table, placeholder_index)
432469
return [sub_where, placeholder_index] unless @config.policy_adapter.respond_to?(:call)
@@ -614,13 +651,86 @@ def parse_function_column(expr)
614651
s = expr.to_s.strip
615652
return nil unless s.include?('(') && s.end_with?(')')
616653

617-
if (m = s.match(/\A\s*(count|sum|avg|max|min)\s*\(\s*(\*|[a-zA-Z0-9_\.]+)\s*\)\s*\z/i))
654+
if (m = s.match(/\A\s*(count|sum|avg|max|min)\s*\(\s*(\*|[a-zA-Z0-9_.]+)\s*\)\s*\z/i))
618655
func = m[1].downcase
619656
col = m[2] == '*' ? nil : m[2]
620657
{ func: func, column: col }
621658
end
622659
end
623660

661+
def build_aggregate_expression(having_spec)
662+
func = having_spec['function'].to_s.upcase
663+
col = having_spec['column']
664+
665+
case func
666+
when 'COUNT'
667+
col ? "COUNT(#{quote_ident(col)})" : 'COUNT(*)'
668+
when 'SUM'
669+
"SUM(#{quote_ident(col)})"
670+
when 'AVG'
671+
"AVG(#{quote_ident(col)})"
672+
when 'MAX'
673+
"MAX(#{quote_ident(col)})"
674+
when 'MIN'
675+
"MIN(#{quote_ident(col)})"
676+
else
677+
'COUNT(*)'
678+
end
679+
end
680+
681+
def validate_in_clause_values!(values, column)
682+
return unless values.is_a?(Array) && values.empty?
683+
684+
raise ArgumentError, "IN clause requires non-empty array for column '#{column}'"
685+
end
686+
687+
def build_arel_aggregate(table, having_spec)
688+
func = having_spec['function'].to_s.downcase
689+
col = having_spec['column']
690+
691+
case func
692+
when 'count'
693+
col ? table[col].count : Arel.star.count
694+
when 'sum'
695+
return nil unless col
696+
697+
table[col].sum
698+
when 'avg'
699+
return nil unless col
700+
701+
table[col].average
702+
when 'max'
703+
return nil unless col
704+
705+
table[col].maximum
706+
when 'min'
707+
return nil unless col
708+
709+
table[col].minimum
710+
else
711+
Arel.star.count
712+
end
713+
end
714+
715+
def build_arel_having_condition(agg_node, operator, key)
716+
bind_param = Arel::Nodes::BindParam.new(key)
717+
718+
case operator
719+
when '='
720+
agg_node.eq(bind_param)
721+
when '!='
722+
agg_node.not_eq(bind_param)
723+
when '>'
724+
agg_node.gt(bind_param)
725+
when '>='
726+
agg_node.gteq(bind_param)
727+
when '<'
728+
agg_node.lt(bind_param)
729+
when '<='
730+
agg_node.lteq(bind_param)
731+
end
732+
end
733+
624734
def normalize_params_with_model(intent)
625735
params = (intent['params'] || {}).dup
626736
return params unless defined?(ActiveRecord::Base)
@@ -671,4 +781,5 @@ def infer_model_for_table(table_name)
671781
nil
672782
end
673783
end
784+
# rubocop:enable Metrics/ClassLength
674785
end

0 commit comments

Comments
 (0)