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`");
}
}