ORM com Diesel: schema e migrations

1. Introdução ao Diesel e configuração do projeto

Diesel é o ORM (Object-Relational Mapping) mais maduro e performático do ecossistema Rust. Ele se destaca por oferecer type safety em tempo de compilação — se sua query está errada, o código nem compila — e zero-cost abstractions, garantindo que o overhead seja mínimo em relação a SQL puro.

Para começar, adicione as dependências no Cargo.toml:

[dependencies]
diesel = { version = "2.1", features = ["postgres"] }
dotenvy = "0.15"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

[dependencies.axum]
version = "0.7"
features = ["macros"]

[dev-dependencies]
tokio = { version = "1", features = ["full"] }

Instale o Diesel CLI globalmente:

cargo install diesel_cli --no-default-features --features postgres

Crie o arquivo .env na raiz do projeto:

DATABASE_URL=postgres://user:password@localhost/diesel_demo

Inicialize o projeto Diesel:

diesel setup

Esse comando cria o banco de dados (se não existir) e o diretório migrations/.

2. Criação e gerenciamento de migrations

Cada migration é composta por dois arquivos: up.sql (aplicação) e down.sql (rollback). Para criar uma migration de usuários:

diesel migration generate create_users

Isso gera migrations/2024-01-01-120000_create_users/up.sql e seu down.sql. No up.sql:

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

No down.sql:

DROP TABLE IF EXISTS users;

Aplique a migration:

diesel migration run

Para reverter: diesel migration redo. Boas práticas: use nomes descritivos como add_role_to_users e mantenha migrations atômicas (uma alteração por migration).

3. Schema gerado automaticamente

Após rodar diesel migration run, o Diesel gera automaticamente src/schema.rs:

// @generated automatically by Diesel CLI.

diesel::table! {
    users (id) {
        id -> Int4,
        name -> Varchar,
        email -> Varchar,
        created_at -> Nullable<Timestamp>,
    }
}

Esse arquivo não deve ser editado manualmente. Para regenerá-lo:

diesel print-schema > src/schema.rs

O mapeamento de tipos é direto: VARCHAR vira String, INTEGER vira i32, TIMESTAMP vira chrono::NaiveDateTime. O Diesel suporta tipos complexos como JSONB via feature flags.

4. Definição de modelos e queries básicas

Crie src/models.rs:

use diesel::prelude::*;
use chrono::NaiveDateTime;
use crate::schema::users;

#[derive(Queryable, Selectable, Debug)]
#[diesel(table_name = users)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct User {
    pub id: i32,
    pub name: String,
    pub email: String,
    pub created_at: Option<NaiveDateTime>,
}

#[derive(Insertable)]
#[diesel(table_name = users)]
pub struct NewUser {
    pub name: String,
    pub email: String,
}

Agora, operações CRUD em src/repository.rs:

use diesel::prelude::*;
use crate::models::{User, NewUser};
use crate::schema::users;

pub fn create_user(conn: &mut PgConnection, name: &str, email: &str) -> QueryResult<User> {
    let new_user = NewUser {
        name: name.to_string(),
        email: email.to_string(),
    };

    diesel::insert_into(users::table)
        .values(&new_user)
        .returning(User::as_returning())
        .get_result(conn)
}

pub fn find_user_by_email(conn: &mut PgConnection, email: &str) -> QueryResult<Option<User>> {
    users::table
        .filter(users::email.eq(email))
        .first(conn)
        .optional()
}

pub fn list_users(conn: &mut PgConnection) -> QueryResult<Vec<User>> {
    users::table
        .order(users::created_at.desc())
        .limit(10)
        .load(conn)
}

5. Migrations avançadas: alterações e rollbacks

Crie uma migration para adicionar coluna role:

diesel migration generate add_role_to_users

up.sql:

ALTER TABLE users ADD COLUMN role VARCHAR(50) NOT NULL DEFAULT 'user';
CREATE INDEX idx_users_role ON users(role);

down.sql:

DROP INDEX IF EXISTS idx_users_role;
ALTER TABLE users DROP COLUMN IF EXISTS role;

Para adicionar chave estrangeira:

CREATE TABLE posts (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title VARCHAR(255) NOT NULL,
    body TEXT
);

Cuidado com dados existentes: ao adicionar NOT NULL sem default, a migration falhará se houver registros. Sempre forneça um valor padrão ou use SET NOT NULL após preencher os dados.

6. Trabalhando com conexões e transações

Configure pool de conexões em src/db.rs:

use diesel::pg::PgConnection;
use diesel::r2d2::{ConnectionManager, Pool, PooledConnection};
use dotenvy::dotenv;
use std::env;

pub type DbPool = Pool<ConnectionManager<PgConnection>>;

pub fn establish_pool() -> DbPool {
    dotenv().ok();
    let database_url = env::var("DATABASE_URL")
        .expect("DATABASE_URL must be set");
    let manager = ConnectionManager::<PgConnection>::new(database_url);
    Pool::builder()
        .max_size(10)
        .build(manager)
        .expect("Failed to create pool")
}

Transações garantem atomicidade:

use diesel::result::Error;

pub fn create_user_with_post(conn: &mut PgConnection, user_name: &str, post_title: &str) -> Result<(User, Post), Error> {
    conn.transaction(|conn| {
        let user = create_user(conn, user_name, "user@example.com")?;
        let post = create_post(conn, user.id, post_title, "content")?;
        Ok((user, post))
    })
}

7. Integração com Axum e patterns comuns

Em src/main.rs:

use axum::{extract::State, routing::post, Json, Router};
use std::sync::Arc;
use crate::db::DbPool;

mod db;
mod models;
mod repository;
mod schema;

#[derive(serde::Deserialize)]
struct CreateUserRequest {
    name: String,
    email: String,
}

async fn create_user_handler(
    State(pool): State<Arc<DbPool>>,
    Json(body): Json<CreateUserRequest>,
) -> Result<Json<User>, (StatusCode, String)> {
    let mut conn = pool.get().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
    let user = repository::create_user(&mut conn, &body.name, &body.email)
        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
    Ok(Json(user))
}

#[tokio::main]
async fn main() {
    let pool = Arc::new(db::establish_pool());
    let app = Router::new()
        .route("/users", post(create_user_handler))
        .with_state(pool);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Pattern recomendado para projetos maiores:

src/
├── main.rs
├── db.rs           # Pool e conexão
├── models.rs       # Structs Queryable/Insertable
├── schema.rs       # Gerado automaticamente
├── repository.rs   # Queries isoladas
└── handlers.rs     # Lógica dos endpoints

Esse layout separa responsabilidades: models define a estrutura, repository contém a lógica de banco, handlers lida com HTTP, e db gerencia o pool.


Referências