SQLx es una librería asíncrona para trabajar con SQL real desde Rust sin convertirlo todo en un ORM.
La idea es bastante atractiva: escribimos SQL normal, pero las macros de consulta pueden comprobarlo al compilar contra una base de datos real o metadatos preparados. Si nos equivocamos en una columna, podemos enterarnos antes de desplegar.
Añadir SQLx al proyecto
Para un ejemplo sencillo vamos a usar SQLite, porque no necesitamos levantar un servidor aparte.
cargo add sqlx --features sqlite,runtime-tokio
cargo add tokio --features full
cargo add dotenvyTambién instalamos la CLI de SQLx para crear la base de datos y gestionar migraciones:
cargo install sqlx-cli --no-default-features --features sqliteConfigurar DATABASE_URL
SQLx usa la variable DATABASE_URL para saber a qué base de datos conectarse.
Creamos un archivo .env:
DATABASE_URL=sqlite://mi_base.dbY creamos la base de datos:
sqlx database createMigraciones
Una migración es un archivo SQL que describe un cambio en la base de datos.
sqlx migrate add crear_usuariosDentro del archivo generado podemos poner:
CREATE TABLE usuarios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nombre TEXT NOT NULL,
activo BOOLEAN NOT NULL DEFAULT 1
);Aplicamos la migración:
sqlx migrate runCrear un pool de conexiones
En aplicaciones web no abrimos una conexión nueva para cada consulta. Usamos un pool.
use dotenvy::dotenv;
use sqlx::sqlite::SqlitePool;
use std::env;
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
dotenv().ok();
let database_url = env::var("DATABASE_URL")
.expect("Falta DATABASE_URL");
let pool = SqlitePool::connect(&database_url).await?;
println!("Conectado a la base de datos");
Ok(())
}El pool mantiene varias conexiones disponibles y las reutiliza cuando hace falta.
Insertar datos
Con query! escribimos SQL literal:
let nombre = "Ferris";
let resultado = sqlx::query!(
"INSERT INTO usuarios (nombre, activo) VALUES (?, ?)",
nombre,
true
)
.execute(&pool)
.await?;
println!("Filas afectadas: {}", resultado.rows_affected());La ventaja de query! es que SQLx puede comprobar la consulta en compilación si tiene acceso a la base de datos o a los metadatos preparados.
Leer datos
Para mapear filas a un struct usamos query_as!.
struct Usuario {
id: i64,
nombre: String,
activo: bool,
}
let usuarios = sqlx::query_as!(
Usuario,
"SELECT id, nombre, activo FROM usuarios WHERE activo = ?",
true
)
.fetch_all(&pool)
.await?;
for usuario in usuarios {
println!("{}: {}", usuario.id, usuario.nombre);
}Aquí seguimos escribiendo SQL normal, pero Rust recibe datos tipados.
Modo offline
Hay un detalle importante. Para comprobar consultas en compilación, SQLx necesita conocer el esquema de la base de datos.
En local puede conectarse usando DATABASE_URL. En CI o despliegue, eso no siempre es cómodo.
Para esos casos existe el modo offline:
cargo sqlx prepareEso genera una carpeta .sqlx con metadatos de las consultas. Debemos incluirla en el control de versiones para que el proyecto pueda compilar sin tener la base de datos levantada, siempre que esos metadatos estén actualizados y DATABASE_URL no fuerce una comprobación en línea.
En CI suele ser buena idea ejecutar cargo sqlx prepare --check para comprobar que la carpeta .sqlx no se ha quedado vieja.
Usarlo con Axum
El patrón habitual es guardar el pool en el estado de la aplicación:
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use serde::Deserialize;
use sqlx::sqlite::SqlitePool;
use std::sync::Arc;
struct AppState {
db: SqlitePool,
}
#[derive(Deserialize)]
struct CrearUsuario {
nombre: String,
}
async fn crear_usuario(
State(state): State<Arc<AppState>>,
Json(datos): Json<CrearUsuario>,
) -> impl IntoResponse {
let resultado = sqlx::query!(
"INSERT INTO usuarios (nombre) VALUES (?)",
datos.nombre
)
.execute(&state.db)
.await;
match resultado {
Ok(_) => StatusCode::CREATED,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}