Part 3 of 8

Rows that don't lie: header-aware, fallible result iteration

In Part 2 we made the data going in honest. This post is about the data coming out.

The old result API had two original sins, and if you've used a database driver in a dynamically-typed mood you'll recognize both: you addressed columns by position (row[0], row[1] — good luck when someone reorders the RETURN), and a value that didn't match your expectation tended to surface as a panic somewhere deep in iteration. Both of those are the kind of bug that passes code review and then pages you at 2am.

The new shape

Two changes, and they compose:

  1. Result sets are header-aware. A row knows its column names, so you read by alias — row.try_get("year") — not by a magic index.
  2. Iteration is fallible. The result stream yields FalkorResult<Row>. A row that fails to parse is a real Err you can ?, not a landmine.

Here's the real examples/rows.rs:

/*
 * Copyright FalkorDB Ltd. 2023 - present
 * Licensed under the MIT License.
 */

//! Header-aware result rows: reading columns by name or index, with strict typed access.
//!
//! Run with: `cargo run --example rows`
//!
//! Requires a running FalkorDB instance (defaults to `127.0.0.1:6379`).

use falkordb::{FalkorClientBuilder, FalkorConnectionInfo, FalkorValue};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let connection_info: FalkorConnectionInfo = "falkor://127.0.0.1:6379".try_into()?;
    let client = FalkorClientBuilder::new()
        .with_connection_info(connection_info)
        .build()?;

    let mut graph = client.select_graph("rows_example");

    // Start from a clean slate so the example is idempotent across runs.
    let _ = graph.delete();

    graph
        .query(
            "CREATE (:Movie {title: 'The Matrix', year: 1999, rating: 8.7}),
                    (:Movie {title: 'Heat', year: 1995, rating: 8.3})",
        )
        .execute()?;

    // ── Read columns by alias, converted to the type you ask for ────────────
    let mut result = graph
        .query(
            "MATCH (m:Movie)
             RETURN m.title AS title, m.year AS year, m.rating AS rating
             ORDER BY m.year",
        )
        .execute()?;

    // `data` yields `FalkorResult<Row>`, so a row that fails to parse is a real `Err` you can `?`.
    for row in result.data.by_ref() {
        let row = row?;
        let title: String = row.try_get("title")?;
        let year: i64 = row.try_get("year")?;
        let rating: f64 = row.try_get("rating")?;
        println!("{title} ({year}) — rated {rating}");
    }

    // ── Read by index, and borrow the raw value without converting ──────────
    let mut counted = graph.query("MATCH (m:Movie) RETURN count(m)").execute()?;
    if let Some(row) = counted.data.next() {
        let row = row?;
        let count: i64 = row.try_get_at(0)?;
        println!("movies: {count}");
        // `get_at` borrows the underlying `FalkorValue` without converting or cloning.
        assert_eq!(row.get_at(0), Some(&FalkorValue::I64(count)));
    }

    // ── Collect a whole result set; `collect` short-circuits on the first error ──
    let titles: Vec<String> = graph
        .query("MATCH (m:Movie) RETURN m.title AS title ORDER BY m.title")
        .execute()?
        .data
        .map(|row| row?.try_get::<String>("title"))
        .collect::<Result<_, _>>()?;
    println!("titles: {titles:?}");

    // ── Turn a row into a column -> value map ───────────────────────────────
    let mut as_map = graph
        .query("MATCH (m:Movie) RETURN m.title AS title, m.year AS year ORDER BY m.year LIMIT 1")
        .execute()?;
    if let Some(row) = as_map.data.next() {
        let map = row?.into_map();
        println!("oldest movie as a map: {map:?}");
    }

    graph.delete()?;
    Ok(())
}

Source: examples/rows.rs — compiled in CI.

Why FalkorResult<Row> is the important part

Look at the iterator item type. It's not Row, it's FalkorResult<Row>. That one decision ripples outward in the nicest way:

  • ? works mid-iteration. let row = row?; and the unhappy path is handled the same way you handle every other error in Rust. No special "did this row secretly fail?" dance.
  • collect short-circuits. Collecting into a Result<Vec<_>, _> stops at the first bad row and hands you the error. One malformed value doesn't get silently dropped or turned into a default.
  • Typed access is explicit. try_get::<i64>("year") says exactly what you expect, and converts in one step. If the column is actually a string, you get an Err describing the mismatch — at the call site, where you can do something about it.

And when you don't want to convert, get_at borrows the raw FalkorValue without cloning, so the escape hatch is there when you need to inspect the wire value directly. Ergonomics shouldn't cost you control.

The "by name" thing is not a small thing

I want to dwell on reading columns by alias for a second, because it's the feature that quietly prevents the most bugs. Positional access couples your Rust code to the order of your RETURN clause. The day someone adds a column, or reorders two for readability, every positional index downstream is now silently pointing at the wrong data — and nothing fails, it just returns nonsense. Reading row.try_get("year") is immune to that. The query can grow and shuffle; your code keeps asking for year and keeps getting year.

There's also a tidy into_map() when you genuinely want the whole row as a column → value map — handy for logging or for shoving a row into something generic.

This is about as far as the untyped row API goes. The natural next question is "can I skip the per-column try_get and just deserialize a row into my own struct?" Yes — that's the serde story in Part 6. But first, the feature I had the most fun building: making the whole thing async and streaming.