1use serde::{Deserialize, Serialize};
53
54pub use crate::types::{OrderByClause, OrderDirection};
55use crate::{
56 compiler::{
57 aggregate_types::{AggregateFunction, HavingOperator, TemporalBucket},
58 fact_table::FactTableMetadata,
59 },
60 db::where_clause::WhereClause,
61 error::{FraiseQLError, Result},
62};
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct AggregationRequest {
67 pub table_name: String,
69 pub where_clause: Option<WhereClause>,
71 pub group_by: Vec<GroupBySelection>,
73 pub aggregates: Vec<AggregateSelection>,
75 pub having: Vec<HavingCondition>,
77 pub order_by: Vec<OrderByClause>,
79 pub limit: Option<u32>,
81 pub offset: Option<u32>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[non_exhaustive]
88pub enum GroupBySelection {
89 Dimension {
91 path: String,
93 alias: String,
95 },
96 TemporalBucket {
98 column: String,
100 bucket: TemporalBucket,
102 alias: String,
104 },
105 CalendarDimension {
107 source_column: String,
109 calendar_column: String,
111 json_key: String,
113 bucket: TemporalBucket,
115 alias: String,
117 },
118 NativeDimension {
123 column: String,
125 pg_cast: String,
127 },
128}
129
130impl GroupBySelection {
131 #[must_use]
133 pub fn alias(&self) -> &str {
134 match self {
135 Self::Dimension { alias, .. }
136 | Self::TemporalBucket { alias, .. }
137 | Self::CalendarDimension { alias, .. } => alias,
138 Self::NativeDimension { column, .. } => column,
140 }
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[non_exhaustive]
147pub enum AggregateSelection {
148 Count {
150 alias: String,
152 },
153 CountDistinct {
155 field: String,
157 alias: String,
159 },
160 MeasureAggregate {
162 measure: String,
164 function: AggregateFunction,
166 alias: String,
168 },
169 BoolAggregate {
171 field: String,
173 function: crate::compiler::aggregate_types::BoolAggregateFunction,
175 alias: String,
177 },
178}
179
180impl AggregateSelection {
181 #[must_use]
183 pub fn alias(&self) -> &str {
184 match self {
185 Self::Count { alias }
186 | Self::CountDistinct { alias, .. }
187 | Self::MeasureAggregate { alias, .. }
188 | Self::BoolAggregate { alias, .. } => alias,
189 }
190 }
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195pub struct HavingCondition {
196 pub aggregate: AggregateSelection,
198 pub operator: HavingOperator,
200 pub value: serde_json::Value,
202}
203
204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct AggregationPlan {
207 pub metadata: FactTableMetadata,
209 pub request: AggregationRequest,
211 pub group_by_expressions: Vec<GroupByExpression>,
213 pub aggregate_expressions: Vec<AggregateExpression>,
215 pub having_conditions: Vec<ValidatedHavingCondition>,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221#[non_exhaustive]
222pub enum GroupByExpression {
223 JsonbPath {
225 jsonb_column: String,
227 path: String,
229 alias: String,
231 },
232 TemporalBucket {
234 column: String,
236 bucket: TemporalBucket,
238 alias: String,
240 },
241 CalendarPath {
243 calendar_column: String,
245 json_key: String,
247 alias: String,
249 },
250 NativeColumn {
254 column: String,
256 pg_cast: String,
258 alias: String,
260 },
261}
262
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265#[non_exhaustive]
266pub enum AggregateExpression {
267 Count {
269 alias: String,
271 },
272 CountDistinct {
274 column: String,
276 alias: String,
278 },
279 MeasureAggregate {
281 column: String,
283 function: AggregateFunction,
285 alias: String,
287 },
288 AdvancedAggregate {
290 column: String,
292 function: AggregateFunction,
294 alias: String,
296 delimiter: Option<String>,
298 order_by: Option<Vec<OrderByClause>>,
300 },
301 BoolAggregate {
303 column: String,
305 function: crate::compiler::aggregate_types::BoolAggregateFunction,
307 alias: String,
309 },
310}
311
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314pub struct ValidatedHavingCondition {
315 pub aggregate: AggregateExpression,
317 pub operator: HavingOperator,
319 pub value: serde_json::Value,
321}
322
323impl AggregationPlan {
324 #[must_use]
331 pub fn native_aliases(&self) -> std::collections::HashSet<&str> {
332 self.group_by_expressions
333 .iter()
334 .filter_map(|e| {
335 if let GroupByExpression::NativeColumn { alias, .. } = e {
336 Some(alias.as_str())
337 } else {
338 None
339 }
340 })
341 .collect()
342 }
343}
344
345pub struct AggregationPlanner;
347
348impl AggregationPlanner {
349 pub fn plan(
363 request: AggregationRequest,
364 metadata: FactTableMetadata,
365 ) -> Result<AggregationPlan> {
366 let group_by_expressions = Self::validate_group_by(&request.group_by, &metadata)?;
368
369 let aggregate_expressions = Self::validate_aggregates(&request.aggregates, &metadata)?;
371
372 let having_conditions = Self::validate_having(&request.having, &aggregate_expressions)?;
374
375 Ok(AggregationPlan {
376 metadata,
377 request,
378 group_by_expressions,
379 aggregate_expressions,
380 having_conditions,
381 })
382 }
383
384 fn validate_group_by(
386 selections: &[GroupBySelection],
387 metadata: &FactTableMetadata,
388 ) -> Result<Vec<GroupByExpression>> {
389 let mut expressions = Vec::new();
390
391 for selection in selections {
392 match selection {
393 GroupBySelection::Dimension { path, alias } => {
394 let known_paths = &metadata.dimensions.paths;
399 if !known_paths.is_empty() && !known_paths.iter().any(|p| p.name == *path) {
400 return Err(FraiseQLError::Validation {
401 message: format!(
402 "Dimension '{}' not found in fact table '{}'",
403 path, metadata.table_name
404 ),
405 path: None,
406 });
407 }
408 expressions.push(GroupByExpression::JsonbPath {
409 jsonb_column: metadata.dimensions.name.clone(),
410 path: path.clone(),
411 alias: alias.clone(),
412 });
413 },
414 GroupBySelection::TemporalBucket {
415 column,
416 bucket,
417 alias,
418 } => {
419 let filter_exists =
421 metadata.denormalized_filters.iter().any(|f| f.name == *column);
422
423 if !filter_exists {
424 return Err(FraiseQLError::Validation {
425 message: format!(
426 "Column '{}' not found in fact table '{}'",
427 column, metadata.table_name
428 ),
429 path: None,
430 });
431 }
432
433 expressions.push(GroupByExpression::TemporalBucket {
434 column: column.clone(),
435 bucket: *bucket,
436 alias: alias.clone(),
437 });
438 },
439 GroupBySelection::CalendarDimension {
440 calendar_column,
441 json_key,
442 alias,
443 ..
444 } => {
445 expressions.push(GroupByExpression::CalendarPath {
447 calendar_column: calendar_column.clone(),
448 json_key: json_key.clone(),
449 alias: alias.clone(),
450 });
451 },
452 GroupBySelection::NativeDimension { column, pg_cast } => {
453 expressions.push(GroupByExpression::NativeColumn {
455 alias: column.clone(),
456 column: column.clone(),
457 pg_cast: pg_cast.clone(),
458 });
459 },
460 }
461 }
462
463 Ok(expressions)
464 }
465
466 fn validate_aggregates(
468 selections: &[AggregateSelection],
469 metadata: &FactTableMetadata,
470 ) -> Result<Vec<AggregateExpression>> {
471 let mut expressions = Vec::new();
472
473 for selection in selections {
474 match selection {
475 AggregateSelection::Count { alias } => {
476 expressions.push(AggregateExpression::Count {
477 alias: alias.clone(),
478 });
479 },
480 AggregateSelection::CountDistinct { field, alias } => {
481 let measure_exists = metadata.measures.iter().any(|m| m.name == *field);
483
484 if !measure_exists {
485 return Err(FraiseQLError::Validation {
486 message: format!(
487 "Measure '{}' not found in fact table '{}'",
488 field, metadata.table_name
489 ),
490 path: None,
491 });
492 }
493
494 expressions.push(AggregateExpression::CountDistinct {
495 column: field.clone(),
496 alias: alias.clone(),
497 });
498 },
499 AggregateSelection::MeasureAggregate {
500 measure,
501 function,
502 alias,
503 } => {
504 let measure_exists = metadata.measures.iter().any(|m| m.name == *measure);
506 let is_dimension = metadata.dimensions.paths.iter().any(|p| p.name == *measure);
507 let is_filter =
508 metadata.denormalized_filters.iter().any(|f| f.name == *measure);
509
510 if !measure_exists && !is_dimension && !is_filter {
511 return Err(FraiseQLError::Validation {
512 message: format!(
513 "Measure or field '{}' not found in fact table '{}'",
514 measure, metadata.table_name
515 ),
516 path: None,
517 });
518 }
519
520 if matches!(
522 function,
523 AggregateFunction::ArrayAgg
524 | AggregateFunction::JsonAgg
525 | AggregateFunction::JsonbAgg
526 | AggregateFunction::StringAgg
527 ) {
528 expressions.push(AggregateExpression::AdvancedAggregate {
529 column: measure.clone(),
530 function: *function,
531 alias: alias.clone(),
532 delimiter: if *function == AggregateFunction::StringAgg {
533 Some(", ".to_string())
534 } else {
535 None
536 },
537 order_by: None,
538 });
539 } else {
540 expressions.push(AggregateExpression::MeasureAggregate {
541 column: measure.clone(),
542 function: *function,
543 alias: alias.clone(),
544 });
545 }
546 },
547 AggregateSelection::BoolAggregate {
548 field,
549 function,
550 alias,
551 } => {
552 let field_exists = metadata.dimensions.paths.iter().any(|p| p.name == *field)
554 || metadata.denormalized_filters.iter().any(|f| f.name == *field);
555
556 if !field_exists {
557 return Err(FraiseQLError::Validation {
558 message: format!(
559 "Boolean field '{}' not found in fact table '{}'",
560 field, metadata.table_name
561 ),
562 path: None,
563 });
564 }
565
566 expressions.push(AggregateExpression::BoolAggregate {
567 column: field.clone(),
568 function: *function,
569 alias: alias.clone(),
570 });
571 },
572 }
573 }
574
575 Ok(expressions)
576 }
577
578 fn validate_having(
580 conditions: &[HavingCondition],
581 _aggregate_expressions: &[AggregateExpression],
582 ) -> Result<Vec<ValidatedHavingCondition>> {
583 let mut validated = Vec::new();
584
585 for condition in conditions {
586 let aggregate_expr = match &condition.aggregate {
588 AggregateSelection::Count { alias } => AggregateExpression::Count {
589 alias: alias.clone(),
590 },
591 AggregateSelection::CountDistinct { field, alias } => {
592 AggregateExpression::CountDistinct {
593 column: field.clone(),
594 alias: alias.clone(),
595 }
596 },
597 AggregateSelection::MeasureAggregate {
598 measure,
599 function,
600 alias,
601 } => {
602 if matches!(
604 function,
605 AggregateFunction::ArrayAgg
606 | AggregateFunction::JsonAgg
607 | AggregateFunction::JsonbAgg
608 | AggregateFunction::StringAgg
609 ) {
610 AggregateExpression::AdvancedAggregate {
611 column: measure.clone(),
612 function: *function,
613 alias: alias.clone(),
614 delimiter: if *function == AggregateFunction::StringAgg {
615 Some(", ".to_string())
616 } else {
617 None
618 },
619 order_by: None,
620 }
621 } else {
622 AggregateExpression::MeasureAggregate {
623 column: measure.clone(),
624 function: *function,
625 alias: alias.clone(),
626 }
627 }
628 },
629 AggregateSelection::BoolAggregate {
630 field,
631 function,
632 alias,
633 } => AggregateExpression::BoolAggregate {
634 column: field.clone(),
635 function: *function,
636 alias: alias.clone(),
637 },
638 };
639
640 validated.push(ValidatedHavingCondition {
644 aggregate: aggregate_expr,
645 operator: condition.operator,
646 value: condition.value.clone(),
647 });
648 }
649
650 Ok(validated)
651 }
652}
653
654#[cfg(test)]
655mod tests {
656 #![allow(clippy::unwrap_used)] use super::*;
659 use crate::compiler::fact_table::{DimensionColumn, FilterColumn, MeasureColumn, SqlType};
660
661 fn create_test_metadata() -> FactTableMetadata {
662 FactTableMetadata {
663 table_name: "tf_sales".to_string(),
664 measures: vec![
665 MeasureColumn {
666 name: "revenue".to_string(),
667 sql_type: SqlType::Decimal,
668 nullable: false,
669 },
670 MeasureColumn {
671 name: "quantity".to_string(),
672 sql_type: SqlType::Int,
673 nullable: false,
674 },
675 ],
676 dimensions: DimensionColumn {
677 name: "dimensions".to_string(),
678 paths: vec![],
679 },
680 denormalized_filters: vec![
681 FilterColumn {
682 name: "customer_id".to_string(),
683 sql_type: SqlType::Uuid,
684 indexed: true,
685 },
686 FilterColumn {
687 name: "occurred_at".to_string(),
688 sql_type: SqlType::Timestamp,
689 indexed: true,
690 },
691 ],
692 calendar_dimensions: vec![],
693 }
694 }
695
696 #[test]
697 fn test_plan_simple_aggregation() {
698 let metadata = create_test_metadata();
699 let request = AggregationRequest {
700 table_name: "tf_sales".to_string(),
701 where_clause: None,
702 group_by: vec![],
703 aggregates: vec![
704 AggregateSelection::Count {
705 alias: "count".to_string(),
706 },
707 AggregateSelection::MeasureAggregate {
708 measure: "revenue".to_string(),
709 function: AggregateFunction::Sum,
710 alias: "revenue_sum".to_string(),
711 },
712 ],
713 having: vec![],
714 order_by: vec![],
715 limit: None,
716 offset: None,
717 };
718
719 let plan = AggregationPlanner::plan(request, metadata).unwrap();
720
721 assert_eq!(plan.aggregate_expressions.len(), 2);
722 assert!(matches!(plan.aggregate_expressions[0], AggregateExpression::Count { .. }));
723 assert!(matches!(
724 plan.aggregate_expressions[1],
725 AggregateExpression::MeasureAggregate { .. }
726 ));
727 }
728
729 #[test]
730 fn test_plan_with_group_by() {
731 let metadata = create_test_metadata();
732 let request = AggregationRequest {
733 table_name: "tf_sales".to_string(),
734 where_clause: None,
735 group_by: vec![
736 GroupBySelection::Dimension {
737 path: "category".to_string(),
738 alias: "category".to_string(),
739 },
740 GroupBySelection::TemporalBucket {
741 column: "occurred_at".to_string(),
742 bucket: TemporalBucket::Day,
743 alias: "occurred_at_day".to_string(),
744 },
745 ],
746 aggregates: vec![AggregateSelection::Count {
747 alias: "count".to_string(),
748 }],
749 having: vec![],
750 order_by: vec![],
751 limit: None,
752 offset: None,
753 };
754
755 let plan = AggregationPlanner::plan(request, metadata).unwrap();
756
757 assert_eq!(plan.group_by_expressions.len(), 2);
758 assert!(matches!(plan.group_by_expressions[0], GroupByExpression::JsonbPath { .. }));
759 assert!(matches!(plan.group_by_expressions[1], GroupByExpression::TemporalBucket { .. }));
760 }
761
762 #[test]
763 fn test_plan_with_having() {
764 let metadata = create_test_metadata();
765 let request = AggregationRequest {
766 table_name: "tf_sales".to_string(),
767 where_clause: None,
768 group_by: vec![GroupBySelection::Dimension {
769 path: "category".to_string(),
770 alias: "category".to_string(),
771 }],
772 aggregates: vec![AggregateSelection::MeasureAggregate {
773 measure: "revenue".to_string(),
774 function: AggregateFunction::Sum,
775 alias: "revenue_sum".to_string(),
776 }],
777 having: vec![HavingCondition {
778 aggregate: AggregateSelection::MeasureAggregate {
779 measure: "revenue".to_string(),
780 function: AggregateFunction::Sum,
781 alias: "revenue_sum".to_string(),
782 },
783 operator: HavingOperator::Gt,
784 value: serde_json::json!(1000),
785 }],
786 order_by: vec![],
787 limit: None,
788 offset: None,
789 };
790
791 let plan = AggregationPlanner::plan(request, metadata).unwrap();
792
793 assert_eq!(plan.having_conditions.len(), 1);
794 assert_eq!(plan.having_conditions[0].operator, HavingOperator::Gt);
795 }
796
797 #[test]
798 fn test_validate_invalid_measure() {
799 let metadata = create_test_metadata();
800 let request = AggregationRequest {
801 table_name: "tf_sales".to_string(),
802 where_clause: None,
803 group_by: vec![],
804 aggregates: vec![AggregateSelection::MeasureAggregate {
805 measure: "nonexistent".to_string(),
806 function: AggregateFunction::Sum,
807 alias: "nonexistent_sum".to_string(),
808 }],
809 having: vec![],
810 order_by: vec![],
811 limit: None,
812 offset: None,
813 };
814
815 let result = AggregationPlanner::plan(request, metadata);
816 assert!(
817 matches!(&result, Err(FraiseQLError::Validation { message, .. }) if message.contains("not found")),
818 "expected Validation error about measure not found, got: {result:?}"
819 );
820 }
821
822 #[test]
823 fn test_validate_invalid_temporal_column() {
824 let metadata = create_test_metadata();
825 let request = AggregationRequest {
826 table_name: "tf_sales".to_string(),
827 where_clause: None,
828 group_by: vec![GroupBySelection::TemporalBucket {
829 column: "nonexistent".to_string(),
830 bucket: TemporalBucket::Day,
831 alias: "day".to_string(),
832 }],
833 aggregates: vec![AggregateSelection::Count {
834 alias: "count".to_string(),
835 }],
836 having: vec![],
837 order_by: vec![],
838 limit: None,
839 offset: None,
840 };
841
842 let result = AggregationPlanner::plan(request, metadata);
843 assert!(
844 matches!(&result, Err(FraiseQLError::Validation { message, .. }) if message.contains("not found")),
845 "expected Validation error about column not found, got: {result:?}"
846 );
847 }
848
849 #[test]
850 fn test_order_by_from_graphql_json_object_format() {
851 let json = serde_json::json!({ "name": "DESC", "created_at": "ASC" });
852 let clauses = OrderByClause::from_graphql_json(&json).unwrap();
853 assert_eq!(clauses.len(), 2);
854 assert!(clauses.iter().any(|c| c.field == "name" && c.direction == OrderDirection::Desc));
855 assert!(
856 clauses
857 .iter()
858 .any(|c| c.field == "created_at" && c.direction == OrderDirection::Asc)
859 );
860 }
861
862 #[test]
863 fn test_order_by_from_graphql_json_array_format() {
864 let json = serde_json::json!([
865 { "field": "name", "direction": "DESC" },
866 { "field": "age" }
867 ]);
868 let clauses = OrderByClause::from_graphql_json(&json).unwrap();
869 assert_eq!(clauses.len(), 2);
870 assert_eq!(clauses[0].field, "name");
871 assert_eq!(clauses[0].direction, OrderDirection::Desc);
872 assert_eq!(clauses[1].field, "age");
873 assert_eq!(clauses[1].direction, OrderDirection::Asc); }
875
876 #[test]
877 fn test_order_by_from_graphql_json_invalid_direction() {
878 let json = serde_json::json!({ "name": "INVALID" });
879 let result = OrderByClause::from_graphql_json(&json);
880 assert!(
881 matches!(result, Err(FraiseQLError::Validation { .. })),
882 "expected Validation error for invalid direction, got: {result:?}"
883 );
884 }
885
886 #[test]
887 fn test_order_by_rejects_sql_injection_in_field() {
888 let json = serde_json::json!({ "x' || pg_sleep(5) || '": "ASC" });
889 let result = OrderByClause::from_graphql_json(&json);
890 assert!(
891 matches!(result, Err(FraiseQLError::Validation { .. })),
892 "expected Validation error for SQL injection in field, got: {result:?}"
893 );
894 }
895
896 #[test]
897 fn test_order_by_rejects_field_with_dot() {
898 let json = serde_json::json!({ "a.b": "ASC" });
899 let result = OrderByClause::from_graphql_json(&json);
900 assert!(
901 matches!(result, Err(FraiseQLError::Validation { .. })),
902 "expected Validation error for dot in field name, got: {result:?}"
903 );
904 }
905
906 #[test]
907 fn test_order_by_rejects_empty_field() {
908 let json = serde_json::json!({ "": "ASC" });
909 let result = OrderByClause::from_graphql_json(&json);
910 assert!(
911 matches!(result, Err(FraiseQLError::Validation { .. })),
912 "expected Validation error for empty field name, got: {result:?}"
913 );
914 }
915
916 #[test]
917 fn test_order_by_accepts_valid_identifiers() {
918 let json = serde_json::json!({ "created_at": "DESC", "_score": "ASC" });
919 let clauses = OrderByClause::from_graphql_json(&json).unwrap();
920 assert_eq!(clauses.len(), 2);
921 }
922
923 #[test]
924 fn test_order_by_array_rejects_injection_field() {
925 let json = serde_json::json!([{ "field": "x' OR '1'='1", "direction": "ASC" }]);
926 let result = OrderByClause::from_graphql_json(&json);
927 assert!(
928 matches!(result, Err(FraiseQLError::Validation { .. })),
929 "expected Validation error for SQL injection in array field, got: {result:?}"
930 );
931 }
932
933 fn create_metadata_with_paths() -> FactTableMetadata {
935 use crate::compiler::fact_table::DimensionPath;
936 let mut meta = create_test_metadata();
937 meta.dimensions.paths = vec![DimensionPath {
938 name: "category".to_string(),
939 json_path: "dimensions->>'category'".to_string(),
940 data_type: "text".to_string(),
941 }];
942 meta
943 }
944
945 #[test]
946 fn test_dimension_allowlist_accepts_declared_path() {
947 let metadata = create_metadata_with_paths();
948 let request = AggregationRequest {
949 table_name: "tf_sales".to_string(),
950 where_clause: None,
951 group_by: vec![GroupBySelection::Dimension {
952 path: "category".to_string(),
953 alias: "category".to_string(),
954 }],
955 aggregates: vec![AggregateSelection::Count {
956 alias: "count".to_string(),
957 }],
958 having: vec![],
959 order_by: vec![],
960 limit: None,
961 offset: None,
962 };
963 AggregationPlanner::plan(request, metadata)
964 .unwrap_or_else(|e| panic!("declared dimension path should be accepted: {e}"));
965 }
966
967 #[test]
968 fn test_dimension_allowlist_rejects_unknown_path() {
969 let metadata = create_metadata_with_paths();
970 let request = AggregationRequest {
971 table_name: "tf_sales".to_string(),
972 where_clause: None,
973 group_by: vec![GroupBySelection::Dimension {
974 path: "undeclared_path".to_string(),
975 alias: "x".to_string(),
976 }],
977 aggregates: vec![AggregateSelection::Count {
978 alias: "count".to_string(),
979 }],
980 having: vec![],
981 order_by: vec![],
982 limit: None,
983 offset: None,
984 };
985 let result = AggregationPlanner::plan(request, metadata);
986 assert!(
987 matches!(&result, Err(FraiseQLError::Validation { message, .. }) if message.contains("not found")),
988 "expected Validation error about undeclared dimension path, got: {result:?}"
989 );
990 }
991
992 #[test]
993 fn test_dimension_allowlist_accepts_any_path_when_paths_empty() {
994 let metadata = create_test_metadata(); let request = AggregationRequest {
998 table_name: "tf_sales".to_string(),
999 where_clause: None,
1000 group_by: vec![GroupBySelection::Dimension {
1001 path: "any_undeclared_path".to_string(),
1002 alias: "x".to_string(),
1003 }],
1004 aggregates: vec![AggregateSelection::Count {
1005 alias: "count".to_string(),
1006 }],
1007 having: vec![],
1008 order_by: vec![],
1009 limit: None,
1010 offset: None,
1011 };
1012 AggregationPlanner::plan(request, metadata)
1013 .unwrap_or_else(|e| panic!("any path should be accepted when paths empty: {e}"));
1014 }
1015}