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        /// Whether the column is a native SQL column (not JSONB-extracted).
288        ///
289        /// When `true`, the SQL generator quotes the column identifier and skips
290        /// any `::numeric` cast that would otherwise be needed for JSONB text values.
291        #[serde(default)]
292        native:   bool,
293    },
294    /// Advanced aggregate with optional parameters
295    AdvancedAggregate {
296        /// Column to aggregate
297        column:    String,
298        /// Aggregate function
299        function:  AggregateFunction,
300        /// Result alias
301        alias:     String,
302        /// Optional delimiter for `STRING_AGG`
303        delimiter: Option<String>,
304        /// Optional ORDER BY for `ARRAY_AGG/STRING_AGG`
305        order_by:  Option<Vec<OrderByClause>>,
306    },
307    /// Boolean aggregate (`BOOL_AND/BOOL_OR`)
308    BoolAggregate {
309        /// Column to aggregate (boolean expression)
310        column:   String,
311        /// Boolean aggregate function
312        function: crate::compiler::aggregate_types::BoolAggregateFunction,
313        /// Result alias
314        alias:    String,
315    },
316}
317
318/// Validated HAVING condition
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320pub struct ValidatedHavingCondition {
321    /// Aggregate expression to filter on
322    pub aggregate: AggregateExpression,
323    /// Comparison operator
324    pub operator:  HavingOperator,
325    /// Value to compare against
326    pub value:     serde_json::Value,
327}
328
329impl AggregationPlan {
330    /// Returns the set of alias strings that correspond to native SQL column
331    /// `GROUP BY` expressions (not JSONB-derived aliases).
332    ///
333    /// Used by the ORDER BY clause builder to document that native columns are
334    /// referenced by alias rather than JSONB path, preventing accidental regressions
335    /// if the ORDER BY logic is ever refactored.
336    #[must_use]
337    pub fn native_aliases(&self) -> std::collections::HashSet<&str> {
338        self.group_by_expressions
339            .iter()
340            .filter_map(|e| {
341                if let GroupByExpression::NativeColumn { alias, .. } = e {
342                    Some(alias.as_str())
343                } else {
344                    None
345                }
346            })
347            .collect()
348    }
349}
350
351/// Aggregation plan generator
352pub struct AggregationPlanner;
353
354impl AggregationPlanner {
355    /// Generate execution plan from request
356    ///
357    /// # Arguments
358    ///
359    /// * `request` - Aggregation request from GraphQL
360    /// * `metadata` - Fact table metadata
361    ///
362    /// # Errors
363    ///
364    /// Returns error if:
365    /// - Request references non-existent measures or dimensions
366    /// - GROUP BY selections are invalid
367    /// - HAVING conditions reference non-computed aggregates
368    pub fn plan(
369        request: AggregationRequest,
370        metadata: FactTableMetadata,
371    ) -> Result<AggregationPlan> {
372        // Validate and convert GROUP BY selections
373        let group_by_expressions = Self::validate_group_by(&request.group_by, &metadata)?;
374
375        // Validate and convert aggregate selections
376        let aggregate_expressions = Self::validate_aggregates(&request.aggregates, &metadata)?;
377
378        // Validate HAVING conditions
379        let having_conditions = Self::validate_having(&request.having, &aggregate_expressions)?;
380
381        Ok(AggregationPlan {
382            metadata,
383            request,
384            group_by_expressions,
385            aggregate_expressions,
386            having_conditions,
387        })
388    }
389
390    /// Validate GROUP BY selections
391    fn validate_group_by(
392        selections: &[GroupBySelection],
393        metadata: &FactTableMetadata,
394    ) -> Result<Vec<GroupByExpression>> {
395        let mut expressions = Vec::new();
396
397        for selection in selections {
398            match selection {
399                GroupBySelection::Dimension { path, alias } => {
400                    // When the schema declares dimension paths, validate against the allowlist.
401                    // This prevents unrecognised paths from reaching `jsonb_extract_sql` even
402                    // after SQL-level escaping (defence in depth). If no paths are declared,
403                    // all paths are accepted — escaping in the runtime layer still applies.
404                    let known_paths = &metadata.dimensions.paths;
405                    if !known_paths.is_empty() && !known_paths.iter().any(|p| p.name == *path) {
406                        return Err(FraiseQLError::Validation {
407                            message: format!(
408                                "Dimension '{}' not found in fact table '{}'",
409                                path, metadata.table_name
410                            ),
411                            path:    None,
412                        });
413                    }
414                    expressions.push(GroupByExpression::JsonbPath {
415                        jsonb_column: metadata.dimensions.name.clone(),
416                        path:         path.clone(),
417                        alias:        alias.clone(),
418                    });
419                },
420                GroupBySelection::TemporalBucket {
421                    column,
422                    bucket,
423                    alias,
424                } => {
425                    // Validate column exists in denormalized filters
426                    let filter_exists =
427                        metadata.denormalized_filters.iter().any(|f| f.name == *column);
428
429                    if !filter_exists {
430                        return Err(FraiseQLError::Validation {
431                            message: format!(
432                                "Column '{}' not found in fact table '{}'",
433                                column, metadata.table_name
434                            ),
435                            path:    None,
436                        });
437                    }
438
439                    expressions.push(GroupByExpression::TemporalBucket {
440                        column: column.clone(),
441                        bucket: *bucket,
442                        alias:  alias.clone(),
443                    });
444                },
445                GroupBySelection::CalendarDimension {
446                    calendar_column,
447                    json_key,
448                    alias,
449                    ..
450                } => {
451                    // Calendar dimension - use pre-computed JSONB field
452                    expressions.push(GroupByExpression::CalendarPath {
453                        calendar_column: calendar_column.clone(),
454                        json_key:        json_key.clone(),
455                        alias:           alias.clone(),
456                    });
457                },
458                GroupBySelection::NativeDimension { column, pg_cast } => {
459                    // Native SQL column — alias equals the column name by convention.
460                    expressions.push(GroupByExpression::NativeColumn {
461                        alias:   column.clone(),
462                        column:  column.clone(),
463                        pg_cast: pg_cast.clone(),
464                    });
465                },
466            }
467        }
468
469        Ok(expressions)
470    }
471
472    /// Validate aggregate selections
473    fn validate_aggregates(
474        selections: &[AggregateSelection],
475        metadata: &FactTableMetadata,
476    ) -> Result<Vec<AggregateExpression>> {
477        let mut expressions = Vec::new();
478
479        for selection in selections {
480            match selection {
481                AggregateSelection::Count { alias } => {
482                    expressions.push(AggregateExpression::Count {
483                        alias: alias.clone(),
484                    });
485                },
486                AggregateSelection::CountDistinct { field, alias } => {
487                    // Validate field is a measure
488                    let measure_exists = metadata.measures.iter().any(|m| m.name == *field);
489
490                    if !measure_exists {
491                        return Err(FraiseQLError::Validation {
492                            message: format!(
493                                "Measure '{}' not found in fact table '{}'",
494                                field, metadata.table_name
495                            ),
496                            path:    None,
497                        });
498                    }
499
500                    expressions.push(AggregateExpression::CountDistinct {
501                        column: field.clone(),
502                        alias:  alias.clone(),
503                    });
504                },
505                AggregateSelection::MeasureAggregate {
506                    measure,
507                    function,
508                    alias,
509                } => {
510                    // Validate measure exists (or is a dimension path, filter, or native measure)
511                    let measure_exists = metadata.measures.iter().any(|m| m.name == *measure);
512                    let is_dimension = metadata.dimensions.paths.iter().any(|p| p.name == *measure);
513                    let is_filter =
514                        metadata.denormalized_filters.iter().any(|f| f.name == *measure);
515                    let is_native_measure = metadata.native_measures.contains_key(measure.as_str());
516
517                    if !measure_exists && !is_dimension && !is_filter && !is_native_measure {
518                        return Err(FraiseQLError::Validation {
519                            message: format!(
520                                "Measure or field '{}' not found in fact table '{}'",
521                                measure, metadata.table_name
522                            ),
523                            path:    None,
524                        });
525                    }
526
527                    // For advanced aggregates, create AdvancedAggregate variant
528                    if matches!(
529                        function,
530                        AggregateFunction::ArrayAgg
531                            | AggregateFunction::JsonAgg
532                            | AggregateFunction::JsonbAgg
533                            | AggregateFunction::StringAgg
534                    ) {
535                        expressions.push(AggregateExpression::AdvancedAggregate {
536                            column:    measure.clone(),
537                            function:  *function,
538                            alias:     alias.clone(),
539                            delimiter: if *function == AggregateFunction::StringAgg {
540                                Some(", ".to_string())
541                            } else {
542                                None
543                            },
544                            order_by:  None,
545                        });
546                    } else {
547                        // Check if the measure references a native measure mapping
548                        let (resolved_column, is_native) = if let Some(native_col) =
549                            metadata.native_measures.get(measure.as_str())
550                        {
551                            (native_col.clone(), true)
552                        } else {
553                            (measure.clone(), false)
554                        };
555                        expressions.push(AggregateExpression::MeasureAggregate {
556                            column:   resolved_column,
557                            function: *function,
558                            alias:    alias.clone(),
559                            native:   is_native,
560                        });
561                    }
562                },
563                AggregateSelection::BoolAggregate {
564                    field,
565                    function,
566                    alias,
567                } => {
568                    // Validate field exists
569                    let field_exists = metadata.dimensions.paths.iter().any(|p| p.name == *field)
570                        || metadata.denormalized_filters.iter().any(|f| f.name == *field);
571
572                    if !field_exists {
573                        return Err(FraiseQLError::Validation {
574                            message: format!(
575                                "Boolean field '{}' not found in fact table '{}'",
576                                field, metadata.table_name
577                            ),
578                            path:    None,
579                        });
580                    }
581
582                    expressions.push(AggregateExpression::BoolAggregate {
583                        column:   field.clone(),
584                        function: *function,
585                        alias:    alias.clone(),
586                    });
587                },
588            }
589        }
590
591        Ok(expressions)
592    }
593
594    /// Validate HAVING conditions
595    fn validate_having(
596        conditions: &[HavingCondition],
597        _aggregate_expressions: &[AggregateExpression],
598    ) -> Result<Vec<ValidatedHavingCondition>> {
599        let mut validated = Vec::new();
600
601        for condition in conditions {
602            // Convert the aggregate selection to an expression
603            let aggregate_expr = match &condition.aggregate {
604                AggregateSelection::Count { alias } => AggregateExpression::Count {
605                    alias: alias.clone(),
606                },
607                AggregateSelection::CountDistinct { field, alias } => {
608                    AggregateExpression::CountDistinct {
609                        column: field.clone(),
610                        alias:  alias.clone(),
611                    }
612                },
613                AggregateSelection::MeasureAggregate {
614                    measure,
615                    function,
616                    alias,
617                } => {
618                    // For advanced aggregates in HAVING, create AdvancedAggregate variant
619                    if matches!(
620                        function,
621                        AggregateFunction::ArrayAgg
622                            | AggregateFunction::JsonAgg
623                            | AggregateFunction::JsonbAgg
624                            | AggregateFunction::StringAgg
625                    ) {
626                        AggregateExpression::AdvancedAggregate {
627                            column:    measure.clone(),
628                            function:  *function,
629                            alias:     alias.clone(),
630                            delimiter: if *function == AggregateFunction::StringAgg {
631                                Some(", ".to_string())
632                            } else {
633                                None
634                            },
635                            order_by:  None,
636                        }
637                    } else {
638                        AggregateExpression::MeasureAggregate {
639                            column:   measure.clone(),
640                            function: *function,
641                            alias:    alias.clone(),
642                            native:   false,
643                        }
644                    }
645                },
646                AggregateSelection::BoolAggregate {
647                    field,
648                    function,
649                    alias,
650                } => AggregateExpression::BoolAggregate {
651                    column:   field.clone(),
652                    function: *function,
653                    alias:    alias.clone(),
654                },
655            };
656
657            // Note: We don't strictly require the aggregate to be in the SELECT list
658            // Some databases allow filtering on aggregates not in SELECT
659
660            validated.push(ValidatedHavingCondition {
661                aggregate: aggregate_expr,
662                operator:  condition.operator,
663                value:     condition.value.clone(),
664            });
665        }
666
667        Ok(validated)
668    }
669}