crates/graphar-falkordb/src/reply.rs
//! **Reading a `GRAPH.QUERY` result set back out.**
//!
//! [`FalkorDbLoader::run_query`](crate::FalkorDbLoader::run_query) hands back a
//! raw `redis::Value`. Every caller that wanted the *rows* — the integration
//! tests, the parity gate, anything asserting what actually landed — has so far
//! hand-rolled its own `walk()` that finds the first integer and throws the rest
//! away. That is enough to check a `count(n)`, and it is structurally unable to
//! check **which** nodes, or **which** endpoints an edge joined.
//!
//! This is the one writer for that: FalkorDB replies to `GRAPH.QUERY` with a
//! three-element array — `[header, rows, statistics]` (a write-only query omits
//! the first two) — where `rows` is an array of arrays of cells. [`rows`]
//! renders that to `Vec<Vec<String>>` in wire order, so a test can assert the
//! exact id set and the exact `(src, dst)` endpoint list rather than a count
//! that a cartesian blow-up or an empty MATCH would both satisfy.
/// One cell of a result row, rendered to a stable string.
///
/// Integers, doubles and strings render as themselves; `Nil` renders as the
/// empty string; a nested array (a Cypher list, or a node/relation projection)
/// renders as its comma-joined elements in brackets.
///
/// Doubles go through `Display` (`{}`), **not** `Debug` (`{:?}`), on purpose:
/// FalkorDB is free to hand an integer property back widened, and `Display`
/// prints `41.0f64` as `41` while `Debug` prints `41.0`. So a widened integer
/// still compares equal to the integer that was written. (An earlier draft put
/// a `strip_suffix(".0")` on top of this — dead code, since `Display` never
/// emits the suffix. It was found by proving the guard below could go red and
/// watching it stay green, then removed.)
pub fn cell(v: &redis::Value) -> String {
match v {
redis::Value::Nil => String::new(),
redis::Value::Int(i) => i.to_string(),
redis::Value::Double(d) => format!("{d}"),
redis::Value::BulkString(b) => String::from_utf8_lossy(b).into_owned(),
redis::Value::SimpleString(s) => s.clone(),
redis::Value::Boolean(b) => b.to_string(),
redis::Value::Array(items) | redis::Value::Set(items) => {
let inner: Vec<String> = items.iter().map(cell).collect();
format!("[{}]", inner.join(","))
}
other => format!("{other:?}"),
}
}
/// The column names of a `GRAPH.QUERY` reply, or empty for a write-only query.
pub fn header(v: &redis::Value) -> Vec<String> {
match v {
redis::Value::Array(parts) if parts.len() >= 2 => match &parts[0] {
redis::Value::Array(cols) => cols.iter().map(cell).collect(),
_ => Vec::new(),
},
_ => Vec::new(),
}
}
/// **The rows of a `GRAPH.QUERY` reply**, each rendered cell-by-cell.
///
/// A reply with fewer than two top-level parts carries no result set (a pure
/// write, e.g. `CREATE …` with no `RETURN`) and yields no rows. A reply whose
/// result set is genuinely empty — the MATCH found nothing — also yields no
/// rows, which is exactly the case a `count(…)`-only assertion cannot tell from
/// a healthy one unless the count itself is asserted to be non-zero.
pub fn rows(v: &redis::Value) -> Vec<Vec<String>> {
let parts = match v {
redis::Value::Array(parts) if parts.len() >= 2 => parts,
_ => return Vec::new(),
};
match &parts[1] {
redis::Value::Array(rs) => rs
.iter()
.map(|r| match r {
redis::Value::Array(cells) | redis::Value::Set(cells) => {
cells.iter().map(cell).collect()
}
single => vec![cell(single)],
})
.collect(),
_ => Vec::new(),
}
}
/// The single scalar of a one-row/one-column reply (`RETURN count(n)`), parsed
/// as `i64`. `None` when the reply has no rows or the cell is not a number —
/// **distinct** from `Some(0)`, so "the query returned nothing" can never be
/// mistaken for "the graph is empty".
pub fn scalar_i64(v: &redis::Value) -> Option<i64> {
rows(v).first()?.first()?.parse().ok()
}
/// Every row flattened to its first column — the common "return one id per row"
/// shape (`MATCH (p:Person) RETURN p.pnr`).
pub fn column0(v: &redis::Value) -> Vec<String> {
rows(v)
.into_iter()
.filter_map(|mut r| {
if r.is_empty() {
None
} else {
Some(r.swap_remove(0))
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use redis::Value;
fn bulk(s: &str) -> Value {
Value::BulkString(s.as_bytes().to_vec())
}
/// A real two-column, two-row reply must come back as exactly those cells —
/// in order, with the header separate from the data. RED-when-broken: index
/// `parts[0]` as the rows and both assertions below flip.
#[test]
fn a_two_column_result_set_reads_back_cell_for_cell() {
let reply = Value::Array(vec![
Value::Array(vec![bulk("p.pnr"), bulk("c.orgnr")]),
Value::Array(vec![
Value::Array(vec![Value::Int(5001), Value::Int(9001)]),
Value::Array(vec![Value::Int(5003), Value::Int(9002)]),
]),
Value::Array(vec![bulk("Cached execution: 0")]),
]);
assert_eq!(header(&reply), vec!["p.pnr", "c.orgnr"]);
assert_eq!(
rows(&reply),
vec![vec!["5001", "9001"], vec!["5003", "9002"]],
"both endpoint pairs, in wire order"
);
assert_eq!(column0(&reply), vec!["5001", "5003"]);
assert_eq!(scalar_i64(&reply), Some(5001));
}
/// An EMPTY result set and a MISSING result set must both yield no rows, and
/// `scalar_i64` must report `None` — never `Some(0)`. A helper that folded
/// "no reply" into `0` would let a dead query pass as an empty graph.
#[test]
fn no_rows_is_none_not_zero() {
let empty = Value::Array(vec![
Value::Array(vec![bulk("count(r)")]),
Value::Array(vec![]),
Value::Array(vec![bulk("Cached execution: 0")]),
]);
assert!(rows(&empty).is_empty());
assert_eq!(scalar_i64(&empty), None, "empty result set is NOT zero");
// A write-only reply (statistics only) has no result set at all.
let write_only = Value::Array(vec![Value::Array(vec![bulk("Nodes created: 3")])]);
assert!(rows(&write_only).is_empty());
assert_eq!(scalar_i64(&write_only), None);
// And a genuine zero count IS Some(0) — the case that must stay distinct.
let zero = Value::Array(vec![
Value::Array(vec![bulk("count(r)")]),
Value::Array(vec![Value::Array(vec![Value::Int(0)])]),
Value::Array(vec![bulk("Cached execution: 0")]),
]);
assert_eq!(scalar_i64(&zero), Some(0), "a real 0 count is Some(0)");
}
/// Cell rendering: nulls, doubles and lists each have one stable form, and a
/// widened integer (`41.0`) compares equal to `41`.
///
/// RED-when-broken (SEEN, 2026-08-01): swap the double arm to `{d:?}` and
/// this reports `left: "41.0" right: "41"` — the drift that would make an
/// Int64 property stop comparing against its own value after a round-trip.
#[test]
fn cells_render_to_one_stable_form() {
assert_eq!(cell(&Value::Nil), "");
assert_eq!(cell(&Value::Int(41)), "41");
assert_eq!(
cell(&Value::Double(41.0)),
"41",
"a widened int is not `41.0`"
);
assert_eq!(cell(&Value::Double(1.5)), "1.5");
assert_eq!(cell(&Value::Array(vec![Value::Int(1), bulk("a")])), "[1,a]");
}
}