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