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(query_json: &Value, metadata: &FactTableMetadata) -> Result<AggregationRequest> {
95 let table_name = query_json
97 .get("table")
98 .and_then(|v| v.as_str())
99 .ok_or_else(|| FraiseQLError::Validation {
100 message: "Missing 'table' field in aggregate query".to_string(),
101 path: None,
102 })?
103 .to_string();
104
105 let where_clause = if let Some(where_obj) = query_json.get("where") {
107 Some(Self::parse_where_clause(where_obj)?)
108 } else {
109 None
110 };
111
112 let group_by = if let Some(group_by_obj) = query_json.get("groupBy") {
114 Self::parse_group_by(group_by_obj, metadata)?
115 } else {
116 vec![]
117 };
118
119 let aggregates = if let Some(agg_array) = query_json.get("aggregates") {
121 Self::parse_aggregates(agg_array, metadata)?
122 } else {
123 vec![]
124 };
125
126 let having = if let Some(having_obj) = query_json.get("having") {
128 Self::parse_having(having_obj, &aggregates, metadata)?
129 } else {
130 vec![]
131 };
132
133 let order_by = if let Some(order_obj) = query_json.get("orderBy") {
135 Self::parse_order_by(order_obj)?
136 } else {
137 vec![]
138 };
139
140 let limit = query_json
142 .get("limit")
143 .and_then(|v| v.as_u64())
144 .map(|n| u32::try_from(n).unwrap_or(u32::MAX));
145
146 let offset = query_json
148 .get("offset")
149 .and_then(|v| v.as_u64())
150 .map(|n| u32::try_from(n).unwrap_or(u32::MAX));
151
152 Ok(AggregationRequest {
153 table_name,
154 where_clause,
155 group_by,
156 aggregates,
157 having,
158 order_by,
159 limit,
160 offset,
161 })
162 }
163
164 fn parse_where_clause(where_obj: &Value) -> Result<WhereClause> {
170 let Some(obj) = where_obj.as_object() else {
171 return Ok(WhereClause::And(vec![]));
172 };
173
174 let mut conditions = Vec::new();
175
176 for (key, value) in obj {
177 if let Some((field, operator_str)) = Self::parse_where_field_and_operator(key)? {
180 let operator = WhereOperator::from_str(operator_str)?;
181
182 conditions.push(WhereClause::Field {
183 path: vec![field.to_string()],
184 operator,
185 value: value.clone(),
186 });
187 }
188 }
189
190 Ok(WhereClause::And(conditions))
191 }
192
193 fn parse_where_field_and_operator(key: &str) -> Result<Option<(&str, &str)>> {
196 if let Some(last_underscore) = key.rfind('_') {
198 let field = &key[..last_underscore];
199 let operator = &key[last_underscore + 1..];
200
201 match WhereOperator::from_str(operator) {
203 Ok(_) => Ok(Some((field, operator))),
204 Err(_) => {
205 Ok(None)
208 },
209 }
210 } else {
211 Ok(None)
213 }
214 }
215
216 fn parse_group_by(
223 group_by_obj: &Value,
224 metadata: &FactTableMetadata,
225 ) -> Result<Vec<GroupBySelection>> {
226 let mut selections = Vec::new();
227
228 if let Some(obj) = group_by_obj.as_object() {
229 for (key, value) in obj {
230 if value.as_bool() == Some(true) {
231 if let Some(calendar_sel) = Self::try_parse_calendar_bucket(key, metadata)? {
234 selections.push(calendar_sel);
235 } else if let Some(bucket_sel) = Self::parse_temporal_bucket(key, metadata)? {
236 selections.push(bucket_sel);
238 } else {
239 selections.push(GroupBySelection::Dimension {
241 path: key.clone(),
242 alias: key.clone(),
243 });
244 }
245 } else if let Some(bucket_str) = value.as_str() {
246 let bucket = TemporalBucket::from_str(bucket_str)?;
248
249 if let Some(calendar_sel) =
251 Self::try_find_calendar_bucket(key, bucket, metadata)
252 {
253 selections.push(calendar_sel);
254 } else {
255 let column_exists =
258 metadata.denormalized_filters.iter().any(|f| f.name == *key);
259
260 if !column_exists {
261 return Err(FraiseQLError::Validation {
262 message: format!(
263 "Temporal bucketing column '{}' not found in denormalized filters",
264 key
265 ),
266 path: None,
267 });
268 }
269
270 selections.push(GroupBySelection::TemporalBucket {
271 column: key.clone(),
272 bucket,
273 alias: key.clone(),
274 });
275 }
276 }
277 }
278 }
279
280 Ok(selections)
281 }
282
283 fn parse_temporal_bucket(
285 key: &str,
286 metadata: &FactTableMetadata,
287 ) -> Result<Option<GroupBySelection>> {
288 for filter_col in &metadata.denormalized_filters {
290 for bucket in &[
291 ("_second", TemporalBucket::Second),
292 ("_minute", TemporalBucket::Minute),
293 ("_hour", TemporalBucket::Hour),
294 ("_day", TemporalBucket::Day),
295 ("_week", TemporalBucket::Week),
296 ("_month", TemporalBucket::Month),
297 ("_quarter", TemporalBucket::Quarter),
298 ("_year", TemporalBucket::Year),
299 ] {
300 let expected_key = format!("{}{}", filter_col.name, bucket.0);
301 if key == expected_key {
302 return Ok(Some(GroupBySelection::TemporalBucket {
303 column: filter_col.name.clone(),
304 bucket: bucket.1,
305 alias: key.to_string(),
306 }));
307 }
308 }
309 }
310
311 Ok(None)
312 }
313
314 fn try_parse_calendar_bucket(
319 key: &str,
320 metadata: &FactTableMetadata,
321 ) -> Result<Option<GroupBySelection>> {
322 for calendar_dim in &metadata.calendar_dimensions {
323 for (suffix, bucket_type) in &[
325 ("_second", TemporalBucket::Second),
326 ("_minute", TemporalBucket::Minute),
327 ("_hour", TemporalBucket::Hour),
328 ("_day", TemporalBucket::Day),
329 ("_week", TemporalBucket::Week),
330 ("_month", TemporalBucket::Month),
331 ("_quarter", TemporalBucket::Quarter),
332 ("_year", TemporalBucket::Year),
333 ] {
334 let expected_key = format!("{}{}", calendar_dim.source_column, suffix);
335 if key == expected_key {
336 if let Some((gran, bucket)) =
338 Self::find_calendar_bucket(calendar_dim, *bucket_type)
339 {
340 return Ok(Some(GroupBySelection::CalendarDimension {
341 source_column: calendar_dim.source_column.clone(),
342 calendar_column: gran.column_name.clone(),
343 json_key: bucket.json_key.clone(),
344 bucket: bucket.bucket_type,
345 alias: key.to_string(),
346 }));
347 }
348 }
349 }
350 }
351 Ok(None)
352 }
353
354 fn try_find_calendar_bucket(
358 column: &str,
359 bucket: TemporalBucket,
360 metadata: &FactTableMetadata,
361 ) -> Option<GroupBySelection> {
362 for calendar_dim in &metadata.calendar_dimensions {
363 if calendar_dim.source_column == column {
364 if let Some((gran, cal_bucket)) = Self::find_calendar_bucket(calendar_dim, bucket) {
365 return Some(GroupBySelection::CalendarDimension {
366 source_column: calendar_dim.source_column.clone(),
367 calendar_column: gran.column_name.clone(),
368 json_key: cal_bucket.json_key.clone(),
369 bucket: cal_bucket.bucket_type,
370 alias: column.to_string(),
371 });
372 }
373 }
374 }
375 None
376 }
377
378 fn find_calendar_bucket(
383 calendar_dim: &crate::compiler::fact_table::CalendarDimension,
384 bucket: TemporalBucket,
385 ) -> Option<(
386 &crate::compiler::fact_table::CalendarGranularity,
387 &crate::compiler::fact_table::CalendarBucket,
388 )> {
389 for granularity in &calendar_dim.granularities {
390 for cal_bucket in &granularity.buckets {
391 if cal_bucket.bucket_type == bucket {
392 return Some((granularity, cal_bucket));
393 }
394 }
395 }
396 None
397 }
398
399 fn parse_aggregates(
401 agg_array: &Value,
402 metadata: &FactTableMetadata,
403 ) -> Result<Vec<AggregateSelection>> {
404 let mut aggregates = Vec::new();
405
406 if let Some(arr) = agg_array.as_array() {
407 for item in arr {
408 if let Some(obj) = item.as_object() {
409 for (agg_name, _value) in obj {
411 aggregates.push(Self::parse_aggregate_selection(agg_name, metadata)?);
412 }
413 }
414 }
415 }
416
417 Ok(aggregates)
418 }
419
420 fn parse_aggregate_selection(
422 agg_name: &str,
423 metadata: &FactTableMetadata,
424 ) -> Result<AggregateSelection> {
425 if agg_name == "count" {
427 return Ok(AggregateSelection::Count {
428 alias: "count".to_string(),
429 });
430 }
431
432 if agg_name == "count_distinct" {
435 let default_field = Self::extract_dimension_paths(metadata)
437 .first()
438 .cloned()
439 .unwrap_or_else(|| "id".to_string());
440 return Ok(AggregateSelection::CountDistinct {
441 field: default_field,
442 alias: "count_distinct".to_string(),
443 });
444 }
445
446 if let Some(stripped) = agg_name.strip_suffix("_count_distinct") {
448 let dimension_paths = Self::extract_dimension_paths(metadata);
450 if dimension_paths.iter().any(|p| p == stripped) {
451 return Ok(AggregateSelection::CountDistinct {
452 field: stripped.to_string(),
453 alias: agg_name.to_string(),
454 });
455 }
456 if metadata.measures.iter().any(|m| m.name == stripped) {
458 return Ok(AggregateSelection::CountDistinct {
459 field: stripped.to_string(),
460 alias: agg_name.to_string(),
461 });
462 }
463 return Err(FraiseQLError::Validation {
465 message: format!(
466 "COUNT DISTINCT field '{}' not found in dimensions or measures. Available: {:?}",
467 stripped, dimension_paths
468 ),
469 path: None,
470 });
471 }
472
473 for dimension_path in Self::extract_dimension_paths(metadata) {
476 if let Some(stripped) = agg_name.strip_suffix("_bool_and") {
477 if stripped == dimension_path {
478 return Ok(AggregateSelection::BoolAggregate {
479 field: dimension_path,
480 function: crate::compiler::aggregate_types::BoolAggregateFunction::And,
481 alias: agg_name.to_string(),
482 });
483 }
484 }
485 if let Some(stripped) = agg_name.strip_suffix("_bool_or") {
486 if stripped == dimension_path {
487 return Ok(AggregateSelection::BoolAggregate {
488 field: dimension_path,
489 function: crate::compiler::aggregate_types::BoolAggregateFunction::Or,
490 alias: agg_name.to_string(),
491 });
492 }
493 }
494 }
495
496 for measure in &metadata.measures {
498 for func in &[
499 ("_sum", AggregateFunction::Sum),
500 ("_avg", AggregateFunction::Avg),
501 ("_min", AggregateFunction::Min),
502 ("_max", AggregateFunction::Max),
503 ("_stddev", AggregateFunction::Stddev),
504 ("_variance", AggregateFunction::Variance),
505 ("_array_agg", AggregateFunction::ArrayAgg),
507 ("_json_agg", AggregateFunction::JsonAgg),
508 ("_jsonb_agg", AggregateFunction::JsonbAgg),
509 ("_string_agg", AggregateFunction::StringAgg),
510 ] {
511 let expected_name = format!("{}{}", measure.name, func.0);
512 if agg_name == expected_name {
513 return Ok(AggregateSelection::MeasureAggregate {
514 measure: measure.name.clone(),
515 function: func.1,
516 alias: agg_name.to_string(),
517 });
518 }
519 }
520 }
521
522 for dimension_path in Self::extract_dimension_paths(metadata) {
525 for func in &[
526 ("_array_agg", AggregateFunction::ArrayAgg),
527 ("_json_agg", AggregateFunction::JsonAgg),
528 ("_jsonb_agg", AggregateFunction::JsonbAgg),
529 ("_string_agg", AggregateFunction::StringAgg),
530 ] {
531 let expected_name = format!("{}{}", dimension_path, func.0);
532 if agg_name == expected_name {
533 return Ok(AggregateSelection::MeasureAggregate {
535 measure: dimension_path,
536 function: func.1,
537 alias: agg_name.to_string(),
538 });
539 }
540 }
541 }
542
543 Err(FraiseQLError::Validation {
544 message: format!("Unknown aggregate selection: {agg_name}"),
545 path: None,
546 })
547 }
548
549 fn extract_dimension_paths(metadata: &FactTableMetadata) -> Vec<String> {
551 let mut paths = Vec::new();
552
553 for dim_path in &metadata.dimensions.paths {
555 paths.push(dim_path.name.clone());
556 }
557
558 for filter in &metadata.denormalized_filters {
560 paths.push(filter.name.clone());
561 }
562
563 paths
564 }
565
566 fn parse_having(
568 having_obj: &Value,
569 aggregates: &[AggregateSelection],
570 _metadata: &FactTableMetadata,
571 ) -> Result<Vec<HavingCondition>> {
572 let mut conditions = Vec::new();
573
574 if let Some(obj) = having_obj.as_object() {
575 for (key, value) in obj {
576 if let Some((agg_name, operator)) = Self::parse_having_key(key) {
578 let aggregate = aggregates
580 .iter()
581 .find(|a| a.alias() == agg_name)
582 .ok_or_else(|| FraiseQLError::Validation {
583 message: format!(
584 "HAVING condition references non-selected aggregate: {agg_name}"
585 ),
586 path: None,
587 })?
588 .clone();
589
590 conditions.push(HavingCondition {
591 aggregate,
592 operator,
593 value: value.clone(),
594 });
595 }
596 }
597 }
598
599 Ok(conditions)
600 }
601
602 fn parse_having_key(key: &str) -> Option<(&str, HavingOperator)> {
604 for (suffix, op) in &[
605 ("_gt", HavingOperator::Gt),
606 ("_gte", HavingOperator::Gte),
607 ("_lt", HavingOperator::Lt),
608 ("_lte", HavingOperator::Lte),
609 ("_eq", HavingOperator::Eq),
610 ("_neq", HavingOperator::Neq),
611 ] {
612 if let Some(agg_name) = key.strip_suffix(suffix) {
613 return Some((agg_name, *op));
614 }
615 }
616 None
617 }
618
619 fn parse_order_by(order_obj: &Value) -> Result<Vec<OrderByClause>> {
621 let mut clauses = Vec::new();
622
623 if let Some(obj) = order_obj.as_object() {
624 for (field, value) in obj {
625 #[allow(clippy::match_same_arms)]
626 let direction = match value.as_str() {
629 Some("ASC" | "asc") => OrderDirection::Asc,
630 Some("DESC" | "desc") => OrderDirection::Desc,
631 _ => OrderDirection::Asc, };
633
634 clauses.push(OrderByClause {
635 field: field.clone(),
636 direction,
637 });
638 }
639 }
640
641 Ok(clauses)
642 }
643}
644
645#[cfg(test)]
646mod tests {
647 #![allow(clippy::unwrap_used)] use serde_json::json;
650
651 use super::*;
652 use crate::compiler::fact_table::{DimensionColumn, FilterColumn, MeasureColumn, SqlType};
653
654 fn create_test_metadata() -> FactTableMetadata {
655 use crate::compiler::fact_table::DimensionPath;
656
657 FactTableMetadata {
658 table_name: "tf_sales".to_string(),
659 measures: vec![
660 MeasureColumn {
661 name: "revenue".to_string(),
662 sql_type: SqlType::Decimal,
663 nullable: false,
664 },
665 MeasureColumn {
666 name: "quantity".to_string(),
667 sql_type: SqlType::Int,
668 nullable: false,
669 },
670 ],
671 dimensions: DimensionColumn {
672 name: "dimensions".to_string(),
673 paths: vec![
674 DimensionPath {
675 name: "category".to_string(),
676 json_path: "data->>'category'".to_string(),
677 data_type: "text".to_string(),
678 },
679 DimensionPath {
680 name: "product".to_string(),
681 json_path: "data->>'product'".to_string(),
682 data_type: "text".to_string(),
683 },
684 ],
685 },
686 denormalized_filters: vec![FilterColumn {
687 name: "occurred_at".to_string(),
688 sql_type: SqlType::Timestamp,
689 indexed: true,
690 }],
691 calendar_dimensions: vec![],
692 }
693 }
694
695 #[test]
696 fn test_parse_simple_count() {
697 let metadata = create_test_metadata();
698 let query = json!({
699 "table": "tf_sales",
700 "aggregates": [
701 {"count": {}}
702 ]
703 });
704
705 let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
706
707 assert_eq!(request.table_name, "tf_sales");
708 assert_eq!(request.aggregates.len(), 1);
709 assert_eq!(request.aggregates[0].alias(), "count");
710 }
711
712 #[test]
713 fn test_parse_group_by_dimension() {
714 let metadata = create_test_metadata();
715 let query = json!({
716 "table": "tf_sales",
717 "groupBy": {
718 "category": true
719 },
720 "aggregates": [
721 {"count": {}}
722 ]
723 });
724
725 let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
726
727 assert_eq!(request.group_by.len(), 1);
728 match &request.group_by[0] {
729 GroupBySelection::Dimension { path, alias } => {
730 assert_eq!(path, "category");
731 assert_eq!(alias, "category");
732 },
733 _ => panic!("Expected Dimension selection"),
734 }
735 }
736
737 #[test]
738 fn test_parse_temporal_bucket() {
739 let metadata = create_test_metadata();
740 let query = json!({
741 "table": "tf_sales",
742 "groupBy": {
743 "occurred_at_day": true
744 },
745 "aggregates": [
746 {"count": {}}
747 ]
748 });
749
750 let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
751
752 assert_eq!(request.group_by.len(), 1);
753 match &request.group_by[0] {
754 GroupBySelection::TemporalBucket {
755 column,
756 bucket,
757 alias,
758 } => {
759 assert_eq!(column, "occurred_at");
760 assert_eq!(*bucket, TemporalBucket::Day);
761 assert_eq!(alias, "occurred_at_day");
762 },
763 _ => panic!("Expected TemporalBucket selection"),
764 }
765 }
766
767 #[test]
768 fn test_parse_multiple_aggregates() {
769 let metadata = create_test_metadata();
770 let query = json!({
771 "table": "tf_sales",
772 "aggregates": [
773 {"count": {}},
774 {"revenue_sum": {}},
775 {"revenue_avg": {}},
776 {"quantity_max": {}}
777 ]
778 });
779
780 let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
781
782 assert_eq!(request.aggregates.len(), 4);
783 assert_eq!(request.aggregates[0].alias(), "count");
784 assert_eq!(request.aggregates[1].alias(), "revenue_sum");
785 assert_eq!(request.aggregates[2].alias(), "revenue_avg");
786 assert_eq!(request.aggregates[3].alias(), "quantity_max");
787 }
788
789 #[test]
790 fn test_parse_having_condition() {
791 let metadata = create_test_metadata();
792 let query = json!({
793 "table": "tf_sales",
794 "aggregates": [
795 {"revenue_sum": {}}
796 ],
797 "having": {
798 "revenue_sum_gt": 1000
799 }
800 });
801
802 let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
803
804 assert_eq!(request.having.len(), 1);
805 assert_eq!(request.having[0].operator, HavingOperator::Gt);
806 assert_eq!(request.having[0].value, json!(1000));
807 }
808
809 #[test]
810 fn test_parse_order_by() {
811 let metadata = create_test_metadata();
812 let query = json!({
813 "table": "tf_sales",
814 "aggregates": [
815 {"revenue_sum": {}}
816 ],
817 "orderBy": {
818 "revenue_sum": "DESC"
819 }
820 });
821
822 let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
823
824 assert_eq!(request.order_by.len(), 1);
825 assert_eq!(request.order_by[0].field, "revenue_sum");
826 assert_eq!(request.order_by[0].direction, OrderDirection::Desc);
827 }
828
829 #[test]
830 fn test_parse_limit_offset() {
831 let metadata = create_test_metadata();
832 let query = json!({
833 "table": "tf_sales",
834 "aggregates": [
835 {"count": {}}
836 ],
837 "limit": 10,
838 "offset": 5
839 });
840
841 let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
842
843 assert_eq!(request.limit, Some(10));
844 assert_eq!(request.offset, Some(5));
845 }
846
847 #[test]
848 fn test_parse_complex_query() {
849 let metadata = create_test_metadata();
850 let query = json!({
851 "table": "tf_sales",
852 "groupBy": {
853 "category": true,
854 "occurred_at_month": true
855 },
856 "aggregates": [
857 {"count": {}},
858 {"revenue_sum": {}},
859 {"revenue_avg": {}},
860 {"quantity_sum": {}}
861 ],
862 "having": {
863 "revenue_sum_gt": 1000,
864 "count_gte": 5
865 },
866 "orderBy": {
867 "revenue_sum": "DESC",
868 "count": "ASC"
869 },
870 "limit": 20
871 });
872
873 let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
874
875 assert_eq!(request.table_name, "tf_sales");
876 assert_eq!(request.group_by.len(), 2);
877 assert_eq!(request.aggregates.len(), 4);
878 assert_eq!(request.having.len(), 2);
879 assert_eq!(request.order_by.len(), 2);
880 assert_eq!(request.limit, Some(20));
881 }
882
883 #[test]
884 fn test_parse_count_distinct_default() {
885 let metadata = create_test_metadata();
886 let query = json!({
887 "table": "tf_sales",
888 "aggregates": [
889 {"count_distinct": {}}
890 ]
891 });
892
893 let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
894
895 assert_eq!(request.aggregates.len(), 1);
896 match &request.aggregates[0] {
897 AggregateSelection::CountDistinct { field, alias } => {
898 assert_eq!(field, "category");
900 assert_eq!(alias, "count_distinct");
901 },
902 _ => panic!("Expected CountDistinct selection"),
903 }
904 }
905
906 #[test]
907 fn test_parse_count_distinct_with_field() {
908 let metadata = create_test_metadata();
909 let query = json!({
910 "table": "tf_sales",
911 "aggregates": [
912 {"product_count_distinct": {}}
913 ]
914 });
915
916 let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
917
918 assert_eq!(request.aggregates.len(), 1);
919 match &request.aggregates[0] {
920 AggregateSelection::CountDistinct { field, alias } => {
921 assert_eq!(field, "product");
922 assert_eq!(alias, "product_count_distinct");
923 },
924 _ => panic!("Expected CountDistinct selection"),
925 }
926 }
927
928 #[test]
929 fn test_parse_count_distinct_on_measure() {
930 let metadata = create_test_metadata();
931 let query = json!({
932 "table": "tf_sales",
933 "aggregates": [
934 {"revenue_count_distinct": {}}
935 ]
936 });
937
938 let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
939
940 assert_eq!(request.aggregates.len(), 1);
941 match &request.aggregates[0] {
942 AggregateSelection::CountDistinct { field, alias } => {
943 assert_eq!(field, "revenue");
944 assert_eq!(alias, "revenue_count_distinct");
945 },
946 _ => panic!("Expected CountDistinct selection"),
947 }
948 }
949
950 #[test]
951 fn test_parse_count_distinct_invalid_field() {
952 let metadata = create_test_metadata();
953 let query = json!({
954 "table": "tf_sales",
955 "aggregates": [
956 {"nonexistent_count_distinct": {}}
957 ]
958 });
959
960 let result = AggregateQueryParser::parse(&query, &metadata);
961
962 let err = result.expect_err("expected Err for invalid count_distinct field");
963 match err {
964 FraiseQLError::Validation { message, .. } => {
965 assert!(
966 message.contains("COUNT DISTINCT field 'nonexistent' not found"),
967 "unexpected message: {message}"
968 );
969 },
970 other => panic!("expected Validation error, got: {other:?}"),
971 }
972 }
973
974 #[test]
975 fn test_parse_multiple_count_distinct() {
976 let metadata = create_test_metadata();
977 let query = json!({
978 "table": "tf_sales",
979 "aggregates": [
980 {"count": {}},
981 {"category_count_distinct": {}},
982 {"product_count_distinct": {}},
983 {"revenue_sum": {}}
984 ]
985 });
986
987 let request = AggregateQueryParser::parse(&query, &metadata).unwrap();
988
989 assert_eq!(request.aggregates.len(), 4);
990 assert_eq!(request.aggregates[0].alias(), "count");
991 assert_eq!(request.aggregates[1].alias(), "category_count_distinct");
992 assert_eq!(request.aggregates[2].alias(), "product_count_distinct");
993 assert_eq!(request.aggregates[3].alias(), "revenue_sum");
994 }
995}