Conversión de tipos entre Rust y PostgreSQL

Algunas notas sobre conversión de tipos entre Rust y Postgres usando tokio-postgres, cliente asíncrono de PostgreSQL para Rust.

Hay dos posibilidades de mapeado entre tipos de Rust y tipos relacionados a la infraestructura que se esté usando, por ejemplo Postgres:

  • Conversión de tipos de Postgres a Rust.
  • Conversión de tipos de Rust a Postgres.

Esta librería expone algunos traits con implementaciones básicas que pueden ser usadas para convertir los tipos de nuetra aplicación entre tipos de PostgreSQL, y a la inversa. A modo de ejemplo, está FromSQL, que convertirán primitivos de Rust en tipos de Postgres: un bool de Rust será transformado en un bool de Postgres; i64 en bigint, &str o String en text, etc. Se debe tener cuidado en la conversión, y podemos encontrar algunos resultados indeseados si el usuario no tiene claro cómo los tipos están siendo convertidos; pero en general está bien documentado y es consistente con las expectativas.

El problema surje cuando se precisa realizar una conversión de tipos entre objetos o tipos más complejos, como un enum de Postgres y un enum de Rust. Esta funcionalidad se puede encontrar frecuentemente en librerías que ofrecen alguna variante de Object Relational Mapping. Afortunadamente, tokio_postgres no lo hace.

Es importante remarcar «afortunadamente»: los ORM suelen ser promocionados como una manera de acelerar y facilitar el desarrollo para tareas relativamente sencillas; pero incorporan al código serios problemas, razón por la que se les ha dado en llamar el Vietnam of Computer Science.

El debate sobre las posibilidades y conveniencia del Object Relational Mapping es largo y está lleno de sutilezas. Entre otros encontramos dos problemas principales:

  • Fuerzan al desarrollador a acoplar las entidades y la infraestructura..
  • Son incapaces de traducir consistentemente el espacio entre objetos y estructuras relacionales, a excepción de casos muy sencillos. La optimización proporcionada por SQL se pierde en cualquier caso no trivial, con duplicaciones de queries que pueden suponer un serio problema de eficiencia.

El hecho de que tokio-postgres carezca de funcionalidades ORM y requiera al usuario mapear manualmente entre tipos de Rust y Postgres no sólo no supone un inconveniente, sino que es un indicio de buenas prácticas.

Mapeando de SQL a Rust

Como se ha mencionado, tokio-postgres expone un trait FromSQL con implementaciones básicas para primitivos y algunos tipos espeficos. Con estas implementaciones el usuario no tiene que preocuparse de la conversión entre text y String, o entre bigint y i64.

El problema surge cuando se requiere realizar una conversión entre tipos complejos, como entre un myenum de Postgres y un MyEnum de Rust.

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

Tipo de Postgres myenum 

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

Tipo de Rust MyEnum 

Las conversiones de enums no están implementadas en tokio-postgres, ya que esta librería no ofrece ninguna funcionalidad de mapeado Object <-> Relational: aquí es donde es útil el trait FromSQL expuesto por esta librería, y que puede ser usado por el usuario para crear una implementación propia para el enum MyEnum de Rust.

El trait FromSQL expone cuatro métodos, de los cuales se pueden usar dos para crear la implementación requerida: from_sql, que se encargará de la conversión entre tipos, y accepts, que comprobará si la conversión debe ser realizada para un tipo determinado.

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;
⁠}

Trait FromSQL

Se trata de una tarea sencilla: from_sql deberá producir las variantes correctas del enum, que se podrán obtener haciendo match contra el raw string binario; en accepts podemos comprobar que el enum que vamos a convertir tiene el nombre correcto.

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

Como se trata de una funcionalidad directamente relacionada con Postgres, puede localizarse en el repositorio o módulo acoplado a la base de datos, manteniéndolo fuera de la capa de dominio. De este modo se evita crear una dependencia de dominio hacia infraestructura, siendo esta última quien referencia al dominio —cuando menciona MyEnum por ejemplo—, y no a la inversa.

Esta implementacion describe a la librería tokio-postgres cómo realizar la conversión de tipos entre SQL y Rust para este tipo específico, de modo que siempre que una query devuelva un tipo myenum de Postgres aparecerá en la aplicación como MyEnum.

Mapeando de Rust a SQL

Para realizar la conversión inversa, de Rust MyEnum a Postgres myenum, se puede implementar el trait ToSql usando los métodos to_sql y accepts:

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

Además hay que implementar to_sql_checked, pero el macro types::to_sql_checked! puede generar esta implementacion automaticamente.

Cuando implementamos to_sql las variantes de MyEnum tienen que ser transformadas a &str antes. Para ello hay que primero implementar Display para MyEnum, de modo que podamos posteriormente implementar ToSQL.

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

Una vez implementado Display para MyEnum es posible implementarToSql , igualmente para 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

De esta manera siempre que Postgres reciba un enum de Rust MyEnum, éste será convertido en un enum de Postgresmyenum.

Conclusión

Cabe mencionar otros casos donde será necesario realizar conversiones entre tipos, como sucede con el tipo Row, que podrá ser usado con el trait From de Rust para convertir entre filas de Postgres y objetos de Rust de la aplicación.

Por ejemplo, para un determinado struct Article, la implementación de From para este struct tendrá este aspecto:

// 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

Como se puede apreciar en general esta librería es bastante sencilla de usar siempre que se consulten los traits expuestos, ofreciendo unas funcionalidades bien delimitadas y facilitando al usuario extenderlas implementando estos traits.