corvis-orm
corvis-orm es el ORM del workspace. Está construido sobre sqlx 0.8, soporta PostgreSQL y SQLite, y se integra con la inyección de dependencias de Corvis mediante OrmModule, OrmService y OrmExceptionFilter. Su cliente acepta los argumentos de consulta en JSON (where, include, select, orderBy, ...) o con helpers tipados generados para cada modelo, y es compatible con proyectos que describen su base con un archivo schema.prisma: puede generar los modelos de Rust a partir de él y aplicar sus migraciones SQL.
Este capítulo cubre la integración con Corvis y lo esencial del cliente. La referencia completa del ORM (todos los filtros, escrituras anidadas, SQL crudo, Decimal) está en crates/corvis-orm/README.md.
Instalación
Agrega el crate junto a corvis. La feature corvis (activa por defecto) incluye el módulo para la inyección de dependencias, y tls-rustls (también por defecto) habilita TLS para Postgres:
[dependencies]
corvis = { path = "../../crates/corvis" }
corvis-orm = { path = "../../crates/corvis-orm" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"(Las rutas son las de los ejemplos del repositorio; ajústalas a tu proyecto.)
Modelos
Un modelo es un struct con #[derive(Model)]. Este es el de las salas de examples/tertulia:
use corvis_orm::prelude::*;
#[derive(Debug, Clone, Serialize, Deserialize, Model)]
#[serde(rename_all = "camelCase")]
#[model(table = "rooms")]
pub struct Room {
#[id]
pub name: String,
pub topic: String,
#[default(50)]
pub max_members: i64,
pub created_by: String,
#[default(now)]
pub created_at: corvis_orm::DateTime,
}Y las relaciones, de examples/blog-orm/src/models.rs:
#[derive(Debug, Clone, Serialize, Deserialize, Model)]
#[serde(rename_all = "camelCase")]
#[model(table = "users")]
pub struct User {
#[id]
#[default(autoincrement)]
pub id: i64,
#[unique]
pub email: String,
pub name: Option<String>,
#[default(now)]
pub created_at: DateTime<Utc>,
#[relation(has_many)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub posts: Option<Vec<Post>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Model)]
#[serde(rename_all = "camelCase")]
#[model(table = "posts")]
pub struct Post {
#[id]
#[default(autoincrement)]
pub id: i64,
pub title: String,
pub content: Option<String>,
#[default(false)]
pub published: bool,
#[default(0)]
pub views: i64,
#[default(now)]
pub created_at: DateTime<Utc>,
#[updated_at]
pub updated_at: DateTime<Utc>,
pub author_id: i64,
#[relation(belongs_to, fields = "author_id", on_delete = "Cascade")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<Box<User>>,
}Atributos de campo: #[id], #[unique], #[default(autoincrement | now | uuid | cuid | <literal>)], #[updated_at], #[map("columna")], #[db("Uuid")], #[db_type(...)] y #[relation(has_many | has_one | belongs_to | many_to_many, fields = "...", references = "...", on_delete = "...", on_update = "...", name = "...")]. En el struct: #[model(table = "...", id(fields = [...]), unique(fields = [...]))].
Las relaciones se declaran como Option porque solo se cargan cuando la consulta las incluye: Option<Vec<T>> para to-many y Option<Box<T>> para to-one. Con skip_serializing_if una relación no incluida no aparece en el JSON.
El derive genera tres cosas por modelo:
impl Model for Usercon los metadatos.- El trait
UserDelegate, que añadedb.user()al cliente. Impórtalo junto con el modelo:use crate::models::{User, UserDelegate};. - El módulo
usercon helpers tipados:user::email::contains(..),user::id::equals(..),user::posts::include(), ...
Generar los modelos desde un esquema
Si ya tienes un schema.prisma, el binario corvis-orm escribe los modelos por ti:
cargo run -q -p corvis-orm --bin corvis-orm -- generate --schema db/schema.prisma --out src/models.rsEl archivo generado no se edita a mano: se vuelve a generar. Opciones: --no-docs (no copia los comentarios ///) y --derive <Trait> (un derive extra para cada modelo, se puede repetir).
Registrar el módulo
OrmModule ofrece tres formas de registrarse. La más habitual lee la URL desde ConfigService con una fábrica asíncrona, como en examples/blog-orm:
use corvis::prelude::*;
use corvis_orm::{OrmModule, OrmModuleOptions};
#[module(
imports = [
ConfigModule::for_root(ConfigModuleOptions { is_global: true, ..Default::default() }),
OrmModule::for_root_async(true, |ctx| async move {
let config = ctx.get::<ConfigService>().await?;
let url: String = config.get_or("DATABASE_URL", "file:./dev.db".to_string());
Ok(OrmModuleOptions::new(url)
.log_queries(config.get_or("LOG_QUERIES", false))
.push::<(User, Post)>())
}),
UsersModule,
PostsModule,
],
)]
pub struct AppModule;| Forma | Uso |
|---|---|
OrmModule::for_root(OrmModuleOptions::new(url).global(true)) | Opciones fijas |
OrmModule::for_root_async(global, |ctx| async move { ... }) | Opciones calculadas; la fábrica puede resolver otros providers |
OrmModule::for_root_from_config(global) | Lee DATABASE_URL de ConfigService (que debe ser global) |
El primer argumento de for_root_async y for_root_from_config indica si el módulo es global. Con true, cualquier módulo puede inyectar OrmService sin importarlo.
Opciones de OrmModuleOptions:
| Método | Efecto |
|---|---|
new(url) | file:./dev.db, sqlite::memory: o postgresql://... |
global(bool) | Registra el módulo como global |
log_queries(bool) | Registra cada consulta SQL con sus parámetros |
max_connections(n) | Tamaño del pool |
explicit_connect(bool) | Conectar en on_module_init (por defecto true) |
transaction_options(..) | maxWait, timeout e isolationLevel por defecto de las transacciones |
push::<(A, B)>() | Crea al arrancar las tablas e índices que falten (útil en desarrollo y pruebas) |
push no modifica ni elimina columnas existentes: para producción usa migraciones.
Usar OrmService
OrmService implementa Deref hacia el cliente, así que los delegados de cada modelo se llaman directamente sobre él:
use corvis_orm::{OrmError, OrmService};
use crate::models::{Room, RoomDelegate};
#[injectable]
pub struct RoomsService {
db: Arc<OrmService>,
events: Arc<EventEmitter>,
}
impl RoomsService {
pub async fn list(&self) -> Result<Vec<Room>, OrmError> {
self.db.room().find_many(json!({ "orderBy": [{ "createdAt": "asc" }, { "name": "asc" }] })).await
}
pub async fn find(&self, name: &str) -> Result<Option<Room>, OrmError> {
self.db.room().find_unique(json!({ "where": { "name": name } })).await
}
}La misma consulta se puede escribir con los helpers tipados. Así pagina examples/blog-orm los posts:
let posts = self
.db
.post()
.find_many(filters)
.order_by(post::created_at::order(SortOrder::Desc))
.skip((page - 1) * limit)
.take(limit)
.include(post::author::include())
.await?;Operaciones disponibles en cada delegado: find_many, find_first, find_unique, find_first_or_throw, find_unique_or_throw, create, create_many, create_many_and_return, update, update_many, update_many_and_return, upsert, delete, delete_many, count, aggregate y group_by. Cada consulta admite .await directo, .exec(), .exec_json() (el JSON tal cual) o .exec_as::<T>().
Transacciones
let (user, post) = db._transaction(|tx| async move {
let user = tx.user().create(json!({ "data": { "email": "x@y.z" } })).await?;
let post = tx.post().create(json!({ "data": { "title": "Hola", "authorId": user.id } })).await?;
Ok::<_, OrmError>((user, post))
}).await?;La closure hace commit si devuelve Ok y rollback si devuelve Err. _transaction_with(opciones, ..) acepta TransactionOptions::new().max_wait(ms).timeout(ms) o el nivel de aislamiento, y _transaction_batch((a, b)) ejecuta varias consultas en una sola transacción. Los métodos llevan _ delante porque reproducen las funciones con $ del cliente JavaScript de referencia, y $ no es válido en un identificador de Rust.
SQL crudo
sql!("SELECT ... WHERE id = {id}") construye una consulta parametrizada: cada {expr} es un parámetro, nunca texto concatenado. Se ejecuta con db._query_raw(..), _query_raw_as::<T>(..) o _execute_raw(..).
Errores HTTP
Las consultas devuelven OrmError, con los códigos P2xxx del cliente de referencia (P2002 clave única duplicada, P2025 registro no encontrado, ...). Hay dos formas de convertirlos en respuestas:
Filtro global. OrmExceptionFilter reconoce los OrmError que llegan como causa de un HttpException y responde con el estado adecuado. Con él basta usar ? en el handler:
// main.rs
app.use_global_filters(OrmExceptionFilter::new()); // P2002 → 409, P2025 → 404, validación → 400, resto → 500
// controlador
#[get(":id")]
async fn find_one(&self, #[param("id", ParseIntPipe)] id: i64) -> HttpResult<User> {
Ok(self.users_service.find_one(id).await?)
}OrmExceptionFilter::new().map_code("P2000", 422) cambia el estado de un código concreto.
Conversión explícita. El trait OrmResultExt añade .http(), que devuelve directamente el HttpException adecuado sin depender del filtro:
use corvis_orm::OrmResultExt;
#[post]
async fn create(&self, #[body] dto: CreateUserDto) -> HttpResult<User> {
self.users_service.create(dto).await.http()
}No existe impl From<OrmError> for HttpException: Corvis ya convierte cualquier std::error::Error en un 500, y Rust no permite las dos implementaciones a la vez. Por eso ? produce un 500 con el error como causa, y el filtro lo reinterpreta.
Migraciones
El binario aplica las migraciones SQL de una carpeta (<carpeta>/<nombre>/migration.sql) y lleva el registro en la misma tabla de control que usan los proyectos con schema.prisma, así que una base migrada con la herramienta original se puede seguir migrando con corvis-orm, y al revés:
cargo run -q -p corvis-orm --bin corvis-orm -- migrate deploy --migrations db/migrations --url "$DATABASE_URL"
cargo run -q -p corvis-orm --bin corvis-orm -- migrate status --migrations db/migrations --url "$DATABASE_URL"Desde código: db.migrate_deploy(dir).await y db.migrate_status(dir).await. No hay comandos para generar migraciones a partir de cambios en los modelos.
Seguridad
- En producción con Postgres usa
sslmode=verify-fullen la URL;prefer(el valor por defecto) no protege contra un atacante en la red. - Pasar el JSON de una petición directamente a una consulta le da al cliente todos los operadores,
selecteinclude. Construye los argumentos con los campos que la ruta permite o valida el cuerpo con un schema estricto. - Los argumentos con más de 125 niveles de anidamiento se rechazan antes de procesarse.
log_queriesescribe los valores de los parámetros en los logs.
Qué no está soportado
MySQL, SQL Server, MongoDB y CockroachDB; generación de migraciones (migrate dev, diff, reset); middlewares y extensiones del cliente; SQL crudo dentro de _transaction_batch.