Create a new type

To add a new type conversion, you need to implement two traits: elephantry::ToSql and elephantry::FromSql which convert a rust value to its postgresql representation and vis versa.

pub trait FromSql: Sized {
    fn from_text(ty: &elephantry::pq::Type, raw: Option<&str>) -> elephantry::Result<Self>;
    fn from_binary(ty: &elephantry::pq::Type, raw: Option<&[u8]>) -> elephantry::Result<Self>;
}

pub trait ToSql {
    fn ty(&self) -> elephantry::pq::Type;
    fn to_text(&self) -> elephantry::Result<Option<String>>;
    fn to_binary(&self) -> elephantry::Result<Option<Vec<u8>>>;
}

Both traits have text and binary versions1.

For this tutorial, we’ll implement step by step these traits to convert the ltree type. According to the postgresql documentation, ltree is:

A label path is a sequence of zero or more labels separated by dots, for example L1.L2.L3, representing a path from the root of a hierarchical tree to a particular node. The length of a label path cannot exceed 65535 labels.

Example: Top.Countries.Europe.Russia

The first step is to determine the best rust representation for this type. Here I choose a custom type that contains a Vec<String>:

#[derive(Default)]
struct Ltree(Vec<String>);

Why? First, Vec<T> already implements these traits where T implements its (and String does). Second, the Vec type has similar operators to ltree.

Now we need to implement traits.

A good place to start is with the server-side C implementation, of these functions. For the core types, you can find code in src/backend/utils/adt, but here it’s an extension and code can be found in contrib/ltree/ltree_io.c.

On the postgres side, we also have 4 functions:

  • *_into_text;
  • *_outfrom_text;
  • *_recvto_binary;
  • *_sendfrom_binary.

The text version is the easier to implement, as long as you understand C…

Type information

Before implementing the conversion, we define your type information, the ToSql::ty() function.

This information is used for query parameters and error messages.

All built-in types have this information in postgresql sources.

Your type comes with an extension and we need to create this information. By default, elephantry::pq::types::UNKNOWN is a good placeholder:

fn ty(&self) -> elephantry::pq::Type {
    elephantry::pq::Type {
        descr: "LQUERY - data type for hierarchical tree-like structures",
        name: "lquery",

        ..elephantry::pq::types::UNKNOWN
    }
}

Text conversion

Converting a Ltree to the postgresql text representation consists of concatenating all elements with dots:

select 'Top.Countries.Europe.Russia'::ltree;
fn to_text(&self) -> elephantry::Result<Option<String>> {
    Ok(Some(self.0.join(".")))
}

This function returns an Option for null value, we always returns Some, this case is threatened by the Option conversion implementations.

If an error occurs during the conversion, you can use the ToSql::error() function to return an elephantry::Result.

To reduce the boitelplate, you can delegate the String conversion to the appropriate trait implementations:

fn to_text(&self) -> elephantry::Result<Option<String>> {
    self.0.join(".").to_text()
}

Well, as you can imagine the from_text is the opposite:

fn from_text(ty: &elephantry::pq::Type, raw: Option<&str>) -> elephantry::Result<Self> {
    // 1
    let s = String::from_text(ty, raw)?;

    // 2
    let ltree = if s.is_empty() {
        Self::default()
    } else {
        // 3
        Self(s.split('.').map(ToString::to_string).collect())
    };

    Ok(ltree)
}
  1. First, we need to convert the raw value into string. This deals with null value, so if raw is null here, it’s an error (the user should use the Option<Ltree> type;
  2. Second, we need to deal with empty string here, because splitting its made an one array element;
  3. Finally, just split the string and convert it to String.

Binary conversion

Well, if you arrive here, you know all you need to know about implementing conversion. The second part is to do the same thing for the binary format and the hardest part is to understanding the C code, again…

Here the function comment is useful and clearly explains how the value is sent:

/*
 * ltree type send function
 *
 * The type is sent as text in binary mode, so this is almost the same
 * as the output function, but it's prefixed with a version number so we
 * can change the binary format sent in future if necessary. For now,
 * only version 1 is supported.
 */

If there is no comment, you can look for pg_send* calls in the code. Here:

pq_sendint8(&buf, version);
pq_sendtext(&buf, res, strlen(res));

Postgresql sends:

  • a i8: the binary format version (1);
  • a String: the deparse version of the ltree (like the text version).
fn to_binary(&self) -> elephantry::Result<Option<Vec<u8>>> {
    let mut buf = vec![1];
    buf.extend_from_slice(&self.0.join(".").into_bytes());

    Ok(Some(buf))
}

As for the text version, we use String::to_binary() to convert the string to postgresql binary format.

Finally, to convert a value from postgresql to rust, it’s similar. Just skip the version number and the leading string can be parsed as the text version:

fn from_binary(ty: &elephantry::pq::Type, raw: Option<&[u8]>) -> elephantry::Result<Self> {
    let mut buf = elephantry::from_sql::not_null(raw)?;

    let _version = elephantry::from_sql::read_i8(&mut buf)?;
    let s = String::from_binary(ty, Some(buf))?;
    Self::from_text(ty, Some(&s))
}

You can retreive the complete code here.


1

ToSql::to_binary is used for Connection::copy function, ToSql::to_text for all other query params. FromSql::from_text is used for Connection::execute and FromSql::from_binary for Connection::query.