Enum mapping in Rust and PostgreSQL

Some notes about type conversions between Rust and Postgres with tokio-postgres library, async PostgreSQL client for Rust.

There are two possibilities when mapping from Rust types into types related to some infrastructure:

  • The type mapping from Postgres types into my Rust types.
  • The type mapping from Rust types into Postgres types.

The library used, tokio-postgres provides traits with some basic implementations that can be used to convert the application types into SQL types and the other way round. For example, there is FromSQL, which will automatically transform Rust primitives into PosgreSQL types: a Rust bool will be transformed into a Postgres bool; i64 into bigint, &str or String into text, and so on. It has to be done carefully, as some unintended results may arise if the developer is not sure about how types are converted; but in general it is consistent with the expectations.

The problem comes when it is required to perform type conversion between objects or some SQL types, as a postgres ENUM into a Rust enum. This is a common feature in libraries providing some kind of Object Relational Mapping (ORMs) functionality; thankfully tokio_postgres does not have this ability.

It is important to remark «thankfully»: ORMs are marketed as a way to speed up development for simple tasks; but they bring some issues into the projects, reason why they were called the Vietnam of Computer Science.

The debate about ORMs is long, and is filled with nuances; among others, there are two main points:

  • ORMs force the developer to couple entity and infrastructure layers.
  • ORMs are unable to fill the gap between objects and relational structures except for basic cases. For any non trivial case, optimization performed by SQL is lost and queries are duplicated in order to build the required objects.

Therefore, lacking an ORM and being required to manually map between Rust and Postgres types is not a drawback, but good practice.

Mapping from SQL into Rust

As previously mentioned, tokio-postgres exposes a FromSQL trait with a basic implementation for primitives and some specific types. With this implementation we don't have to worry about text conversion into String, or bigint into i64.

The issue arises when it is required a type conversion between a Postgres enum myenum into some Rust enum MyEnum.

// PostgreSQL
⁠create type myenum as enum('variant_a', 'variant_b');

Postgres type myenum 

// Rust
⁠pub enum MyEnum {
⁠ VariantA,
⁠ VariantB,
⁠}

Rust type MyEnum 

Enum conversions are not supported by tokio-postgres, as this library doesn't provide any Object <-> Relational mapping feature: the system won't know how to map one to the other. This is where the FromSQL trait exposed by the library can be handy to create an own implementation for the required Rust enum.

FromSQL is a trait that exposes four methods, from which two will be useful for this case: from_sql, which will be in charge of actual type conversion, and accepts, which will check if the type conversion should be performed for the current type or not.

pub trait FromSql<'a>: Sized {
⁠ fn from_sql(
⁠ ty: &Type,
⁠ raw: &'a [u8]
⁠ ) -> Result<Self, Box<dyn Error + Sync + Send>>;
⁠ […]
⁠ fn accepts(ty: &Type) -> bool;
⁠}

FromSQL trait

The task is actually easy: from_sql will have to return the correct enum variants, which can be achieved matching against the raw binary string; in the accepts method is possible to perform a check against the name of the enum.

impl FromSql<'_> for MyEnum {
⁠ fn from_sql(
⁠ _sql_type: &Type,
⁠ value: &[u8]
⁠ ) -> Result<Self, Box<dyn Error + Sync + Send>> {
⁠ match value {
⁠ b"variant_a" => Ok(MyEnum::VariantA),
⁠ b"variant_b" => Ok(MyEnum::VariantB),
⁠ _ => Ok(MyEnum::VariantA),
⁠ }
⁠ }

⁠ fn accepts(sql_type: FromSqlType) -> bool {
⁠ sql_type.name() == "myenum"
⁠ }
⁠}

Mapping from myenum Postgres type into MyEnum Rust type

As this implementation is related to Postgres, it can live within the Postgres repository or the database related code, and outside of the entities and domain layer. This way it is avoided coupling domain and infrastructure, being infrastructure who is referencing the domain —when mentioning MyEnum—, and not the other way round.

This implementation will tell tokio-postgres how to perform type conversion between SQL and Rust for this specific type, so everytime a query returns a myenum Postgres type, it will appear in the application as MyEnum.

Mapping from Rust into SQL

The opposite case is when in the database there is a myenum Postgres type that should be exposed to our application. tokio-postgres can perform this type conversion, but this will require a custom implementation of ToSql trait using to_sql and accepts methods:

pub trait ToSql: fmt::Debug {
⁠ fn to_sql(
&self,
⁠ ty: &Type,
⁠ out: &mut BytesMut
⁠ ) -> Result<IsNull, Box<dyn Error + Sync + Send>> where Self: Sized;

⁠ fn accepts(ty: &Type) -> bool where Self: Sized;

⁠ fn to_sql_checked(
⁠ &self,
⁠ ty: &Type,
⁠ out: &mut BytesMut,
⁠ ) -> Result<IsNull, Box<dyn Error + Sync + Send>>;
⁠}

ToSQL trait

It is required to implement to_sql_checked as well, but the types::to_sql_checked! macro can generate this method implementation automatically.

When implementing to_sql the Rust enum variant should be transformed into a &str first, which can be achieved implementing Display for MyEnum in order to map between MyEnum variants and a string.

use std::fmt::{Display, Formatter, Result };

⁠impl Display for MyEnum {
⁠ fn fmt(&self, f: &mut Formatter) -> Result {
⁠ match self {
⁠ MyEnum::VariantA => write!(f, "variant_a"),
⁠ MyEnum::VariantB => write!(f, "variant_b"),
}
⁠ }
⁠}

Implementing Display for MyEnum

After implementing Display for MyEnum it is possible to implement ToSql for MyEnum:

use tokio_postgres::{
⁠ types::{to_sql_checked, ToSql, IsNull, Type},
⁠};
⁠use bytes::BytesMut;
⁠use std::{error::Error, result::Result};

⁠impl ToSql for Role {
⁠ fn to_sql(
⁠ &self,
⁠ ty: &Type,
⁠ out: &mut BytesMut
⁠ ) -> Result<IsNull, Box<dyn Error + Sync + Send>>; {
⁠ format!("{}", self).to_sql(ty, out)
⁠ }

⁠ fn accepts(sql_type: &Type) -> bool {
⁠ sql_type.name() == "myenum"
⁠ }

⁠ to_sql_checked!();
⁠}

Implementing ToSql for MyEnum

⁠With this, every time Postgres receives a Rust MyEnum, it will be converted into Postgres myenum.

Conclusion

There are other cases where we will need to perform some type conversion, as with the Row type that can be used with Rust's trait From to convert between Postgres rows into Rust entity objects. For example, for a given struct Article, the From implementation on the repository may look like this:

// Domain
⁠pub struct Article {
⁠ pub id: String,
⁠ pub title: String,
⁠ pub created_at: DateTime,
⁠}

⁠// Repository
⁠impl From<Row> for Article {
⁠ fn from(row: Row) -> Self {
⁠ Self {
⁠ id: row.get("id"),
⁠ title: row.get("title"),
⁠ created_at: row.get("created_at"),
⁠ }
⁠ }
⁠}

⁠let result = client.query("select * from article").await?;
⁠let articles: Vec<Article> = result.into_iter()
⁠ .map(Article::from)
⁠.collect();

Implementing From<Row> for some object Article

In general this library it is quite straightforward to use, and limits its own features allowing us to manually perform our type conversion.