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