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}