Skip to main content

prax_query/operations/
aggregate.rs

1//! Aggregation query operations.
2//!
3//! This module provides aggregate operations like `count`, `sum`, `avg`, `min`, `max`,
4//! and `groupBy` for performing statistical queries on the database.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! // Count users
10//! let count = client
11//!     .user()
12//!     .aggregate()
13//!     .count()
14//!     .r#where(user::active::equals(true))
15//!     .exec()
16//!     .await?;
17//!
18//! // Get aggregated statistics
19//! let stats = client
20//!     .user()
21//!     .aggregate()
22//!     .count()
23//!     .avg(user::age())
24//!     .min(user::age())
25//!     .max(user::age())
26//!     .sum(user::age())
27//!     .r#where(user::active::equals(true))
28//!     .exec()
29//!     .await?;
30//!
31//! // Group by with aggregation
32//! let by_country = client
33//!     .user()
34//!     .group_by(user::country())
35//!     .count()
36//!     .avg(user::age())
37//!     .having(aggregate::count::gt(10))
38//!     .exec()
39//!     .await?;
40//! ```
41
42use std::marker::PhantomData;
43
44use crate::error::QueryResult;
45use crate::filter::Filter;
46use crate::sql::quote_identifier;
47use crate::traits::{Model, QueryEngine};
48use crate::types::OrderByField;
49
50/// How a `_count` select column is counted.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum CountSelectMode {
53    /// `COUNT(col)` — counts non-null values.
54    NonNull,
55    /// `COUNT(DISTINCT col)`.
56    Distinct,
57}
58
59/// An aggregation field specifier.
60#[derive(Debug, Clone)]
61pub enum AggregateField {
62    /// Count all rows.
63    CountAll,
64    /// Count non-null values in a column.
65    CountColumn(String),
66    /// Count distinct values in a column.
67    CountDistinct(String),
68    /// Sum of a numeric column.
69    Sum(String),
70    /// Average of a numeric column.
71    Avg(String),
72    /// Minimum value in a column.
73    Min(String),
74    /// Maximum value in a column.
75    Max(String),
76}
77
78impl AggregateField {
79    /// Build the SQL expression for this aggregate.
80    pub fn to_sql(&self) -> String {
81        match self {
82            Self::CountAll => "COUNT(*)".to_string(),
83            Self::CountColumn(col) => format!("COUNT({})", quote_identifier(col)),
84            Self::CountDistinct(col) => format!("COUNT(DISTINCT {})", quote_identifier(col)),
85            Self::Sum(col) => format!("SUM({})", quote_identifier(col)),
86            Self::Avg(col) => format!("AVG({})", quote_identifier(col)),
87            Self::Min(col) => format!("MIN({})", quote_identifier(col)),
88            Self::Max(col) => format!("MAX({})", quote_identifier(col)),
89        }
90    }
91
92    /// Build the SQL expression for this aggregate, quoting the column
93    /// identifier via the given dialect (backticks on MySQL, brackets on
94    /// MSSQL, double quotes on Postgres/SQLite).
95    pub fn to_sql_dialect(&self, dialect: &dyn crate::dialect::SqlDialect) -> String {
96        match self {
97            Self::CountAll => "COUNT(*)".to_string(),
98            Self::CountColumn(col) => format!("COUNT({})", dialect.quote_ident(col)),
99            Self::CountDistinct(col) => {
100                format!("COUNT(DISTINCT {})", dialect.quote_ident(col))
101            }
102            Self::Sum(col) => format!("SUM({})", dialect.quote_ident(col)),
103            Self::Avg(col) => format!("AVG({})", dialect.quote_ident(col)),
104            Self::Min(col) => format!("MIN({})", dialect.quote_ident(col)),
105            Self::Max(col) => format!("MAX({})", dialect.quote_ident(col)),
106        }
107    }
108
109    /// Get the alias for this aggregate.
110    pub fn alias(&self) -> String {
111        match self {
112            Self::CountAll => "_count".to_string(),
113            Self::CountColumn(col) => format!("_count_{}", col),
114            Self::CountDistinct(col) => format!("_count_distinct_{}", col),
115            Self::Sum(col) => format!("_sum_{}", col),
116            Self::Avg(col) => format!("_avg_{}", col),
117            Self::Min(col) => format!("_min_{}", col),
118            Self::Max(col) => format!("_max_{}", col),
119        }
120    }
121}
122
123/// Result of an aggregation query.
124///
125/// Populated from the single-row result of an aggregate query by
126/// [`AggregateOperation::exec`]. The keys in each map are the
127/// *column names* stripped of the `_sum_` / `_avg_` / `_min_` /
128/// `_max_` prefixes emitted by [`AggregateField::alias`], so callers
129/// index by the original column (e.g. `result.sum.get("views")`).
130#[derive(Debug, Clone, Default)]
131pub struct AggregateResult {
132    /// Total count (if requested).
133    pub count: Option<i64>,
134    /// Per-column non-null counts, keyed by column (`COUNT(col)`).
135    pub count_columns: std::collections::HashMap<String, i64>,
136    /// Per-column distinct counts, keyed by column (`COUNT(DISTINCT col)`).
137    pub count_distinct: std::collections::HashMap<String, i64>,
138    /// Sum results keyed by column name.
139    pub sum: std::collections::HashMap<String, f64>,
140    /// Average results keyed by column name.
141    pub avg: std::collections::HashMap<String, f64>,
142    /// Minimum results keyed by column name.
143    pub min: std::collections::HashMap<String, serde_json::Value>,
144    /// Maximum results keyed by column name.
145    pub max: std::collections::HashMap<String, serde_json::Value>,
146}
147
148impl AggregateResult {
149    /// Build an [`AggregateResult`] from the single column-value map
150    /// returned by [`crate::traits::QueryEngine::aggregate_query`] for
151    /// a whole-table aggregate.
152    ///
153    /// The input map's keys are the dialect-emitted aliases
154    /// (`_count`, `_sum_<col>`, `_avg_<col>`, …). This method strips
155    /// the prefix and routes each entry into the right typed accessor
156    /// bucket. Values that don't parse as the expected numeric type
157    /// are dropped silently — aggregates against empty result sets
158    /// legitimately return NULL.
159    pub fn from_row(row: std::collections::HashMap<String, crate::filter::FilterValue>) -> Self {
160        use crate::filter::FilterValue;
161        let mut out = Self::default();
162        for (k, v) in row {
163            if k == "_count" {
164                if let FilterValue::Int(n) = v {
165                    out.count = Some(n);
166                }
167            } else if let Some(col) = k.strip_prefix("_count_distinct_") {
168                if let Some(n) = value_to_i64(&v) {
169                    out.count_distinct.insert(col.to_string(), n);
170                }
171            } else if let Some(col) = k.strip_prefix("_count_") {
172                if let Some(n) = value_to_i64(&v) {
173                    out.count_columns.insert(col.to_string(), n);
174                }
175            } else if let Some(col) = k.strip_prefix("_sum_") {
176                if let Some(f) = value_to_f64(&v) {
177                    out.sum.insert(col.to_string(), f);
178                }
179            } else if let Some(col) = k.strip_prefix("_avg_") {
180                if let Some(f) = value_to_f64(&v) {
181                    out.avg.insert(col.to_string(), f);
182                }
183            } else if let Some(col) = k.strip_prefix("_min_") {
184                out.min.insert(col.to_string(), filter_value_to_json(&v));
185            } else if let Some(col) = k.strip_prefix("_max_") {
186                out.max.insert(col.to_string(), filter_value_to_json(&v));
187            }
188        }
189        out
190    }
191
192    /// Non-null count of a column (`COUNT(col)`), if present.
193    pub fn count_of(&self, column: &str) -> Option<i64> {
194        self.count_columns.get(column).copied()
195    }
196
197    /// Distinct count of a column (`COUNT(DISTINCT col)`), if present.
198    pub fn count_distinct_of(&self, column: &str) -> Option<i64> {
199        self.count_distinct.get(column).copied()
200    }
201
202    /// Pull the sum of a column as `f64` if present.
203    pub fn sum_as_f64(&self, column: &str) -> Option<f64> {
204        self.sum.get(column).copied()
205    }
206
207    /// Pull the average of a column as `f64` if present.
208    pub fn avg_as_f64(&self, column: &str) -> Option<f64> {
209        self.avg.get(column).copied()
210    }
211
212    /// Pull the minimum of a column as `f64` if the stored JSON value
213    /// is numeric.
214    pub fn min_as_f64(&self, column: &str) -> Option<f64> {
215        self.min.get(column).and_then(|v| v.as_f64())
216    }
217
218    /// Pull the maximum of a column as `f64` if the stored JSON value
219    /// is numeric.
220    pub fn max_as_f64(&self, column: &str) -> Option<f64> {
221        self.max.get(column).and_then(|v| v.as_f64())
222    }
223}
224
225fn value_to_i64(v: &crate::filter::FilterValue) -> Option<i64> {
226    use crate::filter::FilterValue;
227    match v {
228        FilterValue::Int(n) => Some(*n),
229        FilterValue::String(s) => s.parse::<i64>().ok(),
230        _ => None,
231    }
232}
233
234fn value_to_f64(v: &crate::filter::FilterValue) -> Option<f64> {
235    use crate::filter::FilterValue;
236    match v {
237        FilterValue::Int(n) => Some(*n as f64),
238        FilterValue::Float(f) => Some(*f),
239        FilterValue::String(s) => s.parse::<f64>().ok(),
240        _ => None,
241    }
242}
243
244fn filter_value_to_json(v: &crate::filter::FilterValue) -> serde_json::Value {
245    use crate::filter::FilterValue;
246    match v {
247        FilterValue::Null => serde_json::Value::Null,
248        FilterValue::Bool(b) => serde_json::Value::Bool(*b),
249        FilterValue::Int(n) => serde_json::Value::from(*n),
250        FilterValue::Float(f) => serde_json::Number::from_f64(*f)
251            .map(serde_json::Value::Number)
252            .unwrap_or(serde_json::Value::Null),
253        FilterValue::String(s) => serde_json::Value::String(s.clone()),
254        FilterValue::Json(j) => j.clone(),
255        FilterValue::List(_) => serde_json::Value::Null,
256    }
257}
258
259/// Aggregate operation builder.
260///
261/// # Engine ownership
262///
263/// The builder stores an `Option<E>` rather than the engine directly
264/// so existing unit tests that construct an `AggregateOperation` just
265/// to exercise SQL emission (`AggregateOperation::<Model,
266/// MockEngine>::new()`) keep working without a real engine.
267/// Production code always goes through [`AggregateOperation::with_engine`]
268/// (what the generated `Client<E>::aggregate()` accessor calls), and
269/// [`Self::exec`] refuses to run when the engine slot is empty.
270#[derive(Debug)]
271pub struct AggregateOperation<M: Model, E: QueryEngine> {
272    /// Phantom data for model type.
273    _model: PhantomData<M>,
274    /// Engine used by [`Self::exec`]. SQL-emission-only constructors
275    /// leave this `None`.
276    engine: Option<E>,
277    /// Aggregate fields to compute.
278    fields: Vec<AggregateField>,
279    /// Filter conditions.
280    filter: Option<Filter>,
281}
282
283impl<M: Model, E: QueryEngine> AggregateOperation<M, E> {
284    /// Create a new aggregate operation without an engine.
285    ///
286    /// Useful for unit tests that only exercise [`Self::build_sql`].
287    /// [`Self::exec`] will refuse to run on a builder created this way.
288    pub fn new() -> Self {
289        Self {
290            _model: PhantomData,
291            engine: None,
292            fields: Vec::new(),
293            filter: None,
294        }
295    }
296
297    /// Create a new aggregate operation bound to a concrete engine.
298    ///
299    /// This is what the generated `Client<E>::aggregate()` accessor
300    /// calls.
301    pub fn with_engine(engine: E) -> Self {
302        Self {
303            _model: PhantomData,
304            engine: Some(engine),
305            fields: Vec::new(),
306            filter: None,
307        }
308    }
309
310    /// Add a count of all rows.
311    pub fn count(mut self) -> Self {
312        self.fields.push(AggregateField::CountAll);
313        self
314    }
315
316    /// Add a count of non-null values in a column.
317    pub fn count_column(mut self, column: impl Into<String>) -> Self {
318        self.fields.push(AggregateField::CountColumn(column.into()));
319        self
320    }
321
322    /// Add a count of distinct values in a column.
323    pub fn count_distinct(mut self, column: impl Into<String>) -> Self {
324        self.fields
325            .push(AggregateField::CountDistinct(column.into()));
326        self
327    }
328
329    /// Add sum of a numeric column.
330    pub fn sum(mut self, column: impl Into<String>) -> Self {
331        self.fields.push(AggregateField::Sum(column.into()));
332        self
333    }
334
335    /// Add average of a numeric column.
336    pub fn avg(mut self, column: impl Into<String>) -> Self {
337        self.fields.push(AggregateField::Avg(column.into()));
338        self
339    }
340
341    /// Add minimum of a column.
342    pub fn min(mut self, column: impl Into<String>) -> Self {
343        self.fields.push(AggregateField::Min(column.into()));
344        self
345    }
346
347    /// Add maximum of a column.
348    pub fn max(mut self, column: impl Into<String>) -> Self {
349        self.fields.push(AggregateField::Max(column.into()));
350        self
351    }
352
353    /// Add a filter condition. AND-composes with any previously set filter.
354    pub fn r#where(mut self, filter: impl Into<Filter>) -> Self {
355        let new_filter = filter.into();
356        self.filter = Some(match self.filter.take() {
357            Some(existing) => existing.and_then(new_filter),
358            None => new_filter,
359        });
360        self
361    }
362
363    /// Apply a typed `WhereInput`. AND-composes with any previously set filter.
364    pub fn with_where_input<W: crate::inputs::WhereInput<Model = M>>(mut self, w: W) -> Self {
365        let f = w.into_ir();
366        self.filter = Some(match self.filter.take() {
367            Some(existing) => existing.and_then(f),
368            None => f,
369        });
370        self
371    }
372
373    /// Build the SQL for this operation.
374    pub fn build_sql(
375        &self,
376        dialect: &dyn crate::dialect::SqlDialect,
377    ) -> (String, Vec<crate::filter::FilterValue>) {
378        let mut params = Vec::new();
379
380        // If no fields specified, default to count
381        let fields = if self.fields.is_empty() {
382            vec![AggregateField::CountAll]
383        } else {
384            self.fields.clone()
385        };
386
387        let select_parts: Vec<String> = fields
388            .iter()
389            .map(|f| {
390                format!(
391                    "{} AS {}",
392                    f.to_sql_dialect(dialect),
393                    dialect.quote_ident(&f.alias())
394                )
395            })
396            .collect();
397
398        let mut sql = format!(
399            "SELECT {} FROM {}",
400            select_parts.join(", "),
401            dialect.quote_ident(M::TABLE_NAME)
402        );
403
404        // Add WHERE clause
405        if let Some(filter) = &self.filter {
406            let (where_sql, where_params) = filter.to_sql(params.len(), dialect);
407            sql.push_str(&format!(" WHERE {}", where_sql));
408            params.extend(where_params);
409        }
410
411        (sql, params)
412    }
413
414    /// Execute the aggregate operation.
415    ///
416    /// Routes the single-row aggregate result through
417    /// [`crate::traits::QueryEngine::aggregate_query`] and folds the
418    /// column→value map into an [`AggregateResult`]. Returns an empty
419    /// result (all fields `None`/empty) if the query yields zero rows
420    /// — aggregates on empty tables do this on Postgres/MySQL/SQLite.
421    ///
422    /// Errors with `QueryError::internal` if the builder was
423    /// constructed via [`Self::new`] without an engine.
424    pub async fn exec(self) -> QueryResult<AggregateResult> {
425        let engine = self.engine.as_ref().ok_or_else(|| {
426            crate::error::QueryError::internal(
427                "AggregateOperation::exec called on a builder without an engine; \
428                 use Client<E>::aggregate() (which calls with_engine) instead of \
429                 AggregateOperation::new()",
430            )
431        })?;
432        let dialect = engine.dialect();
433        let (sql, params) = self.build_sql(dialect);
434        let mut rows = engine.aggregate_query(&sql, params).await?;
435        Ok(AggregateResult::from_row(rows.pop().unwrap_or_default()))
436    }
437}
438
439impl<M: Model, E: QueryEngine> Default for AggregateOperation<M, E> {
440    fn default() -> Self {
441        Self::new()
442    }
443}
444
445/// Group by operation builder.
446///
447/// # Engine ownership
448///
449/// Like [`AggregateOperation`], holds an `Option<E>` so SQL-emission
450/// unit tests compile without a real engine. Production code uses
451/// [`GroupByOperation::with_engine`] via the generated
452/// `Client<E>::group_by` accessor.
453#[derive(Debug)]
454pub struct GroupByOperation<M: Model, E: QueryEngine> {
455    /// Phantom data for model type.
456    _model: PhantomData<M>,
457    /// Engine used by [`Self::exec`]; `None` for SQL-emission-only
458    /// unit-test constructors.
459    engine: Option<E>,
460    /// Columns to group by.
461    group_columns: Vec<String>,
462    /// Aggregate fields to compute.
463    agg_fields: Vec<AggregateField>,
464    /// Filter conditions (WHERE).
465    filter: Option<Filter>,
466    /// Having conditions.
467    having: Option<HavingCondition>,
468    /// Order by clauses.
469    order_by: Vec<OrderByField>,
470    /// Skip count.
471    skip: Option<usize>,
472    /// Take count.
473    take: Option<usize>,
474}
475
476/// A condition for the HAVING clause.
477#[derive(Debug, Clone)]
478pub struct HavingCondition {
479    /// The aggregate field to check.
480    pub field: AggregateField,
481    /// The comparison operator.
482    pub op: HavingOp,
483    /// The value to compare against.
484    pub value: f64,
485}
486
487/// Operators for HAVING conditions.
488#[derive(Debug, Clone, Copy)]
489pub enum HavingOp {
490    /// Greater than.
491    Gt,
492    /// Greater than or equal.
493    Gte,
494    /// Less than.
495    Lt,
496    /// Less than or equal.
497    Lte,
498    /// Equal.
499    Eq,
500    /// Not equal.
501    Ne,
502}
503
504impl HavingOp {
505    /// Get the SQL operator string.
506    pub fn as_str(&self) -> &'static str {
507        match self {
508            Self::Gt => ">",
509            Self::Gte => ">=",
510            Self::Lt => "<",
511            Self::Lte => "<=",
512            Self::Eq => "=",
513            Self::Ne => "<>",
514        }
515    }
516}
517
518impl<M: Model, E: QueryEngine> GroupByOperation<M, E> {
519    /// Create a new group-by operation without an engine.
520    ///
521    /// Useful for unit tests that only exercise [`Self::build_sql`].
522    /// [`Self::exec`] will refuse to run on a builder created this way.
523    pub fn new(columns: Vec<String>) -> Self {
524        Self {
525            _model: PhantomData,
526            engine: None,
527            group_columns: columns,
528            agg_fields: Vec::new(),
529            filter: None,
530            having: None,
531            order_by: Vec::new(),
532            skip: None,
533            take: None,
534        }
535    }
536
537    /// Create a new group-by operation bound to a concrete engine.
538    ///
539    /// This is what the generated `Client<E>::group_by(cols)` accessor
540    /// calls.
541    pub fn with_engine(engine: E, columns: Vec<String>) -> Self {
542        Self {
543            _model: PhantomData,
544            engine: Some(engine),
545            group_columns: columns,
546            agg_fields: Vec::new(),
547            filter: None,
548            having: None,
549            order_by: Vec::new(),
550            skip: None,
551            take: None,
552        }
553    }
554
555    /// Add a count aggregate.
556    pub fn count(mut self) -> Self {
557        self.agg_fields.push(AggregateField::CountAll);
558        self
559    }
560
561    /// Add a per-column non-null count (`COUNT(col)`).
562    pub fn count_column(mut self, column: impl Into<String>) -> Self {
563        self.agg_fields
564            .push(AggregateField::CountColumn(column.into()));
565        self
566    }
567
568    /// Add a distinct count (`COUNT(DISTINCT col)`).
569    pub fn count_distinct(mut self, column: impl Into<String>) -> Self {
570        self.agg_fields
571            .push(AggregateField::CountDistinct(column.into()));
572        self
573    }
574
575    /// Add sum of a column.
576    pub fn sum(mut self, column: impl Into<String>) -> Self {
577        self.agg_fields.push(AggregateField::Sum(column.into()));
578        self
579    }
580
581    /// Add average of a column.
582    pub fn avg(mut self, column: impl Into<String>) -> Self {
583        self.agg_fields.push(AggregateField::Avg(column.into()));
584        self
585    }
586
587    /// Add minimum of a column.
588    pub fn min(mut self, column: impl Into<String>) -> Self {
589        self.agg_fields.push(AggregateField::Min(column.into()));
590        self
591    }
592
593    /// Add maximum of a column.
594    pub fn max(mut self, column: impl Into<String>) -> Self {
595        self.agg_fields.push(AggregateField::Max(column.into()));
596        self
597    }
598
599    /// Add a filter condition. AND-composes with any previously set filter.
600    pub fn r#where(mut self, filter: impl Into<Filter>) -> Self {
601        let new_filter = filter.into();
602        self.filter = Some(match self.filter.take() {
603            Some(existing) => existing.and_then(new_filter),
604            None => new_filter,
605        });
606        self
607    }
608
609    /// Add a having condition.
610    ///
611    /// # Panics
612    ///
613    /// Panics if `condition.value` is NaN or infinite — non-finite floats
614    /// cannot be represented as bound SQL parameters.
615    pub fn having(mut self, condition: HavingCondition) -> Self {
616        assert!(
617            condition.value.is_finite(),
618            "HAVING condition value must be finite, got {}",
619            condition.value
620        );
621        self.having = Some(condition);
622        self
623    }
624
625    /// Add ordering.
626    pub fn order_by(mut self, order: impl Into<OrderByField>) -> Self {
627        self.order_by.push(order.into());
628        self
629    }
630
631    /// Set skip count.
632    pub fn skip(mut self, count: usize) -> Self {
633        self.skip = Some(count);
634        self
635    }
636
637    /// Set take count.
638    pub fn take(mut self, count: usize) -> Self {
639        self.take = Some(count);
640        self
641    }
642
643    /// Build the SQL for this operation.
644    pub fn build_sql(
645        &self,
646        dialect: &dyn crate::dialect::SqlDialect,
647    ) -> (String, Vec<crate::filter::FilterValue>) {
648        let mut params = Vec::new();
649
650        // Build SELECT clause
651        let mut select_parts: Vec<String> = self
652            .group_columns
653            .iter()
654            .map(|c| dialect.quote_ident(c))
655            .collect();
656
657        for field in &self.agg_fields {
658            select_parts.push(format!(
659                "{} AS {}",
660                field.to_sql_dialect(dialect),
661                dialect.quote_ident(&field.alias())
662            ));
663        }
664
665        let mut sql = format!(
666            "SELECT {} FROM {}",
667            select_parts.join(", "),
668            dialect.quote_ident(M::TABLE_NAME)
669        );
670
671        // Add WHERE clause
672        if let Some(filter) = &self.filter {
673            let (where_sql, where_params) = filter.to_sql(params.len(), dialect);
674            sql.push_str(&format!(" WHERE {}", where_sql));
675            params.extend(where_params);
676        }
677
678        // Add GROUP BY clause
679        if !self.group_columns.is_empty() {
680            let group_cols: Vec<String> = self
681                .group_columns
682                .iter()
683                .map(|c| dialect.quote_ident(c))
684                .collect();
685            sql.push_str(&format!(" GROUP BY {}", group_cols.join(", ")));
686        }
687
688        // Add HAVING clause — the comparison value is bound as a parameter,
689        // never interpolated into the SQL text.
690        if let Some(having) = &self.having {
691            params.push(crate::filter::FilterValue::Float(having.value));
692            sql.push_str(&format!(
693                " HAVING {} {} {}",
694                having.field.to_sql_dialect(dialect),
695                having.op.as_str(),
696                dialect.placeholder(params.len())
697            ));
698        }
699
700        // Add ORDER BY clause
701        if !self.order_by.is_empty() {
702            let order_parts: Vec<String> = self
703                .order_by
704                .iter()
705                .map(|o| {
706                    let mut part =
707                        format!("{} {}", dialect.quote_ident(&o.column), o.order.as_sql());
708                    if let Some(nulls) = o.nulls {
709                        part.push(' ');
710                        part.push_str(nulls.as_sql());
711                    }
712                    part
713                })
714                .collect();
715            sql.push_str(&format!(" ORDER BY {}", order_parts.join(", ")));
716        }
717
718        // Add LIMIT/OFFSET
719        if let Some(take) = self.take {
720            sql.push_str(&format!(" LIMIT {}", take));
721        }
722        if let Some(skip) = self.skip {
723            sql.push_str(&format!(" OFFSET {}", skip));
724        }
725
726        (sql, params)
727    }
728
729    /// Execute the group-by operation.
730    ///
731    /// Returns one [`GroupByResult`] per grouped row. Each result
732    /// splits the row map into two buckets:
733    /// - `group_values`: entries whose key matches a column named in
734    ///   `group_columns`.
735    /// - `aggregates`: everything else — parsed through
736    ///   [`AggregateResult::from_row`].
737    ///
738    /// Errors with `QueryError::internal` if the builder was
739    /// constructed via [`Self::new`] without an engine.
740    pub async fn exec(self) -> QueryResult<Vec<GroupByResult>> {
741        let engine = self.engine.as_ref().ok_or_else(|| {
742            crate::error::QueryError::internal(
743                "GroupByOperation::exec called on a builder without an engine; \
744                 use Client<E>::group_by() (which calls with_engine) instead of \
745                 GroupByOperation::new()",
746            )
747        })?;
748        let dialect = engine.dialect();
749        let group_columns = self.group_columns.clone();
750        let (sql, params) = self.build_sql(dialect);
751        let rows = engine.aggregate_query(&sql, params).await?;
752        Ok(rows
753            .into_iter()
754            .map(|row| {
755                let mut group_values = std::collections::HashMap::new();
756                let mut agg_map = std::collections::HashMap::new();
757                for (k, v) in row {
758                    if group_columns.iter().any(|c| c == &k) {
759                        group_values.insert(k, filter_value_to_json(&v));
760                    } else {
761                        agg_map.insert(k, v);
762                    }
763                }
764                GroupByResult {
765                    group_values,
766                    aggregates: AggregateResult::from_row(agg_map),
767                }
768            })
769            .collect())
770    }
771}
772
773/// Result of a group by query.
774#[derive(Debug, Clone)]
775pub struct GroupByResult {
776    /// The grouped column values.
777    pub group_values: std::collections::HashMap<String, serde_json::Value>,
778    /// The aggregate results.
779    pub aggregates: AggregateResult,
780}
781
782/// Helper for creating having conditions.
783pub mod having {
784    use super::*;
785
786    /// Create a having condition for count > value.
787    pub fn count_gt(value: f64) -> HavingCondition {
788        HavingCondition {
789            field: AggregateField::CountAll,
790            op: HavingOp::Gt,
791            value,
792        }
793    }
794
795    /// Create a having condition for count >= value.
796    pub fn count_gte(value: f64) -> HavingCondition {
797        HavingCondition {
798            field: AggregateField::CountAll,
799            op: HavingOp::Gte,
800            value,
801        }
802    }
803
804    /// Create a having condition for count < value.
805    pub fn count_lt(value: f64) -> HavingCondition {
806        HavingCondition {
807            field: AggregateField::CountAll,
808            op: HavingOp::Lt,
809            value,
810        }
811    }
812
813    pub fn count_lte(value: f64) -> HavingCondition {
814        HavingCondition {
815            field: AggregateField::CountAll,
816            op: HavingOp::Lte,
817            value,
818        }
819    }
820
821    pub fn count_eq(value: f64) -> HavingCondition {
822        HavingCondition {
823            field: AggregateField::CountAll,
824            op: HavingOp::Eq,
825            value,
826        }
827    }
828
829    pub fn count_ne(value: f64) -> HavingCondition {
830        HavingCondition {
831            field: AggregateField::CountAll,
832            op: HavingOp::Ne,
833            value,
834        }
835    }
836
837    pub fn sum_gt(column: impl Into<String>, value: f64) -> HavingCondition {
838        HavingCondition {
839            field: AggregateField::Sum(column.into()),
840            op: HavingOp::Gt,
841            value,
842        }
843    }
844
845    pub fn sum_gte(column: impl Into<String>, value: f64) -> HavingCondition {
846        HavingCondition {
847            field: AggregateField::Sum(column.into()),
848            op: HavingOp::Gte,
849            value,
850        }
851    }
852
853    pub fn sum_lt(column: impl Into<String>, value: f64) -> HavingCondition {
854        HavingCondition {
855            field: AggregateField::Sum(column.into()),
856            op: HavingOp::Lt,
857            value,
858        }
859    }
860
861    pub fn sum_lte(column: impl Into<String>, value: f64) -> HavingCondition {
862        HavingCondition {
863            field: AggregateField::Sum(column.into()),
864            op: HavingOp::Lte,
865            value,
866        }
867    }
868
869    pub fn sum_eq(column: impl Into<String>, value: f64) -> HavingCondition {
870        HavingCondition {
871            field: AggregateField::Sum(column.into()),
872            op: HavingOp::Eq,
873            value,
874        }
875    }
876
877    pub fn sum_ne(column: impl Into<String>, value: f64) -> HavingCondition {
878        HavingCondition {
879            field: AggregateField::Sum(column.into()),
880            op: HavingOp::Ne,
881            value,
882        }
883    }
884
885    pub fn avg_gt(column: impl Into<String>, value: f64) -> HavingCondition {
886        HavingCondition {
887            field: AggregateField::Avg(column.into()),
888            op: HavingOp::Gt,
889            value,
890        }
891    }
892
893    pub fn avg_gte(column: impl Into<String>, value: f64) -> HavingCondition {
894        HavingCondition {
895            field: AggregateField::Avg(column.into()),
896            op: HavingOp::Gte,
897            value,
898        }
899    }
900
901    pub fn avg_lt(column: impl Into<String>, value: f64) -> HavingCondition {
902        HavingCondition {
903            field: AggregateField::Avg(column.into()),
904            op: HavingOp::Lt,
905            value,
906        }
907    }
908
909    pub fn avg_lte(column: impl Into<String>, value: f64) -> HavingCondition {
910        HavingCondition {
911            field: AggregateField::Avg(column.into()),
912            op: HavingOp::Lte,
913            value,
914        }
915    }
916
917    pub fn avg_eq(column: impl Into<String>, value: f64) -> HavingCondition {
918        HavingCondition {
919            field: AggregateField::Avg(column.into()),
920            op: HavingOp::Eq,
921            value,
922        }
923    }
924
925    pub fn avg_ne(column: impl Into<String>, value: f64) -> HavingCondition {
926        HavingCondition {
927            field: AggregateField::Avg(column.into()),
928            op: HavingOp::Ne,
929            value,
930        }
931    }
932
933    pub fn min_gt(column: impl Into<String>, value: f64) -> HavingCondition {
934        HavingCondition {
935            field: AggregateField::Min(column.into()),
936            op: HavingOp::Gt,
937            value,
938        }
939    }
940
941    pub fn min_gte(column: impl Into<String>, value: f64) -> HavingCondition {
942        HavingCondition {
943            field: AggregateField::Min(column.into()),
944            op: HavingOp::Gte,
945            value,
946        }
947    }
948
949    pub fn min_lt(column: impl Into<String>, value: f64) -> HavingCondition {
950        HavingCondition {
951            field: AggregateField::Min(column.into()),
952            op: HavingOp::Lt,
953            value,
954        }
955    }
956
957    pub fn min_lte(column: impl Into<String>, value: f64) -> HavingCondition {
958        HavingCondition {
959            field: AggregateField::Min(column.into()),
960            op: HavingOp::Lte,
961            value,
962        }
963    }
964
965    pub fn min_eq(column: impl Into<String>, value: f64) -> HavingCondition {
966        HavingCondition {
967            field: AggregateField::Min(column.into()),
968            op: HavingOp::Eq,
969            value,
970        }
971    }
972
973    pub fn min_ne(column: impl Into<String>, value: f64) -> HavingCondition {
974        HavingCondition {
975            field: AggregateField::Min(column.into()),
976            op: HavingOp::Ne,
977            value,
978        }
979    }
980
981    pub fn max_gt(column: impl Into<String>, value: f64) -> HavingCondition {
982        HavingCondition {
983            field: AggregateField::Max(column.into()),
984            op: HavingOp::Gt,
985            value,
986        }
987    }
988
989    pub fn max_gte(column: impl Into<String>, value: f64) -> HavingCondition {
990        HavingCondition {
991            field: AggregateField::Max(column.into()),
992            op: HavingOp::Gte,
993            value,
994        }
995    }
996
997    pub fn max_lt(column: impl Into<String>, value: f64) -> HavingCondition {
998        HavingCondition {
999            field: AggregateField::Max(column.into()),
1000            op: HavingOp::Lt,
1001            value,
1002        }
1003    }
1004
1005    pub fn max_lte(column: impl Into<String>, value: f64) -> HavingCondition {
1006        HavingCondition {
1007            field: AggregateField::Max(column.into()),
1008            op: HavingOp::Lte,
1009            value,
1010        }
1011    }
1012
1013    pub fn max_eq(column: impl Into<String>, value: f64) -> HavingCondition {
1014        HavingCondition {
1015            field: AggregateField::Max(column.into()),
1016            op: HavingOp::Eq,
1017            value,
1018        }
1019    }
1020
1021    pub fn max_ne(column: impl Into<String>, value: f64) -> HavingCondition {
1022        HavingCondition {
1023            field: AggregateField::Max(column.into()),
1024            op: HavingOp::Ne,
1025            value,
1026        }
1027    }
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032    use super::*;
1033    use crate::filter::{Filter, FilterValue};
1034    use crate::types::NullsOrder;
1035
1036    // A simple test model
1037    struct TestModel;
1038
1039    impl Model for TestModel {
1040        const MODEL_NAME: &'static str = "TestModel";
1041        const TABLE_NAME: &'static str = "test_models";
1042        const PRIMARY_KEY: &'static [&'static str] = &["id"];
1043        const COLUMNS: &'static [&'static str] = &["id", "name", "age", "score"];
1044    }
1045
1046    impl crate::row::FromRow for TestModel {
1047        fn from_row(_row: &impl crate::row::RowRef) -> Result<Self, crate::row::RowError> {
1048            Ok(TestModel)
1049        }
1050    }
1051
1052    // A mock query engine
1053    #[derive(Clone)]
1054    struct MockEngine;
1055
1056    impl QueryEngine for MockEngine {
1057        fn dialect(&self) -> &dyn crate::dialect::SqlDialect {
1058            &crate::dialect::Postgres
1059        }
1060
1061        fn query_many<T: Model + crate::row::FromRow + Send + 'static>(
1062            &self,
1063            _sql: &str,
1064            _params: Vec<crate::filter::FilterValue>,
1065        ) -> crate::traits::BoxFuture<'_, QueryResult<Vec<T>>> {
1066            Box::pin(async { Ok(Vec::new()) })
1067        }
1068
1069        fn query_one<T: Model + crate::row::FromRow + Send + 'static>(
1070            &self,
1071            _sql: &str,
1072            _params: Vec<crate::filter::FilterValue>,
1073        ) -> crate::traits::BoxFuture<'_, QueryResult<T>> {
1074            Box::pin(async { Err(crate::error::QueryError::not_found("Not implemented")) })
1075        }
1076
1077        fn query_optional<T: Model + crate::row::FromRow + Send + 'static>(
1078            &self,
1079            _sql: &str,
1080            _params: Vec<crate::filter::FilterValue>,
1081        ) -> crate::traits::BoxFuture<'_, QueryResult<Option<T>>> {
1082            Box::pin(async { Ok(None) })
1083        }
1084
1085        fn execute_insert<T: Model + crate::row::FromRow + Send + 'static>(
1086            &self,
1087            _sql: &str,
1088            _params: Vec<crate::filter::FilterValue>,
1089        ) -> crate::traits::BoxFuture<'_, QueryResult<T>> {
1090            Box::pin(async { Err(crate::error::QueryError::not_found("Not implemented")) })
1091        }
1092
1093        fn execute_update<T: Model + crate::row::FromRow + Send + 'static>(
1094            &self,
1095            _sql: &str,
1096            _params: Vec<crate::filter::FilterValue>,
1097        ) -> crate::traits::BoxFuture<'_, QueryResult<Vec<T>>> {
1098            Box::pin(async { Ok(Vec::new()) })
1099        }
1100
1101        fn execute_delete(
1102            &self,
1103            _sql: &str,
1104            _params: Vec<crate::filter::FilterValue>,
1105        ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
1106            Box::pin(async { Ok(0) })
1107        }
1108
1109        fn execute_raw(
1110            &self,
1111            _sql: &str,
1112            _params: Vec<crate::filter::FilterValue>,
1113        ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
1114            Box::pin(async { Ok(0) })
1115        }
1116
1117        fn count(
1118            &self,
1119            _sql: &str,
1120            _params: Vec<crate::filter::FilterValue>,
1121        ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
1122            Box::pin(async { Ok(0) })
1123        }
1124    }
1125
1126    // ========== AggregateField Tests ==========
1127
1128    #[test]
1129    fn test_aggregate_field_sql() {
1130        // Note: quote_identifier only quotes when needed (reserved words, special chars)
1131        assert_eq!(AggregateField::CountAll.to_sql(), "COUNT(*)");
1132        assert_eq!(
1133            AggregateField::CountColumn("id".into()).to_sql(),
1134            "COUNT(id)"
1135        );
1136        assert_eq!(
1137            AggregateField::CountDistinct("email".into()).to_sql(),
1138            "COUNT(DISTINCT email)"
1139        );
1140        assert_eq!(AggregateField::Sum("amount".into()).to_sql(), "SUM(amount)");
1141        assert_eq!(
1142            AggregateField::Avg("score".to_string()).to_sql(),
1143            "AVG(score)"
1144        );
1145        assert_eq!(AggregateField::Min("age".into()).to_sql(), "MIN(age)");
1146        assert_eq!(AggregateField::Max("age".into()).to_sql(), "MAX(age)");
1147        // Test with reserved word - should be quoted
1148        assert_eq!(
1149            AggregateField::CountColumn("user".to_string()).to_sql(),
1150            "COUNT(\"user\")"
1151        );
1152    }
1153
1154    #[test]
1155    fn test_aggregate_field_alias() {
1156        assert_eq!(AggregateField::CountAll.alias(), "_count");
1157        assert_eq!(
1158            AggregateField::CountColumn("id".into()).alias(),
1159            "_count_id"
1160        );
1161        assert_eq!(
1162            AggregateField::CountDistinct("email".into()).alias(),
1163            "_count_distinct_email"
1164        );
1165        assert_eq!(AggregateField::Sum("amount".into()).alias(), "_sum_amount");
1166        assert_eq!(
1167            AggregateField::Avg("score".to_string()).alias(),
1168            "_avg_score"
1169        );
1170        assert_eq!(AggregateField::Min("age".into()).alias(), "_min_age");
1171        assert_eq!(
1172            AggregateField::Max("salary".to_string()).alias(),
1173            "_max_salary"
1174        );
1175    }
1176
1177    // ========== AggregateResult Tests ==========
1178
1179    #[test]
1180    fn test_aggregate_result_default() {
1181        let result = AggregateResult::default();
1182        assert!(result.count.is_none());
1183        assert!(result.sum.is_empty());
1184        assert!(result.avg.is_empty());
1185        assert!(result.min.is_empty());
1186        assert!(result.max.is_empty());
1187    }
1188
1189    #[test]
1190    fn test_aggregate_result_debug() {
1191        let result = AggregateResult::default();
1192        let debug_str = format!("{:?}", result);
1193        assert!(debug_str.contains("AggregateResult"));
1194    }
1195
1196    #[test]
1197    fn test_aggregate_result_clone() {
1198        let mut result = AggregateResult {
1199            count: Some(42),
1200            ..AggregateResult::default()
1201        };
1202        result.sum.insert("amount".into(), 1000.0);
1203
1204        let cloned = result.clone();
1205        assert_eq!(cloned.count, Some(42));
1206        assert_eq!(cloned.sum.get("amount"), Some(&1000.0));
1207    }
1208
1209    // ========== AggregateOperation Tests ==========
1210
1211    #[test]
1212    fn test_aggregate_operation_new() {
1213        let op: AggregateOperation<TestModel, MockEngine> = AggregateOperation::new();
1214        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
1215
1216        // Default should be count all
1217        assert!(sql.contains("COUNT(*)"));
1218        assert!(params.is_empty());
1219    }
1220
1221    #[test]
1222    fn test_aggregate_operation_default() {
1223        let op: AggregateOperation<TestModel, MockEngine> = AggregateOperation::default();
1224        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
1225
1226        assert!(sql.contains("COUNT(*)"));
1227        assert!(params.is_empty());
1228    }
1229
1230    #[test]
1231    fn test_aggregate_operation_build_sql() {
1232        let op: AggregateOperation<TestModel, MockEngine> =
1233            AggregateOperation::new().count().sum("score").avg("age");
1234
1235        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
1236
1237        assert!(sql.contains("SELECT"));
1238        assert!(sql.contains("COUNT(*)"));
1239        assert!(sql.contains(r#"SUM("score")"#));
1240        assert!(sql.contains(r#"AVG("age")"#));
1241        assert!(sql.contains(r#"FROM "test_models""#));
1242        assert!(params.is_empty());
1243    }
1244
1245    #[test]
1246    fn test_aggregate_operation_count_column() {
1247        let op: AggregateOperation<TestModel, MockEngine> =
1248            AggregateOperation::new().count_column("email");
1249
1250        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
1251
1252        assert!(sql.contains(r#"COUNT("email")"#));
1253    }
1254
1255    #[test]
1256    fn test_aggregate_operation_count_distinct() {
1257        let op: AggregateOperation<TestModel, MockEngine> =
1258            AggregateOperation::new().count_distinct("email");
1259
1260        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
1261
1262        assert!(sql.contains(r#"COUNT(DISTINCT "email")"#));
1263    }
1264
1265    #[test]
1266    fn test_aggregate_operation_min_max() {
1267        let op: AggregateOperation<TestModel, MockEngine> =
1268            AggregateOperation::new().min("age").max("age");
1269
1270        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
1271
1272        assert!(sql.contains(r#"MIN("age")"#));
1273        assert!(sql.contains(r#"MAX("age")"#));
1274    }
1275
1276    #[test]
1277    fn test_aggregate_with_where() {
1278        let op: AggregateOperation<TestModel, MockEngine> = AggregateOperation::new()
1279            .count()
1280            .r#where(Filter::Gt("age".into(), FilterValue::Int(18)));
1281
1282        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
1283
1284        // First (and only) placeholder must be $1 — param_offset is 0.
1285        assert_eq!(
1286            sql,
1287            r#"SELECT COUNT(*) AS "_count" FROM "test_models" WHERE "age" > $1"#
1288        );
1289        assert_eq!(params, vec![FilterValue::Int(18)]);
1290    }
1291
1292    #[test]
1293    fn test_aggregate_with_complex_filter() {
1294        let op: AggregateOperation<TestModel, MockEngine> = AggregateOperation::new()
1295            .sum("score")
1296            .avg("age")
1297            .r#where(Filter::and([
1298                Filter::Gte("age".into(), FilterValue::Int(18)),
1299                Filter::Equals("active".into(), FilterValue::Bool(true)),
1300            ]));
1301
1302        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
1303
1304        assert!(
1305            sql.contains(r#"WHERE ("age" >= $1 AND "active" = $2)"#),
1306            "got: {sql}"
1307        );
1308        assert_eq!(params.len(), 2);
1309    }
1310
1311    #[test]
1312    fn test_aggregate_where_and_composes() {
1313        // A second `r#where` must AND with the first, not overwrite it.
1314        let op: AggregateOperation<TestModel, MockEngine> = AggregateOperation::new()
1315            .count()
1316            .r#where(Filter::Equals("active".into(), FilterValue::Bool(true)))
1317            .r#where(Filter::Gt("age".into(), FilterValue::Int(18)));
1318
1319        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
1320
1321        assert!(
1322            sql.contains(r#"WHERE ("active" = $1 AND "age" > $2)"#),
1323            "got: {sql}"
1324        );
1325        assert_eq!(params, vec![FilterValue::Bool(true), FilterValue::Int(18)]);
1326    }
1327
1328    #[test]
1329    fn test_aggregate_mysql_dialect() {
1330        let op: AggregateOperation<TestModel, MockEngine> = AggregateOperation::new()
1331            .count()
1332            .sum("score")
1333            .r#where(Filter::Equals("active".into(), FilterValue::Bool(true)));
1334
1335        let (sql, params) = op.build_sql(&crate::dialect::Mysql);
1336
1337        assert_eq!(
1338            sql,
1339            "SELECT COUNT(*) AS `_count`, SUM(`score`) AS `_sum_score` \
1340             FROM `test_models` WHERE `active` = ?"
1341        );
1342        assert_eq!(params, vec![FilterValue::Bool(true)]);
1343    }
1344
1345    #[test]
1346    fn test_aggregate_all_methods() {
1347        let op: AggregateOperation<TestModel, MockEngine> = AggregateOperation::new()
1348            .count()
1349            .count_column("name")
1350            .count_distinct("email")
1351            .sum("score")
1352            .avg("score")
1353            .min("age")
1354            .max("age");
1355
1356        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
1357
1358        assert!(sql.contains("COUNT(*)"));
1359        assert!(sql.contains(r#"COUNT("name")"#));
1360        assert!(sql.contains(r#"COUNT(DISTINCT "email")"#));
1361        assert!(sql.contains(r#"SUM("score")"#));
1362        assert!(sql.contains(r#"AVG("score")"#));
1363        assert!(sql.contains(r#"MIN("age")"#));
1364        assert!(sql.contains(r#"MAX("age")"#));
1365    }
1366
1367    #[tokio::test]
1368    async fn test_aggregate_exec_without_engine_errors() {
1369        // `new()` leaves engine = None; exec must refuse to run rather
1370        // than silently doing nothing or panicking.
1371        let op: AggregateOperation<TestModel, MockEngine> = AggregateOperation::new().count();
1372        let err = op.exec().await.unwrap_err();
1373        assert!(err.to_string().contains("without an engine"));
1374    }
1375
1376    #[tokio::test]
1377    async fn test_aggregate_exec_with_engine_ok() {
1378        // MockEngine doesn't override `aggregate_query`, so the default
1379        // impl returns `unsupported`. We just verify the engine-to-trait
1380        // wiring is intact.
1381        let op: AggregateOperation<TestModel, MockEngine> =
1382            AggregateOperation::with_engine(MockEngine).count();
1383        let err = op.exec().await.unwrap_err();
1384        assert!(err.to_string().contains("aggregate_query"));
1385    }
1386
1387    // ========== GroupByOperation Tests ==========
1388
1389    #[test]
1390    fn test_group_by_new() {
1391        let op: GroupByOperation<TestModel, MockEngine> =
1392            GroupByOperation::new(vec!["department".into()]);
1393
1394        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
1395
1396        assert!(sql.contains(r#"GROUP BY "department""#));
1397    }
1398
1399    #[test]
1400    fn test_group_by_build_sql() {
1401        let op: GroupByOperation<TestModel, MockEngine> =
1402            GroupByOperation::new(vec!["name".to_string()])
1403                .count()
1404                .avg("score");
1405
1406        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
1407
1408        assert!(sql.contains("SELECT"));
1409        assert!(sql.contains(r#""name""#)); // Quoted via the dialect
1410        assert!(sql.contains("COUNT(*)"));
1411        assert!(sql.contains(r#"AVG("score")"#));
1412        assert!(sql.contains(r#"GROUP BY "name""#));
1413        assert!(params.is_empty());
1414    }
1415
1416    #[test]
1417    fn test_group_by_multiple_columns() {
1418        let op: GroupByOperation<TestModel, MockEngine> =
1419            GroupByOperation::new(vec!["department".into(), "role".into()]).count();
1420
1421        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
1422
1423        assert!(sql.contains(r#"GROUP BY "department", "role""#));
1424    }
1425
1426    #[test]
1427    fn test_group_by_with_sum() {
1428        let op: GroupByOperation<TestModel, MockEngine> =
1429            GroupByOperation::new(vec!["category".into()]).sum("amount");
1430
1431        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
1432
1433        assert!(sql.contains(r#"SUM("amount")"#));
1434    }
1435
1436    #[test]
1437    fn test_group_by_with_min_max() {
1438        let op: GroupByOperation<TestModel, MockEngine> =
1439            GroupByOperation::new(vec!["category".into()])
1440                .min("price")
1441                .max("price");
1442
1443        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
1444
1445        assert!(sql.contains(r#"MIN("price")"#));
1446        assert!(sql.contains(r#"MAX("price")"#));
1447    }
1448
1449    #[test]
1450    fn test_group_by_with_where() {
1451        let op: GroupByOperation<TestModel, MockEngine> =
1452            GroupByOperation::new(vec!["department".into()])
1453                .count()
1454                .r#where(Filter::Equals("active".into(), FilterValue::Bool(true)));
1455
1456        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
1457
1458        // First (and only) placeholder must be $1 — param_offset is 0.
1459        assert!(sql.contains(r#"WHERE "active" = $1"#), "got: {sql}");
1460        assert!(sql.contains("GROUP BY"));
1461        assert_eq!(params, vec![FilterValue::Bool(true)]);
1462    }
1463
1464    #[test]
1465    fn test_group_by_where_and_composes() {
1466        // A second `r#where` must AND with the first, not overwrite it.
1467        let op: GroupByOperation<TestModel, MockEngine> =
1468            GroupByOperation::new(vec!["department".into()])
1469                .count()
1470                .r#where(Filter::Equals("active".into(), FilterValue::Bool(true)))
1471                .r#where(Filter::Gt("age".into(), FilterValue::Int(18)));
1472
1473        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
1474
1475        assert!(
1476            sql.contains(r#"WHERE ("active" = $1 AND "age" > $2)"#),
1477            "got: {sql}"
1478        );
1479        assert_eq!(params, vec![FilterValue::Bool(true), FilterValue::Int(18)]);
1480    }
1481
1482    #[test]
1483    fn test_group_by_with_having() {
1484        let op: GroupByOperation<TestModel, MockEngine> =
1485            GroupByOperation::new(vec!["name".to_string()])
1486                .count()
1487                .having(having::count_gt(5.0));
1488
1489        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
1490
1491        // The HAVING value is bound as a parameter, not interpolated.
1492        assert!(sql.contains("HAVING COUNT(*) > $1"), "got: {sql}");
1493        assert_eq!(params, vec![FilterValue::Float(5.0)]);
1494    }
1495
1496    #[test]
1497    fn test_group_by_having_placeholder_follows_where_params() {
1498        let op: GroupByOperation<TestModel, MockEngine> =
1499            GroupByOperation::new(vec!["department".into()])
1500                .count()
1501                .r#where(Filter::Equals("active".into(), FilterValue::Bool(true)))
1502                .having(having::count_gt(5.0));
1503
1504        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
1505
1506        assert!(sql.contains(r#"WHERE "active" = $1"#), "got: {sql}");
1507        assert!(sql.contains("HAVING COUNT(*) > $2"), "got: {sql}");
1508        assert_eq!(
1509            params,
1510            vec![FilterValue::Bool(true), FilterValue::Float(5.0)]
1511        );
1512    }
1513
1514    #[test]
1515    fn test_group_by_mysql_dialect() {
1516        let op: GroupByOperation<TestModel, MockEngine> =
1517            GroupByOperation::new(vec!["department".into()])
1518                .count()
1519                .having(having::count_gt(5.0));
1520
1521        let (sql, params) = op.build_sql(&crate::dialect::Mysql);
1522
1523        assert_eq!(
1524            sql,
1525            "SELECT `department`, COUNT(*) AS `_count` FROM `test_models` \
1526             GROUP BY `department` HAVING COUNT(*) > ?"
1527        );
1528        assert_eq!(params, vec![FilterValue::Float(5.0)]);
1529    }
1530
1531    #[test]
1532    #[should_panic(expected = "must be finite")]
1533    fn test_group_by_having_rejects_nan() {
1534        let _ = GroupByOperation::<TestModel, MockEngine>::new(vec!["department".into()])
1535            .count()
1536            .having(having::count_gt(f64::NAN));
1537    }
1538
1539    #[test]
1540    #[should_panic(expected = "must be finite")]
1541    fn test_group_by_having_rejects_infinity() {
1542        let _ = GroupByOperation::<TestModel, MockEngine>::new(vec!["department".into()])
1543            .count()
1544            .having(having::avg_gt("score", f64::INFINITY));
1545    }
1546
1547    #[test]
1548    fn test_group_by_with_order_and_limit() {
1549        let op: GroupByOperation<TestModel, MockEngine> =
1550            GroupByOperation::new(vec!["name".to_string()])
1551                .count()
1552                .order_by(OrderByField::desc("_count"))
1553                .take(10)
1554                .skip(5);
1555
1556        let (sql, _params) = op.build_sql(&crate::dialect::Postgres);
1557
1558        assert!(sql.contains(r#"ORDER BY "_count" DESC"#)); // Quoted via the dialect
1559        assert!(sql.contains("LIMIT 10"));
1560        assert!(sql.contains("OFFSET 5"));
1561    }
1562
1563    #[test]
1564    fn test_group_by_order_with_nulls() {
1565        let op: GroupByOperation<TestModel, MockEngine> =
1566            GroupByOperation::new(vec!["department".into()])
1567                .count()
1568                .order_by(OrderByField::asc("name").nulls(NullsOrder::First));
1569
1570        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
1571
1572        assert!(sql.contains("ORDER BY"));
1573        assert!(sql.contains("NULLS FIRST"));
1574    }
1575
1576    #[test]
1577    fn test_group_by_skip_only() {
1578        let op: GroupByOperation<TestModel, MockEngine> =
1579            GroupByOperation::new(vec!["department".into()])
1580                .count()
1581                .skip(20);
1582
1583        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
1584
1585        assert!(sql.contains("OFFSET 20"));
1586        assert!(!sql.contains("LIMIT"));
1587    }
1588
1589    #[test]
1590    fn test_group_by_take_only() {
1591        let op: GroupByOperation<TestModel, MockEngine> =
1592            GroupByOperation::new(vec!["department".into()])
1593                .count()
1594                .take(50);
1595
1596        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
1597
1598        assert!(sql.contains("LIMIT 50"));
1599        assert!(!sql.contains("OFFSET"));
1600    }
1601
1602    #[tokio::test]
1603    async fn test_group_by_exec_without_engine_errors() {
1604        let op: GroupByOperation<TestModel, MockEngine> =
1605            GroupByOperation::new(vec!["department".into()]).count();
1606        let err = op.exec().await.unwrap_err();
1607        assert!(err.to_string().contains("without an engine"));
1608    }
1609
1610    #[tokio::test]
1611    async fn test_group_by_exec_with_engine_ok() {
1612        let op: GroupByOperation<TestModel, MockEngine> =
1613            GroupByOperation::with_engine(MockEngine, vec!["department".into()]).count();
1614        let err = op.exec().await.unwrap_err();
1615        assert!(err.to_string().contains("aggregate_query"));
1616    }
1617
1618    // ========== HavingOp Tests ==========
1619
1620    #[test]
1621    fn test_having_op_as_str() {
1622        assert_eq!(HavingOp::Gt.as_str(), ">");
1623        assert_eq!(HavingOp::Gte.as_str(), ">=");
1624        assert_eq!(HavingOp::Lt.as_str(), "<");
1625        assert_eq!(HavingOp::Lte.as_str(), "<=");
1626        assert_eq!(HavingOp::Eq.as_str(), "=");
1627        assert_eq!(HavingOp::Ne.as_str(), "<>");
1628    }
1629
1630    // ========== HavingCondition Tests ==========
1631
1632    #[test]
1633    fn test_having_condition_debug() {
1634        let cond = HavingCondition {
1635            field: AggregateField::CountAll,
1636            op: HavingOp::Gt,
1637            value: 10.0,
1638        };
1639        let debug_str = format!("{:?}", cond);
1640        assert!(debug_str.contains("HavingCondition"));
1641    }
1642
1643    #[test]
1644    fn test_having_condition_clone() {
1645        let cond = HavingCondition {
1646            field: AggregateField::Sum("amount".into()),
1647            op: HavingOp::Gte,
1648            value: 1000.0,
1649        };
1650        let cloned = cond.clone();
1651        assert!((cloned.value - 1000.0).abs() < f64::EPSILON);
1652    }
1653
1654    // ========== Having Helper Tests ==========
1655
1656    #[test]
1657    fn test_having_helpers() {
1658        let cond = having::count_gt(10.0);
1659        assert!(matches!(cond.field, AggregateField::CountAll));
1660        assert!(matches!(cond.op, HavingOp::Gt));
1661        assert!((cond.value - 10.0).abs() < f64::EPSILON);
1662
1663        let cond = having::sum_gt("amount", 1000.0);
1664        if let AggregateField::Sum(col) = cond.field {
1665            assert_eq!(col, "amount");
1666        } else {
1667            panic!("Expected Sum");
1668        }
1669    }
1670
1671    #[test]
1672    fn test_having_count_gte() {
1673        let cond = having::count_gte(5.0);
1674        assert!(matches!(cond.field, AggregateField::CountAll));
1675        assert!(matches!(cond.op, HavingOp::Gte));
1676        assert!((cond.value - 5.0).abs() < f64::EPSILON);
1677    }
1678
1679    #[test]
1680    fn test_having_count_lt() {
1681        let cond = having::count_lt(100.0);
1682        assert!(matches!(cond.field, AggregateField::CountAll));
1683        assert!(matches!(cond.op, HavingOp::Lt));
1684        assert!((cond.value - 100.0).abs() < f64::EPSILON);
1685    }
1686
1687    #[test]
1688    fn test_having_avg_gt() {
1689        let cond = having::avg_gt("score", 75.5);
1690        assert!(matches!(cond.op, HavingOp::Gt));
1691        assert!((cond.value - 75.5).abs() < f64::EPSILON);
1692        if let AggregateField::Avg(col) = cond.field {
1693            assert_eq!(col, "score");
1694        } else {
1695            panic!("Expected Avg");
1696        }
1697    }
1698
1699    #[test]
1700    fn test_having_sum_gt_with_different_columns() {
1701        let cond1 = having::sum_gt("revenue", 50000.0);
1702        let cond2 = having::sum_gt("cost", 10000.0);
1703
1704        if let AggregateField::Sum(col) = &cond1.field {
1705            assert_eq!(col, "revenue");
1706        }
1707        if let AggregateField::Sum(col) = &cond2.field {
1708            assert_eq!(col, "cost");
1709        }
1710    }
1711
1712    // ========== GroupByResult Tests ==========
1713
1714    #[test]
1715    fn test_group_by_result_debug() {
1716        let result = GroupByResult {
1717            group_values: std::collections::HashMap::new(),
1718            aggregates: AggregateResult::default(),
1719        };
1720        let debug_str = format!("{:?}", result);
1721        assert!(debug_str.contains("GroupByResult"));
1722    }
1723
1724    #[test]
1725    fn test_group_by_result_clone() {
1726        let mut result = GroupByResult {
1727            group_values: std::collections::HashMap::new(),
1728            aggregates: AggregateResult::default(),
1729        };
1730        result
1731            .group_values
1732            .insert("category".into(), serde_json::json!("electronics"));
1733        result.aggregates.count = Some(50);
1734
1735        let cloned = result.clone();
1736        assert_eq!(cloned.aggregates.count, Some(50));
1737        assert!(cloned.group_values.contains_key("category"));
1738    }
1739
1740    // ========== SQL Structure Tests ==========
1741
1742    #[test]
1743    fn test_group_by_sql_structure() {
1744        let op: GroupByOperation<TestModel, MockEngine> =
1745            GroupByOperation::new(vec!["department".into()])
1746                .count()
1747                .r#where(Filter::Equals("active".into(), FilterValue::Bool(true)))
1748                .having(having::count_gt(5.0))
1749                .order_by(OrderByField::desc("_count"))
1750                .take(10)
1751                .skip(5);
1752
1753        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
1754
1755        // Check SQL clause ordering: SELECT, FROM, WHERE, GROUP BY, HAVING, ORDER BY, LIMIT, OFFSET
1756        let select_pos = sql.find("SELECT").unwrap();
1757        let from_pos = sql.find("FROM").unwrap();
1758        let where_pos = sql.find("WHERE").unwrap();
1759        let group_pos = sql.find("GROUP BY").unwrap();
1760        let having_pos = sql.find("HAVING").unwrap();
1761        let order_pos = sql.find("ORDER BY").unwrap();
1762        let limit_pos = sql.find("LIMIT").unwrap();
1763        let offset_pos = sql.find("OFFSET").unwrap();
1764
1765        assert!(select_pos < from_pos);
1766        assert!(from_pos < where_pos);
1767        assert!(where_pos < group_pos);
1768        assert!(group_pos < having_pos);
1769        assert!(having_pos < order_pos);
1770        assert!(order_pos < limit_pos);
1771        assert!(limit_pos < offset_pos);
1772    }
1773
1774    #[test]
1775    fn test_aggregate_no_group_by() {
1776        let op: AggregateOperation<TestModel, MockEngine> =
1777            AggregateOperation::new().count().sum("score");
1778
1779        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
1780
1781        assert!(!sql.contains("GROUP BY"));
1782    }
1783
1784    #[test]
1785    fn test_group_by_empty_columns() {
1786        let op: GroupByOperation<TestModel, MockEngine> = GroupByOperation::new(vec![]).count();
1787
1788        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
1789
1790        // Empty group columns should not produce GROUP BY
1791        assert!(!sql.contains("GROUP BY"));
1792    }
1793
1794    #[test]
1795    fn group_by_build_sql_emits_count_column_and_distinct() {
1796        let op: GroupByOperation<TestModel, MockEngine> =
1797            GroupByOperation::new(vec!["team_id".to_string()])
1798                .count_column("email")
1799                .count_distinct("region");
1800        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
1801        assert!(
1802            sql.contains(r#"COUNT("email") AS "_count_email""#),
1803            "got: {sql}"
1804        );
1805        assert!(
1806            sql.contains(r#"COUNT(DISTINCT "region") AS "_count_distinct_region""#),
1807            "got: {sql}"
1808        );
1809    }
1810
1811    #[test]
1812    fn test_having_count_lte_eq_ne() {
1813        let c = having::count_lte(10.0);
1814        assert!(matches!(c.field, AggregateField::CountAll));
1815        assert!(matches!(c.op, HavingOp::Lte));
1816        assert_eq!(c.value, 10.0);
1817
1818        let c = having::count_eq(5.0);
1819        assert!(matches!(c.op, HavingOp::Eq));
1820
1821        let c = having::count_ne(0.0);
1822        assert!(matches!(c.op, HavingOp::Ne));
1823    }
1824
1825    #[test]
1826    fn test_having_sum_variants() {
1827        let c = having::sum_gte("views", 100.0);
1828        assert!(matches!(&c.field, AggregateField::Sum(col) if col == "views"));
1829        assert!(matches!(c.op, HavingOp::Gte));
1830
1831        let c = having::sum_lt("views", 50.0);
1832        assert!(matches!(c.op, HavingOp::Lt));
1833
1834        let c = having::sum_lte("views", 50.0);
1835        assert!(matches!(c.op, HavingOp::Lte));
1836
1837        let c = having::sum_eq("views", 0.0);
1838        assert!(matches!(c.op, HavingOp::Eq));
1839
1840        let c = having::sum_ne("views", 0.0);
1841        assert!(matches!(c.op, HavingOp::Ne));
1842    }
1843
1844    #[test]
1845    fn test_having_avg_variants() {
1846        let c = having::avg_gte("score", 3.5);
1847        assert!(matches!(&c.field, AggregateField::Avg(col) if col == "score"));
1848        assert!(matches!(c.op, HavingOp::Gte));
1849
1850        let c = having::avg_lt("score", 2.0);
1851        assert!(matches!(c.op, HavingOp::Lt));
1852
1853        let c = having::avg_lte("score", 2.0);
1854        assert!(matches!(c.op, HavingOp::Lte));
1855
1856        let c = having::avg_eq("score", 5.0);
1857        assert!(matches!(c.op, HavingOp::Eq));
1858
1859        let c = having::avg_ne("score", 0.0);
1860        assert!(matches!(c.op, HavingOp::Ne));
1861    }
1862
1863    #[test]
1864    fn test_having_min_variants() {
1865        let c = having::min_gt("age", 18.0);
1866        assert!(matches!(&c.field, AggregateField::Min(col) if col == "age"));
1867        assert!(matches!(c.op, HavingOp::Gt));
1868
1869        let c = having::min_gte("age", 18.0);
1870        assert!(matches!(c.op, HavingOp::Gte));
1871
1872        let c = having::min_lt("age", 65.0);
1873        assert!(matches!(c.op, HavingOp::Lt));
1874
1875        let c = having::min_lte("age", 65.0);
1876        assert!(matches!(c.op, HavingOp::Lte));
1877
1878        let c = having::min_eq("age", 21.0);
1879        assert!(matches!(c.op, HavingOp::Eq));
1880
1881        let c = having::min_ne("age", 0.0);
1882        assert!(matches!(c.op, HavingOp::Ne));
1883    }
1884
1885    #[test]
1886    fn test_having_max_variants() {
1887        let c = having::max_gt("salary", 50000.0);
1888        assert!(matches!(&c.field, AggregateField::Max(col) if col == "salary"));
1889        assert!(matches!(c.op, HavingOp::Gt));
1890
1891        let c = having::max_gte("salary", 50000.0);
1892        assert!(matches!(c.op, HavingOp::Gte));
1893
1894        let c = having::max_lt("salary", 200000.0);
1895        assert!(matches!(c.op, HavingOp::Lt));
1896
1897        let c = having::max_lte("salary", 200000.0);
1898        assert!(matches!(c.op, HavingOp::Lte));
1899
1900        let c = having::max_eq("salary", 100000.0);
1901        assert!(matches!(c.op, HavingOp::Eq));
1902
1903        let c = having::max_ne("salary", 0.0);
1904        assert!(matches!(c.op, HavingOp::Ne));
1905    }
1906
1907    #[test]
1908    fn from_row_hydrates_per_column_and_distinct_counts() {
1909        use crate::filter::FilterValue;
1910        use std::collections::HashMap;
1911        let mut row = HashMap::new();
1912        row.insert("_count".to_string(), FilterValue::Int(5));
1913        row.insert("_count_email".to_string(), FilterValue::Int(3));
1914        row.insert("_count_distinct_email".to_string(), FilterValue::Int(2));
1915        let r = AggregateResult::from_row(row);
1916        assert_eq!(r.count, Some(5));
1917        assert_eq!(r.count_of("email"), Some(3));
1918        assert_eq!(r.count_distinct_of("email"), Some(2));
1919        // The distinct entry must NOT leak into count_columns keyed
1920        // "distinct_email" via the _count_ prefix (ordering trap).
1921        assert_eq!(r.count_columns.get("distinct_email"), None);
1922    }
1923}