Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/knut

crates/graphar-falkordb/src/cypher.rs

//! Arrow → Cypher literal conversion utilities.

use std::fmt::Write as _;

use arrow_array::{
    Array, BooleanArray, Date32Array, Date64Array, Float32Array, Float64Array, Int32Array,
    Int64Array, LargeListArray, ListArray, StringArray, TimestampMicrosecondArray,
    TimestampMillisecondArray, TimestampNanosecondArray, TimestampSecondArray,
};
use arrow_schema::{DataType, TimeUnit};

use crate::error::{FalkorError, Result};

/// A column whose Arrow array has been downcast **once**, so each row renders
/// as a direct typed write into a buffer — no per-cell `downcast_ref`, no
/// intermediate `String`. Resolve per column with [`ColRenderer::resolve`],
/// then call [`ColRenderer::write`] per row. Uncommon types fall back to the
/// general [`arrow_value_to_cypher`] path via `Generic`.
pub enum ColRenderer<'a> {
    Bool(&'a BooleanArray),
    I32(&'a Int32Array),
    I64(&'a Int64Array),
    F32(&'a Float32Array),
    F64(&'a Float64Array),
    Str(&'a StringArray),
    Generic(&'a dyn Array),
}

impl<'a> ColRenderer<'a> {
    /// Downcast the array once for the whole column.
    pub fn resolve(array: &'a dyn Array) -> Self {
        match array.data_type() {
            DataType::Boolean => ColRenderer::Bool(array.as_any().downcast_ref().unwrap()),
            DataType::Int32 => ColRenderer::I32(array.as_any().downcast_ref().unwrap()),
            DataType::Int64 => ColRenderer::I64(array.as_any().downcast_ref().unwrap()),
            DataType::Float32 => ColRenderer::F32(array.as_any().downcast_ref().unwrap()),
            DataType::Float64 => ColRenderer::F64(array.as_any().downcast_ref().unwrap()),
            DataType::Utf8 => ColRenderer::Str(array.as_any().downcast_ref().unwrap()),
            _ => ColRenderer::Generic(array),
        }
    }

    fn is_null(&self, row: usize) -> bool {
        match self {
            ColRenderer::Bool(a) => a.is_null(row),
            ColRenderer::I32(a) => a.is_null(row),
            ColRenderer::I64(a) => a.is_null(row),
            ColRenderer::F32(a) => a.is_null(row),
            ColRenderer::F64(a) => a.is_null(row),
            ColRenderer::Str(a) => a.is_null(row),
            ColRenderer::Generic(a) => a.is_null(row),
        }
    }

    /// Write the cell at `row` directly into `buf`.
    pub fn write(&self, buf: &mut String, row: usize) -> Result<()> {
        if self.is_null(row) {
            buf.push_str("null");
            return Ok(());
        }
        match self {
            ColRenderer::Bool(a) => buf.push_str(if a.value(row) { "true" } else { "false" }),
            ColRenderer::I32(a) => {
                let _ = write!(buf, "{}", a.value(row));
            }
            ColRenderer::I64(a) => {
                let _ = write!(buf, "{}", a.value(row));
            }
            // NaN / ±Infinity are not valid Cypher numeric literals; emit
            // `null` so a batch with a non-finite float stays parsable.
            // `{v:?}` (never `{v}`): Debug keeps the decimal point on integral
            // floats ("2.0", not "2"), so the server stores a DOUBLE property —
            // same shortest-roundtrip digits otherwise. Display rendered 2.0 as
            // the Cypher INT literal `2`, which made one column come back over
            // RESP as mixed Int/BulkString (decode refused it) and broke the
            // UNWIND↔GRAPH.BULK parity (bulk always stores DOUBLE).
            ColRenderer::F32(a) => {
                let v = a.value(row);
                if v.is_finite() {
                    let _ = write!(buf, "{v:?}");
                } else {
                    buf.push_str("null");
                }
            }
            ColRenderer::F64(a) => {
                let v = a.value(row);
                if v.is_finite() {
                    let _ = write!(buf, "{v:?}");
                } else {
                    buf.push_str("null");
                }
            }
            ColRenderer::Str(a) => write_cypher_string(buf, a.value(row)),
            // Dates/timestamps/lists and anything else: reuse the general path.
            ColRenderer::Generic(a) => buf.push_str(&arrow_value_to_cypher(*a, row)?),
        }
        Ok(())
    }
}

/// Write a row's Cypher map literal `{ \`k\`: v, … }` straight into `buf` using
/// pre-resolved column renderers. `leading` are already-formatted `k: v`
/// fragments prepended verbatim (e.g. the `_gar_id` of a vertex).
pub fn write_row_map(
    buf: &mut String,
    columns: &[(&str, ColRenderer)],
    row: usize,
    leading: &[(&str, &str)],
) -> Result<()> {
    buf.push('{');
    let mut first = true;
    for (k, v) in leading {
        if !first {
            buf.push_str(", ");
        }
        first = false;
        let _ = write!(buf, "`{k}`: {v}");
    }
    for (name, col) in columns {
        if !first {
            buf.push_str(", ");
        }
        first = false;
        let _ = write!(buf, "`{name}`: ");
        col.write(buf, row)?;
    }
    buf.push('}');
    Ok(())
}

/// Render one row of an Arrow RecordBatch as a Cypher map literal: `{k: v, ...}`.
/// `columns` is `&[(column_name, Arc<dyn Array>)]`.
pub fn row_to_cypher_map(columns: &[(&str, &dyn Array)], row: usize) -> Result<String> {
    let mut parts = Vec::with_capacity(columns.len());
    for (name, array) in columns {
        let val = arrow_value_to_cypher(*array, row)?;
        parts.push(format!("`{name}`: {val}"));
    }
    Ok(format!("{{{}}}", parts.join(", ")))
}

/// Convert a single Arrow array cell to a Cypher literal string.
pub fn arrow_value_to_cypher(array: &dyn Array, row: usize) -> Result<String> {
    if array.is_null(row) {
        return Ok("null".to_string());
    }
    match array.data_type() {
        DataType::Boolean => {
            let v = array
                .as_any()
                .downcast_ref::<BooleanArray>()
                .unwrap()
                .value(row);
            Ok(if v { "true" } else { "false" }.to_string())
        }
        DataType::Int32 => {
            let v = array
                .as_any()
                .downcast_ref::<Int32Array>()
                .unwrap()
                .value(row);
            Ok(v.to_string())
        }
        DataType::Int64 => {
            let v = array
                .as_any()
                .downcast_ref::<Int64Array>()
                .unwrap()
                .value(row);
            Ok(v.to_string())
        }
        DataType::Float32 => {
            let v = array
                .as_any()
                .downcast_ref::<Float32Array>()
                .unwrap()
                .value(row);
            // NaN / ±Infinity have no valid Cypher numeric literal → null.
            // `{v:?}` keeps the decimal point on integral floats (see
            // `ColRenderer::write`), so the stored property is always a DOUBLE.
            Ok(if v.is_finite() {
                format!("{v:?}")
            } else {
                "null".to_string()
            })
        }
        DataType::Float64 => {
            let v = array
                .as_any()
                .downcast_ref::<Float64Array>()
                .unwrap()
                .value(row);
            Ok(if v.is_finite() {
                format!("{v:?}")
            } else {
                "null".to_string()
            })
        }
        DataType::Utf8 => {
            let v = array
                .as_any()
                .downcast_ref::<StringArray>()
                .unwrap()
                .value(row);
            Ok(cypher_string(v))
        }
        DataType::LargeUtf8 => {
            let v = array
                .as_any()
                .downcast_ref::<arrow_array::LargeStringArray>()
                .unwrap()
                .value(row);
            Ok(cypher_string(v))
        }
        // Temporal types render as ISO-8601 string literals (FalkorDB/Cypher
        // has no portable native date literal; a string round-trips cleanly).
        // The raw ISO formatting is shared with the GRAPH.BULK encoder via
        // [`temporal_iso`] so both paths store byte-identical date strings.
        DataType::Date32 | DataType::Date64 | DataType::Timestamp(_, _) => {
            Ok(cypher_string(&temporal_iso(array, row).unwrap_or_default()))
        }
        // Lists render as Cypher list literals `[v1, v2, …]`, recursing on the
        // element type — so `List<Int64>`, `List<Utf8>`, etc. all work.
        DataType::List(_) => {
            let list = array.as_any().downcast_ref::<ListArray>().unwrap();
            cypher_list(list.value(row).as_ref())
        }
        DataType::LargeList(_) => {
            let list = array.as_any().downcast_ref::<LargeListArray>().unwrap();
            cypher_list(list.value(row).as_ref())
        }
        other => Err(FalkorError::UnsupportedType(format!("{other:?}"))),
    }
}

/// Raw (unquoted) ISO-8601 string for a temporal Arrow cell — the **single
/// source of truth** for date/timestamp formatting, shared by BOTH the Cypher
/// renderer (which quotes it into a string literal) and the GRAPH.BULK encoder
/// (which stores it as a `BI_STRING`). Returns `None` for a null cell (the
/// Cypher path maps that to an empty string; the bulk path to `BI_NULL`).
///
/// `Date32`/`Date64` render as `YYYY-MM-DD`; `Timestamp` as `YYYY-MM-DDTHH:MM:SS.f`.
/// Any non-temporal array yields `None`.
pub(crate) fn temporal_iso(array: &dyn Array, row: usize) -> Option<String> {
    if array.is_null(row) {
        return None;
    }
    match array.data_type() {
        DataType::Date32 => array
            .as_any()
            .downcast_ref::<Date32Array>()
            .unwrap()
            .value_as_date(row)
            .map(|d| d.to_string()),
        DataType::Date64 => array
            .as_any()
            .downcast_ref::<Date64Array>()
            .unwrap()
            .value_as_date(row)
            .map(|d| d.to_string()),
        DataType::Timestamp(unit, _tz) => {
            let dt = match unit {
                TimeUnit::Second => array
                    .as_any()
                    .downcast_ref::<TimestampSecondArray>()
                    .unwrap()
                    .value_as_datetime(row),
                TimeUnit::Millisecond => array
                    .as_any()
                    .downcast_ref::<TimestampMillisecondArray>()
                    .unwrap()
                    .value_as_datetime(row),
                TimeUnit::Microsecond => array
                    .as_any()
                    .downcast_ref::<TimestampMicrosecondArray>()
                    .unwrap()
                    .value_as_datetime(row),
                TimeUnit::Nanosecond => array
                    .as_any()
                    .downcast_ref::<TimestampNanosecondArray>()
                    .unwrap()
                    .value_as_datetime(row),
            };
            // ISO-8601 with a 'T' separator.
            dt.map(|d| d.format("%Y-%m-%dT%H:%M:%S%.f").to_string())
        }
        _ => None,
    }
}

/// Render an array's every element as a Cypher list literal.
fn cypher_list(child: &dyn Array) -> Result<String> {
    let mut elems = Vec::with_capacity(child.len());
    for i in 0..child.len() {
        elems.push(arrow_value_to_cypher(child, i)?);
    }
    Ok(format!("[{}]", elems.join(", ")))
}

fn cypher_string(s: &str) -> String {
    let mut buf = String::with_capacity(s.len() + 2);
    write_cypher_string(&mut buf, s);
    buf
}

/// Write a quoted, escaped Cypher string literal into `buf`. Single pass: only
/// allocates an escape when `\` or `'` is actually present (the common case is
/// neither, so it's just a quote + copy).
fn write_cypher_string(buf: &mut String, s: &str) {
    buf.push('\'');
    if s.bytes().any(|b| b == b'\\' || b == b'\'') {
        for c in s.chars() {
            match c {
                '\\' => buf.push_str("\\\\"),
                '\'' => buf.push_str("\\'"),
                _ => buf.push(c),
            }
        }
    } else {
        buf.push_str(s);
    }
    buf.push('\'');
}

/// Build a Cypher `UNWIND` + `CREATE` query for a slice of row maps.
pub fn unwind_create_query(label: &str, maps: &[String]) -> String {
    let list = maps.join(", ");
    format!("UNWIND [{list}] AS row CREATE (n:`{label}`) SET n = row")
}

/// Render a (possibly multi-label) node label suffix, e.g. `` `A`:`B`:`C` ``.
///
/// A single-element slice produces exactly `` `Label` ``, so the single-label
/// path stays byte-identical to the old `format!("`{label}`")`.
pub fn render_labels(labels: &[&str]) -> String {
    let mut buf = String::with_capacity(labels.iter().map(|l| l.len() + 3).sum());
    for (i, l) in labels.iter().enumerate() {
        if i > 0 {
            buf.push(':');
        }
        let _ = write!(buf, "`{l}`");
    }
    buf
}

/// Build a Cypher `UNWIND` + `CREATE` query for a slice of row maps where the
/// node may carry **multiple labels** (`CREATE (n:`A`:`B`:`C`)`).
///
/// A single-element `labels` slice yields exactly the query
/// [`unwind_create_query`] produces.
pub fn unwind_create_query_multi(labels: &[&str], maps: &[String]) -> String {
    let list = maps.join(", ");
    let label = render_labels(labels);
    format!("UNWIND [{list}] AS row CREATE (n:{label}) SET n = row")
}

/// Build a Cypher `UNWIND` + `MATCH` + `CREATE` edge query.
/// `src_id_prop` / `dst_id_prop` are the property names used to locate the endpoints.
pub fn unwind_edge_query(
    src_label: &str,
    edge_label: &str,
    dst_label: &str,
    src_id_prop: &str,
    dst_id_prop: &str,
    rows: &[EdgeRow],
) -> String {
    unwind_edge_query_keyed(
        src_label,
        edge_label,
        dst_label,
        &[src_id_prop],
        &[dst_id_prop],
        rows,
    )
}

/// Name of the per-key carrier column staged on each row map. To keep the
/// single-key path byte-identical to the historical query, a lone key uses the
/// legacy `_src` / `_dst` names; composite keys disambiguate with an index
/// (`_s0`, `_s1`, … / `_d0`, `_d1`, …). `side` is `'s'` (source) or `'d'`
/// (destination); `full` is the corresponding legacy word `"src"` / `"dst"`.
fn carrier(side: char, full: &str, i: usize, n: usize) -> String {
    if n == 1 {
        format!("_{full}")
    } else {
        format!("_{side}{i}")
    }
}

/// Render the comma-joined `` `prop`: row.<carrier> `` fragments for a
/// (possibly composite) key, matching the carrier columns staged on each row.
fn render_key_match(props: &[&str], side: char, full: &str) -> String {
    let n = props.len();
    let mut buf = String::new();
    for (i, p) in props.iter().enumerate() {
        if i > 0 {
            buf.push_str(", ");
        }
        let c = carrier(side, full, i, n);
        let _ = write!(buf, "`{p}`: row.{c}");
    }
    buf
}

/// Build a Cypher `UNWIND` + `MATCH` + `CREATE` edge query keyed on a
/// **composite** endpoint key — MATCH on more than one property per endpoint,
/// e.g. `MATCH (a:`L` {`k1`: row._s0, `k2`: row._s1})`.
///
/// `src_key_props` / `dst_key_props` are the property names that locate the
/// source / destination node; each [`EdgeRow`]'s `src_id` / `dst_id` is a slice
/// of already-rendered Cypher values, positionally matching those props. A
/// single key per endpoint stages the legacy `_src`/`_dst` carriers, so the
/// emitted query is **byte-identical** to the historical single-`_gar_id` path.
pub fn unwind_edge_query_keyed(
    src_label: &str,
    edge_label: &str,
    dst_label: &str,
    src_key_props: &[&str],
    dst_key_props: &[&str],
    rows: &[EdgeRow],
) -> String {
    let list_str = render_edge_row_maps(src_key_props.len(), dst_key_props.len(), rows);
    let body = edge_query_body(src_label, edge_label, dst_label, src_key_props, dst_key_props);
    format!("UNWIND [{list_str}] AS row {body}")
}

/// The **parameterized** sibling of [`unwind_edge_query_keyed`]: the same row
/// maps (rendered by the SAME writer, so the staged carriers and property
/// literals are byte-identical) travel as a `CYPHER rows=[…]` query parameter
/// and the query body reads `UNWIND $rows AS row …`.
///
/// Why it exists — the keyed-edge lane measured ~2.7k edges/s (benching doc,
/// 2026-10-19) even *with* an endpoint index in place: every literal window is
/// a different query string, so FalkorDB runs the full window through the
/// Cypher parser AND re-plans it every time (`Cached execution: 0` on each).
/// With the rows as a parameter the body after the `CYPHER` prefix is one
/// constant string per (labels × keys) shape — parsed and planned ONCE, every
/// later window hits the plan cache, and the rows go through the lighter
/// parameter-value parser. Probed live 2026-10-20 (FalkorDB/valkey-9): the
/// param form plans the same `Node By Index Scan`, and the parameter parser
/// accepts backticked keys, escaped strings, lists, nulls and doubles (`2.0`
/// stays a DOUBLE).
pub fn unwind_edge_query_keyed_param(
    src_label: &str,
    edge_label: &str,
    dst_label: &str,
    src_key_props: &[&str],
    dst_key_props: &[&str],
    rows: &[EdgeRow],
) -> String {
    let list_str = render_edge_row_maps(src_key_props.len(), dst_key_props.len(), rows);
    let body = edge_query_body(src_label, edge_label, dst_label, src_key_props, dst_key_props);
    format!("CYPHER rows=[{list_str}] UNWIND $rows AS row {body}")
}

/// Render the comma-joined row maps for one edge window — the ONE writer of the
/// staged carrier + property map shape, shared by the literal and parameterized
/// query builders so the two lanes stage byte-identical rows.
fn render_edge_row_maps(ns: usize, nd: usize, rows: &[EdgeRow]) -> String {
    let list: Vec<String> = rows
        .iter()
        .map(|r| {
            let mut parts = Vec::with_capacity(r.src_id.len() + r.dst_id.len() + r.props.len());
            for (i, v) in r.src_id.iter().enumerate() {
                parts.push(format!("`{}`: {v}", carrier('s', "src", i, ns)));
            }
            for (i, v) in r.dst_id.iter().enumerate() {
                parts.push(format!("`{}`: {v}", carrier('d', "dst", i, nd)));
            }
            for (k, v) in &r.props {
                parts.push(format!("`{k}`: {v}"));
            }
            format!("{{{}}}", parts.join(", "))
        })
        .collect();
    list.join(", ")
}

/// The `MATCH … CREATE … REMOVE …` tail shared verbatim by the literal and
/// parameterized edge queries (everything after `… AS row `).
fn edge_query_body(
    src_label: &str,
    edge_label: &str,
    dst_label: &str,
    src_key_props: &[&str],
    dst_key_props: &[&str],
) -> String {
    let ns = src_key_props.len();
    let nd = dst_key_props.len();
    let src_match = render_key_match(src_key_props, 's', "src");
    let dst_match = render_key_match(dst_key_props, 'd', "dst");
    // Carriers to strip off the relationship after `SET r = row`.
    let mut removes: Vec<String> = Vec::with_capacity(ns + nd);
    for i in 0..ns {
        removes.push(format!("r.{}", carrier('s', "src", i, ns)));
    }
    for i in 0..nd {
        removes.push(format!("r.{}", carrier('d', "dst", i, nd)));
    }
    let remove_str = removes.join(", ");
    format!(
        "MATCH (a:`{src_label}` {{{src_match}}}), \
               (b:`{dst_label}` {{{dst_match}}}) \
         CREATE (a)-[r:`{edge_label}`]->(b) SET r = row \
         REMOVE {remove_str}"
    )
}

/// One edge to load. `src_id` / `dst_id` are the already-rendered Cypher values
/// for the (composite) source / destination key, in the same order as the key
/// property slices passed to [`unwind_edge_query_keyed`]. The single-key path
/// uses one-element vecs.
pub struct EdgeRow {
    pub src_id: Vec<String>,
    pub dst_id: Vec<String>,
    pub props: Vec<(String, String)>,
}

#[cfg(test)]
mod tests {
    use super::*;
    use arrow_array::builder::{Int64Builder, ListBuilder};
    use arrow_array::{Date32Array, Int64Array, ListArray, StringArray, TimestampMillisecondArray};

    #[test]
    fn date32_renders_iso_string() {
        // 2024-01-15 = 19737 days since epoch.
        let a = Date32Array::from(vec![19737]);
        assert_eq!(arrow_value_to_cypher(&a, 0).unwrap(), "'2024-01-15'");
    }

    #[test]
    fn timestamp_renders_iso_datetime() {
        // 2024-01-15T10:30:00Z = 1705314600000 ms.
        let a = TimestampMillisecondArray::from(vec![1_705_314_600_000]);
        assert_eq!(
            arrow_value_to_cypher(&a, 0).unwrap(),
            "'2024-01-15T10:30:00'"
        );
    }

    #[test]
    fn list_of_int_renders_cypher_list() {
        let mut b = ListBuilder::new(Int64Builder::new());
        b.values().append_value(1);
        b.values().append_value(2);
        b.values().append_value(3);
        b.append(true);
        let a: ListArray = b.finish();
        assert_eq!(arrow_value_to_cypher(&a, 0).unwrap(), "[1, 2, 3]");
    }

    #[test]
    fn list_of_string_renders_quoted() {
        let mut b = ListBuilder::new(arrow_array::builder::StringBuilder::new());
        b.values().append_value("a");
        b.values().append_value("b");
        b.append(true);
        let a: ListArray = b.finish();
        assert_eq!(arrow_value_to_cypher(&a, 0).unwrap(), "['a', 'b']");
    }

    #[test]
    fn null_in_any_type_is_null() {
        let a = Int64Array::from(vec![None, Some(5)]);
        assert_eq!(arrow_value_to_cypher(&a, 0).unwrap(), "null");
        assert_eq!(arrow_value_to_cypher(&a, 1).unwrap(), "5");
    }

    #[test]
    fn string_escaping_is_cypher_safe() {
        let a = StringArray::from(vec!["O'Brien \\ x"]);
        assert_eq!(arrow_value_to_cypher(&a, 0).unwrap(), "'O\\'Brien \\\\ x'");
    }

    /// Floats must ALWAYS carry a decimal point, integral values included.
    /// `{}` rendered 2.0 as "2" — a Cypher INT literal — so the server stored
    /// an integer property for integral floats and a double for the rest, and
    /// the same column came back over RESP as mixed Int/BulkString, which
    /// `response_to_batch_auto`'s single-type sniff rightly refuses
    /// (MalformedResponse, measured live 2026-10-19). It also broke the
    /// documented UNWIND↔GRAPH.BULK parity: the bulk encoder stores DOUBLE for
    /// every float. RED before the fix: 2.0 rendered "2".
    #[test]
    fn finite_floats_render_as_numeric_literals() {
        let a = arrow_array::Float64Array::from(vec![1.5, -2.0, 3.0]);
        assert_eq!(arrow_value_to_cypher(&a, 0).unwrap(), "1.5");
        assert_eq!(arrow_value_to_cypher(&a, 1).unwrap(), "-2.0", "integral floats keep the point");
        assert_eq!(arrow_value_to_cypher(&a, 2).unwrap(), "3.0", "integral floats keep the point");

        let f32s = arrow_array::Float32Array::from(vec![2.0f32, 0.5]);
        assert_eq!(arrow_value_to_cypher(&f32s, 0).unwrap(), "2.0");
        assert_eq!(arrow_value_to_cypher(&f32s, 1).unwrap(), "0.5");

        // The fast ColRenderer path must agree byte-for-byte.
        for (arr, row, want) in [(&a as &dyn Array, 1usize, "-2.0"), (&a, 2, "3.0"), (&a, 0, "1.5")] {
            let r = ColRenderer::resolve(arr);
            let mut buf = String::new();
            r.write(&mut buf, row).unwrap();
            assert_eq!(buf, want);
        }
    }

    #[test]
    fn non_finite_f64_renders_as_null() {
        // NaN/±Infinity have no valid Cypher literal; the safe encoding is `null`
        // (always parseable) rather than `NaN`/`inf`/`-inf`, which would break
        // the surrounding query.
        let a = arrow_array::Float64Array::from(vec![f64::NAN, f64::INFINITY, f64::NEG_INFINITY]);
        assert_eq!(arrow_value_to_cypher(&a, 0).unwrap(), "null", "NaN → null");
        assert_eq!(arrow_value_to_cypher(&a, 1).unwrap(), "null", "+Inf → null");
        assert_eq!(arrow_value_to_cypher(&a, 2).unwrap(), "null", "-Inf → null");
    }

    #[test]
    fn non_finite_f32_renders_as_null() {
        let a = arrow_array::Float32Array::from(vec![f32::NAN, f32::INFINITY, f32::NEG_INFINITY]);
        assert_eq!(arrow_value_to_cypher(&a, 0).unwrap(), "null");
        assert_eq!(arrow_value_to_cypher(&a, 1).unwrap(), "null");
        assert_eq!(arrow_value_to_cypher(&a, 2).unwrap(), "null");
    }

    #[test]
    fn large_utf8_renders_quoted_string() {
        // LargeStringArray (DataType::LargeUtf8) must use LargeStringArray downcast,
        // not StringArray — the previous bug caused a None.unwrap() panic here.
        let a = arrow_array::LargeStringArray::from(vec!["hello world"]);
        assert_eq!(arrow_value_to_cypher(&a, 0).unwrap(), "'hello world'");
    }

    #[test]
    fn non_finite_floats_render_as_null() {
        // NaN / ±inf are not valid Cypher numeric literals; rendering them
        // verbatim ("NaN"/"inf"/"-inf") produces an unparsable GRAPH.QUERY.
        // Both the general path and the fast ColRenderer path must emit `null`.
        let f64s = Float64Array::from(vec![f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1.5]);
        let f32s = Float32Array::from(vec![f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 1.5]);

        // General path.
        assert_eq!(arrow_value_to_cypher(&f64s, 0).unwrap(), "null");
        assert_eq!(arrow_value_to_cypher(&f64s, 1).unwrap(), "null");
        assert_eq!(arrow_value_to_cypher(&f64s, 2).unwrap(), "null");
        assert_eq!(arrow_value_to_cypher(&f64s, 3).unwrap(), "1.5");
        assert_eq!(arrow_value_to_cypher(&f32s, 0).unwrap(), "null");
        assert_eq!(arrow_value_to_cypher(&f32s, 1).unwrap(), "null");
        assert_eq!(arrow_value_to_cypher(&f32s, 2).unwrap(), "null");
        assert_eq!(arrow_value_to_cypher(&f32s, 3).unwrap(), "1.5");

        // Fast ColRenderer path.
        let r64 = ColRenderer::resolve(&f64s);
        let r32 = ColRenderer::resolve(&f32s);
        for (renderer, finite_row) in [(&r64, 3usize), (&r32, 3usize)] {
            for row in 0..3 {
                let mut buf = String::new();
                renderer.write(&mut buf, row).unwrap();
                assert_eq!(buf, "null", "non-finite row {row} must render null");
            }
            let mut buf = String::new();
            renderer.write(&mut buf, finite_row).unwrap();
            assert_eq!(buf, "1.5");
        }
    }

    // ---- Query-builder shape tests (no container) ----

    fn edge_row(src: &[&str], dst: &[&str], props: &[(&str, &str)]) -> EdgeRow {
        EdgeRow {
            src_id: src.iter().map(|s| s.to_string()).collect(),
            dst_id: dst.iter().map(|s| s.to_string()).collect(),
            props: props
                .iter()
                .map(|(k, v)| (k.to_string(), v.to_string()))
                .collect(),
        }
    }

    #[test]
    fn single_label_create_unchanged() {
        // The single-label multi path must be byte-identical to the old single
        // path through `unwind_create_query`.
        let maps = vec!["{`_gar_id`: 0}".to_string(), "{`_gar_id`: 1}".to_string()];
        let single = unwind_create_query("Person", &maps);
        let multi = unwind_create_query_multi(&["Person"], &maps);
        assert_eq!(single, multi);
        assert_eq!(
            single,
            "UNWIND [{`_gar_id`: 0}, {`_gar_id`: 1}] AS row \
             CREATE (n:`Person`) SET n = row"
        );
    }

    #[test]
    fn multi_label_create_emits_joined_labels() {
        let maps = vec!["{`_gar_id`: 0}".to_string()];
        let q = unwind_create_query_multi(&["Person", "Employee", "Admin"], &maps);
        assert_eq!(
            q,
            "UNWIND [{`_gar_id`: 0}] AS row \
             CREATE (n:`Person`:`Employee`:`Admin`) SET n = row"
        );
    }

    #[test]
    fn single_key_edge_query_shape_is_legacy_byte_identical() {
        // Single-key path must use the historical `_src`/`_dst` carriers, so the
        // emitted Cypher is byte-identical to the pre-composite-key version.
        let rows = vec![edge_row(&["0"], &["1"], &[("weight", "2.5")])];
        let q = unwind_edge_query("Person", "KNOWS", "Person", "_gar_id", "_gar_id", &rows);
        assert_eq!(
            q,
            "UNWIND [{`_src`: 0, `_dst`: 1, `weight`: 2.5}] AS row \
             MATCH (a:`Person` {`_gar_id`: row._src}), \
                   (b:`Person` {`_gar_id`: row._dst}) \
             CREATE (a)-[r:`KNOWS`]->(b) SET r = row \
             REMOVE r._src, r._dst"
        );
    }

    #[test]
    fn composite_key_edge_query_shape() {
        let rows = vec![edge_row(&["'SE'", "10"], &["'NO'", "20"], &[])];
        let q = unwind_edge_query_keyed(
            "City",
            "ROUTE",
            "City",
            &["country", "code"],
            &["country", "code"],
            &rows,
        );
        assert_eq!(
            q,
            "UNWIND [{`_s0`: 'SE', `_s1`: 10, `_d0`: 'NO', `_d1`: 20}] AS row \
             MATCH (a:`City` {`country`: row._s0, `code`: row._s1}), \
                   (b:`City` {`country`: row._d0, `code`: row._d1}) \
             CREATE (a)-[r:`ROUTE`]->(b) SET r = row \
             REMOVE r._s0, r._s1, r._d0, r._d1"
        );
    }

    /// The parameterized form carries the SAME rendered row maps as the literal
    /// form — only relocated into the `CYPHER rows=` prefix — and a constant
    /// `UNWIND $rows` body. Byte-level pin on both halves.
    #[test]
    fn param_edge_query_shape_single_key() {
        let rows = vec![edge_row(&["0"], &["1"], &[("weight", "2.5")])];
        let q = unwind_edge_query_keyed_param(
            "Person",
            "KNOWS",
            "Person",
            &["_gar_id"],
            &["_gar_id"],
            &rows,
        );
        assert_eq!(
            q,
            "CYPHER rows=[{`_src`: 0, `_dst`: 1, `weight`: 2.5}] \
             UNWIND $rows AS row \
             MATCH (a:`Person` {`_gar_id`: row._src}), \
                   (b:`Person` {`_gar_id`: row._dst}) \
             CREATE (a)-[r:`KNOWS`]->(b) SET r = row \
             REMOVE r._src, r._dst"
        );
        // Same maps, same body: the literal form differs ONLY in where the rows sit.
        let lit = unwind_edge_query_keyed("Person", "KNOWS", "Person", &["_gar_id"], &["_gar_id"], &rows);
        let maps = "{`_src`: 0, `_dst`: 1, `weight`: 2.5}";
        assert_eq!(lit, format!("UNWIND [{maps}] AS row {}", q.split(" AS row ").nth(1).unwrap()));
    }

    #[test]
    fn param_edge_query_shape_composite_key() {
        let rows = vec![edge_row(&["'SE'", "10"], &["'NO'", "20"], &[])];
        let q = unwind_edge_query_keyed_param(
            "City",
            "ROUTE",
            "City",
            &["country", "code"],
            &["country", "code"],
            &rows,
        );
        assert_eq!(
            q,
            "CYPHER rows=[{`_s0`: 'SE', `_s1`: 10, `_d0`: 'NO', `_d1`: 20}] \
             UNWIND $rows AS row \
             MATCH (a:`City` {`country`: row._s0, `code`: row._s1}), \
                   (b:`City` {`country`: row._d0, `code`: row._d1}) \
             CREATE (a)-[r:`ROUTE`]->(b) SET r = row \
             REMOVE r._s0, r._s1, r._d0, r._d1"
        );
    }

    #[test]
    fn render_labels_single_is_plain() {
        assert_eq!(render_labels(&["X"]), "`X`");
        assert_eq!(render_labels(&["A", "B"]), "`A`:`B`");
    }
}