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}