Part 2 of 8

Stop hand-quoting Cypher: type-safe, injection-proof parameters

Every database client eventually has the same coming-of-age moment: the day it stops letting you build queries with string concatenation. Today's the day for this one.

The bad old way

Here's the shape of code I never want to write again — and rather than hand-type it (this blog has a rule: no uncompiled Rust), I'll point you at the commented-out "before" block right inside the real example below. It builds a query with format!, quoting 'The Matrix' by hand and pasting year straight into the string.

Two problems, one fatal. The annoying one: I'm responsible for quoting and escaping every value, and I will get it wrong the day a title contains an apostrophe. The fatal one: if that title ever comes from a user, they can close my string and append their own Cypher. '; MATCH (n) DETACH DELETE n // and the graph is gone. This is injection, and "remember to escape everything, forever" is not a security strategy.

The way that ships now

Parameters are bound, not interpolated. You hand the client real Rust values and it encodes them into proper Cypher literals for you. The example below shows both the commented-out "before" and the real "after":

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

//! Type-safe, injection-proof query parameters.
//!
//! Run with: `cargo run --example typed_params`
//!
//! Requires a running FalkorDB instance (defaults to `127.0.0.1:6379`).

use falkordb::{FalkorClientBuilder, FalkorConnectionInfo};
use std::collections::BTreeMap;

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_params_example");

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

    // ── Before: hand-quoting and escaping every value yourself ──────────────
    // let mut params = HashMap::new();
    // params.insert("title".to_string(), "'The Matrix'".to_string()); // manual quotes!
    // params.insert("year".to_string(), 1999.to_string());
    // graph.query("CREATE (:Movie {title: $title, year: $year})").with_params(&params)...

    // ── After: typed values, encoding handled for you ───────────────────────
    graph
        .query("CREATE (:Movie {title: $title, year: $year, rating: $rating})")
        .with_param("title", "The Matrix")
        .with_param("year", 1999)
        .with_param("rating", 8.7)
        .execute()?;

    // A string that would be a Cypher-injection attempt is stored as an inert literal.
    graph
        .query("CREATE (:Movie {title: $title, year: $year})")
        .with_param("title", "'; MATCH (n) DETACH DELETE n //")
        .with_param("year", 2003)
        .execute()?;

    // Collections work too: `WHERE x IN $list`.
    let mut titles = graph
        .query("MATCH (m:Movie) WHERE m.year IN $years RETURN m.title AS title ORDER BY m.year")
        .with_param("years", [1999, 2003])
        .execute()?;
    for row in titles.data.by_ref() {
        // Read the column by its alias and convert it in one step.
        let title: String = row?.try_get("title")?;
        println!("matched: {title}");
    }

    // Points can't be bound directly — pass the components as a map and wrap with `point()`.
    let coords = BTreeMap::from([("latitude", 32.07), ("longitude", 34.79)]);
    let mut located = graph
        .query("RETURN point($p)")
        .with_param("p", coords)
        .execute()?;
    if let Some(row) = located.data.next() {
        println!("point: {:?}", row?.get_at(0));
    }

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

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

A few things I want to point at, because they're the whole point:

  • with_param takes typed Rust values. "The Matrix" (a &str), 1999 (an i64), 8.7 (an f64) — each is encoded as the correct Cypher literal. No quotes in your format string, because there is no format string.
  • Injection becomes a non-event. The line that binds "'; MATCH (n) DETACH DELETE n //" as a title stores that whole thing as an inert string property. There is no parser context in which it can "break out," because it was never concatenated into the query text in the first place.
  • Collections just work. WHERE m.year IN $years with [1999, 2003] does what you'd hope — the array is bound as a Cypher list. Great for the IN-list query you write fifteen times a week.
  • The type system catches the edges. Some values (a spatial point) can't be bound as a raw literal, so the API makes you pass the components as a map and wrap them with point(). That's the compiler steering you toward the one correct encoding instead of letting you guess.

The ergonomic version and the safe version are the same version. That's the bit I'm proud of: you don't choose safety here, you just write the obvious code and safety falls out.

Why a graph client especially needs this

Cypher is expressive, which is a polite way of saying there are a lot of places to inject. Node labels, relationship types, property maps, list predicates, path patterns. Bound parameters keep your data out of the query structure, permanently. Your query text becomes a fixed template the server can even cache and re-plan efficiently, and your values ride alongside it where they can't do any harm.

It also makes the next feature possible. Once values are typed on the way in, it's a short hop to making results typed on the way out — which is exactly where Part 3 goes: rows that don't lie about what they contain.