99end
1010
1111module 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
674785end
0 commit comments