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