1use std::collections::{HashMap, HashSet};
29use std::path::Path;
30use std::sync::Arc;
31
32use datafusion::arrow::record_batch::RecordBatch;
33use datafusion::functions_aggregate::count::count_all;
34use datafusion::functions_aggregate::expr_fn::{
35 array_agg, avg, avg_distinct, count, count_distinct, max, min, sum, sum_distinct,
36};
37use datafusion::logical_expr::{
38 Expr as DfExpr, ExprFunctionExt, ExprSchemable, Extension, JoinType, LogicalPlanBuilder,
39 SortExpr, logical_plan::LogicalTableSource,
40};
41
42use graphforge_core::{GfError, OntologyMode, TypeId};
43use graphforge_ir::plan::PATTERN_COMPREHENSION_VALUE_ALIAS;
44use graphforge_ir::{
45 AggExpr, AggFunc, CreatePattern, Direction, ExprArena, ExprId, GraphOp, GraphPlan, IrExpr,
46 IrLiteral, ProjectItem, RemovePropItem, SetPropItem, SortOrder, VarId,
47};
48use graphforge_ontology::OntologyHandle;
49use graphforge_plan::{
50 DeleteTarget, GraphCreateNode, GraphDeleteNode, GraphRemoveNode, GraphSetNode,
51 OptionalMatchNode, RemoveTarget, ResolvedEdgeSpec, ResolvedNodeSpec, SetTarget, UnwindNode,
52 VarLenExpandNode,
53};
54use graphforge_storage::{
55 EXPLORATORY_EDGE_SCHEMA, GraphCatalog, TOPOLOGY_NODES_SCHEMA, TYPED_EDGE_SCHEMA,
56};
57
58use crate::LogicalPlan;
59use crate::expr::{ExprLowerer, LoweringError, VarMap, list_index_range};
60
61const INPUT_ORDER_COLUMN_PREFIX: &str = "__gf_input_order_";
62
63trait MapUnsupportedExpr<T> {
67 fn map_unsupported_expr(self) -> Result<T, LoweringError>;
68}
69
70impl<T, E: std::fmt::Display> MapUnsupportedExpr<T> for Result<T, E> {
71 fn map_unsupported_expr(self) -> Result<T, LoweringError> {
72 self.map_err(|error| LoweringError::UnsupportedExpr(error.to_string()))
73 }
74}
75
76pub struct GraphPlanLowerer<'a> {
88 catalog: Option<&'a GraphCatalog>,
92 type_id_to_rel_name: HashMap<u32, String>,
95 type_id_to_entity_name: HashMap<u32, String>,
98 write_target: Option<(&'a Path, OntologyMode)>,
103 read_dir: Option<(&'a Path, OntologyMode)>,
109 node_shapes: std::sync::RwLock<HashMap<u32, crate::expr::NodeShape>>,
113 inference_rules: HashMap<u32, Vec<(String, String)>>,
119 relational_fixed_hop_reference: bool,
123}
124
125impl<'a> GraphPlanLowerer<'a> {
126 #[must_use]
135 pub fn new(catalog: Option<&'a GraphCatalog>, ontology: Option<&'a OntologyHandle>) -> Self {
136 Self::build(catalog, ontology, None, None)
137 }
138
139 #[must_use]
146 pub fn new_for_writes(
147 catalog: Option<&'a GraphCatalog>,
148 ontology: Option<&'a OntologyHandle>,
149 dir: &'a Path,
150 mode: OntologyMode,
151 ) -> Self {
152 Self::build(catalog, ontology, Some((dir, mode)), Some((dir, mode)))
153 }
154
155 #[must_use]
162 pub fn new_with_dir(
163 catalog: Option<&'a GraphCatalog>,
164 ontology: Option<&'a OntologyHandle>,
165 dir: &'a Path,
166 mode: OntologyMode,
167 ) -> Self {
168 Self::build(catalog, ontology, None, Some((dir, mode)))
169 }
170
171 fn build(
172 catalog: Option<&'a GraphCatalog>,
173 ontology: Option<&'a OntologyHandle>,
174 write_target: Option<(&'a Path, OntologyMode)>,
175 read_dir: Option<(&'a Path, OntologyMode)>,
176 ) -> Self {
177 let mut type_id_to_rel_name = build_type_id_map(ontology);
182 if let Some(c) = catalog {
183 for (id, name) in c.rel_names() {
184 let plan_id =
185 graphforge_ir::runtime_relation_type_id(graphforge_ir::RuntimeTypeId(*id));
186 type_id_to_rel_name.insert(plan_id.0, name.clone());
187 }
188 }
189 Self {
190 catalog,
191 type_id_to_rel_name,
192 type_id_to_entity_name: build_entity_id_map(ontology),
198 write_target,
199 read_dir,
200 node_shapes: std::sync::RwLock::new(HashMap::new()),
201 inference_rules: build_inference_rules(ontology),
202 relational_fixed_hop_reference: false,
203 }
204 }
205
206 #[doc(hidden)]
210 #[must_use]
211 pub fn with_relational_fixed_hop_reference(mut self) -> Self {
212 self.relational_fixed_hop_reference = true;
213 self
214 }
215
216 fn read_dir(&self) -> Option<&'a Path> {
220 self.read_dir.map(|(d, _)| d)
221 }
222
223 fn read_mode(&self) -> OntologyMode {
227 self.read_dir.map_or(OntologyMode::Exploratory, |(_, m)| m)
228 }
229
230 fn prop_names(&self) -> HashMap<u32, String> {
237 match self.catalog {
238 Some(c) => c.prop_names().clone(),
239 None => HashMap::new(),
240 }
241 }
242
243 fn expr_lowerer<'b>(&self, arena: &'b ExprArena, var_map: &'b VarMap) -> ExprLowerer<'b> {
246 let mut node_label_names = self.type_id_to_entity_name.clone();
252 if let Some(c) = self.catalog {
253 for (id, name) in c.label_names() {
254 node_label_names.entry(*id).or_insert_with(|| name.clone());
255 }
256 }
257 let mut lowerer = ExprLowerer::with_prop_names_and_nodes(
258 arena,
259 var_map,
260 self.prop_names(),
261 self.node_shapes
262 .read()
263 .expect("node shapes lock poisoned")
264 .clone(),
265 node_label_names,
266 self.read_dir().is_some(),
272 );
273 if let Some(dir) = self.read_dir() {
275 lowerer = lowerer.with_read_target(dir.to_path_buf());
276 }
277 lowerer
278 }
279
280 fn build_node_shapes(&self, ops: &[GraphOp]) -> HashMap<u32, crate::expr::NodeShape> {
285 let mut shapes = HashMap::new();
286 self.collect_node_shapes(ops, &mut shapes);
287 shapes
288 }
289
290 fn collect_node_shapes(
291 &self,
292 ops: &[GraphOp],
293 shapes: &mut HashMap<u32, crate::expr::NodeShape>,
294 ) {
295 for op in ops {
296 match op {
297 GraphOp::NodeScan { var, ty } => {
298 let prop_names = self.node_prop_cols(*ty);
299 shapes.insert(var.0, crate::expr::NodeShape { prop_names });
300 }
301 GraphOp::Optional { child }
302 | GraphOp::Exists { child, .. }
303 | GraphOp::PatternComprehension { child, .. }
304 | GraphOp::ListElementPatternComprehension { child, .. } => {
305 self.collect_node_shapes(&child.ops, shapes);
306 }
307 GraphOp::Union { inputs, .. } => {
308 for input in inputs {
309 self.collect_node_shapes(&input.ops, shapes);
310 }
311 }
312 _ => {}
313 }
314 }
315 }
316
317 fn prop_table_stem(&self, ty: Option<TypeId>) -> Option<String> {
328 match ty {
329 Some(type_id) => Some(
330 self.type_id_to_entity_name
331 .get(&type_id.0)
332 .cloned()
333 .unwrap_or_else(|| "_untyped".to_owned()),
334 ),
335 None if matches!(self.read_mode(), OntologyMode::Exploratory) => {
336 Some("_untyped".to_owned())
337 }
338 None => None,
339 }
340 }
341
342 fn node_prop_cols(&self, ty: Option<TypeId>) -> Vec<String> {
347 let Some(dir) = self.read_dir() else {
348 return Vec::new();
349 };
350 let Some(stem) = self.prop_table_stem(ty) else {
351 return Vec::new();
352 };
353 let prop_table = graphforge_storage::PropertyTable::open_discovered(dir, &stem);
354 prop_table
355 .schema_ref()
356 .fields()
357 .iter()
358 .map(|f| f.name().clone())
359 .filter(|n| TOPOLOGY_NODES_SCHEMA.field_with_name(n).is_err())
360 .collect()
361 }
362
363 fn join_node_properties(
390 &self,
391 var: VarId,
392 ty: Option<TypeId>,
393 scan: LogicalPlan,
394 ) -> Result<LogicalPlan, LoweringError> {
395 use datafusion::common::Column;
396 use datafusion::logical_expr::col;
397
398 let Some(dir) = self.read_dir() else {
399 return Ok(scan); };
401 let Some(stem) = self.prop_table_stem(ty) else {
402 return Ok(scan); };
404
405 let prop_table = graphforge_storage::PropertyTable::open_discovered(dir, &stem);
406 let prop_schema = prop_table.schema_ref();
407 let node_alias = var_alias(var);
408
409 let existing: std::collections::HashSet<String> = scan
416 .schema()
417 .iter()
418 .filter(|(q, _)| q.is_some_and(|t| t.table() == node_alias.as_str()))
419 .map(|(_, f)| f.name().clone())
420 .collect();
421
422 let prop_cols: Vec<String> = prop_schema
431 .fields()
432 .iter()
433 .map(|f| f.name().clone())
434 .filter(|n| TOPOLOGY_NODES_SCHEMA.field_with_name(n).is_err())
435 .filter(|n| !existing.contains(n))
436 .collect();
437 if prop_cols.is_empty() {
438 return Ok(scan);
439 }
440
441 let prop_alias = format!("{node_alias}__props");
442 let prop_src = datafusion::datasource::provider_as_source(Arc::new(prop_table));
443 let prop_scan = LogicalPlanBuilder::scan(prop_alias.clone(), prop_src, None)
444 .and_then(LogicalPlanBuilder::build)
445 .map_unsupported_expr()?;
446
447 let input_cols: Vec<Column> = scan
453 .schema()
454 .iter()
455 .map(|(qualifier, field)| Column::new(qualifier.cloned(), field.name()))
456 .collect();
457
458 let join_pred =
460 col(format!("{node_alias}.node_uuid")).eq(col(format!("{prop_alias}.node_uuid")));
461 let joined = LogicalPlanBuilder::from(scan)
462 .join_on(prop_scan, JoinType::Left, vec![join_pred])
463 .and_then(LogicalPlanBuilder::build)
464 .map_unsupported_expr()?;
465
466 let mut projections: Vec<DfExpr> = input_cols.into_iter().map(DfExpr::Column).collect();
469 for name in &prop_cols {
470 projections.push(
471 crate::expr::qualified_col(&prop_alias, name)
472 .alias_qualified(Some(node_alias.as_str()), name.as_str()),
473 );
474 }
475
476 LogicalPlanBuilder::from(joined)
477 .project(projections)
478 .and_then(LogicalPlanBuilder::build)
479 .map_unsupported_expr()
480 }
481
482 pub fn lower_plan(&self, plan: &GraphPlan) -> Result<LogicalPlan, GfError> {
495 *self.node_shapes.write().expect("node shapes lock poisoned") =
498 self.build_node_shapes(&plan.ops);
499 let mut var_map = VarMap::new();
500 self.lower_pipeline(&plan.ops, &plan.exprs, &mut var_map)
501 .map_err(|e| GfError::Plan(e.to_string()))
502 }
503
504 pub fn lower_prefix(
524 &self,
525 ops: &[GraphOp],
526 exprs: &ExprArena,
527 var_map: &mut VarMap,
528 ) -> Result<LogicalPlan, GfError> {
529 *self.node_shapes.write().expect("node shapes lock poisoned") = self.build_node_shapes(ops);
530 self.lower_pipeline(ops, exprs, var_map)
531 .map_err(|e| GfError::Plan(e.to_string()))
532 }
533
534 pub fn lower_terminal_suffix(
538 &self,
539 ops: &[GraphOp],
540 exprs: &ExprArena,
541 var_map: &mut VarMap,
542 input_schema: datafusion::common::DFSchemaRef,
543 ) -> Result<LogicalPlan, GfError> {
544 let input = LogicalPlan::EmptyRelation(datafusion::logical_expr::EmptyRelation {
545 produce_one_row: true,
546 schema: input_schema,
547 });
548 self.lower_pipeline_from(ops, exprs, var_map, input, None)
549 .map_err(|e| GfError::Plan(e.to_string()))
550 }
551
552 pub fn lower_write_segment(
555 &self,
556 ops: &[GraphOp],
557 exprs: &ExprArena,
558 var_map: &mut VarMap,
559 input_schema: datafusion::common::DFSchemaRef,
560 pending_nodes: &RecordBatch,
561 ) -> Result<LogicalPlan, GfError> {
562 let input = LogicalPlan::EmptyRelation(datafusion::logical_expr::EmptyRelation {
563 produce_one_row: true,
564 schema: input_schema,
565 });
566 self.lower_pipeline_from(ops, exprs, var_map, input, Some(pending_nodes))
567 .map_err(|e| GfError::Plan(e.to_string()))
568 }
569
570 pub fn resolve_create_pattern(
580 &self,
581 pattern: &CreatePattern,
582 exprs: &ExprArena,
583 var_map: &VarMap,
584 input_schema: &datafusion::common::DFSchemaRef,
585 ) -> Result<(Vec<ResolvedNodeSpec>, Vec<ResolvedEdgeSpec>), GfError> {
586 self.create_specs(pattern, exprs, var_map, Some(input_schema))
587 .map_err(|e| GfError::Plan(e.to_string()))
588 }
589
590 pub fn register_created_node_shapes(&self, nodes: &[ResolvedNodeSpec]) {
593 let mut shapes = self.node_shapes.write().expect("node shapes lock poisoned");
594 for spec in nodes.iter().filter(|n| !n.is_reference) {
595 let prop_names = spec
596 .properties
597 .iter()
598 .map(|(k, _)| k.clone())
599 .chain(spec.computed_properties.iter().map(|(k, _)| k.clone()))
600 .collect();
601 shapes.insert(spec.var, crate::expr::NodeShape { prop_names });
602 }
603 }
604
605 pub fn register_node_property_shape(&self, var: VarId, name: &str) {
607 let mut shapes = self.node_shapes.write().expect("node shapes lock poisoned");
608 let shape = shapes
609 .entry(var.0)
610 .or_insert_with(|| crate::expr::NodeShape { prop_names: vec![] });
611 if !shape.prop_names.iter().any(|existing| existing == name) {
612 shape.prop_names.push(name.to_owned());
613 shape.prop_names.sort();
614 }
615 }
616
617 pub fn lower_value_expr(
623 &self,
624 exprs: &ExprArena,
625 var_map: &VarMap,
626 id: graphforge_ir::ExprId,
627 ) -> Result<DfExpr, GfError> {
628 self.expr_lowerer(exprs, var_map)
629 .lower(id)
630 .map_err(|e| GfError::Plan(e.to_string()))
631 }
632
633 pub fn lower_value_expr_with_input(
641 &self,
642 exprs: &ExprArena,
643 var_map: &VarMap,
644 id: graphforge_ir::ExprId,
645 input_schema: datafusion::common::DFSchemaRef,
646 ) -> Result<DfExpr, GfError> {
647 self.expr_lowerer(exprs, var_map)
648 .with_input_schema(input_schema)
649 .lower(id)
650 .map_err(|e| GfError::Plan(e.to_string()))
651 }
652
653 #[must_use]
656 pub fn entity_name_map(&self) -> HashMap<u32, String> {
657 self.type_id_to_entity_name.clone()
658 }
659
660 fn lower_pipeline(
668 &self,
669 ops: &[GraphOp],
670 exprs: &ExprArena,
671 var_map: &mut VarMap,
672 ) -> Result<LogicalPlan, LoweringError> {
673 let ordered: Vec<&GraphOp> = ops.iter().collect();
674
675 let produce_one_row = !ordered.first().copied().is_some_and(is_source_op);
684 let mut current = LogicalPlanBuilder::empty(produce_one_row)
685 .build()
686 .map_unsupported_expr()?;
687
688 for (i, op) in ordered.iter().enumerate() {
689 let create_feeds_read = matches!(op, GraphOp::Create { .. }) && i + 1 < ordered.len();
693 current = self.lower_op_with_arena(
694 op,
695 current,
696 exprs,
697 var_map,
698 create_feeds_read,
699 false,
700 None,
701 )?;
702 }
703 Ok(current)
704 }
705
706 fn lower_pipeline_from(
708 &self,
709 ops: &[GraphOp],
710 exprs: &ExprArena,
711 var_map: &mut VarMap,
712 mut current: LogicalPlan,
713 pending_nodes: Option<&RecordBatch>,
714 ) -> Result<LogicalPlan, LoweringError> {
715 let ordered: Vec<&GraphOp> = ops.iter().collect();
716 for (i, op) in ordered.iter().enumerate() {
717 let create_feeds_read = matches!(op, GraphOp::Create { .. }) && i + 1 < ordered.len();
718 current = self.lower_op_with_arena(
719 op,
720 current,
721 exprs,
722 var_map,
723 create_feeds_read,
724 true,
725 pending_nodes,
726 )?;
727 }
728 Ok(current)
729 }
730
731 pub fn lower_op(
750 &self,
751 op: &GraphOp,
752 input: LogicalPlan,
753 exprs: &ExprArena,
754 _var_map: &VarMap,
755 expr_lowerer: &ExprLowerer<'_>,
756 ) -> Result<LogicalPlan, LoweringError> {
757 lower_relational_op(op, input, exprs, expr_lowerer)
758 }
759
760 #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
765 fn lower_op_with_arena(
766 &self,
767 op: &GraphOp,
768 input: LogicalPlan,
769 exprs: &ExprArena,
770 var_map: &mut VarMap,
771 create_feeds_read: bool,
772 preserve_empty_input: bool,
773 pending_nodes: Option<&RecordBatch>,
774 ) -> Result<LogicalPlan, LoweringError> {
775 match op {
777 GraphOp::NodeScan { var, ty } => {
778 if let Some(alias) = var_map.get(*var) {
794 let qualifier = datafusion::common::TableReference::bare(alias);
795 let input = if input
796 .schema()
797 .index_of_column_by_name(Some(&qualifier), "node_uuid")
798 .is_some()
799 && input
800 .schema()
801 .index_of_column_by_name(Some(&qualifier), "node_id")
802 .is_none()
803 {
804 enrich_bound_node_identity(&input, alias, self.read_dir())?
805 } else {
806 input
807 };
808 return match ty {
809 Some(type_id) => {
810 let filtered = filter_node_by_type(input, alias, *type_id)?;
811 self.join_node_properties(*var, *ty, filtered)
812 }
813 None => self.join_node_properties(*var, None, input),
819 };
820 }
821 let scan = lower_node_scan(*var, *ty, var_map, self.read_dir(), pending_nodes)?;
822 let scan = self.join_node_properties(*var, *ty, scan)?;
823 if input.schema().fields().is_empty() && !preserve_empty_input {
832 return Ok(scan);
833 }
834 return LogicalPlanBuilder::from(input)
835 .cross_join(scan)
836 .and_then(LogicalPlanBuilder::build)
837 .map_unsupported_expr();
838 }
839 GraphOp::TypedEdgeScan { var, rel_ty } => {
840 return lower_typed_edge_scan(
841 *var,
842 *rel_ty,
843 var_map,
844 self.catalog,
845 &self.type_id_to_rel_name,
846 self.read_dir(),
847 self.read_mode(),
848 );
849 }
850 GraphOp::EdgeScan { var, ty } => {
851 return lower_edge_scan(
852 *var,
853 *ty,
854 var_map,
855 &self.type_id_to_rel_name,
856 self.read_dir(),
857 self.read_mode(),
858 );
859 }
860 GraphOp::Expand {
861 src,
862 edge,
863 dst,
864 rel_ty,
865 dir,
866 min_hops,
867 max_hops,
868 } => {
869 return lower_expand(
870 *src,
871 *edge,
872 *dst,
873 *rel_ty,
874 *dir,
875 *min_hops,
876 *max_hops,
877 input,
878 var_map,
879 self.catalog,
880 &self.type_id_to_rel_name,
881 &self.inference_rules,
882 self.read_dir,
883 self.relational_fixed_hop_reference,
884 );
885 }
886 GraphOp::RelationshipUnique { edge, prior_edges } => {
887 use datafusion::logical_expr::col;
888 let edge_alias = var_map
889 .get(*edge)
890 .ok_or(LoweringError::UnboundVar(edge.0))?;
891 let mut predicates = prior_edges.iter().map(|prior| {
892 let prior_alias = var_map
893 .get(*prior)
894 .ok_or(LoweringError::UnboundVar(prior.0))?;
895 let value = |alias: &str| {
896 if alias.ends_with(graphforge_plan::VAR_LEN_EDGE_LIST_FIELD) {
897 col(alias)
898 } else {
899 col(format!("{alias}.edge_uuid"))
900 }
901 };
902 Ok(crate::expr::relationship_disjoint(
903 value(edge_alias),
904 value(prior_alias),
905 ))
906 });
907 let Some(mut predicate) = predicates.next().transpose()? else {
908 return Ok(input);
909 };
910 for next in predicates {
911 predicate = predicate.and(next?);
912 }
913 return LogicalPlanBuilder::from(input)
914 .filter(predicate)
915 .and_then(LogicalPlanBuilder::build)
916 .map_unsupported_expr();
917 }
918 GraphOp::Unwind { list_expr, alias } => {
921 let expr_lowerer = self
922 .expr_lowerer(exprs, var_map)
923 .with_input_schema(input.schema().clone());
924 let df_expr = expr_lowerer.lower(*list_expr)?;
925 let element_field =
929 unwind_element_field(&df_expr, input.schema(), self.prop_names().values());
930 let node =
931 UnwindNode::new(Arc::new(input), df_expr, var_alias(*alias), &element_field);
932 var_map.insert(*alias, var_alias(*alias));
934 return Ok(LogicalPlan::Extension(Extension {
935 node: Arc::new(node),
936 }));
937 }
938 GraphOp::Call {
939 procedure,
940 args,
941 yields,
942 } => {
943 return self.lower_call_op(procedure, args, yields, input, exprs, var_map);
944 }
945 GraphOp::Union { all, inputs } => {
946 return self.lower_union_op(*all, inputs);
947 }
948 GraphOp::Optional { child } => {
954 let mut child_vm = VarMap::new();
955 let child_plan = self.lower_pipeline(&child.ops, &child.exprs, &mut child_vm)?;
956 let (join_keys, inner_keep_idx) =
957 optional_join_keys(&input, &child_plan, var_map, &child_vm);
958 merge_optional_child_vars(&child_vm, var_map);
959 promote_optional_entity_vars(&input, &child_plan, &child_vm, var_map);
960 let node = OptionalMatchNode::new(
961 Arc::new(input),
962 Arc::new(child_plan),
963 join_keys,
964 inner_keep_idx,
965 );
966 return Ok(LogicalPlan::Extension(Extension {
967 node: Arc::new(node),
968 }));
969 }
970 GraphOp::Exists { child, negated } => {
971 return self.lower_exists_op(child, *negated, input, var_map);
972 }
973 GraphOp::PatternComprehension { child, output } => {
974 return self.lower_pattern_comprehension_op(child, *output, input, var_map);
975 }
976 GraphOp::ListElementPatternComprehension {
977 list_expr,
978 loop_var,
979 child,
980 pattern_output,
981 filter,
982 projection,
983 output,
984 } => {
985 return self.lower_list_element_pattern_comprehension_op(
986 *list_expr,
987 *loop_var,
988 child,
989 *pattern_output,
990 *filter,
991 *projection,
992 *output,
993 input,
994 exprs,
995 var_map,
996 );
997 }
998 GraphOp::Create { pattern } => {
999 return self.lower_create(pattern, exprs, input, var_map, create_feeds_read);
1006 }
1007 GraphOp::Delete { vars, detach, .. } => {
1008 return self.lower_delete_op(vars, *detach, input);
1011 }
1012 GraphOp::Set {
1013 items,
1014 map_items,
1015 label_items,
1016 } => {
1017 if !map_items.is_empty() || !label_items.is_empty() {
1018 return Err(LoweringError::UnsupportedExpr(
1019 "SET map and label assignments execute through the statement driver".into(),
1020 ));
1021 }
1022 return self.lower_set_op(items, input, exprs, var_map);
1023 }
1024 GraphOp::Remove { items, label_items } => {
1025 if !label_items.is_empty() {
1026 return Err(LoweringError::UnsupportedExpr(
1027 "REMOVE label assignments execute through the statement driver".into(),
1028 ));
1029 }
1030 return self.lower_remove_op(items, input);
1031 }
1032 GraphOp::Project { items, distinct }
1033 if items.iter().any(|item| item.out_var.is_some()) =>
1034 {
1035 let plan = {
1036 let lowerer = self
1037 .expr_lowerer(exprs, var_map)
1038 .with_input_schema(input.schema().clone());
1039 lower_project(items, *distinct, input, &lowerer)?
1040 };
1041 for item in items {
1042 if let (Some(v), Some(alias)) = (item.out_var, item.alias.as_ref()) {
1043 var_map.insert(v, alias.clone());
1044 }
1045 }
1046 return Ok(plan);
1047 }
1048 GraphOp::With {
1049 items,
1050 distinct,
1051 where_predicate,
1052 } => {
1053 return self.lower_with_op(
1054 items,
1055 *distinct,
1056 *where_predicate,
1057 input,
1058 exprs,
1059 var_map,
1060 );
1061 }
1062 GraphOp::Aggregate {
1070 group_by,
1071 group_aliases,
1072 group_vars,
1073 aggs,
1074 } => {
1075 let plan = {
1076 let lowerer = self
1077 .expr_lowerer(exprs, var_map)
1078 .with_input_schema(input.schema().clone());
1079 lower_aggregate(
1080 group_by,
1081 group_aliases,
1082 aggs,
1083 input,
1084 exprs,
1085 &lowerer,
1086 Some((group_vars, var_map)),
1087 )?
1088 };
1089 let decomposed = !group_vars.is_empty() || aggs.iter().any(|a| a.out_var.is_some());
1090 if decomposed {
1091 let passthrough: Vec<(VarId, String)> = group_by
1094 .iter()
1095 .zip(group_vars)
1096 .filter_map(|(&expr, group_var)| {
1097 let group_var = (*group_var)?;
1098 match exprs.get(expr) {
1099 IrExpr::VarRef(source) if *source == group_var => var_map
1100 .get(group_var)
1101 .map(|alias| (group_var, alias.to_owned())),
1102 _ => None,
1103 }
1104 })
1105 .collect();
1106 var_map.clear();
1107 for (var, alias) in passthrough {
1108 var_map.insert(var, alias);
1109 }
1110 for (i, gv) in group_vars.iter().enumerate() {
1111 if let (Some(v), Some(Some(alias))) = (gv, group_aliases.get(i)) {
1112 var_map.insert(*v, alias.clone());
1113 }
1114 }
1115 for a in aggs {
1116 if let Some(v) = a.out_var {
1117 var_map.insert(v, a.alias.clone());
1118 }
1119 }
1120 }
1121 return Ok(plan);
1122 }
1123 _ => {}
1124 }
1125 let expr_lowerer = self
1130 .expr_lowerer(exprs, var_map)
1131 .with_input_schema(input.schema().clone());
1132 lower_relational_op(op, input, exprs, &expr_lowerer)
1133 }
1134
1135 fn lower_call_op(
1136 &self,
1137 procedure: &graphforge_ir::ProcedureDefinition,
1138 args: &[ExprId],
1139 yields: &[graphforge_ir::ProcedureYield],
1140 input: LogicalPlan,
1141 exprs: &ExprArena,
1142 var_map: &mut VarMap,
1143 ) -> Result<LogicalPlan, LoweringError> {
1144 use datafusion::arrow::datatypes::{DataType, Field, Schema};
1145 use datafusion::common::{Column, DFSchema};
1146 use datafusion::logical_expr::{EmptyRelation, lit};
1147
1148 let width = procedure.inputs.len() + procedure.outputs.len();
1149 let names: Vec<String> = (0..width)
1150 .map(|index| format!("column{}", index + 1))
1151 .collect();
1152 let fields = procedure
1153 .inputs
1154 .iter()
1155 .chain(&procedure.outputs)
1156 .zip(&names)
1157 .map(|(field, name)| {
1158 let data_type = match field.type_name.to_ascii_uppercase().as_str() {
1159 "BOOLEAN" => DataType::Boolean,
1160 "INTEGER" => DataType::Int64,
1161 "FLOAT" | "NUMBER" => DataType::Float64,
1162 _ => DataType::Utf8,
1163 };
1164 Field::new(name, data_type, field.nullable)
1165 })
1166 .collect::<Vec<_>>();
1167 let schema = Arc::new(
1168 DFSchema::try_from(Schema::new(fields))
1169 .map_err(|error| LoweringError::UnsupportedExpr(error.to_string()))?,
1170 );
1171
1172 let fixture = if width == 0 {
1173 LogicalPlan::EmptyRelation(EmptyRelation {
1174 produce_one_row: !procedure.rows.is_empty(),
1175 schema,
1176 })
1177 } else if procedure.rows.is_empty() {
1178 LogicalPlan::EmptyRelation(EmptyRelation {
1179 produce_one_row: false,
1180 schema,
1181 })
1182 } else {
1183 let rows = procedure
1184 .rows
1185 .iter()
1186 .map(|row| {
1187 row.iter()
1188 .map(|value| lit(crate::expr::ir_literal_to_scalar(value)))
1189 .collect()
1190 })
1191 .collect();
1192 LogicalPlanBuilder::values_with_schema(rows, &schema)
1193 .and_then(LogicalPlanBuilder::build)
1194 .map_err(|error| LoweringError::UnsupportedExpr(error.to_string()))?
1195 };
1196
1197 let input_columns: Vec<Column> = input
1198 .schema()
1199 .iter()
1200 .map(|(qualifier, field)| Column::new(qualifier.cloned(), field.name()))
1201 .collect();
1202 let lowerer = self
1203 .expr_lowerer(exprs, var_map)
1204 .with_input_schema(input.schema().clone());
1205 let predicates = args
1206 .iter()
1207 .enumerate()
1208 .map(|(index, arg)| {
1209 Ok(DfExpr::BinaryExpr(
1210 datafusion::logical_expr::BinaryExpr::new(
1211 Box::new(lowerer.lower(*arg)?),
1212 datafusion::logical_expr::Operator::IsNotDistinctFrom,
1213 Box::new(DfExpr::Column(Column::from_name(names[index].clone()))),
1214 ),
1215 ))
1216 })
1217 .collect::<Result<Vec<_>, LoweringError>>()?;
1218 let joined = LogicalPlanBuilder::from(input)
1219 .join_on(fixture, JoinType::Inner, predicates)
1220 .and_then(LogicalPlanBuilder::build)
1221 .map_err(|error| LoweringError::UnsupportedExpr(error.to_string()))?;
1222
1223 let mut projection: Vec<DfExpr> = input_columns.into_iter().map(DfExpr::Column).collect();
1224 for yielded in yields {
1225 let output_index = procedure
1226 .outputs
1227 .iter()
1228 .position(|field| field.name == yielded.field)
1229 .expect("binder only emits registered procedure outputs");
1230 let alias = yielded.alias.clone();
1231 projection.push(
1232 DfExpr::Column(Column::from_name(
1233 names[procedure.inputs.len() + output_index].clone(),
1234 ))
1235 .alias(alias.clone()),
1236 );
1237 var_map.insert(yielded.var, alias);
1238 }
1239 LogicalPlanBuilder::from(joined)
1240 .project(projection)
1241 .and_then(LogicalPlanBuilder::build)
1242 .map_err(|error| LoweringError::UnsupportedExpr(error.to_string()))
1243 }
1244
1245 fn lower_union_op(
1246 &self,
1247 all: bool,
1248 inputs: &[GraphPlan],
1249 ) -> Result<LogicalPlan, LoweringError> {
1250 if inputs.len() < 2 {
1251 return Err(LoweringError::UnsupportedExpr(
1252 "UNION requires at least two branch plans".into(),
1253 ));
1254 }
1255 let mut plans = inputs
1256 .iter()
1257 .map(|branch| {
1258 let mut branch_vars = VarMap::new();
1259 self.lower_pipeline(&branch.ops, &branch.exprs, &mut branch_vars)
1260 })
1261 .collect::<Result<Vec<_>, _>>()?
1262 .into_iter();
1263 let first = plans.next().expect("UNION branch count checked above");
1264 let union = plans.try_fold(first, |left, right| {
1265 LogicalPlanBuilder::from(left)
1266 .union(right)
1267 .and_then(LogicalPlanBuilder::build)
1268 .map_err(|error| LoweringError::UnsupportedExpr(error.to_string()))
1269 })?;
1270 if all {
1271 Ok(union)
1272 } else {
1273 LogicalPlanBuilder::from(union)
1274 .distinct()
1275 .and_then(LogicalPlanBuilder::build)
1276 .map_err(|error| LoweringError::UnsupportedExpr(error.to_string()))
1277 }
1278 }
1279
1280 fn lower_exists_op(
1281 &self,
1282 child: &GraphPlan,
1283 negated: bool,
1284 input: LogicalPlan,
1285 var_map: &VarMap,
1286 ) -> Result<LogicalPlan, LoweringError> {
1287 if let [GraphOp::Union { inputs, .. }] = child.ops.as_slice() {
1288 return self.lower_exists_alternatives(inputs, negated, input, var_map);
1289 }
1290 let (child_ops, is_full_subquery) = match child.ops.last() {
1295 Some(GraphOp::Project { .. }) => (&child.ops[..child.ops.len() - 1], true),
1296 _ => (child.ops.as_slice(), false),
1297 };
1298 let seed_outer_input = is_full_subquery && full_subquery_needs_outer_input(child, var_map);
1299 let mut child_vm = if seed_outer_input {
1300 var_map.clone()
1301 } else {
1302 VarMap::new()
1303 };
1304 let child_plan = if seed_outer_input {
1305 self.lower_pipeline_from(child_ops, &child.exprs, &mut child_vm, input.clone(), None)?
1306 } else {
1307 self.lower_pipeline(child_ops, &child.exprs, &mut child_vm)?
1308 };
1309 let (join_keys, _) = optional_join_keys(&input, &child_plan, var_map, &child_vm);
1310 if join_keys.is_empty() {
1311 return Err(LoweringError::UnsupportedExpr(
1312 "pattern predicate must share at least one bound variable".into(),
1313 ));
1314 }
1315 let left_keys = join_keys
1316 .iter()
1317 .map(|(outer_idx, _)| schema_join_column(input.schema(), *outer_idx))
1318 .collect::<Vec<_>>();
1319 let right_keys = join_keys
1320 .iter()
1321 .map(|(_, inner_idx)| schema_join_column(child_plan.schema(), *inner_idx))
1322 .collect::<Vec<_>>();
1323 let join_type = if negated {
1324 JoinType::LeftAnti
1325 } else {
1326 JoinType::LeftSemi
1327 };
1328 LogicalPlanBuilder::from(input)
1329 .join(child_plan, join_type, (left_keys, right_keys), None)
1330 .and_then(LogicalPlanBuilder::build)
1331 .map_unsupported_expr()
1332 }
1333
1334 fn lower_exists_alternatives(
1335 &self,
1336 children: &[GraphPlan],
1337 negated: bool,
1338 input: LogicalPlan,
1339 var_map: &VarMap,
1340 ) -> Result<LogicalPlan, LoweringError> {
1341 use datafusion::common::Column;
1342
1343 let mut expected_outer_keys: Option<Vec<usize>> = None;
1344 let mut key_union: Option<LogicalPlan> = None;
1345
1346 for child in children {
1347 let mut child_vm = VarMap::new();
1348 let child_plan = self.lower_pipeline(&child.ops, &child.exprs, &mut child_vm)?;
1349 let (mut join_keys, _) = optional_join_keys(&input, &child_plan, var_map, &child_vm);
1350 if join_keys.is_empty() {
1351 return Err(LoweringError::UnsupportedExpr(
1352 "pattern predicate must share at least one bound variable".into(),
1353 ));
1354 }
1355 join_keys.sort_unstable_by_key(|(outer_idx, _)| *outer_idx);
1356
1357 let outer_keys = join_keys
1358 .iter()
1359 .map(|(outer_idx, _)| *outer_idx)
1360 .collect::<Vec<_>>();
1361 if expected_outer_keys
1362 .as_ref()
1363 .is_some_and(|expected| expected != &outer_keys)
1364 {
1365 return Err(LoweringError::UnsupportedExpr(
1366 "OR pattern predicates must correlate on the same bound variables".into(),
1367 ));
1368 }
1369 expected_outer_keys.get_or_insert(outer_keys);
1370
1371 let key_projection = join_keys
1372 .iter()
1373 .enumerate()
1374 .map(|(key_idx, (_, inner_idx))| {
1375 DfExpr::Column(schema_join_column(child_plan.schema(), *inner_idx))
1376 .alias(format!("__exists_key_{key_idx}"))
1377 })
1378 .collect::<Vec<_>>();
1379 let key_plan = LogicalPlanBuilder::from(child_plan)
1380 .project(key_projection)
1381 .and_then(LogicalPlanBuilder::build)
1382 .map_unsupported_expr()?;
1383 key_union = Some(match key_union {
1384 None => key_plan,
1385 Some(union) => LogicalPlanBuilder::from(union)
1386 .union(key_plan)
1387 .and_then(LogicalPlanBuilder::build)
1388 .map_unsupported_expr()?,
1389 });
1390 }
1391
1392 let outer_keys = expected_outer_keys.ok_or_else(|| {
1393 LoweringError::UnsupportedExpr("pattern predicate has no alternatives".into())
1394 })?;
1395 let key_union = key_union.expect("outer keys imply a key union");
1396 let left_keys = outer_keys
1397 .iter()
1398 .map(|idx| schema_join_column(input.schema(), *idx))
1399 .collect::<Vec<_>>();
1400 let right_keys = (0..left_keys.len())
1401 .map(|idx| Column::from_name(format!("__exists_key_{idx}")))
1402 .collect::<Vec<_>>();
1403 let join_type = if negated {
1404 JoinType::LeftAnti
1405 } else {
1406 JoinType::LeftSemi
1407 };
1408 LogicalPlanBuilder::from(input)
1409 .join(key_union, join_type, (left_keys, right_keys), None)
1410 .and_then(LogicalPlanBuilder::build)
1411 .map_unsupported_expr()
1412 }
1413
1414 fn lower_pattern_comprehension_op(
1415 &self,
1416 child: &GraphPlan,
1417 output: VarId,
1418 input: LogicalPlan,
1419 var_map: &mut VarMap,
1420 ) -> Result<LogicalPlan, LoweringError> {
1421 use datafusion::common::Column;
1422 use datafusion::functions::core::expr_fn::coalesce;
1423 use datafusion::scalar::ScalarValue;
1424
1425 let (terminal, match_ops) = child.ops.split_last().ok_or_else(|| {
1426 LoweringError::UnsupportedExpr("pattern comprehension child is empty".into())
1427 })?;
1428 let GraphOp::Project { items, distinct } = terminal else {
1429 return Err(LoweringError::UnsupportedExpr(
1430 "pattern comprehension child must end in a value projection".into(),
1431 ));
1432 };
1433 if *distinct || items.len() != 1 {
1434 return Err(LoweringError::UnsupportedExpr(
1435 "pattern comprehension child must project exactly one non-distinct value".into(),
1436 ));
1437 }
1438 let item = &items[0];
1439 if item.alias.as_deref() != Some(PATTERN_COMPREHENSION_VALUE_ALIAS) {
1440 return Err(LoweringError::UnsupportedExpr(
1441 "pattern comprehension child has an invalid value projection".into(),
1442 ));
1443 }
1444
1445 let outer_columns = input
1446 .schema()
1447 .iter()
1448 .map(|(qualifier, field)| {
1449 DfExpr::Column(Column::new(qualifier.cloned(), field.name().to_owned()))
1450 })
1451 .collect::<Vec<_>>();
1452 let mut child_vm = VarMap::new();
1453 let child_plan = self.lower_pipeline(match_ops, &child.exprs, &mut child_vm)?;
1454 let (mut join_keys, _) = optional_join_keys(&input, &child_plan, var_map, &child_vm);
1455 if join_keys.is_empty() {
1456 return Err(LoweringError::UnsupportedExpr(
1457 "pattern comprehension must share at least one bound node variable".into(),
1458 ));
1459 }
1460 join_keys.sort_unstable_by_key(|(outer_idx, _)| *outer_idx);
1461
1462 let value = self
1463 .expr_lowerer(&child.exprs, &child_vm)
1464 .with_input_schema(child_plan.schema().clone())
1465 .lower(item.expr)?;
1466 let element_type = value.get_type(child_plan.schema()).map_unsupported_expr()?;
1467 let key_aliases = (0..join_keys.len())
1468 .map(|idx| format!("__gf_pattern_key_{idx}"))
1469 .collect::<Vec<_>>();
1470 let group_exprs = join_keys
1471 .iter()
1472 .zip(&key_aliases)
1473 .map(|((_, inner_idx), alias)| {
1474 DfExpr::Column(schema_join_column(child_plan.schema(), *inner_idx)).alias(alias)
1475 })
1476 .collect::<Vec<_>>();
1477 let output_alias = format!("{PATTERN_COMPREHENSION_VALUE_ALIAS}_{}", output.0);
1478 let aggregate_exprs = vec![array_agg(value).alias(&output_alias)];
1479 let collected = LogicalPlanBuilder::from(child_plan)
1480 .aggregate(group_exprs, aggregate_exprs)
1481 .and_then(LogicalPlanBuilder::build)
1482 .map_unsupported_expr()?;
1483
1484 let left_keys = join_keys
1485 .iter()
1486 .map(|(outer_idx, _)| schema_join_column(input.schema(), *outer_idx))
1487 .collect::<Vec<_>>();
1488 let right_keys = key_aliases
1489 .iter()
1490 .map(|alias| Column::from_name(alias.clone()))
1491 .collect::<Vec<_>>();
1492 let joined = LogicalPlanBuilder::from(input)
1493 .join(collected, JoinType::Left, (left_keys, right_keys), None)
1494 .and_then(LogicalPlanBuilder::build)
1495 .map_unsupported_expr()?;
1496
1497 let empty = ScalarValue::List(ScalarValue::new_list(&[], &element_type, true));
1498 let mut projection = outer_columns;
1499 projection.push(
1500 coalesce(vec![
1501 DfExpr::Column(Column::from_name(output_alias.clone())),
1502 DfExpr::Literal(empty, None),
1503 ])
1504 .alias(&output_alias),
1505 );
1506 let result = LogicalPlanBuilder::from(joined)
1507 .project(projection)
1508 .and_then(LogicalPlanBuilder::build)
1509 .map_unsupported_expr()?;
1510 var_map.insert(output, output_alias);
1511 Ok(result)
1512 }
1513
1514 #[allow(
1515 clippy::too_many_arguments,
1516 clippy::too_many_lines,
1517 reason = "ordinal unwind, child correlation, and ordered regroup form one lowering operation"
1518 )]
1519 fn lower_list_element_pattern_comprehension_op(
1520 &self,
1521 list_expr: ExprId,
1522 loop_var: VarId,
1523 child: &GraphPlan,
1524 pattern_output: VarId,
1525 filter: Option<ExprId>,
1526 projection: Option<ExprId>,
1527 output: VarId,
1528 input: LogicalPlan,
1529 exprs: &ExprArena,
1530 var_map: &mut VarMap,
1531 ) -> Result<LogicalPlan, LoweringError> {
1532 use datafusion::arrow::datatypes::{DataType, Field};
1533 use datafusion::common::Column;
1534 use datafusion::functions::core::expr_fn::{coalesce, get_field};
1535 use datafusion::functions_nested::expr_fn::array_element;
1536 use datafusion::scalar::ScalarValue;
1537
1538 const LIST: &str = "__gf_list_source";
1539 const INDICES: &str = "__gf_list_indices";
1540 const INDEX: &str = "__gf_list_index";
1541
1542 let outer_columns = plan_columns(&input);
1543 let row_keys = input
1544 .schema()
1545 .iter()
1546 .filter(|(_, field)| matches!(field.name().as_str(), "node_id" | "edge_id"))
1547 .map(|(qualifier, field)| Column::new(qualifier.cloned(), field.name().to_owned()))
1548 .collect::<Vec<_>>();
1549 if row_keys.is_empty() {
1550 return Err(LoweringError::UnsupportedExpr(
1551 "graph-valued list comprehension requires an outer entity identity".into(),
1552 ));
1553 }
1554 let row_key_aliases = (0..row_keys.len())
1555 .map(|index| format!("__gf_list_row_key_{index}"))
1556 .collect::<Vec<_>>();
1557 let list = self
1558 .expr_lowerer(exprs, var_map)
1559 .with_input_schema(input.schema().clone())
1560 .lower(list_expr)?;
1561 let DataType::List(item) = list.get_type(input.schema()).map_unsupported_expr()? else {
1562 return Err(LoweringError::InvalidType(
1563 "nested pattern comprehension source must be a list".into(),
1564 ));
1565 };
1566 let DataType::Struct(node_fields) = item.data_type() else {
1567 return Err(LoweringError::InvalidType(
1568 "nested pattern comprehension elements must be node values".into(),
1569 ));
1570 };
1571
1572 let mut indexed_projection = outer_columns.clone();
1573 indexed_projection.push(list.alias(LIST));
1574 let indexed = LogicalPlanBuilder::from(input)
1575 .project(indexed_projection)
1576 .and_then(LogicalPlanBuilder::build)
1577 .map_unsupported_expr()?;
1578
1579 let mut index_projection = plan_columns(&indexed);
1580 index_projection
1581 .push(list_index_range(DfExpr::Column(Column::from_name(LIST))).alias(INDICES));
1582 let with_indices = LogicalPlanBuilder::from(indexed.clone())
1583 .project(index_projection)
1584 .and_then(LogicalPlanBuilder::build)
1585 .map_unsupported_expr()?;
1586 let index_field = Field::new(INDEX, DataType::Int64, true);
1587 let expanded = LogicalPlan::Extension(Extension {
1588 node: Arc::new(UnwindNode::new(
1589 Arc::new(with_indices),
1590 DfExpr::Column(Column::from_name(INDICES)),
1591 INDEX,
1592 &index_field,
1593 )),
1594 });
1595
1596 let element = array_element(
1597 DfExpr::Column(Column::from_name(LIST)),
1598 DfExpr::Column(Column::from_name(INDEX)) + datafusion::logical_expr::lit(1_i64),
1599 );
1600 let loop_alias = var_alias(loop_var);
1601 let mut element_projection = plan_columns(&expanded);
1602 element_projection.extend(node_fields.iter().map(|field| {
1603 get_field(element.clone(), field.name())
1604 .alias_qualified(Some(loop_alias.as_str()), field.name())
1605 }));
1606 let expanded = LogicalPlanBuilder::from(expanded)
1607 .project(element_projection)
1608 .and_then(LogicalPlanBuilder::build)
1609 .map_unsupported_expr()?;
1610 var_map.insert(loop_var, loop_alias.clone());
1611
1612 let matched =
1613 self.lower_pattern_comprehension_op(child, pattern_output, expanded, var_map)?;
1614 let clause_lowerer = self
1615 .expr_lowerer(exprs, var_map)
1616 .with_input_schema(matched.schema().clone());
1617 let filtered = if let Some(predicate) = filter {
1618 lower_filter(predicate, matched, &clause_lowerer)?
1619 } else {
1620 matched
1621 };
1622 let value = match projection {
1623 Some(expr) => self
1624 .expr_lowerer(exprs, var_map)
1625 .with_input_schema(filtered.schema().clone())
1626 .lower(expr)?,
1627 None => DfExpr::Column(Column::new(Some(loop_alias.as_str()), "node_uuid")),
1628 };
1629 let element_type = value.get_type(filtered.schema()).map_unsupported_expr()?;
1630 let output_alias = format!("__gf_list_pattern_{}", output.0);
1631 let ordered = array_agg(value)
1632 .order_by(vec![
1633 DfExpr::Column(Column::from_name(INDEX)).sort(true, true),
1634 ])
1635 .build()
1636 .map_unsupported_expr()?
1637 .alias(&output_alias);
1638 let collected = LogicalPlanBuilder::from(filtered)
1639 .aggregate(
1640 row_keys
1641 .iter()
1642 .zip(&row_key_aliases)
1643 .map(|(column, alias)| DfExpr::Column(column.clone()).alias(alias))
1644 .collect::<Vec<_>>(),
1645 vec![ordered],
1646 )
1647 .and_then(LogicalPlanBuilder::build)
1648 .map_unsupported_expr()?;
1649 let joined = LogicalPlanBuilder::from(indexed)
1650 .join(
1651 collected,
1652 JoinType::Left,
1653 (
1654 row_keys,
1655 row_key_aliases
1656 .iter()
1657 .map(|alias| Column::from_name(alias.clone()))
1658 .collect::<Vec<_>>(),
1659 ),
1660 None,
1661 )
1662 .and_then(LogicalPlanBuilder::build)
1663 .map_unsupported_expr()?;
1664
1665 let empty = ScalarValue::List(ScalarValue::new_list(&[], &element_type, true));
1666 let null = ScalarValue::new_null_list(element_type, true, 1);
1667 let result_list = datafusion::logical_expr::when(
1668 DfExpr::Column(Column::from_name(LIST)).is_null(),
1669 DfExpr::Literal(null, None),
1670 )
1671 .otherwise(coalesce(vec![
1672 DfExpr::Column(Column::from_name(output_alias.clone())),
1673 DfExpr::Literal(empty, None),
1674 ]))
1675 .map_unsupported_expr()?
1676 .alias(&output_alias);
1677 let mut final_projection = outer_columns;
1678 final_projection.push(result_list);
1679 let result = LogicalPlanBuilder::from(joined)
1680 .project(final_projection)
1681 .and_then(LogicalPlanBuilder::build)
1682 .map_unsupported_expr()?;
1683 var_map.insert(output, output_alias);
1684 Ok(result)
1685 }
1686
1687 #[allow(clippy::too_many_lines)]
1694 fn lower_with_op(
1695 &self,
1696 items: &[ProjectItem],
1697 distinct: bool,
1698 where_predicate: Option<ExprId>,
1699 input: LogicalPlan,
1700 exprs: &ExprArena,
1701 var_map: &mut VarMap,
1702 ) -> Result<LogicalPlan, LoweringError> {
1703 let input_order = input_sort_exprs(&input);
1704 let keep_input_for_where = where_predicate.is_some();
1711 let incoming_scope = var_map.clone();
1712 let node_shapes = self
1713 .node_shapes
1714 .read()
1715 .expect("node shapes lock poisoned")
1716 .clone();
1717 let forwarded_prefixes: HashSet<String> = items
1722 .iter()
1723 .filter_map(|item| {
1724 let IrExpr::VarRef(v) = exprs.get(item.expr) else {
1725 return None;
1726 };
1727 let prefix = var_map.get(*v)?;
1728 let is_relationship = input.schema().iter().any(|(qualifier, field)| {
1729 qualifier.is_some_and(|q| q.table() == prefix) && field.name() == "edge_uuid"
1730 });
1731 (node_shapes.contains_key(&v.0) || is_relationship).then(|| prefix.to_owned())
1732 })
1733 .collect();
1734 let physical_names: HashMap<VarId, String> = items
1735 .iter()
1736 .filter_map(|item| {
1737 let out_var = item.out_var?;
1738 let name = item.alias.as_ref()?;
1739 let conflicts_with_forwarded_property =
1740 input.schema().iter().any(|(qualifier, field)| {
1741 qualifier.as_ref().is_some_and(|q| {
1742 forwarded_prefixes.contains(q.table()) && field.name() == name
1743 })
1744 });
1745 let shadows_input = input.schema().iter().any(|(_, field)| field.name() == name);
1746 (conflicts_with_forwarded_property || shadows_input)
1747 .then(|| (out_var, format!("__gf_with_{}", out_var.0)))
1748 })
1749 .collect();
1750 let mut select: Vec<DfExpr> = Vec::new();
1751 let mut new_scope: Vec<(VarId, String)> = Vec::new();
1752 let mut predicate_scope: Vec<(VarId, String)> = Vec::new();
1753 let mut forwarded_node_shapes = Vec::new();
1754 if keep_input_for_where {
1755 for (qualifier, field) in input.schema().iter() {
1756 select.push(DfExpr::Column(datafusion::common::Column::new(
1757 qualifier.cloned(),
1758 field.name(),
1759 )));
1760 }
1761 }
1762 {
1763 let lowerer = self
1769 .expr_lowerer(exprs, var_map)
1770 .with_input_schema(input.schema().clone());
1771 for item in items {
1772 let name = item.alias.as_deref().ok_or_else(|| {
1773 LoweringError::UnsupportedExpr(
1774 "WITH item without an alias (binder should reject)".into(),
1775 )
1776 })?;
1777 if let IrExpr::VarRef(v) = exprs.get(item.expr) {
1782 let prefix = var_map
1783 .get(*v)
1784 .ok_or(LoweringError::UnboundVar(v.0))?
1785 .to_string();
1786 let is_relationship = input.schema().iter().any(|(qualifier, field)| {
1787 qualifier.is_some_and(|q| q.table() == prefix)
1788 && field.name() == "edge_uuid"
1789 });
1790 if node_shapes.contains_key(&v.0) || is_relationship {
1791 let output_var = item.out_var.unwrap_or(*v);
1792 let output_prefix = if output_var == *v {
1793 prefix.clone()
1794 } else {
1795 var_alias(output_var)
1796 };
1797 if output_var != *v
1798 && let Some(shape) = node_shapes.get(&v.0).cloned()
1799 {
1800 forwarded_node_shapes.push((output_var.0, shape));
1801 }
1802 if !keep_input_for_where || output_prefix != prefix {
1803 for (qualifier, field) in input.schema().iter() {
1804 if qualifier.is_some_and(|q| q.table() == prefix) {
1805 let column = DfExpr::Column(datafusion::common::Column::new(
1806 qualifier.cloned(),
1807 field.name(),
1808 ));
1809 select.push(if output_prefix == prefix {
1810 column
1811 } else {
1812 column.alias_qualified(
1813 Some(output_prefix.as_str()),
1814 field.name(),
1815 )
1816 });
1817 }
1818 }
1819 }
1820 new_scope.push((output_var, output_prefix.clone()));
1821 predicate_scope.push((output_var, output_prefix));
1822 } else {
1823 let projected_name = if keep_input_for_where {
1824 item.out_var.map_or_else(
1825 || format!("__gf_with_{}", predicate_scope.len()),
1826 |v| format!("__gf_with_{}", v.0),
1827 )
1828 } else {
1829 item.out_var
1830 .and_then(|v| physical_names.get(&v).cloned())
1831 .unwrap_or_else(|| name.to_string())
1832 };
1833 select.push(lowerer.lower(item.expr)?.alias(projected_name.as_str()));
1834 if let Some(v) = item.out_var {
1835 let output_name = physical_names
1836 .get(&v)
1837 .cloned()
1838 .unwrap_or_else(|| name.to_string());
1839 new_scope.push((v, output_name));
1840 predicate_scope.push((v, projected_name));
1841 }
1842 }
1843 } else {
1844 use datafusion::logical_expr::ExprSchemable;
1845
1846 let projected_name = if keep_input_for_where {
1847 item.out_var.map_or_else(
1848 || format!("__gf_with_{}", predicate_scope.len()),
1849 |v| format!("__gf_with_{}", v.0),
1850 )
1851 } else {
1852 item.out_var
1853 .and_then(|v| physical_names.get(&v).cloned())
1854 .unwrap_or_else(|| name.to_string())
1855 };
1856 let value_expr = lowerer.lower(item.expr)?;
1857 if let Some(output_var) = item.out_var
1858 && let Ok(datafusion::arrow::datatypes::DataType::Struct(fields)) =
1859 value_expr.get_type(input.schema().as_ref())
1860 && fields.iter().any(|field| field.name() == "node_uuid")
1861 {
1862 let output_prefix = var_alias(output_var);
1863 for field in &fields {
1864 select.push(
1865 datafusion::functions::core::expr_fn::get_field(
1866 value_expr.clone(),
1867 field.name(),
1868 )
1869 .alias_qualified(
1870 Some(output_prefix.as_str()),
1871 field.name().as_str(),
1872 ),
1873 );
1874 }
1875 new_scope.push((output_var, output_prefix.clone()));
1876 predicate_scope.push((output_var, output_prefix));
1877 continue;
1878 }
1879 select.push(value_expr.alias(projected_name.as_str()));
1880 if let Some(v) = item.out_var {
1881 let output_name = physical_names
1882 .get(&v)
1883 .cloned()
1884 .unwrap_or_else(|| name.to_string());
1885 new_scope.push((v, output_name));
1886 predicate_scope.push((v, projected_name));
1887 }
1888 }
1889 }
1890 }
1891 if !distinct {
1892 select.extend(input_order.iter().enumerate().map(|(index, sort)| {
1893 sort.expr
1894 .clone()
1895 .alias(format!("{INPUT_ORDER_COLUMN_PREFIX}{index}"))
1896 }));
1897 }
1898 let projected = LogicalPlanBuilder::from(input)
1899 .project(select)
1900 .and_then(LogicalPlanBuilder::build)
1901 .map_unsupported_expr()?;
1902 drop(node_shapes);
1903 if !forwarded_node_shapes.is_empty() {
1904 self.node_shapes
1905 .write()
1906 .expect("node shapes lock poisoned")
1907 .extend(forwarded_node_shapes);
1908 }
1909 let filtered = match where_predicate {
1911 Some(pred) => {
1912 let mut filter_scope = incoming_scope;
1913 for (v, col) in &predicate_scope {
1914 filter_scope.insert(*v, col.clone());
1915 }
1916 let lowerer = self
1917 .expr_lowerer(exprs, &filter_scope)
1918 .with_input_schema(projected.schema().clone());
1919 let df_pred = lowerer.lower(pred)?;
1920 LogicalPlanBuilder::from(projected)
1921 .filter(df_pred)
1922 .and_then(LogicalPlanBuilder::build)
1923 .map_unsupported_expr()?
1924 }
1925 None => projected,
1926 };
1927
1928 let mut output = if keep_input_for_where {
1931 let node_shapes = self
1932 .node_shapes
1933 .read()
1934 .expect("node shapes lock poisoned")
1935 .clone();
1936 let lowerer = self
1937 .expr_lowerer(exprs, var_map)
1938 .with_input_schema(filtered.schema().clone());
1939 let mut final_select: Vec<DfExpr> = Vec::new();
1940 for item in items {
1941 let name = item.alias.as_deref().ok_or_else(|| {
1942 LoweringError::UnsupportedExpr(
1943 "WITH item without an alias (binder should reject)".into(),
1944 )
1945 })?;
1946 if let IrExpr::VarRef(v) = exprs.get(item.expr) {
1947 let prefix = var_map.get(*v).ok_or(LoweringError::UnboundVar(v.0))?;
1948 let is_relationship = filtered.schema().iter().any(|(qualifier, field)| {
1949 qualifier.is_some_and(|q| q.table() == prefix)
1950 && field.name() == "edge_uuid"
1951 });
1952 if node_shapes.contains_key(&v.0) || is_relationship {
1953 let output_var = item.out_var.unwrap_or(*v);
1954 let output_prefix = if output_var == *v {
1955 prefix.to_owned()
1956 } else {
1957 var_alias(output_var)
1958 };
1959 for (qualifier, field) in filtered.schema().iter() {
1960 if qualifier.is_some_and(|q| q.table() == output_prefix) {
1961 final_select.push(DfExpr::Column(datafusion::common::Column::new(
1962 qualifier.cloned(),
1963 field.name(),
1964 )));
1965 }
1966 }
1967 } else {
1968 let projected_name = item
1969 .out_var
1970 .and_then(|v| physical_names.get(&v))
1971 .map_or(name, String::as_str);
1972 final_select.push(lowerer.lower(item.expr)?.alias(projected_name));
1973 }
1974 } else {
1975 let projected_name = item
1976 .out_var
1977 .and_then(|v| physical_names.get(&v))
1978 .map_or(name, String::as_str);
1979 final_select.push(lowerer.lower(item.expr)?.alias(projected_name));
1980 }
1981 }
1982 if !distinct {
1983 final_select.extend(input_order.iter().enumerate().map(|(index, _)| {
1984 DfExpr::Column(datafusion::common::Column::from_name(format!(
1985 "{INPUT_ORDER_COLUMN_PREFIX}{index}"
1986 )))
1987 }));
1988 }
1989 LogicalPlanBuilder::from(filtered)
1990 .project(final_select)
1991 .and_then(LogicalPlanBuilder::build)
1992 .map_unsupported_expr()?
1993 } else {
1994 filtered
1995 };
1996
1997 if distinct {
1998 output = LogicalPlanBuilder::from(output)
1999 .distinct()
2000 .and_then(LogicalPlanBuilder::build)
2001 .map_unsupported_expr()?;
2002 }
2003
2004 var_map.clear();
2008 for (v, col) in new_scope {
2009 var_map.insert(v, col);
2010 }
2011 Ok(output)
2012 }
2013
2014 fn lower_create(
2024 &self,
2025 pattern: &CreatePattern,
2026 exprs: &ExprArena,
2027 input: LogicalPlan,
2028 var_map: &mut VarMap,
2029 feeds_read: bool,
2030 ) -> Result<LogicalPlan, LoweringError> {
2031 let (dir, mode) = self.write_target.ok_or_else(|| {
2032 LoweringError::UnsupportedExpr(
2033 "CREATE requires a write target; lower via new_for_writes".into(),
2034 )
2035 })?;
2036
2037 let (nodes, edges) = self.create_specs(pattern, exprs, var_map, Some(input.schema()))?;
2038
2039 if feeds_read {
2046 let out_schema = Self::created_rows_schema(&nodes, input.schema())?;
2047 self.register_created_node_shapes(&nodes);
2048 for spec in &nodes {
2049 if spec.is_reference {
2053 continue;
2054 }
2055 let v = VarId(spec.var);
2056 var_map.insert(v, var_alias(v));
2057 }
2058 let node = GraphCreateNode::new_emitting(
2059 Arc::new(input),
2060 nodes,
2061 edges,
2062 dir.to_path_buf(),
2063 mode,
2064 out_schema,
2065 );
2066 return Ok(LogicalPlan::Extension(Extension {
2067 node: Arc::new(node),
2068 }));
2069 }
2070
2071 let node = GraphCreateNode::new(Arc::new(input), nodes, edges, dir.to_path_buf(), mode);
2072 Ok(LogicalPlan::Extension(Extension {
2073 node: Arc::new(node),
2074 }))
2075 }
2076
2077 fn created_rows_schema(
2083 nodes: &[ResolvedNodeSpec],
2084 input_schema: &datafusion::common::DFSchemaRef,
2085 ) -> Result<datafusion::common::DFSchemaRef, LoweringError> {
2086 use std::collections::HashMap;
2087
2088 use datafusion::arrow::datatypes::{DataType, Field};
2089 use datafusion::common::{DFSchema, TableReference};
2090 use datafusion::logical_expr::ExprSchemable;
2091
2092 let mut qualified: Vec<(Option<TableReference>, Arc<Field>)> = input_schema
2093 .iter()
2094 .map(|(q, f)| (q.cloned(), Arc::clone(f)))
2095 .collect();
2096
2097 for spec in nodes {
2098 if spec.is_reference {
2102 continue;
2103 }
2104 let qual = TableReference::bare(var_alias(VarId(spec.var)));
2105 let mut push = |name: &str, ty: DataType, nullable: bool| {
2106 qualified.push((Some(qual.clone()), Arc::new(Field::new(name, ty, nullable))));
2107 };
2108 push("node_uuid", DataType::FixedSizeBinary(16), false);
2109 push("node_id", DataType::UInt64, false);
2110 push("type_id", DataType::UInt32, false);
2111 push(
2112 "type_ids",
2113 DataType::List(Arc::new(datafusion::arrow::datatypes::Field::new(
2114 "item",
2115 DataType::UInt32,
2116 false,
2117 ))),
2118 false,
2119 );
2120 for (name, lit) in &spec.properties {
2121 Self::ensure_created_node_emit_property_name(name)?;
2122 let scalar = crate::expr::ir_literal_to_scalar(lit);
2123 push(name, scalar.data_type(), true);
2124 }
2125 for (name, expr) in &spec.computed_properties {
2126 Self::ensure_created_node_emit_property_name(name)?;
2127 let ty = expr.get_type(input_schema).map_unsupported_expr()?;
2128 push(name, ty, true);
2129 }
2130 }
2131 let schema =
2132 DFSchema::new_with_metadata(qualified, HashMap::new()).map_unsupported_expr()?;
2133 Ok(Arc::new(schema))
2134 }
2135
2136 fn ensure_created_node_emit_property_name(name: &str) -> Result<(), LoweringError> {
2137 if matches!(name, "node_uuid" | "node_id" | "type_id" | "type_ids") {
2138 return Err(LoweringError::UnsupportedExpr(format!(
2139 "CREATE property `{name}` collides with a reserved node topology field"
2140 )));
2141 }
2142 Ok(())
2143 }
2144
2145 fn create_specs(
2148 &self,
2149 pattern: &CreatePattern,
2150 exprs: &ExprArena,
2151 var_map: &VarMap,
2152 input_schema: Option<&datafusion::common::DFSchemaRef>,
2153 ) -> Result<(Vec<ResolvedNodeSpec>, Vec<ResolvedEdgeSpec>), LoweringError> {
2154 let nodes: Vec<ResolvedNodeSpec> = pattern
2155 .nodes
2156 .iter()
2157 .map(|n| {
2158 let (properties, computed_properties) =
2159 eval_map_literal(self, n.properties, exprs, var_map, input_schema)?;
2160 Ok(ResolvedNodeSpec {
2161 var: n.var.0,
2162 label_ids: n.labels.iter().map(|t| t.0).collect(),
2163 label_names: n
2164 .labels
2165 .iter()
2166 .filter_map(|t| self.type_id_to_entity_name.get(&t.0).cloned())
2167 .collect(),
2168 properties,
2169 computed_properties,
2170 is_reference: n.is_reference,
2171 })
2172 })
2173 .collect::<Result<_, LoweringError>>()?;
2174
2175 let edges: Vec<ResolvedEdgeSpec> = pattern
2176 .edges
2177 .iter()
2178 .map(|e| {
2179 let (properties, computed_properties) =
2180 eval_map_literal(self, e.properties, exprs, var_map, input_schema)?;
2181 Ok(ResolvedEdgeSpec {
2182 var: e.var.0,
2183 src: e.src.0,
2184 dst: e.dst.0,
2185 rel_type_id: e.rel_type.map(|t| t.0),
2186 rel_type_name: e
2187 .rel_type
2188 .and_then(|t| self.type_id_to_rel_name.get(&t.0).cloned()),
2189 direction: e.direction,
2190 properties,
2191 computed_properties,
2192 })
2193 })
2194 .collect::<Result<_, LoweringError>>()?;
2195 Ok((nodes, edges))
2196 }
2197
2198 fn lower_delete_op(
2208 &self,
2209 vars: &[VarId],
2210 detach: bool,
2211 input: LogicalPlan,
2212 ) -> Result<LogicalPlan, LoweringError> {
2213 use datafusion::common::TableReference;
2214
2215 let (dir, mode) = self.write_target.ok_or_else(|| {
2216 LoweringError::UnsupportedExpr(
2217 "DELETE requires a write target; lower via new_for_writes".into(),
2218 )
2219 })?;
2220
2221 let schema = input.schema();
2222 let targets: Vec<DeleteTarget> = vars
2223 .iter()
2224 .map(|var| {
2225 let qual = TableReference::bare(var_alias(*var));
2226 let is_node = schema
2227 .index_of_column_by_name(Some(&qual), "node_uuid")
2228 .is_some();
2229 let is_edge = schema
2230 .index_of_column_by_name(Some(&qual), "edge_uuid")
2231 .is_some();
2232 match (is_node, is_edge) {
2233 (true, _) => Ok(DeleteTarget {
2234 var: var.0,
2235 is_edge: false,
2236 }),
2237 (false, true) => Ok(DeleteTarget {
2238 var: var.0,
2239 is_edge: true,
2240 }),
2241 (false, false) => Err(LoweringError::UnsupportedExpr(format!(
2242 "DELETE target var_{} has no node_uuid/edge_uuid column in the \
2243 input — it must be bound by a preceding MATCH",
2244 var.0
2245 ))),
2246 }
2247 })
2248 .collect::<Result<_, LoweringError>>()?;
2249
2250 let node = GraphDeleteNode::new(Arc::new(input), targets, detach, dir.to_path_buf(), mode);
2251 Ok(LogicalPlan::Extension(Extension {
2252 node: Arc::new(node),
2253 }))
2254 }
2255
2256 fn resolve_write_kind(
2265 schema: &datafusion::common::DFSchemaRef,
2266 var: VarId,
2267 clause: &str,
2268 ) -> Result<bool, LoweringError> {
2269 use datafusion::common::TableReference;
2270
2271 let qual = TableReference::bare(var_alias(var));
2272 let is_node = schema
2273 .index_of_column_by_name(Some(&qual), "node_uuid")
2274 .is_some();
2275 let is_edge = schema
2276 .index_of_column_by_name(Some(&qual), "edge_uuid")
2277 .is_some();
2278 match (is_node, is_edge) {
2279 (true, _) => Ok(false),
2280 (false, true) => {
2281 if schema
2282 .index_of_column_by_name(Some(&qual), "rel_type_name")
2283 .is_some()
2284 {
2285 Ok(true)
2286 } else {
2287 Err(LoweringError::UnsupportedExpr(format!(
2288 "{clause} on an edge requires a known relation type \
2289 (e.g. `-[r:KNOWS]->`); an untyped edge write is not yet \
2290 supported (follow-up to #791)"
2291 )))
2292 }
2293 }
2294 (false, false) => Err(LoweringError::UnsupportedExpr(format!(
2295 "{clause} target var_{} has no node_uuid/edge_uuid column in the \
2296 input — it must be bound by a preceding MATCH",
2297 var.0
2298 ))),
2299 }
2300 }
2301
2302 fn lower_set_op(
2310 &self,
2311 items: &[SetPropItem],
2312 input: LogicalPlan,
2313 exprs: &ExprArena,
2314 var_map: &VarMap,
2315 ) -> Result<LogicalPlan, LoweringError> {
2316 let (dir, mode) = self.write_target.ok_or_else(|| {
2317 LoweringError::UnsupportedExpr(
2318 "SET requires a write target; lower via new_for_writes".into(),
2319 )
2320 })?;
2321
2322 let expr_lowerer = self.expr_lowerer(exprs, var_map);
2325 let schema = input.schema();
2326 let targets: Vec<SetTarget> = items
2327 .iter()
2328 .map(|item| {
2329 let is_edge = Self::resolve_write_kind(schema, item.target, "SET")?;
2330 let value = expr_lowerer.lower(item.value)?;
2331 Ok(SetTarget {
2332 var: item.target.0,
2333 is_edge,
2334 prop_name: item.prop_name.clone(),
2335 value,
2336 })
2337 })
2338 .collect::<Result<_, LoweringError>>()?;
2339
2340 let node = GraphSetNode::new(
2341 Arc::new(input),
2342 targets,
2343 self.type_id_to_entity_name.clone(),
2344 dir.to_path_buf(),
2345 mode,
2346 );
2347 Ok(LogicalPlan::Extension(Extension {
2348 node: Arc::new(node),
2349 }))
2350 }
2351
2352 fn lower_remove_op(
2355 &self,
2356 items: &[RemovePropItem],
2357 input: LogicalPlan,
2358 ) -> Result<LogicalPlan, LoweringError> {
2359 let (dir, mode) = self.write_target.ok_or_else(|| {
2360 LoweringError::UnsupportedExpr(
2361 "REMOVE requires a write target; lower via new_for_writes".into(),
2362 )
2363 })?;
2364
2365 let schema = input.schema();
2366 let targets: Vec<RemoveTarget> = items
2367 .iter()
2368 .map(|item| {
2369 let is_edge = Self::resolve_write_kind(schema, item.target, "REMOVE")?;
2370 Ok(RemoveTarget {
2371 var: item.target.0,
2372 is_edge,
2373 prop_name: item.prop_name.clone(),
2374 })
2375 })
2376 .collect::<Result<_, LoweringError>>()?;
2377
2378 let node = GraphRemoveNode::new(
2379 Arc::new(input),
2380 targets,
2381 self.type_id_to_entity_name.clone(),
2382 dir.to_path_buf(),
2383 mode,
2384 );
2385 Ok(LogicalPlan::Extension(Extension {
2386 node: Arc::new(node),
2387 }))
2388 }
2389}
2390
2391fn lower_relational_op(
2397 op: &GraphOp,
2398 input: LogicalPlan,
2399 exprs: &ExprArena,
2400 expr_lowerer: &ExprLowerer<'_>,
2401) -> Result<LogicalPlan, LoweringError> {
2402 match op {
2403 GraphOp::Filter { predicate } => lower_filter(*predicate, input, expr_lowerer),
2404 GraphOp::Project { items, distinct } => {
2405 lower_project(items, *distinct, input, expr_lowerer)
2406 }
2407 GraphOp::Aggregate {
2408 group_by,
2409 group_aliases,
2410 aggs,
2411 ..
2412 } => lower_aggregate(
2413 group_by,
2414 group_aliases,
2415 aggs,
2416 input,
2417 exprs,
2418 expr_lowerer,
2419 None,
2420 ),
2421 GraphOp::Sort { keys } => lower_sort(keys, input, expr_lowerer),
2422 GraphOp::Limit { count } => lower_limit(*count, input),
2423 GraphOp::Skip { count } => lower_skip(*count, input),
2424 other => Err(LoweringError::UnsupportedExpr(format!(
2425 "operator not yet lowered (deferred to #577+): {other:?}"
2426 ))),
2427 }
2428}
2429
2430fn lower_filter(
2431 predicate: ExprId,
2432 input: LogicalPlan,
2433 lowerer: &ExprLowerer<'_>,
2434) -> Result<LogicalPlan, LoweringError> {
2435 let df_pred = lowerer.lower(predicate)?;
2436 LogicalPlanBuilder::from(input)
2437 .filter(df_pred)
2438 .and_then(LogicalPlanBuilder::build)
2439 .map_unsupported_expr()
2440}
2441
2442fn lower_project(
2443 items: &[ProjectItem],
2444 distinct: bool,
2445 input: LogicalPlan,
2446 lowerer: &ExprLowerer<'_>,
2447) -> Result<LogicalPlan, LoweringError> {
2448 let select_exprs: Vec<DfExpr> = items
2449 .iter()
2450 .map(|item| {
2451 let e = lowerer.lower(item.expr)?;
2452 Ok::<_, LoweringError>(match &item.alias {
2453 Some(alias) => e.alias(alias),
2454 None => e,
2455 })
2456 })
2457 .collect::<Result<_, _>>()?;
2458
2459 let plan = LogicalPlanBuilder::from(input)
2460 .project(select_exprs)
2461 .and_then(LogicalPlanBuilder::build)
2462 .map_unsupported_expr()?;
2463
2464 if distinct {
2465 LogicalPlanBuilder::from(plan)
2466 .distinct()
2467 .and_then(LogicalPlanBuilder::build)
2468 .map_unsupported_expr()
2469 } else {
2470 Ok(plan)
2471 }
2472}
2473
2474fn input_sort_exprs(input: &LogicalPlan) -> Vec<SortExpr> {
2475 match input {
2476 LogicalPlan::Sort(sort) => sort.expr.clone(),
2477 LogicalPlan::Projection(projection) => input_sort_exprs(&projection.input)
2478 .into_iter()
2479 .enumerate()
2480 .filter_map(|(index, sort)| {
2481 let name = format!("{INPUT_ORDER_COLUMN_PREFIX}{index}");
2482 projection
2483 .schema
2484 .fields()
2485 .iter()
2486 .any(|field| field.name() == &name)
2487 .then(|| {
2488 SortExpr::new(
2489 DfExpr::Column(datafusion::common::Column::from_name(name)),
2490 sort.asc,
2491 sort.nulls_first,
2492 )
2493 })
2494 })
2495 .collect(),
2496 LogicalPlan::Filter(filter) => input_sort_exprs(&filter.input),
2497 _ => Vec::new(),
2498 }
2499}
2500
2501fn preserve_collect_order(
2502 func: AggFunc,
2503 aggregate: DfExpr,
2504 input_order: &[SortExpr],
2505) -> Result<DfExpr, LoweringError> {
2506 if func != AggFunc::Collect || input_order.is_empty() {
2507 return Ok(aggregate);
2508 }
2509 aggregate
2510 .order_by(input_order.to_vec())
2511 .build()
2512 .map_unsupported_expr()
2513}
2514
2515#[allow(
2516 clippy::too_many_lines,
2517 reason = "group shaping, row markers, aggregate lowering, and aliases form one operation"
2518)]
2519fn lower_aggregate(
2520 group_by: &[ExprId],
2521 group_aliases: &[Option<String>],
2522 aggs: &[AggExpr],
2523 input: LogicalPlan,
2524 exprs: &ExprArena,
2525 lowerer: &ExprLowerer<'_>,
2526 passthrough_groups: Option<(&[Option<VarId>], &VarMap)>,
2527) -> Result<LogicalPlan, LoweringError> {
2528 let input_order = input_sort_exprs(&input);
2529 let mut group_exprs = Vec::new();
2532 let mut row_marker_aliases = Vec::new();
2533 for (i, &id) in group_by.iter().enumerate() {
2534 let passthrough = passthrough_groups.and_then(|(group_vars, var_map)| {
2535 let group_var = group_vars.get(i).copied().flatten()?;
2536 match exprs.get(id) {
2537 IrExpr::VarRef(source) if *source == group_var => {
2538 var_map.get(group_var).map(|alias| (group_var, alias))
2539 }
2540 _ => None,
2541 }
2542 });
2543 if let Some((_var, alias)) = passthrough {
2544 let qualifier = datafusion::common::TableReference::bare(alias);
2545 let columns: Vec<DfExpr> = input
2546 .schema()
2547 .iter()
2548 .filter(|(q, _)| q.as_ref().is_some_and(|q| **q == qualifier))
2549 .map(|(q, field)| {
2550 DfExpr::Column(datafusion::common::Column::new(q.cloned(), field.name()))
2551 })
2552 .collect();
2553 if !columns.is_empty() {
2554 group_exprs.extend(columns);
2555 continue;
2556 }
2557 }
2558 let e = lowerer.lower(id)?;
2559 if matches!(
2560 &e,
2561 DfExpr::Literal(datafusion::scalar::ScalarValue::Null, _)
2562 ) && let Some((qualifier, field)) = input
2563 .schema()
2564 .iter()
2565 .find(|(_, field)| {
2566 matches!(
2567 field.name().as_str(),
2568 "node_uuid" | "edge_uuid" | "node_id" | "edge_id" | "src_id" | "dst_id"
2569 )
2570 })
2571 .or_else(|| input.schema().iter().next())
2572 {
2573 let present = DfExpr::Column(datafusion::common::Column::new(
2574 qualifier.cloned(),
2575 field.name(),
2576 ));
2577 let marker_alias = format!("__gf_row_marker_{i}");
2582 group_exprs.push(
2583 crate::expr::CYPHER_ROW_MARKER
2584 .call(vec![present])
2585 .alias(&marker_alias),
2586 );
2587 row_marker_aliases.push(marker_alias);
2588 }
2589 group_exprs.push(match group_aliases.get(i).and_then(Option::as_ref) {
2590 Some(alias) => e.alias(alias),
2591 None => e,
2592 });
2593 }
2594
2595 let aggr_exprs: Result<Vec<DfExpr>, LoweringError> = aggs
2596 .iter()
2597 .map(|a| {
2598 let mut arg = a.arg.map(|id| lowerer.lower(id)).transpose()?;
2599 if a.func == AggFunc::Count
2600 && arg.is_none()
2601 && let Some((qualifier, field)) = input.schema().iter().last()
2602 {
2603 let column = DfExpr::Column(datafusion::common::Column::new(
2604 qualifier.cloned(),
2605 field.name(),
2606 ));
2607 arg = Some(crate::expr::CYPHER_ROW_MARKER.call(vec![column]));
2608 }
2609 let percentile = a.percentile.map(|id| lowerer.lower(id)).transpose()?;
2610 let arg_type = arg.as_ref().and_then(|e| {
2613 use datafusion::logical_expr::ExprSchemable;
2614 e.get_type(input.schema()).ok()
2615 });
2616 let df_agg = lower_agg_func(a.func, arg, percentile, arg_type.as_ref())?;
2617 let df_agg = preserve_collect_order(a.func, df_agg, &input_order)?;
2618 Ok(df_agg.alias(&a.alias))
2619 })
2620 .collect();
2621
2622 let aggregate = LogicalPlanBuilder::from(input)
2623 .aggregate(group_exprs, aggr_exprs?)
2624 .and_then(LogicalPlanBuilder::build)
2625 .map_unsupported_expr()?;
2626 if row_marker_aliases.is_empty() {
2627 return Ok(aggregate);
2628 }
2629
2630 let visible_columns = aggregate
2631 .schema()
2632 .iter()
2633 .filter(|(_, field)| !row_marker_aliases.iter().any(|alias| alias == field.name()))
2634 .map(|(qualifier, field)| {
2635 DfExpr::Column(datafusion::common::Column::new(
2636 qualifier.cloned(),
2637 field.name(),
2638 ))
2639 })
2640 .collect::<Vec<_>>();
2641 LogicalPlanBuilder::from(aggregate)
2642 .project(visible_columns)
2643 .and_then(LogicalPlanBuilder::build)
2644 .map_unsupported_expr()
2645}
2646
2647fn lower_sort(
2648 keys: &[graphforge_ir::SortKey],
2649 input: LogicalPlan,
2650 lowerer: &ExprLowerer<'_>,
2651) -> Result<LogicalPlan, LoweringError> {
2652 let sort_exprs: Result<Vec<SortExpr>, LoweringError> = keys
2653 .iter()
2654 .map(|k| {
2655 let e = lowerer.lower(k.expr)?;
2656 let e = match e.get_type(input.schema()) {
2657 Ok(dt) if crate::expr::needs_cypher_order_key_type(&dt) => {
2658 crate::expr::CYPHER_ORDER_KEY.call(vec![e])
2659 }
2660 Err(_) => crate::expr::CYPHER_ORDER_KEY.call(vec![e]),
2661 _ => e,
2662 };
2663 Ok(SortExpr::new(e, k.order == SortOrder::Asc, k.nulls_first))
2664 })
2665 .collect();
2666
2667 LogicalPlanBuilder::from(input)
2668 .sort(sort_exprs?)
2669 .and_then(LogicalPlanBuilder::build)
2670 .map_unsupported_expr()
2671}
2672
2673fn lower_limit(count: u64, input: LogicalPlan) -> Result<LogicalPlan, LoweringError> {
2674 let fetch = usize::try_from(count).map_err(|_| {
2675 LoweringError::UnsupportedExpr(format!("LIMIT count {count} exceeds platform usize::MAX"))
2676 })?;
2677 LogicalPlanBuilder::from(input)
2678 .limit(0, Some(fetch))
2679 .and_then(LogicalPlanBuilder::build)
2680 .map_unsupported_expr()
2681}
2682
2683fn lower_skip(count: u64, input: LogicalPlan) -> Result<LogicalPlan, LoweringError> {
2684 let skip = usize::try_from(count).map_err(|_| {
2685 LoweringError::UnsupportedExpr(format!("SKIP count {count} exceeds platform usize::MAX"))
2686 })?;
2687 LogicalPlanBuilder::from(input)
2688 .limit(skip, None)
2689 .and_then(LogicalPlanBuilder::build)
2690 .map_unsupported_expr()
2691}
2692
2693fn lower_agg_func(
2694 func: AggFunc,
2695 arg: Option<DfExpr>,
2696 percentile: Option<DfExpr>,
2697 arg_type: Option<&datafusion::arrow::datatypes::DataType>,
2698) -> Result<DfExpr, LoweringError> {
2699 let het = crate::expr::is_het_struct_type(arg_type);
2702 match func {
2703 AggFunc::Count => Ok(match arg {
2704 Some(e) => count(e),
2705 None => count_all(),
2706 }),
2707 AggFunc::CountDistinct => Ok(count_distinct(arg.ok_or_else(|| {
2708 LoweringError::UnsupportedExpr("COUNT DISTINCT requires an argument".into())
2709 })?)),
2710 AggFunc::Sum => Ok(sum(arg.ok_or_else(|| {
2711 LoweringError::UnsupportedExpr("SUM requires an argument".into())
2712 })?)),
2713 AggFunc::SumDistinct => Ok(sum_distinct(arg.ok_or_else(|| {
2714 LoweringError::UnsupportedExpr("SUM DISTINCT requires an argument".into())
2715 })?)),
2716 AggFunc::Avg => {
2717 let arg = arg
2718 .ok_or_else(|| LoweringError::UnsupportedExpr("AVG requires an argument".into()))?;
2719 let arg = if matches!(arg_type, Some(datafusion::arrow::datatypes::DataType::Null)) {
2720 datafusion::logical_expr::expr_fn::cast(
2721 arg,
2722 datafusion::arrow::datatypes::DataType::Float64,
2723 )
2724 } else {
2725 arg
2726 };
2727 Ok(avg(arg))
2728 }
2729 AggFunc::AvgDistinct => {
2730 let arg = arg.ok_or_else(|| {
2731 LoweringError::UnsupportedExpr("AVG DISTINCT requires an argument".into())
2732 })?;
2733 let arg = if matches!(arg_type, Some(datafusion::arrow::datatypes::DataType::Null)) {
2734 datafusion::logical_expr::expr_fn::cast(
2735 arg,
2736 datafusion::arrow::datatypes::DataType::Float64,
2737 )
2738 } else {
2739 arg
2740 };
2741 Ok(avg_distinct(arg))
2742 }
2743 AggFunc::Min => {
2744 let a = arg
2745 .ok_or_else(|| LoweringError::UnsupportedExpr("MIN requires an argument".into()))?;
2746 Ok(if het {
2747 crate::expr::CYPHER_MIN.call(vec![a])
2748 } else {
2749 min(a)
2750 })
2751 }
2752 AggFunc::Max => {
2753 let a = arg
2754 .ok_or_else(|| LoweringError::UnsupportedExpr("MAX requires an argument".into()))?;
2755 Ok(if het {
2756 crate::expr::CYPHER_MAX.call(vec![a])
2757 } else {
2758 max(a)
2759 })
2760 }
2761 AggFunc::Collect => Ok(crate::expr::CYPHER_COLLECT.call(vec![arg.ok_or_else(|| {
2762 LoweringError::UnsupportedExpr("COLLECT requires an argument".into())
2763 })?])),
2764 AggFunc::CollectDistinct => {
2765 Ok(
2766 crate::expr::CYPHER_COLLECT_DISTINCT.call(vec![arg.ok_or_else(|| {
2767 LoweringError::UnsupportedExpr("COLLECT requires an argument".into())
2768 })?]),
2769 )
2770 }
2771 AggFunc::PercentileDisc => Ok(crate::expr::CYPHER_PERCENTILE_DISC.call(vec![
2772 arg.ok_or_else(|| {
2773 LoweringError::UnsupportedExpr("percentileDisc requires a value argument".into())
2774 })?,
2775 percentile.ok_or_else(|| {
2776 LoweringError::UnsupportedExpr(
2777 "percentileDisc requires a percentile argument".into(),
2778 )
2779 })?,
2780 ])),
2781 AggFunc::PercentileCont => Ok(crate::expr::CYPHER_PERCENTILE_CONT.call(vec![
2782 arg.ok_or_else(|| {
2783 LoweringError::UnsupportedExpr("percentileCont requires a value argument".into())
2784 })?,
2785 percentile.ok_or_else(|| {
2786 LoweringError::UnsupportedExpr(
2787 "percentileCont requires a percentile argument".into(),
2788 )
2789 })?,
2790 ])),
2791 }
2792}
2793
2794fn build_type_id_map(ontology: Option<&OntologyHandle>) -> HashMap<u32, String> {
2801 let mut map = HashMap::new();
2802 if let Some(h) = ontology {
2803 for name in h.relation_type_names() {
2804 if let Some(type_id) = h.relation_type_id(name) {
2805 map.insert(type_id.0, name.to_owned());
2806 }
2807 }
2808 }
2809 map
2810}
2811
2812fn build_inference_rules(ontology: Option<&OntologyHandle>) -> HashMap<u32, Vec<(String, String)>> {
2817 let mut map = HashMap::new();
2818 if let Some(h) = ontology {
2819 for name in h.relation_type_names() {
2820 if let Some(type_id) = h.relation_type_id(name) {
2821 let flags = h.semantic_flags(type_id);
2822 let mut rules = Vec::new();
2823 if flags.transitive {
2824 rules.push((format!("transitive:{name}"), "conservative_min".to_owned()));
2825 }
2826 if flags.symmetric {
2827 rules.push((format!("symmetric:{name}"), "conservative_min".to_owned()));
2828 }
2829 if !rules.is_empty() {
2830 map.insert(type_id.0, rules);
2831 }
2832 }
2833 }
2834 }
2835 map
2836}
2837
2838fn build_entity_id_map(ontology: Option<&OntologyHandle>) -> HashMap<u32, String> {
2841 let mut map = HashMap::new();
2842 if let Some(h) = ontology {
2843 for name in h.entity_type_names() {
2844 if let Some(type_id) = h.entity_type_id(name) {
2845 map.insert(type_id.0, name.to_owned());
2846 }
2847 }
2848 }
2849 map
2850}
2851
2852fn const_eval_scalar(df: &DfExpr) -> Option<datafusion::scalar::ScalarValue> {
2857 use datafusion::arrow::array::{RecordBatch, RecordBatchOptions};
2858 use datafusion::arrow::datatypes::Schema;
2859 use datafusion::execution::context::ExecutionProps;
2860 use datafusion::physical_expr::create_physical_expr;
2861 use datafusion::scalar::ScalarValue;
2862
2863 if let DfExpr::Literal(scalar, _) = df {
2864 return Some(scalar.clone());
2865 }
2866 let schema = datafusion::common::DFSchema::empty();
2867 let phys = create_physical_expr(df, &schema, &ExecutionProps::new()).ok()?;
2868 let batch = RecordBatch::try_new_with_options(
2869 std::sync::Arc::new(Schema::empty()),
2870 vec![],
2871 &RecordBatchOptions::new().with_row_count(Some(1)),
2872 )
2873 .ok()?;
2874 let array = phys.evaluate(&batch).ok()?.into_array(1).ok()?;
2875 ScalarValue::try_from_array(&array, 0).ok()
2876}
2877
2878type EvaluatedProps = (Vec<(String, IrLiteral)>, Vec<(String, DfExpr)>);
2892
2893fn eval_map_literal(
2894 lowerer: &GraphPlanLowerer,
2895 id: Option<ExprId>,
2896 exprs: &ExprArena,
2897 var_map: &VarMap,
2898 input_schema: Option<&datafusion::common::DFSchemaRef>,
2899) -> Result<EvaluatedProps, LoweringError> {
2900 let Some(id) = id else {
2901 return Ok((Vec::new(), Vec::new()));
2902 };
2903 let IrExpr::MapLiteral(pairs) = exprs.get(id) else {
2904 return Err(LoweringError::UnsupportedExpr(format!(
2905 "CREATE properties must be a map literal, got: {:?}",
2906 exprs.get(id)
2907 )));
2908 };
2909 let mut literals = Vec::new();
2910 let mut computed = Vec::new();
2911 for (k, vexpr) in pairs {
2912 if let IrExpr::Literal(lit) = exprs.get(*vexpr) {
2913 reject_map_property_value(k, lit)?;
2914 literals.push((k.clone(), lit.clone()));
2915 continue;
2916 }
2917 let mut expr_lowerer = lowerer.expr_lowerer(exprs, var_map);
2921 if let Some(schema) = input_schema {
2922 expr_lowerer = expr_lowerer.with_input_schema(schema.clone());
2923 }
2924 let df = expr_lowerer.lower(*vexpr)?;
2925 match const_eval_scalar(&df) {
2926 Some(scalar) => {
2929 let lit = crate::expr::scalar_to_ir_literal(&scalar)?;
2930 reject_map_property_value(k, &lit)?;
2931 literals.push((k.clone(), lit));
2932 }
2933 None => computed.push((k.clone(), df)),
2935 }
2936 }
2937 Ok((literals, computed))
2938}
2939
2940fn reject_map_property_value(prop_name: &str, lit: &IrLiteral) -> Result<(), LoweringError> {
2941 if contains_map_literal(lit) {
2942 return Err(LoweringError::UnsupportedExpr(format!(
2943 "CREATE property `{prop_name}` cannot store map values"
2944 )));
2945 }
2946 Ok(())
2947}
2948
2949fn contains_map_literal(lit: &IrLiteral) -> bool {
2950 match lit {
2951 IrLiteral::Map(_) => true,
2952 IrLiteral::List(items) => items.iter().any(contains_map_literal),
2953 _ => false,
2954 }
2955}
2956
2957fn var_alias(var: VarId) -> String {
2963 format!("var_{}", var.0)
2964}
2965
2966fn is_source_op(op: &GraphOp) -> bool {
2969 matches!(
2970 op,
2971 GraphOp::NodeScan { .. }
2972 | GraphOp::EdgeScan { .. }
2973 | GraphOp::TypedEdgeScan { .. }
2974 | GraphOp::Expand { .. }
2975 )
2976}
2977
2978fn table_source(schema: datafusion::arrow::datatypes::SchemaRef) -> Arc<LogicalTableSource> {
2981 Arc::new(LogicalTableSource::new(schema))
2982}
2983
2984fn node_scan_source(dir: Option<&Path>) -> Arc<dyn datafusion::logical_expr::TableSource> {
2991 use datafusion::datasource::provider_as_source;
2992 match dir {
2993 Some(d) => {
2994 let path = d.join("topology").join("nodes.parquet");
2995 provider_as_source(Arc::new(graphforge_storage::TopologyNodeTable::new(path)))
2996 }
2997 None => table_source(TOPOLOGY_NODES_SCHEMA.clone()),
2998 }
2999}
3000
3001fn edge_scan_source(
3012 dir: Option<&Path>,
3013 stem: &str,
3014 schema: datafusion::arrow::datatypes::SchemaRef,
3015 mode: OntologyMode,
3016) -> Arc<dyn datafusion::logical_expr::TableSource> {
3017 use datafusion::datasource::provider_as_source;
3018 match dir {
3019 Some(d)
3020 if stem == "_exploratory"
3021 && matches!(mode, OntologyMode::Strict | OntologyMode::Advisory) =>
3022 {
3023 provider_as_source(Arc::new(graphforge_storage::UnionEdgeTable::open(d)))
3024 }
3025 Some(d) => provider_as_source(Arc::new(graphforge_storage::TypedEdgeTable::open(d, stem))),
3026 None => table_source(schema),
3027 }
3028}
3029
3030fn filter_node_by_type(
3036 input: LogicalPlan,
3037 alias: &str,
3038 type_id: TypeId,
3039) -> Result<LogicalPlan, LoweringError> {
3040 use datafusion::functions_nested::expr_fn::array_has;
3041 use datafusion::logical_expr::{col, lit};
3042 LogicalPlanBuilder::from(input)
3043 .filter(array_has(col(format!("{alias}.type_ids")), lit(type_id.0)))
3044 .and_then(LogicalPlanBuilder::build)
3045 .map_unsupported_expr()
3046}
3047
3048fn enrich_bound_node_identity(
3049 input: &LogicalPlan,
3050 alias: &str,
3051 dir: Option<&Path>,
3052) -> Result<LogicalPlan, LoweringError> {
3053 use datafusion::common::Column;
3054 use datafusion::logical_expr::col;
3055
3056 let identity_alias = format!("__gf_identity_{alias}");
3057 let identity = LogicalPlanBuilder::scan(identity_alias.clone(), node_scan_source(dir), None)
3058 .and_then(LogicalPlanBuilder::build)
3059 .map_unsupported_expr()?;
3060 let joined = LogicalPlanBuilder::from(input.clone())
3061 .join(
3062 identity,
3063 datafusion::logical_expr::JoinType::Inner,
3064 (
3065 vec![Column::from_qualified_name(format!("{alias}.node_uuid"))],
3066 vec![Column::from_qualified_name(format!(
3067 "{identity_alias}.node_uuid"
3068 ))],
3069 ),
3070 None,
3071 )
3072 .and_then(LogicalPlanBuilder::build)
3073 .map_unsupported_expr()?;
3074 let mut projection = input
3075 .schema()
3076 .iter()
3077 .map(|(qualifier, field)| DfExpr::Column(Column::new(qualifier.cloned(), field.name())))
3078 .collect::<Vec<_>>();
3079 for name in ["node_id", "type_id", "type_ids"] {
3080 projection.push(col(format!("{identity_alias}.{name}")).alias_qualified(Some(alias), name));
3081 }
3082 LogicalPlanBuilder::from(joined)
3083 .project(projection)
3084 .and_then(LogicalPlanBuilder::build)
3085 .map_unsupported_expr()
3086}
3087
3088fn lower_node_scan(
3089 var: VarId,
3090 ty: Option<TypeId>,
3091 var_map: &mut VarMap,
3092 dir: Option<&Path>,
3093 pending_nodes: Option<&RecordBatch>,
3094) -> Result<LogicalPlan, LoweringError> {
3095 let alias = var_alias(var);
3096 var_map.insert(var, alias.clone());
3097
3098 let src = if let Some(batch) = pending_nodes.filter(|batch| batch.num_rows() > 0) {
3099 use datafusion::datasource::{MemTable, provider_as_source};
3100 let mut batches = dir
3101 .map(graphforge_storage::read_nodes)
3102 .transpose()
3103 .map_unsupported_expr()?
3104 .unwrap_or_default();
3105 batches.push(batch.clone());
3106 let table = MemTable::try_new(batch.schema(), vec![batches]).map_unsupported_expr()?;
3107 provider_as_source(Arc::new(table))
3108 } else {
3109 node_scan_source(dir)
3110 };
3111 let mut builder = LogicalPlanBuilder::scan(alias.clone(), src, None).map_unsupported_expr()?;
3112
3113 if let Some(type_id) = ty {
3114 use datafusion::functions_nested::expr_fn::array_has;
3115 use datafusion::logical_expr::{col, lit};
3116 builder = builder
3117 .filter(array_has(col(format!("{alias}.type_ids")), lit(type_id.0)))
3118 .map_unsupported_expr()?;
3119 }
3120
3121 builder.build().map_unsupported_expr()
3122}
3123
3124fn lower_typed_edge_scan(
3125 var: VarId,
3126 rel_ty: TypeId,
3127 var_map: &mut VarMap,
3128 catalog: Option<&GraphCatalog>,
3129 type_id_to_rel_name: &HashMap<u32, String>,
3130 dir: Option<&Path>,
3131 mode: OntologyMode,
3132) -> Result<LogicalPlan, LoweringError> {
3133 use datafusion::catalog::CatalogProvider;
3134 use datafusion::logical_expr::{col, lit};
3135
3136 let alias = var_alias(var);
3137 var_map.insert(var, alias.clone());
3138
3139 let rel_name = type_id_to_rel_name.get(&rel_ty.0).ok_or_else(|| {
3142 LoweringError::UnsupportedExpr(format!(
3143 "TypedEdgeScan: TypeId({}) has no known relation name; \
3144 ontology may be incomplete or stale",
3145 rel_ty.0
3146 ))
3147 })?;
3148
3149 let use_exploratory = catalog
3151 .and_then(|c| c.schema("graph"))
3152 .is_none_or(|s| !s.table_exist(&format!("edges_{rel_name}")));
3153
3154 if use_exploratory {
3155 let src = edge_scan_source(dir, "_exploratory", EXPLORATORY_EDGE_SCHEMA.clone(), mode);
3156 let filter_expr = col("rel_type_name").eq(lit(rel_name.as_str()));
3157 LogicalPlanBuilder::scan(alias, src, None)
3159 .and_then(|b| b.filter(filter_expr))
3160 .and_then(LogicalPlanBuilder::build)
3161 .map_unsupported_expr()
3162 } else {
3163 let src = edge_scan_source(dir, rel_name, TYPED_EDGE_SCHEMA.clone(), mode);
3164 LogicalPlanBuilder::scan(alias, src, None)
3167 .and_then(LogicalPlanBuilder::build)
3168 .map_unsupported_expr()
3169 }
3170}
3171
3172fn lower_edge_scan(
3173 var: VarId,
3174 ty: Option<TypeId>,
3175 var_map: &mut VarMap,
3176 type_id_to_rel_name: &HashMap<u32, String>,
3177 dir: Option<&Path>,
3178 mode: OntologyMode,
3179) -> Result<LogicalPlan, LoweringError> {
3180 use datafusion::logical_expr::{col, lit};
3181
3182 let alias = var_alias(var);
3183 var_map.insert(var, alias.clone());
3184
3185 let src = edge_scan_source(dir, "_exploratory", EXPLORATORY_EDGE_SCHEMA.clone(), mode);
3186 let mut builder = LogicalPlanBuilder::scan(alias, src, None).map_unsupported_expr()?;
3187
3188 if let Some(type_id) = ty
3189 && let Some(name) = type_id_to_rel_name.get(&type_id.0)
3190 {
3191 builder = builder
3192 .filter(col("rel_type_name").eq(lit(name.as_str())))
3193 .map_unsupported_expr()?;
3194 }
3195
3196 builder.build().map_unsupported_expr()
3197}
3198
3199fn unwind_element_field(
3209 list_expr: &DfExpr,
3210 input_schema: &datafusion::common::DFSchemaRef,
3211 property_names: impl IntoIterator<Item = impl AsRef<str>>,
3212) -> datafusion::arrow::datatypes::Field {
3213 use datafusion::arrow::datatypes::{DataType, Field, Fields};
3214 use datafusion::logical_expr::ExprSchemable;
3215
3216 let element_type =
3217 if let Ok(DataType::List(f) | DataType::LargeList(f) | DataType::FixedSizeList(f, _)) =
3218 list_expr.get_type(input_schema.as_ref())
3219 {
3220 f.data_type().clone()
3221 } else {
3222 let mut names: Vec<String> = property_names
3228 .into_iter()
3229 .map(|name| name.as_ref().to_owned())
3230 .collect();
3231 names.sort();
3232 names.dedup();
3233 if names.is_empty() {
3234 DataType::Int64
3235 } else {
3236 DataType::Struct(Fields::from(
3237 names
3238 .into_iter()
3239 .map(|name| Field::new(name, DataType::Null, true))
3240 .collect::<Vec<_>>(),
3241 ))
3242 }
3243 };
3244 Field::new("elem", element_type, true)
3245}
3246
3247fn merge_optional_child_vars(child_vm: &VarMap, var_map: &mut VarMap) {
3271 for v in child_vm.var_ids() {
3272 if var_map.get(v).is_none() {
3273 let child_col = child_vm
3274 .get(v)
3275 .expect("var_ids yields only registered vars")
3276 .to_owned();
3277 var_map.insert(v, child_col);
3278 }
3279 }
3280}
3281
3282fn promote_optional_entity_vars(
3283 outer: &LogicalPlan,
3284 inner: &LogicalPlan,
3285 child_vm: &VarMap,
3286 var_map: &mut VarMap,
3287) {
3288 use datafusion::common::TableReference;
3289
3290 for var in child_vm.var_ids() {
3291 let Some(outer_alias) = var_map.get(var) else {
3292 continue;
3293 };
3294 let Some(inner_alias) = child_vm.get(var) else {
3295 continue;
3296 };
3297 let outer_qual = TableReference::bare(outer_alias);
3298 let inner_qual = TableReference::bare(inner_alias);
3299 let outer_is_entity = ["node_uuid", "edge_uuid"].iter().any(|name| {
3300 outer
3301 .schema()
3302 .index_of_column_by_name(Some(&outer_qual), name)
3303 .is_some()
3304 });
3305 let inner_is_entity = ["node_uuid", "edge_uuid"].iter().any(|name| {
3306 inner
3307 .schema()
3308 .index_of_column_by_name(Some(&inner_qual), name)
3309 .is_some()
3310 });
3311 if !outer_is_entity && inner_is_entity {
3312 var_map.insert(var, inner_alias.to_owned());
3313 }
3314 }
3315}
3316
3317fn full_subquery_needs_outer_input(child: &GraphPlan, outer_vm: &VarMap) -> bool {
3318 outer_vm.var_ids().any(|var| {
3319 plan_references_var(child, var) && !child.ops.iter().any(|op| graph_op_binds_var(op, var))
3320 })
3321}
3322
3323fn plan_references_var(plan: &GraphPlan, var: VarId) -> bool {
3324 let expression_reference = (0..plan.exprs.len()).any(|index| {
3325 let index = u32::try_from(index).expect("ExprArena length is capped at u32::MAX");
3326 matches!(plan.exprs.get(ExprId(index)), IrExpr::VarRef(found) if *found == var)
3327 });
3328 expression_reference
3329 || plan.ops.iter().any(|op| {
3330 graph_op_binds_var(op, var)
3331 || match op {
3332 GraphOp::Optional { child }
3333 | GraphOp::Exists { child, .. }
3334 | GraphOp::PatternComprehension { child, .. }
3335 | GraphOp::ListElementPatternComprehension { child, .. } => {
3336 plan_references_var(child, var)
3337 }
3338 GraphOp::Union { inputs, .. } => {
3339 inputs.iter().any(|input| plan_references_var(input, var))
3340 }
3341 _ => false,
3342 }
3343 })
3344}
3345
3346fn graph_op_binds_var(op: &GraphOp, var: VarId) -> bool {
3347 match op {
3348 GraphOp::NodeScan { var: found, .. }
3349 | GraphOp::EdgeScan { var: found, .. }
3350 | GraphOp::TypedEdgeScan { var: found, .. } => *found == var,
3351 GraphOp::Expand { src, edge, dst, .. } => *src == var || *edge == var || *dst == var,
3352 _ => false,
3353 }
3354}
3355
3356fn optional_join_keys(
3357 outer: &LogicalPlan,
3358 inner: &LogicalPlan,
3359 outer_vm: &VarMap,
3360 inner_vm: &VarMap,
3361) -> (Vec<(usize, usize)>, Vec<usize>) {
3362 use datafusion::common::TableReference;
3363
3364 let outer_schema = outer.schema();
3365 let inner_schema = inner.schema();
3366
3367 let shared_cols = outer_vm
3368 .var_ids()
3369 .filter_map(|var| {
3370 let outer_col = outer_vm.get(var)?;
3371 let inner_col = inner_vm.get(var)?;
3372 Some((outer_col.to_owned(), inner_col.to_owned()))
3373 })
3374 .collect::<Vec<_>>();
3375
3376 let shared_quals: std::collections::HashSet<String> = shared_cols
3379 .iter()
3380 .filter_map(|(outer_col, inner_col)| {
3381 let outer_qual = TableReference::bare(outer_col.clone());
3382 ["node_id", "node_uuid", "edge_uuid"]
3383 .iter()
3384 .any(|identity| {
3385 outer_schema
3386 .index_of_column_by_name(Some(&outer_qual), identity)
3387 .is_some()
3388 })
3389 .then(|| inner_col.clone())
3390 })
3391 .collect();
3392
3393 let mut join_keys: Vec<(usize, usize)> = Vec::new();
3394 for (outer_col, inner_col) in shared_cols {
3395 let outer_qual = TableReference::bare(outer_col.clone());
3396 let inner_qual = TableReference::bare(inner_col);
3397 for identity in ["node_id", "node_uuid", "edge_uuid"] {
3398 if let (Some(o), Some(i)) = (
3399 outer_schema.index_of_column_by_name(Some(&outer_qual), identity),
3400 inner_schema.index_of_column_by_name(Some(&inner_qual), identity),
3401 ) {
3402 join_keys.push((o, i));
3403 break;
3404 }
3405 }
3406 if !join_keys.iter().any(|(_, inner)| {
3407 ["node_id", "node_uuid", "edge_uuid"]
3408 .iter()
3409 .any(|identity| {
3410 inner_schema.index_of_column_by_name(Some(&inner_qual), identity)
3411 == Some(*inner)
3412 })
3413 }) && let Some(o) = outer_schema.index_of_column_by_name(None, &outer_col)
3414 && let Some(i) = inner_schema
3415 .index_of_column_by_name(Some(&inner_qual), "node_uuid")
3416 .or_else(|| inner_schema.index_of_column_by_name(Some(&inner_qual), "edge_uuid"))
3417 {
3418 join_keys.push((o, i));
3419 }
3420 }
3421
3422 let inner_keep_idx = inner_schema
3426 .iter()
3427 .enumerate()
3428 .filter(|(_, (q, _))| q.is_none_or(|q| !shared_quals.contains(q.table())))
3429 .map(|(i, _)| i)
3430 .collect();
3431
3432 (join_keys, inner_keep_idx)
3433}
3434
3435fn schema_join_column(
3436 schema: &datafusion::common::DFSchema,
3437 index: usize,
3438) -> datafusion::common::Column {
3439 let (qualifier, field) = schema
3440 .iter()
3441 .nth(index)
3442 .expect("join key index must point at a schema field");
3443 datafusion::common::Column::new(qualifier.cloned(), field.name().to_owned())
3444}
3445
3446fn plan_columns(plan: &LogicalPlan) -> Vec<DfExpr> {
3447 use datafusion::common::Column;
3448 plan.schema()
3449 .iter()
3450 .map(|(qualifier, field)| {
3451 DfExpr::Column(Column::new(qualifier.cloned(), field.name().to_owned()))
3452 })
3453 .collect()
3454}
3455
3456#[allow(clippy::too_many_arguments)]
3460#[allow(
3461 clippy::too_many_lines,
3462 reason = "property discovery, bound-variable correlation, and extension shaping are one lowering operation"
3463)]
3464fn lower_var_len_expand(
3465 src: VarId,
3466 edge: VarId,
3467 dst: VarId,
3468 rel_ty: Option<TypeId>,
3469 dir: Direction,
3470 min_hops: u16,
3471 max_hops: Option<u16>,
3472 input: LogicalPlan,
3473 var_map: &mut VarMap,
3474 type_id_to_rel_name: &HashMap<u32, String>,
3475 inference_rules: &HashMap<u32, Vec<(String, String)>>,
3476 target: Option<(&Path, OntologyMode)>,
3477) -> Result<LogicalPlan, LoweringError> {
3478 use datafusion::logical_expr::col;
3479
3480 let bound_edge_list = var_map.get(edge).map(str::to_owned);
3481 let bound_dst = var_map.get(dst).map(str::to_owned);
3482 let traversal_dst = bound_dst
3483 .as_ref()
3484 .map_or(dst, |_| VarId(u32::MAX.saturating_sub(dst.0)));
3485 let rel_name = match rel_ty {
3486 Some(rt) => type_id_to_rel_name.get(&rt.0).cloned().ok_or_else(|| {
3487 LoweringError::UnsupportedExpr(format!(
3488 "VarLenExpand: TypeId({}) has no known relation name; \
3489 ontology may be incomplete or stale",
3490 rt.0
3491 ))
3492 })?,
3493 None => "*".to_owned(),
3494 };
3495 let infer_rules: Vec<(String, String)> = rel_ty
3500 .and_then(|rt| inference_rules.get(&rt.0))
3501 .cloned()
3502 .unwrap_or_default();
3503 let rel_for_infer = rel_name.clone();
3504 let (dir_path, mode) = target.ok_or_else(|| {
3508 LoweringError::UnsupportedExpr(
3509 "variable-length expand requires a project directory; \
3510 lower via new_for_writes or new_with_dir"
3511 .into(),
3512 )
3513 })?;
3514 let topology_names = ["edge_uuid", "src_uuid", "dst_uuid", "rel_type"];
3524 let prop_fields: Vec<datafusion::arrow::datatypes::Field> = if rel_name == "*" {
3525 let mut seen = std::collections::HashSet::new();
3526 let mut fields = Vec::new();
3527 for stem in graphforge_storage::list_edge_property_stems(dir_path) {
3528 let prop_table =
3529 graphforge_storage::EdgePropertyTable::open_discovered(dir_path, &stem);
3530 for f in prop_table.schema_ref().fields() {
3531 if topology_names.contains(&f.name().as_str()) {
3532 continue;
3533 }
3534 if seen.insert(f.name().clone()) {
3535 fields.push(f.as_ref().clone().with_nullable(true));
3536 }
3537 }
3538 }
3539 fields
3540 } else {
3541 let prop_table =
3542 graphforge_storage::EdgePropertyTable::open_discovered(dir_path, &rel_name);
3543 prop_table
3544 .schema_ref()
3545 .fields()
3546 .iter()
3547 .filter(|f| !topology_names.contains(&f.name().as_str()))
3548 .map(|f| f.as_ref().clone())
3549 .collect()
3550 };
3551
3552 let dst_fields = TOPOLOGY_NODES_SCHEMA.fields().iter().cloned().collect();
3558 let node = VarLenExpandNode::new(
3559 Arc::new(input),
3560 rel_name,
3561 min_hops,
3562 max_hops,
3563 src.0,
3564 traversal_dst.0,
3565 edge.0,
3566 dir,
3567 rel_ty.map(|t| t.0),
3568 dir_path.to_path_buf(),
3569 mode,
3570 dst_fields,
3571 graphforge_plan::var_len_edge_list_field(&prop_fields),
3572 );
3573 if bound_dst.is_none() {
3577 var_map.insert(dst, var_alias(dst));
3578 }
3579 var_map.insert(
3585 edge,
3586 format!(
3587 "var_{}.{}",
3588 edge.0,
3589 graphforge_plan::VAR_LEN_EDGE_LIST_FIELD
3590 ),
3591 );
3592 let mut base = LogicalPlan::Extension(Extension {
3593 node: Arc::new(node),
3594 });
3595 if let Some(bound_dst) = bound_dst {
3596 let traversal_alias = var_alias(traversal_dst);
3597 base = LogicalPlanBuilder::from(base)
3598 .filter(
3599 col(format!("{traversal_alias}.node_uuid"))
3600 .eq(col(format!("{bound_dst}.node_uuid"))),
3601 )
3602 .and_then(LogicalPlanBuilder::build)
3603 .map_unsupported_expr()?;
3604 let traversal_ref = datafusion::common::TableReference::bare(traversal_alias);
3605 let projection = base
3606 .schema()
3607 .iter()
3608 .filter(|(qualifier, _)| {
3609 qualifier
3610 .as_ref()
3611 .is_none_or(|qualifier| **qualifier != traversal_ref)
3612 })
3613 .map(|(qualifier, field)| {
3614 DfExpr::Column(datafusion::common::Column::new(
3615 qualifier.cloned(),
3616 field.name(),
3617 ))
3618 })
3619 .collect::<Vec<_>>();
3620 base = LogicalPlanBuilder::from(base)
3621 .project(projection)
3622 .and_then(LogicalPlanBuilder::build)
3623 .map_unsupported_expr()?;
3624 }
3625 if let Some(bound_edge_list) = bound_edge_list {
3626 let produced = format!(
3627 "var_{}.{}",
3628 edge.0,
3629 graphforge_plan::VAR_LEN_EDGE_LIST_FIELD
3630 );
3631 base = LogicalPlanBuilder::from(base)
3632 .filter(col(bound_edge_list).eq(col(produced)))
3633 .and_then(LogicalPlanBuilder::build)
3634 .map_unsupported_expr()?;
3635 }
3636 let plan = infer_rules
3640 .into_iter()
3641 .fold(base, |acc, (rule_id, conf_model)| {
3642 LogicalPlan::Extension(Extension {
3643 node: Arc::new(graphforge_plan::OntologyInferNode::new(
3644 Arc::new(acc),
3645 rel_for_infer.clone(),
3646 rule_id,
3647 conf_model,
3648 )),
3649 })
3650 });
3651 Ok(plan)
3652}
3653
3654#[allow(clippy::too_many_arguments)]
3655#[allow(clippy::too_many_lines)]
3656fn lower_expand(
3657 src: VarId,
3658 edge: VarId,
3659 dst: VarId,
3660 rel_ty: Option<TypeId>,
3661 dir: Direction,
3662 min_hops: u16,
3663 max_hops: Option<u16>,
3664 input: LogicalPlan,
3665 var_map: &mut VarMap,
3666 catalog: Option<&GraphCatalog>,
3667 type_id_to_rel_name: &HashMap<u32, String>,
3668 inference_rules: &HashMap<u32, Vec<(String, String)>>,
3669 target: Option<(&Path, OntologyMode)>,
3670 relational_reference: bool,
3671) -> Result<LogicalPlan, LoweringError> {
3672 if min_hops != 1 || max_hops != Some(1) {
3676 return lower_var_len_expand(
3677 src,
3678 edge,
3679 dst,
3680 rel_ty,
3681 dir,
3682 min_hops,
3683 max_hops,
3684 input,
3685 var_map,
3686 type_id_to_rel_name,
3687 inference_rules,
3688 target,
3689 );
3690 }
3691
3692 let src_alias = var_map
3696 .get(src)
3697 .ok_or(LoweringError::UnboundVar(src.0))?
3698 .to_owned();
3699
3700 if !relational_reference
3704 && let Some(plan) = try_lower_provider_expand(
3705 src,
3706 edge,
3707 dst,
3708 rel_ty,
3709 dir,
3710 &input,
3711 var_map,
3712 type_id_to_rel_name,
3713 target,
3714 )?
3715 {
3716 return Ok(plan);
3717 }
3718
3719 match dir {
3720 Direction::Out => expand_single_dir(
3721 src,
3722 edge,
3723 dst,
3724 rel_ty,
3725 &src_alias,
3726 true,
3727 input,
3728 var_map,
3729 catalog,
3730 type_id_to_rel_name,
3731 target,
3732 ),
3733 Direction::In => expand_single_dir(
3734 src,
3735 edge,
3736 dst,
3737 rel_ty,
3738 &src_alias,
3739 false,
3740 input,
3741 var_map,
3742 catalog,
3743 type_id_to_rel_name,
3744 target,
3745 ),
3746 Direction::Undirected => {
3747 use datafusion::common::Column;
3748 use datafusion::logical_expr::col;
3749
3750 let mut vm_out = var_map.clone();
3754 let mut vm_in = var_map.clone();
3755 let out_plan = expand_single_dir(
3756 src,
3757 edge,
3758 dst,
3759 rel_ty,
3760 &src_alias,
3761 true,
3762 input.clone(),
3763 &mut vm_out,
3764 catalog,
3765 type_id_to_rel_name,
3766 target,
3767 )?;
3768 let in_plan = expand_single_dir(
3769 src,
3770 edge,
3771 dst,
3772 rel_ty,
3773 &src_alias,
3774 false,
3775 input,
3776 &mut vm_in,
3777 catalog,
3778 type_id_to_rel_name,
3779 target,
3780 )?;
3781 if let Some(edge_col) = vm_out.get(edge) {
3783 var_map.insert(edge, edge_col.to_owned());
3784 }
3785 if let Some(dst_col) = vm_out.get(dst) {
3786 var_map.insert(dst, dst_col.to_owned());
3787 }
3788
3789 let edge_alias = var_alias(edge);
3800 let in_plan = LogicalPlanBuilder::from(in_plan)
3801 .filter(
3802 col(format!("{edge_alias}.src_id")).not_eq(col(format!("{edge_alias}.dst_id"))),
3803 )
3804 .and_then(LogicalPlanBuilder::build)
3805 .map_unsupported_expr()?;
3806
3807 let leg_schema = out_plan.schema().clone();
3821 let stable = |plan: LogicalPlan| -> Result<LogicalPlan, LoweringError> {
3822 let proj: Vec<DfExpr> = plan
3823 .schema()
3824 .iter()
3825 .enumerate()
3826 .map(|(i, (q, f))| {
3827 DfExpr::Column(Column::new(q.cloned(), f.name())).alias(format!("__u{i}"))
3828 })
3829 .collect();
3830 LogicalPlanBuilder::from(plan)
3831 .project(proj)
3832 .and_then(LogicalPlanBuilder::build)
3833 .map_unsupported_expr()
3834 };
3835 let out_plan = stable(out_plan)?;
3836 let in_plan = stable(in_plan)?;
3837 let unioned = LogicalPlanBuilder::from(out_plan)
3838 .union(in_plan)
3839 .and_then(LogicalPlanBuilder::build)
3840 .map_unsupported_expr()?;
3841 let projections: Vec<DfExpr> = leg_schema
3842 .iter()
3843 .enumerate()
3844 .map(|(i, (q, f))| {
3845 col(format!("__u{i}")).alias_qualified(q.cloned(), f.name().as_str())
3846 })
3847 .collect();
3848 LogicalPlanBuilder::from(unioned)
3849 .project(projections)
3850 .and_then(LogicalPlanBuilder::build)
3851 .map_unsupported_expr()
3852 }
3853 }
3854}
3855
3856#[allow(clippy::too_many_arguments)]
3865#[allow(
3866 clippy::too_many_lines,
3867 reason = "schema discovery, repeated-destination correlation, and extension shaping are one lowering operation"
3868)]
3869fn try_lower_provider_expand(
3870 src: VarId,
3871 edge: VarId,
3872 dst: VarId,
3873 rel_ty: Option<TypeId>,
3874 dir: Direction,
3875 input: &LogicalPlan,
3876 var_map: &mut VarMap,
3877 type_id_to_rel_name: &HashMap<u32, String>,
3878 target: Option<(&Path, OntologyMode)>,
3879) -> Result<Option<LogicalPlan>, LoweringError> {
3880 let Some((dir_path, mode)) = target else {
3881 return Ok(None); };
3883 if var_map.get(edge).is_some() {
3884 return Ok(None); }
3886 let rel_name = match rel_ty {
3887 Some(rt) => {
3888 let Some(name) = type_id_to_rel_name.get(&rt.0) else {
3889 return Ok(None); };
3891 name.clone()
3892 }
3893 None => "*".to_owned(),
3894 };
3895
3896 let bound_dst = var_map.get(dst).map(str::to_owned);
3897 let traversal_dst = bound_dst
3898 .as_ref()
3899 .map_or(dst, |_| VarId(u32::MAX.saturating_sub(dst.0)));
3900
3901 let edge_schema = if rel_ty.is_none() || matches!(mode, OntologyMode::Exploratory) {
3904 &*EXPLORATORY_EDGE_SCHEMA
3905 } else {
3906 &*TYPED_EDGE_SCHEMA
3907 };
3908 let edge_fields: Vec<Arc<datafusion::arrow::datatypes::Field>> =
3909 edge_schema.fields().iter().cloned().collect();
3910 let base_names: HashSet<&str> = edge_fields.iter().map(|f| f.name().as_str()).collect();
3911 let mut stems = if rel_name == "*" {
3912 graphforge_storage::list_edge_property_stems(dir_path)
3913 } else {
3914 vec![rel_name.clone()]
3915 };
3916 stems.sort();
3917 let mut seen = HashSet::new();
3918 let mut edge_prop_fields = Vec::new();
3919 for stem in stems {
3920 let prop_table = graphforge_storage::EdgePropertyTable::open_discovered(dir_path, &stem);
3921 for field in prop_table.schema_ref().fields() {
3922 if field.name() != "edge_uuid"
3923 && !base_names.contains(field.name().as_str())
3924 && seen.insert(field.name().clone())
3925 {
3926 edge_prop_fields.push(Arc::clone(field));
3927 }
3928 }
3929 }
3930 let dst_fields: Vec<Arc<datafusion::arrow::datatypes::Field>> =
3931 TOPOLOGY_NODES_SCHEMA.fields().iter().cloned().collect();
3932
3933 let node = graphforge_plan::ExpandNode::new(
3934 Arc::new(input.clone()),
3935 rel_name,
3936 src.0,
3937 traversal_dst.0,
3938 edge.0,
3939 dir,
3940 rel_ty.map(|rt| rt.0),
3941 dir_path.to_path_buf(),
3942 mode,
3943 edge_fields,
3944 edge_prop_fields,
3945 dst_fields,
3946 );
3947 var_map.insert(edge, var_alias(edge));
3950 if bound_dst.is_none() {
3951 var_map.insert(dst, var_alias(dst));
3952 }
3953
3954 let mut base = LogicalPlan::Extension(datafusion::logical_expr::Extension {
3955 node: Arc::new(node),
3956 });
3957 if let Some(bound_dst) = bound_dst {
3958 use datafusion::logical_expr::col;
3959
3960 let traversal_alias = var_alias(traversal_dst);
3961 base = LogicalPlanBuilder::from(base)
3962 .filter(
3963 col(format!("{traversal_alias}.node_uuid"))
3964 .eq(col(format!("{bound_dst}.node_uuid"))),
3965 )
3966 .and_then(LogicalPlanBuilder::build)
3967 .map_unsupported_expr()?;
3968 let traversal_ref = datafusion::common::TableReference::bare(traversal_alias);
3969 let projection = base
3970 .schema()
3971 .iter()
3972 .filter(|(qualifier, _)| {
3973 qualifier
3974 .as_ref()
3975 .is_none_or(|qualifier| **qualifier != traversal_ref)
3976 })
3977 .map(|(qualifier, field)| {
3978 DfExpr::Column(datafusion::common::Column::new(
3979 qualifier.cloned(),
3980 field.name(),
3981 ))
3982 })
3983 .collect::<Vec<_>>();
3984 base = LogicalPlanBuilder::from(base)
3985 .project(projection)
3986 .and_then(LogicalPlanBuilder::build)
3987 .map_unsupported_expr()?;
3988 }
3989 Ok(Some(base))
3990}
3991
3992#[allow(clippy::too_many_arguments)]
3997fn expand_single_dir(
3998 _src: VarId,
3999 edge: VarId,
4000 dst: VarId,
4001 rel_ty: Option<TypeId>,
4002 src_alias: &str,
4003 out_direction: bool,
4004 input: LogicalPlan,
4005 var_map: &mut VarMap,
4006 catalog: Option<&GraphCatalog>,
4007 type_id_to_rel_name: &HashMap<u32, String>,
4008 target: Option<(&Path, OntologyMode)>,
4009) -> Result<LogicalPlan, LoweringError> {
4010 use datafusion::logical_expr::col;
4011
4012 let bound_dst = var_map.get(dst).and_then(|alias| {
4013 let qualifier = datafusion::common::TableReference::bare(alias);
4014 if input
4015 .schema()
4016 .index_of_column_by_name(Some(&qualifier), "node_id")
4017 .is_some()
4018 {
4019 Some((alias.to_owned(), "node_id", false))
4020 } else {
4021 input
4022 .schema()
4023 .index_of_column_by_name(Some(&qualifier), "node_uuid")
4024 .is_some()
4025 .then(|| (alias.to_owned(), "node_uuid", true))
4026 }
4027 });
4028 let dir = target.map(|(d, _)| d);
4029 let mode = target.map_or(OntologyMode::Exploratory, |(_, m)| m);
4030 if let Some(edge_alias) = var_map.get(edge).map(str::to_owned) {
4031 return expand_bound_edge_single_dir(
4032 dst,
4033 rel_ty,
4034 src_alias,
4035 &edge_alias,
4036 out_direction,
4037 input,
4038 var_map,
4039 type_id_to_rel_name,
4040 target,
4041 );
4042 }
4043
4044 let edge_plan = match rel_ty {
4046 Some(rt) => {
4047 lower_typed_edge_scan(edge, rt, var_map, catalog, type_id_to_rel_name, dir, mode)?
4048 }
4049 None => lower_edge_scan(edge, None, var_map, type_id_to_rel_name, dir, mode)?,
4050 };
4051 let edge_alias = var_map
4052 .get(edge)
4053 .ok_or(LoweringError::UnboundVar(edge.0))?
4054 .to_owned();
4055
4056 let edge_plan = join_edge_properties(&edge_alias, rel_ty, type_id_to_rel_name, dir, edge_plan)?;
4060
4061 let src_col = col(format!("{src_alias}.node_id"));
4063 let edge_src_col = col(if out_direction {
4064 format!("{edge_alias}.src_id")
4065 } else {
4066 format!("{edge_alias}.dst_id")
4067 });
4068
4069 let join_pred = src_col.eq(edge_src_col);
4071 let joined = LogicalPlanBuilder::from(input)
4072 .join_on(edge_plan, JoinType::Inner, vec![join_pred])
4073 .and_then(LogicalPlanBuilder::build)
4074 .map_unsupported_expr()?;
4075
4076 let edge_dst_col = col(
4077 match (out_direction, bound_dst.as_ref().map(|(_, _, uuid)| *uuid)) {
4078 (true, Some(true)) => format!("{edge_alias}.dst_uuid"),
4079 (false, Some(true)) => format!("{edge_alias}.src_uuid"),
4080 (true, _) => format!("{edge_alias}.dst_id"),
4081 (false, _) => format!("{edge_alias}.src_id"),
4082 },
4083 );
4084 if let Some((dst_alias, dst_field, _)) = bound_dst {
4087 return LogicalPlanBuilder::from(joined)
4088 .filter(edge_dst_col.eq(col(format!("{dst_alias}.{dst_field}"))))
4089 .and_then(LogicalPlanBuilder::build)
4090 .map_unsupported_expr();
4091 }
4092
4093 let dst_plan = lower_node_scan(dst, None, var_map, dir, None)?;
4095 let dst_alias = var_map
4096 .get(dst)
4097 .ok_or(LoweringError::UnboundVar(dst.0))?
4098 .to_owned();
4099
4100 let dst_col = col(format!("{dst_alias}.node_id"));
4101
4102 let join_pred2 = edge_dst_col.eq(dst_col);
4103 LogicalPlanBuilder::from(joined)
4104 .join_on(dst_plan, JoinType::Inner, vec![join_pred2])
4105 .and_then(LogicalPlanBuilder::build)
4106 .map_unsupported_expr()
4107}
4108
4109#[allow(clippy::too_many_arguments)]
4110fn expand_bound_edge_single_dir(
4111 dst: VarId,
4112 rel_ty: Option<TypeId>,
4113 src_alias: &str,
4114 edge_alias: &str,
4115 out_direction: bool,
4116 input: LogicalPlan,
4117 var_map: &mut VarMap,
4118 type_id_to_rel_name: &HashMap<u32, String>,
4119 target: Option<(&Path, OntologyMode)>,
4120) -> Result<LogicalPlan, LoweringError> {
4121 use datafusion::common::TableReference;
4122 use datafusion::logical_expr::{col, lit};
4123
4124 let edge_src_field = if out_direction { "src_id" } else { "dst_id" };
4125 let edge_dst_field = if out_direction { "dst_id" } else { "src_id" };
4126 let mut predicate =
4127 col(format!("{src_alias}.node_id")).eq(col(format!("{edge_alias}.{edge_src_field}")));
4128
4129 if let Some(rt) = rel_ty {
4130 let rel_name = type_id_to_rel_name.get(&rt.0).ok_or_else(|| {
4131 LoweringError::UnsupportedExpr(format!(
4132 "bound edge TypeId({}) has no known relation name; ontology may be incomplete or stale",
4133 rt.0
4134 ))
4135 })?;
4136 let qual = TableReference::bare(edge_alias);
4137 if input
4138 .schema()
4139 .index_of_column_by_name(Some(&qual), "rel_type_name")
4140 .is_some()
4141 {
4142 predicate = predicate
4143 .and(col(format!("{edge_alias}.rel_type_name")).eq(lit(rel_name.as_str())));
4144 }
4145 }
4146
4147 let filtered = LogicalPlanBuilder::from(input)
4148 .filter(predicate)
4149 .and_then(LogicalPlanBuilder::build)
4150 .map_unsupported_expr()?;
4151
4152 let edge_dst = col(format!("{edge_alias}.{edge_dst_field}"));
4153 if let Some(dst_alias) = var_map.get(dst).map(str::to_owned) {
4154 return LogicalPlanBuilder::from(filtered)
4155 .filter(edge_dst.eq(col(format!("{dst_alias}.node_id"))))
4156 .and_then(LogicalPlanBuilder::build)
4157 .map_unsupported_expr();
4158 }
4159
4160 let dir = target.map(|(d, _)| d);
4161 let dst_plan = lower_node_scan(dst, None, var_map, dir, None)?;
4162 let dst_alias = var_map
4163 .get(dst)
4164 .ok_or(LoweringError::UnboundVar(dst.0))?
4165 .to_owned();
4166 LogicalPlanBuilder::from(filtered)
4167 .join_on(
4168 dst_plan,
4169 JoinType::Inner,
4170 vec![edge_dst.eq(col(format!("{dst_alias}.node_id")))],
4171 )
4172 .and_then(LogicalPlanBuilder::build)
4173 .map_unsupported_expr()
4174}
4175
4176fn join_edge_properties(
4191 edge_alias: &str,
4192 rel_ty: Option<TypeId>,
4193 type_id_to_rel_name: &HashMap<u32, String>,
4194 dir: Option<&Path>,
4195 scan: LogicalPlan,
4196) -> Result<LogicalPlan, LoweringError> {
4197 use datafusion::logical_expr::{col, lit};
4198 use std::collections::{HashMap as StdHashMap, HashSet};
4199
4200 let Some(dir) = dir else {
4201 return Ok(scan); };
4203
4204 let base_cols: Vec<String> = scan
4207 .schema()
4208 .fields()
4209 .iter()
4210 .map(|f| f.name().clone())
4211 .collect();
4212
4213 let mut prop_sources = Vec::new();
4214 let mut prop_order = Vec::new();
4215 let mut seen_props = HashSet::new();
4216 let mut push_source = |stem: String| {
4217 let table = graphforge_storage::EdgePropertyTable::open_discovered(dir, &stem);
4218 let prop_cols: Vec<String> = table
4219 .schema_ref()
4220 .fields()
4221 .iter()
4222 .map(|f| f.name().clone())
4223 .filter(|n| !base_cols.contains(n))
4224 .collect();
4225 if prop_cols.is_empty() {
4226 return;
4227 }
4228 for name in &prop_cols {
4229 if seen_props.insert(name.clone()) {
4230 prop_order.push(name.clone());
4231 }
4232 }
4233 prop_sources.push((stem, table, prop_cols));
4234 };
4235 if let Some(rel_ty) = rel_ty {
4236 let Some(rel_name) = type_id_to_rel_name.get(&rel_ty.0) else {
4237 return Ok(scan); };
4239 push_source(rel_name.clone());
4240 } else {
4241 for stem in graphforge_storage::list_edge_property_stems(dir) {
4242 push_source(stem);
4243 }
4244 }
4245 if prop_sources.is_empty() {
4246 return Ok(scan);
4247 }
4248
4249 let wildcard = rel_ty.is_none();
4250 let mut joined = scan;
4251 let mut prop_refs: StdHashMap<String, Vec<DfExpr>> = StdHashMap::new();
4252 for (idx, (stem, prop_table, prop_cols)) in prop_sources.into_iter().enumerate() {
4253 let prop_alias = format!("{edge_alias}__eprops_{idx}");
4254 let prop_src = datafusion::datasource::provider_as_source(Arc::new(prop_table));
4255 let prop_scan = LogicalPlanBuilder::scan(prop_alias.clone(), prop_src, None)
4256 .and_then(LogicalPlanBuilder::build)
4257 .map_unsupported_expr()?;
4258
4259 let mut join_pred =
4263 col(format!("{edge_alias}.edge_uuid")).eq(col(format!("{prop_alias}.edge_uuid")));
4264 if wildcard {
4265 join_pred = join_pred.and(col(format!("{edge_alias}.rel_type_name")).eq(lit(stem)));
4266 }
4267 joined = LogicalPlanBuilder::from(joined)
4268 .join_on(prop_scan, JoinType::Left, vec![join_pred])
4269 .and_then(LogicalPlanBuilder::build)
4270 .map_unsupported_expr()?;
4271 for name in prop_cols {
4272 prop_refs
4273 .entry(name.clone())
4274 .or_default()
4275 .push(crate::expr::qualified_col(&prop_alias, &name));
4276 }
4277 }
4278
4279 let mut projections: Vec<DfExpr> = base_cols
4282 .iter()
4283 .map(|name| col(format!("{edge_alias}.{name}")))
4284 .collect();
4285 for name in prop_order {
4286 let mut refs = prop_refs.remove(&name).unwrap_or_default();
4287 let value = if refs.len() == 1 {
4288 refs.remove(0)
4289 } else {
4290 datafusion::functions::core::expr_fn::coalesce(refs)
4291 };
4292 projections.push(value.alias_qualified(Some(edge_alias), name.as_str()));
4293 }
4294
4295 LogicalPlanBuilder::from(joined)
4296 .project(projections)
4297 .and_then(LogicalPlanBuilder::build)
4298 .map_unsupported_expr()
4299}
4300
4301#[cfg(test)]
4306mod tests {
4307 use super::*;
4308 use datafusion::logical_expr::LogicalPlan as DfLogicalPlan;
4309 use graphforge_core::TypeId;
4310 use graphforge_ir::expr::{IrExpr, IrLiteral};
4311 use graphforge_ir::{Direction, ExprArena, GraphPlan, VarId};
4312
4313 fn empty_base() -> LogicalPlan {
4314 LogicalPlanBuilder::empty(false).build().unwrap()
4315 }
4316
4317 #[test]
4318 fn unsupported_error_mapping_preserves_success_and_exact_diagnostic() {
4319 let success: Result<u8, &str> = Ok(7);
4320 assert_eq!(success.map_unsupported_expr().unwrap(), 7);
4321
4322 let failure: Result<u8, &str> = Err("planner diagnostic: var_9");
4323 assert_eq!(
4324 failure.map_unsupported_expr().unwrap_err().to_string(),
4325 "unsupported expression: planner diagnostic: var_9"
4326 );
4327 }
4328
4329 fn make_catalog_and_lowerer() -> (
4330 tempfile::TempDir,
4331 graphforge_storage::GraphCatalog,
4332 graphforge_ir::RuntimeCatalog,
4333 ) {
4334 let dir = tempfile::TempDir::new().unwrap();
4335 let rc = graphforge_ir::RuntimeCatalog::new();
4336 let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
4337 (dir, catalog, rc)
4338 }
4339
4340 fn node_scan_with_alias(alias: &str) -> LogicalPlan {
4341 let schema = std::sync::Arc::new(datafusion::arrow::datatypes::Schema::new(vec![
4342 datafusion::arrow::datatypes::Field::new(
4343 "node_id",
4344 datafusion::arrow::datatypes::DataType::UInt64,
4345 false,
4346 ),
4347 ]));
4348 LogicalPlanBuilder::scan(alias, table_source(schema), None)
4349 .and_then(LogicalPlanBuilder::build)
4350 .unwrap()
4351 }
4352
4353 #[test]
4354 fn optional_join_keys_use_var_map_qualifiers() {
4355 let outer = node_scan_with_alias("projected_node");
4356 let inner = node_scan_with_alias("pattern_node");
4357 let mut outer_vm = VarMap::new();
4358 outer_vm.insert(VarId(7), "projected_node");
4359 let mut inner_vm = VarMap::new();
4360 inner_vm.insert(VarId(7), "pattern_node");
4361
4362 let (join_keys, inner_keep_idx) = optional_join_keys(&outer, &inner, &outer_vm, &inner_vm);
4363
4364 assert_eq!(join_keys, vec![(0, 0)]);
4365 assert_eq!(inner_keep_idx, Vec::<usize>::new());
4366 }
4367
4368 #[test]
4369 fn filter_lowers_predicate() {
4370 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4371 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4372
4373 let mut arena = ExprArena::new();
4374 let lit = arena.push(IrExpr::Literal(IrLiteral::Bool(true)));
4375 let var_map = VarMap::new();
4376 let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
4377
4378 let result = lowerer
4379 .lower_op(
4380 &GraphOp::Filter { predicate: lit },
4381 empty_base(),
4382 &arena,
4383 &var_map,
4384 &expr_lowerer,
4385 )
4386 .unwrap();
4387
4388 assert!(
4389 matches!(result, DfLogicalPlan::Filter(_)),
4390 "expected Filter, got {result:?}"
4391 );
4392 }
4393
4394 #[test]
4395 fn project_lowers_columns() {
4396 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4397 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4398
4399 let mut arena = ExprArena::new();
4400 let lit = arena.push(IrExpr::Literal(IrLiteral::Int(1)));
4401 let item = ProjectItem {
4402 expr: lit,
4403 alias: Some("x".into()),
4404 out_var: None,
4405 };
4406 let var_map = VarMap::new();
4407 let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
4408
4409 let result = lowerer
4410 .lower_op(
4411 &GraphOp::Project {
4412 items: vec![item],
4413 distinct: false,
4414 },
4415 empty_base(),
4416 &arena,
4417 &var_map,
4418 &expr_lowerer,
4419 )
4420 .unwrap();
4421
4422 assert!(
4423 matches!(result, DfLogicalPlan::Projection(_)),
4424 "expected Projection, got {result:?}"
4425 );
4426 }
4427
4428 #[test]
4429 fn project_with_distinct_wraps_in_distinct() {
4430 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4431 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4432
4433 let mut arena = ExprArena::new();
4434 let lit = arena.push(IrExpr::Literal(IrLiteral::Int(1)));
4435 let item = ProjectItem {
4436 expr: lit,
4437 alias: None,
4438 out_var: None,
4439 };
4440 let var_map = VarMap::new();
4441 let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
4442
4443 let result = lowerer
4444 .lower_op(
4445 &GraphOp::Project {
4446 items: vec![item],
4447 distinct: true,
4448 },
4449 empty_base(),
4450 &arena,
4451 &var_map,
4452 &expr_lowerer,
4453 )
4454 .unwrap();
4455
4456 assert!(
4457 matches!(result, DfLogicalPlan::Distinct(_)),
4458 "expected Distinct, got {result:?}"
4459 );
4460 }
4461
4462 #[test]
4463 fn with_where_scalar_alias_uses_projected_scope_then_drops_inputs() {
4464 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4465 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4466 let mut builder = GraphPlan::builder("openCypher");
4467 let value = builder.push_expr(IrExpr::Literal(IrLiteral::Bool(true)));
4468 let predicate = builder.push_expr(IrExpr::VarRef(VarId(9)));
4469 let plan = builder
4470 .push_op(GraphOp::With {
4471 items: vec![ProjectItem {
4472 expr: value,
4473 alias: Some("keep".into()),
4474 out_var: Some(VarId(9)),
4475 }],
4476 distinct: false,
4477 where_predicate: Some(predicate),
4478 })
4479 .build();
4480
4481 let lowered = lowerer.lower_plan(&plan).unwrap();
4482 let DfLogicalPlan::Projection(final_projection) = lowered else {
4483 panic!("expected final WITH projection");
4484 };
4485 assert_eq!(final_projection.schema.fields().len(), 1);
4486 assert_eq!(final_projection.schema.field(0).name(), "keep");
4487 assert!(matches!(
4488 final_projection.input.as_ref(),
4489 DfLogicalPlan::Filter(_)
4490 ));
4491 }
4492
4493 #[test]
4494 fn with_where_forwards_complete_node_shape_through_new_scope() {
4495 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4496 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4497 let mut builder = GraphPlan::builder("openCypher");
4498 let node = builder.push_expr(IrExpr::VarRef(VarId(0)));
4499 let predicate = builder.push_expr(IrExpr::Literal(IrLiteral::Bool(true)));
4500 let plan = builder
4501 .push_op(GraphOp::NodeScan {
4502 var: VarId(0),
4503 ty: None,
4504 })
4505 .push_op(GraphOp::With {
4506 items: vec![ProjectItem {
4507 expr: node,
4508 alias: Some("n".into()),
4509 out_var: Some(VarId(0)),
4510 }],
4511 distinct: false,
4512 where_predicate: Some(predicate),
4513 })
4514 .build();
4515
4516 let lowered = lowerer.lower_plan(&plan).unwrap();
4517 let DfLogicalPlan::Projection(final_projection) = lowered else {
4518 panic!("expected final WITH projection");
4519 };
4520 assert!(final_projection.schema.fields().len() >= 4);
4521 assert!(final_projection.schema.iter().all(|(qualifier, _)| {
4522 qualifier.is_some_and(|qualifier| qualifier.table() == "var_0")
4523 }));
4524 assert!(matches!(
4525 final_projection.input.as_ref(),
4526 DfLogicalPlan::Filter(_)
4527 ));
4528 }
4529
4530 #[test]
4531 fn aggregate_count_star() {
4532 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4533 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4534
4535 let arena = ExprArena::new();
4536 let agg = AggExpr {
4537 func: AggFunc::Count,
4538 arg: None,
4539 percentile: None,
4540 alias: "total".into(),
4541 out_var: None,
4542 };
4543 let var_map = VarMap::new();
4544 let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
4545
4546 let result = lowerer
4547 .lower_op(
4548 &GraphOp::Aggregate {
4549 group_by: vec![],
4550 group_aliases: vec![],
4551 group_vars: vec![],
4552 aggs: vec![agg],
4553 },
4554 empty_base(),
4555 &arena,
4556 &var_map,
4557 &expr_lowerer,
4558 )
4559 .unwrap();
4560
4561 assert!(
4562 matches!(result, DfLogicalPlan::Aggregate(_)),
4563 "expected Aggregate, got {result:?}"
4564 );
4565 }
4566
4567 #[test]
4568 fn aggregate_function_argument_contract_matrix_and_specialized_paths() {
4569 use datafusion::arrow::datatypes::{DataType, Field, Fields};
4570 use datafusion::logical_expr::lit;
4571
4572 for (function, expected) in [
4573 (
4574 AggFunc::CountDistinct,
4575 "COUNT DISTINCT requires an argument",
4576 ),
4577 (AggFunc::Sum, "SUM requires an argument"),
4578 (AggFunc::SumDistinct, "SUM DISTINCT requires an argument"),
4579 (AggFunc::Avg, "AVG requires an argument"),
4580 (AggFunc::AvgDistinct, "AVG DISTINCT requires an argument"),
4581 (AggFunc::Min, "MIN requires an argument"),
4582 (AggFunc::Max, "MAX requires an argument"),
4583 (AggFunc::Collect, "COLLECT requires an argument"),
4584 (AggFunc::CollectDistinct, "COLLECT requires an argument"),
4585 (
4586 AggFunc::PercentileDisc,
4587 "percentileDisc requires a value argument",
4588 ),
4589 (
4590 AggFunc::PercentileCont,
4591 "percentileCont requires a value argument",
4592 ),
4593 ] {
4594 assert_eq!(
4595 lower_agg_func(function, None, None, None)
4596 .unwrap_err()
4597 .to_string(),
4598 format!("unsupported expression: {expected}")
4599 );
4600 }
4601
4602 for function in [AggFunc::PercentileDisc, AggFunc::PercentileCont] {
4603 assert!(
4604 lower_agg_func(function, Some(lit(1_i64)), None, Some(&DataType::Int64))
4605 .unwrap_err()
4606 .to_string()
4607 .contains("percentile argument")
4608 );
4609 }
4610
4611 for function in [
4612 AggFunc::Count,
4613 AggFunc::CountDistinct,
4614 AggFunc::Sum,
4615 AggFunc::SumDistinct,
4616 AggFunc::Avg,
4617 AggFunc::AvgDistinct,
4618 AggFunc::Min,
4619 AggFunc::Max,
4620 AggFunc::Collect,
4621 AggFunc::CollectDistinct,
4622 ] {
4623 let arg = (function != AggFunc::Count).then(|| lit(1_i64));
4624 assert!(lower_agg_func(function, arg, None, Some(&DataType::Int64)).is_ok());
4625 }
4626 assert!(
4627 lower_agg_func(
4628 AggFunc::Avg,
4629 Some(lit(datafusion::scalar::ScalarValue::Null)),
4630 None,
4631 Some(&DataType::Null),
4632 )
4633 .is_ok()
4634 );
4635 assert!(
4636 lower_agg_func(
4637 AggFunc::AvgDistinct,
4638 Some(lit(datafusion::scalar::ScalarValue::Null)),
4639 None,
4640 Some(&DataType::Null),
4641 )
4642 .is_ok()
4643 );
4644
4645 let heterogeneous = DataType::Struct(Fields::from(vec![
4646 Field::new("__het_tag", DataType::Int8, false),
4647 Field::new("__het_value_0", DataType::Int64, true),
4648 ]));
4649 for function in [AggFunc::Min, AggFunc::Max] {
4650 let expression = lower_agg_func(
4651 function,
4652 Some(lit(datafusion::scalar::ScalarValue::Null)),
4653 None,
4654 Some(&heterogeneous),
4655 )
4656 .unwrap();
4657 assert!(format!("{expression}").contains("cypher_"));
4658 }
4659 }
4660
4661 #[test]
4662 fn sort_lowers_keys() {
4663 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4664 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4665
4666 let mut arena = ExprArena::new();
4669 let lit = arena.push(IrExpr::Literal(IrLiteral::Int(1)));
4670 let var_map = VarMap::new();
4671 let key = graphforge_ir::SortKey {
4672 expr: lit,
4673 order: SortOrder::Desc,
4674 nulls_first: false,
4675 };
4676 let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
4677
4678 let result = lowerer
4679 .lower_op(
4680 &GraphOp::Sort { keys: vec![key] },
4681 empty_base(),
4682 &arena,
4683 &var_map,
4684 &expr_lowerer,
4685 )
4686 .unwrap();
4687
4688 assert!(
4689 matches!(result, DfLogicalPlan::Sort(_)),
4690 "expected Sort, got {result:?}"
4691 );
4692 }
4693
4694 #[test]
4695 fn limit_lowers_correctly() {
4696 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4697 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4698
4699 let arena = ExprArena::new();
4700 let var_map = VarMap::new();
4701 let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
4702
4703 let result = lowerer
4704 .lower_op(
4705 &GraphOp::Limit { count: 10 },
4706 empty_base(),
4707 &arena,
4708 &var_map,
4709 &expr_lowerer,
4710 )
4711 .unwrap();
4712
4713 assert!(
4714 matches!(result, DfLogicalPlan::Limit(_)),
4715 "expected Limit, got {result:?}"
4716 );
4717 }
4718
4719 #[test]
4720 fn skip_lowers_correctly() {
4721 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4722 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4723
4724 let arena = ExprArena::new();
4725 let var_map = VarMap::new();
4726 let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
4727
4728 let result = lowerer
4729 .lower_op(
4730 &GraphOp::Skip { count: 5 },
4731 empty_base(),
4732 &arena,
4733 &var_map,
4734 &expr_lowerer,
4735 )
4736 .unwrap();
4737
4738 assert!(
4740 matches!(result, DfLogicalPlan::Limit(_)),
4741 "expected Limit (skip), got {result:?}"
4742 );
4743 }
4744
4745 #[test]
4746 fn unsupported_op_returns_error() {
4747 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4748 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4749
4750 let arena = ExprArena::new();
4751 let var_map = VarMap::new();
4752 let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
4753
4754 let result = lowerer.lower_op(
4755 &GraphOp::NodeScan {
4756 var: VarId(0),
4757 ty: None,
4758 },
4759 empty_base(),
4760 &arena,
4761 &var_map,
4762 &expr_lowerer,
4763 );
4764 assert!(
4765 matches!(result, Err(LoweringError::UnsupportedExpr(_))),
4766 "expected UnsupportedExpr for NodeScan"
4767 );
4768 }
4769
4770 #[test]
4771 fn statement_driver_only_write_forms_are_rejected_by_relational_lowering() {
4772 use graphforge_ir::{LabelItem, RemovePropItem, SetMapItem};
4773
4774 let dir = tempfile::tempdir().unwrap();
4775 let lowerer = GraphPlanLowerer::new_for_writes(
4776 None,
4777 None,
4778 dir.path(),
4779 graphforge_core::OntologyMode::Exploratory,
4780 );
4781
4782 let mut set_builder = GraphPlan::builder("openCypher");
4783 let map = set_builder.push_expr(IrExpr::MapLiteral(vec![]));
4784 let set_map = set_builder
4785 .push_op(GraphOp::Set {
4786 items: vec![],
4787 map_items: vec![SetMapItem {
4788 target: VarId(1),
4789 map,
4790 replace: false,
4791 }],
4792 label_items: vec![],
4793 })
4794 .build();
4795 assert!(
4796 lowerer
4797 .lower_plan(&set_map)
4798 .unwrap_err()
4799 .to_string()
4800 .contains("statement driver")
4801 );
4802
4803 let set_labels = GraphPlan::builder("openCypher")
4804 .push_op(GraphOp::Set {
4805 items: vec![],
4806 map_items: vec![],
4807 label_items: vec![LabelItem {
4808 target: VarId(1),
4809 labels: vec![TypeId(7)],
4810 }],
4811 })
4812 .build();
4813 assert!(
4814 lowerer
4815 .lower_plan(&set_labels)
4816 .unwrap_err()
4817 .to_string()
4818 .contains("statement driver")
4819 );
4820
4821 let remove_labels = GraphPlan::builder("openCypher")
4822 .push_op(GraphOp::Remove {
4823 items: Vec::<RemovePropItem>::new(),
4824 label_items: vec![LabelItem {
4825 target: VarId(1),
4826 labels: vec![TypeId(7)],
4827 }],
4828 })
4829 .build();
4830 assert!(
4831 lowerer
4832 .lower_plan(&remove_labels)
4833 .unwrap_err()
4834 .to_string()
4835 .contains("statement driver")
4836 );
4837 }
4838
4839 #[test]
4840 fn correlated_subquery_shapes_fail_with_precise_contract_errors() {
4841 let lowerer = GraphPlanLowerer::new(None, None);
4842
4843 let no_alternatives = GraphPlan::builder("openCypher")
4844 .push_op(GraphOp::Exists {
4845 child: Box::new(
4846 GraphPlan::builder("openCypher")
4847 .push_op(GraphOp::Union {
4848 all: true,
4849 inputs: vec![],
4850 })
4851 .build(),
4852 ),
4853 negated: false,
4854 })
4855 .build();
4856 assert!(
4857 lowerer
4858 .lower_plan(&no_alternatives)
4859 .unwrap_err()
4860 .to_string()
4861 .contains("no alternatives")
4862 );
4863
4864 let uncorrelated = GraphPlan::builder("openCypher")
4865 .push_op(GraphOp::Exists {
4866 child: Box::new(GraphPlan::builder("openCypher").build()),
4867 negated: true,
4868 })
4869 .build();
4870 assert!(
4871 lowerer
4872 .lower_plan(&uncorrelated)
4873 .unwrap_err()
4874 .to_string()
4875 .contains("share at least one bound variable")
4876 );
4877
4878 let empty_comprehension = GraphPlan::builder("openCypher")
4879 .push_op(GraphOp::PatternComprehension {
4880 child: Box::new(GraphPlan::builder("openCypher").build()),
4881 output: VarId(10),
4882 })
4883 .build();
4884 assert!(
4885 lowerer
4886 .lower_plan(&empty_comprehension)
4887 .unwrap_err()
4888 .to_string()
4889 .contains("child is empty")
4890 );
4891
4892 let wrong_terminal = GraphPlan::builder("openCypher")
4893 .push_op(GraphOp::PatternComprehension {
4894 child: Box::new(
4895 GraphPlan::builder("openCypher")
4896 .push_op(GraphOp::Limit { count: 1 })
4897 .build(),
4898 ),
4899 output: VarId(10),
4900 })
4901 .build();
4902 assert!(
4903 lowerer
4904 .lower_plan(&wrong_terminal)
4905 .unwrap_err()
4906 .to_string()
4907 .contains("must end in a value projection")
4908 );
4909 }
4910
4911 #[test]
4912 fn pattern_comprehension_projection_contract_is_strict() {
4913 let lowerer = GraphPlanLowerer::new(None, None);
4914 let cases = [
4915 (
4916 true,
4917 PATTERN_COMPREHENSION_VALUE_ALIAS,
4918 "exactly one non-distinct",
4919 ),
4920 (false, "wrong_alias", "invalid value projection"),
4921 ];
4922 for (distinct, alias, expected) in cases {
4923 let mut child = GraphPlan::builder("openCypher");
4924 let value = child.push_expr(IrExpr::Literal(IrLiteral::Int(1)));
4925 child.push_op_mut(GraphOp::Project {
4926 items: vec![ProjectItem {
4927 expr: value,
4928 alias: Some(alias.into()),
4929 out_var: None,
4930 }],
4931 distinct,
4932 });
4933 let plan = GraphPlan::builder("openCypher")
4934 .push_op(GraphOp::PatternComprehension {
4935 child: Box::new(child.build()),
4936 output: VarId(11),
4937 })
4938 .build();
4939 assert!(
4940 lowerer
4941 .lower_plan(&plan)
4942 .unwrap_err()
4943 .to_string()
4944 .contains(expected)
4945 );
4946 }
4947 }
4948
4949 #[test]
4950 fn terminal_suffix_uses_supplied_schema_and_preserves_scope() {
4951 use datafusion::arrow::datatypes::{DataType, Field, Schema};
4952 use datafusion::common::DFSchema;
4953
4954 let lowerer = GraphPlanLowerer::new(None, None);
4955 let schema = Arc::new(
4956 DFSchema::try_from(Schema::new(vec![Field::new(
4957 "seed",
4958 DataType::Int64,
4959 false,
4960 )]))
4961 .unwrap(),
4962 );
4963 let mut arena = ExprArena::new();
4964 let literal = arena.push(IrExpr::Literal(IrLiteral::Int(9)));
4965 let mut vars = VarMap::new();
4966 vars.insert(VarId(4), "seed");
4967 let suffix = lowerer
4968 .lower_terminal_suffix(
4969 &[GraphOp::Project {
4970 items: vec![ProjectItem {
4971 expr: literal,
4972 alias: Some("answer".into()),
4973 out_var: Some(VarId(5)),
4974 }],
4975 distinct: false,
4976 }],
4977 &arena,
4978 &mut vars,
4979 schema,
4980 )
4981 .unwrap();
4982 assert!(matches!(suffix, DfLogicalPlan::Projection(_)));
4983 assert_eq!(vars.get(VarId(5)), Some("answer"));
4984 }
4985
4986 #[test]
4987 fn lower_plan_empty_ops_succeeds() {
4988 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4989 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4990
4991 let plan = GraphPlan::builder("openCypher").build();
4992 let result = lowerer.lower_plan(&plan);
4993 assert!(result.is_ok(), "empty op pipeline should succeed");
4994 }
4995
4996 #[test]
4997 fn union_requires_two_branch_plans() {
4998 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4999 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
5000 let plan = GraphPlan::builder("openCypher")
5001 .push_op(GraphOp::Union {
5002 all: true,
5003 inputs: vec![GraphPlan::builder("openCypher").build()],
5004 })
5005 .build();
5006
5007 let error = lowerer
5008 .lower_plan(&plan)
5009 .expect_err("one branch is invalid");
5010 assert!(error.to_string().contains("at least two branch plans"));
5011 }
5012
5013 #[test]
5014 fn integration_filter_project_limit_pipeline() {
5015 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
5016 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
5017
5018 let mut builder = GraphPlan::builder("openCypher");
5020 let pred = builder.push_expr(IrExpr::Literal(IrLiteral::Bool(true)));
5021 let col_expr = builder.push_expr(IrExpr::Literal(IrLiteral::Str("n".into())));
5022
5023 let plan = builder
5024 .push_op(GraphOp::Filter { predicate: pred })
5025 .push_op(GraphOp::Project {
5026 items: vec![ProjectItem {
5027 expr: col_expr,
5028 alias: Some("name".into()),
5029 out_var: None,
5030 }],
5031 distinct: false,
5032 })
5033 .push_op(GraphOp::Limit { count: 10 })
5034 .build();
5035
5036 let lp = lowerer.lower_plan(&plan).unwrap();
5037 assert!(
5039 matches!(lp, DfLogicalPlan::Limit(_)),
5040 "expected Limit at top, got {lp:?}"
5041 );
5042 }
5043
5044 #[test]
5049 fn node_scan_no_type_produces_table_scan() {
5050 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
5051 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
5052
5053 let plan = GraphPlan::builder("openCypher")
5054 .push_op(GraphOp::NodeScan {
5055 var: VarId(0),
5056 ty: None,
5057 })
5058 .build();
5059 let lp = lowerer.lower_plan(&plan).unwrap();
5060 assert!(
5061 matches!(lp, DfLogicalPlan::TableScan(_)),
5062 "expected TableScan, got {lp:?}"
5063 );
5064 }
5065
5066 #[test]
5067 fn node_scan_with_type_produces_filter_over_scan() {
5068 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
5069 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
5070
5071 let plan = GraphPlan::builder("openCypher")
5072 .push_op(GraphOp::NodeScan {
5073 var: VarId(0),
5074 ty: Some(TypeId(1)),
5075 })
5076 .build();
5077 let lp = lowerer.lower_plan(&plan).unwrap();
5078 assert!(
5079 matches!(lp, DfLogicalPlan::Filter(_)),
5080 "expected Filter over TableScan, got {lp:?}"
5081 );
5082 }
5083
5084 #[test]
5085 fn typed_edge_scan_unknown_type_id_returns_error() {
5086 let lowerer = GraphPlanLowerer::new(None, None);
5089
5090 let plan = GraphPlan::builder("openCypher")
5091 .push_op(GraphOp::TypedEdgeScan {
5092 var: VarId(0),
5093 rel_ty: TypeId(42),
5094 })
5095 .build();
5096 let result = lowerer.lower_plan(&plan);
5097 assert!(
5098 result.is_err(),
5099 "unknown TypeId should return an error, not silently fall back"
5100 );
5101 }
5102
5103 #[test]
5104 fn edge_scan_wildcard_produces_table_scan() {
5105 let lowerer = GraphPlanLowerer::new(None, None);
5106
5107 let plan = GraphPlan::builder("openCypher")
5108 .push_op(GraphOp::EdgeScan {
5109 var: VarId(0),
5110 ty: None,
5111 })
5112 .build();
5113 let lp = lowerer.lower_plan(&plan).unwrap();
5114 assert!(
5115 matches!(lp, DfLogicalPlan::TableScan(_)),
5116 "expected TableScan, got {lp:?}"
5117 );
5118 }
5119
5120 fn var_len_plan() -> GraphPlan {
5121 GraphPlan::builder("openCypher")
5122 .push_op(GraphOp::NodeScan {
5123 var: VarId(0),
5124 ty: None,
5125 })
5126 .push_op(GraphOp::Expand {
5127 src: VarId(0),
5128 edge: VarId(1),
5129 dst: VarId(2),
5130 rel_ty: None,
5131 dir: Direction::Out,
5132 min_hops: 1,
5133 max_hops: Some(3), })
5135 .build()
5136 }
5137
5138 #[test]
5139 fn expand_var_len_produces_extension_node() {
5140 use datafusion::logical_expr::UserDefinedLogicalNodeCore;
5141 use graphforge_plan::VarLenExpandNode;
5142
5143 let (dir, catalog, _rc) = make_catalog_and_lowerer();
5144 let lowerer =
5145 GraphPlanLowerer::new_with_dir(Some(&catalog), None, dir.path(), OntologyMode::Strict);
5146
5147 let lp = lowerer.lower_plan(&var_len_plan()).unwrap();
5148 let DfLogicalPlan::Extension(ext) = &lp else {
5149 panic!("expected Extension (VarLenExpandNode), got {lp:?}");
5150 };
5151 let node = ext
5152 .node
5153 .as_any()
5154 .downcast_ref::<VarLenExpandNode>()
5155 .expect("VarLenExpandNode");
5156
5157 assert_eq!(node.src_var, 0);
5159 assert_eq!(node.dst_var, 2);
5160 assert_eq!(node.direction, Direction::Out);
5161 assert_eq!(node.min_hops, 1);
5162 assert_eq!(node.max_hops, Some(3));
5163 assert_eq!(node.dir, dir.path());
5164 assert_eq!(node.mode, OntologyMode::Strict);
5165
5166 let schema = UserDefinedLogicalNodeCore::schema(node);
5169 let dst = datafusion::common::TableReference::bare("var_2");
5170 assert!(schema.field_with_qualified_name(&dst, "node_id").is_ok());
5171 }
5172
5173 #[test]
5174 fn expand_var_len_without_dir_errors() {
5175 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
5178 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
5179 let err = lowerer.lower_plan(&var_len_plan()).unwrap_err();
5180 assert!(
5181 err.to_string().contains("project directory"),
5182 "expected a project-directory error, got: {err}"
5183 );
5184 }
5185
5186 #[test]
5187 fn unwind_produces_extension_node() {
5188 let lowerer = GraphPlanLowerer::new(None, None);
5189
5190 let mut builder = GraphPlan::builder("openCypher");
5191 let list_expr = builder.push_expr(IrExpr::Literal(IrLiteral::Int(1)));
5192 let plan = builder
5193 .push_op(GraphOp::Unwind {
5194 list_expr,
5195 alias: VarId(0),
5196 })
5197 .build();
5198 let lp = lowerer.lower_plan(&plan).unwrap();
5199 assert!(
5200 matches!(lp, DfLogicalPlan::Extension(_)),
5201 "expected Extension (UnwindNode), got {lp:?}"
5202 );
5203 }
5204
5205 #[test]
5206 fn optional_produces_extension_node() {
5207 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
5208 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
5209
5210 let child = GraphPlan::builder("openCypher")
5211 .push_op(GraphOp::NodeScan {
5212 var: VarId(1),
5213 ty: None,
5214 })
5215 .build();
5216 let plan = GraphPlan::builder("openCypher")
5217 .push_op(GraphOp::NodeScan {
5218 var: VarId(0),
5219 ty: None,
5220 })
5221 .push_op(GraphOp::Optional {
5222 child: Box::new(child),
5223 })
5224 .build();
5225 let lp = lowerer.lower_plan(&plan).unwrap();
5226 assert!(
5227 matches!(lp, DfLogicalPlan::Extension(_)),
5228 "expected Extension (OptionalMatchNode), got {lp:?}"
5229 );
5230 }
5231
5232 #[test]
5233 fn optional_node_output_schema_appends_nullable_inner() {
5234 use datafusion::logical_expr::UserDefinedLogicalNodeCore;
5235 use graphforge_plan::OptionalMatchNode;
5236
5237 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
5238 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
5239
5240 let child = GraphPlan::builder("openCypher")
5246 .push_op(GraphOp::NodeScan {
5247 var: VarId(1),
5248 ty: None,
5249 })
5250 .build();
5251 let plan = GraphPlan::builder("openCypher")
5252 .push_op(GraphOp::NodeScan {
5253 var: VarId(0),
5254 ty: None,
5255 })
5256 .push_op(GraphOp::Optional {
5257 child: Box::new(child),
5258 })
5259 .build();
5260 let lp = lowerer.lower_plan(&plan).unwrap();
5261 let DfLogicalPlan::Extension(ext) = &lp else {
5262 panic!("expected Extension (OptionalMatchNode), got {lp:?}");
5263 };
5264 let node = ext
5265 .node
5266 .as_any()
5267 .downcast_ref::<OptionalMatchNode>()
5268 .expect("OptionalMatchNode");
5269
5270 let schema = UserDefinedLogicalNodeCore::schema(node);
5272 assert_eq!(schema.fields().len(), 12, "outer(6) + inner(6)");
5273 for i in 0..6 {
5274 assert!(
5275 !schema.field(i).is_nullable(),
5276 "outer col {i} stays non-null"
5277 );
5278 }
5279 for i in 6..12 {
5280 assert!(
5281 schema.field(i).is_nullable(),
5282 "inner col {i} must be nullable for null-shaping"
5283 );
5284 }
5285 }
5286
5287 #[test]
5288 fn expand_single_hop_out_produces_join() {
5289 let lowerer = GraphPlanLowerer::new(None, None);
5290
5291 let plan = GraphPlan::builder("openCypher")
5292 .push_op(GraphOp::NodeScan {
5293 var: VarId(0),
5294 ty: None,
5295 })
5296 .push_op(GraphOp::Expand {
5297 src: VarId(0),
5298 edge: VarId(1),
5299 dst: VarId(2),
5300 rel_ty: None,
5301 dir: Direction::Out,
5302 min_hops: 1,
5303 max_hops: Some(1),
5304 })
5305 .build();
5306 let lp = lowerer.lower_plan(&plan).unwrap();
5307 assert!(
5309 matches!(lp, DfLogicalPlan::Join(_)),
5310 "expected Join, got {lp:?}"
5311 );
5312 }
5313
5314 #[test]
5315 fn fixed_hop_dst_label_is_preserved_as_filter() {
5316 let lowerer = GraphPlanLowerer::new(None, None);
5322 let plan = GraphPlan::builder("openCypher")
5323 .push_op(GraphOp::NodeScan {
5324 var: VarId(0),
5325 ty: None,
5326 })
5327 .push_op(GraphOp::Expand {
5328 src: VarId(0),
5329 edge: VarId(1),
5330 dst: VarId(2),
5331 rel_ty: None,
5332 dir: Direction::Out,
5333 min_hops: 1,
5334 max_hops: Some(1),
5335 })
5336 .push_op(GraphOp::NodeScan {
5337 var: VarId(2),
5338 ty: Some(TypeId(7)),
5339 })
5340 .build();
5341 let lp = lowerer.lower_plan(&plan).unwrap();
5342 let rendered = lp.display_indent_schema().to_string();
5343 assert!(
5344 rendered.contains("array_has(var_2.type_ids, UInt32(7))"),
5345 "destination label filter must be applied, got:\n{rendered}"
5346 );
5347 }
5348
5349 #[test]
5350 fn optional_child_with_shared_var_excludes_outer_columns() {
5351 use datafusion::logical_expr::UserDefinedLogicalNodeCore;
5352 use graphforge_plan::OptionalMatchNode;
5353
5354 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
5359 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
5360 let child = GraphPlan::builder("openCypher")
5361 .push_op(GraphOp::NodeScan {
5362 var: VarId(0),
5363 ty: None,
5364 })
5365 .push_op(GraphOp::Expand {
5366 src: VarId(0),
5367 edge: VarId(1),
5368 dst: VarId(2),
5369 rel_ty: None,
5370 dir: Direction::Out,
5371 min_hops: 1,
5372 max_hops: Some(1),
5373 })
5374 .build();
5375 let plan = GraphPlan::builder("openCypher")
5376 .push_op(GraphOp::NodeScan {
5377 var: VarId(0),
5378 ty: None,
5379 })
5380 .push_op(GraphOp::Optional {
5381 child: Box::new(child),
5382 })
5383 .build();
5384 let lp = lowerer.lower_plan(&plan).unwrap();
5385 let DfLogicalPlan::Extension(ext) = &lp else {
5386 panic!("expected Extension (OptionalMatchNode), got {lp:?}");
5387 };
5388 let node = ext
5389 .node
5390 .as_any()
5391 .downcast_ref::<OptionalMatchNode>()
5392 .expect("OptionalMatchNode");
5393
5394 assert_eq!(node.join_keys.len(), 1, "var_0 is the shared join key");
5398 let schema = UserDefinedLogicalNodeCore::schema(node);
5399 assert_eq!(schema.fields().len(), 20, "no duplicate var_0 columns");
5401 }
5404
5405 fn create_plan_with_props() -> GraphPlan {
5410 use graphforge_ir::{CreateNodeSpec, CreatePattern};
5411 let mut builder = GraphPlan::builder("openCypher");
5412 let name_lit = builder.push_expr(IrExpr::Literal(IrLiteral::Str("Alice".into())));
5414 let map = builder.push_expr(IrExpr::MapLiteral(vec![("name".into(), name_lit)]));
5415 builder
5416 .push_op(GraphOp::Create {
5417 pattern: CreatePattern {
5418 nodes: vec![CreateNodeSpec {
5419 var: VarId(0),
5420 labels: vec![TypeId(0)],
5421 properties: Some(map),
5422 is_reference: false,
5423 }],
5424 edges: vec![],
5425 },
5426 })
5427 .build()
5428 }
5429
5430 #[test]
5431 fn create_lowers_to_extension_with_write_target() {
5432 let dir = tempfile::TempDir::new().unwrap();
5433 let lowerer = GraphPlanLowerer::new_for_writes(
5434 None,
5435 None,
5436 dir.path(),
5437 graphforge_core::OntologyMode::Exploratory,
5438 );
5439 let plan = create_plan_with_props();
5440 let lp = lowerer.lower_plan(&plan).unwrap();
5441 assert!(
5442 matches!(lp, DfLogicalPlan::Extension(_)),
5443 "expected Extension (GraphCreateNode), got {lp:?}"
5444 );
5445 }
5446
5447 #[test]
5448 fn create_followed_by_read_emits_created_rows_for_real_pipeline() {
5449 use graphforge_ir::CreateNodeSpec;
5450 use graphforge_plan::GraphCreateNode;
5451
5452 let dir = tempfile::TempDir::new().unwrap();
5453 let lowerer = GraphPlanLowerer::new_for_writes(
5454 None,
5455 None,
5456 dir.path(),
5457 graphforge_core::OntologyMode::Exploratory,
5458 );
5459 let mut builder = GraphPlan::builder("openCypher");
5460 let returned = builder.push_expr(IrExpr::VarRef(VarId(0)));
5461 let plan = builder
5462 .push_op(GraphOp::Create {
5463 pattern: CreatePattern {
5464 nodes: vec![CreateNodeSpec {
5465 var: VarId(0),
5466 labels: vec![],
5467 properties: None,
5468 is_reference: false,
5469 }],
5470 edges: vec![],
5471 },
5472 })
5473 .push_op(GraphOp::Project {
5474 items: vec![ProjectItem {
5475 expr: returned,
5476 alias: Some("created".into()),
5477 out_var: None,
5478 }],
5479 distinct: false,
5480 })
5481 .build();
5482
5483 let lowered = lowerer.lower_plan(&plan).unwrap();
5484 let DfLogicalPlan::Projection(project) = lowered else {
5485 panic!("expected trailing projection");
5486 };
5487 let DfLogicalPlan::Extension(create) = project.input.as_ref() else {
5488 panic!("expected emitting CREATE input");
5489 };
5490 let create = create
5491 .node
5492 .as_any()
5493 .downcast_ref::<GraphCreateNode>()
5494 .expect("GraphCreateNode");
5495 assert!(
5496 !datafusion::logical_expr::UserDefinedLogicalNodeCore::schema(create)
5497 .fields()
5498 .is_empty()
5499 );
5500 }
5501
5502 #[test]
5503 fn create_without_write_target_errors() {
5504 let lowerer = GraphPlanLowerer::new(None, None);
5506 let plan = create_plan_with_props();
5507 let result = lowerer.lower_plan(&plan);
5508 assert!(
5509 result.is_err(),
5510 "CREATE without a write target should error"
5511 );
5512 }
5513
5514 #[test]
5515 fn new_with_dir_does_not_authorize_writes() {
5516 let dir = tempfile::TempDir::new().unwrap();
5520 let lowerer = GraphPlanLowerer::new_with_dir(
5521 None,
5522 None,
5523 dir.path(),
5524 graphforge_core::OntologyMode::Exploratory,
5525 );
5526 let result = lowerer.lower_plan(&create_plan_with_props());
5527 assert!(
5528 result.is_err(),
5529 "new_with_dir must not authorize CREATE; only new_for_writes does"
5530 );
5531 }
5532
5533 #[test]
5534 fn create_non_literal_property_lowers_as_computed() {
5535 use graphforge_ir::{CreateNodeSpec, CreatePattern};
5536 let dir = tempfile::TempDir::new().unwrap();
5537 let lowerer = GraphPlanLowerer::new_for_writes(
5538 None,
5539 None,
5540 dir.path(),
5541 graphforge_core::OntologyMode::Exploratory,
5542 );
5543 let mut builder = GraphPlan::builder("openCypher");
5544 let param = builder.push_expr(IrExpr::Parameter("p".into()));
5548 let map = builder.push_expr(IrExpr::MapLiteral(vec![("name".into(), param)]));
5549 let plan = builder
5550 .push_op(GraphOp::Create {
5551 pattern: CreatePattern {
5552 nodes: vec![CreateNodeSpec {
5553 var: VarId(0),
5554 labels: vec![],
5555 properties: Some(map),
5556 is_reference: false,
5557 }],
5558 edges: vec![],
5559 },
5560 })
5561 .build();
5562 let logical = lowerer
5563 .lower_plan(&plan)
5564 .expect("a non-literal CREATE property lowers to a computed expr");
5565 let datafusion::logical_expr::LogicalPlan::Extension(ext) = &logical else {
5566 panic!("CREATE lowers to an Extension node");
5567 };
5568 let create = ext
5569 .node
5570 .as_any()
5571 .downcast_ref::<graphforge_plan::GraphCreateNode>()
5572 .expect("a GraphCreateNode");
5573 assert!(
5574 create.nodes[0].properties.is_empty(),
5575 "the parameter value is not a baked literal"
5576 );
5577 assert_eq!(
5578 create.nodes[0].computed_properties.len(),
5579 1,
5580 "the parameter value is a row-dependent computed property"
5581 );
5582 }
5583
5584 #[test]
5585 #[allow(clippy::too_many_lines)]
5586 fn created_rows_schema_preserves_input_skips_references_and_types_minted_nodes() {
5587 use std::collections::HashMap;
5588
5589 use datafusion::arrow::datatypes::{DataType, Field};
5590 use datafusion::common::{DFSchema, TableReference};
5591 use datafusion::logical_expr::{col, lit};
5592 use graphforge_plan::ResolvedNodeSpec;
5593
5594 let input = Arc::new(
5595 DFSchema::new_with_metadata(
5596 vec![(
5597 Some(TableReference::bare("input")),
5598 Arc::new(Field::new("seed", DataType::Int64, false)),
5599 )],
5600 HashMap::new(),
5601 )
5602 .unwrap(),
5603 );
5604 let reference = ResolvedNodeSpec {
5605 var: 1,
5606 label_ids: vec![7],
5607 label_names: vec!["Existing".into()],
5608 properties: vec![("ignored".into(), IrLiteral::Int(1))],
5609 computed_properties: vec![],
5610 is_reference: true,
5611 };
5612 let minted = ResolvedNodeSpec {
5613 var: 2,
5614 label_ids: vec![8, 9],
5615 label_names: vec!["New".into(), "Tagged".into()],
5616 properties: vec![("active".into(), IrLiteral::Bool(true))],
5617 computed_properties: vec![("copied_seed".into(), col("seed") + lit(1_i64))],
5618 is_reference: false,
5619 };
5620
5621 let schema = GraphPlanLowerer::created_rows_schema(&[reference, minted], &input).unwrap();
5622 let fields: Vec<_> = schema
5623 .iter()
5624 .map(|(qualifier, field)| {
5625 (
5626 qualifier.map(ToString::to_string),
5627 field.name().clone(),
5628 field.data_type().clone(),
5629 field.is_nullable(),
5630 )
5631 })
5632 .collect();
5633
5634 assert_eq!(
5635 fields.len(),
5636 7,
5637 "one input plus four identity and two property fields"
5638 );
5639 assert_eq!(
5640 fields[0],
5641 (Some("input".into()), "seed".into(), DataType::Int64, false)
5642 );
5643 assert_eq!(
5644 fields[1..5]
5645 .iter()
5646 .map(|(q, name, ty, nullable)| { (q.clone(), name.clone(), ty.clone(), *nullable) })
5647 .collect::<Vec<_>>(),
5648 vec![
5649 (
5650 Some("var_2".into()),
5651 "node_uuid".into(),
5652 DataType::FixedSizeBinary(16),
5653 false
5654 ),
5655 (
5656 Some("var_2".into()),
5657 "node_id".into(),
5658 DataType::UInt64,
5659 false,
5660 ),
5661 (
5662 Some("var_2".into()),
5663 "type_id".into(),
5664 DataType::UInt32,
5665 false,
5666 ),
5667 (
5668 Some("var_2".into()),
5669 "type_ids".into(),
5670 DataType::List(Arc::new(Field::new("item", DataType::UInt32, false))),
5671 false,
5672 ),
5673 ]
5674 );
5675 assert_eq!(
5676 fields[5],
5677 (
5678 Some("var_2".into()),
5679 "active".into(),
5680 DataType::Boolean,
5681 true
5682 )
5683 );
5684 assert_eq!(
5685 fields[6],
5686 (
5687 Some("var_2".into()),
5688 "copied_seed".into(),
5689 DataType::Int64,
5690 true,
5691 )
5692 );
5693 assert!(
5694 fields
5695 .iter()
5696 .all(|(q, _, _, _)| q.as_deref() != Some("var_1")),
5697 "reference nodes are passed through only and never duplicated"
5698 );
5699 }
5700
5701 #[test]
5702 fn created_rows_schema_rejects_reserved_and_unbound_computed_properties() {
5703 use graphforge_plan::ResolvedNodeSpec;
5704
5705 let input = Arc::new(datafusion::common::DFSchema::empty());
5706 for reserved in ["node_uuid", "node_id", "type_id", "type_ids"] {
5707 let spec = ResolvedNodeSpec {
5708 var: 3,
5709 label_ids: vec![],
5710 label_names: vec![],
5711 properties: vec![(reserved.into(), IrLiteral::Null)],
5712 computed_properties: vec![],
5713 is_reference: false,
5714 };
5715 let error = GraphPlanLowerer::created_rows_schema(&[spec], &input).unwrap_err();
5716 assert_eq!(
5717 error.to_string(),
5718 format!(
5719 "unsupported expression: CREATE property `{reserved}` collides with a reserved node topology field"
5720 )
5721 );
5722 }
5723
5724 let unbound = ResolvedNodeSpec {
5725 var: 4,
5726 label_ids: vec![],
5727 label_names: vec![],
5728 properties: vec![],
5729 computed_properties: vec![("value".into(), datafusion::logical_expr::col("missing"))],
5730 is_reference: false,
5731 };
5732 let error = GraphPlanLowerer::created_rows_schema(&[unbound], &input).unwrap_err();
5733 assert!(
5734 error.to_string().contains("No field named missing"),
5735 "unbound computed properties must retain the DataFusion schema error: {error}"
5736 );
5737 }
5738
5739 fn match_delete_plan(detach: bool) -> GraphPlan {
5746 GraphPlan::builder("openCypher")
5747 .push_op(GraphOp::NodeScan {
5748 var: VarId(0),
5749 ty: None,
5750 })
5751 .push_op(GraphOp::Delete {
5752 vars: vec![VarId(0)],
5753 exprs: vec![],
5754 detach,
5755 })
5756 .build()
5757 }
5758
5759 #[test]
5760 fn delete_lowers_to_extension_with_write_target() {
5761 let dir = tempfile::TempDir::new().unwrap();
5762 let rc = graphforge_ir::RuntimeCatalog::new();
5763 let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
5764 let lowerer = GraphPlanLowerer::new_for_writes(
5765 Some(&catalog),
5766 None,
5767 dir.path(),
5768 graphforge_core::OntologyMode::Exploratory,
5769 );
5770 let lp = lowerer.lower_plan(&match_delete_plan(false)).unwrap();
5771 assert!(
5772 matches!(lp, DfLogicalPlan::Extension(_)),
5773 "expected Extension (GraphDeleteNode), got {lp:?}"
5774 );
5775 }
5776
5777 #[test]
5778 fn delete_without_write_target_errors() {
5779 let dir = tempfile::TempDir::new().unwrap();
5780 let rc = graphforge_ir::RuntimeCatalog::new();
5781 let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
5782 let lowerer = GraphPlanLowerer::new_with_dir(
5784 Some(&catalog),
5785 None,
5786 dir.path(),
5787 graphforge_core::OntologyMode::Exploratory,
5788 );
5789 assert!(
5790 lowerer.lower_plan(&match_delete_plan(false)).is_err(),
5791 "DELETE without a write target should error"
5792 );
5793 }
5794
5795 #[test]
5796 fn delete_edge_variable_resolves_is_edge_flag() {
5797 use graphforge_plan::GraphDeleteNode;
5798
5799 let dir = tempfile::TempDir::new().unwrap();
5803 let rc = graphforge_ir::RuntimeCatalog::new();
5804 let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
5805 let lowerer = GraphPlanLowerer::new_for_writes(
5806 Some(&catalog),
5807 None,
5808 dir.path(),
5809 graphforge_core::OntologyMode::Exploratory,
5810 );
5811 let plan = GraphPlan::builder("openCypher")
5812 .push_op(GraphOp::NodeScan {
5813 var: VarId(0),
5814 ty: None,
5815 })
5816 .push_op(GraphOp::Expand {
5817 src: VarId(0),
5818 edge: VarId(1),
5819 dst: VarId(2),
5820 rel_ty: None,
5821 dir: Direction::Out,
5822 min_hops: 1,
5823 max_hops: Some(1),
5824 })
5825 .push_op(GraphOp::Delete {
5826 vars: vec![VarId(1)],
5827 exprs: vec![],
5828 detach: false,
5829 })
5830 .build();
5831
5832 let lp = lowerer.lower_plan(&plan).unwrap();
5833 let DfLogicalPlan::Extension(ext) = &lp else {
5834 panic!("expected Extension (GraphDeleteNode), got {lp:?}");
5835 };
5836 let node = ext
5837 .node
5838 .as_any()
5839 .downcast_ref::<GraphDeleteNode>()
5840 .expect("GraphDeleteNode");
5841 assert_eq!(node.targets.len(), 1, "one delete target");
5842 assert_eq!(node.targets[0].var, 1);
5843 assert!(
5844 node.targets[0].is_edge,
5845 "the edge var must resolve to is_edge=true"
5846 );
5847 }
5848
5849 #[test]
5850 fn write_kind_resolution_covers_node_typed_edge_and_invalid_targets() {
5851 use std::collections::HashMap;
5852
5853 use datafusion::arrow::datatypes::{DataType, Field};
5854 use datafusion::common::{DFSchema, TableReference};
5855
5856 let schema = |names: &[&str]| {
5857 Arc::new(
5858 DFSchema::new_with_metadata(
5859 names
5860 .iter()
5861 .map(|name| {
5862 (
5863 Some(TableReference::bare("var_7")),
5864 Arc::new(Field::new(*name, DataType::Utf8, true)),
5865 )
5866 })
5867 .collect(),
5868 HashMap::new(),
5869 )
5870 .unwrap(),
5871 )
5872 };
5873
5874 assert!(
5875 !GraphPlanLowerer::resolve_write_kind(&schema(&["node_uuid"]), VarId(7), "SET")
5876 .unwrap()
5877 );
5878 assert!(
5879 GraphPlanLowerer::resolve_write_kind(
5880 &schema(&["edge_uuid", "rel_type_name"]),
5881 VarId(7),
5882 "REMOVE"
5883 )
5884 .unwrap()
5885 );
5886
5887 let untyped_edge =
5888 GraphPlanLowerer::resolve_write_kind(&schema(&["edge_uuid"]), VarId(7), "SET")
5889 .unwrap_err();
5890 assert!(untyped_edge.to_string().contains("known relation type"));
5891
5892 let unbound =
5893 GraphPlanLowerer::resolve_write_kind(&schema(&[]), VarId(7), "REMOVE").unwrap_err();
5894 assert!(unbound.to_string().contains("must be bound"));
5895
5896 assert!(
5899 !GraphPlanLowerer::resolve_write_kind(
5900 &schema(&["node_uuid", "edge_uuid", "rel_type_name"]),
5901 VarId(7),
5902 "SET"
5903 )
5904 .unwrap()
5905 );
5906 }
5907
5908 fn writes_lowerer<'a>(
5913 catalog: &'a graphforge_storage::GraphCatalog,
5914 dir: &'a std::path::Path,
5915 ) -> GraphPlanLowerer<'a> {
5916 GraphPlanLowerer::new_for_writes(
5917 Some(catalog),
5918 None,
5919 dir,
5920 graphforge_core::OntologyMode::Exploratory,
5921 )
5922 }
5923
5924 #[test]
5925 fn set_literal_value_lowers_to_extension() {
5926 use graphforge_ir::SetPropItem;
5927 let dir = tempfile::TempDir::new().unwrap();
5928 let rc = graphforge_ir::RuntimeCatalog::new();
5929 let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
5930 let lowerer = writes_lowerer(&catalog, dir.path());
5931
5932 let mut builder = GraphPlan::builder("openCypher");
5933 let value = builder.push_expr(IrExpr::Literal(IrLiteral::Int(42)));
5934 let plan = builder
5935 .push_op(GraphOp::NodeScan {
5936 var: VarId(0),
5937 ty: None,
5938 })
5939 .push_op(GraphOp::Set {
5940 items: vec![SetPropItem {
5941 target: VarId(0),
5942 prop: graphforge_core::PropId(0),
5943 prop_name: "age".into(),
5944 value,
5945 }],
5946 map_items: vec![],
5947 label_items: vec![],
5948 })
5949 .build();
5950 let lp = lowerer.lower_plan(&plan).unwrap();
5951 let DfLogicalPlan::Extension(ext) = &lp else {
5952 panic!("expected Extension (GraphSetNode), got {lp:?}");
5953 };
5954 let node = ext.node.as_any().downcast_ref::<GraphSetNode>().unwrap();
5955 assert_eq!(node.targets.len(), 1);
5956 assert_eq!(node.targets[0].prop_name, "age");
5957 assert!(!node.targets[0].is_edge, "node target");
5958 }
5959
5960 #[test]
5961 fn set_runtime_expr_value_lowers_against_input_schema() {
5962 use graphforge_ir::SetPropItem;
5966 let dir = tempfile::TempDir::new().unwrap();
5967 let rc = graphforge_ir::RuntimeCatalog::new();
5968 let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
5969 let lowerer = writes_lowerer(&catalog, dir.path());
5970
5971 let mut builder = GraphPlan::builder("openCypher");
5972 let var = builder.push_expr(IrExpr::VarRef(VarId(0)));
5974 let age = builder.push_expr(IrExpr::PropertyAccess {
5975 base: var,
5976 prop: graphforge_core::PropId(0),
5977 });
5978 let one = builder.push_expr(IrExpr::Literal(IrLiteral::Int(1)));
5979 let sum = builder.push_expr(IrExpr::BinaryOp {
5980 op: graphforge_ir::expr::BinaryOpKind::Add,
5981 left: age,
5982 right: one,
5983 });
5984 let plan = builder
5985 .push_op(GraphOp::NodeScan {
5986 var: VarId(0),
5987 ty: None,
5988 })
5989 .push_op(GraphOp::Set {
5990 items: vec![SetPropItem {
5991 target: VarId(0),
5992 prop: graphforge_core::PropId(0),
5993 prop_name: "age".into(),
5994 value: sum,
5995 }],
5996 map_items: vec![],
5997 label_items: vec![],
5998 })
5999 .build();
6000 let lp = lowerer.lower_plan(&plan).unwrap();
6002 assert!(matches!(lp, DfLogicalPlan::Extension(_)));
6003 }
6004
6005 #[test]
6006 fn remove_lowers_to_extension() {
6007 use graphforge_ir::RemovePropItem;
6008 let dir = tempfile::TempDir::new().unwrap();
6009 let rc = graphforge_ir::RuntimeCatalog::new();
6010 let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
6011 let lowerer = writes_lowerer(&catalog, dir.path());
6012
6013 let plan = GraphPlan::builder("openCypher")
6014 .push_op(GraphOp::NodeScan {
6015 var: VarId(0),
6016 ty: None,
6017 })
6018 .push_op(GraphOp::Remove {
6019 items: vec![RemovePropItem {
6020 target: VarId(0),
6021 prop: graphforge_core::PropId(0),
6022 prop_name: "age".into(),
6023 }],
6024 label_items: vec![],
6025 })
6026 .build();
6027 let lp = lowerer.lower_plan(&plan).unwrap();
6028 let DfLogicalPlan::Extension(ext) = &lp else {
6029 panic!("expected Extension (GraphRemoveNode), got {lp:?}");
6030 };
6031 let node = ext.node.as_any().downcast_ref::<GraphRemoveNode>().unwrap();
6032 assert_eq!(node.targets.len(), 1);
6033 assert_eq!(node.targets[0].prop_name, "age");
6034 }
6035
6036 #[test]
6037 fn set_without_write_target_errors() {
6038 use graphforge_ir::SetPropItem;
6039 let dir = tempfile::TempDir::new().unwrap();
6040 let rc = graphforge_ir::RuntimeCatalog::new();
6041 let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
6042 let lowerer = GraphPlanLowerer::new_with_dir(
6044 Some(&catalog),
6045 None,
6046 dir.path(),
6047 graphforge_core::OntologyMode::Exploratory,
6048 );
6049 let mut builder = GraphPlan::builder("openCypher");
6050 let value = builder.push_expr(IrExpr::Literal(IrLiteral::Int(1)));
6051 let plan = builder
6052 .push_op(GraphOp::NodeScan {
6053 var: VarId(0),
6054 ty: None,
6055 })
6056 .push_op(GraphOp::Set {
6057 items: vec![SetPropItem {
6058 target: VarId(0),
6059 prop: graphforge_core::PropId(0),
6060 prop_name: "age".into(),
6061 value,
6062 }],
6063 map_items: vec![],
6064 label_items: vec![],
6065 })
6066 .build();
6067 assert!(
6068 lowerer.lower_plan(&plan).is_err(),
6069 "SET without a write target should error"
6070 );
6071 }
6072
6073 fn typed_single_hop_fixture(
6080 dir: Direction,
6081 ) -> (
6082 tempfile::TempDir,
6083 graphforge_storage::GraphCatalog,
6084 GraphPlan,
6085 ) {
6086 let tmp = tempfile::TempDir::new().unwrap();
6087 let mut rc = graphforge_ir::RuntimeCatalog::new();
6088 let rel = graphforge_ir::runtime_relation_type_id(rc.intern_relation_type("KNOWS"));
6089 let catalog = graphforge_storage::GraphCatalog::open(tmp.path(), None, &rc).unwrap();
6090 let plan = GraphPlan::builder("openCypher")
6091 .push_op(GraphOp::NodeScan {
6092 var: VarId(0),
6093 ty: None,
6094 })
6095 .push_op(GraphOp::Expand {
6096 src: VarId(0),
6097 edge: VarId(1),
6098 dst: VarId(2),
6099 rel_ty: Some(rel),
6100 dir,
6101 min_hops: 1,
6102 max_hops: Some(1),
6103 })
6104 .build();
6105 (tmp, catalog, plan)
6106 }
6107
6108 #[test]
6109 fn project_backed_single_hop_emits_expand_extension_node() {
6110 use datafusion::logical_expr::UserDefinedLogicalNodeCore;
6111
6112 let (tmp, catalog, plan) = typed_single_hop_fixture(Direction::Out);
6113 let lowerer =
6114 GraphPlanLowerer::new_with_dir(Some(&catalog), None, tmp.path(), OntologyMode::Strict);
6115
6116 let lp = lowerer.lower_plan(&plan).unwrap();
6117 let DfLogicalPlan::Extension(ext) = &lp else {
6118 panic!("expected Extension (ExpandNode), got {lp:?}");
6119 };
6120 let node = ext
6121 .node
6122 .as_any()
6123 .downcast_ref::<graphforge_plan::ExpandNode>()
6124 .expect("ExpandNode");
6125 assert_eq!(node.rel_type_name, "KNOWS");
6126 assert_eq!(node.src_var, 0);
6127 assert_eq!(node.edge_var, 1);
6128 assert_eq!(node.dst_var, 2);
6129 assert_eq!(node.direction, Direction::Out);
6130 assert_eq!(node.mode, OntologyMode::Strict);
6131 assert_eq!(node.edge_prop_count, 0, "no edge_properties file on disk");
6132
6133 let schema = UserDefinedLogicalNodeCore::schema(node);
6135 let edge = datafusion::common::TableReference::bare("var_1");
6136 let dst = datafusion::common::TableReference::bare("var_2");
6137 for name in ["edge_uuid", "src_id", "dst_id", "edge_id"] {
6138 assert!(
6139 schema.field_with_qualified_name(&edge, name).is_ok(),
6140 "{name}"
6141 );
6142 }
6143 assert!(schema.field_with_qualified_name(&dst, "node_id").is_ok());
6144 }
6145
6146 #[test]
6147 fn project_backed_undirected_single_hop_emits_plain_extension() {
6148 let (tmp, catalog, plan) = typed_single_hop_fixture(Direction::Undirected);
6149 let lowerer =
6150 GraphPlanLowerer::new_with_dir(Some(&catalog), None, tmp.path(), OntologyMode::Strict);
6151
6152 let lp = lowerer.lower_plan(&plan).unwrap();
6153 let DfLogicalPlan::Extension(ext) = &lp else {
6157 panic!("expected Extension (ExpandNode), got {lp:?}");
6158 };
6159 let node = ext
6160 .node
6161 .as_any()
6162 .downcast_ref::<graphforge_plan::ExpandNode>()
6163 .expect("ExpandNode");
6164 assert_eq!(node.direction, Direction::Undirected);
6165 }
6166
6167 #[test]
6168 fn schema_only_single_hop_keeps_join_path() {
6169 let (_tmp, catalog, plan) = typed_single_hop_fixture(Direction::Out);
6170 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
6171 let lp = lowerer.lower_plan(&plan).unwrap();
6172 assert!(
6173 matches!(lp, DfLogicalPlan::Join(_)),
6174 "expected Join chain, got {lp:?}"
6175 );
6176 }
6177
6178 #[test]
6179 fn exploratory_mode_emits_expand_with_dynamic_edge_schema() {
6180 use datafusion::logical_expr::UserDefinedLogicalNodeCore;
6181
6182 let (tmp, catalog, plan) = typed_single_hop_fixture(Direction::Out);
6183 let lowerer = GraphPlanLowerer::new_with_dir(
6184 Some(&catalog),
6185 None,
6186 tmp.path(),
6187 OntologyMode::Exploratory,
6188 );
6189 let lp = lowerer.lower_plan(&plan).unwrap();
6190 let DfLogicalPlan::Extension(ext) = &lp else {
6191 panic!("expected exploratory ExpandNode, got {lp:?}");
6192 };
6193 let node = ext
6194 .node
6195 .as_any()
6196 .downcast_ref::<graphforge_plan::ExpandNode>()
6197 .expect("ExpandNode");
6198 let edge = datafusion::common::TableReference::bare("var_1");
6199 assert!(
6200 UserDefinedLogicalNodeCore::schema(node)
6201 .field_with_qualified_name(&edge, "rel_type_name")
6202 .is_ok()
6203 );
6204 }
6205
6206 #[test]
6207 fn wildcard_single_hop_emits_expand_extension() {
6208 let tmp = tempfile::TempDir::new().unwrap();
6209 let rc = graphforge_ir::RuntimeCatalog::new();
6210 let catalog = graphforge_storage::GraphCatalog::open(tmp.path(), None, &rc).unwrap();
6211 let plan = GraphPlan::builder("openCypher")
6212 .push_op(GraphOp::NodeScan {
6213 var: VarId(0),
6214 ty: None,
6215 })
6216 .push_op(GraphOp::Expand {
6217 src: VarId(0),
6218 edge: VarId(1),
6219 dst: VarId(2),
6220 rel_ty: None,
6221 dir: Direction::Out,
6222 min_hops: 1,
6223 max_hops: Some(1),
6224 })
6225 .build();
6226 let lowerer =
6227 GraphPlanLowerer::new_with_dir(Some(&catalog), None, tmp.path(), OntologyMode::Strict);
6228 let lp = lowerer.lower_plan(&plan).unwrap();
6229 let DfLogicalPlan::Extension(ext) = &lp else {
6230 panic!("expected wildcard ExpandNode, got {lp:?}");
6231 };
6232 let node = ext
6233 .node
6234 .as_any()
6235 .downcast_ref::<graphforge_plan::ExpandNode>()
6236 .expect("ExpandNode");
6237 assert_eq!(node.rel_type_name, "*");
6238 assert_eq!(node.rel_ty, None);
6239 }
6240
6241 #[test]
6242 fn unbound_source_errors_on_provider_path() {
6243 let tmp = tempfile::TempDir::new().unwrap();
6247 let mut rc = graphforge_ir::RuntimeCatalog::new();
6248 let rel = graphforge_ir::runtime_relation_type_id(rc.intern_relation_type("KNOWS"));
6249 let catalog = graphforge_storage::GraphCatalog::open(tmp.path(), None, &rc).unwrap();
6250 let plan = GraphPlan::builder("openCypher")
6252 .push_op(GraphOp::Expand {
6253 src: VarId(0),
6254 edge: VarId(1),
6255 dst: VarId(2),
6256 rel_ty: Some(rel),
6257 dir: Direction::Out,
6258 min_hops: 1,
6259 max_hops: Some(1),
6260 })
6261 .build();
6262 let lowerer =
6263 GraphPlanLowerer::new_with_dir(Some(&catalog), None, tmp.path(), OntologyMode::Strict);
6264 let err = lowerer.lower_plan(&plan).unwrap_err();
6265 assert!(
6266 err.to_string().contains("unbound") || err.to_string().contains("Unbound"),
6267 "expected an unbound-variable error, got {err:?}"
6268 );
6269 }
6270
6271 #[test]
6272 fn pure_lowering_helpers_cover_constants_collections_and_aggregate_contracts() {
6273 use datafusion::arrow::datatypes::{DataType, Field, Schema};
6274 use datafusion::common::DFSchema;
6275 use datafusion::logical_expr::expr_fn::col;
6276 use datafusion::logical_expr::{Operator, lit};
6277 use datafusion::scalar::ScalarValue;
6278
6279 assert_eq!(
6280 const_eval_scalar(&lit(ScalarValue::Int64(Some(7)))),
6281 Some(ScalarValue::Int64(Some(7)))
6282 );
6283 assert_eq!(
6284 const_eval_scalar(&DfExpr::BinaryExpr(
6285 datafusion::logical_expr::BinaryExpr::new(
6286 Box::new(lit(ScalarValue::Int64(Some(2)))),
6287 Operator::Plus,
6288 Box::new(lit(ScalarValue::Int64(Some(3)))),
6289 )
6290 )),
6291 Some(ScalarValue::Int64(Some(5)))
6292 );
6293 assert_eq!(const_eval_scalar(&col("missing")), None);
6294
6295 for literal in [
6296 IrLiteral::Null,
6297 IrLiteral::Int(1),
6298 IrLiteral::List(vec![IrLiteral::Int(1)]),
6299 ] {
6300 assert!(!contains_map_literal(&literal));
6301 reject_map_property_value("safe", &literal).unwrap();
6302 }
6303 for literal in [
6304 IrLiteral::Map(vec![]),
6305 IrLiteral::List(vec![IrLiteral::Map(vec![])]),
6306 ] {
6307 assert!(contains_map_literal(&literal));
6308 assert!(
6309 reject_map_property_value("nested", &literal)
6310 .unwrap_err()
6311 .to_string()
6312 .contains("cannot store map values")
6313 );
6314 }
6315 assert_eq!(var_alias(VarId(42)), "var_42");
6316 assert!(build_type_id_map(None).is_empty());
6317 assert!(build_entity_id_map(None).is_empty());
6318 assert!(build_inference_rules(None).is_empty());
6319
6320 let schema = Arc::new(
6321 DFSchema::try_from(Schema::new(vec![
6322 Field::new("small", DataType::new_list(DataType::Utf8, true), true),
6323 Field::new(
6324 "large",
6325 DataType::new_large_list(DataType::Int32, true),
6326 true,
6327 ),
6328 Field::new(
6329 "fixed",
6330 DataType::FixedSizeList(
6331 Arc::new(Field::new_list_field(DataType::Boolean, true)),
6332 2,
6333 ),
6334 true,
6335 ),
6336 ]))
6337 .unwrap(),
6338 );
6339 for (name, expected) in [
6340 ("small", DataType::Utf8),
6341 ("large", DataType::Int32),
6342 ("fixed", DataType::Boolean),
6343 ] {
6344 assert_eq!(
6345 unwind_element_field(&col(name), &schema, std::iter::empty::<&str>()).data_type(),
6346 &expected
6347 );
6348 }
6349 let unknown = unwind_element_field(&col("unknown"), &schema, ["z", "a", "a"]);
6350 let DataType::Struct(fields) = unknown.data_type() else {
6351 panic!("unknown typed UNWIND must expose a map-shaped element")
6352 };
6353 assert_eq!(
6354 fields.iter().map(|f| f.name().as_str()).collect::<Vec<_>>(),
6355 ["a", "z"]
6356 );
6357
6358 let value = Some(col("value"));
6359 for function in [
6360 AggFunc::Count,
6361 AggFunc::CountDistinct,
6362 AggFunc::Sum,
6363 AggFunc::SumDistinct,
6364 AggFunc::Avg,
6365 AggFunc::AvgDistinct,
6366 AggFunc::Min,
6367 AggFunc::Max,
6368 AggFunc::Collect,
6369 AggFunc::CollectDistinct,
6370 ] {
6371 assert!(
6372 lower_agg_func(function, value.clone(), None, Some(&DataType::Int64)).is_ok(),
6373 "{function:?}"
6374 );
6375 }
6376 for function in [AggFunc::PercentileDisc, AggFunc::PercentileCont] {
6377 assert!(
6378 lower_agg_func(
6379 function,
6380 value.clone(),
6381 Some(lit(ScalarValue::Float64(Some(0.5)))),
6382 Some(&DataType::Int64),
6383 )
6384 .is_ok()
6385 );
6386 }
6387 assert!(lower_agg_func(AggFunc::Count, None, None, None).is_ok());
6388 for function in [
6389 AggFunc::CountDistinct,
6390 AggFunc::Sum,
6391 AggFunc::SumDistinct,
6392 AggFunc::Avg,
6393 AggFunc::AvgDistinct,
6394 AggFunc::Min,
6395 AggFunc::Max,
6396 AggFunc::Collect,
6397 AggFunc::CollectDistinct,
6398 AggFunc::PercentileDisc,
6399 AggFunc::PercentileCont,
6400 ] {
6401 assert!(
6402 lower_agg_func(function, None, None, None).is_err(),
6403 "{function:?}"
6404 );
6405 }
6406 for function in [AggFunc::Min, AggFunc::Max] {
6407 let heterogeneous =
6408 DataType::Struct(vec![Field::new("__het_tag", DataType::Int8, false)].into());
6409 assert!(
6410 format!(
6411 "{}",
6412 lower_agg_func(function, value.clone(), None, Some(&heterogeneous),).unwrap()
6413 )
6414 .contains("cypher_")
6415 );
6416 }
6417 }
6418
6419 #[test]
6420 fn exact_zero_var_len_unknown_relation_and_directional_join_paths() {
6421 let unknown = GraphPlan::builder("openCypher")
6422 .push_op(GraphOp::NodeScan {
6423 var: VarId(0),
6424 ty: None,
6425 })
6426 .push_op(GraphOp::Expand {
6427 src: VarId(0),
6428 edge: VarId(1),
6429 dst: VarId(2),
6430 rel_ty: Some(TypeId(999_999)),
6431 dir: Direction::Out,
6432 min_hops: 1,
6433 max_hops: Some(3),
6434 })
6435 .build();
6436 assert!(
6437 GraphPlanLowerer::new(None, None)
6438 .lower_plan(&unknown)
6439 .unwrap_err()
6440 .to_string()
6441 .contains("has no known relation name")
6442 );
6443
6444 for direction in [Direction::In, Direction::Undirected] {
6445 let plan = GraphPlan::builder("openCypher")
6446 .push_op(GraphOp::NodeScan {
6447 var: VarId(0),
6448 ty: None,
6449 })
6450 .push_op(GraphOp::NodeScan {
6451 var: VarId(2),
6452 ty: None,
6453 })
6454 .push_op(GraphOp::Expand {
6455 src: VarId(0),
6456 edge: VarId(1),
6457 dst: VarId(2),
6458 rel_ty: None,
6459 dir: direction,
6460 min_hops: 1,
6461 max_hops: Some(1),
6462 })
6463 .build();
6464 let lowered = GraphPlanLowerer::new(None, None).lower_plan(&plan).unwrap();
6465 let rendered = lowered.display_indent_schema().to_string();
6466 assert!(rendered.contains("Filter"), "{direction:?}: {rendered}");
6467 }
6468 }
6469
6470 #[test]
6471 fn exact_zero_recursive_plan_reference_and_binding_analysis() {
6472 let wanted = VarId(17);
6473 let other = VarId(18);
6474 let mut referenced_builder = GraphPlan::builder("openCypher");
6475 referenced_builder.push_expr(IrExpr::VarRef(wanted));
6476 let referenced = referenced_builder.build();
6477 let mut unrelated_builder = GraphPlan::builder("openCypher");
6478 unrelated_builder.push_expr(IrExpr::VarRef(other));
6479 let unrelated = unrelated_builder.build();
6480
6481 assert!(plan_references_var(&referenced, wanted));
6482 assert!(!plan_references_var(&unrelated, wanted));
6483
6484 let wrappers = [
6485 GraphOp::Optional {
6486 child: Box::new(referenced.clone()),
6487 },
6488 GraphOp::Exists {
6489 child: Box::new(referenced.clone()),
6490 negated: false,
6491 },
6492 GraphOp::PatternComprehension {
6493 child: Box::new(referenced.clone()),
6494 output: VarId(20),
6495 },
6496 GraphOp::ListElementPatternComprehension {
6497 list_expr: ExprId(0),
6498 loop_var: VarId(21),
6499 child: Box::new(referenced.clone()),
6500 pattern_output: VarId(22),
6501 filter: None,
6502 projection: None,
6503 output: VarId(23),
6504 },
6505 GraphOp::Union {
6506 all: true,
6507 inputs: vec![unrelated.clone(), referenced.clone()],
6508 },
6509 ];
6510 for wrapper in wrappers {
6511 let plan = GraphPlan::builder("openCypher").push_op(wrapper).build();
6512 assert!(plan_references_var(&plan, wanted));
6513 }
6514
6515 for op in [
6516 GraphOp::NodeScan {
6517 var: wanted,
6518 ty: None,
6519 },
6520 GraphOp::EdgeScan {
6521 var: wanted,
6522 ty: None,
6523 },
6524 GraphOp::TypedEdgeScan {
6525 var: wanted,
6526 rel_ty: TypeId(1),
6527 },
6528 GraphOp::Expand {
6529 src: other,
6530 edge: wanted,
6531 dst: VarId(19),
6532 rel_ty: None,
6533 dir: Direction::Out,
6534 min_hops: 1,
6535 max_hops: Some(1),
6536 },
6537 ] {
6538 assert!(graph_op_binds_var(&op, wanted));
6539 }
6540 assert!(!graph_op_binds_var(
6541 &GraphOp::NodeScan {
6542 var: other,
6543 ty: None,
6544 },
6545 wanted
6546 ));
6547
6548 let mut outer = VarMap::new();
6549 outer.insert(wanted, "outer");
6550 assert!(!full_subquery_needs_outer_input(&unrelated, &outer));
6551 assert!(full_subquery_needs_outer_input(&referenced, &outer));
6552 let mut locally_bound_builder = GraphPlan::builder("openCypher");
6553 locally_bound_builder.push_op_mut(GraphOp::NodeScan {
6554 var: wanted,
6555 ty: None,
6556 });
6557 locally_bound_builder.push_expr(IrExpr::VarRef(wanted));
6558 let locally_bound = locally_bound_builder.build();
6559 assert!(!full_subquery_needs_outer_input(&locally_bound, &outer));
6560 }
6561
6562 #[test]
6563 fn exact_zero_optional_scope_promotes_only_inner_entity_shapes() {
6564 use datafusion::arrow::datatypes::{DataType, Field, Schema};
6565
6566 let scan = |alias: &str, field: Field| {
6567 LogicalPlanBuilder::scan(
6568 alias,
6569 table_source(Arc::new(Schema::new(vec![field]))),
6570 None,
6571 )
6572 .and_then(LogicalPlanBuilder::build)
6573 .unwrap()
6574 };
6575 let outer = scan("outer_scalar", Field::new("value", DataType::Int64, true));
6576 let inner = scan("inner_node", Field::new("node_uuid", DataType::Utf8, false));
6577 let promoted = VarId(30);
6578 let absent_outer = VarId(31);
6579 let mut child_vm = VarMap::new();
6580 child_vm.insert(promoted, "inner_node");
6581 child_vm.insert(absent_outer, "inner_node");
6582 let mut outer_vm = VarMap::new();
6583 outer_vm.insert(promoted, "outer_scalar");
6584
6585 promote_optional_entity_vars(&outer, &inner, &child_vm, &mut outer_vm);
6586 assert_eq!(outer_vm.get(promoted), Some("inner_node"));
6587 assert_eq!(outer_vm.get(absent_outer), None);
6588
6589 let already_entity = scan("outer_node", Field::new("node_uuid", DataType::Utf8, false));
6590 outer_vm.insert(promoted, "outer_node");
6591 promote_optional_entity_vars(&already_entity, &inner, &child_vm, &mut outer_vm);
6592 assert_eq!(outer_vm.get(promoted), Some("outer_node"));
6593 }
6594
6595 #[test]
6596 fn exact_zero_validation_errors_are_specific_and_non_interpolated() {
6597 let lowerer = GraphPlanLowerer::new(None, None);
6598 let empty_vm = VarMap::new();
6599 let no_alternatives = lowerer
6600 .lower_exists_alternatives(&[], false, empty_base(), &empty_vm)
6601 .unwrap_err();
6602 assert_eq!(
6603 no_alternatives.to_string(),
6604 "unsupported expression: pattern predicate has no alternatives"
6605 );
6606
6607 let uncorrelated = GraphPlan::builder("openCypher").build();
6608 let uncorrelated_error = lowerer
6609 .lower_exists_alternatives(&[uncorrelated], true, empty_base(), &empty_vm)
6610 .unwrap_err();
6611 assert!(
6612 uncorrelated_error
6613 .to_string()
6614 .contains("must share at least one bound variable")
6615 );
6616
6617 let mut arena = ExprArena::new();
6618 let not_a_map = arena.push(IrExpr::Literal(IrLiteral::Int(1)));
6619 let map_error =
6620 eval_map_literal(&lowerer, Some(not_a_map), &arena, &empty_vm, None).unwrap_err();
6621 assert!(
6622 map_error
6623 .to_string()
6624 .contains("CREATE properties must be a map literal")
6625 );
6626 assert_eq!(
6627 eval_map_literal(&lowerer, None, &arena, &empty_vm, None).unwrap(),
6628 (Vec::new(), Vec::new())
6629 );
6630
6631 let mut edge_vm = VarMap::new();
6632 let bound_error = expand_bound_edge_single_dir(
6633 VarId(2),
6634 Some(TypeId(999_999)),
6635 "var_0",
6636 "var_1",
6637 true,
6638 empty_base(),
6639 &mut edge_vm,
6640 &HashMap::new(),
6641 None,
6642 )
6643 .unwrap_err();
6644 assert!(bound_error.to_string().contains("TypeId(999999)"));
6645 assert!(bound_error.to_string().contains("no known relation name"));
6646 }
6647
6648 #[test]
6649 fn exact_zero_pagination_accepts_platform_boundary_values() {
6650 assert!(matches!(
6651 lower_limit(0, empty_base()).unwrap(),
6652 DfLogicalPlan::Limit(_)
6653 ));
6654 assert!(matches!(
6655 lower_skip(0, empty_base()).unwrap(),
6656 DfLogicalPlan::Limit(_)
6657 ));
6658 assert!(matches!(
6659 lower_limit(u64::try_from(usize::MAX).unwrap(), empty_base()).unwrap(),
6660 DfLogicalPlan::Limit(_)
6661 ));
6662 assert!(matches!(
6663 lower_skip(u64::try_from(usize::MAX).unwrap(), empty_base()).unwrap(),
6664 DfLogicalPlan::Limit(_)
6665 ));
6666 }
6667}