Skip to main content

fraiseql_core/runtime/
aggregate_parser.rs

1//! Aggregate Query Parser
2//!
3//! Parses GraphQL aggregate queries into `AggregationRequest` for execution.
4//!
5//! # GraphQL Query Format
6//!
7//! ```graphql
8//! query {
9//!   sales_aggregate(
10//!     where: { customer_id: { _eq: "uuid-123" } }
11//!     groupBy: { category: true, occurred_at_day: true }
12//!     having: { revenue_sum_gt: 1000 }
13//!     orderBy: { revenue_sum: DESC }
14//!     limit: 10
15//!   ) {
16//!     category
17//!     occurred_at_day
18//!     count
19//!     revenue_sum
20//!     revenue_avg
21//!   }
22//! }
23//! ```
24//!
25//! # Parsed Result
26//!
27//! ```text
28//! // Illustrative output structure only — not executable code.
29//! AggregationRequest {
30//!     table_name: "tf_sales",
31//!     where_clause: Some(...),
32//!     group_by: vec![
33//!         GroupBySelection::Dimension { path: "category", alias: "category" },
34//!         GroupBySelection::TemporalBucket { column: "occurred_at", bucket: Day, alias: "occurred_at_day" },
35//!     ],
36//!     aggregates: vec![
37//!         AggregateSelection::Count { alias: "count" },
38//!         AggregateSelection::MeasureAggregate { measure: "revenue", function: Sum, alias: "revenue_sum" },
39//!         AggregateSelection::MeasureAggregate { measure: "revenue", function: Avg, alias: "revenue_avg" },
40//!     ],
41//!     having: vec![...],
42//!     order_by: vec![...],
43//!     limit: Some(10),
44//!     offset: None,
45//! }
46//! ```
47
48use serde_json::Value;
49
50use crate::{
51    compiler::{
52        aggregate_types::{AggregateFunction, HavingOperator, TemporalBucket},
53        aggregation::{
54            AggregateSelection, AggregationRequest, GroupBySelection, HavingCondition,
55            OrderByClause, OrderDirection,
56        },
57        fact_table::FactTableMetadata,
58    },
59    db::where_clause::{WhereClause, WhereOperator},
60    error::{FraiseQLError, Result},
61};
62
63/// Aggregate query parser
64pub struct AggregateQueryParser;
65
66impl AggregateQueryParser {
67    /// Parse a simplified aggregate query into `AggregationRequest`.
68    ///
69    /// For we'll accept a JSON structure that represents the query:
70    /// ```json
71    /// {
72    ///   "table": "tf_sales",
73    ///   "groupBy": {
74    ///     "category": true,
75    ///     "occurred_at_day": true
76    ///   },
77    ///   "aggregates": [
78    ///     {"count": {}},
79    ///     {"revenue_sum": {}}
80    ///   ],
81    ///   "having": {
82    ///     "revenue_sum_gt": 1000
83    ///   },
84    ///   "orderBy": {
85    ///     "revenue_sum": "DESC"
86    ///   },
87    ///   "limit": 10
88    /// }
89    /// ```
90    ///
91    /// # Errors
92    ///
93    /// Returns error if the query structure is invalid or references non-existent measures.
94    pub fn parse(query_json: &Value, metadata: &FactTableMetadata) -> Result<AggregationRequest> {
95        // Extract table name
96        let table_name = query_json
97            .get("table")
98            .and_then(|v| v.as_str())
99            .ok_or_else(|| FraiseQLError::Validation {
100                message: "Missing 'table' field in aggregate query".to_string(),
101                path:    None,
102            })?
103            .to_string();
104
105        // Parse WHERE clause (if present)
106        let where_clause = if let Some(where_obj) = query_json.get("where") {
107            Some(Self::parse_where_clause(where_obj)?)
108        } else {
109            None
110        };
111
112        // Parse GROUP BY selections
113        let group_by = if let Some(group_by_obj) = query_json.get("groupBy") {
114            Self::parse_group_by(group_by_obj, metadata)?
115        } else {
116            vec![]
117        };
118
119        // Parse aggregate selections from requested fields
120        let aggregates = if let Some(agg_array) = query_json.get("aggregates") {
121            Self::parse_aggregates(agg_array, metadata)?
122        } else {
123            vec![]
124        };
125
126        // Parse HAVING conditions
127        let having = if let Some(having_obj) = query_json.get("having") {
128            Self::parse_having(having_obj, &aggregates, metadata)?
129        } else {
130            vec![]
131        };
132
133        // Parse ORDER BY clauses
134        let order_by = if let Some(order_obj) = query_json.get("orderBy") {
135            Self::parse_order_by(order_obj)?
136        } else {
137            vec![]
138        };
139
140        // Parse LIMIT
141        let limit = query_json
142            .get("limit")
143            .and_then(|v| v.as_u64())
144            .map(|n| u32::try_from(n).unwrap_or(u32::MAX));
145
146        // Parse OFFSET
147        let offset = query_json
148            .get("offset")
149            .and_then(|v| v.as_u64())
150            .map(|n| u32::try_from(n).unwrap_or(u32::MAX));
151
152        Ok(AggregationRequest {
153            table_name,
154            where_clause,
155            group_by,
156            aggregates,
157            having,
158            order_by,
159            limit,
160            offset,
161        })
162    }
163
164    /// Parse WHERE clause from JSON
165    ///
166    /// For aggregate queries, WHERE works on denormalized filter columns only.
167    /// Expected format: `{ "field_operator": value }`
168    /// Example: `{ "customer_id_eq": "123", "occurred_at_gte": "2024-01-01" }`
169    fn parse_where_clause(where_obj: &Value) -> Result<WhereClause> {
170        let Some(obj) = where_obj.as_object() else {
171            return Ok(WhereClause::And(vec![]));
172        };
173
174        let mut conditions = Vec::new();
175
176        for (key, value) in obj {
177            // Parse field_operator format (e.g., "customer_id_eq" -> field="customer_id",
178            // operator="eq")
179            if let Some((field, operator_str)) = Self::parse_where_field_and_operator(key)? {
180                let operator = WhereOperator::from_str(operator_str)?;
181
182                conditions.push(WhereClause::Field {
183                    path: vec![field.to_string()],
184                    operator,
185                    value: value.clone(),
186                });
187            }
188        }
189
190        Ok(WhereClause::And(conditions))
191    }
192
193    /// Parse WHERE field and operator from key (e.g., "`customer_id_eq`" -> ("`customer_id`",
194    /// "eq"))
195    fn parse_where_field_and_operator(key: &str) -> Result<Option<(&str, &str)>> {
196        // Find last underscore to split field from operator
197        if let Some(last_underscore) = key.rfind('_') {
198            let field = &key[..last_underscore];
199            let operator = &key[last_underscore + 1..];
200
201            // Validate operator is known
202            match WhereOperator::from_str(operator) {
203                Ok(_) => Ok(Some((field, operator))),
204                Err(_) => {
205                    // Not a valid operator suffix, treat entire key as field (might be used
206                    // elsewhere)
207                    Ok(None)
208                },
209            }
210        } else {
211            // No underscore, not a WHERE condition
212            Ok(None)
213        }
214    }
215
216    /// Parse GROUP BY selections
217    ///
218    /// Supports two formats:
219    /// 1. Boolean true: {"category": true} -> regular dimension
220    /// 2. Boolean true with suffix: {"`occurred_at_day"`: true} -> temporal bucket
221    /// 3. String bucket name: {"`occurred_at"`: "day"} -> temporal bucket
222    fn parse_group_by(
223        group_by_obj: &Value,
224        metadata: &FactTableMetadata,
225    ) -> Result<Vec<GroupBySelection>> {
226        let mut selections = Vec::new();
227
228        if let Some(obj) = group_by_obj.as_object() {
229            for (key, value) in obj {
230                if value.as_bool() == Some(true) {
231                    // Format 1 & 2: Boolean true (with or without suffix)
232                    // Priority 1: Try calendar dimension first (highest performance)
233                    if let Some(calendar_sel) = Self::try_parse_calendar_bucket(key, metadata)? {
234                        selections.push(calendar_sel);
235                    } else if let Some(bucket_sel) = Self::parse_temporal_bucket(key, metadata)? {
236                        // Priority 2: Fall back to DATE_TRUNC if no calendar dimension
237                        selections.push(bucket_sel);
238                    } else {
239                        // Priority 3: Regular dimension
240                        selections.push(GroupBySelection::Dimension {
241                            path:  key.clone(),
242                            alias: key.clone(),
243                        });
244                    }
245                } else if let Some(bucket_str) = value.as_str() {
246                    // Format 3: String bucket name {"occurred_at": "day"}
247                    let bucket = TemporalBucket::from_str(bucket_str)?;
248
249                    // Priority 1: Try calendar dimension first
250                    if let Some(calendar_sel) =
251                        Self::try_find_calendar_bucket(key, bucket, metadata)
252                    {
253                        selections.push(calendar_sel);
254                    } else {
255                        // Priority 2: Fall back to DATE_TRUNC
256                        // Verify this column exists in denormalized_filters
257                        let column_exists =
258                            metadata.denormalized_filters.iter().any(|f| f.name == *key);
259
260                        if !column_exists {
261                            return Err(FraiseQLError::Validation {
262                                message: format!(
263                                    "Temporal bucketing column '{}' not found in denormalized filters",
264                                    key
265                                ),
266                                path:    None,
267                            });
268                        }
269
270                        selections.push(GroupBySelection::TemporalBucket {
271                            column: key.clone(),
272                            bucket,
273                            alias: key.clone(),
274                        });
275                    }
276                }
277            }
278        }
279
280        Ok(selections)
281    }
282
283    /// Parse temporal bucket if the key matches pattern
284    fn parse_temporal_bucket(
285        key: &str,
286        metadata: &FactTableMetadata,
287    ) -> Result<Option<GroupBySelection>> {
288        // Check for temporal bucket patterns: column_day, column_week, etc.
289        for filter_col in &metadata.denormalized_filters {
290            for bucket in &[
291                ("_second", TemporalBucket::Second),
292                ("_minute", TemporalBucket::Minute),
293                ("_hour", TemporalBucket::Hour),
294                ("_day", TemporalBucket::Day),
295                ("_week", TemporalBucket::Week),
296                ("_month", TemporalBucket::Month),
297                ("_quarter", TemporalBucket::Quarter),
298                ("_year", TemporalBucket::Year),
299            ] {
300                let expected_key = format!("{}{}", filter_col.name, bucket.0);
301                if key == expected_key {
302                    return Ok(Some(GroupBySelection::TemporalBucket {
303                        column: filter_col.name.clone(),
304                        bucket: bucket.1,
305                        alias:  key.to_string(),
306                    }));
307                }
308            }
309        }
310
311        Ok(None)
312    }
313
314    /// Try to parse calendar dimension from key pattern (e.g., "`occurred_at_day`")
315    ///
316    /// Checks if the key matches a calendar dimension pattern and returns
317    /// a `CalendarDimension` selection if available, otherwise None.
318    fn try_parse_calendar_bucket(
319        key: &str,
320        metadata: &FactTableMetadata,
321    ) -> Result<Option<GroupBySelection>> {
322        for calendar_dim in &metadata.calendar_dimensions {
323            // Check all temporal bucket suffixes
324            for (suffix, bucket_type) in &[
325                ("_second", TemporalBucket::Second),
326                ("_minute", TemporalBucket::Minute),
327                ("_hour", TemporalBucket::Hour),
328                ("_day", TemporalBucket::Day),
329                ("_week", TemporalBucket::Week),
330                ("_month", TemporalBucket::Month),
331                ("_quarter", TemporalBucket::Quarter),
332                ("_year", TemporalBucket::Year),
333            ] {
334                let expected_key = format!("{}{}", calendar_dim.source_column, suffix);
335                if key == expected_key {
336                    // Find matching calendar bucket
337                    if let Some((gran, bucket)) =
338                        Self::find_calendar_bucket(calendar_dim, *bucket_type)
339                    {
340                        return Ok(Some(GroupBySelection::CalendarDimension {
341                            source_column:   calendar_dim.source_column.clone(),
342                            calendar_column: gran.column_name.clone(),
343                            json_key:        bucket.json_key.clone(),
344                            bucket:          bucket.bucket_type,
345                            alias:           key.to_string(),
346                        }));
347                    }
348                }
349            }
350        }
351        Ok(None)
352    }
353
354    /// Try to find calendar bucket for explicit temporal request
355    ///
356    /// Used when user provides explicit bucket like {"`occurred_at"`: "day"}
357    fn try_find_calendar_bucket(
358        column: &str,
359        bucket: TemporalBucket,
360        metadata: &FactTableMetadata,
361    ) -> Option<GroupBySelection> {
362        for calendar_dim in &metadata.calendar_dimensions {
363            if calendar_dim.source_column == column {
364                if let Some((gran, cal_bucket)) = Self::find_calendar_bucket(calendar_dim, bucket) {
365                    return Some(GroupBySelection::CalendarDimension {
366                        source_column:   calendar_dim.source_column.clone(),
367                        calendar_column: gran.column_name.clone(),
368                        json_key:        cal_bucket.json_key.clone(),
369                        bucket:          cal_bucket.bucket_type,
370                        alias:           column.to_string(),
371                    });
372                }
373            }
374        }
375        None
376    }
377
378    /// Find calendar bucket in available granularities
379    ///
380    /// Searches through calendar dimension granularities to find a matching bucket type.
381    /// Returns the granularity and bucket if found.
382    fn find_calendar_bucket(
383        calendar_dim: &crate::compiler::fact_table::CalendarDimension,
384        bucket: TemporalBucket,
385    ) -> Option<(
386        &crate::compiler::fact_table::CalendarGranularity,
387        &crate::compiler::fact_table::CalendarBucket,
388    )> {
389        for granularity in &calendar_dim.granularities {
390            for cal_bucket in &granularity.buckets {
391                if cal_bucket.bucket_type == bucket {
392                    return Some((granularity, cal_bucket));
393                }
394            }
395        }
396        None
397    }
398
399    /// Parse aggregate selections
400    fn parse_aggregates(
401        agg_array: &Value,
402        metadata: &FactTableMetadata,
403    ) -> Result<Vec<AggregateSelection>> {
404        let mut aggregates = Vec::new();
405
406        if let Some(arr) = agg_array.as_array() {
407            for item in arr {
408                if let Some(obj) = item.as_object() {
409                    // Each object should have one key (the aggregate name)
410                    for (agg_name, _value) in obj {
411                        aggregates.push(Self::parse_aggregate_selection(agg_name, metadata)?);
412                    }
413                }
414            }
415        }
416
417        Ok(aggregates)
418    }
419
420    /// Parse a single aggregate selection
421    fn parse_aggregate_selection(
422        agg_name: &str,
423        metadata: &FactTableMetadata,
424    ) -> Result<AggregateSelection> {
425        // Handle COUNT
426        if agg_name == "count" {
427            return Ok(AggregateSelection::Count {
428                alias: "count".to_string(),
429            });
430        }
431
432        // Handle COUNT_DISTINCT: supports both "count_distinct" (defaults to first dimension)
433        // and "field_count_distinct" pattern (e.g., "product_id_count_distinct")
434        if agg_name == "count_distinct" {
435            // Default to first dimension path, or "id" if none available
436            let default_field = Self::extract_dimension_paths(metadata)
437                .first()
438                .cloned()
439                .unwrap_or_else(|| "id".to_string());
440            return Ok(AggregateSelection::CountDistinct {
441                field: default_field,
442                alias: "count_distinct".to_string(),
443            });
444        }
445
446        // Handle field_count_distinct pattern (e.g., "customer_id_count_distinct")
447        if let Some(stripped) = agg_name.strip_suffix("_count_distinct") {
448            // Check if the stripped part matches a dimension path
449            let dimension_paths = Self::extract_dimension_paths(metadata);
450            if dimension_paths.iter().any(|p| p == stripped) {
451                return Ok(AggregateSelection::CountDistinct {
452                    field: stripped.to_string(),
453                    alias: agg_name.to_string(),
454                });
455            }
456            // Also allow count distinct on measures
457            if metadata.measures.iter().any(|m| m.name == stripped) {
458                return Ok(AggregateSelection::CountDistinct {
459                    field: stripped.to_string(),
460                    alias: agg_name.to_string(),
461                });
462            }
463            // If no match found, return error with helpful message
464            return Err(FraiseQLError::Validation {
465                message: format!(
466                    "COUNT DISTINCT field '{}' not found in dimensions or measures. Available: {:?}",
467                    stripped, dimension_paths
468                ),
469                path:    None,
470            });
471        }
472
473        // Handle boolean aggregates (BOOL_AND, BOOL_OR)
474        // e.g., "is_active_bool_and", "has_discount_bool_or"
475        for dimension_path in Self::extract_dimension_paths(metadata) {
476            if let Some(stripped) = agg_name.strip_suffix("_bool_and") {
477                if stripped == dimension_path {
478                    return Ok(AggregateSelection::BoolAggregate {
479                        field:    dimension_path,
480                        function: crate::compiler::aggregate_types::BoolAggregateFunction::And,
481                        alias:    agg_name.to_string(),
482                    });
483                }
484            }
485            if let Some(stripped) = agg_name.strip_suffix("_bool_or") {
486                if stripped == dimension_path {
487                    return Ok(AggregateSelection::BoolAggregate {
488                        field:    dimension_path,
489                        function: crate::compiler::aggregate_types::BoolAggregateFunction::Or,
490                        alias:    agg_name.to_string(),
491                    });
492                }
493            }
494        }
495
496        // Handle measure aggregates: revenue_sum, revenue_avg, etc.
497        for measure in &metadata.measures {
498            for func in &[
499                ("_sum", AggregateFunction::Sum),
500                ("_avg", AggregateFunction::Avg),
501                ("_min", AggregateFunction::Min),
502                ("_max", AggregateFunction::Max),
503                ("_stddev", AggregateFunction::Stddev),
504                ("_variance", AggregateFunction::Variance),
505                // Advanced aggregates
506                ("_array_agg", AggregateFunction::ArrayAgg),
507                ("_json_agg", AggregateFunction::JsonAgg),
508                ("_jsonb_agg", AggregateFunction::JsonbAgg),
509                ("_string_agg", AggregateFunction::StringAgg),
510            ] {
511                let expected_name = format!("{}{}", measure.name, func.0);
512                if agg_name == expected_name {
513                    return Ok(AggregateSelection::MeasureAggregate {
514                        measure:  measure.name.clone(),
515                        function: func.1,
516                        alias:    agg_name.to_string(),
517                    });
518                }
519            }
520        }
521
522        // Check for dimension-level advanced aggregates
523        // e.g., "product_id_array_agg", "product_name_string_agg"
524        for dimension_path in Self::extract_dimension_paths(metadata) {
525            for func in &[
526                ("_array_agg", AggregateFunction::ArrayAgg),
527                ("_json_agg", AggregateFunction::JsonAgg),
528                ("_jsonb_agg", AggregateFunction::JsonbAgg),
529                ("_string_agg", AggregateFunction::StringAgg),
530            ] {
531                let expected_name = format!("{}{}", dimension_path, func.0);
532                if agg_name == expected_name {
533                    // For dimension aggregates, store the path as the "measure"
534                    return Ok(AggregateSelection::MeasureAggregate {
535                        measure:  dimension_path,
536                        function: func.1,
537                        alias:    agg_name.to_string(),
538                    });
539                }
540            }
541        }
542
543        Err(FraiseQLError::Validation {
544            message: format!("Unknown aggregate selection: {agg_name}"),
545            path:    None,
546        })
547    }
548
549    /// Extract dimension paths from metadata for advanced aggregate parsing
550    fn extract_dimension_paths(metadata: &FactTableMetadata) -> Vec<String> {
551        let mut paths = Vec::new();
552
553        // Add dimension paths from JSONB column
554        for dim_path in &metadata.dimensions.paths {
555            paths.push(dim_path.name.clone());
556        }
557
558        // Add denormalized filter columns (these can also be aggregated)
559        for filter in &metadata.denormalized_filters {
560            paths.push(filter.name.clone());
561        }
562
563        paths
564    }
565
566    /// Parse HAVING conditions
567    fn parse_having(
568        having_obj: &Value,
569        aggregates: &[AggregateSelection],
570        _metadata: &FactTableMetadata,
571    ) -> Result<Vec<HavingCondition>> {
572        let mut conditions = Vec::new();
573
574        if let Some(obj) = having_obj.as_object() {
575            for (key, value) in obj {
576                // Parse condition: revenue_sum_gt: 1000
577                if let Some((agg_name, operator)) = Self::parse_having_key(key) {
578                    // Find the aggregate
579                    let aggregate = aggregates
580                        .iter()
581                        .find(|a| a.alias() == agg_name)
582                        .ok_or_else(|| FraiseQLError::Validation {
583                            message: format!(
584                                "HAVING condition references non-selected aggregate: {agg_name}"
585                            ),
586                            path:    None,
587                        })?
588                        .clone();
589
590                    conditions.push(HavingCondition {
591                        aggregate,
592                        operator,
593                        value: value.clone(),
594                    });
595                }
596            }
597        }
598
599        Ok(conditions)
600    }
601
602    /// Parse HAVING key to extract aggregate name and operator
603    fn parse_having_key(key: &str) -> Option<(&str, HavingOperator)> {
604        for (suffix, op) in &[
605            ("_gt", HavingOperator::Gt),
606            ("_gte", HavingOperator::Gte),
607            ("_lt", HavingOperator::Lt),
608            ("_lte", HavingOperator::Lte),
609            ("_eq", HavingOperator::Eq),
610            ("_neq", HavingOperator::Neq),
611        ] {
612            if let Some(agg_name) = key.strip_suffix(suffix) {
613                return Some((agg_name, *op));
614            }
615        }
616        None
617    }
618
619    /// Parse ORDER BY clauses
620    fn parse_order_by(order_obj: &Value) -> Result<Vec<OrderByClause>> {
621        let mut clauses = Vec::new();
622
623        if let Some(obj) = order_obj.as_object() {
624            for (field, value) in obj {
625                #[allow(clippy::match_same_arms)]
626                // Reason: explicit ASC arm documents accepted values; wildcard handles malformed
627                // input
628                let direction = match value.as_str() {
629                    Some("ASC" | "asc") => OrderDirection::Asc,
630                    Some("DESC" | "desc") => OrderDirection::Desc,
631                    _ => OrderDirection::Asc, // Default to ASC
632                };
633
634                clauses.push(OrderByClause {
635                    field: field.clone(),
636                    direction,
637                });
638            }
639        }
640
641        Ok(clauses)
642    }
643}
644
645#[cfg(test)]
646mod tests {
647    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
648
649    use serde_json::json;
650
651    use super::*;
652    use crate::compiler::fact_table::{DimensionColumn, FilterColumn, MeasureColumn, SqlType};
653
654    fn create_test_metadata() -> FactTableMetadata {
655        use crate::compiler::fact_table::DimensionPath;
656
657        FactTableMetadata {
658            table_name:           "tf_sales".to_string(),
659            measures:             vec![
660                MeasureColumn {
661                    name:     "revenue".to_string(),
662                    sql_type: SqlType::Decimal,
663                    nullable: false,
664                },
665                MeasureColumn {
666                    name:     "quantity".to_string(),
667                    sql_type: SqlType::Int,
668                    nullable: false,
669                },
670            ],
671            dimensions:           DimensionColumn {
672                name:  "dimensions".to_string(),
673                paths: vec![
674                    DimensionPath {
675                        name:      "category".to_string(),
676                        json_path: "data->>'category'".to_string(),
677                        data_type: "text".to_string(),
678                    },
679                    DimensionPath {
680                        name:      "product".to_string(),
681                        json_path: "data->>'product'".to_string(),
682                        data_type: "text".to_string(),
683                    },
684                ],
685            },
686            denormalized_filters: vec![FilterColumn {
687                name:     "occurred_at".to_string(),
688                sql_type: SqlType::Timestamp,
689                indexed:  true,
690            }],
691            calendar_dimensions:  vec![],
692        }
693    }
694
695    #[test]
696    fn test_parse_simple_count() {
697        let metadata = create_test_metadata();
698        let query = json!({
699            "table": "tf_sales",
700            "aggregates": [
701                {"count": {}}
702            ]
703        });
704
705        let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
706
707        assert_eq!(request.table_name, "tf_sales");
708        assert_eq!(request.aggregates.len(), 1);
709        assert_eq!(request.aggregates[0].alias(), "count");
710    }
711
712    #[test]
713    fn test_parse_group_by_dimension() {
714        let metadata = create_test_metadata();
715        let query = json!({
716            "table": "tf_sales",
717            "groupBy": {
718                "category": true
719            },
720            "aggregates": [
721                {"count": {}}
722            ]
723        });
724
725        let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
726
727        assert_eq!(request.group_by.len(), 1);
728        match &request.group_by[0] {
729            GroupBySelection::Dimension { path, alias } => {
730                assert_eq!(path, "category");
731                assert_eq!(alias, "category");
732            },
733            _ => panic!("Expected Dimension selection"),
734        }
735    }
736
737    #[test]
738    fn test_parse_temporal_bucket() {
739        let metadata = create_test_metadata();
740        let query = json!({
741            "table": "tf_sales",
742            "groupBy": {
743                "occurred_at_day": true
744            },
745            "aggregates": [
746                {"count": {}}
747            ]
748        });
749
750        let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
751
752        assert_eq!(request.group_by.len(), 1);
753        match &request.group_by[0] {
754            GroupBySelection::TemporalBucket {
755                column,
756                bucket,
757                alias,
758            } => {
759                assert_eq!(column, "occurred_at");
760                assert_eq!(*bucket, TemporalBucket::Day);
761                assert_eq!(alias, "occurred_at_day");
762            },
763            _ => panic!("Expected TemporalBucket selection"),
764        }
765    }
766
767    #[test]
768    fn test_parse_multiple_aggregates() {
769        let metadata = create_test_metadata();
770        let query = json!({
771            "table": "tf_sales",
772            "aggregates": [
773                {"count": {}},
774                {"revenue_sum": {}},
775                {"revenue_avg": {}},
776                {"quantity_max": {}}
777            ]
778        });
779
780        let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
781
782        assert_eq!(request.aggregates.len(), 4);
783        assert_eq!(request.aggregates[0].alias(), "count");
784        assert_eq!(request.aggregates[1].alias(), "revenue_sum");
785        assert_eq!(request.aggregates[2].alias(), "revenue_avg");
786        assert_eq!(request.aggregates[3].alias(), "quantity_max");
787    }
788
789    #[test]
790    fn test_parse_having_condition() {
791        let metadata = create_test_metadata();
792        let query = json!({
793            "table": "tf_sales",
794            "aggregates": [
795                {"revenue_sum": {}}
796            ],
797            "having": {
798                "revenue_sum_gt": 1000
799            }
800        });
801
802        let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
803
804        assert_eq!(request.having.len(), 1);
805        assert_eq!(request.having[0].operator, HavingOperator::Gt);
806        assert_eq!(request.having[0].value, json!(1000));
807    }
808
809    #[test]
810    fn test_parse_order_by() {
811        let metadata = create_test_metadata();
812        let query = json!({
813            "table": "tf_sales",
814            "aggregates": [
815                {"revenue_sum": {}}
816            ],
817            "orderBy": {
818                "revenue_sum": "DESC"
819            }
820        });
821
822        let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
823
824        assert_eq!(request.order_by.len(), 1);
825        assert_eq!(request.order_by[0].field, "revenue_sum");
826        assert_eq!(request.order_by[0].direction, OrderDirection::Desc);
827    }
828
829    #[test]
830    fn test_parse_limit_offset() {
831        let metadata = create_test_metadata();
832        let query = json!({
833            "table": "tf_sales",
834            "aggregates": [
835                {"count": {}}
836            ],
837            "limit": 10,
838            "offset": 5
839        });
840
841        let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
842
843        assert_eq!(request.limit, Some(10));
844        assert_eq!(request.offset, Some(5));
845    }
846
847    #[test]
848    fn test_parse_complex_query() {
849        let metadata = create_test_metadata();
850        let query = json!({
851            "table": "tf_sales",
852            "groupBy": {
853                "category": true,
854                "occurred_at_month": true
855            },
856            "aggregates": [
857                {"count": {}},
858                {"revenue_sum": {}},
859                {"revenue_avg": {}},
860                {"quantity_sum": {}}
861            ],
862            "having": {
863                "revenue_sum_gt": 1000,
864                "count_gte": 5
865            },
866            "orderBy": {
867                "revenue_sum": "DESC",
868                "count": "ASC"
869            },
870            "limit": 20
871        });
872
873        let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
874
875        assert_eq!(request.table_name, "tf_sales");
876        assert_eq!(request.group_by.len(), 2);
877        assert_eq!(request.aggregates.len(), 4);
878        assert_eq!(request.having.len(), 2);
879        assert_eq!(request.order_by.len(), 2);
880        assert_eq!(request.limit, Some(20));
881    }
882
883    #[test]
884    fn test_parse_count_distinct_default() {
885        let metadata = create_test_metadata();
886        let query = json!({
887            "table": "tf_sales",
888            "aggregates": [
889                {"count_distinct": {}}
890            ]
891        });
892
893        let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
894
895        assert_eq!(request.aggregates.len(), 1);
896        match &request.aggregates[0] {
897            AggregateSelection::CountDistinct { field, alias } => {
898                // Defaults to first dimension: "category"
899                assert_eq!(field, "category");
900                assert_eq!(alias, "count_distinct");
901            },
902            _ => panic!("Expected CountDistinct selection"),
903        }
904    }
905
906    #[test]
907    fn test_parse_count_distinct_with_field() {
908        let metadata = create_test_metadata();
909        let query = json!({
910            "table": "tf_sales",
911            "aggregates": [
912                {"product_count_distinct": {}}
913            ]
914        });
915
916        let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
917
918        assert_eq!(request.aggregates.len(), 1);
919        match &request.aggregates[0] {
920            AggregateSelection::CountDistinct { field, alias } => {
921                assert_eq!(field, "product");
922                assert_eq!(alias, "product_count_distinct");
923            },
924            _ => panic!("Expected CountDistinct selection"),
925        }
926    }
927
928    #[test]
929    fn test_parse_count_distinct_on_measure() {
930        let metadata = create_test_metadata();
931        let query = json!({
932            "table": "tf_sales",
933            "aggregates": [
934                {"revenue_count_distinct": {}}
935            ]
936        });
937
938        let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
939
940        assert_eq!(request.aggregates.len(), 1);
941        match &request.aggregates[0] {
942            AggregateSelection::CountDistinct { field, alias } => {
943                assert_eq!(field, "revenue");
944                assert_eq!(alias, "revenue_count_distinct");
945            },
946            _ => panic!("Expected CountDistinct selection"),
947        }
948    }
949
950    #[test]
951    fn test_parse_count_distinct_invalid_field() {
952        let metadata = create_test_metadata();
953        let query = json!({
954            "table": "tf_sales",
955            "aggregates": [
956                {"nonexistent_count_distinct": {}}
957            ]
958        });
959
960        let result = AggregateQueryParser::parse(&query, &metadata);
961
962        let err = result.expect_err("expected Err for invalid count_distinct field");
963        match err {
964            FraiseQLError::Validation { message, .. } => {
965                assert!(
966                    message.contains("COUNT DISTINCT field 'nonexistent' not found"),
967                    "unexpected message: {message}"
968                );
969            },
970            other => panic!("expected Validation error, got: {other:?}"),
971        }
972    }
973
974    #[test]
975    fn test_parse_multiple_count_distinct() {
976        let metadata = create_test_metadata();
977        let query = json!({
978            "table": "tf_sales",
979            "aggregates": [
980                {"count": {}},
981                {"category_count_distinct": {}},
982                {"product_count_distinct": {}},
983                {"revenue_sum": {}}
984            ]
985        });
986
987        let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
988
989        assert_eq!(request.aggregates.len(), 4);
990        assert_eq!(request.aggregates[0].alias(), "count");
991        assert_eq!(request.aggregates[1].alias(), "category_count_distinct");
992        assert_eq!(request.aggregates[2].alias(), "product_count_distinct");
993        assert_eq!(request.aggregates[3].alias(), "revenue_sum");
994    }
995}