SQLx en Rust: queries tipadas en compilación, async, migraciones y PostgreSQL

SQLx es el toolkit de base de datos async para Rust que verifica tus queries SQL contra la base de datos real en tiempo de compilación, antes de ejecutar el código. Si la query tiene un error de sintaxis, referencia una columna que no existe o devuelve un tipo incompatible con el struct de destino, obtienes un error de compilación, no un panic en producción.

Conexión con PgPool y configuración básica

SQLx trabaja con pools de conexiones. PgPool gestiona un conjunto de conexiones a PostgreSQL y las reutiliza entre peticiones. El pool es Clone y Send + Sync, por lo que puede compartirse fácilmente con Axum o Tokio.

// [dependencies]
// sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
// tokio = { version = "1", features = ["full"] }

use sqlx::PgPool;

#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
    let database_url = std::env::var("DATABASE_URL")
        .unwrap_or_else(|_| "postgres://user:pass@localhost/midb".into());

    let pool = PgPool::connect(&database_url).await?;

    // Verificar conexión
    let row: (i32,) = sqlx::query_as("SELECT 1").fetch_one(&pool).await?;
    println!("Conexión OK: {}", row.0);

    // Configurar pool con opciones avanzadas
    let pool_config = sqlx::postgres::PgPoolOptions::new()
        .max_connections(20)
        .min_connections(2)
        .acquire_timeout(std::time::Duration::from_secs(5))
        .connect(&database_url)
        .await?;

    println!("Pool configurado con {} conexiones máximas", pool_config.size());
    Ok(())
}

query_as! y fetch_all: mapeo a structs

La macro query_as! compila la query y verifica que los tipos del struct destino coinciden con los de la query. Para que la verificación funcione en compilación, SQLx necesita acceso a la base de datos durante cargo build (o usa archivos de metadatos generados con cargo sqlx prepare para CI offline).

use sqlx::{PgPool, FromRow};

#[derive(Debug, FromRow)]
struct Usuario {
    id: i64,
    nombre: String,
    email: String,
    activo: bool,
}

async fn listar_usuarios(pool: &PgPool) -> Result<Vec<Usuario>, sqlx::Error> {
    // query_as! verifica la SQL contra la DB en compilación
    sqlx::query_as!(Usuario,
        "SELECT id, nombre, email, activo FROM usuarios WHERE activo = true ORDER BY nombre"
    )
    .fetch_all(pool)
    .await
}

async fn buscar_usuario(pool: &PgPool, id: i64) -> Result<Option<Usuario>, sqlx::Error> {
    sqlx::query_as!(Usuario,
        "SELECT id, nombre, email, activo FROM usuarios WHERE id = $1",
        id
    )
    .fetch_optional(pool)
    .await
}

async fn crear_usuario(pool: &PgPool, nombre: &str, email: &str) -> Result<i64, sqlx::Error> {
    let row = sqlx::query!(
        "INSERT INTO usuarios (nombre, email, activo) VALUES ($1, $2, true) RETURNING id",
        nombre, email
    )
    .fetch_one(pool)
    .await?;

    Ok(row.id)
}

Transacciones

SQLx ofrece transacciones con soporte completo de RAII: si el Transaction sale del scope sin que se haya llamado a commit(), hace rollback automáticamente. Esto garantiza que los errores nunca dejan la base de datos en estado inconsistente.

use sqlx::PgPool;

async fn transferir_saldo(
    pool: &PgPool,
    origen_id: i64,
    destino_id: i64,
    importe: f64,
) -> Result<(), sqlx::Error> {
    let mut tx = pool.begin().await?;

    // Verificar saldo suficiente
    let saldo: (f64,) = sqlx::query_as(
        "SELECT saldo FROM cuentas WHERE id = $1 FOR UPDATE"
    )
    .bind(origen_id)
    .fetch_one(&mut *tx)
    .await?;

    if saldo.0 < importe {
        return Err(sqlx::Error::RowNotFound); // forzar rollback
    }

    // Restar del origen
    sqlx::query!("UPDATE cuentas SET saldo = saldo - $1 WHERE id = $2", importe, origen_id)
        .execute(&mut *tx)
        .await?;

    // Sumar al destino
    sqlx::query!("UPDATE cuentas SET saldo = saldo + $1 WHERE id = $2", importe, destino_id)
        .execute(&mut *tx)
        .await?;

    tx.commit().await?; // sin esto, el Drop haría rollback
    Ok(())
}

Migraciones con sqlx migrate

SQLx incluye un sistema de migraciones integrado. Los archivos de migración son SQL numerado en el directorio migrations/ y se aplican programáticamente al iniciar la aplicación o con el CLI de sqlx.

// Estructura de archivos:
// migrations/
//   0001_crear_usuarios.sql
//   0002_agregar_perfil.sql
//   0003_indices.sql

// migrations/0001_crear_usuarios.sql
-- CREATE TABLE usuarios (
--     id SERIAL PRIMARY KEY,
--     nombre VARCHAR(100) NOT NULL,
--     email VARCHAR(255) UNIQUE NOT NULL,
--     activo BOOLEAN NOT NULL DEFAULT true,
--     creado_en TIMESTAMP WITH TIME ZONE DEFAULT NOW()
-- );

// Aplicar migraciones al iniciar la aplicación:
use sqlx::PgPool;

async fn inicializar_db(pool: &PgPool) -> Result<(), sqlx::Error> {
    sqlx::migrate!("./migrations")
        .run(pool)
        .await?;
    println!("Migraciones aplicadas");
    Ok(())
}

// Comandos CLI de sqlx:
// cargo install sqlx-cli
// sqlx migrate add crear_usuarios    -- crear nuevo archivo de migración
// sqlx migrate run                   -- aplicar pendientes
// sqlx migrate revert                -- revertir la última
// cargo sqlx prepare                 -- generar metadatos offline para CI

La verificación en compilación de SQLx elimina toda una categoría de bugs que en otros lenguajes solo aparecen en producción. El coste es que necesitas acceso a la base de datos durante el desarrollo (o usar los metadatos offline), pero en la práctica ese acceso ya existe en cualquier flujo de desarrollo serio.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP