Skip to main content

fraiseql_core/compiler/
aggregation.rs

1//! Aggregation Execution Plan Module
2//!
3//! This module generates execution plans for GROUP BY queries with aggregations.
4//!
5//! # Execution Plan Flow
6//!
7//! ```text
8//! GraphQL Query
9//!      ↓
10//! AggregationRequest (parsed)
11//!      ↓
12//! AggregationPlan (validated, optimized)
13//!      ↓
14//! SQL Generation (database-specific)
15//!      ↓
16//! Query Execution
17//! ```
18//!
19//! # Example
20//!
21//! ```graphql
22//! query {
23//!   sales_aggregate(
24//!     where: { customer_id: { _eq: "uuid-123" } }
25//!     groupBy: { category: true, occurred_at_day: true }
26//!     having: { revenue_sum_gt: 1000 }
27//!   ) {
28//!     category
29//!     occurred_at_day
30//!     count
31//!     revenue_sum
32//!     revenue_avg
33//!   }
34//! }
35//! ```
36//!
37//! Generates:
38//!
39//! ```sql
40//! SELECT
41//!   data->>'category' AS category,
42//!   DATE_TRUNC('day', occurred_at) AS occurred_at_day,
43//!   COUNT(*) AS count,
44//!   SUM(revenue) AS revenue_sum,
45//!   AVG(revenue) AS revenue_avg
46//! FROM tf_sales
47//! WHERE customer_id = $1
48//! GROUP BY data->>'category', DATE_TRUNC('day', occurred_at)
49//! HAVING SUM(revenue) > $2
50//! ```
51
52use serde::{Deserialize, Serialize};
53
54pub use crate::types::{OrderByClause, OrderDirection};
55use crate::{
56    compiler::{
57        aggregate_types::{AggregateFunction, HavingOperator, TemporalBucket},
58        fact_table::FactTableMetadata,
59    },
60    db::where_clause::WhereClause,
61    error::{FraiseQLError, Result},
62};
63
64/// Aggregation request from GraphQL query
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct AggregationRequest {
67    /// Fact table name
68    pub table_name:   String,
69    /// WHERE clause filters (applied before GROUP BY)
70    pub where_clause: Option<WhereClause>,
71    /// GROUP BY selections
72    pub group_by:     Vec<GroupBySelection>,
73    /// Aggregate selections (what to compute)
74    pub aggregates:   Vec<AggregateSelection>,
75    /// HAVING clause filters (applied after GROUP BY)
76    pub having:       Vec<HavingCondition>,
77    /// ORDER BY clauses
78    pub order_by:     Vec<OrderByClause>,
79    /// LIMIT
80    pub limit:        Option<u32>,
81    /// OFFSET
82    pub offset:       Option<u32>,
83}
84
85/// GROUP BY selection
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[non_exhaustive]
88pub enum GroupBySelection {
89    /// Group by JSONB dimension
90    Dimension {
91        /// JSONB path (e.g., "category")
92        path:  String,
93        /// Alias for result
94        alias: String,
95    },
96    /// Group by temporal bucket
97    TemporalBucket {
98        /// Column name (e.g., "`occurred_at`")
99        column: String,
100        /// Bucket type
101        bucket: TemporalBucket,
102        /// Alias for result
103        alias:  String,
104    },
105    /// Group by pre-computed calendar dimension
106    CalendarDimension {
107        /// Source timestamp column (e.g., "`occurred_at`")
108        source_column:   String,
109        /// Calendar JSONB column (e.g., "`date_info`")
110        calendar_column: String,
111        /// JSON key within calendar column (e.g., "month")
112        json_key:        String,
113        /// Temporal bucket type
114        bucket:          TemporalBucket,
115        /// Alias for result
116        alias:           String,
117    },
118    /// Group by a native SQL column (not JSONB-extracted).
119    ///
120    /// Produced by [`crate::runtime::AggregateQueryParser`] when the GROUP BY field
121    /// matches an entry in the query's `native_columns` map.
122    NativeDimension {
123        /// Column name as it appears in the CREATE VIEW DDL.
124        column:  String,
125        /// PostgreSQL type for cast expressions (e.g. `"int8"`).
126        pg_cast: String,
127    },
128}
129
130impl GroupBySelection {
131    /// Get the result alias for this selection
132    #[must_use]
133    pub fn alias(&self) -> &str {
134        match self {
135            Self::Dimension { alias, .. }
136            | Self::TemporalBucket { alias, .. }
137            | Self::CalendarDimension { alias, .. } => alias,
138            // NativeDimension uses the column name as its alias by convention.
139            Self::NativeDimension { column, .. } => column,
140        }
141    }
142}
143
144/// Aggregate selection (what to compute)
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[non_exhaustive]
147pub enum AggregateSelection {
148    /// COUNT(*)
149    Count {
150        /// Alias for result
151        alias: String,
152    },
153    /// COUNT(DISTINCT field)
154    CountDistinct {
155        /// Field to count
156        field: String,
157        /// Alias for result
158        alias: String,
159    },
160    /// Aggregate function on a measure
161    MeasureAggregate {
162        /// Measure column name
163        measure:  String,
164        /// Aggregate function
165        function: AggregateFunction,
166        /// Alias for result
167        alias:    String,
168    },
169    /// Boolean aggregate
170    BoolAggregate {
171        /// Field to aggregate
172        field:    String,
173        /// Boolean aggregate function
174        function: crate::compiler::aggregate_types::BoolAggregateFunction,
175        /// Alias for result
176        alias:    String,
177    },
178}
179
180impl AggregateSelection {
181    /// Get the result alias for this selection
182    #[must_use]
183    pub fn alias(&self) -> &str {
184        match self {
185            Self::Count { alias }
186            | Self::CountDistinct { alias, .. }
187            | Self::MeasureAggregate { alias, .. }
188            | Self::BoolAggregate { alias, .. } => alias,
189        }
190    }
191}
192
193/// HAVING condition (post-aggregation filter)
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195pub struct HavingCondition {
196    /// Aggregate to filter on
197    pub aggregate: AggregateSelection,
198    /// Comparison operator
199    pub operator:  HavingOperator,
200    /// Value to compare against
201    pub value:     serde_json::Value,
202}
203
204/// Validated and optimized aggregation execution plan
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct AggregationPlan {
207    /// Fact table metadata
208    pub metadata:              FactTableMetadata,
209    /// Original request
210    pub request:               AggregationRequest,
211    /// Validated GROUP BY expressions
212    pub group_by_expressions:  Vec<GroupByExpression>,
213    /// Validated aggregate expressions
214    pub aggregate_expressions: Vec<AggregateExpression>,
215    /// Validated HAVING conditions
216    pub having_conditions:     Vec<ValidatedHavingCondition>,
217}
218
219/// Validated GROUP BY expression
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221#[non_exhaustive]
222pub enum GroupByExpression {
223    /// JSONB dimension extraction
224    JsonbPath {
225        /// JSONB column name (usually "data")
226        jsonb_column: String,
227        /// Path to extract (e.g., "category")
228        path:         String,
229        /// Result alias
230        alias:        String,
231    },
232    /// Temporal bucket with `DATE_TRUNC`
233    TemporalBucket {
234        /// Timestamp column name
235        column: String,
236        /// Bucket type
237        bucket: TemporalBucket,
238        /// Result alias
239        alias:  String,
240    },
241    /// Pre-computed calendar dimension extraction
242    CalendarPath {
243        /// Calendar JSONB column (e.g., "`date_info`")
244        calendar_column: String,
245        /// JSON key within calendar column (e.g., "month")
246        json_key:        String,
247        /// Result alias
248        alias:           String,
249    },
250    /// A native SQL column on the view/fact table — referenced directly,
251    /// not via JSONB extraction. Generates a dialect-quoted column reference in
252    /// GROUP BY / SELECT, enabling btree index usage.
253    NativeColumn {
254        /// Column name as it appears in the CREATE VIEW DDL.
255        column:  String,
256        /// PostgreSQL type suffix for casting (e.g. `"uuid"`, `"int8"`, `""`).
257        pg_cast: String,
258        /// Alias used in SELECT and referenced by ORDER BY.
259        alias:   String,
260    },
261}
262
263/// Validated aggregate expression
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265#[non_exhaustive]
266pub enum AggregateExpression {
267    /// COUNT(*)
268    Count {
269        /// Result alias
270        alias: String,
271    },
272    /// COUNT(DISTINCT field)
273    CountDistinct {
274        /// Column to count
275        column: String,
276        /// Result alias
277        alias:  String,
278    },
279    /// Aggregate function on measure column
280    MeasureAggregate {
281        /// Measure column name
282        column:   String,
283        /// Aggregate function
284        function: AggregateFunction,
285        /// Result alias
286        alias:    String,
287    },
288    /// Advanced aggregate with optional parameters
289    AdvancedAggregate {
290        /// Column to aggregate
291        column:    String,
292        /// Aggregate function
293        function:  AggregateFunction,
294        /// Result alias
295        alias:     String,
296        /// Optional delimiter for `STRING_AGG`
297        delimiter: Option<String>,
298        /// Optional ORDER BY for `ARRAY_AGG/STRING_AGG`
299        order_by:  Option<Vec<OrderByClause>>,
300    },
301    /// Boolean aggregate (`BOOL_AND/BOOL_OR`)
302    BoolAggregate {
303        /// Column to aggregate (boolean expression)
304        column:   String,
305        /// Boolean aggregate function
306        function: crate::compiler::aggregate_types::BoolAggregateFunction,
307        /// Result alias
308        alias:    String,
309    },
310}
311
312/// Validated HAVING condition
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314pub struct ValidatedHavingCondition {
315    /// Aggregate expression to filter on
316    pub aggregate: AggregateExpression,
317    /// Comparison operator
318    pub operator:  HavingOperator,
319    /// Value to compare against
320    pub value:     serde_json::Value,
321}
322
323impl AggregationPlan {
324    /// Returns the set of alias strings that correspond to native SQL column
325    /// `GROUP BY` expressions (not JSONB-derived aliases).
326    ///
327    /// Used by the ORDER BY clause builder to document that native columns are
328    /// referenced by alias rather than JSONB path, preventing accidental regressions
329    /// if the ORDER BY logic is ever refactored.
330    #[must_use]
331    pub fn native_aliases(&self) -> std::collections::HashSet<&str> {
332        self.group_by_expressions
333            .iter()
334            .filter_map(|e| {
335                if let GroupByExpression::NativeColumn { alias, .. } = e {
336                    Some(alias.as_str())
337                } else {
338                    None
339                }
340            })
341            .collect()
342    }
343}
344
345/// Aggregation plan generator
346pub struct AggregationPlanner;
347
348impl AggregationPlanner {
349    /// Generate execution plan from request
350    ///
351    /// # Arguments
352    ///
353    /// * `request` - Aggregation request from GraphQL
354    /// * `metadata` - Fact table metadata
355    ///
356    /// # Errors
357    ///
358    /// Returns error if:
359    /// - Request references non-existent measures or dimensions
360    /// - GROUP BY selections are invalid
361    /// - HAVING conditions reference non-computed aggregates
362    pub fn plan(
363        request: AggregationRequest,
364        metadata: FactTableMetadata,
365    ) -> Result<AggregationPlan> {
366        // Validate and convert GROUP BY selections
367        let group_by_expressions = Self::validate_group_by(&request.group_by, &metadata)?;
368
369        // Validate and convert aggregate selections
370        let aggregate_expressions = Self::validate_aggregates(&request.aggregates, &metadata)?;
371
372        // Validate HAVING conditions
373        let having_conditions = Self::validate_having(&request.having, &aggregate_expressions)?;
374
375        Ok(AggregationPlan {
376            metadata,
377            request,
378            group_by_expressions,
379            aggregate_expressions,
380            having_conditions,
381        })
382    }
383
384    /// Validate GROUP BY selections
385    fn validate_group_by(
386        selections: &[GroupBySelection],
387        metadata: &FactTableMetadata,
388    ) -> Result<Vec<GroupByExpression>> {
389        let mut expressions = Vec::new();
390
391        for selection in selections {
392            match selection {
393                GroupBySelection::Dimension { path, alias } => {
394                    // When the schema declares dimension paths, validate against the allowlist.
395                    // This prevents unrecognised paths from reaching `jsonb_extract_sql` even
396                    // after SQL-level escaping (defence in depth). If no paths are declared,
397                    // all paths are accepted — escaping in the runtime layer still applies.
398                    let known_paths = &metadata.dimensions.paths;
399                    if !known_paths.is_empty() && !known_paths.iter().any(|p| p.name == *path) {
400                        return Err(FraiseQLError::Validation {
401                            message: format!(
402                                "Dimension '{}' not found in fact table '{}'",
403                                path, metadata.table_name
404                            ),
405                            path:    None,
406                        });
407                    }
408                    expressions.push(GroupByExpression::JsonbPath {
409                        jsonb_column: metadata.dimensions.name.clone(),
410                        path:         path.clone(),
411                        alias:        alias.clone(),
412                    });
413                },
414                GroupBySelection::TemporalBucket {
415                    column,
416                    bucket,
417                    alias,
418                } => {
419                    // Validate column exists in denormalized filters
420                    let filter_exists =
421                        metadata.denormalized_filters.iter().any(|f| f.name == *column);
422
423                    if !filter_exists {
424                        return Err(FraiseQLError::Validation {
425                            message: format!(
426                                "Column '{}' not found in fact table '{}'",
427                                column, metadata.table_name
428                            ),
429                            path:    None,
430                        });
431                    }
432
433                    expressions.push(GroupByExpression::TemporalBucket {
434                        column: column.clone(),
435                        bucket: *bucket,
436                        alias:  alias.clone(),
437                    });
438                },
439                GroupBySelection::CalendarDimension {
440                    calendar_column,
441                    json_key,
442                    alias,
443                    ..
444                } => {
445                    // Calendar dimension - use pre-computed JSONB field
446                    expressions.push(GroupByExpression::CalendarPath {
447                        calendar_column: calendar_column.clone(),
448                        json_key:        json_key.clone(),
449                        alias:           alias.clone(),
450                    });
451                },
452                GroupBySelection::NativeDimension { column, pg_cast } => {
453                    // Native SQL column — alias equals the column name by convention.
454                    expressions.push(GroupByExpression::NativeColumn {
455                        alias:   column.clone(),
456                        column:  column.clone(),
457                        pg_cast: pg_cast.clone(),
458                    });
459                },
460            }
461        }
462
463        Ok(expressions)
464    }
465
466    /// Validate aggregate selections
467    fn validate_aggregates(
468        selections: &[AggregateSelection],
469        metadata: &FactTableMetadata,
470    ) -> Result<Vec<AggregateExpression>> {
471        let mut expressions = Vec::new();
472
473        for selection in selections {
474            match selection {
475                AggregateSelection::Count { alias } => {
476                    expressions.push(AggregateExpression::Count {
477                        alias: alias.clone(),
478                    });
479                },
480                AggregateSelection::CountDistinct { field, alias } => {
481                    // Validate field is a measure
482                    let measure_exists = metadata.measures.iter().any(|m| m.name == *field);
483
484                    if !measure_exists {
485                        return Err(FraiseQLError::Validation {
486                            message: format!(
487                                "Measure '{}' not found in fact table '{}'",
488                                field, metadata.table_name
489                            ),
490                            path:    None,
491                        });
492                    }
493
494                    expressions.push(AggregateExpression::CountDistinct {
495                        column: field.clone(),
496                        alias:  alias.clone(),
497                    });
498                },
499                AggregateSelection::MeasureAggregate {
500                    measure,
501                    function,
502                    alias,
503                } => {
504                    // Validate measure exists (or is a dimension path for advanced aggregates)
505                    let measure_exists = metadata.measures.iter().any(|m| m.name == *measure);
506                    let is_dimension = metadata.dimensions.paths.iter().any(|p| p.name == *measure);
507                    let is_filter =
508                        metadata.denormalized_filters.iter().any(|f| f.name == *measure);
509
510                    if !measure_exists && !is_dimension && !is_filter {
511                        return Err(FraiseQLError::Validation {
512                            message: format!(
513                                "Measure or field '{}' not found in fact table '{}'",
514                                measure, metadata.table_name
515                            ),
516                            path:    None,
517                        });
518                    }
519
520                    // For advanced aggregates, create AdvancedAggregate variant
521                    if matches!(
522                        function,
523                        AggregateFunction::ArrayAgg
524                            | AggregateFunction::JsonAgg
525                            | AggregateFunction::JsonbAgg
526                            | AggregateFunction::StringAgg
527                    ) {
528                        expressions.push(AggregateExpression::AdvancedAggregate {
529                            column:    measure.clone(),
530                            function:  *function,
531                            alias:     alias.clone(),
532                            delimiter: if *function == AggregateFunction::StringAgg {
533                                Some(", ".to_string())
534                            } else {
535                                None
536                            },
537                            order_by:  None,
538                        });
539                    } else {
540                        expressions.push(AggregateExpression::MeasureAggregate {
541                            column:   measure.clone(),
542                            function: *function,
543                            alias:    alias.clone(),
544                        });
545                    }
546                },
547                AggregateSelection::BoolAggregate {
548                    field,
549                    function,
550                    alias,
551                } => {
552                    // Validate field exists
553                    let field_exists = metadata.dimensions.paths.iter().any(|p| p.name == *field)
554                        || metadata.denormalized_filters.iter().any(|f| f.name == *field);
555
556                    if !field_exists {
557                        return Err(FraiseQLError::Validation {
558                            message: format!(
559                                "Boolean field '{}' not found in fact table '{}'",
560                                field, metadata.table_name
561                            ),
562                            path:    None,
563                        });
564                    }
565
566                    expressions.push(AggregateExpression::BoolAggregate {
567                        column:   field.clone(),
568                        function: *function,
569                        alias:    alias.clone(),
570                    });
571                },
572            }
573        }
574
575        Ok(expressions)
576    }
577
578    /// Validate HAVING conditions
579    fn validate_having(
580        conditions: &[HavingCondition],
581        _aggregate_expressions: &[AggregateExpression],
582    ) -> Result<Vec<ValidatedHavingCondition>> {
583        let mut validated = Vec::new();
584
585        for condition in conditions {
586            // Convert the aggregate selection to an expression
587            let aggregate_expr = match &condition.aggregate {
588                AggregateSelection::Count { alias } => AggregateExpression::Count {
589                    alias: alias.clone(),
590                },
591                AggregateSelection::CountDistinct { field, alias } => {
592                    AggregateExpression::CountDistinct {
593                        column: field.clone(),
594                        alias:  alias.clone(),
595                    }
596                },
597                AggregateSelection::MeasureAggregate {
598                    measure,
599                    function,
600                    alias,
601                } => {
602                    // For advanced aggregates in HAVING, create AdvancedAggregate variant
603                    if matches!(
604                        function,
605                        AggregateFunction::ArrayAgg
606                            | AggregateFunction::JsonAgg
607                            | AggregateFunction::JsonbAgg
608                            | AggregateFunction::StringAgg
609                    ) {
610                        AggregateExpression::AdvancedAggregate {
611                            column:    measure.clone(),
612                            function:  *function,
613                            alias:     alias.clone(),
614                            delimiter: if *function == AggregateFunction::StringAgg {
615                                Some(", ".to_string())
616                            } else {
617                                None
618                            },
619                            order_by:  None,
620                        }
621                    } else {
622                        AggregateExpression::MeasureAggregate {
623                            column:   measure.clone(),
624                            function: *function,
625                            alias:    alias.clone(),
626                        }
627                    }
628                },
629                AggregateSelection::BoolAggregate {
630                    field,
631                    function,
632                    alias,
633                } => AggregateExpression::BoolAggregate {
634                    column:   field.clone(),
635                    function: *function,
636                    alias:    alias.clone(),
637                },
638            };
639
640            // Note: We don't strictly require the aggregate to be in the SELECT list
641            // Some databases allow filtering on aggregates not in SELECT
642
643            validated.push(ValidatedHavingCondition {
644                aggregate: aggregate_expr,
645                operator:  condition.operator,
646                value:     condition.value.clone(),
647            });
648        }
649
650        Ok(validated)
651    }
652}
653
654#[cfg(test)]
655mod tests {
656    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
657
658    use super::*;
659    use crate::compiler::fact_table::{DimensionColumn, FilterColumn, MeasureColumn, SqlType};
660
661    fn create_test_metadata() -> FactTableMetadata {
662        FactTableMetadata {
663            table_name:           "tf_sales".to_string(),
664            measures:             vec![
665                MeasureColumn {
666                    name:     "revenue".to_string(),
667                    sql_type: SqlType::Decimal,
668                    nullable: false,
669                },
670                MeasureColumn {
671                    name:     "quantity".to_string(),
672                    sql_type: SqlType::Int,
673                    nullable: false,
674                },
675            ],
676            dimensions:           DimensionColumn {
677                name:  "dimensions".to_string(),
678                paths: vec![],
679            },
680            denormalized_filters: vec![
681                FilterColumn {
682                    name:     "customer_id".to_string(),
683                    sql_type: SqlType::Uuid,
684                    indexed:  true,
685                },
686                FilterColumn {
687                    name:     "occurred_at".to_string(),
688                    sql_type: SqlType::Timestamp,
689                    indexed:  true,
690                },
691            ],
692            calendar_dimensions:  vec![],
693        }
694    }
695
696    #[test]
697    fn test_plan_simple_aggregation() {
698        let metadata = create_test_metadata();
699        let request = AggregationRequest {
700            table_name:   "tf_sales".to_string(),
701            where_clause: None,
702            group_by:     vec![],
703            aggregates:   vec![
704                AggregateSelection::Count {
705                    alias: "count".to_string(),
706                },
707                AggregateSelection::MeasureAggregate {
708                    measure:  "revenue".to_string(),
709                    function: AggregateFunction::Sum,
710                    alias:    "revenue_sum".to_string(),
711                },
712            ],
713            having:       vec![],
714            order_by:     vec![],
715            limit:        None,
716            offset:       None,
717        };
718
719        let plan = AggregationPlanner::plan(request, metadata).unwrap();
720
721        assert_eq!(plan.aggregate_expressions.len(), 2);
722        assert!(matches!(plan.aggregate_expressions[0], AggregateExpression::Count { .. }));
723        assert!(matches!(
724            plan.aggregate_expressions[1],
725            AggregateExpression::MeasureAggregate { .. }
726        ));
727    }
728
729    #[test]
730    fn test_plan_with_group_by() {
731        let metadata = create_test_metadata();
732        let request = AggregationRequest {
733            table_name:   "tf_sales".to_string(),
734            where_clause: None,
735            group_by:     vec![
736                GroupBySelection::Dimension {
737                    path:  "category".to_string(),
738                    alias: "category".to_string(),
739                },
740                GroupBySelection::TemporalBucket {
741                    column: "occurred_at".to_string(),
742                    bucket: TemporalBucket::Day,
743                    alias:  "occurred_at_day".to_string(),
744                },
745            ],
746            aggregates:   vec![AggregateSelection::Count {
747                alias: "count".to_string(),
748            }],
749            having:       vec![],
750            order_by:     vec![],
751            limit:        None,
752            offset:       None,
753        };
754
755        let plan = AggregationPlanner::plan(request, metadata).unwrap();
756
757        assert_eq!(plan.group_by_expressions.len(), 2);
758        assert!(matches!(plan.group_by_expressions[0], GroupByExpression::JsonbPath { .. }));
759        assert!(matches!(plan.group_by_expressions[1], GroupByExpression::TemporalBucket { .. }));
760    }
761
762    #[test]
763    fn test_plan_with_having() {
764        let metadata = create_test_metadata();
765        let request = AggregationRequest {
766            table_name:   "tf_sales".to_string(),
767            where_clause: None,
768            group_by:     vec![GroupBySelection::Dimension {
769                path:  "category".to_string(),
770                alias: "category".to_string(),
771            }],
772            aggregates:   vec![AggregateSelection::MeasureAggregate {
773                measure:  "revenue".to_string(),
774                function: AggregateFunction::Sum,
775                alias:    "revenue_sum".to_string(),
776            }],
777            having:       vec![HavingCondition {
778                aggregate: AggregateSelection::MeasureAggregate {
779                    measure:  "revenue".to_string(),
780                    function: AggregateFunction::Sum,
781                    alias:    "revenue_sum".to_string(),
782                },
783                operator:  HavingOperator::Gt,
784                value:     serde_json::json!(1000),
785            }],
786            order_by:     vec![],
787            limit:        None,
788            offset:       None,
789        };
790
791        let plan = AggregationPlanner::plan(request, metadata).unwrap();
792
793        assert_eq!(plan.having_conditions.len(), 1);
794        assert_eq!(plan.having_conditions[0].operator, HavingOperator::Gt);
795    }
796
797    #[test]
798    fn test_validate_invalid_measure() {
799        let metadata = create_test_metadata();
800        let request = AggregationRequest {
801            table_name:   "tf_sales".to_string(),
802            where_clause: None,
803            group_by:     vec![],
804            aggregates:   vec![AggregateSelection::MeasureAggregate {
805                measure:  "nonexistent".to_string(),
806                function: AggregateFunction::Sum,
807                alias:    "nonexistent_sum".to_string(),
808            }],
809            having:       vec![],
810            order_by:     vec![],
811            limit:        None,
812            offset:       None,
813        };
814
815        let result = AggregationPlanner::plan(request, metadata);
816        assert!(
817            matches!(&result, Err(FraiseQLError::Validation { message, .. }) if message.contains("not found")),
818            "expected Validation error about measure not found, got: {result:?}"
819        );
820    }
821
822    #[test]
823    fn test_validate_invalid_temporal_column() {
824        let metadata = create_test_metadata();
825        let request = AggregationRequest {
826            table_name:   "tf_sales".to_string(),
827            where_clause: None,
828            group_by:     vec![GroupBySelection::TemporalBucket {
829                column: "nonexistent".to_string(),
830                bucket: TemporalBucket::Day,
831                alias:  "day".to_string(),
832            }],
833            aggregates:   vec![AggregateSelection::Count {
834                alias: "count".to_string(),
835            }],
836            having:       vec![],
837            order_by:     vec![],
838            limit:        None,
839            offset:       None,
840        };
841
842        let result = AggregationPlanner::plan(request, metadata);
843        assert!(
844            matches!(&result, Err(FraiseQLError::Validation { message, .. }) if message.contains("not found")),
845            "expected Validation error about column not found, got: {result:?}"
846        );
847    }
848
849    #[test]
850    fn test_order_by_from_graphql_json_object_format() {
851        let json = serde_json::json!({ "name": "DESC", "created_at": "ASC" });
852        let clauses = OrderByClause::from_graphql_json(&json).unwrap();
853        assert_eq!(clauses.len(), 2);
854        assert!(clauses.iter().any(|c| c.field == "name" && c.direction == OrderDirection::Desc));
855        assert!(
856            clauses
857                .iter()
858                .any(|c| c.field == "created_at" && c.direction == OrderDirection::Asc)
859        );
860    }
861
862    #[test]
863    fn test_order_by_from_graphql_json_array_format() {
864        let json = serde_json::json!([
865            { "field": "name", "direction": "DESC" },
866            { "field": "age" }
867        ]);
868        let clauses = OrderByClause::from_graphql_json(&json).unwrap();
869        assert_eq!(clauses.len(), 2);
870        assert_eq!(clauses[0].field, "name");
871        assert_eq!(clauses[0].direction, OrderDirection::Desc);
872        assert_eq!(clauses[1].field, "age");
873        assert_eq!(clauses[1].direction, OrderDirection::Asc); // default
874    }
875
876    #[test]
877    fn test_order_by_from_graphql_json_invalid_direction() {
878        let json = serde_json::json!({ "name": "INVALID" });
879        let result = OrderByClause::from_graphql_json(&json);
880        assert!(
881            matches!(result, Err(FraiseQLError::Validation { .. })),
882            "expected Validation error for invalid direction, got: {result:?}"
883        );
884    }
885
886    #[test]
887    fn test_order_by_rejects_sql_injection_in_field() {
888        let json = serde_json::json!({ "x' || pg_sleep(5) || '": "ASC" });
889        let result = OrderByClause::from_graphql_json(&json);
890        assert!(
891            matches!(result, Err(FraiseQLError::Validation { .. })),
892            "expected Validation error for SQL injection in field, got: {result:?}"
893        );
894    }
895
896    #[test]
897    fn test_order_by_rejects_field_with_dot() {
898        let json = serde_json::json!({ "a.b": "ASC" });
899        let result = OrderByClause::from_graphql_json(&json);
900        assert!(
901            matches!(result, Err(FraiseQLError::Validation { .. })),
902            "expected Validation error for dot in field name, got: {result:?}"
903        );
904    }
905
906    #[test]
907    fn test_order_by_rejects_empty_field() {
908        let json = serde_json::json!({ "": "ASC" });
909        let result = OrderByClause::from_graphql_json(&json);
910        assert!(
911            matches!(result, Err(FraiseQLError::Validation { .. })),
912            "expected Validation error for empty field name, got: {result:?}"
913        );
914    }
915
916    #[test]
917    fn test_order_by_accepts_valid_identifiers() {
918        let json = serde_json::json!({ "created_at": "DESC", "_score": "ASC" });
919        let clauses = OrderByClause::from_graphql_json(&json).unwrap();
920        assert_eq!(clauses.len(), 2);
921    }
922
923    #[test]
924    fn test_order_by_array_rejects_injection_field() {
925        let json = serde_json::json!([{ "field": "x' OR '1'='1", "direction": "ASC" }]);
926        let result = OrderByClause::from_graphql_json(&json);
927        assert!(
928            matches!(result, Err(FraiseQLError::Validation { .. })),
929            "expected Validation error for SQL injection in array field, got: {result:?}"
930        );
931    }
932
933    /// Helper: metadata with declared dimension paths (for allowlist tests).
934    fn create_metadata_with_paths() -> FactTableMetadata {
935        use crate::compiler::fact_table::DimensionPath;
936        let mut meta = create_test_metadata();
937        meta.dimensions.paths = vec![DimensionPath {
938            name:      "category".to_string(),
939            json_path: "dimensions->>'category'".to_string(),
940            data_type: "text".to_string(),
941        }];
942        meta
943    }
944
945    #[test]
946    fn test_dimension_allowlist_accepts_declared_path() {
947        let metadata = create_metadata_with_paths();
948        let request = AggregationRequest {
949            table_name:   "tf_sales".to_string(),
950            where_clause: None,
951            group_by:     vec![GroupBySelection::Dimension {
952                path:  "category".to_string(),
953                alias: "category".to_string(),
954            }],
955            aggregates:   vec![AggregateSelection::Count {
956                alias: "count".to_string(),
957            }],
958            having:       vec![],
959            order_by:     vec![],
960            limit:        None,
961            offset:       None,
962        };
963        AggregationPlanner::plan(request, metadata)
964            .unwrap_or_else(|e| panic!("declared dimension path should be accepted: {e}"));
965    }
966
967    #[test]
968    fn test_dimension_allowlist_rejects_unknown_path() {
969        let metadata = create_metadata_with_paths();
970        let request = AggregationRequest {
971            table_name:   "tf_sales".to_string(),
972            where_clause: None,
973            group_by:     vec![GroupBySelection::Dimension {
974                path:  "undeclared_path".to_string(),
975                alias: "x".to_string(),
976            }],
977            aggregates:   vec![AggregateSelection::Count {
978                alias: "count".to_string(),
979            }],
980            having:       vec![],
981            order_by:     vec![],
982            limit:        None,
983            offset:       None,
984        };
985        let result = AggregationPlanner::plan(request, metadata);
986        assert!(
987            matches!(&result, Err(FraiseQLError::Validation { message, .. }) if message.contains("not found")),
988            "expected Validation error about undeclared dimension path, got: {result:?}"
989        );
990    }
991
992    #[test]
993    fn test_dimension_allowlist_accepts_any_path_when_paths_empty() {
994        // When metadata.dimensions.paths is empty, any path is allowed
995        // (schema did not declare a dimension allowlist).
996        let metadata = create_test_metadata(); // paths: vec![]
997        let request = AggregationRequest {
998            table_name:   "tf_sales".to_string(),
999            where_clause: None,
1000            group_by:     vec![GroupBySelection::Dimension {
1001                path:  "any_undeclared_path".to_string(),
1002                alias: "x".to_string(),
1003            }],
1004            aggregates:   vec![AggregateSelection::Count {
1005                alias: "count".to_string(),
1006            }],
1007            having:       vec![],
1008            order_by:     vec![],
1009            limit:        None,
1010            offset:       None,
1011        };
1012        AggregationPlanner::plan(request, metadata)
1013            .unwrap_or_else(|e| panic!("any path should be accepted when paths empty: {e}"));
1014    }
1015}