Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 88 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
# => #<struct count=1000>

# Count specific column (non-null values)
User.simple_query.count(:email).execute
# => #<struct count_email=995>

# Sum values
Company.simple_query.sum(:annual_revenue).execute
# => #<struct sum_annual_revenue=50000000>

# Average values
Company.simple_query.avg(:annual_revenue).execute
# => #<struct avg_annual_revenue=1000000.5>

# Find minimum and maximum
Company.simple_query.min(:annual_revenue).max(:annual_revenue).execute
# => #<struct min_annual_revenue=100000, max_annual_revenue=5000000>
```

### Statistical Functions

```ruby
# Variance and standard deviation
User.simple_query.variance(:score).stddev(:score).execute
# => #<struct variance_score=125.67, stddev_score=11.21>

# Database-specific group concatenation
User.simple_query
.select(:department)
.group_concat(:name, separator: ", ")
.group(:department)
.execute
# => #<struct department="Engineering", group_concat_name="Alice, Bob, Charlie">
```

### Advanced Aggregation Features

```ruby
# Get comprehensive statistics for a column
Company.simple_query.stats(:annual_revenue).execute
# => #<struct
# annual_revenue_count=100,
# annual_revenue_sum=50000000,
# annual_revenue_avg=500000,
# annual_revenue_min=100000,
# annual_revenue_max=2000000
# >

# Custom aggregations
Company.simple_query
.custom_aggregation("COUNT(DISTINCT industry)", "unique_industries")
.execute
# => #<struct unique_industries=5>

# Combining with other features
Company.simple_query
.select(:industry)
.count
.sum(:annual_revenue)
.group(:industry)
.execute
# => [
# #<struct industry="Technology", count=50, sum_annual_revenue=25000000>,
# #<struct industry="Finance", count=30, sum_annual_revenue=20000000>
# ]
```

### 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
# => #<struct total_users=1000, total_score=85000>
```

## 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.

Expand Down
1 change: 1 addition & 0 deletions lib/simple_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
129 changes: 127 additions & 2 deletions lib/simple_query/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading