Skip to main content

graphforge_rel/
lowerer.rs

1//! [`GraphPlanLowerer`] — converts a [`GraphPlan`] operator pipeline into a
2//! DataFusion [`LogicalPlan`].
3//!
4//! ## Scope (M12 #575 + #576)
5//!
6//! | [`GraphOp`] | DataFusion node |
7//! |---|---|
8//! | `Filter { predicate }` | `Filter` |
9//! | `Project { items, distinct }` | `Projection` (+ optional `Distinct`) |
10//! | `Aggregate { group_by, aggs }` | `Aggregate` |
11//! | `Sort { keys }` | `Sort` |
12//! | `Limit { count }` | `Limit` (skip=0, fetch=count) |
13//! | `Skip { count }` | `Limit` (skip=count, fetch=None) |
14//! | `NodeScan { var, ty }` | `TableScan("var_N")` + optional `Filter(type_id)` |
15//! | `TypedEdgeScan { var, rel_ty }` | `TableScan("edges_NAME")` or exploratory fallback |
16//! | `EdgeScan { var, ty }` | `TableScan("edges__exploratory")` + optional filter |
17//! | `Expand { .. min_hops=1, max_hops=Some(1) }` | provider-backed `ExpandNode` (relational fallback for schema-only/bound-edge plans) |
18//! | `Expand { variable-length }` | `VarLenExpandNode` (`graphforge-plan` Extension stub) |
19//! | `Optional { child }` | `OptionalMatchNode` (`graphforge-plan` Extension stub) |
20//! | `Exists { child, .. }` | `LeftSemi` / `LeftAnti` join; correlated key union for alternatives |
21//! | `PatternComprehension { child, .. }` | correlated aggregate + left join |
22//! | `ListElementPatternComprehension { .. }` | ordinal unwind + correlated aggregate + regroup |
23//! | `Unwind { list_expr, alias }` | `UnwindNode` (`graphforge-plan` Extension stub) |
24//!
25//! Graph-native operators (#578) lower to `graphforge-plan` logical stub nodes wrapped
26//! as [`LogicalPlan::Extension`]; their physical execution is deferred to M13.
27
28use 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
63// ---------------------------------------------------------------------------
64// GraphPlanLowerer
65// ---------------------------------------------------------------------------
66
67/// Converts a [`GraphPlan`] operator pipeline into a DataFusion
68/// [`LogicalPlan`].
69///
70/// Construct via [`GraphPlanLowerer::new`] and call either
71/// [`lower_plan`](Self::lower_plan) (processes the full pipeline) or
72/// [`lower_op`](Self::lower_op) (processes a single operator given an
73/// existing input plan).
74pub struct GraphPlanLowerer<'a> {
75    /// The catalog used by scan operators. `None` when called from
76    /// `lower()` without a catalog (scan ops return an error). Also the source
77    /// of the `PropId → name` map used to resolve property accesses.
78    catalog: Option<&'a GraphCatalog>,
79    /// Reverse map: `TypeId.0` → relation type name.
80    /// Populated at construction from the ontology; empty in exploratory mode.
81    type_id_to_rel_name: HashMap<u32, String>,
82    /// Reverse map: `TypeId.0` → entity (label) name.
83    /// Populated at construction from the ontology; empty in exploratory mode.
84    type_id_to_entity_name: HashMap<u32, String>,
85    /// Write target for `CREATE`/`MERGE` lowering: the project directory and
86    /// ontology mode.  Set **only** by [`new_for_writes`](Self::new_for_writes)
87    /// — this is the gate that authorizes the write path.  `None` for read
88    /// lowering (`CREATE` then errors).
89    write_target: Option<(&'a Path, OntologyMode)>,
90    /// Read-side project directory + mode, for read operators that must touch
91    /// the on-disk store — variable-length `Expand` bakes this into its
92    /// physical node to read edges.  Set by [`new_with_dir`](Self::new_with_dir)
93    /// **and** implied by a write target.  Crucially this does **not** authorize
94    /// writes, keeping the read/write separation intact.
95    read_dir: Option<(&'a Path, OntologyMode)>,
96    /// `VarId.0 → NodeShape`, seeded once per `lower_plan` from the plan's
97    /// `NodeScan`s for bare-node-value materialization (#785). Interior
98    /// mutability so the `&self` lowering pass can populate it.
99    node_shapes: std::sync::RwLock<HashMap<u32, crate::expr::NodeShape>>,
100    /// `TypeId.0 → [(rule_id, confidence_model)]` for relations carrying
101    /// inference semantics (transitive/symmetric), built from the ontology at
102    /// construction (#605). Empty in exploratory mode — the TCK-safety gate: a
103    /// var-len expand only wraps in `OntologyInferNode` when this lookup is
104    /// non-empty, so the no-ontology TCK plan is byte-identical.
105    inference_rules: HashMap<u32, Vec<(String, String)>>,
106    /// Test-only escape hatch that retains the relational fixed-hop lowering
107    /// as an independent semantic oracle. Production constructors leave this
108    /// disabled, so provider-backed expansion remains the sole default.
109    relational_fixed_hop_reference: bool,
110}
111
112impl<'a> GraphPlanLowerer<'a> {
113    /// Create a new lowerer for read/query plans.
114    ///
115    /// - `catalog`: the [`GraphCatalog`] used by scan operators, or `None`
116    ///   when no catalog is available (scan ops will return an error).
117    /// - `ontology`: the compiled ontology, or `None` in exploratory mode.
118    ///
119    /// Lowering a `CREATE` plan with this constructor errors — use
120    /// [`new_for_writes`](Self::new_for_writes) instead.
121    #[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    /// Create a lowerer that can lower `CREATE` plans, given the project
127    /// directory and ontology mode the write should target.
128    ///
129    /// This is the **only** constructor that authorizes the write path.  The
130    /// same directory is also exposed read-side, so read operators that need it
131    /// (variable-length `Expand`) work in mixed read/write pipelines.
132    #[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    /// Create a read/query lowerer that also knows the project directory.
143    ///
144    /// Required for queries containing variable-length `Expand`, whose physical
145    /// node reads edges directly from `dir`.  Equivalent to [`new`](Self::new)
146    /// for all other read operators.  Unlike [`new_for_writes`], this does
147    /// **not** authorize the write path — `CREATE` still errors.
148    #[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        // Relation-name map: ontology IDs and tagged runtime-catalog IDs occupy
165        // disjoint plan key spaces. This is essential in advisory mode, where
166        // both source ID spaces begin at zero and an unknown relation must not
167        // resolve through a colliding ontology ID.
168        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            // Ontology-only: this map drives property-table routing
180            // (`node_prop_cols` / `join_node_properties`) and write specs, where
181            // an exploratory node's properties live in `_untyped` (not a
182            // per-label table). The runtime-catalog labels are merged in
183            // separately for node-value rendering only — see `expr_lowerer`.
184            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    /// Select the legacy relational fixed-hop lowering as a differential-test
194    /// oracle. This is deliberately doc-hidden and never enabled by production
195    /// API paths.
196    #[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    /// The project directory available to read operators (scans bind their real
204    /// Parquet-backed providers from it). `None` for pure logical/explain
205    /// lowering, where scans use a schema-only source.
206    fn read_dir(&self) -> Option<&'a Path> {
207        self.read_dir.map(|(d, _)| d)
208    }
209
210    /// The ontology mode for read operators. Defaults to `Exploratory` for pure
211    /// logical/explain lowering (`read_dir` is `None`), where scans use a
212    /// schema-only source and the mode is never consulted.
213    fn read_mode(&self) -> OntologyMode {
214        self.read_dir.map_or(OntologyMode::Exploratory, |(_, m)| m)
215    }
216
217    /// The `PropId.0 → name` map for resolving `PropertyAccess`.
218    ///
219    /// Sourced from the catalog (built from the runtime catalog at `open` time).
220    /// With no catalog (pure logical/explain lowering) the map is empty, so
221    /// property accesses fall back to `"prop_<id>"` — those paths render plans,
222    /// not data.
223    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    /// Build an `ExprLowerer` for the current plan, seeded with the resolved
231    /// `PropId → name` map.
232    fn expr_lowerer<'b>(&self, arena: &'b ExprArena, var_map: &'b VarMap) -> ExprLowerer<'b> {
233        // Label-name map for node-value rendering (#889): the ontology entity
234        // names plus the runtime catalog's labels (exploratory). Built here,
235        // separate from `type_id_to_entity_name` (which must stay ontology-only
236        // for property-table routing), so an unlabelled `MATCH (n) RETURN n` can
237        // resolve a node's stored `type_id` to its label name.
238        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            // Authoritative property lists only when a backing dataset is present:
254            // `node_prop_cols` reads each node's columns from the property table
255            // under `read_dir`. With no dir (schema-only/explain lowering) an empty
256            // `prop_names` means "unknown", not "absent", so the missing-property→
257            // null rewrite (#598) must NOT fire — gate it on having the dataset.
258            self.read_dir().is_some(),
259        );
260        // With a dataset attached, `nodes(p)` hydrates its elements (#1024).
261        if let Some(dir) = self.read_dir() {
262            lowerer = lowerer.with_read_target(dir.to_path_buf());
263        }
264        lowerer
265    }
266
267    /// Build the `VarId.0 → NodeShape` map for a plan from its `NodeScan`s (#785):
268    /// each node var's resolved label + the property columns its scan joins in.
269    /// Read-side only; empty in schema-only lowering. Optional child pipelines bind
270    /// variables that later projections can read, so include their scans too.
271    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    /// The property-file stem for a node of type `ty` — the table
305    /// `join_node_properties` reads `var_N.<prop>` from, and `node_prop_cols`
306    /// reads the column names from.
307    ///
308    /// - labelled: the ontology entity name, or `_untyped` when the label is only
309    ///   known to the runtime catalog (exploratory mode);
310    /// - unlabelled in **exploratory** mode: `_untyped`, where all exploratory
311    ///   properties are written — so `MATCH (n) RETURN n` carries its props (#889);
312    /// - unlabelled in an **ontology** mode: `None` — properties are spread across
313    ///   per-entity tables with no single stem (a multi-table union; out of scope).
314    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    /// The persisted property column names a node of type `ty` carries — the
330    /// columns `join_node_properties` materializes as `var_N.<name>`. Empty in
331    /// schema-only lowering or when no single property table applies (see
332    /// [`prop_table_stem`](Self::prop_table_stem)).
333    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    /// LEFT-join the property table for a labelled node `var` onto `scan`, so a
351    /// later `PropertyAccess` (`var_N.<prop>`) resolves to a real column (#704).
352    ///
353    /// The property values live in `properties/<Entity>.parquet` (strict /
354    /// advisory) or `properties/_untyped.parquet` (exploratory), keyed by
355    /// `node_uuid`. We join on `var_N.node_uuid = <props>.node_uuid`, pass **every
356    /// column already in `scan` through unchanged**, then append each property
357    /// column (except the duplicate `node_uuid` key) **re-qualified** under
358    /// `var_N` so it resolves as `var_N.<prop>`.
359    ///
360    /// Preserving all of `scan`'s existing columns (rather than just the joined
361    /// node's topology columns) is what lets this run on **both** a fresh
362    /// single-var node scan *and* an already-joined multi-var plan — e.g. the
363    /// destination of a fixed single-hop `Expand`, whose plan carries the source
364    /// and edge columns too (#789). On a fresh single-var scan the preserved set
365    /// is exactly that node's topology columns, so the result is unchanged.
366    ///
367    /// No-ops (returns `scan` unchanged) when:
368    /// - there is no project directory (schema-only explain/golden lowering),
369    /// - no single property table applies — an unlabelled node in an ontology
370    ///   mode, whose properties are spread across per-entity tables (see
371    ///   [`prop_table_stem`](Self::prop_table_stem); an unlabelled node in
372    ///   *exploratory* mode resolves to `_untyped` and DOES join), or
373    /// - the property table has no columns beyond `node_uuid`.
374    ///
375    /// A LEFT join preserves nodes that have no property row written yet.
376    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); // schema-only lowering: no real provider to join
387        };
388        let Some(stem) = self.prop_table_stem(ty) else {
389            return Ok(scan); // no single property table applies (see prop_table_stem)
390        };
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        // Property columns already present under this var's qualifier. A var whose
397        // properties were joined upstream and then re-matched — e.g. forwarded
398        // through `WITH a` and matched again in `MATCH (a)-[…]->(b)` — must NOT
399        // have them joined a second time: that re-qualifies a second `var_N.<prop>`
400        // and a later `RETURN *` projects two columns of the same name, which
401        // DataFusion rejects ("Projections require unique expression names").
402        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        // The non-key property columns to ADD. Exclude every node-topology column
410        // name (not just the `node_uuid` join key): a property that happens to
411        // share a topology column name — `node_id`, `type_id`, `created_at`,
412        // `updated_at` — would re-qualify to a second `var_N.<name>` field and
413        // build a duplicate-qualified schema DataFusion rejects. Dropping the
414        // collision keeps the topology column authoritative. Also exclude any
415        // property already present under this var (idempotent re-join, above). If
416        // nothing remains, the join would add nothing — skip it.
417        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        // Snapshot the input's existing qualified columns BEFORE the join, so the
435        // projection preserves exactly what `scan` carried (one var for a fresh
436        // node scan; src + edge + dst for an already-joined Expand plan) — and
437        // never accidentally re-projects the joined-in `<prop>` columns by their
438        // bare names.
439        let input_cols: Vec<Column> = scan
440            .schema()
441            .iter()
442            .map(|(qualifier, field)| Column::new(qualifier.cloned(), field.name()))
443            .collect();
444
445        // LEFT join: node ⟕ props ON node.node_uuid = props.node_uuid.
446        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        // Project: every pre-join input column through unchanged, then each
454        // property column re-qualified under var_N so `var_N.<prop>` resolves.
455        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    /// Lower a complete [`GraphPlan`] to a DataFusion [`LogicalPlan`].
470    ///
471    /// Processes the operator pipeline in order.  Scan operators
472    /// (`NodeScan`, `TypedEdgeScan`, `Expand`) are not yet implemented (#576)
473    /// and return [`GfError::NotImplemented`].
474    ///
475    /// The starting base for the pipeline is an `EmptyRelation` (zero rows,
476    /// no columns).  #576 will replace this with a real table scan.
477    ///
478    /// # Errors
479    ///
480    /// Returns [`GfError`] if any operator in the pipeline cannot be lowered.
481    pub fn lower_plan(&self, plan: &GraphPlan) -> Result<LogicalPlan, GfError> {
482        // Seed node shapes for bare-node-value materialization (#785) from the
483        // plan's NodeScans before lowering its expressions.
484        *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    // -----------------------------------------------------------------------
492    // Unified write statement driver support (#792, #817)
493    //
494    // The driver in graphforge-exec runs one read prefix and then applies the write
495    // clauses itself (phase loop), so it needs the lowering building blocks
496    // individually: the prefix plan WITH its variable registrations, a CREATE
497    // pattern's resolved specs, and per-expression value lowering against a
498    // driver-extended VarMap.
499    // -----------------------------------------------------------------------
500
501    /// Lower a statement's read prefix (`ops`), exposing the [`VarMap`] the
502    /// write phases resolve identity columns and value expressions against.
503    ///
504    /// Identical to [`lower_plan`](Self::lower_plan) except the caller owns
505    /// the map. An empty `ops` lowers to the implicit one-row unit relation
506    /// (the standalone-`CREATE` prefix).
507    ///
508    /// # Errors
509    /// Returns [`GfError::Plan`] if any prefix operator cannot be lowered.
510    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    /// Lower a terminal read suffix over an already-materialized input schema.
522    /// The returned plan has a synthetic empty leaf that the executor replaces
523    /// with the statement driver's final frontier.
524    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    /// Lower a statement-local relational segment with buffered node topology
540    /// visible alongside persisted nodes.
541    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    /// Resolve a `CREATE` pattern to its executable node/edge specs (label and
558    /// relation names resolved, property maps evaluated to literals).
559    ///
560    /// The same resolution `lower_create` bakes into a [`GraphCreateNode`];
561    /// exposed so the statement driver can run the create phase without a
562    /// logical plan node.
563    ///
564    /// # Errors
565    /// Returns [`GfError::Plan`] when a property map cannot be evaluated.
566    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    /// Register freshly-created node shapes in the write driver so a
578    /// terminal `RETURN n` can materialize a same-statement created node value.
579    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    /// Extend a bound node's same-statement value shape after a dynamic write.
593    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    /// Lower one IR value expression against `var_map` (the driver's frontier
605    /// registrations, including variables created earlier in the statement).
606    ///
607    /// # Errors
608    /// Returns [`GfError::Plan`] when the expression cannot be lowered.
609    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    /// Lower one IR value expression against `var_map` and an existing input
621    /// schema. The write driver uses this for a terminal read suffix over
622    /// its materialized frontier, where temporal/map accessors need the
623    /// frontier's Arrow types (#814).
624    ///
625    /// # Errors
626    /// Returns [`GfError::Plan`] when the expression cannot be lowered.
627    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    /// The `TypeId.0 → entity name` map (from the ontology), for per-row
641    /// property-file stem resolution in the statement driver.
642    #[must_use]
643    pub fn entity_name_map(&self) -> HashMap<u32, String> {
644        self.type_id_to_entity_name.clone()
645    }
646
647    /// Lower an operator pipeline starting from an `EmptyRelation` base.
648    ///
649    /// Shared by [`lower_plan`](Self::lower_plan) and the `Optional` arm of
650    /// [`lower_op_with_arena`](Self::lower_op_with_arena) (which lowers a
651    /// nested child pipeline). Operators are folded in binder-emitted order;
652    /// projection shaping depends on whether ORDER BY was deliberately placed
653    /// before or after the projection boundary.
654    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        // Base relation. Only the FIRST op consumes this base: a scan/source op
663        // (NodeScan/EdgeScan/Expand) builds its own plan and ignores it, so a
664        // zero-row base is fine there. Any other leading op FOLDS on the base —
665        // `RETURN 1`, `UNWIND [..]`, a leading `WITH 1 AS x` (then `MATCH`), or a
666        // bare `CREATE (n)` — and must see the single implicit "unit" row, or it
667        // projects/creates over zero rows. Keying off `ops.first()` (not "any
668        // source op present") is what makes `WITH … MATCH …` cross-join instead
669        // of collapsing to empty (#920 temporal arithmetic; corpus-wide).
670        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            // A `CREATE` with any clause after it feeds a read (RETURN/WITH/…),
677            // so it must emit the created-entity rows rather than the one-row
678            // summary (#814 write-result RETURN).
679            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    /// Lower an operator pipeline over an existing correlated input.
694    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    /// Lower a single [`GraphOp`] given an `input` plan (convenience wrapper).
719    ///
720    /// Scan operators (`NodeScan`, `TypedEdgeScan`, `EdgeScan`) ignore `input`
721    /// and produce a fresh plan.  All other operators fold on top of `input`.
722    ///
723    /// # Errors
724    ///
725    /// Returns [`LoweringError`] if the operator cannot be lowered.
726    /// Lower a single relational [`GraphOp`] given an `input` plan.
727    ///
728    /// This method handles non-scan operators only.  Scan operators
729    /// (`NodeScan`, `TypedEdgeScan`, `EdgeScan`, `Expand`) are handled by
730    /// [`lower_op_with_arena`](Self::lower_op_with_arena) which manages `VarMap`
731    /// mutation separately.
732    ///
733    /// # Errors
734    ///
735    /// Returns [`LoweringError`] if the operator cannot be lowered.
736    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    /// Internal helper: creates a fresh [`ExprLowerer`] per op so that
748    /// `var_map` can be borrowed mutably for scan operators in the same loop.
749    // A flat dispatch over every `GraphOp` kind — long by nature, like the
750    // binder's `lower_expr`.
751    #[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        // Scan ops don't use the expression lowerer — handle them first.
763        match op {
764            GraphOp::NodeScan { var, ty } => {
765                // A NodeScan for a variable already bound upstream (e.g. the
766                // destination of a preceding Expand/var-length Expand) must not
767                // re-scan — that would discard the current plan (Extension stubs
768                // included). But the destination's label, if any, lives only on
769                // this trailing scan (the binder emits `Expand` then
770                // `NodeScan{dst, ty}`), so apply it as a filter on the already-
771                // bound `var_<dst>.type_id` rather than dropping it (#718).
772                //
773                // The label also selects the property table: join it here so a
774                // downstream `RETURN/WHERE/ORDER BY` (or inline `{prop:val}`
775                // filter) on the destination's properties resolves — the fresh
776                // scan below does this via `join_node_properties`, but a bound
777                // var never hit it before, so `var_<dst>.<prop>` was unresolved
778                // and the query failed to plan (#789). `join_node_properties`
779                // preserves the existing multi-var (src + edge + dst) columns.
780                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                        // An already-bound but UNLABELLED node (e.g. the dst of
801                        // `(n)-[r]->(x)`) still needs its properties joined so a
802                        // later `x.prop` / `WHERE x.p = …` resolves. In
803                        // exploratory mode `join_node_properties` routes to
804                        // `_untyped` (#889); a no-op otherwise.
805                        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                // Multi-pattern MATCH: comma-separated patterns (`MATCH (a), (b)`)
811                // lower to consecutive *fresh* NodeScans. The first replaces the
812                // column-less base; a later disconnected scan is a CROSS PRODUCT
813                // with the rows built so far, not a replacement — without this the
814                // earlier pattern's columns were dropped (`No field named var_0`).
815                // A connected pattern's trailing node binds to the Expand's dst
816                // (the bound-var path above), so a fresh scan over a non-empty
817                // input is always a genuinely disconnected component.
818                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            // UNWIND — emit the graphforge-plan stub Extension node (physical execution
907            // deferred to M13).  Needs the expression lowerer for `list_expr`.
908            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                // The output adds one column of the list's element type; resolve
914                // it from df_expr's type against the input schema, defaulting to
915                // a nullable Int64 when it can't be determined pre-execution.
916                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                // Register the unwound variable so downstream ops can refer to it.
921                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            // OPTIONAL MATCH — lower the nested child pipeline and wrap both the
937            // outer input and the optional sub-plan in the OptionalMatch node,
938            // computing the join keys (the variables shared between the outer
939            // scope and the optional sub-plan) so the physical node can left-join
940            // and null-shape correctly.
941            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                // The create node is input-driven: it runs one write per input
988                // row (#703). Fold the accumulated pipeline (`current`) in as
989                // that input — so a preceding `MATCH` drives the CREATE per
990                // matched row, referencing MATCH-bound vars' identities. For a
991                // standalone CREATE, `current` is the implicit single unit-row
992                // base (no source op), so the create runs exactly once.
993                return self.lower_create(pattern, exprs, input, var_map, create_feeds_read);
994            }
995            GraphOp::Delete { vars, detach, .. } => {
996                // Input-driven like CREATE: the preceding MATCH supplies the
997                // matched entities' identities, one delete per matched row.
998                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            // Aggregate is handled here (not only in `lower_relational_op`) because
1051            // a DECOMPOSED aggregate (#599 nested aggregates) is followed by a
1052            // `Project` that references the aggregate's outputs via synthetic
1053            // variables — so the aggregate must reset the scope to those outputs.
1054            // A plain top-level aggregate has no synthetic vars and leaves the
1055            // scope untouched (so `RETURN count(*) AS c ORDER BY c` still resolves
1056            // `c` against the unchanged map, exactly as before).
1057            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                    // The aggregate's output columns are its group-key aliases and
1080                    // its agg aliases; bind each synthetic var to its column.
1081                    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        // Relational ops need the expression lowerer; var_map is immutable here.
1114        // They do not register new variables so &mut is not needed. The input
1115        // schema is attached so a `PropertyAccess` can resolve a temporal-
1116        // component accessor by the base column's type (#920).
1117        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        // A full existential subquery's terminal RETURN controls syntax and
1279        // inner evaluation only; its projected values are not exposed. Keep the
1280        // pre-projection relation so correlated outer keys remain available for
1281        // the semi-join.
1282        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    /// Lower a [`GraphOp::With`] (#814): a mid-pipeline projection that also
1683    /// *introduces a new scope*. Each item is projected to its alias column; the
1684    /// item's `out_var` is then registered in the [`VarMap`] so a later clause
1685    /// referencing the alias resolves. An optional `WHERE` filters over both the
1686    /// incoming scope and the projected aliases (#1028), then the operator drops
1687    /// back to exactly the projected aliases for downstream clauses.
1688    #[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        // 1. Project the items against the CURRENT scope (their expressions
1700        //    reference upstream vars). A scalar item projects its expression
1701        //    aliased to its name; a whole-node item (`WITH n`) forwards ALL of
1702        //    the node's columns (a node var spans `var_<v>.node_uuid` + props)
1703        //    so a downstream `RETURN n` / `n.x` still resolves. `new_scope`
1704        //    collects each out_var's resulting column (or column prefix).
1705        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        // A forwarded entity keeps qualified property columns such as
1713        // `var_0.name`. Give a scalar alias named `name` an internal physical
1714        // column so DataFusion never has to resolve an ambiguous unqualified
1715        // `name`; the terminal projection still restores the public alias.
1716        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            // Attach the input schema so a `PropertyAccess` on a map/temporal-typed
1759            // column resolves by the base column's Arrow type (`input.list` on an
1760            // UNWIND-bound map → `get_field`, #1017; `d.year` → temporal accessor,
1761            // #920) and `is_list_typed` can type a computed `+` operand for list
1762            // append. Relational ops already do this (see `lower_relational_op`).
1763            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                // Whole-entity forwarding: a bare VarRef to a node or relationship
1773                // var. Forward every input column under that var's qualifier,
1774                // unchanged, so downstream projections can still materialize the
1775                // entity value.
1776                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        // 2. Apply WITH's WHERE over the incoming columns plus projected aliases.
1906        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        // 3. When WHERE needed incoming columns, project back down to the true
1926        //    WITH output so pre-WITH variables do not leak downstream.
1927        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        // 4. Install the new scope: WITH resets it to exactly its aliases, so
2004        //    drop every pre-WITH variable (mirroring the binder) before mapping
2005        //    each out_var to its projected column / prefix.
2006        var_map.clear();
2007        for (v, col) in new_scope {
2008            var_map.insert(v, col);
2009        }
2010        Ok(output)
2011    }
2012
2013    /// Lower a [`GraphOp::Create`] into a self-contained [`GraphCreateNode`]
2014    /// wrapped as a DataFusion [`Extension`].
2015    ///
2016    /// Resolves each spec's label/relation-type name (from the ontology maps)
2017    /// and evaluates its property map here: constant values bake to literals,
2018    /// row-dependent values lower to DataFusion `Expr`s over the input columns
2019    /// (evaluated per row by the execution layer) — so the executor needs no
2020    /// access to the IR arena or ontology.  The write target (directory + mode)
2021    /// must have been supplied via [`new_for_writes`](Self::new_for_writes).
2022    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        // Write-result RETURN (#814): when a read clause follows CREATE, build
2039        // an output relation so trailing RETURN/WITH can read each created node's
2040        // `var_<n>`-qualified columns. MATCH/WITH-bound reference nodes and
2041        // edges are supported here: references arrive via input passthrough,
2042        // edges are minted but project no columns (so `RETURN r` remains a loud
2043        // unresolved-var error rather than a false pass).
2044        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                // Reference vars are already registered by the preceding
2049                // MATCH/WITH. Re-registering would clobber their node shape and
2050                // make `RETURN a` / `a.prop` lose data.
2051                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    /// Build the created-entity output schema for emit-rows mode (#814): the
2077    /// input columns (passed through), then per freshly-minted node spec its
2078    /// `var_<n>`-qualified `node_uuid`/`node_id`/`type_id` identity columns and
2079    /// one column per property (a literal's type via its `ScalarValue`, a
2080    /// computed value's via the expr's logical type).
2081    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            // Reference vars are not minted; their `var_<n>` columns already
2098            // arrive via the input passthrough above. Re-adding them would build
2099            // a duplicate-qualified schema that DataFusion rejects.
2100            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    /// Resolve a `CREATE` pattern's node and edge specs: label / relation
2147    /// names from the catalog maps, property maps evaluated to literals.
2148    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    /// Lower a [`GraphOp::Delete`] into a self-contained [`GraphDeleteNode`]
2200    /// wrapped as a DataFusion [`Extension`] (#740).
2201    ///
2202    /// Each target var's node-vs-edge kind is resolved from `input`'s schema:
2203    /// a node var carries a `var_<n>.node_uuid` column, an edge var a
2204    /// `var_<n>.edge_uuid` column (the qualified topology columns the scans
2205    /// produce). A var whose identity column is absent — i.e. it was not bound
2206    /// by a preceding scan — is a lowering error. The write target (directory +
2207    /// mode) must have been supplied via [`new_for_writes`](Self::new_for_writes).
2208    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    /// Resolve a write-target variable's node-vs-edge kind from `schema`.
2258    ///
2259    /// A node var carries a `var_<n>.node_uuid` column; an edge var a
2260    /// `var_<n>.edge_uuid` column. An edge var must also carry the
2261    /// `var_<n>.rel_type_name` column — the per-row file stem for edge property
2262    /// writes (present in Exploratory mode; a typed-only Strict-mode edge has no
2263    /// such column and is rejected here, a documented #791 follow-up). Returns
2264    /// `is_edge` or a lowering error.
2265    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    /// Lower a [`GraphOp::Set`] into a [`GraphSetNode`] Extension (#791).
2304    ///
2305    /// Each item's value expression is lowered to a DataFusion `Expr` against
2306    /// the input schema (so `var_<n>.prop` and cross-var columns resolve) and
2307    /// evaluated per matched row by the execution layer — values are **not**
2308    /// coerced to literals here. Node/edge kind is resolved from the input
2309    /// schema; an untyped edge target is rejected.
2310    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        // Lower each value expr against the matched-row schema so `var_<n>.prop`
2324        // and cross-var columns resolve; evaluated per row in the exec layer.
2325        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    /// Lower a [`GraphOp::Remove`] into a [`GraphRemoveNode`] Extension (#791) —
2354    /// the value-less dual of [`lower_set_op`](Self::lower_set_op).
2355    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
2392// ---------------------------------------------------------------------------
2393// Per-operator lowering functions
2394// ---------------------------------------------------------------------------
2395
2396/// Dispatch for relational operators that do not mutate the `VarMap`.
2397fn 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    // Each group key may carry an output-column alias (its RETURN source text, so
2534    // a mixed `RETURN n.name, count(*)` produces the `n.name` header — #599).
2535    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            // DataFusion drops a literal-null group key and turns an empty
2582            // grouped aggregate into a one-row global aggregate. This
2583            // row-dependent tautology keeps the grouping set non-empty while
2584            // remaining one value for both present and null identities.
2585            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            // Resolve the argument's type against the input schema so min/max can
2615            // detect a heterogeneous (tagged) column (ADR 0011).
2616            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    // `min`/`max` over a heterogeneous (tagged) list column (ADR 0011) need Cypher
2709    // orderability — native min/max would order by the struct's first field.
2710    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
2803// ---------------------------------------------------------------------------
2804// TypeId → relation name reverse map
2805// ---------------------------------------------------------------------------
2806
2807/// Build a `TypeId.0 → relation_name` map from the ontology at construction
2808/// time so that scan lowering can resolve `TypeId`s without repeated iteration.
2809fn 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
2821/// Build the `TypeId.0 → [(rule_id, confidence_model)]` inference-rule map from
2822/// the ontology (#605): a relation flagged `transitive`/`symmetric` gets a rule
2823/// (`transitive:NAME` / `symmetric:NAME`) with the `conservative_min` model.
2824/// Empty when no ontology is loaded (exploratory) — the TCK-safety gate.
2825fn 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
2847/// Build a `TypeId.0 → entity (label) name` map from the ontology, mirroring
2848/// [`build_type_id_map`] for relation types.  Empty in exploratory mode.
2849fn 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
2861/// Constant-fold a lowered value expression to a scalar: a literal is taken
2862/// directly; anything else is evaluated against a single empty row (so a pure
2863/// constant like `1 + 2` folds, while a column/variable reference fails to
2864/// resolve against the empty schema and yields `None` — kept deferred). (#814)
2865fn 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
2887/// Evaluate an optional `CREATE` property map, splitting each value into either
2888/// a **constant** literal or a **row-dependent** DataFusion `Expr`.
2889///
2890/// The expression must be an [`IrExpr::MapLiteral`]. A plain [`IrExpr::Literal`]
2891/// value is taken directly (the fast path). A computed value (e.g. `date({...})`,
2892/// `1 + 2`) is **constant-folded**: lowered and, if it reduces to a literal
2893/// scalar, converted back to an [`IrLiteral`] (#814 Slice 1a). A *row-dependent*
2894/// value (a variable reference like `{n: x}` from a driving `UNWIND`/`MATCH`)
2895/// does not fold; it is lowered against `var_map` to an `Expr` over the input
2896/// columns and returned in the second vec, for per-row evaluation by the
2897/// execution layer (#814 Slice 1b).
2898///
2899/// Returns `(constant literals, row-dependent exprs)`.
2900type 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        // Lower against the matched-row scope so a column/variable reference
2927        // resolves; a genuinely constant expression is column-free and still
2928        // constant-folds (slice 1a), a row-dependent one is deferred to exec.
2929        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            // Constant-foldable AND storable → bake as a literal (slice 1a). A
2936            // constant the storage layer can't represent yet keeps its error.
2937            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            // Row-dependent: evaluated per minted row by the execution layer.
2943            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
2966// ---------------------------------------------------------------------------
2967// Scan operator lowering (#576)
2968// ---------------------------------------------------------------------------
2969
2970/// Produce a plan alias string for a `VarId`.
2971fn var_alias(var: VarId) -> String {
2972    format!("var_{}", var.0)
2973}
2974
2975/// Whether `op` produces rows from the store (a scan or expand) and therefore
2976/// supplies its own base relation, as opposed to transforming an existing one.
2977fn 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
2987/// Wrap a schema in a [`LogicalTableSource`] suitable for
2988/// [`LogicalPlanBuilder::scan`].
2989fn table_source(schema: datafusion::arrow::datatypes::SchemaRef) -> Arc<LogicalTableSource> {
2990    Arc::new(LogicalTableSource::new(schema))
2991}
2992
2993/// The data source for a node scan.
2994///
2995/// With a project `dir`, build a real Parquet-backed [`TopologyNodeTable`]
2996/// provider (wrapped via `provider_as_source`) so the scan reads actual rows at
2997/// execution time. Without a `dir` (pure logical/explain lowering, e.g. golden
2998/// tests), fall back to the schema-only [`LogicalTableSource`].
2999fn 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
3010/// The data source for an edge scan over `stem` (a relation name for the typed
3011/// table, or `"_exploratory"`). Real provider when `dir` is set; otherwise the
3012/// schema-only source (`schema` chooses typed vs exploratory shape).
3013///
3014/// In a typed project (Strict/Advisory) there is no `_exploratory.parquet`, so
3015/// an untyped edge scan over the `"_exploratory"` stem reads the **union** of
3016/// every per-relation file via [`graphforge_storage::UnionEdgeTable`] (#823) — the same
3017/// `EXPLORATORY_EDGE_SCHEMA`-shaped, `rel_type_name`-tagged rows the shared
3018/// exploratory file would have carried, so the untyped single-hop join path is
3019/// unchanged otherwise. Exploratory mode keeps reading its shared file.
3020fn 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
3039/// Filter an already-bound node variable by its label type.
3040///
3041/// Used when a destination node's `NodeScan` is a no-op (the var was bound by a
3042/// preceding `Expand`) but still carries a label: the label predicate is applied
3043/// against the already-present `<alias>.type_id` column.
3044fn 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    // Require a known relation name — silently falling back to _exploratory
3154    // would change query semantics (wrong table / over-scan).
3155    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    // Check if the catalog has a typed edge table for this relation.
3164    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        // Use alias as the scan qualifier so var_map column refs resolve correctly.
3172        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        // Use alias as the scan qualifier so downstream join predicates
3179        // (var_map.get(edge) → "var_N") can resolve edge columns correctly.
3180        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
3216/// Resolve the element column **type** for an `UNWIND <list_expr>` from the
3217/// lowered expression's type.
3218///
3219/// If `list_expr` types to a `List`/`LargeList`/`FixedSizeList`, the element
3220/// field's data type is used. Otherwise (e.g. a `$param` whose type is unknown
3221/// pre-execution) it defaults to a nullable `Int64` — the physical node uses
3222/// the actual `ListArray`'s element type at run time regardless. The field name
3223/// is a placeholder; `UnwindNode::new` renames it to the alias so a bare
3224/// `RETURN x` resolves.
3225fn 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            // Parameters are untyped until DataFusion substitutes their values.
3240            // Give an unknown UNWIND element a map-shaped schema containing the
3241            // query's interned property names so downstream `item.key` expressions
3242            // lower to `get_field(item, key)`. UnwindNode retypes this field from
3243            // the bound parameter before physical planning.
3244            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
3264/// Compute an `OPTIONAL MATCH`'s join keys and the inner output field list.
3265///
3266/// The join keys are the variables bound in **both** the outer scope and the
3267/// optional sub-plan (the implicit shared variables of the pattern, e.g. `a` in
3268/// `MATCH (a) OPTIONAL MATCH (a)-[:R]->(b)`). For each such variable, the key is
3269/// the pair of `node_id` column indices `(outer_idx, inner_idx)` resolved
3270/// against the respective qualified schemas.
3271///
3272/// The inner output fields are the child plan's columns with **every** column of
3273/// a shared variable removed — not just its `node_id` join key. Once fixed
3274/// single-hop hops lower to a real join (#718), the inner plan carries all of a
3275/// shared variable's topology columns (`node_uuid`, `type_id`, …), and those
3276/// fully duplicate the outer side; appending them would build a schema with
3277/// duplicate `var_<shared>` qualified fields. The remaining (non-shared) inner
3278/// columns get appended nullable to form the node's output.
3279/// Register the optional-side variables the child introduced (those NOT
3280/// already in the outer scope) so a downstream `RETURN m.x` resolves.
3281///
3282/// Each keeps the column name the child registered — usually the bare
3283/// `var_<v>` alias, but a var-length edge var registers fully qualified
3284/// `var_<v>.rels` (#709), and the OptionalMatch node appends the child's
3285/// columns under their child qualifiers either way (see `inner_keep_idx`).
3286/// Shared vars keep their existing outer alias.
3287fn 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    // Qualifiers of the variables shared between the outer scope and the
3394    // optional sub-plan; their columns are sourced entirely from the outer side.
3395    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    // Keep the inner columns whose qualifier is NOT a shared variable (those are
3440    // carried by the outer side); the node makes the remainder nullable. A field
3441    // with no qualifier (e.g. a computed column) is kept.
3442    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/// Lower a variable-length `Expand` (`min_hops != 1 || max_hops != Some(1)`)
3474/// into the `VarLenExpandNode` Extension whose physical execution (M13) runs an
3475/// iterative BFS over the edge table.
3476#[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    // Ontology inference (#605): if this relation carries semantic rules
3513    // (transitive/symmetric), wrap the var-len traversal in OntologyInferNode(s)
3514    // so the closure is auditable. Empty in exploratory mode (no ontology) → the
3515    // TCK-safety gate. Captured here before `rel_name` is moved into the node.
3516    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    // The physical node reads edges directly from the project directory, so the
3522    // dir/mode must be threaded through at lowering time (the ExtensionPlanner
3523    // only sees DataFusion session state).
3524    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    // Discover the relation's persisted edge-property columns (#755) so the
3532    // edge-list struct carries them and `r[i].<prop>` resolves. Mirrors the
3533    // fixed-hop `join_edge_properties`: read the dynamic on-disk schema and drop
3534    // the `edge_uuid` key + any name colliding with the struct's four topology
3535    // fields (`edge_uuid`/`src_uuid`/`dst_uuid`/`rel_type`). A wildcard (`*`)
3536    // has no single property file, so it unions EVERY relation's fields (#1023)
3537    // — sorted stem order for determinism, first occurrence of a name wins,
3538    // forced nullable since an edge from a relation without the column is NULL
3539    // (the exec coalesces each edge's values from its own relation's file).
3540    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    // The output extends the input with the destination node's columns,
3570    // qualified `var_<dst>` (mirrors a `NodeScan(dst)`), then a trailing
3571    // edge-list column qualified `var_<edge>` (#709).  Pass the field lists in
3572    // so graphforge-plan need not depend on graphforge-storage; the edge-list field type is the
3573    // shared single source of truth in graphforge-plan.
3574    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    // Register the destination var so the binder's trailing NodeScan(dst)
3591    // recognises it as already bound (and stays a no-op, preserving this
3592    // Extension node as the plan root).
3593    if bound_dst.is_none() {
3594        var_map.insert(dst, var_alias(dst));
3595    }
3596    // Register the edge var bound to the single `List<Struct>` relationship-list
3597    // column.  Unlike a node var (which stores the bare `var_<n>` qualifier and
3598    // resolves only via `n.<field>`), the edge var maps to one concrete column,
3599    // so it is registered fully-qualified: `RETURN r` lowers to
3600    // `col("var_<edge>.rels")` and `length(r)` to `array_length` over it.
3601    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    // Wrap in one OntologyInferNode per applicable rule (#605); the execution
3654    // session records a kind="inference" provenance event per rule. Pass-through
3655    // physically — VarLenExpand already computes the closure.
3656    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    // Variable-length expand cannot be expressed in relational algebra; emit
3690    // the graphforge-plan Extension node whose physical execution (M13) performs an
3691    // iterative BFS over the edge table.
3692    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    // The source must be bound BEFORE either single-hop strategy: the
3710    // adjacency path would otherwise reach execution with no
3711    // `var_<src>.node_id` and silently seed from column 0.
3712    let src_alias = var_map
3713        .get(src)
3714        .ok_or(LoweringError::UnboundVar(src.0))?
3715        .to_owned();
3716
3717    // A project-backed fixed hop always uses the provider-backed Extension
3718    // node (#1248). The provider owns hit/miss/building fallback, keeping the
3719    // physical shape stable so a terminal LIMIT can cancel traversal work.
3720    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            // Undirected = Out ∪ In. Build both legs with independent VarMaps
3768            // (both register the same `var_<n>` aliases), then copy the
3769            // edge/dst registrations back into the outer map.
3770            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            // Copy the newly registered edge/dst vars from the Out map.
3799            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            // The ONLY full-row duplicate between the two legs is a self-loop:
3807            // an edge with `src_id == dst_id` is matched by BOTH the Out join
3808            // (`src = edge.src_id`) and the In join (`src = edge.dst_id`) for the
3809            // same bound node, yielding an identical row; every other edge
3810            // matches exactly one leg per bound node. Drop self-loops from the
3811            // In leg so the union is duplicate-free WITHOUT a `Distinct` — a
3812            // wrapping `Distinct` over the merged multi-`var_<n>` schema trips
3813            // DataFusion's duplicate-field-name check at physical planning (the
3814            // #825 failure). This mirrors the adjacency path, which likewise
3815            // collapses only the self-loop's double entry.
3816            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            // DataFusion's `union` DROPS all relation qualifiers and, for
3825            // same-named columns (`var_0.node_id` and `var_2.node_id` both become
3826            // bare `node_id`), appends positional suffixes (`node_id_1`). Those
3827            // synthetic names are UNSTABLE under the `optimize_projections` pass
3828            // (pruning a collision shifts the suffix), so a projection that
3829            // references them post-union breaks the optimizer. Instead, rename
3830            // each leg's columns to collision-free positional names (`__u{i}`)
3831            // BEFORE the union — so the union output has no collisions to
3832            // disambiguate — then restore the original `var_<n>` qualifiers AFTER
3833            // it from the captured leg schema, referencing only the stable names
3834            // (mirroring `join_*_properties`' `alias_qualified` re-qualification).
3835            // Without this, downstream `b.<prop>` / `r.<prop>` refs and the
3836            // trailing `NodeScan{dst}` property join cannot resolve their columns.
3837            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/// Emit the provider-backed [`graphforge_plan::ExpandNode`] for a project-backed fixed
3874/// hop (#1248).
3875///
3876/// The provider, not lowering, owns hit/miss/building fallback. Schema-only
3877/// lowering and already-bound relationship variables keep the relational path;
3878/// the latter is already a cheap row-local constraint rather than a graph
3879/// expansion. A repeated destination is expanded under a private binding and
3880/// filtered back to the existing node, preserving cyclic-pattern semantics.
3881#[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); // schema-only lowering has no execution provider
3899    };
3900    if var_map.get(edge).is_some() {
3901        return Ok(None); // already-bound relationship: row-local filter path
3902    }
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); // relational path reports the unknown TypeId
3907            };
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    // Edge property fields, discovered exactly like `join_edge_properties`:
3919    // wildcard traversal unions every relation's fields, first occurrence wins.
3920    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    // Register new bindings exactly like the join path. A repeated destination
3965    // keeps its existing registration and is correlated below.
3966    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/// Lower a single-direction (Out or In) fixed-hop expand.
4010///
4011/// `out_direction = true`  → join on `src.node_id = edge.src_id` (Out)
4012/// `out_direction = false` → join on `src.node_id = edge.dst_id` (In)
4013#[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    // Produce the edge scan (registers edge var in var_map).
4062    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    // Enrich the edge scan with its persisted properties (#784) so a downstream
4074    // `RETURN r.<prop>` resolves to a real column. A wildcard edge scan
4075    // (`rel_ty == None`) has no single relation file, so no property join.
4076    let edge_plan = join_edge_properties(&edge_alias, rel_ty, type_id_to_rel_name, dir, edge_plan)?;
4077
4078    // Join input (has src) with edge scan.
4079    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    // Join input (has src) with edge scan using ON expression.
4087    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    // Reusing a node variable constrains this hop to the node already present
4102    // in the row; it must not append a second scan with the same qualifier.
4103    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    // Produce the dst node scan.
4111    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
4193/// LEFT-join an edge scan with its persisted properties (#784), the edge
4194/// analogue of [`GraphPlanLowerer::join_node_properties`].
4195///
4196/// Returns `scan` unchanged when:
4197/// - there is no read directory (schema-only lowering),
4198/// - `rel_ty` is `None` (a wildcard edge scan has no single relation file), or
4199/// - the edge-property table has no columns beyond the `edge_uuid` join key.
4200///
4201/// Otherwise it opens `edge_properties/<rel>.parquet`, LEFT-joins on `edge_uuid`
4202/// (preserving edges with no property row yet), and re-qualifies each property
4203/// column under the edge var alias so `var_<edge>.<prop>` resolves. The base
4204/// topology columns are read from the edge `scan`'s own schema (typed and
4205/// exploratory edge files differ in width), keeping the projection in lock-step
4206/// with whichever edge file the scan reads.
4207fn 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); // schema-only lowering: no real provider to join
4219    };
4220
4221    // Base topology columns come from the edge scan's own schema (typed: 9 cols;
4222    // exploratory: + `rel_type_name`), so the projection matches the live file.
4223    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); // unknown relation name: nothing to resolve
4255        };
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        // LEFT join: edge ⟕ props ON edge.edge_uuid = props.edge_uuid. For a
4277        // wildcard edge scan, constrain each property table to its relation so a
4278        // relation-specific property file never contributes to another relation.
4279        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    // Project: all edge topology columns (qualified var_N) unchanged, then each
4297    // property column re-qualified under var_N so `var_N.<prop>` resolves.
4298    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// ---------------------------------------------------------------------------
4319// Tests
4320// ---------------------------------------------------------------------------
4321
4322#[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        // Use a literal rather than a VarRef to avoid schema validation on
4510        // the empty base relation (DataFusion rejects unknown column names).
4511        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        // Skip is implemented as Limit with a non-zero skip offset
4582        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        // Build the plan using GraphPlanBuilder so expressions live in plan.exprs.
4646        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        // Outermost plan node should be Limit
4665        assert!(
4666            matches!(lp, DfLogicalPlan::Limit(_)),
4667            "expected Limit at top, got {lp:?}"
4668        );
4669    }
4670
4671    // -----------------------------------------------------------------------
4672    // Scan operator tests (#576)
4673    // -----------------------------------------------------------------------
4674
4675    #[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        // TypeId(42) is not in the type_id_to_rel_name map (no ontology),
4714        // so lower_plan must return an error rather than silently falling back.
4715        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), // variable-length — emits VarLenExpandNode
4761            })
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        // Baked execution context + pattern fields.
4785        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        // Output schema carries the destination node's columns, qualified
4794        // `var_2`, so a downstream `RETURN b.node_id` can resolve them.
4795        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        // The read-only constructor has no project directory, so a
4803        // variable-length expand cannot bake its edge-read path.
4804        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        // Outer binds var_0; the optional child binds a fresh, unshared var_1,
4868        // so `join_keys` is empty and all 5 inner columns are kept. This test
4869        // pins the *schema* contract (outer ++ nullable inner); the shared-var
4870        // exclusion (non-empty join keys) is covered by
4871        // `optional_child_with_shared_var_excludes_outer_columns` below.
4872        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        // Output = outer (6 topology cols) ++ inner (6 cols, all nullable).
4898        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        // Top-level should be an inner join (dst scan joined onto edge scan)
4935        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        // A binder emits `NodeScan{a} → Expand → NodeScan{b, ty}` for
4944        // `(a)-[:R]->(b:Label)`. The trailing `NodeScan{b}` is a no-op (b is
4945        // already bound by Expand), but b's label must still filter the result
4946        // rather than being dropped (#718). The optimizer leaves a `Filter` on
4947        // `var_2.type_id` somewhere in the tree.
4948        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        // `MATCH (a) OPTIONAL MATCH (a)-[:R]->(b)`: the optional child now lowers
4982        // to a real join that re-binds the shared `a` (var_0). Its columns must
4983        // NOT be appended again (they live on the outer side) — otherwise the
4984        // node's schema would carry duplicate `var_0` fields (#718).
4985        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        // The shared var_0 is a join key, so the inner output drops all 6 of its
5022        // columns — keeping only the exploratory edge (var_1, 8 cols) and dst
5023        // (var_2, 6).
5024        assert_eq!(node.join_keys.len(), 1, "var_0 is the shared join key");
5025        let schema = UserDefinedLogicalNodeCore::schema(node);
5026        // outer var_0 (6) ++ inner kept (edge 8 + var_2 6 = 14) = 20.
5027        assert_eq!(schema.fields().len(), 20, "no duplicate var_0 columns");
5028        // Building the schema would have panicked on a duplicate qualified field
5029        // if var_0's columns were appended, so reaching here proves exclusion.
5030    }
5031
5032    // -----------------------------------------------------------------------
5033    // CREATE lowering (#700)
5034    // -----------------------------------------------------------------------
5035
5036    fn create_plan_with_props() -> GraphPlan {
5037        use graphforge_ir::{CreateNodeSpec, CreatePattern};
5038        let mut builder = GraphPlan::builder("openCypher");
5039        // Property map {name: 'Alice'} in the arena.
5040        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        // The read-only `new` constructor has no write target.
5077        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        // `new_with_dir` grants read-side directory access (for var-length
5089        // Expand) but must NOT open the write path — only `new_for_writes`
5090        // authorizes CREATE.
5091        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        // A non-literal property value (here a parameter) is no longer rejected
5117        // (#814): it lowers to a row-dependent computed `Expr` on the create node,
5118        // evaluated per row by the execution layer.
5119        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    // -----------------------------------------------------------------------
5157    // DELETE lowering (#740)
5158    // -----------------------------------------------------------------------
5159
5160    /// `MATCH (n) DELETE n` over a real read dir, so the NodeScan binds
5161    /// `var_0.node_uuid` for the delete-target kind resolution.
5162    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        // `new_with_dir` grants read access but not the write path.
5200        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        // `MATCH ()-[r]->() DELETE r`: the edge var must resolve to a DeleteTarget
5217        // with is_edge=true (the input schema carries var_1.edge_uuid, not
5218        // node_uuid), so the executor reads the edge identity column.
5219        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    // -----------------------------------------------------------------------
5267    // SET / REMOVE lowering (#791)
5268    // -----------------------------------------------------------------------
5269
5270    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        // `SET n.age = n.age + 1` — the value references the matched-row column
5321        // `var_0.age`; it must lower without error (the topology scan carries the
5322        // bound var, and the value expr resolves against the input schema).
5323        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        // n.age + 1
5331        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        // The runtime value expr is carried onto the SET node, not collapsed.
5359        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        // Read-side dir access only — no write authorization.
5401        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    // -----------------------------------------------------------------------
5432    // Provider-backed fixed-hop lowering (#763, #1248)
5433    // -----------------------------------------------------------------------
5434
5435    /// Single-hop typed plan over an interned KNOWS relation; returns the
5436    /// fixture pieces plus the relation TypeId.
5437    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        // Schema parity essentials: edge topology under var_1, dst under var_2.
5492        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        // No DISTINCT wrapper: the self-loop dedup happens inside ExpandExec
5512        // (a wrapping Distinct trips DataFusion's duplicate-field-name
5513        // disambiguation against the extension's multi-var schema).
5514        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        // The adjacency path must not bypass the source-binding invariant: an
5602        // Expand whose src var was never bound is a lowering error, not a
5603        // silently column-0-seeded ExpandNode.
5604        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        // No NodeScan: src VarId(0) is never registered.
5609        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}