Adrianistán

Diesel, un ORM para Rust

03/01/2020

Un tipo de librería muy popular en lenguajes dinámicos son los ORM. Los ORM son librerías que hacen de intermediario entre la base de datos y nuestra aplicación, permitiéndonos expresar en el lenguaje de programación deseado las estructuras y datos y procedimientos. Rust, a pesar de ser un lenguaje de programación estático, cuenta con un potente ORM gracias al sistema de macros. Se llama Diesel  y es compatible con PostgreSQL, SQLite y MySQL. Un ejemplo uso de Diesel es este mismo blog, que lo usa para almacenar posts y comentarios en una base de datos PostgreSQL.

Enfoque

Dentro de los ORM existen varios enfoques, por un lado están aquellos que lo único que hacen es hacer accesibles las estructuras de datos desde el lenguaje, realizando las conversiones de tipos. Estos ORM más básicos se suelen llamar data mappers. Diesel soporta esto, pero además soporta un enfoque donde las operaciones también se realizan en Rust y también podemos usar migraciones (a través de una CLI).

Personalmente no uso la CLI, pero para proyectos nuevos puede ser más que interesante. En este tutorial, intentaremos ver todas las formas de usar Diesel.

Creando el proyecto con migraciones

Lo primero será crear un proyecto Rust, con cargo new y añadir Diesel como dependencia en el fichero Cargo.toml. Todo el código lo tenéis aquí por si en algún momento necesitáis referencia. Será el momento de instalar diesel_cli y crear la base de datos.


cargo new diesel-sample
cargo install diesel_cli --no-default-features --features sqlite
diesel setup --database-url db.sqlite3

Se habrá generado un fichero diese.toml y una carpeta migrations vacía. Vamos a crear una migración nueva (init) en SQL.


diesel migration generate init

Habrá generado una carpeta dentro de migrations con dos ficheros up y down. Up sirve para hacer la migración, down para revertirla. El contenido de up.sql será el siguiente:


CREATE TABLE post (
	id INTEGER,
	title TEXT NOT NULL,
	content TEXT NOT NULL,
	PRIMARY KEY(id));

Y el de down.sql


DROP TABLE post;

Podemos ejecutar la migración con:


diesel migration run

Y deshacerla con


diesel migration redo

Además de eso se nos habrá generado un fichero llamado schema.rs, en el lugar especificado por el fichero diesel.toml (normalmente será en src). Este fichero sigue una estructura muy interesante y es el verdadero pegamento entre nuestra aplicación y la base de datos. Las migraciones lo único que hacen es ejecutar el SQL y tomar nota del resultado final para generar el fichero schema.rs, que contiene una macro.

Es decir, ahora podemos seguir, vayas a usar migraciones o no.

Schema.rs

Si usas migraciones no deberías editar este archivo a mano pero si no vas a usar migraciones (sistemas heredados o simplemente tienes otra forma de manejar el SQL) puedes crearlo y editarlo a mano. La sintaxis es sencilla:


table! {
    post (id) {
        id -> Integer,
        title -> Text,
        content -> Text,
    }
}

Por un lado hay un table! por cada tabla en la base de datos. Esta lleva el nombre de la tabla (Post) y su clave primaria (id), a continuación se define un listado de campos y su tipo. Estos tipos no son ni de Rust ni SQL, son tipos que define Diesel.

Otras macros que pueden aparecer en el fichero schema.rs son joinable y allow_tables_to_appear_in_same_query.

Joinable indica que las tablas están relacionadas entre sí y se puede hacer un JOIN. Así pues, en el caso de que hubiese comentarios en la base de datos, y estos tuvieran un campo post_id, lo podríamos relacionar así:


joinable!(comment -> post (post_id));

Normalmente siempre se usa juno con allow_tables_to_appear_in_same_query, que permite que en una misma query se pueda acceder a dos tablas:


allow_tables_to_appear_in_same_query!(post, comment);

Queryable

Ahora podemos definir estructuras en Rust que representen el resultado de una consulta a la base de datos, esto lo podemos hacer con Queryable. La estructura no tiene por qué ser idéntica a la tabla, incluso puede tener menos campos o más resultado de un JOIN. Pero es muy importante que el orden de los campos estructura. Si queremos despreocuparnos podemos usar QueryableByName, pero tendremos que indicar la tabla. Pongámoslo todo junto. Además, primero vamos a meter algunos datos en la base de datos:


sqlite3 db.sqlite3
> INSERT INTO post VALUES (1, "El Guardián entre el Centeno", "El libro de los psicópatas");
> INSERT INTO post VALUES (2, "On the road", "Esencia de la generación beat");

