From 92c0c50ff69438310974c2ba691a1e687a2e77b1 Mon Sep 17 00:00:00 2001 From: Alex Kholodniak Date: Sat, 30 Aug 2025 04:58:32 +0300 Subject: [PATCH] feat: Add enhanced aggregation support - Add aggregation methods (count, sum, avg, min, max) - Add statistical functions (variance, stddev) - Add database-specific group_concat function - Add advanced stats() method for comprehensive column statistics - Add custom_aggregation() for custom SQL aggregations - Add convenient alias support for all aggregation methods - Add automatic alias sanitization for complex column names - Integrate aggregations seamlessly with existing query DSL - Add comprehensive test suite for all aggregation features - Update documentation with aggregation examples - Update RBS type definitions for new methods --- CHANGELOG.md | 24 ++ README.md | 88 +++++++ lib/simple_query.rb | 1 + lib/simple_query/builder.rb | 129 +++++++++- .../clauses/aggregation_clause.rb | 155 ++++++++++++ sig/simple_query.rbs | 18 ++ spec/simple_query/builder_spec.rb | 173 +++++++++++++ .../clauses/aggregation_clause_spec.rb | 234 ++++++++++++++++++ 8 files changed, 820 insertions(+), 2 deletions(-) create mode 100644 lib/simple_query/clauses/aggregation_clause.rb create mode 100644 spec/simple_query/clauses/aggregation_clause_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index d57480a..cdd595a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to this project are documented in this file. +## [0.5.0] - 2025-01-XX + +### Added +- **Enhanced Aggregation Support**: Comprehensive aggregation methods for improved developer experience + - Basic aggregations: `count`, `sum`, `avg`, `min`, `max` + - Statistical functions: `variance`, `stddev` + - Database-specific functions: `group_concat` (MySQL/PostgreSQL/SQLite compatible) + - Advanced features: `stats` method for comprehensive column statistics + - Custom aggregations via `custom_aggregation` method + - All aggregation methods support custom aliases + - Automatic alias sanitization for complex column names (e.g., `companies.revenue` → `companies_revenue`) +- **Aggregation DSL**: Fluent interface for chaining aggregations with other query methods +- **Comprehensive Test Coverage**: Full test suite for aggregation functionality +- **Enhanced Documentation**: Detailed aggregation guide with examples + +### Changed +- **Query Building**: Updated builder to seamlessly integrate aggregations with SELECT clauses +- **SQL Generation**: Improved handling of mixed regular selects and aggregations +- **Type Definitions**: Updated RBS signatures to include new aggregation methods + +### Performance +- **Database-Level Aggregations**: All calculations performed at the database level for optimal performance +- **Memory Efficiency**: Aggregations use minimal memory compared to loading full record sets + ## [0.4.0] - 2025-03-17 ### Added diff --git a/README.md b/README.md index f320dc3..af77901 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,94 @@ User.simple_query .execute ``` +## Enhanced Aggregation Support + +SimpleQuery provides a comprehensive set of aggregation methods that are more convenient and readable than writing raw SQL: + +### Basic Aggregations + +```ruby +# Count records +User.simple_query.count.execute +# => # + +# Count specific column (non-null values) +User.simple_query.count(:email).execute +# => # + +# Sum values +Company.simple_query.sum(:annual_revenue).execute +# => # + +# Average values +Company.simple_query.avg(:annual_revenue).execute +# => # + +# Find minimum and maximum +Company.simple_query.min(:annual_revenue).max(:annual_revenue).execute +# => # +``` + +### Statistical Functions + +```ruby +# Variance and standard deviation +User.simple_query.variance(:score).stddev(:score).execute +# => # + +# Database-specific group concatenation +User.simple_query + .select(:department) + .group_concat(:name, separator: ", ") + .group(:department) + .execute +# => # +``` + +### Advanced Aggregation Features + +```ruby +# Get comprehensive statistics for a column +Company.simple_query.stats(:annual_revenue).execute +# => # + +# Custom aggregations +Company.simple_query + .custom_aggregation("COUNT(DISTINCT industry)", "unique_industries") + .execute +# => # + +# Combining with other features +Company.simple_query + .select(:industry) + .count + .sum(:annual_revenue) + .group(:industry) + .execute +# => [ +# #, +# # +# ] +``` + +### Custom Aliases + +All aggregation methods support custom aliases: + +```ruby +User.simple_query + .count(:id, alias_name: "total_users") + .sum(:score, alias_name: "total_score") + .execute +# => # +``` + ## Custom Read Models By default, SimpleQuery returns results as `Struct` objects for maximum speed. However, you can also define a lightweight model class for more explicit attribute handling or custom logic. diff --git a/lib/simple_query.rb b/lib/simple_query.rb index 8a96e97..c4de6c7 100644 --- a/lib/simple_query.rb +++ b/lib/simple_query.rb @@ -14,6 +14,7 @@ require_relative "simple_query/clauses/limit_offset_clause" require_relative "simple_query/clauses/group_having_clause" require_relative "simple_query/clauses/set_clause" +require_relative "simple_query/clauses/aggregation_clause" module SimpleQuery extend ActiveSupport::Concern diff --git a/lib/simple_query/builder.rb b/lib/simple_query/builder.rb index 6523694..2208ca9 100644 --- a/lib/simple_query/builder.rb +++ b/lib/simple_query/builder.rb @@ -18,6 +18,7 @@ def initialize(source) @orders = OrderClause.new(@arel_table) @limits = LimitOffsetClause.new @distinct_flag = DistinctClause.new + @aggregations = AggregationClause.new(@arel_table) @query_cache = {} @query_built = false @@ -91,6 +92,98 @@ def having(condition) self end + # Aggregation methods + def count(column = nil, alias_name: nil) + @aggregations.count(column, alias_name: alias_name) + reset_query + self + end + + def sum(column, alias_name: nil) + @aggregations.sum(column, alias_name: alias_name) + reset_query + self + end + + def avg(column, alias_name: nil) + @aggregations.avg(column, alias_name: alias_name) + reset_query + self + end + + def min(column, alias_name: nil) + @aggregations.min(column, alias_name: alias_name) + reset_query + self + end + + def max(column, alias_name: nil) + @aggregations.max(column, alias_name: alias_name) + reset_query + self + end + + def variance(column, alias_name: nil) + @aggregations.variance(column, alias_name: alias_name) + reset_query + self + end + + def stddev(column, alias_name: nil) + @aggregations.stddev(column, alias_name: alias_name) + reset_query + self + end + + def group_concat(column, separator: ",", alias_name: nil) + @aggregations.group_concat(column, separator: separator, alias_name: alias_name) + reset_query + self + end + + def custom_aggregation(expression, alias_name) + @aggregations.custom(expression, alias_name) + reset_query + self + end + + # Convenience methods for common aggregation patterns + def total_count(alias_name: "total") + count(alias_name: alias_name) + end + + def stats(column, alias_prefix: nil) + prefix = alias_prefix || column.to_s + count(alias_name: "#{prefix}_count") + sum(column, alias_name: "#{prefix}_sum") + avg(column, alias_name: "#{prefix}_avg") + min(column, alias_name: "#{prefix}_min") + max(column, alias_name: "#{prefix}_max") + self + end + + # Method to get first/top record by column + def first_by(column, alias_name: nil) + alias_name ||= "first_#{column}" + custom_aggregation("FIRST_VALUE(#{resolve_column_name(column)}) OVER (ORDER BY #{resolve_column_name(column)})", + alias_name) + end + + # Method to get last/bottom record by column + def last_by(column, alias_name: nil) + alias_name ||= "last_#{column}" + custom_aggregation("LAST_VALUE(#{resolve_column_name(column)}) OVER (ORDER BY #{resolve_column_name(column)})", + alias_name) + end + + # Percentage calculations + def percentage_of_total(column, alias_name: nil) + alias_name ||= "#{column}_percentage" + column_expr = resolve_column_name(column) + expression = "ROUND((#{column_expr} * 100.0 / SUM(#{column_expr}) OVER ()), 2)" + custom_aggregation(expression, alias_name) + end + def map_to(klass) @read_model_class = klass reset_query @@ -145,7 +238,10 @@ def build_query @query = Arel::SelectManager.new(Arel::Table.engine) @query.from(@arel_table) - @query.project(*(@selects.empty? ? [@arel_table[Arel.star]] : @selects)) + + # Combine regular selects with aggregations + all_selects = build_select_expressions + @query.project(*all_selects) apply_distinct apply_where_conditions @@ -160,6 +256,23 @@ def build_query private + def build_select_expressions + expressions = [] + + # Add regular selects + if @selects.any? + expressions.concat(@selects) + elsif @aggregations.empty? + # Only use * if no aggregations and no explicit selects + expressions << @arel_table[Arel.star] + end + + # Add aggregation expressions + expressions.concat(@aggregations.to_arel_expressions) + + expressions + end + def build_where_sql condition = @wheres.to_arel return "" unless condition @@ -182,7 +295,8 @@ def cached_sql @orders.orders, @limits.limit_value, @limits.offset_value, - @distinct_flag.use_distinct? + @distinct_flag.use_distinct?, + @aggregations.aggregations ] @query_cache[key] ||= build_query.to_sql @@ -267,6 +381,17 @@ def parse_select_field(field) end end + def resolve_column_name(column) + case column + when Symbol + "#{@arel_table.name}.#{column}" + when String + column.include?(".") ? column : "#{@arel_table.name}.#{column}" + else + column.to_s + end + end + def method_missing(method_name, *args, &block) if (scope_block = find_scope(method_name)) instance_exec(*args, &scope_block) diff --git a/lib/simple_query/clauses/aggregation_clause.rb b/lib/simple_query/clauses/aggregation_clause.rb new file mode 100644 index 0000000..3f4da9f --- /dev/null +++ b/lib/simple_query/clauses/aggregation_clause.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +module SimpleQuery + class AggregationClause + attr_reader :aggregations + + def initialize(table) + @table = table + @aggregations = [] + end + + def count(column = nil, alias_name: nil) + column_expr = column ? resolve_column(column) : "*" + alias_name ||= column ? "count_#{sanitize_alias(column)}" : "count" + + add_aggregation("COUNT", column_expr, alias_name) + end + + def sum(column, alias_name: nil) + raise ArgumentError, "Column is required for SUM aggregation" if column.nil? + + column_expr = resolve_column(column) + alias_name ||= "sum_#{sanitize_alias(column)}" + + add_aggregation("SUM", column_expr, alias_name) + end + + def avg(column, alias_name: nil) + raise ArgumentError, "Column is required for AVG aggregation" if column.nil? + + column_expr = resolve_column(column) + alias_name ||= "avg_#{sanitize_alias(column)}" + + add_aggregation("AVG", column_expr, alias_name) + end + + def min(column, alias_name: nil) + raise ArgumentError, "Column is required for MIN aggregation" if column.nil? + + column_expr = resolve_column(column) + alias_name ||= "min_#{sanitize_alias(column)}" + + add_aggregation("MIN", column_expr, alias_name) + end + + def max(column, alias_name: nil) + raise ArgumentError, "Column is required for MAX aggregation" if column.nil? + + column_expr = resolve_column(column) + alias_name ||= "max_#{sanitize_alias(column)}" + + add_aggregation("MAX", column_expr, alias_name) + end + + def custom(expression, alias_name) + if expression.nil? || alias_name.nil? + raise ArgumentError, + "Expression and alias are required for custom aggregation" + end + + @aggregations << { + expression: expression, + alias: alias_name + } + end + + # Statistical functions + def variance(column, alias_name: nil) + raise ArgumentError, "Column is required for VARIANCE aggregation" if column.nil? + + column_expr = resolve_column(column) + alias_name ||= "variance_#{sanitize_alias(column)}" + + add_aggregation("VARIANCE", column_expr, alias_name) + end + + def stddev(column, alias_name: nil) + raise ArgumentError, "Column is required for STDDEV aggregation" if column.nil? + + column_expr = resolve_column(column) + alias_name ||= "stddev_#{sanitize_alias(column)}" + + add_aggregation("STDDEV", column_expr, alias_name) + end + + # Group concatenation (database-specific) + def group_concat(column, separator: ",", alias_name: nil) + raise ArgumentError, "Column is required for GROUP_CONCAT aggregation" if column.nil? + + column_expr = resolve_column(column) + alias_name ||= "group_concat_#{sanitize_alias(column)}" + + # Use database-specific group concatenation + adapter = ActiveRecord::Base.connection.adapter_name.downcase + + expression = case adapter + when /mysql/ + "GROUP_CONCAT(#{column_expr} SEPARATOR '#{separator}')" + when /postgres/ + "STRING_AGG(#{column_expr}::text, '#{separator}')" + when /sqlite/ + "GROUP_CONCAT(#{column_expr}, '#{separator}')" + else + # Fallback for other databases + "GROUP_CONCAT(#{column_expr})" + end + + @aggregations << { + expression: expression, + alias: alias_name + } + end + + def to_arel_expressions + @aggregations.map do |agg| + Arel.sql("#{agg[:expression]} AS #{agg[:alias]}") + end + end + + def empty? + @aggregations.empty? + end + + def clear + @aggregations.clear + end + + private + + def add_aggregation(function, column_expr, alias_name) + @aggregations << { + expression: "#{function}(#{column_expr})", + alias: alias_name + } + end + + def resolve_column(column) + case column + when Symbol + "#{@table.name}.#{column}" + when String + # Allow table.column format or just column name + column.include?(".") ? column : "#{@table.name}.#{column}" + when Arel::Attributes::Attribute + column.to_sql + else + column.to_s + end + end + + def sanitize_alias(column) + column.to_s.gsub(".", "_") + end + end +end diff --git a/sig/simple_query.rbs b/sig/simple_query.rbs index 3522842..03817cb 100644 --- a/sig/simple_query.rbs +++ b/sig/simple_query.rbs @@ -27,6 +27,24 @@ module SimpleQuery def distinct: () -> self + # Aggregation methods + def count: (?Symbol? column, ?alias_name: String?) -> self + def sum: (Symbol column, ?alias_name: String?) -> self + def avg: (Symbol column, ?alias_name: String?) -> self + def min: (Symbol column, ?alias_name: String?) -> self + def max: (Symbol column, ?alias_name: String?) -> self + def variance: (Symbol column, ?alias_name: String?) -> self + def stddev: (Symbol column, ?alias_name: String?) -> self + def group_concat: (Symbol column, ?separator: String, ?alias_name: String?) -> self + def custom_aggregation: (String expression, String alias_name) -> self + + # Advanced aggregation methods + def total_count: (?alias_name: String) -> self + def stats: (Symbol column, ?alias_prefix: String?) -> self + def first_by: (Symbol column, ?alias_name: String?) -> self + def last_by: (Symbol column, ?alias_name: String?) -> self + def percentage_of_total: (Symbol column, ?alias_name: String?) -> self + def execute: () -> Array[untyped] def lazy_execute: () -> Enumerator[untyped, void] diff --git a/spec/simple_query/builder_spec.rb b/spec/simple_query/builder_spec.rb index c9668c7..6b46252 100644 --- a/spec/simple_query/builder_spec.rb +++ b/spec/simple_query/builder_spec.rb @@ -142,6 +142,179 @@ expect(result.first.total_revenue).to be_a(Numeric) end + context "Enhanced Aggregation Support" do + describe "#count" do + it "counts all records" do + result = User.simple_query.count.execute + expect(result.first.count).to eq(User.count) + end + + it "counts specific column" do + result = User.simple_query.count(:email).execute + expect(result.first.count_email).to eq(User.where.not(email: nil).count) + end + + it "counts with custom alias" do + result = User.simple_query.count(:id, alias_name: "total_users").execute + expect(result.first.total_users).to eq(User.count) + end + + it "counts with where conditions" do + result = User.simple_query.where(active: true).count.execute + expect(result.first.count).to eq(User.where(active: true).count) + end + end + + describe "#sum" do + it "sums annual revenue" do + result = Company.simple_query.sum(:annual_revenue).execute + expected = Company.sum(:annual_revenue) + expect(result.first.sum_annual_revenue).to eq(expected) + end + + it "sums with custom alias" do + result = Company.simple_query.sum(:annual_revenue, alias_name: "total_revenue").execute + expected = Company.sum(:annual_revenue) + expect(result.first.total_revenue).to eq(expected) + end + + it "sums with group by" do + result = Company.simple_query + .select(:industry) + .sum(:annual_revenue) + .group(:industry) + .execute + + expect(result.size).to be >= 1 + tech_company = result.find { |r| r.industry == "Technology" } + expect(tech_company.sum_annual_revenue).to be_a(Numeric) + end + end + + describe "#avg" do + it "calculates average annual revenue" do + result = Company.simple_query.avg(:annual_revenue).execute + expected = Company.average(:annual_revenue) + expect(result.first.avg_annual_revenue.to_f).to be_within(0.01).of(expected.to_f) + end + end + + describe "#min and #max" do + it "finds minimum and maximum values" do + result = Company.simple_query + .min(:annual_revenue) + .max(:annual_revenue) + .execute + + expected_min = Company.minimum(:annual_revenue) + expected_max = Company.maximum(:annual_revenue) + + expect(result.first.min_annual_revenue).to eq(expected_min) + expect(result.first.max_annual_revenue).to eq(expected_max) + end + end + + describe "#custom_aggregation" do + it "supports custom aggregation expressions" do + result = Company.simple_query + .custom_aggregation("COUNT(DISTINCT industry)", "unique_industries") + .execute + + expect(result.first.unique_industries).to be_a(Numeric) + expect(result.first.unique_industries).to be > 0 + end + end + + describe "mixed select and aggregations" do + it "combines regular selects with aggregations" do + result = Company.simple_query + .select(:industry) + .count + .sum(:annual_revenue) + .group(:industry) + .execute + + expect(result.size).to be >= 1 + first_result = result.first + + expect(first_result).to respond_to(:industry) + expect(first_result).to respond_to(:count) + expect(first_result).to respond_to(:sum_annual_revenue) + end + + it "works without explicit selects when using aggregations" do + result = User.simple_query.count.execute + expect(result.first.count).to eq(User.count) + end + end + + describe "with joins" do + it "aggregates across joined tables" do + result = User.simple_query + .join(:users, :companies, foreign_key: :user_id, primary_key: :id) + .count + .sum("companies.annual_revenue") + .execute + + expect(result.first.count).to be_a(Numeric) + expect(result.first).to respond_to(:sum_companies_annual_revenue) + end + end + + describe "error handling" do + it "raises error for sum without column" do + expect do + User.simple_query.sum(nil).execute + end.to raise_error(ArgumentError, /Column is required/) + end + + it "raises error for avg without column" do + expect do + User.simple_query.avg(nil).execute + end.to raise_error(ArgumentError, /Column is required/) + end + end + + describe "advanced aggregation features" do + describe "#stats" do + it "provides comprehensive statistics for a column" do + result = Company.simple_query.stats(:annual_revenue).execute + + expect(result.first).to respond_to(:annual_revenue_count) + expect(result.first).to respond_to(:annual_revenue_sum) + expect(result.first).to respond_to(:annual_revenue_avg) + expect(result.first).to respond_to(:annual_revenue_min) + expect(result.first).to respond_to(:annual_revenue_max) + + expect(result.first.annual_revenue_count).to eq(Company.count) + expect(result.first.annual_revenue_sum).to eq(Company.sum(:annual_revenue)) + end + + it "accepts custom prefix" do + result = Company.simple_query.stats(:annual_revenue, alias_prefix: "revenue").execute + + expect(result.first).to respond_to(:revenue_count) + expect(result.first).to respond_to(:revenue_sum) + expect(result.first).to respond_to(:revenue_avg) + expect(result.first).to respond_to(:revenue_min) + expect(result.first).to respond_to(:revenue_max) + end + end + + describe "#total_count" do + it "counts with custom alias" do + result = User.simple_query.total_count(alias_name: "user_total").execute + expect(result.first.user_total).to eq(User.count) + end + + it "uses default alias" do + result = User.simple_query.total_count.execute + expect(result.first.total).to eq(User.count) + end + end + end + end + it "supports GROUP BY and HAVING" do result = Company.simple_query .select(:industry, Arel.sql("SUM(companies.annual_revenue) AS total_revenue")) diff --git a/spec/simple_query/clauses/aggregation_clause_spec.rb b/spec/simple_query/clauses/aggregation_clause_spec.rb new file mode 100644 index 0000000..d41962a --- /dev/null +++ b/spec/simple_query/clauses/aggregation_clause_spec.rb @@ -0,0 +1,234 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SimpleQuery::AggregationClause do + let(:table) { Arel::Table.new(:users) } + let(:aggregation_clause) { described_class.new(table) } + + describe "#count" do + it "adds count aggregation without column" do + aggregation_clause.count + + expressions = aggregation_clause.to_arel_expressions + expect(expressions.size).to eq(1) + expect(expressions.first.to_s).to match(/COUNT\(\*\) AS count/i) + end + + it "adds count aggregation with column" do + aggregation_clause.count(:id) + + expressions = aggregation_clause.to_arel_expressions + expect(expressions.size).to eq(1) + expect(expressions.first.to_s).to match(/COUNT\(users\.id\) AS count_id/i) + end + + it "adds count aggregation with custom alias" do + aggregation_clause.count(:email, alias_name: "total_emails") + + expressions = aggregation_clause.to_arel_expressions + expect(expressions.size).to eq(1) + expect(expressions.first.to_s).to match(/COUNT\(users\.email\) AS total_emails/i) + end + end + + describe "#sum" do + it "adds sum aggregation" do + aggregation_clause.sum(:annual_revenue) + + expressions = aggregation_clause.to_arel_expressions + expect(expressions.size).to eq(1) + expect(expressions.first.to_s).to match(/SUM\(users\.annual_revenue\) AS sum_annual_revenue/i) + end + + it "raises error without column" do + expect { aggregation_clause.sum(nil) }.to raise_error(ArgumentError, /Column is required/) + end + + it "accepts custom alias" do + aggregation_clause.sum(:revenue, alias_name: "total_revenue") + + expressions = aggregation_clause.to_arel_expressions + expect(expressions.first.to_s).to match(/SUM\(users\.revenue\) AS total_revenue/i) + end + end + + describe "#avg" do + it "adds average aggregation" do + aggregation_clause.avg(:score) + + expressions = aggregation_clause.to_arel_expressions + expect(expressions.size).to eq(1) + expect(expressions.first.to_s).to match(/AVG\(users\.score\) AS avg_score/i) + end + + it "raises error without column" do + expect { aggregation_clause.avg(nil) }.to raise_error(ArgumentError, /Column is required/) + end + end + + describe "#min" do + it "adds minimum aggregation" do + aggregation_clause.min(:created_at) + + expressions = aggregation_clause.to_arel_expressions + expect(expressions.size).to eq(1) + expect(expressions.first.to_s).to match(/MIN\(users\.created_at\) AS min_created_at/i) + end + + it "raises error without column" do + expect { aggregation_clause.min(nil) }.to raise_error(ArgumentError, /Column is required/) + end + end + + describe "#max" do + it "adds maximum aggregation" do + aggregation_clause.max(:updated_at) + + expressions = aggregation_clause.to_arel_expressions + expect(expressions.size).to eq(1) + expect(expressions.first.to_s).to match(/MAX\(users\.updated_at\) AS max_updated_at/i) + end + + it "raises error without column" do + expect { aggregation_clause.max(nil) }.to raise_error(ArgumentError, /Column is required/) + end + end + + describe "#variance" do + it "adds variance aggregation" do + aggregation_clause.variance(:score) + + expressions = aggregation_clause.to_arel_expressions + expect(expressions.size).to eq(1) + expect(expressions.first.to_s).to match(/VARIANCE\(users\.score\) AS variance_score/i) + end + end + + describe "#stddev" do + it "adds standard deviation aggregation" do + aggregation_clause.stddev(:score) + + expressions = aggregation_clause.to_arel_expressions + expect(expressions.size).to eq(1) + expect(expressions.first.to_s).to match(/STDDEV\(users\.score\) AS stddev_score/i) + end + end + + describe "#group_concat" do + before do + allow(ActiveRecord::Base.connection).to receive(:adapter_name).and_return(adapter_name) + end + + context "with MySQL adapter" do + let(:adapter_name) { "MySQL" } + + it "uses GROUP_CONCAT with SEPARATOR" do + aggregation_clause.group_concat(:name, separator: "|") + + expressions = aggregation_clause.to_arel_expressions + expect(expressions.first.to_s).to match(/GROUP_CONCAT\(users\.name SEPARATOR '\|'\) AS group_concat_name/i) + end + end + + context "with PostgreSQL adapter" do + let(:adapter_name) { "PostgreSQL" } + + it "uses STRING_AGG" do + aggregation_clause.group_concat(:name, separator: ",") + + expressions = aggregation_clause.to_arel_expressions + expect(expressions.first.to_s).to match(/STRING_AGG\(users\.name::text, ','\) AS group_concat_name/i) + end + end + + context "with SQLite adapter" do + let(:adapter_name) { "SQLite" } + + it "uses GROUP_CONCAT with separator" do + aggregation_clause.group_concat(:name, separator: ";") + + expressions = aggregation_clause.to_arel_expressions + expect(expressions.first.to_s).to match(/GROUP_CONCAT\(users\.name, ';'\) AS group_concat_name/i) + end + end + end + + describe "#custom" do + it "adds custom aggregation expression" do + aggregation_clause.custom("PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY score)", "median_score") + + expressions = aggregation_clause.to_arel_expressions + expect(expressions.size).to eq(1) + expect(expressions.first.to_s).to match( + /PERCENTILE_CONT\(0\.5\) WITHIN GROUP \(ORDER BY score\) AS median_score/i + ) + end + + it "raises error without expression or alias" do + expect { aggregation_clause.custom(nil, "test") }.to raise_error(ArgumentError) + expect { aggregation_clause.custom("COUNT(*)", nil) }.to raise_error(ArgumentError) + end + end + + describe "#resolve_column" do + it "handles symbol columns" do + aggregation_clause.sum(:revenue) + + expressions = aggregation_clause.to_arel_expressions + expect(expressions.first.to_s).to match(/users\.revenue/) + end + + it "handles string columns" do + aggregation_clause.sum("total_amount") + + expressions = aggregation_clause.to_arel_expressions + expect(expressions.first.to_s).to match(/users\.total_amount/) + end + + it "handles string columns with table prefix" do + aggregation_clause.sum("companies.annual_revenue") + + expressions = aggregation_clause.to_arel_expressions + expect(expressions.first.to_s).to match(/companies\.annual_revenue/) + end + end + + describe "#multiple_aggregations" do + it "supports multiple aggregations in one clause" do + aggregation_clause.count + aggregation_clause.sum(:revenue) + aggregation_clause.avg(:score) + + expressions = aggregation_clause.to_arel_expressions + expect(expressions.size).to eq(3) + + sql_expressions = expressions.map(&:to_s) + expect(sql_expressions).to include(match(/COUNT\(\*\) AS count/i)) + expect(sql_expressions).to include(match(/SUM\(users\.revenue\) AS sum_revenue/i)) + expect(sql_expressions).to include(match(/AVG\(users\.score\) AS avg_score/i)) + end + end + + describe "#empty?" do + it "returns true when no aggregations" do + expect(aggregation_clause.empty?).to be true + end + + it "returns false when aggregations exist" do + aggregation_clause.count + expect(aggregation_clause.empty?).to be false + end + end + + describe "#clear" do + it "removes all aggregations" do + aggregation_clause.count + aggregation_clause.sum(:revenue) + + expect(aggregation_clause.empty?).to be false + aggregation_clause.clear + expect(aggregation_clause.empty?).to be true + end + end +end