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:
- 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. - Iteration is fallible. The result stream yields
FalkorResult<Row>. A row that fails to parse is a realErryou 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.collectshort-circuits. Collecting into aResult<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 anErrdescribing 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.