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
63pub struct GraphPlanLowerer<'a> {
75 catalog: Option<&'a GraphCatalog>,
79 type_id_to_rel_name: HashMap<u32, String>,
82 type_id_to_entity_name: HashMap<u32, String>,
85 write_target: Option<(&'a Path, OntologyMode)>,
90 read_dir: Option<(&'a Path, OntologyMode)>,
96 node_shapes: std::sync::RwLock<HashMap<u32, crate::expr::NodeShape>>,
100 inference_rules: HashMap<u32, Vec<(String, String)>>,
106 relational_fixed_hop_reference: bool,
110}
111
112impl<'a> GraphPlanLowerer<'a> {
113 #[must_use]
122 pub fn new(catalog: Option<&'a GraphCatalog>, ontology: Option<&'a OntologyHandle>) -> Self {
123 Self::build(catalog, ontology, None, None)
124 }
125
126 #[must_use]
133 pub fn new_for_writes(
134 catalog: Option<&'a GraphCatalog>,
135 ontology: Option<&'a OntologyHandle>,
136 dir: &'a Path,
137 mode: OntologyMode,
138 ) -> Self {
139 Self::build(catalog, ontology, Some((dir, mode)), Some((dir, mode)))
140 }
141
142 #[must_use]
149 pub fn new_with_dir(
150 catalog: Option<&'a GraphCatalog>,
151 ontology: Option<&'a OntologyHandle>,
152 dir: &'a Path,
153 mode: OntologyMode,
154 ) -> Self {
155 Self::build(catalog, ontology, None, Some((dir, mode)))
156 }
157
158 fn build(
159 catalog: Option<&'a GraphCatalog>,
160 ontology: Option<&'a OntologyHandle>,
161 write_target: Option<(&'a Path, OntologyMode)>,
162 read_dir: Option<(&'a Path, OntologyMode)>,
163 ) -> Self {
164 let mut type_id_to_rel_name = build_type_id_map(ontology);
169 if let Some(c) = catalog {
170 for (id, name) in c.rel_names() {
171 let plan_id =
172 graphforge_ir::runtime_relation_type_id(graphforge_ir::RuntimeTypeId(*id));
173 type_id_to_rel_name.insert(plan_id.0, name.clone());
174 }
175 }
176 Self {
177 catalog,
178 type_id_to_rel_name,
179 type_id_to_entity_name: build_entity_id_map(ontology),
185 write_target,
186 read_dir,
187 node_shapes: std::sync::RwLock::new(HashMap::new()),
188 inference_rules: build_inference_rules(ontology),
189 relational_fixed_hop_reference: false,
190 }
191 }
192
193 #[doc(hidden)]
197 #[must_use]
198 pub fn with_relational_fixed_hop_reference(mut self) -> Self {
199 self.relational_fixed_hop_reference = true;
200 self
201 }
202
203 fn read_dir(&self) -> Option<&'a Path> {
207 self.read_dir.map(|(d, _)| d)
208 }
209
210 fn read_mode(&self) -> OntologyMode {
214 self.read_dir.map_or(OntologyMode::Exploratory, |(_, m)| m)
215 }
216
217 fn prop_names(&self) -> HashMap<u32, String> {
224 match self.catalog {
225 Some(c) => c.prop_names().clone(),
226 None => HashMap::new(),
227 }
228 }
229
230 fn expr_lowerer<'b>(&self, arena: &'b ExprArena, var_map: &'b VarMap) -> ExprLowerer<'b> {
233 let mut node_label_names = self.type_id_to_entity_name.clone();
239 if let Some(c) = self.catalog {
240 for (id, name) in c.label_names() {
241 node_label_names.entry(*id).or_insert_with(|| name.clone());
242 }
243 }
244 let mut lowerer = ExprLowerer::with_prop_names_and_nodes(
245 arena,
246 var_map,
247 self.prop_names(),
248 self.node_shapes
249 .read()
250 .expect("node shapes lock poisoned")
251 .clone(),
252 node_label_names,
253 self.read_dir().is_some(),
259 );
260 if let Some(dir) = self.read_dir() {
262 lowerer = lowerer.with_read_target(dir.to_path_buf());
263 }
264 lowerer
265 }
266
267 fn build_node_shapes(&self, ops: &[GraphOp]) -> HashMap<u32, crate::expr::NodeShape> {
272 let mut shapes = HashMap::new();
273 self.collect_node_shapes(ops, &mut shapes);
274 shapes
275 }
276
277 fn collect_node_shapes(
278 &self,
279 ops: &[GraphOp],
280 shapes: &mut HashMap<u32, crate::expr::NodeShape>,
281 ) {
282 for op in ops {
283 match op {
284 GraphOp::NodeScan { var, ty } => {
285 let prop_names = self.node_prop_cols(*ty);
286 shapes.insert(var.0, crate::expr::NodeShape { prop_names });
287 }
288 GraphOp::Optional { child }
289 | GraphOp::Exists { child, .. }
290 | GraphOp::PatternComprehension { child, .. }
291 | GraphOp::ListElementPatternComprehension { child, .. } => {
292 self.collect_node_shapes(&child.ops, shapes);
293 }
294 GraphOp::Union { inputs, .. } => {
295 for input in inputs {
296 self.collect_node_shapes(&input.ops, shapes);
297 }
298 }
299 _ => {}
300 }
301 }
302 }
303
304 fn prop_table_stem(&self, ty: Option<TypeId>) -> Option<String> {
315 match ty {
316 Some(type_id) => Some(
317 self.type_id_to_entity_name
318 .get(&type_id.0)
319 .cloned()
320 .unwrap_or_else(|| "_untyped".to_owned()),
321 ),
322 None if matches!(self.read_mode(), OntologyMode::Exploratory) => {
323 Some("_untyped".to_owned())
324 }
325 None => None,
326 }
327 }
328
329 fn node_prop_cols(&self, ty: Option<TypeId>) -> Vec<String> {
334 let Some(dir) = self.read_dir() else {
335 return Vec::new();
336 };
337 let Some(stem) = self.prop_table_stem(ty) else {
338 return Vec::new();
339 };
340 let prop_table = graphforge_storage::PropertyTable::open_discovered(dir, &stem);
341 prop_table
342 .schema_ref()
343 .fields()
344 .iter()
345 .map(|f| f.name().clone())
346 .filter(|n| TOPOLOGY_NODES_SCHEMA.field_with_name(n).is_err())
347 .collect()
348 }
349
350 fn join_node_properties(
377 &self,
378 var: VarId,
379 ty: Option<TypeId>,
380 scan: LogicalPlan,
381 ) -> Result<LogicalPlan, LoweringError> {
382 use datafusion::common::Column;
383 use datafusion::logical_expr::col;
384
385 let Some(dir) = self.read_dir() else {
386 return Ok(scan); };
388 let Some(stem) = self.prop_table_stem(ty) else {
389 return Ok(scan); };
391
392 let prop_table = graphforge_storage::PropertyTable::open_discovered(dir, &stem);
393 let prop_schema = prop_table.schema_ref();
394 let node_alias = var_alias(var);
395
396 let existing: std::collections::HashSet<String> = scan
403 .schema()
404 .iter()
405 .filter(|(q, _)| q.is_some_and(|t| t.table() == node_alias.as_str()))
406 .map(|(_, f)| f.name().clone())
407 .collect();
408
409 let prop_cols: Vec<String> = prop_schema
418 .fields()
419 .iter()
420 .map(|f| f.name().clone())
421 .filter(|n| TOPOLOGY_NODES_SCHEMA.field_with_name(n).is_err())
422 .filter(|n| !existing.contains(n))
423 .collect();
424 if prop_cols.is_empty() {
425 return Ok(scan);
426 }
427
428 let prop_alias = format!("{node_alias}__props");
429 let prop_src = datafusion::datasource::provider_as_source(Arc::new(prop_table));
430 let prop_scan = LogicalPlanBuilder::scan(prop_alias.clone(), prop_src, None)
431 .and_then(LogicalPlanBuilder::build)
432 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
433
434 let input_cols: Vec<Column> = scan
440 .schema()
441 .iter()
442 .map(|(qualifier, field)| Column::new(qualifier.cloned(), field.name()))
443 .collect();
444
445 let join_pred =
447 col(format!("{node_alias}.node_uuid")).eq(col(format!("{prop_alias}.node_uuid")));
448 let joined = LogicalPlanBuilder::from(scan)
449 .join_on(prop_scan, JoinType::Left, vec![join_pred])
450 .and_then(LogicalPlanBuilder::build)
451 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
452
453 let mut projections: Vec<DfExpr> = input_cols.into_iter().map(DfExpr::Column).collect();
456 for name in &prop_cols {
457 projections.push(
458 crate::expr::qualified_col(&prop_alias, name)
459 .alias_qualified(Some(node_alias.as_str()), name.as_str()),
460 );
461 }
462
463 LogicalPlanBuilder::from(joined)
464 .project(projections)
465 .and_then(LogicalPlanBuilder::build)
466 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))
467 }
468
469 pub fn lower_plan(&self, plan: &GraphPlan) -> Result<LogicalPlan, GfError> {
482 *self.node_shapes.write().expect("node shapes lock poisoned") =
485 self.build_node_shapes(&plan.ops);
486 let mut var_map = VarMap::new();
487 self.lower_pipeline(&plan.ops, &plan.exprs, &mut var_map)
488 .map_err(|e| GfError::Plan(e.to_string()))
489 }
490
491 pub fn lower_prefix(
511 &self,
512 ops: &[GraphOp],
513 exprs: &ExprArena,
514 var_map: &mut VarMap,
515 ) -> Result<LogicalPlan, GfError> {
516 *self.node_shapes.write().expect("node shapes lock poisoned") = self.build_node_shapes(ops);
517 self.lower_pipeline(ops, exprs, var_map)
518 .map_err(|e| GfError::Plan(e.to_string()))
519 }
520
521 pub fn lower_terminal_suffix(
525 &self,
526 ops: &[GraphOp],
527 exprs: &ExprArena,
528 var_map: &mut VarMap,
529 input_schema: datafusion::common::DFSchemaRef,
530 ) -> Result<LogicalPlan, GfError> {
531 let input = LogicalPlan::EmptyRelation(datafusion::logical_expr::EmptyRelation {
532 produce_one_row: true,
533 schema: input_schema,
534 });
535 self.lower_pipeline_from(ops, exprs, var_map, input, None)
536 .map_err(|e| GfError::Plan(e.to_string()))
537 }
538
539 pub fn lower_write_segment(
542 &self,
543 ops: &[GraphOp],
544 exprs: &ExprArena,
545 var_map: &mut VarMap,
546 input_schema: datafusion::common::DFSchemaRef,
547 pending_nodes: &RecordBatch,
548 ) -> Result<LogicalPlan, GfError> {
549 let input = LogicalPlan::EmptyRelation(datafusion::logical_expr::EmptyRelation {
550 produce_one_row: true,
551 schema: input_schema,
552 });
553 self.lower_pipeline_from(ops, exprs, var_map, input, Some(pending_nodes))
554 .map_err(|e| GfError::Plan(e.to_string()))
555 }
556
557 pub fn resolve_create_pattern(
567 &self,
568 pattern: &CreatePattern,
569 exprs: &ExprArena,
570 var_map: &VarMap,
571 input_schema: &datafusion::common::DFSchemaRef,
572 ) -> Result<(Vec<ResolvedNodeSpec>, Vec<ResolvedEdgeSpec>), GfError> {
573 self.create_specs(pattern, exprs, var_map, Some(input_schema))
574 .map_err(|e| GfError::Plan(e.to_string()))
575 }
576
577 pub fn register_created_node_shapes(&self, nodes: &[ResolvedNodeSpec]) {
580 let mut shapes = self.node_shapes.write().expect("node shapes lock poisoned");
581 for spec in nodes.iter().filter(|n| !n.is_reference) {
582 let prop_names = spec
583 .properties
584 .iter()
585 .map(|(k, _)| k.clone())
586 .chain(spec.computed_properties.iter().map(|(k, _)| k.clone()))
587 .collect();
588 shapes.insert(spec.var, crate::expr::NodeShape { prop_names });
589 }
590 }
591
592 pub fn register_node_property_shape(&self, var: VarId, name: &str) {
594 let mut shapes = self.node_shapes.write().expect("node shapes lock poisoned");
595 let shape = shapes
596 .entry(var.0)
597 .or_insert_with(|| crate::expr::NodeShape { prop_names: vec![] });
598 if !shape.prop_names.iter().any(|existing| existing == name) {
599 shape.prop_names.push(name.to_owned());
600 shape.prop_names.sort();
601 }
602 }
603
604 pub fn lower_value_expr(
610 &self,
611 exprs: &ExprArena,
612 var_map: &VarMap,
613 id: graphforge_ir::ExprId,
614 ) -> Result<DfExpr, GfError> {
615 self.expr_lowerer(exprs, var_map)
616 .lower(id)
617 .map_err(|e| GfError::Plan(e.to_string()))
618 }
619
620 pub fn lower_value_expr_with_input(
628 &self,
629 exprs: &ExprArena,
630 var_map: &VarMap,
631 id: graphforge_ir::ExprId,
632 input_schema: datafusion::common::DFSchemaRef,
633 ) -> Result<DfExpr, GfError> {
634 self.expr_lowerer(exprs, var_map)
635 .with_input_schema(input_schema)
636 .lower(id)
637 .map_err(|e| GfError::Plan(e.to_string()))
638 }
639
640 #[must_use]
643 pub fn entity_name_map(&self) -> HashMap<u32, String> {
644 self.type_id_to_entity_name.clone()
645 }
646
647 fn lower_pipeline(
655 &self,
656 ops: &[GraphOp],
657 exprs: &ExprArena,
658 var_map: &mut VarMap,
659 ) -> Result<LogicalPlan, LoweringError> {
660 let ordered: Vec<&GraphOp> = ops.iter().collect();
661
662 let produce_one_row = !ordered.first().copied().is_some_and(is_source_op);
671 let mut current = LogicalPlanBuilder::empty(produce_one_row)
672 .build()
673 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
674
675 for (i, op) in ordered.iter().enumerate() {
676 let create_feeds_read = matches!(op, GraphOp::Create { .. }) && i + 1 < ordered.len();
680 current = self.lower_op_with_arena(
681 op,
682 current,
683 exprs,
684 var_map,
685 create_feeds_read,
686 false,
687 None,
688 )?;
689 }
690 Ok(current)
691 }
692
693 fn lower_pipeline_from(
695 &self,
696 ops: &[GraphOp],
697 exprs: &ExprArena,
698 var_map: &mut VarMap,
699 mut current: LogicalPlan,
700 pending_nodes: Option<&RecordBatch>,
701 ) -> Result<LogicalPlan, LoweringError> {
702 let ordered: Vec<&GraphOp> = ops.iter().collect();
703 for (i, op) in ordered.iter().enumerate() {
704 let create_feeds_read = matches!(op, GraphOp::Create { .. }) && i + 1 < ordered.len();
705 current = self.lower_op_with_arena(
706 op,
707 current,
708 exprs,
709 var_map,
710 create_feeds_read,
711 true,
712 pending_nodes,
713 )?;
714 }
715 Ok(current)
716 }
717
718 pub fn lower_op(
737 &self,
738 op: &GraphOp,
739 input: LogicalPlan,
740 exprs: &ExprArena,
741 _var_map: &VarMap,
742 expr_lowerer: &ExprLowerer<'_>,
743 ) -> Result<LogicalPlan, LoweringError> {
744 lower_relational_op(op, input, exprs, expr_lowerer)
745 }
746
747 #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
752 fn lower_op_with_arena(
753 &self,
754 op: &GraphOp,
755 input: LogicalPlan,
756 exprs: &ExprArena,
757 var_map: &mut VarMap,
758 create_feeds_read: bool,
759 preserve_empty_input: bool,
760 pending_nodes: Option<&RecordBatch>,
761 ) -> Result<LogicalPlan, LoweringError> {
762 match op {
764 GraphOp::NodeScan { var, ty } => {
765 if let Some(alias) = var_map.get(*var) {
781 let qualifier = datafusion::common::TableReference::bare(alias);
782 let input = if input
783 .schema()
784 .index_of_column_by_name(Some(&qualifier), "node_uuid")
785 .is_some()
786 && input
787 .schema()
788 .index_of_column_by_name(Some(&qualifier), "node_id")
789 .is_none()
790 {
791 enrich_bound_node_identity(&input, alias, self.read_dir())?
792 } else {
793 input
794 };
795 return match ty {
796 Some(type_id) => {
797 let filtered = filter_node_by_type(input, alias, *type_id)?;
798 self.join_node_properties(*var, *ty, filtered)
799 }
800 None => self.join_node_properties(*var, None, input),
806 };
807 }
808 let scan = lower_node_scan(*var, *ty, var_map, self.read_dir(), pending_nodes)?;
809 let scan = self.join_node_properties(*var, *ty, scan)?;
810 if input.schema().fields().is_empty() && !preserve_empty_input {
819 return Ok(scan);
820 }
821 return LogicalPlanBuilder::from(input)
822 .cross_join(scan)
823 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?
824 .build()
825 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()));
826 }
827 GraphOp::TypedEdgeScan { var, rel_ty } => {
828 return lower_typed_edge_scan(
829 *var,
830 *rel_ty,
831 var_map,
832 self.catalog,
833 &self.type_id_to_rel_name,
834 self.read_dir(),
835 self.read_mode(),
836 );
837 }
838 GraphOp::EdgeScan { var, ty } => {
839 return lower_edge_scan(
840 *var,
841 *ty,
842 var_map,
843 &self.type_id_to_rel_name,
844 self.read_dir(),
845 self.read_mode(),
846 );
847 }
848 GraphOp::Expand {
849 src,
850 edge,
851 dst,
852 rel_ty,
853 dir,
854 min_hops,
855 max_hops,
856 } => {
857 return lower_expand(
858 *src,
859 *edge,
860 *dst,
861 *rel_ty,
862 *dir,
863 *min_hops,
864 *max_hops,
865 input,
866 var_map,
867 self.catalog,
868 &self.type_id_to_rel_name,
869 &self.inference_rules,
870 self.read_dir,
871 self.relational_fixed_hop_reference,
872 );
873 }
874 GraphOp::RelationshipUnique { edge, prior_edges } => {
875 use datafusion::logical_expr::col;
876 let edge_alias = var_map
877 .get(*edge)
878 .ok_or(LoweringError::UnboundVar(edge.0))?;
879 let mut predicates = prior_edges.iter().map(|prior| {
880 let prior_alias = var_map
881 .get(*prior)
882 .ok_or(LoweringError::UnboundVar(prior.0))?;
883 let value = |alias: &str| {
884 if alias.ends_with(graphforge_plan::VAR_LEN_EDGE_LIST_FIELD) {
885 col(alias)
886 } else {
887 col(format!("{alias}.edge_uuid"))
888 }
889 };
890 Ok(crate::expr::relationship_disjoint(
891 value(edge_alias),
892 value(prior_alias),
893 ))
894 });
895 let Some(mut predicate) = predicates.next().transpose()? else {
896 return Ok(input);
897 };
898 for next in predicates {
899 predicate = predicate.and(next?);
900 }
901 return LogicalPlanBuilder::from(input)
902 .filter(predicate)
903 .and_then(LogicalPlanBuilder::build)
904 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()));
905 }
906 GraphOp::Unwind { list_expr, alias } => {
909 let expr_lowerer = self
910 .expr_lowerer(exprs, var_map)
911 .with_input_schema(input.schema().clone());
912 let df_expr = expr_lowerer.lower(*list_expr)?;
913 let element_field =
917 unwind_element_field(&df_expr, input.schema(), self.prop_names().values());
918 let node =
919 UnwindNode::new(Arc::new(input), df_expr, var_alias(*alias), &element_field);
920 var_map.insert(*alias, var_alias(*alias));
922 return Ok(LogicalPlan::Extension(Extension {
923 node: Arc::new(node),
924 }));
925 }
926 GraphOp::Call {
927 procedure,
928 args,
929 yields,
930 } => {
931 return self.lower_call_op(procedure, args, yields, input, exprs, var_map);
932 }
933 GraphOp::Union { all, inputs } => {
934 return self.lower_union_op(*all, inputs);
935 }
936 GraphOp::Optional { child } => {
942 let mut child_vm = VarMap::new();
943 let child_plan = self.lower_pipeline(&child.ops, &child.exprs, &mut child_vm)?;
944 let (join_keys, inner_keep_idx) =
945 optional_join_keys(&input, &child_plan, var_map, &child_vm);
946 merge_optional_child_vars(&child_vm, var_map);
947 promote_optional_entity_vars(&input, &child_plan, &child_vm, var_map);
948 let node = OptionalMatchNode::new(
949 Arc::new(input),
950 Arc::new(child_plan),
951 join_keys,
952 inner_keep_idx,
953 );
954 return Ok(LogicalPlan::Extension(Extension {
955 node: Arc::new(node),
956 }));
957 }
958 GraphOp::Exists { child, negated } => {
959 return self.lower_exists_op(child, *negated, input, var_map);
960 }
961 GraphOp::PatternComprehension { child, output } => {
962 return self.lower_pattern_comprehension_op(child, *output, input, var_map);
963 }
964 GraphOp::ListElementPatternComprehension {
965 list_expr,
966 loop_var,
967 child,
968 pattern_output,
969 filter,
970 projection,
971 output,
972 } => {
973 return self.lower_list_element_pattern_comprehension_op(
974 *list_expr,
975 *loop_var,
976 child,
977 *pattern_output,
978 *filter,
979 *projection,
980 *output,
981 input,
982 exprs,
983 var_map,
984 );
985 }
986 GraphOp::Create { pattern } => {
987 return self.lower_create(pattern, exprs, input, var_map, create_feeds_read);
994 }
995 GraphOp::Delete { vars, detach, .. } => {
996 return self.lower_delete_op(vars, *detach, input);
999 }
1000 GraphOp::Set {
1001 items,
1002 map_items,
1003 label_items,
1004 } => {
1005 if !map_items.is_empty() || !label_items.is_empty() {
1006 return Err(LoweringError::UnsupportedExpr(
1007 "SET map and label assignments execute through the statement driver".into(),
1008 ));
1009 }
1010 return self.lower_set_op(items, input, exprs, var_map);
1011 }
1012 GraphOp::Remove { items, label_items } => {
1013 if !label_items.is_empty() {
1014 return Err(LoweringError::UnsupportedExpr(
1015 "REMOVE label assignments execute through the statement driver".into(),
1016 ));
1017 }
1018 return self.lower_remove_op(items, input);
1019 }
1020 GraphOp::Project { items, distinct }
1021 if items.iter().any(|item| item.out_var.is_some()) =>
1022 {
1023 let plan = {
1024 let lowerer = self
1025 .expr_lowerer(exprs, var_map)
1026 .with_input_schema(input.schema().clone());
1027 lower_project(items, *distinct, input, &lowerer)?
1028 };
1029 for item in items {
1030 if let (Some(v), Some(alias)) = (item.out_var, item.alias.as_ref()) {
1031 var_map.insert(v, alias.clone());
1032 }
1033 }
1034 return Ok(plan);
1035 }
1036 GraphOp::With {
1037 items,
1038 distinct,
1039 where_predicate,
1040 } => {
1041 return self.lower_with_op(
1042 items,
1043 *distinct,
1044 *where_predicate,
1045 input,
1046 exprs,
1047 var_map,
1048 );
1049 }
1050 GraphOp::Aggregate {
1058 group_by,
1059 group_aliases,
1060 group_vars,
1061 aggs,
1062 } => {
1063 let plan = {
1064 let lowerer = self
1065 .expr_lowerer(exprs, var_map)
1066 .with_input_schema(input.schema().clone());
1067 lower_aggregate(
1068 group_by,
1069 group_aliases,
1070 aggs,
1071 input,
1072 exprs,
1073 &lowerer,
1074 Some((group_vars, var_map)),
1075 )?
1076 };
1077 let decomposed = !group_vars.is_empty() || aggs.iter().any(|a| a.out_var.is_some());
1078 if decomposed {
1079 let passthrough: Vec<(VarId, String)> = group_by
1082 .iter()
1083 .zip(group_vars)
1084 .filter_map(|(&expr, group_var)| {
1085 let group_var = (*group_var)?;
1086 match exprs.get(expr) {
1087 IrExpr::VarRef(source) if *source == group_var => var_map
1088 .get(group_var)
1089 .map(|alias| (group_var, alias.to_owned())),
1090 _ => None,
1091 }
1092 })
1093 .collect();
1094 var_map.clear();
1095 for (var, alias) in passthrough {
1096 var_map.insert(var, alias);
1097 }
1098 for (i, gv) in group_vars.iter().enumerate() {
1099 if let (Some(v), Some(Some(alias))) = (gv, group_aliases.get(i)) {
1100 var_map.insert(*v, alias.clone());
1101 }
1102 }
1103 for a in aggs {
1104 if let Some(v) = a.out_var {
1105 var_map.insert(v, a.alias.clone());
1106 }
1107 }
1108 }
1109 return Ok(plan);
1110 }
1111 _ => {}
1112 }
1113 let expr_lowerer = self
1118 .expr_lowerer(exprs, var_map)
1119 .with_input_schema(input.schema().clone());
1120 lower_relational_op(op, input, exprs, &expr_lowerer)
1121 }
1122
1123 fn lower_call_op(
1124 &self,
1125 procedure: &graphforge_ir::ProcedureDefinition,
1126 args: &[ExprId],
1127 yields: &[graphforge_ir::ProcedureYield],
1128 input: LogicalPlan,
1129 exprs: &ExprArena,
1130 var_map: &mut VarMap,
1131 ) -> Result<LogicalPlan, LoweringError> {
1132 use datafusion::arrow::datatypes::{DataType, Field, Schema};
1133 use datafusion::common::{Column, DFSchema};
1134 use datafusion::logical_expr::{EmptyRelation, lit};
1135
1136 let width = procedure.inputs.len() + procedure.outputs.len();
1137 let names: Vec<String> = (0..width)
1138 .map(|index| format!("column{}", index + 1))
1139 .collect();
1140 let fields = procedure
1141 .inputs
1142 .iter()
1143 .chain(&procedure.outputs)
1144 .zip(&names)
1145 .map(|(field, name)| {
1146 let data_type = match field.type_name.to_ascii_uppercase().as_str() {
1147 "BOOLEAN" => DataType::Boolean,
1148 "INTEGER" => DataType::Int64,
1149 "FLOAT" | "NUMBER" => DataType::Float64,
1150 _ => DataType::Utf8,
1151 };
1152 Field::new(name, data_type, field.nullable)
1153 })
1154 .collect::<Vec<_>>();
1155 let schema = Arc::new(
1156 DFSchema::try_from(Schema::new(fields))
1157 .map_err(|error| LoweringError::UnsupportedExpr(error.to_string()))?,
1158 );
1159
1160 let fixture = if width == 0 {
1161 LogicalPlan::EmptyRelation(EmptyRelation {
1162 produce_one_row: !procedure.rows.is_empty(),
1163 schema,
1164 })
1165 } else if procedure.rows.is_empty() {
1166 LogicalPlan::EmptyRelation(EmptyRelation {
1167 produce_one_row: false,
1168 schema,
1169 })
1170 } else {
1171 let rows = procedure
1172 .rows
1173 .iter()
1174 .map(|row| {
1175 row.iter()
1176 .map(|value| lit(crate::expr::ir_literal_to_scalar(value)))
1177 .collect()
1178 })
1179 .collect();
1180 LogicalPlanBuilder::values_with_schema(rows, &schema)
1181 .and_then(LogicalPlanBuilder::build)
1182 .map_err(|error| LoweringError::UnsupportedExpr(error.to_string()))?
1183 };
1184
1185 let input_columns: Vec<Column> = input
1186 .schema()
1187 .iter()
1188 .map(|(qualifier, field)| Column::new(qualifier.cloned(), field.name()))
1189 .collect();
1190 let lowerer = self
1191 .expr_lowerer(exprs, var_map)
1192 .with_input_schema(input.schema().clone());
1193 let predicates = args
1194 .iter()
1195 .enumerate()
1196 .map(|(index, arg)| {
1197 Ok(DfExpr::BinaryExpr(
1198 datafusion::logical_expr::BinaryExpr::new(
1199 Box::new(lowerer.lower(*arg)?),
1200 datafusion::logical_expr::Operator::IsNotDistinctFrom,
1201 Box::new(DfExpr::Column(Column::from_name(names[index].clone()))),
1202 ),
1203 ))
1204 })
1205 .collect::<Result<Vec<_>, LoweringError>>()?;
1206 let joined = LogicalPlanBuilder::from(input)
1207 .join_on(fixture, JoinType::Inner, predicates)
1208 .and_then(LogicalPlanBuilder::build)
1209 .map_err(|error| LoweringError::UnsupportedExpr(error.to_string()))?;
1210
1211 let mut projection: Vec<DfExpr> = input_columns.into_iter().map(DfExpr::Column).collect();
1212 for yielded in yields {
1213 let output_index = procedure
1214 .outputs
1215 .iter()
1216 .position(|field| field.name == yielded.field)
1217 .expect("binder only emits registered procedure outputs");
1218 let alias = yielded.alias.clone();
1219 projection.push(
1220 DfExpr::Column(Column::from_name(
1221 names[procedure.inputs.len() + output_index].clone(),
1222 ))
1223 .alias(alias.clone()),
1224 );
1225 var_map.insert(yielded.var, alias);
1226 }
1227 LogicalPlanBuilder::from(joined)
1228 .project(projection)
1229 .and_then(LogicalPlanBuilder::build)
1230 .map_err(|error| LoweringError::UnsupportedExpr(error.to_string()))
1231 }
1232
1233 fn lower_union_op(
1234 &self,
1235 all: bool,
1236 inputs: &[GraphPlan],
1237 ) -> Result<LogicalPlan, LoweringError> {
1238 if inputs.len() < 2 {
1239 return Err(LoweringError::UnsupportedExpr(
1240 "UNION requires at least two branch plans".into(),
1241 ));
1242 }
1243 let mut plans = inputs
1244 .iter()
1245 .map(|branch| {
1246 let mut branch_vars = VarMap::new();
1247 self.lower_pipeline(&branch.ops, &branch.exprs, &mut branch_vars)
1248 })
1249 .collect::<Result<Vec<_>, _>>()?
1250 .into_iter();
1251 let first = plans.next().expect("UNION branch count checked above");
1252 let union = plans.try_fold(first, |left, right| {
1253 LogicalPlanBuilder::from(left)
1254 .union(right)
1255 .and_then(LogicalPlanBuilder::build)
1256 .map_err(|error| LoweringError::UnsupportedExpr(error.to_string()))
1257 })?;
1258 if all {
1259 Ok(union)
1260 } else {
1261 LogicalPlanBuilder::from(union)
1262 .distinct()
1263 .and_then(LogicalPlanBuilder::build)
1264 .map_err(|error| LoweringError::UnsupportedExpr(error.to_string()))
1265 }
1266 }
1267
1268 fn lower_exists_op(
1269 &self,
1270 child: &GraphPlan,
1271 negated: bool,
1272 input: LogicalPlan,
1273 var_map: &VarMap,
1274 ) -> Result<LogicalPlan, LoweringError> {
1275 if let [GraphOp::Union { inputs, .. }] = child.ops.as_slice() {
1276 return self.lower_exists_alternatives(inputs, negated, input, var_map);
1277 }
1278 let (child_ops, is_full_subquery) = match child.ops.last() {
1283 Some(GraphOp::Project { .. }) => (&child.ops[..child.ops.len() - 1], true),
1284 _ => (child.ops.as_slice(), false),
1285 };
1286 let seed_outer_input = is_full_subquery && full_subquery_needs_outer_input(child, var_map);
1287 let mut child_vm = if seed_outer_input {
1288 var_map.clone()
1289 } else {
1290 VarMap::new()
1291 };
1292 let child_plan = if seed_outer_input {
1293 self.lower_pipeline_from(child_ops, &child.exprs, &mut child_vm, input.clone(), None)?
1294 } else {
1295 self.lower_pipeline(child_ops, &child.exprs, &mut child_vm)?
1296 };
1297 let (join_keys, _) = optional_join_keys(&input, &child_plan, var_map, &child_vm);
1298 if join_keys.is_empty() {
1299 return Err(LoweringError::UnsupportedExpr(
1300 "pattern predicate must share at least one bound variable".into(),
1301 ));
1302 }
1303 let left_keys = join_keys
1304 .iter()
1305 .map(|(outer_idx, _)| schema_join_column(input.schema(), *outer_idx))
1306 .collect::<Vec<_>>();
1307 let right_keys = join_keys
1308 .iter()
1309 .map(|(_, inner_idx)| schema_join_column(child_plan.schema(), *inner_idx))
1310 .collect::<Vec<_>>();
1311 let join_type = if negated {
1312 JoinType::LeftAnti
1313 } else {
1314 JoinType::LeftSemi
1315 };
1316 LogicalPlanBuilder::from(input)
1317 .join(child_plan, join_type, (left_keys, right_keys), None)
1318 .and_then(LogicalPlanBuilder::build)
1319 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))
1320 }
1321
1322 fn lower_exists_alternatives(
1323 &self,
1324 children: &[GraphPlan],
1325 negated: bool,
1326 input: LogicalPlan,
1327 var_map: &VarMap,
1328 ) -> Result<LogicalPlan, LoweringError> {
1329 use datafusion::common::Column;
1330
1331 let mut expected_outer_keys: Option<Vec<usize>> = None;
1332 let mut key_union: Option<LogicalPlan> = None;
1333
1334 for child in children {
1335 let mut child_vm = VarMap::new();
1336 let child_plan = self.lower_pipeline(&child.ops, &child.exprs, &mut child_vm)?;
1337 let (mut join_keys, _) = optional_join_keys(&input, &child_plan, var_map, &child_vm);
1338 if join_keys.is_empty() {
1339 return Err(LoweringError::UnsupportedExpr(
1340 "pattern predicate must share at least one bound variable".into(),
1341 ));
1342 }
1343 join_keys.sort_unstable_by_key(|(outer_idx, _)| *outer_idx);
1344
1345 let outer_keys = join_keys
1346 .iter()
1347 .map(|(outer_idx, _)| *outer_idx)
1348 .collect::<Vec<_>>();
1349 if expected_outer_keys
1350 .as_ref()
1351 .is_some_and(|expected| expected != &outer_keys)
1352 {
1353 return Err(LoweringError::UnsupportedExpr(
1354 "OR pattern predicates must correlate on the same bound variables".into(),
1355 ));
1356 }
1357 expected_outer_keys.get_or_insert(outer_keys);
1358
1359 let key_projection = join_keys
1360 .iter()
1361 .enumerate()
1362 .map(|(key_idx, (_, inner_idx))| {
1363 DfExpr::Column(schema_join_column(child_plan.schema(), *inner_idx))
1364 .alias(format!("__exists_key_{key_idx}"))
1365 })
1366 .collect::<Vec<_>>();
1367 let key_plan = LogicalPlanBuilder::from(child_plan)
1368 .project(key_projection)
1369 .and_then(LogicalPlanBuilder::build)
1370 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
1371 key_union = Some(match key_union {
1372 None => key_plan,
1373 Some(union) => LogicalPlanBuilder::from(union)
1374 .union(key_plan)
1375 .and_then(LogicalPlanBuilder::build)
1376 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?,
1377 });
1378 }
1379
1380 let outer_keys = expected_outer_keys.ok_or_else(|| {
1381 LoweringError::UnsupportedExpr("pattern predicate has no alternatives".into())
1382 })?;
1383 let key_union = key_union.expect("outer keys imply a key union");
1384 let left_keys = outer_keys
1385 .iter()
1386 .map(|idx| schema_join_column(input.schema(), *idx))
1387 .collect::<Vec<_>>();
1388 let right_keys = (0..left_keys.len())
1389 .map(|idx| Column::from_name(format!("__exists_key_{idx}")))
1390 .collect::<Vec<_>>();
1391 let join_type = if negated {
1392 JoinType::LeftAnti
1393 } else {
1394 JoinType::LeftSemi
1395 };
1396 LogicalPlanBuilder::from(input)
1397 .join(key_union, join_type, (left_keys, right_keys), None)
1398 .and_then(LogicalPlanBuilder::build)
1399 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))
1400 }
1401
1402 fn lower_pattern_comprehension_op(
1403 &self,
1404 child: &GraphPlan,
1405 output: VarId,
1406 input: LogicalPlan,
1407 var_map: &mut VarMap,
1408 ) -> Result<LogicalPlan, LoweringError> {
1409 use datafusion::common::Column;
1410 use datafusion::functions::core::expr_fn::coalesce;
1411 use datafusion::scalar::ScalarValue;
1412
1413 let (terminal, match_ops) = child.ops.split_last().ok_or_else(|| {
1414 LoweringError::UnsupportedExpr("pattern comprehension child is empty".into())
1415 })?;
1416 let GraphOp::Project { items, distinct } = terminal else {
1417 return Err(LoweringError::UnsupportedExpr(
1418 "pattern comprehension child must end in a value projection".into(),
1419 ));
1420 };
1421 if *distinct || items.len() != 1 {
1422 return Err(LoweringError::UnsupportedExpr(
1423 "pattern comprehension child must project exactly one non-distinct value".into(),
1424 ));
1425 }
1426 let item = &items[0];
1427 if item.alias.as_deref() != Some(PATTERN_COMPREHENSION_VALUE_ALIAS) {
1428 return Err(LoweringError::UnsupportedExpr(
1429 "pattern comprehension child has an invalid value projection".into(),
1430 ));
1431 }
1432
1433 let outer_columns = input
1434 .schema()
1435 .iter()
1436 .map(|(qualifier, field)| {
1437 DfExpr::Column(Column::new(qualifier.cloned(), field.name().to_owned()))
1438 })
1439 .collect::<Vec<_>>();
1440 let mut child_vm = VarMap::new();
1441 let child_plan = self.lower_pipeline(match_ops, &child.exprs, &mut child_vm)?;
1442 let (mut join_keys, _) = optional_join_keys(&input, &child_plan, var_map, &child_vm);
1443 if join_keys.is_empty() {
1444 return Err(LoweringError::UnsupportedExpr(
1445 "pattern comprehension must share at least one bound node variable".into(),
1446 ));
1447 }
1448 join_keys.sort_unstable_by_key(|(outer_idx, _)| *outer_idx);
1449
1450 let value = self
1451 .expr_lowerer(&child.exprs, &child_vm)
1452 .with_input_schema(child_plan.schema().clone())
1453 .lower(item.expr)?;
1454 let element_type = value
1455 .get_type(child_plan.schema())
1456 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
1457 let key_aliases = (0..join_keys.len())
1458 .map(|idx| format!("__gf_pattern_key_{idx}"))
1459 .collect::<Vec<_>>();
1460 let group_exprs = join_keys
1461 .iter()
1462 .zip(&key_aliases)
1463 .map(|((_, inner_idx), alias)| {
1464 DfExpr::Column(schema_join_column(child_plan.schema(), *inner_idx)).alias(alias)
1465 })
1466 .collect::<Vec<_>>();
1467 let output_alias = format!("{PATTERN_COMPREHENSION_VALUE_ALIAS}_{}", output.0);
1468 let aggregate_exprs = vec![array_agg(value).alias(&output_alias)];
1469 let collected = LogicalPlanBuilder::from(child_plan)
1470 .aggregate(group_exprs, aggregate_exprs)
1471 .and_then(LogicalPlanBuilder::build)
1472 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
1473
1474 let left_keys = join_keys
1475 .iter()
1476 .map(|(outer_idx, _)| schema_join_column(input.schema(), *outer_idx))
1477 .collect::<Vec<_>>();
1478 let right_keys = key_aliases
1479 .iter()
1480 .map(|alias| Column::from_name(alias.clone()))
1481 .collect::<Vec<_>>();
1482 let joined = LogicalPlanBuilder::from(input)
1483 .join(collected, JoinType::Left, (left_keys, right_keys), None)
1484 .and_then(LogicalPlanBuilder::build)
1485 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
1486
1487 let empty = ScalarValue::List(ScalarValue::new_list(&[], &element_type, true));
1488 let mut projection = outer_columns;
1489 projection.push(
1490 coalesce(vec![
1491 DfExpr::Column(Column::from_name(output_alias.clone())),
1492 DfExpr::Literal(empty, None),
1493 ])
1494 .alias(&output_alias),
1495 );
1496 let result = LogicalPlanBuilder::from(joined)
1497 .project(projection)
1498 .and_then(LogicalPlanBuilder::build)
1499 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
1500 var_map.insert(output, output_alias);
1501 Ok(result)
1502 }
1503
1504 #[allow(
1505 clippy::too_many_arguments,
1506 clippy::too_many_lines,
1507 reason = "ordinal unwind, child correlation, and ordered regroup form one lowering operation"
1508 )]
1509 fn lower_list_element_pattern_comprehension_op(
1510 &self,
1511 list_expr: ExprId,
1512 loop_var: VarId,
1513 child: &GraphPlan,
1514 pattern_output: VarId,
1515 filter: Option<ExprId>,
1516 projection: Option<ExprId>,
1517 output: VarId,
1518 input: LogicalPlan,
1519 exprs: &ExprArena,
1520 var_map: &mut VarMap,
1521 ) -> Result<LogicalPlan, LoweringError> {
1522 use datafusion::arrow::datatypes::{DataType, Field};
1523 use datafusion::common::Column;
1524 use datafusion::functions::core::expr_fn::{coalesce, get_field};
1525 use datafusion::functions_nested::expr_fn::array_element;
1526 use datafusion::scalar::ScalarValue;
1527
1528 const LIST: &str = "__gf_list_source";
1529 const INDICES: &str = "__gf_list_indices";
1530 const INDEX: &str = "__gf_list_index";
1531
1532 let outer_columns = plan_columns(&input);
1533 let row_keys = input
1534 .schema()
1535 .iter()
1536 .filter(|(_, field)| matches!(field.name().as_str(), "node_id" | "edge_id"))
1537 .map(|(qualifier, field)| Column::new(qualifier.cloned(), field.name().to_owned()))
1538 .collect::<Vec<_>>();
1539 if row_keys.is_empty() {
1540 return Err(LoweringError::UnsupportedExpr(
1541 "graph-valued list comprehension requires an outer entity identity".into(),
1542 ));
1543 }
1544 let row_key_aliases = (0..row_keys.len())
1545 .map(|index| format!("__gf_list_row_key_{index}"))
1546 .collect::<Vec<_>>();
1547 let list = self
1548 .expr_lowerer(exprs, var_map)
1549 .with_input_schema(input.schema().clone())
1550 .lower(list_expr)?;
1551 let DataType::List(item) = list
1552 .get_type(input.schema())
1553 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?
1554 else {
1555 return Err(LoweringError::InvalidType(
1556 "nested pattern comprehension source must be a list".into(),
1557 ));
1558 };
1559 let DataType::Struct(node_fields) = item.data_type() else {
1560 return Err(LoweringError::InvalidType(
1561 "nested pattern comprehension elements must be node values".into(),
1562 ));
1563 };
1564
1565 let mut indexed_projection = outer_columns.clone();
1566 indexed_projection.push(list.alias(LIST));
1567 let indexed = LogicalPlanBuilder::from(input)
1568 .project(indexed_projection)
1569 .and_then(LogicalPlanBuilder::build)
1570 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
1571
1572 let mut index_projection = plan_columns(&indexed);
1573 index_projection
1574 .push(list_index_range(DfExpr::Column(Column::from_name(LIST))).alias(INDICES));
1575 let with_indices = LogicalPlanBuilder::from(indexed.clone())
1576 .project(index_projection)
1577 .and_then(LogicalPlanBuilder::build)
1578 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
1579 let index_field = Field::new(INDEX, DataType::Int64, true);
1580 let expanded = LogicalPlan::Extension(Extension {
1581 node: Arc::new(UnwindNode::new(
1582 Arc::new(with_indices),
1583 DfExpr::Column(Column::from_name(INDICES)),
1584 INDEX,
1585 &index_field,
1586 )),
1587 });
1588
1589 let element = array_element(
1590 DfExpr::Column(Column::from_name(LIST)),
1591 DfExpr::Column(Column::from_name(INDEX)) + datafusion::logical_expr::lit(1_i64),
1592 );
1593 let loop_alias = var_alias(loop_var);
1594 let mut element_projection = plan_columns(&expanded);
1595 element_projection.extend(node_fields.iter().map(|field| {
1596 get_field(element.clone(), field.name())
1597 .alias_qualified(Some(loop_alias.as_str()), field.name())
1598 }));
1599 let expanded = LogicalPlanBuilder::from(expanded)
1600 .project(element_projection)
1601 .and_then(LogicalPlanBuilder::build)
1602 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
1603 var_map.insert(loop_var, loop_alias.clone());
1604
1605 let matched =
1606 self.lower_pattern_comprehension_op(child, pattern_output, expanded, var_map)?;
1607 let clause_lowerer = self
1608 .expr_lowerer(exprs, var_map)
1609 .with_input_schema(matched.schema().clone());
1610 let filtered = if let Some(predicate) = filter {
1611 lower_filter(predicate, matched, &clause_lowerer)?
1612 } else {
1613 matched
1614 };
1615 let value = match projection {
1616 Some(expr) => self
1617 .expr_lowerer(exprs, var_map)
1618 .with_input_schema(filtered.schema().clone())
1619 .lower(expr)?,
1620 None => DfExpr::Column(Column::new(Some(loop_alias.as_str()), "node_uuid")),
1621 };
1622 let element_type = value
1623 .get_type(filtered.schema())
1624 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
1625 let output_alias = format!("__gf_list_pattern_{}", output.0);
1626 let ordered = array_agg(value)
1627 .order_by(vec![
1628 DfExpr::Column(Column::from_name(INDEX)).sort(true, true),
1629 ])
1630 .build()
1631 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?
1632 .alias(&output_alias);
1633 let collected = LogicalPlanBuilder::from(filtered)
1634 .aggregate(
1635 row_keys
1636 .iter()
1637 .zip(&row_key_aliases)
1638 .map(|(column, alias)| DfExpr::Column(column.clone()).alias(alias))
1639 .collect::<Vec<_>>(),
1640 vec![ordered],
1641 )
1642 .and_then(LogicalPlanBuilder::build)
1643 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
1644 let joined = LogicalPlanBuilder::from(indexed)
1645 .join(
1646 collected,
1647 JoinType::Left,
1648 (
1649 row_keys,
1650 row_key_aliases
1651 .iter()
1652 .map(|alias| Column::from_name(alias.clone()))
1653 .collect::<Vec<_>>(),
1654 ),
1655 None,
1656 )
1657 .and_then(LogicalPlanBuilder::build)
1658 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
1659
1660 let empty = ScalarValue::List(ScalarValue::new_list(&[], &element_type, true));
1661 let null = ScalarValue::new_null_list(element_type, true, 1);
1662 let result_list = datafusion::logical_expr::when(
1663 DfExpr::Column(Column::from_name(LIST)).is_null(),
1664 DfExpr::Literal(null, None),
1665 )
1666 .otherwise(coalesce(vec![
1667 DfExpr::Column(Column::from_name(output_alias.clone())),
1668 DfExpr::Literal(empty, None),
1669 ]))
1670 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?
1671 .alias(&output_alias);
1672 let mut final_projection = outer_columns;
1673 final_projection.push(result_list);
1674 let result = LogicalPlanBuilder::from(joined)
1675 .project(final_projection)
1676 .and_then(LogicalPlanBuilder::build)
1677 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
1678 var_map.insert(output, output_alias);
1679 Ok(result)
1680 }
1681
1682 #[allow(clippy::too_many_lines)]
1689 fn lower_with_op(
1690 &self,
1691 items: &[ProjectItem],
1692 distinct: bool,
1693 where_predicate: Option<ExprId>,
1694 input: LogicalPlan,
1695 exprs: &ExprArena,
1696 var_map: &mut VarMap,
1697 ) -> Result<LogicalPlan, LoweringError> {
1698 let input_order = input_sort_exprs(&input);
1699 let keep_input_for_where = where_predicate.is_some();
1706 let incoming_scope = var_map.clone();
1707 let node_shapes = self
1708 .node_shapes
1709 .read()
1710 .expect("node shapes lock poisoned")
1711 .clone();
1712 let forwarded_prefixes: HashSet<String> = items
1717 .iter()
1718 .filter_map(|item| {
1719 let IrExpr::VarRef(v) = exprs.get(item.expr) else {
1720 return None;
1721 };
1722 let prefix = var_map.get(*v)?;
1723 let is_relationship = input.schema().iter().any(|(qualifier, field)| {
1724 qualifier.is_some_and(|q| q.table() == prefix) && field.name() == "edge_uuid"
1725 });
1726 (node_shapes.contains_key(&v.0) || is_relationship).then(|| prefix.to_owned())
1727 })
1728 .collect();
1729 let physical_names: HashMap<VarId, String> = items
1730 .iter()
1731 .filter_map(|item| {
1732 let out_var = item.out_var?;
1733 let name = item.alias.as_ref()?;
1734 let conflicts_with_forwarded_property =
1735 input.schema().iter().any(|(qualifier, field)| {
1736 qualifier.as_ref().is_some_and(|q| {
1737 forwarded_prefixes.contains(q.table()) && field.name() == name
1738 })
1739 });
1740 let shadows_input = input.schema().iter().any(|(_, field)| field.name() == name);
1741 (conflicts_with_forwarded_property || shadows_input)
1742 .then(|| (out_var, format!("__gf_with_{}", out_var.0)))
1743 })
1744 .collect();
1745 let mut select: Vec<DfExpr> = Vec::new();
1746 let mut new_scope: Vec<(VarId, String)> = Vec::new();
1747 let mut predicate_scope: Vec<(VarId, String)> = Vec::new();
1748 let mut forwarded_node_shapes = Vec::new();
1749 if keep_input_for_where {
1750 for (qualifier, field) in input.schema().iter() {
1751 select.push(DfExpr::Column(datafusion::common::Column::new(
1752 qualifier.cloned(),
1753 field.name(),
1754 )));
1755 }
1756 }
1757 {
1758 let lowerer = self
1764 .expr_lowerer(exprs, var_map)
1765 .with_input_schema(input.schema().clone());
1766 for item in items {
1767 let name = item.alias.as_deref().ok_or_else(|| {
1768 LoweringError::UnsupportedExpr(
1769 "WITH item without an alias (binder should reject)".into(),
1770 )
1771 })?;
1772 if let IrExpr::VarRef(v) = exprs.get(item.expr) {
1777 let prefix = var_map
1778 .get(*v)
1779 .ok_or(LoweringError::UnboundVar(v.0))?
1780 .to_string();
1781 let is_relationship = input.schema().iter().any(|(qualifier, field)| {
1782 qualifier.is_some_and(|q| q.table() == prefix)
1783 && field.name() == "edge_uuid"
1784 });
1785 if node_shapes.contains_key(&v.0) || is_relationship {
1786 let output_var = item.out_var.unwrap_or(*v);
1787 let output_prefix = if output_var == *v {
1788 prefix.clone()
1789 } else {
1790 var_alias(output_var)
1791 };
1792 if output_var != *v
1793 && let Some(shape) = node_shapes.get(&v.0).cloned()
1794 {
1795 forwarded_node_shapes.push((output_var.0, shape));
1796 }
1797 if !keep_input_for_where || output_prefix != prefix {
1798 for (qualifier, field) in input.schema().iter() {
1799 if qualifier.is_some_and(|q| q.table() == prefix) {
1800 let column = DfExpr::Column(datafusion::common::Column::new(
1801 qualifier.cloned(),
1802 field.name(),
1803 ));
1804 select.push(if output_prefix == prefix {
1805 column
1806 } else {
1807 column.alias_qualified(
1808 Some(output_prefix.as_str()),
1809 field.name(),
1810 )
1811 });
1812 }
1813 }
1814 }
1815 new_scope.push((output_var, output_prefix.clone()));
1816 predicate_scope.push((output_var, output_prefix));
1817 } else {
1818 let projected_name = if keep_input_for_where {
1819 item.out_var.map_or_else(
1820 || format!("__gf_with_{}", predicate_scope.len()),
1821 |v| format!("__gf_with_{}", v.0),
1822 )
1823 } else {
1824 item.out_var
1825 .and_then(|v| physical_names.get(&v).cloned())
1826 .unwrap_or_else(|| name.to_string())
1827 };
1828 select.push(lowerer.lower(item.expr)?.alias(projected_name.as_str()));
1829 if let Some(v) = item.out_var {
1830 let output_name = physical_names
1831 .get(&v)
1832 .cloned()
1833 .unwrap_or_else(|| name.to_string());
1834 new_scope.push((v, output_name));
1835 predicate_scope.push((v, projected_name));
1836 }
1837 }
1838 } else {
1839 use datafusion::logical_expr::ExprSchemable;
1840
1841 let projected_name = if keep_input_for_where {
1842 item.out_var.map_or_else(
1843 || format!("__gf_with_{}", predicate_scope.len()),
1844 |v| format!("__gf_with_{}", v.0),
1845 )
1846 } else {
1847 item.out_var
1848 .and_then(|v| physical_names.get(&v).cloned())
1849 .unwrap_or_else(|| name.to_string())
1850 };
1851 let value_expr = lowerer.lower(item.expr)?;
1852 if let Some(output_var) = item.out_var
1853 && let Ok(datafusion::arrow::datatypes::DataType::Struct(fields)) =
1854 value_expr.get_type(input.schema().as_ref())
1855 && fields.iter().any(|field| field.name() == "node_uuid")
1856 {
1857 let output_prefix = var_alias(output_var);
1858 for field in &fields {
1859 select.push(
1860 datafusion::functions::core::expr_fn::get_field(
1861 value_expr.clone(),
1862 field.name(),
1863 )
1864 .alias_qualified(
1865 Some(output_prefix.as_str()),
1866 field.name().as_str(),
1867 ),
1868 );
1869 }
1870 new_scope.push((output_var, output_prefix.clone()));
1871 predicate_scope.push((output_var, output_prefix));
1872 continue;
1873 }
1874 select.push(value_expr.alias(projected_name.as_str()));
1875 if let Some(v) = item.out_var {
1876 let output_name = physical_names
1877 .get(&v)
1878 .cloned()
1879 .unwrap_or_else(|| name.to_string());
1880 new_scope.push((v, output_name));
1881 predicate_scope.push((v, projected_name));
1882 }
1883 }
1884 }
1885 }
1886 if !distinct {
1887 select.extend(input_order.iter().enumerate().map(|(index, sort)| {
1888 sort.expr
1889 .clone()
1890 .alias(format!("{INPUT_ORDER_COLUMN_PREFIX}{index}"))
1891 }));
1892 }
1893 let projected = LogicalPlanBuilder::from(input)
1894 .project(select)
1895 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?
1896 .build()
1897 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
1898 drop(node_shapes);
1899 if !forwarded_node_shapes.is_empty() {
1900 self.node_shapes
1901 .write()
1902 .expect("node shapes lock poisoned")
1903 .extend(forwarded_node_shapes);
1904 }
1905 let filtered = match where_predicate {
1907 Some(pred) => {
1908 let mut filter_scope = incoming_scope;
1909 for (v, col) in &predicate_scope {
1910 filter_scope.insert(*v, col.clone());
1911 }
1912 let lowerer = self
1913 .expr_lowerer(exprs, &filter_scope)
1914 .with_input_schema(projected.schema().clone());
1915 let df_pred = lowerer.lower(pred)?;
1916 LogicalPlanBuilder::from(projected)
1917 .filter(df_pred)
1918 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?
1919 .build()
1920 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?
1921 }
1922 None => projected,
1923 };
1924
1925 let mut output = if keep_input_for_where {
1928 let node_shapes = self
1929 .node_shapes
1930 .read()
1931 .expect("node shapes lock poisoned")
1932 .clone();
1933 let lowerer = self
1934 .expr_lowerer(exprs, var_map)
1935 .with_input_schema(filtered.schema().clone());
1936 let mut final_select: Vec<DfExpr> = Vec::new();
1937 for item in items {
1938 let name = item.alias.as_deref().ok_or_else(|| {
1939 LoweringError::UnsupportedExpr(
1940 "WITH item without an alias (binder should reject)".into(),
1941 )
1942 })?;
1943 if let IrExpr::VarRef(v) = exprs.get(item.expr) {
1944 let prefix = var_map.get(*v).ok_or(LoweringError::UnboundVar(v.0))?;
1945 let is_relationship = filtered.schema().iter().any(|(qualifier, field)| {
1946 qualifier.is_some_and(|q| q.table() == prefix)
1947 && field.name() == "edge_uuid"
1948 });
1949 if node_shapes.contains_key(&v.0) || is_relationship {
1950 let output_var = item.out_var.unwrap_or(*v);
1951 let output_prefix = if output_var == *v {
1952 prefix.to_owned()
1953 } else {
1954 var_alias(output_var)
1955 };
1956 for (qualifier, field) in filtered.schema().iter() {
1957 if qualifier.is_some_and(|q| q.table() == output_prefix) {
1958 final_select.push(DfExpr::Column(datafusion::common::Column::new(
1959 qualifier.cloned(),
1960 field.name(),
1961 )));
1962 }
1963 }
1964 } else {
1965 let projected_name = item
1966 .out_var
1967 .and_then(|v| physical_names.get(&v))
1968 .map_or(name, String::as_str);
1969 final_select.push(lowerer.lower(item.expr)?.alias(projected_name));
1970 }
1971 } else {
1972 let projected_name = item
1973 .out_var
1974 .and_then(|v| physical_names.get(&v))
1975 .map_or(name, String::as_str);
1976 final_select.push(lowerer.lower(item.expr)?.alias(projected_name));
1977 }
1978 }
1979 if !distinct {
1980 final_select.extend(input_order.iter().enumerate().map(|(index, _)| {
1981 DfExpr::Column(datafusion::common::Column::from_name(format!(
1982 "{INPUT_ORDER_COLUMN_PREFIX}{index}"
1983 )))
1984 }));
1985 }
1986 LogicalPlanBuilder::from(filtered)
1987 .project(final_select)
1988 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?
1989 .build()
1990 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?
1991 } else {
1992 filtered
1993 };
1994
1995 if distinct {
1996 output = LogicalPlanBuilder::from(output)
1997 .distinct()
1998 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?
1999 .build()
2000 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
2001 }
2002
2003 var_map.clear();
2007 for (v, col) in new_scope {
2008 var_map.insert(v, col);
2009 }
2010 Ok(output)
2011 }
2012
2013 fn lower_create(
2023 &self,
2024 pattern: &CreatePattern,
2025 exprs: &ExprArena,
2026 input: LogicalPlan,
2027 var_map: &mut VarMap,
2028 feeds_read: bool,
2029 ) -> Result<LogicalPlan, LoweringError> {
2030 let (dir, mode) = self.write_target.ok_or_else(|| {
2031 LoweringError::UnsupportedExpr(
2032 "CREATE requires a write target; lower via new_for_writes".into(),
2033 )
2034 })?;
2035
2036 let (nodes, edges) = self.create_specs(pattern, exprs, var_map, Some(input.schema()))?;
2037
2038 if feeds_read {
2045 let out_schema = Self::created_rows_schema(&nodes, input.schema())?;
2046 self.register_created_node_shapes(&nodes);
2047 for spec in &nodes {
2048 if spec.is_reference {
2052 continue;
2053 }
2054 let v = VarId(spec.var);
2055 var_map.insert(v, var_alias(v));
2056 }
2057 let node = GraphCreateNode::new_emitting(
2058 Arc::new(input),
2059 nodes,
2060 edges,
2061 dir.to_path_buf(),
2062 mode,
2063 out_schema,
2064 );
2065 return Ok(LogicalPlan::Extension(Extension {
2066 node: Arc::new(node),
2067 }));
2068 }
2069
2070 let node = GraphCreateNode::new(Arc::new(input), nodes, edges, dir.to_path_buf(), mode);
2071 Ok(LogicalPlan::Extension(Extension {
2072 node: Arc::new(node),
2073 }))
2074 }
2075
2076 fn created_rows_schema(
2082 nodes: &[ResolvedNodeSpec],
2083 input_schema: &datafusion::common::DFSchemaRef,
2084 ) -> Result<datafusion::common::DFSchemaRef, LoweringError> {
2085 use std::collections::HashMap;
2086
2087 use datafusion::arrow::datatypes::{DataType, Field};
2088 use datafusion::common::{DFSchema, TableReference};
2089 use datafusion::logical_expr::ExprSchemable;
2090
2091 let mut qualified: Vec<(Option<TableReference>, Arc<Field>)> = input_schema
2092 .iter()
2093 .map(|(q, f)| (q.cloned(), Arc::clone(f)))
2094 .collect();
2095
2096 for spec in nodes {
2097 if spec.is_reference {
2101 continue;
2102 }
2103 let qual = TableReference::bare(var_alias(VarId(spec.var)));
2104 let mut push = |name: &str, ty: DataType, nullable: bool| {
2105 qualified.push((Some(qual.clone()), Arc::new(Field::new(name, ty, nullable))));
2106 };
2107 push("node_uuid", DataType::FixedSizeBinary(16), false);
2108 push("node_id", DataType::UInt64, false);
2109 push("type_id", DataType::UInt32, false);
2110 push(
2111 "type_ids",
2112 DataType::List(Arc::new(datafusion::arrow::datatypes::Field::new(
2113 "item",
2114 DataType::UInt32,
2115 false,
2116 ))),
2117 false,
2118 );
2119 for (name, lit) in &spec.properties {
2120 Self::ensure_created_node_emit_property_name(name)?;
2121 let scalar = crate::expr::ir_literal_to_scalar(lit);
2122 push(name, scalar.data_type(), true);
2123 }
2124 for (name, expr) in &spec.computed_properties {
2125 Self::ensure_created_node_emit_property_name(name)?;
2126 let ty = expr
2127 .get_type(input_schema)
2128 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
2129 push(name, ty, true);
2130 }
2131 }
2132 let schema = DFSchema::new_with_metadata(qualified, HashMap::new())
2133 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
2134 Ok(Arc::new(schema))
2135 }
2136
2137 fn ensure_created_node_emit_property_name(name: &str) -> Result<(), LoweringError> {
2138 if matches!(name, "node_uuid" | "node_id" | "type_id" | "type_ids") {
2139 return Err(LoweringError::UnsupportedExpr(format!(
2140 "CREATE property `{name}` collides with a reserved node topology field"
2141 )));
2142 }
2143 Ok(())
2144 }
2145
2146 fn create_specs(
2149 &self,
2150 pattern: &CreatePattern,
2151 exprs: &ExprArena,
2152 var_map: &VarMap,
2153 input_schema: Option<&datafusion::common::DFSchemaRef>,
2154 ) -> Result<(Vec<ResolvedNodeSpec>, Vec<ResolvedEdgeSpec>), LoweringError> {
2155 let nodes: Vec<ResolvedNodeSpec> = pattern
2156 .nodes
2157 .iter()
2158 .map(|n| {
2159 let (properties, computed_properties) =
2160 eval_map_literal(self, n.properties, exprs, var_map, input_schema)?;
2161 Ok(ResolvedNodeSpec {
2162 var: n.var.0,
2163 label_ids: n.labels.iter().map(|t| t.0).collect(),
2164 label_names: n
2165 .labels
2166 .iter()
2167 .filter_map(|t| self.type_id_to_entity_name.get(&t.0).cloned())
2168 .collect(),
2169 properties,
2170 computed_properties,
2171 is_reference: n.is_reference,
2172 })
2173 })
2174 .collect::<Result<_, LoweringError>>()?;
2175
2176 let edges: Vec<ResolvedEdgeSpec> = pattern
2177 .edges
2178 .iter()
2179 .map(|e| {
2180 let (properties, computed_properties) =
2181 eval_map_literal(self, e.properties, exprs, var_map, input_schema)?;
2182 Ok(ResolvedEdgeSpec {
2183 var: e.var.0,
2184 src: e.src.0,
2185 dst: e.dst.0,
2186 rel_type_id: e.rel_type.map(|t| t.0),
2187 rel_type_name: e
2188 .rel_type
2189 .and_then(|t| self.type_id_to_rel_name.get(&t.0).cloned()),
2190 direction: e.direction,
2191 properties,
2192 computed_properties,
2193 })
2194 })
2195 .collect::<Result<_, LoweringError>>()?;
2196 Ok((nodes, edges))
2197 }
2198
2199 fn lower_delete_op(
2209 &self,
2210 vars: &[VarId],
2211 detach: bool,
2212 input: LogicalPlan,
2213 ) -> Result<LogicalPlan, LoweringError> {
2214 use datafusion::common::TableReference;
2215
2216 let (dir, mode) = self.write_target.ok_or_else(|| {
2217 LoweringError::UnsupportedExpr(
2218 "DELETE requires a write target; lower via new_for_writes".into(),
2219 )
2220 })?;
2221
2222 let schema = input.schema();
2223 let targets: Vec<DeleteTarget> = vars
2224 .iter()
2225 .map(|var| {
2226 let qual = TableReference::bare(var_alias(*var));
2227 let is_node = schema
2228 .index_of_column_by_name(Some(&qual), "node_uuid")
2229 .is_some();
2230 let is_edge = schema
2231 .index_of_column_by_name(Some(&qual), "edge_uuid")
2232 .is_some();
2233 match (is_node, is_edge) {
2234 (true, _) => Ok(DeleteTarget {
2235 var: var.0,
2236 is_edge: false,
2237 }),
2238 (false, true) => Ok(DeleteTarget {
2239 var: var.0,
2240 is_edge: true,
2241 }),
2242 (false, false) => Err(LoweringError::UnsupportedExpr(format!(
2243 "DELETE target var_{} has no node_uuid/edge_uuid column in the \
2244 input — it must be bound by a preceding MATCH",
2245 var.0
2246 ))),
2247 }
2248 })
2249 .collect::<Result<_, LoweringError>>()?;
2250
2251 let node = GraphDeleteNode::new(Arc::new(input), targets, detach, dir.to_path_buf(), mode);
2252 Ok(LogicalPlan::Extension(Extension {
2253 node: Arc::new(node),
2254 }))
2255 }
2256
2257 fn resolve_write_kind(
2266 schema: &datafusion::common::DFSchemaRef,
2267 var: VarId,
2268 clause: &str,
2269 ) -> Result<bool, LoweringError> {
2270 use datafusion::common::TableReference;
2271
2272 let qual = TableReference::bare(var_alias(var));
2273 let is_node = schema
2274 .index_of_column_by_name(Some(&qual), "node_uuid")
2275 .is_some();
2276 let is_edge = schema
2277 .index_of_column_by_name(Some(&qual), "edge_uuid")
2278 .is_some();
2279 match (is_node, is_edge) {
2280 (true, _) => Ok(false),
2281 (false, true) => {
2282 if schema
2283 .index_of_column_by_name(Some(&qual), "rel_type_name")
2284 .is_some()
2285 {
2286 Ok(true)
2287 } else {
2288 Err(LoweringError::UnsupportedExpr(format!(
2289 "{clause} on an edge requires a known relation type \
2290 (e.g. `-[r:KNOWS]->`); an untyped edge write is not yet \
2291 supported (follow-up to #791)"
2292 )))
2293 }
2294 }
2295 (false, false) => Err(LoweringError::UnsupportedExpr(format!(
2296 "{clause} target var_{} has no node_uuid/edge_uuid column in the \
2297 input — it must be bound by a preceding MATCH",
2298 var.0
2299 ))),
2300 }
2301 }
2302
2303 fn lower_set_op(
2311 &self,
2312 items: &[SetPropItem],
2313 input: LogicalPlan,
2314 exprs: &ExprArena,
2315 var_map: &VarMap,
2316 ) -> Result<LogicalPlan, LoweringError> {
2317 let (dir, mode) = self.write_target.ok_or_else(|| {
2318 LoweringError::UnsupportedExpr(
2319 "SET requires a write target; lower via new_for_writes".into(),
2320 )
2321 })?;
2322
2323 let expr_lowerer = self.expr_lowerer(exprs, var_map);
2326 let schema = input.schema();
2327 let targets: Vec<SetTarget> = items
2328 .iter()
2329 .map(|item| {
2330 let is_edge = Self::resolve_write_kind(schema, item.target, "SET")?;
2331 let value = expr_lowerer.lower(item.value)?;
2332 Ok(SetTarget {
2333 var: item.target.0,
2334 is_edge,
2335 prop_name: item.prop_name.clone(),
2336 value,
2337 })
2338 })
2339 .collect::<Result<_, LoweringError>>()?;
2340
2341 let node = GraphSetNode::new(
2342 Arc::new(input),
2343 targets,
2344 self.type_id_to_entity_name.clone(),
2345 dir.to_path_buf(),
2346 mode,
2347 );
2348 Ok(LogicalPlan::Extension(Extension {
2349 node: Arc::new(node),
2350 }))
2351 }
2352
2353 fn lower_remove_op(
2356 &self,
2357 items: &[RemovePropItem],
2358 input: LogicalPlan,
2359 ) -> Result<LogicalPlan, LoweringError> {
2360 let (dir, mode) = self.write_target.ok_or_else(|| {
2361 LoweringError::UnsupportedExpr(
2362 "REMOVE requires a write target; lower via new_for_writes".into(),
2363 )
2364 })?;
2365
2366 let schema = input.schema();
2367 let targets: Vec<RemoveTarget> = items
2368 .iter()
2369 .map(|item| {
2370 let is_edge = Self::resolve_write_kind(schema, item.target, "REMOVE")?;
2371 Ok(RemoveTarget {
2372 var: item.target.0,
2373 is_edge,
2374 prop_name: item.prop_name.clone(),
2375 })
2376 })
2377 .collect::<Result<_, LoweringError>>()?;
2378
2379 let node = GraphRemoveNode::new(
2380 Arc::new(input),
2381 targets,
2382 self.type_id_to_entity_name.clone(),
2383 dir.to_path_buf(),
2384 mode,
2385 );
2386 Ok(LogicalPlan::Extension(Extension {
2387 node: Arc::new(node),
2388 }))
2389 }
2390}
2391
2392fn lower_relational_op(
2398 op: &GraphOp,
2399 input: LogicalPlan,
2400 exprs: &ExprArena,
2401 expr_lowerer: &ExprLowerer<'_>,
2402) -> Result<LogicalPlan, LoweringError> {
2403 match op {
2404 GraphOp::Filter { predicate } => lower_filter(*predicate, input, expr_lowerer),
2405 GraphOp::Project { items, distinct } => {
2406 lower_project(items, *distinct, input, expr_lowerer)
2407 }
2408 GraphOp::Aggregate {
2409 group_by,
2410 group_aliases,
2411 aggs,
2412 ..
2413 } => lower_aggregate(
2414 group_by,
2415 group_aliases,
2416 aggs,
2417 input,
2418 exprs,
2419 expr_lowerer,
2420 None,
2421 ),
2422 GraphOp::Sort { keys } => lower_sort(keys, input, expr_lowerer),
2423 GraphOp::Limit { count } => lower_limit(*count, input),
2424 GraphOp::Skip { count } => lower_skip(*count, input),
2425 other => Err(LoweringError::UnsupportedExpr(format!(
2426 "operator not yet lowered (deferred to #577+): {other:?}"
2427 ))),
2428 }
2429}
2430
2431fn lower_filter(
2432 predicate: ExprId,
2433 input: LogicalPlan,
2434 lowerer: &ExprLowerer<'_>,
2435) -> Result<LogicalPlan, LoweringError> {
2436 let df_pred = lowerer.lower(predicate)?;
2437 LogicalPlanBuilder::from(input)
2438 .filter(df_pred)
2439 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?
2440 .build()
2441 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))
2442}
2443
2444fn lower_project(
2445 items: &[ProjectItem],
2446 distinct: bool,
2447 input: LogicalPlan,
2448 lowerer: &ExprLowerer<'_>,
2449) -> Result<LogicalPlan, LoweringError> {
2450 let select_exprs: Vec<DfExpr> = items
2451 .iter()
2452 .map(|item| {
2453 let e = lowerer.lower(item.expr)?;
2454 Ok::<_, LoweringError>(match &item.alias {
2455 Some(alias) => e.alias(alias),
2456 None => e,
2457 })
2458 })
2459 .collect::<Result<_, _>>()?;
2460
2461 let plan = LogicalPlanBuilder::from(input)
2462 .project(select_exprs)
2463 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?
2464 .build()
2465 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
2466
2467 if distinct {
2468 LogicalPlanBuilder::from(plan)
2469 .distinct()
2470 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?
2471 .build()
2472 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))
2473 } else {
2474 Ok(plan)
2475 }
2476}
2477
2478fn input_sort_exprs(input: &LogicalPlan) -> Vec<SortExpr> {
2479 match input {
2480 LogicalPlan::Sort(sort) => sort.expr.clone(),
2481 LogicalPlan::Projection(projection) => input_sort_exprs(&projection.input)
2482 .into_iter()
2483 .enumerate()
2484 .filter_map(|(index, sort)| {
2485 let name = format!("{INPUT_ORDER_COLUMN_PREFIX}{index}");
2486 projection
2487 .schema
2488 .fields()
2489 .iter()
2490 .any(|field| field.name() == &name)
2491 .then(|| {
2492 SortExpr::new(
2493 DfExpr::Column(datafusion::common::Column::from_name(name)),
2494 sort.asc,
2495 sort.nulls_first,
2496 )
2497 })
2498 })
2499 .collect(),
2500 LogicalPlan::Filter(filter) => input_sort_exprs(&filter.input),
2501 _ => Vec::new(),
2502 }
2503}
2504
2505fn preserve_collect_order(
2506 func: AggFunc,
2507 aggregate: DfExpr,
2508 input_order: &[SortExpr],
2509) -> Result<DfExpr, LoweringError> {
2510 if func != AggFunc::Collect || input_order.is_empty() {
2511 return Ok(aggregate);
2512 }
2513 aggregate
2514 .order_by(input_order.to_vec())
2515 .build()
2516 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))
2517}
2518
2519#[allow(
2520 clippy::too_many_lines,
2521 reason = "group shaping, row markers, aggregate lowering, and aliases form one operation"
2522)]
2523fn lower_aggregate(
2524 group_by: &[ExprId],
2525 group_aliases: &[Option<String>],
2526 aggs: &[AggExpr],
2527 input: LogicalPlan,
2528 exprs: &ExprArena,
2529 lowerer: &ExprLowerer<'_>,
2530 passthrough_groups: Option<(&[Option<VarId>], &VarMap)>,
2531) -> Result<LogicalPlan, LoweringError> {
2532 let input_order = input_sort_exprs(&input);
2533 let mut group_exprs = Vec::new();
2536 let mut row_marker_aliases = Vec::new();
2537 for (i, &id) in group_by.iter().enumerate() {
2538 let passthrough = passthrough_groups.and_then(|(group_vars, var_map)| {
2539 let group_var = group_vars.get(i).copied().flatten()?;
2540 match exprs.get(id) {
2541 IrExpr::VarRef(source) if *source == group_var => {
2542 var_map.get(group_var).map(|alias| (group_var, alias))
2543 }
2544 _ => None,
2545 }
2546 });
2547 if let Some((_var, alias)) = passthrough {
2548 let qualifier = datafusion::common::TableReference::bare(alias);
2549 let columns: Vec<DfExpr> = input
2550 .schema()
2551 .iter()
2552 .filter(|(q, _)| q.as_ref().is_some_and(|q| **q == qualifier))
2553 .map(|(q, field)| {
2554 DfExpr::Column(datafusion::common::Column::new(q.cloned(), field.name()))
2555 })
2556 .collect();
2557 if !columns.is_empty() {
2558 group_exprs.extend(columns);
2559 continue;
2560 }
2561 }
2562 let e = lowerer.lower(id)?;
2563 if matches!(
2564 &e,
2565 DfExpr::Literal(datafusion::scalar::ScalarValue::Null, _)
2566 ) && let Some((qualifier, field)) = input
2567 .schema()
2568 .iter()
2569 .find(|(_, field)| {
2570 matches!(
2571 field.name().as_str(),
2572 "node_uuid" | "edge_uuid" | "node_id" | "edge_id" | "src_id" | "dst_id"
2573 )
2574 })
2575 .or_else(|| input.schema().iter().next())
2576 {
2577 let present = DfExpr::Column(datafusion::common::Column::new(
2578 qualifier.cloned(),
2579 field.name(),
2580 ));
2581 let marker_alias = format!("__gf_row_marker_{i}");
2586 group_exprs.push(
2587 crate::expr::CYPHER_ROW_MARKER
2588 .call(vec![present])
2589 .alias(&marker_alias),
2590 );
2591 row_marker_aliases.push(marker_alias);
2592 }
2593 group_exprs.push(match group_aliases.get(i).and_then(Option::as_ref) {
2594 Some(alias) => e.alias(alias),
2595 None => e,
2596 });
2597 }
2598
2599 let aggr_exprs: Result<Vec<DfExpr>, LoweringError> = aggs
2600 .iter()
2601 .map(|a| {
2602 let mut arg = a.arg.map(|id| lowerer.lower(id)).transpose()?;
2603 if a.func == AggFunc::Count
2604 && arg.is_none()
2605 && let Some((qualifier, field)) = input.schema().iter().last()
2606 {
2607 let column = DfExpr::Column(datafusion::common::Column::new(
2608 qualifier.cloned(),
2609 field.name(),
2610 ));
2611 arg = Some(crate::expr::CYPHER_ROW_MARKER.call(vec![column]));
2612 }
2613 let percentile = a.percentile.map(|id| lowerer.lower(id)).transpose()?;
2614 let arg_type = arg.as_ref().and_then(|e| {
2617 use datafusion::logical_expr::ExprSchemable;
2618 e.get_type(input.schema()).ok()
2619 });
2620 let df_agg = lower_agg_func(a.func, arg, percentile, arg_type.as_ref())?;
2621 let df_agg = preserve_collect_order(a.func, df_agg, &input_order)?;
2622 Ok(df_agg.alias(&a.alias))
2623 })
2624 .collect();
2625
2626 let aggregate = LogicalPlanBuilder::from(input)
2627 .aggregate(group_exprs, aggr_exprs?)
2628 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?
2629 .build()
2630 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
2631 if row_marker_aliases.is_empty() {
2632 return Ok(aggregate);
2633 }
2634
2635 let visible_columns = aggregate
2636 .schema()
2637 .iter()
2638 .filter(|(_, field)| !row_marker_aliases.iter().any(|alias| alias == field.name()))
2639 .map(|(qualifier, field)| {
2640 DfExpr::Column(datafusion::common::Column::new(
2641 qualifier.cloned(),
2642 field.name(),
2643 ))
2644 })
2645 .collect::<Vec<_>>();
2646 LogicalPlanBuilder::from(aggregate)
2647 .project(visible_columns)
2648 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?
2649 .build()
2650 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))
2651}
2652
2653fn lower_sort(
2654 keys: &[graphforge_ir::SortKey],
2655 input: LogicalPlan,
2656 lowerer: &ExprLowerer<'_>,
2657) -> Result<LogicalPlan, LoweringError> {
2658 let sort_exprs: Result<Vec<SortExpr>, LoweringError> = keys
2659 .iter()
2660 .map(|k| {
2661 let e = lowerer.lower(k.expr)?;
2662 let e = match e.get_type(input.schema()) {
2663 Ok(dt) if crate::expr::needs_cypher_order_key_type(&dt) => {
2664 crate::expr::CYPHER_ORDER_KEY.call(vec![e])
2665 }
2666 Err(_) => crate::expr::CYPHER_ORDER_KEY.call(vec![e]),
2667 _ => e,
2668 };
2669 Ok(SortExpr::new(e, k.order == SortOrder::Asc, k.nulls_first))
2670 })
2671 .collect();
2672
2673 LogicalPlanBuilder::from(input)
2674 .sort(sort_exprs?)
2675 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?
2676 .build()
2677 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))
2678}
2679
2680fn lower_limit(count: u64, input: LogicalPlan) -> Result<LogicalPlan, LoweringError> {
2681 let fetch = usize::try_from(count).map_err(|_| {
2682 LoweringError::UnsupportedExpr(format!("LIMIT count {count} exceeds platform usize::MAX"))
2683 })?;
2684 LogicalPlanBuilder::from(input)
2685 .limit(0, Some(fetch))
2686 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?
2687 .build()
2688 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))
2689}
2690
2691fn lower_skip(count: u64, input: LogicalPlan) -> Result<LogicalPlan, LoweringError> {
2692 let skip = usize::try_from(count).map_err(|_| {
2693 LoweringError::UnsupportedExpr(format!("SKIP count {count} exceeds platform usize::MAX"))
2694 })?;
2695 LogicalPlanBuilder::from(input)
2696 .limit(skip, None)
2697 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?
2698 .build()
2699 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))
2700}
2701
2702fn lower_agg_func(
2703 func: AggFunc,
2704 arg: Option<DfExpr>,
2705 percentile: Option<DfExpr>,
2706 arg_type: Option<&datafusion::arrow::datatypes::DataType>,
2707) -> Result<DfExpr, LoweringError> {
2708 let het = crate::expr::is_het_struct_type(arg_type);
2711 match func {
2712 AggFunc::Count => Ok(match arg {
2713 Some(e) => count(e),
2714 None => count_all(),
2715 }),
2716 AggFunc::CountDistinct => Ok(count_distinct(arg.ok_or_else(|| {
2717 LoweringError::UnsupportedExpr("COUNT DISTINCT requires an argument".into())
2718 })?)),
2719 AggFunc::Sum => Ok(sum(arg.ok_or_else(|| {
2720 LoweringError::UnsupportedExpr("SUM requires an argument".into())
2721 })?)),
2722 AggFunc::SumDistinct => Ok(sum_distinct(arg.ok_or_else(|| {
2723 LoweringError::UnsupportedExpr("SUM DISTINCT requires an argument".into())
2724 })?)),
2725 AggFunc::Avg => {
2726 let arg = arg
2727 .ok_or_else(|| LoweringError::UnsupportedExpr("AVG requires an argument".into()))?;
2728 let arg = if matches!(arg_type, Some(datafusion::arrow::datatypes::DataType::Null)) {
2729 datafusion::logical_expr::expr_fn::cast(
2730 arg,
2731 datafusion::arrow::datatypes::DataType::Float64,
2732 )
2733 } else {
2734 arg
2735 };
2736 Ok(avg(arg))
2737 }
2738 AggFunc::AvgDistinct => {
2739 let arg = arg.ok_or_else(|| {
2740 LoweringError::UnsupportedExpr("AVG DISTINCT requires an argument".into())
2741 })?;
2742 let arg = if matches!(arg_type, Some(datafusion::arrow::datatypes::DataType::Null)) {
2743 datafusion::logical_expr::expr_fn::cast(
2744 arg,
2745 datafusion::arrow::datatypes::DataType::Float64,
2746 )
2747 } else {
2748 arg
2749 };
2750 Ok(avg_distinct(arg))
2751 }
2752 AggFunc::Min => {
2753 let a = arg
2754 .ok_or_else(|| LoweringError::UnsupportedExpr("MIN requires an argument".into()))?;
2755 Ok(if het {
2756 crate::expr::CYPHER_MIN.call(vec![a])
2757 } else {
2758 min(a)
2759 })
2760 }
2761 AggFunc::Max => {
2762 let a = arg
2763 .ok_or_else(|| LoweringError::UnsupportedExpr("MAX requires an argument".into()))?;
2764 Ok(if het {
2765 crate::expr::CYPHER_MAX.call(vec![a])
2766 } else {
2767 max(a)
2768 })
2769 }
2770 AggFunc::Collect => Ok(crate::expr::CYPHER_COLLECT.call(vec![arg.ok_or_else(|| {
2771 LoweringError::UnsupportedExpr("COLLECT requires an argument".into())
2772 })?])),
2773 AggFunc::CollectDistinct => {
2774 Ok(
2775 crate::expr::CYPHER_COLLECT_DISTINCT.call(vec![arg.ok_or_else(|| {
2776 LoweringError::UnsupportedExpr("COLLECT requires an argument".into())
2777 })?]),
2778 )
2779 }
2780 AggFunc::PercentileDisc => Ok(crate::expr::CYPHER_PERCENTILE_DISC.call(vec![
2781 arg.ok_or_else(|| {
2782 LoweringError::UnsupportedExpr("percentileDisc requires a value argument".into())
2783 })?,
2784 percentile.ok_or_else(|| {
2785 LoweringError::UnsupportedExpr(
2786 "percentileDisc requires a percentile argument".into(),
2787 )
2788 })?,
2789 ])),
2790 AggFunc::PercentileCont => Ok(crate::expr::CYPHER_PERCENTILE_CONT.call(vec![
2791 arg.ok_or_else(|| {
2792 LoweringError::UnsupportedExpr("percentileCont requires a value argument".into())
2793 })?,
2794 percentile.ok_or_else(|| {
2795 LoweringError::UnsupportedExpr(
2796 "percentileCont requires a percentile argument".into(),
2797 )
2798 })?,
2799 ])),
2800 }
2801}
2802
2803fn build_type_id_map(ontology: Option<&OntologyHandle>) -> HashMap<u32, String> {
2810 let mut map = HashMap::new();
2811 if let Some(h) = ontology {
2812 for name in h.relation_type_names() {
2813 if let Some(type_id) = h.relation_type_id(name) {
2814 map.insert(type_id.0, name.to_owned());
2815 }
2816 }
2817 }
2818 map
2819}
2820
2821fn build_inference_rules(ontology: Option<&OntologyHandle>) -> HashMap<u32, Vec<(String, String)>> {
2826 let mut map = HashMap::new();
2827 if let Some(h) = ontology {
2828 for name in h.relation_type_names() {
2829 if let Some(type_id) = h.relation_type_id(name) {
2830 let flags = h.semantic_flags(type_id);
2831 let mut rules = Vec::new();
2832 if flags.transitive {
2833 rules.push((format!("transitive:{name}"), "conservative_min".to_owned()));
2834 }
2835 if flags.symmetric {
2836 rules.push((format!("symmetric:{name}"), "conservative_min".to_owned()));
2837 }
2838 if !rules.is_empty() {
2839 map.insert(type_id.0, rules);
2840 }
2841 }
2842 }
2843 }
2844 map
2845}
2846
2847fn build_entity_id_map(ontology: Option<&OntologyHandle>) -> HashMap<u32, String> {
2850 let mut map = HashMap::new();
2851 if let Some(h) = ontology {
2852 for name in h.entity_type_names() {
2853 if let Some(type_id) = h.entity_type_id(name) {
2854 map.insert(type_id.0, name.to_owned());
2855 }
2856 }
2857 }
2858 map
2859}
2860
2861fn const_eval_scalar(df: &DfExpr) -> Option<datafusion::scalar::ScalarValue> {
2866 use datafusion::arrow::array::{RecordBatch, RecordBatchOptions};
2867 use datafusion::arrow::datatypes::Schema;
2868 use datafusion::execution::context::ExecutionProps;
2869 use datafusion::physical_expr::create_physical_expr;
2870 use datafusion::scalar::ScalarValue;
2871
2872 if let DfExpr::Literal(scalar, _) = df {
2873 return Some(scalar.clone());
2874 }
2875 let schema = datafusion::common::DFSchema::empty();
2876 let phys = create_physical_expr(df, &schema, &ExecutionProps::new()).ok()?;
2877 let batch = RecordBatch::try_new_with_options(
2878 std::sync::Arc::new(Schema::empty()),
2879 vec![],
2880 &RecordBatchOptions::new().with_row_count(Some(1)),
2881 )
2882 .ok()?;
2883 let array = phys.evaluate(&batch).ok()?.into_array(1).ok()?;
2884 ScalarValue::try_from_array(&array, 0).ok()
2885}
2886
2887type EvaluatedProps = (Vec<(String, IrLiteral)>, Vec<(String, DfExpr)>);
2901
2902fn eval_map_literal(
2903 lowerer: &GraphPlanLowerer,
2904 id: Option<ExprId>,
2905 exprs: &ExprArena,
2906 var_map: &VarMap,
2907 input_schema: Option<&datafusion::common::DFSchemaRef>,
2908) -> Result<EvaluatedProps, LoweringError> {
2909 let Some(id) = id else {
2910 return Ok((Vec::new(), Vec::new()));
2911 };
2912 let IrExpr::MapLiteral(pairs) = exprs.get(id) else {
2913 return Err(LoweringError::UnsupportedExpr(format!(
2914 "CREATE properties must be a map literal, got: {:?}",
2915 exprs.get(id)
2916 )));
2917 };
2918 let mut literals = Vec::new();
2919 let mut computed = Vec::new();
2920 for (k, vexpr) in pairs {
2921 if let IrExpr::Literal(lit) = exprs.get(*vexpr) {
2922 reject_map_property_value(k, lit)?;
2923 literals.push((k.clone(), lit.clone()));
2924 continue;
2925 }
2926 let mut expr_lowerer = lowerer.expr_lowerer(exprs, var_map);
2930 if let Some(schema) = input_schema {
2931 expr_lowerer = expr_lowerer.with_input_schema(schema.clone());
2932 }
2933 let df = expr_lowerer.lower(*vexpr)?;
2934 match const_eval_scalar(&df) {
2935 Some(scalar) => {
2938 let lit = crate::expr::scalar_to_ir_literal(&scalar)?;
2939 reject_map_property_value(k, &lit)?;
2940 literals.push((k.clone(), lit));
2941 }
2942 None => computed.push((k.clone(), df)),
2944 }
2945 }
2946 Ok((literals, computed))
2947}
2948
2949fn reject_map_property_value(prop_name: &str, lit: &IrLiteral) -> Result<(), LoweringError> {
2950 if contains_map_literal(lit) {
2951 return Err(LoweringError::UnsupportedExpr(format!(
2952 "CREATE property `{prop_name}` cannot store map values"
2953 )));
2954 }
2955 Ok(())
2956}
2957
2958fn contains_map_literal(lit: &IrLiteral) -> bool {
2959 match lit {
2960 IrLiteral::Map(_) => true,
2961 IrLiteral::List(items) => items.iter().any(contains_map_literal),
2962 _ => false,
2963 }
2964}
2965
2966fn var_alias(var: VarId) -> String {
2972 format!("var_{}", var.0)
2973}
2974
2975fn is_source_op(op: &GraphOp) -> bool {
2978 matches!(
2979 op,
2980 GraphOp::NodeScan { .. }
2981 | GraphOp::EdgeScan { .. }
2982 | GraphOp::TypedEdgeScan { .. }
2983 | GraphOp::Expand { .. }
2984 )
2985}
2986
2987fn table_source(schema: datafusion::arrow::datatypes::SchemaRef) -> Arc<LogicalTableSource> {
2990 Arc::new(LogicalTableSource::new(schema))
2991}
2992
2993fn node_scan_source(dir: Option<&Path>) -> Arc<dyn datafusion::logical_expr::TableSource> {
3000 use datafusion::datasource::provider_as_source;
3001 match dir {
3002 Some(d) => {
3003 let path = d.join("topology").join("nodes.parquet");
3004 provider_as_source(Arc::new(graphforge_storage::TopologyNodeTable::new(path)))
3005 }
3006 None => table_source(TOPOLOGY_NODES_SCHEMA.clone()),
3007 }
3008}
3009
3010fn edge_scan_source(
3021 dir: Option<&Path>,
3022 stem: &str,
3023 schema: datafusion::arrow::datatypes::SchemaRef,
3024 mode: OntologyMode,
3025) -> Arc<dyn datafusion::logical_expr::TableSource> {
3026 use datafusion::datasource::provider_as_source;
3027 match dir {
3028 Some(d)
3029 if stem == "_exploratory"
3030 && matches!(mode, OntologyMode::Strict | OntologyMode::Advisory) =>
3031 {
3032 provider_as_source(Arc::new(graphforge_storage::UnionEdgeTable::open(d)))
3033 }
3034 Some(d) => provider_as_source(Arc::new(graphforge_storage::TypedEdgeTable::open(d, stem))),
3035 None => table_source(schema),
3036 }
3037}
3038
3039fn filter_node_by_type(
3045 input: LogicalPlan,
3046 alias: &str,
3047 type_id: TypeId,
3048) -> Result<LogicalPlan, LoweringError> {
3049 use datafusion::functions_nested::expr_fn::array_has;
3050 use datafusion::logical_expr::{col, lit};
3051 LogicalPlanBuilder::from(input)
3052 .filter(array_has(col(format!("{alias}.type_ids")), lit(type_id.0)))
3053 .and_then(LogicalPlanBuilder::build)
3054 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))
3055}
3056
3057fn enrich_bound_node_identity(
3058 input: &LogicalPlan,
3059 alias: &str,
3060 dir: Option<&Path>,
3061) -> Result<LogicalPlan, LoweringError> {
3062 use datafusion::common::Column;
3063 use datafusion::logical_expr::col;
3064
3065 let identity_alias = format!("__gf_identity_{alias}");
3066 let identity = LogicalPlanBuilder::scan(identity_alias.clone(), node_scan_source(dir), None)
3067 .and_then(LogicalPlanBuilder::build)
3068 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
3069 let joined = LogicalPlanBuilder::from(input.clone())
3070 .join(
3071 identity,
3072 datafusion::logical_expr::JoinType::Inner,
3073 (
3074 vec![Column::from_qualified_name(format!("{alias}.node_uuid"))],
3075 vec![Column::from_qualified_name(format!(
3076 "{identity_alias}.node_uuid"
3077 ))],
3078 ),
3079 None,
3080 )
3081 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?
3082 .build()
3083 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
3084 let mut projection = input
3085 .schema()
3086 .iter()
3087 .map(|(qualifier, field)| DfExpr::Column(Column::new(qualifier.cloned(), field.name())))
3088 .collect::<Vec<_>>();
3089 for name in ["node_id", "type_id", "type_ids"] {
3090 projection.push(col(format!("{identity_alias}.{name}")).alias_qualified(Some(alias), name));
3091 }
3092 LogicalPlanBuilder::from(joined)
3093 .project(projection)
3094 .and_then(LogicalPlanBuilder::build)
3095 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))
3096}
3097
3098fn lower_node_scan(
3099 var: VarId,
3100 ty: Option<TypeId>,
3101 var_map: &mut VarMap,
3102 dir: Option<&Path>,
3103 pending_nodes: Option<&RecordBatch>,
3104) -> Result<LogicalPlan, LoweringError> {
3105 let alias = var_alias(var);
3106 var_map.insert(var, alias.clone());
3107
3108 let src = if let Some(batch) = pending_nodes.filter(|batch| batch.num_rows() > 0) {
3109 use datafusion::datasource::{MemTable, provider_as_source};
3110 let mut batches = dir
3111 .map(graphforge_storage::read_nodes)
3112 .transpose()
3113 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?
3114 .unwrap_or_default();
3115 batches.push(batch.clone());
3116 let table = MemTable::try_new(batch.schema(), vec![batches])
3117 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
3118 provider_as_source(Arc::new(table))
3119 } else {
3120 node_scan_source(dir)
3121 };
3122 let mut builder = LogicalPlanBuilder::scan(alias.clone(), src, None)
3123 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
3124
3125 if let Some(type_id) = ty {
3126 use datafusion::functions_nested::expr_fn::array_has;
3127 use datafusion::logical_expr::{col, lit};
3128 builder = builder
3129 .filter(array_has(col(format!("{alias}.type_ids")), lit(type_id.0)))
3130 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
3131 }
3132
3133 builder
3134 .build()
3135 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))
3136}
3137
3138fn lower_typed_edge_scan(
3139 var: VarId,
3140 rel_ty: TypeId,
3141 var_map: &mut VarMap,
3142 catalog: Option<&GraphCatalog>,
3143 type_id_to_rel_name: &HashMap<u32, String>,
3144 dir: Option<&Path>,
3145 mode: OntologyMode,
3146) -> Result<LogicalPlan, LoweringError> {
3147 use datafusion::catalog::CatalogProvider;
3148 use datafusion::logical_expr::{col, lit};
3149
3150 let alias = var_alias(var);
3151 var_map.insert(var, alias.clone());
3152
3153 let rel_name = type_id_to_rel_name.get(&rel_ty.0).ok_or_else(|| {
3156 LoweringError::UnsupportedExpr(format!(
3157 "TypedEdgeScan: TypeId({}) has no known relation name; \
3158 ontology may be incomplete or stale",
3159 rel_ty.0
3160 ))
3161 })?;
3162
3163 let use_exploratory = catalog
3165 .and_then(|c| c.schema("graph"))
3166 .is_none_or(|s| !s.table_exist(&format!("edges_{rel_name}")));
3167
3168 if use_exploratory {
3169 let src = edge_scan_source(dir, "_exploratory", EXPLORATORY_EDGE_SCHEMA.clone(), mode);
3170 let filter_expr = col("rel_type_name").eq(lit(rel_name.as_str()));
3171 LogicalPlanBuilder::scan(alias, src, None)
3173 .and_then(|b| b.filter(filter_expr))
3174 .and_then(LogicalPlanBuilder::build)
3175 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))
3176 } else {
3177 let src = edge_scan_source(dir, rel_name, TYPED_EDGE_SCHEMA.clone(), mode);
3178 LogicalPlanBuilder::scan(alias, src, None)
3181 .and_then(LogicalPlanBuilder::build)
3182 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))
3183 }
3184}
3185
3186fn lower_edge_scan(
3187 var: VarId,
3188 ty: Option<TypeId>,
3189 var_map: &mut VarMap,
3190 type_id_to_rel_name: &HashMap<u32, String>,
3191 dir: Option<&Path>,
3192 mode: OntologyMode,
3193) -> Result<LogicalPlan, LoweringError> {
3194 use datafusion::logical_expr::{col, lit};
3195
3196 let alias = var_alias(var);
3197 var_map.insert(var, alias.clone());
3198
3199 let src = edge_scan_source(dir, "_exploratory", EXPLORATORY_EDGE_SCHEMA.clone(), mode);
3200 let mut builder = LogicalPlanBuilder::scan(alias, src, None)
3201 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
3202
3203 if let Some(type_id) = ty
3204 && let Some(name) = type_id_to_rel_name.get(&type_id.0)
3205 {
3206 builder = builder
3207 .filter(col("rel_type_name").eq(lit(name.as_str())))
3208 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
3209 }
3210
3211 builder
3212 .build()
3213 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))
3214}
3215
3216fn unwind_element_field(
3226 list_expr: &DfExpr,
3227 input_schema: &datafusion::common::DFSchemaRef,
3228 property_names: impl IntoIterator<Item = impl AsRef<str>>,
3229) -> datafusion::arrow::datatypes::Field {
3230 use datafusion::arrow::datatypes::{DataType, Field, Fields};
3231 use datafusion::logical_expr::ExprSchemable;
3232
3233 let element_type =
3234 if let Ok(DataType::List(f) | DataType::LargeList(f) | DataType::FixedSizeList(f, _)) =
3235 list_expr.get_type(input_schema.as_ref())
3236 {
3237 f.data_type().clone()
3238 } else {
3239 let mut names: Vec<String> = property_names
3245 .into_iter()
3246 .map(|name| name.as_ref().to_owned())
3247 .collect();
3248 names.sort();
3249 names.dedup();
3250 if names.is_empty() {
3251 DataType::Int64
3252 } else {
3253 DataType::Struct(Fields::from(
3254 names
3255 .into_iter()
3256 .map(|name| Field::new(name, DataType::Null, true))
3257 .collect::<Vec<_>>(),
3258 ))
3259 }
3260 };
3261 Field::new("elem", element_type, true)
3262}
3263
3264fn merge_optional_child_vars(child_vm: &VarMap, var_map: &mut VarMap) {
3288 for v in child_vm.var_ids() {
3289 if var_map.get(v).is_none() {
3290 let child_col = child_vm
3291 .get(v)
3292 .expect("var_ids yields only registered vars")
3293 .to_owned();
3294 var_map.insert(v, child_col);
3295 }
3296 }
3297}
3298
3299fn promote_optional_entity_vars(
3300 outer: &LogicalPlan,
3301 inner: &LogicalPlan,
3302 child_vm: &VarMap,
3303 var_map: &mut VarMap,
3304) {
3305 use datafusion::common::TableReference;
3306
3307 for var in child_vm.var_ids() {
3308 let Some(outer_alias) = var_map.get(var) else {
3309 continue;
3310 };
3311 let Some(inner_alias) = child_vm.get(var) else {
3312 continue;
3313 };
3314 let outer_qual = TableReference::bare(outer_alias);
3315 let inner_qual = TableReference::bare(inner_alias);
3316 let outer_is_entity = ["node_uuid", "edge_uuid"].iter().any(|name| {
3317 outer
3318 .schema()
3319 .index_of_column_by_name(Some(&outer_qual), name)
3320 .is_some()
3321 });
3322 let inner_is_entity = ["node_uuid", "edge_uuid"].iter().any(|name| {
3323 inner
3324 .schema()
3325 .index_of_column_by_name(Some(&inner_qual), name)
3326 .is_some()
3327 });
3328 if !outer_is_entity && inner_is_entity {
3329 var_map.insert(var, inner_alias.to_owned());
3330 }
3331 }
3332}
3333
3334fn full_subquery_needs_outer_input(child: &GraphPlan, outer_vm: &VarMap) -> bool {
3335 outer_vm.var_ids().any(|var| {
3336 plan_references_var(child, var) && !child.ops.iter().any(|op| graph_op_binds_var(op, var))
3337 })
3338}
3339
3340fn plan_references_var(plan: &GraphPlan, var: VarId) -> bool {
3341 let expression_reference = (0..plan.exprs.len()).any(|index| {
3342 let index = u32::try_from(index).expect("ExprArena length is capped at u32::MAX");
3343 matches!(plan.exprs.get(ExprId(index)), IrExpr::VarRef(found) if *found == var)
3344 });
3345 expression_reference
3346 || plan.ops.iter().any(|op| {
3347 graph_op_binds_var(op, var)
3348 || match op {
3349 GraphOp::Optional { child }
3350 | GraphOp::Exists { child, .. }
3351 | GraphOp::PatternComprehension { child, .. }
3352 | GraphOp::ListElementPatternComprehension { child, .. } => {
3353 plan_references_var(child, var)
3354 }
3355 GraphOp::Union { inputs, .. } => {
3356 inputs.iter().any(|input| plan_references_var(input, var))
3357 }
3358 _ => false,
3359 }
3360 })
3361}
3362
3363fn graph_op_binds_var(op: &GraphOp, var: VarId) -> bool {
3364 match op {
3365 GraphOp::NodeScan { var: found, .. }
3366 | GraphOp::EdgeScan { var: found, .. }
3367 | GraphOp::TypedEdgeScan { var: found, .. } => *found == var,
3368 GraphOp::Expand { src, edge, dst, .. } => *src == var || *edge == var || *dst == var,
3369 _ => false,
3370 }
3371}
3372
3373fn optional_join_keys(
3374 outer: &LogicalPlan,
3375 inner: &LogicalPlan,
3376 outer_vm: &VarMap,
3377 inner_vm: &VarMap,
3378) -> (Vec<(usize, usize)>, Vec<usize>) {
3379 use datafusion::common::TableReference;
3380
3381 let outer_schema = outer.schema();
3382 let inner_schema = inner.schema();
3383
3384 let shared_cols = outer_vm
3385 .var_ids()
3386 .filter_map(|var| {
3387 let outer_col = outer_vm.get(var)?;
3388 let inner_col = inner_vm.get(var)?;
3389 Some((outer_col.to_owned(), inner_col.to_owned()))
3390 })
3391 .collect::<Vec<_>>();
3392
3393 let shared_quals: std::collections::HashSet<String> = shared_cols
3396 .iter()
3397 .filter_map(|(outer_col, inner_col)| {
3398 let outer_qual = TableReference::bare(outer_col.clone());
3399 ["node_id", "node_uuid", "edge_uuid"]
3400 .iter()
3401 .any(|identity| {
3402 outer_schema
3403 .index_of_column_by_name(Some(&outer_qual), identity)
3404 .is_some()
3405 })
3406 .then(|| inner_col.clone())
3407 })
3408 .collect();
3409
3410 let mut join_keys: Vec<(usize, usize)> = Vec::new();
3411 for (outer_col, inner_col) in shared_cols {
3412 let outer_qual = TableReference::bare(outer_col.clone());
3413 let inner_qual = TableReference::bare(inner_col);
3414 for identity in ["node_id", "node_uuid", "edge_uuid"] {
3415 if let (Some(o), Some(i)) = (
3416 outer_schema.index_of_column_by_name(Some(&outer_qual), identity),
3417 inner_schema.index_of_column_by_name(Some(&inner_qual), identity),
3418 ) {
3419 join_keys.push((o, i));
3420 break;
3421 }
3422 }
3423 if !join_keys.iter().any(|(_, inner)| {
3424 ["node_id", "node_uuid", "edge_uuid"]
3425 .iter()
3426 .any(|identity| {
3427 inner_schema.index_of_column_by_name(Some(&inner_qual), identity)
3428 == Some(*inner)
3429 })
3430 }) && let Some(o) = outer_schema.index_of_column_by_name(None, &outer_col)
3431 && let Some(i) = inner_schema
3432 .index_of_column_by_name(Some(&inner_qual), "node_uuid")
3433 .or_else(|| inner_schema.index_of_column_by_name(Some(&inner_qual), "edge_uuid"))
3434 {
3435 join_keys.push((o, i));
3436 }
3437 }
3438
3439 let inner_keep_idx = inner_schema
3443 .iter()
3444 .enumerate()
3445 .filter(|(_, (q, _))| q.is_none_or(|q| !shared_quals.contains(q.table())))
3446 .map(|(i, _)| i)
3447 .collect();
3448
3449 (join_keys, inner_keep_idx)
3450}
3451
3452fn schema_join_column(
3453 schema: &datafusion::common::DFSchema,
3454 index: usize,
3455) -> datafusion::common::Column {
3456 let (qualifier, field) = schema
3457 .iter()
3458 .nth(index)
3459 .expect("join key index must point at a schema field");
3460 datafusion::common::Column::new(qualifier.cloned(), field.name().to_owned())
3461}
3462
3463fn plan_columns(plan: &LogicalPlan) -> Vec<DfExpr> {
3464 use datafusion::common::Column;
3465 plan.schema()
3466 .iter()
3467 .map(|(qualifier, field)| {
3468 DfExpr::Column(Column::new(qualifier.cloned(), field.name().to_owned()))
3469 })
3470 .collect()
3471}
3472
3473#[allow(clippy::too_many_arguments)]
3477#[allow(
3478 clippy::too_many_lines,
3479 reason = "property discovery, bound-variable correlation, and extension shaping are one lowering operation"
3480)]
3481fn lower_var_len_expand(
3482 src: VarId,
3483 edge: VarId,
3484 dst: VarId,
3485 rel_ty: Option<TypeId>,
3486 dir: Direction,
3487 min_hops: u16,
3488 max_hops: Option<u16>,
3489 input: LogicalPlan,
3490 var_map: &mut VarMap,
3491 type_id_to_rel_name: &HashMap<u32, String>,
3492 inference_rules: &HashMap<u32, Vec<(String, String)>>,
3493 target: Option<(&Path, OntologyMode)>,
3494) -> Result<LogicalPlan, LoweringError> {
3495 use datafusion::logical_expr::col;
3496
3497 let bound_edge_list = var_map.get(edge).map(str::to_owned);
3498 let bound_dst = var_map.get(dst).map(str::to_owned);
3499 let traversal_dst = bound_dst
3500 .as_ref()
3501 .map_or(dst, |_| VarId(u32::MAX.saturating_sub(dst.0)));
3502 let rel_name = match rel_ty {
3503 Some(rt) => type_id_to_rel_name.get(&rt.0).cloned().ok_or_else(|| {
3504 LoweringError::UnsupportedExpr(format!(
3505 "VarLenExpand: TypeId({}) has no known relation name; \
3506 ontology may be incomplete or stale",
3507 rt.0
3508 ))
3509 })?,
3510 None => "*".to_owned(),
3511 };
3512 let infer_rules: Vec<(String, String)> = rel_ty
3517 .and_then(|rt| inference_rules.get(&rt.0))
3518 .cloned()
3519 .unwrap_or_default();
3520 let rel_for_infer = rel_name.clone();
3521 let (dir_path, mode) = target.ok_or_else(|| {
3525 LoweringError::UnsupportedExpr(
3526 "variable-length expand requires a project directory; \
3527 lower via new_for_writes or new_with_dir"
3528 .into(),
3529 )
3530 })?;
3531 let topology_names = ["edge_uuid", "src_uuid", "dst_uuid", "rel_type"];
3541 let prop_fields: Vec<datafusion::arrow::datatypes::Field> = if rel_name == "*" {
3542 let mut seen = std::collections::HashSet::new();
3543 let mut fields = Vec::new();
3544 for stem in graphforge_storage::list_edge_property_stems(dir_path) {
3545 let prop_table =
3546 graphforge_storage::EdgePropertyTable::open_discovered(dir_path, &stem);
3547 for f in prop_table.schema_ref().fields() {
3548 if topology_names.contains(&f.name().as_str()) {
3549 continue;
3550 }
3551 if seen.insert(f.name().clone()) {
3552 fields.push(f.as_ref().clone().with_nullable(true));
3553 }
3554 }
3555 }
3556 fields
3557 } else {
3558 let prop_table =
3559 graphforge_storage::EdgePropertyTable::open_discovered(dir_path, &rel_name);
3560 prop_table
3561 .schema_ref()
3562 .fields()
3563 .iter()
3564 .filter(|f| !topology_names.contains(&f.name().as_str()))
3565 .map(|f| f.as_ref().clone())
3566 .collect()
3567 };
3568
3569 let dst_fields = TOPOLOGY_NODES_SCHEMA.fields().iter().cloned().collect();
3575 let node = VarLenExpandNode::new(
3576 Arc::new(input),
3577 rel_name,
3578 min_hops,
3579 max_hops,
3580 src.0,
3581 traversal_dst.0,
3582 edge.0,
3583 dir,
3584 rel_ty.map(|t| t.0),
3585 dir_path.to_path_buf(),
3586 mode,
3587 dst_fields,
3588 graphforge_plan::var_len_edge_list_field(&prop_fields),
3589 );
3590 if bound_dst.is_none() {
3594 var_map.insert(dst, var_alias(dst));
3595 }
3596 var_map.insert(
3602 edge,
3603 format!(
3604 "var_{}.{}",
3605 edge.0,
3606 graphforge_plan::VAR_LEN_EDGE_LIST_FIELD
3607 ),
3608 );
3609 let mut base = LogicalPlan::Extension(Extension {
3610 node: Arc::new(node),
3611 });
3612 if let Some(bound_dst) = bound_dst {
3613 let traversal_alias = var_alias(traversal_dst);
3614 base = LogicalPlanBuilder::from(base)
3615 .filter(
3616 col(format!("{traversal_alias}.node_uuid"))
3617 .eq(col(format!("{bound_dst}.node_uuid"))),
3618 )
3619 .and_then(LogicalPlanBuilder::build)
3620 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
3621 let traversal_ref = datafusion::common::TableReference::bare(traversal_alias);
3622 let projection = base
3623 .schema()
3624 .iter()
3625 .filter(|(qualifier, _)| {
3626 qualifier
3627 .as_ref()
3628 .is_none_or(|qualifier| **qualifier != traversal_ref)
3629 })
3630 .map(|(qualifier, field)| {
3631 DfExpr::Column(datafusion::common::Column::new(
3632 qualifier.cloned(),
3633 field.name(),
3634 ))
3635 })
3636 .collect::<Vec<_>>();
3637 base = LogicalPlanBuilder::from(base)
3638 .project(projection)
3639 .and_then(LogicalPlanBuilder::build)
3640 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
3641 }
3642 if let Some(bound_edge_list) = bound_edge_list {
3643 let produced = format!(
3644 "var_{}.{}",
3645 edge.0,
3646 graphforge_plan::VAR_LEN_EDGE_LIST_FIELD
3647 );
3648 base = LogicalPlanBuilder::from(base)
3649 .filter(col(bound_edge_list).eq(col(produced)))
3650 .and_then(LogicalPlanBuilder::build)
3651 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
3652 }
3653 let plan = infer_rules
3657 .into_iter()
3658 .fold(base, |acc, (rule_id, conf_model)| {
3659 LogicalPlan::Extension(Extension {
3660 node: Arc::new(graphforge_plan::OntologyInferNode::new(
3661 Arc::new(acc),
3662 rel_for_infer.clone(),
3663 rule_id,
3664 conf_model,
3665 )),
3666 })
3667 });
3668 Ok(plan)
3669}
3670
3671#[allow(clippy::too_many_arguments)]
3672#[allow(clippy::too_many_lines)]
3673fn lower_expand(
3674 src: VarId,
3675 edge: VarId,
3676 dst: VarId,
3677 rel_ty: Option<TypeId>,
3678 dir: Direction,
3679 min_hops: u16,
3680 max_hops: Option<u16>,
3681 input: LogicalPlan,
3682 var_map: &mut VarMap,
3683 catalog: Option<&GraphCatalog>,
3684 type_id_to_rel_name: &HashMap<u32, String>,
3685 inference_rules: &HashMap<u32, Vec<(String, String)>>,
3686 target: Option<(&Path, OntologyMode)>,
3687 relational_reference: bool,
3688) -> Result<LogicalPlan, LoweringError> {
3689 if min_hops != 1 || max_hops != Some(1) {
3693 return lower_var_len_expand(
3694 src,
3695 edge,
3696 dst,
3697 rel_ty,
3698 dir,
3699 min_hops,
3700 max_hops,
3701 input,
3702 var_map,
3703 type_id_to_rel_name,
3704 inference_rules,
3705 target,
3706 );
3707 }
3708
3709 let src_alias = var_map
3713 .get(src)
3714 .ok_or(LoweringError::UnboundVar(src.0))?
3715 .to_owned();
3716
3717 if !relational_reference
3721 && let Some(plan) = try_lower_provider_expand(
3722 src,
3723 edge,
3724 dst,
3725 rel_ty,
3726 dir,
3727 &input,
3728 var_map,
3729 type_id_to_rel_name,
3730 target,
3731 )?
3732 {
3733 return Ok(plan);
3734 }
3735
3736 match dir {
3737 Direction::Out => expand_single_dir(
3738 src,
3739 edge,
3740 dst,
3741 rel_ty,
3742 &src_alias,
3743 true,
3744 input,
3745 var_map,
3746 catalog,
3747 type_id_to_rel_name,
3748 target,
3749 ),
3750 Direction::In => expand_single_dir(
3751 src,
3752 edge,
3753 dst,
3754 rel_ty,
3755 &src_alias,
3756 false,
3757 input,
3758 var_map,
3759 catalog,
3760 type_id_to_rel_name,
3761 target,
3762 ),
3763 Direction::Undirected => {
3764 use datafusion::common::Column;
3765 use datafusion::logical_expr::col;
3766
3767 let mut vm_out = var_map.clone();
3771 let mut vm_in = var_map.clone();
3772 let out_plan = expand_single_dir(
3773 src,
3774 edge,
3775 dst,
3776 rel_ty,
3777 &src_alias,
3778 true,
3779 input.clone(),
3780 &mut vm_out,
3781 catalog,
3782 type_id_to_rel_name,
3783 target,
3784 )?;
3785 let in_plan = expand_single_dir(
3786 src,
3787 edge,
3788 dst,
3789 rel_ty,
3790 &src_alias,
3791 false,
3792 input,
3793 &mut vm_in,
3794 catalog,
3795 type_id_to_rel_name,
3796 target,
3797 )?;
3798 if let Some(edge_col) = vm_out.get(edge) {
3800 var_map.insert(edge, edge_col.to_owned());
3801 }
3802 if let Some(dst_col) = vm_out.get(dst) {
3803 var_map.insert(dst, dst_col.to_owned());
3804 }
3805
3806 let edge_alias = var_alias(edge);
3817 let in_plan = LogicalPlanBuilder::from(in_plan)
3818 .filter(
3819 col(format!("{edge_alias}.src_id")).not_eq(col(format!("{edge_alias}.dst_id"))),
3820 )
3821 .and_then(LogicalPlanBuilder::build)
3822 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
3823
3824 let leg_schema = out_plan.schema().clone();
3838 let stable = |plan: LogicalPlan| -> Result<LogicalPlan, LoweringError> {
3839 let proj: Vec<DfExpr> = plan
3840 .schema()
3841 .iter()
3842 .enumerate()
3843 .map(|(i, (q, f))| {
3844 DfExpr::Column(Column::new(q.cloned(), f.name())).alias(format!("__u{i}"))
3845 })
3846 .collect();
3847 LogicalPlanBuilder::from(plan)
3848 .project(proj)
3849 .and_then(LogicalPlanBuilder::build)
3850 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))
3851 };
3852 let out_plan = stable(out_plan)?;
3853 let in_plan = stable(in_plan)?;
3854 let unioned = LogicalPlanBuilder::from(out_plan)
3855 .union(in_plan)
3856 .and_then(LogicalPlanBuilder::build)
3857 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
3858 let projections: Vec<DfExpr> = leg_schema
3859 .iter()
3860 .enumerate()
3861 .map(|(i, (q, f))| {
3862 col(format!("__u{i}")).alias_qualified(q.cloned(), f.name().as_str())
3863 })
3864 .collect();
3865 LogicalPlanBuilder::from(unioned)
3866 .project(projections)
3867 .and_then(LogicalPlanBuilder::build)
3868 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))
3869 }
3870 }
3871}
3872
3873#[allow(clippy::too_many_arguments)]
3882#[allow(
3883 clippy::too_many_lines,
3884 reason = "schema discovery, repeated-destination correlation, and extension shaping are one lowering operation"
3885)]
3886fn try_lower_provider_expand(
3887 src: VarId,
3888 edge: VarId,
3889 dst: VarId,
3890 rel_ty: Option<TypeId>,
3891 dir: Direction,
3892 input: &LogicalPlan,
3893 var_map: &mut VarMap,
3894 type_id_to_rel_name: &HashMap<u32, String>,
3895 target: Option<(&Path, OntologyMode)>,
3896) -> Result<Option<LogicalPlan>, LoweringError> {
3897 let Some((dir_path, mode)) = target else {
3898 return Ok(None); };
3900 if var_map.get(edge).is_some() {
3901 return Ok(None); }
3903 let rel_name = match rel_ty {
3904 Some(rt) => {
3905 let Some(name) = type_id_to_rel_name.get(&rt.0) else {
3906 return Ok(None); };
3908 name.clone()
3909 }
3910 None => "*".to_owned(),
3911 };
3912
3913 let bound_dst = var_map.get(dst).map(str::to_owned);
3914 let traversal_dst = bound_dst
3915 .as_ref()
3916 .map_or(dst, |_| VarId(u32::MAX.saturating_sub(dst.0)));
3917
3918 let edge_schema = if rel_ty.is_none() || matches!(mode, OntologyMode::Exploratory) {
3921 &*EXPLORATORY_EDGE_SCHEMA
3922 } else {
3923 &*TYPED_EDGE_SCHEMA
3924 };
3925 let edge_fields: Vec<Arc<datafusion::arrow::datatypes::Field>> =
3926 edge_schema.fields().iter().cloned().collect();
3927 let base_names: HashSet<&str> = edge_fields.iter().map(|f| f.name().as_str()).collect();
3928 let mut stems = if rel_name == "*" {
3929 graphforge_storage::list_edge_property_stems(dir_path)
3930 } else {
3931 vec![rel_name.clone()]
3932 };
3933 stems.sort();
3934 let mut seen = HashSet::new();
3935 let mut edge_prop_fields = Vec::new();
3936 for stem in stems {
3937 let prop_table = graphforge_storage::EdgePropertyTable::open_discovered(dir_path, &stem);
3938 for field in prop_table.schema_ref().fields() {
3939 if field.name() != "edge_uuid"
3940 && !base_names.contains(field.name().as_str())
3941 && seen.insert(field.name().clone())
3942 {
3943 edge_prop_fields.push(Arc::clone(field));
3944 }
3945 }
3946 }
3947 let dst_fields: Vec<Arc<datafusion::arrow::datatypes::Field>> =
3948 TOPOLOGY_NODES_SCHEMA.fields().iter().cloned().collect();
3949
3950 let node = graphforge_plan::ExpandNode::new(
3951 Arc::new(input.clone()),
3952 rel_name,
3953 src.0,
3954 traversal_dst.0,
3955 edge.0,
3956 dir,
3957 rel_ty.map(|rt| rt.0),
3958 dir_path.to_path_buf(),
3959 mode,
3960 edge_fields,
3961 edge_prop_fields,
3962 dst_fields,
3963 );
3964 var_map.insert(edge, var_alias(edge));
3967 if bound_dst.is_none() {
3968 var_map.insert(dst, var_alias(dst));
3969 }
3970
3971 let mut base = LogicalPlan::Extension(datafusion::logical_expr::Extension {
3972 node: Arc::new(node),
3973 });
3974 if let Some(bound_dst) = bound_dst {
3975 use datafusion::logical_expr::col;
3976
3977 let traversal_alias = var_alias(traversal_dst);
3978 base = LogicalPlanBuilder::from(base)
3979 .filter(
3980 col(format!("{traversal_alias}.node_uuid"))
3981 .eq(col(format!("{bound_dst}.node_uuid"))),
3982 )
3983 .and_then(LogicalPlanBuilder::build)
3984 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
3985 let traversal_ref = datafusion::common::TableReference::bare(traversal_alias);
3986 let projection = base
3987 .schema()
3988 .iter()
3989 .filter(|(qualifier, _)| {
3990 qualifier
3991 .as_ref()
3992 .is_none_or(|qualifier| **qualifier != traversal_ref)
3993 })
3994 .map(|(qualifier, field)| {
3995 DfExpr::Column(datafusion::common::Column::new(
3996 qualifier.cloned(),
3997 field.name(),
3998 ))
3999 })
4000 .collect::<Vec<_>>();
4001 base = LogicalPlanBuilder::from(base)
4002 .project(projection)
4003 .and_then(LogicalPlanBuilder::build)
4004 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
4005 }
4006 Ok(Some(base))
4007}
4008
4009#[allow(clippy::too_many_arguments)]
4014fn expand_single_dir(
4015 _src: VarId,
4016 edge: VarId,
4017 dst: VarId,
4018 rel_ty: Option<TypeId>,
4019 src_alias: &str,
4020 out_direction: bool,
4021 input: LogicalPlan,
4022 var_map: &mut VarMap,
4023 catalog: Option<&GraphCatalog>,
4024 type_id_to_rel_name: &HashMap<u32, String>,
4025 target: Option<(&Path, OntologyMode)>,
4026) -> Result<LogicalPlan, LoweringError> {
4027 use datafusion::logical_expr::col;
4028
4029 let bound_dst = var_map.get(dst).and_then(|alias| {
4030 let qualifier = datafusion::common::TableReference::bare(alias);
4031 if input
4032 .schema()
4033 .index_of_column_by_name(Some(&qualifier), "node_id")
4034 .is_some()
4035 {
4036 Some((alias.to_owned(), "node_id", false))
4037 } else {
4038 input
4039 .schema()
4040 .index_of_column_by_name(Some(&qualifier), "node_uuid")
4041 .is_some()
4042 .then(|| (alias.to_owned(), "node_uuid", true))
4043 }
4044 });
4045 let dir = target.map(|(d, _)| d);
4046 let mode = target.map_or(OntologyMode::Exploratory, |(_, m)| m);
4047 if let Some(edge_alias) = var_map.get(edge).map(str::to_owned) {
4048 return expand_bound_edge_single_dir(
4049 dst,
4050 rel_ty,
4051 src_alias,
4052 &edge_alias,
4053 out_direction,
4054 input,
4055 var_map,
4056 type_id_to_rel_name,
4057 target,
4058 );
4059 }
4060
4061 let edge_plan = match rel_ty {
4063 Some(rt) => {
4064 lower_typed_edge_scan(edge, rt, var_map, catalog, type_id_to_rel_name, dir, mode)?
4065 }
4066 None => lower_edge_scan(edge, None, var_map, type_id_to_rel_name, dir, mode)?,
4067 };
4068 let edge_alias = var_map
4069 .get(edge)
4070 .ok_or(LoweringError::UnboundVar(edge.0))?
4071 .to_owned();
4072
4073 let edge_plan = join_edge_properties(&edge_alias, rel_ty, type_id_to_rel_name, dir, edge_plan)?;
4077
4078 let src_col = col(format!("{src_alias}.node_id"));
4080 let edge_src_col = col(if out_direction {
4081 format!("{edge_alias}.src_id")
4082 } else {
4083 format!("{edge_alias}.dst_id")
4084 });
4085
4086 let join_pred = src_col.eq(edge_src_col);
4088 let joined = LogicalPlanBuilder::from(input)
4089 .join_on(edge_plan, JoinType::Inner, vec![join_pred])
4090 .and_then(LogicalPlanBuilder::build)
4091 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
4092
4093 let edge_dst_col = col(
4094 match (out_direction, bound_dst.as_ref().map(|(_, _, uuid)| *uuid)) {
4095 (true, Some(true)) => format!("{edge_alias}.dst_uuid"),
4096 (false, Some(true)) => format!("{edge_alias}.src_uuid"),
4097 (true, _) => format!("{edge_alias}.dst_id"),
4098 (false, _) => format!("{edge_alias}.src_id"),
4099 },
4100 );
4101 if let Some((dst_alias, dst_field, _)) = bound_dst {
4104 return LogicalPlanBuilder::from(joined)
4105 .filter(edge_dst_col.eq(col(format!("{dst_alias}.{dst_field}"))))
4106 .and_then(LogicalPlanBuilder::build)
4107 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()));
4108 }
4109
4110 let dst_plan = lower_node_scan(dst, None, var_map, dir, None)?;
4112 let dst_alias = var_map
4113 .get(dst)
4114 .ok_or(LoweringError::UnboundVar(dst.0))?
4115 .to_owned();
4116
4117 let dst_col = col(format!("{dst_alias}.node_id"));
4118
4119 let join_pred2 = edge_dst_col.eq(dst_col);
4120 LogicalPlanBuilder::from(joined)
4121 .join_on(dst_plan, JoinType::Inner, vec![join_pred2])
4122 .and_then(LogicalPlanBuilder::build)
4123 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))
4124}
4125
4126#[allow(clippy::too_many_arguments)]
4127fn expand_bound_edge_single_dir(
4128 dst: VarId,
4129 rel_ty: Option<TypeId>,
4130 src_alias: &str,
4131 edge_alias: &str,
4132 out_direction: bool,
4133 input: LogicalPlan,
4134 var_map: &mut VarMap,
4135 type_id_to_rel_name: &HashMap<u32, String>,
4136 target: Option<(&Path, OntologyMode)>,
4137) -> Result<LogicalPlan, LoweringError> {
4138 use datafusion::common::TableReference;
4139 use datafusion::logical_expr::{col, lit};
4140
4141 let edge_src_field = if out_direction { "src_id" } else { "dst_id" };
4142 let edge_dst_field = if out_direction { "dst_id" } else { "src_id" };
4143 let mut predicate =
4144 col(format!("{src_alias}.node_id")).eq(col(format!("{edge_alias}.{edge_src_field}")));
4145
4146 if let Some(rt) = rel_ty {
4147 let rel_name = type_id_to_rel_name.get(&rt.0).ok_or_else(|| {
4148 LoweringError::UnsupportedExpr(format!(
4149 "bound edge TypeId({}) has no known relation name; ontology may be incomplete or stale",
4150 rt.0
4151 ))
4152 })?;
4153 let qual = TableReference::bare(edge_alias);
4154 if input
4155 .schema()
4156 .index_of_column_by_name(Some(&qual), "rel_type_name")
4157 .is_some()
4158 {
4159 predicate = predicate
4160 .and(col(format!("{edge_alias}.rel_type_name")).eq(lit(rel_name.as_str())));
4161 }
4162 }
4163
4164 let filtered = LogicalPlanBuilder::from(input)
4165 .filter(predicate)
4166 .and_then(LogicalPlanBuilder::build)
4167 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
4168
4169 let edge_dst = col(format!("{edge_alias}.{edge_dst_field}"));
4170 if let Some(dst_alias) = var_map.get(dst).map(str::to_owned) {
4171 return LogicalPlanBuilder::from(filtered)
4172 .filter(edge_dst.eq(col(format!("{dst_alias}.node_id"))))
4173 .and_then(LogicalPlanBuilder::build)
4174 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()));
4175 }
4176
4177 let dir = target.map(|(d, _)| d);
4178 let dst_plan = lower_node_scan(dst, None, var_map, dir, None)?;
4179 let dst_alias = var_map
4180 .get(dst)
4181 .ok_or(LoweringError::UnboundVar(dst.0))?
4182 .to_owned();
4183 LogicalPlanBuilder::from(filtered)
4184 .join_on(
4185 dst_plan,
4186 JoinType::Inner,
4187 vec![edge_dst.eq(col(format!("{dst_alias}.node_id")))],
4188 )
4189 .and_then(LogicalPlanBuilder::build)
4190 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))
4191}
4192
4193fn join_edge_properties(
4208 edge_alias: &str,
4209 rel_ty: Option<TypeId>,
4210 type_id_to_rel_name: &HashMap<u32, String>,
4211 dir: Option<&Path>,
4212 scan: LogicalPlan,
4213) -> Result<LogicalPlan, LoweringError> {
4214 use datafusion::logical_expr::{col, lit};
4215 use std::collections::{HashMap as StdHashMap, HashSet};
4216
4217 let Some(dir) = dir else {
4218 return Ok(scan); };
4220
4221 let base_cols: Vec<String> = scan
4224 .schema()
4225 .fields()
4226 .iter()
4227 .map(|f| f.name().clone())
4228 .collect();
4229
4230 let mut prop_sources = Vec::new();
4231 let mut prop_order = Vec::new();
4232 let mut seen_props = HashSet::new();
4233 let mut push_source = |stem: String| {
4234 let table = graphforge_storage::EdgePropertyTable::open_discovered(dir, &stem);
4235 let prop_cols: Vec<String> = table
4236 .schema_ref()
4237 .fields()
4238 .iter()
4239 .map(|f| f.name().clone())
4240 .filter(|n| !base_cols.contains(n))
4241 .collect();
4242 if prop_cols.is_empty() {
4243 return;
4244 }
4245 for name in &prop_cols {
4246 if seen_props.insert(name.clone()) {
4247 prop_order.push(name.clone());
4248 }
4249 }
4250 prop_sources.push((stem, table, prop_cols));
4251 };
4252 if let Some(rel_ty) = rel_ty {
4253 let Some(rel_name) = type_id_to_rel_name.get(&rel_ty.0) else {
4254 return Ok(scan); };
4256 push_source(rel_name.clone());
4257 } else {
4258 for stem in graphforge_storage::list_edge_property_stems(dir) {
4259 push_source(stem);
4260 }
4261 }
4262 if prop_sources.is_empty() {
4263 return Ok(scan);
4264 }
4265
4266 let wildcard = rel_ty.is_none();
4267 let mut joined = scan;
4268 let mut prop_refs: StdHashMap<String, Vec<DfExpr>> = StdHashMap::new();
4269 for (idx, (stem, prop_table, prop_cols)) in prop_sources.into_iter().enumerate() {
4270 let prop_alias = format!("{edge_alias}__eprops_{idx}");
4271 let prop_src = datafusion::datasource::provider_as_source(Arc::new(prop_table));
4272 let prop_scan = LogicalPlanBuilder::scan(prop_alias.clone(), prop_src, None)
4273 .and_then(LogicalPlanBuilder::build)
4274 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
4275
4276 let mut join_pred =
4280 col(format!("{edge_alias}.edge_uuid")).eq(col(format!("{prop_alias}.edge_uuid")));
4281 if wildcard {
4282 join_pred = join_pred.and(col(format!("{edge_alias}.rel_type_name")).eq(lit(stem)));
4283 }
4284 joined = LogicalPlanBuilder::from(joined)
4285 .join_on(prop_scan, JoinType::Left, vec![join_pred])
4286 .and_then(LogicalPlanBuilder::build)
4287 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))?;
4288 for name in prop_cols {
4289 prop_refs
4290 .entry(name.clone())
4291 .or_default()
4292 .push(crate::expr::qualified_col(&prop_alias, &name));
4293 }
4294 }
4295
4296 let mut projections: Vec<DfExpr> = base_cols
4299 .iter()
4300 .map(|name| col(format!("{edge_alias}.{name}")))
4301 .collect();
4302 for name in prop_order {
4303 let mut refs = prop_refs.remove(&name).unwrap_or_default();
4304 let value = if refs.len() == 1 {
4305 refs.remove(0)
4306 } else {
4307 datafusion::functions::core::expr_fn::coalesce(refs)
4308 };
4309 projections.push(value.alias_qualified(Some(edge_alias), name.as_str()));
4310 }
4311
4312 LogicalPlanBuilder::from(joined)
4313 .project(projections)
4314 .and_then(LogicalPlanBuilder::build)
4315 .map_err(|e| LoweringError::UnsupportedExpr(e.to_string()))
4316}
4317
4318#[cfg(test)]
4323mod tests {
4324 use super::*;
4325 use datafusion::logical_expr::LogicalPlan as DfLogicalPlan;
4326 use graphforge_core::TypeId;
4327 use graphforge_ir::expr::{IrExpr, IrLiteral};
4328 use graphforge_ir::{Direction, ExprArena, GraphPlan, VarId};
4329
4330 fn empty_base() -> LogicalPlan {
4331 LogicalPlanBuilder::empty(false).build().unwrap()
4332 }
4333
4334 fn make_catalog_and_lowerer() -> (
4335 tempfile::TempDir,
4336 graphforge_storage::GraphCatalog,
4337 graphforge_ir::RuntimeCatalog,
4338 ) {
4339 let dir = tempfile::TempDir::new().unwrap();
4340 let rc = graphforge_ir::RuntimeCatalog::new();
4341 let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
4342 (dir, catalog, rc)
4343 }
4344
4345 fn node_scan_with_alias(alias: &str) -> LogicalPlan {
4346 let schema = std::sync::Arc::new(datafusion::arrow::datatypes::Schema::new(vec![
4347 datafusion::arrow::datatypes::Field::new(
4348 "node_id",
4349 datafusion::arrow::datatypes::DataType::UInt64,
4350 false,
4351 ),
4352 ]));
4353 LogicalPlanBuilder::scan(alias, table_source(schema), None)
4354 .and_then(LogicalPlanBuilder::build)
4355 .unwrap()
4356 }
4357
4358 #[test]
4359 fn optional_join_keys_use_var_map_qualifiers() {
4360 let outer = node_scan_with_alias("projected_node");
4361 let inner = node_scan_with_alias("pattern_node");
4362 let mut outer_vm = VarMap::new();
4363 outer_vm.insert(VarId(7), "projected_node");
4364 let mut inner_vm = VarMap::new();
4365 inner_vm.insert(VarId(7), "pattern_node");
4366
4367 let (join_keys, inner_keep_idx) = optional_join_keys(&outer, &inner, &outer_vm, &inner_vm);
4368
4369 assert_eq!(join_keys, vec![(0, 0)]);
4370 assert_eq!(inner_keep_idx, Vec::<usize>::new());
4371 }
4372
4373 #[test]
4374 fn filter_lowers_predicate() {
4375 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4376 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4377
4378 let mut arena = ExprArena::new();
4379 let lit = arena.push(IrExpr::Literal(IrLiteral::Bool(true)));
4380 let var_map = VarMap::new();
4381 let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
4382
4383 let result = lowerer
4384 .lower_op(
4385 &GraphOp::Filter { predicate: lit },
4386 empty_base(),
4387 &arena,
4388 &var_map,
4389 &expr_lowerer,
4390 )
4391 .unwrap();
4392
4393 assert!(
4394 matches!(result, DfLogicalPlan::Filter(_)),
4395 "expected Filter, got {result:?}"
4396 );
4397 }
4398
4399 #[test]
4400 fn project_lowers_columns() {
4401 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4402 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4403
4404 let mut arena = ExprArena::new();
4405 let lit = arena.push(IrExpr::Literal(IrLiteral::Int(1)));
4406 let item = ProjectItem {
4407 expr: lit,
4408 alias: Some("x".into()),
4409 out_var: None,
4410 };
4411 let var_map = VarMap::new();
4412 let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
4413
4414 let result = lowerer
4415 .lower_op(
4416 &GraphOp::Project {
4417 items: vec![item],
4418 distinct: false,
4419 },
4420 empty_base(),
4421 &arena,
4422 &var_map,
4423 &expr_lowerer,
4424 )
4425 .unwrap();
4426
4427 assert!(
4428 matches!(result, DfLogicalPlan::Projection(_)),
4429 "expected Projection, got {result:?}"
4430 );
4431 }
4432
4433 #[test]
4434 fn project_with_distinct_wraps_in_distinct() {
4435 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4436 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4437
4438 let mut arena = ExprArena::new();
4439 let lit = arena.push(IrExpr::Literal(IrLiteral::Int(1)));
4440 let item = ProjectItem {
4441 expr: lit,
4442 alias: None,
4443 out_var: None,
4444 };
4445 let var_map = VarMap::new();
4446 let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
4447
4448 let result = lowerer
4449 .lower_op(
4450 &GraphOp::Project {
4451 items: vec![item],
4452 distinct: true,
4453 },
4454 empty_base(),
4455 &arena,
4456 &var_map,
4457 &expr_lowerer,
4458 )
4459 .unwrap();
4460
4461 assert!(
4462 matches!(result, DfLogicalPlan::Distinct(_)),
4463 "expected Distinct, got {result:?}"
4464 );
4465 }
4466
4467 #[test]
4468 fn aggregate_count_star() {
4469 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4470 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4471
4472 let arena = ExprArena::new();
4473 let agg = AggExpr {
4474 func: AggFunc::Count,
4475 arg: None,
4476 percentile: None,
4477 alias: "total".into(),
4478 out_var: None,
4479 };
4480 let var_map = VarMap::new();
4481 let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
4482
4483 let result = lowerer
4484 .lower_op(
4485 &GraphOp::Aggregate {
4486 group_by: vec![],
4487 group_aliases: vec![],
4488 group_vars: vec![],
4489 aggs: vec![agg],
4490 },
4491 empty_base(),
4492 &arena,
4493 &var_map,
4494 &expr_lowerer,
4495 )
4496 .unwrap();
4497
4498 assert!(
4499 matches!(result, DfLogicalPlan::Aggregate(_)),
4500 "expected Aggregate, got {result:?}"
4501 );
4502 }
4503
4504 #[test]
4505 fn sort_lowers_keys() {
4506 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4507 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4508
4509 let mut arena = ExprArena::new();
4512 let lit = arena.push(IrExpr::Literal(IrLiteral::Int(1)));
4513 let var_map = VarMap::new();
4514 let key = graphforge_ir::SortKey {
4515 expr: lit,
4516 order: SortOrder::Desc,
4517 nulls_first: false,
4518 };
4519 let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
4520
4521 let result = lowerer
4522 .lower_op(
4523 &GraphOp::Sort { keys: vec![key] },
4524 empty_base(),
4525 &arena,
4526 &var_map,
4527 &expr_lowerer,
4528 )
4529 .unwrap();
4530
4531 assert!(
4532 matches!(result, DfLogicalPlan::Sort(_)),
4533 "expected Sort, got {result:?}"
4534 );
4535 }
4536
4537 #[test]
4538 fn limit_lowers_correctly() {
4539 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4540 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4541
4542 let arena = ExprArena::new();
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::Limit { count: 10 },
4549 empty_base(),
4550 &arena,
4551 &var_map,
4552 &expr_lowerer,
4553 )
4554 .unwrap();
4555
4556 assert!(
4557 matches!(result, DfLogicalPlan::Limit(_)),
4558 "expected Limit, got {result:?}"
4559 );
4560 }
4561
4562 #[test]
4563 fn skip_lowers_correctly() {
4564 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4565 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4566
4567 let arena = ExprArena::new();
4568 let var_map = VarMap::new();
4569 let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
4570
4571 let result = lowerer
4572 .lower_op(
4573 &GraphOp::Skip { count: 5 },
4574 empty_base(),
4575 &arena,
4576 &var_map,
4577 &expr_lowerer,
4578 )
4579 .unwrap();
4580
4581 assert!(
4583 matches!(result, DfLogicalPlan::Limit(_)),
4584 "expected Limit (skip), got {result:?}"
4585 );
4586 }
4587
4588 #[test]
4589 fn unsupported_op_returns_error() {
4590 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4591 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4592
4593 let arena = ExprArena::new();
4594 let var_map = VarMap::new();
4595 let expr_lowerer = ExprLowerer::new(&arena, None, &var_map);
4596
4597 let result = lowerer.lower_op(
4598 &GraphOp::NodeScan {
4599 var: VarId(0),
4600 ty: None,
4601 },
4602 empty_base(),
4603 &arena,
4604 &var_map,
4605 &expr_lowerer,
4606 );
4607 assert!(
4608 matches!(result, Err(LoweringError::UnsupportedExpr(_))),
4609 "expected UnsupportedExpr for NodeScan"
4610 );
4611 }
4612
4613 #[test]
4614 fn lower_plan_empty_ops_succeeds() {
4615 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4616 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4617
4618 let plan = GraphPlan::builder("openCypher").build();
4619 let result = lowerer.lower_plan(&plan);
4620 assert!(result.is_ok(), "empty op pipeline should succeed");
4621 }
4622
4623 #[test]
4624 fn union_requires_two_branch_plans() {
4625 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4626 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4627 let plan = GraphPlan::builder("openCypher")
4628 .push_op(GraphOp::Union {
4629 all: true,
4630 inputs: vec![GraphPlan::builder("openCypher").build()],
4631 })
4632 .build();
4633
4634 let error = lowerer
4635 .lower_plan(&plan)
4636 .expect_err("one branch is invalid");
4637 assert!(error.to_string().contains("at least two branch plans"));
4638 }
4639
4640 #[test]
4641 fn integration_filter_project_limit_pipeline() {
4642 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4643 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4644
4645 let mut builder = GraphPlan::builder("openCypher");
4647 let pred = builder.push_expr(IrExpr::Literal(IrLiteral::Bool(true)));
4648 let col_expr = builder.push_expr(IrExpr::Literal(IrLiteral::Str("n".into())));
4649
4650 let plan = builder
4651 .push_op(GraphOp::Filter { predicate: pred })
4652 .push_op(GraphOp::Project {
4653 items: vec![ProjectItem {
4654 expr: col_expr,
4655 alias: Some("name".into()),
4656 out_var: None,
4657 }],
4658 distinct: false,
4659 })
4660 .push_op(GraphOp::Limit { count: 10 })
4661 .build();
4662
4663 let lp = lowerer.lower_plan(&plan).unwrap();
4664 assert!(
4666 matches!(lp, DfLogicalPlan::Limit(_)),
4667 "expected Limit at top, got {lp:?}"
4668 );
4669 }
4670
4671 #[test]
4676 fn node_scan_no_type_produces_table_scan() {
4677 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4678 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4679
4680 let plan = GraphPlan::builder("openCypher")
4681 .push_op(GraphOp::NodeScan {
4682 var: VarId(0),
4683 ty: None,
4684 })
4685 .build();
4686 let lp = lowerer.lower_plan(&plan).unwrap();
4687 assert!(
4688 matches!(lp, DfLogicalPlan::TableScan(_)),
4689 "expected TableScan, got {lp:?}"
4690 );
4691 }
4692
4693 #[test]
4694 fn node_scan_with_type_produces_filter_over_scan() {
4695 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4696 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4697
4698 let plan = GraphPlan::builder("openCypher")
4699 .push_op(GraphOp::NodeScan {
4700 var: VarId(0),
4701 ty: Some(TypeId(1)),
4702 })
4703 .build();
4704 let lp = lowerer.lower_plan(&plan).unwrap();
4705 assert!(
4706 matches!(lp, DfLogicalPlan::Filter(_)),
4707 "expected Filter over TableScan, got {lp:?}"
4708 );
4709 }
4710
4711 #[test]
4712 fn typed_edge_scan_unknown_type_id_returns_error() {
4713 let lowerer = GraphPlanLowerer::new(None, None);
4716
4717 let plan = GraphPlan::builder("openCypher")
4718 .push_op(GraphOp::TypedEdgeScan {
4719 var: VarId(0),
4720 rel_ty: TypeId(42),
4721 })
4722 .build();
4723 let result = lowerer.lower_plan(&plan);
4724 assert!(
4725 result.is_err(),
4726 "unknown TypeId should return an error, not silently fall back"
4727 );
4728 }
4729
4730 #[test]
4731 fn edge_scan_wildcard_produces_table_scan() {
4732 let lowerer = GraphPlanLowerer::new(None, None);
4733
4734 let plan = GraphPlan::builder("openCypher")
4735 .push_op(GraphOp::EdgeScan {
4736 var: VarId(0),
4737 ty: None,
4738 })
4739 .build();
4740 let lp = lowerer.lower_plan(&plan).unwrap();
4741 assert!(
4742 matches!(lp, DfLogicalPlan::TableScan(_)),
4743 "expected TableScan, got {lp:?}"
4744 );
4745 }
4746
4747 fn var_len_plan() -> GraphPlan {
4748 GraphPlan::builder("openCypher")
4749 .push_op(GraphOp::NodeScan {
4750 var: VarId(0),
4751 ty: None,
4752 })
4753 .push_op(GraphOp::Expand {
4754 src: VarId(0),
4755 edge: VarId(1),
4756 dst: VarId(2),
4757 rel_ty: None,
4758 dir: Direction::Out,
4759 min_hops: 1,
4760 max_hops: Some(3), })
4762 .build()
4763 }
4764
4765 #[test]
4766 fn expand_var_len_produces_extension_node() {
4767 use datafusion::logical_expr::UserDefinedLogicalNodeCore;
4768 use graphforge_plan::VarLenExpandNode;
4769
4770 let (dir, catalog, _rc) = make_catalog_and_lowerer();
4771 let lowerer =
4772 GraphPlanLowerer::new_with_dir(Some(&catalog), None, dir.path(), OntologyMode::Strict);
4773
4774 let lp = lowerer.lower_plan(&var_len_plan()).unwrap();
4775 let DfLogicalPlan::Extension(ext) = &lp else {
4776 panic!("expected Extension (VarLenExpandNode), got {lp:?}");
4777 };
4778 let node = ext
4779 .node
4780 .as_any()
4781 .downcast_ref::<VarLenExpandNode>()
4782 .expect("VarLenExpandNode");
4783
4784 assert_eq!(node.src_var, 0);
4786 assert_eq!(node.dst_var, 2);
4787 assert_eq!(node.direction, Direction::Out);
4788 assert_eq!(node.min_hops, 1);
4789 assert_eq!(node.max_hops, Some(3));
4790 assert_eq!(node.dir, dir.path());
4791 assert_eq!(node.mode, OntologyMode::Strict);
4792
4793 let schema = UserDefinedLogicalNodeCore::schema(node);
4796 let dst = datafusion::common::TableReference::bare("var_2");
4797 assert!(schema.field_with_qualified_name(&dst, "node_id").is_ok());
4798 }
4799
4800 #[test]
4801 fn expand_var_len_without_dir_errors() {
4802 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4805 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4806 let err = lowerer.lower_plan(&var_len_plan()).unwrap_err();
4807 assert!(
4808 err.to_string().contains("project directory"),
4809 "expected a project-directory error, got: {err}"
4810 );
4811 }
4812
4813 #[test]
4814 fn unwind_produces_extension_node() {
4815 let lowerer = GraphPlanLowerer::new(None, None);
4816
4817 let mut builder = GraphPlan::builder("openCypher");
4818 let list_expr = builder.push_expr(IrExpr::Literal(IrLiteral::Int(1)));
4819 let plan = builder
4820 .push_op(GraphOp::Unwind {
4821 list_expr,
4822 alias: VarId(0),
4823 })
4824 .build();
4825 let lp = lowerer.lower_plan(&plan).unwrap();
4826 assert!(
4827 matches!(lp, DfLogicalPlan::Extension(_)),
4828 "expected Extension (UnwindNode), got {lp:?}"
4829 );
4830 }
4831
4832 #[test]
4833 fn optional_produces_extension_node() {
4834 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4835 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4836
4837 let child = GraphPlan::builder("openCypher")
4838 .push_op(GraphOp::NodeScan {
4839 var: VarId(1),
4840 ty: None,
4841 })
4842 .build();
4843 let plan = GraphPlan::builder("openCypher")
4844 .push_op(GraphOp::NodeScan {
4845 var: VarId(0),
4846 ty: None,
4847 })
4848 .push_op(GraphOp::Optional {
4849 child: Box::new(child),
4850 })
4851 .build();
4852 let lp = lowerer.lower_plan(&plan).unwrap();
4853 assert!(
4854 matches!(lp, DfLogicalPlan::Extension(_)),
4855 "expected Extension (OptionalMatchNode), got {lp:?}"
4856 );
4857 }
4858
4859 #[test]
4860 fn optional_node_output_schema_appends_nullable_inner() {
4861 use datafusion::logical_expr::UserDefinedLogicalNodeCore;
4862 use graphforge_plan::OptionalMatchNode;
4863
4864 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4865 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4866
4867 let child = GraphPlan::builder("openCypher")
4873 .push_op(GraphOp::NodeScan {
4874 var: VarId(1),
4875 ty: None,
4876 })
4877 .build();
4878 let plan = GraphPlan::builder("openCypher")
4879 .push_op(GraphOp::NodeScan {
4880 var: VarId(0),
4881 ty: None,
4882 })
4883 .push_op(GraphOp::Optional {
4884 child: Box::new(child),
4885 })
4886 .build();
4887 let lp = lowerer.lower_plan(&plan).unwrap();
4888 let DfLogicalPlan::Extension(ext) = &lp else {
4889 panic!("expected Extension (OptionalMatchNode), got {lp:?}");
4890 };
4891 let node = ext
4892 .node
4893 .as_any()
4894 .downcast_ref::<OptionalMatchNode>()
4895 .expect("OptionalMatchNode");
4896
4897 let schema = UserDefinedLogicalNodeCore::schema(node);
4899 assert_eq!(schema.fields().len(), 12, "outer(6) + inner(6)");
4900 for i in 0..6 {
4901 assert!(
4902 !schema.field(i).is_nullable(),
4903 "outer col {i} stays non-null"
4904 );
4905 }
4906 for i in 6..12 {
4907 assert!(
4908 schema.field(i).is_nullable(),
4909 "inner col {i} must be nullable for null-shaping"
4910 );
4911 }
4912 }
4913
4914 #[test]
4915 fn expand_single_hop_out_produces_join() {
4916 let lowerer = GraphPlanLowerer::new(None, None);
4917
4918 let plan = GraphPlan::builder("openCypher")
4919 .push_op(GraphOp::NodeScan {
4920 var: VarId(0),
4921 ty: None,
4922 })
4923 .push_op(GraphOp::Expand {
4924 src: VarId(0),
4925 edge: VarId(1),
4926 dst: VarId(2),
4927 rel_ty: None,
4928 dir: Direction::Out,
4929 min_hops: 1,
4930 max_hops: Some(1),
4931 })
4932 .build();
4933 let lp = lowerer.lower_plan(&plan).unwrap();
4934 assert!(
4936 matches!(lp, DfLogicalPlan::Join(_)),
4937 "expected Join, got {lp:?}"
4938 );
4939 }
4940
4941 #[test]
4942 fn fixed_hop_dst_label_is_preserved_as_filter() {
4943 let lowerer = GraphPlanLowerer::new(None, None);
4949 let plan = GraphPlan::builder("openCypher")
4950 .push_op(GraphOp::NodeScan {
4951 var: VarId(0),
4952 ty: None,
4953 })
4954 .push_op(GraphOp::Expand {
4955 src: VarId(0),
4956 edge: VarId(1),
4957 dst: VarId(2),
4958 rel_ty: None,
4959 dir: Direction::Out,
4960 min_hops: 1,
4961 max_hops: Some(1),
4962 })
4963 .push_op(GraphOp::NodeScan {
4964 var: VarId(2),
4965 ty: Some(TypeId(7)),
4966 })
4967 .build();
4968 let lp = lowerer.lower_plan(&plan).unwrap();
4969 let rendered = lp.display_indent_schema().to_string();
4970 assert!(
4971 rendered.contains("array_has(var_2.type_ids, UInt32(7))"),
4972 "destination label filter must be applied, got:\n{rendered}"
4973 );
4974 }
4975
4976 #[test]
4977 fn optional_child_with_shared_var_excludes_outer_columns() {
4978 use datafusion::logical_expr::UserDefinedLogicalNodeCore;
4979 use graphforge_plan::OptionalMatchNode;
4980
4981 let (_dir, catalog, _rc) = make_catalog_and_lowerer();
4986 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
4987 let child = GraphPlan::builder("openCypher")
4988 .push_op(GraphOp::NodeScan {
4989 var: VarId(0),
4990 ty: None,
4991 })
4992 .push_op(GraphOp::Expand {
4993 src: VarId(0),
4994 edge: VarId(1),
4995 dst: VarId(2),
4996 rel_ty: None,
4997 dir: Direction::Out,
4998 min_hops: 1,
4999 max_hops: Some(1),
5000 })
5001 .build();
5002 let plan = GraphPlan::builder("openCypher")
5003 .push_op(GraphOp::NodeScan {
5004 var: VarId(0),
5005 ty: None,
5006 })
5007 .push_op(GraphOp::Optional {
5008 child: Box::new(child),
5009 })
5010 .build();
5011 let lp = lowerer.lower_plan(&plan).unwrap();
5012 let DfLogicalPlan::Extension(ext) = &lp else {
5013 panic!("expected Extension (OptionalMatchNode), got {lp:?}");
5014 };
5015 let node = ext
5016 .node
5017 .as_any()
5018 .downcast_ref::<OptionalMatchNode>()
5019 .expect("OptionalMatchNode");
5020
5021 assert_eq!(node.join_keys.len(), 1, "var_0 is the shared join key");
5025 let schema = UserDefinedLogicalNodeCore::schema(node);
5026 assert_eq!(schema.fields().len(), 20, "no duplicate var_0 columns");
5028 }
5031
5032 fn create_plan_with_props() -> GraphPlan {
5037 use graphforge_ir::{CreateNodeSpec, CreatePattern};
5038 let mut builder = GraphPlan::builder("openCypher");
5039 let name_lit = builder.push_expr(IrExpr::Literal(IrLiteral::Str("Alice".into())));
5041 let map = builder.push_expr(IrExpr::MapLiteral(vec![("name".into(), name_lit)]));
5042 builder
5043 .push_op(GraphOp::Create {
5044 pattern: CreatePattern {
5045 nodes: vec![CreateNodeSpec {
5046 var: VarId(0),
5047 labels: vec![TypeId(0)],
5048 properties: Some(map),
5049 is_reference: false,
5050 }],
5051 edges: vec![],
5052 },
5053 })
5054 .build()
5055 }
5056
5057 #[test]
5058 fn create_lowers_to_extension_with_write_target() {
5059 let dir = tempfile::TempDir::new().unwrap();
5060 let lowerer = GraphPlanLowerer::new_for_writes(
5061 None,
5062 None,
5063 dir.path(),
5064 graphforge_core::OntologyMode::Exploratory,
5065 );
5066 let plan = create_plan_with_props();
5067 let lp = lowerer.lower_plan(&plan).unwrap();
5068 assert!(
5069 matches!(lp, DfLogicalPlan::Extension(_)),
5070 "expected Extension (GraphCreateNode), got {lp:?}"
5071 );
5072 }
5073
5074 #[test]
5075 fn create_without_write_target_errors() {
5076 let lowerer = GraphPlanLowerer::new(None, None);
5078 let plan = create_plan_with_props();
5079 let result = lowerer.lower_plan(&plan);
5080 assert!(
5081 result.is_err(),
5082 "CREATE without a write target should error"
5083 );
5084 }
5085
5086 #[test]
5087 fn new_with_dir_does_not_authorize_writes() {
5088 let dir = tempfile::TempDir::new().unwrap();
5092 let lowerer = GraphPlanLowerer::new_with_dir(
5093 None,
5094 None,
5095 dir.path(),
5096 graphforge_core::OntologyMode::Exploratory,
5097 );
5098 let result = lowerer.lower_plan(&create_plan_with_props());
5099 assert!(
5100 result.is_err(),
5101 "new_with_dir must not authorize CREATE; only new_for_writes does"
5102 );
5103 }
5104
5105 #[test]
5106 fn create_non_literal_property_lowers_as_computed() {
5107 use graphforge_ir::{CreateNodeSpec, CreatePattern};
5108 let dir = tempfile::TempDir::new().unwrap();
5109 let lowerer = GraphPlanLowerer::new_for_writes(
5110 None,
5111 None,
5112 dir.path(),
5113 graphforge_core::OntologyMode::Exploratory,
5114 );
5115 let mut builder = GraphPlan::builder("openCypher");
5116 let param = builder.push_expr(IrExpr::Parameter("p".into()));
5120 let map = builder.push_expr(IrExpr::MapLiteral(vec![("name".into(), param)]));
5121 let plan = builder
5122 .push_op(GraphOp::Create {
5123 pattern: CreatePattern {
5124 nodes: vec![CreateNodeSpec {
5125 var: VarId(0),
5126 labels: vec![],
5127 properties: Some(map),
5128 is_reference: false,
5129 }],
5130 edges: vec![],
5131 },
5132 })
5133 .build();
5134 let logical = lowerer
5135 .lower_plan(&plan)
5136 .expect("a non-literal CREATE property lowers to a computed expr");
5137 let datafusion::logical_expr::LogicalPlan::Extension(ext) = &logical else {
5138 panic!("CREATE lowers to an Extension node");
5139 };
5140 let create = ext
5141 .node
5142 .as_any()
5143 .downcast_ref::<graphforge_plan::GraphCreateNode>()
5144 .expect("a GraphCreateNode");
5145 assert!(
5146 create.nodes[0].properties.is_empty(),
5147 "the parameter value is not a baked literal"
5148 );
5149 assert_eq!(
5150 create.nodes[0].computed_properties.len(),
5151 1,
5152 "the parameter value is a row-dependent computed property"
5153 );
5154 }
5155
5156 fn match_delete_plan(detach: bool) -> GraphPlan {
5163 GraphPlan::builder("openCypher")
5164 .push_op(GraphOp::NodeScan {
5165 var: VarId(0),
5166 ty: None,
5167 })
5168 .push_op(GraphOp::Delete {
5169 vars: vec![VarId(0)],
5170 exprs: vec![],
5171 detach,
5172 })
5173 .build()
5174 }
5175
5176 #[test]
5177 fn delete_lowers_to_extension_with_write_target() {
5178 let dir = tempfile::TempDir::new().unwrap();
5179 let rc = graphforge_ir::RuntimeCatalog::new();
5180 let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
5181 let lowerer = GraphPlanLowerer::new_for_writes(
5182 Some(&catalog),
5183 None,
5184 dir.path(),
5185 graphforge_core::OntologyMode::Exploratory,
5186 );
5187 let lp = lowerer.lower_plan(&match_delete_plan(false)).unwrap();
5188 assert!(
5189 matches!(lp, DfLogicalPlan::Extension(_)),
5190 "expected Extension (GraphDeleteNode), got {lp:?}"
5191 );
5192 }
5193
5194 #[test]
5195 fn delete_without_write_target_errors() {
5196 let dir = tempfile::TempDir::new().unwrap();
5197 let rc = graphforge_ir::RuntimeCatalog::new();
5198 let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
5199 let lowerer = GraphPlanLowerer::new_with_dir(
5201 Some(&catalog),
5202 None,
5203 dir.path(),
5204 graphforge_core::OntologyMode::Exploratory,
5205 );
5206 assert!(
5207 lowerer.lower_plan(&match_delete_plan(false)).is_err(),
5208 "DELETE without a write target should error"
5209 );
5210 }
5211
5212 #[test]
5213 fn delete_edge_variable_resolves_is_edge_flag() {
5214 use graphforge_plan::GraphDeleteNode;
5215
5216 let dir = tempfile::TempDir::new().unwrap();
5220 let rc = graphforge_ir::RuntimeCatalog::new();
5221 let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
5222 let lowerer = GraphPlanLowerer::new_for_writes(
5223 Some(&catalog),
5224 None,
5225 dir.path(),
5226 graphforge_core::OntologyMode::Exploratory,
5227 );
5228 let plan = GraphPlan::builder("openCypher")
5229 .push_op(GraphOp::NodeScan {
5230 var: VarId(0),
5231 ty: None,
5232 })
5233 .push_op(GraphOp::Expand {
5234 src: VarId(0),
5235 edge: VarId(1),
5236 dst: VarId(2),
5237 rel_ty: None,
5238 dir: Direction::Out,
5239 min_hops: 1,
5240 max_hops: Some(1),
5241 })
5242 .push_op(GraphOp::Delete {
5243 vars: vec![VarId(1)],
5244 exprs: vec![],
5245 detach: false,
5246 })
5247 .build();
5248
5249 let lp = lowerer.lower_plan(&plan).unwrap();
5250 let DfLogicalPlan::Extension(ext) = &lp else {
5251 panic!("expected Extension (GraphDeleteNode), got {lp:?}");
5252 };
5253 let node = ext
5254 .node
5255 .as_any()
5256 .downcast_ref::<GraphDeleteNode>()
5257 .expect("GraphDeleteNode");
5258 assert_eq!(node.targets.len(), 1, "one delete target");
5259 assert_eq!(node.targets[0].var, 1);
5260 assert!(
5261 node.targets[0].is_edge,
5262 "the edge var must resolve to is_edge=true"
5263 );
5264 }
5265
5266 fn writes_lowerer<'a>(
5271 catalog: &'a graphforge_storage::GraphCatalog,
5272 dir: &'a std::path::Path,
5273 ) -> GraphPlanLowerer<'a> {
5274 GraphPlanLowerer::new_for_writes(
5275 Some(catalog),
5276 None,
5277 dir,
5278 graphforge_core::OntologyMode::Exploratory,
5279 )
5280 }
5281
5282 #[test]
5283 fn set_literal_value_lowers_to_extension() {
5284 use graphforge_ir::SetPropItem;
5285 let dir = tempfile::TempDir::new().unwrap();
5286 let rc = graphforge_ir::RuntimeCatalog::new();
5287 let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
5288 let lowerer = writes_lowerer(&catalog, dir.path());
5289
5290 let mut builder = GraphPlan::builder("openCypher");
5291 let value = builder.push_expr(IrExpr::Literal(IrLiteral::Int(42)));
5292 let plan = builder
5293 .push_op(GraphOp::NodeScan {
5294 var: VarId(0),
5295 ty: None,
5296 })
5297 .push_op(GraphOp::Set {
5298 items: vec![SetPropItem {
5299 target: VarId(0),
5300 prop: graphforge_core::PropId(0),
5301 prop_name: "age".into(),
5302 value,
5303 }],
5304 map_items: vec![],
5305 label_items: vec![],
5306 })
5307 .build();
5308 let lp = lowerer.lower_plan(&plan).unwrap();
5309 let DfLogicalPlan::Extension(ext) = &lp else {
5310 panic!("expected Extension (GraphSetNode), got {lp:?}");
5311 };
5312 let node = ext.node.as_any().downcast_ref::<GraphSetNode>().unwrap();
5313 assert_eq!(node.targets.len(), 1);
5314 assert_eq!(node.targets[0].prop_name, "age");
5315 assert!(!node.targets[0].is_edge, "node target");
5316 }
5317
5318 #[test]
5319 fn set_runtime_expr_value_lowers_against_input_schema() {
5320 use graphforge_ir::SetPropItem;
5324 let dir = tempfile::TempDir::new().unwrap();
5325 let rc = graphforge_ir::RuntimeCatalog::new();
5326 let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
5327 let lowerer = writes_lowerer(&catalog, dir.path());
5328
5329 let mut builder = GraphPlan::builder("openCypher");
5330 let var = builder.push_expr(IrExpr::VarRef(VarId(0)));
5332 let age = builder.push_expr(IrExpr::PropertyAccess {
5333 base: var,
5334 prop: graphforge_core::PropId(0),
5335 });
5336 let one = builder.push_expr(IrExpr::Literal(IrLiteral::Int(1)));
5337 let sum = builder.push_expr(IrExpr::BinaryOp {
5338 op: graphforge_ir::expr::BinaryOpKind::Add,
5339 left: age,
5340 right: one,
5341 });
5342 let plan = builder
5343 .push_op(GraphOp::NodeScan {
5344 var: VarId(0),
5345 ty: None,
5346 })
5347 .push_op(GraphOp::Set {
5348 items: vec![SetPropItem {
5349 target: VarId(0),
5350 prop: graphforge_core::PropId(0),
5351 prop_name: "age".into(),
5352 value: sum,
5353 }],
5354 map_items: vec![],
5355 label_items: vec![],
5356 })
5357 .build();
5358 let lp = lowerer.lower_plan(&plan).unwrap();
5360 assert!(matches!(lp, DfLogicalPlan::Extension(_)));
5361 }
5362
5363 #[test]
5364 fn remove_lowers_to_extension() {
5365 use graphforge_ir::RemovePropItem;
5366 let dir = tempfile::TempDir::new().unwrap();
5367 let rc = graphforge_ir::RuntimeCatalog::new();
5368 let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
5369 let lowerer = writes_lowerer(&catalog, dir.path());
5370
5371 let plan = GraphPlan::builder("openCypher")
5372 .push_op(GraphOp::NodeScan {
5373 var: VarId(0),
5374 ty: None,
5375 })
5376 .push_op(GraphOp::Remove {
5377 items: vec![RemovePropItem {
5378 target: VarId(0),
5379 prop: graphforge_core::PropId(0),
5380 prop_name: "age".into(),
5381 }],
5382 label_items: vec![],
5383 })
5384 .build();
5385 let lp = lowerer.lower_plan(&plan).unwrap();
5386 let DfLogicalPlan::Extension(ext) = &lp else {
5387 panic!("expected Extension (GraphRemoveNode), got {lp:?}");
5388 };
5389 let node = ext.node.as_any().downcast_ref::<GraphRemoveNode>().unwrap();
5390 assert_eq!(node.targets.len(), 1);
5391 assert_eq!(node.targets[0].prop_name, "age");
5392 }
5393
5394 #[test]
5395 fn set_without_write_target_errors() {
5396 use graphforge_ir::SetPropItem;
5397 let dir = tempfile::TempDir::new().unwrap();
5398 let rc = graphforge_ir::RuntimeCatalog::new();
5399 let catalog = graphforge_storage::GraphCatalog::open(dir.path(), None, &rc).unwrap();
5400 let lowerer = GraphPlanLowerer::new_with_dir(
5402 Some(&catalog),
5403 None,
5404 dir.path(),
5405 graphforge_core::OntologyMode::Exploratory,
5406 );
5407 let mut builder = GraphPlan::builder("openCypher");
5408 let value = builder.push_expr(IrExpr::Literal(IrLiteral::Int(1)));
5409 let plan = builder
5410 .push_op(GraphOp::NodeScan {
5411 var: VarId(0),
5412 ty: None,
5413 })
5414 .push_op(GraphOp::Set {
5415 items: vec![SetPropItem {
5416 target: VarId(0),
5417 prop: graphforge_core::PropId(0),
5418 prop_name: "age".into(),
5419 value,
5420 }],
5421 map_items: vec![],
5422 label_items: vec![],
5423 })
5424 .build();
5425 assert!(
5426 lowerer.lower_plan(&plan).is_err(),
5427 "SET without a write target should error"
5428 );
5429 }
5430
5431 fn typed_single_hop_fixture(
5438 dir: Direction,
5439 ) -> (
5440 tempfile::TempDir,
5441 graphforge_storage::GraphCatalog,
5442 GraphPlan,
5443 ) {
5444 let tmp = tempfile::TempDir::new().unwrap();
5445 let mut rc = graphforge_ir::RuntimeCatalog::new();
5446 let rel = graphforge_ir::runtime_relation_type_id(rc.intern_relation_type("KNOWS"));
5447 let catalog = graphforge_storage::GraphCatalog::open(tmp.path(), None, &rc).unwrap();
5448 let plan = GraphPlan::builder("openCypher")
5449 .push_op(GraphOp::NodeScan {
5450 var: VarId(0),
5451 ty: None,
5452 })
5453 .push_op(GraphOp::Expand {
5454 src: VarId(0),
5455 edge: VarId(1),
5456 dst: VarId(2),
5457 rel_ty: Some(rel),
5458 dir,
5459 min_hops: 1,
5460 max_hops: Some(1),
5461 })
5462 .build();
5463 (tmp, catalog, plan)
5464 }
5465
5466 #[test]
5467 fn project_backed_single_hop_emits_expand_extension_node() {
5468 use datafusion::logical_expr::UserDefinedLogicalNodeCore;
5469
5470 let (tmp, catalog, plan) = typed_single_hop_fixture(Direction::Out);
5471 let lowerer =
5472 GraphPlanLowerer::new_with_dir(Some(&catalog), None, tmp.path(), OntologyMode::Strict);
5473
5474 let lp = lowerer.lower_plan(&plan).unwrap();
5475 let DfLogicalPlan::Extension(ext) = &lp else {
5476 panic!("expected Extension (ExpandNode), got {lp:?}");
5477 };
5478 let node = ext
5479 .node
5480 .as_any()
5481 .downcast_ref::<graphforge_plan::ExpandNode>()
5482 .expect("ExpandNode");
5483 assert_eq!(node.rel_type_name, "KNOWS");
5484 assert_eq!(node.src_var, 0);
5485 assert_eq!(node.edge_var, 1);
5486 assert_eq!(node.dst_var, 2);
5487 assert_eq!(node.direction, Direction::Out);
5488 assert_eq!(node.mode, OntologyMode::Strict);
5489 assert_eq!(node.edge_prop_count, 0, "no edge_properties file on disk");
5490
5491 let schema = UserDefinedLogicalNodeCore::schema(node);
5493 let edge = datafusion::common::TableReference::bare("var_1");
5494 let dst = datafusion::common::TableReference::bare("var_2");
5495 for name in ["edge_uuid", "src_id", "dst_id", "edge_id"] {
5496 assert!(
5497 schema.field_with_qualified_name(&edge, name).is_ok(),
5498 "{name}"
5499 );
5500 }
5501 assert!(schema.field_with_qualified_name(&dst, "node_id").is_ok());
5502 }
5503
5504 #[test]
5505 fn project_backed_undirected_single_hop_emits_plain_extension() {
5506 let (tmp, catalog, plan) = typed_single_hop_fixture(Direction::Undirected);
5507 let lowerer =
5508 GraphPlanLowerer::new_with_dir(Some(&catalog), None, tmp.path(), OntologyMode::Strict);
5509
5510 let lp = lowerer.lower_plan(&plan).unwrap();
5511 let DfLogicalPlan::Extension(ext) = &lp else {
5515 panic!("expected Extension (ExpandNode), got {lp:?}");
5516 };
5517 let node = ext
5518 .node
5519 .as_any()
5520 .downcast_ref::<graphforge_plan::ExpandNode>()
5521 .expect("ExpandNode");
5522 assert_eq!(node.direction, Direction::Undirected);
5523 }
5524
5525 #[test]
5526 fn schema_only_single_hop_keeps_join_path() {
5527 let (_tmp, catalog, plan) = typed_single_hop_fixture(Direction::Out);
5528 let lowerer = GraphPlanLowerer::new(Some(&catalog), None);
5529 let lp = lowerer.lower_plan(&plan).unwrap();
5530 assert!(
5531 matches!(lp, DfLogicalPlan::Join(_)),
5532 "expected Join chain, got {lp:?}"
5533 );
5534 }
5535
5536 #[test]
5537 fn exploratory_mode_emits_expand_with_dynamic_edge_schema() {
5538 use datafusion::logical_expr::UserDefinedLogicalNodeCore;
5539
5540 let (tmp, catalog, plan) = typed_single_hop_fixture(Direction::Out);
5541 let lowerer = GraphPlanLowerer::new_with_dir(
5542 Some(&catalog),
5543 None,
5544 tmp.path(),
5545 OntologyMode::Exploratory,
5546 );
5547 let lp = lowerer.lower_plan(&plan).unwrap();
5548 let DfLogicalPlan::Extension(ext) = &lp else {
5549 panic!("expected exploratory ExpandNode, got {lp:?}");
5550 };
5551 let node = ext
5552 .node
5553 .as_any()
5554 .downcast_ref::<graphforge_plan::ExpandNode>()
5555 .expect("ExpandNode");
5556 let edge = datafusion::common::TableReference::bare("var_1");
5557 assert!(
5558 UserDefinedLogicalNodeCore::schema(node)
5559 .field_with_qualified_name(&edge, "rel_type_name")
5560 .is_ok()
5561 );
5562 }
5563
5564 #[test]
5565 fn wildcard_single_hop_emits_expand_extension() {
5566 let tmp = tempfile::TempDir::new().unwrap();
5567 let rc = graphforge_ir::RuntimeCatalog::new();
5568 let catalog = graphforge_storage::GraphCatalog::open(tmp.path(), None, &rc).unwrap();
5569 let plan = GraphPlan::builder("openCypher")
5570 .push_op(GraphOp::NodeScan {
5571 var: VarId(0),
5572 ty: None,
5573 })
5574 .push_op(GraphOp::Expand {
5575 src: VarId(0),
5576 edge: VarId(1),
5577 dst: VarId(2),
5578 rel_ty: None,
5579 dir: Direction::Out,
5580 min_hops: 1,
5581 max_hops: Some(1),
5582 })
5583 .build();
5584 let lowerer =
5585 GraphPlanLowerer::new_with_dir(Some(&catalog), None, tmp.path(), OntologyMode::Strict);
5586 let lp = lowerer.lower_plan(&plan).unwrap();
5587 let DfLogicalPlan::Extension(ext) = &lp else {
5588 panic!("expected wildcard ExpandNode, got {lp:?}");
5589 };
5590 let node = ext
5591 .node
5592 .as_any()
5593 .downcast_ref::<graphforge_plan::ExpandNode>()
5594 .expect("ExpandNode");
5595 assert_eq!(node.rel_type_name, "*");
5596 assert_eq!(node.rel_ty, None);
5597 }
5598
5599 #[test]
5600 fn unbound_source_errors_on_provider_path() {
5601 let tmp = tempfile::TempDir::new().unwrap();
5605 let mut rc = graphforge_ir::RuntimeCatalog::new();
5606 let rel = graphforge_ir::runtime_relation_type_id(rc.intern_relation_type("KNOWS"));
5607 let catalog = graphforge_storage::GraphCatalog::open(tmp.path(), None, &rc).unwrap();
5608 let plan = GraphPlan::builder("openCypher")
5610 .push_op(GraphOp::Expand {
5611 src: VarId(0),
5612 edge: VarId(1),
5613 dst: VarId(2),
5614 rel_ty: Some(rel),
5615 dir: Direction::Out,
5616 min_hops: 1,
5617 max_hops: Some(1),
5618 })
5619 .build();
5620 let lowerer =
5621 GraphPlanLowerer::new_with_dir(Some(&catalog), None, tmp.path(), OntologyMode::Strict);
5622 let err = lowerer.lower_plan(&plan).unwrap_err();
5623 assert!(
5624 err.to_string().contains("unbound") || err.to_string().contains("Unbound"),
5625 "expected an unbound-variable error, got {err:?}"
5626 );
5627 }
5628}