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