Part 6 of 8
Typed mapping with serde, and temporal values that do real math
Part 3 gave us honest rows you read column-by-column. This post is
about two ways to stop reading column-by-column: map a whole row into your own type with serde, and
get temporal values back as real types instead of opaque scalars.
Rows into structs, with serde
Per-column try_get is great until you're pulling ten fields off a node, at which point it's ten
lines of boilerplate that all say the same thing. With the serde feature you derive Deserialize
on your struct and let the row map itself:
/*
* Copyright FalkorDB Ltd. 2023 - present
* Licensed under the MIT License.
*/
//! Typed result mapping with `serde`.
//!
//! Run with: `cargo run --example typed_mapping --features serde`
//!
//! Requires a running FalkorDB instance (defaults to `127.0.0.1:6379`).
use falkordb::{FalkorClientBuilder, FalkorConnectionInfo};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Movie {
title: String,
year: i64,
#[serde(default)]
rating: Option<f64>,
}
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("typed_mapping_example");
// Start from a clean slate so the example is idempotent across repeated runs.
// The graph may not exist yet, so ignore a "missing graph" error here.
let _ = graph.delete();
graph
.query("CREATE (:Movie {title: 'Heat', year: 1995, rating: 8.3})")
.execute()?;
// ── Value-level mapping ──────────────────────────────────────────────────
// Without typed mapping you would hand-match every `FalkorValue` variant.
// With the `serde` feature, deserialize a single returned value into a struct:
let mut result = graph.query("MATCH (m:Movie) RETURN m").execute()?;
for row in result.data.by_ref() {
if let Some(node) = row?.into_iter().next() {
let movie: Movie = node.deserialize_into()?;
println!(
"value: {} ({}) rating={:?}",
movie.title, movie.year, movie.rating
);
}
}
// ── Row-level mapping with `query_as` ────────────────────────────────────
// Map every row in one shot. A single-column `RETURN m` maps the node's properties:
let movies: Vec<Movie> = graph
.query("MATCH (m:Movie) RETURN m")
.query_as::<Movie>()
.execute()?
.data
.collect::<Result<_, _>>()?;
for movie in &movies {
println!("row: {} ({})", movie.title, movie.year);
}
// Multi-column rows map column aliases onto struct fields:
let summaries: Vec<Movie> = graph
.query("MATCH (m:Movie) RETURN m.title AS title, m.year AS year")
.query_as::<Movie>()
.execute()?
.data
.collect::<Result<_, _>>()?;
for movie in &summaries {
println!("aliased: {} ({})", movie.title, movie.year);
}
// Scalars work too — `RETURN count(m)` is a single-column row:
let counts: Vec<i64> = graph
.query("MATCH (m:Movie) RETURN count(m)")
.query_as::<i64>()
.execute()?
.data
.collect::<Result<_, _>>()?;
println!("count: {counts:?}");
graph.delete()?;
Ok(())
}Source: examples/typed_mapping.rs — compiled in CI.
What I like here is that it works at three levels without three different APIs:
- Value-level:
node.deserialize_into::<Movie>()turns a single returned node into a struct. - Row-level with
query_as:RETURN mmaps the node's properties onto your fields; a multi-columnRETURN m.title AS title, m.year AS yearmaps the aliases onto the same fields. YourRETURNand your struct meet in the middle, by name. - Scalars:
RETURN count(m)deserializes straight intoi64. Even the degenerate case is consistent.
And because it's serde, the usual tools just work — #[serde(default)] on rating: Option<f64>
means a missing property is None instead of an error. You already know this vocabulary; the client
just speaks it.
Temporal values that aren't lying integers
Now the one that's secretly hard. FalkorDB has real temporal types — date, time/localtime,
duration, datetime. For a while the client surfaced some of these as Unparseable, which is a
polite way of saying "here's a blob, you figure it out." That's a trap: the moment you turn a date
into a bare i64 to do arithmetic, you've thrown away the type system's ability to stop you from
adding a duration to a count of bananas.
So we decode them into proper Date / Time / Duration / DateTime value types, each exposing its
scalar as a typed Seconds — not a raw integer — and DateTime/Duration get a small, type-safe
algebra:
/*
* Copyright FalkorDB Ltd. 2023 - present
* Licensed under the MIT License.
*/
//! Decoding FalkorDB temporal values and using the type-safe temporal algebra.
//!
//! Run with: `cargo run --example temporal` (needs a FalkorDB server on 127.0.0.1:6379).
use falkordb::{Date, DateTime, Duration, FalkorClientBuilder, FalkorResult, Time};
fn main() -> FalkorResult<()> {
let client = FalkorClientBuilder::new()
.with_connection_info("falkor://127.0.0.1:6379".try_into()?)
.build()?;
let mut graph = client.select_graph("temporal_example");
let _ = graph.delete();
// FalkorDB returns `date` / `time` / `duration` as typed, seconds-based scalars. The client
// decodes them into the `Date` / `Time` / `Duration` value types rather than leaving them
// `Unparseable`, so you can read them with strict typed access.
let mut result = graph
.query("RETURN date('1947-11-29') AS d, localtime() AS t, duration({days: 3}) AS dur")
.execute()?;
let row = result.data.next().expect("expected a row")?;
let d: Date = row.try_get("d")?;
let t: Time = row.try_get("t")?;
let dur: Duration = row.try_get("dur")?;
// Each value exposes its scalar as a typed `Seconds` (not a bare `i64`) — call `.get()` for the
// underlying integer. This keeps a temporal scalar from being silently mixed with a plain int.
println!("date is {} seconds since the Unix epoch", d.seconds().get());
println!("localtime is {} seconds", t.seconds());
println!("duration is {} seconds", dur.seconds().get());
println!(
"duration as std::time::Duration: {:?}",
dur.as_std_duration()
);
// `DateTime` and `Duration` support a small, type-safe algebra. Subtracting two instants yields
// a `Duration`; shifting an instant by a `Duration` yields another `DateTime`.
let instant = DateTime::new(d.seconds().get());
let earlier = DateTime::new(d.seconds().get() - 60);
let elapsed: Duration = instant - earlier;
assert_eq!(elapsed.seconds().get(), 60);
println!("instant is {} seconds after `earlier`", elapsed.seconds());
// Shifting forward by a duration and back again is a no-op.
assert_eq!(instant + dur - dur, instant);
// Overflow-checked variants return `None` instead of panicking.
assert!(DateTime::new(i64::MAX).checked_add(dur).is_none());
// Nonsensical combinations simply have no impl and fail to compile, e.g.:
// let _ = instant + earlier; // error: cannot add two instants
graph.delete()?;
Ok(())
}Source: examples/temporal.rs — compiled in CI.
The algebra is the point, and it's deliberately restrictive:
DateTime − DateTime → Duration. The difference between two instants is a span. ✓DateTime ± Duration → DateTime. Shifting an instant by a span gives another instant. ✓DateTime + DateTime? That has no meaning, so it has no impl and won't compile. You can't add two Tuesdays together, and now the compiler agrees with you.
There are checked_* variants that return None instead of panicking on overflow, because temporal
arithmetic at the extremes is exactly where the silent wraparound bugs hide. DateTime::new(i64::MAX) .checked_add(dur) is None, not a wrapped-around timestamp from the Bronze Age.
This is a tiny domain, but it's a perfect showcase for the whole thesis of this series: encode the rules of your domain into types, and a category of bugs simply stops being expressible. Next, we take that same "make failure honest" energy to the network itself — retries, tracing, and metrics.