Skip to main content

graphforge_rel/
expr.rs

1//! IR expression arena → DataFusion [`Expr`] lowering.
2//!
3//! [`ExprLowerer`] is a pure, I/O-free transformation: it walks the
4//! [`ExprArena`] from a [`GraphPlan`] and produces DataFusion [`Expr`] values
5//! that can be consumed by operator lowering (#575, #576).
6
7use std::any::Any;
8use std::collections::HashMap;
9use std::sync::{Arc, LazyLock};
10
11use datafusion::arrow::array::{
12    Array, FixedSizeListArray, LargeListArray, ListArray, new_empty_array,
13};
14use datafusion::arrow::datatypes::{DataType, Field, FieldRef};
15use datafusion::logical_expr::expr::Placeholder;
16use datafusion::logical_expr::{
17    ColumnarValue, Expr as DfExpr, ExprSchemable, Operator, ReturnFieldArgs, ScalarFunctionArgs,
18    ScalarUDF, ScalarUDFImpl, Signature, Volatility, cast, col, lit, not, when,
19};
20use datafusion::scalar::ScalarValue;
21
22use graphforge_core::PropId;
23use graphforge_ir::expr::{BinaryOpKind, IrExpr, IrLiteral, UnaryOpKind};
24use graphforge_ir::{ExprArena, ExprId, VarId};
25use graphforge_ontology::OntologyHandle;
26
27// ---------------------------------------------------------------------------
28// Public types
29// ---------------------------------------------------------------------------
30
31/// Maps [`VarId`]s to DataFusion column name strings.
32///
33/// The binder allocates one `VarId` per distinct bound variable in a query
34/// (e.g. `a`, `b`, `r`).  `VarMap` records the DataFusion column name each
35/// variable resolves to at execution time — typically `"<alias>.node_id"` for
36/// node variables and `"<alias>.edge_id"` for edge variables.
37#[derive(Debug, Clone, Default)]
38pub struct VarMap(HashMap<u32, String>);
39
40impl VarMap {
41    /// Creates an empty map.
42    #[must_use]
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    /// Records that variable `var` maps to DataFusion column `col_name`.
48    pub fn insert(&mut self, var: VarId, col_name: impl Into<String>) {
49        self.0.insert(var.0, col_name.into());
50    }
51
52    /// Drops every registered variable. Used to install a fresh `WITH` scope so
53    /// pre-`WITH` variables stop resolving (mirrors the binder's scope reset).
54    pub fn clear(&mut self) {
55        self.0.clear();
56    }
57
58    /// Returns the column name for `var`, or `None` if it has not been registered.
59    #[must_use]
60    pub fn get(&self, var: VarId) -> Option<&str> {
61        self.0.get(&var.0).map(String::as_str)
62    }
63
64    /// Iterates the [`VarId`]s currently registered, in arbitrary order.
65    ///
66    /// Used to compute the variables shared between two scopes (e.g. the outer
67    /// and optional sides of an `OPTIONAL MATCH`, which become its join keys).
68    pub fn var_ids(&self) -> impl Iterator<Item = VarId> + '_ {
69        self.0.keys().map(|&k| VarId(k))
70    }
71}
72
73/// Errors that can occur when lowering an IR expression to DataFusion.
74#[derive(Debug, Clone, thiserror::Error)]
75pub enum LoweringError {
76    /// A [`FunctionCall`](graphforge_ir::expr::IrExpr::FunctionCall) name has no
77    /// DataFusion built-in equivalent.
78    #[error("unknown built-in function: {0}")]
79    UnknownFunction(String),
80
81    /// An [`IrExpr`] variant cannot be lowered yet (e.g. `MapLiteral`).
82    #[error("unsupported expression: {0}")]
83    UnsupportedExpr(String),
84
85    /// A [`VarId`] referenced by a `VarRef` or `PropertyAccess` is not in the
86    /// [`VarMap`].
87    #[error("unbound variable: VarId({0})")]
88    UnboundVar(u32),
89
90    /// A genuine Cypher type error caught at planning — e.g. a quantifier
91    /// predicate that cannot apply to the list's element type (`x % 2` over a
92    /// string list). A deliberate validation rejection (openCypher
93    /// `InvalidArgumentType`), distinct from a capability gap. (#955)
94    #[error("invalid argument type: {0}")]
95    InvalidType(String),
96}
97
98/// The shape of a node value materialized for a bare `RETURN n` (#785): the
99/// node's resolved label (if known at lowering) and the persisted property
100/// columns its scan joined in (available as `var_N.<prop>`).
101#[derive(Clone, Debug)]
102pub struct NodeShape {
103    /// Persisted property column names, materialized as `var_N.<name>` by
104    /// `join_node_properties`. The label is supplied separately by the binder
105    /// (the ontology map is empty in exploratory mode).
106    pub prop_names: Vec<String>,
107}
108
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110enum EntityIdentityKind {
111    Node,
112    Edge,
113}
114
115// ---------------------------------------------------------------------------
116// ExprLowerer
117// ---------------------------------------------------------------------------
118
119/// Lowers IR expressions from an [`ExprArena`] into DataFusion [`Expr`] values.
120///
121/// Construct once per plan lowering pass and call [`lower`](Self::lower) for
122/// each [`ExprId`] you need to convert.
123pub struct ExprLowerer<'a> {
124    arena: &'a ExprArena,
125    var_map: &'a VarMap,
126    /// Reverse map `PropId.0` → property column name. Built from the ontology
127    /// and/or the runtime catalog. Falls back to `"prop_<id>"` for any `PropId`
128    /// not present (e.g. a strict-mode unresolved property).
129    prop_names: HashMap<u32, String>,
130    /// `VarId.0` → node shape, for materializing a bare `RETURN n` as a whole
131    /// node value (#785). Empty unless the plan projects a node var by value.
132    node_shapes: HashMap<u32, NodeShape>,
133    /// Reverse map `TypeId.0` → entity-type (node label) name, merged from the
134    /// ontology and runtime catalog. Used to render a real label for an
135    /// *unlabelled* node value (`MATCH (n) RETURN n`) by switching on the node's
136    /// stored `type_id` (#889). Empty in schema-only/no-catalog lowering.
137    type_id_to_entity_name: HashMap<u32, String>,
138    /// Forward map of entity-type (node label) name to `TypeId.0`. Derived once
139    /// from `type_id_to_entity_name` so a literal `'<label>' IN labels(node)` can
140    /// lower directly to topology membership without rebuilding the complete
141    /// string label list for every pattern predicate.
142    entity_name_to_type_id: HashMap<String, u32>,
143    /// Whether `node_shapes`' property lists are AUTHORITATIVE — i.e. read from a
144    /// real backing dataset, so an absent property name truly means the node lacks
145    /// it (→ Cypher `null`). False for schema-only / explain lowering (no dataset),
146    /// where an empty `prop_names` just means "unknown", not "absent" — there a
147    /// property access must stay an unresolved column reference, never be nulled.
148    /// Gates the missing-property→null rewrite (#598). See `node_prop_cols`.
149    props_authoritative: bool,
150    /// The input plan's schema, when lowering a relational op's expressions
151    /// (set per-op via [`with_input_schema`](Self::with_input_schema)). Lets a
152    /// `PropertyAccess` consult the base column's Arrow type — so `d.year` on a
153    /// `Date32` lowers to a temporal-component extraction rather than a property
154    /// column reference (ADR 0009 / #920). `None` in schema-only lowering.
155    input_schema: Option<datafusion::common::DFSchemaRef>,
156    /// The chain of synthetic per-element column names in scope while lowering
157    /// a quantifier / list-comprehension predicate (#1004): one entry per
158    /// enclosing loop, outermost first (`__gf_elem`, `__gf_elem_1`, …), so
159    /// nested loops keep distinct bindings (#1021). A `PropertyAccess` whose
160    /// base resolves to ANY of these columns is lowered via struct-aware
161    /// `get_field` rather than a dotted property-column name — the element is
162    /// a single struct column, not a table whose fields are top-level columns.
163    /// Empty elsewhere; its length is the current nesting depth.
164    elem_struct_cols: Vec<String>,
165    /// The project directory for read-side lowering, when one is attached
166    /// (#1024): lets `nodes(p)` bake a hydrating `cypher_path_nodes` whose
167    /// elements carry labels + the property union discovered from
168    /// `properties/*.parquet`. `None` in schema-only/explain lowering — the
169    /// UDF then keeps its `node_uuid`-only shape.
170    read_target: Option<std::path::PathBuf>,
171    /// Wall-clock instant captured ONCE per lowering (lazily, on first use) so all
172    /// zero-arg current-time constructors — `date()`/`localtime()`/…/`datetime()`
173    /// — in one query fold to the SAME value, making
174    /// `duration.inSeconds(localtime(), localtime())` exactly zero. (#1007)
175    now: std::sync::OnceLock<chrono::NaiveDateTime>,
176}
177
178impl<'a> ExprLowerer<'a> {
179    /// Creates a new lowerer, building the `PropId → name` map from the
180    /// ontology only (empty in exploratory mode).
181    ///
182    /// - `arena`: the expression arena from the [`GraphPlan`] being lowered.
183    /// - `ontology`: the ontology handle, if one is loaded (may be `None` in
184    ///   exploratory mode).
185    /// - `var_map`: maps variable IDs to DataFusion column name strings.
186    ///
187    /// Prefer [`with_prop_names`](Self::with_prop_names) when the caller has a
188    /// map that also covers runtime-catalog (exploratory) property names.
189    #[must_use]
190    pub fn new(
191        arena: &'a ExprArena,
192        ontology: Option<&'a OntologyHandle>,
193        var_map: &'a VarMap,
194    ) -> Self {
195        Self {
196            arena,
197            var_map,
198            prop_names: build_prop_names(ontology),
199            node_shapes: HashMap::new(),
200            type_id_to_entity_name: HashMap::new(),
201            entity_name_to_type_id: HashMap::new(),
202            props_authoritative: false,
203            input_schema: None,
204            elem_struct_cols: Vec::new(),
205            read_target: None,
206            now: std::sync::OnceLock::new(),
207        }
208    }
209
210    /// Creates a lowerer with a prebuilt `PropId → name` map.
211    ///
212    /// The [`GraphPlanLowerer`](crate::GraphPlanLowerer) builds the map once
213    /// (merging ontology + runtime catalog) and clones it into each
214    /// per-operator `ExprLowerer` (the maps are small).
215    #[must_use]
216    pub fn with_prop_names(
217        arena: &'a ExprArena,
218        var_map: &'a VarMap,
219        prop_names: HashMap<u32, String>,
220    ) -> Self {
221        Self {
222            arena,
223            var_map,
224            prop_names,
225            node_shapes: HashMap::new(),
226            type_id_to_entity_name: HashMap::new(),
227            entity_name_to_type_id: HashMap::new(),
228            props_authoritative: false,
229            input_schema: None,
230            elem_struct_cols: Vec::new(),
231            read_target: None,
232            now: std::sync::OnceLock::new(),
233        }
234    }
235
236    /// Like [`with_prop_names`](Self::with_prop_names) but also seeded with the
237    /// `VarId.0 → NodeShape` map for bare-node-value materialization (#785).
238    ///
239    /// `props_authoritative` is true when `node_shapes`' property lists come from
240    /// a real backing dataset (so an absent property is genuinely absent → `null`,
241    /// #598); pass false for schema-only / explain lowering. See the field docs.
242    #[must_use]
243    pub fn with_prop_names_and_nodes(
244        arena: &'a ExprArena,
245        var_map: &'a VarMap,
246        prop_names: HashMap<u32, String>,
247        node_shapes: HashMap<u32, NodeShape>,
248        type_id_to_entity_name: HashMap<u32, String>,
249        props_authoritative: bool,
250    ) -> Self {
251        let entity_name_to_type_id = type_id_to_entity_name
252            .iter()
253            .map(|(id, name)| (name.clone(), *id))
254            .collect();
255        Self {
256            arena,
257            var_map,
258            prop_names,
259            node_shapes,
260            type_id_to_entity_name,
261            entity_name_to_type_id,
262            props_authoritative,
263            input_schema: None,
264            elem_struct_cols: Vec::new(),
265            read_target: None,
266            now: std::sync::OnceLock::new(),
267        }
268    }
269
270    /// Attach the input plan's schema so a relational op's `PropertyAccess` can
271    /// resolve a temporal-component accessor (`d.year`) by the base column's
272    /// Arrow type (ADR 0009 / #920).
273    #[must_use]
274    pub fn with_input_schema(mut self, schema: datafusion::common::DFSchemaRef) -> Self {
275        self.input_schema = Some(schema);
276        self
277    }
278
279    /// Push `col` onto the chain of synthetic per-element columns in scope, so
280    /// a `PropertyAccess` on it lowers via struct-aware `get_field` rather than
281    /// a dotted property-column name (#1004). Called once per enclosing loop,
282    /// outermost first, so nested loops keep distinct bindings (#1021).
283    #[must_use]
284    pub fn with_elem_struct_col(mut self, col: String) -> Self {
285        self.elem_struct_cols.push(col);
286        self
287    }
288
289    /// Attach the project directory for read-side lowering, so `nodes(p)` bakes
290    /// a hydrating `cypher_path_nodes` (labels + property union, #1024).
291    #[must_use]
292    pub fn with_read_target(mut self, dir: std::path::PathBuf) -> Self {
293        self.read_target = Some(dir);
294        self
295    }
296
297    /// Lower the expression identified by `id` to a DataFusion [`Expr`].
298    ///
299    /// # Errors
300    /// Returns [`LoweringError`] if a variable is unbound, a function is
301    /// unknown, or an expression variant is not yet supported.
302    #[allow(
303        clippy::too_many_lines,
304        reason = "one cohesive dispatch match over every IrExpr variant plus the \
305                  namespaced temporal builtins (date/time/datetime truncate); \
306                  splitting the arms would scatter the lowering logic"
307    )]
308    pub fn lower(&self, id: ExprId) -> Result<DfExpr, LoweringError> {
309        match self.arena.get(id) {
310            IrExpr::Literal(lit_val) => Ok(lower_literal(lit_val)),
311
312            IrExpr::VarRef(var_id) => {
313                let col_name = self
314                    .var_map
315                    .get(*var_id)
316                    .ok_or(LoweringError::UnboundVar(var_id.0))?;
317                if let Some(schema) = self.input_schema.as_ref()
318                    && schema.field_with_unqualified_name(col_name).is_err()
319                {
320                    let qual = datafusion::common::TableReference::bare(col_name);
321                    if schema
322                        .index_of_column_by_name(Some(&qual), "node_uuid")
323                        .is_some()
324                    {
325                        return Ok(col(format!("{col_name}.node_uuid")));
326                    }
327                    if schema
328                        .index_of_column_by_name(Some(&qual), "edge_uuid")
329                        .is_some()
330                    {
331                        return Ok(col(format!("{col_name}.edge_uuid")));
332                    }
333                }
334                // Reference the variable's column by its LITERAL name (#957):
335                // `col()` parses + lowercases unquoted identifiers, silently
336                // breaking a mixed-case alias (`WITH v AS otherDate RETURN
337                // otherDate` → "No field named otherdate").
338                if self
339                    .input_schema
340                    .as_ref()
341                    .is_some_and(|schema| schema.index_of_column_by_name(None, col_name).is_some())
342                {
343                    Ok(DfExpr::Column(datafusion::common::Column::new_unqualified(
344                        col_name,
345                    )))
346                } else {
347                    Ok(col_literal(col_name))
348                }
349            }
350
351            IrExpr::PropertyAccess { base, prop } => {
352                if let Some(prop_name) = self.prop_names.get(&prop.0).cloned()
353                    && let Some(out) = self.lower_static_value_access(*base, &prop_name)?
354                {
355                    return Ok(out);
356                }
357                // Cypher: reading a property a node does not have yields `null`,
358                // not an error. The columns resolvable under a node var's
359                // qualifier are its TOPOLOGY columns (`node_uuid`, `type_id`, …)
360                // PLUS the property columns its scan joined in
361                // (`NodeShape::prop_names`, authoritative — see `node_prop_cols`,
362                // which excludes the topology columns precisely because they are
363                // always present). A property is genuinely absent only when in
364                // NEITHER set; then the dotted column `var_N.<prop>` does not
365                // exist, so emit a null literal rather than a dangling column
366                // reference DataFusion rejects at planning. (Without the topology
367                // exemption an access like `n.node_uuid` — used by the bindings —
368                // would be wrongly nulled.) The name is resolved exactly as
369                // `resolve_prop_col` resolves it, so the membership test matches
370                // the column it would build. (#598, Null1)
371                if self.props_authoritative
372                    && let IrExpr::VarRef(v) = self.arena.get(*base)
373                    && let Some(shape) = self.node_shapes.get(&v.0)
374                {
375                    let prop_name = self
376                        .prop_names
377                        .get(&prop.0)
378                        .cloned()
379                        .unwrap_or_else(|| format!("prop_{}", prop.0));
380                    let is_topology = graphforge_storage::TOPOLOGY_NODES_SCHEMA
381                        .field_with_name(&prop_name)
382                        .is_ok();
383                    if !is_topology && !shape.prop_names.contains(&prop_name) {
384                        return Ok(lit(ScalarValue::Null));
385                    }
386                }
387                // Temporal component accessor (#920): `d.year` where `d` is a
388                // date-struct-typed column (`Struct{epoch_day}`, ADR 0012) lowers to
389                // component extraction, not a property column. Dispatch needs the
390                // base's type, so it only fires when the input schema is known and
391                // the base is a var whose column is the date struct. (Other types'
392                // accessors follow once those types are typed.)
393                if let IrExpr::VarRef(v) = self.arena.get(*base)
394                    && let Some(col_name) = self.var_map.get(*v)
395                    && let Some(prop_name) = self.prop_names.get(&prop.0)
396                    && crate::temporal::is_date_accessor(prop_name)
397                    && let Some(schema) = self.input_schema.as_ref()
398                    && let Ok(field) = schema.field_with_unqualified_name(col_name)
399                    && is_date_struct(field.data_type())
400                {
401                    return Ok(CYPHER_DATE_COMPONENT
402                        .call(vec![col_literal(col_name), lit(prop_name.as_str())]));
403                }
404                // Duration component accessor (#920): `d.days`/`d.seconds`/… where
405                // `d` is a typed `duration` struct column.
406                if let IrExpr::VarRef(v) = self.arena.get(*base)
407                    && let Some(col_name) = self.var_map.get(*v)
408                    && let Some(prop_name) = self.prop_names.get(&prop.0)
409                    && crate::temporal::is_duration_accessor(prop_name)
410                    && let Some(schema) = self.input_schema.as_ref()
411                    && let Ok(field) = schema.field_with_unqualified_name(col_name)
412                    && is_duration_struct(field.data_type())
413                {
414                    return Ok(CYPHER_DURATION_COMPONENT
415                        .call(vec![col_literal(col_name), lit(prop_name.as_str())]));
416                }
417                // Other typed-temporal component accessors (#1008): `localtime`
418                // (`Time64`), `time`/`localdatetime`/`datetime` (structs). `Date32`
419                // and duration are handled above; here we extract time-of-day, date
420                // (for localdatetime/datetime), zone, and epoch (datetime)
421                // components. Zone strings (`timezone`/`offset`) → `Utf8`, all else
422                // `Int64`; the UDFs inspect the column's Arrow type to pick the field.
423                if let IrExpr::VarRef(v) = self.arena.get(*base)
424                    && let Some(col_name) = self.var_map.get(*v)
425                    && let Some(prop_name) = self.prop_names.get(&prop.0)
426                    && let Some(schema) = self.input_schema.as_ref()
427                    && let Ok(field) = schema.field_with_unqualified_name(col_name)
428                    && temporal_accessor_valid(field.data_type(), prop_name)
429                {
430                    let args = vec![col_literal(col_name), lit(prop_name.as_str())];
431                    return Ok(if crate::temporal::is_zone_str_accessor(prop_name) {
432                        CYPHER_TEMPORAL_ZONE_STR.call(args)
433                    } else {
434                        CYPHER_TEMPORAL_COMPONENT.call(args)
435                    });
436                }
437                // Struct-field access on a plain-map column (#1017): a variable bound
438                // to a map value — `UNWIND [{k: …}] AS m` then `m.k`, or `WITH {…} AS
439                // m` — is a single `Struct` column, so its fields are struct fields,
440                // not dotted property columns. Resolve via struct-aware `get_field`,
441                // mirroring the quantifier element case (#1004). Entities keep dotted
442                // property columns (via `resolve_prop_col`); temporal structs are
443                // handled above.
444                if let IrExpr::VarRef(v) = self.arena.get(*base)
445                    && let Some(col_name) = self.var_map.get(*v)
446                    && let Some(schema) = self.input_schema.as_ref()
447                    && let Ok(field) = schema.field_with_unqualified_name(col_name)
448                    && is_plain_map_struct_type(field.data_type())
449                {
450                    let prop_name = self
451                        .prop_names
452                        .get(&prop.0)
453                        .cloned()
454                        .unwrap_or_else(|| format!("prop_{}", prop.0));
455                    return Ok(datafusion::functions::core::expr_fn::get_field(
456                        col_literal(col_name),
457                        prop_name,
458                    ));
459                }
460                // Lower the base expression (typically a VarRef) and append the
461                // property name. For a VarRef base, keep the qualifier itself
462                // (`var_N`), not the scalarized entity identity (`var_N.node_uuid`)
463                // used when a bare entity variable appears in scalar contexts.
464                let base_expr = if let IrExpr::VarRef(v) = self.arena.get(*base) {
465                    col_literal(self.var_map.get(*v).ok_or(LoweringError::UnboundVar(v.0))?)
466                } else {
467                    self.lower(*base)?
468                };
469                if self.is_known_non_value_access_container(&base_expr) {
470                    let prop_name = self
471                        .prop_names
472                        .get(&prop.0)
473                        .cloned()
474                        .unwrap_or_else(|| format!("prop_{}", prop.0));
475                    return Err(LoweringError::InvalidType(format!(
476                        "property access `{prop_name}` requires a map or graph element"
477                    )));
478                }
479                let prop_col = self.resolve_prop_col(base_expr, *prop);
480                Ok(prop_col)
481            }
482
483            IrExpr::BinaryOp { op, left, right } => self.lower_binary(*op, *left, *right),
484
485            IrExpr::UnaryOp { op, expr } => self.lower_unary(*op, *expr),
486
487            IrExpr::FunctionCall { name, args } if name == "_node_struct" => {
488                self.lower_node_struct(args)
489            }
490
491            IrExpr::FunctionCall { name, args } if name == "_node_struct_list" => {
492                self.lower_node_struct_list(args)
493            }
494
495            IrExpr::FunctionCall { name, args } if name == "_rel_struct" => {
496                self.lower_rel_struct(args)
497            }
498
499            IrExpr::FunctionCall { name, args } if name == "_rel_struct_list" => {
500                self.lower_rel_struct_list(args)
501            }
502
503            IrExpr::FunctionCall { name, args } if name == "keys" => self.lower_keys(args),
504
505            IrExpr::FunctionCall { name, args } if name == "properties" => {
506                self.lower_properties(args)
507            }
508
509            IrExpr::FunctionCall { name, args } if name == "labels" => self.lower_labels(args),
510
511            IrExpr::FunctionCall { name, args }
512                if matches!(name.as_str(), "nodes" | "relationships") =>
513            {
514                let [arg] = args.as_slice() else {
515                    return Err(LoweringError::InvalidType(format!(
516                        "{name}() expects one path argument"
517                    )));
518                };
519                Ok(datafusion::functions::core::expr_fn::get_field(
520                    self.lower(*arg)?,
521                    name,
522                ))
523            }
524
525            IrExpr::FunctionCall { name, args } if name == "_subscript" => {
526                self.lower_subscript(args)
527            }
528
529            IrExpr::FunctionCall { name, args }
530                if matches!(
531                    name.as_str(),
532                    "date" | "localtime" | "time" | "localdatetime" | "datetime" | "duration"
533                ) =>
534            {
535                self.lower_temporal(name, args)
536            }
537
538            IrExpr::FunctionCall { name, args }
539                if matches!(
540                    name.as_str(),
541                    "datetime.fromepoch" | "datetime.fromepochmillis"
542                ) =>
543            {
544                self.lower_from_epoch(name, args)
545            }
546
547            IrExpr::FunctionCall { name, args } if name == "date.truncate" => {
548                self.lower_date_truncate(args)
549            }
550            IrExpr::FunctionCall { name, args } if name == "localtime.truncate" => {
551                self.lower_localtime_truncate(args)
552            }
553            IrExpr::FunctionCall { name, args } if name == "localdatetime.truncate" => {
554                self.lower_localdatetime_truncate(args)
555            }
556            IrExpr::FunctionCall { name, args } if name == "time.truncate" => {
557                self.lower_time_truncate(args)
558            }
559            IrExpr::FunctionCall { name, args } if name == "datetime.truncate" => {
560                self.lower_datetime_truncate(args)
561            }
562            // Clock functions `<type>.transaction/.statement/.realtime` (#920).
563            // A non-deterministic current-time clock is not modelled; the corpus
564            // only exercises the null-propagating form (`date.realtime(null)` →
565            // `null`, Temporal4 [13]), so handle that and leave the live-clock
566            // form unsupported.
567            IrExpr::FunctionCall { name, args } if is_temporal_clock_fn(name) => {
568                if self.sole_arg_is_null(args) {
569                    Ok(DfExpr::Literal(temporal_null_scalar(name), None))
570                } else {
571                    Err(LoweringError::UnsupportedExpr(format!(
572                        "{name}: temporal clock functions are not supported in a \
573                         deterministic query context"
574                    )))
575                }
576            }
577            // `duration.between(a, b)` and the single-unit `inMonths`/`inDays`/
578            // `inSeconds` (#920). Matched case-insensitively (function names are
579            // case-insensitive in Cypher).
580            IrExpr::FunctionCall { name, args }
581                if matches!(
582                    name.to_ascii_lowercase().as_str(),
583                    "duration.between"
584                        | "duration.inmonths"
585                        | "duration.indays"
586                        | "duration.inseconds"
587                ) =>
588            {
589                self.lower_duration_between(&name.to_ascii_lowercase(), args)
590            }
591
592            IrExpr::FunctionCall { name, args } => {
593                let lowered = if is_path_builtin_name(name) {
594                    args.iter()
595                        .map(|&a| self.lower_path_builtin_arg(a))
596                        .collect::<Result<Vec<_>, _>>()?
597                } else {
598                    args.iter()
599                        .map(|&a| self.lower(a))
600                        .collect::<Result<Vec<_>, _>>()?
601                };
602                // `reverse` is polymorphic: a string reverses its characters, a
603                // list its elements. Dispatch on the lowered argument's type —
604                // only a statically-known string takes the char path; a list (or
605                // an unknown type) reverses as a list. (#955)
606                if name == "reverse"
607                    && let [arg] = lowered.as_slice()
608                {
609                    return Ok(if self.is_string_typed(arg) {
610                        datafusion::functions::unicode::expr_fn::reverse(arg.clone())
611                    } else if self.is_list_typed(arg) {
612                        datafusion::functions_nested::expr_fn::array_reverse(arg.clone())
613                    } else {
614                        // Type not known at plan time (parameter / unresolved
615                        // property) — dispatch at runtime rather than assuming a
616                        // list (which would mis-plan a string). (#955)
617                        CYPHER_REVERSE.call(vec![arg.clone()])
618                    });
619                }
620                resolve_builtin(name, lowered, || self.path_node_hydration())
621                    .ok_or_else(|| LoweringError::UnknownFunction(name.clone()))
622            }
623
624            IrExpr::Parameter(name) => Ok(DfExpr::Placeholder(Placeholder {
625                // DataFusion's named-parameter substitution (`ParamValues::Map`)
626                // strips the leading char of the placeholder id before looking
627                // it up (it assumes `$name` ids keyed by bare `name`), so the id
628                // must carry the `$` the lexer stripped — otherwise binding by
629                // name fails. See `ExecutionSession::execute_plan_with_params`.
630                id: format!("${name}"),
631                field: None,
632            })),
633
634            IrExpr::Case {
635                operand,
636                arms,
637                else_expr,
638            } => self.lower_case(operand.as_ref().copied(), arms, else_expr.as_ref().copied()),
639
640            IrExpr::ListLiteral(ids) => {
641                let elems: Vec<DfExpr> = ids
642                    .iter()
643                    .map(|&id| self.lower_value(id))
644                    .collect::<Result<_, _>>()?;
645                Ok(lower_list_literal(elems, self.input_schema.as_deref()))
646            }
647
648            IrExpr::MapLiteral(entries) => self.lower_map_literal(entries),
649
650            IrExpr::Quantifier {
651                kind,
652                loop_var,
653                list,
654                predicate,
655            } => self.lower_quantifier(*kind, *loop_var, *list, *predicate),
656
657            IrExpr::ListComprehension {
658                loop_var,
659                list,
660                filter,
661                projection,
662            } => self.lower_list_comprehension(*loop_var, *list, *filter, *projection),
663        }
664    }
665
666    fn lower_value(&self, id: ExprId) -> Result<DfExpr, LoweringError> {
667        if let IrExpr::VarRef(var_id) = self.arena.get(id) {
668            let base = self
669                .var_map
670                .get(*var_id)
671                .ok_or(LoweringError::UnboundVar(var_id.0))?;
672            if self.node_shapes.contains_key(&var_id.0) || self.is_node_var(base) {
673                let prop_names = self
674                    .node_shapes
675                    .get(&var_id.0)
676                    .map(|s| s.prop_names.clone())
677                    .unwrap_or_default();
678                return Ok(node_value_struct(
679                    base,
680                    None,
681                    &self.type_id_to_entity_name,
682                    &prop_names,
683                ));
684            }
685            if self.is_edge_var(base) {
686                let props = self.edge_prop_names(base);
687                let value =
688                    relationship_value_struct(base, col(format!("{base}.rel_type_name")), &props);
689                return Ok(null_unless(edge_present_qual(base), value));
690            }
691        }
692        self.lower(id)
693    }
694
695    fn lower_path_builtin_arg(&self, id: ExprId) -> Result<DfExpr, LoweringError> {
696        if let IrExpr::VarRef(v) = self.arena.get(id) {
697            return Ok(col_literal(
698                self.var_map.get(*v).ok_or(LoweringError::UnboundVar(v.0))?,
699            ));
700        }
701        self.lower(id)
702    }
703
704    fn lower_subscript(&self, args: &[ExprId]) -> Result<DfExpr, LoweringError> {
705        let [base_id, key_id] = args else {
706            return Err(LoweringError::UnsupportedExpr(
707                "_subscript expects two arguments".into(),
708            ));
709        };
710        let key_expr = self.lower(*key_id)?;
711        if let Some(key) = const_string_key(&key_expr) {
712            match key {
713                ConstStringKey::Null => return Ok(lit(ScalarValue::Null)),
714                ConstStringKey::Value(k) => {
715                    if let Some(value) = self.lower_static_value_access(*base_id, &k)? {
716                        return Ok(value);
717                    }
718                }
719            }
720        }
721        if let Some(container) = self.lower_dynamic_access_container(*base_id)? {
722            return Ok(CYPHER_VALUE_ACCESS.call(vec![container, key_expr]));
723        }
724        let base = self.lower(*base_id)?;
725        if self.expr_data_type(&base).is_some_and(|dt| {
726            !matches!(
727                dt,
728                DataType::Null
729                    | DataType::List(_)
730                    | DataType::LargeList(_)
731                    | DataType::FixedSizeList(_, _)
732            ) && !is_plain_map_struct_type(&dt)
733                && !is_het_struct_type(Some(&dt))
734        }) {
735            return Err(LoweringError::InvalidType(
736                "subscript requires a list, map, node, relationship, or null".into(),
737            ));
738        }
739        if self.is_list_typed(&base) {
740            match self.expr_data_type(&key_expr) {
741                Some(dt) if is_integer_data_type(&dt) => {
742                    return Ok(datafusion::functions_nested::expr_fn::array_element(
743                        base,
744                        one_based_index(key_expr),
745                    ));
746                }
747                Some(DataType::Null) | None => {}
748                Some(_) => {
749                    return Err(LoweringError::InvalidType(
750                        "list subscript index must be an integer or null".into(),
751                    ));
752                }
753            }
754        }
755        Ok(CYPHER_VALUE_ACCESS.call(vec![base, key_expr]))
756    }
757
758    fn lower_static_value_access(
759        &self,
760        base: ExprId,
761        key: &str,
762    ) -> Result<Option<DfExpr>, LoweringError> {
763        if matches!(self.arena.get(base), IrExpr::Literal(IrLiteral::Null)) {
764            return Ok(Some(lit(ScalarValue::Null)));
765        }
766        if let Some(value) = self.lower_static_indexed_value_access(base, key)? {
767            return Ok(Some(value));
768        }
769        if let Some(value) = self.lower_map_literal_static_field(base, key)? {
770            return Ok(Some(value));
771        }
772        if let IrExpr::VarRef(var_id) = self.arena.get(base)
773            && let Some(base_name) = self.var_map.get(*var_id)
774        {
775            if let Some(value) = self.lower_entity_static_property(*var_id, base_name, key) {
776                return Ok(Some(value));
777            }
778            if let Some(value) = self.lower_struct_static_field(col_literal(base_name), key) {
779                return Ok(Some(value));
780            }
781        }
782
783        let base_expr = self.lower(base)?;
784        Ok(self.lower_struct_static_field(base_expr, key))
785    }
786
787    fn lower_map_literal_static_field(
788        &self,
789        base: ExprId,
790        key: &str,
791    ) -> Result<Option<DfExpr>, LoweringError> {
792        let IrExpr::MapLiteral(entries) = self.arena.get(base) else {
793            return Ok(None);
794        };
795        entries
796            .iter()
797            .find(|(k, _)| k == key)
798            .map_or(Ok(Some(lit(ScalarValue::Null))), |(_, id)| {
799                self.lower(*id).map(Some)
800            })
801    }
802
803    fn lower_entity_static_property(&self, var_id: VarId, base: &str, key: &str) -> Option<DfExpr> {
804        let qual = datafusion::common::TableReference::bare(base);
805        if let Some(schema) = self.input_schema.as_ref() {
806            if schema.index_of_column_by_name(Some(&qual), key).is_some() {
807                return Some(qualified_col(base, key));
808            }
809            if self.is_node_var(base) || self.is_edge_var(base) {
810                return Some(lit(ScalarValue::Null));
811            }
812        }
813        if let Some(shape) = self.node_shapes.get(&var_id.0) {
814            let is_topology = graphforge_storage::TOPOLOGY_NODES_SCHEMA
815                .field_with_name(key)
816                .is_ok();
817            if is_topology || shape.prop_names.iter().any(|p| p == key) {
818                return Some(qualified_col(base, key));
819            }
820            if self.props_authoritative {
821                return Some(lit(ScalarValue::Null));
822            }
823        }
824        None
825    }
826
827    fn lower_static_indexed_value_access(
828        &self,
829        base: ExprId,
830        key: &str,
831    ) -> Result<Option<DfExpr>, LoweringError> {
832        let IrExpr::FunctionCall { name, args } = self.arena.get(base) else {
833            return Ok(None);
834        };
835        if name != "_subscript" {
836            return Ok(None);
837        }
838        let [list_id, index_id] = args.as_slice() else {
839            return Ok(None);
840        };
841        let IrExpr::ListLiteral(items) = self.arena.get(*list_id) else {
842            return Ok(None);
843        };
844        let IrExpr::Literal(IrLiteral::Int(idx)) = self.arena.get(*index_id) else {
845            return Ok(None);
846        };
847        let len = i64::try_from(items.len()).map_err(|_| {
848            LoweringError::UnsupportedExpr("list literal length exceeds i64 range".into())
849        })?;
850        let pos = if *idx < 0 { len + idx } else { *idx };
851        if pos < 0 || pos >= len {
852            return Ok(Some(lit(ScalarValue::Null)));
853        }
854        let pos = usize::try_from(pos)
855            .map_err(|_| LoweringError::UnsupportedExpr("list index exceeds usize".into()))?;
856        self.lower_static_value_access(items[pos], key)
857    }
858
859    fn lower_struct_static_field(&self, base_expr: DfExpr, key: &str) -> Option<DfExpr> {
860        let dt = self.expr_data_type(&base_expr)?;
861        match dt {
862            DataType::Null => Some(lit(ScalarValue::Null)),
863            dt if is_het_struct_type(Some(&dt)) => Some(
864                ScalarUDF::new_from_impl(CypherStaticValueAccess::new(key.to_owned()))
865                    .call(vec![base_expr]),
866            ),
867            DataType::Struct(fields)
868                if is_plain_map_struct_type(&DataType::Struct(fields.clone())) =>
869            {
870                if fields.iter().any(|f| f.name() == key) {
871                    Some(datafusion::functions::core::expr_fn::get_field(
872                        base_expr,
873                        key.to_owned(),
874                    ))
875                } else {
876                    Some(lit(ScalarValue::Null))
877                }
878            }
879            DataType::Struct(fields) => {
880                let is_entity = fields
881                    .iter()
882                    .any(|field| matches!(field.name().as_str(), "node_uuid" | "edge_uuid"));
883                if is_entity && fields.iter().any(|field| field.name() == key) {
884                    Some(datafusion::functions::core::expr_fn::get_field(
885                        base_expr,
886                        key.to_owned(),
887                    ))
888                } else if is_entity {
889                    Some(lit(ScalarValue::Null))
890                } else {
891                    None
892                }
893            }
894            _ => None,
895        }
896    }
897
898    fn lower_dynamic_access_container(
899        &self,
900        base: ExprId,
901    ) -> Result<Option<DfExpr>, LoweringError> {
902        if matches!(self.arena.get(base), IrExpr::Literal(IrLiteral::Null)) {
903            return Ok(Some(lit(ScalarValue::Null)));
904        }
905        if matches!(self.arena.get(base), IrExpr::MapLiteral(_)) {
906            return self.lower(base).map(Some);
907        }
908        if let IrExpr::VarRef(var_id) = self.arena.get(base)
909            && let Some(base_name) = self.var_map.get(*var_id)
910        {
911            if let Some(expr) = self.entity_property_bag(*var_id, base_name) {
912                return Ok(Some(expr));
913            }
914            let base_expr = col_literal(base_name);
915            if self.expr_data_type(&base_expr).is_some_and(|dt| {
916                matches!(dt, DataType::Null)
917                    || is_plain_map_struct_type(&dt)
918                    || is_het_struct_type(Some(&dt))
919            }) {
920                return Ok(Some(base_expr));
921            }
922        }
923        let base_expr = self.lower(base)?;
924        Ok(self
925            .expr_data_type(&base_expr)
926            .is_some_and(|dt| {
927                matches!(dt, DataType::Null)
928                    || is_plain_map_struct_type(&dt)
929                    || is_het_struct_type(Some(&dt))
930            })
931            .then_some(base_expr))
932    }
933
934    fn entity_property_bag(&self, var_id: VarId, base: &str) -> Option<DfExpr> {
935        self.entity_property_bag_inner(var_id, base, false)
936    }
937
938    fn entity_property_bag_with_empty(&self, var_id: VarId, base: &str) -> Option<DfExpr> {
939        self.entity_property_bag_inner(var_id, base, true)
940    }
941
942    fn entity_property_bag_inner(
943        &self,
944        var_id: VarId,
945        base: &str,
946        empty_map_for_present_entity: bool,
947    ) -> Option<DfExpr> {
948        use datafusion::functions::core::expr_fn::named_struct;
949
950        let (prop_names, present) = if let Some(shape) = self.node_shapes.get(&var_id.0) {
951            let has_node_uuid = self.input_schema.as_ref().is_some_and(|schema| {
952                let qual = datafusion::common::TableReference::bare(base);
953                schema
954                    .index_of_column_by_name(Some(&qual), "node_uuid")
955                    .is_some()
956            });
957            let present = if has_node_uuid {
958                col(format!("{base}.node_uuid")).is_not_null()
959            } else {
960                lit(true)
961            };
962            (shape.prop_names.clone(), present)
963        } else if self.is_edge_var(base) {
964            (self.edge_prop_names(base), edge_present_qual(base))
965        } else {
966            return None;
967        };
968        if prop_names.is_empty() {
969            let value = if empty_map_for_present_entity {
970                ScalarUDF::new_from_impl(CypherEntityProperties::new(1)).call(vec![present.clone()])
971            } else {
972                lit(ScalarValue::Null)
973            };
974            return Some(null_unless(present, value));
975        }
976        if empty_map_for_present_entity {
977            let mut args = Vec::with_capacity(1 + prop_names.len() * 2);
978            args.push(present);
979            for prop in prop_names {
980                args.push(lit(prop.as_str()));
981                args.push(qualified_col(base, &prop));
982            }
983            return Some(
984                ScalarUDF::new_from_impl(CypherEntityProperties::new(args.len())).call(args),
985            );
986        }
987        let mut args = Vec::with_capacity(prop_names.len() * 2);
988        for prop in prop_names {
989            args.push(lit(prop.as_str()));
990            args.push(qualified_col(base, &prop));
991        }
992        Some(null_unless(present, named_struct(args)))
993    }
994
995    /// Lower `all/any/none/single(loop_var IN list WHERE predicate)` (#955) to a
996    /// `cypher_quantifier` UDF call. The predicate is lowered with `loop_var`
997    /// mapped to a synthetic element column `__gf_elem` and any OUTER variables to
998    /// their real columns (correlated quantifiers); the UDF evaluates the
999    /// predicate per list element (building a per-element batch) and folds the
1000    /// booleans with three-valued logic.
1001    fn lower_quantifier(
1002        &self,
1003        kind: graphforge_ir::QuantifierKind,
1004        loop_var: VarId,
1005        list: ExprId,
1006        predicate: ExprId,
1007    ) -> Result<DfExpr, LoweringError> {
1008        // One synthetic element column per nesting level (#1021): the outermost
1009        // loop keeps the historical `__gf_elem`; a nested loop gets
1010        // `__gf_elem_<depth>` so its binding cannot shadow an enclosing one.
1011        let elem_name = match self.elem_struct_cols.len() {
1012            0 => "__gf_elem".to_owned(),
1013            d => format!("__gf_elem_{d}"),
1014        };
1015        let list_expr = self.lower(list)?;
1016        if let IrExpr::Literal(IrLiteral::Bool(predicate)) = self.arena.get(predicate) {
1017            let udf =
1018                ScalarUDF::new_from_impl(CypherInvariantQuantifier::new(kind, Some(*predicate)));
1019            return Ok(udf.call(vec![list_expr]));
1020        }
1021        if matches!(self.arena.get(predicate), IrExpr::Literal(IrLiteral::Null)) {
1022            let udf = ScalarUDF::new_from_impl(CypherInvariantQuantifier::new(kind, None));
1023            return Ok(udf.call(vec![list_expr]));
1024        }
1025        // Lower the predicate with the loop var bound to the synthetic element
1026        // column (outer vars keep their real columns — correlation).
1027        let mut elem_vars = VarMap::new();
1028        for v in self.var_map.var_ids() {
1029            if let Some(name) = self.var_map.get(v) {
1030                elem_vars.insert(v, name.to_owned());
1031            }
1032        }
1033        elem_vars.insert(loop_var, elem_name.as_str());
1034        let pred_lowerer = {
1035            let mut l =
1036                ExprLowerer::with_prop_names(self.arena, &elem_vars, self.prop_names.clone());
1037            if let Some(s) = self.input_schema.as_ref() {
1038                l = l.with_input_schema(s.clone());
1039            }
1040            if let Some(t) = self.read_target.as_ref() {
1041                l = l.with_read_target(t.clone());
1042            }
1043            // Ancestor element columns stay in scope — an inner predicate may
1044            // access an OUTER element's struct fields — then this loop's own.
1045            for c in &self.elem_struct_cols {
1046                l = l.with_elem_struct_col(c.clone());
1047            }
1048            l.with_elem_struct_col(elem_name.clone())
1049        };
1050        let pred_expr = pred_lowerer.lower(predicate)?;
1051
1052        // OUTER columns referenced by the predicate (everything but the element).
1053        // An enclosing loop's element column counts as outer: it flows in as a
1054        // UDF argument the enclosing invoke broadcasts per element (#1021).
1055        let mut outer: Vec<String> = pred_expr
1056            .column_refs()
1057            .into_iter()
1058            .map(|c| c.name.clone())
1059            .filter(|n| n != &elem_name)
1060            .collect();
1061        outer.sort();
1062        outer.dedup();
1063
1064        // Plan-time type validation (#955): when the element type is statically
1065        // known (a literal list, or a typed list column) AND there is no outer
1066        // correlation to schema-resolve, try to plan the predicate over it. A
1067        // failure is a genuine Cypher type error (`x % 2` over a string list →
1068        // `InvalidArgumentType`), surfaced as a `plan error` rather than a
1069        // runtime capability-gap error so it counts as deliberate validation.
1070        // A statically-EMPTY list is exempt: the predicate never runs (the result
1071        // is the trivial `all`/`none` = true, `any`/`single` = false), so its type
1072        // is irrelevant — `none(x IN [] WHERE x.a = 2)` must not be rejected (#1005).
1073        if outer.is_empty()
1074            && !is_empty_list_literal(&list_expr)
1075            && let Some(elem_type) = self.list_element_type(&list_expr)
1076        {
1077            use datafusion::arrow::datatypes::{Field, Schema};
1078            use datafusion::common::DFSchema;
1079            use datafusion::logical_expr::execution_props::ExecutionProps;
1080            use datafusion::physical_expr::create_physical_expr;
1081            let schema = Schema::new(vec![Field::new(&elem_name, elem_type, true)]);
1082            if let Ok(df_schema) = DFSchema::try_from(schema)
1083                && create_physical_expr(&pred_expr, &df_schema, &ExecutionProps::new()).is_err()
1084            {
1085                return Err(LoweringError::InvalidType(format!(
1086                    "quantifier predicate cannot apply to the list's element type ({kind:?})"
1087                )));
1088            }
1089        }
1090
1091        let mut call_args = Vec::with_capacity(1 + outer.len());
1092        call_args.push(list_expr);
1093        for name in &outer {
1094            call_args.push(col_literal(name));
1095        }
1096        let udf =
1097            ScalarUDF::new_from_impl(CypherQuantifier::new(kind, pred_expr, elem_name, outer));
1098        Ok(udf.call(call_args))
1099    }
1100
1101    /// Lower `[loop_var IN list WHERE filter | projection]` (#955) to a
1102    /// `CypherListComp` UDF call. Both clauses are lowered over the synthetic
1103    /// element column `__gf_elem` plus any outer columns they reference
1104    /// (correlation); the UDF builds a per-element batch per row, filters, maps,
1105    /// and rebuilds the result `ListArray`.
1106    #[allow(
1107        clippy::too_many_lines,
1108        reason = "schema synthesis, correlation rebinding, and UDF construction stay aligned"
1109    )]
1110    fn lower_list_comprehension(
1111        &self,
1112        loop_var: VarId,
1113        list: ExprId,
1114        filter: Option<ExprId>,
1115        projection: Option<ExprId>,
1116    ) -> Result<DfExpr, LoweringError> {
1117        // One synthetic element column per nesting level (#1021), mirroring
1118        // `lower_quantifier` — the two forms nest through each other, so they
1119        // share the same depth-derived naming.
1120        let elem_name = match self.elem_struct_cols.len() {
1121            0 => "__gf_elem".to_owned(),
1122            d => format!("__gf_elem_{d}"),
1123        };
1124        let list_expr = self.lower(list)?;
1125        let clause_schema = self.list_element_type(&list_expr).and_then(|element_type| {
1126            let mut fields = self.input_schema.as_ref().map_or_else(Vec::new, |schema| {
1127                schema
1128                    .iter()
1129                    .map(|(qualifier, field)| (qualifier.cloned(), Arc::clone(field)))
1130                    .collect()
1131            });
1132            fields.push((
1133                None,
1134                Arc::new(datafusion::arrow::datatypes::Field::new(
1135                    &elem_name,
1136                    element_type,
1137                    true,
1138                )),
1139            ));
1140            datafusion::common::DFSchema::new_with_metadata(fields, HashMap::new())
1141                .ok()
1142                .map(Arc::new)
1143        });
1144
1145        // Lower the clauses with the loop var bound to the synthetic element
1146        // column; outer vars keep their real columns (correlation).
1147        let mut elem_vars = VarMap::new();
1148        for v in self.var_map.var_ids() {
1149            if let Some(name) = self.var_map.get(v) {
1150                elem_vars.insert(v, name.to_owned());
1151            }
1152        }
1153        elem_vars.insert(loop_var, elem_name.as_str());
1154        let clause_lowerer = {
1155            let mut l =
1156                ExprLowerer::with_prop_names(self.arena, &elem_vars, self.prop_names.clone());
1157            if let Some(s) = clause_schema.as_ref().or(self.input_schema.as_ref()) {
1158                l = l.with_input_schema(s.clone());
1159            }
1160            if let Some(t) = self.read_target.as_ref() {
1161                l = l.with_read_target(t.clone());
1162            }
1163            // Ancestor element columns stay in scope, then this loop's own.
1164            for c in &self.elem_struct_cols {
1165                l = l.with_elem_struct_col(c.clone());
1166            }
1167            l.with_elem_struct_col(elem_name.clone())
1168        };
1169        let mut filter_expr = filter.map(|f| clause_lowerer.lower(f)).transpose()?;
1170        let mut projection_expr = projection.map(|p| clause_lowerer.lower(p)).transpose()?;
1171
1172        // OUTER columns referenced by either clause (everything but the element).
1173        let mut outer_columns = Vec::new();
1174        for e in [filter_expr.as_ref(), projection_expr.as_ref()]
1175            .into_iter()
1176            .flatten()
1177        {
1178            for c in e.column_refs() {
1179                if c.name != elem_name && !outer_columns.contains(c) {
1180                    outer_columns.push(c.clone());
1181                }
1182            }
1183        }
1184        outer_columns.sort_by_key(datafusion::common::Column::flat_name);
1185        let outer = (0..outer_columns.len())
1186            .map(|index| format!("__gf_outer_{index}"))
1187            .collect::<Vec<_>>();
1188        if !outer_columns.is_empty() {
1189            use datafusion::common::tree_node::{Transformed, TreeNode};
1190            let rewrite = |expr: DfExpr| {
1191                expr.transform_up(|expr| {
1192                    let DfExpr::Column(column) = &expr else {
1193                        return Ok(Transformed::no(expr));
1194                    };
1195                    let Some(index) = outer_columns.iter().position(|outer| outer == column) else {
1196                        return Ok(Transformed::no(expr));
1197                    };
1198                    Ok(Transformed::yes(DfExpr::Column(
1199                        datafusion::common::Column::from_name(outer[index].clone()),
1200                    )))
1201                })
1202                .map(|transformed| transformed.data)
1203            };
1204            filter_expr = filter_expr
1205                .map(&rewrite)
1206                .transpose()
1207                .map_err(|error| LoweringError::UnsupportedExpr(error.to_string()))?;
1208            projection_expr = projection_expr
1209                .map(rewrite)
1210                .transpose()
1211                .map_err(|error| LoweringError::UnsupportedExpr(error.to_string()))?;
1212        }
1213
1214        // Plan-time validation (#955), mirroring `lower_quantifier`: when the
1215        // element type is statically known and there is no outer correlation,
1216        // confirm the filter predicate can actually plan over that element type.
1217        // A failure (`WHERE x % 2 = 0` over a string list) is a genuine Cypher
1218        // type error, surfaced as a clean `plan error` rather than a runtime
1219        // failure inside the UDF.
1220        if let Some(fexpr) = filter_expr.as_ref()
1221            && outer.is_empty()
1222            && let Some(elem_type) = self.list_element_type(&list_expr)
1223        {
1224            use datafusion::arrow::datatypes::{Field, Schema};
1225            use datafusion::common::DFSchema;
1226            use datafusion::logical_expr::execution_props::ExecutionProps;
1227            use datafusion::physical_expr::create_physical_expr;
1228            let schema = Schema::new(vec![Field::new(&elem_name, elem_type, true)]);
1229            if let Ok(df_schema) = DFSchema::try_from(schema)
1230                && create_physical_expr(fexpr, &df_schema, &ExecutionProps::new()).is_err()
1231            {
1232                return Err(LoweringError::InvalidType(
1233                    "list comprehension filter cannot apply to the list's element type".into(),
1234                ));
1235            }
1236        }
1237
1238        let mut call_args = Vec::with_capacity(1 + outer.len());
1239        call_args.push(list_expr);
1240        for column in &outer_columns {
1241            call_args.push(DfExpr::Column(column.clone()));
1242        }
1243        let udf = ScalarUDF::new_from_impl(CypherListComp::new(
1244            filter_expr,
1245            projection_expr,
1246            elem_name,
1247            outer,
1248        ));
1249        Ok(udf.call(call_args))
1250    }
1251
1252    /// Lower a map literal `{k: v, …}` to an Arrow `Struct` via `named_struct`
1253    /// (keys become field names, values the fields) — the same representation
1254    /// node/relationship property bags use, so `m.k` resolves through
1255    /// `resolve_prop_col`'s `get_field` fallback and the renderer prints it as
1256    /// `{k: v, …}` (#600). An empty map `{}` builds an empty struct.
1257    ///
1258    /// A constant map stays a `named_struct` call here (so an all-map list keeps
1259    /// its `make_array` coercion path, #1004); `lower_list_literal` folds it to a
1260    /// `ScalarValue::Struct` on demand when a mixed list needs the tagged het path
1261    /// (via [`try_const_scalar`], #1005).
1262    fn lower_map_literal(&self, entries: &[(String, ExprId)]) -> Result<DfExpr, LoweringError> {
1263        use datafusion::functions::core::expr_fn::named_struct;
1264        if entries.is_empty() {
1265            // `named_struct()` rejects zero args; an empty map is an empty struct.
1266            return Ok(empty_map_struct());
1267        }
1268        let mut args: Vec<DfExpr> = Vec::with_capacity(entries.len() * 2);
1269        for (key, value) in entries {
1270            args.push(lit(key.as_str()));
1271            args.push(self.lower_value(*value)?);
1272        }
1273        Ok(named_struct(args))
1274    }
1275
1276    /// Lower a temporal constructor — `date`/`localtime`/`time`/
1277    /// `localdatetime`/`datetime`/`duration`. When the single argument is a
1278    /// constant the openCypher TCK uses — an ISO string literal or a map of
1279    /// literal fields — parse and canonicalise it at lowering time and emit the
1280    /// quoted-ISO `Utf8` literal the TCK renders, so no runtime UDF is needed.
1281    /// The supported forms are documented in [`crate::temporal`]. Non-constant /
1282    /// unsupported-form arguments fall back to `resolve_builtin` (today only
1283    /// `date` has a runtime path, via `to_date`/`to_char`; the others error).
1284    /// (#599)
1285    /// Whether the call has a single argument that is a literal `null` — a
1286    /// temporal constructor / clock function of `null` propagates to `null`
1287    /// (openCypher Temporal4 [13]). (#920)
1288    fn sole_arg_is_null(&self, args: &[ExprId]) -> bool {
1289        matches!(args, [a] if matches!(self.arena.get(*a), IrExpr::Literal(graphforge_ir::IrLiteral::Null)))
1290    }
1291
1292    /// Fold a zero-arg current-time constructor (`date()`/`localtime()`/`time()`/
1293    /// `localdatetime()`/`datetime()`) to a constant scalar from a single `now`
1294    /// captured once per lowering, so two calls in one query fold IDENTICALLY
1295    /// (`duration.inSeconds(localtime(), localtime())` → `PT0S`). `time`/`datetime`
1296    /// use UTC (offset 0). Non-deterministic by nature — a deliberate exception to
1297    /// the engine's constant-folding determinism; only the difference-invariant is
1298    /// exercised by the TCK. (#1007, Temporal10 [12])
1299    fn lower_clock_now(&self, name: &str) -> DfExpr {
1300        use chrono::Timelike;
1301        let now = *self.now.get_or_init(|| chrono::Utc::now().naive_utc());
1302        let days = crate::temporal::date_to_epoch_days(now.date()).unwrap_or(0);
1303        let nanos = i64::from(now.time().num_seconds_from_midnight()) * 1_000_000_000
1304            + i64::from(now.time().nanosecond());
1305        let scalar = match name {
1306            "date" => date_scalar(Some(days)),
1307            "localtime" => ScalarValue::Time64Nanosecond(Some(nanos)),
1308            "localdatetime" => localdatetime_scalar(Some((days, nanos))),
1309            "time" => time_scalar(Some((nanos, 0))),
1310            // "datetime": UTC instant, offset 0, no named zone.
1311            _ => datetime_scalar(Some((days, nanos, 0, None))),
1312        };
1313        lit(scalar)
1314    }
1315
1316    fn lower_temporal(&self, name: &str, args: &[ExprId]) -> Result<DfExpr, LoweringError> {
1317        // A temporal constructor of a literal `null` is `null`, typed so the
1318        // temporal Arrow contract survives null propagation (#920).
1319        if self.sole_arg_is_null(args) {
1320            return Ok(DfExpr::Literal(temporal_null_scalar(name), None));
1321        }
1322        // A zero-arg current-time constructor `date()`/`localtime()`/`time()`/
1323        // `localdatetime()`/`datetime()` (#1007). `duration()` has no clock form.
1324        if args.is_empty() && name != "duration" {
1325            return Ok(self.lower_clock_now(name));
1326        }
1327        if let [arg] = args {
1328            // `date` is a typed `Struct{epoch_day: Int64}` value (ADR 0009/0012). A
1329            // constant lowers to a date-struct scalar; a runtime argument (a column,
1330            // or `{date: other, …overrides}`) goes through the `cypher_date_project`
1331            // UDF (#920).
1332            if name == "date" {
1333                if let Some(days) = self.const_date(*arg) {
1334                    return Ok(DfExpr::Literal(date_scalar(Some(days)), None));
1335                }
1336                // `date(other)` / `date({date: other, …})` → projection UDF. A
1337                // map *without* a `date` anchor (runtime field construction)
1338                // returns None and falls through to the runtime builtin path.
1339                if let Some(projected) = self.lower_date_runtime(*arg)? {
1340                    return Ok(projected);
1341                }
1342            }
1343            // `localtime` is a typed `Time64(Nanosecond)` value (ADR 0009). A
1344            // constant lowers to a scalar; a runtime argument (a column, or
1345            // `{time: other, …overrides}`) goes through `cypher_localtime_project`.
1346            if name == "localtime" {
1347                if let Some(nanos) = self.const_local_time(*arg) {
1348                    return Ok(DfExpr::Literal(
1349                        ScalarValue::Time64Nanosecond(Some(nanos)),
1350                        None,
1351                    ));
1352                }
1353                if let Some(projected) = self.lower_localtime_runtime(*arg)? {
1354                    return Ok(projected);
1355                }
1356            }
1357            // `localdatetime` is a typed `Struct{date: Date32, time: Time64(ns)}`
1358            // value (ADR 0009) — a date + time-of-day with no zone, two-field so
1359            // it spans the full year range at nanosecond precision. A constant
1360            // lowers to a struct scalar; a runtime argument goes through
1361            // `cypher_localdatetime_project`.
1362            if name == "localdatetime" {
1363                if let Some((days, nanos)) = self.const_local_date_time(*arg) {
1364                    return Ok(DfExpr::Literal(
1365                        localdatetime_scalar(Some((days, nanos))),
1366                        None,
1367                    ));
1368                }
1369                if let Some(projected) = self.lower_localdatetime_runtime(*arg)? {
1370                    return Ok(projected);
1371                }
1372            }
1373            // `time` is a typed `Struct{time: Time64(ns), offset: Int32}` value
1374            // (ADR 0009) — a time of day with a zone offset. A constant lowers to
1375            // a struct scalar; a runtime argument goes through `cypher_time_project`.
1376            if name == "time" {
1377                if let Some((nanos, offset)) = self.const_time(*arg) {
1378                    return Ok(DfExpr::Literal(time_scalar(Some((nanos, offset))), None));
1379                }
1380                if let Some(projected) = self.lower_time_runtime(*arg)? {
1381                    return Ok(projected);
1382                }
1383            }
1384            // `datetime` is a typed `Struct{date: Date32, time: Time64(ns),
1385            // offset: Int32, zone: Utf8?}` value (ADR 0009) — a date + time + zone
1386            // (resolved offset plus an optional named-IANA-zone label). A constant
1387            // lowers to a struct scalar; a runtime argument goes through
1388            // `cypher_datetime_project`.
1389            if name == "datetime" {
1390                if let Some(parts) = self.const_datetime(*arg) {
1391                    return Ok(DfExpr::Literal(datetime_scalar(Some(parts)), None));
1392                }
1393                if let Some(projected) = self.lower_datetime_runtime(*arg)? {
1394                    return Ok(projected);
1395                }
1396            }
1397            // `duration` is a typed `Struct{months, days, seconds, nanos}` value (ADR
1398            // 0009). A constant (literal ISO string or field map) lowers to a
1399            // struct scalar; a runtime ISO-string argument (e.g.
1400            // `duration(toString(d))`) goes through `cypher_duration_parse`.
1401            if name == "duration" {
1402                if let Some(dur) = self.const_duration(*arg) {
1403                    return Ok(DfExpr::Literal(duration_scalar(Some(dur)), None));
1404                }
1405                let lowered = self.lower(*arg)?;
1406                if self.is_string_typed(&lowered) {
1407                    return Ok(CYPHER_DURATION_PARSE.call(vec![lowered]));
1408                }
1409            }
1410            match self.arena.get(*arg) {
1411                IrExpr::Literal(IrLiteral::Str(s)) => {
1412                    if let Some(rendered) = render_temporal(name, s) {
1413                        return Ok(lit(rendered));
1414                    }
1415                }
1416                IrExpr::MapLiteral(entries) => {
1417                    if let Some(fields) = self.extract_temporal_fields(entries)
1418                        && let Some(rendered) = crate::temporal::render_temporal_map(name, &fields)
1419                    {
1420                        return Ok(lit(rendered));
1421                    }
1422                }
1423                _ => {}
1424            }
1425        }
1426        let lowered: Vec<DfExpr> = args
1427            .iter()
1428            .map(|&a| self.lower(a))
1429            .collect::<Result<_, _>>()?;
1430        resolve_builtin(name, lowered, || self.path_node_hydration())
1431            .ok_or_else(|| LoweringError::UnknownFunction(name.to_string()))
1432    }
1433
1434    /// Resolve a `date(<arg>)` argument to constant i64 epoch-days when the
1435    /// argument is a literal ISO string or a literal field map. (ADR 0009/0012)
1436    fn const_date(&self, arg: ExprId) -> Option<i64> {
1437        match self.arena.get(arg) {
1438            IrExpr::Literal(IrLiteral::Str(s)) => crate::temporal::parse_date_string(s),
1439            IrExpr::MapLiteral(entries) => {
1440                let fields = self.extract_temporal_fields(entries)?;
1441                crate::temporal::date_from_map(&fields)
1442            }
1443            _ => None,
1444        }
1445    }
1446
1447    /// Lower a runtime (non-constant) `date(<arg>)` to a `cypher_date_project`
1448    /// call returning `Date32`: `date({date: base, …overrides})` projects the
1449    /// base date's components; a bare `date(<expr>)` extracts the date from a
1450    /// `Date32` or ISO date/datetime string (no overrides). Returns `None` for a
1451    /// map *without* a `date` anchor (runtime field construction — not a
1452    /// projection), so the caller falls through to the runtime builtin. (#920)
1453    fn lower_date_runtime(&self, arg: ExprId) -> Result<Option<DfExpr>, LoweringError> {
1454        let null_i64 = || DfExpr::Literal(ScalarValue::Int64(None), None);
1455        let (base, overrides) = match self.arena.get(arg) {
1456            IrExpr::MapLiteral(entries) if entries.iter().any(|(k, _)| k == "date") => {
1457                let field = |name: &str| entries.iter().find(|(k, _)| k == name).map(|(_, v)| *v);
1458                let base = self.lower(field("date").expect("checked `date` key exists"))?;
1459                let ov = |name: &str| match field(name) {
1460                    Some(id) => self.lower(id),
1461                    None => Ok(null_i64()),
1462                };
1463                let overrides = [
1464                    ov("year")?,
1465                    ov("month")?,
1466                    ov("day")?,
1467                    ov("week")?,
1468                    ov("dayOfWeek")?,
1469                    ov("ordinalDay")?,
1470                    ov("quarter")?,
1471                    ov("dayOfQuarter")?,
1472                ];
1473                (base, overrides)
1474            }
1475            // A map without a `date` anchor is field-construction, not a
1476            // projection — let the caller's runtime path handle it.
1477            IrExpr::MapLiteral(_) => return Ok(None),
1478            // A bare `date(<expr>)` — extract the date, no overrides.
1479            _ => (self.lower(arg)?, std::array::from_fn(|_| null_i64())),
1480        };
1481        let mut call_args = Vec::with_capacity(9);
1482        call_args.push(base);
1483        call_args.extend(overrides);
1484        Ok(Some(CYPHER_DATE_PROJECT.call(call_args)))
1485    }
1486
1487    /// Resolve a `localtime(<arg>)` argument to constant nanoseconds-of-day when
1488    /// the argument is a literal ISO string or a literal field map. (ADR 0009)
1489    fn const_local_time(&self, arg: ExprId) -> Option<i64> {
1490        match self.arena.get(arg) {
1491            IrExpr::Literal(IrLiteral::Str(s)) => crate::temporal::localtime_nanos_from_str(s),
1492            IrExpr::MapLiteral(entries) => {
1493                let fields = self.extract_temporal_fields(entries)?;
1494                crate::temporal::localtime_nanos_from_map(&fields)
1495            }
1496            _ => None,
1497        }
1498    }
1499
1500    /// Lower a runtime `localtime(<arg>)` to a `cypher_localtime_project` call
1501    /// returning `Time64(Nanosecond)`: `localtime({time: base, …overrides})`
1502    /// projects the base's time-of-day; a bare `localtime(<expr>)` extracts the
1503    /// time-of-day from a `Time64` or any ISO temporal string. Returns `None` for
1504    /// a map *without* a `time` anchor (field-construction, not projection), so
1505    /// the caller falls through to the runtime builtin. (ADR 0009)
1506    fn lower_localtime_runtime(&self, arg: ExprId) -> Result<Option<DfExpr>, LoweringError> {
1507        let null_i64 = || DfExpr::Literal(ScalarValue::Int64(None), None);
1508        let (base, overrides) = match self.arena.get(arg) {
1509            IrExpr::MapLiteral(entries) if entries.iter().any(|(k, _)| k == "time") => {
1510                let field = |name: &str| entries.iter().find(|(k, _)| k == name).map(|(_, v)| *v);
1511                let base = self.lower(field("time").expect("checked `time` key exists"))?;
1512                let ov = |name: &str| match field(name) {
1513                    Some(id) => self.lower(id),
1514                    None => Ok(null_i64()),
1515                };
1516                let overrides = [
1517                    ov("hour")?,
1518                    ov("minute")?,
1519                    ov("second")?,
1520                    ov("millisecond")?,
1521                    ov("microsecond")?,
1522                    ov("nanosecond")?,
1523                ];
1524                (base, overrides)
1525            }
1526            IrExpr::MapLiteral(_) => return Ok(None),
1527            _ => (self.lower(arg)?, std::array::from_fn(|_| null_i64())),
1528        };
1529        let mut call_args = Vec::with_capacity(7);
1530        call_args.push(base);
1531        call_args.extend(overrides);
1532        Ok(Some(CYPHER_LOCALTIME_PROJECT.call(call_args)))
1533    }
1534
1535    /// Resolve a `localdatetime(<arg>)` argument to constant `(date_days,
1536    /// nanoseconds_of_day)` when the argument is a literal ISO string or a literal
1537    /// field map. (ADR 0009)
1538    fn const_local_date_time(&self, arg: ExprId) -> Option<(i64, i64)> {
1539        match self.arena.get(arg) {
1540            IrExpr::Literal(IrLiteral::Str(s)) => crate::temporal::localdatetime_parts_from_str(s),
1541            IrExpr::MapLiteral(entries) => {
1542                let fields = self.extract_temporal_fields(entries)?;
1543                crate::temporal::localdatetime_parts_from_map(&fields)
1544            }
1545            _ => None,
1546        }
1547    }
1548
1549    /// Lower a runtime `localdatetime(<arg>)` to a `cypher_localdatetime_project`
1550    /// call returning `Timestamp(Nanosecond, None)`. The map's `datetime`/`date`
1551    /// anchor (or a bare `localdatetime(<expr>)`) supplies the base date and the
1552    /// `datetime`/`time` anchor the base time; the remaining fields are date and
1553    /// time overrides (a missing date defaults to the epoch, a missing time to
1554    /// midnight, so explicit fields act as construction defaults). (ADR 0009)
1555    fn lower_localdatetime_runtime(&self, arg: ExprId) -> Result<Option<DfExpr>, LoweringError> {
1556        let null = || DfExpr::Literal(ScalarValue::Null, None);
1557        let null_i64 = || DfExpr::Literal(ScalarValue::Int64(None), None);
1558        let (date_src, time_src, overrides) =
1559            if let IrExpr::MapLiteral(entries) = self.arena.get(arg) {
1560                let field = |name: &str| entries.iter().find(|(k, _)| k == name).map(|(_, v)| *v);
1561                let lower_or = |id: Option<ExprId>, default: &dyn Fn() -> DfExpr| match id {
1562                    Some(id) => self.lower(id),
1563                    None => Ok(default()),
1564                };
1565                // A `datetime:` anchor supplies BOTH base date and base time; a
1566                // `date:`/`time:` anchor supplies one each.
1567                let date_anchor = field("datetime").or_else(|| field("date"));
1568                let time_anchor = field("datetime").or_else(|| field("time"));
1569                let date_src = lower_or(date_anchor, &null)?;
1570                let time_src = lower_or(time_anchor, &null)?;
1571                let ov = |name: &str| lower_or(field(name), &null_i64);
1572                let overrides = [
1573                    ov("year")?,
1574                    ov("month")?,
1575                    ov("day")?,
1576                    ov("week")?,
1577                    ov("dayOfWeek")?,
1578                    ov("ordinalDay")?,
1579                    ov("quarter")?,
1580                    ov("dayOfQuarter")?,
1581                    ov("hour")?,
1582                    ov("minute")?,
1583                    ov("second")?,
1584                    ov("millisecond")?,
1585                    ov("microsecond")?,
1586                    ov("nanosecond")?,
1587                ];
1588                (date_src, time_src, overrides)
1589            } else {
1590                // A bare `localdatetime(<expr>)` — the value is both date and time
1591                // source, no overrides.
1592                let base = self.lower(arg)?;
1593                (base.clone(), base, std::array::from_fn(|_| null_i64()))
1594            };
1595        let mut call_args = Vec::with_capacity(16);
1596        call_args.push(date_src);
1597        call_args.push(time_src);
1598        call_args.extend(overrides);
1599        Ok(Some(CYPHER_LOCALDATETIME_PROJECT.call(call_args)))
1600    }
1601
1602    /// Resolve a `time(<arg>)` argument to constant `(nanoseconds_of_day,
1603    /// offset_seconds)` when the argument is a literal ISO string or a literal
1604    /// field map. (ADR 0009)
1605    fn const_time(&self, arg: ExprId) -> Option<(i64, i32)> {
1606        match self.arena.get(arg) {
1607            IrExpr::Literal(IrLiteral::Str(s)) => crate::temporal::time_value_from_str(s),
1608            IrExpr::MapLiteral(entries) => {
1609                let fields = self.extract_temporal_fields(entries)?;
1610                crate::temporal::time_value_from_map(&fields)
1611            }
1612            _ => None,
1613        }
1614    }
1615
1616    /// Lower a runtime `time(<arg>)` to a `cypher_time_project` call returning the
1617    /// `time` struct: `time({time: base, …overrides, timezone})` / a bare
1618    /// `time(<expr>)`. The base's time-of-day comes from a `Time64`/`time`-struct/
1619    /// `localdatetime`-struct/temporal-string; component overrides and the zone
1620    /// (`timezone`) apply on top. Returns `None` for a map without a `time` anchor
1621    /// (field-construction, not projection). (ADR 0009)
1622    fn lower_time_runtime(&self, arg: ExprId) -> Result<Option<DfExpr>, LoweringError> {
1623        let null_i64 = || DfExpr::Literal(ScalarValue::Int64(None), None);
1624        let null_str = || DfExpr::Literal(ScalarValue::Utf8(None), None);
1625        let (base, overrides, timezone) = match self.arena.get(arg) {
1626            IrExpr::MapLiteral(entries) if entries.iter().any(|(k, _)| k == "time") => {
1627                let field = |name: &str| entries.iter().find(|(k, _)| k == name).map(|(_, v)| *v);
1628                let base = self.lower(field("time").expect("checked `time` key exists"))?;
1629                let ov = |name: &str| match field(name) {
1630                    Some(id) => self.lower(id),
1631                    None => Ok(null_i64()),
1632                };
1633                let overrides = [
1634                    ov("hour")?,
1635                    ov("minute")?,
1636                    ov("second")?,
1637                    ov("millisecond")?,
1638                    ov("microsecond")?,
1639                    ov("nanosecond")?,
1640                ];
1641                let timezone = match field("timezone") {
1642                    Some(id) => self.lower(id)?,
1643                    None => null_str(),
1644                };
1645                (base, overrides, timezone)
1646            }
1647            // A map without a `time` anchor is field-construction, not
1648            // projection; and a string literal that `const_time` rejected (e.g.
1649            // no offset) must NOT be leniently re-parsed by the UDF —
1650            // `time('21:40')` is an error, not `21:40Z`. Both fall through so the
1651            // untyped path handles them.
1652            IrExpr::MapLiteral(_) | IrExpr::Literal(IrLiteral::Str(_)) => return Ok(None),
1653            _ => (
1654                self.lower(arg)?,
1655                std::array::from_fn(|_| null_i64()),
1656                null_str(),
1657            ),
1658        };
1659        let mut call_args = Vec::with_capacity(8);
1660        call_args.push(base);
1661        call_args.extend(overrides);
1662        call_args.push(timezone);
1663        Ok(Some(CYPHER_TIME_PROJECT.call(call_args)))
1664    }
1665
1666    /// Resolve a `datetime(<arg>)` argument to constant `(date_days, nanos,
1667    /// offset_seconds, zone_label)` when the argument is a literal ISO string or a
1668    /// literal field map. (ADR 0009)
1669    fn const_datetime(&self, arg: ExprId) -> Option<(i64, i64, i32, Option<String>)> {
1670        match self.arena.get(arg) {
1671            IrExpr::Literal(IrLiteral::Str(s)) => crate::temporal::datetime_value_from_str(s),
1672            IrExpr::MapLiteral(entries) => {
1673                let fields = self.extract_temporal_fields(entries)?;
1674                crate::temporal::datetime_value_from_map(&fields)
1675            }
1676            _ => None,
1677        }
1678    }
1679
1680    /// Resolve a `duration(<arg>)` argument to a constant [`DurationValue`] when
1681    /// the argument is a literal ISO string or a literal field map. (#920)
1682    fn const_duration(&self, arg: ExprId) -> Option<crate::temporal::DurationValue> {
1683        match self.arena.get(arg) {
1684            IrExpr::Literal(IrLiteral::Str(s)) => crate::temporal::duration_value_from_str(s),
1685            IrExpr::MapLiteral(entries) => {
1686                let fields = self.extract_temporal_fields(entries)?;
1687                crate::temporal::duration_value_from_map(&fields)
1688            }
1689            _ => None,
1690        }
1691    }
1692
1693    /// Lower a runtime `datetime(<arg>)` to a `cypher_datetime_project` call
1694    /// returning the `datetime` struct. The map's `datetime`/`date` anchor (or a
1695    /// bare `datetime(<expr>)`) supplies the base date, the `datetime`/`time`
1696    /// anchor the base time (and its offset/zone); the remaining fields are date
1697    /// and time overrides, and `timezone` re-zones the result. Returns `None` for
1698    /// a string literal (`const_datetime` already validated it). (ADR 0009)
1699    fn lower_datetime_runtime(&self, arg: ExprId) -> Result<Option<DfExpr>, LoweringError> {
1700        let null = || DfExpr::Literal(ScalarValue::Null, None);
1701        let null_i64 = || DfExpr::Literal(ScalarValue::Int64(None), None);
1702        let null_str = || DfExpr::Literal(ScalarValue::Utf8(None), None);
1703        let (date_src, time_src, overrides, timezone) = match self.arena.get(arg) {
1704            IrExpr::MapLiteral(entries) => {
1705                let field = |name: &str| entries.iter().find(|(k, _)| k == name).map(|(_, v)| *v);
1706                let lower_or = |id: Option<ExprId>, default: &dyn Fn() -> DfExpr| match id {
1707                    Some(id) => self.lower(id),
1708                    None => Ok(default()),
1709                };
1710                let date_anchor = field("datetime").or_else(|| field("date"));
1711                let time_anchor = field("datetime").or_else(|| field("time"));
1712                let date_src = lower_or(date_anchor, &null)?;
1713                let time_src = lower_or(time_anchor, &null)?;
1714                let ov = |name: &str| lower_or(field(name), &null_i64);
1715                let overrides = [
1716                    ov("year")?,
1717                    ov("month")?,
1718                    ov("day")?,
1719                    ov("week")?,
1720                    ov("dayOfWeek")?,
1721                    ov("ordinalDay")?,
1722                    ov("quarter")?,
1723                    ov("dayOfQuarter")?,
1724                    ov("hour")?,
1725                    ov("minute")?,
1726                    ov("second")?,
1727                    ov("millisecond")?,
1728                    ov("microsecond")?,
1729                    ov("nanosecond")?,
1730                ];
1731                (
1732                    date_src,
1733                    time_src,
1734                    overrides,
1735                    lower_or(field("timezone"), &null_str)?,
1736                )
1737            }
1738            // A string literal that `const_datetime` rejected must not be lenient-
1739            // projected; fall through to the untyped path.
1740            IrExpr::Literal(IrLiteral::Str(_)) => return Ok(None),
1741            _ => {
1742                let base = self.lower(arg)?;
1743                (
1744                    base.clone(),
1745                    base,
1746                    std::array::from_fn(|_| null_i64()),
1747                    null_str(),
1748                )
1749            }
1750        };
1751        let mut call_args = Vec::with_capacity(17);
1752        call_args.push(date_src);
1753        call_args.push(time_src);
1754        call_args.extend(overrides);
1755        call_args.push(timezone);
1756        Ok(Some(CYPHER_DATETIME_PROJECT.call(call_args)))
1757    }
1758
1759    /// Lower `date.truncate(unit, value [, map])` to a `cypher_date_truncate`
1760    /// call (`Temporal9`): truncate `value`'s date to `unit`, then apply the
1761    /// optional override map's components. (#920)
1762    fn lower_date_truncate(&self, args: &[ExprId]) -> Result<DfExpr, LoweringError> {
1763        let null_i64 = || DfExpr::Literal(ScalarValue::Int64(None), None);
1764        let [unit_id, value_id, rest @ ..] = args else {
1765            return Err(LoweringError::UnknownFunction("date.truncate".to_string()));
1766        };
1767        let unit = self.lower(*unit_id)?;
1768        let value = self.lower(*value_id)?;
1769        // The optional third argument is a component-override map. Overrides are
1770        // extracted at lowering time, so only a *literal* map is supported; a
1771        // non-literal third argument (e.g. `$m` or a variable) would otherwise
1772        // be silently dropped and return a subtly wrong date — error instead.
1773        let overrides: [DfExpr; 8] = match rest.first() {
1774            None => std::array::from_fn(|_| null_i64()),
1775            Some(map_id) => {
1776                let IrExpr::MapLiteral(entries) = self.arena.get(*map_id) else {
1777                    return Err(LoweringError::UnsupportedExpr(
1778                        "date.truncate override map must be a literal map".to_string(),
1779                    ));
1780                };
1781                let field = |name: &str| entries.iter().find(|(k, _)| k == name).map(|(_, v)| *v);
1782                let ov = |name: &str| match field(name) {
1783                    Some(id) => self.lower(id),
1784                    None => Ok(null_i64()),
1785                };
1786                [
1787                    ov("year")?,
1788                    ov("month")?,
1789                    ov("day")?,
1790                    ov("week")?,
1791                    ov("dayOfWeek")?,
1792                    ov("ordinalDay")?,
1793                    ov("quarter")?,
1794                    ov("dayOfQuarter")?,
1795                ]
1796            }
1797        };
1798        let mut call_args = Vec::with_capacity(10);
1799        call_args.push(value);
1800        call_args.push(unit);
1801        call_args.extend(overrides);
1802        Ok(CYPHER_DATE_TRUNCATE.call(call_args))
1803    }
1804
1805    /// Lower `localtime.truncate(unit, value [, map])` to a `cypher_localtime_truncate`
1806    /// call (`Temporal9`): truncate `value`'s time-of-day to `unit`, then apply the
1807    /// optional override map's time components. (#920)
1808    fn lower_localtime_truncate(&self, args: &[ExprId]) -> Result<DfExpr, LoweringError> {
1809        let null_i64 = || DfExpr::Literal(ScalarValue::Int64(None), None);
1810        let [unit_id, value_id, rest @ ..] = args else {
1811            return Err(LoweringError::UnknownFunction(
1812                "localtime.truncate".to_string(),
1813            ));
1814        };
1815        let unit = self.lower(*unit_id)?;
1816        let value = self.lower(*value_id)?;
1817        // Optional third arg is a literal component-override map (see `lower_date_truncate`).
1818        let overrides: [DfExpr; 6] = match rest.first() {
1819            None => std::array::from_fn(|_| null_i64()),
1820            Some(map_id) => {
1821                let IrExpr::MapLiteral(entries) = self.arena.get(*map_id) else {
1822                    return Err(LoweringError::UnsupportedExpr(
1823                        "localtime.truncate override map must be a literal map".to_string(),
1824                    ));
1825                };
1826                let field = |name: &str| entries.iter().find(|(k, _)| k == name).map(|(_, v)| *v);
1827                let ov = |name: &str| match field(name) {
1828                    Some(id) => self.lower(id),
1829                    None => Ok(null_i64()),
1830                };
1831                [
1832                    ov("hour")?,
1833                    ov("minute")?,
1834                    ov("second")?,
1835                    ov("millisecond")?,
1836                    ov("microsecond")?,
1837                    ov("nanosecond")?,
1838                ]
1839            }
1840        };
1841        let mut call_args = Vec::with_capacity(8);
1842        call_args.push(value);
1843        call_args.push(unit);
1844        call_args.extend(overrides);
1845        Ok(CYPHER_LOCALTIME_TRUNCATE.call(call_args))
1846    }
1847
1848    /// Lower `localdatetime.truncate(unit, value [, map])` to a
1849    /// `cypher_localdatetime_truncate` call (`Temporal9`): truncate `value` to
1850    /// `unit` (date component for day-and-coarser units, time component for finer
1851    /// units), then apply the optional override map's date + time components. (#920)
1852    fn lower_localdatetime_truncate(&self, args: &[ExprId]) -> Result<DfExpr, LoweringError> {
1853        let null_i64 = || DfExpr::Literal(ScalarValue::Int64(None), None);
1854        let [unit_id, value_id, rest @ ..] = args else {
1855            return Err(LoweringError::UnknownFunction(
1856                "localdatetime.truncate".to_string(),
1857            ));
1858        };
1859        let unit = self.lower(*unit_id)?;
1860        let value = self.lower(*value_id)?;
1861        // Optional third arg is a literal override map carrying date AND time
1862        // components (see `lower_date_truncate`).
1863        let overrides: [DfExpr; 14] = match rest.first() {
1864            None => std::array::from_fn(|_| null_i64()),
1865            Some(map_id) => {
1866                let IrExpr::MapLiteral(entries) = self.arena.get(*map_id) else {
1867                    return Err(LoweringError::UnsupportedExpr(
1868                        "localdatetime.truncate override map must be a literal map".to_string(),
1869                    ));
1870                };
1871                let field = |name: &str| entries.iter().find(|(k, _)| k == name).map(|(_, v)| *v);
1872                let ov = |name: &str| match field(name) {
1873                    Some(id) => self.lower(id),
1874                    None => Ok(null_i64()),
1875                };
1876                [
1877                    ov("year")?,
1878                    ov("month")?,
1879                    ov("day")?,
1880                    ov("week")?,
1881                    ov("dayOfWeek")?,
1882                    ov("ordinalDay")?,
1883                    ov("quarter")?,
1884                    ov("dayOfQuarter")?,
1885                    ov("hour")?,
1886                    ov("minute")?,
1887                    ov("second")?,
1888                    ov("millisecond")?,
1889                    ov("microsecond")?,
1890                    ov("nanosecond")?,
1891                ]
1892            }
1893        };
1894        let mut call_args = Vec::with_capacity(16);
1895        call_args.push(value);
1896        call_args.push(unit);
1897        call_args.extend(overrides);
1898        Ok(CYPHER_LOCALDATETIME_TRUNCATE.call(call_args))
1899    }
1900
1901    /// Lower `time.truncate(unit, value [, map])` to a `cypher_time_truncate` call
1902    /// (`Temporal9`): truncate `value`'s time-of-day to `unit` (keeping its zone
1903    /// offset), then apply the override map's time components and optional
1904    /// `timezone`. (#920)
1905    fn lower_time_truncate(&self, args: &[ExprId]) -> Result<DfExpr, LoweringError> {
1906        let null_i64 = || DfExpr::Literal(ScalarValue::Int64(None), None);
1907        let null_str = || DfExpr::Literal(ScalarValue::Utf8(None), None);
1908        let [unit_id, value_id, rest @ ..] = args else {
1909            return Err(LoweringError::UnknownFunction("time.truncate".to_string()));
1910        };
1911        let unit = self.lower(*unit_id)?;
1912        let value = self.lower(*value_id)?;
1913        let (overrides, timezone): ([DfExpr; 6], DfExpr) = match rest.first() {
1914            None => (std::array::from_fn(|_| null_i64()), null_str()),
1915            Some(map_id) => {
1916                let IrExpr::MapLiteral(entries) = self.arena.get(*map_id) else {
1917                    return Err(LoweringError::UnsupportedExpr(
1918                        "time.truncate override map must be a literal map".to_string(),
1919                    ));
1920                };
1921                let field = |name: &str| entries.iter().find(|(k, _)| k == name).map(|(_, v)| *v);
1922                let ov = |name: &str| match field(name) {
1923                    Some(id) => self.lower(id),
1924                    None => Ok(null_i64()),
1925                };
1926                let tz = match field("timezone") {
1927                    Some(id) => self.lower(id)?,
1928                    None => null_str(),
1929                };
1930                (
1931                    [
1932                        ov("hour")?,
1933                        ov("minute")?,
1934                        ov("second")?,
1935                        ov("millisecond")?,
1936                        ov("microsecond")?,
1937                        ov("nanosecond")?,
1938                    ],
1939                    tz,
1940                )
1941            }
1942        };
1943        let mut call_args = Vec::with_capacity(9);
1944        call_args.push(value);
1945        call_args.push(unit);
1946        call_args.extend(overrides);
1947        call_args.push(timezone);
1948        Ok(CYPHER_TIME_TRUNCATE.call(call_args))
1949    }
1950
1951    /// Lower `datetime.truncate(unit, value [, map])` to a `cypher_datetime_truncate`
1952    /// call (`Temporal9`): truncate `value` to `unit` (date for day-and-coarser
1953    /// units, time for finer units; keeping its zone), then apply the override
1954    /// map's date + time components and optional `timezone`. (#920)
1955    fn lower_datetime_truncate(&self, args: &[ExprId]) -> Result<DfExpr, LoweringError> {
1956        let null_i64 = || DfExpr::Literal(ScalarValue::Int64(None), None);
1957        let null_str = || DfExpr::Literal(ScalarValue::Utf8(None), None);
1958        let [unit_id, value_id, rest @ ..] = args else {
1959            return Err(LoweringError::UnknownFunction(
1960                "datetime.truncate".to_string(),
1961            ));
1962        };
1963        let unit = self.lower(*unit_id)?;
1964        let value = self.lower(*value_id)?;
1965        let (overrides, timezone): ([DfExpr; 14], DfExpr) = match rest.first() {
1966            None => (std::array::from_fn(|_| null_i64()), null_str()),
1967            Some(map_id) => {
1968                let IrExpr::MapLiteral(entries) = self.arena.get(*map_id) else {
1969                    return Err(LoweringError::UnsupportedExpr(
1970                        "datetime.truncate override map must be a literal map".to_string(),
1971                    ));
1972                };
1973                let field = |name: &str| entries.iter().find(|(k, _)| k == name).map(|(_, v)| *v);
1974                let ov = |name: &str| match field(name) {
1975                    Some(id) => self.lower(id),
1976                    None => Ok(null_i64()),
1977                };
1978                let tz = match field("timezone") {
1979                    Some(id) => self.lower(id)?,
1980                    None => null_str(),
1981                };
1982                (
1983                    [
1984                        ov("year")?,
1985                        ov("month")?,
1986                        ov("day")?,
1987                        ov("week")?,
1988                        ov("dayOfWeek")?,
1989                        ov("ordinalDay")?,
1990                        ov("quarter")?,
1991                        ov("dayOfQuarter")?,
1992                        ov("hour")?,
1993                        ov("minute")?,
1994                        ov("second")?,
1995                        ov("millisecond")?,
1996                        ov("microsecond")?,
1997                        ov("nanosecond")?,
1998                    ],
1999                    tz,
2000                )
2001            }
2002        };
2003        let mut call_args = Vec::with_capacity(17);
2004        call_args.push(value);
2005        call_args.push(unit);
2006        call_args.extend(overrides);
2007        call_args.push(timezone);
2008        Ok(CYPHER_DATETIME_TRUNCATE.call(call_args))
2009    }
2010
2011    /// Lower `duration.between(a, b)` / `inMonths` / `inDays` / `inSeconds` to a
2012    /// `cypher_duration_between` call `[a, b, mode]`, where `mode` is the
2013    /// (lowercased) function name. (#920)
2014    fn lower_duration_between(&self, name: &str, args: &[ExprId]) -> Result<DfExpr, LoweringError> {
2015        let [a_id, b_id] = args else {
2016            return Err(LoweringError::UnknownFunction(name.to_string()));
2017        };
2018        let a = self.lower(*a_id)?;
2019        let b = self.lower(*b_id)?;
2020        Ok(CYPHER_DURATION_BETWEEN.call(vec![a, b, lit(name)]))
2021    }
2022
2023    fn extract_temporal_fields(
2024        &self,
2025        entries: &[(String, ExprId)],
2026    ) -> Option<std::collections::HashMap<String, crate::temporal::TemporalField>> {
2027        let mut fields = std::collections::HashMap::with_capacity(entries.len());
2028        for (key, value) in entries {
2029            fields.insert(key.clone(), self.extract_temporal_field(*value)?);
2030        }
2031        Some(fields)
2032    }
2033
2034    /// Read a single temporal map field value as a constant. A nested `date(…)`
2035    /// anchor (the `Temporal1` week forms) is rendered and re-parsed to a date.
2036    fn extract_temporal_field(&self, id: ExprId) -> Option<crate::temporal::TemporalField> {
2037        use crate::temporal::TemporalField;
2038        use graphforge_ir::expr::UnaryOpKind;
2039        match self.arena.get(id) {
2040            IrExpr::Literal(IrLiteral::Int(n)) => Some(TemporalField::Int(*n)),
2041            IrExpr::Literal(IrLiteral::Float(x)) => Some(TemporalField::Float(*x)),
2042            IrExpr::Literal(IrLiteral::Str(s)) => Some(TemporalField::Str(s.clone())),
2043            // A negative field (`days: -14`) lowers to unary-minus over a literal,
2044            // not a negative literal — fold it so the map still constant-folds.
2045            IrExpr::UnaryOp {
2046                op: UnaryOpKind::Neg,
2047                expr,
2048            } => match self.arena.get(*expr) {
2049                IrExpr::Literal(IrLiteral::Int(n)) => Some(TemporalField::Int(-n)),
2050                IrExpr::Literal(IrLiteral::Float(x)) => Some(TemporalField::Float(-x)),
2051                _ => None,
2052            },
2053            IrExpr::FunctionCall { name, args } if name == "date" => {
2054                if let [a] = args.as_slice()
2055                    && let IrExpr::Literal(IrLiteral::Str(s)) = self.arena.get(*a)
2056                {
2057                    crate::temporal::parse_date_string(s).map(TemporalField::Date)
2058                } else {
2059                    None
2060                }
2061            }
2062            _ => None,
2063        }
2064    }
2065
2066    /// Lower `datetime.fromepoch(seconds, nanoseconds)` /
2067    /// `datetime.fromepochmillis(milliseconds)` from integer-literal arguments
2068    /// to the canonical UTC datetime string. (#599)
2069    fn lower_from_epoch(&self, name: &str, args: &[ExprId]) -> Result<DfExpr, LoweringError> {
2070        self.try_from_epoch(name, args)
2071            .map(lit)
2072            .ok_or_else(|| LoweringError::UnknownFunction(name.to_string()))
2073    }
2074
2075    fn try_from_epoch(&self, name: &str, args: &[ExprId]) -> Option<String> {
2076        match (name, args) {
2077            ("datetime.fromepoch", &[a, b]) => {
2078                crate::temporal::render_from_epoch(self.int_literal(a)?, self.int_literal(b)?)
2079            }
2080            ("datetime.fromepochmillis", &[a]) => {
2081                crate::temporal::render_from_epoch_millis(self.int_literal(a)?)
2082            }
2083            _ => None,
2084        }
2085    }
2086
2087    /// Read an integer literal argument, or `None` if it isn't one.
2088    fn int_literal(&self, id: ExprId) -> Option<i64> {
2089        match self.arena.get(id) {
2090            IrExpr::Literal(IrLiteral::Int(n)) => Some(*n),
2091            _ => None,
2092        }
2093    }
2094
2095    // -----------------------------------------------------------------------
2096    // Helpers
2097    // -----------------------------------------------------------------------
2098
2099    /// Lower a `_node_struct(VarRef)` call (emitted by the binder for a bare
2100    /// `RETURN n`, #785) into a whole node value — `Struct{node_uuid, labels,
2101    /// <props…>}`. The node's shape (label + property columns) comes from the
2102    /// `node_shapes` map this lowerer was seeded with; an absent shape yields a
2103    /// uuid-only struct.
2104    fn lower_node_struct(&self, args: &[ExprId]) -> Result<DfExpr, LoweringError> {
2105        // args[0] = the node `VarRef`; args[1] (optional) = a `Str` literal label
2106        // captured from the bind-time pattern.
2107        let Some(&base_id) = args.first() else {
2108            return Err(LoweringError::UnsupportedExpr(
2109                "_node_struct expects at least one argument".into(),
2110            ));
2111        };
2112        let IrExpr::VarRef(var_id) = self.arena.get(base_id) else {
2113            return Err(LoweringError::UnsupportedExpr(
2114                "_node_struct argument must be a node variable".into(),
2115            ));
2116        };
2117        let base = self
2118            .var_map
2119            .get(*var_id)
2120            .ok_or(LoweringError::UnboundVar(var_id.0))?;
2121        let label = args.get(1).and_then(|&id| match self.arena.get(id) {
2122            IrExpr::Literal(IrLiteral::Str(s)) => Some(s.as_str()),
2123            _ => None,
2124        });
2125        let prop_names = self
2126            .node_shapes
2127            .get(&var_id.0)
2128            .map(|s| s.prop_names.clone())
2129            .unwrap_or_default();
2130        let labels = self.input_schema.as_ref().and_then(|schema| {
2131            let qualifier = datafusion::common::TableReference::bare(base);
2132            schema
2133                .index_of_column_by_name(Some(&qualifier), "labels")
2134                .is_some()
2135                .then(|| qualified_col(base, "labels"))
2136        });
2137        Ok(labels.map_or_else(
2138            || node_value_struct(base, label, &self.type_id_to_entity_name, &prop_names),
2139            |labels| node_value_struct_with_labels(base, labels, &prop_names),
2140        ))
2141    }
2142
2143    fn lower_node_struct_list(&self, args: &[ExprId]) -> Result<DfExpr, LoweringError> {
2144        use datafusion::functions_nested::expr_fn::make_array;
2145
2146        let [first, second, edge] = args else {
2147            return Err(LoweringError::UnsupportedExpr(
2148                "_node_struct_list expects two nodes and one relationship".into(),
2149            ));
2150        };
2151        let node_vars = [first, second].map(|id| match self.arena.get(*id) {
2152            IrExpr::VarRef(var) => Ok(*var),
2153            _ => Err(LoweringError::UnsupportedExpr(
2154                "_node_struct_list node arguments must be variables".into(),
2155            )),
2156        });
2157        let [first_var, second_var] = node_vars;
2158        let node_vars = [first_var?, second_var?];
2159        let prop_names = node_vars
2160            .iter()
2161            .filter_map(|var| self.node_shapes.get(&var.0))
2162            .flat_map(|shape| shape.prop_names.iter().cloned())
2163            .collect::<std::collections::BTreeSet<_>>()
2164            .into_iter()
2165            .collect::<Vec<_>>();
2166        let nodes = node_vars
2167            .iter()
2168            .map(|var| {
2169                let base = self
2170                    .var_map
2171                    .get(*var)
2172                    .ok_or(LoweringError::UnboundVar(var.0))?;
2173                Ok(node_value_struct(
2174                    base,
2175                    None,
2176                    &self.type_id_to_entity_name,
2177                    &prop_names,
2178                ))
2179            })
2180            .collect::<Result<Vec<_>, LoweringError>>()?;
2181        let edge = self.lower_path_builtin_arg(*edge)?;
2182        let present = edge_present(&edge).ok_or_else(|| {
2183            LoweringError::UnsupportedExpr(
2184                "_node_struct_list relationship argument must be a bound edge".into(),
2185            )
2186        })?;
2187        Ok(null_unless(present, make_array(nodes)))
2188    }
2189
2190    fn lower_rel_struct(&self, args: &[ExprId]) -> Result<DfExpr, LoweringError> {
2191        let (base, rel_type) = self.lower_rel_struct_args(args)?;
2192        let props = self.edge_prop_names(base);
2193        let value = relationship_value_struct(base, rel_type, &props);
2194        Ok(null_unless(edge_present_qual(base), value))
2195    }
2196
2197    fn lower_rel_struct_list(&self, args: &[ExprId]) -> Result<DfExpr, LoweringError> {
2198        use datafusion::functions_nested::expr_fn::make_array;
2199
2200        let (base, rel_type) = self.lower_rel_struct_args(args)?;
2201        let props = self.edge_prop_names(base);
2202        let value = relationship_value_struct(base, rel_type, &props);
2203        Ok(null_unless(
2204            edge_present_qual(base),
2205            make_array(vec![value]),
2206        ))
2207    }
2208
2209    fn lower_rel_struct_args(&self, args: &[ExprId]) -> Result<(&str, DfExpr), LoweringError> {
2210        let Some(&base_id) = args.first() else {
2211            return Err(LoweringError::UnsupportedExpr(
2212                "_rel_struct expects an edge variable".into(),
2213            ));
2214        };
2215        let IrExpr::VarRef(var_id) = self.arena.get(base_id) else {
2216            return Err(LoweringError::UnsupportedExpr(
2217                "_rel_struct argument must be a relationship variable".into(),
2218            ));
2219        };
2220        let base = self
2221            .var_map
2222            .get(*var_id)
2223            .ok_or(LoweringError::UnboundVar(var_id.0))?;
2224        let rel_type = match args.get(1).map(|&id| (id, self.arena.get(id))) {
2225            Some((_, IrExpr::Literal(IrLiteral::Null))) | None => {
2226                col(format!("{base}.rel_type_name"))
2227            }
2228            Some((id, _)) => self.lower(id)?,
2229        };
2230        Ok((base, rel_type))
2231    }
2232
2233    fn edge_prop_names(&self, base: &str) -> Vec<String> {
2234        let Some(schema) = self.input_schema.as_ref() else {
2235            return Vec::new();
2236        };
2237        schema
2238            .iter()
2239            .filter_map(|(qualifier, field)| {
2240                let q = qualifier?;
2241                if q.to_string() != base
2242                    || is_edge_value_topology_field(field.name())
2243                    || matches!(field.data_type(), DataType::Null)
2244                {
2245                    return None;
2246                }
2247                Some(field.name().clone())
2248            })
2249            .collect()
2250    }
2251
2252    /// `labels(node)` — the node's complete label set as a list, with
2253    /// optional/unmatched nodes and `labels(null)` propagating to null.
2254    fn lower_labels(&self, args: &[ExprId]) -> Result<DfExpr, LoweringError> {
2255        let Some(&base_id) = args.first() else {
2256            return Err(LoweringError::UnsupportedExpr(
2257                "labels() expects one argument".into(),
2258            ));
2259        };
2260        match self.arena.get(base_id) {
2261            IrExpr::Literal(IrLiteral::Null) => Ok(null_utf8_list()),
2262            IrExpr::VarRef(var_id) if self.node_shapes.contains_key(&var_id.0) => {
2263                let base = self
2264                    .var_map
2265                    .get(*var_id)
2266                    .ok_or(LoweringError::UnboundVar(var_id.0))?;
2267                Ok(null_unless(
2268                    col(format!("{base}.node_uuid")).is_not_null(),
2269                    node_labels_list(base, None, &self.type_id_to_entity_name),
2270                ))
2271            }
2272            IrExpr::VarRef(var_id) => {
2273                let base = self
2274                    .var_map
2275                    .get(*var_id)
2276                    .ok_or(LoweringError::UnboundVar(var_id.0))?;
2277                if self.is_node_var(base) {
2278                    Ok(null_unless(
2279                        col(format!("{base}.node_uuid")).is_not_null(),
2280                        node_labels_list(base, None, &self.type_id_to_entity_name),
2281                    ))
2282                } else {
2283                    let value = self.lower(base_id)?;
2284                    Ok(CYPHER_LABELS.call(vec![value]))
2285                }
2286            }
2287            _ => {
2288                let value = self.lower(base_id)?;
2289                Ok(CYPHER_LABELS.call(vec![value]))
2290            }
2291        }
2292    }
2293
2294    /// `keys(map|node|relationship)` — map keys include null-valued entries;
2295    /// entity keys include only non-null stored property columns for each row.
2296    fn lower_keys(&self, args: &[ExprId]) -> Result<DfExpr, LoweringError> {
2297        use datafusion::functions_nested::expr_fn::{array_concat, make_array};
2298
2299        let Some(&base_id) = args.first() else {
2300            return Err(LoweringError::UnsupportedExpr(
2301                "keys() expects one argument".into(),
2302            ));
2303        };
2304        if let Some(map_keys) = self.lower_map_keys(base_id)? {
2305            return Ok(map_keys);
2306        }
2307        let var_id = match self.arena.get(base_id) {
2308            IrExpr::VarRef(var_id) => *var_id,
2309            _ => {
2310                return Err(LoweringError::InvalidType(
2311                    "keys() requires a map, node, relationship, or null".into(),
2312                ));
2313            }
2314        };
2315        let base = self
2316            .var_map
2317            .get(var_id)
2318            .ok_or(LoweringError::UnboundVar(var_id.0))?;
2319        let (prop_names, present) = if let Some(shape) = self.node_shapes.get(&var_id.0) {
2320            let has_node_uuid = self.input_schema.as_ref().is_some_and(|schema| {
2321                let qual = datafusion::common::TableReference::bare(base);
2322                schema
2323                    .index_of_column_by_name(Some(&qual), "node_uuid")
2324                    .is_some()
2325            });
2326            let node_present = if has_node_uuid {
2327                col(format!("{base}.node_uuid")).is_not_null()
2328            } else {
2329                lit(true)
2330            };
2331            (shape.prop_names.clone(), node_present)
2332        } else if self.is_edge_var(base) {
2333            (self.edge_prop_names(base), edge_present_qual(base))
2334        } else {
2335            return Err(LoweringError::UnsupportedExpr(
2336                "keys() requires an entity with a known shape".into(),
2337            ));
2338        };
2339        // An empty `List<Utf8>` — the result for a node with no properties, and
2340        // the "absent" branch each property folds in.
2341        let empty = empty_utf8_list();
2342        let parts: Vec<DfExpr> = prop_names
2343            .iter()
2344            .map(|p| {
2345                when(
2346                    qualified_col(base, p).is_not_null(),
2347                    make_array(vec![lit(p.as_str())]),
2348                )
2349                .otherwise(empty.clone())
2350                .expect("CASE build")
2351            })
2352            .collect();
2353        let value = parts
2354            .into_iter()
2355            .reduce(|acc, part| array_concat(vec![acc, part]))
2356            .unwrap_or(empty);
2357        Ok(null_unless(present, value))
2358    }
2359
2360    fn lower_map_keys(&self, base_id: ExprId) -> Result<Option<DfExpr>, LoweringError> {
2361        match self.arena.get(base_id) {
2362            IrExpr::Literal(IrLiteral::Null) => return Ok(Some(null_utf8_list())),
2363            IrExpr::MapLiteral(_) => {
2364                return Ok(Some(CYPHER_MAP_KEYS.call(vec![self.lower(base_id)?])));
2365            }
2366            IrExpr::ListLiteral(_) => {
2367                return Err(LoweringError::InvalidType(
2368                    "keys() requires a map, node, relationship, or null".into(),
2369                ));
2370            }
2371            _ => {}
2372        }
2373        if let IrExpr::VarRef(var_id) = self.arena.get(base_id)
2374            && let Some(base) = self.var_map.get(*var_id)
2375        {
2376            if self.node_shapes.contains_key(&var_id.0) || self.is_edge_var(base) {
2377                return Ok(None);
2378            }
2379            if let Some(schema) = self.input_schema.as_ref()
2380                && let Ok(field) = schema.field_with_unqualified_name(base)
2381            {
2382                if matches!(field.data_type(), DataType::Null)
2383                    || is_plain_map_struct_type(field.data_type())
2384                    || is_het_struct_type(Some(field.data_type()))
2385                {
2386                    return Ok(Some(CYPHER_MAP_KEYS.call(vec![col_literal(base)])));
2387                }
2388                return Ok(None);
2389            }
2390        }
2391        let value = self.lower(base_id)?;
2392        if let Some(dt) = self.expr_data_type(&value) {
2393            if matches!(dt, DataType::Null)
2394                || is_plain_map_struct_type(&dt)
2395                || is_het_struct_type(Some(&dt))
2396            {
2397                return Ok(Some(CYPHER_MAP_KEYS.call(vec![value])));
2398            }
2399            return Err(LoweringError::InvalidType(
2400                "keys() requires a map, node, relationship, or null".into(),
2401            ));
2402        }
2403        Ok(Some(CYPHER_MAP_KEYS.call(vec![value])))
2404    }
2405
2406    fn lower_properties(&self, args: &[ExprId]) -> Result<DfExpr, LoweringError> {
2407        let Some(&base_id) = args.first() else {
2408            return Err(LoweringError::UnsupportedExpr(
2409                "properties() expects one argument".into(),
2410            ));
2411        };
2412        match self.arena.get(base_id) {
2413            IrExpr::Literal(IrLiteral::Null) => return Ok(lit(ScalarValue::Null)),
2414            IrExpr::MapLiteral(_) => return self.lower(base_id),
2415            IrExpr::ListLiteral(_) => {
2416                return Err(LoweringError::InvalidType(
2417                    "properties() requires a map, node, relationship, or null".into(),
2418                ));
2419            }
2420            _ => {}
2421        }
2422        if let IrExpr::VarRef(var_id) = self.arena.get(base_id)
2423            && let Some(base) = self.var_map.get(*var_id)
2424        {
2425            if let Some(value) = self.entity_property_bag_with_empty(*var_id, base) {
2426                return Ok(value);
2427            }
2428            if let Some(schema) = self.input_schema.as_ref()
2429                && let Ok(field) = schema.field_with_unqualified_name(base)
2430            {
2431                return match field.data_type() {
2432                    DataType::Null => Ok(lit(ScalarValue::Null)),
2433                    dt if is_plain_map_struct_type(dt) => Ok(col_literal(base)),
2434                    _ => Err(LoweringError::InvalidType(
2435                        "properties() requires a map, node, relationship, or null".into(),
2436                    )),
2437                };
2438            }
2439        }
2440        let value = self.lower(base_id)?;
2441        if let Some(dt) = self.expr_data_type(&value) {
2442            return match dt {
2443                DataType::Null => Ok(lit(ScalarValue::Null)),
2444                dt if is_plain_map_struct_type(&dt) => Ok(value),
2445                _ => Err(LoweringError::InvalidType(
2446                    "properties() requires a map, node, relationship, or null".into(),
2447                )),
2448            };
2449        }
2450        Ok(value)
2451    }
2452
2453    fn is_edge_var(&self, base: &str) -> bool {
2454        let Some(schema) = self.input_schema.as_ref() else {
2455            return false;
2456        };
2457        let qual = datafusion::common::TableReference::bare(base);
2458        schema
2459            .index_of_column_by_name(Some(&qual), "edge_uuid")
2460            .is_some()
2461    }
2462
2463    fn is_node_var(&self, base: &str) -> bool {
2464        let Some(schema) = self.input_schema.as_ref() else {
2465            return false;
2466        };
2467        let qual = datafusion::common::TableReference::bare(base);
2468        schema
2469            .index_of_column_by_name(Some(&qual), "node_uuid")
2470            .is_some()
2471    }
2472
2473    /// `col("var_<v>.node_uuid")` / `col("var_<v>.edge_uuid")` when `id` is a
2474    /// bare `VarRef` to an entity variable — the entity's identity, for
2475    /// comparisons. A bare entity var is a multi-column qualifier with no scalar
2476    /// lowering, so its identity column is the comparison contract (#598/#962).
2477    fn identity_uuid_of(&self, id: ExprId) -> Option<(EntityIdentityKind, DfExpr)> {
2478        if let IrExpr::VarRef(v) = self.arena.get(id)
2479            && self.node_shapes.contains_key(&v.0)
2480        {
2481            let base = self.var_map.get(*v)?;
2482            return Some((EntityIdentityKind::Node, col(format!("{base}.node_uuid"))));
2483        }
2484        if let IrExpr::VarRef(v) = self.arena.get(id) {
2485            let base = self.var_map.get(*v)?;
2486            let qual = datafusion::common::TableReference::bare(base);
2487            if let Some(schema) = self.input_schema.as_ref()
2488                && schema
2489                    .index_of_column_by_name(Some(&qual), "edge_uuid")
2490                    .is_some()
2491            {
2492                return Some((EntityIdentityKind::Edge, col(format!("{base}.edge_uuid"))));
2493            }
2494        }
2495        None
2496    }
2497
2498    /// Whether a lowered expression is list-typed — a list literal, or a column
2499    /// whose type in the input schema is a `List`/`LargeList`/`FixedSizeList`.
2500    /// Drives `+`'s list-concatenation path (#957).
2501    fn is_list_typed(&self, e: &DfExpr) -> bool {
2502        if is_list_literal(e) {
2503            return true;
2504        }
2505        if let Some(schema) = self.input_schema.as_ref()
2506            && let Ok(dt) = e.get_type(schema)
2507        {
2508            return matches!(
2509                dt,
2510                DataType::List(_) | DataType::LargeList(_) | DataType::FixedSizeList(_, _)
2511            );
2512        }
2513        false
2514    }
2515
2516    /// Whether a lowered expression is string-typed — a `Utf8` literal, or a
2517    /// column whose type in the input schema is `Utf8`/`LargeUtf8`. Drives `+`'s
2518    /// string-concatenation path (#957).
2519    fn is_string_typed(&self, e: &DfExpr) -> bool {
2520        if matches!(
2521            e,
2522            DfExpr::Literal(ScalarValue::Utf8(_) | ScalarValue::LargeUtf8(_), _)
2523        ) {
2524            return true;
2525        }
2526        if let Some(schema) = self.input_schema.as_ref()
2527            && let Ok(dt) = e.get_type(schema)
2528        {
2529            return matches!(
2530                dt,
2531                DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View
2532            );
2533        }
2534        false
2535    }
2536
2537    fn is_known_non_string(&self, e: &DfExpr) -> bool {
2538        if is_list_literal(e)
2539            || matches!(e, DfExpr::ScalarFunction(f) if f.func.name() == "named_struct")
2540        {
2541            return true;
2542        }
2543        self.expr_data_type(e).is_some_and(|dt| {
2544            !matches!(
2545                dt,
2546                DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View | DataType::Null
2547            )
2548        })
2549    }
2550
2551    /// The Arrow type of a lowered expression — a literal's type, or a column's
2552    /// type from the input schema. Drives temporal±duration dispatch (#920).
2553    fn expr_data_type(&self, e: &DfExpr) -> Option<DataType> {
2554        if let DfExpr::Literal(sv, _) = e {
2555            return Some(sv.data_type());
2556        }
2557        self.input_schema.as_ref().and_then(|s| e.get_type(s).ok())
2558    }
2559
2560    /// Whether a lowered expression's type is statically KNOWN to be
2561    /// non-boolean — so a boolean operator (`AND`/`OR`/`XOR`/`NOT`) over it is a
2562    /// compile-time type error (openCypher `InvalidArgumentType`, #956).
2563    ///
2564    /// Conservative: an UNKNOWN type (a parameter, or an untyped property with
2565    /// no input-schema entry) returns `false` — Cypher rejects only a PROVEN
2566    /// mismatch. `Null` also returns `false` (three-valued logic: `null AND x`
2567    /// is valid).
2568    fn is_known_non_bool(&self, e: &DfExpr) -> bool {
2569        // A list or map literal is a known composite value, never a boolean
2570        // (`NOT {k: v}` / `[1] AND x`). A map lowers to a `named_struct` call,
2571        // whose type `expr_data_type` cannot see, so match it directly.
2572        if is_list_literal(e)
2573            || matches!(e, DfExpr::ScalarFunction(f) if f.func.name() == "named_struct")
2574        {
2575            return true;
2576        }
2577        !matches!(
2578            self.expr_data_type(e),
2579            None | Some(DataType::Boolean | DataType::Null)
2580        )
2581    }
2582
2583    /// Whether a lowered expression's type is statically KNOWN to be
2584    /// non-numeric — so arithmetic that requires a number (unary `-`, `%`, `^`)
2585    /// over it is a compile-time type error (#956). Unknown types and `Null`
2586    /// return `false` (same conservative rule as [`is_known_non_bool`]).
2587    fn is_known_non_numeric(&self, e: &DfExpr) -> bool {
2588        match self.expr_data_type(e) {
2589            None | Some(DataType::Null) => false,
2590            Some(dt) => !dt.is_numeric(),
2591        }
2592    }
2593
2594    fn is_known_non_list(&self, e: &DfExpr) -> bool {
2595        if matches!(e, DfExpr::ScalarFunction(f) if f.func.name() == "named_struct") {
2596            return true;
2597        }
2598        match self.expr_data_type(e) {
2599            None
2600            | Some(
2601                DataType::Null
2602                | DataType::List(_)
2603                | DataType::LargeList(_)
2604                | DataType::FixedSizeList(_, _),
2605            ) => false,
2606            Some(dt) if is_het_struct_type(Some(&dt)) => false,
2607            Some(_) => true,
2608        }
2609    }
2610
2611    /// Whether a lowered expression is a typed temporal value (date / localtime /
2612    /// time / localdatetime / datetime). (#920)
2613    fn is_temporal_typed(&self, e: &DfExpr) -> bool {
2614        match self.expr_data_type(e) {
2615            Some(DataType::Time64(_)) => true,
2616            Some(dt) => {
2617                is_date_struct(&dt)
2618                    || is_localdatetime_struct(&dt)
2619                    || is_time_struct(&dt)
2620                    || is_datetime_struct(&dt)
2621            }
2622            None => false,
2623        }
2624    }
2625
2626    fn is_known_non_value_access_container(&self, e: &DfExpr) -> bool {
2627        match self.expr_data_type(e) {
2628            None | Some(DataType::Null) => false,
2629            Some(dt) => {
2630                !is_plain_map_struct_type(&dt)
2631                    && !is_het_struct_type(Some(&dt))
2632                    && !matches!(dt, DataType::Struct(_))
2633            }
2634        }
2635    }
2636
2637    /// Whether a lowered expression is a typed `duration` struct. (#920)
2638    fn is_duration_typed(&self, e: &DfExpr) -> bool {
2639        self.expr_data_type(e)
2640            .is_some_and(|dt| is_duration_struct(&dt))
2641    }
2642
2643    /// The element type of a list expression, when statically known (a literal
2644    /// list or a typed list column). Drives plan-time quantifier validation (#955).
2645    fn list_element_type(&self, list: &DfExpr) -> Option<DataType> {
2646        let schema = self
2647            .input_schema
2648            .clone()
2649            .unwrap_or_else(|| std::sync::Arc::new(datafusion::common::DFSchema::empty()));
2650        match list.get_type(&schema).ok()? {
2651            DataType::List(f) | DataType::LargeList(f) | DataType::FixedSizeList(f, _) => {
2652                Some(f.data_type().clone())
2653            }
2654            _ => None,
2655        }
2656    }
2657
2658    #[allow(
2659        clippy::too_many_lines,
2660        reason = "one arm per Cypher binary operator, several with temporal/list/string dispatch"
2661    )]
2662    fn lower_binary(
2663        &self,
2664        op: BinaryOpKind,
2665        left: ExprId,
2666        right: ExprId,
2667    ) -> Result<DfExpr, LoweringError> {
2668        // Multi-label patterns arrive as `'<label>' IN labels(node)` for every
2669        // label after the first. Recognize that raw IR shape before lowering
2670        // either operand: lowering `labels(node)` first materializes the full
2671        // runtime-catalog label list, once per predicate, and makes plan work
2672        // quadratic in the number of labels (#1275).
2673        if op == BinaryOpKind::In
2674            && let Some(membership) = self.lower_known_label_membership(left, right)?
2675        {
2676            return Ok(membership);
2677        }
2678
2679        // Entity identity comparison: `a = b` / `a <> b` over node/relationship
2680        // variables compares UUID columns (#598/#962). Different entity kinds are
2681        // never equal, but optional nulls still propagate as Cypher null.
2682        if matches!(op, BinaryOpKind::Eq | BinaryOpKind::Neq)
2683            && let (Some((lk, lc)), Some((rk, rc))) =
2684                (self.identity_uuid_of(left), self.identity_uuid_of(right))
2685        {
2686            if lk != rk {
2687                let value = matches!(op, BinaryOpKind::Neq);
2688                return Ok(when(
2689                    lc.clone().is_null().or(rc.clone().is_null()),
2690                    lit(ScalarValue::Boolean(None)),
2691                )
2692                .otherwise(lit(value))
2693                .expect("CASE build is infallible for mismatched entity comparison"));
2694            }
2695            return Ok(if matches!(op, BinaryOpKind::Eq) {
2696                lc.eq(rc)
2697            } else {
2698                lc.not_eq(rc)
2699            });
2700        }
2701        let l = self.lower(left)?;
2702        let r = self.lower(right)?;
2703        let expr = match op {
2704            // Cypher equality is type-tolerant: comparing values of different
2705            // types is `false` (`<>` → `true`), never an error — unlike SQL `=`,
2706            // which DataFusion rejects at planning for incompatible types. Route
2707            // through `cypher_eq` (ADR 0009); `<>` is its three-valued negation
2708            // (`not(null)` stays `null`).
2709            BinaryOpKind::Eq => CYPHER_EQ.call(vec![l, r]),
2710            BinaryOpKind::Neq => datafusion::logical_expr::not(CYPHER_EQ.call(vec![l, r])),
2711            // Order comparisons are Cypher comparability, not SQL ordering:
2712            // cross-type ordering is null, numeric NaN comparisons are false, and
2713            // lists compare lexicographically. Route all four operators through a
2714            // boolean UDF instead of native DataFusion comparisons (#962).
2715            BinaryOpKind::Lt | BinaryOpKind::Lte | BinaryOpKind::Gt | BinaryOpKind::Gte => {
2716                let code = match op {
2717                    BinaryOpKind::Lt => 0i8,
2718                    BinaryOpKind::Lte => 1i8,
2719                    BinaryOpKind::Gt => 2i8,
2720                    _ => 3i8,
2721                };
2722                CYPHER_CMP_PRED.call(vec![l, r, lit(code)])
2723            }
2724            // Boolean operators require boolean (or null/unknown) operands. A
2725            // proven non-boolean is an openCypher InvalidArgumentType (#956);
2726            // route it to a clean `plan error` rather than a DataFusion coercion
2727            // failure. Keep XOR as one UDF node rather than expanding it into
2728            // shared AND/OR subtrees, which makes chained lowering exponential.
2729            BinaryOpKind::And | BinaryOpKind::Or | BinaryOpKind::Xor => {
2730                let keyword = match op {
2731                    BinaryOpKind::And => "AND",
2732                    BinaryOpKind::Or => "OR",
2733                    BinaryOpKind::Xor => "XOR",
2734                    _ => unreachable!("matched boolean operator"),
2735                };
2736                if self.is_known_non_bool(&l) || self.is_known_non_bool(&r) {
2737                    return Err(LoweringError::InvalidType(format!(
2738                        "{keyword} requires boolean operands"
2739                    )));
2740                }
2741                match op {
2742                    BinaryOpKind::And => CYPHER_AND.call(vec![l, r]),
2743                    BinaryOpKind::Or => CYPHER_OR.call(vec![l, r]),
2744                    BinaryOpKind::Xor => CYPHER_XOR.call(vec![l, r]),
2745                    _ => unreachable!("matched boolean operator"),
2746                }
2747            }
2748            BinaryOpKind::Add => {
2749                // Cypher `+` is polymorphic: two lists CONCATENATE (`[1,2] + [3,4]`
2750                // → `[1,2,3,4]`), two strings CONCATENATE (`'a' + 'b'` → `'ab'`),
2751                // and numbers add. DataFusion's `Plus` only does arithmetic (and
2752                // rejects lists/strings at planning), so route list operands to
2753                // `array_concat` and string operands to the null-propagating `||`
2754                // (`StringConcat`) — Cypher `+` with a null operand is null, which
2755                // `||` matches (unlike `concat`, which skips nulls).
2756                if self.is_temporal_typed(&l) && self.is_duration_typed(&r) {
2757                    // temporal + duration (#920)
2758                    CYPHER_TEMPORAL_ARITH.call(vec![l, r, lit(1i64)])
2759                } else if self.is_duration_typed(&l) && self.is_temporal_typed(&r) {
2760                    // duration + temporal (commutative)
2761                    CYPHER_TEMPORAL_ARITH.call(vec![r, l, lit(1i64)])
2762                } else if self.is_duration_typed(&l) && self.is_duration_typed(&r) {
2763                    // duration + duration (component-wise)
2764                    CYPHER_DURATION_ADD.call(vec![l, r, lit(1i64)])
2765                } else if let (Some(le), Some(re)) =
2766                    (self.list_element_type(&l), self.list_element_type(&r))
2767                {
2768                    if le == re && !is_het_struct_type(Some(&le)) {
2769                        datafusion::functions_nested::expr_fn::array_concat(vec![l, r])
2770                    } else if graph_value_types_compatible(&le, &re) {
2771                        let target = self.expr_data_type(&l).ok_or_else(|| {
2772                            LoweringError::UnsupportedExpr(
2773                                "cannot resolve path-list type for concatenation".into(),
2774                            )
2775                        })?;
2776                        datafusion::functions_nested::expr_fn::array_concat(vec![
2777                            l,
2778                            cast(r, target),
2779                        ])
2780                    } else {
2781                        CYPHER_LIST_PLUS.call(vec![l, r])
2782                    }
2783                } else if let Some(le) = self.list_element_type(&l) {
2784                    if !is_het_struct_type(Some(&le))
2785                        && self
2786                            .expr_data_type(&r)
2787                            .is_some_and(|rt| rt == le || matches!(rt, DataType::Null))
2788                    {
2789                        datafusion::functions_nested::expr_fn::array_append(l, r)
2790                    } else {
2791                        CYPHER_LIST_PLUS.call(vec![l, r])
2792                    }
2793                } else if let Some(re) = self.list_element_type(&r) {
2794                    if !is_het_struct_type(Some(&re))
2795                        && self
2796                            .expr_data_type(&l)
2797                            .is_some_and(|lt| lt == re || matches!(lt, DataType::Null))
2798                    {
2799                        datafusion::functions_nested::expr_fn::array_prepend(l, r)
2800                    } else {
2801                        CYPHER_LIST_PLUS.call(vec![l, r])
2802                    }
2803                } else if self.is_list_typed(&l) || self.is_list_typed(&r) {
2804                    CYPHER_LIST_PLUS.call(vec![l, r])
2805                } else if (self.is_string_typed(&l) && !self.is_known_non_string(&r))
2806                    || (self.is_string_typed(&r) && !self.is_known_non_string(&l))
2807                {
2808                    DfExpr::BinaryExpr(datafusion::logical_expr::BinaryExpr {
2809                        left: Box::new(l),
2810                        op: Operator::StringConcat,
2811                        right: Box::new(r),
2812                    })
2813                } else {
2814                    DfExpr::BinaryExpr(datafusion::logical_expr::BinaryExpr {
2815                        left: Box::new(l),
2816                        op: Operator::Plus,
2817                        right: Box::new(r),
2818                    })
2819                }
2820            }
2821            BinaryOpKind::Sub => {
2822                if self.is_temporal_typed(&l) && self.is_duration_typed(&r) {
2823                    // temporal - duration (#920)
2824                    CYPHER_TEMPORAL_ARITH.call(vec![l, r, lit(-1i64)])
2825                } else if self.is_duration_typed(&l) && self.is_duration_typed(&r) {
2826                    // duration - duration (component-wise)
2827                    CYPHER_DURATION_ADD.call(vec![l, r, lit(-1i64)])
2828                } else {
2829                    DfExpr::BinaryExpr(datafusion::logical_expr::BinaryExpr {
2830                        left: Box::new(l),
2831                        op: Operator::Minus,
2832                        right: Box::new(r),
2833                    })
2834                }
2835            }
2836            BinaryOpKind::Mul => {
2837                // `duration * number` (commutative) scales the duration (#920).
2838                if self.is_duration_typed(&l) {
2839                    CYPHER_DURATION_SCALE.call(vec![l, r, lit(false)])
2840                } else if self.is_duration_typed(&r) {
2841                    CYPHER_DURATION_SCALE.call(vec![r, l, lit(false)])
2842                } else {
2843                    DfExpr::BinaryExpr(datafusion::logical_expr::BinaryExpr {
2844                        left: Box::new(l),
2845                        op: Operator::Multiply,
2846                        right: Box::new(r),
2847                    })
2848                }
2849            }
2850            BinaryOpKind::Div => {
2851                // `duration / number` scales the duration (not commutative) (#920).
2852                if self.is_duration_typed(&l) {
2853                    CYPHER_DURATION_SCALE.call(vec![l, r, lit(true)])
2854                } else {
2855                    let (l, r) = match (self.expr_data_type(&l), self.expr_data_type(&r)) {
2856                        (Some(DataType::Float64), Some(rt)) if is_integer_data_type(&rt) => {
2857                            (l, cast(r, DataType::Float64))
2858                        }
2859                        (Some(lt), Some(DataType::Float64)) if is_integer_data_type(&lt) => {
2860                            (cast(l, DataType::Float64), r)
2861                        }
2862                        _ => (l, r),
2863                    };
2864                    DfExpr::BinaryExpr(datafusion::logical_expr::BinaryExpr {
2865                        left: Box::new(l),
2866                        op: Operator::Divide,
2867                        right: Box::new(r),
2868                    })
2869                }
2870            }
2871            BinaryOpKind::Mod => {
2872                if self.is_known_non_numeric(&l) || self.is_known_non_numeric(&r) {
2873                    return Err(LoweringError::InvalidType(
2874                        "% requires numeric operands".into(),
2875                    ));
2876                }
2877                DfExpr::BinaryExpr(datafusion::logical_expr::BinaryExpr {
2878                    left: Box::new(l),
2879                    op: Operator::Modulo,
2880                    right: Box::new(r),
2881                })
2882            }
2883            BinaryOpKind::Pow => {
2884                if self.is_known_non_numeric(&l) || self.is_known_non_numeric(&r) {
2885                    return Err(LoweringError::InvalidType(
2886                        "^ requires numeric operands".into(),
2887                    ));
2888                }
2889                datafusion::functions::math::expr_fn::power(l, r)
2890            }
2891            // Cypher `x IN list` is structural three-valued list MEMBERSHIP, never
2892            // SQL's `in_list` (which treats the whole list as one element).
2893            // Statically known non-lists are compile-time InvalidArgumentType;
2894            // parameters/untyped values stay conservative and dispatch at runtime.
2895            BinaryOpKind::In => {
2896                if self.is_known_non_list(&r) {
2897                    return Err(LoweringError::InvalidType(
2898                        "IN requires a list or null right-hand operand".into(),
2899                    ));
2900                }
2901                CYPHER_IN.call(vec![l, r])
2902            }
2903            BinaryOpKind::StartsWith => CYPHER_STARTS_WITH.call(vec![l, r]),
2904            BinaryOpKind::EndsWith => CYPHER_ENDS_WITH.call(vec![l, r]),
2905            BinaryOpKind::Contains => CYPHER_CONTAINS.call(vec![l, r]),
2906            BinaryOpKind::RegexMatch => {
2907                // DataFusion regexp_like(str, pattern)
2908                datafusion::functions::regex::expr_fn::regexp_like(l, r, None)
2909            }
2910        };
2911        Ok(expr)
2912    }
2913
2914    /// Lower a known literal label-membership predicate directly against the
2915    /// node topology's canonical `type_ids` list.
2916    ///
2917    /// Unknown literals and dynamic expressions deliberately return `None` so
2918    /// the generic three-valued `cypher_in` path remains authoritative.
2919    fn lower_known_label_membership(
2920        &self,
2921        left: ExprId,
2922        right: ExprId,
2923    ) -> Result<Option<DfExpr>, LoweringError> {
2924        use datafusion::functions_nested::expr_fn::array_has;
2925
2926        let IrExpr::Literal(IrLiteral::Str(label)) = self.arena.get(left) else {
2927            return Ok(None);
2928        };
2929        let IrExpr::FunctionCall { name, args } = self.arena.get(right) else {
2930            return Ok(None);
2931        };
2932        let [arg] = args.as_slice() else {
2933            return Ok(None);
2934        };
2935        if name != "labels" {
2936            return Ok(None);
2937        }
2938        let IrExpr::VarRef(var_id) = self.arena.get(*arg) else {
2939            return Ok(None);
2940        };
2941        let Some(type_id) = self.entity_name_to_type_id.get(label) else {
2942            return Ok(None);
2943        };
2944        let base = self
2945            .var_map
2946            .get(*var_id)
2947            .ok_or(LoweringError::UnboundVar(var_id.0))?;
2948        Ok(Some(array_has(
2949            col(format!("{base}.type_ids")),
2950            lit(*type_id),
2951        )))
2952    }
2953
2954    fn lower_unary(&self, op: UnaryOpKind, expr: ExprId) -> Result<DfExpr, LoweringError> {
2955        let e = self.lower(expr)?;
2956        let result = match op {
2957            UnaryOpKind::Not => {
2958                if self.is_known_non_bool(&e) {
2959                    return Err(LoweringError::InvalidType(
2960                        "NOT requires a boolean operand".into(),
2961                    ));
2962                }
2963                not(e)
2964            }
2965            UnaryOpKind::Neg => {
2966                if self.is_known_non_numeric(&e) {
2967                    return Err(LoweringError::InvalidType(
2968                        "unary minus requires a numeric operand".into(),
2969                    ));
2970                }
2971                DfExpr::Negative(Box::new(e))
2972            }
2973            UnaryOpKind::IsNull => e.is_null(),
2974            UnaryOpKind::IsNotNull => e.is_not_null(),
2975        };
2976        Ok(result)
2977    }
2978
2979    fn lower_case(
2980        &self,
2981        operand: Option<ExprId>,
2982        arms: &[graphforge_ir::expr::CaseArm],
2983        else_expr: Option<ExprId>,
2984    ) -> Result<DfExpr, LoweringError> {
2985        let when_thens: Result<Vec<_>, _> = arms
2986            .iter()
2987            .map(|arm| {
2988                let when = self.lower(arm.when)?;
2989                let then = self.lower(arm.then)?;
2990                Ok((Box::new(when), Box::new(then)))
2991            })
2992            .collect();
2993        let when_thens = when_thens?;
2994        let else_expr_df = else_expr.map(|id| self.lower(id)).transpose()?;
2995
2996        Ok(DfExpr::Case(datafusion::logical_expr::expr::Case {
2997            expr: operand.map(|id| self.lower(id)).transpose()?.map(Box::new),
2998            when_then_expr: when_thens,
2999            else_expr: else_expr_df.map(Box::new),
3000        }))
3001    }
3002
3003    /// Build a DataFusion column reference for a property access.
3004    ///
3005    /// If `base` resolved to a plain `col("a")`, the property column is
3006    /// `col("a.prop_name")`.  Falls back to `"prop_<id>"` for runtime-catalog
3007    /// properties not present in the ontology.
3008    fn resolve_prop_col(&self, base_expr: DfExpr, prop: PropId) -> DfExpr {
3009        let prop_name = self
3010            .prop_names
3011            .get(&prop.0)
3012            .cloned()
3013            .unwrap_or_else(|| format!("prop_{}", prop.0));
3014
3015        // If the base is a plain column, compose a dotted column name — UNLESS it
3016        // is a synthetic quantifier/comprehension element column (#1004) at any
3017        // nesting depth (#1021), whose fields are struct fields, not top-level
3018        // property columns: access those via struct-aware `get_field` so `x.a` in
3019        // `none(x IN [{a:2}] WHERE x.a=2)` resolves against the element's `Struct`
3020        // type rather than a missing dotted column `__gf_elem.a`.
3021        if let DfExpr::Column(col_ref) = &base_expr
3022            && !self
3023                .elem_struct_cols
3024                .iter()
3025                .any(|c| c == col_ref.name.as_str())
3026        {
3027            return qualified_col(&col_ref.name, &prop_name);
3028        }
3029
3030        // Fallback: get_field(base, "prop_name") — handles computed bases and the
3031        // struct-element column above.
3032        datafusion::functions::core::expr_fn::get_field(base_expr, prop_name)
3033    }
3034
3035    /// The lowering-baked context for hydrating `nodes(p)` elements (#1024):
3036    /// the element fields are `node_uuid`, `labels`, then the **union** of
3037    /// every `properties/<stem>.parquet` schema's columns (sorted stems, first
3038    /// occurrence of a name wins, forced nullable — a node without the column
3039    /// is NULL). `None` without a read target (schema-only lowering), keeping
3040    /// the UDF's original `node_uuid`-only shape.
3041    fn path_node_hydration(&self) -> Option<PathNodeHydration> {
3042        use datafusion::arrow::datatypes::Field;
3043        let dir = self.read_target.as_ref()?;
3044        let stems = graphforge_storage::list_property_stems(dir);
3045        let mut fields = vec![
3046            Field::new("node_uuid", DataType::FixedSizeBinary(16), false),
3047            Field::new("labels", DataType::new_list(DataType::Utf8, true), true),
3048        ];
3049        let mut seen: std::collections::HashSet<String> =
3050            fields.iter().map(|f| f.name().clone()).collect();
3051        for stem in &stems {
3052            let table = graphforge_storage::PropertyTable::open_discovered(dir, stem);
3053            for f in table.schema_ref().fields() {
3054                if f.name() == "node_uuid" || !seen.insert(f.name().clone()) {
3055                    continue;
3056                }
3057                fields.push(f.as_ref().clone().with_nullable(true));
3058            }
3059        }
3060        let mut labels_by_type: Vec<(u32, String)> = self
3061            .type_id_to_entity_name
3062            .iter()
3063            .map(|(id, name)| (*id, name.clone()))
3064            .collect();
3065        labels_by_type.sort();
3066        Some(PathNodeHydration {
3067            dir: dir.clone(),
3068            labels_by_type,
3069            prop_stems: stems,
3070            fields: fields.into(),
3071        })
3072    }
3073}
3074
3075// ---------------------------------------------------------------------------
3076// Free functions
3077// ---------------------------------------------------------------------------
3078
3079/// Reference a column by its LITERAL name, preserving case (#957).
3080///
3081/// `col(name)` runs DataFusion's SQL-identifier parser, which **lowercases**
3082/// unquoted identifiers — so a mixed-case alias (`WITH v AS otherDate`) becomes
3083/// `otherdate` and fails to resolve. A simple (undotted) name is therefore built
3084/// as an unqualified [`Column`] verbatim. A dotted name keeps `col()`'s parsing,
3085/// preserving the lowercase dotted-property-column scheme (`graphforge-plan`).
3086fn col_literal(name: &str) -> DfExpr {
3087    if name.contains('.') {
3088        col(name)
3089    } else {
3090        DfExpr::Column(datafusion::common::Column::new_unqualified(name))
3091    }
3092}
3093
3094pub(crate) fn qualified_col(relation: &str, name: &str) -> DfExpr {
3095    DfExpr::Column(datafusion::common::Column::new(
3096        Some(datafusion::common::TableReference::bare(relation)),
3097        name,
3098    ))
3099}
3100
3101/// Build a `PropId.0 → column_name` reverse map.
3102///
3103/// The binder interns properties into the [`RuntimeCatalog`](graphforge_ir::RuntimeCatalog)
3104/// (runtime `PropId`s) in every mode that admits property reads, so the
3105/// authoritative name map comes from there, supplied by the
3106/// [`GraphPlanLowerer`](crate::GraphPlanLowerer) via
3107/// [`with_prop_names`](ExprLowerer::with_prop_names). This ontology-only
3108/// constructor path has no runtime catalog, so it returns an empty map and
3109/// property accesses fall back to `"prop_<id>"`.
3110fn build_prop_names(_ontology: Option<&OntologyHandle>) -> HashMap<u32, String> {
3111    HashMap::new()
3112}
3113
3114/// Lower a list literal's (already-lowered) elements to a DataFusion list `Expr`.
3115///
3116/// When every element is a constant, fold into a single `ScalarValue::List`
3117/// literal (the form `UnwindExec` consumes directly). Otherwise, build a
3118/// `make_array(...)` scalar-function call so per-row expression elements are
3119/// evaluated at execution time. An empty list folds to an empty `Int64` list
3120/// (`UNWIND []` yields zero rows regardless of element type).
3121/// The fixed Arrow layout of a heterogeneous ("tagged") FLAT-scalar list element
3122/// (ADR 0010). `__het_key` is the element's numeric value (null for non-numeric)
3123/// and is FIRST so DataFusion's native lexicographic `Struct` min/max orders
3124/// numeric elements by value and returns the original — no custom aggregate UDF.
3125/// `__het_tag`: `0`=int, `1`=float, `2`=string, `3`=bool; exactly one of the
3126/// `__het_int`/`__het_float`/`__het_str`/`__het_bool` fields is populated. The
3127/// names are reserved so the result renderer can detect and decode the element.
3128///
3129/// NOTE (ADR 0010 limitation): this representation is **flat-scalar only**. A list
3130/// whose elements are themselves lists/maps (nested heterogeneous values) cannot
3131/// be a tagged struct — an Arrow type cannot be recursive — so such lists are left
3132/// to `make_array` (and currently error). See the ADR's "nested" limitation.
3133/// Whether `e` is a statically-EMPTY list literal (`[]`) — a folded
3134/// `ScalarValue::List` whose single row has zero elements. Used to exempt an empty
3135/// list from quantifier plan-time type validation (its predicate never runs).
3136fn is_empty_list_literal(e: &DfExpr) -> bool {
3137    use datafusion::arrow::array::Array;
3138    matches!(e, DfExpr::Literal(ScalarValue::List(arr), _) if arr.value(0).is_empty())
3139}
3140
3141/// Resolve a lowered list element to a constant `ScalarValue` — a literal, or a
3142/// `named_struct(...)` map whose keys are string literals and whose values are
3143/// themselves constant (recursively). `None` for a non-constant element. Lets a
3144/// list literal that mixes maps with scalars/containers fold every element and
3145/// reach the tagged het path without folding maps everywhere (#1005).
3146fn try_const_scalar(e: &DfExpr) -> Option<ScalarValue> {
3147    match e {
3148        DfExpr::Literal(s, _) => Some(s.clone()),
3149        DfExpr::ScalarFunction(f) if f.func.name() == "named_struct" => {
3150            let mut entries: Vec<(String, ScalarValue)> = Vec::with_capacity(f.args.len() / 2);
3151            let pairs = f.args.chunks_exact(2);
3152            if !pairs.remainder().is_empty() {
3153                return None;
3154            }
3155            for pair in pairs {
3156                let DfExpr::Literal(ScalarValue::Utf8(Some(k)), _) = &pair[0] else {
3157                    return None;
3158                };
3159                entries.push((k.clone(), try_const_scalar(&pair[1])?));
3160            }
3161            const_map_scalar(&entries)
3162        }
3163        _ => None,
3164    }
3165}
3166
3167enum ConstStringKey {
3168    Null,
3169    Value(String),
3170}
3171
3172fn const_string_key(e: &DfExpr) -> Option<ConstStringKey> {
3173    match e {
3174        DfExpr::Literal(ScalarValue::Utf8(v) | ScalarValue::LargeUtf8(v), _) => Some(
3175            v.clone()
3176                .map_or(ConstStringKey::Null, ConstStringKey::Value),
3177        ),
3178        DfExpr::BinaryExpr(b) if b.op == Operator::StringConcat => {
3179            let l = const_string_key(&b.left)?;
3180            let r = const_string_key(&b.right)?;
3181            Some(match (l, r) {
3182                (ConstStringKey::Value(l), ConstStringKey::Value(r)) => {
3183                    ConstStringKey::Value(format!("{l}{r}"))
3184                }
3185                _ => ConstStringKey::Null,
3186            })
3187        }
3188        _ => None,
3189    }
3190}
3191
3192/// Build a constant map `{k: v, …}` as a `ScalarValue::Struct` from constant
3193/// entries — the exact `Struct` shape `named_struct` produces (each field
3194/// nullable), so `m.k` access, equality, and rendering are unchanged.
3195fn const_map_scalar(entries: &[(String, ScalarValue)]) -> Option<ScalarValue> {
3196    use datafusion::arrow::array::{ArrayRef, StructArray};
3197    use datafusion::arrow::datatypes::{Field, Fields};
3198    use std::sync::Arc;
3199    if entries.is_empty() {
3200        return Some(ScalarValue::Struct(Arc::new(
3201            StructArray::new_empty_fields(1, None),
3202        )));
3203    }
3204    let mut fields: Vec<Field> = Vec::with_capacity(entries.len());
3205    let mut arrays: Vec<ArrayRef> = Vec::with_capacity(entries.len());
3206    for (k, sv) in entries {
3207        let arr = sv.to_array().ok()?; // length-1 array
3208        fields.push(Field::new(k, arr.data_type().clone(), true));
3209        arrays.push(arr);
3210    }
3211    let s = StructArray::try_new(Fields::from(fields), arrays, None).ok()?;
3212    Some(ScalarValue::Struct(Arc::new(s)))
3213}
3214
3215/// Whether a `Struct` value is a plain Cypher MAP — three-valued structural value
3216/// — rather than one of the reserved struct shapes that must NOT be encoded as a
3217/// het map element (#1005): a het-tagged element, a node/relationship/path entity,
3218/// or a typed temporal value (`date`/`time`/`localdatetime`/`datetime`/`duration`).
3219fn is_plain_map_struct(arr: &datafusion::arrow::array::StructArray) -> bool {
3220    use datafusion::arrow::array::Array;
3221    is_plain_map_struct_type(arr.data_type())
3222}
3223
3224/// [`is_plain_map_struct`] on a `DataType` — a `Struct` that is a plain Cypher map,
3225/// not a het-tagged element, a typed temporal value, or a node/relationship/path
3226/// entity. Used to route `m.k` on a map-typed column to `get_field` (#1017).
3227fn is_plain_map_struct_type(dt: &DataType) -> bool {
3228    let DataType::Struct(fields) = dt else {
3229        return false;
3230    };
3231    // Reserved entity field names (mirror `is_entity_struct`).
3232    let is_entity = fields.iter().any(|f| {
3233        matches!(
3234            f.name().as_str(),
3235            "node_uuid" | "src_uuid" | "dst_uuid" | "nodes" | "relationships" | "labels"
3236        )
3237    });
3238    !is_entity
3239        && !is_het_struct_type(Some(dt))
3240        && !is_date_struct(dt)
3241        && !is_localdatetime_struct(dt)
3242        && !is_duration_struct(dt)
3243        && !is_time_struct(dt)
3244        && !is_datetime_struct(dt)
3245}
3246
3247/// The entry-struct fields of a het map element (ADR 0011 slice 2, #1005): a
3248/// `__het_mkey: Utf8` key paired with a `__het_mval` tagged value one level
3249/// shallower — so a map's values recurse by value exactly like list children.
3250fn het_map_entry_fields(depth: usize) -> datafusion::arrow::datatypes::Fields {
3251    use datafusion::arrow::datatypes::{DataType, Field};
3252    datafusion::arrow::datatypes::Fields::from(vec![
3253        Field::new("__het_mkey", DataType::Utf8, false),
3254        Field::new("__het_mval", DataType::Struct(het_fields(depth)), true),
3255    ])
3256}
3257
3258/// Arrow fields of a tagged heterogeneous list element (ADR 0010/0011) that can
3259/// nest to `depth` levels. `__het_key` is first (native `Struct` min/max orders
3260/// flat numeric lists by value — ADR 0010). `__het_tag`: 0=int, 1=float, 2=str,
3261/// 3=bool, 4=list, 5=map. For `depth >= 1` a `__het_list: List<Struct{…depth-1…}>`
3262/// field holds a nested-list element's tagged children and a `__het_map:
3263/// List<Struct{__het_mkey, __het_mval: Struct{…depth-1…}}>` field holds a map
3264/// element's key/tagged-value entries — a *distinct, shallower, finite* type per
3265/// level (recursion by value, not a recursive Arrow type; the literal's depth is
3266/// known at lowering time — ADR 0011).
3267fn het_fields(depth: usize) -> datafusion::arrow::datatypes::Fields {
3268    use datafusion::arrow::datatypes::{DataType, Field};
3269    use std::sync::Arc;
3270    let mut v = vec![
3271        Field::new("__het_key", DataType::Float64, true),
3272        Field::new("__het_tag", DataType::Int8, false),
3273        Field::new("__het_int", DataType::Int64, true),
3274        Field::new("__het_float", DataType::Float64, true),
3275        Field::new("__het_str", DataType::Utf8, true),
3276        Field::new("__het_bool", DataType::Boolean, true),
3277    ];
3278    if depth >= 1 {
3279        let inner = Field::new("item", DataType::Struct(het_fields(depth - 1)), true);
3280        v.push(Field::new(
3281            "__het_list",
3282            DataType::List(Arc::new(inner)),
3283            true,
3284        ));
3285        let entry = Field::new(
3286            "item",
3287            DataType::Struct(het_map_entry_fields(depth - 1)),
3288            true,
3289        );
3290        v.push(Field::new(
3291            "__het_map",
3292            DataType::List(Arc::new(entry)),
3293            true,
3294        ));
3295    }
3296    datafusion::arrow::datatypes::Fields::from(v)
3297}
3298
3299/// The nesting depth of a value as a het element: a scalar is `0`, a list or map
3300/// is `1 + max child depth` (empty container = 1). `None` if the value cannot be a
3301/// het element (a node/relationship/path entity or a typed temporal value).
3302fn het_depth(s: &ScalarValue) -> Option<usize> {
3303    use datafusion::arrow::array::Array;
3304    let s = unwrap_het(s.clone());
3305    match &s {
3306        ScalarValue::Int64(_)
3307        | ScalarValue::Float64(_)
3308        | ScalarValue::Utf8(_)
3309        | ScalarValue::LargeUtf8(_)
3310        | ScalarValue::Utf8View(_)
3311        | ScalarValue::Boolean(_)
3312        | ScalarValue::Null => Some(0),
3313        ScalarValue::List(arr) => {
3314            let inner = arr.value(0);
3315            let mut d = 0;
3316            for i in 0..inner.len() {
3317                // An inner list that is itself heterogeneous was already lowered to
3318                // a tagged struct (bottom-up lowering); unwrap it back to the plain
3319                // value so depth/encoding are computed uniformly.
3320                let e = unwrap_het(ScalarValue::try_from_array(&inner, i).ok()?);
3321                d = d.max(het_depth(&e)?);
3322            }
3323            Some(1 + d)
3324        }
3325        // A plain map (#1005): 1 + the deepest value; an empty map is depth 1. A
3326        // value already tagged (a het list value) unwraps back to its plain form.
3327        ScalarValue::Struct(arr) if is_plain_map_struct(arr) => {
3328            let mut d = 0;
3329            for i in 0..arr.num_columns() {
3330                let v = unwrap_het(ScalarValue::try_from_array(arr.column(i), 0).ok()?);
3331                d = d.max(het_depth(&v)?);
3332            }
3333            Some(1 + d)
3334        }
3335        _ => None,
3336    }
3337}
3338
3339/// Decode an already-tagged het element back to its plain value (for re-encoding
3340/// uniformly at an outer level); leaves a non-tagged value unchanged.
3341fn unwrap_het(s: ScalarValue) -> ScalarValue {
3342    if let ScalarValue::Dictionary(_, value) = s {
3343        return unwrap_het(*value);
3344    }
3345    decode_het(&s).unwrap_or(s)
3346}
3347
3348/// Build the tagged-struct array for `scalars`, every element encoded uniformly at
3349/// `depth` (ADR 0011). List elements recurse their children at `depth - 1`.
3350#[allow(
3351    clippy::too_many_lines,
3352    reason = "one cohesive per-field array builder; splitting it would obscure the field/offset bookkeeping"
3353)]
3354fn build_het_struct(
3355    scalars: &[ScalarValue],
3356    depth: usize,
3357) -> Option<datafusion::arrow::array::StructArray> {
3358    use datafusion::arrow::array::{
3359        ArrayRef, BooleanArray, Float64Array, Int8Array, Int64Array, ListArray, StringArray,
3360        StructArray,
3361    };
3362    use datafusion::arrow::buffer::{NullBuffer, OffsetBuffer};
3363    use datafusion::arrow::datatypes::{DataType, Field};
3364    use std::sync::Arc;
3365
3366    let n = scalars.len();
3367    let mut keys: Vec<Option<f64>> = Vec::with_capacity(n);
3368    let mut tags: Vec<i8> = Vec::with_capacity(n);
3369    let mut ints: Vec<Option<i64>> = Vec::with_capacity(n);
3370    let mut floats: Vec<Option<f64>> = Vec::with_capacity(n);
3371    let mut strs: Vec<Option<String>> = Vec::with_capacity(n);
3372    let mut bools: Vec<Option<bool>> = Vec::with_capacity(n);
3373    let mut valid: Vec<bool> = Vec::with_capacity(n);
3374    // Children of list elements, flattened, with one (offset, valid) per element.
3375    let mut child_elems: Vec<ScalarValue> = Vec::new();
3376    let mut child_offsets: Vec<i32> = vec![0];
3377    let mut child_valid: Vec<bool> = Vec::new();
3378    // Entries of map elements, flattened as parallel (key, tagged-value) columns,
3379    // with one (offset, valid) per element (mirrors the list-child bookkeeping).
3380    let mut map_keys: Vec<String> = Vec::new();
3381    let mut map_vals: Vec<ScalarValue> = Vec::new();
3382    let mut map_offsets: Vec<i32> = vec![0];
3383    let mut map_valid: Vec<bool> = Vec::new();
3384    for scalar in scalars {
3385        let scalar = unwrap_het(scalar.clone());
3386        // Scalar fields default to null/false; each arm sets only what it needs.
3387        let (mut key, mut tag, mut int_v, mut float_v, mut str_v, mut bool_v, mut ok) =
3388            (None, 0i8, None, None, None, None, true);
3389        let mut child: Option<Vec<ScalarValue>> = None;
3390        let mut map_child: Option<Vec<(String, ScalarValue)>> = None;
3391        match &scalar {
3392            ScalarValue::Int64(Some(x)) => {
3393                #[allow(
3394                    clippy::cast_precision_loss,
3395                    reason = "key feeds only min/max ORDERING; the exact integer is preserved in __het_int"
3396                )]
3397                let k = Some(*x as f64);
3398                key = k;
3399                tag = 0;
3400                int_v = Some(*x);
3401            }
3402            ScalarValue::Float64(Some(x)) => {
3403                key = Some(*x);
3404                tag = 1;
3405                float_v = Some(*x);
3406            }
3407            ScalarValue::Utf8(Some(x))
3408            | ScalarValue::LargeUtf8(Some(x))
3409            | ScalarValue::Utf8View(Some(x)) => {
3410                tag = 2;
3411                str_v = Some(x.clone());
3412            }
3413            ScalarValue::Boolean(Some(x)) => {
3414                tag = 3;
3415                bool_v = Some(*x);
3416            }
3417            ScalarValue::List(arr) => {
3418                if depth == 0 {
3419                    return None; // shape deeper than the computed depth — defensive
3420                }
3421                tag = 4;
3422                let inner = arr.value(0);
3423                let mut elems = Vec::with_capacity(inner.len());
3424                for idx in 0..inner.len() {
3425                    elems.push(unwrap_het(ScalarValue::try_from_array(&inner, idx).ok()?));
3426                }
3427                child = Some(elems);
3428            }
3429            // A plain map (#1005): tag 5; its (key, value) entries recurse as
3430            // tagged values one level shallower (empty map → zero entries).
3431            ScalarValue::Struct(arr) if is_plain_map_struct(arr) => {
3432                if depth == 0 {
3433                    return None; // shape deeper than the computed depth — defensive
3434                }
3435                tag = 5;
3436                let mut kv = Vec::with_capacity(arr.num_columns());
3437                for (i, f) in arr.fields().iter().enumerate() {
3438                    let v = unwrap_het(ScalarValue::try_from_array(arr.column(i), 0).ok()?);
3439                    kv.push((f.name().clone(), v));
3440                }
3441                map_child = Some(kv);
3442            }
3443            // Null (typed or untyped) → a null element.
3444            ScalarValue::Int64(None)
3445            | ScalarValue::Float64(None)
3446            | ScalarValue::Utf8(None)
3447            | ScalarValue::LargeUtf8(None)
3448            | ScalarValue::Utf8View(None)
3449            | ScalarValue::Boolean(None)
3450            | ScalarValue::Null => ok = false,
3451            _ => return None, // entity/temporal/other struct → not this slice
3452        }
3453        keys.push(key);
3454        tags.push(tag);
3455        ints.push(int_v);
3456        floats.push(float_v);
3457        strs.push(str_v);
3458        bools.push(bool_v);
3459        valid.push(ok);
3460        if depth >= 1 {
3461            if let Some(elems) = child {
3462                child_elems.extend(elems);
3463                child_valid.push(true);
3464            } else {
3465                child_valid.push(false);
3466            }
3467            child_offsets.push(i32::try_from(child_elems.len()).ok()?);
3468            if let Some(kv) = map_child {
3469                for (k, v) in kv {
3470                    map_keys.push(k);
3471                    map_vals.push(v);
3472                }
3473                map_valid.push(true);
3474            } else {
3475                map_valid.push(false);
3476            }
3477            map_offsets.push(i32::try_from(map_keys.len()).ok()?);
3478        }
3479    }
3480
3481    let mut arrays: Vec<ArrayRef> = vec![
3482        Arc::new(Float64Array::from(keys)),
3483        Arc::new(Int8Array::from(tags)),
3484        Arc::new(Int64Array::from(ints)),
3485        Arc::new(Float64Array::from(floats)),
3486        Arc::new(StringArray::from(strs)),
3487        Arc::new(BooleanArray::from(bools)),
3488    ];
3489    if depth >= 1 {
3490        let child_struct = build_het_struct(&child_elems, depth - 1)?;
3491        let inner_field = Arc::new(Field::new(
3492            "item",
3493            DataType::Struct(het_fields(depth - 1)),
3494            true,
3495        ));
3496        let het_list = ListArray::new(
3497            inner_field,
3498            OffsetBuffer::new(child_offsets.into()),
3499            Arc::new(child_struct),
3500            Some(NullBuffer::from(child_valid)),
3501        );
3502        arrays.push(Arc::new(het_list));
3503
3504        // __het_map: a List<Struct{__het_mkey, __het_mval}> — each map element's
3505        // key/tagged-value entries, with values encoded one level shallower.
3506        let entry_fields = het_map_entry_fields(depth - 1);
3507        let mkey_arr = Arc::new(StringArray::from(map_keys)) as ArrayRef;
3508        let mval_struct = build_het_struct(&map_vals, depth - 1)?;
3509        let entry_struct = StructArray::new(
3510            entry_fields.clone(),
3511            vec![mkey_arr, Arc::new(mval_struct)],
3512            None,
3513        );
3514        let entry_field = Arc::new(Field::new("item", DataType::Struct(entry_fields), true));
3515        let het_map = ListArray::new(
3516            entry_field,
3517            OffsetBuffer::new(map_offsets.into()),
3518            Arc::new(entry_struct),
3519            Some(NullBuffer::from(map_valid)),
3520        );
3521        arrays.push(Arc::new(het_map));
3522    }
3523    Some(StructArray::new(
3524        het_fields(depth),
3525        arrays,
3526        Some(NullBuffer::from(valid)),
3527    ))
3528}
3529
3530/// Build a heterogeneous list literal as the ADR-0010/0011 tagged struct
3531/// (`List<Struct{__het_*}>`) when `scalars` is a constant list that cannot be a
3532/// homogeneous Arrow array — a flat mix of `int`/`float`/`string`/`bool`, or a
3533/// list with nested-list elements (`[1, [1, 2]]`). Returns `None` if any element
3534/// is a map/struct/entity (deferred to a later ADR-0011 slice) — those fall to
3535/// `make_array`. Only called after the homogeneous const-fold has been ruled out,
3536/// so homogeneous lists keep their primitive `new_list` representation untouched.
3537fn tagged_numeric_list(scalars: &[ScalarValue]) -> Option<DfExpr> {
3538    use datafusion::arrow::array::ListArray;
3539    use datafusion::arrow::buffer::OffsetBuffer;
3540    use datafusion::arrow::datatypes::{DataType, Field};
3541    use std::sync::Arc;
3542
3543    // Every element must be het-representable (scalar or nested list); compute the
3544    // literal's nesting depth so the per-level struct types are finite and exact.
3545    let mut depth = 0usize;
3546    for s in scalars {
3547        depth = depth.max(het_depth(s)?);
3548    }
3549    let n = scalars.len();
3550    let elem = build_het_struct(scalars, depth)?;
3551    let list_field = Arc::new(Field::new(
3552        "item",
3553        DataType::Struct(het_fields(depth)),
3554        true,
3555    ));
3556    let list = ListArray::new(
3557        list_field,
3558        OffsetBuffer::from_lengths([n]),
3559        Arc::new(elem),
3560        None,
3561    );
3562    Some(DfExpr::Literal(ScalarValue::List(Arc::new(list)), None))
3563}
3564
3565static CYPHER_DYNAMIC_HET_LIST: LazyLock<ScalarUDF> =
3566    LazyLock::new(|| ScalarUDF::new_from_impl(CypherDynamicHetList::new()));
3567
3568#[derive(Debug, PartialEq, Eq, Hash)]
3569struct CypherDynamicHetList {
3570    signature: Signature,
3571}
3572
3573impl CypherDynamicHetList {
3574    fn new() -> Self {
3575        Self {
3576            signature: Signature::variadic_any(Volatility::Immutable),
3577        }
3578    }
3579}
3580
3581fn dynamic_het_type(arg_types: &[DataType]) -> DataType {
3582    use datafusion::arrow::datatypes::Fields;
3583    let mut fields = Vec::with_capacity(arg_types.len() + 1);
3584    fields.push(Field::new("__het_tag", DataType::Int8, false));
3585    fields.extend(
3586        arg_types
3587            .iter()
3588            .enumerate()
3589            .map(|(i, ty)| Field::new(format!("__het_value_{i}"), ty.clone(), true)),
3590    );
3591    DataType::new_list(DataType::Struct(Fields::from(fields)), true)
3592}
3593
3594impl ScalarUDFImpl for CypherDynamicHetList {
3595    fn as_any(&self) -> &dyn Any {
3596        self
3597    }
3598
3599    fn name(&self) -> &'static str {
3600        "cypher_dynamic_het_list"
3601    }
3602
3603    fn signature(&self) -> &Signature {
3604        &self.signature
3605    }
3606
3607    fn return_type(&self, arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
3608        Ok(dynamic_het_type(arg_types))
3609    }
3610
3611    fn return_field_from_args(&self, args: ReturnFieldArgs) -> datafusion::error::Result<FieldRef> {
3612        let arg_types = args
3613            .arg_fields
3614            .iter()
3615            .map(|field| field.data_type().clone())
3616            .collect::<Vec<_>>();
3617        Ok(Arc::new(Field::new(
3618            self.name(),
3619            dynamic_het_type(&arg_types),
3620            false,
3621        )))
3622    }
3623
3624    fn invoke_with_args(
3625        &self,
3626        args: ScalarFunctionArgs,
3627    ) -> datafusion::error::Result<ColumnarValue> {
3628        use datafusion::arrow::array::{ArrayRef, Int8Array, Int32Array, StructArray};
3629        use datafusion::arrow::buffer::{NullBuffer, OffsetBuffer};
3630        use datafusion::arrow::compute::take;
3631        use datafusion::error::DataFusionError;
3632
3633        let rows = args.number_rows;
3634        let width = args.args.len();
3635        let width_i8 = i8::try_from(width).map_err(|_| {
3636            DataFusionError::Plan("heterogeneous list literal exceeds 127 elements".into())
3637        })?;
3638        let values = args
3639            .args
3640            .iter()
3641            .map(|value| value.to_array(rows))
3642            .collect::<datafusion::error::Result<Vec<_>>>()?;
3643        let tags = Int8Array::from_iter_values((0..rows).flat_map(|_| 0..width_i8));
3644        let mut columns: Vec<ArrayRef> = vec![Arc::new(tags)];
3645        for (value_idx, value) in values.iter().enumerate() {
3646            let indices = (0..rows)
3647                .flat_map(|row| {
3648                    (0..width).map(move |element_idx| {
3649                        (element_idx == value_idx)
3650                            .then(|| i32::try_from(row).ok())
3651                            .flatten()
3652                    })
3653                })
3654                .collect::<Int32Array>();
3655            columns.push(take(value.as_ref(), &indices, None)?);
3656        }
3657        let valid = (0..rows)
3658            .flat_map(|row| values.iter().map(move |value| !value.is_null(row)))
3659            .collect::<NullBuffer>();
3660        let DataType::List(item) = args.return_field.data_type() else {
3661            return Err(DataFusionError::Internal(
3662                "dynamic heterogeneous list has a non-list return type".into(),
3663            ));
3664        };
3665        let DataType::Struct(fields) = item.data_type() else {
3666            return Err(DataFusionError::Internal(
3667                "dynamic heterogeneous list has a non-struct element type".into(),
3668            ));
3669        };
3670        let elements = StructArray::new(fields.clone(), columns, Some(valid));
3671        let list = ListArray::new(
3672            item.clone(),
3673            OffsetBuffer::from_lengths(std::iter::repeat_n(width, rows)),
3674            Arc::new(elements),
3675            None,
3676        );
3677        Ok(ColumnarValue::Array(Arc::new(list)))
3678    }
3679}
3680
3681fn lower_list_literal(
3682    elems: Vec<DfExpr>,
3683    input_schema: Option<&datafusion::common::DFSchema>,
3684) -> DfExpr {
3685    // Resolve every element to a constant if possible (a literal, or a const map
3686    // folded from `named_struct`, #1005). `None` if any element is non-constant.
3687    if let Some(scalars) = elems
3688        .iter()
3689        .map(try_const_scalar)
3690        .collect::<Option<Vec<ScalarValue>>>()
3691    {
3692        // Element type: the first non-null element's type, else Int64.
3693        let elem_type = scalars
3694            .iter()
3695            .map(ScalarValue::data_type)
3696            .find(|t| *t != DataType::Null)
3697            .unwrap_or(DataType::Int64);
3698        // Re-type untyped `Null`s to `elem_type` so the list can be a nullable array
3699        // of that type (`[1, null]` → `Int64[1, null]`). Without this, `new_list`
3700        // panics building a homogeneous array from an untyped null.
3701        let typed: Vec<ScalarValue> = scalars
3702            .iter()
3703            .map(|s| {
3704                if matches!(s, ScalarValue::Null) {
3705                    ScalarValue::try_from(&elem_type).unwrap_or(ScalarValue::Null)
3706                } else {
3707                    s.clone()
3708                }
3709            })
3710            .collect();
3711        // Const-fold a HOMOGENEOUS list to a single `ScalarValue::List` — including
3712        // a same-shape all-map list, which stays a PLAIN `List<Struct>` (so `x.field`
3713        // access in a quantifier resolves, #1004) AND is a literal (so it can nest
3714        // inside an outer tagged het list, #1005).
3715        if typed.iter().all(|s| s.data_type() == elem_type) {
3716            let list = ScalarValue::new_list(&typed, &elem_type, true);
3717            return DfExpr::Literal(ScalarValue::List(list), None);
3718        }
3719        // A list whose elements are ALL maps (with any nulls) keeps each element a
3720        // PLAIN map so `x.field` access in a quantifier resolves (#1004): a
3721        // DIFFERENT-shape all-map list is padded to the union of keys (missing key
3722        // → null) into a homogeneous `List<Struct>` literal — which `make_array`
3723        // itself cannot unify. Only a genuinely MIXED list (maps alongside
3724        // scalars/lists) uses the tagged het path, where map elements carry no
3725        // accessible fields. (#1005)
3726        let all_maps = scalars
3727            .iter()
3728            .any(|s| matches!(s, ScalarValue::Struct(a) if is_plain_map_struct(a)))
3729            && scalars.iter().all(|s| {
3730                s.is_null() || matches!(s, ScalarValue::Struct(a) if is_plain_map_struct(a))
3731            });
3732        if all_maps {
3733            if let Some(padded) = all_map_union_list(&scalars) {
3734                return padded;
3735            }
3736        } else if let Some(tagged) = tagged_numeric_list(&scalars) {
3737            return tagged;
3738        }
3739    }
3740    if let Some(schema) = input_schema
3741        && let Some(types) = elems
3742            .iter()
3743            .map(|elem| elem.get_type(schema).ok())
3744            .collect::<Option<Vec<_>>>()
3745        && types.windows(2).any(|pair| pair[0] != pair[1])
3746    {
3747        return CYPHER_DYNAMIC_HET_LIST.call(elems);
3748    }
3749    datafusion::functions_nested::expr_fn::make_array(elems)
3750}
3751
3752static CYPHER_LIST_PLUS: LazyLock<ScalarUDF> =
3753    LazyLock::new(|| ScalarUDF::new_from_impl(CypherListPlus::new()));
3754
3755static CYPHER_RELATIONSHIP_DISJOINT: LazyLock<ScalarUDF> =
3756    LazyLock::new(|| ScalarUDF::new_from_impl(CypherRelationshipDisjoint::new()));
3757
3758pub(crate) fn relationship_disjoint(left: DfExpr, right: DfExpr) -> DfExpr {
3759    CYPHER_RELATIONSHIP_DISJOINT.call(vec![left, right])
3760}
3761
3762#[derive(Debug, PartialEq, Eq, Hash)]
3763struct CypherRelationshipDisjoint {
3764    signature: Signature,
3765}
3766
3767impl CypherRelationshipDisjoint {
3768    fn new() -> Self {
3769        Self {
3770            signature: Signature::any(2, Volatility::Immutable),
3771        }
3772    }
3773}
3774
3775impl ScalarUDFImpl for CypherRelationshipDisjoint {
3776    fn as_any(&self) -> &dyn Any {
3777        self
3778    }
3779
3780    fn name(&self) -> &'static str {
3781        "cypher_relationship_disjoint"
3782    }
3783
3784    fn signature(&self) -> &Signature {
3785        &self.signature
3786    }
3787
3788    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
3789        Ok(DataType::Boolean)
3790    }
3791
3792    fn invoke_with_args(
3793        &self,
3794        args: ScalarFunctionArgs,
3795    ) -> datafusion::error::Result<ColumnarValue> {
3796        use datafusion::arrow::array::BooleanArray;
3797
3798        let rows = args.number_rows;
3799        let left = args.args[0].to_array(rows)?;
3800        let right = args.args[1].to_array(rows)?;
3801        let values = (0..rows)
3802            .map(|row| {
3803                let left = ScalarValue::try_from_array(&left, row)?;
3804                let right = ScalarValue::try_from_array(&right, row)?;
3805                let mut left_ids = Vec::new();
3806                let mut right_ids = Vec::new();
3807                relationship_ids(&left, &mut left_ids);
3808                relationship_ids(&right, &mut right_ids);
3809                Ok(!left_ids.iter().any(|id| right_ids.contains(id)))
3810            })
3811            .collect::<datafusion::error::Result<BooleanArray>>()?;
3812        Ok(ColumnarValue::Array(std::sync::Arc::new(values)))
3813    }
3814}
3815
3816fn relationship_ids(value: &ScalarValue, ids: &mut Vec<Vec<u8>>) {
3817    match value {
3818        ScalarValue::FixedSizeBinary(_, Some(uuid)) => ids.push(uuid.clone()),
3819        ScalarValue::List(list) if !list.is_null(0) => {
3820            let values = list.value(0);
3821            for index in 0..values.len() {
3822                if let Ok(value) = ScalarValue::try_from_array(&values, index) {
3823                    relationship_ids(&value, ids);
3824                }
3825            }
3826        }
3827        ScalarValue::Struct(value) if !value.is_null(0) => {
3828            if let Some(uuid) = value.column_by_name("edge_uuid")
3829                && let Ok(uuid) = ScalarValue::try_from_array(uuid, 0)
3830            {
3831                relationship_ids(&uuid, ids);
3832            }
3833        }
3834        _ => {}
3835    }
3836}
3837
3838/// Graph-value structs produced by separate physical paths can differ only in
3839/// Arrow field nullability. They are still the same Cypher value shape and can
3840/// be normalized with a cast before native list concatenation.
3841fn graph_value_types_compatible(left: &DataType, right: &DataType) -> bool {
3842    let (DataType::Struct(left), DataType::Struct(right)) = (left, right) else {
3843        return false;
3844    };
3845    left.len() == right.len()
3846        && left.iter().zip(right.iter()).all(|(left, right)| {
3847            left.name() == right.name()
3848                && match (left.data_type(), right.data_type()) {
3849                    (DataType::Struct(_), DataType::Struct(_)) => {
3850                        graph_value_types_compatible(left.data_type(), right.data_type())
3851                    }
3852                    (DataType::List(left), DataType::List(right)) => {
3853                        left.data_type() == right.data_type()
3854                    }
3855                    (left, right) => left == right,
3856                }
3857        })
3858}
3859
3860#[derive(Debug, PartialEq, Eq, Hash)]
3861struct CypherListPlus {
3862    signature: Signature,
3863}
3864
3865impl CypherListPlus {
3866    fn new() -> Self {
3867        Self {
3868            signature: Signature::any(2, Volatility::Immutable),
3869        }
3870    }
3871}
3872
3873impl ScalarUDFImpl for CypherListPlus {
3874    fn as_any(&self) -> &dyn Any {
3875        self
3876    }
3877
3878    fn name(&self) -> &'static str {
3879        "cypher_list_plus"
3880    }
3881
3882    fn signature(&self) -> &Signature {
3883        &self.signature
3884    }
3885
3886    fn return_type(&self, arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
3887        if list_plus_has_graph_value(arg_types) {
3888            Ok(list_plus_return_type(arg_types))
3889        } else {
3890            Ok(DataType::new_list(
3891                DataType::Struct(het_fields(list_plus_depth(arg_types))),
3892                true,
3893            ))
3894        }
3895    }
3896
3897    #[allow(
3898        clippy::too_many_lines,
3899        reason = "list/list and list/element shaping share one offset and validity pass"
3900    )]
3901    fn invoke_with_args(
3902        &self,
3903        args: ScalarFunctionArgs,
3904    ) -> datafusion::error::Result<ColumnarValue> {
3905        use datafusion::arrow::array::{Array, ArrayRef, ListArray};
3906        use datafusion::arrow::buffer::{NullBuffer, OffsetBuffer, ScalarBuffer};
3907        use datafusion::arrow::datatypes::DataType;
3908        use datafusion::error::DataFusionError;
3909        use std::sync::Arc;
3910
3911        let rows = args.number_rows;
3912        let left = args.args[0].to_array(rows)?;
3913        let right = args.args[1].to_array(rows)?;
3914        let left_is_list = list_item_type(left.data_type()).is_some();
3915        let right_is_list = list_item_type(right.data_type()).is_some();
3916        if !left_is_list && !right_is_list {
3917            return Err(DataFusionError::Execution(
3918                "list + requires at least one list operand".into(),
3919            ));
3920        }
3921
3922        if left_is_list
3923            && !right_is_list
3924            && let Some(result) =
3925                invoke_tagged_list_element_plus(&left, &right, args.return_field.data_type())?
3926        {
3927            return Ok(ColumnarValue::Array(result));
3928        }
3929
3930        let mut flat: Vec<ScalarValue> = Vec::new();
3931        let mut offsets: Vec<i32> = Vec::with_capacity(rows + 1);
3932        let mut validity: Vec<bool> = Vec::with_capacity(rows);
3933        offsets.push(0);
3934
3935        for row in 0..rows {
3936            let row_values = match (left_is_list, right_is_list) {
3937                (true, true) => match (
3938                    list_elements_at(&left, row)?,
3939                    list_elements_at(&right, row)?,
3940                ) {
3941                    (Some(mut l), Some(r)) => {
3942                        l.extend(r);
3943                        Some(l)
3944                    }
3945                    _ => None,
3946                },
3947                (true, false) => match list_elements_at(&left, row)? {
3948                    Some(mut l) => {
3949                        let r = decoded_scalar_at(&right, row)?;
3950                        if let Some(r) = scalar_list_elements(&r)? {
3951                            l.extend(r);
3952                        } else {
3953                            l.push(r);
3954                        }
3955                        Some(l)
3956                    }
3957                    None => None,
3958                },
3959                (false, true) => match list_elements_at(&right, row)? {
3960                    Some(mut r) => {
3961                        let l = decoded_scalar_at(&left, row)?;
3962                        let mut l = scalar_list_elements(&l)?.unwrap_or_else(|| vec![l]);
3963                        l.append(&mut r);
3964                        Some(l)
3965                    }
3966                    None => None,
3967                },
3968                (false, false) => unreachable!("checked above"),
3969            };
3970
3971            match row_values {
3972                Some(values) => {
3973                    flat.extend(values);
3974                    validity.push(true);
3975                }
3976                None => validity.push(false),
3977            }
3978            offsets.push(i32::try_from(flat.len()).map_err(|_| {
3979                DataFusionError::Execution("cypher_list_plus: list too long".into())
3980            })?);
3981        }
3982
3983        let DataType::List(field) = args.return_field.data_type() else {
3984            return Err(DataFusionError::Internal(
3985                "cypher_list_plus return type is not a list".into(),
3986            ));
3987        };
3988        if is_het_struct_type(Some(field.data_type()))
3989            && !is_dynamic_variant_struct(field.data_type())
3990        {
3991            let depth = list_plus_depth(&[left.data_type().clone(), right.data_type().clone()]);
3992            let values = build_het_struct(&flat, depth).ok_or_else(|| {
3993                DataFusionError::Execution(
3994                    "cypher_list_plus: cannot encode value in heterogeneous list".into(),
3995                )
3996            })?;
3997            let out = ListArray::new(
3998                field.clone(),
3999                OffsetBuffer::new(ScalarBuffer::from(offsets)),
4000                Arc::new(values) as ArrayRef,
4001                Some(NullBuffer::from(validity)),
4002            );
4003            return Ok(ColumnarValue::Array(Arc::new(out)));
4004        }
4005        if !is_dynamic_variant_struct(field.data_type()) {
4006            let flat = flat
4007                .into_iter()
4008                .map(|value| {
4009                    if value.data_type() == *field.data_type() {
4010                        Ok(value)
4011                    } else {
4012                        value.cast_to(field.data_type())
4013                    }
4014                })
4015                .collect::<datafusion::error::Result<Vec<_>>>()?;
4016            let values = if flat.is_empty() {
4017                new_empty_array(field.data_type())
4018            } else {
4019                ScalarValue::iter_to_array(flat)?
4020            };
4021            let out = ListArray::new(
4022                field.clone(),
4023                OffsetBuffer::new(ScalarBuffer::from(offsets)),
4024                values,
4025                Some(NullBuffer::from(validity)),
4026            );
4027            return Ok(ColumnarValue::Array(Arc::new(out)));
4028        }
4029        let DataType::Struct(fields) = field.data_type() else {
4030            return Err(DataFusionError::Internal(
4031                "cypher_list_plus element type is not tagged".into(),
4032            ));
4033        };
4034        let variants = fields
4035            .iter()
4036            .filter(|field| field.name().starts_with("__het_value_"))
4037            .map(|field| field.data_type().clone())
4038            .collect::<Vec<_>>();
4039        let mut tags = Vec::with_capacity(flat.len());
4040        let mut valid = Vec::with_capacity(flat.len());
4041        let mut columns = Vec::with_capacity(variants.len() + 1);
4042        for value in &flat {
4043            let tag = variants
4044                .iter()
4045                .position(|variant| {
4046                    value.data_type() == *variant
4047                        || graph_value_types_compatible(&value.data_type(), variant)
4048                })
4049                .unwrap_or(0);
4050            tags.push(i8::try_from(tag).map_err(|_| {
4051                DataFusionError::Execution("cypher_list_plus has too many value variants".into())
4052            })?);
4053            valid.push(!value.is_null());
4054        }
4055        columns.push(Arc::new(datafusion::arrow::array::Int8Array::from(tags.clone())) as ArrayRef);
4056        for (variant_index, variant) in variants.iter().enumerate() {
4057            let null = ScalarValue::try_new_null(variant)?;
4058            let values = flat.iter().zip(&tags).map(|(value, tag)| {
4059                if usize::try_from(*tag).ok() == Some(variant_index) {
4060                    value.clone()
4061                } else {
4062                    null.clone()
4063                }
4064            });
4065            columns.push(ScalarValue::iter_to_array(values)?);
4066        }
4067        let values = datafusion::arrow::array::StructArray::new(
4068            fields.clone(),
4069            columns,
4070            Some(NullBuffer::from(valid)),
4071        );
4072        let out = ListArray::new(
4073            field.clone(),
4074            OffsetBuffer::new(ScalarBuffer::from(offsets)),
4075            Arc::new(values) as ArrayRef,
4076            Some(NullBuffer::from(validity)),
4077        );
4078        Ok(ColumnarValue::Array(Arc::new(out)))
4079    }
4080}
4081
4082/// Append a tagged heterogeneous element to each list row without round-tripping
4083/// every existing element through [`ScalarValue`]. A runtime element whose tag is
4084/// itself a list still uses Cypher's dynamic list concatenation semantics; only
4085/// those nested children are promoted to the enclosing tagged depth.
4086#[allow(
4087    clippy::too_many_lines,
4088    reason = "one range-assembly pass keeps offsets, validity, and three Arrow sources synchronized"
4089)]
4090fn invoke_tagged_list_element_plus(
4091    left: &datafusion::arrow::array::ArrayRef,
4092    right: &datafusion::arrow::array::ArrayRef,
4093    return_type: &DataType,
4094) -> datafusion::error::Result<Option<datafusion::arrow::array::ArrayRef>> {
4095    use arrow_data::transform::MutableArrayData;
4096    use datafusion::arrow::array::{Array, Int8Array, ListArray, StructArray, make_array};
4097    use datafusion::arrow::buffer::{NullBuffer, OffsetBuffer, ScalarBuffer};
4098    use datafusion::arrow::datatypes::DataType;
4099    use datafusion::arrow::error::ArrowError;
4100    use datafusion::error::DataFusionError;
4101    use std::sync::Arc;
4102
4103    let Some(left) = left.as_any().downcast_ref::<ListArray>() else {
4104        return Ok(None);
4105    };
4106    let Some(right) = right.as_any().downcast_ref::<StructArray>() else {
4107        return Ok(None);
4108    };
4109    let DataType::List(return_field) = return_type else {
4110        return Ok(None);
4111    };
4112    if left.value_type() != right.data_type().clone()
4113        || return_field.data_type() != right.data_type()
4114        || !is_het_struct_type(Some(right.data_type()))
4115    {
4116        return Ok(None);
4117    }
4118
4119    let Some(tags) = right
4120        .column_by_name("__het_tag")
4121        .and_then(|column| column.as_any().downcast_ref::<Int8Array>())
4122    else {
4123        return Ok(None);
4124    };
4125    let Some(nested) = right
4126        .column_by_name("__het_list")
4127        .and_then(|column| column.as_any().downcast_ref::<ListArray>())
4128    else {
4129        return Ok(None);
4130    };
4131
4132    let nested_values = nested.values();
4133    let nested_offsets = nested.value_offsets();
4134    let mut promoted_ranges = vec![None; right.len()];
4135    for row in 0..right.len() {
4136        if right.is_null(row) || tags.value(row) != 4 || nested.is_null(row) {
4137            continue;
4138        }
4139        let start = usize::try_from(nested_offsets[row]).map_err(|_| {
4140            DataFusionError::ArrowError(
4141                Box::new(ArrowError::ComputeError(
4142                    "negative heterogeneous-list offset".into(),
4143                )),
4144                None,
4145            )
4146        })?;
4147        let end = usize::try_from(nested_offsets[row + 1]).map_err(|_| {
4148            DataFusionError::ArrowError(
4149                Box::new(ArrowError::ComputeError(
4150                    "negative heterogeneous-list offset".into(),
4151                )),
4152                None,
4153            )
4154        })?;
4155        promoted_ranges[row] = Some((start, end));
4156    }
4157    let promoted = if nested_values.is_empty() {
4158        Arc::new(right.slice(0, 0)) as datafusion::arrow::array::ArrayRef
4159    } else {
4160        promote_het_array(nested_values, right.data_type())?
4161    };
4162
4163    let left_data = left.values().to_data();
4164    let right_data = right.to_data();
4165    let promoted_data = promoted.to_data();
4166    let capacity = left.values().len() + right.len() + promoted.len();
4167    let mut values = MutableArrayData::new(
4168        vec![&left_data, &right_data, &promoted_data],
4169        true,
4170        capacity,
4171    );
4172    let left_offsets = left.value_offsets();
4173    let mut offsets = Vec::with_capacity(left.len() + 1);
4174    let mut validity = Vec::with_capacity(left.len());
4175    let mut output_len = 0usize;
4176    offsets.push(0i32);
4177    for row in 0..left.len() {
4178        if left.is_null(row) {
4179            validity.push(false);
4180            offsets.push(i32::try_from(output_len).map_err(|_| {
4181                DataFusionError::Execution("cypher_list_plus: list too long".into())
4182            })?);
4183            continue;
4184        }
4185        validity.push(true);
4186        let start = usize::try_from(left_offsets[row]).map_err(|_| {
4187            DataFusionError::Execution("cypher_list_plus: negative list offset".into())
4188        })?;
4189        let end = usize::try_from(left_offsets[row + 1]).map_err(|_| {
4190            DataFusionError::Execution("cypher_list_plus: negative list offset".into())
4191        })?;
4192        values.extend(0, start, end);
4193        output_len += end - start;
4194        if let Some((start, end)) = promoted_ranges[row] {
4195            values.extend(2, start, end);
4196            output_len += end - start;
4197        } else {
4198            values.extend(1, row, row + 1);
4199            output_len += 1;
4200        }
4201        offsets.push(
4202            i32::try_from(output_len).map_err(|_| {
4203                DataFusionError::Execution("cypher_list_plus: list too long".into())
4204            })?,
4205        );
4206    }
4207
4208    let values = make_array(values.freeze());
4209    Ok(Some(Arc::new(ListArray::new(
4210        return_field.clone(),
4211        OffsetBuffer::new(ScalarBuffer::from(offsets)),
4212        values,
4213        Some(NullBuffer::from(validity)),
4214    ))))
4215}
4216
4217/// Promote a tagged value array to a deeper version of the same recursive
4218/// heterogeneous schema. Existing buffers are reused; only missing deeper
4219/// list/map fields are introduced as null arrays.
4220fn promote_het_array(
4221    source: &datafusion::arrow::array::ArrayRef,
4222    target: &DataType,
4223) -> datafusion::error::Result<datafusion::arrow::array::ArrayRef> {
4224    use datafusion::arrow::array::{Array, ListArray, StructArray, new_null_array};
4225    use datafusion::arrow::compute::cast;
4226    use datafusion::arrow::datatypes::DataType;
4227    use datafusion::error::DataFusionError;
4228    use std::sync::Arc;
4229
4230    if source.data_type() == target {
4231        return Ok(source.clone());
4232    }
4233    match (source.data_type(), target) {
4234        (DataType::Struct(_), DataType::Struct(target_fields)) => {
4235            let source = source
4236                .as_any()
4237                .downcast_ref::<StructArray>()
4238                .ok_or_else(|| {
4239                    DataFusionError::Internal(
4240                        "heterogeneous value has a non-struct physical array".into(),
4241                    )
4242                })?;
4243            let columns = target_fields
4244                .iter()
4245                .map(|field| {
4246                    source.column_by_name(field.name()).map_or_else(
4247                        || Ok(new_null_array(field.data_type(), source.len())),
4248                        |column| promote_het_array(column, field.data_type()),
4249                    )
4250                })
4251                .collect::<datafusion::error::Result<Vec<_>>>()?;
4252            Ok(Arc::new(StructArray::new(
4253                target_fields.clone(),
4254                columns,
4255                source.nulls().cloned(),
4256            )))
4257        }
4258        (DataType::List(_), DataType::List(target_field)) => {
4259            let source = source.as_any().downcast_ref::<ListArray>().ok_or_else(|| {
4260                DataFusionError::Internal("heterogeneous list has a non-list physical array".into())
4261            })?;
4262            let values = promote_het_array(source.values(), target_field.data_type())?;
4263            Ok(Arc::new(ListArray::new(
4264                target_field.clone(),
4265                source.offsets().clone(),
4266                values,
4267                source.nulls().cloned(),
4268            )))
4269        }
4270        _ => Ok(cast(source, target)?),
4271    }
4272}
4273
4274fn list_plus_return_type(arg_types: &[DataType]) -> DataType {
4275    use datafusion::arrow::datatypes::Fields;
4276
4277    let value_types = arg_types
4278        .iter()
4279        .flat_map(|arg_type| {
4280            let value_type = list_item_type(arg_type).unwrap_or(arg_type);
4281            dynamic_variant_types(value_type)
4282        })
4283        .filter(|value_type| !matches!(value_type, DataType::Null))
4284        .collect::<Vec<_>>();
4285    if let Some(first) = value_types.first()
4286        && is_graph_value_struct(first)
4287        && value_types
4288            .iter()
4289            .all(|value_type| graph_value_types_compatible(first, value_type))
4290    {
4291        return DataType::new_list((*first).clone(), true);
4292    }
4293
4294    let mut variants = Vec::new();
4295    for arg_type in arg_types {
4296        let value_type = list_item_type(arg_type).unwrap_or(arg_type);
4297        if let DataType::Struct(fields) = value_type
4298            && fields
4299                .iter()
4300                .any(|field| field.name().starts_with("__het_value_"))
4301        {
4302            for field in fields
4303                .iter()
4304                .filter(|field| field.name().starts_with("__het_value_"))
4305            {
4306                if !variants.contains(field.data_type()) {
4307                    variants.push(field.data_type().clone());
4308                }
4309            }
4310        } else if let DataType::Struct(fields) = value_type
4311            && fields.iter().any(|field| field.name() == "__het_tag")
4312        {
4313            for (name, data_type) in [
4314                ("__het_int", DataType::Int64),
4315                ("__het_float", DataType::Float64),
4316                ("__het_str", DataType::Utf8),
4317                ("__het_bool", DataType::Boolean),
4318            ] {
4319                if fields.iter().any(|field| field.name() == name) && !variants.contains(&data_type)
4320                {
4321                    variants.push(data_type);
4322                }
4323            }
4324        } else if !matches!(value_type, DataType::Null) && !variants.contains(value_type) {
4325            variants.push(value_type.clone());
4326        }
4327    }
4328    if variants.is_empty() {
4329        variants.push(DataType::Null);
4330    }
4331    let mut fields = vec![Field::new("__het_tag", DataType::Int8, false)];
4332    fields.extend(
4333        variants
4334            .into_iter()
4335            .enumerate()
4336            .map(|(index, data_type)| Field::new(format!("__het_value_{index}"), data_type, true)),
4337    );
4338    DataType::new_list(DataType::Struct(Fields::from(fields)), true)
4339}
4340
4341fn list_plus_has_graph_value(arg_types: &[DataType]) -> bool {
4342    arg_types.iter().any(|arg_type| {
4343        let value_type = list_item_type(arg_type).unwrap_or(arg_type);
4344        dynamic_variant_types(value_type)
4345            .into_iter()
4346            .any(is_graph_value_struct)
4347    })
4348}
4349
4350fn is_graph_value_struct(data_type: &DataType) -> bool {
4351    matches!(data_type, DataType::Struct(fields) if fields.iter().any(|field| {
4352        matches!(
4353            field.name().as_str(),
4354            "node_uuid" | "edge_uuid" | "nodes" | "relationships"
4355        )
4356    }))
4357}
4358
4359fn is_dynamic_variant_struct(data_type: &DataType) -> bool {
4360    matches!(data_type, DataType::Struct(fields) if fields
4361        .iter()
4362        .any(|field| field.name().starts_with("__het_value_")))
4363}
4364
4365fn dynamic_variant_types(data_type: &DataType) -> Vec<&DataType> {
4366    if let DataType::Struct(fields) = data_type {
4367        let variants = fields
4368            .iter()
4369            .filter(|field| field.name().starts_with("__het_value_"))
4370            .map(|field| field.data_type())
4371            .collect::<Vec<_>>();
4372        if !variants.is_empty() {
4373            return variants;
4374        }
4375    }
4376    vec![data_type]
4377}
4378
4379fn list_item_type(dt: &DataType) -> Option<&DataType> {
4380    match dt {
4381        DataType::List(f) | DataType::LargeList(f) | DataType::FixedSizeList(f, _) => {
4382            Some(f.data_type())
4383        }
4384        _ => None,
4385    }
4386}
4387
4388fn list_plus_depth(arg_types: &[DataType]) -> usize {
4389    arg_types
4390        .iter()
4391        .filter_map(|data_type| {
4392            list_item_type(data_type)
4393                .or(Some(data_type))
4394                .and_then(het_depth_for_data_type)
4395        })
4396        .max()
4397        .unwrap_or(0)
4398}
4399
4400fn het_depth_for_data_type(data_type: &DataType) -> Option<usize> {
4401    if is_het_struct_type(Some(data_type)) {
4402        return het_struct_type_depth(data_type);
4403    }
4404    match data_type {
4405        DataType::Null
4406        | DataType::Boolean
4407        | DataType::Int8
4408        | DataType::Int16
4409        | DataType::Int32
4410        | DataType::Int64
4411        | DataType::UInt8
4412        | DataType::UInt16
4413        | DataType::UInt32
4414        | DataType::UInt64
4415        | DataType::Float16
4416        | DataType::Float32
4417        | DataType::Float64
4418        | DataType::Utf8
4419        | DataType::LargeUtf8 => Some(0),
4420        DataType::List(field) | DataType::LargeList(field) | DataType::FixedSizeList(field, _) => {
4421            Some(1 + het_depth_for_data_type(field.data_type())?)
4422        }
4423        DataType::Struct(fields) if is_plain_map_struct_type(data_type) => fields
4424            .iter()
4425            .filter_map(|field| het_depth_for_data_type(field.data_type()))
4426            .max()
4427            .map_or(Some(1), |depth| Some(1 + depth)),
4428        _ => None,
4429    }
4430}
4431
4432fn het_struct_type_depth(data_type: &DataType) -> Option<usize> {
4433    let DataType::Struct(fields) = data_type else {
4434        return None;
4435    };
4436    let Some(list_field) = fields.iter().find(|field| field.name() == "__het_list") else {
4437        return Some(0);
4438    };
4439    match list_field.data_type() {
4440        DataType::List(inner) => het_struct_type_depth(inner.data_type()).map(|depth| depth + 1),
4441        _ => Some(0),
4442    }
4443}
4444
4445fn list_elements_at(
4446    array: &datafusion::arrow::array::ArrayRef,
4447    row: usize,
4448) -> datafusion::error::Result<Option<Vec<ScalarValue>>> {
4449    use datafusion::arrow::array::{Array, FixedSizeListArray, LargeListArray, ListArray};
4450
4451    let values = if let Some(list) = array.as_any().downcast_ref::<ListArray>() {
4452        if list.is_null(row) {
4453            return Ok(None);
4454        }
4455        list.value(row)
4456    } else if let Some(list) = array.as_any().downcast_ref::<LargeListArray>() {
4457        if list.is_null(row) {
4458            return Ok(None);
4459        }
4460        list.value(row)
4461    } else if let Some(list) = array.as_any().downcast_ref::<FixedSizeListArray>() {
4462        if list.is_null(row) {
4463            return Ok(None);
4464        }
4465        list.value(row)
4466    } else {
4467        return Ok(None);
4468    };
4469
4470    (0..values.len())
4471        .map(|i| ScalarValue::try_from_array(&values, i).map(unwrap_het))
4472        .collect::<datafusion::error::Result<Vec<_>>>()
4473        .map(Some)
4474}
4475
4476fn scalar_list_elements(
4477    value: &ScalarValue,
4478) -> datafusion::error::Result<Option<Vec<ScalarValue>>> {
4479    use datafusion::arrow::array::Array;
4480
4481    match value {
4482        ScalarValue::List(list) => {
4483            if list.is_null(0) {
4484                return Ok(None);
4485            }
4486            let values = list.value(0);
4487            (0..values.len())
4488                .map(|i| ScalarValue::try_from_array(&values, i).map(unwrap_het))
4489                .collect::<datafusion::error::Result<Vec<_>>>()
4490                .map(Some)
4491        }
4492        ScalarValue::LargeList(list) => {
4493            if list.is_null(0) {
4494                return Ok(None);
4495            }
4496            let values = list.value(0);
4497            (0..values.len())
4498                .map(|i| ScalarValue::try_from_array(&values, i).map(unwrap_het))
4499                .collect::<datafusion::error::Result<Vec<_>>>()
4500                .map(Some)
4501        }
4502        _ => Ok(None),
4503    }
4504}
4505
4506/// Const-fold an all-map list of DIFFERENT shapes into a homogeneous
4507/// `List<Struct<union-of-keys>>` literal — each map padded with a typed null for
4508/// keys it lacks (Cypher: a missing key reads as `null`), so `x.field` access
4509/// works and the list is a literal that can nest. `None` on an unresolvable key
4510/// type conflict (same key, two different non-null types) — left to `make_array`.
4511/// (#1005)
4512fn all_map_union_list(scalars: &[ScalarValue]) -> Option<DfExpr> {
4513    use datafusion::arrow::array::{Array, ArrayRef, StructArray, new_null_array};
4514    use datafusion::arrow::buffer::{NullBuffer, OffsetBuffer};
4515    use datafusion::arrow::compute::{cast, concat};
4516    use datafusion::arrow::datatypes::{Field, Fields};
4517    use std::collections::HashMap;
4518    use std::sync::Arc;
4519
4520    // Ordered union of key → resolved (non-null) type; a null-typed field yields
4521    // to a later real type, two differing real types are a conflict.
4522    let mut order: Vec<String> = Vec::new();
4523    let mut types: HashMap<String, DataType> = HashMap::new();
4524    for s in scalars {
4525        let arr = match s {
4526            ScalarValue::Struct(a) => a,
4527            ScalarValue::Null => continue,
4528            _ => return None,
4529        };
4530        for f in arr.fields() {
4531            let t = f.data_type().clone();
4532            match types.get(f.name()) {
4533                None => {
4534                    order.push(f.name().clone());
4535                    types.insert(f.name().clone(), t);
4536                }
4537                Some(prev) if *prev == DataType::Null => {
4538                    types.insert(f.name().clone(), t);
4539                }
4540                Some(prev) if t != DataType::Null && t != *prev => return None,
4541                _ => {}
4542            }
4543        }
4544    }
4545    let union_fields: Fields = order
4546        .iter()
4547        .map(|n| Field::new(n, types.get(n).cloned().unwrap_or(DataType::Null), true))
4548        .collect::<Vec<_>>()
4549        .into();
4550
4551    // One concatenated column per union key (each row cast to the union type, a
4552    // missing key or a null-list-element → a typed null).
4553    let mut columns: Vec<ArrayRef> = Vec::with_capacity(order.len());
4554    for name in &order {
4555        let ut = types.get(name).cloned().unwrap_or(DataType::Null);
4556        let mut pieces: Vec<ArrayRef> = Vec::with_capacity(scalars.len());
4557        for s in scalars {
4558            let piece = match s {
4559                ScalarValue::Struct(a) => a
4560                    .column_by_name(name)
4561                    .and_then(|c| cast(c, &ut).ok())
4562                    .unwrap_or_else(|| new_null_array(&ut, 1)),
4563                _ => new_null_array(&ut, 1),
4564            };
4565            pieces.push(piece);
4566        }
4567        let refs: Vec<&dyn Array> = pieces.iter().map(AsRef::as_ref).collect();
4568        columns.push(concat(&refs).ok()?);
4569    }
4570    // A `null` list element (not a map) → a null struct row.
4571    let valid: NullBuffer = scalars.iter().map(|s| !s.is_null()).collect();
4572    let elem = StructArray::try_new(union_fields, columns, Some(valid)).ok()?;
4573    let n = scalars.len();
4574    let list_field = Arc::new(Field::new("item", elem.data_type().clone(), true));
4575    let list = datafusion::arrow::array::ListArray::new(
4576        list_field,
4577        OffsetBuffer::from_lengths([n]),
4578        Arc::new(elem),
4579        None,
4580    );
4581    Some(DfExpr::Literal(ScalarValue::List(Arc::new(list)), None))
4582}
4583
4584/// Lower an [`IrLiteral`] to a DataFusion [`Expr::Literal`].
4585fn lower_literal(lit_val: &IrLiteral) -> DfExpr {
4586    lit(ir_literal_to_scalar(lit_val))
4587}
4588
4589/// Convert an [`IrLiteral`] to a DataFusion [`ScalarValue`].
4590///
4591/// The single source of truth for the IR-literal → Arrow-scalar mapping, used
4592/// both for literal expression lowering ([`lower_literal`]) and for binding
4593/// query parameters to placeholder values (`$param` injection, #584).
4594#[must_use]
4595pub fn ir_literal_to_scalar(lit_val: &IrLiteral) -> ScalarValue {
4596    match lit_val {
4597        IrLiteral::Null => ScalarValue::Null,
4598        IrLiteral::Bool(b) => ScalarValue::Boolean(Some(*b)),
4599        IrLiteral::Int(n) => ScalarValue::Int64(Some(*n)),
4600        IrLiteral::Float(f) => ScalarValue::Float64(Some(*f)),
4601        IrLiteral::Str(s) => ScalarValue::Utf8(Some(s.clone())),
4602        IrLiteral::Uuid(uuid) => ScalarValue::FixedSizeBinary(16, Some(uuid.to_vec())),
4603        IrLiteral::Duration {
4604            months,
4605            days,
4606            seconds,
4607            nanos,
4608        } => duration_scalar(Some(crate::temporal::DurationValue {
4609            months: *months,
4610            days: *days,
4611            seconds: *seconds,
4612            nanos: *nanos,
4613        })),
4614        IrLiteral::DateTime(us) => ScalarValue::TimestampMicrosecond(Some(*us), Some("UTC".into())),
4615        IrLiteral::Date(days) => date_scalar(Some(*days)),
4616        IrLiteral::LocalDateTime { days, nanos } => localdatetime_scalar(Some((*days, *nanos))),
4617        IrLiteral::Time(nanos) => ScalarValue::Time64Nanosecond(Some(*nanos)),
4618        IrLiteral::ZonedTime { nanos, offset } => time_scalar(Some((*nanos, *offset))),
4619        IrLiteral::ZonedDateTime {
4620            days,
4621            nanos,
4622            offset,
4623            zone,
4624        } => datetime_scalar(Some((*days, *nanos, *offset, zone.clone()))),
4625        // A homogeneous list → a `ScalarValue::List` of the element scalars; the
4626        // inner type is the first element's (re-typing untyped nulls to it so the
4627        // array stays homogeneous, as the list-literal lowering does). (#1006)
4628        IrLiteral::List(items) => {
4629            let scalars: Vec<ScalarValue> = items.iter().map(ir_literal_to_scalar).collect();
4630            let elem_type = scalars
4631                .iter()
4632                .find(|s| !s.is_null())
4633                .map_or(DataType::Null, ScalarValue::data_type);
4634            let typed: Vec<ScalarValue> = scalars
4635                .iter()
4636                .map(|s| {
4637                    if matches!(s, ScalarValue::Null) {
4638                        ScalarValue::try_from(&elem_type).unwrap_or(ScalarValue::Null)
4639                    } else {
4640                        s.clone()
4641                    }
4642                })
4643                .collect();
4644            ScalarValue::List(ScalarValue::new_list(&typed, &elem_type, true))
4645        }
4646        IrLiteral::Map(entries) => {
4647            let scalars: Vec<(String, ScalarValue)> = entries
4648                .iter()
4649                .map(|(key, value)| (key.clone(), ir_literal_to_scalar(value)))
4650                .collect();
4651            const_map_scalar(&scalars).expect("IR map literal should lower to an Arrow struct")
4652        }
4653    }
4654}
4655
4656/// Convert a DataFusion [`ScalarValue`] back to an [`IrLiteral`] for storage as
4657/// a property value (#791 SET).
4658///
4659/// The inverse of [`ir_literal_to_scalar`], used by the SET execution node:
4660/// after evaluating a value expression per row it gets a `ScalarValue` that must
4661/// be stored as an `IrLiteral`. Numeric and temporal widths the writer does not
4662/// have a dedicated literal for are normalised to the nearest `IrLiteral`
4663/// (smaller ints → `Int`, `Float32` → `Float`, dates/times → `DateTime`). A
4664/// `null` scalar (typed or untyped) → [`IrLiteral::Null`].
4665///
4666/// Lists and temporal structs are converted recursively. Maps, graph values,
4667/// and other non-property shapes return an invalid-property-type error.
4668///
4669/// # Errors
4670/// Returns [`LoweringError::InvalidType`] for a value that openCypher does not
4671/// permit as a stored property.
4672pub fn scalar_to_ir_literal(value: &ScalarValue) -> Result<IrLiteral, LoweringError> {
4673    // Any null (typed `Int64(None)` or untyped `Null`) stores as Cypher null.
4674    if value.is_null() {
4675        return Ok(IrLiteral::Null);
4676    }
4677    let lit = match value {
4678        ScalarValue::Boolean(Some(b)) => IrLiteral::Bool(*b),
4679        ScalarValue::Int8(Some(n)) => IrLiteral::Int(i64::from(*n)),
4680        ScalarValue::Int16(Some(n)) => IrLiteral::Int(i64::from(*n)),
4681        ScalarValue::Int32(Some(n)) => IrLiteral::Int(i64::from(*n)),
4682        ScalarValue::Int64(Some(n)) => IrLiteral::Int(*n),
4683        ScalarValue::UInt8(Some(n)) => IrLiteral::Int(i64::from(*n)),
4684        ScalarValue::UInt16(Some(n)) => IrLiteral::Int(i64::from(*n)),
4685        ScalarValue::UInt32(Some(n)) => IrLiteral::Int(i64::from(*n)),
4686        ScalarValue::UInt64(Some(n)) => i64::try_from(*n).map(IrLiteral::Int).map_err(|_| {
4687            LoweringError::UnsupportedExpr(format!("SET value {n} exceeds the i64 range"))
4688        })?,
4689        ScalarValue::Float32(Some(f)) => IrLiteral::Float(f64::from(*f)),
4690        ScalarValue::Float64(Some(f)) => IrLiteral::Float(*f),
4691        ScalarValue::Utf8(Some(s))
4692        | ScalarValue::LargeUtf8(Some(s))
4693        | ScalarValue::Utf8View(Some(s)) => IrLiteral::Str(s.clone()),
4694        ScalarValue::FixedSizeBinary(16, Some(_)) => {
4695            return Err(LoweringError::InvalidType(
4696                "UUID values cannot be stored as graph properties".into(),
4697            ));
4698        }
4699        // Flat native-Arrow duration widths carry no month/day part. Split each
4700        // unit into whole seconds + non-negative nanos-of-second WITHOUT forming a
4701        // `*1e9` total (which would overflow i64 for large native durations — the
4702        // seconds field stores them directly). (#1011)
4703        ScalarValue::DurationSecond(Some(s)) => duration_value_to_ir(dur_secs_nanos(*s, 0)),
4704        ScalarValue::DurationMillisecond(Some(ms)) => duration_value_to_ir(dur_secs_nanos(
4705            ms.div_euclid(1_000),
4706            ms.rem_euclid(1_000) * 1_000_000,
4707        )),
4708        ScalarValue::DurationMicrosecond(Some(us)) => duration_value_to_ir(dur_secs_nanos(
4709            us.div_euclid(1_000_000),
4710            us.rem_euclid(1_000_000) * 1_000,
4711        )),
4712        ScalarValue::DurationNanosecond(Some(ns)) => {
4713            duration_value_to_ir(crate::temporal::DurationValue::from_total_nanos(0, 0, *ns))
4714        }
4715        ScalarValue::TimestampMicrosecond(Some(us), _) => IrLiteral::DateTime(*us),
4716        ScalarValue::TimestampSecond(Some(s), _) => IrLiteral::DateTime(s * 1_000_000),
4717        ScalarValue::TimestampMillisecond(Some(ms), _) => IrLiteral::DateTime(ms * 1_000),
4718        ScalarValue::TimestampNanosecond(Some(ns), _) => IrLiteral::DateTime(ns / 1_000),
4719        // A date keeps its date identity (ADR 0009/0012): a `Struct{epoch_day}` of
4720        // i64 days, not coerced to a `DateTime` — so it reads back and renders as a
4721        // date.
4722        ScalarValue::Struct(arr) if is_date_struct(&DataType::Struct(arr.fields().clone())) => {
4723            match date_struct_value(arr, 0) {
4724                Some(days) => IrLiteral::Date(days),
4725                None => IrLiteral::Null,
4726            }
4727        }
4728        // A typed `duration` struct (#920) keeps its months/days/seconds/nanos model.
4729        ScalarValue::Struct(arr) if is_duration_struct(&DataType::Struct(arr.fields().clone())) => {
4730            match duration_struct_parts(arr, 0) {
4731                Some(d) => duration_value_to_ir(d),
4732                None => IrLiteral::Null,
4733            }
4734        }
4735        // A typed `localdatetime` struct (#920): date-days + nanos-of-day.
4736        ScalarValue::Struct(arr)
4737            if is_localdatetime_struct(&DataType::Struct(arr.fields().clone())) =>
4738        {
4739            match localdatetime_struct_parts(arr, 0) {
4740                Some((days, nanos)) => IrLiteral::LocalDateTime { days, nanos },
4741                None => IrLiteral::Null,
4742            }
4743        }
4744        // A typed `localtime` (#920): nanoseconds-of-day, no zone.
4745        ScalarValue::Time64Nanosecond(Some(n)) => IrLiteral::Time(*n),
4746        // A typed `time` struct (#920): time-of-day + UTC offset.
4747        ScalarValue::Struct(arr) if is_time_struct(&DataType::Struct(arr.fields().clone())) => {
4748            match time_struct_parts(arr, 0) {
4749                Some((nanos, offset)) => IrLiteral::ZonedTime { nanos, offset },
4750                None => IrLiteral::Null,
4751            }
4752        }
4753        // A typed `datetime` struct (#920): date + time + offset + named zone.
4754        ScalarValue::Struct(arr) if is_datetime_struct(&DataType::Struct(arr.fields().clone())) => {
4755            match datetime_struct_parts(arr, 0) {
4756                Some((days, nanos, offset, zone)) => IrLiteral::ZonedDateTime {
4757                    days,
4758                    nanos,
4759                    offset,
4760                    zone,
4761                },
4762                None => IrLiteral::Null,
4763            }
4764        }
4765        // A homogeneous list (#1006): recurse element-wise (a null element →
4766        // `IrLiteral::Null`; an element type the gate rejects propagates its
4767        // error). Single-row `List`/`LargeList` scalars carry the elements at
4768        // row 0.
4769        ScalarValue::List(arr) => list_scalar_to_ir_literal(&arr.value(0))?,
4770        ScalarValue::LargeList(arr) => list_scalar_to_ir_literal(&arr.value(0))?,
4771        other => {
4772            // Plain maps, graph structs, and any width not handled above are
4773            // invalid openCypher property values.
4774            return Err(LoweringError::InvalidType(format!(
4775                "invalid property type for SET value: {:?}",
4776                other.data_type()
4777            )));
4778        }
4779    };
4780    Ok(lit)
4781}
4782
4783/// Build an [`IrLiteral::List`] from a list scalar's element array, recursing
4784/// through [`scalar_to_ir_literal`] per element. (#1006)
4785fn list_scalar_to_ir_literal(
4786    elems: &datafusion::arrow::array::ArrayRef,
4787) -> Result<IrLiteral, LoweringError> {
4788    let mut items = Vec::with_capacity(elems.len());
4789    for j in 0..elems.len() {
4790        let ev = ScalarValue::try_from_array(elems, j).map_err(|e| {
4791            LoweringError::UnsupportedExpr(format!("list element is not a scalar value: {e}"))
4792        })?;
4793        items.push(scalar_to_ir_literal(&ev)?);
4794    }
4795    Ok(IrLiteral::List(items))
4796}
4797
4798/// Canonicalise a literal-string temporal-constructor argument. Returns `None`
4799/// when the constructor doesn't take a string, or the string isn't a form the
4800/// `temporal` module recognises (the caller then falls back to the runtime
4801/// path). (#599)
4802fn render_temporal(name: &str, s: &str) -> Option<String> {
4803    use crate::temporal;
4804    match name {
4805        "date" => temporal::render_date(s),
4806        "localtime" => temporal::render_local_time(s),
4807        "time" => temporal::render_time(s),
4808        "localdatetime" => temporal::render_local_date_time(s),
4809        "datetime" => temporal::render_date_time(s),
4810        "duration" => temporal::render_duration(s),
4811        _ => None,
4812    }
4813}
4814
4815/// Look up a Cypher built-in function name and produce the DataFusion call.
4816///
4817/// Returns `None` if the name is not in the built-in table.
4818#[allow(
4819    clippy::too_many_lines,
4820    reason = "a flat one-arm-per-builtin dispatch table; clearest kept inline"
4821)]
4822fn resolve_builtin(
4823    name: &str,
4824    args: Vec<DfExpr>,
4825    path_hydration: impl FnOnce() -> Option<PathNodeHydration>,
4826) -> Option<DfExpr> {
4827    use datafusion::functions::math::expr_fn as mfn;
4828    use datafusion::functions::string::expr_fn as sfn;
4829
4830    let name = name.to_ascii_lowercase();
4831    let mut a = args;
4832    match name.as_str() {
4833        // String functions
4834        "toupper" | "upper" => Some(sfn::upper(a.remove(0))),
4835        "tolower" | "lower" => Some(sfn::lower(a.remove(0))),
4836        "trim" => Some(sfn::btrim(a)),
4837        "ltrim" => Some(sfn::ltrim(a)),
4838        "rtrim" => Some(sfn::rtrim(a)),
4839        "string.concat" | "concat" => Some(sfn::concat(a)),
4840        "replace" => Some(sfn::replace(a.remove(0), a.remove(0), a.remove(0))),
4841        "substring" if a.len() == 2 || a.len() == 3 => Some(cypher_substring(a)),
4842        // Unambiguously a string character-count operation.
4843        "char_length" | "character_length" => Some(
4844            datafusion::functions::unicode::expr_fn::char_length(a.remove(0)),
4845        ),
4846        // Type conversion. `toString` routes through `cypher_to_string` so a
4847        // typed temporal (Date32/Time64/`localdatetime`/`time` struct) renders to
4848        // its canonical openCypher string; every other type falls back to a plain
4849        // `Utf8` cast (unchanged behaviour). The `datetime` renderer arm lands with
4850        // the `datetime`-struct migration. (ADR 0009)
4851        "tostring" => Some(CYPHER_TO_STRING.call(vec![a.remove(0)])),
4852        "tointeger" => Some(CYPHER_TO_INTEGER.call(vec![a.remove(0)])),
4853        "tofloat" => Some(CYPHER_TO_FLOAT.call(vec![a.remove(0)])),
4854        "toboolean" => Some(CYPHER_TO_BOOLEAN.call(vec![a.remove(0)])),
4855
4856        // Runtime `date(<expr>)` (a non-constant argument; constants are handled
4857        // as a `Date32` scalar in `lower_temporal`). `to_date` already yields a
4858        // typed `Date32`, so dates round-trip as one type — e.g.
4859        // `date(toString(d)) = d` compares `Date32 == Date32` (ADR 0009). A
4860        // `Date32` argument passes through unchanged; an ISO string is parsed.
4861        "date" if a.len() == 1 => {
4862            use datafusion::functions::datetime::expr_fn::to_date;
4863            Some(to_date(vec![a.remove(0)]))
4864        }
4865
4866        // Math functions
4867        "abs" => Some(mfn::abs(a.remove(0))),
4868        "ceil" => Some(mfn::ceil(a.remove(0))),
4869        "floor" => Some(mfn::floor(a.remove(0))),
4870        "round" => Some(mfn::round(a)),
4871        "sqrt" => Some(mfn::sqrt(a.remove(0))),
4872        "log" => Some(mfn::log(a.remove(0), a.remove(0))),
4873        "exp" => Some(mfn::exp(a.remove(0))),
4874        "power" => Some(mfn::power(a.remove(0), a.remove(0))),
4875        // `rand()` → a random float in [0, 1). Non-deterministic, but the
4876        // Quantifier9-12 invariant scenarios only use it to build a random
4877        // sub-list and then assert a result that holds for ANY list. (#955)
4878        "rand" if a.is_empty() => Some(mfn::random()),
4879        // `sign(n)` → -1 / 0 / 1 (openCypher returns an integer). Arity-guarded so
4880        // a wrong-arity call errors rather than panicking on `remove(0)`.
4881        "sign" if a.len() == 1 => Some(cast(mfn::signum(a.remove(0)), DataType::Int64)),
4882        // `coalesce(a, b, …)` → first non-null argument (at least one).
4883        "coalesce" if !a.is_empty() => Some(datafusion::functions::core::expr_fn::coalesce(a)),
4884        // `tail(list)` → every element but the first.
4885        "tail" if a.len() == 1 => Some(datafusion::functions_nested::expr_fn::array_pop_front(
4886            a.remove(0),
4887        )),
4888        // `split(str, delim)` → list of substrings.
4889        "split" if a.len() == 2 => Some(datafusion::functions_nested::expr_fn::string_to_array(
4890            a.remove(0),
4891            a.remove(0),
4892            DfExpr::Literal(ScalarValue::Utf8(None), None),
4893        )),
4894
4895        // Cypher `length(<list>)` → element count. openCypher `length` is
4896        // path/list-oriented, so it maps cleanly to `array_length` (#709) — for
4897        // a variable-length edge list `r`, `length(r)` is the hop count per path.
4898        "length" => Some(datafusion::functions_nested::expr_fn::array_length(
4899            a.remove(0),
4900        )),
4901
4902        // Cypher `size()` — element count of a list OR character count of a
4903        // string. Polymorphic, so it dispatches on the argument's runtime type
4904        // in `cypher_size` (a static `ScalarUDF`) rather than statically mapping
4905        // to `array_length` (which would mis-handle `size("str")`).
4906        "size" => Some(CYPHER_SIZE.call(vec![a.remove(0)])),
4907
4908        // ---- list / relationship-list access (#743) ----
4909        // openCypher list indexing is 0-based with negative-from-end and
4910        // null-on-out-of-range; DataFusion `array_element` is 1-based (negatives
4911        // already count from the end, OOB → null), so a non-negative index is
4912        // shifted +1 and negatives pass through. See `one_based_index`.
4913        "_subscript" => {
4914            let list = a.remove(0);
4915            let idx = a.remove(0);
4916            Some(datafusion::functions_nested::expr_fn::array_element(
4917                list,
4918                one_based_index(idx),
4919            ))
4920        }
4921
4922        // `head(list)` / `last(list)` — first / last element.
4923        "head" => Some(datafusion::functions_nested::expr_fn::array_element(
4924            a.remove(0),
4925            lit(1_i64),
4926        )),
4927        "last" => Some(datafusion::functions_nested::expr_fn::array_element(
4928            a.remove(0),
4929            lit(-1_i64),
4930        )),
4931
4932        // `r[start..end]` slicing. The parser emits distinct internal function
4933        // names for omitted bounds so explicit `null` can propagate to a null
4934        // list (#962). openCypher: 0-based, start inclusive, end **exclusive**,
4935        // negatives from end; DataFusion
4936        // `array_slice(list, begin, end)` is 1-based and end **inclusive**.
4937        // Translation (per bound, via CASE on sign):
4938        //   begin: omitted → 1; s>=0 → s+1; s<0 → s (from end)
4939        //   end:   omitted → array_length(list); e>=0 → e (excl e == incl e-1,
4940        //          and 1-based incl == 0-based e-1, so the 1-based bound is just
4941        //          e); e<0 → e-1 (exclusive → inclusive shifts one toward start)
4942        "_slice" => {
4943            let list = a.remove(0);
4944            let start = a.remove(0);
4945            let end = a.remove(0);
4946            Some(cypher_slice(list, Some(start), Some(end)))
4947        }
4948        "_slice_from_start" => {
4949            let list = a.remove(0);
4950            let end = a.remove(0);
4951            Some(cypher_slice(list, None, Some(end)))
4952        }
4953        "_slice_to_end" => {
4954            let list = a.remove(0);
4955            let start = a.remove(0);
4956            Some(cypher_slice(list, Some(start), None))
4957        }
4958
4959        // `range(start, end [, step])` — an integer list INCLUSIVE of `end`
4960        // (Cypher), unlike DataFusion's `range(start, stop, step)` which is
4961        // exclusive (`[start, stop)`). Shift the stop one past `end` so `end` is
4962        // included: `+1` for an ascending range, `-1` for a literal-negative
4963        // step. `step` defaults to 1. (A non-literal step is assumed ascending.)
4964        "range" if a.len() == 2 || a.len() == 3 => {
4965            let from = a.remove(0);
4966            let end = a.remove(0);
4967            let by = if a.is_empty() {
4968                lit(1_i64)
4969            } else {
4970                a.remove(0)
4971            };
4972            Some(CYPHER_RANGE.call(vec![from, end, by]))
4973        }
4974
4975        // `type(rel)` — the relation-type name. For a relationship-list element
4976        // (a `Struct<…, rel_type>`), read the `rel_type` field.
4977        "type" => Some(CYPHER_REL_TYPE.call(vec![a.remove(0)])),
4978
4979        // Named-path internal builtins (#754) live in their own table.
4980        other => resolve_path_builtin(other, a, path_hydration),
4981    }
4982}
4983
4984/// Build zero-based element ordinals for a list while preserving null versus
4985/// empty input (`null -> null`, `[] -> []`). Used by relationally lifted list
4986/// comprehensions whose element order must survive an unwind/regroup cycle.
4987pub(crate) fn list_index_range(list: DfExpr) -> DfExpr {
4988    let len = cast(
4989        datafusion::functions_nested::expr_fn::array_length(list),
4990        DataType::Int64,
4991    );
4992    CYPHER_RANGE.call(vec![lit(0_i64), len - lit(1_i64), lit(1_i64)])
4993}
4994
4995fn cypher_substring(mut args: Vec<DfExpr>) -> DfExpr {
4996    let original = args.remove(0);
4997    let start = args.remove(0) + lit(1_i64);
4998    let substring = if args.is_empty() {
4999        datafusion::functions::unicode::expr_fn::substr(original, start)
5000    } else {
5001        datafusion::functions::unicode::expr_fn::substring(original, start, args.remove(0))
5002    };
5003    cast(substring, DataType::Utf8)
5004}
5005
5006fn is_path_builtin_name(name: &str) -> bool {
5007    matches!(name, "_path_nodes" | "_path_fixed_length" | "_path_struct")
5008}
5009
5010/// The named-path internal builtins (#754): the binder rewrites `nodes(p)` /
5011/// `relationships(p)` / `length(p)` / bare `p` into these, split by whether
5012/// the path's single segment is variable-length (list column) or a fixed hop
5013/// (scalar edge/node columns).
5014///
5015/// Every form null-propagates: an unmatched `OPTIONAL MATCH` row's path is
5016/// Cypher `null`, so its functions must be too. The var-length forms inherit
5017/// this from their inputs (`array_length`/`cypher_path_nodes` of a null list
5018/// are null); the fixed-hop forms gate on the edge's `edge_uuid` being
5019/// non-null (composed values like `named_struct` would otherwise be non-null
5020/// even over all-null columns).
5021fn resolve_path_builtin(
5022    name: &str,
5023    args: Vec<DfExpr>,
5024    hydration: impl FnOnce() -> Option<PathNodeHydration>,
5025) -> Option<DfExpr> {
5026    use datafusion::functions::core::expr_fn::named_struct;
5027
5028    let mut a = args;
5029    match name {
5030        // `nodes(p)` over a variable-length segment: recover the traversal
5031        // node sequence by walking the relationship-list column from the
5032        // start node (`cypher_path_nodes`). With a read target, the elements
5033        // are hydrated with labels + properties (#1024); without one they
5034        // stay `node_uuid`-only.
5035        "_path_nodes" => {
5036            let seed = node_uuid_col(a.remove(0));
5037            let rels = a.remove(0);
5038            Some(match hydration() {
5039                Some(h) => ScalarUDF::new_from_impl(CypherPathNodes::with_hydration(h))
5040                    .call(vec![seed, rels]),
5041                None => CYPHER_PATH_NODES.call(vec![seed, rels]),
5042            })
5043        }
5044
5045        // `length(p)` over a fixed single hop: exactly one relationship when
5046        // the hop matched. UInt64 so fixed and var-length (`array_length`)
5047        // agree on the output type.
5048        "_path_fixed_length" => {
5049            let present = edge_present(&a.remove(0))?;
5050            Some(
5051                when(present, lit(1_u64))
5052                    .otherwise(lit(ScalarValue::UInt64(None)))
5053                    .expect("CASE build is infallible for a single WHEN + ELSE"),
5054            )
5055        }
5056
5057        // A bare path value (`RETURN p`): Struct{nodes, relationships} over
5058        // the already-lowered component expressions. Gated on the nodes list
5059        // (null exactly when the path is unmatched, for both segment kinds) —
5060        // a bare `named_struct` would be non-null even over null fields.
5061        "_path_struct" => {
5062            let nodes = a.remove(0);
5063            let rels = a.remove(0);
5064            let struct_expr = named_struct(vec![
5065                lit("nodes"),
5066                nodes.clone(),
5067                lit("relationships"),
5068                rels,
5069            ]);
5070            Some(null_unless(nodes.is_not_null(), struct_expr))
5071        }
5072
5073        _ => None,
5074    }
5075}
5076
5077/// `edge_uuid IS NOT NULL` for a lowered edge `VarRef` — whether the hop
5078/// matched (false only on an unmatched `OPTIONAL MATCH` row). `None` when the
5079/// edge did not lower to its bare scan qualifier.
5080fn edge_present(edge: &DfExpr) -> Option<DfExpr> {
5081    let DfExpr::Column(c) = edge else {
5082        return None;
5083    };
5084    Some(col(format!("{}.edge_uuid", c.name)).is_not_null())
5085}
5086
5087/// `CASE WHEN <present> THEN <value> ELSE NULL END` — null-propagation for
5088/// composed path values whose parts would otherwise build non-null containers
5089/// over all-null columns.
5090fn null_unless(present: DfExpr, value: DfExpr) -> DfExpr {
5091    when(present, value)
5092        .otherwise(lit(ScalarValue::Null))
5093        .expect("CASE build is infallible for a single WHEN + ELSE")
5094}
5095
5096/// Compose the `node_uuid` column of a lowered node `VarRef`.
5097///
5098/// A node variable lowers to its bare scan qualifier (`col("var_<n>")`), so
5099/// the uuid column is the dotted composition — the same rule
5100/// `resolve_prop_col` applies to property columns. Falls back to `get_field`
5101/// for a computed base.
5102fn node_uuid_col(base: DfExpr) -> DfExpr {
5103    match &base {
5104        DfExpr::Column(c) => col(format!("{}.node_uuid", c.name)),
5105        _ => datafusion::functions::core::expr_fn::get_field(base, "node_uuid"),
5106    }
5107}
5108
5109fn edge_present_qual(base: &str) -> DfExpr {
5110    col(format!("{base}.edge_uuid")).is_not_null()
5111}
5112
5113fn is_edge_value_topology_field(name: &str) -> bool {
5114    matches!(
5115        name,
5116        "edge_uuid"
5117            | "src_uuid"
5118            | "dst_uuid"
5119            | "edge_id"
5120            | "src_id"
5121            | "dst_id"
5122            | "created_at"
5123            | "rel_type_name"
5124    )
5125}
5126
5127fn empty_utf8_list() -> DfExpr {
5128    DfExpr::Literal(
5129        ScalarValue::List(ScalarValue::new_list(&[], &DataType::Utf8, true)),
5130        None,
5131    )
5132}
5133
5134fn empty_map_struct() -> DfExpr {
5135    DfExpr::Literal(
5136        ScalarValue::Struct(std::sync::Arc::new(
5137            datafusion::arrow::array::StructArray::new_empty_fields(1, None),
5138        )),
5139        None,
5140    )
5141}
5142
5143fn null_utf8_list() -> DfExpr {
5144    null_unless(lit(false), empty_utf8_list())
5145}
5146
5147fn node_labels_list(base: &str, label: Option<&str>, type_id_map: &HashMap<u32, String>) -> DfExpr {
5148    use datafusion::functions_nested::expr_fn::{array_concat, array_has, make_array};
5149
5150    if type_id_map.is_empty() {
5151        return label.map_or_else(empty_utf8_list, |name| make_array(vec![lit(name)]));
5152    }
5153
5154    let mut entries: Vec<(u32, &str)> = type_id_map
5155        .iter()
5156        .map(|(id, name)| (*id, name.as_str()))
5157        .collect();
5158    entries.sort_by_key(|(id, _)| *id);
5159    let labels = col(format!("{base}.type_ids"));
5160    let parts = entries
5161        .into_iter()
5162        .map(|(id, name)| {
5163            when(
5164                array_has(labels.clone(), lit(id)),
5165                make_array(vec![lit(name)]),
5166            )
5167            .otherwise(empty_utf8_list())
5168            .expect("CASE build is infallible for a single WHEN + ELSE")
5169        })
5170        .collect();
5171    array_concat(parts)
5172}
5173
5174/// Assemble a whole node value for a bare `RETURN n` (#785):
5175/// `Struct{node_uuid, labels: List<Utf8>, <prop…>}` over the node var's lowered
5176/// scan qualifier `base` (e.g. `"var_0"`). Property columns are referenced as
5177/// `base.<prop>` (already materialized by `join_node_properties`). Gated
5178/// `null_unless(node_uuid present)` so an unmatched OPTIONAL row yields null.
5179fn node_value_struct(
5180    base: &str,
5181    label: Option<&str>,
5182    type_id_map: &HashMap<u32, String>,
5183    prop_names: &[String],
5184) -> DfExpr {
5185    let labels = node_labels_list(base, label, type_id_map);
5186    node_value_struct_with_labels(base, labels, prop_names)
5187}
5188
5189fn node_value_struct_with_labels(base: &str, labels: DfExpr, prop_names: &[String]) -> DfExpr {
5190    use datafusion::functions::core::expr_fn::named_struct;
5191
5192    let mut fields = vec![
5193        lit("node_uuid"),
5194        col(format!("{base}.node_uuid")),
5195        lit("labels"),
5196        labels,
5197    ];
5198    for name in prop_names {
5199        fields.push(lit(name.as_str()));
5200        fields.push(qualified_col(base, name));
5201    }
5202    let value = named_struct(fields);
5203    null_unless(qualified_col(base, "node_uuid").is_not_null(), value)
5204}
5205
5206/// Assemble a whole relationship value for a bare `RETURN r` / fixed-hop
5207/// `relationships(p)` element (#889): `Struct{edge_uuid, src_uuid, dst_uuid,
5208/// rel_type, <prop…>}` over the edge var's lowered scan qualifier `base`.
5209/// Property columns are referenced as `base.<prop>` after
5210/// `join_edge_properties` has materialized them.
5211fn relationship_value_struct(base: &str, rel_type: DfExpr, prop_names: &[String]) -> DfExpr {
5212    use datafusion::functions::core::expr_fn::named_struct;
5213
5214    let mut fields = vec![
5215        lit("edge_uuid"),
5216        col(format!("{base}.edge_uuid")),
5217        lit("src_uuid"),
5218        col(format!("{base}.src_uuid")),
5219        lit("dst_uuid"),
5220        col(format!("{base}.dst_uuid")),
5221        lit("rel_type"),
5222        cast(rel_type, DataType::Utf8),
5223    ];
5224    for name in prop_names {
5225        fields.push(lit(name.as_str()));
5226        fields.push(qualified_col(base, name));
5227    }
5228    named_struct(fields)
5229}
5230
5231/// openCypher 0-based index → DataFusion `array_element` 1-based index.
5232///
5233/// `CASE WHEN idx >= 0 THEN idx + 1 ELSE idx END` — non-negative indices shift
5234/// up by one; negative indices already count from the end in both systems.
5235fn one_based_index(idx: DfExpr) -> DfExpr {
5236    when(idx.clone().gt_eq(lit(0_i64)), idx.clone() + lit(1_i64))
5237        .otherwise(idx)
5238        .expect("CASE build is infallible for a single WHEN + ELSE")
5239}
5240
5241fn is_integer_data_type(dt: &DataType) -> bool {
5242    matches!(
5243        dt,
5244        DataType::Int8
5245            | DataType::Int16
5246            | DataType::Int32
5247            | DataType::Int64
5248            | DataType::UInt8
5249            | DataType::UInt16
5250            | DataType::UInt32
5251            | DataType::UInt64
5252    )
5253}
5254
5255/// Whether a lowered expression is the `Null` literal.
5256fn as_null_literal(e: &DfExpr) -> bool {
5257    matches!(e, DfExpr::Literal(ScalarValue::Null, _))
5258}
5259
5260fn cypher_slice(list: DfExpr, start: Option<DfExpr>, end: Option<DfExpr>) -> DfExpr {
5261    let mut present = lit(true);
5262    let begin_expr = match start {
5263        Some(start) if as_null_literal(&start) => {
5264            present = lit(false);
5265            lit(1_i64)
5266        }
5267        Some(start) => {
5268            present = present.and(start.clone().is_not_null());
5269            when(start.clone().gt_eq(lit(0_i64)), start.clone() + lit(1_i64))
5270                .otherwise(start)
5271                .expect("CASE build")
5272        }
5273        None => lit(1_i64),
5274    };
5275    let end_expr = match end {
5276        Some(end) if as_null_literal(&end) => {
5277            present = lit(false);
5278            cast(
5279                datafusion::functions_nested::expr_fn::array_length(list.clone()),
5280                DataType::Int64,
5281            )
5282        }
5283        Some(end) => {
5284            present = present.and(end.clone().is_not_null());
5285            when(end.clone().gt_eq(lit(0_i64)), end.clone())
5286                .otherwise(end - lit(1_i64))
5287                .expect("CASE build")
5288        }
5289        None => cast(
5290            datafusion::functions_nested::expr_fn::array_length(list.clone()),
5291            DataType::Int64,
5292        ),
5293    };
5294    let slice =
5295        datafusion::functions_nested::expr_fn::array_slice(list, begin_expr, end_expr, None);
5296    null_unless(present, slice)
5297}
5298
5299// ---------------------------------------------------------------------------
5300// Cypher conversion UDFs
5301// ---------------------------------------------------------------------------
5302
5303static CYPHER_TO_INTEGER: LazyLock<ScalarUDF> = LazyLock::new(|| {
5304    ScalarUDF::new_from_impl(CypherConversion::new(CypherConversionKind::Integer))
5305});
5306static CYPHER_TO_FLOAT: LazyLock<ScalarUDF> =
5307    LazyLock::new(|| ScalarUDF::new_from_impl(CypherConversion::new(CypherConversionKind::Float)));
5308static CYPHER_TO_BOOLEAN: LazyLock<ScalarUDF> = LazyLock::new(|| {
5309    ScalarUDF::new_from_impl(CypherConversion::new(CypherConversionKind::Boolean))
5310});
5311
5312#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
5313enum CypherConversionKind {
5314    Integer,
5315    Float,
5316    Boolean,
5317}
5318
5319#[derive(Debug, PartialEq, Eq, Hash)]
5320struct CypherConversion {
5321    kind: CypherConversionKind,
5322    signature: Signature,
5323}
5324
5325impl CypherConversion {
5326    fn new(kind: CypherConversionKind) -> Self {
5327        Self {
5328            kind,
5329            signature: Signature::any(1, Volatility::Immutable),
5330        }
5331    }
5332}
5333
5334impl ScalarUDFImpl for CypherConversion {
5335    fn as_any(&self) -> &dyn Any {
5336        self
5337    }
5338
5339    fn name(&self) -> &'static str {
5340        match self.kind {
5341            CypherConversionKind::Integer => "cypher_to_integer",
5342            CypherConversionKind::Float => "cypher_to_float",
5343            CypherConversionKind::Boolean => "cypher_to_boolean",
5344        }
5345    }
5346
5347    fn signature(&self) -> &Signature {
5348        &self.signature
5349    }
5350
5351    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
5352        Ok(match self.kind {
5353            CypherConversionKind::Integer => DataType::Int64,
5354            CypherConversionKind::Float => DataType::Float64,
5355            CypherConversionKind::Boolean => DataType::Boolean,
5356        })
5357    }
5358
5359    fn invoke_with_args(
5360        &self,
5361        args: ScalarFunctionArgs,
5362    ) -> datafusion::error::Result<ColumnarValue> {
5363        use datafusion::arrow::array::{BooleanArray, Float64Array, Int64Array};
5364
5365        let array = args.args[0].to_array(args.number_rows)?;
5366        match self.kind {
5367            CypherConversionKind::Integer => {
5368                let out: datafusion::error::Result<Int64Array> = (0..array.len())
5369                    .map(|i| {
5370                        let value = decoded_scalar_at(&array, i)?;
5371                        to_cypher_integer(&value)
5372                    })
5373                    .collect();
5374                Ok(ColumnarValue::Array(std::sync::Arc::new(out?)))
5375            }
5376            CypherConversionKind::Float => {
5377                let out: datafusion::error::Result<Float64Array> = (0..array.len())
5378                    .map(|i| {
5379                        let value = decoded_scalar_at(&array, i)?;
5380                        to_cypher_float(&value)
5381                    })
5382                    .collect();
5383                Ok(ColumnarValue::Array(std::sync::Arc::new(out?)))
5384            }
5385            CypherConversionKind::Boolean => {
5386                let out: datafusion::error::Result<BooleanArray> = (0..array.len())
5387                    .map(|i| {
5388                        let value = decoded_scalar_at(&array, i)?;
5389                        to_cypher_boolean(&value)
5390                    })
5391                    .collect();
5392                Ok(ColumnarValue::Array(std::sync::Arc::new(out?)))
5393            }
5394        }
5395    }
5396}
5397
5398fn decoded_scalar_at(
5399    array: &datafusion::arrow::array::ArrayRef,
5400    row: usize,
5401) -> datafusion::error::Result<ScalarValue> {
5402    let value = ScalarValue::try_from_array(array, row)?;
5403    Ok(unwrap_het(value))
5404}
5405
5406fn conversion_type_error(fn_name: &str, value: &ScalarValue) -> datafusion::error::DataFusionError {
5407    datafusion::error::DataFusionError::Execution(format!(
5408        "{fn_name}() cannot convert value of type {:?}",
5409        value.data_type()
5410    ))
5411}
5412
5413fn to_cypher_integer(value: &ScalarValue) -> datafusion::error::Result<Option<i64>> {
5414    if value.is_null() {
5415        return Ok(None);
5416    }
5417    if let Some(i) = scalar_as_i128(value) {
5418        return i64::try_from(i)
5419            .map(Some)
5420            .map_err(|_| conversion_type_error("toInteger", value));
5421    }
5422    match value {
5423        ScalarValue::Float32(Some(f)) => Ok(trunc_float_to_i64(f64::from(*f))),
5424        ScalarValue::Float64(Some(f)) => Ok(trunc_float_to_i64(*f)),
5425        ScalarValue::Utf8(Some(s)) | ScalarValue::LargeUtf8(Some(s)) => Ok(s
5426            .parse::<f64>()
5427            .ok()
5428            .filter(|f| f.is_finite())
5429            .and_then(trunc_float_to_i64)),
5430        _ => Err(conversion_type_error("toInteger", value)),
5431    }
5432}
5433
5434#[allow(
5435    clippy::cast_possible_truncation,
5436    clippy::cast_precision_loss,
5437    reason = "openCypher toInteger truncates finite floating values toward zero"
5438)]
5439fn trunc_float_to_i64(f: f64) -> Option<i64> {
5440    if !f.is_finite() {
5441        return None;
5442    }
5443    let truncated = f.trunc();
5444    if truncated < i64::MIN as f64 || truncated > i64::MAX as f64 {
5445        return None;
5446    }
5447    Some(truncated as i64)
5448}
5449
5450fn to_cypher_float(value: &ScalarValue) -> datafusion::error::Result<Option<f64>> {
5451    if value.is_null() {
5452        return Ok(None);
5453    }
5454    if let Some(f) = scalar_as_f64(value) {
5455        return Ok(Some(f));
5456    }
5457    match value {
5458        ScalarValue::Utf8(Some(s)) | ScalarValue::LargeUtf8(Some(s)) => {
5459            Ok(s.parse::<f64>().ok().filter(|f| f.is_finite()))
5460        }
5461        _ => Err(conversion_type_error("toFloat", value)),
5462    }
5463}
5464
5465fn to_cypher_boolean(value: &ScalarValue) -> datafusion::error::Result<Option<bool>> {
5466    if value.is_null() {
5467        return Ok(None);
5468    }
5469    match value {
5470        ScalarValue::Boolean(Some(b)) => Ok(Some(*b)),
5471        ScalarValue::Utf8(Some(s)) | ScalarValue::LargeUtf8(Some(s)) => match s.as_str() {
5472            "true" => Ok(Some(true)),
5473            "false" => Ok(Some(false)),
5474            _ => Ok(None),
5475        },
5476        _ => Err(conversion_type_error("toBoolean", value)),
5477    }
5478}
5479
5480fn cypher_float_string(f: f64) -> String {
5481    if f == 0.0 {
5482        "0.0".to_owned()
5483    } else if f.is_nan() {
5484        "NaN".to_owned()
5485    } else if f.is_infinite() {
5486        if f.is_sign_positive() {
5487            "Infinity".to_owned()
5488        } else {
5489            "-Infinity".to_owned()
5490        }
5491    } else {
5492        let mut s = f.to_string();
5493        if !s.contains('.') && !s.contains('e') && !s.contains('E') {
5494            s.push_str(".0");
5495        }
5496        s
5497    }
5498}
5499
5500fn to_cypher_string(value: &ScalarValue) -> datafusion::error::Result<Option<String>> {
5501    if value.is_null() {
5502        return Ok(None);
5503    }
5504    match value {
5505        ScalarValue::Int8(Some(n)) => Ok(Some(n.to_string())),
5506        ScalarValue::Int16(Some(n)) => Ok(Some(n.to_string())),
5507        ScalarValue::Int32(Some(n)) => Ok(Some(n.to_string())),
5508        ScalarValue::Int64(Some(n)) => Ok(Some(n.to_string())),
5509        ScalarValue::UInt8(Some(n)) => Ok(Some(n.to_string())),
5510        ScalarValue::UInt16(Some(n)) => Ok(Some(n.to_string())),
5511        ScalarValue::UInt32(Some(n)) => Ok(Some(n.to_string())),
5512        ScalarValue::UInt64(Some(n)) => Ok(Some(n.to_string())),
5513        ScalarValue::Float32(Some(f)) => Ok(Some(cypher_float_string(f64::from(*f)))),
5514        ScalarValue::Float64(Some(f)) => Ok(Some(cypher_float_string(*f))),
5515        ScalarValue::Boolean(Some(b)) => Ok(Some(b.to_string())),
5516        ScalarValue::Utf8(Some(s)) | ScalarValue::LargeUtf8(Some(s)) => Ok(Some(s.clone())),
5517        _ => Err(conversion_type_error("toString", value)),
5518    }
5519}
5520
5521// ---------------------------------------------------------------------------
5522// cypher_size UDF
5523// ---------------------------------------------------------------------------
5524
5525/// The Cypher `size()` scalar function: element count of a list **or** character
5526/// count of a string, dispatched on the argument's runtime type.
5527///
5528/// openCypher `size()` is polymorphic, so it cannot be statically mapped to a
5529/// single DataFusion function (`array_length` would mis-handle a string,
5530/// `char_length` a list). This UDF inspects the argument's [`DataType`] and
5531/// delegates to the matching Arrow kernel; an unsupported type yields `Null`.
5532///
5533/// Defined as a static [`ScalarUDF`] and invoked inline via
5534/// [`ScalarUDF::call`], so it carries its own implementation in the produced
5535/// `Expr` and needs no `SessionContext` registration.
5536static CYPHER_SIZE: LazyLock<ScalarUDF> =
5537    LazyLock::new(|| ScalarUDF::new_from_impl(CypherSize::new()));
5538
5539/// Opaque per-row marker used beside literal-null grouping keys. DataFusion
5540/// otherwise removes the null key and changes an empty grouped aggregate into
5541/// a one-row global aggregate.
5542pub(crate) static CYPHER_ROW_MARKER: LazyLock<ScalarUDF> =
5543    LazyLock::new(|| ScalarUDF::new_from_impl(CypherRowMarker::new()));
5544
5545#[derive(Debug, PartialEq, Eq, Hash)]
5546struct CypherRowMarker {
5547    signature: Signature,
5548}
5549
5550impl CypherRowMarker {
5551    fn new() -> Self {
5552        Self {
5553            signature: Signature::any(1, Volatility::Immutable),
5554        }
5555    }
5556}
5557
5558impl ScalarUDFImpl for CypherRowMarker {
5559    fn as_any(&self) -> &dyn Any {
5560        self
5561    }
5562
5563    fn name(&self) -> &'static str {
5564        "cypher_row_marker"
5565    }
5566
5567    fn signature(&self) -> &Signature {
5568        &self.signature
5569    }
5570
5571    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
5572        Ok(DataType::Boolean)
5573    }
5574
5575    fn invoke_with_args(
5576        &self,
5577        args: ScalarFunctionArgs,
5578    ) -> datafusion::error::Result<ColumnarValue> {
5579        let values = datafusion::arrow::array::BooleanArray::from(vec![true; args.number_rows]);
5580        Ok(ColumnarValue::Array(Arc::new(values)))
5581    }
5582}
5583
5584#[derive(Debug, PartialEq, Eq, Hash)]
5585struct CypherSize {
5586    signature: Signature,
5587}
5588
5589impl CypherSize {
5590    fn new() -> Self {
5591        // One argument of any type; immutable (same input → same output).
5592        Self {
5593            signature: Signature::any(1, Volatility::Immutable),
5594        }
5595    }
5596}
5597
5598impl ScalarUDFImpl for CypherSize {
5599    fn as_any(&self) -> &dyn Any {
5600        self
5601    }
5602
5603    fn name(&self) -> &'static str {
5604        "cypher_size"
5605    }
5606
5607    fn signature(&self) -> &Signature {
5608        &self.signature
5609    }
5610
5611    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
5612        Ok(DataType::Int64)
5613    }
5614
5615    fn invoke_with_args(
5616        &self,
5617        args: ScalarFunctionArgs,
5618    ) -> datafusion::error::Result<ColumnarValue> {
5619        use datafusion::arrow::array::{Array, Int64Array};
5620        use datafusion::arrow::compute::kernels::length::length;
5621        use datafusion::common::cast::{as_large_list_array, as_list_array};
5622
5623        let array = args.args[0].to_array(args.number_rows)?;
5624        let out: Int64Array = match array.data_type() {
5625            // Element count per list row (null lists → null).
5626            DataType::List(_) => {
5627                let list = as_list_array(&array)?;
5628                (0..list.len())
5629                    .map(|i| {
5630                        (!list.is_null(i))
5631                            .then(|| i64::try_from(list.value(i).len()).unwrap_or(i64::MAX))
5632                    })
5633                    .collect()
5634            }
5635            DataType::LargeList(_) => {
5636                let list = as_large_list_array(&array)?;
5637                (0..list.len())
5638                    .map(|i| {
5639                        (!list.is_null(i))
5640                            .then(|| i64::try_from(list.value(i).len()).unwrap_or(i64::MAX))
5641                    })
5642                    .collect()
5643            }
5644            // Character/byte count for strings — Arrow's `length` kernel returns
5645            // the count as an integer array; cast to Int64 for a uniform return.
5646            DataType::Utf8 | DataType::LargeUtf8 => {
5647                let lengths = length(&array)?;
5648                let casted = datafusion::arrow::compute::cast(&lengths, &DataType::Int64)?;
5649                casted
5650                    .as_any()
5651                    .downcast_ref::<Int64Array>()
5652                    .expect("cast to Int64 yields Int64Array")
5653                    .clone()
5654            }
5655            // A heterogeneous tagged element (ADR 0011) — e.g. the loop variable
5656            // of `none(x IN [[1, 2, 3], ['a']] WHERE size(x) = 3)`. Decode per
5657            // row and count a list payload's elements or a string payload's
5658            // bytes; any other payload falls through to null like the untyped
5659            // arm below.
5660            t if is_het_struct_type(Some(t)) => (0..array.len())
5661                .map(|i| {
5662                    let sv = ScalarValue::try_from_array(&array, i).ok()?;
5663                    match decode_het(&sv)? {
5664                        ScalarValue::List(l) => (!l.is_null(0))
5665                            .then(|| i64::try_from(l.value(0).len()).unwrap_or(i64::MAX)),
5666                        ScalarValue::LargeList(l) => (!l.is_null(0))
5667                            .then(|| i64::try_from(l.value(0).len()).unwrap_or(i64::MAX)),
5668                        ScalarValue::Utf8(Some(s)) | ScalarValue::LargeUtf8(Some(s)) => {
5669                            i64::try_from(s.len()).ok()
5670                        }
5671                        _ => None,
5672                    }
5673                })
5674                .collect(),
5675            // Unsupported argument type → all-null (Cypher `size` of a non-
5676            // list/string is undefined; null is the lenient choice).
5677            _ => (0..array.len()).map(|_| None).collect(),
5678        };
5679        Ok(ColumnarValue::Array(std::sync::Arc::new(out)))
5680    }
5681}
5682
5683// ---------------------------------------------------------------------------
5684// Graph metadata UDFs
5685// ---------------------------------------------------------------------------
5686
5687static CYPHER_LABELS: LazyLock<ScalarUDF> =
5688    LazyLock::new(|| ScalarUDF::new_from_impl(CypherGraphMetadata::new(GraphMetadataKind::Labels)));
5689static CYPHER_REL_TYPE: LazyLock<ScalarUDF> = LazyLock::new(|| {
5690    ScalarUDF::new_from_impl(CypherGraphMetadata::new(
5691        GraphMetadataKind::RelationshipType,
5692    ))
5693});
5694
5695#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
5696enum GraphMetadataKind {
5697    Labels,
5698    RelationshipType,
5699}
5700
5701#[derive(Debug, PartialEq, Eq, Hash)]
5702struct CypherGraphMetadata {
5703    kind: GraphMetadataKind,
5704    signature: Signature,
5705}
5706
5707impl CypherGraphMetadata {
5708    fn new(kind: GraphMetadataKind) -> Self {
5709        Self {
5710            kind,
5711            signature: Signature::any(1, Volatility::Immutable),
5712        }
5713    }
5714}
5715
5716impl ScalarUDFImpl for CypherGraphMetadata {
5717    fn as_any(&self) -> &dyn Any {
5718        self
5719    }
5720
5721    fn name(&self) -> &'static str {
5722        match self.kind {
5723            GraphMetadataKind::Labels => "cypher_labels",
5724            GraphMetadataKind::RelationshipType => "cypher_relationship_type",
5725        }
5726    }
5727
5728    fn signature(&self) -> &Signature {
5729        &self.signature
5730    }
5731
5732    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
5733        Ok(match self.kind {
5734            GraphMetadataKind::Labels => DataType::new_list(DataType::Utf8, true),
5735            GraphMetadataKind::RelationshipType => DataType::Utf8,
5736        })
5737    }
5738
5739    fn invoke_with_args(
5740        &self,
5741        args: ScalarFunctionArgs,
5742    ) -> datafusion::error::Result<ColumnarValue> {
5743        use datafusion::arrow::array::new_empty_array;
5744        use datafusion::error::DataFusionError;
5745
5746        let rows = args.number_rows;
5747        let values = args.args[0].to_array(rows)?;
5748        let field = match self.kind {
5749            GraphMetadataKind::Labels => "labels",
5750            GraphMetadataKind::RelationshipType => "rel_type",
5751        };
5752        let identity_field = match self.kind {
5753            GraphMetadataKind::Labels => "node_uuid",
5754            GraphMetadataKind::RelationshipType => "edge_uuid",
5755        };
5756        let mut output = Vec::with_capacity(rows);
5757        for row in 0..rows {
5758            let value = ScalarValue::try_from_array(&values, row)?;
5759            let value = decode_het(&value).unwrap_or(value);
5760            if value.is_null() {
5761                output.push(ScalarValue::try_new_null(&self.return_type(&[])?)?);
5762                continue;
5763            }
5764            let ScalarValue::Struct(entity) = value else {
5765                return Err(DataFusionError::Execution(format!(
5766                    "InvalidArgumentValue: {}() requires a graph element",
5767                    self.name()
5768                )));
5769            };
5770            if entity.column_by_name(identity_field).is_none() {
5771                return Err(DataFusionError::Execution(format!(
5772                    "InvalidArgumentValue: {}() received the wrong graph element kind",
5773                    self.name()
5774                )));
5775            }
5776            let column = entity.column_by_name(field).ok_or_else(|| {
5777                DataFusionError::Execution(format!(
5778                    "InvalidArgumentValue: {}() received the wrong graph element kind",
5779                    self.name()
5780                ))
5781            })?;
5782            output.push(ScalarValue::try_from_array(column, 0)?);
5783        }
5784        let data_type = self.return_type(&[])?;
5785        let array = if output.is_empty() {
5786            new_empty_array(&data_type)
5787        } else {
5788            ScalarValue::iter_to_array(output)?
5789        };
5790        Ok(ColumnarValue::Array(array))
5791    }
5792}
5793
5794// ---------------------------------------------------------------------------
5795// cypher_map_keys UDF
5796// ---------------------------------------------------------------------------
5797
5798const ENTITY_PROPERTY_MAP_HET_DEPTH: usize = 3;
5799
5800#[derive(Debug, PartialEq, Eq, Hash)]
5801struct CypherEntityProperties {
5802    signature: Signature,
5803}
5804
5805impl CypherEntityProperties {
5806    fn new(arity: usize) -> Self {
5807        Self {
5808            signature: Signature::any(arity, Volatility::Immutable),
5809        }
5810    }
5811}
5812
5813impl ScalarUDFImpl for CypherEntityProperties {
5814    fn as_any(&self) -> &dyn Any {
5815        self
5816    }
5817
5818    fn name(&self) -> &'static str {
5819        "cypher_entity_properties"
5820    }
5821
5822    fn signature(&self) -> &Signature {
5823        &self.signature
5824    }
5825
5826    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
5827        Ok(DataType::Struct(het_fields(ENTITY_PROPERTY_MAP_HET_DEPTH)))
5828    }
5829
5830    fn invoke_with_args(
5831        &self,
5832        args: ScalarFunctionArgs,
5833    ) -> datafusion::error::Result<ColumnarValue> {
5834        use datafusion::arrow::array::ArrayRef;
5835        use datafusion::error::DataFusionError;
5836        use std::sync::Arc;
5837
5838        let rows = args.number_rows;
5839        if args.args.is_empty() || !(args.args.len() - 1).is_multiple_of(2) {
5840            return Err(DataFusionError::Plan(
5841                "properties() entity map expects present plus key/value pairs".into(),
5842            ));
5843        }
5844        let cols: Vec<ArrayRef> = args
5845            .args
5846            .iter()
5847            .map(|arg| arg.to_array(rows))
5848            .collect::<datafusion::error::Result<_>>()?;
5849        let mut maps = Vec::with_capacity(rows);
5850        for row in 0..rows {
5851            let present = match ScalarValue::try_from_array(&cols[0], row)? {
5852                ScalarValue::Boolean(Some(true)) => true,
5853                ScalarValue::Boolean(Some(false) | None) | ScalarValue::Null => false,
5854                other => {
5855                    return Err(DataFusionError::Execution(format!(
5856                        "properties() entity presence must be boolean, got {other:?}"
5857                    )));
5858                }
5859            };
5860            if !present {
5861                maps.push(ScalarValue::Null);
5862                continue;
5863            }
5864            let mut entries = Vec::with_capacity((cols.len() - 1) / 2);
5865            for pair in cols[1..].chunks_exact(2) {
5866                let key = ScalarValue::try_from_array(&pair[0], row)?;
5867                let Some(key) = scalar_access_key(&key)? else {
5868                    continue;
5869                };
5870                let value = ScalarValue::try_from_array(&pair[1], row)?;
5871                if value.is_null() {
5872                    continue;
5873                }
5874                entries.push((key, unwrap_het(value)));
5875            }
5876            let map = const_map_scalar(&entries).ok_or_else(|| {
5877                DataFusionError::Execution(
5878                    "properties() could not encode entity property map".into(),
5879                )
5880            })?;
5881            maps.push(map);
5882        }
5883        let out = build_het_struct(&maps, ENTITY_PROPERTY_MAP_HET_DEPTH).ok_or_else(|| {
5884            DataFusionError::Execution("properties() could not encode entity property map".into())
5885        })?;
5886        Ok(ColumnarValue::Array(Arc::new(out)))
5887    }
5888}
5889
5890static CYPHER_MAP_KEYS: LazyLock<ScalarUDF> =
5891    LazyLock::new(|| ScalarUDF::new_from_impl(CypherMapKeys::new()));
5892
5893#[derive(Debug, PartialEq, Eq, Hash)]
5894struct CypherMapKeys {
5895    signature: Signature,
5896}
5897
5898impl CypherMapKeys {
5899    fn new() -> Self {
5900        Self {
5901            signature: Signature::any(1, Volatility::Immutable),
5902        }
5903    }
5904}
5905
5906impl ScalarUDFImpl for CypherMapKeys {
5907    fn as_any(&self) -> &dyn Any {
5908        self
5909    }
5910
5911    fn name(&self) -> &'static str {
5912        "cypher_map_keys"
5913    }
5914
5915    fn signature(&self) -> &Signature {
5916        &self.signature
5917    }
5918
5919    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
5920        Ok(DataType::new_list(DataType::Utf8, true))
5921    }
5922
5923    fn invoke_with_args(
5924        &self,
5925        args: ScalarFunctionArgs,
5926    ) -> datafusion::error::Result<ColumnarValue> {
5927        use datafusion::arrow::array::{Array, ArrayRef, ListArray, StringArray, StructArray};
5928        use datafusion::arrow::buffer::{NullBuffer, OffsetBuffer, ScalarBuffer};
5929        use datafusion::error::DataFusionError;
5930        use std::sync::Arc;
5931
5932        let rows = args.number_rows;
5933        let values = args.args[0].to_array(rows)?;
5934        if matches!(values.data_type(), DataType::Null) {
5935            let nulls = NullBuffer::from(vec![false; rows]);
5936            let list = ListArray::new(
5937                Arc::new(Field::new("item", DataType::Utf8, true)),
5938                OffsetBuffer::new(ScalarBuffer::from(vec![0_i32; rows + 1])),
5939                Arc::new(StringArray::from(Vec::<Option<String>>::new())) as ArrayRef,
5940                Some(nulls),
5941            );
5942            return Ok(ColumnarValue::Array(Arc::new(list)));
5943        }
5944        let DataType::Struct(fields) = values.data_type() else {
5945            return Err(DataFusionError::Execution(format!(
5946                "keys() requires a map, node, relationship, or null, got {:?}",
5947                values.data_type()
5948            )));
5949        };
5950        let map = values
5951            .as_any()
5952            .downcast_ref::<StructArray>()
5953            .ok_or_else(|| DataFusionError::Execution("keys() expected a struct map".into()))?;
5954        if is_het_struct_type(Some(values.data_type())) {
5955            return tagged_map_keys(map, rows);
5956        }
5957        if !is_plain_map_struct_type(values.data_type()) {
5958            return Err(DataFusionError::Execution(format!(
5959                "keys() requires a map, node, relationship, or null, got {:?}",
5960                values.data_type()
5961            )));
5962        }
5963        let names: Vec<String> = fields.iter().map(|f| f.name().clone()).collect();
5964        let mut offsets = Vec::with_capacity(rows + 1);
5965        let mut values = Vec::new();
5966        let mut valid = Vec::with_capacity(rows);
5967        offsets.push(0_i32);
5968        for row in 0..rows {
5969            if map.is_null(row) {
5970                valid.push(false);
5971            } else {
5972                valid.push(true);
5973                values.extend(names.iter().cloned().map(Some));
5974            }
5975            offsets.push(i32::try_from(values.len()).map_err(|_| {
5976                DataFusionError::Execution("keys() result exceeded i32 list offsets".into())
5977            })?);
5978        }
5979        let list = ListArray::new(
5980            Arc::new(Field::new("item", DataType::Utf8, true)),
5981            OffsetBuffer::new(ScalarBuffer::from(offsets)),
5982            Arc::new(StringArray::from(values)) as ArrayRef,
5983            Some(NullBuffer::from(valid)),
5984        );
5985        Ok(ColumnarValue::Array(Arc::new(list)))
5986    }
5987}
5988
5989fn tagged_map_keys(
5990    map: &datafusion::arrow::array::StructArray,
5991    rows: usize,
5992) -> datafusion::error::Result<ColumnarValue> {
5993    use datafusion::arrow::array::{
5994        Array, ArrayRef, Int8Array, ListArray, StringArray, StructArray,
5995    };
5996    use datafusion::arrow::buffer::{NullBuffer, OffsetBuffer, ScalarBuffer};
5997    use datafusion::error::DataFusionError;
5998    use std::sync::Arc;
5999
6000    let tags = map
6001        .column_by_name("__het_tag")
6002        .and_then(|c| c.as_any().downcast_ref::<Int8Array>())
6003        .ok_or_else(|| DataFusionError::Plan("tagged map is missing __het_tag".into()))?;
6004    let entries = map
6005        .column_by_name("__het_map")
6006        .and_then(|c| c.as_any().downcast_ref::<ListArray>())
6007        .ok_or_else(|| DataFusionError::Plan("tagged map is missing __het_map".into()))?;
6008    let mut offsets = Vec::with_capacity(rows + 1);
6009    let mut values = Vec::new();
6010    let mut valid = Vec::with_capacity(rows);
6011    offsets.push(0_i32);
6012    for row in 0..rows {
6013        if map.is_null(row) {
6014            valid.push(false);
6015        } else {
6016            if tags.value(row) != 5 {
6017                return Err(DataFusionError::Execution(
6018                    "keys() requires a map, node, relationship, or null".into(),
6019                ));
6020            }
6021            valid.push(true);
6022            if !entries.is_null(row) {
6023                let entry_values = entries.value(row);
6024                let entry_struct = entry_values
6025                    .as_any()
6026                    .downcast_ref::<StructArray>()
6027                    .ok_or_else(|| {
6028                        DataFusionError::Plan("tagged map entries must be structs".into())
6029                    })?;
6030                let map_keys = entry_struct
6031                    .column_by_name("__het_mkey")
6032                    .and_then(|c| c.as_any().downcast_ref::<StringArray>())
6033                    .ok_or_else(|| {
6034                        DataFusionError::Plan("tagged map entries must carry __het_mkey".into())
6035                    })?;
6036                for idx in 0..entry_struct.len() {
6037                    if !map_keys.is_null(idx) {
6038                        values.push(Some(map_keys.value(idx).to_owned()));
6039                    }
6040                }
6041            }
6042        }
6043        offsets.push(i32::try_from(values.len()).map_err(|_| {
6044            DataFusionError::Execution("keys() result exceeded i32 list offsets".into())
6045        })?);
6046    }
6047    let list = ListArray::new(
6048        Arc::new(Field::new("item", DataType::Utf8, true)),
6049        OffsetBuffer::new(ScalarBuffer::from(offsets)),
6050        Arc::new(StringArray::from(values)) as ArrayRef,
6051        Some(NullBuffer::from(valid)),
6052    );
6053    Ok(ColumnarValue::Array(Arc::new(list)))
6054}
6055
6056// ---------------------------------------------------------------------------
6057// cypher_value_access UDF
6058// ---------------------------------------------------------------------------
6059
6060static CYPHER_VALUE_ACCESS: LazyLock<ScalarUDF> =
6061    LazyLock::new(|| ScalarUDF::new_from_impl(CypherValueAccess::new()));
6062
6063#[derive(Debug, PartialEq, Eq, Hash)]
6064struct CypherStaticValueAccess {
6065    key: String,
6066    signature: Signature,
6067}
6068
6069impl CypherStaticValueAccess {
6070    fn new(key: String) -> Self {
6071        Self {
6072            key,
6073            signature: Signature::any(1, Volatility::Immutable),
6074        }
6075    }
6076}
6077
6078impl ScalarUDFImpl for CypherStaticValueAccess {
6079    fn as_any(&self) -> &dyn Any {
6080        self
6081    }
6082
6083    fn name(&self) -> &'static str {
6084        "cypher_static_value_access"
6085    }
6086
6087    fn signature(&self) -> &Signature {
6088        &self.signature
6089    }
6090
6091    fn return_type(&self, arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
6092        static_value_access_return_type(arg_types.first(), &self.key)
6093    }
6094
6095    fn return_field_from_args(&self, args: ReturnFieldArgs) -> datafusion::error::Result<FieldRef> {
6096        Ok(Arc::new(Field::new(
6097            self.name(),
6098            static_value_access_return_type(
6099                args.arg_fields.first().map(|field| field.data_type()),
6100                &self.key,
6101            )?,
6102            true,
6103        )))
6104    }
6105
6106    fn invoke_with_args(
6107        &self,
6108        args: ScalarFunctionArgs,
6109    ) -> datafusion::error::Result<ColumnarValue> {
6110        use datafusion::error::DataFusionError;
6111
6112        let rows = args.number_rows;
6113        let values = args.args[0].to_array(rows)?;
6114        let return_type = args.return_field.data_type();
6115        let null_value = ScalarValue::try_new_null(return_type)?;
6116        let output = (0..rows)
6117            .map(|row| {
6118                let value = ScalarValue::try_from_array(&values, row)?;
6119                let value = decode_het(&value).unwrap_or(value);
6120                if value.is_null() {
6121                    return Ok(null_value.clone());
6122                }
6123                let ScalarValue::Struct(value) = value else {
6124                    return Err(DataFusionError::Execution(
6125                        "InvalidArgumentValue: property access requires a map or graph element"
6126                            .into(),
6127                    ));
6128                };
6129                let Some(column) = value.column_by_name(&self.key) else {
6130                    return Ok(null_value.clone());
6131                };
6132                let result = ScalarValue::try_from_array(column, 0)?;
6133                if result.data_type() == *return_type {
6134                    Ok(result)
6135                } else if result.is_null() {
6136                    Ok(null_value.clone())
6137                } else {
6138                    Err(DataFusionError::Execution(format!(
6139                        "property `{}` has incompatible runtime type {:?}; expected {:?}",
6140                        self.key,
6141                        result.data_type(),
6142                        return_type
6143                    )))
6144                }
6145            })
6146            .collect::<datafusion::error::Result<Vec<_>>>()?;
6147        Ok(ColumnarValue::Array(ScalarValue::iter_to_array(output)?))
6148    }
6149}
6150
6151#[derive(Debug, PartialEq, Eq, Hash)]
6152struct CypherValueAccess {
6153    signature: Signature,
6154}
6155
6156impl CypherValueAccess {
6157    fn new() -> Self {
6158        Self {
6159            signature: Signature::any(2, Volatility::Immutable),
6160        }
6161    }
6162}
6163
6164impl ScalarUDFImpl for CypherValueAccess {
6165    fn as_any(&self) -> &dyn Any {
6166        self
6167    }
6168
6169    fn name(&self) -> &'static str {
6170        "cypher_value_access"
6171    }
6172
6173    fn signature(&self) -> &Signature {
6174        &self.signature
6175    }
6176
6177    fn return_type(&self, arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
6178        value_access_return_type(arg_types.first())
6179    }
6180
6181    fn return_field_from_args(&self, args: ReturnFieldArgs) -> datafusion::error::Result<FieldRef> {
6182        Ok(std::sync::Arc::new(Field::new(
6183            self.name(),
6184            value_access_return_type(args.arg_fields.first().map(|f| f.data_type()))?,
6185            true,
6186        )))
6187    }
6188
6189    fn invoke_with_args(
6190        &self,
6191        args: ScalarFunctionArgs,
6192    ) -> datafusion::error::Result<ColumnarValue> {
6193        use datafusion::arrow::array::StructArray;
6194        use datafusion::error::DataFusionError;
6195
6196        let rows = args.number_rows;
6197        let values = args.args[0].to_array(rows)?;
6198        let keys = args.args[1].to_array(rows)?;
6199        let return_type = args.return_field.data_type().clone();
6200        let null_value = ScalarValue::try_from(&return_type).unwrap_or(ScalarValue::Null);
6201        if matches!(values.data_type(), DataType::Null) {
6202            return Ok(ColumnarValue::Array(ScalarValue::iter_to_array(
6203                (0..rows).map(|_| null_value.clone()),
6204            )?));
6205        }
6206        if let Some(list) = ListView::from_array(&values) {
6207            let out = (0..rows)
6208                .map(|i| list_access_value(&list, &keys, i, &null_value))
6209                .collect::<datafusion::error::Result<Vec<_>>>()?;
6210            return Ok(ColumnarValue::Array(ScalarValue::iter_to_array(out)?));
6211        }
6212        let map = values
6213            .as_any()
6214            .downcast_ref::<StructArray>()
6215            .ok_or_else(|| {
6216                DataFusionError::Execution(format!(
6217                    "dynamic subscript requires a list or map/entity struct, got {:?}",
6218                    values.data_type()
6219                ))
6220            })?;
6221        if is_het_struct_type(Some(values.data_type())) {
6222            let out = (0..rows)
6223                .map(|i| het_map_access_value(map, &keys, i, &null_value))
6224                .collect::<datafusion::error::Result<Vec<_>>>()?;
6225            return Ok(ColumnarValue::Array(ScalarValue::iter_to_array(out)?));
6226        }
6227        let out = (0..rows)
6228            .map(|i| {
6229                if map.is_null(i) {
6230                    return Ok(null_value.clone());
6231                }
6232                let key = ScalarValue::try_from_array(&keys, i)?;
6233                let Some(key) = scalar_access_key(&key)? else {
6234                    return Ok(null_value.clone());
6235                };
6236                let Some(col) = map.column_by_name(&key) else {
6237                    return Ok(null_value.clone());
6238                };
6239                ScalarValue::try_from_array(col, i)
6240            })
6241            .collect::<datafusion::error::Result<Vec<_>>>()?;
6242        Ok(ColumnarValue::Array(ScalarValue::iter_to_array(out)?))
6243    }
6244}
6245
6246fn value_access_return_type(dt: Option<&DataType>) -> datafusion::error::Result<DataType> {
6247    let Some(dt) = dt else {
6248        return Ok(DataType::Null);
6249    };
6250    match dt {
6251        DataType::Null => Ok(DataType::Null),
6252        DataType::List(field) | DataType::LargeList(field) | DataType::FixedSizeList(field, _) => {
6253            Ok(field.data_type().clone())
6254        }
6255        dt if is_het_struct_type(Some(dt)) => het_value_access_return_type(dt),
6256        DataType::Struct(fields) => common_struct_field_type(fields),
6257        // Parameter values are bound after logical lowering. If a parameter later
6258        // turns out not to be a list/map/entity, defer the invalid-argument failure to
6259        // UDF invocation so Cypher observes it as a runtime type error.
6260        _ => Ok(DataType::Null),
6261    }
6262}
6263
6264fn static_value_access_return_type(
6265    dt: Option<&DataType>,
6266    key: &str,
6267) -> datafusion::error::Result<DataType> {
6268    let Some(DataType::Struct(fields)) = dt else {
6269        return Ok(DataType::Null);
6270    };
6271    if !is_het_struct_type(dt) {
6272        return fields
6273            .iter()
6274            .find(|field| field.name() == key)
6275            .map_or(Ok(DataType::Null), |field| Ok(field.data_type().clone()));
6276    }
6277    if fields.iter().any(|field| field.name() == "__het_map") {
6278        return het_value_access_return_type(&DataType::Struct(fields.clone()));
6279    }
6280    let mut data_type = None;
6281    for variant in fields
6282        .iter()
6283        .filter(|field| field.name().starts_with("__het_value_"))
6284    {
6285        let DataType::Struct(value_fields) = variant.data_type() else {
6286            continue;
6287        };
6288        let Some(property) = value_fields.iter().find(|field| field.name() == key) else {
6289            continue;
6290        };
6291        if matches!(property.data_type(), DataType::Null) {
6292            continue;
6293        }
6294        match &data_type {
6295            None => data_type = Some(property.data_type().clone()),
6296            Some(existing) if existing == property.data_type() => {}
6297            Some(existing) => {
6298                return Err(datafusion::error::DataFusionError::Plan(format!(
6299                    "property `{key}` has incompatible graph-value types {existing:?} and {:?}",
6300                    property.data_type()
6301                )));
6302            }
6303        }
6304    }
6305    Ok(data_type.unwrap_or(DataType::Null))
6306}
6307
6308fn list_access_value(
6309    list: &ListView<'_>,
6310    keys: &datafusion::arrow::array::ArrayRef,
6311    row: usize,
6312    null_value: &ScalarValue,
6313) -> datafusion::error::Result<ScalarValue> {
6314    if list.is_null(row) {
6315        return Ok(null_value.clone());
6316    }
6317    let key = ScalarValue::try_from_array(keys, row)?;
6318    let Some(idx) = scalar_list_index(&key)? else {
6319        return Ok(null_value.clone());
6320    };
6321    let elems = list.value(row);
6322    let len = i64::try_from(elems.len()).map_err(|_| {
6323        datafusion::error::DataFusionError::Execution(
6324            "dynamic list access length exceeds i64 range".into(),
6325        )
6326    })?;
6327    let pos = if idx < 0 { len + idx } else { idx };
6328    if pos < 0 || pos >= len {
6329        return Ok(null_value.clone());
6330    }
6331    let pos = usize::try_from(pos).map_err(|_| {
6332        datafusion::error::DataFusionError::Execution(
6333            "dynamic list access index exceeds usize range".into(),
6334        )
6335    })?;
6336    ScalarValue::try_from_array(&elems, pos)
6337}
6338
6339fn scalar_list_index(s: &ScalarValue) -> datafusion::error::Result<Option<i64>> {
6340    if s.is_null() {
6341        return Ok(None);
6342    }
6343    macro_rules! signed_index {
6344        ($value:expr) => {
6345            $value.map(i64::from)
6346        };
6347    }
6348    let idx = match s {
6349        ScalarValue::Int8(v) => signed_index!(*v),
6350        ScalarValue::Int16(v) => signed_index!(*v),
6351        ScalarValue::Int32(v) => signed_index!(*v),
6352        ScalarValue::Int64(v) => *v,
6353        ScalarValue::UInt8(v) => v.map(i64::from),
6354        ScalarValue::UInt16(v) => v.map(i64::from),
6355        ScalarValue::UInt32(v) => v.map(i64::from),
6356        ScalarValue::UInt64(v) => v.map(i64::try_from).transpose().map_err(|_| {
6357            datafusion::error::DataFusionError::Execution(
6358                "dynamic list access index exceeds i64 range".into(),
6359            )
6360        })?,
6361        other => {
6362            return Err(datafusion::error::DataFusionError::Execution(format!(
6363                "dynamic list access index must be an integer, got {other:?}"
6364            )));
6365        }
6366    };
6367    Ok(idx)
6368}
6369
6370fn het_value_access_return_type(dt: &DataType) -> datafusion::error::Result<DataType> {
6371    use datafusion::error::DataFusionError;
6372
6373    let DataType::Struct(fields) = dt else {
6374        unreachable!("caller checked het struct type")
6375    };
6376    let Some(map_field) = fields.iter().find(|f| f.name() == "__het_map") else {
6377        return Err(DataFusionError::Plan(
6378            "dynamic value access requires a tagged map element".into(),
6379        ));
6380    };
6381    let DataType::List(entry_field) = map_field.data_type() else {
6382        return Err(DataFusionError::Plan(
6383            "tagged map field must be a list".into(),
6384        ));
6385    };
6386    let DataType::Struct(entry_fields) = entry_field.data_type() else {
6387        return Err(DataFusionError::Plan(
6388            "tagged map entries must be structs".into(),
6389        ));
6390    };
6391    entry_fields
6392        .iter()
6393        .find(|f| f.name() == "__het_mval")
6394        .map(|f| f.data_type().clone())
6395        .ok_or_else(|| DataFusionError::Plan("tagged map entries must carry __het_mval".into()))
6396}
6397
6398fn het_map_access_value(
6399    map: &datafusion::arrow::array::StructArray,
6400    keys: &datafusion::arrow::array::ArrayRef,
6401    row: usize,
6402    null_value: &ScalarValue,
6403) -> datafusion::error::Result<ScalarValue> {
6404    use datafusion::arrow::array::{Array, Int8Array, ListArray, StringArray, StructArray};
6405    use datafusion::error::DataFusionError;
6406
6407    if map.is_null(row) {
6408        return Ok(null_value.clone());
6409    }
6410    let key = ScalarValue::try_from_array(keys, row)?;
6411    let Some(key) = scalar_access_key(&key)? else {
6412        return Ok(null_value.clone());
6413    };
6414    let tag = map
6415        .column_by_name("__het_tag")
6416        .and_then(|c| c.as_any().downcast_ref::<Int8Array>())
6417        .ok_or_else(|| DataFusionError::Plan("tagged value is missing __het_tag".into()))?;
6418    if tag.value(row) != 5 {
6419        return Err(DataFusionError::Execution(
6420            "invalid argument type: dynamic value access requires a map".into(),
6421        ));
6422    }
6423    let entries = map
6424        .column_by_name("__het_map")
6425        .and_then(|c| c.as_any().downcast_ref::<ListArray>())
6426        .ok_or_else(|| DataFusionError::Plan("tagged map value is missing __het_map".into()))?;
6427    if entries.is_null(row) {
6428        return Ok(null_value.clone());
6429    }
6430    let entry_values = entries.value(row);
6431    let entry_struct = entry_values
6432        .as_any()
6433        .downcast_ref::<StructArray>()
6434        .ok_or_else(|| DataFusionError::Plan("tagged map entries must be structs".into()))?;
6435    let map_keys = entry_struct
6436        .column_by_name("__het_mkey")
6437        .and_then(|c| c.as_any().downcast_ref::<StringArray>())
6438        .ok_or_else(|| DataFusionError::Plan("tagged map entries must carry __het_mkey".into()))?;
6439    let map_values = entry_struct
6440        .column_by_name("__het_mval")
6441        .ok_or_else(|| DataFusionError::Plan("tagged map entries must carry __het_mval".into()))?;
6442    for idx in 0..entry_struct.len() {
6443        if !map_keys.is_null(idx) && map_keys.value(idx) == key {
6444            return ScalarValue::try_from_array(map_values, idx);
6445        }
6446    }
6447    Ok(null_value.clone())
6448}
6449
6450fn common_struct_field_type(
6451    fields: &datafusion::arrow::datatypes::Fields,
6452) -> datafusion::error::Result<DataType> {
6453    let mut dtype: Option<DataType> = None;
6454    for field in fields {
6455        let field_type = field.data_type();
6456        if matches!(field_type, DataType::Null) {
6457            continue;
6458        }
6459        match &dtype {
6460            None => dtype = Some(field_type.clone()),
6461            Some(prev) if prev == field_type => {}
6462            Some(prev) => {
6463                return Err(datafusion::error::DataFusionError::Plan(format!(
6464                    "dynamic value access over mixed field types is not supported: {prev:?} and {field_type:?}"
6465                )));
6466            }
6467        }
6468    }
6469    Ok(dtype.unwrap_or(DataType::Null))
6470}
6471
6472fn scalar_access_key(s: &ScalarValue) -> datafusion::error::Result<Option<String>> {
6473    if s.is_null() {
6474        return Ok(None);
6475    }
6476    match s {
6477        ScalarValue::Utf8(v) | ScalarValue::LargeUtf8(v) | ScalarValue::Utf8View(v) => {
6478            Ok(v.clone())
6479        }
6480        other => Err(datafusion::error::DataFusionError::Execution(format!(
6481            "dynamic map/property access key must be a string, got {other:?}"
6482        ))),
6483    }
6484}
6485
6486// ---------------------------------------------------------------------------
6487// cypher_reverse UDF
6488// ---------------------------------------------------------------------------
6489
6490/// `reverse(x)` for an argument whose type is unknown at plan time (e.g. a
6491/// parameter or an unresolved property). Dispatches at runtime: a string
6492/// reverses its characters, a list its elements. The known-string / known-list
6493/// cases are routed directly to `unicode::reverse` / `array_reverse` at lowering
6494/// and never reach this UDF. (#955)
6495static CYPHER_REVERSE: LazyLock<ScalarUDF> =
6496    LazyLock::new(|| ScalarUDF::new_from_impl(CypherReverse::new()));
6497
6498#[derive(Debug, PartialEq, Eq, Hash)]
6499struct CypherReverse {
6500    signature: Signature,
6501}
6502
6503impl CypherReverse {
6504    fn new() -> Self {
6505        Self {
6506            signature: Signature::any(1, Volatility::Immutable),
6507        }
6508    }
6509}
6510
6511impl ScalarUDFImpl for CypherReverse {
6512    fn as_any(&self) -> &dyn Any {
6513        self
6514    }
6515    fn name(&self) -> &'static str {
6516        "cypher_reverse"
6517    }
6518    fn signature(&self) -> &Signature {
6519        &self.signature
6520    }
6521    fn return_type(&self, arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
6522        // Same type in, same type out.
6523        Ok(arg_types.first().cloned().unwrap_or(DataType::Null))
6524    }
6525
6526    fn invoke_with_args(
6527        &self,
6528        args: ScalarFunctionArgs,
6529    ) -> datafusion::error::Result<ColumnarValue> {
6530        use datafusion::arrow::array::{Array, ListArray, StringArray, UInt32Array};
6531        use datafusion::common::cast::as_list_array;
6532        use datafusion::error::DataFusionError;
6533        use std::sync::Arc;
6534
6535        let array = args.args[0].to_array(args.number_rows)?;
6536        match array.data_type() {
6537            DataType::Utf8 | DataType::LargeUtf8 => {
6538                let s = datafusion::arrow::compute::cast(&array, &DataType::Utf8)?;
6539                let s = s.as_any().downcast_ref::<StringArray>().ok_or_else(|| {
6540                    DataFusionError::Internal("cypher_reverse: not a string array".into())
6541                })?;
6542                let out: StringArray = (0..s.len())
6543                    .map(|i| (!s.is_null(i)).then(|| s.value(i).chars().rev().collect::<String>()))
6544                    .collect();
6545                Ok(ColumnarValue::Array(Arc::new(out)))
6546            }
6547            DataType::List(_) => {
6548                let list = as_list_array(&array)?;
6549                let values = list.values();
6550                let offsets = list.offsets();
6551                // Per row, emit element indices in reverse — the row's length is
6552                // unchanged, so the original offsets/nulls are reused verbatim.
6553                let mut idx: Vec<u32> = Vec::with_capacity(values.len());
6554                for w in offsets.windows(2) {
6555                    let (start, end) = (w[0], w[1]);
6556                    for j in (start..end).rev() {
6557                        idx.push(u32::try_from(j).unwrap_or(0));
6558                    }
6559                }
6560                let taken =
6561                    datafusion::arrow::compute::take(values, &UInt32Array::from(idx), None)?;
6562                let field = match list.data_type() {
6563                    DataType::List(f) => Arc::clone(f),
6564                    _ => unreachable!("matched List above"),
6565                };
6566                let reversed = ListArray::new(field, offsets.clone(), taken, list.nulls().cloned());
6567                Ok(ColumnarValue::Array(Arc::new(reversed)))
6568            }
6569            other => Err(DataFusionError::Plan(format!(
6570                "reverse() expects a string or list, got {other:?}"
6571            ))),
6572        }
6573    }
6574}
6575
6576// ---------------------------------------------------------------------------
6577// Cypher boolean / predicate UDFs
6578// ---------------------------------------------------------------------------
6579
6580static CYPHER_AND: LazyLock<ScalarUDF> =
6581    LazyLock::new(|| ScalarUDF::new_from_impl(CypherBoolOp::new(CypherBoolOpKind::And)));
6582static CYPHER_OR: LazyLock<ScalarUDF> =
6583    LazyLock::new(|| ScalarUDF::new_from_impl(CypherBoolOp::new(CypherBoolOpKind::Or)));
6584static CYPHER_XOR: LazyLock<ScalarUDF> =
6585    LazyLock::new(|| ScalarUDF::new_from_impl(CypherBoolOp::new(CypherBoolOpKind::Xor)));
6586
6587#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
6588enum CypherBoolOpKind {
6589    And,
6590    Or,
6591    Xor,
6592}
6593
6594#[derive(Debug, PartialEq, Eq, Hash)]
6595struct CypherBoolOp {
6596    signature: Signature,
6597    kind: CypherBoolOpKind,
6598}
6599
6600impl CypherBoolOp {
6601    fn new(kind: CypherBoolOpKind) -> Self {
6602        Self {
6603            signature: Signature::any(2, Volatility::Immutable),
6604            kind,
6605        }
6606    }
6607}
6608
6609impl ScalarUDFImpl for CypherBoolOp {
6610    fn as_any(&self) -> &dyn Any {
6611        self
6612    }
6613    fn name(&self) -> &'static str {
6614        match self.kind {
6615            CypherBoolOpKind::And => "cypher_and",
6616            CypherBoolOpKind::Or => "cypher_or",
6617            CypherBoolOpKind::Xor => "cypher_xor",
6618        }
6619    }
6620    fn signature(&self) -> &Signature {
6621        &self.signature
6622    }
6623    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
6624        Ok(DataType::Boolean)
6625    }
6626    fn invoke_with_args(
6627        &self,
6628        args: ScalarFunctionArgs,
6629    ) -> datafusion::error::Result<ColumnarValue> {
6630        use datafusion::arrow::array::BooleanArray;
6631        let rows = args.number_rows;
6632        let lhs = args.args[0].to_array(rows)?;
6633        let rhs = args.args[1].to_array(rows)?;
6634        let out: BooleanArray = (0..rows)
6635            .map(|i| {
6636                let l = ScalarValue::try_from_array(&lhs, i)?;
6637                let r = ScalarValue::try_from_array(&rhs, i)?;
6638                let l = scalar_as_bool(&l)?;
6639                let r = scalar_as_bool(&r)?;
6640                Ok::<Option<bool>, datafusion::error::DataFusionError>(match self.kind {
6641                    CypherBoolOpKind::And => match (l, r) {
6642                        (Some(false), _) | (_, Some(false)) => Some(false),
6643                        (Some(true), Some(true)) => Some(true),
6644                        _ => None,
6645                    },
6646                    CypherBoolOpKind::Or => match (l, r) {
6647                        (Some(true), _) | (_, Some(true)) => Some(true),
6648                        (Some(false), Some(false)) => Some(false),
6649                        _ => None,
6650                    },
6651                    CypherBoolOpKind::Xor => match (l, r) {
6652                        (Some(left), Some(right)) => Some(left ^ right),
6653                        _ => None,
6654                    },
6655                })
6656            })
6657            .collect::<datafusion::error::Result<Vec<_>>>()?
6658            .into_iter()
6659            .collect();
6660        Ok(ColumnarValue::Array(std::sync::Arc::new(out)))
6661    }
6662}
6663
6664fn scalar_as_bool(s: &ScalarValue) -> datafusion::error::Result<Option<bool>> {
6665    let s = unwrap_het(s.clone());
6666    if s.is_null() {
6667        return Ok(None);
6668    }
6669    match s {
6670        ScalarValue::Boolean(v) => Ok(v),
6671        other => Err(datafusion::error::DataFusionError::Plan(format!(
6672            "expected boolean operand, got {other:?}"
6673        ))),
6674    }
6675}
6676
6677static CYPHER_CMP_PRED: LazyLock<ScalarUDF> =
6678    LazyLock::new(|| ScalarUDF::new_from_impl(CypherCmpPred::new()));
6679
6680#[derive(Debug, PartialEq, Eq, Hash)]
6681struct CypherCmpPred {
6682    signature: Signature,
6683}
6684
6685impl CypherCmpPred {
6686    fn new() -> Self {
6687        Self {
6688            signature: Signature::any(3, Volatility::Immutable),
6689        }
6690    }
6691}
6692
6693impl ScalarUDFImpl for CypherCmpPred {
6694    fn as_any(&self) -> &dyn Any {
6695        self
6696    }
6697    fn name(&self) -> &'static str {
6698        "cypher_cmp_pred"
6699    }
6700    fn signature(&self) -> &Signature {
6701        &self.signature
6702    }
6703    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
6704        Ok(DataType::Boolean)
6705    }
6706    fn invoke_with_args(
6707        &self,
6708        args: ScalarFunctionArgs,
6709    ) -> datafusion::error::Result<ColumnarValue> {
6710        use datafusion::arrow::array::BooleanArray;
6711        let rows = args.number_rows;
6712        let lhs = args.args[0].to_array(rows)?;
6713        let rhs = args.args[1].to_array(rows)?;
6714        let op = args.args[2].to_array(rows)?;
6715        let out: BooleanArray = (0..rows)
6716            .map(|i| {
6717                let l = ScalarValue::try_from_array(&lhs, i)?;
6718                let r = ScalarValue::try_from_array(&rhs, i)?;
6719                let op = ScalarValue::try_from_array(&op, i)?;
6720                let op = scalar_as_i8(&op)?;
6721                Ok::<Option<bool>, datafusion::error::DataFusionError>(cypher_compare_pred(
6722                    &l, &r, op,
6723                ))
6724            })
6725            .collect::<datafusion::error::Result<Vec<_>>>()?
6726            .into_iter()
6727            .collect();
6728        Ok(ColumnarValue::Array(std::sync::Arc::new(out)))
6729    }
6730}
6731
6732fn scalar_as_i8(s: &ScalarValue) -> datafusion::error::Result<i8> {
6733    match s {
6734        ScalarValue::Int8(Some(v)) => Ok(*v),
6735        ScalarValue::Int64(Some(v)) => i8::try_from(*v).map_err(|_| {
6736            datafusion::error::DataFusionError::Plan(format!(
6737                "comparison opcode {v} is outside i8 range"
6738            ))
6739        }),
6740        other => Err(datafusion::error::DataFusionError::Plan(format!(
6741            "comparison opcode must be an integer, got {other:?}"
6742        ))),
6743    }
6744}
6745
6746fn cypher_compare_pred(l: &ScalarValue, r: &ScalarValue, op: i8) -> Option<bool> {
6747    let l = unwrap_het(l.clone());
6748    let r = unwrap_het(r.clone());
6749    if l.is_null() || r.is_null() {
6750        return None;
6751    }
6752    if is_numeric_scalar(&l) && is_numeric_scalar(&r) {
6753        let lf = scalar_as_f64(&l)?;
6754        let rf = scalar_as_f64(&r)?;
6755        if lf.is_nan() || rf.is_nan() {
6756            return Some(false);
6757        }
6758    }
6759    let cmp = cypher_compare(&l, &r)?;
6760    Some(match op {
6761        0 => cmp < 0,
6762        1 => cmp <= 0,
6763        2 => cmp > 0,
6764        3 => cmp >= 0,
6765        _ => false,
6766    })
6767}
6768
6769fn is_numeric_scalar(s: &ScalarValue) -> bool {
6770    scalar_as_f64(s).is_some()
6771}
6772
6773static CYPHER_STARTS_WITH: LazyLock<ScalarUDF> =
6774    LazyLock::new(|| ScalarUDF::new_from_impl(CypherStringPredicate::new(StringPredicate::Starts)));
6775static CYPHER_ENDS_WITH: LazyLock<ScalarUDF> =
6776    LazyLock::new(|| ScalarUDF::new_from_impl(CypherStringPredicate::new(StringPredicate::Ends)));
6777static CYPHER_CONTAINS: LazyLock<ScalarUDF> = LazyLock::new(|| {
6778    ScalarUDF::new_from_impl(CypherStringPredicate::new(StringPredicate::Contains))
6779});
6780
6781#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
6782enum StringPredicate {
6783    Starts,
6784    Ends,
6785    Contains,
6786}
6787
6788#[derive(Debug, PartialEq, Eq, Hash)]
6789struct CypherStringPredicate {
6790    signature: Signature,
6791    kind: StringPredicate,
6792}
6793
6794impl CypherStringPredicate {
6795    fn new(kind: StringPredicate) -> Self {
6796        Self {
6797            signature: Signature::any(2, Volatility::Immutable),
6798            kind,
6799        }
6800    }
6801}
6802
6803impl ScalarUDFImpl for CypherStringPredicate {
6804    fn as_any(&self) -> &dyn Any {
6805        self
6806    }
6807    fn name(&self) -> &'static str {
6808        match self.kind {
6809            StringPredicate::Starts => "cypher_starts_with",
6810            StringPredicate::Ends => "cypher_ends_with",
6811            StringPredicate::Contains => "cypher_contains",
6812        }
6813    }
6814    fn signature(&self) -> &Signature {
6815        &self.signature
6816    }
6817    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
6818        Ok(DataType::Boolean)
6819    }
6820    fn invoke_with_args(
6821        &self,
6822        args: ScalarFunctionArgs,
6823    ) -> datafusion::error::Result<ColumnarValue> {
6824        use datafusion::arrow::array::BooleanArray;
6825        let rows = args.number_rows;
6826        let lhs = args.args[0].to_array(rows)?;
6827        let rhs = args.args[1].to_array(rows)?;
6828        let out: BooleanArray = (0..rows)
6829            .map(|i| {
6830                let l = ScalarValue::try_from_array(&lhs, i).ok()?;
6831                let r = ScalarValue::try_from_array(&rhs, i).ok()?;
6832                let l = scalar_as_string(&l)?;
6833                let r = scalar_as_string(&r)?;
6834                Some(match self.kind {
6835                    StringPredicate::Starts => l.starts_with(&r),
6836                    StringPredicate::Ends => l.ends_with(&r),
6837                    StringPredicate::Contains => l.contains(&r),
6838                })
6839            })
6840            .collect();
6841        Ok(ColumnarValue::Array(std::sync::Arc::new(out)))
6842    }
6843}
6844
6845fn scalar_as_string(s: &ScalarValue) -> Option<String> {
6846    let s = unwrap_het(s.clone());
6847    if s.is_null() {
6848        return None;
6849    }
6850    match s {
6851        ScalarValue::Utf8(v) | ScalarValue::LargeUtf8(v) => v,
6852        _ => None,
6853    }
6854}
6855
6856static CYPHER_RANGE: LazyLock<ScalarUDF> =
6857    LazyLock::new(|| ScalarUDF::new_from_impl(CypherRange::new()));
6858pub(crate) static CYPHER_ORDER_KEY: LazyLock<ScalarUDF> =
6859    LazyLock::new(|| ScalarUDF::new_from_impl(CypherOrderKey::new()));
6860
6861#[derive(Debug, PartialEq, Eq, Hash)]
6862struct CypherRange {
6863    signature: Signature,
6864}
6865
6866impl CypherRange {
6867    fn new() -> Self {
6868        Self {
6869            // Keep literal invalid-argument cases in the runtime phase; the TCK
6870            // asserts `range()` argument errors at runtime, and constant-folding
6871            // would otherwise wrap them as DataFusion planning failures.
6872            signature: Signature::any(3, Volatility::Volatile),
6873        }
6874    }
6875}
6876
6877impl ScalarUDFImpl for CypherRange {
6878    fn as_any(&self) -> &dyn Any {
6879        self
6880    }
6881    fn name(&self) -> &'static str {
6882        "cypher_range"
6883    }
6884    fn signature(&self) -> &Signature {
6885        &self.signature
6886    }
6887    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
6888        Ok(DataType::new_list(DataType::Int64, true))
6889    }
6890    fn invoke_with_args(
6891        &self,
6892        args: ScalarFunctionArgs,
6893    ) -> datafusion::error::Result<ColumnarValue> {
6894        use datafusion::arrow::array::{Int64Builder, ListBuilder};
6895        let rows = args.number_rows;
6896        let starts = args.args[0].to_array(rows)?;
6897        let ends = args.args[1].to_array(rows)?;
6898        let steps = args.args[2].to_array(rows)?;
6899        let mut out = ListBuilder::new(Int64Builder::new());
6900        for i in 0..rows {
6901            let start = ScalarValue::try_from_array(&starts, i)?;
6902            let end = ScalarValue::try_from_array(&ends, i)?;
6903            let step = ScalarValue::try_from_array(&steps, i)?;
6904            if start.is_null() || end.is_null() || step.is_null() {
6905                out.append_null();
6906                continue;
6907            }
6908            let start = scalar_as_i64_arg(&start, "range start")?;
6909            let end = scalar_as_i64_arg(&end, "range end")?;
6910            let step = scalar_as_i64_arg(&step, "range step")?;
6911            if step == 0 {
6912                return Err(datafusion::error::DataFusionError::Plan(
6913                    "range step must not be zero".into(),
6914                ));
6915            }
6916            if (step > 0 && start > end) || (step < 0 && start < end) {
6917                out.append(true);
6918                continue;
6919            }
6920            let mut cur = start;
6921            loop {
6922                out.values().append_value(cur);
6923                if cur == end {
6924                    break;
6925                }
6926                let Some(next) = cur.checked_add(step) else {
6927                    return Err(datafusion::error::DataFusionError::Plan(
6928                        "range overflowed i64".into(),
6929                    ));
6930                };
6931                if (step > 0 && next > end) || (step < 0 && next < end) {
6932                    break;
6933                }
6934                cur = next;
6935            }
6936            out.append(true);
6937        }
6938        Ok(ColumnarValue::Array(std::sync::Arc::new(out.finish())))
6939    }
6940}
6941
6942fn scalar_as_i64_arg(s: &ScalarValue, name: &str) -> datafusion::error::Result<i64> {
6943    match s {
6944        ScalarValue::Int8(Some(v)) => Ok(i64::from(*v)),
6945        ScalarValue::Int16(Some(v)) => Ok(i64::from(*v)),
6946        ScalarValue::Int32(Some(v)) => Ok(i64::from(*v)),
6947        ScalarValue::Int64(Some(v)) => Ok(*v),
6948        ScalarValue::UInt8(Some(v)) => Ok(i64::from(*v)),
6949        ScalarValue::UInt16(Some(v)) => Ok(i64::from(*v)),
6950        ScalarValue::UInt32(Some(v)) => Ok(i64::from(*v)),
6951        ScalarValue::UInt64(Some(v)) => i64::try_from(*v).map_err(|_| {
6952            datafusion::error::DataFusionError::Plan(format!("{name} exceeds i64::MAX"))
6953        }),
6954        other => Err(datafusion::error::DataFusionError::Plan(format!(
6955            "{name} must be an integer, got {other:?}"
6956        ))),
6957    }
6958}
6959
6960#[derive(Debug, PartialEq, Eq, Hash)]
6961struct CypherOrderKey {
6962    signature: Signature,
6963}
6964
6965impl CypherOrderKey {
6966    fn new() -> Self {
6967        Self {
6968            signature: Signature::any(1, Volatility::Immutable),
6969        }
6970    }
6971}
6972
6973impl ScalarUDFImpl for CypherOrderKey {
6974    fn as_any(&self) -> &dyn Any {
6975        self
6976    }
6977    fn name(&self) -> &'static str {
6978        "cypher_order_key"
6979    }
6980    fn signature(&self) -> &Signature {
6981        &self.signature
6982    }
6983    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
6984        Ok(DataType::Utf8)
6985    }
6986    fn invoke_with_args(
6987        &self,
6988        args: ScalarFunctionArgs,
6989    ) -> datafusion::error::Result<ColumnarValue> {
6990        use datafusion::arrow::array::StringArray;
6991        let rows = args.number_rows;
6992        let values = args.args[0].to_array(rows)?;
6993        let out: StringArray = (0..rows)
6994            .map(|i| {
6995                let v = ScalarValue::try_from_array(&values, i).ok()?;
6996                Some(cypher_order_key(&v))
6997            })
6998            .collect();
6999        Ok(ColumnarValue::Array(std::sync::Arc::new(out)))
7000    }
7001}
7002
7003pub(crate) fn needs_cypher_order_key_type(t: &DataType) -> bool {
7004    matches!(
7005        t,
7006        DataType::List(_) | DataType::LargeList(_) | DataType::FixedSizeList(_, _)
7007    ) || (matches!(t, DataType::Struct(_))
7008        && !is_date_struct(t)
7009        && !is_localdatetime_struct(t)
7010        && !is_duration_struct(t))
7011}
7012
7013fn cypher_order_key(v: &ScalarValue) -> String {
7014    let v = unwrap_het(v.clone());
7015    if v.is_null() {
7016        return "99:null".to_string();
7017    }
7018    match &v {
7019        ScalarValue::Struct(s) if is_time_struct(&v.data_type()) => {
7020            let Some((nanos, offset)) = time_struct_parts(s, 0) else {
7021                return "99:null".to_string();
7022            };
7023            let instant = i128::from(nanos) - i128::from(offset) * 1_000_000_000;
7024            format!("55:time:{}", ordered_i128_key(instant))
7025        }
7026        ScalarValue::Struct(s) if is_datetime_struct(&v.data_type()) => {
7027            let Some((days, nanos, offset, _)) = datetime_struct_parts(s, 0) else {
7028                return "99:null".to_string();
7029            };
7030            let instant = i128::from(days) * 86_400_000_000_000 + i128::from(nanos)
7031                - i128::from(offset) * 1_000_000_000;
7032            format!("55:datetime:{}", ordered_i128_key(instant))
7033        }
7034        ScalarValue::Struct(s) if is_path_struct(s) => "50:path".to_string(),
7035        ScalarValue::Struct(s) if is_rel_struct(s) => "30:rel".to_string(),
7036        ScalarValue::Struct(s) if is_node_struct(s) => "20:node".to_string(),
7037        ScalarValue::Struct(_) => "10:map".to_string(),
7038        ScalarValue::List(a) => format!("40:list:{}", cypher_list_order_key(&a.value(0))),
7039        ScalarValue::LargeList(a) => format!("40:list:{}", cypher_list_order_key(&a.value(0))),
7040        ScalarValue::Utf8(Some(s)) | ScalarValue::LargeUtf8(Some(s)) => format!("60:str:{s}"),
7041        ScalarValue::Boolean(Some(b)) => format!("70:bool:{}", u8::from(*b)),
7042        ScalarValue::Int64(Some(n)) => {
7043            #[allow(
7044                clippy::cast_precision_loss,
7045                reason = "Cypher numeric order shares a number bucket across ints and floats"
7046            )]
7047            let n = *n as f64;
7048            format!("80:num:{}", ordered_f64_key(n))
7049        }
7050        ScalarValue::Float64(Some(f)) if f.is_nan() => "90:nan".to_string(),
7051        ScalarValue::Float64(Some(f)) => format!("80:num:{}", ordered_f64_key(*f)),
7052        _ => "98:other".to_string(),
7053    }
7054}
7055
7056fn cypher_list_order_key(values: &datafusion::arrow::array::ArrayRef) -> String {
7057    let mut out = String::new();
7058    for i in 0..values.len() {
7059        let v = ScalarValue::try_from_array(values, i).unwrap_or(ScalarValue::Null);
7060        out.push_str(&cypher_order_key(&v));
7061        out.push('|');
7062    }
7063    out.push_str("00:end");
7064    out
7065}
7066
7067fn ordered_f64_key(f: f64) -> String {
7068    let bits = f.to_bits();
7069    let key = if (bits >> 63) == 0 {
7070        bits | (1 << 63)
7071    } else {
7072        !bits
7073    };
7074    format!("{key:016x}")
7075}
7076
7077fn ordered_i128_key(value: i128) -> String {
7078    let key = value.cast_unsigned() ^ (1_u128 << 127);
7079    format!("{key:032x}")
7080}
7081
7082fn is_node_struct(s: &datafusion::arrow::array::StructArray) -> bool {
7083    s.column_by_name("node_uuid").is_some() || s.column_by_name("labels").is_some()
7084}
7085
7086fn is_rel_struct(s: &datafusion::arrow::array::StructArray) -> bool {
7087    s.column_by_name("edge_uuid").is_some()
7088        || s.column_by_name("src_uuid").is_some()
7089        || s.column_by_name("dst_uuid").is_some()
7090}
7091
7092fn is_path_struct(s: &datafusion::arrow::array::StructArray) -> bool {
7093    s.column_by_name("nodes").is_some() || s.column_by_name("relationships").is_some()
7094}
7095
7096// ---------------------------------------------------------------------------
7097// cypher_eq UDF
7098// ---------------------------------------------------------------------------
7099
7100/// Cypher equality (`=`; `<>` is its negation). Unlike SQL `=`, comparing two
7101/// values of **different types** is `false` rather than a planning error, and
7102/// `null = x` is `null` (three-valued). Same-type and mixed-numeric operands use
7103/// Arrow's null-propagating `eq` kernel; everything else (string-vs-number,
7104/// temporal-vs-other, …) is `false` where both operands are non-null. (ADR 0009)
7105static CYPHER_EQ: LazyLock<ScalarUDF> = LazyLock::new(|| ScalarUDF::new_from_impl(CypherEq::new()));
7106
7107#[derive(Debug, PartialEq, Eq, Hash)]
7108struct CypherEq {
7109    signature: Signature,
7110}
7111
7112impl CypherEq {
7113    fn new() -> Self {
7114        Self {
7115            signature: Signature::any(2, Volatility::Immutable),
7116        }
7117    }
7118}
7119
7120impl ScalarUDFImpl for CypherEq {
7121    fn as_any(&self) -> &dyn Any {
7122        self
7123    }
7124
7125    fn name(&self) -> &'static str {
7126        "cypher_eq"
7127    }
7128
7129    fn signature(&self) -> &Signature {
7130        &self.signature
7131    }
7132
7133    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
7134        Ok(DataType::Boolean)
7135    }
7136
7137    /// Rewrite to the native `=` so DataFusion keeps its optimizations (filter
7138    /// pushdown, join-key recognition) whenever the operands are statically the
7139    /// **same** primitive type, or a type can't be resolved (e.g. a `$param`,
7140    /// which native `=` coerces and pushes down). The type-tolerant UDF is kept
7141    /// for everything else: **nested** operands (three-valued structural
7142    /// equality) and **differing** types — including differing numeric widths
7143    /// (`UInt64` vs `Int64`), where native `=` not only risks a planning error
7144    /// but trips DataFusion's interval analysis (`lhs_type == rhs_type`); the
7145    /// UDF compares those via `f64` at runtime instead.
7146    fn simplify(
7147        &self,
7148        args: Vec<DfExpr>,
7149        info: &datafusion::logical_expr::simplify::SimplifyContext,
7150    ) -> datafusion::error::Result<datafusion::logical_expr::simplify::ExprSimplifyResult> {
7151        use datafusion::logical_expr::simplify::ExprSimplifyResult;
7152        let [l, r] = args.as_slice() else {
7153            return Ok(ExprSimplifyResult::Original(args));
7154        };
7155        let nested = |t: &DataType| {
7156            matches!(
7157                t,
7158                DataType::List(_) | DataType::LargeList(_) | DataType::Struct(_)
7159            )
7160        };
7161        let floaty =
7162            |t: &DataType| matches!(t, DataType::Float16 | DataType::Float32 | DataType::Float64);
7163        // A `$param` placeholder has no statically-fixed type here; native `=`
7164        // coerces it to the other operand at bind time and pushes down (the
7165        // common `WHERE prop = $x`), so don't trap it in the UDF.
7166        let placeholder = |e: &DfExpr| matches!(e, DfExpr::Placeholder(_));
7167        let keep_udf = if placeholder(l) || placeholder(r) {
7168            false
7169        } else if let (Ok(lt), Ok(rt)) = (info.get_data_type(l), info.get_data_type(r)) {
7170            nested(&lt) || nested(&rt) || floaty(&lt) || floaty(&rt) || lt != rt
7171        } else {
7172            false // unresolved operand type(s) ⇒ native `=` handles it (and pushes down)
7173        };
7174        if keep_udf {
7175            return Ok(ExprSimplifyResult::Original(args));
7176        }
7177        let [l, r]: [DfExpr; 2] = args.try_into().expect("checked length 2 above");
7178        Ok(ExprSimplifyResult::Simplified(l.eq(r)))
7179    }
7180
7181    fn invoke_with_args(
7182        &self,
7183        args: ScalarFunctionArgs,
7184    ) -> datafusion::error::Result<ColumnarValue> {
7185        use datafusion::arrow::array::BooleanArray;
7186        use datafusion::arrow::compute::kernels::cmp::eq;
7187
7188        let rows = args.number_rows;
7189        let lhs = args.args[0].to_array(rows)?;
7190        let rhs = args.args[1].to_array(rows)?;
7191        let (lt, rt) = (lhs.data_type(), rhs.data_type());
7192
7193        let nested = |t: &DataType| {
7194            matches!(
7195                t,
7196                DataType::List(_) | DataType::LargeList(_) | DataType::Struct(_)
7197            )
7198        };
7199
7200        let floaty =
7201            |t: &DataType| matches!(t, DataType::Float16 | DataType::Float32 | DataType::Float64);
7202
7203        // Fast path: same non-float primitive type → Arrow's null-propagating
7204        // equality kernel (vectorised, and identical to the prior native `=`
7205        // behaviour). Floats stay on the Cypher path so NaN never equals itself.
7206        if lt == rt && !nested(lt) && !floaty(lt) {
7207            let res = eq(&lhs, &rhs)?;
7208            return Ok(ColumnarValue::Array(std::sync::Arc::new(res)));
7209        }
7210
7211        // Float, nested, numeric-coercion and cross-type comparisons need Cypher's three-valued
7212        // structural equality, which the scalar `=` kernel doesn't provide:
7213        // compare value-by-value via `ScalarValue`.
7214        let out: BooleanArray = (0..rows)
7215            .map(|i| {
7216                let l = ScalarValue::try_from_array(&lhs, i).ok()?;
7217                let r = ScalarValue::try_from_array(&rhs, i).ok()?;
7218                cypher_value_eq(&l, &r)
7219            })
7220            .collect();
7221        Ok(ColumnarValue::Array(std::sync::Arc::new(out)))
7222    }
7223}
7224
7225/// `x IN list` with Cypher three-valued structural membership (ADR 0011): used
7226/// when the list is a heterogeneous/nested tagged list, which DataFusion's native
7227/// `in_list` cannot compare. Decodes each element and reuses [`cypher_value_eq`];
7228/// a definitive match wins, else any `null` comparison yields `null`, else `false`
7229/// (an empty list is `false`, even for a `null` left operand).
7230static CYPHER_IN: LazyLock<ScalarUDF> = LazyLock::new(|| ScalarUDF::new_from_impl(CypherIn::new()));
7231
7232#[derive(Debug, PartialEq, Eq, Hash)]
7233struct CypherIn {
7234    signature: Signature,
7235}
7236
7237impl CypherIn {
7238    fn new() -> Self {
7239        Self {
7240            signature: Signature::any(2, Volatility::Immutable),
7241        }
7242    }
7243}
7244
7245enum ListView<'a> {
7246    Fixed(&'a FixedSizeListArray),
7247    List(&'a ListArray),
7248    Large(&'a LargeListArray),
7249}
7250
7251impl ListView<'_> {
7252    fn from_array(array: &datafusion::arrow::array::ArrayRef) -> Option<ListView<'_>> {
7253        if let Some(list) = array.as_any().downcast_ref::<FixedSizeListArray>() {
7254            Some(ListView::Fixed(list))
7255        } else if let Some(list) = array.as_any().downcast_ref::<ListArray>() {
7256            Some(ListView::List(list))
7257        } else {
7258            array
7259                .as_any()
7260                .downcast_ref::<LargeListArray>()
7261                .map(ListView::Large)
7262        }
7263    }
7264
7265    fn is_null(&self, row: usize) -> bool {
7266        match self {
7267            Self::Fixed(a) => a.is_null(row),
7268            Self::List(a) => a.is_null(row),
7269            Self::Large(a) => a.is_null(row),
7270        }
7271    }
7272
7273    fn value(&self, row: usize) -> datafusion::arrow::array::ArrayRef {
7274        match self {
7275            Self::Fixed(a) => a.value(row),
7276            Self::List(a) => a.value(row),
7277            Self::Large(a) => a.value(row),
7278        }
7279    }
7280}
7281
7282fn cypher_in_elems(lhs: &ScalarValue, elems: &datafusion::arrow::array::ArrayRef) -> Option<bool> {
7283    let mut saw_null = false;
7284    for j in 0..elems.len() {
7285        let ev = ScalarValue::try_from_array(elems, j).ok()?;
7286        match cypher_value_eq(lhs, &ev) {
7287            Some(true) => return Some(true),
7288            None => saw_null = true,
7289            Some(false) => {}
7290        }
7291    }
7292    if saw_null { None } else { Some(false) }
7293}
7294
7295fn cypher_in_tagged_list(
7296    lhs: &ScalarValue,
7297    rhs: &datafusion::arrow::array::ArrayRef,
7298    row: usize,
7299) -> Option<bool> {
7300    let rv = ScalarValue::try_from_array(rhs, row).ok()?;
7301    match unwrap_het(rv) {
7302        ScalarValue::List(list) => {
7303            if list.is_null(0) {
7304                None
7305            } else {
7306                cypher_in_elems(lhs, &list.value(0))
7307            }
7308        }
7309        ScalarValue::LargeList(list) => {
7310            if list.is_null(0) {
7311                None
7312            } else {
7313                cypher_in_elems(lhs, &list.value(0))
7314            }
7315        }
7316        _ => None,
7317    }
7318}
7319
7320impl ScalarUDFImpl for CypherIn {
7321    fn as_any(&self) -> &dyn Any {
7322        self
7323    }
7324    fn name(&self) -> &'static str {
7325        "cypher_in"
7326    }
7327    fn signature(&self) -> &Signature {
7328        &self.signature
7329    }
7330    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
7331        Ok(DataType::Boolean)
7332    }
7333
7334    fn invoke_with_args(
7335        &self,
7336        args: ScalarFunctionArgs,
7337    ) -> datafusion::error::Result<ColumnarValue> {
7338        use datafusion::arrow::array::BooleanArray;
7339        let rows = args.number_rows;
7340        let lhs = args.args[0].to_array(rows)?;
7341        let rhs = args.args[1].to_array(rows)?;
7342        if is_het_struct_type(Some(rhs.data_type())) {
7343            let out: BooleanArray = (0..rows)
7344                .map(|i| {
7345                    let lv = ScalarValue::try_from_array(&lhs, i).ok()?;
7346                    cypher_in_tagged_list(&lv, &rhs, i)
7347                })
7348                .collect();
7349            return Ok(ColumnarValue::Array(std::sync::Arc::new(out)));
7350        }
7351        let list = if let Some(list) = rhs.as_any().downcast_ref::<FixedSizeListArray>() {
7352            ListView::Fixed(list)
7353        } else if let Some(list) = rhs.as_any().downcast_ref::<ListArray>() {
7354            ListView::List(list)
7355        } else if let Some(list) = rhs.as_any().downcast_ref::<LargeListArray>() {
7356            ListView::Large(list)
7357        } else {
7358            return Ok(ColumnarValue::Array(std::sync::Arc::new(
7359                BooleanArray::new_null(rows),
7360            )));
7361        };
7362        let out: BooleanArray = (0..rows)
7363            .map(|i| {
7364                if list.is_null(i) {
7365                    return None; // `x IN null` → null
7366                }
7367                let lv = ScalarValue::try_from_array(&lhs, i).ok()?;
7368                let elems = list.value(i);
7369                cypher_in_elems(&lv, &elems)
7370            })
7371            .collect();
7372        Ok(ColumnarValue::Array(std::sync::Arc::new(out)))
7373    }
7374}
7375
7376/// Cypher order COMPARABILITY of `<`/`<=`/`>`/`>=`: `Some(-1|0|1)` when the two
7377/// values are comparable, `None` (= `null`) when either is null or the types are
7378/// not order-comparable (e.g. a list vs a boolean). Numbers compare across
7379/// `Int`/`Float`; same-typed strings/booleans compare directly; two lists compare
7380/// lexicographically (a shorter prefix sorts first), with an incomparable element
7381/// making the whole comparison `null`. Unlike *orderability* (used by min/max),
7382/// comparability does NOT impose a cross-type order.
7383fn cypher_compare(a: &ScalarValue, b: &ScalarValue) -> Option<i8> {
7384    let to_i8 = |o: std::cmp::Ordering| o as i8;
7385    let a = unwrap_het(a.clone());
7386    let b = unwrap_het(b.clone());
7387    if a.is_null() || b.is_null() {
7388        return None;
7389    }
7390    match (&a, &b) {
7391        _ if is_numeric_scalar(&a) && is_numeric_scalar(&b) => {
7392            if let (Some(x), Some(y)) = (scalar_as_i128(&a), scalar_as_i128(&b)) {
7393                Some(to_i8(x.cmp(&y)))
7394            } else {
7395                scalar_as_f64(&a)?
7396                    .partial_cmp(&scalar_as_f64(&b)?)
7397                    .map(to_i8)
7398            }
7399        }
7400        (ScalarValue::Utf8(Some(x)), ScalarValue::Utf8(Some(y))) => Some(to_i8(x.cmp(y))),
7401        (ScalarValue::Boolean(Some(x)), ScalarValue::Boolean(Some(y))) => Some(to_i8(x.cmp(y))),
7402        (ScalarValue::Time64Nanosecond(Some(x)), ScalarValue::Time64Nanosecond(Some(y))) => {
7403            Some(to_i8(x.cmp(y)))
7404        }
7405        (ScalarValue::List(x), ScalarValue::List(y)) => {
7406            cypher_seq_compare(&x.value(0), &y.value(0))
7407        }
7408        (ScalarValue::Struct(x), ScalarValue::Struct(y))
7409            if is_date_struct(&a.data_type()) && is_date_struct(&b.data_type()) =>
7410        {
7411            Some(to_i8(
7412                date_struct_value(x, 0)?.cmp(&date_struct_value(y, 0)?),
7413            ))
7414        }
7415        (ScalarValue::Struct(x), ScalarValue::Struct(y))
7416            if is_localdatetime_struct(&a.data_type())
7417                && is_localdatetime_struct(&b.data_type()) =>
7418        {
7419            Some(to_i8(
7420                localdatetime_struct_parts(x, 0)?.cmp(&localdatetime_struct_parts(y, 0)?),
7421            ))
7422        }
7423        // `time` orders by its UTC instant (`time - offset`), not the struct's
7424        // native lexicographic `(time, offset)`. (#1008, Temporal7 [3])
7425        (ScalarValue::Struct(x), ScalarValue::Struct(y))
7426            if is_time_struct(&a.data_type()) && is_time_struct(&b.data_type()) =>
7427        {
7428            let (xn, xo) = time_struct_parts(x, 0)?;
7429            let (yn, yo) = time_struct_parts(y, 0)?;
7430            let xi = i128::from(xn) - i128::from(xo) * 1_000_000_000;
7431            let yi = i128::from(yn) - i128::from(yo) * 1_000_000_000;
7432            Some(to_i8(xi.cmp(&yi)))
7433        }
7434        (ScalarValue::Struct(x), ScalarValue::Struct(y))
7435            if is_datetime_struct(&a.data_type()) && is_datetime_struct(&b.data_type()) =>
7436        {
7437            let (xd, xn, xo, _) = datetime_struct_parts(x, 0)?;
7438            let (yd, yn, yo, _) = datetime_struct_parts(y, 0)?;
7439            let xi = i128::from(xd) * 86_400_000_000_000 + i128::from(xn)
7440                - i128::from(xo) * 1_000_000_000;
7441            let yi = i128::from(yd) * 86_400_000_000_000 + i128::from(yn)
7442                - i128::from(yo) * 1_000_000_000;
7443            Some(to_i8(xi.cmp(&yi)))
7444        }
7445        _ => None, // incomparable types
7446    }
7447}
7448
7449/// Lexicographic comparability of two list element arrays (see [`cypher_compare`]).
7450fn cypher_seq_compare(
7451    a: &datafusion::arrow::array::ArrayRef,
7452    b: &datafusion::arrow::array::ArrayRef,
7453) -> Option<i8> {
7454    let common = a.len().min(b.len());
7455    for i in 0..common {
7456        let av = ScalarValue::try_from_array(a, i).ok()?;
7457        let bv = ScalarValue::try_from_array(b, i).ok()?;
7458        match cypher_compare(&av, &bv)? {
7459            0 => {}
7460            c => return Some(c),
7461        }
7462    }
7463    Some(a.len().cmp(&b.len()) as i8) // a shared prefix → the shorter list sorts first
7464}
7465
7466/// Whether a lowered expression is a constant `List` literal.
7467fn is_list_literal(e: &DfExpr) -> bool {
7468    matches!(e, DfExpr::Literal(ScalarValue::List(_), _))
7469}
7470
7471/// Whether a DataType is the ADR-0011 heterogeneous tagged-struct element type
7472/// (so `min`/`max` over it must use Cypher orderability, not native min/max).
7473pub(crate) fn is_het_struct_type(t: Option<&DataType>) -> bool {
7474    matches!(t, Some(DataType::Struct(fields)) if fields.iter().any(|f| f.name() == "__het_tag"))
7475}
7476
7477/// Cypher ORDERABILITY total order, used by `min`/`max` (which exclude nulls).
7478/// Ascending type rank `list < string < boolean < number < map`, then by value
7479/// within a type (numbers by `f64`, strings/booleans naturally, lists
7480/// lexicographically, maps by sorted `(key, value)` entries — ADR 0011 slice 5).
7481/// Distinct from [`cypher_compare`]: orderability is a TOTAL order across types,
7482/// whereas comparability (`<`) is three-valued with no cross-type order.
7483fn cypher_order(a: &ScalarValue, b: &ScalarValue) -> std::cmp::Ordering {
7484    use std::cmp::Ordering;
7485    let a = unwrap_het(a.clone());
7486    let b = unwrap_het(b.clone());
7487    let rank = |v: &ScalarValue| -> u8 {
7488        match v {
7489            ScalarValue::List(_) | ScalarValue::LargeList(_) => 1,
7490            ScalarValue::Utf8(_) | ScalarValue::LargeUtf8(_) => 2,
7491            ScalarValue::Boolean(_) => 3,
7492            _ if is_numeric_scalar(v) => 4,
7493            ScalarValue::Struct(_) => 5,
7494            _ => 0,
7495        }
7496    };
7497    let (ra, rb) = (rank(&a), rank(&b));
7498    if ra != rb {
7499        return ra.cmp(&rb);
7500    }
7501    match (&a, &b) {
7502        (ScalarValue::List(x), ScalarValue::List(y)) => cypher_seq_order(&x.value(0), &y.value(0)),
7503        (ScalarValue::Utf8(Some(x)), ScalarValue::Utf8(Some(y))) => x.cmp(y),
7504        (ScalarValue::Boolean(Some(x)), ScalarValue::Boolean(Some(y))) => x.cmp(y),
7505        (ScalarValue::Struct(x), ScalarValue::Struct(y)) => cypher_map_order(x, y),
7506        _ => match (scalar_as_f64(&a), scalar_as_f64(&b)) {
7507            (Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
7508            _ => Ordering::Equal,
7509        },
7510    }
7511}
7512
7513/// Orderability of two maps (ADR 0011 slice 5): compare their entries sorted by
7514/// key — key-by-key (lexicographic), then value-by-value ([`cypher_order`]); a
7515/// map with fewer keys sorts first when it is a prefix of the other.
7516fn cypher_map_order(
7517    a: &datafusion::arrow::array::StructArray,
7518    b: &datafusion::arrow::array::StructArray,
7519) -> std::cmp::Ordering {
7520    let sorted = |s: &datafusion::arrow::array::StructArray| -> Vec<(String, ScalarValue)> {
7521        let mut e: Vec<(String, ScalarValue)> = s
7522            .fields()
7523            .iter()
7524            .enumerate()
7525            .map(|(i, f)| {
7526                (
7527                    f.name().clone(),
7528                    ScalarValue::try_from_array(s.column(i), 0).unwrap_or(ScalarValue::Null),
7529                )
7530            })
7531            .collect();
7532        e.sort_by(|x, y| x.0.cmp(&y.0));
7533        e
7534    };
7535    let (ea, eb) = (sorted(a), sorted(b));
7536    for ((ka, va), (kb, vb)) in ea.iter().zip(eb.iter()) {
7537        match ka.cmp(kb) {
7538            std::cmp::Ordering::Equal => {}
7539            c => return c,
7540        }
7541        match cypher_order(va, vb) {
7542            std::cmp::Ordering::Equal => {}
7543            c => return c,
7544        }
7545    }
7546    ea.len().cmp(&eb.len())
7547}
7548
7549/// Lexicographic orderability of two list element arrays (shorter prefix sorts
7550/// first), decoding tagged elements.
7551fn cypher_seq_order(
7552    a: &datafusion::arrow::array::ArrayRef,
7553    b: &datafusion::arrow::array::ArrayRef,
7554) -> std::cmp::Ordering {
7555    let common = a.len().min(b.len());
7556    for i in 0..common {
7557        let av = ScalarValue::try_from_array(a, i).unwrap_or(ScalarValue::Null);
7558        let bv = ScalarValue::try_from_array(b, i).unwrap_or(ScalarValue::Null);
7559        match cypher_order(&av, &bv) {
7560            std::cmp::Ordering::Equal => {}
7561            c => return c,
7562        }
7563    }
7564    a.len().cmp(&b.len())
7565}
7566
7567/// `min`/`max` over a heterogeneous (tagged) column use Cypher orderability
7568/// ([`cypher_order`]) — native struct min/max would order by `__het_key` (null for
7569/// non-numeric elements). Returns the original tagged element so it renders right.
7570pub(crate) static CYPHER_MAX: LazyLock<datafusion::logical_expr::AggregateUDF> =
7571    LazyLock::new(|| {
7572        datafusion::logical_expr::AggregateUDF::new_from_impl(CypherExtreme::new(true))
7573    });
7574pub(crate) static CYPHER_MIN: LazyLock<datafusion::logical_expr::AggregateUDF> =
7575    LazyLock::new(|| {
7576        datafusion::logical_expr::AggregateUDF::new_from_impl(CypherExtreme::new(false))
7577    });
7578pub(crate) static CYPHER_COLLECT: LazyLock<datafusion::logical_expr::AggregateUDF> =
7579    LazyLock::new(|| {
7580        datafusion::logical_expr::AggregateUDF::new_from_impl(CypherCollect::new(false))
7581    });
7582pub(crate) static CYPHER_COLLECT_DISTINCT: LazyLock<datafusion::logical_expr::AggregateUDF> =
7583    LazyLock::new(|| {
7584        datafusion::logical_expr::AggregateUDF::new_from_impl(CypherCollect::new(true))
7585    });
7586pub(crate) static CYPHER_PERCENTILE_DISC: LazyLock<datafusion::logical_expr::AggregateUDF> =
7587    LazyLock::new(|| {
7588        datafusion::logical_expr::AggregateUDF::new_from_impl(CypherPercentile::new(false))
7589    });
7590pub(crate) static CYPHER_PERCENTILE_CONT: LazyLock<datafusion::logical_expr::AggregateUDF> =
7591    LazyLock::new(|| {
7592        datafusion::logical_expr::AggregateUDF::new_from_impl(CypherPercentile::new(true))
7593    });
7594
7595#[derive(Debug)]
7596struct CypherExtreme {
7597    signature: Signature,
7598    is_max: bool,
7599}
7600impl CypherExtreme {
7601    fn new(is_max: bool) -> Self {
7602        Self {
7603            signature: Signature::any(1, Volatility::Immutable),
7604            is_max,
7605        }
7606    }
7607}
7608impl PartialEq for CypherExtreme {
7609    fn eq(&self, o: &Self) -> bool {
7610        self.is_max == o.is_max
7611    }
7612}
7613impl Eq for CypherExtreme {}
7614impl std::hash::Hash for CypherExtreme {
7615    fn hash<H: std::hash::Hasher>(&self, st: &mut H) {
7616        self.is_max.hash(st);
7617    }
7618}
7619impl datafusion::logical_expr::AggregateUDFImpl for CypherExtreme {
7620    fn as_any(&self) -> &dyn Any {
7621        self
7622    }
7623    fn name(&self) -> &str {
7624        if self.is_max {
7625            "cypher_max"
7626        } else {
7627            "cypher_min"
7628        }
7629    }
7630    fn signature(&self) -> &Signature {
7631        &self.signature
7632    }
7633    fn return_type(&self, arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
7634        Ok(arg_types[0].clone())
7635    }
7636    fn accumulator(
7637        &self,
7638        args: datafusion::logical_expr::function::AccumulatorArgs,
7639    ) -> datafusion::error::Result<Box<dyn datafusion::logical_expr::Accumulator>> {
7640        Ok(Box::new(ExtremeAcc {
7641            is_max: self.is_max,
7642            dtype: args.return_field.data_type().clone(),
7643            best: None,
7644        }))
7645    }
7646    fn state_fields(
7647        &self,
7648        args: datafusion::logical_expr::function::StateFieldsArgs,
7649    ) -> datafusion::error::Result<Vec<datafusion::arrow::datatypes::FieldRef>> {
7650        use datafusion::arrow::datatypes::Field;
7651        Ok(vec![std::sync::Arc::new(Field::new(
7652            "best",
7653            args.return_field.data_type().clone(),
7654            true,
7655        ))])
7656    }
7657}
7658
7659#[derive(Debug)]
7660struct ExtremeAcc {
7661    is_max: bool,
7662    dtype: DataType,
7663    best: Option<ScalarValue>,
7664}
7665impl datafusion::logical_expr::Accumulator for ExtremeAcc {
7666    fn update_batch(
7667        &mut self,
7668        values: &[datafusion::arrow::array::ArrayRef],
7669    ) -> datafusion::error::Result<()> {
7670        use datafusion::arrow::array::Array;
7671        let arr = &values[0];
7672        for i in 0..arr.len() {
7673            if arr.is_null(i) {
7674                continue;
7675            }
7676            let v = ScalarValue::try_from_array(arr, i)?;
7677            if v.is_null() {
7678                continue;
7679            }
7680            let take = match &self.best {
7681                None => true,
7682                Some(b) => {
7683                    let ord = cypher_order(&v, b);
7684                    (self.is_max && ord == std::cmp::Ordering::Greater)
7685                        || (!self.is_max && ord == std::cmp::Ordering::Less)
7686                }
7687            };
7688            if take {
7689                self.best = Some(v);
7690            }
7691        }
7692        Ok(())
7693    }
7694    fn evaluate(&mut self) -> datafusion::error::Result<ScalarValue> {
7695        match &self.best {
7696            Some(b) => Ok(b.clone()),
7697            None => ScalarValue::try_from(&self.dtype),
7698        }
7699    }
7700    fn size(&self) -> usize {
7701        std::mem::size_of_val(self) + self.best.as_ref().map_or(0, ScalarValue::size)
7702    }
7703    fn state(&mut self) -> datafusion::error::Result<Vec<ScalarValue>> {
7704        Ok(vec![self.evaluate()?])
7705    }
7706    fn merge_batch(
7707        &mut self,
7708        states: &[datafusion::arrow::array::ArrayRef],
7709    ) -> datafusion::error::Result<()> {
7710        self.update_batch(states)
7711    }
7712}
7713
7714#[derive(Debug)]
7715struct CypherCollect {
7716    signature: Signature,
7717    distinct: bool,
7718}
7719
7720impl CypherCollect {
7721    fn new(distinct: bool) -> Self {
7722        Self {
7723            signature: Signature::any(1, Volatility::Immutable),
7724            distinct,
7725        }
7726    }
7727}
7728
7729impl PartialEq for CypherCollect {
7730    fn eq(&self, o: &Self) -> bool {
7731        self.distinct == o.distinct
7732    }
7733}
7734
7735impl Eq for CypherCollect {}
7736
7737impl std::hash::Hash for CypherCollect {
7738    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
7739        self.distinct.hash(state);
7740    }
7741}
7742
7743impl datafusion::logical_expr::AggregateUDFImpl for CypherCollect {
7744    fn as_any(&self) -> &dyn Any {
7745        self
7746    }
7747    fn name(&self) -> &str {
7748        if self.distinct {
7749            "cypher_collect_distinct"
7750        } else {
7751            "cypher_collect"
7752        }
7753    }
7754    fn signature(&self) -> &Signature {
7755        &self.signature
7756    }
7757    fn return_type(&self, arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
7758        Ok(DataType::new_list(arg_types[0].clone(), true))
7759    }
7760    fn accumulator(
7761        &self,
7762        args: datafusion::logical_expr::function::AccumulatorArgs,
7763    ) -> datafusion::error::Result<Box<dyn datafusion::logical_expr::Accumulator>> {
7764        let DataType::List(field) = args.return_field.data_type() else {
7765            return Err(datafusion::error::DataFusionError::Plan(
7766                "cypher_collect return type must be a list".into(),
7767            ));
7768        };
7769        Ok(Box::new(CollectAcc {
7770            distinct: self.distinct,
7771            elem_type: field.data_type().clone(),
7772            values: Vec::new(),
7773        }))
7774    }
7775    fn state_fields(
7776        &self,
7777        args: datafusion::logical_expr::function::StateFieldsArgs,
7778    ) -> datafusion::error::Result<Vec<datafusion::arrow::datatypes::FieldRef>> {
7779        use datafusion::arrow::datatypes::Field;
7780        Ok(vec![std::sync::Arc::new(Field::new(
7781            "values",
7782            args.return_field.data_type().clone(),
7783            true,
7784        ))])
7785    }
7786}
7787
7788#[derive(Debug)]
7789struct CollectAcc {
7790    distinct: bool,
7791    elem_type: DataType,
7792    values: Vec<ScalarValue>,
7793}
7794
7795impl CollectAcc {
7796    fn push_value(&mut self, v: ScalarValue) {
7797        if v.is_null() {
7798            return;
7799        }
7800        if self.distinct
7801            && self
7802                .values
7803                .iter()
7804                .any(|seen| cypher_value_eq(seen, &v) == Some(true))
7805        {
7806            return;
7807        }
7808        self.values.push(v);
7809    }
7810
7811    fn as_list(&self) -> ScalarValue {
7812        ScalarValue::List(ScalarValue::new_list(&self.values, &self.elem_type, true))
7813    }
7814}
7815
7816impl datafusion::logical_expr::Accumulator for CollectAcc {
7817    fn update_batch(
7818        &mut self,
7819        values: &[datafusion::arrow::array::ArrayRef],
7820    ) -> datafusion::error::Result<()> {
7821        use datafusion::arrow::array::Array;
7822        let arr = &values[0];
7823        for i in 0..arr.len() {
7824            if arr.is_null(i) {
7825                continue;
7826            }
7827            self.push_value(ScalarValue::try_from_array(arr, i)?);
7828        }
7829        Ok(())
7830    }
7831    fn evaluate(&mut self) -> datafusion::error::Result<ScalarValue> {
7832        Ok(self.as_list())
7833    }
7834    fn size(&self) -> usize {
7835        std::mem::size_of_val(self) + self.values.iter().map(ScalarValue::size).sum::<usize>()
7836    }
7837    fn state(&mut self) -> datafusion::error::Result<Vec<ScalarValue>> {
7838        Ok(vec![self.as_list()])
7839    }
7840    fn merge_batch(
7841        &mut self,
7842        states: &[datafusion::arrow::array::ArrayRef],
7843    ) -> datafusion::error::Result<()> {
7844        use datafusion::arrow::array::{Array, ListArray};
7845        let arr = &states[0];
7846        let Some(list) = arr.as_any().downcast_ref::<ListArray>() else {
7847            return Err(datafusion::error::DataFusionError::Plan(
7848                "cypher_collect state must be a list".into(),
7849            ));
7850        };
7851        for row in 0..list.len() {
7852            if list.is_null(row) {
7853                continue;
7854            }
7855            let values = list.value(row);
7856            for i in 0..values.len() {
7857                if values.is_null(i) {
7858                    continue;
7859                }
7860                self.push_value(ScalarValue::try_from_array(&values, i)?);
7861            }
7862        }
7863        Ok(())
7864    }
7865}
7866
7867#[derive(Debug)]
7868struct CypherPercentile {
7869    signature: Signature,
7870    continuous: bool,
7871}
7872
7873impl CypherPercentile {
7874    fn new(continuous: bool) -> Self {
7875        Self {
7876            signature: Signature::any(2, Volatility::Immutable),
7877            continuous,
7878        }
7879    }
7880}
7881
7882impl PartialEq for CypherPercentile {
7883    fn eq(&self, o: &Self) -> bool {
7884        self.continuous == o.continuous
7885    }
7886}
7887
7888impl Eq for CypherPercentile {}
7889
7890impl std::hash::Hash for CypherPercentile {
7891    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
7892        self.continuous.hash(state);
7893    }
7894}
7895
7896impl datafusion::logical_expr::AggregateUDFImpl for CypherPercentile {
7897    fn as_any(&self) -> &dyn Any {
7898        self
7899    }
7900
7901    fn name(&self) -> &str {
7902        if self.continuous {
7903            "cypher_percentile_cont"
7904        } else {
7905            "cypher_percentile_disc"
7906        }
7907    }
7908
7909    fn signature(&self) -> &Signature {
7910        &self.signature
7911    }
7912
7913    fn return_type(&self, arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
7914        let Some(value_type) = arg_types.first() else {
7915            return Err(datafusion::error::DataFusionError::Plan(
7916                "percentile aggregate requires value and percentile arguments".into(),
7917            ));
7918        };
7919        if !is_percentile_numeric_type(value_type) {
7920            return Err(datafusion::error::DataFusionError::Plan(format!(
7921                "percentile value expression must be numeric, got {value_type}"
7922            )));
7923        }
7924        if self.continuous {
7925            Ok(DataType::Float64)
7926        } else {
7927            Ok(value_type.clone())
7928        }
7929    }
7930
7931    fn accumulator(
7932        &self,
7933        args: datafusion::logical_expr::function::AccumulatorArgs,
7934    ) -> datafusion::error::Result<Box<dyn datafusion::logical_expr::Accumulator>> {
7935        let value_type = args.expr_fields.first().map_or_else(
7936            || args.return_field.data_type().clone(),
7937            |f| f.data_type().clone(),
7938        );
7939        Ok(Box::new(PercentileAcc {
7940            continuous: self.continuous,
7941            value_type,
7942            result_type: args.return_field.data_type().clone(),
7943            values: Vec::new(),
7944            percentile: None,
7945        }))
7946    }
7947
7948    fn state_fields(
7949        &self,
7950        args: datafusion::logical_expr::function::StateFieldsArgs,
7951    ) -> datafusion::error::Result<Vec<datafusion::arrow::datatypes::FieldRef>> {
7952        use datafusion::arrow::datatypes::Field;
7953        let value_type = args.input_fields.first().map_or_else(
7954            || args.return_field.data_type().clone(),
7955            |f| f.data_type().clone(),
7956        );
7957        Ok(vec![
7958            std::sync::Arc::new(Field::new(
7959                "values",
7960                DataType::new_list(value_type, true),
7961                true,
7962            )),
7963            std::sync::Arc::new(Field::new("percentile", DataType::Float64, true)),
7964        ])
7965    }
7966}
7967
7968#[derive(Debug)]
7969struct PercentileAcc {
7970    continuous: bool,
7971    value_type: DataType,
7972    result_type: DataType,
7973    values: Vec<ScalarValue>,
7974    percentile: Option<f64>,
7975}
7976
7977impl PercentileAcc {
7978    fn push_value(&mut self, v: ScalarValue) -> datafusion::error::Result<()> {
7979        if v.is_null() {
7980            return Ok(());
7981        }
7982        if scalar_as_f64(&v).is_none() {
7983            return Err(datafusion::error::DataFusionError::Execution(format!(
7984                "percentile value expression must be numeric, got {}",
7985                v.data_type()
7986            )));
7987        }
7988        self.values.push(v);
7989        Ok(())
7990    }
7991
7992    fn observe_percentile(&mut self, p: Option<f64>) -> datafusion::error::Result<()> {
7993        let Some(p) = p else {
7994            return Ok(());
7995        };
7996        if !p.is_finite() || !(0.0..=1.0).contains(&p) {
7997            return Err(datafusion::error::DataFusionError::Execution(format!(
7998                "percentile argument must be a finite number between 0.0 and 1.0 inclusive, got {p}"
7999            )));
8000        }
8001        match self.percentile {
8002            Some(existing) if (existing - p).abs() > f64::EPSILON => {
8003                Err(datafusion::error::DataFusionError::Execution(
8004                    "percentile argument must be constant within an aggregate group".into(),
8005                ))
8006            }
8007            Some(_) => Ok(()),
8008            None => {
8009                self.percentile = Some(p);
8010                Ok(())
8011            }
8012        }
8013    }
8014
8015    fn null_result(&self) -> datafusion::error::Result<ScalarValue> {
8016        ScalarValue::try_from(&self.result_type)
8017    }
8018
8019    fn percentile_scalar(
8020        values: &[datafusion::arrow::array::ArrayRef],
8021        row: usize,
8022    ) -> datafusion::error::Result<Option<f64>> {
8023        use datafusion::arrow::array::Array;
8024        let arr = &values[1];
8025        if arr.is_null(row) {
8026            return Ok(None);
8027        }
8028        let scalar = ScalarValue::try_from_array(arr, row)?;
8029        if scalar.is_null() {
8030            Ok(None)
8031        } else {
8032            scalar_as_f64(&scalar).map(Some).ok_or_else(|| {
8033                datafusion::error::DataFusionError::Execution(format!(
8034                    "percentile argument must be numeric, got {}",
8035                    scalar.data_type()
8036                ))
8037            })
8038        }
8039    }
8040}
8041
8042impl datafusion::logical_expr::Accumulator for PercentileAcc {
8043    fn update_batch(
8044        &mut self,
8045        values: &[datafusion::arrow::array::ArrayRef],
8046    ) -> datafusion::error::Result<()> {
8047        use datafusion::arrow::array::Array;
8048        let value_arr = &values[0];
8049        for row in 0..value_arr.len() {
8050            self.observe_percentile(Self::percentile_scalar(values, row)?)?;
8051            if value_arr.is_null(row) {
8052                continue;
8053            }
8054            self.push_value(ScalarValue::try_from_array(value_arr, row)?)?;
8055        }
8056        Ok(())
8057    }
8058
8059    fn evaluate(&mut self) -> datafusion::error::Result<ScalarValue> {
8060        let Some(percentile) = self.percentile else {
8061            return self.null_result();
8062        };
8063        if self.values.is_empty() {
8064            return self.null_result();
8065        }
8066        let mut values: Vec<(f64, ScalarValue)> = self
8067            .values
8068            .iter()
8069            .filter_map(|v| scalar_as_f64(v).map(|f| (f, v.clone())))
8070            .collect();
8071        if values.is_empty() {
8072            return self.null_result();
8073        }
8074        values.sort_by(|(l, _), (r, _)| l.total_cmp(r));
8075
8076        if self.continuous {
8077            let len = values.len();
8078            if len == 1 {
8079                return Ok(ScalarValue::Float64(Some(values[0].0)));
8080            }
8081            let (lower_index, upper_index, fraction) = percentile_cont_indices(percentile, len);
8082            let result = if lower_index == upper_index {
8083                values[lower_index].0
8084            } else {
8085                let lower = values[lower_index].0;
8086                let upper = values[upper_index].0;
8087                lower + (upper - lower) * fraction
8088            };
8089            Ok(ScalarValue::Float64(Some(result)))
8090        } else {
8091            let index = percentile_disc_index(percentile, values.len());
8092            Ok(values[index].1.clone())
8093        }
8094    }
8095
8096    fn size(&self) -> usize {
8097        std::mem::size_of_val(self) + self.values.iter().map(ScalarValue::size).sum::<usize>()
8098    }
8099
8100    fn state(&mut self) -> datafusion::error::Result<Vec<ScalarValue>> {
8101        Ok(vec![
8102            ScalarValue::List(ScalarValue::new_list(&self.values, &self.value_type, true)),
8103            ScalarValue::Float64(self.percentile),
8104        ])
8105    }
8106
8107    fn merge_batch(
8108        &mut self,
8109        states: &[datafusion::arrow::array::ArrayRef],
8110    ) -> datafusion::error::Result<()> {
8111        use datafusion::arrow::array::{Array, Float64Array, ListArray};
8112        let values = &states[0];
8113        let Some(lists) = values.as_any().downcast_ref::<ListArray>() else {
8114            return Err(datafusion::error::DataFusionError::Plan(
8115                "percentile state values must be a list".into(),
8116            ));
8117        };
8118        let Some(percentiles) = states[1].as_any().downcast_ref::<Float64Array>() else {
8119            return Err(datafusion::error::DataFusionError::Plan(
8120                "percentile state percentile must be Float64".into(),
8121            ));
8122        };
8123        for row in 0..lists.len() {
8124            self.observe_percentile(if percentiles.is_null(row) {
8125                None
8126            } else {
8127                Some(percentiles.value(row))
8128            })?;
8129            if lists.is_null(row) {
8130                continue;
8131            }
8132            let values = lists.value(row);
8133            for i in 0..values.len() {
8134                if values.is_null(i) {
8135                    continue;
8136                }
8137                self.push_value(ScalarValue::try_from_array(&values, i)?)?;
8138            }
8139        }
8140        Ok(())
8141    }
8142}
8143
8144#[allow(
8145    clippy::cast_possible_truncation,
8146    clippy::cast_precision_loss,
8147    clippy::cast_sign_loss,
8148    reason = "percentile ranks are defined by converting bounded [0, 1] floats into sorted indexes"
8149)]
8150fn percentile_cont_indices(percentile: f64, len: usize) -> (usize, usize, f64) {
8151    let index = percentile * ((len - 1) as f64);
8152    let lower = index.floor() as usize;
8153    let upper = index.ceil() as usize;
8154    (lower, upper, index.fract())
8155}
8156
8157#[allow(
8158    clippy::cast_possible_truncation,
8159    clippy::cast_precision_loss,
8160    clippy::cast_sign_loss,
8161    reason = "percentile ranks are defined by converting bounded [0, 1] floats into sorted indexes"
8162)]
8163fn percentile_disc_index(percentile: f64, len: usize) -> usize {
8164    if percentile <= f64::EPSILON {
8165        0
8166    } else {
8167        ((percentile * (len as f64)).ceil() as usize)
8168            .saturating_sub(1)
8169            .min(len - 1)
8170    }
8171}
8172
8173fn is_percentile_numeric_type(dt: &DataType) -> bool {
8174    matches!(
8175        dt,
8176        DataType::Int8
8177            | DataType::Null
8178            | DataType::Int16
8179            | DataType::Int32
8180            | DataType::Int64
8181            | DataType::UInt8
8182            | DataType::UInt16
8183            | DataType::UInt32
8184            | DataType::UInt64
8185            | DataType::Float32
8186            | DataType::Float64
8187    )
8188}
8189
8190/// Three-valued Cypher equality of two scalar values: `Some(true)`,
8191/// `Some(false)`, or `None` (= `null`). Numbers compare across `Int`/`Float`;
8192/// lists and maps compare structurally with the Cypher rules — a length or
8193/// key-set mismatch is `false`, a `null` element with no definitive inequality
8194/// is `null`; otherwise different types are `false`. (ADR 0009)
8195fn cypher_value_eq(l: &ScalarValue, r: &ScalarValue) -> Option<bool> {
8196    if l.is_null() || r.is_null() {
8197        return None;
8198    }
8199    // A heterogeneous-list element (ADR 0011 tagged struct) decodes to its plain
8200    // value, then compares by normal Cypher rules — so a tagged `int = 1` equals a
8201    // native `1`, and a native list equals a tagged list element-by-element.
8202    if let Some(dl) = decode_het(l) {
8203        return cypher_value_eq(&dl, r);
8204    }
8205    if let Some(dr) = decode_het(r) {
8206        return cypher_value_eq(l, &dr);
8207    }
8208    match (l, r) {
8209        (ScalarValue::List(a), ScalarValue::List(b)) => cypher_seq_eq(&a.value(0), &b.value(0)),
8210        (ScalarValue::LargeList(a), ScalarValue::LargeList(b)) => {
8211            cypher_seq_eq(&a.value(0), &b.value(0))
8212        }
8213        // Node/relationship/path structs compare by **identity** — structurally
8214        // equal (a null property counts as equal). Plain maps use three-valued
8215        // structural equality (a null value propagates: `{a: null} = {a: null}`
8216        // is `null`, not `true`).
8217        (ScalarValue::Struct(a), ScalarValue::Struct(b)) => {
8218            if is_entity_struct(a) || is_entity_struct(b) {
8219                Some(l == r)
8220            } else {
8221                cypher_struct_eq(a, b)
8222            }
8223        }
8224        _ if is_numeric_scalar(l) && is_numeric_scalar(r) => {
8225            if let (Some(li), Some(ri)) = (scalar_as_i128(l), scalar_as_i128(r)) {
8226                return Some(li == ri);
8227            }
8228            let (Some(lf), Some(rf)) = (scalar_as_f64(l), scalar_as_f64(r)) else {
8229                return None;
8230            };
8231            Some(!lf.is_nan() && !rf.is_nan() && lf == rf)
8232        }
8233        // Same-typed scalar ⇒ direct equality; different types ⇒ false.
8234        _ if std::mem::discriminant(l) == std::mem::discriminant(r) => Some(l == r),
8235        _ => Some(false),
8236    }
8237}
8238
8239/// Decode a heterogeneous-list element (ADR 0011 tagged struct) to its plain
8240/// `ScalarValue`; `None` for any non-tagged value (so normal values pass through
8241/// `cypher_value_eq` unchanged). A null element → `Null`; a list element
8242/// (`__het_tag == 4`) → a `List` of tagged children; a map element
8243/// (`__het_tag == 5`) → a `Struct` map whose values stay tagged (decoded in turn
8244/// by recursion through `cypher_value_eq`).
8245fn decode_het(s: &ScalarValue) -> Option<ScalarValue> {
8246    use datafusion::arrow::array::{
8247        Array, ArrayRef, BooleanArray, Float64Array, Int8Array, Int64Array, ListArray, StringArray,
8248        StructArray,
8249    };
8250    use datafusion::arrow::datatypes::{Field, Fields};
8251    use std::sync::Arc;
8252    let ScalarValue::Struct(arr) = s else {
8253        return None;
8254    };
8255    arr.column_by_name("__het_tag")?; // not a tagged element → leave as-is
8256    if arr.is_null(0) {
8257        return Some(ScalarValue::Null);
8258    }
8259    let tag = arr
8260        .column_by_name("__het_tag")?
8261        .as_any()
8262        .downcast_ref::<Int8Array>()?
8263        .value(0);
8264    let col = |name: &str| arr.column_by_name(name);
8265    if let Some(value) = col(&format!("__het_value_{tag}")) {
8266        return ScalarValue::try_from_array(value, 0).ok();
8267    }
8268    let v = match tag {
8269        0 => ScalarValue::Int64(Some(
8270            col("__het_int")?
8271                .as_any()
8272                .downcast_ref::<Int64Array>()?
8273                .value(0),
8274        )),
8275        1 => ScalarValue::Float64(Some(
8276            col("__het_float")?
8277                .as_any()
8278                .downcast_ref::<Float64Array>()?
8279                .value(0),
8280        )),
8281        2 => ScalarValue::Utf8(Some(
8282            col("__het_str")?
8283                .as_any()
8284                .downcast_ref::<StringArray>()?
8285                .value(0)
8286                .to_string(),
8287        )),
8288        3 => ScalarValue::Boolean(Some(
8289            col("__het_bool")?
8290                .as_any()
8291                .downcast_ref::<BooleanArray>()?
8292                .value(0),
8293        )),
8294        4 => ScalarValue::try_from_array(col("__het_list")?, 0).ok()?,
8295        5 => {
8296            let entries = col("__het_map")?
8297                .as_any()
8298                .downcast_ref::<ListArray>()?
8299                .value(0);
8300            let es = entries.as_any().downcast_ref::<StructArray>()?;
8301            if es.is_empty() {
8302                return Some(ScalarValue::Struct(Arc::new(
8303                    StructArray::new_empty_fields(1, None),
8304                )));
8305            }
8306            let mkeys = es
8307                .column_by_name("__het_mkey")?
8308                .as_any()
8309                .downcast_ref::<StringArray>()?;
8310            let mvals = es.column_by_name("__het_mval")?;
8311            let mut fields: Vec<Field> = Vec::with_capacity(es.len());
8312            let mut cols: Vec<ArrayRef> = Vec::with_capacity(es.len());
8313            for i in 0..es.len() {
8314                // Keep the value tagged (its length-1 slice); `cypher_value_eq`
8315                // decodes it per-field, mirroring the list (`tag 4`) decode.
8316                let varr = mvals.slice(i, 1);
8317                fields.push(Field::new(mkeys.value(i), varr.data_type().clone(), true));
8318                cols.push(varr);
8319            }
8320            ScalarValue::Struct(Arc::new(
8321                StructArray::try_new(Fields::from(fields), cols, None).ok()?,
8322            ))
8323        }
8324        _ => return None,
8325    };
8326    Some(v)
8327}
8328
8329/// Decode one tagged heterogeneous scalar into its logical value.
8330#[must_use]
8331pub fn decode_het_scalar(value: &ScalarValue) -> Option<ScalarValue> {
8332    decode_het(value)
8333}
8334
8335/// Whether a `Struct` is a node / relationship / path value (whose equality is
8336/// identity-based) rather than a Cypher map (three-valued structural equality),
8337/// detected by the reserved field names those values carry.
8338fn is_entity_struct(s: &datafusion::arrow::array::StructArray) -> bool {
8339    s.fields().iter().any(|f| {
8340        matches!(
8341            f.name().as_str(),
8342            "node_uuid" | "src_uuid" | "dst_uuid" | "nodes" | "relationships" | "labels"
8343        )
8344    })
8345}
8346
8347// ---------------------------------------------------------------------------
8348// cypher_date_component UDF
8349// ---------------------------------------------------------------------------
8350
8351/// `date` component accessor (`Temporal5`): `cypher_date_component(date, name)`
8352/// where `date` is a `Date32` and `name` is the accessor (`year`/`quarter`/
8353/// `month`/`week`/`weekYear`/`day`/`ordinalDay`/`weekDay`/`dayOfQuarter`).
8354/// Returns the component as `Int64`. (ADR 0009 / #920)
8355static CYPHER_DATE_COMPONENT: LazyLock<ScalarUDF> =
8356    LazyLock::new(|| ScalarUDF::new_from_impl(CypherDateComponent::new()));
8357
8358#[derive(Debug, PartialEq, Eq, Hash)]
8359struct CypherDateComponent {
8360    signature: Signature,
8361}
8362
8363impl CypherDateComponent {
8364    fn new() -> Self {
8365        Self {
8366            signature: Signature::any(2, Volatility::Immutable),
8367        }
8368    }
8369}
8370
8371impl ScalarUDFImpl for CypherDateComponent {
8372    fn as_any(&self) -> &dyn Any {
8373        self
8374    }
8375
8376    fn name(&self) -> &'static str {
8377        "cypher_date_component"
8378    }
8379
8380    fn signature(&self) -> &Signature {
8381        &self.signature
8382    }
8383
8384    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
8385        Ok(DataType::Int64)
8386    }
8387
8388    fn invoke_with_args(
8389        &self,
8390        args: ScalarFunctionArgs,
8391    ) -> datafusion::error::Result<ColumnarValue> {
8392        use crate::temporal::date_component;
8393        use datafusion::arrow::array::{Array, Int64Array, StringArray, StructArray};
8394
8395        let rows = args.number_rows;
8396        let dates = args.args[0].to_array(rows)?;
8397        let names = args.args[1].to_array(rows)?;
8398        let d = dates.as_any().downcast_ref::<StructArray>();
8399        let n = names.as_any().downcast_ref::<StringArray>();
8400        let out: Int64Array = (0..rows)
8401            .map(|i| {
8402                let (d, n) = (d?, n?);
8403                if n.is_null(i) {
8404                    return None;
8405                }
8406                date_component(date_struct_value(d, i)?, n.value(i))
8407            })
8408            .collect();
8409        Ok(ColumnarValue::Array(std::sync::Arc::new(out)))
8410    }
8411}
8412
8413// ---------------------------------------------------------------------------
8414// cypher_duration_component UDF
8415// ---------------------------------------------------------------------------
8416
8417/// `duration` component accessor (`d.days`/`d.seconds`/`d.monthsOfQuarter`/…):
8418/// `[interval_value, component_name]` → `Int64`. (#920)
8419static CYPHER_DURATION_COMPONENT: LazyLock<ScalarUDF> =
8420    LazyLock::new(|| ScalarUDF::new_from_impl(CypherDurationComponent::new()));
8421
8422#[derive(Debug, PartialEq, Eq, Hash)]
8423struct CypherDurationComponent {
8424    signature: Signature,
8425}
8426
8427impl CypherDurationComponent {
8428    fn new() -> Self {
8429        Self {
8430            signature: Signature::any(2, Volatility::Immutable),
8431        }
8432    }
8433}
8434
8435impl ScalarUDFImpl for CypherDurationComponent {
8436    fn as_any(&self) -> &dyn Any {
8437        self
8438    }
8439
8440    fn name(&self) -> &'static str {
8441        "cypher_duration_component"
8442    }
8443
8444    fn signature(&self) -> &Signature {
8445        &self.signature
8446    }
8447
8448    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
8449        Ok(DataType::Int64)
8450    }
8451
8452    fn invoke_with_args(
8453        &self,
8454        args: ScalarFunctionArgs,
8455    ) -> datafusion::error::Result<ColumnarValue> {
8456        use crate::temporal::duration_component;
8457        use datafusion::arrow::array::{Array, Int64Array, StringArray, StructArray};
8458
8459        let rows = args.number_rows;
8460        let durs = args.args[0].to_array(rows)?;
8461        let names = args.args[1].to_array(rows)?;
8462        let d = durs.as_any().downcast_ref::<StructArray>();
8463        let n = names.as_any().downcast_ref::<StringArray>();
8464        let out: Int64Array = (0..rows)
8465            .map(|i| {
8466                let (d, n) = (d?, n?);
8467                if d.is_null(i) || n.is_null(i) {
8468                    return None;
8469                }
8470                duration_component(&duration_struct_parts(d, i)?, n.value(i))
8471            })
8472            .collect();
8473        Ok(ColumnarValue::Array(std::sync::Arc::new(out)))
8474    }
8475}
8476
8477// ---------------------------------------------------------------------------
8478// cypher_temporal_component / cypher_temporal_zone_str UDFs (Temporal5 accessors)
8479// ---------------------------------------------------------------------------
8480
8481/// Whether `name` is a valid component accessor for a typed-temporal COLUMN type
8482/// other than `Date32`/duration (which dispatch separately): `localtime`
8483/// (`Time64`), or a `time`/`localdatetime`/`datetime` struct. Gates the accessor
8484/// dispatch so a non-temporal property access still falls through to a column
8485/// lookup. (#1008)
8486fn temporal_accessor_valid(dt: &DataType, name: &str) -> bool {
8487    use crate::temporal::{
8488        is_date_accessor, is_epoch_accessor, is_time_accessor, is_zone_int_accessor,
8489        is_zone_str_accessor,
8490    };
8491    match dt {
8492        DataType::Time64(_) => is_time_accessor(name),
8493        DataType::Struct(_) if is_time_struct(dt) => {
8494            is_time_accessor(name) || is_zone_int_accessor(name) || is_zone_str_accessor(name)
8495        }
8496        DataType::Struct(_) if is_localdatetime_struct(dt) => {
8497            is_date_accessor(name) || is_time_accessor(name)
8498        }
8499        DataType::Struct(_) if is_datetime_struct(dt) => {
8500            is_date_accessor(name)
8501                || is_time_accessor(name)
8502                || is_zone_int_accessor(name)
8503                || is_zone_str_accessor(name)
8504                || is_epoch_accessor(name)
8505        }
8506        _ => false,
8507    }
8508}
8509
8510/// `Temporal5` INT component accessor (`d.hour`/`d.year`/`d.offsetSeconds`/
8511/// `d.epochMillis`/…): `[value, name]` → `Int64`. `value` is a typed `localtime`
8512/// (`Time64`) or `time`/`localdatetime`/`datetime` struct; the UDF inspects the
8513/// Arrow type to extract the relevant field. (#1008)
8514static CYPHER_TEMPORAL_COMPONENT: LazyLock<ScalarUDF> =
8515    LazyLock::new(|| ScalarUDF::new_from_impl(CypherTemporalComponent::new()));
8516
8517#[derive(Debug, PartialEq, Eq, Hash)]
8518struct CypherTemporalComponent {
8519    signature: Signature,
8520}
8521
8522impl CypherTemporalComponent {
8523    fn new() -> Self {
8524        Self {
8525            signature: Signature::any(2, Volatility::Immutable),
8526        }
8527    }
8528}
8529
8530impl ScalarUDFImpl for CypherTemporalComponent {
8531    fn as_any(&self) -> &dyn Any {
8532        self
8533    }
8534
8535    fn name(&self) -> &'static str {
8536        "cypher_temporal_component"
8537    }
8538
8539    fn signature(&self) -> &Signature {
8540        &self.signature
8541    }
8542
8543    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
8544        Ok(DataType::Int64)
8545    }
8546
8547    fn invoke_with_args(
8548        &self,
8549        args: ScalarFunctionArgs,
8550    ) -> datafusion::error::Result<ColumnarValue> {
8551        use crate::temporal::{
8552            date_component, epoch_component, is_date_accessor, is_time_accessor,
8553            is_zone_int_accessor, time_component, zone_int_component,
8554        };
8555        use datafusion::arrow::array::{
8556            Array, Int64Array, StringArray, StructArray, Time64NanosecondArray,
8557        };
8558        use datafusion::arrow::datatypes::TimeUnit;
8559
8560        let rows = args.number_rows;
8561        let vals = args.args[0].to_array(rows)?;
8562        let names = args.args[1].to_array(rows)?;
8563        let n = names.as_any().downcast_ref::<StringArray>();
8564        let out: Int64Array = (0..rows)
8565            .map(|i| {
8566                let n = n?;
8567                if vals.is_null(i) || n.is_null(i) {
8568                    return None;
8569                }
8570                let name = n.value(i);
8571                match vals.data_type() {
8572                    DataType::Time64(TimeUnit::Nanosecond) => {
8573                        let v = vals.as_any().downcast_ref::<Time64NanosecondArray>()?;
8574                        time_component(v.value(i), name)
8575                    }
8576                    DataType::Struct(_) => {
8577                        let s = vals.as_any().downcast_ref::<StructArray>()?;
8578                        if is_time_struct(vals.data_type()) {
8579                            let (nanos, offset) = time_struct_parts(s, i)?;
8580                            if is_zone_int_accessor(name) {
8581                                zone_int_component(offset, name)
8582                            } else {
8583                                time_component(nanos, name)
8584                            }
8585                        } else if is_localdatetime_struct(vals.data_type()) {
8586                            let (days, nanos) = localdatetime_struct_parts(s, i)?;
8587                            if is_date_accessor(name) {
8588                                date_component(days, name)
8589                            } else {
8590                                time_component(nanos, name)
8591                            }
8592                        } else if is_datetime_struct(vals.data_type()) {
8593                            let (days, nanos, offset, _) = datetime_struct_parts(s, i)?;
8594                            if is_date_accessor(name) {
8595                                date_component(days, name)
8596                            } else if is_time_accessor(name) {
8597                                time_component(nanos, name)
8598                            } else if is_zone_int_accessor(name) {
8599                                zone_int_component(offset, name)
8600                            } else {
8601                                epoch_component(days, nanos, offset, name)
8602                            }
8603                        } else {
8604                            None
8605                        }
8606                    }
8607                    _ => None,
8608                }
8609            })
8610            .collect();
8611        Ok(ColumnarValue::Array(std::sync::Arc::new(out)))
8612    }
8613}
8614
8615/// `Temporal5` STRING component accessor (`d.timezone`/`d.offset`): `[value,
8616/// name]` → `Utf8`. `value` is a typed `time` or `datetime` struct. (#1008)
8617static CYPHER_TEMPORAL_ZONE_STR: LazyLock<ScalarUDF> =
8618    LazyLock::new(|| ScalarUDF::new_from_impl(CypherTemporalZoneStr::new()));
8619
8620#[derive(Debug, PartialEq, Eq, Hash)]
8621struct CypherTemporalZoneStr {
8622    signature: Signature,
8623}
8624
8625impl CypherTemporalZoneStr {
8626    fn new() -> Self {
8627        Self {
8628            signature: Signature::any(2, Volatility::Immutable),
8629        }
8630    }
8631}
8632
8633impl ScalarUDFImpl for CypherTemporalZoneStr {
8634    fn as_any(&self) -> &dyn Any {
8635        self
8636    }
8637
8638    fn name(&self) -> &'static str {
8639        "cypher_temporal_zone_str"
8640    }
8641
8642    fn signature(&self) -> &Signature {
8643        &self.signature
8644    }
8645
8646    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
8647        Ok(DataType::Utf8)
8648    }
8649
8650    fn invoke_with_args(
8651        &self,
8652        args: ScalarFunctionArgs,
8653    ) -> datafusion::error::Result<ColumnarValue> {
8654        use crate::temporal::zone_str_component;
8655        use datafusion::arrow::array::{Array, StringArray, StructArray};
8656
8657        let rows = args.number_rows;
8658        let vals = args.args[0].to_array(rows)?;
8659        let names = args.args[1].to_array(rows)?;
8660        let n = names.as_any().downcast_ref::<StringArray>();
8661        let out: StringArray = (0..rows)
8662            .map(|i| {
8663                let n = n?;
8664                if vals.is_null(i) || n.is_null(i) {
8665                    return None;
8666                }
8667                let name = n.value(i);
8668                let s = vals.as_any().downcast_ref::<StructArray>()?;
8669                if is_time_struct(vals.data_type()) {
8670                    let (_, offset) = time_struct_parts(s, i)?;
8671                    zone_str_component(offset, None, name)
8672                } else if is_datetime_struct(vals.data_type()) {
8673                    let (_, _, offset, zone) = datetime_struct_parts(s, i)?;
8674                    zone_str_component(offset, zone.as_deref(), name)
8675                } else {
8676                    None
8677                }
8678            })
8679            .collect();
8680        Ok(ColumnarValue::Array(std::sync::Arc::new(out)))
8681    }
8682}
8683
8684// ---------------------------------------------------------------------------
8685// cypher_duration_between UDF
8686// ---------------------------------------------------------------------------
8687
8688/// `duration.between(a, b)` / `inMonths` / `inDays` / `inSeconds` (`Temporal10`):
8689/// `[a, b, mode]` → `Interval(MonthDayNano)`. `a`/`b` are typed temporals
8690/// (`Date32`/`Time64`/`localdatetime`/`time`/`datetime` struct); `mode` selects
8691/// the family member. (#920)
8692static CYPHER_DURATION_BETWEEN: LazyLock<ScalarUDF> =
8693    LazyLock::new(|| ScalarUDF::new_from_impl(CypherDurationBetween::new()));
8694
8695#[derive(Debug, PartialEq, Eq, Hash)]
8696struct CypherDurationBetween {
8697    signature: Signature,
8698}
8699
8700impl CypherDurationBetween {
8701    fn new() -> Self {
8702        Self {
8703            signature: Signature::any(3, Volatility::Immutable),
8704        }
8705    }
8706}
8707
8708/// Extract a [`BetweenOperand`](crate::temporal::BetweenOperand) — `(date, nanos,
8709/// offset)` — from a typed temporal array at row `i`.
8710fn between_operand(
8711    arr: &datafusion::arrow::array::ArrayRef,
8712    i: usize,
8713) -> Option<crate::temporal::BetweenOperand> {
8714    use datafusion::arrow::array::{Array, StructArray, Time64NanosecondArray};
8715    use datafusion::arrow::datatypes::TimeUnit;
8716    if arr.is_null(i) {
8717        return None;
8718    }
8719    match arr.data_type() {
8720        DataType::Time64(TimeUnit::Nanosecond) => Some((
8721            None,
8722            arr.as_any()
8723                .downcast_ref::<Time64NanosecondArray>()?
8724                .value(i),
8725            None,
8726            None,
8727        )),
8728        DataType::Struct(_) => {
8729            let s = arr.as_any().downcast_ref::<StructArray>()?;
8730            if is_date_struct(arr.data_type()) {
8731                Some((Some(date_struct_value(s, i)?), 0, None, None))
8732            } else if is_datetime_struct(arr.data_type()) {
8733                // Keep the named zone (if any) so DST is re-resolvable across a
8734                // span (#1007).
8735                let (days, nanos, offset, zone) = datetime_struct_parts(s, i)?;
8736                Some((Some(days), nanos, Some(offset), zone))
8737            } else if is_time_struct(arr.data_type()) {
8738                let (nanos, offset) = time_struct_parts(s, i)?;
8739                Some((None, nanos, Some(offset), None))
8740            } else {
8741                let (days, nanos) = localdatetime_struct_parts(s, i)?;
8742                Some((Some(days), nanos, None, None))
8743            }
8744        }
8745        _ => None,
8746    }
8747}
8748
8749impl ScalarUDFImpl for CypherDurationBetween {
8750    fn as_any(&self) -> &dyn Any {
8751        self
8752    }
8753
8754    fn name(&self) -> &'static str {
8755        "cypher_duration_between"
8756    }
8757
8758    fn signature(&self) -> &Signature {
8759        &self.signature
8760    }
8761
8762    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
8763        Ok(DataType::Struct(
8764            graphforge_storage::schemas::duration_struct_fields(),
8765        ))
8766    }
8767
8768    fn invoke_with_args(
8769        &self,
8770        args: ScalarFunctionArgs,
8771    ) -> datafusion::error::Result<ColumnarValue> {
8772        use crate::temporal::{BetweenMode, duration_between};
8773        use datafusion::arrow::array::{Array, StringArray};
8774        use datafusion::arrow::compute::cast;
8775        use datafusion::error::DataFusionError;
8776
8777        let rows = args.number_rows;
8778        let cols: Vec<_> = args
8779            .args
8780            .iter()
8781            .map(|a| a.to_array(rows))
8782            .collect::<datafusion::error::Result<_>>()?;
8783        let mode_arr = cast(&cols[2], &DataType::Utf8).map_err(DataFusionError::from)?;
8784        let modes = mode_arr.as_any().downcast_ref::<StringArray>();
8785
8786        let parts: Vec<Option<crate::temporal::DurationValue>> = (0..rows)
8787            .map(|i| {
8788                let m = modes?;
8789                if m.is_null(i) {
8790                    return None;
8791                }
8792                let mode = match m.value(i) {
8793                    "duration.between" => BetweenMode::Between,
8794                    "duration.inmonths" => BetweenMode::Months,
8795                    "duration.indays" => BetweenMode::Days,
8796                    "duration.inseconds" => BetweenMode::Seconds,
8797                    _ => return None,
8798                };
8799                let a = between_operand(&cols[0], i)?;
8800                let b = between_operand(&cols[1], i)?;
8801                duration_between(&a, &b, mode)
8802            })
8803            .collect();
8804        Ok(ColumnarValue::Array(std::sync::Arc::new(
8805            build_duration_struct(&parts),
8806        )))
8807    }
8808}
8809
8810// ---------------------------------------------------------------------------
8811// cypher_temporal_arith UDF (temporal ± duration)
8812// ---------------------------------------------------------------------------
8813
8814/// `temporal ± duration` (`Temporal8`): `[temporal, duration_struct, sign]` →
8815/// the SAME type as the temporal operand (`return_type` echoes `arg_types[0]`).
8816/// `sign` is `+1` (add) or `-1` (subtract). Dispatches on the temporal type:
8817/// date adds months+days (sub-day time → whole days); localtime/time wrap the
8818/// time-of-day mod 24h; localdatetime/datetime add months+days+time carrying
8819/// overflow (zone offset / named zone preserved). (#920)
8820static CYPHER_TEMPORAL_ARITH: LazyLock<ScalarUDF> =
8821    LazyLock::new(|| ScalarUDF::new_from_impl(CypherTemporalArith::new()));
8822
8823#[derive(Debug, PartialEq, Eq, Hash)]
8824struct CypherTemporalArith {
8825    signature: Signature,
8826}
8827
8828impl CypherTemporalArith {
8829    fn new() -> Self {
8830        Self {
8831            signature: Signature::any(3, Volatility::Immutable),
8832        }
8833    }
8834}
8835
8836impl ScalarUDFImpl for CypherTemporalArith {
8837    fn as_any(&self) -> &dyn Any {
8838        self
8839    }
8840
8841    fn name(&self) -> &'static str {
8842        "cypher_temporal_arith"
8843    }
8844
8845    fn signature(&self) -> &Signature {
8846        &self.signature
8847    }
8848
8849    fn return_type(&self, arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
8850        // The result is the same temporal type as the first (temporal) operand.
8851        Ok(arg_types.first().cloned().unwrap_or(DataType::Null))
8852    }
8853
8854    #[allow(
8855        clippy::too_many_lines,
8856        reason = "one cohesive per-temporal-type dispatch (date/localtime/time/\
8857                  localdatetime/datetime) applying a signed duration"
8858    )]
8859    fn invoke_with_args(
8860        &self,
8861        args: ScalarFunctionArgs,
8862    ) -> datafusion::error::Result<ColumnarValue> {
8863        use crate::temporal::{
8864            date_plus_duration, datetime_plus_duration, localtime_plus_duration,
8865        };
8866        use datafusion::arrow::array::{
8867            Array, ArrayRef, Int64Array, StructArray, Time64NanosecondArray,
8868        };
8869        use datafusion::arrow::datatypes::TimeUnit;
8870
8871        let rows = args.number_rows;
8872        let cols: Vec<_> = args
8873            .args
8874            .iter()
8875            .map(|a| a.to_array(rows))
8876            .collect::<datafusion::error::Result<_>>()?;
8877        let temporal = &cols[0];
8878        let dur = cols[1].as_any().downcast_ref::<StructArray>();
8879        let signs = cols[2].as_any().downcast_ref::<Int64Array>();
8880
8881        // The signed [`DurationValue`] for row `i` (None if either operand is null).
8882        let signed = |i: usize| -> Option<crate::temporal::DurationValue> {
8883            let (d, sg) = (dur?, signs?);
8884            if d.is_null(i) || sg.is_null(i) {
8885                return None;
8886            }
8887            let dv = duration_struct_parts(d, i)?;
8888            Some(if sg.value(i) < 0 {
8889                crate::temporal::DurationValue {
8890                    months: -dv.months,
8891                    days: -dv.days,
8892                    seconds: -dv.seconds,
8893                    nanos: -dv.nanos,
8894                }
8895            } else {
8896                dv
8897            })
8898        };
8899        // Total signed sub-day nanoseconds of a duration (time-of-day arithmetic).
8900        // Sub-day nanoseconds of a duration for time-of-day (mod-24h) arithmetic.
8901        // Reduce `seconds` mod a day FIRST so a huge duration can't overflow the
8902        // `* 1e9` (the result is used mod a day anyway, so this is exact). (#1011)
8903        let sub_day_nanos = |d: &crate::temporal::DurationValue| {
8904            d.seconds.rem_euclid(86_400) * 1_000_000_000 + d.nanos
8905        };
8906
8907        let out: ArrayRef = match temporal.data_type() {
8908            DataType::Struct(_) if is_date_struct(temporal.data_type()) => {
8909                let t = temporal.as_any().downcast_ref::<StructArray>();
8910                let days: Vec<Option<i64>> = (0..rows)
8911                    .map(|i| {
8912                        let t = t?;
8913                        let d = date_struct_value(t, i)?;
8914                        let dv = signed(i)?;
8915                        Some(date_plus_duration(d, &dv))
8916                    })
8917                    .collect();
8918                std::sync::Arc::new(build_date_struct(&days))
8919            }
8920            DataType::Time64(TimeUnit::Nanosecond) => {
8921                let t = temporal.as_any().downcast_ref::<Time64NanosecondArray>();
8922                let a: Time64NanosecondArray = (0..rows)
8923                    .map(|i| {
8924                        let t = t?;
8925                        if t.is_null(i) {
8926                            return None;
8927                        }
8928                        let dv = signed(i)?;
8929                        Some(localtime_plus_duration(t.value(i), sub_day_nanos(&dv)))
8930                    })
8931                    .collect();
8932                std::sync::Arc::new(a)
8933            }
8934            DataType::Struct(_) if is_time_struct(temporal.data_type()) => {
8935                let s = temporal.as_any().downcast_ref::<StructArray>();
8936                let parts: Vec<Option<(i64, i32)>> = (0..rows)
8937                    .map(|i| {
8938                        let s = s?;
8939                        let (nanos, offset) = time_struct_parts(s, i)?;
8940                        let dv = signed(i)?;
8941                        Some((localtime_plus_duration(nanos, sub_day_nanos(&dv)), offset))
8942                    })
8943                    .collect();
8944                std::sync::Arc::new(build_time_struct(&parts))
8945            }
8946            DataType::Struct(_) if is_datetime_struct(temporal.data_type()) => {
8947                let s = temporal.as_any().downcast_ref::<StructArray>();
8948                let parts: Vec<DateTimeRow> = (0..rows)
8949                    .map(|i| {
8950                        let s = s?;
8951                        let (days, nanos, offset, zone) = datetime_struct_parts(s, i)?;
8952                        let dv = signed(i)?;
8953                        let (date, no) = datetime_plus_duration(days, nanos, &dv);
8954                        Some((date, no, offset, zone))
8955                    })
8956                    .collect();
8957                std::sync::Arc::new(build_datetime_struct(&parts))
8958            }
8959            // localdatetime struct (date + time, no zone).
8960            DataType::Struct(_) if is_localdatetime_struct(temporal.data_type()) => {
8961                let s = temporal.as_any().downcast_ref::<StructArray>();
8962                let parts: Vec<Option<(i64, i64)>> = (0..rows)
8963                    .map(|i| {
8964                        let s = s?;
8965                        let (days, nanos) = localdatetime_struct_parts(s, i)?;
8966                        let dv = signed(i)?;
8967                        let (date, no) = datetime_plus_duration(days, nanos, &dv);
8968                        Some((date, no))
8969                    })
8970                    .collect();
8971                std::sync::Arc::new(build_localdatetime_struct(&parts))
8972            }
8973            other => {
8974                return Err(datafusion::error::DataFusionError::Internal(format!(
8975                    "cypher_temporal_arith: left operand is not a temporal value ({other:?})"
8976                )));
8977            }
8978        };
8979        Ok(ColumnarValue::Array(out))
8980    }
8981}
8982
8983// ---------------------------------------------------------------------------
8984// cypher_duration_add UDF (duration ± duration)
8985// ---------------------------------------------------------------------------
8986
8987/// Runtime `duration(<string-expr>)` (`Temporal6`): parse an ISO-8601 duration
8988/// string per row into a `Struct{months, days, seconds, nanos}` (null on unparseable or
8989/// null input), the inverse of the `toString` render. Used when the argument is
8990/// not a constant (e.g. `duration(toString(d))`). (#920)
8991static CYPHER_DURATION_PARSE: LazyLock<ScalarUDF> =
8992    LazyLock::new(|| ScalarUDF::new_from_impl(CypherDurationParse::new()));
8993
8994#[derive(Debug, PartialEq, Eq, Hash)]
8995struct CypherDurationParse {
8996    signature: Signature,
8997}
8998
8999impl CypherDurationParse {
9000    fn new() -> Self {
9001        Self {
9002            signature: Signature::any(1, Volatility::Immutable),
9003        }
9004    }
9005}
9006
9007impl ScalarUDFImpl for CypherDurationParse {
9008    fn as_any(&self) -> &dyn Any {
9009        self
9010    }
9011    fn name(&self) -> &'static str {
9012        "cypher_duration_parse"
9013    }
9014    fn signature(&self) -> &Signature {
9015        &self.signature
9016    }
9017    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
9018        Ok(DataType::Struct(
9019            graphforge_storage::schemas::duration_struct_fields(),
9020        ))
9021    }
9022
9023    fn invoke_with_args(
9024        &self,
9025        args: ScalarFunctionArgs,
9026    ) -> datafusion::error::Result<ColumnarValue> {
9027        use datafusion::arrow::array::{Array, StringArray};
9028        use datafusion::arrow::compute::cast;
9029
9030        let rows = args.number_rows;
9031        let arr = cast(&args.args[0].to_array(rows)?, &DataType::Utf8)?;
9032        let s = arr.as_any().downcast_ref::<StringArray>();
9033        let parts: Vec<Option<crate::temporal::DurationValue>> = (0..rows)
9034            .map(|i| {
9035                let s = s?;
9036                if s.is_null(i) {
9037                    return Option::None;
9038                }
9039                crate::temporal::duration_value_from_str(s.value(i))
9040            })
9041            .collect();
9042        Ok(ColumnarValue::Array(std::sync::Arc::new(
9043            build_duration_struct(&parts),
9044        )))
9045    }
9046}
9047
9048/// `duration ± duration` (`Temporal8`): `[a, b, sign]` → component-wise
9049/// `(a.months + sign·b.months, …days, …nanos)` as a duration struct. (#920)
9050static CYPHER_DURATION_ADD: LazyLock<ScalarUDF> =
9051    LazyLock::new(|| ScalarUDF::new_from_impl(CypherDurationAdd::new()));
9052
9053#[derive(Debug, PartialEq, Eq, Hash)]
9054struct CypherDurationAdd {
9055    signature: Signature,
9056}
9057
9058impl CypherDurationAdd {
9059    fn new() -> Self {
9060        Self {
9061            signature: Signature::any(3, Volatility::Immutable),
9062        }
9063    }
9064}
9065
9066impl ScalarUDFImpl for CypherDurationAdd {
9067    fn as_any(&self) -> &dyn Any {
9068        self
9069    }
9070
9071    fn name(&self) -> &'static str {
9072        "cypher_duration_add"
9073    }
9074
9075    fn signature(&self) -> &Signature {
9076        &self.signature
9077    }
9078
9079    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
9080        Ok(DataType::Struct(
9081            graphforge_storage::schemas::duration_struct_fields(),
9082        ))
9083    }
9084
9085    fn invoke_with_args(
9086        &self,
9087        args: ScalarFunctionArgs,
9088    ) -> datafusion::error::Result<ColumnarValue> {
9089        use datafusion::arrow::array::{Array, Int64Array, StructArray};
9090
9091        let rows = args.number_rows;
9092        let cols: Vec<_> = args
9093            .args
9094            .iter()
9095            .map(|a| a.to_array(rows))
9096            .collect::<datafusion::error::Result<_>>()?;
9097        let a = cols[0].as_any().downcast_ref::<StructArray>();
9098        let b = cols[1].as_any().downcast_ref::<StructArray>();
9099        let signs = cols[2].as_any().downcast_ref::<Int64Array>();
9100
9101        let parts: Vec<Option<crate::temporal::DurationValue>> = (0..rows)
9102            .map(|i| {
9103                let (a, b, sg) = (a?, b?, signs?);
9104                let av = duration_struct_parts(a, i)?;
9105                let bv = duration_struct_parts(b, i)?;
9106                let s: i64 = if sg.is_null(i) || sg.value(i) >= 0 {
9107                    1
9108                } else {
9109                    -1
9110                };
9111                // Add componentwise and normalise the nanos carry into seconds —
9112                // WITHOUT forming a `seconds * 1e9` total (which would overflow
9113                // i64 for combined sub-day spans > ~292 years, defeating the
9114                // widened `seconds` field). `nanos` sums into (-1e9, 2e9), so
9115                // div/rem_euclid re-canonicalise to a non-negative `[0, 1e9)`. (#1011)
9116                let nanos_sum = av.nanos + s * bv.nanos;
9117                let seconds = av.seconds + s * bv.seconds + nanos_sum.div_euclid(1_000_000_000);
9118                Some(crate::temporal::DurationValue {
9119                    months: av.months + s * bv.months,
9120                    days: av.days + s * bv.days,
9121                    seconds,
9122                    nanos: nanos_sum.rem_euclid(1_000_000_000),
9123                })
9124            })
9125            .collect();
9126        Ok(ColumnarValue::Array(std::sync::Arc::new(
9127            build_duration_struct(&parts),
9128        )))
9129    }
9130}
9131
9132/// `duration * number` / `duration / number` (#920 Temporal8 [7]). Args are
9133/// `[duration_struct, number, is_div]`; scales each component and re-normalises
9134/// via [`crate::temporal::scale_duration`] (fractional months → days → time).
9135static CYPHER_DURATION_SCALE: LazyLock<ScalarUDF> =
9136    LazyLock::new(|| ScalarUDF::new_from_impl(CypherDurationScale::new()));
9137
9138#[derive(Debug, PartialEq, Eq, Hash)]
9139struct CypherDurationScale {
9140    signature: Signature,
9141}
9142
9143impl CypherDurationScale {
9144    fn new() -> Self {
9145        Self {
9146            signature: Signature::any(3, Volatility::Immutable),
9147        }
9148    }
9149}
9150
9151impl ScalarUDFImpl for CypherDurationScale {
9152    fn as_any(&self) -> &dyn Any {
9153        self
9154    }
9155    fn name(&self) -> &'static str {
9156        "cypher_duration_scale"
9157    }
9158    fn signature(&self) -> &Signature {
9159        &self.signature
9160    }
9161    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
9162        Ok(DataType::Struct(
9163            graphforge_storage::schemas::duration_struct_fields(),
9164        ))
9165    }
9166
9167    fn invoke_with_args(
9168        &self,
9169        args: ScalarFunctionArgs,
9170    ) -> datafusion::error::Result<ColumnarValue> {
9171        use datafusion::arrow::array::{Array, BooleanArray, Float64Array, StructArray};
9172        use datafusion::arrow::compute::cast;
9173
9174        let rows = args.number_rows;
9175        let cols: Vec<_> = args
9176            .args
9177            .iter()
9178            .map(|a| a.to_array(rows))
9179            .collect::<datafusion::error::Result<_>>()?;
9180        let dur = cols[0].as_any().downcast_ref::<StructArray>();
9181        // The numeric factor may arrive as any int/float width — cast to f64.
9182        let num = cast(&cols[1], &DataType::Float64)?;
9183        let num = num.as_any().downcast_ref::<Float64Array>();
9184        let is_div = cols[2].as_any().downcast_ref::<BooleanArray>();
9185
9186        let parts: Vec<Option<crate::temporal::DurationValue>> = (0..rows)
9187            .map(|i| {
9188                let (dur, num, is_div) = (dur?, num?, is_div?);
9189                if num.is_null(i) {
9190                    return Option::None; // duration ∘ null = null
9191                }
9192                let dv = duration_struct_parts(dur, i)?;
9193                let divide = !is_div.is_null(i) && is_div.value(i);
9194                Some(crate::temporal::scale_duration(&dv, num.value(i), divide))
9195            })
9196            .collect();
9197        Ok(ColumnarValue::Array(std::sync::Arc::new(
9198            build_duration_struct(&parts),
9199        )))
9200    }
9201}
9202
9203// ---------------------------------------------------------------------------
9204// cypher_quantifier UDF (all/any/none/single)
9205// ---------------------------------------------------------------------------
9206
9207/// Fold per-element predicate results with three-valued logic per quantifier
9208/// (#955). `n` is the element count; `bools[j]` is the predicate on element `j`
9209/// (null = unknown). Empty list: all/none → true, any/single → false.
9210#[allow(
9211    clippy::match_same_arms,
9212    reason = "per-quantifier arms read clearest grouped by kind, even where two \
9213              fallback bodies coincide (all/none both default true)"
9214)]
9215fn reduce_quantifier(
9216    kind: graphforge_ir::QuantifierKind,
9217    bools: &datafusion::arrow::array::BooleanArray,
9218    n: usize,
9219) -> Option<bool> {
9220    use datafusion::arrow::array::Array;
9221    use graphforge_ir::QuantifierKind as Q;
9222    let (mut any_true, mut any_false, mut any_null, mut count_true) = (false, false, false, 0u32);
9223    for j in 0..n {
9224        if bools.is_null(j) {
9225            any_null = true;
9226        } else if bools.value(j) {
9227            any_true = true;
9228            count_true += 1;
9229        } else {
9230            any_false = true;
9231        }
9232    }
9233    // Three-valued logic: an unknown (null) element only matters when no
9234    // definitive element already settles the result.
9235    match kind {
9236        Q::All if any_false => Some(false),
9237        Q::All => (!any_null).then_some(true),
9238        Q::Any if any_true => Some(true),
9239        Q::Any => (!any_null).then_some(false),
9240        Q::None if any_true => Some(false),
9241        Q::None => (!any_null).then_some(true),
9242        Q::Single if count_true > 1 => Some(false),
9243        Q::Single => (!any_null).then_some(count_true == 1),
9244    }
9245}
9246
9247/// `all/any/none/single(loop_var IN list WHERE predicate)` (#955). Holds the
9248/// predicate as a logical `Expr` over a synthetic element column + the outer
9249/// columns it references; at invoke time it builds a per-element `RecordBatch`,
9250/// evaluates the predicate, and folds with [`reduce_quantifier`]. Returns `Boolean`.
9251#[derive(Debug, PartialEq, Eq, Hash)]
9252struct CypherQuantifier {
9253    kind: graphforge_ir::QuantifierKind,
9254    predicate: DfExpr,
9255    elem_name: String,
9256    outer_names: Vec<String>,
9257    signature: Signature,
9258}
9259
9260impl CypherQuantifier {
9261    fn new(
9262        kind: graphforge_ir::QuantifierKind,
9263        predicate: DfExpr,
9264        elem_name: String,
9265        outer_names: Vec<String>,
9266    ) -> Self {
9267        let arity = 1 + outer_names.len();
9268        Self {
9269            kind,
9270            predicate,
9271            elem_name,
9272            outer_names,
9273            // The predicate is embedded in the UDF rather than represented as
9274            // a call argument and may itself be volatile.
9275            signature: Signature::any(arity, Volatility::Volatile),
9276        }
9277    }
9278}
9279
9280/// A quantifier whose predicate is statically true, false, or null. The input
9281/// list is still evaluated eagerly by DataFusion, but no per-element predicate
9282/// batch is needed; only list nullability and cardinality affect the result.
9283#[derive(Debug, PartialEq, Eq, Hash)]
9284struct CypherInvariantQuantifier {
9285    kind: graphforge_ir::QuantifierKind,
9286    predicate: Option<bool>,
9287    signature: Signature,
9288}
9289
9290#[cfg(test)]
9291static INVARIANT_QUANTIFIER_ROWS: std::sync::atomic::AtomicUsize =
9292    std::sync::atomic::AtomicUsize::new(0);
9293
9294impl CypherInvariantQuantifier {
9295    fn new(kind: graphforge_ir::QuantifierKind, predicate: Option<bool>) -> Self {
9296        Self {
9297            kind,
9298            predicate,
9299            signature: Signature::any(1, Volatility::Immutable),
9300        }
9301    }
9302}
9303
9304fn reduce_invariant_quantifier(
9305    kind: graphforge_ir::QuantifierKind,
9306    predicate: Option<bool>,
9307    len: usize,
9308) -> Option<bool> {
9309    use graphforge_ir::QuantifierKind as Q;
9310    if len == 0 {
9311        return Some(matches!(kind, Q::All | Q::None));
9312    }
9313    match (kind, predicate) {
9314        (_, None) => None,
9315        (Q::All | Q::Any, Some(value)) => Some(value),
9316        (Q::None, Some(value)) => Some(!value),
9317        (Q::Single, Some(true)) => Some(len == 1),
9318        (Q::Single, Some(false)) => Some(false),
9319    }
9320}
9321
9322impl ScalarUDFImpl for CypherInvariantQuantifier {
9323    fn as_any(&self) -> &dyn Any {
9324        self
9325    }
9326    fn name(&self) -> &'static str {
9327        "cypher_invariant_quantifier"
9328    }
9329    fn signature(&self) -> &Signature {
9330        &self.signature
9331    }
9332    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
9333        Ok(DataType::Boolean)
9334    }
9335    fn invoke_with_args(
9336        &self,
9337        args: ScalarFunctionArgs,
9338    ) -> datafusion::error::Result<ColumnarValue> {
9339        use datafusion::arrow::array::{Array, BooleanArray, ListArray};
9340        use datafusion::error::DataFusionError;
9341
9342        let rows = args.number_rows;
9343        #[cfg(test)]
9344        INVARIANT_QUANTIFIER_ROWS.fetch_add(rows, std::sync::atomic::Ordering::SeqCst);
9345        let list = args.args[0].to_array(rows)?;
9346        let list = list.as_any().downcast_ref::<ListArray>().ok_or_else(|| {
9347            DataFusionError::Internal("cypher_invariant_quantifier: argument is not a list".into())
9348        })?;
9349        let values = (0..rows).map(|row| {
9350            if list.is_null(row) {
9351                None
9352            } else {
9353                reduce_invariant_quantifier(self.kind, self.predicate, list.value(row).len())
9354            }
9355        });
9356        Ok(ColumnarValue::Array(Arc::new(
9357            values.collect::<BooleanArray>(),
9358        )))
9359    }
9360}
9361
9362impl ScalarUDFImpl for CypherQuantifier {
9363    fn as_any(&self) -> &dyn Any {
9364        self
9365    }
9366    fn name(&self) -> &'static str {
9367        "cypher_quantifier"
9368    }
9369    fn signature(&self) -> &Signature {
9370        &self.signature
9371    }
9372    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
9373        Ok(DataType::Boolean)
9374    }
9375
9376    fn invoke_with_args(
9377        &self,
9378        args: ScalarFunctionArgs,
9379    ) -> datafusion::error::Result<ColumnarValue> {
9380        use datafusion::arrow::array::{Array, ArrayRef, BooleanArray, ListArray, RecordBatch};
9381        use datafusion::arrow::datatypes::{Field, Schema};
9382        use datafusion::common::DFSchema;
9383        use datafusion::error::DataFusionError;
9384        use datafusion::logical_expr::execution_props::ExecutionProps;
9385        use datafusion::physical_expr::create_physical_expr;
9386        use std::sync::Arc;
9387
9388        let rows = args.number_rows;
9389        let cols: Vec<ArrayRef> = args
9390            .args
9391            .iter()
9392            .map(|a| a.to_array(rows))
9393            .collect::<datafusion::error::Result<_>>()?;
9394        let list = cols[0]
9395            .as_any()
9396            .downcast_ref::<ListArray>()
9397            .ok_or_else(|| {
9398                DataFusionError::Internal("cypher_quantifier: first argument is not a list".into())
9399            })?;
9400        let elem_type = match list.data_type() {
9401            DataType::List(f) | DataType::LargeList(f) => f.data_type().clone(),
9402            _ => DataType::Null,
9403        };
9404
9405        // Synthetic schema: the element column + each referenced outer column.
9406        let mut fields = vec![Field::new(&self.elem_name, elem_type, true)];
9407        for (i, name) in self.outer_names.iter().enumerate() {
9408            fields.push(Field::new(name, cols[i + 1].data_type().clone(), true));
9409        }
9410        let schema = Arc::new(Schema::new(fields));
9411        let df_schema = DFSchema::try_from(schema.as_ref().clone())?;
9412        // Deferred build: a predicate that cannot plan over the element type
9413        // (e.g. `x.a = 2` against the Int64 default of a statically-empty
9414        // list) only errors if a non-empty row actually evaluates it — every
9415        // empty row short-circuits to the fold identity below.
9416        let phys = create_physical_expr(&self.predicate, &df_schema, &ExecutionProps::new());
9417
9418        let mut out = BooleanArray::builder(rows);
9419        for row in 0..rows {
9420            if list.is_null(row) {
9421                out.append_null(); // a quantifier over a null list is null
9422                continue;
9423            }
9424            let elems = list.value(row);
9425            let n = elems.len();
9426            if n == 0 {
9427                // The n = 0 fold yields the identity (`all`/`none` → true,
9428                // `any`/`single` → false) without running the predicate, whose
9429                // type over an empty list is irrelevant.
9430                out.append_option(reduce_quantifier(self.kind, &BooleanArray::new_null(0), 0));
9431                continue;
9432            }
9433            let phys = phys
9434                .as_ref()
9435                .map_err(|e| DataFusionError::Execution(e.to_string()))?;
9436            let verdict = (|| {
9437                let mut batch_cols: Vec<ArrayRef> = Vec::with_capacity(1 + self.outer_names.len());
9438                batch_cols.push(elems);
9439                for i in 0..self.outer_names.len() {
9440                    let sv = ScalarValue::try_from_array(&cols[i + 1], row).ok()?;
9441                    batch_cols.push(sv.to_array_of_size(n).ok()?);
9442                }
9443                let batch = RecordBatch::try_new(Arc::clone(&schema), batch_cols).ok()?;
9444                let evaluated = phys.evaluate(&batch).ok()?.into_array(n).ok()?;
9445                // A typeless evaluation (`WHERE x` over untyped elements) is
9446                // 3VL unknown per element, not a row failure.
9447                if evaluated.data_type() == &DataType::Null {
9448                    return reduce_quantifier(self.kind, &BooleanArray::new_null(n), n);
9449                }
9450                let bools = evaluated.as_any().downcast_ref::<BooleanArray>()?;
9451                reduce_quantifier(self.kind, bools, n)
9452            })();
9453            out.append_option(verdict);
9454        }
9455        Ok(ColumnarValue::Array(std::sync::Arc::new(out.finish())))
9456    }
9457}
9458
9459/// `[loop_var IN list WHERE filter | projection]` (#955). Holds the optional
9460/// filter + projection as logical `Expr`s over a synthetic element column +
9461/// referenced outer columns. At invoke time it builds a per-element
9462/// `RecordBatch` per row, keeps the elements the filter accepts (3VL: only
9463/// definitively-true), maps them through the projection, and reassembles a
9464/// `ListArray`. A bare `[x IN list]` (no clauses) is the list itself; a null
9465/// list row yields a null list.
9466#[derive(Debug, PartialEq, Eq, Hash)]
9467struct CypherListComp {
9468    filter: Option<DfExpr>,
9469    projection: Option<DfExpr>,
9470    elem_name: String,
9471    outer_names: Vec<String>,
9472    signature: Signature,
9473}
9474
9475impl CypherListComp {
9476    fn new(
9477        filter: Option<DfExpr>,
9478        projection: Option<DfExpr>,
9479        elem_name: String,
9480        outer_names: Vec<String>,
9481    ) -> Self {
9482        let arity = 1 + outer_names.len();
9483        Self {
9484            filter,
9485            projection,
9486            elem_name,
9487            outer_names,
9488            // Filter/projection expressions are embedded in the UDF and may
9489            // contain rand() or another volatile function.
9490            signature: Signature::any(arity, Volatility::Volatile),
9491        }
9492    }
9493
9494    /// The element type of the result list: the projection's output type over
9495    /// the synthetic schema, or the input element type when there is no
9496    /// projection. Computed identically at plan time (`return_type`) and invoke
9497    /// time so the produced `ListArray`'s child type matches the declared type.
9498    fn item_type(
9499        &self,
9500        elem_type: &DataType,
9501        outer_types: &[DataType],
9502    ) -> datafusion::error::Result<DataType> {
9503        use datafusion::arrow::datatypes::{Field, Schema};
9504        use datafusion::common::DFSchema;
9505        use datafusion::logical_expr::execution_props::ExecutionProps;
9506        use datafusion::physical_expr::create_physical_expr;
9507        let Some(proj) = &self.projection else {
9508            return Ok(elem_type.clone());
9509        };
9510        let mut fields = vec![Field::new(&self.elem_name, elem_type.clone(), true)];
9511        for (name, dt) in self.outer_names.iter().zip(outer_types) {
9512            fields.push(Field::new(name, dt.clone(), true));
9513        }
9514        let schema = Schema::new(fields);
9515        let df_schema = DFSchema::try_from(schema.clone())?;
9516        let phys = create_physical_expr(proj, &df_schema, &ExecutionProps::new())?;
9517        phys.data_type(&schema)
9518    }
9519
9520    #[allow(
9521        clippy::too_many_lines,
9522        reason = "one flatten/filter/project/reassemble pass keeps volatile evaluation and row offsets aligned"
9523    )]
9524    fn invoke_uncorrelated(
9525        list: &datafusion::arrow::array::ListArray,
9526        schema: datafusion::arrow::datatypes::SchemaRef,
9527        filter_phys: Option<&Arc<dyn datafusion::physical_expr::PhysicalExpr>>,
9528        proj_phys: Option<&Arc<dyn datafusion::physical_expr::PhysicalExpr>>,
9529        item_type: &DataType,
9530    ) -> datafusion::error::Result<ColumnarValue> {
9531        use datafusion::arrow::array::{
9532            Array, ArrayRef, BooleanArray, ListArray, UInt32Array, new_empty_array,
9533        };
9534        use datafusion::arrow::buffer::{NullBuffer, OffsetBuffer, ScalarBuffer};
9535        use datafusion::arrow::compute::{cast, filter_record_batch, take};
9536        use datafusion::arrow::datatypes::Field;
9537        use datafusion::arrow::record_batch::RecordBatch;
9538        use datafusion::error::DataFusionError;
9539
9540        let rows = list.len();
9541        let mut validity = Vec::with_capacity(rows);
9542        let mut lengths = Vec::with_capacity(rows);
9543        let mut indices = Vec::new();
9544        let offsets = list.value_offsets();
9545        for row in 0..rows {
9546            let valid = list.is_valid(row);
9547            validity.push(valid);
9548            let length = if valid {
9549                usize::try_from(offsets[row + 1] - offsets[row]).map_err(|_| {
9550                    DataFusionError::Internal("negative list-comprehension length".into())
9551                })?
9552            } else {
9553                0
9554            };
9555            lengths.push(length);
9556            if valid {
9557                for index in offsets[row]..offsets[row + 1] {
9558                    indices.push(u32::try_from(index).map_err(|_| {
9559                        DataFusionError::Internal(
9560                            "list-comprehension element index exceeds u32::MAX".into(),
9561                        )
9562                    })?);
9563                }
9564            }
9565        }
9566
9567        let flat: ArrayRef = if indices.len() == list.values().len()
9568            && indices.first().is_none_or(|first| *first == 0)
9569        {
9570            Arc::clone(list.values())
9571        } else {
9572            take(list.values(), &UInt32Array::from(indices), None)?
9573        };
9574        let total = flat.len();
9575        let batch = RecordBatch::try_new(schema, vec![flat])?;
9576        let mask = if let Some(filter) = filter_phys
9577            && total > 0
9578        {
9579            let evaluated = filter.evaluate(&batch)?.into_array(total)?;
9580            let evaluated = evaluated
9581                .as_any()
9582                .downcast_ref::<BooleanArray>()
9583                .ok_or_else(|| {
9584                    DataFusionError::Internal(
9585                        "cypher_list_comprehension: filter did not evaluate to boolean".into(),
9586                    )
9587                })?;
9588            Some(
9589                (0..total)
9590                    .map(|index| evaluated.is_valid(index) && evaluated.value(index))
9591                    .collect::<BooleanArray>(),
9592            )
9593        } else {
9594            None
9595        };
9596        let kept = if let Some(mask) = &mask {
9597            filter_record_batch(&batch, mask)?
9598        } else {
9599            batch
9600        };
9601        let kept_rows = kept.num_rows();
9602        let projected = if let Some(projection) = proj_phys {
9603            if kept_rows == 0 {
9604                new_empty_array(item_type)
9605            } else {
9606                projection.evaluate(&kept)?.into_array(kept_rows)?
9607            }
9608        } else {
9609            Arc::clone(kept.column(0))
9610        };
9611        let projected = if projected.data_type() == item_type {
9612            projected
9613        } else {
9614            cast(&projected, item_type)?
9615        };
9616
9617        let mut output_offsets = Vec::with_capacity(rows + 1);
9618        output_offsets.push(0i32);
9619        let mut input_offset = 0usize;
9620        let mut output_offset = 0i32;
9621        for length in lengths {
9622            let kept = mask.as_ref().map_or(length, |mask| {
9623                (input_offset..input_offset + length)
9624                    .filter(|index| mask.value(*index))
9625                    .count()
9626            });
9627            input_offset += length;
9628            let kept = i32::try_from(kept).map_err(|_| {
9629                DataFusionError::Internal(
9630                    "cypher_list_comprehension: list length exceeds i32::MAX".into(),
9631                )
9632            })?;
9633            output_offset = output_offset.checked_add(kept).ok_or_else(|| {
9634                DataFusionError::Internal(
9635                    "cypher_list_comprehension: total list length exceeds i32::MAX".into(),
9636                )
9637            })?;
9638            output_offsets.push(output_offset);
9639        }
9640        let list = ListArray::try_new(
9641            Arc::new(Field::new("item", item_type.clone(), true)),
9642            OffsetBuffer::new(ScalarBuffer::from(output_offsets)),
9643            projected,
9644            Some(NullBuffer::from(validity)),
9645        )?;
9646        Ok(ColumnarValue::Array(Arc::new(list)))
9647    }
9648}
9649
9650impl ScalarUDFImpl for CypherListComp {
9651    fn as_any(&self) -> &dyn Any {
9652        self
9653    }
9654    fn name(&self) -> &'static str {
9655        "cypher_list_comprehension"
9656    }
9657    fn signature(&self) -> &Signature {
9658        &self.signature
9659    }
9660    fn return_type(&self, arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
9661        use datafusion::arrow::datatypes::Field;
9662        // Cypher lists lower to Arrow `List` (i32 offsets), which is also what
9663        // this UDF produces — keep input handling aligned with `invoke` (which
9664        // downcasts to `ListArray`) so a `LargeList` cannot pass planning and
9665        // then fail at runtime.
9666        let elem_type = match arg_types.first() {
9667            Some(DataType::List(f)) => f.data_type().clone(),
9668            _ => DataType::Null,
9669        };
9670        let item = self.item_type(&elem_type, arg_types.get(1..).unwrap_or(&[]))?;
9671        Ok(DataType::List(std::sync::Arc::new(Field::new(
9672            "item", item, true,
9673        ))))
9674    }
9675
9676    #[allow(
9677        clippy::too_many_lines,
9678        reason = "the per-row filter/project/reassemble loop reads clearest inline"
9679    )]
9680    fn invoke_with_args(
9681        &self,
9682        args: ScalarFunctionArgs,
9683    ) -> datafusion::error::Result<ColumnarValue> {
9684        use datafusion::arrow::array::{
9685            Array, ArrayRef, BooleanArray, ListArray, RecordBatch, new_empty_array,
9686        };
9687        use datafusion::arrow::buffer::{NullBuffer, OffsetBuffer, ScalarBuffer};
9688        use datafusion::arrow::compute::{cast, concat, filter_record_batch};
9689        use datafusion::arrow::datatypes::{Field, Schema};
9690        use datafusion::common::DFSchema;
9691        use datafusion::error::DataFusionError;
9692        use datafusion::logical_expr::execution_props::ExecutionProps;
9693        use datafusion::physical_expr::create_physical_expr;
9694        use std::sync::Arc;
9695
9696        let rows = args.number_rows;
9697        let cols: Vec<ArrayRef> = args
9698            .args
9699            .iter()
9700            .map(|a| a.to_array(rows))
9701            .collect::<datafusion::error::Result<_>>()?;
9702        let list = cols[0]
9703            .as_any()
9704            .downcast_ref::<ListArray>()
9705            .ok_or_else(|| {
9706                DataFusionError::Internal(
9707                    "cypher_list_comprehension: first argument is not a list".into(),
9708                )
9709            })?;
9710        let elem_type = match list.data_type() {
9711            DataType::List(f) => f.data_type().clone(),
9712            _ => DataType::Null,
9713        };
9714        let outer_types: Vec<DataType> = (0..self.outer_names.len())
9715            .map(|i| cols[i + 1].data_type().clone())
9716            .collect();
9717        let item_type = self.item_type(&elem_type, &outer_types)?;
9718
9719        // Synthetic schema: the element column + each referenced outer column.
9720        let mut fields = vec![Field::new(&self.elem_name, elem_type, true)];
9721        for (i, name) in self.outer_names.iter().enumerate() {
9722            fields.push(Field::new(name, cols[i + 1].data_type().clone(), true));
9723        }
9724        let schema = Arc::new(Schema::new(fields));
9725        let df_schema = DFSchema::try_from(schema.as_ref().clone())?;
9726        let props = ExecutionProps::new();
9727        let filter_phys = self
9728            .filter
9729            .as_ref()
9730            .map(|f| create_physical_expr(f, &df_schema, &props))
9731            .transpose()?;
9732        let proj_phys = self
9733            .projection
9734            .as_ref()
9735            .map(|p| create_physical_expr(p, &df_schema, &props))
9736            .transpose()?;
9737
9738        if self.outer_names.is_empty() {
9739            return Self::invoke_uncorrelated(
9740                list,
9741                schema,
9742                filter_phys.as_ref(),
9743                proj_phys.as_ref(),
9744                &item_type,
9745            );
9746        }
9747
9748        let mut pieces: Vec<ArrayRef> = Vec::new();
9749        let mut offsets: Vec<i32> = Vec::with_capacity(rows + 1);
9750        offsets.push(0);
9751        let mut validity: Vec<bool> = Vec::with_capacity(rows);
9752        let mut cur: i32 = 0;
9753
9754        for row in 0..rows {
9755            if list.is_null(row) {
9756                validity.push(false);
9757                offsets.push(cur);
9758                continue;
9759            }
9760            validity.push(true);
9761            let elems = list.value(row);
9762            let n = elems.len();
9763            let mut batch_cols: Vec<ArrayRef> = Vec::with_capacity(1 + self.outer_names.len());
9764            batch_cols.push(elems);
9765            for i in 0..self.outer_names.len() {
9766                let sv = ScalarValue::try_from_array(&cols[i + 1], row)?;
9767                batch_cols.push(sv.to_array_of_size(n)?);
9768            }
9769            let batch = RecordBatch::try_new(Arc::clone(&schema), batch_cols)?;
9770
9771            // Filter: keep only elements the predicate accepts (3VL → null/false drop).
9772            let kept = if let Some(fp) = &filter_phys {
9773                let mask = fp.evaluate(&batch)?.into_array(n)?;
9774                let mask = mask
9775                    .as_any()
9776                    .downcast_ref::<BooleanArray>()
9777                    .ok_or_else(|| {
9778                        DataFusionError::Internal(
9779                            "cypher_list_comprehension: filter did not evaluate to boolean".into(),
9780                        )
9781                    })?;
9782                let clean: BooleanArray =
9783                    (0..n).map(|j| mask.is_valid(j) && mask.value(j)).collect();
9784                filter_record_batch(&batch, &clean)?
9785            } else {
9786                batch
9787            };
9788
9789            // Projection: map each surviving element (or the element itself).
9790            let projected: ArrayRef = if let Some(pp) = &proj_phys {
9791                let m = kept.num_rows();
9792                if m == 0 {
9793                    new_empty_array(&item_type)
9794                } else {
9795                    pp.evaluate(&kept)?.into_array(m)?
9796                }
9797            } else {
9798                Arc::clone(kept.column(0))
9799            };
9800            let projected = if projected.data_type() == &item_type {
9801                projected
9802            } else {
9803                cast(&projected, &item_type)?
9804            };
9805
9806            let len = i32::try_from(projected.len()).map_err(|_| {
9807                DataFusionError::Internal("cypher_list_comprehension: list too long".into())
9808            })?;
9809            cur = cur.checked_add(len).ok_or_else(|| {
9810                DataFusionError::Internal(
9811                    "cypher_list_comprehension: total list length exceeds i32::MAX".into(),
9812                )
9813            })?;
9814            offsets.push(cur);
9815            pieces.push(projected);
9816        }
9817
9818        let child: ArrayRef = if pieces.is_empty() {
9819            new_empty_array(&item_type)
9820        } else {
9821            let refs: Vec<&dyn Array> = pieces.iter().map(AsRef::as_ref).collect();
9822            concat(&refs)?
9823        };
9824        let field = Arc::new(Field::new("item", item_type, true));
9825        let list_arr = ListArray::try_new(
9826            field,
9827            OffsetBuffer::new(ScalarBuffer::from(offsets)),
9828            child,
9829            Some(NullBuffer::from(validity)),
9830        )?;
9831        Ok(ColumnarValue::Array(Arc::new(list_arr)))
9832    }
9833}
9834
9835// ---------------------------------------------------------------------------
9836// cypher_date_project UDF
9837// ---------------------------------------------------------------------------
9838
9839/// `date`-from-value projection (`Temporal3`): `date(base)` / `date({date: base,
9840/// …overrides})`. Args are `[base, year, month, day, week, dayOfWeek,
9841/// ordinalDay, quarter, dayOfQuarter]` — `base` is a `Date32` or an ISO date/
9842/// datetime string, the eight overrides are nullable integers (null ⇒ keep the
9843/// base's component). Returns `Date32`. (ADR 0009 / #920)
9844static CYPHER_DATE_PROJECT: LazyLock<ScalarUDF> =
9845    LazyLock::new(|| ScalarUDF::new_from_impl(CypherDateProject::new()));
9846
9847#[derive(Debug, PartialEq, Eq, Hash)]
9848struct CypherDateProject {
9849    signature: Signature,
9850}
9851
9852impl CypherDateProject {
9853    fn new() -> Self {
9854        Self {
9855            signature: Signature::any(9, Volatility::Immutable),
9856        }
9857    }
9858}
9859
9860impl ScalarUDFImpl for CypherDateProject {
9861    fn as_any(&self) -> &dyn Any {
9862        self
9863    }
9864
9865    fn name(&self) -> &'static str {
9866        "cypher_date_project"
9867    }
9868
9869    fn signature(&self) -> &Signature {
9870        &self.signature
9871    }
9872
9873    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
9874        Ok(DataType::Struct(
9875            graphforge_storage::schemas::date_struct_fields(),
9876        ))
9877    }
9878
9879    fn invoke_with_args(
9880        &self,
9881        args: ScalarFunctionArgs,
9882    ) -> datafusion::error::Result<ColumnarValue> {
9883        use crate::temporal::{DateOverrides, parse_date_or_datetime_prefix};
9884        use datafusion::arrow::array::{Array, Int64Array, StringArray, StructArray};
9885        use datafusion::arrow::compute::cast;
9886
9887        let rows = args.number_rows;
9888        let cols: Vec<_> = args
9889            .args
9890            .iter()
9891            .map(|a| a.to_array(rows))
9892            .collect::<datafusion::error::Result<_>>()?;
9893        // A typed temporal-struct base (date / localdatetime / time / datetime) is
9894        // read directly; a string base is cast to `Utf8` (handling `Utf8View`).
9895        let base = if is_date_struct(cols[0].data_type())
9896            || is_localdatetime_struct(cols[0].data_type())
9897            || is_time_struct(cols[0].data_type())
9898            || is_datetime_struct(cols[0].data_type())
9899        {
9900            std::sync::Arc::clone(&cols[0])
9901        } else {
9902            cast(&cols[0], &DataType::Utf8).map_err(datafusion::error::DataFusionError::from)?
9903        };
9904        // Overrides: cast each to Int64 once (a null/absent override stays null).
9905        let ov: Vec<_> = cols[1..9]
9906            .iter()
9907            .map(|c| cast(c, &DataType::Int64))
9908            .collect::<std::result::Result<Vec<_>, _>>()
9909            .map_err(datafusion::error::DataFusionError::from)?;
9910
9911        let base_date = |i: usize| -> Option<i64> {
9912            if base.is_null(i) {
9913                return None;
9914            }
9915            match base.data_type() {
9916                DataType::Struct(_) => {
9917                    let s = base.as_any().downcast_ref::<StructArray>()?;
9918                    if is_date_struct(base.data_type()) {
9919                        date_struct_value(s, i)
9920                    } else {
9921                        // A `localdatetime`/`datetime` value — take its date component.
9922                        Some(localdatetime_struct_parts(s, i)?.0)
9923                    }
9924                }
9925                DataType::Utf8 => parse_date_or_datetime_prefix(
9926                    base.as_any().downcast_ref::<StringArray>()?.value(i),
9927                ),
9928                _ => None,
9929            }
9930        };
9931        let field = |idx: usize, i: usize| -> Option<i64> {
9932            let a = ov[idx].as_any().downcast_ref::<Int64Array>()?;
9933            (!a.is_null(i)).then(|| a.value(i))
9934        };
9935
9936        let out: Vec<Option<i64>> = (0..rows)
9937            .map(|i| {
9938                let overrides = DateOverrides {
9939                    year: field(0, i),
9940                    month: field(1, i),
9941                    day: field(2, i),
9942                    week: field(3, i),
9943                    day_of_week: field(4, i),
9944                    ordinal_day: field(5, i),
9945                    quarter: field(6, i),
9946                    day_of_quarter: field(7, i),
9947                };
9948                crate::temporal::project_date(base_date(i)?, &overrides)
9949            })
9950            .collect();
9951        Ok(ColumnarValue::Array(std::sync::Arc::new(
9952            build_date_struct(&out),
9953        )))
9954    }
9955}
9956
9957// ---------------------------------------------------------------------------
9958// cypher_localtime_project UDF
9959// ---------------------------------------------------------------------------
9960
9961/// `localtime`-from-value projection (`Temporal3`): `localtime(base)` /
9962/// `localtime({time: base, …overrides})`. Args are `[base, hour, minute, second,
9963/// millisecond, microsecond, nanosecond]` — `base` is a `Time64(Nanosecond)` or
9964/// any ISO temporal string (its time-of-day is extracted), the six overrides are
9965/// nullable integers (null ⇒ keep the base's component). Returns
9966/// `Time64(Nanosecond)`. (ADR 0009)
9967static CYPHER_LOCALTIME_PROJECT: LazyLock<ScalarUDF> =
9968    LazyLock::new(|| ScalarUDF::new_from_impl(CypherLocalTimeProject::new()));
9969
9970#[derive(Debug, PartialEq, Eq, Hash)]
9971struct CypherLocalTimeProject {
9972    signature: Signature,
9973}
9974
9975impl CypherLocalTimeProject {
9976    fn new() -> Self {
9977        Self {
9978            signature: Signature::any(7, Volatility::Immutable),
9979        }
9980    }
9981}
9982
9983impl ScalarUDFImpl for CypherLocalTimeProject {
9984    fn as_any(&self) -> &dyn Any {
9985        self
9986    }
9987
9988    fn name(&self) -> &'static str {
9989        "cypher_localtime_project"
9990    }
9991
9992    fn signature(&self) -> &Signature {
9993        &self.signature
9994    }
9995
9996    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
9997        Ok(DataType::Time64(
9998            datafusion::arrow::datatypes::TimeUnit::Nanosecond,
9999        ))
10000    }
10001
10002    fn invoke_with_args(
10003        &self,
10004        args: ScalarFunctionArgs,
10005    ) -> datafusion::error::Result<ColumnarValue> {
10006        use crate::temporal::{LocalTimeOverrides, project_localtime, time_of_day_nanos_any};
10007        use datafusion::arrow::array::{
10008            Array, ArrayRef, Int64Array, StringArray, StructArray, Time64NanosecondArray,
10009        };
10010        use datafusion::arrow::compute::cast;
10011        use datafusion::arrow::datatypes::TimeUnit;
10012        use datafusion::error::DataFusionError;
10013
10014        let rows = args.number_rows;
10015        let cols: Vec<_> = args
10016            .args
10017            .iter()
10018            .map(|a| a.to_array(rows))
10019            .collect::<datafusion::error::Result<_>>()?;
10020        // A `Time64(Nanosecond)` or `localdatetime`-struct base is read directly;
10021        // any other (string) base is cast to `Utf8` (handling `Utf8View`).
10022        let base: ArrayRef =
10023            if matches!(cols[0].data_type(), DataType::Time64(TimeUnit::Nanosecond))
10024                || is_localdatetime_struct(cols[0].data_type())
10025                || is_time_struct(cols[0].data_type())
10026                || is_datetime_struct(cols[0].data_type())
10027            {
10028                std::sync::Arc::clone(&cols[0])
10029            } else {
10030                cast(&cols[0], &DataType::Utf8).map_err(DataFusionError::from)?
10031            };
10032        let ov: Vec<_> = cols[1..7]
10033            .iter()
10034            .map(|c| cast(c, &DataType::Int64))
10035            .collect::<std::result::Result<Vec<_>, _>>()
10036            .map_err(DataFusionError::from)?;
10037
10038        let base_nanos = |i: usize| -> Option<i64> {
10039            if base.is_null(i) {
10040                return None;
10041            }
10042            match base.data_type() {
10043                DataType::Time64(TimeUnit::Nanosecond) => Some(
10044                    base.as_any()
10045                        .downcast_ref::<Time64NanosecondArray>()?
10046                        .value(i),
10047                ),
10048                // A `localdatetime` or `time` value — take its time-of-day.
10049                DataType::Struct(_) => {
10050                    let s = base.as_any().downcast_ref::<StructArray>()?;
10051                    if is_time_struct(base.data_type()) {
10052                        Some(time_struct_parts(s, i)?.0)
10053                    } else {
10054                        Some(localdatetime_struct_parts(s, i)?.1)
10055                    }
10056                }
10057                DataType::Utf8 => {
10058                    time_of_day_nanos_any(base.as_any().downcast_ref::<StringArray>()?.value(i))
10059                }
10060                _ => None,
10061            }
10062        };
10063        let field = |idx: usize, i: usize| -> Option<i64> {
10064            let a = ov[idx].as_any().downcast_ref::<Int64Array>()?;
10065            (!a.is_null(i)).then(|| a.value(i))
10066        };
10067
10068        let out: Time64NanosecondArray = (0..rows)
10069            .map(|i| {
10070                let overrides = LocalTimeOverrides {
10071                    hour: field(0, i),
10072                    minute: field(1, i),
10073                    second: field(2, i),
10074                    millisecond: field(3, i),
10075                    microsecond: field(4, i),
10076                    nanosecond: field(5, i),
10077                };
10078                project_localtime(base_nanos(i)?, &overrides)
10079            })
10080            .collect();
10081        Ok(ColumnarValue::Array(std::sync::Arc::new(out)))
10082    }
10083}
10084
10085// ---------------------------------------------------------------------------
10086// cypher_localtime_truncate UDF
10087// ---------------------------------------------------------------------------
10088
10089/// `localtime.truncate(unit, value, map)` (`Temporal9`): truncate `value`'s
10090/// time-of-day to `unit`, then apply the override `map`. Args are `[value, unit,
10091/// hour, minute, second, millisecond, microsecond, nanosecond]` — `value` is a
10092/// `Time64(ns)` / `localdatetime`/`time`/`datetime` struct / ISO string, `unit` a
10093/// string, the six overrides nullable integers. Returns `Time64(ns)`. (#920)
10094static CYPHER_LOCALTIME_TRUNCATE: LazyLock<ScalarUDF> =
10095    LazyLock::new(|| ScalarUDF::new_from_impl(CypherLocalTimeTruncate::new()));
10096
10097#[derive(Debug, PartialEq, Eq, Hash)]
10098struct CypherLocalTimeTruncate {
10099    signature: Signature,
10100}
10101
10102impl CypherLocalTimeTruncate {
10103    fn new() -> Self {
10104        Self {
10105            signature: Signature::any(8, Volatility::Immutable),
10106        }
10107    }
10108}
10109
10110impl ScalarUDFImpl for CypherLocalTimeTruncate {
10111    fn as_any(&self) -> &dyn Any {
10112        self
10113    }
10114
10115    fn name(&self) -> &'static str {
10116        "cypher_localtime_truncate"
10117    }
10118
10119    fn signature(&self) -> &Signature {
10120        &self.signature
10121    }
10122
10123    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
10124        Ok(DataType::Time64(
10125            datafusion::arrow::datatypes::TimeUnit::Nanosecond,
10126        ))
10127    }
10128
10129    fn invoke_with_args(
10130        &self,
10131        args: ScalarFunctionArgs,
10132    ) -> datafusion::error::Result<ColumnarValue> {
10133        use crate::temporal::{
10134            LocalTimeOverrides, project_localtime, time_of_day_nanos_any, truncate_time_nanos,
10135        };
10136        use datafusion::arrow::array::{
10137            Array, ArrayRef, Int64Array, StringArray, StructArray, Time64NanosecondArray,
10138        };
10139        use datafusion::arrow::compute::cast;
10140        use datafusion::arrow::datatypes::TimeUnit;
10141        use datafusion::error::DataFusionError;
10142
10143        let rows = args.number_rows;
10144        let cols: Vec<_> = args
10145            .args
10146            .iter()
10147            .map(|a| a.to_array(rows))
10148            .collect::<datafusion::error::Result<_>>()?;
10149        let base: ArrayRef =
10150            if matches!(cols[0].data_type(), DataType::Time64(TimeUnit::Nanosecond))
10151                || is_localdatetime_struct(cols[0].data_type())
10152                || is_time_struct(cols[0].data_type())
10153                || is_datetime_struct(cols[0].data_type())
10154            {
10155                std::sync::Arc::clone(&cols[0])
10156            } else {
10157                cast(&cols[0], &DataType::Utf8).map_err(DataFusionError::from)?
10158            };
10159        let units_arr = cast(&cols[1], &DataType::Utf8).map_err(DataFusionError::from)?;
10160        let units = units_arr.as_any().downcast_ref::<StringArray>();
10161        let ov: Vec<_> = cols[2..8]
10162            .iter()
10163            .map(|c| cast(c, &DataType::Int64))
10164            .collect::<std::result::Result<Vec<_>, _>>()
10165            .map_err(DataFusionError::from)?;
10166
10167        let base_nanos = |i: usize| -> Option<i64> {
10168            if base.is_null(i) {
10169                return None;
10170            }
10171            match base.data_type() {
10172                DataType::Time64(TimeUnit::Nanosecond) => Some(
10173                    base.as_any()
10174                        .downcast_ref::<Time64NanosecondArray>()?
10175                        .value(i),
10176                ),
10177                DataType::Struct(_) => {
10178                    let s = base.as_any().downcast_ref::<StructArray>()?;
10179                    if is_time_struct(base.data_type()) {
10180                        Some(time_struct_parts(s, i)?.0)
10181                    } else {
10182                        Some(localdatetime_struct_parts(s, i)?.1)
10183                    }
10184                }
10185                DataType::Utf8 => {
10186                    time_of_day_nanos_any(base.as_any().downcast_ref::<StringArray>()?.value(i))
10187                }
10188                _ => None,
10189            }
10190        };
10191        let field = |idx: usize, i: usize| -> Option<i64> {
10192            let a = ov[idx].as_any().downcast_ref::<Int64Array>()?;
10193            (!a.is_null(i)).then(|| a.value(i))
10194        };
10195
10196        let out: Time64NanosecondArray = (0..rows)
10197            .map(|i| {
10198                let u = units?;
10199                if u.is_null(i) {
10200                    return None;
10201                }
10202                let truncated = truncate_time_nanos(base_nanos(i)?, u.value(i))?;
10203                let overrides = LocalTimeOverrides {
10204                    hour: field(0, i),
10205                    minute: field(1, i),
10206                    second: field(2, i),
10207                    millisecond: field(3, i),
10208                    microsecond: field(4, i),
10209                    nanosecond: field(5, i),
10210                };
10211                project_localtime(truncated, &overrides)
10212            })
10213            .collect();
10214        Ok(ColumnarValue::Array(std::sync::Arc::new(out)))
10215    }
10216}
10217
10218// ---------------------------------------------------------------------------
10219// cypher_localdatetime_project UDF
10220// ---------------------------------------------------------------------------
10221
10222/// The Arrow fields of a standalone `date` value — `Struct{epoch_day: Int64}`
10223/// (ADR 0012). A one-field struct (not a bare `Int64`) so a `date` is
10224/// self-describing on storage decode — a plain integer property would be
10225/// indistinguishable — while spanning the full openCypher year range (#1011).
10226fn date_fields() -> datafusion::arrow::datatypes::Fields {
10227    graphforge_storage::schemas::date_struct_fields()
10228}
10229
10230/// True if `dt` is the standalone `date` struct type (`Struct{epoch_day: Int64}`),
10231/// distinguished from the other temporal structs by its single field name.
10232fn is_date_struct(dt: &DataType) -> bool {
10233    matches!(dt, DataType::Struct(fields)
10234        if fields.len() == 1
10235            && fields[0].name() == "epoch_day"
10236            && *fields[0].data_type() == DataType::Int64)
10237}
10238
10239/// Build a standalone `date` struct array from per-row i64 epoch-days (`None` ⇒ a
10240/// null row).
10241fn build_date_struct(rows: &[Option<i64>]) -> datafusion::arrow::array::StructArray {
10242    use datafusion::arrow::array::Int64Array;
10243    use datafusion::arrow::buffer::NullBuffer;
10244    let days: Int64Array = rows.iter().copied().collect();
10245    let nulls = rows.iter().map(Option::is_some).collect::<NullBuffer>();
10246    datafusion::arrow::array::StructArray::new(
10247        date_fields(),
10248        vec![std::sync::Arc::new(days)],
10249        Some(nulls),
10250    )
10251}
10252
10253/// A standalone `date` scalar (`None` ⇒ a null value).
10254fn date_scalar(days: Option<i64>) -> ScalarValue {
10255    ScalarValue::Struct(std::sync::Arc::new(build_date_struct(&[days])))
10256}
10257
10258/// The i64 epoch-day of a standalone `date` struct row (`None` for a null row).
10259fn date_struct_value(arr: &datafusion::arrow::array::StructArray, i: usize) -> Option<i64> {
10260    use datafusion::arrow::array::{Array, Int64Array};
10261    if arr.is_null(i) {
10262        return None;
10263    }
10264    let days = arr.column(0).as_any().downcast_ref::<Int64Array>()?;
10265    days.is_valid(i).then(|| days.value(i))
10266}
10267
10268/// The Arrow fields of a `localdatetime` value — `Struct{date: Int64, time:
10269/// Time64(Nanosecond)}`. `date` is first so DataFusion's row-format sort orders
10270/// chronologically (date, then time-of-day); `date` is i64 days (#1011).
10271fn localdatetime_fields() -> datafusion::arrow::datatypes::Fields {
10272    graphforge_storage::schemas::localdatetime_struct_fields()
10273}
10274
10275/// True if `dt` is the `localdatetime` struct type (used to dispatch base
10276/// extraction and rendering without colliding with user maps, whose `date`/
10277/// `time` fields would not have these exact `Int64`/`Time64` types).
10278fn is_localdatetime_struct(dt: &DataType) -> bool {
10279    use datafusion::arrow::datatypes::TimeUnit;
10280    matches!(dt, DataType::Struct(fields)
10281        if fields.len() == 2
10282            && fields[0].name() == "date"
10283            && *fields[0].data_type() == DataType::Int64
10284            && fields[1].name() == "time"
10285            && *fields[1].data_type() == DataType::Time64(TimeUnit::Nanosecond))
10286}
10287
10288/// Build a `localdatetime` struct array from per-row `(date_days, nanos_of_day)`
10289/// (`None` ⇒ a null row).
10290fn build_localdatetime_struct(
10291    rows: &[Option<(i64, i64)>],
10292) -> datafusion::arrow::array::StructArray {
10293    use datafusion::arrow::array::{Int64Array, Time64NanosecondArray};
10294    use datafusion::arrow::buffer::NullBuffer;
10295    let days: Int64Array = rows.iter().map(|r| r.map(|(d, _)| d)).collect();
10296    let nanos: Time64NanosecondArray = rows.iter().map(|r| r.map(|(_, n)| n)).collect();
10297    let nulls = rows.iter().map(Option::is_some).collect::<NullBuffer>();
10298    datafusion::arrow::array::StructArray::new(
10299        localdatetime_fields(),
10300        vec![std::sync::Arc::new(days), std::sync::Arc::new(nanos)],
10301        Some(nulls),
10302    )
10303}
10304
10305/// A `localdatetime` scalar (`None` ⇒ a null value).
10306fn localdatetime_scalar(parts: Option<(i64, i64)>) -> ScalarValue {
10307    ScalarValue::Struct(std::sync::Arc::new(build_localdatetime_struct(&[parts])))
10308}
10309
10310/// True if `dt` is the typed `duration` struct (`Struct{months, days, seconds, nanos}`,
10311/// ADR 0009) — distinguished from the other temporal structs by its field names.
10312fn is_duration_struct(dt: &DataType) -> bool {
10313    matches!(dt, DataType::Struct(fields)
10314        if fields.len() == 4
10315            && fields[0].name() == "months"
10316            && fields[1].name() == "days"
10317            && fields[2].name() == "seconds"
10318            && fields[3].name() == "nanos")
10319}
10320
10321/// Whether a function name is a temporal clock accessor —
10322/// `<type>.transaction` / `.statement` / `.realtime` for an instant type
10323/// (`date`/`localtime`/`time`/`localdatetime`/`datetime`). (#920)
10324fn is_temporal_clock_fn(name: &str) -> bool {
10325    // Cypher function names are case-insensitive (`Date.Realtime` ≡ `date.realtime`).
10326    matches!(
10327        name.to_ascii_lowercase().split_once('.'),
10328        Some((
10329            "date" | "localtime" | "time" | "localdatetime" | "datetime",
10330            "transaction" | "statement" | "realtime",
10331        ))
10332    )
10333}
10334
10335/// The typed null for a temporal constructor / clock function — `date` →
10336/// `Date32(None)`, `localtime` → `Time64(None)`, etc. — so null propagation
10337/// preserves the Arrow temporal type rather than erasing it to `Null` (which
10338/// would defeat downstream `is_temporal_typed` checks). The base name is the
10339/// part before any `.clock` suffix, matched case-insensitively. (#920)
10340fn temporal_null_scalar(name: &str) -> ScalarValue {
10341    let lower = name.to_ascii_lowercase();
10342    let base = lower.split('.').next().unwrap_or(&lower);
10343    match base {
10344        "date" => date_scalar(None),
10345        "localtime" => ScalarValue::Time64Nanosecond(None),
10346        "time" => time_scalar(None),
10347        "localdatetime" => localdatetime_scalar(None),
10348        "datetime" => datetime_scalar(None),
10349        "duration" => duration_scalar(None),
10350        _ => ScalarValue::Null,
10351    }
10352}
10353
10354/// Build a `duration` struct array from per-row [`DurationValue`]s (`None` ⇒ a
10355/// null row). The on-disk + query representation of a Cypher duration —
10356/// `Struct{months,days,seconds,nanos}` all Int64 (Parquet cannot persist Arrow
10357/// `Interval(MonthDayNano)`). (#920/#1011)
10358fn build_duration_struct(
10359    rows: &[Option<crate::temporal::DurationValue>],
10360) -> datafusion::arrow::array::StructArray {
10361    use datafusion::arrow::array::Int64Array;
10362    use datafusion::arrow::buffer::NullBuffer;
10363    let months: Int64Array = rows.iter().map(|r| r.map(|d| d.months)).collect();
10364    let days: Int64Array = rows.iter().map(|r| r.map(|d| d.days)).collect();
10365    let seconds: Int64Array = rows.iter().map(|r| r.map(|d| d.seconds)).collect();
10366    let nanos: Int64Array = rows.iter().map(|r| r.map(|d| d.nanos)).collect();
10367    let nulls = rows.iter().map(Option::is_some).collect::<NullBuffer>();
10368    datafusion::arrow::array::StructArray::new(
10369        graphforge_storage::schemas::duration_struct_fields(),
10370        vec![
10371            std::sync::Arc::new(months),
10372            std::sync::Arc::new(days),
10373            std::sync::Arc::new(seconds),
10374            std::sync::Arc::new(nanos),
10375        ],
10376        Some(nulls),
10377    )
10378}
10379
10380/// Extract a [`DurationValue`] from a `duration` struct array at row `i`
10381/// (`None` for a null row). (#920/#1011)
10382fn duration_struct_parts(
10383    arr: &datafusion::arrow::array::StructArray,
10384    i: usize,
10385) -> Option<crate::temporal::DurationValue> {
10386    use datafusion::arrow::array::{Array, Int64Array};
10387    if arr.is_null(i) {
10388        return None;
10389    }
10390    let col = |idx: usize| arr.column(idx).as_any().downcast_ref::<Int64Array>();
10391    Some(crate::temporal::DurationValue {
10392        months: col(0)?.value(i),
10393        days: col(1)?.value(i),
10394        seconds: col(2)?.value(i),
10395        nanos: col(3)?.value(i),
10396    })
10397}
10398
10399/// Build a typed `duration` scalar from a [`DurationValue`] (`None` ⇒ null). (#920)
10400fn duration_scalar(parts: Option<crate::temporal::DurationValue>) -> ScalarValue {
10401    ScalarValue::Struct(std::sync::Arc::new(build_duration_struct(&[parts])))
10402}
10403
10404/// A sub-day-only [`DurationValue`] from whole `seconds` + non-negative
10405/// `nanos`-of-second (no month/day part) — for the native-Arrow duration arms. (#1011)
10406fn dur_secs_nanos(seconds: i64, nanos: i64) -> crate::temporal::DurationValue {
10407    crate::temporal::DurationValue {
10408        months: 0,
10409        days: 0,
10410        seconds,
10411        nanos,
10412    }
10413}
10414
10415/// Convert a [`DurationValue`] to an `IrLiteral::Duration` (storage form). (#1011)
10416fn duration_value_to_ir(d: crate::temporal::DurationValue) -> IrLiteral {
10417    IrLiteral::Duration {
10418        months: d.months,
10419        days: d.days,
10420        seconds: d.seconds,
10421        nanos: d.nanos,
10422    }
10423}
10424
10425/// Extract `(date_days, nanos_of_day)` from a `localdatetime` struct array at
10426/// row `i` (`None` for a null row). Also used to read the local date+time of a
10427/// `datetime` struct (whose leading two fields are the same `Int64`+`Time64`),
10428/// dropping its zone — the correct semantics for `date`/`localtime`/
10429/// `localdatetime` projections from a `datetime`.
10430fn localdatetime_struct_parts(
10431    arr: &datafusion::arrow::array::StructArray,
10432    i: usize,
10433) -> Option<(i64, i64)> {
10434    use datafusion::arrow::array::{Array, Int64Array, Time64NanosecondArray};
10435    if arr.is_null(i) {
10436        return None;
10437    }
10438    let d = arr.column(0).as_any().downcast_ref::<Int64Array>()?;
10439    let t = arr
10440        .column(1)
10441        .as_any()
10442        .downcast_ref::<Time64NanosecondArray>()?;
10443    (!d.is_null(i) && !t.is_null(i)).then(|| (d.value(i), t.value(i)))
10444}
10445
10446/// `localdatetime`-from-value projection (`Temporal3`). Args are `[date_source,
10447/// time_source, year, month, day, week, dayOfWeek, ordinalDay, quarter,
10448/// dayOfQuarter, hour, minute, second, millisecond, microsecond, nanosecond]`.
10449/// `date_source`/`time_source` are the lowered `datetime`/`date`/`time` anchors
10450/// (a `Date32`/`Time64`/`localdatetime`-struct/temporal-string, or null),
10451/// interpreted as a date and a time-of-day respectively (a null date ⇒ epoch, a
10452/// null time ⇒ midnight). The 14 overrides are nullable integers (null ⇒ keep
10453/// the base's component). Returns the `localdatetime` struct. (ADR 0009)
10454static CYPHER_LOCALDATETIME_PROJECT: LazyLock<ScalarUDF> =
10455    LazyLock::new(|| ScalarUDF::new_from_impl(CypherLocalDateTimeProject::new()));
10456
10457#[derive(Debug, PartialEq, Eq, Hash)]
10458struct CypherLocalDateTimeProject {
10459    signature: Signature,
10460}
10461
10462impl CypherLocalDateTimeProject {
10463    fn new() -> Self {
10464        Self {
10465            signature: Signature::any(16, Volatility::Immutable),
10466        }
10467    }
10468}
10469
10470impl ScalarUDFImpl for CypherLocalDateTimeProject {
10471    fn as_any(&self) -> &dyn Any {
10472        self
10473    }
10474
10475    fn name(&self) -> &'static str {
10476        "cypher_localdatetime_project"
10477    }
10478
10479    fn signature(&self) -> &Signature {
10480        &self.signature
10481    }
10482
10483    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
10484        Ok(DataType::Struct(localdatetime_fields()))
10485    }
10486
10487    #[allow(
10488        clippy::too_many_lines,
10489        reason = "one cohesive per-row projection: two typed base extractions \
10490                  (date + time) plus 14 component overrides — splitting it would \
10491                  scatter the row logic across helpers without aiding clarity"
10492    )]
10493    fn invoke_with_args(
10494        &self,
10495        args: ScalarFunctionArgs,
10496    ) -> datafusion::error::Result<ColumnarValue> {
10497        use crate::temporal::{
10498            DateOverrides, LocalTimeOverrides, parse_date_or_datetime_prefix, project_date,
10499            project_localtime, time_of_day_nanos_any,
10500        };
10501        use datafusion::arrow::array::{
10502            Array, ArrayRef, Int64Array, StringArray, StructArray, Time64NanosecondArray,
10503        };
10504        use datafusion::arrow::compute::cast;
10505        use datafusion::arrow::datatypes::TimeUnit;
10506        use datafusion::error::DataFusionError;
10507
10508        let rows = args.number_rows;
10509        let cols: Vec<_> = args
10510            .args
10511            .iter()
10512            .map(|a| a.to_array(rows))
10513            .collect::<datafusion::error::Result<_>>()?;
10514        // Typed sources (`date`/`localdatetime`/`time`/`datetime` struct or
10515        // `Time64`) are read directly; a string source is cast to `Utf8`
10516        // (handling `Utf8View`).
10517        let typed_or_utf8 = |a: &ArrayRef| -> datafusion::error::Result<ArrayRef> {
10518            if matches!(a.data_type(), DataType::Time64(TimeUnit::Nanosecond))
10519                || is_date_struct(a.data_type())
10520                || is_localdatetime_struct(a.data_type())
10521                || is_time_struct(a.data_type())
10522                || is_datetime_struct(a.data_type())
10523            {
10524                Ok(std::sync::Arc::clone(a))
10525            } else {
10526                cast(a, &DataType::Utf8).map_err(DataFusionError::from)
10527            }
10528        };
10529        let date_src = typed_or_utf8(&cols[0])?;
10530        let time_src = typed_or_utf8(&cols[1])?;
10531        let ov: Vec<_> = cols[2..16]
10532            .iter()
10533            .map(|c| cast(c, &DataType::Int64))
10534            .collect::<std::result::Result<Vec<_>, _>>()
10535            .map_err(DataFusionError::from)?;
10536
10537        let base_date = |i: usize| -> Option<i64> {
10538            if date_src.is_null(i) {
10539                return Some(0); // a missing date defaults to the epoch (day 0)
10540            }
10541            match date_src.data_type() {
10542                DataType::Struct(_) => {
10543                    let s = date_src.as_any().downcast_ref::<StructArray>()?;
10544                    if is_date_struct(date_src.data_type()) {
10545                        date_struct_value(s, i)
10546                    } else {
10547                        // A `localdatetime`/`datetime` value — take its date.
10548                        Some(localdatetime_struct_parts(s, i)?.0)
10549                    }
10550                }
10551                DataType::Utf8 => parse_date_or_datetime_prefix(
10552                    date_src.as_any().downcast_ref::<StringArray>()?.value(i),
10553                ),
10554                _ => None,
10555            }
10556        };
10557        let base_time = |i: usize| -> Option<i64> {
10558            if time_src.is_null(i) {
10559                return Some(0); // a missing time defaults to midnight
10560            }
10561            match time_src.data_type() {
10562                DataType::Time64(TimeUnit::Nanosecond) => Some(
10563                    time_src
10564                        .as_any()
10565                        .downcast_ref::<Time64NanosecondArray>()?
10566                        .value(i),
10567                ),
10568                DataType::Struct(_) => {
10569                    let s = time_src.as_any().downcast_ref::<StructArray>()?;
10570                    // A date-only source (bare `localdatetime(date(…))`, where the
10571                    // same value feeds both slots) has no time-of-day → midnight,
10572                    // matching `localdatetime({date: d})`. (A time-only source in
10573                    // the *date* slot correctly stays null — no date to fabricate.)
10574                    if is_date_struct(time_src.data_type()) {
10575                        Some(0)
10576                    } else if is_time_struct(time_src.data_type()) {
10577                        Some(time_struct_parts(s, i)?.0)
10578                    } else {
10579                        Some(localdatetime_struct_parts(s, i)?.1)
10580                    }
10581                }
10582                DataType::Utf8 => {
10583                    time_of_day_nanos_any(time_src.as_any().downcast_ref::<StringArray>()?.value(i))
10584                }
10585                _ => None,
10586            }
10587        };
10588        let field = |idx: usize, i: usize| -> Option<i64> {
10589            let a = ov[idx].as_any().downcast_ref::<Int64Array>()?;
10590            (!a.is_null(i)).then(|| a.value(i))
10591        };
10592
10593        let parts: Vec<Option<(i64, i64)>> = (0..rows)
10594            .map(|i| {
10595                let date_overrides = DateOverrides {
10596                    year: field(0, i),
10597                    month: field(1, i),
10598                    day: field(2, i),
10599                    week: field(3, i),
10600                    day_of_week: field(4, i),
10601                    ordinal_day: field(5, i),
10602                    quarter: field(6, i),
10603                    day_of_quarter: field(7, i),
10604                };
10605                let time_overrides = LocalTimeOverrides {
10606                    hour: field(8, i),
10607                    minute: field(9, i),
10608                    second: field(10, i),
10609                    millisecond: field(11, i),
10610                    microsecond: field(12, i),
10611                    nanosecond: field(13, i),
10612                };
10613                let date = project_date(base_date(i)?, &date_overrides)?;
10614                let time = project_localtime(base_time(i)?, &time_overrides)?;
10615                Some((date, time))
10616            })
10617            .collect();
10618        Ok(ColumnarValue::Array(std::sync::Arc::new(
10619            build_localdatetime_struct(&parts),
10620        )))
10621    }
10622}
10623
10624// ---------------------------------------------------------------------------
10625// cypher_localdatetime_truncate UDF
10626// ---------------------------------------------------------------------------
10627
10628/// `localdatetime.truncate(unit, value, map)` (`Temporal9`): truncate `value` to
10629/// `unit` — for `day`-and-coarser units the date is truncated and the time zeroed
10630/// to midnight; for finer units (`hour`…`microsecond`) the date is kept and the
10631/// time-of-day floored — then the override `map` is applied. Args are `[value,
10632/// unit, year, month, day, week, dayOfWeek, ordinalDay, quarter, dayOfQuarter,
10633/// hour, minute, second, millisecond, microsecond, nanosecond]`. Returns the
10634/// `localdatetime` struct. (#920)
10635static CYPHER_LOCALDATETIME_TRUNCATE: LazyLock<ScalarUDF> =
10636    LazyLock::new(|| ScalarUDF::new_from_impl(CypherLocalDateTimeTruncate::new()));
10637
10638#[derive(Debug, PartialEq, Eq, Hash)]
10639struct CypherLocalDateTimeTruncate {
10640    signature: Signature,
10641}
10642
10643impl CypherLocalDateTimeTruncate {
10644    fn new() -> Self {
10645        Self {
10646            signature: Signature::any(16, Volatility::Immutable),
10647        }
10648    }
10649}
10650
10651impl ScalarUDFImpl for CypherLocalDateTimeTruncate {
10652    fn as_any(&self) -> &dyn Any {
10653        self
10654    }
10655
10656    fn name(&self) -> &'static str {
10657        "cypher_localdatetime_truncate"
10658    }
10659
10660    fn signature(&self) -> &Signature {
10661        &self.signature
10662    }
10663
10664    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
10665        Ok(DataType::Struct(localdatetime_fields()))
10666    }
10667
10668    #[allow(
10669        clippy::too_many_lines,
10670        reason = "one cohesive per-row truncation: typed source extraction, the \
10671                  date/time granularity split, and 14 component overrides"
10672    )]
10673    fn invoke_with_args(
10674        &self,
10675        args: ScalarFunctionArgs,
10676    ) -> datafusion::error::Result<ColumnarValue> {
10677        use crate::temporal::{
10678            DateOverrides, LocalTimeOverrides, parse_date_or_datetime_prefix, project_date,
10679            project_localtime, time_of_day_nanos_any, truncate_date, truncate_time_nanos,
10680        };
10681        use datafusion::arrow::array::{
10682            Array, ArrayRef, Int64Array, StringArray, StructArray, Time64NanosecondArray,
10683        };
10684        use datafusion::arrow::compute::cast;
10685        use datafusion::arrow::datatypes::TimeUnit;
10686        use datafusion::error::DataFusionError;
10687
10688        let rows = args.number_rows;
10689        let cols: Vec<_> = args
10690            .args
10691            .iter()
10692            .map(|a| a.to_array(rows))
10693            .collect::<datafusion::error::Result<_>>()?;
10694        // One `value` source feeds both the date and time components.
10695        let value: ArrayRef =
10696            if matches!(cols[0].data_type(), DataType::Time64(TimeUnit::Nanosecond))
10697                || is_date_struct(cols[0].data_type())
10698                || is_localdatetime_struct(cols[0].data_type())
10699                || is_time_struct(cols[0].data_type())
10700                || is_datetime_struct(cols[0].data_type())
10701            {
10702                std::sync::Arc::clone(&cols[0])
10703            } else {
10704                cast(&cols[0], &DataType::Utf8).map_err(DataFusionError::from)?
10705            };
10706        let units_arr = cast(&cols[1], &DataType::Utf8).map_err(DataFusionError::from)?;
10707        let units = units_arr.as_any().downcast_ref::<StringArray>();
10708        let ov: Vec<_> = cols[2..16]
10709            .iter()
10710            .map(|c| cast(c, &DataType::Int64))
10711            .collect::<std::result::Result<Vec<_>, _>>()
10712            .map_err(DataFusionError::from)?;
10713
10714        let base_date = |i: usize| -> Option<i64> {
10715            if value.is_null(i) {
10716                return None;
10717            }
10718            match value.data_type() {
10719                DataType::Struct(_) => {
10720                    let s = value.as_any().downcast_ref::<StructArray>()?;
10721                    if is_date_struct(value.data_type()) {
10722                        date_struct_value(s, i)
10723                    } else {
10724                        Some(localdatetime_struct_parts(s, i)?.0)
10725                    }
10726                }
10727                DataType::Utf8 => parse_date_or_datetime_prefix(
10728                    value.as_any().downcast_ref::<StringArray>()?.value(i),
10729                ),
10730                _ => None,
10731            }
10732        };
10733        let base_time = |i: usize| -> Option<i64> {
10734            if value.is_null(i) {
10735                return None;
10736            }
10737            match value.data_type() {
10738                DataType::Time64(TimeUnit::Nanosecond) => Some(
10739                    value
10740                        .as_any()
10741                        .downcast_ref::<Time64NanosecondArray>()?
10742                        .value(i),
10743                ),
10744                DataType::Struct(_) => {
10745                    let s = value.as_any().downcast_ref::<StructArray>()?;
10746                    if is_date_struct(value.data_type()) {
10747                        Some(0) // date-only → midnight
10748                    } else if is_time_struct(value.data_type()) {
10749                        Some(time_struct_parts(s, i)?.0)
10750                    } else {
10751                        Some(localdatetime_struct_parts(s, i)?.1)
10752                    }
10753                }
10754                DataType::Utf8 => {
10755                    time_of_day_nanos_any(value.as_any().downcast_ref::<StringArray>()?.value(i))
10756                }
10757                _ => None,
10758            }
10759        };
10760        let field = |idx: usize, i: usize| -> Option<i64> {
10761            let a = ov[idx].as_any().downcast_ref::<Int64Array>()?;
10762            (!a.is_null(i)).then(|| a.value(i))
10763        };
10764
10765        let parts: Vec<Option<(i64, i64)>> = (0..rows)
10766            .map(|i| {
10767                let u = units?;
10768                if u.is_null(i) {
10769                    return None;
10770                }
10771                // A `day`-and-coarser unit truncates the date and zeroes the time;
10772                // a finer unit keeps the date and floors the time-of-day.
10773                let (date, time) = match truncate_date(base_date(i)?, u.value(i)) {
10774                    Some(d) => (d, 0i64),
10775                    None => (
10776                        base_date(i)?,
10777                        truncate_time_nanos(base_time(i)?, u.value(i))?,
10778                    ),
10779                };
10780                let date_overrides = DateOverrides {
10781                    year: field(0, i),
10782                    month: field(1, i),
10783                    day: field(2, i),
10784                    week: field(3, i),
10785                    day_of_week: field(4, i),
10786                    ordinal_day: field(5, i),
10787                    quarter: field(6, i),
10788                    day_of_quarter: field(7, i),
10789                };
10790                let time_overrides = LocalTimeOverrides {
10791                    hour: field(8, i),
10792                    minute: field(9, i),
10793                    second: field(10, i),
10794                    millisecond: field(11, i),
10795                    microsecond: field(12, i),
10796                    nanosecond: field(13, i),
10797                };
10798                let date = project_date(date, &date_overrides)?;
10799                let time = project_localtime(time, &time_overrides)?;
10800                Some((date, time))
10801            })
10802            .collect();
10803        Ok(ColumnarValue::Array(std::sync::Arc::new(
10804            build_localdatetime_struct(&parts),
10805        )))
10806    }
10807}
10808
10809// ---------------------------------------------------------------------------
10810// cypher_time_project UDF + time-struct helpers
10811// ---------------------------------------------------------------------------
10812
10813/// The Arrow fields of a `time` value — `Struct{time: Time64(Nanosecond),
10814/// offset: Int32}` (nanoseconds-of-day + zone offset in seconds).
10815fn time_fields() -> datafusion::arrow::datatypes::Fields {
10816    graphforge_storage::schemas::time_struct_fields()
10817}
10818
10819/// True if `dt` is the `time` struct type (dispatches base extraction/rendering
10820/// without colliding with the `localdatetime` struct or user maps).
10821fn is_time_struct(dt: &DataType) -> bool {
10822    use datafusion::arrow::datatypes::TimeUnit;
10823    matches!(dt, DataType::Struct(fields)
10824        if fields.len() == 2
10825            && fields[0].name() == "time"
10826            && *fields[0].data_type() == DataType::Time64(TimeUnit::Nanosecond)
10827            && fields[1].name() == "offset"
10828            && *fields[1].data_type() == DataType::Int32)
10829}
10830
10831/// Build a `time` struct array from per-row `(nanos_of_day, offset_seconds)`
10832/// (`None` ⇒ a null row).
10833fn build_time_struct(rows: &[Option<(i64, i32)>]) -> datafusion::arrow::array::StructArray {
10834    use datafusion::arrow::array::{Int32Array, Time64NanosecondArray};
10835    use datafusion::arrow::buffer::NullBuffer;
10836    let nanos: Time64NanosecondArray = rows.iter().map(|r| r.map(|(n, _)| n)).collect();
10837    let offset: Int32Array = rows.iter().map(|r| r.map(|(_, o)| o)).collect();
10838    let nulls = rows.iter().map(Option::is_some).collect::<NullBuffer>();
10839    datafusion::arrow::array::StructArray::new(
10840        time_fields(),
10841        vec![std::sync::Arc::new(nanos), std::sync::Arc::new(offset)],
10842        Some(nulls),
10843    )
10844}
10845
10846/// A `time` scalar (`None` ⇒ a null value).
10847fn time_scalar(parts: Option<(i64, i32)>) -> ScalarValue {
10848    ScalarValue::Struct(std::sync::Arc::new(build_time_struct(&[parts])))
10849}
10850
10851/// Extract `(nanos_of_day, offset_seconds)` from a `time` struct array at row
10852/// `i` (`None` for a null row).
10853fn time_struct_parts(arr: &datafusion::arrow::array::StructArray, i: usize) -> Option<(i64, i32)> {
10854    use datafusion::arrow::array::{Array, Int32Array, Time64NanosecondArray};
10855    if arr.is_null(i) {
10856        return None;
10857    }
10858    let t = arr
10859        .column(0)
10860        .as_any()
10861        .downcast_ref::<Time64NanosecondArray>()?;
10862    let o = arr.column(1).as_any().downcast_ref::<Int32Array>()?;
10863    (!t.is_null(i) && !o.is_null(i)).then(|| (t.value(i), o.value(i)))
10864}
10865
10866/// `time`-from-value projection (`Temporal3`). Args are `[base, hour, minute,
10867/// second, millisecond, microsecond, nanosecond, timezone]`. `base` is a
10868/// `Time64`/`time`-struct/`localdatetime`-struct/temporal-string (its time-of-day
10869/// and, for `time`/`datetime` bases, its offset are read); the six integer
10870/// overrides adjust the time-of-day; `timezone` (a `+HH:MM`/`Z` string, or null)
10871/// attaches a zone — shifting the wall-clock time if the base already had one.
10872/// Returns the `time` struct. (ADR 0009)
10873static CYPHER_TIME_PROJECT: LazyLock<ScalarUDF> =
10874    LazyLock::new(|| ScalarUDF::new_from_impl(CypherTimeProject::new()));
10875
10876#[derive(Debug, PartialEq, Eq, Hash)]
10877struct CypherTimeProject {
10878    signature: Signature,
10879}
10880
10881impl CypherTimeProject {
10882    fn new() -> Self {
10883        Self {
10884            signature: Signature::any(8, Volatility::Immutable),
10885        }
10886    }
10887}
10888
10889impl ScalarUDFImpl for CypherTimeProject {
10890    fn as_any(&self) -> &dyn Any {
10891        self
10892    }
10893
10894    fn name(&self) -> &'static str {
10895        "cypher_time_project"
10896    }
10897
10898    fn signature(&self) -> &Signature {
10899        &self.signature
10900    }
10901
10902    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
10903        Ok(DataType::Struct(time_fields()))
10904    }
10905
10906    fn invoke_with_args(
10907        &self,
10908        args: ScalarFunctionArgs,
10909    ) -> datafusion::error::Result<ColumnarValue> {
10910        use crate::temporal::{
10911            LocalTimeOverrides, parse_offset_seconds, project_localtime, project_time,
10912            time_of_day_with_offset,
10913        };
10914        use datafusion::arrow::array::{
10915            Array, ArrayRef, Int64Array, StringArray, StructArray, Time64NanosecondArray,
10916        };
10917        use datafusion::arrow::compute::cast;
10918        use datafusion::arrow::datatypes::TimeUnit;
10919        use datafusion::error::DataFusionError;
10920
10921        let rows = args.number_rows;
10922        let cols: Vec<_> = args
10923            .args
10924            .iter()
10925            .map(|a| a.to_array(rows))
10926            .collect::<datafusion::error::Result<_>>()?;
10927        let base: ArrayRef =
10928            if matches!(cols[0].data_type(), DataType::Time64(TimeUnit::Nanosecond))
10929                || is_time_struct(cols[0].data_type())
10930                || is_localdatetime_struct(cols[0].data_type())
10931                || is_datetime_struct(cols[0].data_type())
10932            {
10933                std::sync::Arc::clone(&cols[0])
10934            } else {
10935                cast(&cols[0], &DataType::Utf8).map_err(DataFusionError::from)?
10936            };
10937        let ov: Vec<_> = cols[1..7]
10938            .iter()
10939            .map(|c| cast(c, &DataType::Int64))
10940            .collect::<std::result::Result<Vec<_>, _>>()
10941            .map_err(DataFusionError::from)?;
10942        let tz_arr = cast(&cols[7], &DataType::Utf8).map_err(DataFusionError::from)?;
10943        let tz = tz_arr.as_any().downcast_ref::<StringArray>();
10944
10945        // Base time-of-day + whether it carried an offset (`None` ⇒ attach a new
10946        // zone; `Some` ⇒ shift to preserve the instant).
10947        let base_parts = |i: usize| -> Option<(i64, Option<i32>)> {
10948            if base.is_null(i) {
10949                return None;
10950            }
10951            match base.data_type() {
10952                DataType::Time64(TimeUnit::Nanosecond) => Some((
10953                    base.as_any()
10954                        .downcast_ref::<Time64NanosecondArray>()?
10955                        .value(i),
10956                    None,
10957                )),
10958                DataType::Struct(_) => {
10959                    let s = base.as_any().downcast_ref::<StructArray>()?;
10960                    if is_time_struct(base.data_type()) {
10961                        let (n, o) = time_struct_parts(s, i)?;
10962                        Some((n, Some(o)))
10963                    } else if is_datetime_struct(base.data_type()) {
10964                        // A `datetime` carries its zone offset — keep it so a new
10965                        // zone shifts the instant (`time(datetime)`).
10966                        let (_, n, o, _) = datetime_struct_parts(s, i)?;
10967                        Some((n, Some(o)))
10968                    } else {
10969                        // localdatetime struct — its time-of-day, no offset.
10970                        Some((localdatetime_struct_parts(s, i)?.1, None))
10971                    }
10972                }
10973                DataType::Utf8 => {
10974                    time_of_day_with_offset(base.as_any().downcast_ref::<StringArray>()?.value(i))
10975                }
10976                _ => None,
10977            }
10978        };
10979        let field = |idx: usize, i: usize| -> Option<i64> {
10980            let a = ov[idx].as_any().downcast_ref::<Int64Array>()?;
10981            (!a.is_null(i)).then(|| a.value(i))
10982        };
10983
10984        let parts: Vec<Option<(i64, i32)>> = (0..rows)
10985            .map(|i| {
10986                let (base_nanos, base_offset) = base_parts(i)?;
10987                let overrides = LocalTimeOverrides {
10988                    hour: field(0, i),
10989                    minute: field(1, i),
10990                    second: field(2, i),
10991                    millisecond: field(3, i),
10992                    microsecond: field(4, i),
10993                    nanosecond: field(5, i),
10994                };
10995                let nanos = project_localtime(base_nanos, &overrides)?;
10996                // A `timezone` override (offset string) re-zones the value.
10997                let new_offset = match tz {
10998                    Some(a) if !a.is_null(i) => Some(parse_offset_seconds(a.value(i))?),
10999                    _ => None,
11000                };
11001                Some(project_time(nanos, base_offset, new_offset))
11002            })
11003            .collect();
11004        Ok(ColumnarValue::Array(std::sync::Arc::new(
11005            build_time_struct(&parts),
11006        )))
11007    }
11008}
11009
11010// ---------------------------------------------------------------------------
11011// cypher_time_truncate UDF
11012// ---------------------------------------------------------------------------
11013
11014/// `time.truncate(unit, value, map)` (`Temporal9`): truncate `value`'s
11015/// time-of-day to `unit` (keeping its zone offset), then apply the override
11016/// `map`. Args are `[value, unit, hour, minute, second, millisecond,
11017/// microsecond, nanosecond, timezone]`. Returns the `time` struct. (#920)
11018static CYPHER_TIME_TRUNCATE: LazyLock<ScalarUDF> =
11019    LazyLock::new(|| ScalarUDF::new_from_impl(CypherTimeTruncate::new()));
11020
11021#[derive(Debug, PartialEq, Eq, Hash)]
11022struct CypherTimeTruncate {
11023    signature: Signature,
11024}
11025
11026impl CypherTimeTruncate {
11027    fn new() -> Self {
11028        Self {
11029            signature: Signature::any(9, Volatility::Immutable),
11030        }
11031    }
11032}
11033
11034impl ScalarUDFImpl for CypherTimeTruncate {
11035    fn as_any(&self) -> &dyn Any {
11036        self
11037    }
11038
11039    fn name(&self) -> &'static str {
11040        "cypher_time_truncate"
11041    }
11042
11043    fn signature(&self) -> &Signature {
11044        &self.signature
11045    }
11046
11047    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
11048        Ok(DataType::Struct(time_fields()))
11049    }
11050
11051    fn invoke_with_args(
11052        &self,
11053        args: ScalarFunctionArgs,
11054    ) -> datafusion::error::Result<ColumnarValue> {
11055        use crate::temporal::{
11056            LocalTimeOverrides, parse_offset_seconds, project_localtime, project_time,
11057            time_of_day_with_offset, truncate_time_nanos,
11058        };
11059        use datafusion::arrow::array::{
11060            Array, ArrayRef, Int64Array, StringArray, StructArray, Time64NanosecondArray,
11061        };
11062        use datafusion::arrow::compute::cast;
11063        use datafusion::arrow::datatypes::TimeUnit;
11064        use datafusion::error::DataFusionError;
11065
11066        let rows = args.number_rows;
11067        let cols: Vec<_> = args
11068            .args
11069            .iter()
11070            .map(|a| a.to_array(rows))
11071            .collect::<datafusion::error::Result<_>>()?;
11072        let base: ArrayRef =
11073            if matches!(cols[0].data_type(), DataType::Time64(TimeUnit::Nanosecond))
11074                || is_time_struct(cols[0].data_type())
11075                || is_localdatetime_struct(cols[0].data_type())
11076                || is_datetime_struct(cols[0].data_type())
11077            {
11078                std::sync::Arc::clone(&cols[0])
11079            } else {
11080                cast(&cols[0], &DataType::Utf8).map_err(DataFusionError::from)?
11081            };
11082        let units_arr = cast(&cols[1], &DataType::Utf8).map_err(DataFusionError::from)?;
11083        let units = units_arr.as_any().downcast_ref::<StringArray>();
11084        let ov: Vec<_> = cols[2..8]
11085            .iter()
11086            .map(|c| cast(c, &DataType::Int64))
11087            .collect::<std::result::Result<Vec<_>, _>>()
11088            .map_err(DataFusionError::from)?;
11089        let tz_arr = cast(&cols[8], &DataType::Utf8).map_err(DataFusionError::from)?;
11090        let tz = tz_arr.as_any().downcast_ref::<StringArray>();
11091
11092        let base_parts = |i: usize| -> Option<(i64, Option<i32>)> {
11093            if base.is_null(i) {
11094                return None;
11095            }
11096            match base.data_type() {
11097                DataType::Time64(TimeUnit::Nanosecond) => Some((
11098                    base.as_any()
11099                        .downcast_ref::<Time64NanosecondArray>()?
11100                        .value(i),
11101                    None,
11102                )),
11103                DataType::Struct(_) => {
11104                    let s = base.as_any().downcast_ref::<StructArray>()?;
11105                    if is_time_struct(base.data_type()) {
11106                        let (n, o) = time_struct_parts(s, i)?;
11107                        Some((n, Some(o)))
11108                    } else if is_datetime_struct(base.data_type()) {
11109                        let (_, n, o, _) = datetime_struct_parts(s, i)?;
11110                        Some((n, Some(o)))
11111                    } else {
11112                        Some((localdatetime_struct_parts(s, i)?.1, None))
11113                    }
11114                }
11115                DataType::Utf8 => {
11116                    time_of_day_with_offset(base.as_any().downcast_ref::<StringArray>()?.value(i))
11117                }
11118                _ => None,
11119            }
11120        };
11121        let field = |idx: usize, i: usize| -> Option<i64> {
11122            let a = ov[idx].as_any().downcast_ref::<Int64Array>()?;
11123            (!a.is_null(i)).then(|| a.value(i))
11124        };
11125
11126        let parts: Vec<Option<(i64, i32)>> = (0..rows)
11127            .map(|i| {
11128                let u = units?;
11129                if u.is_null(i) {
11130                    return None;
11131                }
11132                let (base_nanos, base_offset) = base_parts(i)?;
11133                let truncated = truncate_time_nanos(base_nanos, u.value(i))?;
11134                let overrides = LocalTimeOverrides {
11135                    hour: field(0, i),
11136                    minute: field(1, i),
11137                    second: field(2, i),
11138                    millisecond: field(3, i),
11139                    microsecond: field(4, i),
11140                    nanosecond: field(5, i),
11141                };
11142                let nanos = project_localtime(truncated, &overrides)?;
11143                let new_offset = match tz {
11144                    Some(a) if !a.is_null(i) => Some(parse_offset_seconds(a.value(i))?),
11145                    _ => None,
11146                };
11147                // A `timezone` override ATTACHES to the truncated wall-clock (the
11148                // instant is not shifted) — pass `None` as the base offset so
11149                // `project_time` attaches, mirroring datetime.truncate (#990).
11150                // (#1008, Temporal9 [5])
11151                let eff_offset = if new_offset.is_some() {
11152                    None
11153                } else {
11154                    base_offset
11155                };
11156                Some(project_time(nanos, eff_offset, new_offset))
11157            })
11158            .collect();
11159        Ok(ColumnarValue::Array(std::sync::Arc::new(
11160            build_time_struct(&parts),
11161        )))
11162    }
11163}
11164
11165// ---------------------------------------------------------------------------
11166// cypher_datetime_project UDF + datetime-struct helpers
11167// ---------------------------------------------------------------------------
11168
11169/// One `datetime` row: `(date_days, nanos_of_day, offset_seconds, zone_label)`,
11170/// or `None` for a null value.
11171type DateTimeRow = Option<(i64, i64, i32, Option<String>)>;
11172
11173/// The Arrow fields of a `datetime` value — `Struct{date: Int64, time:
11174/// Time64(Nanosecond), offset: Int32, zone: Utf8}` (the date = i64 days,
11175/// time-of-day, resolved zone offset in seconds, and an optional named-IANA-zone
11176/// label). (#1011)
11177fn datetime_fields() -> datafusion::arrow::datatypes::Fields {
11178    graphforge_storage::schemas::datetime_struct_fields()
11179}
11180
11181/// True if `dt` is the `datetime` struct type.
11182fn is_datetime_struct(dt: &DataType) -> bool {
11183    use datafusion::arrow::datatypes::TimeUnit;
11184    matches!(dt, DataType::Struct(fields)
11185        if fields.len() == 4
11186            && fields[0].name() == "date" && *fields[0].data_type() == DataType::Int64
11187            && fields[1].name() == "time"
11188            && *fields[1].data_type() == DataType::Time64(TimeUnit::Nanosecond)
11189            && fields[2].name() == "offset" && *fields[2].data_type() == DataType::Int32
11190            && fields[3].name() == "zone" && *fields[3].data_type() == DataType::Utf8)
11191}
11192
11193/// Build a `datetime` struct array from per-row `(date_days, nanos_of_day,
11194/// offset_seconds, zone_label)` (`None` ⇒ a null row).
11195fn build_datetime_struct(rows: &[DateTimeRow]) -> datafusion::arrow::array::StructArray {
11196    use datafusion::arrow::array::{Int32Array, Int64Array, StringArray, Time64NanosecondArray};
11197    use datafusion::arrow::buffer::NullBuffer;
11198    let days: Int64Array = rows.iter().map(|r| r.as_ref().map(|t| t.0)).collect();
11199    let nanos: Time64NanosecondArray = rows.iter().map(|r| r.as_ref().map(|t| t.1)).collect();
11200    let offset: Int32Array = rows.iter().map(|r| r.as_ref().map(|t| t.2)).collect();
11201    // The zone field is empty (NOT null) when there is no named zone, so two
11202    // offset-only datetimes compare equal — `cypher_struct_eq` propagates null,
11203    // and a null=null field would make the whole equality null (Temporal7 [5]).
11204    let zone: StringArray = rows
11205        .iter()
11206        .map(|r| r.as_ref().map(|t| t.3.clone().unwrap_or_default()))
11207        .collect();
11208    let nulls = rows.iter().map(Option::is_some).collect::<NullBuffer>();
11209    datafusion::arrow::array::StructArray::new(
11210        datetime_fields(),
11211        vec![
11212            std::sync::Arc::new(days),
11213            std::sync::Arc::new(nanos),
11214            std::sync::Arc::new(offset),
11215            std::sync::Arc::new(zone),
11216        ],
11217        Some(nulls),
11218    )
11219}
11220
11221/// A `datetime` scalar (`None` ⇒ a null value).
11222fn datetime_scalar(parts: DateTimeRow) -> ScalarValue {
11223    ScalarValue::Struct(std::sync::Arc::new(build_datetime_struct(&[parts])))
11224}
11225
11226/// Extract `(date_days, nanos_of_day, offset_seconds, zone_label)` from a
11227/// `datetime` struct array at row `i` (`None` for a null row).
11228fn datetime_struct_parts(arr: &datafusion::arrow::array::StructArray, i: usize) -> DateTimeRow {
11229    use datafusion::arrow::array::{
11230        Array, Int32Array, Int64Array, StringArray, Time64NanosecondArray,
11231    };
11232    if arr.is_null(i) {
11233        return None;
11234    }
11235    let days = arr.column(0).as_any().downcast_ref::<Int64Array>()?;
11236    let nanos = arr
11237        .column(1)
11238        .as_any()
11239        .downcast_ref::<Time64NanosecondArray>()?;
11240    let offset = arr.column(2).as_any().downcast_ref::<Int32Array>()?;
11241    let zone = arr.column(3).as_any().downcast_ref::<StringArray>()?;
11242    if days.is_null(i) || nanos.is_null(i) || offset.is_null(i) {
11243        return None;
11244    }
11245    // An empty zone label means "no named zone" (offset-only datetime).
11246    let zone_label =
11247        (!zone.is_null(i) && !zone.value(i).is_empty()).then(|| zone.value(i).to_string());
11248    Some((days.value(i), nanos.value(i), offset.value(i), zone_label))
11249}
11250
11251/// `datetime`-from-value projection (`Temporal3` [8]-[11]). Args are `[date_src,
11252/// time_src, year, month, day, week, dayOfWeek, ordinalDay, quarter,
11253/// dayOfQuarter, hour, minute, second, millisecond, microsecond, nanosecond,
11254/// timezone]`. The date/time sources are any temporal value/string (the time
11255/// source also carries the source offset + named zone); the 14 integer overrides
11256/// adjust the local date/time; `timezone` re-zones. Returns the `datetime`
11257/// struct. (ADR 0009)
11258static CYPHER_DATETIME_PROJECT: LazyLock<ScalarUDF> =
11259    LazyLock::new(|| ScalarUDF::new_from_impl(CypherDateTimeProject::new()));
11260
11261#[derive(Debug, PartialEq, Eq, Hash)]
11262struct CypherDateTimeProject {
11263    signature: Signature,
11264}
11265
11266impl CypherDateTimeProject {
11267    fn new() -> Self {
11268        Self {
11269            signature: Signature::any(17, Volatility::Immutable),
11270        }
11271    }
11272}
11273
11274impl ScalarUDFImpl for CypherDateTimeProject {
11275    fn as_any(&self) -> &dyn Any {
11276        self
11277    }
11278
11279    fn name(&self) -> &'static str {
11280        "cypher_datetime_project"
11281    }
11282
11283    fn signature(&self) -> &Signature {
11284        &self.signature
11285    }
11286
11287    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
11288        Ok(DataType::Struct(datetime_fields()))
11289    }
11290
11291    #[allow(
11292        clippy::too_many_lines,
11293        reason = "one cohesive per-row projection: typed date/time/zone source \
11294                  extraction, 14 component overrides, and zone re-resolution"
11295    )]
11296    fn invoke_with_args(
11297        &self,
11298        args: ScalarFunctionArgs,
11299    ) -> datafusion::error::Result<ColumnarValue> {
11300        use crate::temporal::{
11301            DateOverrides, LocalTimeOverrides, parse_date_or_datetime_prefix, project_date,
11302            project_datetime, project_localtime, time_offset_zone,
11303        };
11304        use datafusion::arrow::array::{
11305            Array, ArrayRef, Int64Array, StringArray, StructArray, Time64NanosecondArray,
11306        };
11307        use datafusion::arrow::compute::cast;
11308        use datafusion::arrow::datatypes::TimeUnit;
11309        use datafusion::error::DataFusionError;
11310
11311        let rows = args.number_rows;
11312        let cols: Vec<_> = args
11313            .args
11314            .iter()
11315            .map(|a| a.to_array(rows))
11316            .collect::<datafusion::error::Result<_>>()?;
11317        let typed_or_utf8 = |a: &ArrayRef| -> datafusion::error::Result<ArrayRef> {
11318            if matches!(a.data_type(), DataType::Time64(TimeUnit::Nanosecond))
11319                || is_date_struct(a.data_type())
11320                || is_localdatetime_struct(a.data_type())
11321                || is_time_struct(a.data_type())
11322                || is_datetime_struct(a.data_type())
11323            {
11324                Ok(std::sync::Arc::clone(a))
11325            } else {
11326                cast(a, &DataType::Utf8).map_err(DataFusionError::from)
11327            }
11328        };
11329        let date_src = typed_or_utf8(&cols[0])?;
11330        let time_src = typed_or_utf8(&cols[1])?;
11331        let ov: Vec<_> = cols[2..16]
11332            .iter()
11333            .map(|c| cast(c, &DataType::Int64))
11334            .collect::<std::result::Result<Vec<_>, _>>()
11335            .map_err(DataFusionError::from)?;
11336        let tz_arr = cast(&cols[16], &DataType::Utf8).map_err(DataFusionError::from)?;
11337        let tz = tz_arr.as_any().downcast_ref::<StringArray>();
11338
11339        let base_date = |i: usize| -> Option<i64> {
11340            if date_src.is_null(i) {
11341                return Some(0); // a missing date defaults to the epoch (day 0)
11342            }
11343            match date_src.data_type() {
11344                DataType::Struct(_) => {
11345                    let s = date_src.as_any().downcast_ref::<StructArray>()?;
11346                    if is_date_struct(date_src.data_type()) {
11347                        date_struct_value(s, i)
11348                    } else if is_datetime_struct(date_src.data_type()) {
11349                        Some(datetime_struct_parts(s, i)?.0)
11350                    } else {
11351                        Some(localdatetime_struct_parts(s, i)?.0)
11352                    }
11353                }
11354                DataType::Utf8 => parse_date_or_datetime_prefix(
11355                    date_src.as_any().downcast_ref::<StringArray>()?.value(i),
11356                ),
11357                _ => None,
11358            }
11359        };
11360        // Base time-of-day plus the source's offset and named zone (if any).
11361        let base_time = |i: usize| -> Option<(i64, Option<i32>, Option<String>)> {
11362            if time_src.is_null(i) {
11363                return Some((0, None, None));
11364            }
11365            match time_src.data_type() {
11366                DataType::Time64(TimeUnit::Nanosecond) => Some((
11367                    time_src
11368                        .as_any()
11369                        .downcast_ref::<Time64NanosecondArray>()?
11370                        .value(i),
11371                    None,
11372                    None,
11373                )),
11374                DataType::Struct(_) => {
11375                    let s = time_src.as_any().downcast_ref::<StructArray>()?;
11376                    if is_date_struct(time_src.data_type()) {
11377                        // A bare date carries no time-of-day (matches the pre-#1011
11378                        // `Date32` fall-through: `datetime(date(…))` → null).
11379                        None
11380                    } else if is_datetime_struct(time_src.data_type()) {
11381                        let (_, n, o, z) = datetime_struct_parts(s, i)?;
11382                        Some((n, Some(o), z))
11383                    } else if is_time_struct(time_src.data_type()) {
11384                        let (n, o) = time_struct_parts(s, i)?;
11385                        Some((n, Some(o), None))
11386                    } else {
11387                        Some((localdatetime_struct_parts(s, i)?.1, None, None))
11388                    }
11389                }
11390                DataType::Utf8 => {
11391                    time_offset_zone(time_src.as_any().downcast_ref::<StringArray>()?.value(i))
11392                }
11393                _ => None,
11394            }
11395        };
11396        let field = |idx: usize, i: usize| -> Option<i64> {
11397            let a = ov[idx].as_any().downcast_ref::<Int64Array>()?;
11398            (!a.is_null(i)).then(|| a.value(i))
11399        };
11400
11401        let parts: Vec<DateTimeRow> = (0..rows)
11402            .map(|i| {
11403                let date_overrides = DateOverrides {
11404                    year: field(0, i),
11405                    month: field(1, i),
11406                    day: field(2, i),
11407                    week: field(3, i),
11408                    day_of_week: field(4, i),
11409                    ordinal_day: field(5, i),
11410                    quarter: field(6, i),
11411                    day_of_quarter: field(7, i),
11412                };
11413                let time_overrides = LocalTimeOverrides {
11414                    hour: field(8, i),
11415                    minute: field(9, i),
11416                    second: field(10, i),
11417                    millisecond: field(11, i),
11418                    microsecond: field(12, i),
11419                    nanosecond: field(13, i),
11420                };
11421                let (base_nanos, src_offset, src_zone) = base_time(i)?;
11422                let date = project_date(base_date(i)?, &date_overrides)?;
11423                let nanos = project_localtime(base_nanos, &time_overrides)?;
11424                let new_tz = tz.and_then(|a| (!a.is_null(i)).then(|| a.value(i)));
11425                let (date, nanos, offset, zone) =
11426                    project_datetime(date, nanos, src_offset, src_zone.as_deref(), new_tz)?;
11427                Some((date, nanos, offset, zone))
11428            })
11429            .collect();
11430        Ok(ColumnarValue::Array(std::sync::Arc::new(
11431            build_datetime_struct(&parts),
11432        )))
11433    }
11434}
11435
11436// ---------------------------------------------------------------------------
11437// cypher_datetime_truncate UDF
11438// ---------------------------------------------------------------------------
11439
11440/// `datetime.truncate(unit, value, map)` (`Temporal9`): truncate `value` to
11441/// `unit` — date for `day`-and-coarser units (time zeroed), time-of-day for finer
11442/// units — keeping the source zone, then apply the override `map` and optional
11443/// `timezone`. Args are `[value, unit, year, month, day, week, dayOfWeek,
11444/// ordinalDay, quarter, dayOfQuarter, hour, minute, second, millisecond,
11445/// microsecond, nanosecond, timezone]`. Returns the `datetime` struct. (#920)
11446static CYPHER_DATETIME_TRUNCATE: LazyLock<ScalarUDF> =
11447    LazyLock::new(|| ScalarUDF::new_from_impl(CypherDateTimeTruncate::new()));
11448
11449#[derive(Debug, PartialEq, Eq, Hash)]
11450struct CypherDateTimeTruncate {
11451    signature: Signature,
11452}
11453
11454impl CypherDateTimeTruncate {
11455    fn new() -> Self {
11456        Self {
11457            signature: Signature::any(17, Volatility::Immutable),
11458        }
11459    }
11460}
11461
11462impl ScalarUDFImpl for CypherDateTimeTruncate {
11463    fn as_any(&self) -> &dyn Any {
11464        self
11465    }
11466
11467    fn name(&self) -> &'static str {
11468        "cypher_datetime_truncate"
11469    }
11470
11471    fn signature(&self) -> &Signature {
11472        &self.signature
11473    }
11474
11475    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
11476        Ok(DataType::Struct(datetime_fields()))
11477    }
11478
11479    #[allow(
11480        clippy::too_many_lines,
11481        reason = "one cohesive per-row truncation: typed source extraction, the \
11482                  date/time granularity split, 14 overrides, and zone re-resolution"
11483    )]
11484    fn invoke_with_args(
11485        &self,
11486        args: ScalarFunctionArgs,
11487    ) -> datafusion::error::Result<ColumnarValue> {
11488        use crate::temporal::{
11489            DateOverrides, LocalTimeOverrides, parse_date_or_datetime_prefix, project_date,
11490            project_datetime, project_localtime, time_offset_zone, truncate_date,
11491            truncate_time_nanos,
11492        };
11493        use datafusion::arrow::array::{
11494            Array, ArrayRef, Int64Array, StringArray, StructArray, Time64NanosecondArray,
11495        };
11496        use datafusion::arrow::compute::cast;
11497        use datafusion::arrow::datatypes::TimeUnit;
11498        use datafusion::error::DataFusionError;
11499
11500        let rows = args.number_rows;
11501        let cols: Vec<_> = args
11502            .args
11503            .iter()
11504            .map(|a| a.to_array(rows))
11505            .collect::<datafusion::error::Result<_>>()?;
11506        // One `value` source feeds both the date and time components.
11507        let value: ArrayRef =
11508            if matches!(cols[0].data_type(), DataType::Time64(TimeUnit::Nanosecond))
11509                || is_date_struct(cols[0].data_type())
11510                || is_localdatetime_struct(cols[0].data_type())
11511                || is_time_struct(cols[0].data_type())
11512                || is_datetime_struct(cols[0].data_type())
11513            {
11514                std::sync::Arc::clone(&cols[0])
11515            } else {
11516                cast(&cols[0], &DataType::Utf8).map_err(DataFusionError::from)?
11517            };
11518        let units_arr = cast(&cols[1], &DataType::Utf8).map_err(DataFusionError::from)?;
11519        let units = units_arr.as_any().downcast_ref::<StringArray>();
11520        let ov: Vec<_> = cols[2..16]
11521            .iter()
11522            .map(|c| cast(c, &DataType::Int64))
11523            .collect::<std::result::Result<Vec<_>, _>>()
11524            .map_err(DataFusionError::from)?;
11525        let tz_arr = cast(&cols[16], &DataType::Utf8).map_err(DataFusionError::from)?;
11526        let tz = tz_arr.as_any().downcast_ref::<StringArray>();
11527
11528        let base_date = |i: usize| -> Option<i64> {
11529            if value.is_null(i) {
11530                return None;
11531            }
11532            match value.data_type() {
11533                DataType::Struct(_) => {
11534                    let s = value.as_any().downcast_ref::<StructArray>()?;
11535                    if is_date_struct(value.data_type()) {
11536                        date_struct_value(s, i)
11537                    } else if is_datetime_struct(value.data_type()) {
11538                        Some(datetime_struct_parts(s, i)?.0)
11539                    } else {
11540                        Some(localdatetime_struct_parts(s, i)?.0)
11541                    }
11542                }
11543                DataType::Utf8 => parse_date_or_datetime_prefix(
11544                    value.as_any().downcast_ref::<StringArray>()?.value(i),
11545                ),
11546                _ => None,
11547            }
11548        };
11549        // Base time-of-day plus the source's offset and named zone (if any).
11550        let base_time = |i: usize| -> Option<(i64, Option<i32>, Option<String>)> {
11551            if value.is_null(i) {
11552                return None;
11553            }
11554            match value.data_type() {
11555                DataType::Time64(TimeUnit::Nanosecond) => Some((
11556                    value
11557                        .as_any()
11558                        .downcast_ref::<Time64NanosecondArray>()?
11559                        .value(i),
11560                    None,
11561                    None,
11562                )),
11563                DataType::Struct(_) => {
11564                    let s = value.as_any().downcast_ref::<StructArray>()?;
11565                    if is_date_struct(value.data_type()) {
11566                        Some((0, None, None)) // date-only → midnight
11567                    } else if is_datetime_struct(value.data_type()) {
11568                        let (_, n, o, z) = datetime_struct_parts(s, i)?;
11569                        Some((n, Some(o), z))
11570                    } else if is_time_struct(value.data_type()) {
11571                        let (n, o) = time_struct_parts(s, i)?;
11572                        Some((n, Some(o), None))
11573                    } else {
11574                        Some((localdatetime_struct_parts(s, i)?.1, None, None))
11575                    }
11576                }
11577                DataType::Utf8 => {
11578                    time_offset_zone(value.as_any().downcast_ref::<StringArray>()?.value(i))
11579                }
11580                _ => None,
11581            }
11582        };
11583        let field = |idx: usize, i: usize| -> Option<i64> {
11584            let a = ov[idx].as_any().downcast_ref::<Int64Array>()?;
11585            (!a.is_null(i)).then(|| a.value(i))
11586        };
11587
11588        let parts: Vec<DateTimeRow> = (0..rows)
11589            .map(|i| {
11590                let u = units?;
11591                if u.is_null(i) {
11592                    return None;
11593                }
11594                let (bt_nanos, src_offset, src_zone) = base_time(i)?;
11595                // A `day`-and-coarser unit truncates the date and zeroes the time;
11596                // a finer unit keeps the date and floors the time-of-day.
11597                let (date0, nanos0) = match truncate_date(base_date(i)?, u.value(i)) {
11598                    Some(d) => (d, 0i64),
11599                    None => (base_date(i)?, truncate_time_nanos(bt_nanos, u.value(i))?),
11600                };
11601                let date_overrides = DateOverrides {
11602                    year: field(0, i),
11603                    month: field(1, i),
11604                    day: field(2, i),
11605                    week: field(3, i),
11606                    day_of_week: field(4, i),
11607                    ordinal_day: field(5, i),
11608                    quarter: field(6, i),
11609                    day_of_quarter: field(7, i),
11610                };
11611                let time_overrides = LocalTimeOverrides {
11612                    hour: field(8, i),
11613                    minute: field(9, i),
11614                    second: field(10, i),
11615                    millisecond: field(11, i),
11616                    microsecond: field(12, i),
11617                    nanosecond: field(13, i),
11618                };
11619                let date = project_date(date0, &date_overrides)?;
11620                let nanos = project_localtime(nanos0, &time_overrides)?;
11621                let new_tz = tz.and_then(|a| (!a.is_null(i)).then(|| a.value(i)));
11622                // Truncation is WALL-CLOCK preserving: a `{timezone: …}` override
11623                // ATTACHES the zone to the truncated local time (midnight stays
11624                // midnight), it does not re-express the source instant. So drop
11625                // the source offset when a new zone is given, forcing
11626                // `project_datetime`'s attach path instead of an instant shift
11627                // (#920 — otherwise `truncate(…, {timezone: 'Europe/Stockholm'})`
11628                // shifted midnight by the source offset).
11629                let src_offset = if new_tz.is_some() { None } else { src_offset };
11630                let (date, nanos, offset, zone) =
11631                    project_datetime(date, nanos, src_offset, src_zone.as_deref(), new_tz)?;
11632                Some((date, nanos, offset, zone))
11633            })
11634            .collect();
11635        Ok(ColumnarValue::Array(std::sync::Arc::new(
11636            build_datetime_struct(&parts),
11637        )))
11638    }
11639}
11640
11641// ---------------------------------------------------------------------------
11642// cypher_to_string UDF
11643// ---------------------------------------------------------------------------
11644
11645/// `toString(x)`: a typed temporal value renders to its canonical openCypher
11646/// string (a plain `cast` to `Utf8` would emit a fixed, untrimmed form for
11647/// `Time64`, and outright fail for the temporal structs). Handles `Date32`/
11648/// `Time64`/`localdatetime`/`time`; every other type — including `datetime`,
11649/// which is still a `Utf8` value until its migration — falls back to the same
11650/// `Utf8` cast as before, so non-temporal `toString` behaviour is unchanged.
11651/// (ADR 0009)
11652static CYPHER_TO_STRING: LazyLock<ScalarUDF> =
11653    LazyLock::new(|| ScalarUDF::new_from_impl(CypherToString::new()));
11654
11655#[derive(Debug, PartialEq, Eq, Hash)]
11656struct CypherToString {
11657    signature: Signature,
11658}
11659
11660impl CypherToString {
11661    fn new() -> Self {
11662        Self {
11663            signature: Signature::any(1, Volatility::Immutable),
11664        }
11665    }
11666}
11667
11668impl ScalarUDFImpl for CypherToString {
11669    fn as_any(&self) -> &dyn Any {
11670        self
11671    }
11672
11673    fn name(&self) -> &'static str {
11674        "cypher_to_string"
11675    }
11676
11677    fn signature(&self) -> &Signature {
11678        &self.signature
11679    }
11680
11681    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
11682        Ok(DataType::Utf8)
11683    }
11684
11685    fn invoke_with_args(
11686        &self,
11687        args: ScalarFunctionArgs,
11688    ) -> datafusion::error::Result<ColumnarValue> {
11689        use crate::temporal::{
11690            format_date, render_localdatetime, render_localtime_nanos, render_time_value,
11691        };
11692        use datafusion::arrow::array::{Array, StringArray, StructArray, Time64NanosecondArray};
11693        use datafusion::arrow::compute::cast;
11694        use datafusion::arrow::datatypes::TimeUnit;
11695
11696        let arr = args.args[0].to_array(args.number_rows)?;
11697        let rows = arr.len();
11698        // Render one canonical string per row from a closure, preserving nulls.
11699        let render = |f: &dyn Fn(usize) -> Option<String>| -> ColumnarValue {
11700            let out: StringArray = (0..rows)
11701                .map(|i| if arr.is_null(i) { None } else { f(i) })
11702                .collect();
11703            ColumnarValue::Array(std::sync::Arc::new(out))
11704        };
11705
11706        let result = match arr.data_type() {
11707            DataType::Struct(_) if is_date_struct(arr.data_type()) => {
11708                let s = arr.as_any().downcast_ref::<StructArray>().unwrap();
11709                render(&|i| date_struct_value(s, i).map(format_date))
11710            }
11711            DataType::Time64(TimeUnit::Nanosecond) => {
11712                let a = arr
11713                    .as_any()
11714                    .downcast_ref::<Time64NanosecondArray>()
11715                    .unwrap();
11716                render(&|i| Some(render_localtime_nanos(a.value(i))))
11717            }
11718            DataType::Struct(_) if is_localdatetime_struct(arr.data_type()) => {
11719                let s = arr.as_any().downcast_ref::<StructArray>().unwrap();
11720                render(&|i| {
11721                    localdatetime_struct_parts(s, i).map(|(d, n)| render_localdatetime(d, n))
11722                })
11723            }
11724            DataType::Struct(_) if is_time_struct(arr.data_type()) => {
11725                let s = arr.as_any().downcast_ref::<StructArray>().unwrap();
11726                render(&|i| time_struct_parts(s, i).map(|(n, o)| render_time_value(n, o)))
11727            }
11728            DataType::Struct(_) if is_datetime_struct(arr.data_type()) => {
11729                let s = arr.as_any().downcast_ref::<StructArray>().unwrap();
11730                render(&|i| {
11731                    datetime_struct_parts(s, i).map(|(d, n, o, z)| {
11732                        crate::temporal::render_datetime_value(d, n, o, z.as_deref())
11733                    })
11734                })
11735            }
11736            DataType::Struct(_) if is_duration_struct(arr.data_type()) => {
11737                let s = arr.as_any().downcast_ref::<StructArray>().unwrap();
11738                render(&|i| {
11739                    duration_struct_parts(s, i).map(|d| crate::temporal::render_duration_value(&d))
11740                })
11741            }
11742            DataType::Struct(_) if is_het_struct_type(Some(arr.data_type())) => {
11743                let out: datafusion::error::Result<StringArray> = (0..rows)
11744                    .map(|i| {
11745                        let value = decoded_scalar_at(&arr, i)?;
11746                        to_cypher_string(&value)
11747                    })
11748                    .collect();
11749                ColumnarValue::Array(std::sync::Arc::new(out?))
11750            }
11751            // Non-temporal (or non-temporal struct): the original `Utf8` cast.
11752            _ => ColumnarValue::Array(
11753                cast(&arr, &DataType::Utf8).map_err(datafusion::error::DataFusionError::from)?,
11754            ),
11755        };
11756        Ok(result)
11757    }
11758}
11759
11760/// Integer scalar as `i128` for exact cross-width integer equality/comparison.
11761fn scalar_as_i128(v: &ScalarValue) -> Option<i128> {
11762    match v {
11763        ScalarValue::Int8(Some(n)) => Some(i128::from(*n)),
11764        ScalarValue::Int16(Some(n)) => Some(i128::from(*n)),
11765        ScalarValue::Int32(Some(n)) => Some(i128::from(*n)),
11766        ScalarValue::Int64(Some(n)) => Some(i128::from(*n)),
11767        ScalarValue::UInt8(Some(n)) => Some(i128::from(*n)),
11768        ScalarValue::UInt16(Some(n)) => Some(i128::from(*n)),
11769        ScalarValue::UInt32(Some(n)) => Some(i128::from(*n)),
11770        ScalarValue::UInt64(Some(n)) => Some(i128::from(*n)),
11771        _ => None,
11772    }
11773}
11774
11775/// Numeric scalar as `f64` for cross integer/float equality and ordering.
11776fn scalar_as_f64(v: &ScalarValue) -> Option<f64> {
11777    #[allow(
11778        clippy::cast_precision_loss,
11779        reason = "only used for mixed integer/float numeric semantics; pure integers use i128"
11780    )]
11781    match v {
11782        ScalarValue::Int8(Some(n)) => Some(f64::from(*n)),
11783        ScalarValue::Int16(Some(n)) => Some(f64::from(*n)),
11784        ScalarValue::Int32(Some(n)) => Some(f64::from(*n)),
11785        ScalarValue::Int64(Some(n)) => Some(*n as f64),
11786        ScalarValue::UInt8(Some(n)) => Some(f64::from(*n)),
11787        ScalarValue::UInt16(Some(n)) => Some(f64::from(*n)),
11788        ScalarValue::UInt32(Some(n)) => Some(f64::from(*n)),
11789        ScalarValue::UInt64(Some(n)) => Some(*n as f64),
11790        ScalarValue::Float32(Some(f)) => Some(f64::from(*f)),
11791        ScalarValue::Float64(Some(f)) => Some(*f),
11792        _ => None,
11793    }
11794}
11795
11796/// Three-valued Cypher equality of two list element arrays.
11797fn cypher_seq_eq(
11798    a: &datafusion::arrow::array::ArrayRef,
11799    b: &datafusion::arrow::array::ArrayRef,
11800) -> Option<bool> {
11801    if a.len() != b.len() {
11802        return Some(false); // length mismatch ⇒ false, even with nulls present
11803    }
11804    let mut saw_null = false;
11805    for i in 0..a.len() {
11806        let av = ScalarValue::try_from_array(a, i).ok()?;
11807        let bv = ScalarValue::try_from_array(b, i).ok()?;
11808        match cypher_value_eq(&av, &bv) {
11809            Some(false) => return Some(false),
11810            None => saw_null = true,
11811            Some(true) => {}
11812        }
11813    }
11814    if saw_null { None } else { Some(true) }
11815}
11816
11817/// Three-valued Cypher equality of two map (`Struct`) values.
11818fn cypher_struct_eq(
11819    a: &datafusion::arrow::array::StructArray,
11820    b: &datafusion::arrow::array::StructArray,
11821) -> Option<bool> {
11822    // Key sets must match — a key with a `null` value still counts as present.
11823    let mut ka: Vec<&str> = a.fields().iter().map(|f| f.name().as_str()).collect();
11824    let mut kb: Vec<&str> = b.fields().iter().map(|f| f.name().as_str()).collect();
11825    ka.sort_unstable();
11826    kb.sort_unstable();
11827    if ka != kb {
11828        return Some(false);
11829    }
11830    let mut saw_null = false;
11831    for key in ka {
11832        let av = ScalarValue::try_from_array(a.column_by_name(key)?, 0).ok()?;
11833        let bv = ScalarValue::try_from_array(b.column_by_name(key)?, 0).ok()?;
11834        match cypher_value_eq(&av, &bv) {
11835            Some(false) => return Some(false),
11836            None => saw_null = true,
11837            Some(true) => {}
11838        }
11839    }
11840    if saw_null { None } else { Some(true) }
11841}
11842
11843// ---------------------------------------------------------------------------
11844// cypher_date_truncate UDF
11845// ---------------------------------------------------------------------------
11846
11847/// `date.truncate(unit, value, map)` (`Temporal9`): truncate `value`'s date to
11848/// `unit`, then apply the override `map` (same fields as projection). Args are
11849/// `[value, unit, year, month, day, week, dayOfWeek, ordinalDay, quarter,
11850/// dayOfQuarter]` — `value` is a `Date32` or ISO date/datetime string, `unit` a
11851/// string, the eight overrides nullable integers. Returns `Date32`. (#920)
11852static CYPHER_DATE_TRUNCATE: LazyLock<ScalarUDF> =
11853    LazyLock::new(|| ScalarUDF::new_from_impl(CypherDateTruncate::new()));
11854
11855#[derive(Debug, PartialEq, Eq, Hash)]
11856struct CypherDateTruncate {
11857    signature: Signature,
11858}
11859
11860impl CypherDateTruncate {
11861    fn new() -> Self {
11862        Self {
11863            signature: Signature::any(10, Volatility::Immutable),
11864        }
11865    }
11866}
11867
11868impl ScalarUDFImpl for CypherDateTruncate {
11869    fn as_any(&self) -> &dyn Any {
11870        self
11871    }
11872
11873    fn name(&self) -> &'static str {
11874        "cypher_date_truncate"
11875    }
11876
11877    fn signature(&self) -> &Signature {
11878        &self.signature
11879    }
11880
11881    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
11882        Ok(DataType::Struct(
11883            graphforge_storage::schemas::date_struct_fields(),
11884        ))
11885    }
11886
11887    fn invoke_with_args(
11888        &self,
11889        args: ScalarFunctionArgs,
11890    ) -> datafusion::error::Result<ColumnarValue> {
11891        use crate::temporal::{
11892            DateOverrides, parse_date_or_datetime_prefix, project_date, truncate_date,
11893        };
11894        use datafusion::arrow::array::{Array, ArrayRef, Int64Array, StringArray, StructArray};
11895        use datafusion::arrow::compute::cast;
11896        use datafusion::error::DataFusionError;
11897
11898        let rows = args.number_rows;
11899        let cols: Vec<_> = args
11900            .args
11901            .iter()
11902            .map(|a| a.to_array(rows))
11903            .collect::<datafusion::error::Result<_>>()?;
11904        // DataFusion emits `Utf8View` for string *columns* by default (string
11905        // literals stay `Utf8`), and our downcasts target `StringArray` — cast
11906        // string inputs to `Utf8` so a column-typed value/unit isn't silently
11907        // nulled. A `date`/`localdatetime`/`time`/`datetime` struct is taken directly.
11908        let value: ArrayRef = if is_date_struct(cols[0].data_type())
11909            || is_localdatetime_struct(cols[0].data_type())
11910            || is_time_struct(cols[0].data_type())
11911            || is_datetime_struct(cols[0].data_type())
11912        {
11913            std::sync::Arc::clone(&cols[0])
11914        } else {
11915            cast(&cols[0], &DataType::Utf8).map_err(DataFusionError::from)?
11916        };
11917        let units_arr = cast(&cols[1], &DataType::Utf8).map_err(DataFusionError::from)?;
11918        let units = units_arr.as_any().downcast_ref::<StringArray>();
11919        let ov: Vec<_> = cols[2..10]
11920            .iter()
11921            .map(|c| cast(c, &DataType::Int64))
11922            .collect::<std::result::Result<Vec<_>, _>>()
11923            .map_err(DataFusionError::from)?;
11924
11925        let base_date = |i: usize| -> Option<i64> {
11926            if value.is_null(i) {
11927                return None;
11928            }
11929            match value.data_type() {
11930                DataType::Struct(_) => {
11931                    let s = value.as_any().downcast_ref::<StructArray>()?;
11932                    if is_date_struct(value.data_type()) {
11933                        date_struct_value(s, i)
11934                    } else {
11935                        // A `localdatetime`/`datetime` value — truncate its date.
11936                        Some(localdatetime_struct_parts(s, i)?.0)
11937                    }
11938                }
11939                DataType::Utf8 => parse_date_or_datetime_prefix(
11940                    value.as_any().downcast_ref::<StringArray>()?.value(i),
11941                ),
11942                _ => None,
11943            }
11944        };
11945        let field = |idx: usize, i: usize| -> Option<i64> {
11946            let a = ov[idx].as_any().downcast_ref::<Int64Array>()?;
11947            (!a.is_null(i)).then(|| a.value(i))
11948        };
11949
11950        let out: Vec<Option<i64>> = (0..rows)
11951            .map(|i| {
11952                let u = units?;
11953                if u.is_null(i) {
11954                    return None;
11955                }
11956                let truncated = truncate_date(base_date(i)?, u.value(i))?;
11957                let overrides = DateOverrides {
11958                    year: field(0, i),
11959                    month: field(1, i),
11960                    day: field(2, i),
11961                    week: field(3, i),
11962                    day_of_week: field(4, i),
11963                    ordinal_day: field(5, i),
11964                    quarter: field(6, i),
11965                    day_of_quarter: field(7, i),
11966                };
11967                project_date(truncated, &overrides)
11968            })
11969            .collect();
11970        Ok(ColumnarValue::Array(std::sync::Arc::new(
11971            build_date_struct(&out),
11972        )))
11973    }
11974}
11975
11976// ---------------------------------------------------------------------------
11977// cypher_path_nodes UDF
11978// ---------------------------------------------------------------------------
11979
11980/// The traversed node sequence of a named path (#754): given the start node's
11981/// uuid and the path's relationship list (the #709 edge-list column), emit
11982/// `List<Struct{node_uuid}>` with `hops + 1` entries in traversal order.
11983///
11984/// The edge structs store `src_uuid`/`dst_uuid` in **storage** orientation,
11985/// while the BFS traverses `In`/`Undirected` edges against it — but every
11986/// emission is a connected walk, so the sequence is recovered per hop as "the
11987/// edge's other endpoint": `next = (cur == src ? dst : src)`. Self-loops
11988/// resolve to `cur`; an edge matching neither endpoint is impossible for a
11989/// well-formed emission and raises an execution error.
11990///
11991/// A null seed or null list yields null (an unmatched `OPTIONAL MATCH` row);
11992/// an empty list is the 0-hop self-path and yields `[{seed}]`.
11993static CYPHER_PATH_NODES: LazyLock<ScalarUDF> =
11994    LazyLock::new(|| ScalarUDF::new_from_impl(CypherPathNodes::new()));
11995
11996/// The `node_uuid`-only struct fields of one `cypher_path_nodes` list element.
11997///
11998/// A struct (rather than a bare `FixedSizeBinary`) mirrors the edge-list
11999/// element shape and leaves room to add node properties/labels later without
12000/// changing the container kind.
12001fn path_node_struct_fields() -> datafusion::arrow::datatypes::Fields {
12002    use datafusion::arrow::datatypes::Field;
12003    vec![Field::new(
12004        "node_uuid",
12005        DataType::FixedSizeBinary(16),
12006        false,
12007    )]
12008    .into()
12009}
12010
12011/// Lowering-baked context for hydrating path-node elements with labels and
12012/// properties (#1024). Sorted `Vec`s rather than maps so the UDF stays
12013/// `Hash`/`Eq`.
12014#[derive(Debug, PartialEq, Eq, Hash)]
12015struct PathNodeHydration {
12016    /// The project directory the invoke reads node/property files from.
12017    dir: std::path::PathBuf,
12018    /// `type_id → label` (ontology + runtime catalog), sorted by id.
12019    labels_by_type: Vec<(u32, String)>,
12020    /// The `properties/<stem>.parquet` stems whose fields form the union,
12021    /// sorted — the invoke coalesces each node's values across them.
12022    prop_stems: Vec<String>,
12023    /// The full element fields: `node_uuid`, `labels`, then the property union.
12024    fields: datafusion::arrow::datatypes::Fields,
12025}
12026
12027#[derive(Debug, PartialEq, Eq, Hash)]
12028struct CypherPathNodes {
12029    signature: Signature,
12030    hydrate: Option<PathNodeHydration>,
12031}
12032
12033impl CypherPathNodes {
12034    fn new() -> Self {
12035        // (seed_uuid, relationship_list); immutable.
12036        Self {
12037            signature: Signature::any(2, Volatility::Immutable),
12038            hydrate: None,
12039        }
12040    }
12041
12042    fn with_hydration(hydrate: PathNodeHydration) -> Self {
12043        Self {
12044            signature: Signature::any(2, Volatility::Immutable),
12045            hydrate: Some(hydrate),
12046        }
12047    }
12048
12049    fn element_fields(&self) -> datafusion::arrow::datatypes::Fields {
12050        self.hydrate
12051            .as_ref()
12052            .map_or_else(path_node_struct_fields, |h| h.fields.clone())
12053    }
12054}
12055
12056impl ScalarUDFImpl for CypherPathNodes {
12057    fn as_any(&self) -> &dyn Any {
12058        self
12059    }
12060
12061    fn name(&self) -> &'static str {
12062        "cypher_path_nodes"
12063    }
12064
12065    fn signature(&self) -> &Signature {
12066        &self.signature
12067    }
12068
12069    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
12070        Ok(DataType::new_list(
12071            DataType::Struct(self.element_fields()),
12072            true,
12073        ))
12074    }
12075
12076    fn invoke_with_args(
12077        &self,
12078        args: ScalarFunctionArgs,
12079    ) -> datafusion::error::Result<ColumnarValue> {
12080        use datafusion::arrow::array::{
12081            Array, ArrayRef, FixedSizeBinaryArray, ListArray, StructArray, new_empty_array,
12082        };
12083        use datafusion::arrow::buffer::{NullBuffer, OffsetBuffer};
12084        use datafusion::arrow::datatypes::Field;
12085        use datafusion::common::cast::as_list_array;
12086        use datafusion::error::DataFusionError;
12087        use std::sync::Arc;
12088
12089        let exec_err = |m: String| DataFusionError::Execution(m);
12090        let as_fsb16 =
12091            |array: &dyn Array, what: &str| -> datafusion::error::Result<FixedSizeBinaryArray> {
12092                array
12093                    .as_any()
12094                    .downcast_ref::<FixedSizeBinaryArray>()
12095                    .filter(|a| a.value_length() == 16)
12096                    .cloned()
12097                    .ok_or_else(|| {
12098                        exec_err(format!(
12099                            "cypher_path_nodes: expected FixedSizeBinary(16) {what}, got {:?}",
12100                            array.data_type()
12101                        ))
12102                    })
12103            };
12104
12105        let seeds = args.args[0].to_array(args.number_rows)?;
12106        let seeds = as_fsb16(seeds.as_ref(), "start-node uuid")?;
12107        let rels = args.args[1].to_array(args.number_rows)?;
12108        let rels = as_list_array(&rels)?.clone();
12109
12110        // Walk each row's relationship list from its seed, flattening the node
12111        // sequences: one uuid per visited node, one (length, validity) per row.
12112        let mut flat: Vec<[u8; 16]> = Vec::new();
12113        let mut lengths: Vec<usize> = Vec::with_capacity(rels.len());
12114        let mut valid: Vec<bool> = Vec::with_capacity(rels.len());
12115        for row in 0..rels.len() {
12116            if seeds.is_null(row) || rels.is_null(row) {
12117                lengths.push(0);
12118                valid.push(false);
12119                continue;
12120            }
12121            let start = flat.len();
12122            let mut cur = [0u8; 16];
12123            cur.copy_from_slice(seeds.value(row));
12124            flat.push(cur);
12125
12126            let edges = rels.value(row);
12127            let edges = edges
12128                .as_any()
12129                .downcast_ref::<StructArray>()
12130                .ok_or_else(|| {
12131                    exec_err("cypher_path_nodes: relationship-list items must be structs".into())
12132                })?;
12133            // Topology children by name — property fields are appended after
12134            // them, so positional access would be wrong (#755).
12135            let src = edges.column_by_name("src_uuid").ok_or_else(|| {
12136                exec_err("cypher_path_nodes: relationship struct has no src_uuid".into())
12137            })?;
12138            let src = as_fsb16(src.as_ref(), "src_uuid")?;
12139            let dst = edges.column_by_name("dst_uuid").ok_or_else(|| {
12140                exec_err("cypher_path_nodes: relationship struct has no dst_uuid".into())
12141            })?;
12142            let dst = as_fsb16(dst.as_ref(), "dst_uuid")?;
12143
12144            for i in 0..edges.len() {
12145                let s = src.value(i);
12146                let d = dst.value(i);
12147                let next = if cur == s {
12148                    d
12149                } else if cur == d {
12150                    s
12151                } else {
12152                    return Err(exec_err(format!(
12153                        "cypher_path_nodes: edge {i} is disconnected from the \
12154                         path (corrupt traversal emission)"
12155                    )));
12156                };
12157                cur.copy_from_slice(next);
12158                flat.push(cur);
12159            }
12160            lengths.push(flat.len() - start);
12161            valid.push(true);
12162        }
12163
12164        // node_uuid child — width-16 even when there are zero total nodes
12165        // (`try_from_iter` would infer width 0 and fail the schema check).
12166        let uuid_child: ArrayRef = if flat.is_empty() {
12167            new_empty_array(&DataType::FixedSizeBinary(16))
12168        } else {
12169            Arc::new(
12170                FixedSizeBinaryArray::try_from_iter(flat.iter())
12171                    .map_err(|e| exec_err(e.to_string()))?,
12172            )
12173        };
12174        let fields = self.element_fields();
12175        let mut children: Vec<ArrayRef> = vec![uuid_child];
12176        if let Some(h) = self.hydrate.as_ref() {
12177            children.extend(hydrate_path_node_children(h, &flat)?);
12178        }
12179
12180        let struct_arr = StructArray::try_new(fields.clone(), children, None)
12181            .map_err(|e| exec_err(e.to_string()))?;
12182        let offsets = OffsetBuffer::<i32>::from_lengths(lengths);
12183        let item = Arc::new(Field::new("item", DataType::Struct(fields), true));
12184        let list = ListArray::try_new(
12185            item,
12186            offsets,
12187            Arc::new(struct_arr),
12188            Some(NullBuffer::from(valid)),
12189        )
12190        .map_err(|e| exec_err(e.to_string()))?;
12191        Ok(ColumnarValue::Array(Arc::new(list)))
12192    }
12193}
12194
12195/// Build the `labels` + property-union children for hydrated path-node
12196/// elements (#1024), one entry per flattened node uuid.
12197fn hydrate_path_node_children(
12198    h: &PathNodeHydration,
12199    flat: &[[u8; 16]],
12200) -> datafusion::error::Result<Vec<datafusion::arrow::array::ArrayRef>> {
12201    let mut children = vec![path_node_labels_child(h, flat)?];
12202    children.extend(path_node_prop_children(h, flat)?);
12203    Ok(children)
12204}
12205
12206/// A `FixedSizeBinary(16)` column by name, for the hydration readers.
12207fn hydration_fsb16(
12208    b: &datafusion::arrow::array::RecordBatch,
12209    name: &str,
12210) -> datafusion::error::Result<datafusion::arrow::array::FixedSizeBinaryArray> {
12211    use datafusion::arrow::array::FixedSizeBinaryArray;
12212    b.column_by_name(name)
12213        .and_then(|c| c.as_any().downcast_ref::<FixedSizeBinaryArray>().cloned())
12214        .filter(|a| a.value_length() == 16)
12215        .ok_or_else(|| {
12216            datafusion::error::DataFusionError::Execution(format!(
12217                "cypher_path_nodes: no FixedSizeBinary(16) {name} column"
12218            ))
12219        })
12220}
12221
12222/// The `labels` child (#1024): one-element `List<Utf8>` per flattened node,
12223/// resolved through `topology/nodes.parquet`'s `type_id` and the baked
12224/// (sorted) map — the element is NULL when the type is unknown (mirroring
12225/// `node_value_struct`'s no-map case).
12226fn path_node_labels_child(
12227    h: &PathNodeHydration,
12228    flat: &[[u8; 16]],
12229) -> datafusion::error::Result<datafusion::arrow::array::ArrayRef> {
12230    use datafusion::arrow::array::{Array, ListBuilder, StringBuilder};
12231    use datafusion::error::DataFusionError;
12232    use std::collections::HashMap;
12233
12234    let exec_err = |m: String| DataFusionError::Execution(m);
12235    let node_batches =
12236        graphforge_storage::read_nodes(&h.dir).map_err(|e| exec_err(e.to_string()))?;
12237    let mut label_of: HashMap<[u8; 16], usize> = HashMap::new();
12238    for b in &node_batches {
12239        let uuids = hydration_fsb16(b, "node_uuid")?;
12240        let type_ids = b
12241            .column_by_name("type_id")
12242            .and_then(|c| {
12243                c.as_any()
12244                    .downcast_ref::<datafusion::arrow::array::UInt32Array>()
12245                    .cloned()
12246            })
12247            .ok_or_else(|| exec_err("cypher_path_nodes: no UInt32 type_id column".into()))?;
12248        for r in 0..b.num_rows() {
12249            if uuids.is_null(r) || type_ids.is_null(r) {
12250                continue;
12251            }
12252            let mut u = [0u8; 16];
12253            u.copy_from_slice(uuids.value(r));
12254            if let Ok(i) = h
12255                .labels_by_type
12256                .binary_search_by_key(&type_ids.value(r), |(id, _)| *id)
12257            {
12258                label_of.insert(u, i);
12259            }
12260        }
12261    }
12262    let mut labels_b = ListBuilder::new(StringBuilder::new());
12263    for u in flat {
12264        match label_of.get(u) {
12265            Some(&i) => labels_b.values().append_value(&h.labels_by_type[i].1),
12266            None => labels_b.values().append_null(),
12267        }
12268        labels_b.append(true);
12269    }
12270    Ok(std::sync::Arc::new(labels_b.finish()))
12271}
12272
12273/// The property-union children (#1024), one array per union field: each node
12274/// takes values from the stem file owning its `node_uuid`, coalesced across
12275/// files — NULL where the owning file lacks the column or the row (LEFT-join
12276/// parity with `join_node_properties`).
12277fn path_node_prop_children(
12278    h: &PathNodeHydration,
12279    flat: &[[u8; 16]],
12280) -> datafusion::error::Result<Vec<datafusion::arrow::array::ArrayRef>> {
12281    use datafusion::arrow::array::{Array, ArrayRef, UInt32Array, new_null_array};
12282    use datafusion::arrow::compute::kernels::zip::zip;
12283    use datafusion::arrow::compute::{concat_batches, is_not_null, take};
12284    use datafusion::error::DataFusionError;
12285    use std::collections::HashMap;
12286
12287    let exec_err = |m: String| DataFusionError::Execution(m);
12288
12289    // One concatenated property batch per stem, then uuid → (owning batch,
12290    // row). A node belongs to one property file, so first-wins is a no-op for
12291    // well-formed data.
12292    let mut batches = Vec::with_capacity(h.prop_stems.len());
12293    for stem in &h.prop_stems {
12294        let bs = graphforge_storage::read_properties(&h.dir, stem)
12295            .map_err(|e| exec_err(e.to_string()))?;
12296        if let Some(first) = bs.first() {
12297            batches
12298                .push(concat_batches(&first.schema(), &bs).map_err(|e| exec_err(e.to_string()))?);
12299        }
12300    }
12301    let mut uuid_to_loc: HashMap<[u8; 16], (usize, u32)> = HashMap::new();
12302    for (bi, b) in batches.iter().enumerate() {
12303        let key = hydration_fsb16(b, "node_uuid")?;
12304        for r in 0..key.len() {
12305            if key.is_null(r) {
12306                continue;
12307            }
12308            let mut u = [0u8; 16];
12309            u.copy_from_slice(key.value(r));
12310            uuid_to_loc.entry(u).or_insert((
12311                bi,
12312                u32::try_from(r).map_err(|_| exec_err(format!("property row {r} exceeds u32")))?,
12313            ));
12314        }
12315    }
12316    let take_by_batch: Vec<UInt32Array> = (0..batches.len())
12317        .map(|bi| {
12318            flat.iter()
12319                .map(|u| match uuid_to_loc.get(u) {
12320                    Some(&(owner, row)) if owner == bi => Some(row),
12321                    _ => None,
12322                })
12323                .collect()
12324        })
12325        .collect();
12326
12327    let mut children = Vec::with_capacity(h.fields.len().saturating_sub(2));
12328    for field in h.fields.iter().skip(2) {
12329        let mut child: ArrayRef = new_null_array(field.data_type(), flat.len());
12330        for (bi, b) in batches.iter().enumerate() {
12331            // Field in the union but absent in this stem's file — this stem's
12332            // nodes contribute NULLs.
12333            let Some(col) = b.column_by_name(field.name()) else {
12334                continue;
12335            };
12336            let taken = take(col, &take_by_batch[bi], None).map_err(|e| exec_err(e.to_string()))?;
12337            child = if batches.len() == 1 {
12338                taken
12339            } else {
12340                let mask = is_not_null(&taken).map_err(|e| exec_err(e.to_string()))?;
12341                zip(&mask, &taken, &child).map_err(|e| exec_err(e.to_string()))?
12342            };
12343        }
12344        children.push(child);
12345    }
12346    Ok(children)
12347}
12348
12349// ---------------------------------------------------------------------------
12350// Tests
12351// ---------------------------------------------------------------------------
12352
12353#[cfg(test)]
12354mod tests {
12355    use super::*;
12356    use graphforge_core::PropId;
12357    use graphforge_ir::expr::{BinaryOpKind, IrExpr, IrLiteral, UnaryOpKind};
12358    use graphforge_ir::{ExprArena, VarId};
12359    use std::sync::atomic::{AtomicUsize, Ordering};
12360
12361    static VOLATILE_CALLS: AtomicUsize = AtomicUsize::new(0);
12362    static VOLATILE_ROWS: AtomicUsize = AtomicUsize::new(0);
12363
12364    #[derive(Debug, PartialEq, Eq, Hash)]
12365    struct CountingVolatilePredicate {
12366        signature: Signature,
12367    }
12368
12369    impl CountingVolatilePredicate {
12370        fn new() -> Self {
12371            Self {
12372                signature: Signature::nullary(Volatility::Volatile),
12373            }
12374        }
12375    }
12376
12377    impl ScalarUDFImpl for CountingVolatilePredicate {
12378        fn as_any(&self) -> &dyn Any {
12379            self
12380        }
12381
12382        fn name(&self) -> &'static str {
12383            "counting_volatile_predicate"
12384        }
12385
12386        fn signature(&self) -> &Signature {
12387            &self.signature
12388        }
12389
12390        fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
12391            Ok(DataType::Boolean)
12392        }
12393
12394        fn invoke_with_args(
12395            &self,
12396            args: ScalarFunctionArgs,
12397        ) -> datafusion::error::Result<ColumnarValue> {
12398            use datafusion::arrow::array::BooleanArray;
12399            VOLATILE_CALLS.fetch_add(1, Ordering::SeqCst);
12400            VOLATILE_ROWS.fetch_add(args.number_rows, Ordering::SeqCst);
12401            Ok(ColumnarValue::Array(std::sync::Arc::new(
12402                BooleanArray::from(vec![true; args.number_rows]),
12403            )))
12404        }
12405    }
12406
12407    #[test]
12408    fn temporal_clock_fn_recognition() {
12409        // Every instant type × clock accessor is recognised (#920).
12410        for base in ["date", "localtime", "time", "localdatetime", "datetime"] {
12411            for clock in ["transaction", "statement", "realtime"] {
12412                assert!(is_temporal_clock_fn(&format!("{base}.{clock}")));
12413            }
12414        }
12415        // Case-insensitive (Cypher function names are).
12416        assert!(is_temporal_clock_fn("Date.Realtime"));
12417        assert!(is_temporal_clock_fn("DATETIME.TRANSACTION"));
12418        // Non-clock and non-temporal names are not.
12419        assert!(!is_temporal_clock_fn("datetime.truncate"));
12420        assert!(!is_temporal_clock_fn("duration.realtime")); // duration has no clock
12421        assert!(!is_temporal_clock_fn("date"));
12422        assert!(!is_temporal_clock_fn("foo.realtime"));
12423
12424        // A null temporal arg lowers to a TYPED null, not generic Null.
12425        assert_eq!(
12426            temporal_null_scalar("date").data_type(),
12427            date_scalar(None).data_type()
12428        );
12429        assert_eq!(
12430            temporal_null_scalar("localtime"),
12431            ScalarValue::Time64Nanosecond(None)
12432        );
12433        assert_eq!(
12434            temporal_null_scalar("datetime.realtime").data_type(),
12435            datetime_scalar(None).data_type()
12436        );
12437    }
12438
12439    #[test]
12440    fn quantifier_three_valued_reduce() {
12441        use datafusion::arrow::array::BooleanArray;
12442        use graphforge_ir::QuantifierKind::{All, Any, None, Single};
12443        let b = |v: Vec<Option<bool>>| BooleanArray::from(v);
12444        let r = |k, v: Vec<Option<bool>>| {
12445            let arr = b(v.clone());
12446            reduce_quantifier(k, &arr, v.len())
12447        };
12448        // Empty list: all/none → true, any/single → false.
12449        assert_eq!(r(All, vec![]), Some(true));
12450        assert_eq!(r(None, vec![]), Some(true));
12451        assert_eq!(r(Any, vec![]), Some(false));
12452        assert_eq!(r(Single, vec![]), Some(false));
12453        // Definitive results.
12454        assert_eq!(r(All, vec![Some(true), Some(true)]), Some(true));
12455        assert_eq!(r(All, vec![Some(true), Some(false)]), Some(false));
12456        assert_eq!(r(Any, vec![Some(false), Some(true)]), Some(true));
12457        assert_eq!(r(None, vec![Some(false), Some(false)]), Some(true));
12458        assert_eq!(r(Single, vec![Some(true), Some(false)]), Some(true));
12459        assert_eq!(r(Single, vec![Some(true), Some(true)]), Some(false));
12460        // Three-valued: a null only matters when nothing definitive settles it.
12461        assert_eq!(r(All, vec![Some(true), Option::None]), Option::None); // unknown
12462        assert_eq!(r(All, vec![Some(false), Option::None]), Some(false)); // false wins
12463        assert_eq!(r(Any, vec![Some(false), Option::None]), Option::None);
12464        assert_eq!(r(Any, vec![Some(true), Option::None]), Some(true)); // true wins
12465        assert_eq!(
12466            r(Single, vec![Some(true), Some(true), Option::None]),
12467            Some(false)
12468        ); // >1 wins
12469        assert_eq!(r(Single, vec![Some(true), Option::None]), Option::None);
12470    }
12471
12472    #[test]
12473    fn invariant_quantifier_truth_matrix_preserves_cardinality_and_nulls() {
12474        use graphforge_ir::QuantifierKind::{All, Any, None as NoneQ, Single};
12475
12476        for kind in [All, Any, NoneQ, Single] {
12477            for predicate in [Some(true), Some(false), Option::None] {
12478                for length in [0, 1, 4] {
12479                    assert_eq!(
12480                        reduce_invariant_quantifier(kind, predicate, length),
12481                        match predicate {
12482                            Some(value) => {
12483                                let values = datafusion::arrow::array::BooleanArray::from(vec![
12484                                        value;
12485                                        length
12486                                    ]);
12487                                reduce_quantifier(kind, &values, length)
12488                            }
12489                            Option::None => {
12490                                let values =
12491                                    datafusion::arrow::array::BooleanArray::new_null(length);
12492                                reduce_quantifier(kind, &values, length)
12493                            }
12494                        },
12495                        "{kind:?}, predicate={predicate:?}, length={length}"
12496                    );
12497                }
12498            }
12499        }
12500    }
12501
12502    #[test]
12503    fn invariant_quantifier_scaling_counts_rows_not_heterogeneous_elements() {
12504        use datafusion::arrow::array::{Array, ArrayRef, BooleanArray, ListArray};
12505        use datafusion::arrow::buffer::{NullBuffer, OffsetBuffer, ScalarBuffer};
12506        use datafusion::arrow::datatypes::Field;
12507        use datafusion::config::ConfigOptions;
12508        use datafusion::scalar::ScalarValue as S;
12509        use graphforge_ir::QuantifierKind::{All, Any, None as NoneQ, Single};
12510        use std::sync::Arc;
12511
12512        let pattern = [
12513            S::Int64(Some(1)),
12514            S::Null,
12515            S::Boolean(Some(true)),
12516            S::Utf8(Some("x".to_owned())),
12517        ];
12518        let flat = pattern
12519            .iter()
12520            .cloned()
12521            .chain(pattern.iter().cloned().cycle().take(40))
12522            .collect::<Vec<_>>();
12523        let values: ArrayRef = Arc::new(build_het_struct(&flat, 0).unwrap());
12524        let item = Arc::new(Field::new("item", values.data_type().clone(), true));
12525        let list: ArrayRef = Arc::new(ListArray::new(
12526            item,
12527            OffsetBuffer::new(ScalarBuffer::from(vec![0i32, 4, 44, 44])),
12528            values,
12529            Some(NullBuffer::from(vec![true, true, false])),
12530        ));
12531        INVARIANT_QUANTIFIER_ROWS.store(0, Ordering::SeqCst);
12532
12533        for kind in [All, Any, NoneQ, Single] {
12534            for predicate in [Some(true), Some(false)] {
12535                let udf = CypherInvariantQuantifier::new(kind, predicate);
12536                let output = udf
12537                    .invoke_with_args(ScalarFunctionArgs {
12538                        args: vec![ColumnarValue::Array(Arc::clone(&list))],
12539                        arg_fields: vec![Arc::new(Field::new(
12540                            "list",
12541                            list.data_type().clone(),
12542                            true,
12543                        ))],
12544                        number_rows: 3,
12545                        return_field: Arc::new(Field::new("out", DataType::Boolean, true)),
12546                        config_options: Arc::new(ConfigOptions::default()),
12547                    })
12548                    .unwrap()
12549                    .into_array(3)
12550                    .unwrap();
12551                let output = output.as_any().downcast_ref::<BooleanArray>().unwrap();
12552                assert_eq!(
12553                    output.value(0),
12554                    reduce_invariant_quantifier(kind, predicate, 4).unwrap()
12555                );
12556                assert_eq!(
12557                    output.value(1),
12558                    reduce_invariant_quantifier(kind, predicate, 40).unwrap()
12559                );
12560                assert!(output.is_null(2));
12561            }
12562        }
12563        assert_eq!(
12564            INVARIANT_QUANTIFIER_ROWS.load(Ordering::SeqCst),
12565            4 * 2 * 3,
12566            "1x and 10x element counts must keep invariant work at one fold per list row"
12567        );
12568    }
12569
12570    #[test]
12571    fn invariant_quantifier_lowering_uses_cardinality_only_udf() {
12572        use graphforge_ir::QuantifierKind::None as NoneQ;
12573
12574        for (predicate, expected) in [
12575            (IrLiteral::Bool(true), Some(true)),
12576            (IrLiteral::Bool(false), Some(false)),
12577            (IrLiteral::Null, Option::None),
12578        ] {
12579            let mut arena = ExprArena::new();
12580            let list = arena.push(IrExpr::VarRef(VarId(0)));
12581            let predicate = arena.push(IrExpr::Literal(predicate));
12582            let quantifier = arena.push(IrExpr::Quantifier {
12583                kind: NoneQ,
12584                loop_var: VarId(1),
12585                list,
12586                predicate,
12587            });
12588            let mut vars = VarMap::new();
12589            vars.insert(VarId(0), "list");
12590            let lowered = make_lowerer(&arena, &vars).lower(quantifier).unwrap();
12591            let DfExpr::ScalarFunction(function) = lowered else {
12592                panic!("invariant quantifier must lower to a scalar UDF")
12593            };
12594            let invariant = function
12595                .func
12596                .inner()
12597                .as_any()
12598                .downcast_ref::<CypherInvariantQuantifier>()
12599                .expect("cardinality-only quantifier UDF");
12600            assert_eq!(invariant.predicate, expected);
12601        }
12602    }
12603
12604    #[test]
12605    fn uncorrelated_list_comprehension_batches_volatile_predicate_once() {
12606        use datafusion::arrow::array::{Array, Int64Builder, ListArray, ListBuilder};
12607        use datafusion::arrow::datatypes::Field;
12608        use datafusion::config::ConfigOptions;
12609        use std::sync::Arc;
12610
12611        VOLATILE_CALLS.store(0, Ordering::SeqCst);
12612        VOLATILE_ROWS.store(0, Ordering::SeqCst);
12613        let mut builder = ListBuilder::new(Int64Builder::new());
12614        builder.append_null();
12615        builder.append(true);
12616        builder.values().append_value(1);
12617        builder.values().append_value(2);
12618        builder.append(true);
12619        builder.values().append_value(3);
12620        builder.append(true);
12621        let input = Arc::new(builder.finish()) as datafusion::arrow::array::ArrayRef;
12622        let predicate = ScalarUDF::new_from_impl(CountingVolatilePredicate::new()).call(vec![]);
12623        let udf = CypherListComp::new(Some(predicate), None, "__gf_elem".to_owned(), vec![]);
12624        let return_type = udf.return_type(&[input.data_type().clone()]).unwrap();
12625        let output = udf
12626            .invoke_with_args(ScalarFunctionArgs {
12627                args: vec![ColumnarValue::Array(input)],
12628                arg_fields: vec![Arc::new(Field::new(
12629                    "list",
12630                    DataType::new_list(DataType::Int64, true),
12631                    true,
12632                ))],
12633                number_rows: 4,
12634                return_field: Arc::new(Field::new("out", return_type, true)),
12635                config_options: Arc::new(ConfigOptions::default()),
12636            })
12637            .unwrap()
12638            .into_array(4)
12639            .unwrap();
12640        let output = output.as_any().downcast_ref::<ListArray>().unwrap();
12641
12642        assert!(output.is_null(0));
12643        assert_eq!(output.value(1).len(), 0);
12644        assert_eq!(output.value(2).len(), 2);
12645        assert_eq!(output.value(3).len(), 1);
12646        assert_eq!(VOLATILE_CALLS.load(Ordering::SeqCst), 1);
12647        assert_eq!(VOLATILE_ROWS.load(Ordering::SeqCst), 3);
12648    }
12649
12650    /// Invoke a `cypher_quantifier` UDF (no outer columns) over a single list
12651    /// column and return the per-row boolean verdicts.
12652    fn invoke_cypher_quantifier(
12653        kind: graphforge_ir::QuantifierKind,
12654        predicate: DfExpr,
12655        list: datafusion::arrow::array::ArrayRef,
12656    ) -> datafusion::error::Result<datafusion::arrow::array::BooleanArray> {
12657        use datafusion::arrow::array::BooleanArray;
12658        use datafusion::arrow::datatypes::Field;
12659        use datafusion::config::ConfigOptions;
12660        use std::sync::Arc;
12661
12662        let n = list.len();
12663        let field = Arc::new(Field::new("l", list.data_type().clone(), true));
12664        let ret = Arc::new(Field::new("q", DataType::Boolean, true));
12665        let args = ScalarFunctionArgs {
12666            args: vec![ColumnarValue::Array(list)],
12667            arg_fields: vec![field],
12668            number_rows: n,
12669            return_field: ret,
12670            config_options: Arc::new(ConfigOptions::default()),
12671        };
12672        let udf = CypherQuantifier::new(kind, predicate, "__gf_elem".to_owned(), vec![]);
12673        let arr = match udf.invoke_with_args(args)? {
12674            ColumnarValue::Array(a) => a,
12675            ColumnarValue::Scalar(s) => s.to_array_of_size(n)?,
12676        };
12677        Ok(arr
12678            .as_any()
12679            .downcast_ref::<BooleanArray>()
12680            .expect("boolean verdicts")
12681            .clone())
12682    }
12683
12684    #[test]
12685    fn cypher_quantifier_empty_list_yields_identity_without_predicate() {
12686        use datafusion::arrow::array::{Array, ListArray};
12687        use datafusion::arrow::datatypes::Int64Type;
12688        use graphforge_ir::QuantifierKind::{All, Any, None as NoneQ, Single};
12689
12690        // One empty Int64 list row — the shape a statically-empty `[]` takes.
12691        let empty = || {
12692            std::sync::Arc::new(ListArray::from_iter_primitive::<Int64Type, _, _>(vec![
12693                Some(Vec::<Option<i64>>::new()),
12694            ])) as datafusion::arrow::array::ArrayRef
12695        };
12696
12697        // `x.a = 2` cannot plan over Int64 elements; `x` alone evaluates to
12698        // Int64, not Boolean. Neither may disturb the empty-list identity
12699        // (#1020) — the predicate must not run at all.
12700        let field_pred =
12701            datafusion::functions::core::expr_fn::get_field(col_literal("__gf_elem"), "a")
12702                .eq(DfExpr::Literal(ScalarValue::Int64(Some(2)), Option::None));
12703        let elem_pred = col_literal("__gf_elem");
12704
12705        for (kind, expected) in [(All, true), (NoneQ, true), (Any, false), (Single, false)] {
12706            for pred in [field_pred.clone(), elem_pred.clone()] {
12707                let out = invoke_cypher_quantifier(kind, pred, empty()).expect("no error");
12708                assert!(!out.is_null(0), "{kind:?}: empty list must be definitive");
12709                assert_eq!(out.value(0), expected, "{kind:?} identity");
12710            }
12711        }
12712    }
12713
12714    #[test]
12715    fn cypher_quantifier_mixed_batch_and_unplannable_predicate() {
12716        use datafusion::arrow::array::{Array, BooleanBuilder, ListBuilder};
12717        use graphforge_ir::QuantifierKind::Any;
12718
12719        // Rows: null list, empty list, [true, false].
12720        let mut b = ListBuilder::new(BooleanBuilder::new());
12721        b.append_null();
12722        b.append(true);
12723        b.values().append_value(true);
12724        b.values().append_value(false);
12725        b.append(true);
12726        let list = std::sync::Arc::new(b.finish()) as datafusion::arrow::array::ArrayRef;
12727
12728        // `any(x IN ... WHERE x)` over Boolean elements: per-row verdicts.
12729        let out = invoke_cypher_quantifier(Any, col_literal("__gf_elem"), list.clone())
12730            .expect("no error");
12731        assert!(out.is_null(0), "null list → null");
12732        assert!(!out.is_null(1) && !out.value(1), "any over [] is false");
12733        assert!(out.value(2), "any over [true, false] is true");
12734
12735        // An unbuildable predicate still errors once a non-empty row needs it.
12736        let bad = datafusion::functions::core::expr_fn::get_field(col_literal("__gf_elem"), "a");
12737        assert!(
12738            invoke_cypher_quantifier(Any, bad, list).is_err(),
12739            "non-empty row against an unbuildable predicate must error"
12740        );
12741    }
12742
12743    /// Build a minimal lowerer with no ontology.
12744    fn make_lowerer<'a>(arena: &'a ExprArena, var_map: &'a VarMap) -> ExprLowerer<'a> {
12745        ExprLowerer::new(arena, None, var_map)
12746    }
12747
12748    // -----------------------------------------------------------------------
12749    // Conservative operand type-check (#956, InvalidArgumentType)
12750    // -----------------------------------------------------------------------
12751
12752    #[test]
12753    fn boolean_and_numeric_operators_reject_known_bad_operands() {
12754        use graphforge_ir::expr::{BinaryOpKind as B, IrExpr, IrLiteral, UnaryOpKind as U};
12755        let vm = VarMap::new();
12756
12757        // A statically-known incompatible operand is rejected as InvalidType.
12758        let reject = |build: &dyn Fn(&mut ExprArena) -> ExprId| {
12759            let mut a = ExprArena::new();
12760            let id = build(&mut a);
12761            let err = make_lowerer(&a, &vm).lower(id).expect_err("should reject");
12762            assert!(
12763                matches!(err, LoweringError::InvalidType(_)),
12764                "expected InvalidType, got {err:?}"
12765            );
12766        };
12767        // `1 AND true`
12768        reject(&|a| {
12769            let l = a.push(IrExpr::Literal(IrLiteral::Int(1)));
12770            let r = a.push(IrExpr::Literal(IrLiteral::Bool(true)));
12771            a.push(IrExpr::BinaryOp {
12772                op: B::And,
12773                left: l,
12774                right: r,
12775            })
12776        });
12777        // `1 XOR true`
12778        reject(&|a| {
12779            let l = a.push(IrExpr::Literal(IrLiteral::Int(1)));
12780            let r = a.push(IrExpr::Literal(IrLiteral::Bool(true)));
12781            a.push(IrExpr::BinaryOp {
12782                op: B::Xor,
12783                left: l,
12784                right: r,
12785            })
12786        });
12787        // `NOT 'x'`
12788        reject(&|a| {
12789            let e = a.push(IrExpr::Literal(IrLiteral::Str("x".into())));
12790            a.push(IrExpr::UnaryOp {
12791                op: U::Not,
12792                expr: e,
12793            })
12794        });
12795        // `-true`
12796        reject(&|a| {
12797            let e = a.push(IrExpr::Literal(IrLiteral::Bool(true)));
12798            a.push(IrExpr::UnaryOp {
12799                op: U::Neg,
12800                expr: e,
12801            })
12802        });
12803        // `'a' % 2`
12804        reject(&|a| {
12805            let l = a.push(IrExpr::Literal(IrLiteral::Str("a".into())));
12806            let r = a.push(IrExpr::Literal(IrLiteral::Int(2)));
12807            a.push(IrExpr::BinaryOp {
12808                op: B::Mod,
12809                left: l,
12810                right: r,
12811            })
12812        });
12813        // `NOT {k: 1}` — a map literal is a known non-boolean.
12814        reject(&|a| {
12815            let v = a.push(IrExpr::Literal(IrLiteral::Int(1)));
12816            let m = a.push(IrExpr::MapLiteral(vec![("k".into(), v)]));
12817            a.push(IrExpr::UnaryOp {
12818                op: U::Not,
12819                expr: m,
12820            })
12821        });
12822    }
12823
12824    #[test]
12825    fn operator_type_check_accepts_valid_and_unknown_operands() {
12826        use graphforge_ir::VarId;
12827        use graphforge_ir::expr::{BinaryOpKind as B, IrExpr, IrLiteral, UnaryOpKind as U};
12828        let accept = |vm: &VarMap, build: &dyn Fn(&mut ExprArena) -> ExprId| {
12829            let mut a = ExprArena::new();
12830            let id = build(&mut a);
12831            make_lowerer(&a, vm)
12832                .lower(id)
12833                .expect("valid/unknown operands must lower cleanly");
12834        };
12835        // `true AND false`
12836        accept(&VarMap::new(), &|a| {
12837            let l = a.push(IrExpr::Literal(IrLiteral::Bool(true)));
12838            let r = a.push(IrExpr::Literal(IrLiteral::Bool(false)));
12839            a.push(IrExpr::BinaryOp {
12840                op: B::And,
12841                left: l,
12842                right: r,
12843            })
12844        });
12845        // `null AND true` — null is valid in three-valued logic.
12846        accept(&VarMap::new(), &|a| {
12847            let l = a.push(IrExpr::Literal(IrLiteral::Null));
12848            let r = a.push(IrExpr::Literal(IrLiteral::Bool(true)));
12849            a.push(IrExpr::BinaryOp {
12850                op: B::And,
12851                left: l,
12852                right: r,
12853            })
12854        });
12855        // `NOT x` where x is an untyped variable (unknown type — do not reject).
12856        let mut vm = VarMap::new();
12857        vm.insert(VarId(0), "x");
12858        accept(&vm, &|a| {
12859            let e = a.push(IrExpr::VarRef(VarId(0)));
12860            a.push(IrExpr::UnaryOp {
12861                op: U::Not,
12862                expr: e,
12863            })
12864        });
12865    }
12866
12867    #[test]
12868    fn nested_quantifiers_get_per_depth_elem_columns() {
12869        use graphforge_ir::QuantifierKind::{None as NoneQ, Single};
12870
12871        // none(x IN list WHERE single(y IN list WHERE x + y = 15)): the inner
12872        // predicate references BOTH loop elements, so the bindings must stay
12873        // distinct across depths (#1021).
12874        let mut arena = ExprArena::new();
12875        let list_outer = arena.push(IrExpr::VarRef(VarId(0)));
12876        let list_inner = arena.push(IrExpr::VarRef(VarId(0)));
12877        let x = arena.push(IrExpr::VarRef(VarId(1)));
12878        let y = arena.push(IrExpr::VarRef(VarId(2)));
12879        let sum = arena.push(IrExpr::BinaryOp {
12880            op: BinaryOpKind::Add,
12881            left: x,
12882            right: y,
12883        });
12884        let fifteen = arena.push(IrExpr::Literal(IrLiteral::Int(15)));
12885        let eq = arena.push(IrExpr::BinaryOp {
12886            op: BinaryOpKind::Eq,
12887            left: sum,
12888            right: fifteen,
12889        });
12890        let inner = arena.push(IrExpr::Quantifier {
12891            kind: Single,
12892            loop_var: VarId(2),
12893            list: list_inner,
12894            predicate: eq,
12895        });
12896        let outer = arena.push(IrExpr::Quantifier {
12897            kind: NoneQ,
12898            loop_var: VarId(1),
12899            list: list_outer,
12900            predicate: inner,
12901        });
12902
12903        let mut vm = VarMap::new();
12904        vm.insert(VarId(0), "list");
12905        let lowered = make_lowerer(&arena, &vm).lower(outer).expect("lower");
12906
12907        let as_quant = |e: &DfExpr| -> Option<(String, Vec<String>, Vec<DfExpr>)> {
12908            let DfExpr::ScalarFunction(f) = e else {
12909                return Option::None;
12910            };
12911            let q = f.func.inner().as_any().downcast_ref::<CypherQuantifier>()?;
12912            Some((q.elem_name.clone(), q.outer_names.clone(), f.args.clone()))
12913        };
12914
12915        // Outer loop keeps the historical name; its only outer column is the
12916        // real `list` (its own element must NOT leak into its args).
12917        let (outer_elem, outer_outers, _) = as_quant(&lowered).expect("outer quantifier UDF");
12918        assert_eq!(outer_elem, "__gf_elem");
12919        assert_eq!(outer_outers, vec!["list".to_owned()]);
12920
12921        // The inner loop gets the depth-1 name, and the OUTER element flows in
12922        // as one of its outer columns (broadcast per outer element at invoke).
12923        let DfExpr::ScalarFunction(outer_fn) = &lowered else {
12924            panic!("outer is a scalar function")
12925        };
12926        let outer_q = outer_fn
12927            .func
12928            .inner()
12929            .as_any()
12930            .downcast_ref::<CypherQuantifier>()
12931            .unwrap();
12932        let (inner_elem, inner_outers, inner_args) =
12933            as_quant(&outer_q.predicate).expect("inner quantifier UDF");
12934        assert_eq!(inner_elem, "__gf_elem_1");
12935        assert_eq!(
12936            inner_outers,
12937            vec!["__gf_elem".to_owned()],
12938            "the outer element is an outer column of the inner UDF (the list \
12939             argument resolves in the enclosing batch, not via outer_names)"
12940        );
12941        // Call args: the list, then one arg per outer_names entry.
12942        let arg_names: Vec<String> = inner_args.iter().map(|a| a.to_string()).collect();
12943        assert_eq!(arg_names, vec!["list", "__gf_elem"]);
12944    }
12945
12946    #[test]
12947    fn hydrated_path_nodes_return_type_carries_labels_and_props() {
12948        use datafusion::arrow::datatypes::Field;
12949
12950        // Without hydration: the original node_uuid-only element (#754).
12951        let bare = CypherPathNodes::new().return_type(&[]).unwrap();
12952        let DataType::List(item) = &bare else {
12953            panic!("list return, got {bare:?}")
12954        };
12955        let DataType::Struct(fields) = item.data_type() else {
12956            panic!("struct element")
12957        };
12958        assert_eq!(fields.len(), 1);
12959        assert_eq!(fields[0].name(), "node_uuid");
12960
12961        // With hydration: node_uuid, labels, then the baked property union
12962        // (#1024) — the shape `render_node_struct` and `x.<prop>` need.
12963        let hydrated = CypherPathNodes::with_hydration(PathNodeHydration {
12964            dir: std::path::PathBuf::from("/nonexistent"),
12965            labels_by_type: vec![(0, "A".to_owned())],
12966            prop_stems: vec!["_untyped".to_owned()],
12967            fields: vec![
12968                Field::new("node_uuid", DataType::FixedSizeBinary(16), false),
12969                Field::new("labels", DataType::new_list(DataType::Utf8, true), true),
12970                Field::new("name", DataType::Utf8, true),
12971            ]
12972            .into(),
12973        })
12974        .return_type(&[])
12975        .unwrap();
12976        let DataType::List(item) = &hydrated else {
12977            panic!("list return, got {hydrated:?}")
12978        };
12979        let DataType::Struct(fields) = item.data_type() else {
12980            panic!("struct element")
12981        };
12982        let names: Vec<&str> = fields.iter().map(|f| f.name().as_str()).collect();
12983        assert_eq!(names, vec!["node_uuid", "labels", "name"]);
12984    }
12985
12986    #[test]
12987    fn elem_struct_col_routes_property_to_get_field() {
12988        // #1004: a property access on the synthetic quantifier/comprehension
12989        // element column (`__gf_elem`) is a STRUCT-FIELD access, so it must lower
12990        // via `get_field(__gf_elem, "a")` — not the dotted property-column
12991        // `__gf_elem.a` used for node properties (which DataFusion reads as a
12992        // qualified column that does not exist on a single struct column, giving
12993        // "No field named a"). Any OTHER base still uses the dotted form.
12994        let mut arena = ExprArena::new();
12995        let base = arena.push(IrExpr::VarRef(VarId(0)));
12996        let access = arena.push(IrExpr::PropertyAccess {
12997            base,
12998            prop: PropId(0),
12999        });
13000        let mut vm = VarMap::new();
13001        vm.insert(VarId(0), "__gf_elem");
13002        let mut prop_names = HashMap::new();
13003        prop_names.insert(0u32, "a".to_owned());
13004
13005        // Without the marker: node-property dotted-column form — a QUALIFIED
13006        // column `__gf_elem.a` (relation `__gf_elem`, column `a`), which is
13007        // exactly what DataFusion cannot resolve against a single struct column.
13008        let dotted = ExprLowerer::with_prop_names(&arena, &vm, prop_names.clone())
13009            .lower(access)
13010            .unwrap();
13011        assert!(
13012            matches!(&dotted, DfExpr::Column(_)) && dotted.to_string() == "__gf_elem.a",
13013            "expected dotted column, got {dotted:?}"
13014        );
13015
13016        // With the marker: struct-aware `get_field` (a scalar-function call, not a
13017        // Column), so plan-time validation resolves against the element's Struct.
13018        let via_get_field = ExprLowerer::with_prop_names(&arena, &vm, prop_names.clone())
13019            .with_elem_struct_col("__gf_elem".to_owned())
13020            .lower(access)
13021            .unwrap();
13022        assert!(
13023            !matches!(&via_get_field, DfExpr::Column(_)),
13024            "element field access must not be a dotted column: {via_get_field:?}"
13025        );
13026        assert!(
13027            via_get_field.to_string().contains("get_field"),
13028            "expected a get_field call, got {via_get_field}"
13029        );
13030    }
13031
13032    #[test]
13033    fn map_column_field_access_uses_get_field_via_schema() {
13034        // #1017: a `PropertyAccess` on a plain-map-typed column (e.g. `input.list`
13035        // where `input` was bound by `UNWIND [{list: …}] AS input`) resolves via
13036        // struct-aware `get_field` — NOT a dotted qualified column `input.list`,
13037        // which fails "No field named input.list" against a single struct column.
13038        use datafusion::arrow::datatypes::{DataType, Field, Fields, Schema};
13039        use datafusion::common::DFSchema;
13040
13041        let map_ty = DataType::Struct(Fields::from(vec![
13042            Field::new("list", DataType::new_list(DataType::Int64, true), true),
13043            Field::new("fixed", DataType::Boolean, true),
13044        ]));
13045        let schema = Schema::new(vec![Field::new("input", map_ty, true)]);
13046        let df_schema = std::sync::Arc::new(DFSchema::try_from(schema).unwrap());
13047
13048        let mut arena = ExprArena::new();
13049        let base = arena.push(IrExpr::VarRef(VarId(0)));
13050        let access = arena.push(IrExpr::PropertyAccess {
13051            base,
13052            prop: PropId(0),
13053        });
13054        let mut vm = VarMap::new();
13055        vm.insert(VarId(0), "input");
13056        let mut prop_names = HashMap::new();
13057        prop_names.insert(0u32, "list".to_owned());
13058
13059        let out = ExprLowerer::with_prop_names(&arena, &vm, prop_names)
13060            .with_input_schema(df_schema)
13061            .lower(access)
13062            .unwrap();
13063        assert!(
13064            !matches!(&out, DfExpr::Column(_)),
13065            "map field must not be a dotted column: {out:?}"
13066        );
13067        assert!(
13068            out.to_string().contains("get_field"),
13069            "expected a get_field call, got {out}"
13070        );
13071    }
13072
13073    #[test]
13074    fn list_plus_uses_native_ops_for_homogeneous_schema_types() {
13075        // #1017: with the input schema attached, `is_list_typed` types a list-valued
13076        // COLUMN operand. Homogeneous list ops stay in native Arrow list functions
13077        // so downstream quantifiers keep concrete element types; heterogeneous
13078        // list ops route to Cypher list-plus instead of numeric `+`.
13079        use datafusion::arrow::datatypes::{DataType, Field, Schema};
13080        use datafusion::common::DFSchema;
13081        use graphforge_ir::expr::BinaryOpKind;
13082
13083        let schema = Schema::new(vec![
13084            Field::new("xs", DataType::new_list(DataType::Int64, true), true),
13085            Field::new("y", DataType::Int64, true),
13086            Field::new("ys", DataType::new_list(DataType::Int64, true), true),
13087            Field::new("s", DataType::Utf8, true),
13088            Field::new("ss", DataType::new_list(DataType::Utf8, true), true),
13089        ]);
13090        let df_schema = std::sync::Arc::new(DFSchema::try_from(schema).unwrap());
13091
13092        let mut arena = ExprArena::new();
13093        let xs = arena.push(IrExpr::VarRef(VarId(0)));
13094        let y = arena.push(IrExpr::VarRef(VarId(1)));
13095        let ys = arena.push(IrExpr::VarRef(VarId(2)));
13096        let s = arena.push(IrExpr::VarRef(VarId(3)));
13097        let ss = arena.push(IrExpr::VarRef(VarId(4)));
13098        let append = arena.push(IrExpr::BinaryOp {
13099            op: BinaryOpKind::Add,
13100            left: xs,
13101            right: y,
13102        });
13103        let concat = arena.push(IrExpr::BinaryOp {
13104            op: BinaryOpKind::Add,
13105            left: xs,
13106            right: ys,
13107        });
13108        let hetero_append = arena.push(IrExpr::BinaryOp {
13109            op: BinaryOpKind::Add,
13110            left: xs,
13111            right: s,
13112        });
13113        let hetero_concat = arena.push(IrExpr::BinaryOp {
13114            op: BinaryOpKind::Add,
13115            left: xs,
13116            right: ss,
13117        });
13118        let mut vm = VarMap::new();
13119        vm.insert(VarId(0), "xs");
13120        vm.insert(VarId(1), "y");
13121        vm.insert(VarId(2), "ys");
13122        vm.insert(VarId(3), "s");
13123        vm.insert(VarId(4), "ss");
13124
13125        let lowerer =
13126            ExprLowerer::with_prop_names(&arena, &vm, HashMap::new()).with_input_schema(df_schema);
13127        assert!(
13128            lowerer
13129                .lower(append)
13130                .unwrap()
13131                .to_string()
13132                .contains("array_append"),
13133            "list + element should append"
13134        );
13135        assert!(
13136            lowerer
13137                .lower(concat)
13138                .unwrap()
13139                .to_string()
13140                .contains("array_concat"),
13141            "list + list should concat"
13142        );
13143        assert!(
13144            lowerer
13145                .lower(hetero_append)
13146                .unwrap()
13147                .to_string()
13148                .contains("cypher_list_plus"),
13149            "list + heterogeneous element should use tagged list-plus"
13150        );
13151        assert!(
13152            lowerer
13153                .lower(hetero_concat)
13154                .unwrap()
13155                .to_string()
13156                .contains("cypher_list_plus"),
13157            "list + heterogeneous list should use tagged list-plus"
13158        );
13159    }
13160
13161    #[test]
13162    fn cypher_list_plus_concats_decoded_list_element() {
13163        use datafusion::arrow::array::{Array, ArrayRef, ListArray};
13164        use datafusion::arrow::datatypes::Field;
13165        use datafusion::config::ConfigOptions;
13166        use datafusion::scalar::ScalarValue as S;
13167        use std::sync::Arc;
13168
13169        let list = |items: Vec<S>| S::List(S::new_list(&items, &DataType::Int64, true));
13170        let left_items = vec![
13171            list(vec![S::Int64(Some(1))]),
13172            list(vec![S::Int64(Some(2)), S::Int64(Some(3))]),
13173            list(vec![S::Int64(Some(4)), S::Int64(Some(5))]),
13174        ];
13175        let left = S::List(S::new_list(&left_items, &left_items[0].data_type(), true))
13176            .to_array()
13177            .unwrap();
13178
13179        let right_list = list(vec![S::Int64(Some(8)), S::Int64(Some(9))]);
13180        let right: ArrayRef = Arc::new(build_het_struct(&[right_list], 1).unwrap());
13181        let udf = CypherListPlus::new();
13182        let arg_types = vec![left.data_type().clone(), right.data_type().clone()];
13183        let return_type = udf.return_type(&arg_types).unwrap();
13184        let args = ScalarFunctionArgs {
13185            args: vec![ColumnarValue::Array(left), ColumnarValue::Array(right)],
13186            arg_fields: vec![
13187                Arc::new(Field::new(
13188                    "l",
13189                    DataType::new_list(DataType::Int64, true),
13190                    true,
13191                )),
13192                Arc::new(Field::new("r", DataType::Struct(het_fields(1)), true)),
13193            ],
13194            number_rows: 1,
13195            return_field: Arc::new(Field::new("out", return_type, true)),
13196            config_options: Arc::new(ConfigOptions::default()),
13197        };
13198        let out = match udf.invoke_with_args(args).unwrap() {
13199            ColumnarValue::Array(a) => a,
13200            ColumnarValue::Scalar(s) => s.to_array().unwrap(),
13201        };
13202        let out = out.as_any().downcast_ref::<ListArray>().unwrap();
13203        assert!(!out.is_null(0));
13204        let values = out.value(0);
13205        assert_eq!(values.len(), 5);
13206        assert_eq!(
13207            decode_het(&ScalarValue::try_from_array(&values, 3).unwrap()),
13208            Some(S::Int64(Some(8)))
13209        );
13210        assert_eq!(
13211            decode_het(&ScalarValue::try_from_array(&values, 4).unwrap()),
13212            Some(S::Int64(Some(9)))
13213        );
13214    }
13215
13216    #[test]
13217    fn tagged_list_element_plus_preserves_dynamic_concat_and_null_rows() {
13218        use datafusion::arrow::array::{Array, ArrayRef, ListArray};
13219        use datafusion::arrow::buffer::{NullBuffer, OffsetBuffer, ScalarBuffer};
13220        use datafusion::arrow::datatypes::Field;
13221        use datafusion::scalar::ScalarValue as S;
13222        use std::sync::Arc;
13223
13224        let values: ArrayRef = Arc::new(
13225            build_het_struct(
13226                &[S::Int64(Some(1)), S::Boolean(Some(true)), S::Int64(Some(2))],
13227                1,
13228            )
13229            .unwrap(),
13230        );
13231        let item = Arc::new(Field::new("item", values.data_type().clone(), true));
13232        let left: ArrayRef = Arc::new(ListArray::new(
13233            item.clone(),
13234            OffsetBuffer::new(ScalarBuffer::from(vec![0i32, 2, 3, 3, 3])),
13235            values,
13236            Some(NullBuffer::from(vec![true, true, true, false])),
13237        ));
13238        let nested = S::List(S::new_list(
13239            &[S::Int64(Some(8)), S::Int64(Some(7))],
13240            &DataType::Int64,
13241            true,
13242        ));
13243        let right: ArrayRef = Arc::new(
13244            build_het_struct(&[S::Int64(Some(9)), nested, S::Null, S::Int64(Some(1))], 1).unwrap(),
13245        );
13246        let return_type = DataType::List(item);
13247        let output = invoke_tagged_list_element_plus(&left, &right, &return_type)
13248            .unwrap()
13249            .expect("tagged fast path");
13250        let output = output.as_any().downcast_ref::<ListArray>().unwrap();
13251        let decode_row = |row: usize| {
13252            let values = output.value(row);
13253            (0..values.len())
13254                .map(|index| {
13255                    decode_het(&ScalarValue::try_from_array(&values, index).unwrap()).unwrap()
13256                })
13257                .collect::<Vec<_>>()
13258        };
13259
13260        assert_eq!(
13261            decode_row(0),
13262            vec![S::Int64(Some(1)), S::Boolean(Some(true)), S::Int64(Some(9))]
13263        );
13264        assert_eq!(
13265            decode_row(1),
13266            vec![S::Int64(Some(2)), S::Int64(Some(8)), S::Int64(Some(7))]
13267        );
13268        assert_eq!(decode_row(2), vec![S::Null]);
13269        assert!(output.is_null(3));
13270    }
13271
13272    #[test]
13273    fn cypher_conversions_decode_tagged_values() {
13274        use datafusion::arrow::array::{Array, ArrayRef, Float64Array, Int64Array, StringArray};
13275        use datafusion::arrow::datatypes::Field;
13276        use datafusion::config::ConfigOptions;
13277        use datafusion::scalar::ScalarValue as S;
13278        use std::sync::Arc;
13279
13280        let values: ArrayRef = Arc::new(
13281            build_het_struct(
13282                &[
13283                    S::Int64(Some(2)),
13284                    S::Float64(Some(2.9)),
13285                    S::Utf8(Some("foo".to_owned())),
13286                ],
13287                0,
13288            )
13289            .unwrap(),
13290        );
13291        let invoke = |kind: CypherConversionKind| {
13292            let udf = CypherConversion::new(kind);
13293            let return_type = udf.return_type(&[values.data_type().clone()]).unwrap();
13294            let args = ScalarFunctionArgs {
13295                args: vec![ColumnarValue::Array(Arc::clone(&values))],
13296                arg_fields: vec![Arc::new(Field::new("v", values.data_type().clone(), true))],
13297                number_rows: values.len(),
13298                return_field: Arc::new(Field::new("out", return_type, true)),
13299                config_options: Arc::new(ConfigOptions::default()),
13300            };
13301            match udf.invoke_with_args(args).unwrap() {
13302                ColumnarValue::Array(a) => a,
13303                ColumnarValue::Scalar(s) => s.to_array_of_size(values.len()).unwrap(),
13304            }
13305        };
13306
13307        let ints = invoke(CypherConversionKind::Integer);
13308        let ints = ints.as_any().downcast_ref::<Int64Array>().unwrap();
13309        assert_eq!(ints.value(0), 2);
13310        assert_eq!(ints.value(1), 2);
13311        assert!(ints.is_null(2));
13312
13313        let floats = invoke(CypherConversionKind::Float);
13314        let floats = floats.as_any().downcast_ref::<Float64Array>().unwrap();
13315        assert_eq!(floats.value(0), 2.0);
13316        assert_eq!(floats.value(1), 2.9);
13317        assert!(floats.is_null(2));
13318
13319        let strings = match CYPHER_TO_STRING
13320            .invoke_with_args(ScalarFunctionArgs {
13321                args: vec![ColumnarValue::Array(values)],
13322                arg_fields: vec![Arc::new(Field::new(
13323                    "v",
13324                    DataType::Struct(het_fields(0)),
13325                    true,
13326                ))],
13327                number_rows: 3,
13328                return_field: Arc::new(Field::new("out", DataType::Utf8, true)),
13329                config_options: Arc::new(ConfigOptions::default()),
13330            })
13331            .unwrap()
13332        {
13333            ColumnarValue::Array(a) => a,
13334            ColumnarValue::Scalar(s) => s.to_array_of_size(3).unwrap(),
13335        };
13336        let strings = strings.as_any().downcast_ref::<StringArray>().unwrap();
13337        assert_eq!(strings.value(0), "2");
13338        assert_eq!(strings.value(1), "2.9");
13339        assert_eq!(strings.value(2), "foo");
13340    }
13341
13342    #[test]
13343    fn het_list_map_roundtrip_and_order() {
13344        use datafusion::scalar::ScalarValue as S;
13345        // A plain map is recognised; a typed temporal struct is NOT (#1005).
13346        let map = const_map_scalar(&[
13347            ("a".to_owned(), S::Int64(Some(2))),
13348            ("b".to_owned(), S::Boolean(Some(true))),
13349        ])
13350        .expect("map scalar");
13351        let S::Struct(m) = &map else {
13352            panic!("map is a struct")
13353        };
13354        assert!(is_plain_map_struct(m));
13355        let S::Struct(d) = date_scalar(Some(0)) else {
13356            panic!("date is a struct")
13357        };
13358        assert!(!is_plain_map_struct(&d));
13359
13360        // A mixed het list `[1, {a: 2, b: true}]` encodes to a tagged struct whose
13361        // map element (tag 5) decodes back to a structurally-equal map.
13362        let scalars = vec![S::Int64(Some(1)), map.clone()];
13363        assert_eq!(het_depth(&scalars[0]), Some(0));
13364        assert_eq!(het_depth(&scalars[1]), Some(1));
13365        let depth = scalars.iter().filter_map(het_depth).max().unwrap();
13366        let elem = build_het_struct(&scalars, depth).expect("build tagged struct");
13367        let e0 = ScalarValue::try_from_array(&elem, 0).unwrap();
13368        assert_eq!(decode_het(&e0), Some(S::Int64(Some(1))));
13369        let e1 = ScalarValue::try_from_array(&elem, 1).unwrap();
13370        let decoded = decode_het(&e1).expect("decode map element");
13371        assert_eq!(cypher_value_eq(&decoded, &map), Some(true));
13372
13373        // Orderability (ADR 0011 slice 5): maps rank above numbers; two maps order
13374        // by their (sorted) entries.
13375        let m1 = const_map_scalar(&[("a".to_owned(), S::Int64(Some(1)))]).unwrap();
13376        let m2 = const_map_scalar(&[("a".to_owned(), S::Int64(Some(2)))]).unwrap();
13377        assert_eq!(cypher_order(&m1, &m2), std::cmp::Ordering::Less);
13378        assert_eq!(
13379            cypher_order(&S::Int64(Some(99)), &m1),
13380            std::cmp::Ordering::Less
13381        );
13382    }
13383
13384    #[test]
13385    fn empty_map_scalar_has_one_row_and_no_fields() {
13386        use datafusion::arrow::array::Array;
13387        use datafusion::scalar::ScalarValue as S;
13388
13389        let map = const_map_scalar(&[]).expect("empty map scalar");
13390        let S::Struct(values) = map else {
13391            panic!("empty map is a struct")
13392        };
13393        assert_eq!(values.len(), 1);
13394        assert_eq!(values.num_columns(), 0);
13395        assert!(is_plain_map_struct(&values));
13396
13397        let scalars = vec![S::Int64(Some(1)), S::Struct(values.clone())];
13398        let encoded = build_het_struct(&scalars, 1).expect("encode empty map");
13399        let tagged = S::try_from_array(&encoded, 1).expect("tagged empty map");
13400        let S::Struct(decoded) = decode_het(&tagged).expect("decode empty map") else {
13401            panic!("decoded empty map is a struct")
13402        };
13403        assert_eq!(decoded.len(), 1);
13404        assert_eq!(decoded.num_columns(), 0);
13405    }
13406
13407    #[test]
13408    fn dictionary_scalar_is_normalized_for_heterogeneous_encoding() {
13409        use datafusion::arrow::datatypes::DataType;
13410        use datafusion::scalar::ScalarValue as S;
13411
13412        let dictionary = S::Dictionary(
13413            Box::new(DataType::Int32),
13414            Box::new(S::Utf8(Some("value".to_owned()))),
13415        );
13416        let scalars = vec![S::Int64(Some(1)), dictionary];
13417        assert_eq!(het_depth(&scalars[1]), Some(0));
13418
13419        let encoded = build_het_struct(&scalars, 0).expect("encode dictionary scalar");
13420        let tagged = S::try_from_array(&encoded, 1).expect("tagged dictionary scalar");
13421        assert_eq!(decode_het(&tagged), Some(S::Utf8(Some("value".to_owned()))));
13422    }
13423
13424    #[test]
13425    fn cypher_value_eq_scalars() {
13426        use datafusion::scalar::ScalarValue as S;
13427        let i = |n| S::Int64(Some(n));
13428
13429        assert_eq!(cypher_value_eq(&i(1), &i(1)), Some(true));
13430        assert_eq!(cypher_value_eq(&i(1), &i(2)), Some(false));
13431        // number vs string ⇒ false (different types are never equal, not an error)
13432        assert_eq!(
13433            cypher_value_eq(&i(1), &S::Utf8(Some("1".into()))),
13434            Some(false)
13435        );
13436        // 1 = 1.0 (cross numeric)
13437        assert_eq!(cypher_value_eq(&i(1), &S::Float64(Some(1.0))), Some(true));
13438        assert_eq!(cypher_value_eq(&i(1), &S::UInt64(Some(1))), Some(true));
13439        assert_eq!(
13440            cypher_value_eq(&S::Float64(Some(f64::NAN)), &S::Float64(Some(f64::NAN))),
13441            Some(false)
13442        );
13443        // null propagates
13444        assert_eq!(cypher_value_eq(&i(1), &S::Int64(None)), None);
13445        assert_eq!(cypher_value_eq(&S::Null, &i(1)), None);
13446    }
13447
13448    #[test]
13449    fn cypher_comparison_predicate_handles_nan_and_cross_type() {
13450        use datafusion::scalar::ScalarValue as S;
13451
13452        assert_eq!(
13453            cypher_compare_pred(&S::Float64(Some(f64::NAN)), &S::Int64(Some(1)), 2),
13454            Some(false)
13455        );
13456        assert_eq!(
13457            cypher_compare_pred(&S::Utf8(Some("1".to_owned())), &S::Int64(Some(1)), 0,),
13458            None
13459        );
13460        assert_eq!(
13461            cypher_compare_pred(&S::Int64(Some(1)), &S::Float64(Some(2.0)), 0),
13462            Some(true)
13463        );
13464        assert_eq!(
13465            cypher_compare_pred(&S::UInt64(Some(1)), &S::Int64(Some(2)), 0),
13466            Some(true)
13467        );
13468    }
13469
13470    // -----------------------------------------------------------------------
13471    // Literal tests
13472    // -----------------------------------------------------------------------
13473
13474    #[test]
13475    fn literal_null() {
13476        let mut arena = ExprArena::new();
13477        let id = arena.push(IrExpr::Literal(IrLiteral::Null));
13478        let vm = VarMap::new();
13479        let result = make_lowerer(&arena, &vm).lower(id).unwrap();
13480        assert!(matches!(result, DfExpr::Literal(ScalarValue::Null, _)));
13481    }
13482
13483    #[test]
13484    fn literal_bool() {
13485        let mut arena = ExprArena::new();
13486        let id = arena.push(IrExpr::Literal(IrLiteral::Bool(true)));
13487        let vm = VarMap::new();
13488        let result = make_lowerer(&arena, &vm).lower(id).unwrap();
13489        assert!(matches!(
13490            result,
13491            DfExpr::Literal(ScalarValue::Boolean(Some(true)), _)
13492        ));
13493    }
13494
13495    #[test]
13496    fn literal_int() {
13497        let mut arena = ExprArena::new();
13498        let id = arena.push(IrExpr::Literal(IrLiteral::Int(42)));
13499        let vm = VarMap::new();
13500        let result = make_lowerer(&arena, &vm).lower(id).unwrap();
13501        assert!(matches!(
13502            result,
13503            DfExpr::Literal(ScalarValue::Int64(Some(42)), _)
13504        ));
13505    }
13506
13507    #[test]
13508    fn literal_float() {
13509        let mut arena = ExprArena::new();
13510        let id = arena.push(IrExpr::Literal(IrLiteral::Float(2.71)));
13511        let vm = VarMap::new();
13512        let result = make_lowerer(&arena, &vm).lower(id).unwrap();
13513        assert!(matches!(
13514            result,
13515            DfExpr::Literal(ScalarValue::Float64(Some(_)), _)
13516        ));
13517    }
13518
13519    #[test]
13520    fn literal_str() {
13521        let mut arena = ExprArena::new();
13522        let id = arena.push(IrExpr::Literal(IrLiteral::Str("hello".into())));
13523        let vm = VarMap::new();
13524        let result = make_lowerer(&arena, &vm).lower(id).unwrap();
13525        assert!(matches!(
13526            result,
13527            DfExpr::Literal(ScalarValue::Utf8(Some(_)), _)
13528        ));
13529    }
13530
13531    #[test]
13532    fn literal_duration() {
13533        let mut arena = ExprArena::new();
13534        let id = arena.push(IrExpr::Literal(IrLiteral::Duration {
13535            months: 0,
13536            days: 0,
13537            seconds: 1,
13538            nanos: 0,
13539        }));
13540        let vm = VarMap::new();
13541        let result = make_lowerer(&arena, &vm).lower(id).unwrap();
13542        assert!(matches!(result, DfExpr::Literal(ScalarValue::Struct(_), _)));
13543    }
13544
13545    #[test]
13546    fn literal_datetime() {
13547        let mut arena = ExprArena::new();
13548        let id = arena.push(IrExpr::Literal(IrLiteral::DateTime(0)));
13549        let vm = VarMap::new();
13550        let result = make_lowerer(&arena, &vm).lower(id).unwrap();
13551        assert!(matches!(
13552            result,
13553            DfExpr::Literal(ScalarValue::TimestampMicrosecond(Some(0), Some(_)), _)
13554        ));
13555    }
13556
13557    // -----------------------------------------------------------------------
13558    // VarRef tests
13559    // -----------------------------------------------------------------------
13560
13561    #[test]
13562    fn var_ref_bound() {
13563        let mut arena = ExprArena::new();
13564        let id = arena.push(IrExpr::VarRef(VarId(0)));
13565        let mut vm = VarMap::new();
13566        vm.insert(VarId(0), "node_id");
13567        let result = make_lowerer(&arena, &vm).lower(id).unwrap();
13568        assert!(matches!(result, DfExpr::Column(_)));
13569        if let DfExpr::Column(col) = result {
13570            assert_eq!(col.name, "node_id");
13571        }
13572    }
13573
13574    #[test]
13575    fn var_ref_unbound_returns_error() {
13576        let mut arena = ExprArena::new();
13577        let id = arena.push(IrExpr::VarRef(VarId(99)));
13578        let vm = VarMap::new();
13579        let result = make_lowerer(&arena, &vm).lower(id);
13580        assert!(matches!(result, Err(LoweringError::UnboundVar(99))));
13581    }
13582
13583    // -----------------------------------------------------------------------
13584    // UnaryOp tests
13585    // -----------------------------------------------------------------------
13586
13587    #[test]
13588    fn unary_not() {
13589        let mut arena = ExprArena::new();
13590        let inner = arena.push(IrExpr::Literal(IrLiteral::Bool(true)));
13591        let id = arena.push(IrExpr::UnaryOp {
13592            op: UnaryOpKind::Not,
13593            expr: inner,
13594        });
13595        let vm = VarMap::new();
13596        let result = make_lowerer(&arena, &vm).lower(id).unwrap();
13597        assert!(matches!(result, DfExpr::Not(_)));
13598    }
13599
13600    #[test]
13601    fn unary_is_null() {
13602        let mut arena = ExprArena::new();
13603        let mut vm = VarMap::new();
13604        vm.insert(VarId(0), "x");
13605        let inner = arena.push(IrExpr::VarRef(VarId(0)));
13606        let id = arena.push(IrExpr::UnaryOp {
13607            op: UnaryOpKind::IsNull,
13608            expr: inner,
13609        });
13610        let result = make_lowerer(&arena, &vm).lower(id).unwrap();
13611        assert!(matches!(result, DfExpr::IsNull(_)));
13612    }
13613
13614    #[test]
13615    fn unary_is_not_null() {
13616        let mut arena = ExprArena::new();
13617        let mut vm = VarMap::new();
13618        vm.insert(VarId(0), "x");
13619        let inner = arena.push(IrExpr::VarRef(VarId(0)));
13620        let id = arena.push(IrExpr::UnaryOp {
13621            op: UnaryOpKind::IsNotNull,
13622            expr: inner,
13623        });
13624        let result = make_lowerer(&arena, &vm).lower(id).unwrap();
13625        assert!(matches!(result, DfExpr::IsNotNull(_)));
13626    }
13627
13628    // -----------------------------------------------------------------------
13629    // BinaryOp tests
13630    // -----------------------------------------------------------------------
13631
13632    #[test]
13633    fn binary_eq() {
13634        let mut arena = ExprArena::new();
13635        let l = arena.push(IrExpr::Literal(IrLiteral::Int(1)));
13636        let r = arena.push(IrExpr::Literal(IrLiteral::Int(2)));
13637        let id = arena.push(IrExpr::BinaryOp {
13638            op: BinaryOpKind::Eq,
13639            left: l,
13640            right: r,
13641        });
13642        let vm = VarMap::new();
13643        let result = make_lowerer(&arena, &vm).lower(id).unwrap();
13644        // `=` lowers to the type-tolerant `cypher_eq` UDF (ADR 0009), not a
13645        // native `BinaryExpr` (which would plan-error on mismatched types).
13646        let DfExpr::ScalarFunction(sf) = result else {
13647            panic!("expected a cypher_eq scalar-function call, got {result:?}");
13648        };
13649        assert_eq!(sf.func.name(), "cypher_eq");
13650        assert_eq!(sf.args.len(), 2);
13651    }
13652
13653    #[test]
13654    fn binary_in_list() {
13655        let mut arena = ExprArena::new();
13656        let mut vm = VarMap::new();
13657        vm.insert(VarId(0), "x");
13658        let l = arena.push(IrExpr::VarRef(VarId(0)));
13659        let one = arena.push(IrExpr::Literal(IrLiteral::Int(1)));
13660        let two = arena.push(IrExpr::Literal(IrLiteral::Int(2)));
13661        let list = arena.push(IrExpr::ListLiteral(vec![one, two]));
13662        let id = arena.push(IrExpr::BinaryOp {
13663            op: BinaryOpKind::In,
13664            left: l,
13665            right: list,
13666        });
13667        let result = make_lowerer(&arena, &vm).lower(id).unwrap();
13668        // Cypher `IN` lowers to the structural three-valued `cypher_in` UDF
13669        // (ADR 0011), not DataFusion's `in_list` (which treats the whole list as a
13670        // single element and type-errors on `3 IN ([1,2,3])`).
13671        let DfExpr::ScalarFunction(sf) = result else {
13672            panic!("expected a cypher_in scalar-function call, got {result:?}");
13673        };
13674        assert_eq!(sf.func.name(), "cypher_in");
13675        assert_eq!(sf.args.len(), 2);
13676    }
13677
13678    fn label_membership_expr(arena: &mut ExprArena, left: IrExpr, node_var: VarId) -> ExprId {
13679        let left = arena.push(left);
13680        let node = arena.push(IrExpr::VarRef(node_var));
13681        let labels = arena.push(IrExpr::FunctionCall {
13682            name: "labels".into(),
13683            args: vec![node],
13684        });
13685        arena.push(IrExpr::BinaryOp {
13686            op: BinaryOpKind::In,
13687            left,
13688            right: labels,
13689        })
13690    }
13691
13692    fn label_lowerer<'a>(arena: &'a ExprArena, var_map: &'a VarMap) -> ExprLowerer<'a> {
13693        ExprLowerer::with_prop_names_and_nodes(
13694            arena,
13695            var_map,
13696            HashMap::new(),
13697            HashMap::from([(0, NodeShape { prop_names: vec![] })]),
13698            HashMap::from([(7, "Known".to_owned())]),
13699            false,
13700        )
13701    }
13702
13703    #[test]
13704    fn known_literal_in_labels_lowers_to_direct_type_id_membership() {
13705        let mut arena = ExprArena::new();
13706        let id = label_membership_expr(
13707            &mut arena,
13708            IrExpr::Literal(IrLiteral::Str("Known".into())),
13709            VarId(0),
13710        );
13711        let mut vm = VarMap::new();
13712        vm.insert(VarId(0), "var_0");
13713
13714        let result = label_lowerer(&arena, &vm).lower(id).unwrap();
13715        let DfExpr::ScalarFunction(sf) = &result else {
13716            panic!("expected array_has scalar function, got {result:?}");
13717        };
13718        assert_eq!(sf.func.name(), "array_has");
13719        let rendered = result.to_string();
13720        assert!(rendered.contains("var_0.type_ids"));
13721        assert!(rendered.contains("UInt32(7)"));
13722        assert!(!rendered.contains("cypher_in"));
13723        assert!(!rendered.contains("array_concat"));
13724    }
13725
13726    #[test]
13727    fn unknown_literal_in_labels_retains_generic_membership() {
13728        let mut arena = ExprArena::new();
13729        let id = label_membership_expr(
13730            &mut arena,
13731            IrExpr::Literal(IrLiteral::Str("Unknown".into())),
13732            VarId(0),
13733        );
13734        let mut vm = VarMap::new();
13735        vm.insert(VarId(0), "var_0");
13736
13737        let result = label_lowerer(&arena, &vm).lower(id).unwrap();
13738        let DfExpr::ScalarFunction(sf) = result else {
13739            panic!("expected cypher_in scalar function");
13740        };
13741        assert_eq!(sf.func.name(), "cypher_in");
13742    }
13743
13744    #[test]
13745    fn dynamic_in_labels_retains_generic_membership() {
13746        let mut arena = ExprArena::new();
13747        let id = label_membership_expr(&mut arena, IrExpr::VarRef(VarId(1)), VarId(0));
13748        let mut vm = VarMap::new();
13749        vm.insert(VarId(0), "var_0");
13750        vm.insert(VarId(1), "label_name");
13751
13752        let result = label_lowerer(&arena, &vm).lower(id).unwrap();
13753        let DfExpr::ScalarFunction(sf) = result else {
13754            panic!("expected cypher_in scalar function");
13755        };
13756        assert_eq!(sf.func.name(), "cypher_in");
13757    }
13758
13759    // -----------------------------------------------------------------------
13760    // Compound predicate: a.age > 30 AND b.name = $name
13761    // -----------------------------------------------------------------------
13762
13763    #[test]
13764    fn compound_predicate() {
13765        let mut arena = ExprArena::new();
13766        let mut vm = VarMap::new();
13767        vm.insert(VarId(0), "a.age"); // a.age resolved to column
13768        vm.insert(VarId(1), "b.name"); // b.name resolved to column
13769
13770        let a_age = arena.push(IrExpr::VarRef(VarId(0)));
13771        let thirty = arena.push(IrExpr::Literal(IrLiteral::Int(30)));
13772        let gt = arena.push(IrExpr::BinaryOp {
13773            op: BinaryOpKind::Gt,
13774            left: a_age,
13775            right: thirty,
13776        });
13777
13778        let b_name = arena.push(IrExpr::VarRef(VarId(1)));
13779        let param = arena.push(IrExpr::Parameter("name".into()));
13780        let eq = arena.push(IrExpr::BinaryOp {
13781            op: BinaryOpKind::Eq,
13782            left: b_name,
13783            right: param,
13784        });
13785
13786        let and = arena.push(IrExpr::BinaryOp {
13787            op: BinaryOpKind::And,
13788            left: gt,
13789            right: eq,
13790        });
13791
13792        let result = make_lowerer(&arena, &vm).lower(and).unwrap();
13793        assert!(matches!(result, DfExpr::ScalarFunction(_)));
13794        if let DfExpr::ScalarFunction(sf) = result {
13795            assert_eq!(sf.func.name(), "cypher_and");
13796        }
13797    }
13798
13799    #[test]
13800    fn xor_chain_lowering_has_one_udf_per_source_operator() {
13801        use datafusion::common::tree_node::{TreeNode, TreeNodeRecursion};
13802
13803        let lower_and_count = |operands: usize| {
13804            let mut arena = ExprArena::new();
13805            let mut root = arena.push(IrExpr::Literal(IrLiteral::Bool(true)));
13806            for index in 1..operands {
13807                let right = match index % 3 {
13808                    0 => IrLiteral::Bool(true),
13809                    1 => IrLiteral::Bool(false),
13810                    _ => IrLiteral::Null,
13811                };
13812                let right = arena.push(IrExpr::Literal(right));
13813                root = arena.push(IrExpr::BinaryOp {
13814                    op: BinaryOpKind::Xor,
13815                    left: root,
13816                    right,
13817                });
13818            }
13819
13820            let lowered = make_lowerer(&arena, &VarMap::new())
13821                .lower(root)
13822                .expect("XOR chain lowers");
13823            let mut udf_count = 0;
13824            lowered
13825                .apply(|expr| {
13826                    if matches!(
13827                        expr,
13828                        DfExpr::ScalarFunction(function)
13829                            if function.func.name() == "cypher_xor"
13830                    ) {
13831                        udf_count += 1;
13832                    }
13833                    Ok(TreeNodeRecursion::Continue)
13834                })
13835                .expect("expression traversal succeeds");
13836            udf_count
13837        };
13838
13839        let eleven = lower_and_count(11);
13840        let twenty_two = lower_and_count(22);
13841        assert_eq!(eleven, 10);
13842        assert_eq!(twenty_two, 21);
13843        assert!(
13844            twenty_two <= eleven * 3,
13845            "doubling operands must keep deterministic lowering work within 3x"
13846        );
13847    }
13848
13849    #[test]
13850    fn cypher_xor_implements_three_valued_truth_table() {
13851        use datafusion::arrow::array::{Array, BooleanArray};
13852        use datafusion::arrow::datatypes::Field;
13853        use datafusion::config::ConfigOptions;
13854
13855        let left = BooleanArray::from(vec![
13856            Some(false),
13857            Some(false),
13858            Some(false),
13859            Some(true),
13860            Some(true),
13861            Some(true),
13862            None,
13863            None,
13864            None,
13865        ]);
13866        let right = BooleanArray::from(vec![
13867            Some(false),
13868            Some(true),
13869            None,
13870            Some(false),
13871            Some(true),
13872            None,
13873            Some(false),
13874            Some(true),
13875            None,
13876        ]);
13877        let expected = [
13878            Some(false),
13879            Some(true),
13880            None,
13881            Some(true),
13882            Some(false),
13883            None,
13884            None,
13885            None,
13886            None,
13887        ];
13888        let field = Arc::new(Field::new("value", DataType::Boolean, true));
13889        let arguments = ScalarFunctionArgs {
13890            args: vec![
13891                ColumnarValue::Array(Arc::new(left)),
13892                ColumnarValue::Array(Arc::new(right)),
13893            ],
13894            arg_fields: vec![Arc::clone(&field), field],
13895            number_rows: expected.len(),
13896            return_field: Arc::new(Field::new("xor", DataType::Boolean, true)),
13897            config_options: Arc::new(ConfigOptions::default()),
13898        };
13899        let result = CypherBoolOp::new(CypherBoolOpKind::Xor)
13900            .invoke_with_args(arguments)
13901            .expect("XOR evaluates");
13902        let ColumnarValue::Array(result) = result else {
13903            panic!("array inputs must produce an array")
13904        };
13905        let result = result
13906            .as_any()
13907            .downcast_ref::<BooleanArray>()
13908            .expect("XOR result is boolean");
13909
13910        for (index, expected) in expected.into_iter().enumerate() {
13911            let actual = (!result.is_null(index)).then(|| result.value(index));
13912            assert_eq!(actual, expected, "truth-table row {index}");
13913        }
13914    }
13915
13916    // -----------------------------------------------------------------------
13917    // Function call tests
13918    // -----------------------------------------------------------------------
13919
13920    #[test]
13921    fn function_call_to_upper() {
13922        let mut arena = ExprArena::new();
13923        let mut vm = VarMap::new();
13924        vm.insert(VarId(0), "n.name");
13925        let arg = arena.push(IrExpr::VarRef(VarId(0)));
13926        let id = arena.push(IrExpr::FunctionCall {
13927            name: "toUpper".into(),
13928            args: vec![arg],
13929        });
13930        let result = make_lowerer(&arena, &vm).lower(id).unwrap();
13931        // upper() produces a ScalarFunction expr
13932        assert!(matches!(result, DfExpr::ScalarFunction(_)));
13933    }
13934
13935    #[test]
13936    fn function_call_unknown_returns_error() {
13937        let mut arena = ExprArena::new();
13938        let id = arena.push(IrExpr::FunctionCall {
13939            name: "unknownFn".into(),
13940            args: vec![],
13941        });
13942        let vm = VarMap::new();
13943        let result = make_lowerer(&arena, &vm).lower(id);
13944        assert!(matches!(result, Err(LoweringError::UnknownFunction(_))));
13945    }
13946
13947    // -----------------------------------------------------------------------
13948    // Relationship-list access lowering (#743)
13949    // -----------------------------------------------------------------------
13950
13951    /// Lower `fn_name(VarRef("r"), <int args>)` over a var `r` and return the
13952    /// rendered DataFusion expression string.
13953    fn lower_rel_fn(name: &str, int_args: &[i64]) -> String {
13954        let mut arena = ExprArena::new();
13955        let mut vm = VarMap::new();
13956        vm.insert(VarId(0), "var_1.rels");
13957        let mut args = vec![arena.push(IrExpr::VarRef(VarId(0)))];
13958        for &n in int_args {
13959            args.push(arena.push(IrExpr::Literal(IrLiteral::Int(n))));
13960        }
13961        let id = arena.push(IrExpr::FunctionCall {
13962            name: name.into(),
13963            args,
13964        });
13965        let expr = make_lowerer(&arena, &vm).lower(id).expect("lower");
13966        format!("{expr}")
13967    }
13968
13969    #[test]
13970    fn subscript_lowers_to_cypher_value_access() {
13971        // r[0] routes through Cypher's runtime subscript UDF so unknown and
13972        // parameterized list/map containers get Cypher error/null semantics.
13973        let s = lower_rel_fn("_subscript", &[0]);
13974        assert!(s.contains("cypher_value_access"), "got {s}");
13975        assert!(s.contains("var_1.rels"), "got {s}");
13976    }
13977
13978    #[test]
13979    fn head_and_last_lower_to_array_element() {
13980        assert!(lower_rel_fn("head", &[]).contains("array_element"));
13981        assert!(lower_rel_fn("last", &[]).contains("array_element"));
13982    }
13983
13984    #[test]
13985    fn slice_lowers_to_array_slice() {
13986        // r[0..2] → array_slice(var_1.rels, begin, end)
13987        let s = lower_rel_fn("_slice", &[0, 2]);
13988        assert!(s.contains("array_slice"), "got {s}");
13989        assert!(s.contains("var_1.rels"), "got {s}");
13990    }
13991
13992    #[test]
13993    fn slice_with_null_bounds_uses_array_length() {
13994        // r[..2]: start is Null → begin defaults to 1 (no array_length needed).
13995        // r[1..]: end is Null → end defaults to array_length(list).
13996        let mut arena = ExprArena::new();
13997        let mut vm = VarMap::new();
13998        vm.insert(VarId(0), "var_1.rels");
13999        let list = arena.push(IrExpr::VarRef(VarId(0)));
14000        let start = arena.push(IrExpr::Literal(IrLiteral::Int(1)));
14001        let end_null = arena.push(IrExpr::Literal(IrLiteral::Null));
14002        let id = arena.push(IrExpr::FunctionCall {
14003            name: "_slice".into(),
14004            args: vec![list, start, end_null],
14005        });
14006        let expr = make_lowerer(&arena, &vm).lower(id).expect("lower");
14007        let s = format!("{expr}");
14008        assert!(s.contains("array_slice"), "got {s}");
14009        assert!(
14010            s.contains("array_length"),
14011            "an unbounded end must default to array_length: {s}"
14012        );
14013    }
14014
14015    #[test]
14016    fn type_of_element_lowers_to_runtime_graph_metadata_dispatch() {
14017        let mut arena = ExprArena::new();
14018        let mut vm = VarMap::new();
14019        vm.insert(VarId(0), "var_1.rels");
14020        let list = arena.push(IrExpr::VarRef(VarId(0)));
14021        let idx = arena.push(IrExpr::Literal(IrLiteral::Int(0)));
14022        let elem = arena.push(IrExpr::FunctionCall {
14023            name: "_subscript".into(),
14024            args: vec![list, idx],
14025        });
14026        let id = arena.push(IrExpr::FunctionCall {
14027            name: "type".into(),
14028            args: vec![elem],
14029        });
14030        let expr = make_lowerer(&arena, &vm).lower(id).expect("lower");
14031        let s = format!("{expr}");
14032        assert!(
14033            s.contains("cypher_relationship_type"),
14034            "must dispatch graph metadata by runtime value: {s}"
14035        );
14036        assert!(
14037            s.contains("cypher_value_access"),
14038            "over the indexed element: {s}"
14039        );
14040    }
14041
14042    fn invoke_graph_metadata(
14043        kind: GraphMetadataKind,
14044        value: ScalarValue,
14045    ) -> datafusion::error::Result<ScalarValue> {
14046        use datafusion::config::ConfigOptions;
14047
14048        let udf = CypherGraphMetadata::new(kind);
14049        let return_type = udf.return_type(&[])?;
14050        let result = udf.invoke_with_args(ScalarFunctionArgs {
14051            args: vec![ColumnarValue::Scalar(value.clone())],
14052            arg_fields: vec![Arc::new(Field::new("value", value.data_type(), true))],
14053            number_rows: 1,
14054            return_field: Arc::new(Field::new("metadata", return_type, true)),
14055            config_options: Arc::new(ConfigOptions::default()),
14056        })?;
14057        match result {
14058            ColumnarValue::Array(array) => ScalarValue::try_from_array(&array, 0),
14059            ColumnarValue::Scalar(value) => Ok(value),
14060        }
14061    }
14062
14063    #[test]
14064    fn graph_metadata_runtime_dispatch_validates_entity_kind() {
14065        use datafusion::arrow::array::{Int64Array, StringArray, StructArray};
14066        use datafusion::arrow::datatypes::Fields;
14067
14068        let labels = ScalarValue::List(ScalarValue::new_list(
14069            &[ScalarValue::Utf8(Some("Person".into()))],
14070            &DataType::Utf8,
14071            true,
14072        ));
14073        let node = ScalarValue::Struct(Arc::new(StructArray::new(
14074            Fields::from(vec![
14075                Field::new("node_uuid", DataType::Int64, false),
14076                Field::new("labels", labels.data_type(), true),
14077                Field::new("rel_type", DataType::Utf8, true),
14078            ]),
14079            vec![
14080                Arc::new(Int64Array::from(vec![1])),
14081                match &labels {
14082                    ScalarValue::List(array) => Arc::clone(array) as _,
14083                    _ => unreachable!(),
14084                },
14085                Arc::new(StringArray::from(vec![Some("property, not metadata")])),
14086            ],
14087            None,
14088        )));
14089        let relationship = ScalarValue::Struct(Arc::new(StructArray::new(
14090            Fields::from(vec![
14091                Field::new("edge_uuid", DataType::Int64, false),
14092                Field::new("rel_type", DataType::Utf8, false),
14093            ]),
14094            vec![
14095                Arc::new(Int64Array::from(vec![2])),
14096                Arc::new(StringArray::from(vec!["KNOWS"])),
14097            ],
14098            None,
14099        )));
14100
14101        let actual_labels =
14102            invoke_graph_metadata(GraphMetadataKind::Labels, node.clone()).expect("labels(node)");
14103        assert_eq!(actual_labels, labels);
14104        assert_eq!(
14105            invoke_graph_metadata(GraphMetadataKind::RelationshipType, relationship.clone())
14106                .expect("type(relationship)"),
14107            ScalarValue::Utf8(Some("KNOWS".into()))
14108        );
14109        assert!(invoke_graph_metadata(GraphMetadataKind::RelationshipType, node).is_err());
14110        assert!(invoke_graph_metadata(GraphMetadataKind::Labels, relationship).is_err());
14111        assert!(
14112            invoke_graph_metadata(GraphMetadataKind::Labels, ScalarValue::Null)
14113                .expect("labels(null)")
14114                .is_null()
14115        );
14116
14117        let colliding_map = ScalarValue::Struct(Arc::new(StructArray::new(
14118            Fields::from(vec![Field::new("labels", labels.data_type(), true)]),
14119            vec![match labels {
14120                ScalarValue::List(array) => array as _,
14121                _ => unreachable!(),
14122            }],
14123            None,
14124        )));
14125        assert!(invoke_graph_metadata(GraphMetadataKind::Labels, colliding_map).is_err());
14126    }
14127
14128    #[test]
14129    fn size_lowers_to_cypher_size_udf() {
14130        let mut arena = ExprArena::new();
14131        let mut vm = VarMap::new();
14132        vm.insert(VarId(0), "var_1.rels");
14133        let list = arena.push(IrExpr::VarRef(VarId(0)));
14134        let id = arena.push(IrExpr::FunctionCall {
14135            name: "size".into(),
14136            args: vec![list],
14137        });
14138        let expr = make_lowerer(&arena, &vm).lower(id).expect("lower");
14139        assert!(format!("{expr}").contains("cypher_size"), "got {expr}");
14140    }
14141
14142    #[test]
14143    fn cypher_in_decodes_tagged_list_rhs() {
14144        use datafusion::arrow::array::{Array, BooleanArray};
14145        use datafusion::scalar::ScalarValue as S;
14146        use std::sync::Arc;
14147
14148        let lhs = Arc::new(BooleanArray::from(vec![
14149            Some(true),
14150            Some(true),
14151            Some(true),
14152            Some(true),
14153        ]));
14154        let rhs_values = vec![
14155            S::List(S::new_list(
14156                &[S::Boolean(Some(true))],
14157                &DataType::Boolean,
14158                true,
14159            )),
14160            S::List(S::new_list(
14161                &[S::Boolean(Some(false))],
14162                &DataType::Boolean,
14163                true,
14164            )),
14165            S::List(S::new_list(&[S::Boolean(None)], &DataType::Boolean, true)),
14166            S::List(S::new_list(&[], &DataType::Boolean, true)),
14167        ];
14168        let depth = rhs_values.iter().filter_map(het_depth).max().unwrap();
14169        let rhs = Arc::new(build_het_struct(&rhs_values, depth).expect("tagged RHS"));
14170        let out = invoke_cypher_in(lhs, rhs);
14171        let bools = out.as_any().downcast_ref::<BooleanArray>().unwrap();
14172
14173        assert!(bools.value(0), "true IN [true]");
14174        assert!(!bools.value(1), "true IN [false]");
14175        assert!(bools.is_null(2), "true IN [null] -> null");
14176        assert!(!bools.value(3), "true IN []");
14177    }
14178
14179    fn invoke_cypher_in(
14180        lhs: datafusion::arrow::array::ArrayRef,
14181        rhs: datafusion::arrow::array::ArrayRef,
14182    ) -> datafusion::arrow::array::ArrayRef {
14183        use std::sync::Arc;
14184
14185        use datafusion::arrow::datatypes::Field;
14186        use datafusion::config::ConfigOptions;
14187
14188        let n = lhs.len();
14189        let lhs_field = Arc::new(Field::new("lhs", lhs.data_type().clone(), true));
14190        let rhs_field = Arc::new(Field::new("rhs", rhs.data_type().clone(), true));
14191        let ret = Arc::new(Field::new("in", DataType::Boolean, true));
14192        let args = ScalarFunctionArgs {
14193            args: vec![ColumnarValue::Array(lhs), ColumnarValue::Array(rhs)],
14194            arg_fields: vec![lhs_field, rhs_field],
14195            number_rows: n,
14196            return_field: ret,
14197            config_options: Arc::new(ConfigOptions::default()),
14198        };
14199        match CypherIn::new().invoke_with_args(args).unwrap() {
14200            ColumnarValue::Array(a) => a,
14201            ColumnarValue::Scalar(s) => s.to_array_of_size(n).unwrap(),
14202        }
14203    }
14204
14205    // -----------------------------------------------------------------------
14206    // cypher_size UDF — runtime type dispatch (#743)
14207    // -----------------------------------------------------------------------
14208
14209    #[test]
14210    fn cypher_size_counts_list_elements() {
14211        use datafusion::arrow::array::{Int64Array, ListArray};
14212        use datafusion::arrow::datatypes::Int32Type;
14213
14214        // Two list rows: [10,20,30] and [].
14215        let arr = ListArray::from_iter_primitive::<Int32Type, _, _>(vec![
14216            Some(vec![Some(10), Some(20), Some(30)]),
14217            Some(vec![]),
14218        ]);
14219        let out = invoke_cypher_size(std::sync::Arc::new(arr));
14220        let counts = out.as_any().downcast_ref::<Int64Array>().unwrap();
14221        assert_eq!(counts.value(0), 3);
14222        assert_eq!(counts.value(1), 0);
14223    }
14224
14225    #[test]
14226    fn cypher_size_counts_string_chars() {
14227        use datafusion::arrow::array::{Array, Int64Array, StringArray};
14228
14229        let arr = StringArray::from(vec![Some("abc"), Some(""), None]);
14230        let out = invoke_cypher_size(std::sync::Arc::new(arr));
14231        let counts = out.as_any().downcast_ref::<Int64Array>().unwrap();
14232        assert_eq!(counts.value(0), 3);
14233        assert_eq!(counts.value(1), 0);
14234        assert!(counts.is_null(2), "null string → null size");
14235    }
14236
14237    #[test]
14238    fn cypher_size_counts_het_tagged_elements() {
14239        use datafusion::arrow::array::{Array, Int64Array};
14240        use datafusion::scalar::ScalarValue as S;
14241
14242        // The four element shapes a quantifier loop variable can take over
14243        // `[[1, 2, 3], 'ab', true, null]`: a list payload counts its elements,
14244        // a string its bytes, and a non-list/string or null element is null.
14245        let scalars = vec![
14246            S::List(S::new_list(
14247                &[S::Int64(Some(1)), S::Int64(Some(2)), S::Int64(Some(3))],
14248                &DataType::Int64,
14249                true,
14250            )),
14251            S::Utf8(Some("ab".to_owned())),
14252            S::Boolean(Some(true)),
14253            S::Null,
14254        ];
14255        let depth = scalars.iter().filter_map(het_depth).max().unwrap();
14256        let elems = build_het_struct(&scalars, depth).expect("build tagged struct");
14257        let out = invoke_cypher_size(std::sync::Arc::new(elems));
14258        let counts = out.as_any().downcast_ref::<Int64Array>().unwrap();
14259        assert_eq!(counts.value(0), 3, "tag-4 list element → element count");
14260        assert_eq!(counts.value(1), 2, "tag-2 string element → char count");
14261        assert!(counts.is_null(2), "non-list/string element → null");
14262        assert!(counts.is_null(3), "null element → null");
14263    }
14264
14265    /// Invoke the `cypher_size` UDF over a single-column array and return the
14266    /// result array.
14267    fn invoke_cypher_size(
14268        array: datafusion::arrow::array::ArrayRef,
14269    ) -> datafusion::arrow::array::ArrayRef {
14270        use std::sync::Arc;
14271
14272        use datafusion::arrow::datatypes::Field;
14273        use datafusion::config::ConfigOptions;
14274
14275        let n = array.len();
14276        let field = Arc::new(Field::new("x", array.data_type().clone(), true));
14277        let ret = Arc::new(Field::new("size", DataType::Int64, true));
14278        let args = ScalarFunctionArgs {
14279            args: vec![ColumnarValue::Array(array)],
14280            arg_fields: vec![field],
14281            number_rows: n,
14282            return_field: ret,
14283            config_options: Arc::new(ConfigOptions::default()),
14284        };
14285        match CypherSize::new().invoke_with_args(args).unwrap() {
14286            ColumnarValue::Array(a) => a,
14287            ColumnarValue::Scalar(s) => s.to_array_of_size(n).unwrap(),
14288        }
14289    }
14290
14291    // -----------------------------------------------------------------------
14292    // cypher_path_nodes UDF — traversal node sequence (#754)
14293    // -----------------------------------------------------------------------
14294
14295    #[test]
14296    fn path_nodes_lowers_to_udf_over_node_uuid() {
14297        let mut arena = ExprArena::new();
14298        let mut vm = VarMap::new();
14299        vm.insert(VarId(0), "var_0"); // start node — bare scan qualifier
14300        vm.insert(VarId(1), "var_1.rels"); // var-length edge list
14301        let start = arena.push(IrExpr::VarRef(VarId(0)));
14302        let rels = arena.push(IrExpr::VarRef(VarId(1)));
14303        let id = arena.push(IrExpr::FunctionCall {
14304            name: "_path_nodes".into(),
14305            args: vec![start, rels],
14306        });
14307        let expr = make_lowerer(&arena, &vm).lower(id).expect("lower");
14308        let s = format!("{expr}");
14309        assert!(s.contains("cypher_path_nodes"), "got {s}");
14310        assert!(
14311            s.contains("var_0.node_uuid"),
14312            "seed is the uuid column: {s}"
14313        );
14314    }
14315
14316    /// A 16-byte uuid stand-in: byte `b` repeated.
14317    fn uuid16(b: u8) -> Vec<u8> {
14318        vec![b; 16]
14319    }
14320
14321    /// Build a `List<Struct{src_uuid, dst_uuid}>` edge-list column. Each edge
14322    /// is `(src_byte, dst_byte)` in **storage** orientation; `None` rows are
14323    /// null lists.
14324    fn edge_list(rows: &[Option<&[(u8, u8)]>]) -> datafusion::arrow::array::ArrayRef {
14325        use datafusion::arrow::array::{FixedSizeBinaryBuilder, ListBuilder, StructBuilder};
14326        use datafusion::arrow::datatypes::Field;
14327
14328        let fields: datafusion::arrow::datatypes::Fields = vec![
14329            Field::new("src_uuid", DataType::FixedSizeBinary(16), false),
14330            Field::new("dst_uuid", DataType::FixedSizeBinary(16), false),
14331        ]
14332        .into();
14333        let mut b = ListBuilder::new(StructBuilder::new(
14334            fields,
14335            vec![
14336                Box::new(FixedSizeBinaryBuilder::new(16)),
14337                Box::new(FixedSizeBinaryBuilder::new(16)),
14338            ],
14339        ));
14340        for row in rows {
14341            let Some(edges) = row else {
14342                b.append_null();
14343                continue;
14344            };
14345            for (src, dst) in *edges {
14346                b.values()
14347                    .field_builder::<FixedSizeBinaryBuilder>(0)
14348                    .unwrap()
14349                    .append_value(uuid16(*src))
14350                    .unwrap();
14351                b.values()
14352                    .field_builder::<FixedSizeBinaryBuilder>(1)
14353                    .unwrap()
14354                    .append_value(uuid16(*dst))
14355                    .unwrap();
14356                b.values().append(true);
14357            }
14358            b.append(true);
14359        }
14360        std::sync::Arc::new(b.finish())
14361    }
14362
14363    /// Build the seed-uuid column; `None` entries are null seeds.
14364    fn seed_uuids(vals: &[Option<u8>]) -> datafusion::arrow::array::ArrayRef {
14365        use datafusion::arrow::array::FixedSizeBinaryBuilder;
14366        let mut b = FixedSizeBinaryBuilder::new(16);
14367        for v in vals {
14368            match v {
14369                Some(x) => b.append_value(uuid16(*x)).unwrap(),
14370                None => b.append_null(),
14371            }
14372        }
14373        std::sync::Arc::new(b.finish())
14374    }
14375
14376    fn invoke_path_nodes(
14377        seed: datafusion::arrow::array::ArrayRef,
14378        rels: datafusion::arrow::array::ArrayRef,
14379    ) -> datafusion::error::Result<datafusion::arrow::array::ArrayRef> {
14380        use std::sync::Arc;
14381
14382        use datafusion::arrow::datatypes::Field;
14383        use datafusion::config::ConfigOptions;
14384
14385        let udf = CypherPathNodes::new();
14386        let n = seed.len();
14387        let args = ScalarFunctionArgs {
14388            args: vec![
14389                ColumnarValue::Array(Arc::clone(&seed)),
14390                ColumnarValue::Array(Arc::clone(&rels)),
14391            ],
14392            arg_fields: vec![
14393                Arc::new(Field::new("seed", seed.data_type().clone(), true)),
14394                Arc::new(Field::new("rels", rels.data_type().clone(), true)),
14395            ],
14396            number_rows: n,
14397            return_field: Arc::new(Field::new("nodes", udf.return_type(&[])?, true)),
14398            config_options: Arc::new(ConfigOptions::default()),
14399        };
14400        udf.invoke_with_args(args).map(|v| match v {
14401            ColumnarValue::Array(a) => a,
14402            ColumnarValue::Scalar(s) => s.to_array_of_size(n).unwrap(),
14403        })
14404    }
14405
14406    /// Row `i` of the result as the node uuids' first bytes, or `None` for a
14407    /// null path.
14408    fn path_node_bytes(out: &datafusion::arrow::array::ArrayRef, i: usize) -> Option<Vec<u8>> {
14409        use datafusion::arrow::array::{Array, FixedSizeBinaryArray, ListArray, StructArray};
14410        let list = out.as_any().downcast_ref::<ListArray>().unwrap();
14411        if list.is_null(i) {
14412            return None;
14413        }
14414        let items = list.value(i);
14415        let items = items.as_any().downcast_ref::<StructArray>().unwrap();
14416        let uuids = items.column_by_name("node_uuid").unwrap();
14417        let uuids = uuids
14418            .as_any()
14419            .downcast_ref::<FixedSizeBinaryArray>()
14420            .unwrap();
14421        Some((0..uuids.len()).map(|j| uuids.value(j)[0]).collect())
14422    }
14423
14424    #[test]
14425    fn path_nodes_walks_forward_chain() {
14426        let out = invoke_path_nodes(
14427            seed_uuids(&[Some(1)]),
14428            edge_list(&[Some(&[(1, 2), (2, 3)])]),
14429        )
14430        .unwrap();
14431        assert_eq!(path_node_bytes(&out, 0), Some(vec![1, 2, 3]));
14432    }
14433
14434    #[test]
14435    fn path_nodes_flips_reversed_storage_orientation() {
14436        // Edge stored 2→1 but traversed from 1 (an `In`/`Undirected` hop):
14437        // the next node is the *other* endpoint, not blindly dst_uuid.
14438        let out = invoke_path_nodes(seed_uuids(&[Some(1)]), edge_list(&[Some(&[(2, 1)])])).unwrap();
14439        assert_eq!(path_node_bytes(&out, 0), Some(vec![1, 2]));
14440    }
14441
14442    #[test]
14443    fn path_nodes_mixed_orientation_walk() {
14444        // 1 →(stored 1→2)→ 2 →(stored 3→2, traversed against storage)→ 3.
14445        let out = invoke_path_nodes(
14446            seed_uuids(&[Some(1)]),
14447            edge_list(&[Some(&[(1, 2), (3, 2)])]),
14448        )
14449        .unwrap();
14450        assert_eq!(path_node_bytes(&out, 0), Some(vec![1, 2, 3]));
14451    }
14452
14453    #[test]
14454    fn path_nodes_self_loop_stays_put() {
14455        let out = invoke_path_nodes(seed_uuids(&[Some(1)]), edge_list(&[Some(&[(1, 1)])])).unwrap();
14456        assert_eq!(path_node_bytes(&out, 0), Some(vec![1, 1]));
14457    }
14458
14459    #[test]
14460    fn path_nodes_zero_hop_is_seed_only() {
14461        let out = invoke_path_nodes(seed_uuids(&[Some(7)]), edge_list(&[Some(&[])])).unwrap();
14462        assert_eq!(path_node_bytes(&out, 0), Some(vec![7]));
14463    }
14464
14465    #[test]
14466    fn path_nodes_null_seed_or_list_is_null() {
14467        // Unmatched OPTIONAL MATCH rows: null seed (row 0) or null list (row 1).
14468        let out = invoke_path_nodes(
14469            seed_uuids(&[None, Some(1)]),
14470            edge_list(&[Some(&[(1, 2)]), None]),
14471        )
14472        .unwrap();
14473        assert_eq!(path_node_bytes(&out, 0), None);
14474        assert_eq!(path_node_bytes(&out, 1), None);
14475    }
14476
14477    #[test]
14478    fn path_nodes_disconnected_edge_errors() {
14479        let err = invoke_path_nodes(seed_uuids(&[Some(1)]), edge_list(&[Some(&[(5, 6)])]))
14480            .expect_err("an edge touching neither endpoint is a corrupt emission");
14481        assert!(err.to_string().contains("disconnected"), "got {err}");
14482    }
14483
14484    #[test]
14485    fn path_nodes_output_matches_declared_return_type() {
14486        // DataFusion verifies the produced array against `return_type` at
14487        // execution; catch any list-field/nullability drift here first.
14488        let out = invoke_path_nodes(seed_uuids(&[Some(1)]), edge_list(&[Some(&[(1, 2)])])).unwrap();
14489        assert_eq!(
14490            out.data_type(),
14491            &CypherPathNodes::new().return_type(&[]).unwrap()
14492        );
14493    }
14494
14495    // -----------------------------------------------------------------------
14496    // Parameter test
14497    // -----------------------------------------------------------------------
14498
14499    #[test]
14500    fn parameter_produces_placeholder() {
14501        let mut arena = ExprArena::new();
14502        let id = arena.push(IrExpr::Parameter("eid".into()));
14503        let vm = VarMap::new();
14504        let result = make_lowerer(&arena, &vm).lower(id).unwrap();
14505        if let DfExpr::Placeholder(p) = result {
14506            // The `$` is reattached so DataFusion's named-param binding resolves.
14507            assert_eq!(p.id, "$eid");
14508        } else {
14509            panic!("expected Placeholder, got {result:?}");
14510        }
14511    }
14512
14513    // -----------------------------------------------------------------------
14514    // List literal tests (#714)
14515    // -----------------------------------------------------------------------
14516
14517    #[test]
14518    fn list_literal_of_ints_folds_to_scalar_list() {
14519        use datafusion::arrow::array::Array;
14520        // [1, 2, 3] → a single ScalarValue::List literal with 3 Int64 elements.
14521        let mut arena = ExprArena::new();
14522        let e1 = arena.push(IrExpr::Literal(IrLiteral::Int(1)));
14523        let e2 = arena.push(IrExpr::Literal(IrLiteral::Int(2)));
14524        let e3 = arena.push(IrExpr::Literal(IrLiteral::Int(3)));
14525        let id = arena.push(IrExpr::ListLiteral(vec![e1, e2, e3]));
14526        let vm = VarMap::new();
14527        let result = make_lowerer(&arena, &vm).lower(id).unwrap();
14528        let DfExpr::Literal(ScalarValue::List(arr), _) = result else {
14529            panic!("expected a ScalarValue::List literal, got {result:?}");
14530        };
14531        // One list row holding 3 elements.
14532        assert_eq!(arr.len(), 1);
14533        assert_eq!(arr.value(0).len(), 3);
14534        assert_eq!(arr.value(0).data_type(), &DataType::Int64);
14535    }
14536
14537    #[test]
14538    fn empty_list_literal_folds_to_empty_int64_list() {
14539        use datafusion::arrow::array::Array;
14540        let mut arena = ExprArena::new();
14541        let id = arena.push(IrExpr::ListLiteral(vec![]));
14542        let vm = VarMap::new();
14543        let result = make_lowerer(&arena, &vm).lower(id).unwrap();
14544        let DfExpr::Literal(ScalarValue::List(arr), _) = result else {
14545            panic!("expected an empty ScalarValue::List literal, got {result:?}");
14546        };
14547        assert_eq!(arr.len(), 1);
14548        assert_eq!(arr.value(0).len(), 0, "no elements");
14549    }
14550
14551    #[test]
14552    fn list_literal_with_expression_element_uses_make_array() {
14553        // [n.age, 1] has a non-constant element, so it lowers to make_array(...).
14554        let mut arena = ExprArena::new();
14555        let var = arena.push(IrExpr::VarRef(VarId(0)));
14556        let age = arena.push(IrExpr::PropertyAccess {
14557            base: var,
14558            prop: PropId(0),
14559        });
14560        let one = arena.push(IrExpr::Literal(IrLiteral::Int(1)));
14561        let id = arena.push(IrExpr::ListLiteral(vec![age, one]));
14562        let mut vm = VarMap::new();
14563        vm.insert(VarId(0), "var_0");
14564        let result = make_lowerer(&arena, &vm).lower(id).unwrap();
14565        let DfExpr::ScalarFunction(f) = result else {
14566            panic!("expected a make_array ScalarFunction, got {result:?}");
14567        };
14568        assert_eq!(f.name(), "make_array");
14569        assert_eq!(f.args.len(), 2);
14570    }
14571
14572    // -----------------------------------------------------------------------
14573    // scalar_to_ir_literal (#791)
14574    // -----------------------------------------------------------------------
14575
14576    #[test]
14577    fn scalar_to_ir_literal_round_trips_each_kind() {
14578        // Each IrLiteral → ScalarValue → IrLiteral is identity for the canonical
14579        // widths `ir_literal_to_scalar` emits.
14580        for lit in [
14581            IrLiteral::Bool(true),
14582            IrLiteral::Int(42),
14583            IrLiteral::Float(1.5),
14584            IrLiteral::Str("hi".into()),
14585            IrLiteral::Duration {
14586                months: 14,
14587                days: 3,
14588                seconds: 5,
14589                nanos: 1000,
14590            },
14591            IrLiteral::DateTime(1_700_000_000_000_000),
14592        ] {
14593            let scalar = ir_literal_to_scalar(&lit);
14594            assert_eq!(scalar_to_ir_literal(&scalar).unwrap(), lit);
14595        }
14596    }
14597
14598    #[test]
14599    fn scalar_to_ir_literal_rejects_graph_identity_as_a_property_value() {
14600        let scalar = ir_literal_to_scalar(&IrLiteral::Uuid([0x42; 16]));
14601        assert!(matches!(
14602            scalar_to_ir_literal(&scalar),
14603            Err(LoweringError::InvalidType(message))
14604                if message == "UUID values cannot be stored as graph properties"
14605        ));
14606    }
14607
14608    #[test]
14609    fn scalar_to_ir_literal_null_variants_map_to_null() {
14610        assert_eq!(
14611            scalar_to_ir_literal(&ScalarValue::Null).unwrap(),
14612            IrLiteral::Null
14613        );
14614        assert_eq!(
14615            scalar_to_ir_literal(&ScalarValue::Int64(None)).unwrap(),
14616            IrLiteral::Null
14617        );
14618    }
14619
14620    #[test]
14621    fn scalar_to_ir_literal_widens_smaller_ints() {
14622        assert_eq!(
14623            scalar_to_ir_literal(&ScalarValue::Int32(Some(7))).unwrap(),
14624            IrLiteral::Int(7)
14625        );
14626        assert_eq!(
14627            scalar_to_ir_literal(&ScalarValue::UInt8(Some(255))).unwrap(),
14628            IrLiteral::Int(255)
14629        );
14630    }
14631
14632    #[test]
14633    fn scalar_to_ir_literal_round_trips_a_list() {
14634        // A homogeneous list now stores (#1006): scalar List → IrLiteral::List
14635        // and back, element-wise — including a list of typed temporals.
14636        for lit in [
14637            IrLiteral::List(vec![IrLiteral::Int(1), IrLiteral::Int(2)]),
14638            IrLiteral::List(vec![IrLiteral::Date(5428), IrLiteral::Date(5429)]),
14639        ] {
14640            let scalar = ir_literal_to_scalar(&lit);
14641            assert_eq!(scalar_to_ir_literal(&scalar).unwrap(), lit);
14642        }
14643    }
14644
14645    #[test]
14646    fn zoned_temporal_order_keys_compare_absolute_instants() {
14647        let hour = 3_600_000_000_000_i64;
14648        let early_time = time_scalar(Some((12 * hour + 35 * 60_000_000_000, 5 * 3_600)));
14649        let late_time = time_scalar(Some((10 * hour + 35 * 60_000_000_000, -8 * 3_600)));
14650        assert!(cypher_order_key(&early_time) < cypher_order_key(&late_time));
14651
14652        let earlier_datetime = datetime_scalar(Some((5_000, 12 * hour, 3_600, None)));
14653        let later_datetime = datetime_scalar(Some((5_000, 12 * hour, 0, None)));
14654        assert!(cypher_order_key(&earlier_datetime) < cypher_order_key(&later_datetime));
14655    }
14656
14657    #[test]
14658    fn zoned_temporal_structs_require_cypher_order_keys() {
14659        assert!(needs_cypher_order_key_type(&time_scalar(None).data_type()));
14660        assert!(needs_cypher_order_key_type(
14661            &datetime_scalar(None).data_type()
14662        ));
14663    }
14664
14665    #[test]
14666    fn dynamic_heterogeneous_list_preserves_graph_value_payloads() {
14667        use datafusion::arrow::array::{Int64Array, StructArray};
14668        use datafusion::arrow::datatypes::{Field, Fields};
14669        use datafusion::config::ConfigOptions;
14670
14671        let node_array = StructArray::new(
14672            Fields::from(vec![Field::new("node_uuid", DataType::Int64, false)]),
14673            vec![Arc::new(Int64Array::from(vec![7]))],
14674            None,
14675        );
14676        let node = ScalarValue::Struct(Arc::new(node_array));
14677        let number = ScalarValue::Int64(Some(42));
14678        let arg_types = vec![node.data_type(), number.data_type()];
14679        let args = ScalarFunctionArgs {
14680            args: vec![
14681                ColumnarValue::Scalar(node.clone()),
14682                ColumnarValue::Scalar(number.clone()),
14683            ],
14684            arg_fields: arg_types
14685                .iter()
14686                .enumerate()
14687                .map(|(i, ty)| Arc::new(Field::new(format!("arg_{i}"), ty.clone(), true)))
14688                .collect(),
14689            number_rows: 1,
14690            return_field: Arc::new(Field::new("out", dynamic_het_type(&arg_types), false)),
14691            config_options: Arc::new(ConfigOptions::default()),
14692        };
14693        let out = match CypherDynamicHetList::new()
14694            .invoke_with_args(args)
14695            .expect("dynamic heterogeneous list")
14696        {
14697            ColumnarValue::Array(array) => array,
14698            ColumnarValue::Scalar(value) => value.to_array().expect("scalar list"),
14699        };
14700        let list = out.as_any().downcast_ref::<ListArray>().expect("List");
14701        let values = list.value(0);
14702        let first = ScalarValue::try_from_array(&values, 0).expect("node element");
14703        let second = ScalarValue::try_from_array(&values, 1).expect("number element");
14704        assert_eq!(decode_het(&first), Some(node));
14705        assert_eq!(decode_het(&second), Some(number));
14706        assert!(cypher_order_key(&first).starts_with("20:node"));
14707        assert!(cypher_order_key(&second).starts_with("80:num"));
14708    }
14709}