1use 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
63pub struct AggregateQueryParser;
65
66impl AggregateQueryParser {
67 pub fn parse(
100 query_json: &Value,
101 metadata: &FactTableMetadata,
102 native_columns: &std::collections::HashMap<String, String>,
103 ) -> Result<AggregationRequest> {
104 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 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 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 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 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 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 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 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 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 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 path: vec![field.to_string()],
208 operator,
209 value: value.clone(),
210 }
211 };
212 conditions.push(clause);
213 }
214 }
215
216 Ok(WhereClause::And(conditions))
217 }
218
219 fn parse_where_field_and_operator(key: &str) -> Result<Option<(&str, &str)>> {
222 if let Some(last_underscore) = key.rfind('_') {
224 let field = &key[..last_underscore];
225 let operator = &key[last_underscore + 1..];
226
227 match WhereOperator::from_str(operator) {
229 Ok(_) => Ok(Some((field, operator))),
230 Err(_) => {
231 Ok(None)
234 },
235 }
236 } else {
237 Ok(None)
239 }
240 }
241
242 fn parse_group_by(
252 group_by_obj: &Value,
253 metadata: &FactTableMetadata,
254 native_columns: &std::collections::HashMap<String, String>,
255 ) -> Result<Vec<GroupBySelection>> {
256 let mut selections = Vec::new();
257
258 if let Some(obj) = group_by_obj.as_object() {
259 for (key, value) in obj {
260 if value.as_bool() == Some(true) {
261 if let Some(calendar_sel) = Self::try_parse_calendar_bucket(key, metadata)? {
264 selections.push(calendar_sel);
265 } else if let Some(bucket_sel) = Self::parse_temporal_bucket(key, metadata)? {
266 selections.push(bucket_sel);
268 } else if let Some(mapped_col) =
269 metadata.native_dimension_mapping.get(key.as_str())
270 {
271 selections.push(GroupBySelection::NativeDimension {
273 column: mapped_col.clone(),
274 pg_cast: String::new(),
275 });
276 } else if let Some(pg_cast) = native_columns.get(key.as_str()) {
277 selections.push(GroupBySelection::NativeDimension {
279 column: key.clone(),
280 pg_cast: pg_cast.clone(),
281 });
282 } else {
283 validate_dimension_key(key)?;
293 selections.push(GroupBySelection::Dimension {
294 path: key.clone(),
295 alias: key.clone(),
296 });
297 }
298 } else if let Some(bucket_str) = value.as_str() {
299 let bucket = TemporalBucket::from_str(bucket_str)?;
301
302 if let Some(calendar_sel) =
304 Self::try_find_calendar_bucket(key, bucket, metadata)
305 {
306 selections.push(calendar_sel);
307 } else {
308 let column_exists =
311 metadata.denormalized_filters.iter().any(|f| f.name == *key);
312
313 if !column_exists {
314 return Err(FraiseQLError::Validation {
315 message: format!(
316 "Temporal bucketing column '{}' not found in denormalized filters",
317 key
318 ),
319 path: None,
320 });
321 }
322
323 selections.push(GroupBySelection::TemporalBucket {
324 column: key.clone(),
325 bucket,
326 alias: key.clone(),
327 });
328 }
329 }
330 }
331 }
332
333 Ok(selections)
334 }
335
336 fn parse_temporal_bucket(
338 key: &str,
339 metadata: &FactTableMetadata,
340 ) -> Result<Option<GroupBySelection>> {
341 for filter_col in &metadata.denormalized_filters {
343 for bucket in &[
344 ("_second", TemporalBucket::Second),
345 ("_minute", TemporalBucket::Minute),
346 ("_hour", TemporalBucket::Hour),
347 ("_day", TemporalBucket::Day),
348 ("_week", TemporalBucket::Week),
349 ("_month", TemporalBucket::Month),
350 ("_quarter", TemporalBucket::Quarter),
351 ("_year", TemporalBucket::Year),
352 ] {
353 let expected_key = format!("{}{}", filter_col.name, bucket.0);
354 if key == expected_key {
355 return Ok(Some(GroupBySelection::TemporalBucket {
356 column: filter_col.name.clone(),
357 bucket: bucket.1,
358 alias: key.to_string(),
359 }));
360 }
361 }
362 }
363
364 Ok(None)
365 }
366
367 fn try_parse_calendar_bucket(
372 key: &str,
373 metadata: &FactTableMetadata,
374 ) -> Result<Option<GroupBySelection>> {
375 for calendar_dim in &metadata.calendar_dimensions {
376 for (suffix, bucket_type) in &[
378 ("_second", TemporalBucket::Second),
379 ("_minute", TemporalBucket::Minute),
380 ("_hour", TemporalBucket::Hour),
381 ("_day", TemporalBucket::Day),
382 ("_week", TemporalBucket::Week),
383 ("_month", TemporalBucket::Month),
384 ("_quarter", TemporalBucket::Quarter),
385 ("_year", TemporalBucket::Year),
386 ] {
387 let expected_key = format!("{}{}", calendar_dim.source_column, suffix);
388 if key == expected_key {
389 if let Some((gran, bucket)) =
391 Self::find_calendar_bucket(calendar_dim, *bucket_type)
392 {
393 return Ok(Some(GroupBySelection::CalendarDimension {
394 source_column: calendar_dim.source_column.clone(),
395 calendar_column: gran.column_name.clone(),
396 json_key: bucket.json_key.clone(),
397 bucket: bucket.bucket_type,
398 alias: key.to_string(),
399 }));
400 }
401 }
402 }
403 }
404 Ok(None)
405 }
406
407 fn try_find_calendar_bucket(
411 column: &str,
412 bucket: TemporalBucket,
413 metadata: &FactTableMetadata,
414 ) -> Option<GroupBySelection> {
415 for calendar_dim in &metadata.calendar_dimensions {
416 if calendar_dim.source_column == column {
417 if let Some((gran, cal_bucket)) = Self::find_calendar_bucket(calendar_dim, bucket) {
418 return Some(GroupBySelection::CalendarDimension {
419 source_column: calendar_dim.source_column.clone(),
420 calendar_column: gran.column_name.clone(),
421 json_key: cal_bucket.json_key.clone(),
422 bucket: cal_bucket.bucket_type,
423 alias: column.to_string(),
424 });
425 }
426 }
427 }
428 None
429 }
430
431 fn find_calendar_bucket(
436 calendar_dim: &crate::compiler::fact_table::CalendarDimension,
437 bucket: TemporalBucket,
438 ) -> Option<(
439 &crate::compiler::fact_table::CalendarGranularity,
440 &crate::compiler::fact_table::CalendarBucket,
441 )> {
442 for granularity in &calendar_dim.granularities {
443 for cal_bucket in &granularity.buckets {
444 if cal_bucket.bucket_type == bucket {
445 return Some((granularity, cal_bucket));
446 }
447 }
448 }
449 None
450 }
451
452 fn parse_aggregates(
454 agg_array: &Value,
455 metadata: &FactTableMetadata,
456 ) -> Result<Vec<AggregateSelection>> {
457 let mut aggregates = Vec::new();
458
459 if let Some(arr) = agg_array.as_array() {
460 for item in arr {
461 if let Some(obj) = item.as_object() {
462 for (agg_name, _value) in obj {
464 aggregates.push(Self::parse_aggregate_selection(agg_name, metadata)?);
465 }
466 }
467 }
468 }
469
470 Ok(aggregates)
471 }
472
473 fn parse_aggregate_selection(
475 agg_name: &str,
476 metadata: &FactTableMetadata,
477 ) -> Result<AggregateSelection> {
478 if agg_name == "count" {
480 return Ok(AggregateSelection::Count {
481 alias: "count".to_string(),
482 });
483 }
484
485 if agg_name == "count_distinct" {
488 let default_field = Self::extract_dimension_paths(metadata)
490 .first()
491 .cloned()
492 .unwrap_or_else(|| "id".to_string());
493 return Ok(AggregateSelection::CountDistinct {
494 field: default_field,
495 alias: "count_distinct".to_string(),
496 });
497 }
498
499 if let Some(stripped) = agg_name.strip_suffix("_count_distinct") {
501 let dimension_paths = Self::extract_dimension_paths(metadata);
503 if dimension_paths.iter().any(|p| p == stripped) {
504 return Ok(AggregateSelection::CountDistinct {
505 field: stripped.to_string(),
506 alias: agg_name.to_string(),
507 });
508 }
509 if metadata.measures.iter().any(|m| m.name == stripped) {
511 return Ok(AggregateSelection::CountDistinct {
512 field: stripped.to_string(),
513 alias: agg_name.to_string(),
514 });
515 }
516 return Err(FraiseQLError::Validation {
518 message: format!(
519 "COUNT DISTINCT field '{}' not found in dimensions or measures. Available: {:?}",
520 stripped, dimension_paths
521 ),
522 path: None,
523 });
524 }
525
526 for dimension_path in Self::extract_dimension_paths(metadata) {
529 if let Some(stripped) = agg_name.strip_suffix("_bool_and") {
530 if stripped == dimension_path {
531 return Ok(AggregateSelection::BoolAggregate {
532 field: dimension_path,
533 function: crate::compiler::aggregate_types::BoolAggregateFunction::And,
534 alias: agg_name.to_string(),
535 });
536 }
537 }
538 if let Some(stripped) = agg_name.strip_suffix("_bool_or") {
539 if stripped == dimension_path {
540 return Ok(AggregateSelection::BoolAggregate {
541 field: dimension_path,
542 function: crate::compiler::aggregate_types::BoolAggregateFunction::Or,
543 alias: agg_name.to_string(),
544 });
545 }
546 }
547 }
548
549 for measure in &metadata.measures {
551 for func in &[
552 ("_sum", AggregateFunction::Sum),
553 ("_avg", AggregateFunction::Avg),
554 ("_min", AggregateFunction::Min),
555 ("_max", AggregateFunction::Max),
556 ("_stddev", AggregateFunction::Stddev),
557 ("_variance", AggregateFunction::Variance),
558 ("_array_agg", AggregateFunction::ArrayAgg),
560 ("_json_agg", AggregateFunction::JsonAgg),
561 ("_jsonb_agg", AggregateFunction::JsonbAgg),
562 ("_string_agg", AggregateFunction::StringAgg),
563 ] {
564 let expected_name = format!("{}{}", measure.name, func.0);
565 if agg_name == expected_name {
566 return Ok(AggregateSelection::MeasureAggregate {
567 measure: measure.name.clone(),
568 function: func.1,
569 alias: agg_name.to_string(),
570 });
571 }
572 }
573 }
574
575 for jsonb_path in metadata.native_measures.keys() {
578 for func in &[
579 ("_sum", AggregateFunction::Sum),
580 ("_avg", AggregateFunction::Avg),
581 ("_min", AggregateFunction::Min),
582 ("_max", AggregateFunction::Max),
583 ("_stddev", AggregateFunction::Stddev),
584 ("_variance", AggregateFunction::Variance),
585 ("_array_agg", AggregateFunction::ArrayAgg),
586 ("_json_agg", AggregateFunction::JsonAgg),
587 ("_jsonb_agg", AggregateFunction::JsonbAgg),
588 ("_string_agg", AggregateFunction::StringAgg),
589 ] {
590 let expected_name = format!("{}{}", jsonb_path, func.0);
591 if agg_name == expected_name {
592 return Ok(AggregateSelection::MeasureAggregate {
595 measure: jsonb_path.clone(),
596 function: func.1,
597 alias: agg_name.to_string(),
598 });
599 }
600 }
601 }
602
603 for dimension_path in Self::extract_dimension_paths(metadata) {
606 for func in &[
607 ("_array_agg", AggregateFunction::ArrayAgg),
608 ("_json_agg", AggregateFunction::JsonAgg),
609 ("_jsonb_agg", AggregateFunction::JsonbAgg),
610 ("_string_agg", AggregateFunction::StringAgg),
611 ] {
612 let expected_name = format!("{}{}", dimension_path, func.0);
613 if agg_name == expected_name {
614 return Ok(AggregateSelection::MeasureAggregate {
616 measure: dimension_path,
617 function: func.1,
618 alias: agg_name.to_string(),
619 });
620 }
621 }
622 }
623
624 Err(FraiseQLError::Validation {
625 message: format!("Unknown aggregate selection: {agg_name}"),
626 path: None,
627 })
628 }
629
630 fn extract_dimension_paths(metadata: &FactTableMetadata) -> Vec<String> {
632 let mut paths = Vec::new();
633
634 for dim_path in &metadata.dimensions.paths {
636 paths.push(dim_path.name.clone());
637 }
638
639 for filter in &metadata.denormalized_filters {
641 paths.push(filter.name.clone());
642 }
643
644 paths
645 }
646
647 fn parse_having(
649 having_obj: &Value,
650 aggregates: &[AggregateSelection],
651 _metadata: &FactTableMetadata,
652 ) -> Result<Vec<HavingCondition>> {
653 let mut conditions = Vec::new();
654
655 if let Some(obj) = having_obj.as_object() {
656 for (key, value) in obj {
657 if let Some((agg_name, operator)) = Self::parse_having_key(key) {
659 let aggregate = aggregates
661 .iter()
662 .find(|a| a.alias() == agg_name)
663 .ok_or_else(|| FraiseQLError::Validation {
664 message: format!(
665 "HAVING condition references non-selected aggregate: {agg_name}"
666 ),
667 path: None,
668 })?
669 .clone();
670
671 conditions.push(HavingCondition {
672 aggregate,
673 operator,
674 value: value.clone(),
675 });
676 }
677 }
678 }
679
680 Ok(conditions)
681 }
682
683 fn parse_having_key(key: &str) -> Option<(&str, HavingOperator)> {
685 for (suffix, op) in &[
686 ("_gt", HavingOperator::Gt),
687 ("_gte", HavingOperator::Gte),
688 ("_lt", HavingOperator::Lt),
689 ("_lte", HavingOperator::Lte),
690 ("_eq", HavingOperator::Eq),
691 ("_neq", HavingOperator::Neq),
692 ] {
693 if let Some(agg_name) = key.strip_suffix(suffix) {
694 return Some((agg_name, *op));
695 }
696 }
697 None
698 }
699
700 fn parse_order_by(order_obj: &Value) -> Result<Vec<OrderByClause>> {
702 let mut clauses = Vec::new();
703
704 if let Some(obj) = order_obj.as_object() {
705 for (field, value) in obj {
706 #[allow(clippy::match_same_arms)]
707 let direction = match value.as_str() {
710 Some("ASC" | "asc") => OrderDirection::Asc,
711 Some("DESC" | "desc") => OrderDirection::Desc,
712 _ => OrderDirection::Asc, };
714
715 clauses.push(OrderByClause::new(field.clone(), direction));
716 }
717 }
718
719 Ok(clauses)
720 }
721}
722
723fn validate_dimension_key(key: &str) -> Result<()> {
739 let mut chars = key.chars();
740 let first_ok = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
741 let rest_ok = chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
742 if first_ok && rest_ok {
743 Ok(())
744 } else {
745 Err(FraiseQLError::Validation {
746 message: format!(
747 "groupBy dimension '{key}' contains invalid characters; \
748 only [_A-Za-z][_0-9A-Za-z]* is allowed"
749 ),
750 path: None,
751 })
752 }
753}
754
755#[cfg(test)]
756mod alias_injection_tests {
757 #![allow(clippy::unwrap_used)]
758 use std::collections::HashMap;
759
760 use super::*;
761
762 fn empty_metadata() -> FactTableMetadata {
763 serde_json::from_value(serde_json::json!({
767 "table_name": "tf_sales",
768 "measures": [],
769 "dimensions": { "name": "dimensions", "paths": [] },
770 "denormalized_filters": []
771 }))
772 .expect("valid empty fact-table metadata")
773 }
774
775 #[test]
776 fn validate_dimension_key_accepts_plain_identifiers() {
777 assert!(validate_dimension_key("category").is_ok());
778 assert!(validate_dimension_key("occurred_at_day").is_ok());
779 assert!(validate_dimension_key("_private").is_ok());
780 }
781
782 #[test]
783 fn validate_dimension_key_rejects_injection_shapes() {
784 assert!(validate_dimension_key("a, (SELECT 1) AS x").is_err());
785 assert!(validate_dimension_key("x; DROP TABLE t").is_err());
786 assert!(validate_dimension_key("1leading_digit").is_err());
787 assert!(validate_dimension_key("dotted.path").is_err());
788 assert!(validate_dimension_key("").is_err());
789 }
790
791 #[test]
792 fn parse_rejects_hostile_group_by_key_with_empty_allowlist() {
793 let query = serde_json::json!({
797 "table": "tf_sales",
798 "groupBy": {
799 "a, (SELECT string_agg(rolpassword, ',') FROM pg_authid) AS leak": true
800 }
801 });
802 let err = AggregateQueryParser::parse(&query, &empty_metadata(), &HashMap::new())
803 .expect_err("hostile groupBy key must be rejected at parse time");
804 assert!(matches!(err, FraiseQLError::Validation { .. }), "got {err:?}");
805 }
806
807 #[test]
808 fn parse_accepts_plain_group_by_key() {
809 let query = serde_json::json!({
810 "table": "tf_sales",
811 "groupBy": { "category": true }
812 });
813 let req = AggregateQueryParser::parse(&query, &empty_metadata(), &HashMap::new())
814 .expect("plain groupBy key must parse");
815 assert_eq!(req.group_by.len(), 1);
816 }
817}