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    backend::where_clause::WhereClause,
57    compiler::{
58        aggregate_types::{AggregateFunction, HavingOperator, TemporalBucket},
59        fact_table::FactTableMetadata,
60    },
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 level or the depth of an `ltree` path column (#1498): `{"path":
119    /// {"level": n}}` rolls each path up to its first `n` labels, `{"path": "depth"}`
120    /// groups by the number of labels.
121    TreeLevel {
122        /// The denormalized filter column holding the path.
123        column:   String,
124        /// Which part of the tree to group by.
125        grouping: TreeGrouping,
126        /// Alias for result
127        alias:    String,
128    },
129    /// Group by a native SQL column (not JSONB-extracted).
130    ///
131    /// Produced by [`crate::runtime::AggregateQueryParser`] when the GROUP BY field
132    /// matches an entry in the query's `native_columns` map.
133    NativeDimension {
134        /// Column name as it appears in the CREATE VIEW DDL.
135        column:  String,
136        /// PostgreSQL type for cast expressions (e.g. `"int8"`).
137        pg_cast: String,
138        /// The key the request grouped by, which the result answers under: the column's
139        /// own name, or the dimension a `native_dimension_mapping` maps to it (#1231).
140        alias:   String,
141    },
142}
143
144/// What a tree grouping keeps of an `ltree` path (#1498).
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146#[non_exhaustive]
147pub enum TreeGrouping {
148    /// The first `n` labels (`subpath(path, 0, n)`); a shorter path is kept whole.
149    Level(u32),
150    /// The number of labels (`nlevel(path)`).
151    Depth,
152}
153
154impl GroupBySelection {
155    /// Get the result alias for this selection
156    #[must_use]
157    pub fn alias(&self) -> &str {
158        match self {
159            Self::Dimension { alias, .. }
160            | Self::TemporalBucket { alias, .. }
161            | Self::CalendarDimension { alias, .. }
162            | Self::TreeLevel { alias, .. }
163            | Self::NativeDimension { alias, .. } => alias,
164        }
165    }
166}
167
168/// Aggregate selection (what to compute)
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[non_exhaustive]
171pub enum AggregateSelection {
172    /// COUNT(*)
173    Count {
174        /// Alias for result
175        alias: String,
176    },
177    /// COUNT(DISTINCT field)
178    CountDistinct {
179        /// Field to count
180        field: String,
181        /// Alias for result
182        alias: String,
183    },
184    /// Aggregate function on a measure
185    MeasureAggregate {
186        /// Measure column name
187        measure:  String,
188        /// Aggregate function
189        function: AggregateFunction,
190        /// Alias for result
191        alias:    String,
192    },
193    /// Boolean aggregate
194    BoolAggregate {
195        /// Field to aggregate
196        field:    String,
197        /// Boolean aggregate function
198        function: crate::compiler::aggregate_types::BoolAggregateFunction,
199        /// Alias for result
200        alias:    String,
201    },
202}
203
204impl AggregateSelection {
205    /// Get the result alias for this selection
206    #[must_use]
207    pub fn alias(&self) -> &str {
208        match self {
209            Self::Count { alias }
210            | Self::CountDistinct { alias, .. }
211            | Self::MeasureAggregate { alias, .. }
212            | Self::BoolAggregate { alias, .. } => alias,
213        }
214    }
215}
216
217/// HAVING condition (post-aggregation filter)
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219pub struct HavingCondition {
220    /// Aggregate to filter on
221    pub aggregate: AggregateSelection,
222    /// Comparison operator
223    pub operator:  HavingOperator,
224    /// Value to compare against
225    pub value:     serde_json::Value,
226}
227
228/// Validated and optimized aggregation execution plan
229#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
230pub struct AggregationPlan {
231    /// Fact table metadata
232    pub metadata:              FactTableMetadata,
233    /// Original request
234    pub request:               AggregationRequest,
235    /// Validated GROUP BY expressions
236    pub group_by_expressions:  Vec<GroupByExpression>,
237    /// Validated aggregate expressions
238    pub aggregate_expressions: Vec<AggregateExpression>,
239    /// Validated HAVING conditions
240    pub having_conditions:     Vec<ValidatedHavingCondition>,
241}
242
243/// Validated GROUP BY expression
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245#[non_exhaustive]
246pub enum GroupByExpression {
247    /// JSONB dimension extraction
248    JsonbPath {
249        /// JSONB column name (usually "data")
250        jsonb_column: String,
251        /// Path to extract (e.g., "category")
252        path:         String,
253        /// Result alias
254        alias:        String,
255    },
256    /// Temporal bucket with `DATE_TRUNC`
257    TemporalBucket {
258        /// Timestamp column name
259        column: String,
260        /// Bucket type
261        bucket: TemporalBucket,
262        /// Result alias
263        alias:  String,
264    },
265    /// Pre-computed calendar dimension extraction
266    CalendarPath {
267        /// Calendar JSONB column (e.g., "`date_info`")
268        calendar_column: String,
269        /// JSON key within calendar column (e.g., "month")
270        json_key:        String,
271        /// Result alias
272        alias:           String,
273    },
274    /// A level or the depth of an `ltree` path column (#1498).
275    TreeLevel {
276        /// The path column.
277        column:   String,
278        /// Which part of the tree to group by.
279        grouping: TreeGrouping,
280        /// Result alias
281        alias:    String,
282    },
283    /// A native SQL column on the view/fact table — referenced directly,
284    /// not via JSONB extraction. Generates a dialect-quoted column reference in
285    /// GROUP BY / SELECT, enabling btree index usage.
286    NativeColumn {
287        /// Column name as it appears in the CREATE VIEW DDL.
288        column:  String,
289        /// PostgreSQL type suffix for casting (e.g. `"uuid"`, `"int8"`, `""`).
290        pg_cast: String,
291        /// Alias used in SELECT and referenced by ORDER BY.
292        alias:   String,
293    },
294}
295
296/// Validated aggregate expression
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298#[non_exhaustive]
299pub enum AggregateExpression {
300    /// COUNT(*)
301    Count {
302        /// Result alias
303        alias: String,
304    },
305    /// COUNT(DISTINCT field)
306    CountDistinct {
307        /// Column to count
308        column: String,
309        /// Result alias
310        alias:  String,
311    },
312    /// Aggregate function on measure column
313    MeasureAggregate {
314        /// Measure column name
315        column:   String,
316        /// Aggregate function
317        function: AggregateFunction,
318        /// Result alias
319        alias:    String,
320        /// Whether the column is a native SQL column (not JSONB-extracted).
321        ///
322        /// When `true`, the SQL generator quotes the column identifier and skips
323        /// any `::numeric` cast that would otherwise be needed for JSONB text values.
324        #[serde(default)]
325        native:   bool,
326    },
327    /// Advanced aggregate with optional parameters
328    AdvancedAggregate {
329        /// Column to aggregate
330        column:    String,
331        /// Aggregate function
332        function:  AggregateFunction,
333        /// Result alias
334        alias:     String,
335        /// Optional delimiter for `STRING_AGG`
336        delimiter: Option<String>,
337        /// Optional ORDER BY for `ARRAY_AGG/STRING_AGG`
338        order_by:  Option<Vec<OrderByClause>>,
339    },
340    /// Boolean aggregate (`BOOL_AND/BOOL_OR`)
341    BoolAggregate {
342        /// Column to aggregate (boolean expression)
343        column:   String,
344        /// Boolean aggregate function
345        function: crate::compiler::aggregate_types::BoolAggregateFunction,
346        /// Result alias
347        alias:    String,
348    },
349}
350
351/// Validated HAVING condition
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353pub struct ValidatedHavingCondition {
354    /// Aggregate expression to filter on
355    pub aggregate: AggregateExpression,
356    /// Comparison operator
357    pub operator:  HavingOperator,
358    /// Value to compare against
359    pub value:     serde_json::Value,
360}
361
362impl AggregationPlan {
363    /// Returns the set of alias strings that correspond to native SQL column
364    /// `GROUP BY` expressions (not JSONB-derived aliases).
365    ///
366    /// Used by the ORDER BY clause builder to document that native columns are
367    /// referenced by alias rather than JSONB path, preventing accidental regressions
368    /// if the ORDER BY logic is ever refactored.
369    #[must_use]
370    pub fn native_aliases(&self) -> std::collections::HashSet<&str> {
371        self.group_by_expressions
372            .iter()
373            .filter_map(|e| {
374                if let GroupByExpression::NativeColumn { alias, .. } = e {
375                    Some(alias.as_str())
376                } else {
377                    None
378                }
379            })
380            .collect()
381    }
382}
383
384/// Aggregation plan generator
385pub struct AggregationPlanner;
386
387impl AggregationPlanner {
388    /// Generate execution plan from request
389    ///
390    /// # Arguments
391    ///
392    /// * `request` - Aggregation request from GraphQL
393    /// * `metadata` - Fact table metadata
394    ///
395    /// # Errors
396    ///
397    /// Returns error if:
398    /// - Request references non-existent measures or dimensions
399    /// - GROUP BY selections are invalid
400    /// - HAVING conditions reference non-computed aggregates
401    pub fn plan(
402        request: AggregationRequest,
403        metadata: FactTableMetadata,
404    ) -> Result<AggregationPlan> {
405        // SECURITY (#795): the relation is determined by the GraphQL root field, which
406        // already resolved `metadata`. The request's `table` key was a second, unchecked
407        // channel selecting the same thing, so it could name any relation — or a whole
408        // subquery — and the RLS policy, looked up by that same name
409        // (`runners/aggregate.rs` `policy.evaluate(ctx, &request.table_name)`), returned
410        // `None` for an unpolicied table and composed no WHERE clause at all.
411        if request.table_name != metadata.table_name {
412            return Err(FraiseQLError::Validation {
413                message: format!(
414                    "aggregate query 'table' ({}) does not match the fact table resolved from \
415                     the query field ({}); the relation is determined by the schema, not the \
416                     request",
417                    request.table_name, metadata.table_name
418                ),
419                path:    None,
420            });
421        }
422
423        // Validate and convert GROUP BY selections
424        let group_by_expressions = Self::validate_group_by(&request.group_by, &metadata)?;
425
426        // Validate and convert aggregate selections
427        let aggregate_expressions = Self::validate_aggregates(&request.aggregates, &metadata)?;
428
429        // Validate HAVING conditions
430        let having_conditions = Self::validate_having(&request.having, &aggregate_expressions)?;
431
432        Ok(AggregationPlan {
433            metadata,
434            request,
435            group_by_expressions,
436            aggregate_expressions,
437            having_conditions,
438        })
439    }
440
441    /// Validate GROUP BY selections
442    fn validate_group_by(
443        selections: &[GroupBySelection],
444        metadata: &FactTableMetadata,
445    ) -> Result<Vec<GroupByExpression>> {
446        let mut expressions = Vec::new();
447
448        for selection in selections {
449            match selection {
450                GroupBySelection::Dimension { path, alias } => {
451                    // When the schema declares dimension paths, validate against the allowlist.
452                    // This prevents unrecognised paths from reaching `jsonb_extract_sql` even
453                    // after SQL-level escaping (defence in depth). If no paths are declared,
454                    // all paths are accepted — escaping in the runtime layer still applies.
455                    let known_paths = &metadata.dimensions.paths;
456                    if !known_paths.is_empty() && !known_paths.iter().any(|p| p.name == *path) {
457                        return Err(FraiseQLError::Validation {
458                            message: format!(
459                                "Dimension '{}' not found in fact table '{}'",
460                                path, metadata.table_name
461                            ),
462                            path:    None,
463                        });
464                    }
465                    expressions.push(GroupByExpression::JsonbPath {
466                        jsonb_column: metadata.dimensions.name.clone(),
467                        path:         path.clone(),
468                        alias:        alias.clone(),
469                    });
470                },
471                GroupBySelection::TemporalBucket {
472                    column,
473                    bucket,
474                    alias,
475                } => {
476                    // Validate column exists in denormalized filters
477                    let filter_exists =
478                        metadata.denormalized_filters.iter().any(|f| f.name == *column);
479
480                    if !filter_exists {
481                        return Err(FraiseQLError::Validation {
482                            message: format!(
483                                "Column '{}' not found in fact table '{}'",
484                                column, metadata.table_name
485                            ),
486                            path:    None,
487                        });
488                    }
489
490                    expressions.push(GroupByExpression::TemporalBucket {
491                        column: column.clone(),
492                        bucket: *bucket,
493                        alias:  alias.clone(),
494                    });
495                },
496                GroupBySelection::CalendarDimension {
497                    calendar_column,
498                    json_key,
499                    alias,
500                    ..
501                } => {
502                    // Calendar dimension - use pre-computed JSONB field
503                    expressions.push(GroupByExpression::CalendarPath {
504                        calendar_column: calendar_column.clone(),
505                        json_key:        json_key.clone(),
506                        alias:           alias.clone(),
507                    });
508                },
509                GroupBySelection::TreeLevel {
510                    column,
511                    grouping,
512                    alias,
513                } => {
514                    let filter = metadata.denormalized_filters.iter().find(|f| f.name == *column);
515                    if filter.map(|f| &f.sql_type) != Some(&super::fact_table::SqlType::Ltree) {
516                        return Err(FraiseQLError::Validation {
517                            message: format!(
518                                "Grouping '{column}' by a tree level or depth needs an ltree \
519                                 denormalized filter column on fact table '{}'",
520                                metadata.table_name
521                            ),
522                            path:    None,
523                        });
524                    }
525                    expressions.push(GroupByExpression::TreeLevel {
526                        column:   column.clone(),
527                        grouping: *grouping,
528                        alias:    alias.clone(),
529                    });
530                },
531                GroupBySelection::NativeDimension {
532                    column,
533                    pg_cast,
534                    alias,
535                } => {
536                    expressions.push(GroupByExpression::NativeColumn {
537                        alias:   alias.clone(),
538                        column:  column.clone(),
539                        pg_cast: pg_cast.clone(),
540                    });
541                },
542            }
543        }
544
545        Ok(expressions)
546    }
547
548    /// Validate aggregate selections
549    fn validate_aggregates(
550        selections: &[AggregateSelection],
551        metadata: &FactTableMetadata,
552    ) -> Result<Vec<AggregateExpression>> {
553        let mut expressions = Vec::new();
554
555        for selection in selections {
556            match selection {
557                AggregateSelection::Count { alias } => {
558                    expressions.push(AggregateExpression::Count {
559                        alias: alias.clone(),
560                    });
561                },
562                AggregateSelection::CountDistinct { field, alias } => {
563                    // Validate field is a measure
564                    let measure_exists = metadata.measures.iter().any(|m| m.name == *field);
565
566                    if !measure_exists {
567                        return Err(FraiseQLError::Validation {
568                            message: format!(
569                                "Measure '{}' not found in fact table '{}'",
570                                field, metadata.table_name
571                            ),
572                            path:    None,
573                        });
574                    }
575
576                    expressions.push(AggregateExpression::CountDistinct {
577                        column: field.clone(),
578                        alias:  alias.clone(),
579                    });
580                },
581                AggregateSelection::MeasureAggregate {
582                    measure,
583                    function,
584                    alias,
585                } => {
586                    // Validate measure exists (or is a dimension path, filter, or native measure)
587                    let measure_exists = metadata.measures.iter().any(|m| m.name == *measure);
588                    let is_dimension = metadata.dimensions.paths.iter().any(|p| p.name == *measure);
589                    let is_filter =
590                        metadata.denormalized_filters.iter().any(|f| f.name == *measure);
591                    let is_native_measure = metadata.native_measures.contains_key(measure.as_str());
592
593                    if !measure_exists && !is_dimension && !is_filter && !is_native_measure {
594                        return Err(FraiseQLError::Validation {
595                            message: format!(
596                                "Measure or field '{}' not found in fact table '{}'",
597                                measure, metadata.table_name
598                            ),
599                            path:    None,
600                        });
601                    }
602
603                    // For advanced aggregates, create AdvancedAggregate variant
604                    if matches!(
605                        function,
606                        AggregateFunction::ArrayAgg
607                            | AggregateFunction::JsonAgg
608                            | AggregateFunction::JsonbAgg
609                            | AggregateFunction::StringAgg
610                    ) {
611                        expressions.push(AggregateExpression::AdvancedAggregate {
612                            column:    measure.clone(),
613                            function:  *function,
614                            alias:     alias.clone(),
615                            delimiter: if *function == AggregateFunction::StringAgg {
616                                Some(", ".to_string())
617                            } else {
618                                None
619                            },
620                            order_by:  None,
621                        });
622                    } else {
623                        // Check if the measure references a native measure mapping
624                        let (resolved_column, is_native) = if let Some(native_col) =
625                            metadata.native_measures.get(measure.as_str())
626                        {
627                            (native_col.clone(), true)
628                        } else {
629                            (measure.clone(), false)
630                        };
631                        expressions.push(AggregateExpression::MeasureAggregate {
632                            column:   resolved_column,
633                            function: *function,
634                            alias:    alias.clone(),
635                            native:   is_native,
636                        });
637                    }
638                },
639                AggregateSelection::BoolAggregate {
640                    field,
641                    function,
642                    alias,
643                } => {
644                    // Validate field exists
645                    let field_exists = metadata.dimensions.paths.iter().any(|p| p.name == *field)
646                        || metadata.denormalized_filters.iter().any(|f| f.name == *field);
647
648                    if !field_exists {
649                        return Err(FraiseQLError::Validation {
650                            message: format!(
651                                "Boolean field '{}' not found in fact table '{}'",
652                                field, metadata.table_name
653                            ),
654                            path:    None,
655                        });
656                    }
657
658                    expressions.push(AggregateExpression::BoolAggregate {
659                        column:   field.clone(),
660                        function: *function,
661                        alias:    alias.clone(),
662                    });
663                },
664            }
665        }
666
667        Ok(expressions)
668    }
669
670    /// Validate HAVING conditions
671    fn validate_having(
672        conditions: &[HavingCondition],
673        _aggregate_expressions: &[AggregateExpression],
674    ) -> Result<Vec<ValidatedHavingCondition>> {
675        let mut validated = Vec::new();
676
677        for condition in conditions {
678            // Convert the aggregate selection to an expression
679            let aggregate_expr = match &condition.aggregate {
680                AggregateSelection::Count { alias } => AggregateExpression::Count {
681                    alias: alias.clone(),
682                },
683                AggregateSelection::CountDistinct { field, alias } => {
684                    AggregateExpression::CountDistinct {
685                        column: field.clone(),
686                        alias:  alias.clone(),
687                    }
688                },
689                AggregateSelection::MeasureAggregate {
690                    measure,
691                    function,
692                    alias,
693                } => {
694                    // For advanced aggregates in HAVING, create AdvancedAggregate variant
695                    if matches!(
696                        function,
697                        AggregateFunction::ArrayAgg
698                            | AggregateFunction::JsonAgg
699                            | AggregateFunction::JsonbAgg
700                            | AggregateFunction::StringAgg
701                    ) {
702                        AggregateExpression::AdvancedAggregate {
703                            column:    measure.clone(),
704                            function:  *function,
705                            alias:     alias.clone(),
706                            delimiter: if *function == AggregateFunction::StringAgg {
707                                Some(", ".to_string())
708                            } else {
709                                None
710                            },
711                            order_by:  None,
712                        }
713                    } else {
714                        AggregateExpression::MeasureAggregate {
715                            column:   measure.clone(),
716                            function: *function,
717                            alias:    alias.clone(),
718                            native:   false,
719                        }
720                    }
721                },
722                AggregateSelection::BoolAggregate {
723                    field,
724                    function,
725                    alias,
726                } => AggregateExpression::BoolAggregate {
727                    column:   field.clone(),
728                    function: *function,
729                    alias:    alias.clone(),
730                },
731            };
732
733            // Note: We don't strictly require the aggregate to be in the SELECT list
734            // Some databases allow filtering on aggregates not in SELECT
735
736            validated.push(ValidatedHavingCondition {
737                aggregate: aggregate_expr,
738                operator:  condition.operator,
739                value:     condition.value.clone(),
740            });
741        }
742
743        Ok(validated)
744    }
745}