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 m maps the node's properties onto your fields; a multi-column RETURN m.title AS title, m.year AS year maps the aliases onto the same fields. Your RETURN and your struct meet in the middle, by name.
  • Scalars: RETURN count(m) deserializes straight into i64. 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.