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}