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    compiler::{
52        aggregate_types::{AggregateFunction, HavingOperator, TemporalBucket},
53        aggregation::{
54            AggregateSelection, AggregationRequest, GroupBySelection, HavingCondition,
55            OrderByClause, OrderDirection,
56        },
57        fact_table::FactTableMetadata,
58    },
59    db::where_clause::{WhereClause, WhereOperator},
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, 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 only.
176    /// Expected format: `{ "field_operator": value }`
177    /// Example: `{ "customer_id_eq": "123", "occurred_at_gte": "2024-01-01" }`
178    ///
179    /// When a field name matches an entry in `native_columns`, the clause is emitted
180    /// as [`WhereClause::NativeField`] (direct column reference) rather than
181    /// [`WhereClause::Field`] (JSONB extraction).
182    fn parse_where_clause(
183        where_obj: &Value,
184        native_columns: &std::collections::HashMap<String, String>,
185    ) -> Result<WhereClause> {
186        let Some(obj) = where_obj.as_object() else {
187            return Ok(WhereClause::And(vec![]));
188        };
189
190        let mut conditions = Vec::new();
191
192        for (key, value) in obj {
193            // Parse field_operator format (e.g., "customer_id_eq" -> field="customer_id",
194            // operator="eq")
195            if let Some((field, operator_str)) = Self::parse_where_field_and_operator(key)? {
196                let operator = WhereOperator::from_str(operator_str)?;
197
198                let clause = if let Some(pg_cast) = native_columns.get(field) {
199                    WhereClause::NativeField {
200                        column: field.to_string(),
201                        pg_cast: pg_cast.clone(),
202                        operator,
203                        value: value.clone(),
204                    }
205                } else {
206                    WhereClause::Field {
207                        // Recase the JSONB key so a camelCase aggregate filter
208                        // (`organizationId_eq`) builds `data->>'organization_id'`
209                        // rather than a never-matching `organizationId` key (#486).
210                        // Only the non-native branch recases — the native lookup
211                        // above stays on the surface name (mirror `query_params`).
212                        path: vec![crate::utils::to_snake_case(field)],
213                        operator,
214                        value: value.clone(),
215                    }
216                };
217                conditions.push(clause);
218            }
219        }
220
221        Ok(WhereClause::And(conditions))
222    }
223
224    /// Parse WHERE field and operator from key (e.g., "`customer_id_eq`" -> ("`customer_id`",
225    /// "eq"))
226    fn parse_where_field_and_operator(key: &str) -> Result<Option<(&str, &str)>> {
227        // Find last underscore to split field from operator
228        if let Some(last_underscore) = key.rfind('_') {
229            let field = &key[..last_underscore];
230            let operator = &key[last_underscore + 1..];
231
232            // Validate operator is known
233            match WhereOperator::from_str(operator) {
234                Ok(_) => Ok(Some((field, operator))),
235                Err(_) => {
236                    // Not a valid operator suffix, treat entire key as field (might be used
237                    // elsewhere)
238                    Ok(None)
239                },
240            }
241        } else {
242            // No underscore, not a WHERE condition
243            Ok(None)
244        }
245    }
246
247    /// Parse GROUP BY selections.
248    ///
249    /// Supports two formats:
250    /// 1. Boolean true: {"category": true} -> regular dimension
251    /// 2. Boolean true with suffix: {"`occurred_at_day"`: true} -> temporal bucket
252    /// 3. String bucket name: {"`occurred_at"`: "day"} -> temporal bucket
253    ///
254    /// When a field name matches an entry in `native_columns`, the selection is emitted
255    /// as [`GroupBySelection::NativeDimension`] rather than [`GroupBySelection::Dimension`].
256    fn parse_group_by(
257        group_by_obj: &Value,
258        metadata: &FactTableMetadata,
259        native_columns: &std::collections::HashMap<String, String>,
260    ) -> Result<Vec<GroupBySelection>> {
261        let mut selections = Vec::new();
262
263        if let Some(obj) = group_by_obj.as_object() {
264            for (key, value) in obj {
265                if value.as_bool() == Some(true) {
266                    // Format 1 & 2: Boolean true (with or without suffix)
267                    // Priority 1: Try calendar dimension first (highest performance)
268                    if let Some(calendar_sel) = Self::try_parse_calendar_bucket(key, metadata)? {
269                        selections.push(calendar_sel);
270                    } else if let Some(bucket_sel) = Self::parse_temporal_bucket(key, metadata)? {
271                        // Priority 2: Fall back to DATE_TRUNC if no calendar dimension
272                        selections.push(bucket_sel);
273                    } else if let Some(mapped_col) =
274                        metadata.native_dimension_mapping.get(key.as_str())
275                    {
276                        // Priority 3: Deep JSONB path mapped to a native column
277                        selections.push(GroupBySelection::NativeDimension {
278                            column:  mapped_col.clone(),
279                            pg_cast: String::new(),
280                        });
281                    } else if let Some(pg_cast) = native_columns.get(key.as_str()) {
282                        // Priority 4: Native SQL column (filter-derived) — direct reference
283                        selections.push(GroupBySelection::NativeDimension {
284                            column:  key.clone(),
285                            pg_cast: pg_cast.clone(),
286                        });
287                    } else {
288                        // Priority 5: Regular JSONB dimension. Unlike priorities 1-4
289                        // (each gated by a lookup against server-defined calendar/
290                        // temporal/native metadata), this fallback echoes the raw
291                        // GraphQL key verbatim as the SELECT alias (`… AS {key}`),
292                        // which is interpolated into SQL without quoting. Reject keys
293                        // that are not safe identifiers so an attacker cannot inject an
294                        // extra SELECT column (H1) — e.g. `a, (SELECT …) AS leak`. This
295                        // fires at parse time, independent of the compiler's dimension
296                        // allowlist (which is skipped when `dimensions.paths` is empty).
297                        validate_dimension_key(key)?;
298                        selections.push(GroupBySelection::Dimension {
299                            // The `path` is the JSONB extraction key → recase to
300                            // snake_case so a camelCase dimension (`machineStatus`)
301                            // groups by `data->>'machine_status'` (#486). The `alias`
302                            // is the result/response column name, consumed verbatim by
303                            // `AggregationProjector`, so it keeps the camel surface name
304                            // (the #418/#410 "output key is the alias" rule).
305                            path:  crate::utils::to_snake_case(key),
306                            alias: key.clone(),
307                        });
308                    }
309                } else if let Some(bucket_str) = value.as_str() {
310                    // Format 3: String bucket name {"occurred_at": "day"}
311                    let bucket = TemporalBucket::from_str(bucket_str)?;
312
313                    // Priority 1: Try calendar dimension first
314                    if let Some(calendar_sel) =
315                        Self::try_find_calendar_bucket(key, bucket, metadata)
316                    {
317                        selections.push(calendar_sel);
318                    } else {
319                        // Priority 2: Fall back to DATE_TRUNC
320                        // Verify this column exists in denormalized_filters
321                        let column_exists =
322                            metadata.denormalized_filters.iter().any(|f| f.name == *key);
323
324                        if !column_exists {
325                            return Err(FraiseQLError::Validation {
326                                message: format!(
327                                    "Temporal bucketing column '{}' not found in denormalized filters",
328                                    key
329                                ),
330                                path:    None,
331                            });
332                        }
333
334                        selections.push(GroupBySelection::TemporalBucket {
335                            column: key.clone(),
336                            bucket,
337                            alias: key.clone(),
338                        });
339                    }
340                }
341            }
342        }
343
344        Ok(selections)
345    }
346
347    /// Parse temporal bucket if the key matches pattern
348    fn parse_temporal_bucket(
349        key: &str,
350        metadata: &FactTableMetadata,
351    ) -> Result<Option<GroupBySelection>> {
352        // Check for temporal bucket patterns: column_day, column_week, etc.
353        for filter_col in &metadata.denormalized_filters {
354            for bucket in &[
355                ("_second", TemporalBucket::Second),
356                ("_minute", TemporalBucket::Minute),
357                ("_hour", TemporalBucket::Hour),
358                ("_day", TemporalBucket::Day),
359                ("_week", TemporalBucket::Week),
360                ("_month", TemporalBucket::Month),
361                ("_quarter", TemporalBucket::Quarter),
362                ("_year", TemporalBucket::Year),
363            ] {
364                let expected_key = format!("{}{}", filter_col.name, bucket.0);
365                if key == expected_key {
366                    return Ok(Some(GroupBySelection::TemporalBucket {
367                        column: filter_col.name.clone(),
368                        bucket: bucket.1,
369                        alias:  key.to_string(),
370                    }));
371                }
372            }
373        }
374
375        Ok(None)
376    }
377
378    /// Try to parse calendar dimension from key pattern (e.g., "`occurred_at_day`")
379    ///
380    /// Checks if the key matches a calendar dimension pattern and returns
381    /// a `CalendarDimension` selection if available, otherwise None.
382    fn try_parse_calendar_bucket(
383        key: &str,
384        metadata: &FactTableMetadata,
385    ) -> Result<Option<GroupBySelection>> {
386        for calendar_dim in &metadata.calendar_dimensions {
387            // Check all temporal bucket suffixes
388            for (suffix, bucket_type) 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!("{}{}", calendar_dim.source_column, suffix);
399                if key == expected_key {
400                    // Find matching calendar bucket
401                    if let Some((gran, bucket)) =
402                        Self::find_calendar_bucket(calendar_dim, *bucket_type)
403                    {
404                        return Ok(Some(GroupBySelection::CalendarDimension {
405                            source_column:   calendar_dim.source_column.clone(),
406                            calendar_column: gran.column_name.clone(),
407                            json_key:        bucket.json_key.clone(),
408                            bucket:          bucket.bucket_type,
409                            alias:           key.to_string(),
410                        }));
411                    }
412                }
413            }
414        }
415        Ok(None)
416    }
417
418    /// Try to find calendar bucket for explicit temporal request
419    ///
420    /// Used when user provides explicit bucket like {"`occurred_at"`: "day"}
421    fn try_find_calendar_bucket(
422        column: &str,
423        bucket: TemporalBucket,
424        metadata: &FactTableMetadata,
425    ) -> Option<GroupBySelection> {
426        for calendar_dim in &metadata.calendar_dimensions {
427            if calendar_dim.source_column == column {
428                if let Some((gran, cal_bucket)) = Self::find_calendar_bucket(calendar_dim, bucket) {
429                    return Some(GroupBySelection::CalendarDimension {
430                        source_column:   calendar_dim.source_column.clone(),
431                        calendar_column: gran.column_name.clone(),
432                        json_key:        cal_bucket.json_key.clone(),
433                        bucket:          cal_bucket.bucket_type,
434                        alias:           column.to_string(),
435                    });
436                }
437            }
438        }
439        None
440    }
441
442    /// Find calendar bucket in available granularities
443    ///
444    /// Searches through calendar dimension granularities to find a matching bucket type.
445    /// Returns the granularity and bucket if found.
446    fn find_calendar_bucket(
447        calendar_dim: &crate::compiler::fact_table::CalendarDimension,
448        bucket: TemporalBucket,
449    ) -> Option<(
450        &crate::compiler::fact_table::CalendarGranularity,
451        &crate::compiler::fact_table::CalendarBucket,
452    )> {
453        for granularity in &calendar_dim.granularities {
454            for cal_bucket in &granularity.buckets {
455                if cal_bucket.bucket_type == bucket {
456                    return Some((granularity, cal_bucket));
457                }
458            }
459        }
460        None
461    }
462
463    /// Parse aggregate selections
464    fn parse_aggregates(
465        agg_array: &Value,
466        metadata: &FactTableMetadata,
467    ) -> Result<Vec<AggregateSelection>> {
468        let mut aggregates = Vec::new();
469
470        if let Some(arr) = agg_array.as_array() {
471            for item in arr {
472                if let Some(obj) = item.as_object() {
473                    // Each object should have one key (the aggregate name)
474                    for (agg_name, _value) in obj {
475                        aggregates.push(Self::parse_aggregate_selection(agg_name, metadata)?);
476                    }
477                }
478            }
479        }
480
481        Ok(aggregates)
482    }
483
484    /// Parse a single aggregate selection
485    fn parse_aggregate_selection(
486        agg_name: &str,
487        metadata: &FactTableMetadata,
488    ) -> Result<AggregateSelection> {
489        // Handle COUNT
490        if agg_name == "count" {
491            return Ok(AggregateSelection::Count {
492                alias: "count".to_string(),
493            });
494        }
495
496        // Handle COUNT_DISTINCT: supports both "count_distinct" (defaults to first dimension)
497        // and "field_count_distinct" pattern (e.g., "product_id_count_distinct")
498        if agg_name == "count_distinct" {
499            // Default to first dimension path, or "id" if none available
500            let default_field = Self::extract_dimension_paths(metadata)
501                .first()
502                .cloned()
503                .unwrap_or_else(|| "id".to_string());
504            return Ok(AggregateSelection::CountDistinct {
505                field: default_field,
506                alias: "count_distinct".to_string(),
507            });
508        }
509
510        // Handle field_count_distinct pattern (e.g., "customer_id_count_distinct")
511        if let Some(stripped) = agg_name.strip_suffix("_count_distinct") {
512            // Check if the stripped part matches a dimension path
513            let dimension_paths = Self::extract_dimension_paths(metadata);
514            if dimension_paths.iter().any(|p| p == stripped) {
515                return Ok(AggregateSelection::CountDistinct {
516                    field: stripped.to_string(),
517                    alias: agg_name.to_string(),
518                });
519            }
520            // Also allow count distinct on measures
521            if metadata.measures.iter().any(|m| m.name == stripped) {
522                return Ok(AggregateSelection::CountDistinct {
523                    field: stripped.to_string(),
524                    alias: agg_name.to_string(),
525                });
526            }
527            // If no match found, return error with helpful message
528            return Err(FraiseQLError::Validation {
529                message: format!(
530                    "COUNT DISTINCT field '{}' not found in dimensions or measures. Available: {:?}",
531                    stripped, dimension_paths
532                ),
533                path:    None,
534            });
535        }
536
537        // Handle boolean aggregates (BOOL_AND, BOOL_OR)
538        // e.g., "is_active_bool_and", "has_discount_bool_or"
539        for dimension_path in Self::extract_dimension_paths(metadata) {
540            if let Some(stripped) = agg_name.strip_suffix("_bool_and") {
541                if stripped == dimension_path {
542                    return Ok(AggregateSelection::BoolAggregate {
543                        field:    dimension_path,
544                        function: crate::compiler::aggregate_types::BoolAggregateFunction::And,
545                        alias:    agg_name.to_string(),
546                    });
547                }
548            }
549            if let Some(stripped) = agg_name.strip_suffix("_bool_or") {
550                if stripped == dimension_path {
551                    return Ok(AggregateSelection::BoolAggregate {
552                        field:    dimension_path,
553                        function: crate::compiler::aggregate_types::BoolAggregateFunction::Or,
554                        alias:    agg_name.to_string(),
555                    });
556                }
557            }
558        }
559
560        // Handle measure aggregates: revenue_sum, revenue_avg, etc.
561        for measure in &metadata.measures {
562            for func in &[
563                ("_sum", AggregateFunction::Sum),
564                ("_avg", AggregateFunction::Avg),
565                ("_min", AggregateFunction::Min),
566                ("_max", AggregateFunction::Max),
567                ("_stddev", AggregateFunction::Stddev),
568                ("_variance", AggregateFunction::Variance),
569                // Advanced aggregates
570                ("_array_agg", AggregateFunction::ArrayAgg),
571                ("_json_agg", AggregateFunction::JsonAgg),
572                ("_jsonb_agg", AggregateFunction::JsonbAgg),
573                ("_string_agg", AggregateFunction::StringAgg),
574            ] {
575                let expected_name = format!("{}{}", measure.name, func.0);
576                if agg_name == expected_name {
577                    return Ok(AggregateSelection::MeasureAggregate {
578                        measure:  measure.name.clone(),
579                        function: func.1,
580                        alias:    agg_name.to_string(),
581                    });
582                }
583            }
584        }
585
586        // Handle native measure aggregates: measures.volume_sum, measures.cost_avg, etc.
587        // These are JSONB measure paths mapped to flat SQL columns via native_measures.
588        for jsonb_path in metadata.native_measures.keys() {
589            for func in &[
590                ("_sum", AggregateFunction::Sum),
591                ("_avg", AggregateFunction::Avg),
592                ("_min", AggregateFunction::Min),
593                ("_max", AggregateFunction::Max),
594                ("_stddev", AggregateFunction::Stddev),
595                ("_variance", AggregateFunction::Variance),
596                ("_array_agg", AggregateFunction::ArrayAgg),
597                ("_json_agg", AggregateFunction::JsonAgg),
598                ("_jsonb_agg", AggregateFunction::JsonbAgg),
599                ("_string_agg", AggregateFunction::StringAgg),
600            ] {
601                let expected_name = format!("{}{}", jsonb_path, func.0);
602                if agg_name == expected_name {
603                    // Store the JSONB path as the measure name; the planner resolves
604                    // it to the native column via FactTableMetadata.native_measures.
605                    return Ok(AggregateSelection::MeasureAggregate {
606                        measure:  jsonb_path.clone(),
607                        function: func.1,
608                        alias:    agg_name.to_string(),
609                    });
610                }
611            }
612        }
613
614        // Check for dimension-level advanced aggregates
615        // e.g., "product_id_array_agg", "product_name_string_agg"
616        for dimension_path in Self::extract_dimension_paths(metadata) {
617            for func in &[
618                ("_array_agg", AggregateFunction::ArrayAgg),
619                ("_json_agg", AggregateFunction::JsonAgg),
620                ("_jsonb_agg", AggregateFunction::JsonbAgg),
621                ("_string_agg", AggregateFunction::StringAgg),
622            ] {
623                let expected_name = format!("{}{}", dimension_path, func.0);
624                if agg_name == expected_name {
625                    // For dimension aggregates, store the path as the "measure"
626                    return Ok(AggregateSelection::MeasureAggregate {
627                        measure:  dimension_path,
628                        function: func.1,
629                        alias:    agg_name.to_string(),
630                    });
631                }
632            }
633        }
634
635        Err(FraiseQLError::Validation {
636            message: format!("Unknown aggregate selection: {agg_name}"),
637            path:    None,
638        })
639    }
640
641    /// Extract dimension paths from metadata for advanced aggregate parsing
642    fn extract_dimension_paths(metadata: &FactTableMetadata) -> Vec<String> {
643        let mut paths = Vec::new();
644
645        // Add dimension paths from JSONB column
646        for dim_path in &metadata.dimensions.paths {
647            paths.push(dim_path.name.clone());
648        }
649
650        // Add denormalized filter columns (these can also be aggregated)
651        for filter in &metadata.denormalized_filters {
652            paths.push(filter.name.clone());
653        }
654
655        paths
656    }
657
658    /// Parse HAVING conditions
659    fn parse_having(
660        having_obj: &Value,
661        aggregates: &[AggregateSelection],
662        _metadata: &FactTableMetadata,
663    ) -> Result<Vec<HavingCondition>> {
664        let mut conditions = Vec::new();
665
666        if let Some(obj) = having_obj.as_object() {
667            for (key, value) in obj {
668                // Parse condition: revenue_sum_gt: 1000
669                if let Some((agg_name, operator)) = Self::parse_having_key(key) {
670                    // Find the aggregate
671                    let aggregate = aggregates
672                        .iter()
673                        .find(|a| a.alias() == agg_name)
674                        .ok_or_else(|| FraiseQLError::Validation {
675                            message: format!(
676                                "HAVING condition references non-selected aggregate: {agg_name}"
677                            ),
678                            path:    None,
679                        })?
680                        .clone();
681
682                    conditions.push(HavingCondition {
683                        aggregate,
684                        operator,
685                        value: value.clone(),
686                    });
687                }
688            }
689        }
690
691        Ok(conditions)
692    }
693
694    /// Parse HAVING key to extract aggregate name and operator
695    fn parse_having_key(key: &str) -> Option<(&str, HavingOperator)> {
696        for (suffix, op) in &[
697            ("_gt", HavingOperator::Gt),
698            ("_gte", HavingOperator::Gte),
699            ("_lt", HavingOperator::Lt),
700            ("_lte", HavingOperator::Lte),
701            ("_eq", HavingOperator::Eq),
702            ("_neq", HavingOperator::Neq),
703        ] {
704            if let Some(agg_name) = key.strip_suffix(suffix) {
705                return Some((agg_name, *op));
706            }
707        }
708        None
709    }
710
711    /// Parse ORDER BY clauses
712    fn parse_order_by(order_obj: &Value) -> Result<Vec<OrderByClause>> {
713        let mut clauses = Vec::new();
714
715        if let Some(obj) = order_obj.as_object() {
716            for (field, value) in obj {
717                #[allow(clippy::match_same_arms)]
718                // Reason: explicit ASC arm documents accepted values; wildcard handles malformed
719                // input
720                let direction = match value.as_str() {
721                    Some("ASC" | "asc") => OrderDirection::Asc,
722                    Some("DESC" | "desc") => OrderDirection::Desc,
723                    _ => OrderDirection::Asc, // Default to ASC
724                };
725
726                clauses.push(OrderByClause::new(field.clone(), direction));
727            }
728        }
729
730        Ok(clauses)
731    }
732}
733
734/// Validate that a regular-JSONB-dimension GraphQL key is a safe SQL identifier
735/// (`[_A-Za-z][_0-9A-Za-z]*`).
736///
737/// Regular dimensions echo this key verbatim as the SELECT-list alias
738/// (`… AS {key}`), interpolated into SQL with no quoting; a key containing
739/// commas, parentheses, or a subquery would inject an extra SELECT column (H1).
740/// Calendar/temporal/native dimensions are gated by lookups against server-
741/// defined metadata, so only this fallback path needs the charset guard.
742/// Rejecting (rather than quoting) keeps the response key — which equals the SQL
743/// column name — unchanged for legitimate identifiers.
744///
745/// # Errors
746///
747/// Returns [`FraiseQLError::Validation`] if `key` is empty or contains a
748/// character outside `[_A-Za-z][_0-9A-Za-z]*`.
749fn validate_dimension_key(key: &str) -> Result<()> {
750    let mut chars = key.chars();
751    let first_ok = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
752    let rest_ok = chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
753    if first_ok && rest_ok {
754        Ok(())
755    } else {
756        Err(FraiseQLError::Validation {
757            message: format!(
758                "groupBy dimension '{key}' contains invalid characters; \
759                 only [_A-Za-z][_0-9A-Za-z]* is allowed"
760            ),
761            path:    None,
762        })
763    }
764}
765
766#[cfg(test)]
767mod alias_injection_tests {
768    #![allow(clippy::unwrap_used, clippy::panic)]
769    use std::collections::HashMap;
770
771    use super::*;
772
773    fn empty_metadata() -> FactTableMetadata {
774        // `dimensions.paths` is empty on purpose: the compiler's dimension
775        // allowlist is skipped in that state (the realistic empty-table-at-
776        // compile-time default), so parse-time validation is the live guard.
777        serde_json::from_value(serde_json::json!({
778            "table_name": "tf_sales",
779            "measures": [],
780            "dimensions": { "name": "dimensions", "paths": [] },
781            "denormalized_filters": []
782        }))
783        .expect("valid empty fact-table metadata")
784    }
785
786    #[test]
787    fn validate_dimension_key_accepts_plain_identifiers() {
788        assert!(validate_dimension_key("category").is_ok());
789        assert!(validate_dimension_key("occurred_at_day").is_ok());
790        assert!(validate_dimension_key("_private").is_ok());
791    }
792
793    #[test]
794    fn validate_dimension_key_rejects_injection_shapes() {
795        assert!(validate_dimension_key("a, (SELECT 1) AS x").is_err());
796        assert!(validate_dimension_key("x; DROP TABLE t").is_err());
797        assert!(validate_dimension_key("1leading_digit").is_err());
798        assert!(validate_dimension_key("dotted.path").is_err());
799        assert!(validate_dimension_key("").is_err());
800    }
801
802    #[test]
803    fn parse_rejects_hostile_group_by_key_with_empty_allowlist() {
804        // The H1 exploit: an alias that appends a scalar-subquery column. With
805        // `dimensions.paths` empty the compiler allowlist never fires, so parse-
806        // time validation must reject this on its own.
807        let query = serde_json::json!({
808            "table": "tf_sales",
809            "groupBy": {
810                "a, (SELECT string_agg(rolpassword, ',') FROM pg_authid) AS leak": true
811            }
812        });
813        let err = AggregateQueryParser::parse(&query, &empty_metadata(), &HashMap::new())
814            .expect_err("hostile groupBy key must be rejected at parse time");
815        assert!(matches!(err, FraiseQLError::Validation { .. }), "got {err:?}");
816    }
817
818    #[test]
819    fn parse_accepts_plain_group_by_key() {
820        let query = serde_json::json!({
821            "table": "tf_sales",
822            "groupBy": { "category": true }
823        });
824        let req = AggregateQueryParser::parse(&query, &empty_metadata(), &HashMap::new())
825            .expect("plain groupBy key must parse");
826        assert_eq!(req.group_by.len(), 1);
827    }
828
829    #[test]
830    fn camel_group_by_dimension_recases_path_but_keeps_alias() {
831        // #486: a camelCase groupBy dimension must extract the snake_case JSONB key
832        // (`data->>'machine_status'`) so grouping actually buckets rows, while the
833        // alias stays the camel surface name because `AggregationProjector` uses it
834        // verbatim as the GraphQL response key (#418/#410 "output key is the alias").
835        let query = serde_json::json!({
836            "table": "tf_sales",
837            "groupBy": { "machineStatus": true }
838        });
839        let req = AggregateQueryParser::parse(&query, &empty_metadata(), &HashMap::new())
840            .expect("camel groupBy key must parse");
841        match req.group_by.as_slice() {
842            [GroupBySelection::Dimension { path, alias }] => {
843                assert_eq!(path, "machine_status", "path is the snake JSONB key");
844                assert_eq!(alias, "machineStatus", "alias is the camel response key");
845            },
846            other => panic!("expected a single Dimension, got {other:?}"),
847        }
848    }
849}