Querying EdgeDB with named parameters in Rust

Recently I rebuilt this website, using Rust as backend programming language and EdgeDB as database. EdgeDB is great, but its Rust support is still weak comparing to other languages. One very important, but missing, feature is to support named parameters in query. Fortunately, there is already a base for the support in the future. In this post, we will exploit that minimum base to let our work done.

Current state

As the time of this writing, if you look into edgedb-tokio, the official Rust client library, documentation and example, you will see that it only supports using positional (numbered) parameters:

let categories: Vec<BlogCategory> = conn.query("SELECT BlogCategory ORDER BY .title OFFSET <int64>$0 LIMIT <int64>$1", &(10, 20)).await?;

That limitation is quite inconvenient, because in real application, which parameters to pass to the query often varies by external input. Like if you are building a page that list products in a store, the users need to be able filter the products by some criteria (brand, price, etc.) and hence, the query to database needs to be dynamic.

You may think about using an IndexMap to hold the to-be-used parameters then deduces the value and the positions from that map. But unfortunately, the arguments parameter of the query() function expects a tuple, and in Rust, tuple size must be fixed at compile time, not as in Python, so this solution is not applicable.

Solution

Fortunately, when surfing the reference documentation, checking the signature of query() function:

pub async fn query<R, A>(
    &self,
    query: &str,
    arguments: &A
) -> Result<Vec<R>, Error>
where
    A: QueryArgs,
    R: QueryResult,

I clicked to the link of QueryArgs and saw this at the very last of the page:

impl QueryArgs for Value

Well, this must be a hint and open a chance for using named parameters.

Follow the Value type and I found that, is has some variants which can be candidate for holding named parameters:

pub enum Value {
    // Other variants
    Object { shape: ObjectShape, fields: Vec<Option<Value>> },
    SparseObject(SparseObject),
    NamedTuple { shape: NamedTupleShape, fields: Vec<Value> },
    // Other variants
}

After some experiments (facing with the errors and debugging), I finally find out that Value::Object can be used to hold named parameters and pass to query. But it is quite verbose to build Value::Object, due to its complex structure.

The most convenient way is to convert from a map to it. So let's define a function:

use edgedb_protocol::value::Value as EValue;
use edgedb_protocol::codec::{ShapeElement, ObjectShape};
use edgedb_protocol::common::Cardinality;

pub fn edge_object_from_pairs<N, V>(iter: impl IntoIterator<Item = (N, (V, Cardinality))>) -> EValue
where
    N: ToString,
    V: Into<Option<EValue>>,
{
    let mut elements = Vec::new();
    let mut fields: Vec<Option<EValue>> = Vec::new();
    for (key, (val, cardinality)) in iter.into_iter() {
        elements.push(create_shape_element(key, cardinality));
        fields.push(val.into());
    }
    EValue::Object {
        shape: ObjectShape::new(elements),
        fields,
    }
}

pub fn create_shape_element<N: ToString>(name: N, cardinality: Cardinality) -> ShapeElement {
    ShapeElement {
        name: name.to_string(),
        cardinality: Some(cardinality),
        flag_link: false,
        flag_link_property: false,
        flag_implicit: false,
    }
}

We can then use the utility as:

use edgedb_protocol::common::Cardinality as Cd;

// offset (i64), limit (i64) are data from external source
let pairs = indexmap! {
    offset => (Some(EValue::Int64(offset)), Cd::One),
    limit => (Some(EValue::Int64(limit)), Cd::One)
}

let args = edge_object_from_pairs(pairs);
let categories: Vec<BlogCategory> = conn.query("SELECT BlogCategory ORDER BY .title OFFSET <int64>$offset LIMIT <int64>$limit", &args).await?;

You can use any map type. Here I use IndexMap just because I'm from Python and familiar with its dict behaviour.

Some notes regarding to Cardinality:

  • If your passed value can be None (not fixedly Some), use Cardinality::AtMostOne, and the "type cast" expression must be <optional ...>, e.g. <optional str>$content.

  • If your passed value is fixedly None, like when you want to clear the data of some object field in the database, the corresponding element in Value::Object.fields vector must be None (rule for cardinality is the same as above).

  • In contrast to what we may think, even if we are setting value for multi property, the cardinality for parameter is still Cardinality::One, not Cardinality::Many, nor Cardinality::Cardinality. For example, we have:

    type BlogPost {
        multi link categories: BlogCategory;
    }
    

    and we want to update BlogPost.categories field with this query:

    categories := (
        SELECT BlogCategory FILTER .id IN array_unpack(<array<uuid>>$categories)
    )
    

    we still use Cardinality::One for $categories.

So I have given you a way to fully exploit EdgeDB. Hope your experience with EdgeDB will be good. The conversion function above, I don't extract and publish as a library, because I still hope that EdgeDB authors will provide a more elegant way to build Value::Object (with proc macro, for example).