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}