El código es el siguiente:


#[macro_use]
extern crate diesel;

use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use self::schema::*;

mod schema;

#[derive(Queryable)]
struct Post{
    id: i32,
    title: String,
    content: String,
}

fn main() {
    let conn = SqliteConnection::establish("db.sqlite3").unwrap();

    let posts = post::table.load::<Post>(&conn).unwrap();
    for post in posts {
        println!("Title: {}\tContent: {}", post.title, post.content);
    }
}

Lo primero que hay que hacer es importar diesel con macros. Luego cargamos el preludio de Diesel, la conexión con Sqlite y nuestro esquema. Hay unos helpers disponibles bajo el submódulo dsl. Sin embargo, en mi experiencia, la colisión de nombres se vuelve un problema real y es mejor ser más verboso.

Esta selección es muy sencilla, veamos otra que tire de WHERE.

Para añadir condiciones usamos filter. Las condiciones tienen que escribirse con una API fluent, no se pueden usar condiciones igual que en Rust de momento. Por ejemplo, para pedir los posts con ID > 1:


    let posts = post::table
        .filter(post::id.gt(1))
        .load::<Post>(&conn)
        .unwrap();

Donde gt significa "greater than" y es el equivalente a >.

SELECT y JOIN

La API de Diesel admite más opciones por supuesto. Podemos elegir los campos que queremos que se carguen de la base de datos. Evidentemente, la estructura a la que carguemos los datos deberá soportarlo. De hecho, si solo seleccionamos una columna podremos ahorrarnos este paso e ir directamente con tipos primitivos.


    let posts = post::table
        .select(post::title)
        .load::<String>(&conn)
        .unwrap();
    for post_title in posts {
        println!("Title: {}", post_title);
    }

Para realizar JOIN, es necesario que las tablas estén relacionadas por joinable y una vez hecho eso, podemos hacer el join directamente, sin especificar IDs ni claves foráneas, simplemente agregando inner_join a la petición.

Insertable

¿Y si queremos insertar algo en la base de datos? Diesel nos ofrece Insertable, para poder volcar estructuras en las base de datos. Su uso es muy similar a Queryable pero añadiendo el nombre de la tabla. De hecho, en el ejemplo que voy a poner, PostInsert y Post podrían ser la misma estructura pero lo voy a separar. Al igual que en SQL, no tenemos que proveer todos los campos si el esquema lo permite. Añadimos los datos con insert_into.


#[derive(Insertable)]
#[table_name="post"]
struct PostInsert{
    id: i32,
    title: String,
    content: String,
}

...

let new_post = PostInsert {
        id: 5,
        title: "Test".to_string(),
        content: "Lorem Ipsum".to_string(),
    };

    diesel::insert_into(post::table)
        .values(&new_post)
        .execute(&conn);

SQL

Diesel nos permite escribir queries en SQL directamente también. Para ello tenemos sql_query. Las estructuras donde obtendremos la información deben tener QueryableByName, esto es debido a que Queryable hace la traducción simplemente por el orden de los elementos, QueryableByName la hace teniendo en cuenta el nombre de las columnas.


#[derive(QueryableByName)]
#[table_name="post"]
struct PostRaw {
    id: i32,
    title: String,
}
...
    let posts = diesel::sql_query("SELECT id,title FROM post WHERE id > 0").load::<Post>(&conn).unwrap();
    for post in posts {
        println!("Title: {}\tID: {}", post.title, post.id);
    }

De este modo, Diesel solo realiza las funciones de data mapper y podemos reusar nuestro código SQL o si preferimos SQL, podemos usarlo.

UPDATE y DELETE

Para realizar UPDATE o DELETE, tenemos que seleccionar primero las filas (con find o filter) y después ejecutar.

En el caso de UPDATE, usaremos set para ir seteando los nuevos valores. Para introducir el valor usamos eq (igual):


diesel::update(post::table.find(1))
        .set(post::title.eq("Título actualizado".to_string()))
        .execute(&conn);

Y el DELETE sería similar.


    diesel::delete(post::table.find(1))
        .execute(&conn);

Y con esto ya tendríamos las operaciones básicas de CRUD y alguna más implementadas con Diesel.

Con el paso del tiempo, Diesel ha resultado ser una de las librerías más interesantes del mundo Rust, una librería estable y que nos ahorra mucho trabajo y nos ayuda a encontrar errores. Todo el código del artículo está en GitHub.

Tags: programacion rust tutorial sql orm