Skip to main content

fraiseql_core/runtime/
aggregate_parser.rs

1//! Aggregate Query Parser
2//!
3//! Parses GraphQL aggregate queries into `AggregationRequest` for execution.
4//!
5//! # GraphQL Query Format
6//!
7//! ```graphql
8//! query {
9//!   sales_aggregate(
10//!     where: { customer_id: { _eq: "uuid-123" } }
11//!     groupBy: { category: true, occurred_at_day: true }
12//!     having: { revenue_sum_gt: 1000 }
13//!     orderBy: { revenue_sum: DESC }
14//!     limit: 10
15//!   ) {
16//!     category
17//!     occurred_at_day
18//!     count
19//!     revenue_sum
20//!     revenue_avg
21//!   }
22//! }
23//! ```
24//!
25//! # Parsed Result
26//!
27//! ```text
28//! // Illustrative output structure only — not executable code.
29//! AggregationRequest {
30//!     table_name: "tf_sales",
31//!     where_clause: Some(...),
32//!     group_by: vec![
33//!         GroupBySelection::Dimension { path: "category", alias: "category" },
34//!         GroupBySelection::TemporalBucket { column: "occurred_at", bucket: Day, alias: "occurred_at_day" },
35//!     ],
36//!     aggregates: vec![
37//!         AggregateSelection::Count { alias: "count" },
38//!         AggregateSelection::MeasureAggregate { measure: "revenue", function: Sum, alias: "revenue_sum" },
39//!         AggregateSelection::MeasureAggregate { measure: "revenue", function: Avg, alias: "revenue_avg" },
40//!     ],
41//!     having: vec![...],
42//!     order_by: vec![...],
43//!     limit: Some(10),
44//!     offset: None,
45//! }
46//! ```
47
48use serde_json::Value;
49
50use crate::{
51    backend::where_clause::{WhereClause, WhereOperator},
52    compiler::{
53        aggregate_types::{AggregateFunction, HavingOperator, TemporalBucket},
54        aggregation::{
55            AggregateSelection, AggregationRequest, GroupBySelection, HavingCondition,
56            OrderByClause, OrderDirection, TreeGrouping,
57        },
58        fact_table::FactTableMetadata,
59    },
60    error::{FraiseQLError, Result},
61};
62
63/// Aggregate query parser
64pub struct AggregateQueryParser;
65
66impl AggregateQueryParser {
67    /// Parse a simplified aggregate query into `AggregationRequest`.
68    ///
69    /// For we'll accept a JSON structure that represents the query:
70    /// ```json
71    /// {
72    ///   "table": "tf_sales",
73    ///   "groupBy": {
74    ///     "category": true,
75    ///     "occurred_at_day": true
76    ///   },
77    ///   "aggregates": [
78    ///     {"count": {}},
79    ///     {"revenue_sum": {}}
80    ///   ],
81    ///   "having": {
82    ///     "revenue_sum_gt": 1000
83    ///   },
84    ///   "orderBy": {
85    ///     "revenue_sum": "DESC"
86    ///   },
87    ///   "limit": 10
88    /// }
89    /// ```
90    ///
91    /// # Errors
92    ///
93    /// Returns error if the query structure is invalid or references non-existent measures.
94    ///
95    /// The `native_columns` map (column name → PostgreSQL cast string) controls whether
96    /// WHERE filters and GROUP BY fields are emitted as direct column references
97    /// (`WhereClause::NativeField`, `GroupBySelection::NativeDimension`) rather than
98    /// JSONB extraction variants.  Pass an empty map to get the old behaviour.
99    pub fn parse(
100        query_json: &Value,
101        metadata: &FactTableMetadata,
102        native_columns: &std::collections::HashMap<String, String>,
103    ) -> Result<AggregationRequest> {
104        // Extract table name
105        let table_name = query_json
106            .get("table")
107            .and_then(|v| v.as_str())
108            .ok_or_else(|| FraiseQLError::Validation {
109                message: "Missing 'table' field in aggregate query".to_string(),
110                path:    None,
111            })?
112            .to_string();
113
114        // Parse WHERE clause (if present)
115        let where_clause = if let Some(where_obj) = query_json.get("where") {
116            Some(Self::parse_where_clause(where_obj, metadata, native_columns)?)
117        } else {
118            None
119        };
120
121        // Parse GROUP BY selections
122        let group_by = if let Some(group_by_obj) = query_json.get("groupBy") {
123            Self::parse_group_by(group_by_obj, metadata, native_columns)?
124        } else {
125            vec![]
126        };
127
128        // Parse aggregate selections from requested fields
129        let aggregates = if let Some(agg_array) = query_json.get("aggregates") {
130            Self::parse_aggregates(agg_array, metadata)?
131        } else {
132            vec![]
133        };
134
135        // Parse HAVING conditions
136        let having = if let Some(having_obj) = query_json.get("having") {
137            Self::parse_having(having_obj, &aggregates, metadata)?
138        } else {
139            vec![]
140        };
141
142        // Parse ORDER BY clauses
143        let order_by = if let Some(order_obj) = query_json.get("orderBy") {
144            Self::parse_order_by(order_obj)?
145        } else {
146            vec![]
147        };
148
149        // Parse LIMIT
150        let limit = query_json
151            .get("limit")
152            .and_then(|v| v.as_u64())
153            .map(|n| u32::try_from(n).unwrap_or(u32::MAX));
154
155        // Parse OFFSET
156        let offset = query_json
157            .get("offset")
158            .and_then(|v| v.as_u64())
159            .map(|n| u32::try_from(n).unwrap_or(u32::MAX));
160
161        Ok(AggregationRequest {
162            table_name,
163            where_clause,
164            group_by,
165            aggregates,
166            having,
167            order_by,
168            limit,
169            offset,
170        })
171    }
172
173    /// Parse WHERE clause from JSON.
174    ///
175    /// For aggregate queries, WHERE works on denormalized filter columns, on dimensions a
176    /// `native_dimension_mapping` maps to a column (read from that column), and on
177    /// top-level JSONB dimension keys.
178    /// Expected format: `{ "field_operator": value }`
179    /// Example: `{ "customer_id_eq": "123", "occurred_at_gte": "2024-01-01" }`
180    ///
181    /// When a field name matches an entry in `native_columns`, the clause is emitted
182    /// as [`WhereClause::NativeField`] (direct column reference) rather than
183    /// [`WhereClause::Field`] (JSONB extraction).
184    fn parse_where_clause(
185        where_obj: &Value,
186        metadata: &FactTableMetadata,
187        native_columns: &std::collections::HashMap<String, String>,
188    ) -> Result<WhereClause> {
189        let Some(obj) = where_obj.as_object() else {
190            return Ok(WhereClause::And(vec![]));
191        };
192
193        let mut conditions = Vec::new();
194
195        for (key, value) in obj {
196            // Parse field_operator format (e.g., "customer_id_eq" -> field="customer_id",
197            // operator="eq")
198            let (field, operator) = split_where_key(key, metadata, native_columns)?;
199
200            let clause = if let Some(column) = mapped_column(metadata, field) {
201                // A dimension mapped to a native column is read from the column, as its
202                // `groupBy` is (#1231), cast as the column's declared filter type is.
203                WhereClause::NativeField {
204                    column: column.clone(),
205                    pg_cast: native_columns.get(column.as_str()).cloned().unwrap_or_default(),
206                    operator,
207                    value: value.clone(),
208                }
209            } else if let Some(pg_cast) = native_columns.get(field) {
210                WhereClause::NativeField {
211                    column: field.to_string(),
212                    pg_cast: pg_cast.clone(),
213                    operator,
214                    value: value.clone(),
215                }
216            } else {
217                WhereClause::Field {
218                    // Recase the JSONB key so a camelCase aggregate filter
219                    // (`organizationId_eq`) builds `data->>'organization_id'`
220                    // rather than a never-matching `organizationId` key (#486).
221                    // Only the non-native branch recases — the native lookup
222                    // above stays on the surface name (mirror `query_params`).
223                    path: vec![crate::utils::to_snake_case(field)],
224                    operator,
225                    value: value.clone(),
226                }
227            };
228            conditions.push(clause);
229        }
230
231        Ok(WhereClause::And(conditions))
232    }
233
234    /// Parse GROUP BY selections.
235    ///
236    /// Supports two formats:
237    /// 1. Boolean true: {"category": true} -> regular dimension
238    /// 2. Boolean true with suffix: {"`occurred_at_day"`: true} -> temporal bucket
239    /// 3. String bucket name: {"`occurred_at"`: "day"} -> temporal bucket
240    ///
241    /// When a field name matches an entry in `native_columns`, the selection is emitted
242    /// as [`GroupBySelection::NativeDimension`] rather than [`GroupBySelection::Dimension`].
243    fn parse_group_by(
244        group_by_obj: &Value,
245        metadata: &FactTableMetadata,
246        native_columns: &std::collections::HashMap<String, String>,
247    ) -> Result<Vec<GroupBySelection>> {
248        let mut selections = Vec::new();
249
250        if let Some(obj) = group_by_obj.as_object() {
251            for (key, value) in obj {
252                if value.as_bool() == Some(true) {
253                    // Format 1 & 2: Boolean true (with or without suffix)
254                    // Priority 1: Try calendar dimension first (highest performance)
255                    if let Some(calendar_sel) = Self::try_parse_calendar_bucket(key, metadata)? {
256                        selections.push(calendar_sel);
257                    } else if let Some(bucket_sel) = Self::parse_temporal_bucket(key, metadata)? {
258                        // Priority 2: Fall back to DATE_TRUNC if no calendar dimension
259                        selections.push(bucket_sel);
260                    } else if let Some(mapped_col) = mapped_column(metadata, key) {
261                        // Priority 3: Deep JSONB path mapped to a native column
262                        selections.push(GroupBySelection::NativeDimension {
263                            column:  mapped_col.clone(),
264                            pg_cast: native_columns
265                                .get(mapped_col.as_str())
266                                .cloned()
267                                .unwrap_or_default(),
268                            alias:   key.clone(),
269                        });
270                    } else if let Some(pg_cast) = native_columns.get(key.as_str()) {
271                        // Priority 4: Native SQL column (filter-derived) — direct reference
272                        selections.push(GroupBySelection::NativeDimension {
273                            column:  key.clone(),
274                            pg_cast: pg_cast.clone(),
275                            alias:   key.clone(),
276                        });
277                    } else {
278                        // Priority 5: Regular JSONB dimension. Unlike priorities 1-4
279                        // (each gated by a lookup against server-defined calendar/
280                        // temporal/native metadata), this fallback echoes the raw
281                        // GraphQL key verbatim as the SELECT alias (`… AS {key}`),
282                        // which is interpolated into SQL without quoting. Reject keys
283                        // that are not safe identifiers so an attacker cannot inject an
284                        // extra SELECT column (H1) — e.g. `a, (SELECT …) AS leak`. This
285                        // fires at parse time, independent of the compiler's dimension
286                        // allowlist (which is skipped when `dimensions.paths` is empty).
287                        validate_dimension_key(key)?;
288                        selections.push(GroupBySelection::Dimension {
289                            // The `path` is the JSONB extraction key → recase to
290                            // snake_case so a camelCase dimension (`machineStatus`)
291                            // groups by `data->>'machine_status'` (#486). The `alias`
292                            // is the result/response column name, consumed verbatim by
293                            // `AggregationProjector`, so it keeps the camel surface name
294                            // (the #418/#410 "output key is the alias" rule).
295                            path:  crate::utils::to_snake_case(key),
296                            alias: key.clone(),
297                        });
298                    }
299                } else if value.as_str() == Some("depth") {
300                    // Format 4: tree depth of an ltree path {"path": "depth"} (#1498)
301                    selections.push(Self::tree_level(key, TreeGrouping::Depth, metadata)?);
302                } else if let Some(level) = value.as_object().and_then(|o| o.get("level")) {
303                    // Format 5: roll a path up to a tree level {"path": {"level": 2}} (#1498)
304                    let level = level
305                        .as_u64()
306                        .filter(|n| *n >= 1)
307                        .and_then(|n| u32::try_from(n).ok())
308                        .ok_or_else(|| FraiseQLError::Validation {
309                            message: format!(
310                                "groupBy '{key}': level must be a positive integer, got {level}"
311                            ),
312                            path:    None,
313                        })?;
314                    selections.push(Self::tree_level(key, TreeGrouping::Level(level), metadata)?);
315                } else if let Some(bucket_str) = value.as_str() {
316                    // Format 3: String bucket name {"occurred_at": "day"}
317                    let bucket = TemporalBucket::from_str(bucket_str)?;
318
319                    // Priority 1: Try calendar dimension first
320                    if let Some(calendar_sel) =
321                        Self::try_find_calendar_bucket(key, bucket, metadata)
322                    {
323                        selections.push(calendar_sel);
324                    } else {
325                        // Priority 2: Fall back to DATE_TRUNC
326                        // Verify this column exists in denormalized_filters
327                        let column_exists =
328                            metadata.denormalized_filters.iter().any(|f| f.name == *key);
329
330                        if !column_exists {
331                            return Err(FraiseQLError::Validation {
332                                message: format!(
333                                    "Temporal bucketing column '{}' not found in denormalized filters",
334                                    key
335                                ),
336                                path:    None,
337                            });
338                        }
339
340                        selections.push(GroupBySelection::TemporalBucket {
341                            column: key.clone(),
342                            bucket,
343                            alias: key.clone(),
344                        });
345                    }
346                }
347            }
348        }
349
350        Ok(selections)
351    }
352
353    /// A tree grouping of the denormalized filter column `key` (#1498). The column's
354    /// type is checked when the request is planned.
355    fn tree_level(
356        key: &str,
357        grouping: TreeGrouping,
358        metadata: &FactTableMetadata,
359    ) -> Result<GroupBySelection> {
360        if !metadata.denormalized_filters.iter().any(|f| f.name == key) {
361            return Err(FraiseQLError::Validation {
362                message: format!(
363                    "groupBy '{key}': a tree level or depth needs an ltree denormalized filter \
364                     column, and '{key}' is not a denormalized filter of '{}'",
365                    metadata.table_name
366                ),
367                path:    None,
368            });
369        }
370        let alias = match grouping {
371            TreeGrouping::Depth => format!("{key}_depth"),
372            TreeGrouping::Level(_) => key.to_string(),
373        };
374        Ok(GroupBySelection::TreeLevel {
375            column: key.to_string(),
376            grouping,
377            alias,
378        })
379    }
380
381    /// Parse temporal bucket if the key matches pattern
382    fn parse_temporal_bucket(
383        key: &str,
384        metadata: &FactTableMetadata,
385    ) -> Result<Option<GroupBySelection>> {
386        // Check for temporal bucket patterns: column_day, column_week, etc.
387        for filter_col in &metadata.denormalized_filters {
388            for bucket in &[
389                ("_second", TemporalBucket::Second),
390                ("_minute", TemporalBucket::Minute),
391                ("_hour", TemporalBucket::Hour),
392                ("_day", TemporalBucket::Day),
393                ("_week", TemporalBucket::Week),
394                ("_month", TemporalBucket::Month),
395                ("_quarter", TemporalBucket::Quarter),
396                ("_year", TemporalBucket::Year),
397            ] {
398                let expected_key = format!("{}{}", filter_col.name, bucket.0);
399                if key == expected_key {
400                    return Ok(Some(GroupBySelection::TemporalBucket {
401                        column: filter_col.name.clone(),
402                        bucket: bucket.1,
403                        alias:  key.to_string(),
404                    }));
405                }
406            }
407        }
408
409        Ok(None)
410    }
411
412    /// Try to parse calendar dimension from key pattern (e.g., "`occurred_at_day`")
413    ///
414    /// Checks if the key matches a calendar dimension pattern and returns
415    /// a `CalendarDimension` selection if available, otherwise None.
416    fn try_parse_calendar_bucket(
417        key: &str,
418        metadata: &FactTableMetadata,
419    ) -> Result<Option<GroupBySelection>> {
420        for calendar_dim in &metadata.calendar_dimensions {
421            // Check all temporal bucket suffixes
422            for (suffix, bucket_type) in &[
423                ("_second", TemporalBucket::Second),
424                ("_minute", TemporalBucket::Minute),
425                ("_hour", TemporalBucket::Hour),
426                ("_day", TemporalBucket::Day),
427                ("_week", TemporalBucket::Week),
428                ("_month", TemporalBucket::Month),
429                ("_quarter", TemporalBucket::Quarter),
430                ("_year", TemporalBucket::Year),
431            ] {
432                let expected_key = format!("{}{}", calendar_dim.source_column, suffix);
433                if key == expected_key {
434                    // Find matching calendar bucket
435                    if let Some((gran, bucket)) =
436                        Self::find_calendar_bucket(calendar_dim, *bucket_type)
437                    {
438                        return Ok(Some(GroupBySelection::CalendarDimension {
439                            source_column:   calendar_dim.source_column.clone(),
440                            calendar_column: gran.column_name.clone(),
441                            json_key:        bucket.json_key.clone(),
442                            bucket:          bucket.bucket_type,
443                            alias:           key.to_string(),
444                        }));
445                    }
446                }
447            }
448        }
449        Ok(None)
450    }
451
452    /// Try to find calendar bucket for explicit temporal request
453    ///
454    /// Used when user provides explicit bucket like {"`occurred_at"`: "day"}
455    fn try_find_calendar_bucket(
456        column: &str,
457        bucket: TemporalBucket,
458        metadata: &FactTableMetadata,
459    ) -> Option<GroupBySelection> {
460        for calendar_dim in &metadata.calendar_dimensions {
461            if calendar_dim.source_column == column {
462                if let Some((gran, cal_bucket)) = Self::find_calendar_bucket(calendar_dim, bucket) {
463                    return Some(GroupBySelection::CalendarDimension {
464                        source_column:   calendar_dim.source_column.clone(),
465                        calendar_column: gran.column_name.clone(),
466                        json_key:        cal_bucket.json_key.clone(),
467                        bucket:          cal_bucket.bucket_type,
468                        alias:           column.to_string(),
469                    });
470                }
471            }
472        }
473        None
474    }
475
476    /// Find calendar bucket in available granularities
477    ///
478    /// Searches through calendar dimension granularities to find a matching bucket type.
479    /// Returns the granularity and bucket if found.
480    fn find_calendar_bucket(
481        calendar_dim: &crate::compiler::fact_table::CalendarDimension,
482        bucket: TemporalBucket,
483    ) -> Option<(
484        &crate::compiler::fact_table::CalendarGranularity,
485        &crate::compiler::fact_table::CalendarBucket,
486    )> {
487        for granularity in &calendar_dim.granularities {
488            for cal_bucket in &granularity.buckets {
489                if cal_bucket.bucket_type == bucket {
490                    return Some((granularity, cal_bucket));
491                }
492            }
493        }
494        None
495    }
496
497    /// Parse aggregate selections
498    fn parse_aggregates(
499        agg_array: &Value,
500        metadata: &FactTableMetadata,
501    ) -> Result<Vec<AggregateSelection>> {
502        let mut aggregates = Vec::new();
503
504        if let Some(arr) = agg_array.as_array() {
505            for item in arr {
506                if let Some(obj) = item.as_object() {
507                    // Each object should have one key (the aggregate name)
508                    for (agg_name, _value) in obj {
509                        aggregates.push(Self::parse_aggregate_selection(agg_name, metadata)?);
510                    }
511                }
512            }
513        }
514
515        Ok(aggregates)
516    }
517
518    /// Parse a single aggregate selection
519    fn parse_aggregate_selection(
520        agg_name: &str,
521        metadata: &FactTableMetadata,
522    ) -> Result<AggregateSelection> {
523        // Handle COUNT
524        if agg_name == "count" {
525            return Ok(AggregateSelection::Count {
526                alias: "count".to_string(),
527            });
528        }
529
530        // Handle COUNT_DISTINCT: supports both "count_distinct" (defaults to first dimension)
531        // and "field_count_distinct" pattern (e.g., "product_id_count_distinct")
532        if agg_name == "count_distinct" {
533            // Default to first dimension path, or "id" if none available
534            let default_field = Self::extract_dimension_paths(metadata)
535                .first()
536                .cloned()
537                .unwrap_or_else(|| "id".to_string());
538            return Ok(AggregateSelection::CountDistinct {
539                field: default_field,
540                alias: "count_distinct".to_string(),
541            });
542        }
543
544        // Handle field_count_distinct pattern (e.g., "customer_id_count_distinct")
545        if let Some(stripped) = agg_name.strip_suffix("_count_distinct") {
546            // Check if the stripped part matches a dimension path
547            let dimension_paths = Self::extract_dimension_paths(metadata);
548            if dimension_paths.iter().any(|p| p == stripped) {
549                return Ok(AggregateSelection::CountDistinct {
550                    field: stripped.to_string(),
551                    alias: agg_name.to_string(),
552                });
553            }
554            // Also allow count distinct on measures
555            if metadata.measures.iter().any(|m| m.name == stripped) {
556                return Ok(AggregateSelection::CountDistinct {
557                    field: stripped.to_string(),
558                    alias: agg_name.to_string(),
559                });
560            }
561            // If no match found, return error with helpful message
562            return Err(FraiseQLError::Validation {
563                message: format!(
564                    "COUNT DISTINCT field '{}' not found in dimensions or measures. Available: {:?}",
565                    stripped, dimension_paths
566                ),
567                path:    None,
568            });
569        }
570
571        // Handle boolean aggregates (BOOL_AND, BOOL_OR)
572        // e.g., "is_active_bool_and", "has_discount_bool_or"
573        for dimension_path in Self::extract_dimension_paths(metadata) {
574            if let Some(stripped) = agg_name.strip_suffix("_bool_and") {
575                if stripped == dimension_path {
576                    return Ok(AggregateSelection::BoolAggregate {
577                        field:    dimension_path,
578                        function: crate::compiler::aggregate_types::BoolAggregateFunction::And,
579                        alias:    agg_name.to_string(),
580                    });
581                }
582            }
583            if let Some(stripped) = agg_name.strip_suffix("_bool_or") {
584                if stripped == dimension_path {
585                    return Ok(AggregateSelection::BoolAggregate {
586                        field:    dimension_path,
587                        function: crate::compiler::aggregate_types::BoolAggregateFunction::Or,
588                        alias:    agg_name.to_string(),
589                    });
590                }
591            }
592        }
593
594        // Handle measure aggregates: revenue_sum, revenue_avg, etc.
595        for measure in &metadata.measures {
596            for func in &[
597                ("_sum", AggregateFunction::Sum),
598                ("_avg", AggregateFunction::Avg),
599                ("_min", AggregateFunction::Min),
600                ("_max", AggregateFunction::Max),
601                ("_stddev", AggregateFunction::Stddev),
602                ("_variance", AggregateFunction::Variance),
603                // Advanced aggregates
604                ("_array_agg", AggregateFunction::ArrayAgg),
605                ("_json_agg", AggregateFunction::JsonAgg),
606                ("_jsonb_agg", AggregateFunction::JsonbAgg),
607                ("_string_agg", AggregateFunction::StringAgg),
608            ] {
609                let expected_name = format!("{}{}", measure.name, func.0);
610                if agg_name == expected_name {
611                    return Ok(AggregateSelection::MeasureAggregate {
612                        measure:  measure.name.clone(),
613                        function: func.1,
614                        alias:    agg_name.to_string(),
615                    });
616                }
617            }
618        }
619
620        // Handle native measure aggregates: measures.volume_sum, measures.cost_avg, etc.
621        // These are JSONB measure paths mapped to flat SQL columns via native_measures.
622        for jsonb_path in metadata.native_measures.keys() {
623            for func in &[
624                ("_sum", AggregateFunction::Sum),
625                ("_avg", AggregateFunction::Avg),
626                ("_min", AggregateFunction::Min),
627                ("_max", AggregateFunction::Max),
628                ("_stddev", AggregateFunction::Stddev),
629                ("_variance", AggregateFunction::Variance),
630                ("_array_agg", AggregateFunction::ArrayAgg),
631                ("_json_agg", AggregateFunction::JsonAgg),
632                ("_jsonb_agg", AggregateFunction::JsonbAgg),
633                ("_string_agg", AggregateFunction::StringAgg),
634            ] {
635                let expected_name = format!("{}{}", jsonb_path, func.0);
636                if agg_name == expected_name {
637                    // Store the JSONB path as the measure name; the planner resolves
638                    // it to the native column via FactTableMetadata.native_measures.
639                    return Ok(AggregateSelection::MeasureAggregate {
640                        measure:  jsonb_path.clone(),
641                        function: func.1,
642                        alias:    agg_name.to_string(),
643                    });
644                }
645            }
646        }
647
648        // Check for dimension-level advanced aggregates
649        // e.g., "product_id_array_agg", "product_name_string_agg"
650        for dimension_path in Self::extract_dimension_paths(metadata) {
651            for func in &[
652                ("_array_agg", AggregateFunction::ArrayAgg),
653                ("_json_agg", AggregateFunction::JsonAgg),
654                ("_jsonb_agg", AggregateFunction::JsonbAgg),
655                ("_string_agg", AggregateFunction::StringAgg),
656            ] {
657                let expected_name = format!("{}{}", dimension_path, func.0);
658                if agg_name == expected_name {
659                    // For dimension aggregates, store the path as the "measure"
660                    return Ok(AggregateSelection::MeasureAggregate {
661                        measure:  dimension_path,
662                        function: func.1,
663                        alias:    agg_name.to_string(),
664                    });
665                }
666            }
667        }
668
669        Err(FraiseQLError::Validation {
670            message: format!("Unknown aggregate selection: {agg_name}"),
671            path:    None,
672        })
673    }
674
675    /// Extract dimension paths from metadata for advanced aggregate parsing
676    fn extract_dimension_paths(metadata: &FactTableMetadata) -> Vec<String> {
677        let mut paths = Vec::new();
678
679        // Add dimension paths from JSONB column
680        for dim_path in &metadata.dimensions.paths {
681            paths.push(dim_path.name.clone());
682        }
683
684        // Add denormalized filter columns (these can also be aggregated)
685        for filter in &metadata.denormalized_filters {
686            paths.push(filter.name.clone());
687        }
688
689        paths
690    }
691
692    /// Parse HAVING conditions
693    fn parse_having(
694        having_obj: &Value,
695        aggregates: &[AggregateSelection],
696        _metadata: &FactTableMetadata,
697    ) -> Result<Vec<HavingCondition>> {
698        let mut conditions = Vec::new();
699
700        if let Some(obj) = having_obj.as_object() {
701            for (key, value) in obj {
702                // Parse condition: revenue_sum_gt: 1000
703                if let Some((agg_name, operator)) = Self::parse_having_key(key) {
704                    // Find the aggregate
705                    let aggregate = aggregates
706                        .iter()
707                        .find(|a| a.alias() == agg_name)
708                        .ok_or_else(|| FraiseQLError::Validation {
709                            message: format!(
710                                "HAVING condition references non-selected aggregate: {agg_name}"
711                            ),
712                            path:    None,
713                        })?
714                        .clone();
715
716                    conditions.push(HavingCondition {
717                        aggregate,
718                        operator,
719                        value: value.clone(),
720                    });
721                }
722            }
723        }
724
725        Ok(conditions)
726    }
727
728    /// Parse HAVING key to extract aggregate name and operator
729    fn parse_having_key(key: &str) -> Option<(&str, HavingOperator)> {
730        for (suffix, op) in &[
731            ("_gt", HavingOperator::Gt),
732            ("_gte", HavingOperator::Gte),
733            ("_lt", HavingOperator::Lt),
734            ("_lte", HavingOperator::Lte),
735            ("_eq", HavingOperator::Eq),
736            ("_neq", HavingOperator::Neq),
737        ] {
738            if let Some(agg_name) = key.strip_suffix(suffix) {
739                return Some((agg_name, *op));
740            }
741        }
742        None
743    }
744
745    /// Parse ORDER BY clauses
746    fn parse_order_by(order_obj: &Value) -> Result<Vec<OrderByClause>> {
747        let mut clauses = Vec::new();
748
749        if let Some(obj) = order_obj.as_object() {
750            for (field, value) in obj {
751                #[allow(clippy::match_same_arms)]
752                // Reason: explicit ASC arm documents accepted values; wildcard handles malformed
753                // input
754                let direction = match value.as_str() {
755                    Some("ASC" | "asc") => OrderDirection::Asc,
756                    Some("DESC" | "desc") => OrderDirection::Desc,
757                    _ => OrderDirection::Asc, // Default to ASC
758                };
759
760                clauses.push(OrderByClause::new(field.clone(), direction));
761            }
762        }
763
764        Ok(clauses)
765    }
766}
767
768/// Validate that a regular-JSONB-dimension GraphQL key is a safe SQL identifier
769/// (`[_A-Za-z][_0-9A-Za-z]*`).
770///
771/// Regular dimensions echo this key verbatim as the SELECT-list alias
772/// (`… AS {key}`), interpolated into SQL with no quoting; a key containing
773/// commas, parentheses, or a subquery would inject an extra SELECT column (H1).
774/// Calendar/temporal/native dimensions are gated by lookups against server-
775/// defined metadata, so only this fallback path needs the charset guard.
776/// Rejecting (rather than quoting) keeps the response key — which equals the SQL
777/// column name — unchanged for legitimate identifiers.
778///
779/// # Errors
780///
781/// Returns [`FraiseQLError::Validation`] if `key` is empty or contains a
782/// character outside `[_A-Za-z][_0-9A-Za-z]*`.
783fn validate_dimension_key(key: &str) -> Result<()> {
784    let mut chars = key.chars();
785    let first_ok = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
786    let rest_ok = chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
787    if first_ok && rest_ok {
788        Ok(())
789    } else {
790        Err(FraiseQLError::Validation {
791            message: format!(
792                "groupBy dimension '{key}' contains invalid characters; \
793                 only [_A-Za-z][_0-9A-Za-z]* is allowed"
794            ),
795            path:    None,
796        })
797    }
798}
799
800/// Split a fact-table `where` key, `<field>_<operator>`, against the operator table.
801///
802/// Shared by the aggregate and window parsers. Splitting at the last underscore could not
803/// reach an operator spelled with one (`descendant_of`, `is_not_null`, `depth_eq`); the
804/// aggregate parser refused such keys and the window parser dropped them, widening the row
805/// set (#1460, #1499). Every split whose suffix names an operator is a candidate. When
806/// several are (`path_depth_eq` reads as `path` + `depth_eq` or as `path_depth` + `eq`),
807/// the one whose field the fact table declares (a denormalized filter, a native column or a
808/// dimension path) wins, and a key that stays ambiguous is refused rather than guessed.
809///
810/// # Errors
811///
812/// `FraiseQLError::Validation` naming the key when no split names an operator, or when the
813/// key stays ambiguous.
814/// The native column a `native_dimension_mapping` maps the dimension `key` to (#1231).
815///
816/// Keys compare in `snake_case`, segment by segment: a mapping declared `itemCategory` and
817/// one declared `item_category` both serve a request for either spelling. Looking the raw
818/// request key up instead made a mapping in the other casing never fire, silently.
819pub(super) fn mapped_column<'m>(metadata: &'m FactTableMetadata, key: &str) -> Option<&'m String> {
820    let wanted = crate::compiler::fact_table::dimension_key(key);
821    metadata
822        .native_dimension_mapping
823        .iter()
824        .find(|(declared, _)| crate::compiler::fact_table::dimension_key(declared) == wanted)
825        .map(|(_, column)| column)
826}
827
828pub(super) fn split_where_key<'k>(
829    key: &'k str,
830    metadata: &FactTableMetadata,
831    native_columns: &std::collections::HashMap<String, String>,
832) -> Result<(&'k str, WhereOperator)> {
833    let is_declared = |field: &str| {
834        let snake = crate::utils::to_snake_case(field);
835        native_columns.contains_key(field)
836            || metadata.denormalized_filters.iter().any(|f| f.name == field || f.name == snake)
837            || metadata.dimensions.paths.iter().any(|p| p.name == field || p.name == snake)
838    };
839    let candidates: Vec<(&str, &str, WhereOperator)> = key
840        .match_indices('_')
841        .filter_map(|(i, _)| {
842            let (field, suffix) = (&key[..i], &key[i + 1..]);
843            let operator = WhereOperator::from_str(suffix).ok()?;
844            (!field.is_empty()).then_some((field, suffix, operator))
845        })
846        .collect();
847    let declared: Vec<_> = candidates.iter().filter(|(f, ..)| is_declared(f)).collect();
848    let chosen = match (candidates.as_slice(), declared.as_slice()) {
849        ([], _) => {
850            return Err(FraiseQLError::validation(format!(
851                "`where` key `{key}` is not `<field>_<operator>` with a supported operator"
852            )));
853        },
854        ([only], _) => only,
855        (_, [only]) => *only,
856        (many, _) => {
857            let readings: Vec<String> =
858                many.iter().map(|(f, op, _)| format!("`{f}` `{op}`")).collect();
859            return Err(FraiseQLError::validation(format!(
860                "`where` key `{key}` is ambiguous: it reads as {}; declare the field as a \
861                 filter column or a dimension path",
862                readings.join(" or ")
863            )));
864        },
865    };
866    let (field, _, operator) = chosen;
867    Ok((field, operator.clone()))
868}
869
870#[cfg(test)]
871mod alias_injection_tests {
872    #![allow(clippy::unwrap_used, clippy::panic)] // Reason: test code, panics are acceptable
873    use std::collections::HashMap;
874
875    use super::*;
876
877    fn empty_metadata() -> FactTableMetadata {
878        // `dimensions.paths` is empty on purpose: the compiler's dimension
879        // allowlist is skipped in that state (the realistic empty-table-at-
880        // compile-time default), so parse-time validation is the live guard.
881        serde_json::from_value(serde_json::json!({
882            "table_name": "tf_sales",
883            "measures": [],
884            "dimensions": { "name": "dimensions", "paths": [] },
885            "denormalized_filters": []
886        }))
887        .expect("valid empty fact-table metadata")
888    }
889
890    #[test]
891    fn validate_dimension_key_accepts_plain_identifiers() {
892        assert!(validate_dimension_key("category").is_ok());
893        assert!(validate_dimension_key("occurred_at_day").is_ok());
894        assert!(validate_dimension_key("_private").is_ok());
895    }
896
897    #[test]
898    fn validate_dimension_key_rejects_injection_shapes() {
899        assert!(validate_dimension_key("a, (SELECT 1) AS x").is_err());
900        assert!(validate_dimension_key("x; DROP TABLE t").is_err());
901        assert!(validate_dimension_key("1leading_digit").is_err());
902        assert!(validate_dimension_key("dotted.path").is_err());
903        assert!(validate_dimension_key("").is_err());
904    }
905
906    #[test]
907    fn parse_rejects_hostile_group_by_key_with_empty_allowlist() {
908        // The H1 exploit: an alias that appends a scalar-subquery column. With
909        // `dimensions.paths` empty the compiler allowlist never fires, so parse-
910        // time validation must reject this on its own.
911        let query = serde_json::json!({
912            "table": "tf_sales",
913            "groupBy": {
914                "a, (SELECT string_agg(rolpassword, ',') FROM pg_authid) AS leak": true
915            }
916        });
917        let err = AggregateQueryParser::parse(&query, &empty_metadata(), &HashMap::new())
918            .expect_err("hostile groupBy key must be rejected at parse time");
919        assert!(matches!(err, FraiseQLError::Validation { .. }), "got {err:?}");
920    }
921
922    #[test]
923    fn parse_accepts_plain_group_by_key() {
924        let query = serde_json::json!({
925            "table": "tf_sales",
926            "groupBy": { "category": true }
927        });
928        let req = AggregateQueryParser::parse(&query, &empty_metadata(), &HashMap::new())
929            .expect("plain groupBy key must parse");
930        assert_eq!(req.group_by.len(), 1);
931    }
932
933    #[test]
934    fn camel_group_by_dimension_recases_path_but_keeps_alias() {
935        // #486: a camelCase groupBy dimension must extract the snake_case JSONB key
936        // (`data->>'machine_status'`) so grouping actually buckets rows, while the
937        // alias stays the camel surface name because `AggregationProjector` uses it
938        // verbatim as the GraphQL response key (#418/#410 "output key is the alias").
939        let query = serde_json::json!({
940            "table": "tf_sales",
941            "groupBy": { "machineStatus": true }
942        });
943        let req = AggregateQueryParser::parse(&query, &empty_metadata(), &HashMap::new())
944            .expect("camel groupBy key must parse");
945        match req.group_by.as_slice() {
946            [GroupBySelection::Dimension { path, alias }] => {
947                assert_eq!(path, "machine_status", "path is the snake JSONB key");
948                assert_eq!(alias, "machineStatus", "alias is the camel response key");
949            },
950            other => panic!("expected a single Dimension, got {other:?}"),
951        }
952    }
953}