¿Qué es?
entity-derive es una macro procedural de Rust que genera una capa de dominio completa a partir de una única definición de entidad. No es solo CRUD — es un framework arquitectónico con eventos, hooks, comandos y filtrado type-safe.
El Problema
Un backend Rust típico con ~10 entidades significa:
| Componente | Líneas de Código | Problemas |
|---|---|---|
| DTOs (Create, Update, Response) | ~60 por entidad | Sincronización manual, campos olvidados |
| Repository trait + impl | ~150 por entidad | Errores SQL en runtime, copy-paste |
| Mapeo Entity ↔ DTO | ~40 por entidad | Fugas de datos (password_hash en Response) |
| Validación y hooks | Dispersos en servicios | Duplicación, sin fuente única |
| Eventos/auditoría | Ausentes o ad-hoc | Sin historial de cambios |
Total: ~2500 líneas de boilerplate para 10 entidades. Y cada cambio de esquema requiere ediciones manuales en 5+ lugares.
La Solución
#[derive(Entity)]
#[entity(table = "users", events, hooks, commands)]
#[command(Register)]
#[command(Deactivate, requires_id)]
pub struct User {
#[id]
pub id: Uuid,
#[field(create, update, response)]
#[filter(like)]
pub email: String,
#[field(skip)] // Nunca se filtra a la API
pub password_hash: String,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
}
15 líneas → capa de dominio completa:
CreateUserRequest,UpdateUserRequest,UserResponseUserRepositorycon SQL type-safeUserEvent::Created,Updated,DeletedUserHookspara lógica de negocio- Comandos
RegisterUser,DeactivateUser UserQuerypara filtrado
Simple para Principiantes
Ejemplo mínimo — 10 líneas:
#[derive(Entity)]
#[entity(table = "posts")]
pub struct Post {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub title: String,
#[field(create, update, response)]
pub content: String,
}
Listo. Tienes:
CreatePostRequest,UpdatePostRequest,PostResponsePostRepositoryconcreate(),find_by_id(),update(),delete(),list()- SQL type-safe para PostgreSQL
- Todo funciona de inmediato
Sin magia. Ejecuta cargo expand — verás exactamente el código que escribirías tú mismo. Solo que sin errores y en segundos.
¿Por qué Eventos?
Problema: Las aplicaciones CRUD no tienen historial. ¿Quién cambió el registro? ¿Cuándo? ¿Qué había antes? La auditoría requiere infraestructura separada y disciplina.
Solución: #[entity(events)] genera eventos tipados:
pub enum UserEvent {
Created(User),
Updated { id: Uuid, changes: UpdateUserRequest },
Deleted(Uuid),
}
Beneficios:
- Auditoría de serie — suscríbete a eventos, guarda en log
- Event Sourcing — puedes restaurar estado desde historial de eventos
- Integraciones — Kafka, notificaciones WebSocket, invalidación de caché
- Depuración — historial completo de cambios para cada entidad
¿Por qué Hooks?
Problema: La lógica de negocio está dispersa. Validación de email — en controlador. Hashing de contraseña — en servicio. Envío de email — en worker separado. ¿Dónde buscar la lógica de creación de usuario?
Solución: #[entity(hooks)] centraliza el ciclo de vida:
impl UserHooks for MyHooks {
async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Error> {
dto.email = dto.email.to_lowercase(); // Normalización
validate_email(&dto.email)?; // Validación
Ok(())
}
async fn after_create(&self, user: &User) -> Result<(), Error> {
self.mailer.send_welcome(user).await?; // Acción de negocio
Ok(())
}
}
Beneficios:
- Lugar único — toda la lógica de entidad junto a la definición
- Predecibilidad — claro cuándo se ejecuta qué
- Testabilidad — los hooks se pueden mockear y probar aisladamente
- Composición — diferentes implementaciones para diferentes contextos
¿Por qué Comandos?
Problema: La API REST oculta la intención. POST /users — ¿es registro? ¿Creación por admin? ¿Importación CSV? PATCH /users/123 — ¿desactivación? ¿Cambio de email? ¿Ban?
Solución: #[command(...)] expresa el dominio de negocio:
#[command(Register)] // Auto-registro
#[command(Invite)] // Invitación por admin
#[command(Deactivate, requires_id)] // Desactivación de cuenta
#[command(Ban, requires_id)] // Ban por violaciones
Compara:
// CRUD (¿qué está pasando?)
pool.update(user_id, UpdateUserRequest { active: Some(false), ..default() }).await?;
// Comandos (intención clara)
handler.handle(DeactivateUser { id: user_id }).await?;
Beneficios:
- API auto-documentada — nombres de comandos = vocabulario de negocio
- Lógica diferente —
DeactivateyBanpueden tener diferentes side-effects - CQRS listo — comandos fáciles de enrutar, loguear, reintentar
- Type safety — el compilador verifica que el comando existe
¿Por qué Filtrado Type-Safe?
Problema: Los query params de string son fuente de errores en runtime:
GET /users?stauts=active // Typo — ignorado silenciosamente
GET /users?created_at=tomorrow // Fecha inválida — panic en runtime
Solución: #[filter] genera struct tipado:
let query = UserQuery {
email: Some("@company.com".into()), // ILIKE '%@company.com%'
created_at_min: Some(week_ago), // >= week_ago
created_at_max: Some(now), // <= now
..Default::default()
};
let users = pool.list_filtered(&query, 100, 0).await?;
Beneficios:
- Verificación en tiempo de compilación — typo en nombre de campo = error de compilación
- Type safety — no puedes comparar
DateTimeconString - Autocompletado — el IDE sugiere filtros disponibles
- Protección contra SQL injection — parámetros se bindean, no se concatenan
Transparencia
La macro no oculta lógica. Todo lo generado es código Rust normal que puedes:
- Leer —
cargo expandmuestra todo el código generado - Entender — sin reflection en runtime, solo structs y traits
- Sobrescribir —
sql = "trait"y escribe tu propio SQL - Depurar — errores del compilador apuntan a tu código, no a internos de la macro
// ¿Quieres entender qué se genera?
cargo expand --lib | grep -A 50 "impl UserRepository"
Zero magic. Si la macro falla — siempre puedes escribir código a mano. Sin lock-in.
Todo el Poder de Rust
Garantías en Tiempo de Compilación
#[field(skip)]
pub password_hash: String,
Esto no es una verificación en runtime “no serialices este campo”. Es la ausencia física del campo en la struct UserResponse. No puedes devolverlo accidentalmente — el campo simplemente no existe.
Abstracciones Zero-cost
El código generado es:
structregular sin Box/dyn- Llamadas directas a sqlx sin capas intermedias
#[inline]en hot paths- Sin allocaciones más allá de lo necesario
Benchmark: el repositorio generado corre a la misma velocidad que el escrito a mano. Porque es el mismo código.
Async de Serie
// Todo async, todo Send + Sync
let user = pool.find_by_id(id).await?;
let users = pool.list(100, 0).await?;
Compatibilidad total con tokio, async-std, cualquier runtime async.
Tipado Estricto
// Error de compilación: no existe ese campo
let query = UserQuery { naem: "test".into(), ..default() };
^^^^ unknown field
// Error de compilación: tipo incorrecto
let query = UserQuery { created_at_min: "yesterday".into(), ..default() };
^^^^^^^^^^^^ expected DateTime<Utc>
Si el código compila — funciona correctamente.
Arquitectura Profesional
Clean Architecture Listo
Domain Layer (entity-derive)
├── Entities — #[derive(Entity)]
├── DTOs — CreateRequest, UpdateRequest, Response
├── Repository Trait — abstracción de almacenamiento
├── Events — eventos de dominio
├── Commands — operaciones de negocio
└── Hooks — lógica de ciclo de vida
Infrastructure Layer (tu código)
├── Repository Impl — PgPool automático o custom
├── Event Handlers — suscripciones a eventos
├── Command Handlers — implementación de lógica de negocio
└── External Services — integraciones
Separación limpia. Domain no sabe nada de HTTP, base de datos, Kafka. Esos son detalles de implementación.
CQRS/Event Sourcing Listo
// Command side
handler.handle(RegisterUser { email, name }).await?;
// Query side
let users = pool.list_filtered(&query, 100, 0).await?;
// Event side
match event {
UserEvent::Created(user) => kafka.send("user.created", &user).await?,
UserEvent::Updated { id, changes } => audit_log.record(id, changes).await?,
_ => {}
}
¿Quieres CRUD simple? Lo tienes. ¿Quieres CQRS completo? Activa commands y events. La arquitectura crece con el proyecto.
Extensibilidad
Nivel 1: CRUD Básico
#[entity(table = "users")]
Nivel 2: + Filtrado
#[entity(table = "users")]
// + #[filter] en campos
Nivel 3: + Eventos y Hooks
#[entity(table = "users", events, hooks)]
Nivel 4: + Comandos CQRS
#[entity(table = "users", events, hooks, commands)]
#[command(Register)]
#[command(Deactivate, requires_id)]
Nivel 5: Control Total
#[entity(table = "users", sql = "trait", events, hooks, commands)]
// Tu SQL, tu lógica, pero manteniendo todos los DTOs y tipos
Empieza simple. Añade features mientras creces. No reescribas — extiende.
Seguridad
#[field(skip)]
pub password_hash: String,
skip significa: este campo nunca aparecerá en:
CreateUserRequest(no se puede pasar desde fuera)UpdateUserRequest(no se puede modificar vía API)UserResponse(no se puede devolver accidentalmente al cliente)
La única forma de trabajar con password_hash — directamente a través de la entidad en código. Fuga imposible por diseño.
Por Qué Esto es Genial
| Aspecto | Lo Que Obtienes |
|---|---|
| Velocidad de Desarrollo | 10 entidades en una hora en lugar de un día |
| Fiabilidad | Verificación en tiempo de compilación de todo |
| Seguridad | Imposible filtrar datos accidentalmente |
| Rendimiento | Zero-cost, como código escrito a mano |
| Claridad | Generación transparente, sin magia |
| Flexibilidad | De CRUD simple a CQRS con un atributo |
| Escalabilidad | La arquitectura crece con el proyecto |
| Mantenibilidad | Una sola fuente de verdad, menos bugs |
Documentación
| Tema | Descripción |
|---|---|
| [[Atributos|Atributos]] | Referencia completa de atributos |
| [[Filtrado|Filtrado]] | Filtrado de consultas type-safe |
| [[Relaciones|Relaciones]] | belongs_to y has_many |
| [[Eventos|Eventos]] | Eventos de ciclo de vida |
| [[Hooks|Ganchos]] | Hooks before/after |
| [[Comandos|Comandos]] | Patrón CQRS |
| [[SQL Personalizado|SQL-Personalizado]] | Consultas complejas |
| [[Ejemplos|Ejemplos]] | Casos de uso reales |
| [[Web Frameworks|Frameworks-Web]] | Integración con Axum, Actix |
| [[Mejores Prácticas|Mejores-Prácticas]] | Guías para producción |
Esto no es un framework que dicta cómo vivir. Es una herramienta que elimina la rutina y te permite construir correctamente.
Atributos
Guía completa de todos los atributos soportados por entity-derive.
Atributos a Nivel de Entidad
Se aplican a la estructura con #[entity(...)]:
#[derive(Entity)]
#[entity(
table = "users",
schema = "core",
sql = "full",
dialect = "postgres",
uuid = "v7",
soft_delete,
returning = "full",
error = "AppError",
events,
hooks,
commands
)]
pub struct User { /* ... */ }
Referencia Rápida
| Atributo | Requerido | Por Defecto | Descripción |
|---|---|---|---|
table | Sí | — | Nombre de la tabla en BD |
schema | No | "public" | Esquema de BD |
sql | No | "full" | Nivel de generación SQL |
dialect | No | "postgres" | Dialecto de BD |
uuid | No | "v7" | Versión UUID para generación de ID |
soft_delete | No | false | Habilitar borrado lógico |
returning | No | "full" | Modo de cláusula RETURNING |
upsert(...) | No | — | Genera un método upsert con INSERT ... ON CONFLICT |
api(guard = "...") | No | — | Guard FromRequestParts aplicado en los handlers generados |
error | No | sqlx::Error | Tipo de error personalizado |
events | No | false | Generar eventos de ciclo de vida |
hooks | No | false | Generar trait de hooks |
commands | No | false | Habilitar patrón CQRS |
table (requerido)
Nombre de la tabla en base de datos.
#[entity(table = "users")] // → FROM users
#[entity(table = "user_profiles")] // → FROM user_profiles
schema (opcional)
Esquema de base de datos. Por defecto: "public".
#[entity(table = "users")] // → FROM public.users
#[entity(table = "users", schema = "core")] // → FROM core.users
#[entity(table = "users", schema = "auth")] // → FROM auth.users
sql (opcional)
Nivel de generación SQL. Por defecto: "full".
| Valor | Trait Repository | Impl PgPool | Caso de Uso |
|---|---|---|---|
"full" | Sí | Sí | Entidades CRUD estándar |
"trait" | Sí | No | Consultas personalizadas (joins, CTEs) |
"none" | No | No | Solo DTOs, sin base de datos |
#[entity(table = "users", sql = "full")] // Automatización completa (defecto)
#[entity(table = "users", sql = "trait")] // Solo trait, implementar SQL manualmente
#[entity(table = "users", sql = "none")] // Sin capa de base de datos
dialect (opcional)
Dialecto de BD para generación SQL. Por defecto: "postgres".
| Dialecto | Alias | Tipo Cliente | Estado |
|---|---|---|---|
"postgres" | "pg", "postgresql" | sqlx::PgPool | Estable |
"clickhouse" | "ch" | clickhouse::Client | Planificado |
"mongodb" | "mongo" | mongodb::Client | Planificado |
uuid (opcional)
Versión UUID para claves primarias auto-generadas. Por defecto: "v7".
| Versión | Método | Propiedades |
|---|---|---|
"v7" | Uuid::now_v7() | Ordenado por tiempo, ordenable (recomendado) |
"v4" | Uuid::new_v4() | Aleatorio, ampliamente compatible |
#[entity(table = "users", uuid = "v7")] // Ordenado por tiempo (defecto)
#[entity(table = "sessions", uuid = "v4")] // UUID aleatorio
¿Por qué UUID v7?
- Ordenado por tiempo: ordenamiento natural por fecha de creación
- Mejor rendimiento de índices en BD
- No requiere coordinación (a diferencia de secuencias)
- Globalmente único en sistemas distribuidos
soft_delete (opcional)
Habilita borrado lógico para marcar registros como eliminados en lugar de borrarlos.
#[derive(Entity)]
#[entity(table = "documents", soft_delete)]
pub struct Document {
#[id]
pub id: Uuid,
#[field(create, response)]
pub title: String,
#[field(skip)]
pub deleted_at: Option<DateTime<Utc>>, // Campo requerido
}
Métodos generados:
delete()— Establecedeleted_at = NOW()en lugar de DELETEhard_delete()— Elimina permanentemente el registrorestore()— Establecedeleted_at = NULLfind_by_id()/list()— Filtran automáticamente registros eliminadosfind_by_id_with_deleted()/list_with_deleted()— Incluyen registros eliminados
returning (opcional)
Controla qué datos se obtienen después de INSERT/UPDATE. Por defecto: "full".
| Modo | Cláusula SQL | Caso de Uso |
|---|---|---|
"full" | RETURNING * | Obtener todos los campos incluyendo los generados por BD |
"id" | RETURNING id | Confirmar inserción, devolver entidad pre-construida |
"none" | (sin RETURNING) | Fire-and-forget, opción más rápida |
"col1, col2" | RETURNING col1, col2 | Devolver columnas específicas |
#[entity(table = "logs", returning = "none")] // Más rápido
#[entity(table = "users", returning = "full")] // Obtener valores generados
#[entity(table = "events", returning = "id, created_at")] // Columnas personalizadas
events(outbox) (opcional)
Entrega duradera de eventos mediante un outbox transaccional. events solo genera el enum; con streams, NOTIFY es fire-and-forget. events(outbox) hace que cada escritura generada inserte el evento serializado en la tabla entity_outbox dentro de la misma transacción, y el runtime OutboxDrainer (entity-core, feature outbox) entrega las filas con FOR UPDATE SKIP LOCKED, backoff exponencial y aparcamiento tras max_attempts. At-least-once — los handlers deben ser idempotentes. Se combina con streams.
#[derive(Entity, Serialize, Deserialize)]
#[entity(table = "orders", events(outbox), migrations)]
pub struct Order { /* ... */ }
sqlx::query(Order::MIGRATION_OUTBOX).execute(&pool).await?;
struct Notifier;
#[async_trait::async_trait]
impl entity_core::outbox::OutboxHandler for Notifier {
type Error = anyhow::Error;
async fn handle(&self, row: &OutboxRow) -> Result<(), Self::Error> {
deliver(&row.entity, &row.payload).await
}
}
entity_core::outbox::OutboxDrainer::new(pool, Notifier).run().await;
upsert(...) (opcional)
Genera un método upsert del repositorio basado en INSERT ... ON CONFLICT.
#[derive(Entity)]
#[entity(table = "users", upsert(conflict = "email"))]
pub struct User {
#[id]
pub id: Uuid,
#[field(create, response)]
#[column(unique)]
pub email: String,
#[field(create, update, response)]
pub name: String,
}
| Opción | Requerida | Por defecto | Descripción |
|---|---|---|---|
conflict | Sí | — | Columnas de conflicto separadas por comas |
action | No | "update" | "update" (DO UPDATE) o "nothing" (DO NOTHING) |
Generado:
action = "update"→async fn upsert(&self, dto: CreateUserRequest) -> Result<User, Error>— sobrescribe todas las columnas no conflictivas (DO UPDATE SET col = EXCLUDED.col) y devuelve la fila persistidaaction = "nothing"→async fn upsert(&self, dto: CreateUserRequest) -> Result<Option<User>, Error>— mantiene la fila existente;Nonesignifica que ya existía una fila en conflicto
Validación en tiempo de compilación:
- las columnas de conflicto deben existir y tener garantía de unicidad (
#[id],#[column(unique)]o ununique_index(...)coincidente) - requiere
returning = "full"(el valor por defecto) action = "update"necesita al menos una columna actualizable fuera del conflicto
Con streams habilitado, upsert publica una notificación Created por cada fila devuelta.
api(guard = "...") (opcional)
Aplica autenticación en los handlers generados. security = "..." solo documenta la autenticación en OpenAPI; guard inyecta un extractor axum (FromRequestParts) como primer argumento de cada handler CRUD y de comandos generado — una extracción fallida rechaza la petición antes de ejecutar el cuerpo del handler.
pub struct RequireAuth;
impl<S: Send + Sync> FromRequestParts<S> for RequireAuth {
type Rejection = StatusCode;
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
parts.headers.contains_key("authorization")
.then_some(Self)
.ok_or(StatusCode::UNAUTHORIZED)
}
}
#[derive(Entity)]
#[entity(table = "users", api(tag = "Users", handlers, guard = "RequireAuth", guard(list = "none")))]
pub struct User { /* ... */ }
Overrides por operación: guard(create = "Admin", list = "none", ...) con operaciones create, get, update, delete, list, commands; el literal "none" desactiva el guard. Los comandos en public = [...] nunca reciben guard.
error (opcional)
Tipo de error personalizado para repositorio. Por defecto: sqlx::Error.
#[derive(Debug)]
pub enum AppError {
Database(sqlx::Error),
NotFound,
Validation(String),
}
impl std::error::Error for AppError {}
impl std::fmt::Display for AppError { /* ... */ }
// Requerido: convertir desde sqlx::Error
impl From<sqlx::Error> for AppError {
fn from(err: sqlx::Error) -> Self {
AppError::Database(err)
}
}
#[derive(Entity)]
#[entity(table = "users", error = "AppError")]
pub struct User { /* ... */ }
// El repositorio generado usa AppError:
// impl UserRepository for PgPool {
// type Error = AppError;
// ...
// }
events (opcional)
Genera enum de eventos del ciclo de vida. Ver [[Eventos|Eventos]] para detalles.
#[entity(table = "orders", events)]
Generado:
pub enum OrderEvent {
Created(Order),
Updated { id: Uuid, changes: UpdateOrderRequest },
Deleted(Uuid),
}
hooks (opcional)
Genera trait de hooks del ciclo de vida. Ver [[Ganchos|Hooks]] para detalles.
#[entity(table = "users", hooks)]
Generado:
#[async_trait]
pub trait UserHooks: Send + Sync {
type Error: std::error::Error + Send + Sync;
async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error>;
async fn after_create(&self, entity: &User) -> Result<(), Self::Error>;
async fn before_update(&self, id: &Uuid, dto: &mut UpdateUserRequest) -> Result<(), Self::Error>;
async fn after_update(&self, entity: &User) -> Result<(), Self::Error>;
async fn before_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
}
commands (opcional)
Habilita patrón de comandos CQRS. Ver [[Comandos|Comandos]] para detalles.
#[entity(table = "users", commands)]
#[command(Register)]
#[command(Deactivate, requires_id)]
Atributos a Nivel de Campo
Se aplican a campos individuales.
#[id]
Marca el campo de clave primaria.
Comportamiento:
- Auto-genera UUID (v7 por defecto, configurable con atributo
uuid) - Siempre incluido en DTO
Response - Excluido de
CreateRequestyUpdateRequest
#[id]
pub id: Uuid,
#[auto]
Marca campos auto-generados (timestamps, secuencias).
Comportamiento:
- Obtiene
Default::default()enFrom<CreateRequest> - Excluido de
CreateRequestyUpdateRequest - Puede incluirse en
Responsecon#[field(response)]
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
#[field(...)]
Controla inclusión en DTOs. Combina múltiples opciones:
#[field(create)] // Solo en CreateRequest
#[field(update)] // Solo en UpdateRequest
#[field(response)] // Solo en Response
#[field(create, response)] // En Create y Response
#[field(create, update, response)] // En los tres
#[field(skip)] // Excluido de todos los DTOs
create
Incluye campo en DTO CreateRequest.
#[field(create)]
pub email: String,
// Generado:
pub struct CreateUserRequest {
pub email: String,
}
update
Incluye campo en DTO UpdateRequest.
Importante: Los campos no opcionales se envuelven automáticamente en Option<T> para actualizaciones parciales.
#[field(update)]
pub name: String, // No Option
// Generado:
pub struct UpdateUserRequest {
pub name: Option<String>, // Envuelto automáticamente
}
response
Incluye campo en DTO Response.
#[field(response)]
pub email: String,
// Generado:
pub struct UserResponse {
pub id: Uuid, // Siempre incluido (tiene #[id])
pub email: String, // Incluido
}
skip
Excluye campo de todos los DTOs. Usar para datos sensibles.
#[field(skip)]
pub password_hash: String,
Importante: skip anula todas las demás opciones de campo. El campo solo existirá en:
- Estructura de entidad original
- Estructura
Row(para lecturas de BD) - Estructura
Insertable(para escrituras en BD)
#[column(pg_enum = "...")]
Conecta un enum Postgres (ValueObject) a la generación de DDL.
#[derive(ValueObject, Debug, Clone, Serialize, Deserialize)]
#[value_object(pg_type = "order_status", sqlx)]
pub enum OrderStatus { Pending, Shipped, Delivered }
#[derive(Entity)]
#[entity(table = "orders", migrations)]
pub struct Order {
#[id]
pub id: Uuid,
#[field(create, update, response)]
#[column(pg_enum = "order_status")]
pub status: OrderStatus,
}
for ddl in Order::MIGRATION_TYPES {
sqlx::query(ddl).execute(&pool).await?;
}
sqlx::query(Order::MIGRATION_UP).execute(&pool).await?;
- Define el tipo de columna en el DDL (de lo contrario los campos enum caen a TEXT)
- Registra el DDL idempotente
PG_CREATE_TYPEdel enum en{Entity}::MIGRATION_TYPES— ejecútalos antes deMIGRATION_UP - El nombre declarado se verifica contra la constante
PG_TYPEdel enum en tiempo de compilación; una discrepancia rompe la compilación - El flag opcional
sqlxdeValueObjectgenera implssqlx::Type/Encode/Decode; omítelo si ya derivassqlx::Type
#[owner]
Alcance por propietario a nivel de fila. Marca la columna con el id del propietario; el repositorio obtiene métodos con alcance que nunca revelan si una fila existe para otro propietario y respetan soft_delete.
#[derive(Entity)]
#[entity(table = "orders")]
pub struct Order {
#[id]
pub id: Uuid,
#[owner]
pub user_id: Uuid,
#[field(create, update, response)]
pub note: String,
}
let mine = pool.list_by_owner(user_id, 20, 0).await?;
let order = pool.find_by_id_scoped(id, user_id).await?;
let updated = pool.update_scoped(id, user_id, patch).await?;
let removed = pool.delete_scoped(id, user_id).await?;
Generado: find_by_id_scoped, list_by_owner, update_scoped (cuando hay campos update; None si la fila no es suya), delete_scoped. Como máximo un campo #[owner]; combinarlo con #[id] se rechaza en compilación.
#[filter] / #[filter(...)]
Genera campos de filtro de consulta. Ver [[Filtrado|Filtrado]] para detalles.
#[filter] // Coincidencia exacta: WHERE field = $n
#[filter(eq)] // Igual que arriba
#[filter(like)] // Patrón: WHERE field ILIKE $n
#[filter(range)] // Rango: WHERE field >= $n AND field <= $m
#[belongs_to(Entity)]
Relación de clave foránea. Ver [[Relaciones|Relaciones]] para detalles.
#[belongs_to(User)]
pub user_id: Uuid,
Generado: Método find_user() en repositorio.
#[has_many(Entity)]
Relación uno-a-muchos (nivel de entidad). Ver [[Relaciones|Relaciones]] para detalles.
#[has_many(Post)]
pub struct User { /* ... */ }
Generado: Método find_posts() en repositorio.
#[projection(Name: fields)]
Genera estructura de vista parcial (nivel de entidad).
#[projection(Public: id, name, avatar)]
#[projection(Admin: id, name, email, role)]
pub struct User { /* ... */ }
Generado:
UserPublic { id, name, avatar }UserAdmin { id, name, email, role }- Implementaciones
From<User> - Métodos
find_by_id_public(),find_by_id_admin()
Atributos de Comando
Se aplican a nivel de entidad con #[command(...)].
Referencia Rápida
| Sintaxis | Efecto |
|---|---|
#[command(Name)] | Usa todos los campos #[field(create)] |
#[command(Name: field1, field2)] | Usa solo campos especificados (añade requires_id) |
#[command(Name, requires_id)] | Añade campo ID, sin otros campos |
#[command(Name, source = "create")] | Usa explícitamente campos create (defecto) |
#[command(Name, source = "update")] | Usa campos update (opcionales, añade requires_id) |
#[command(Name, source = "none")] | Sin campos de payload |
#[command(Name, payload = "Type")] | Usa estructura payload personalizada |
#[command(Name, result = "Type")] | Usa tipo de resultado personalizado |
#[command(Name, kind = "create")] | Sugerencia: crea entidad (defecto) |
#[command(Name, kind = "update")] | Sugerencia: modifica entidad |
#[command(Name, kind = "delete")] | Sugerencia: elimina entidad (devuelve ()) |
#[command(Name, kind = "custom")] | Sugerencia: operación personalizada |
Ver [[Comandos|Comandos]] para documentación detallada.
Ejemplo Completo
#[derive(Entity)]
#[entity(
table = "posts",
schema = "blog",
sql = "full",
dialect = "postgres",
uuid = "v7",
soft_delete,
returning = "full",
events,
hooks,
commands
)]
#[has_many(Comment)]
#[projection(Summary: id, title, author_id, created_at)]
#[command(Publish)]
#[command(Archive, requires_id)]
pub struct Post {
#[id]
pub id: Uuid,
#[field(create, update, response)]
#[filter(like)]
pub title: String,
#[field(create, update, response)]
pub content: String,
#[field(create, response)]
#[belongs_to(User)]
#[filter]
pub author_id: Uuid,
#[field(update, response)]
pub published: bool,
#[field(response)]
#[filter(range)]
pub view_count: i64,
#[field(skip)]
pub moderation_notes: String,
#[field(skip)]
pub deleted_at: Option<DateTime<Utc>>,
#[auto]
#[field(response)]
#[filter(range)]
pub created_at: DateTime<Utc>,
#[auto]
#[field(response)]
pub updated_at: DateTime<Utc>,
}
Matriz de Decisiones
| Quiero… | Atributos |
|---|---|
| Auto-generar clave primaria | #[id] |
| Usar UUID aleatorio | uuid = "v4" en entidad |
| Usar UUID ordenado por tiempo | uuid = "v7" (defecto) |
| Aceptar en cuerpo POST | #[field(create)] |
| Aceptar en cuerpo PATCH | #[field(update)] |
| Devolver en respuesta API | #[field(response)] |
| Aceptar y devolver | #[field(create, update, response)] |
| Ocultar de todas las APIs | #[field(skip)] |
| Auto-generar timestamp | #[auto] + #[field(response)] |
| Solo lectura (gestionado por BD) | Solo #[field(response)] |
| Solo escritura (sin retorno) | Solo #[field(create)] |
| Consultas SQL personalizadas | sql = "trait" |
| Solo DTOs, sin BD | sql = "none" |
| Borrado lógico de registros | soft_delete en entidad |
| Tipo de error personalizado | error = "MyError" en entidad |
| Filtrar por valor exacto | #[filter] en campo |
| Filtrar por patrón | #[filter(like)] en campo |
| Filtrar por rango | #[filter(range)] en campo |
| Rastrear cambios de entidad | events en entidad |
| Ejecutar código en ciclo de vida | hooks en entidad |
| Usar comandos de dominio | commands en entidad + #[command(...)] |
| Definir relación | #[belongs_to(Entity)] o #[has_many(Entity)] |
| Vista parcial de entidad | #[projection(Name: fields)] |
DTO de actualización: semántica PATCH
Las actualizaciones generadas son parches parciales reales: la cláusula SET se construye en tiempo de ejecución con los campos realmente presentes; los omitidos no se tocan. Las columnas anulables usan doble Option (None = dejar, Some(None) = poner NULL, Some(Some(v)) = poner v) mediante entity_core::serde_helpers::double_option.
// {} → nothing changes
// {"nickname": null} → nickname = NULL
// {"nickname": "neo"} → nickname = 'neo'
let patch: UpdateProfileRequest = serde_json::from_str(body)?;
let profile = pool.update(id, patch).await?;
Opciones de migrations(...)
Además del flag simple, migrations acepta opciones DDL: touch_updated_at (función plpgsql compartida + trigger BEFORE UPDATE por tabla que mantiene updated_at fresco; requiere un campo updated_at, verificado en compilación), audit (tabla entity_audit_log + trigger con diffs to_jsonb(OLD/NEW)) y extensions = "pg_trgm, pgcrypto" (CREATE EXTENSION idempotente). Nuevas constantes: MIGRATION_TRIGGERS (ejecutar después de MIGRATION_UP) y MIGRATION_EXTENSIONS (antes).
#[entity(table = "articles", migrations(touch_updated_at, audit, extensions = "pg_trgm"))]
pub struct Article { /* ... */ }
for ddl in Article::MIGRATION_EXTENSIONS { sqlx::query(ddl).execute(&pool).await?; }
sqlx::query(Article::MIGRATION_UP).execute(&pool).await?;
for ddl in Article::MIGRATION_TRIGGERS { sqlx::query(ddl).execute(&pool).await?; }
#[version]
Bloqueo optimista. Marca una columna entera (i16/i32/i64); el DTO de actualización gana un expected_version obligatorio, el UPDATE generado incrementa la columna y solo se aplica mientras la versión almacenada coincide — una escritura obsoleta falla con un error de conflicto en lugar de sobrescribir datos más recientes. El DDL usa INTEGER NOT NULL DEFAULT 0. Aplica a updates normales, con alcance y transaccionales.
#[derive(Entity)]
#[entity(table = "orders", migrations)]
pub struct Order {
#[id] pub id: Uuid,
#[field(create, update, response)] pub note: String,
#[version] #[field(response)] #[auto] pub version: i32,
}
let patch = UpdateOrderRequest { note: Some("v2".into()), expected_version: order.version };
let updated = pool.update(order.id, patch).await?;
typed_constraints (opcional)
La macro conoce cada constraint que crea. Con este flag, los métodos de escritura generados resuelven los nombres de constraints violados (columnas únicas, claves foráneas belongs_to, checks de columna, nombres de unique_index) y devuelven entity_core::ConstraintError { kind, constraint, field } en lugar de un error crudo del driver. Requiere un tipo error propio con From<ConstraintError>; sin el flag nada cambia.
#[entity(table = "users", typed_constraints, error = "AppError")]
pub struct User {
#[id] pub id: Uuid,
#[field(create, response)] #[column(unique)] pub email: String,
}
match pool.create(dto).await {
Err(AppError::Constraint(v)) if v.field == Some("email") => conflict_409(),
other => other?,
}
#[embed(prefix = "...", fields(...))]
Aplana un value object en columnas escalares con prefijo. El DDL, la estructura Row, el SQL CRUD y los PATCH dinámicos operan sobre price_amount_cents / price_currency, mientras los DTO y la entidad llevan la estructura misma. La forma declarada se desestructura contra la estructura real en compilación — un campo renombrado, retipado, ausente o extra rompe la compilación. Padres Option<T> aún no soportados.
pub struct Money { pub amount_cents: i64, pub currency: String }
#[derive(Entity)]
#[entity(table = "products", migrations)]
pub struct Product {
#[id] pub id: Uuid,
#[field(create, update, response)]
#[embed(prefix = "price_", fields(amount_cents: i64, currency: String))]
pub price: Money,
}
Feature garde (backend de validación)
Habilita garde::Validate en los DTO generados como alternativa mantenida a validator. Las reglas #[validate(...)] (length, range, email, url, pattern) se traducen a sintaxis garde; los campos sin restricciones reciben garde(skip); los campos Option del DTO de actualización validan el valor interno con inner(...). Con ambas features activas, validate tiene prioridad.
#[field(create, update, response)]
#[validate(length(min = 3, max = 8))]
pub name: String,
let dto: CreateUserRequest = serde_json::from_str(body)?;
garde::Validate::validate(&dto)?;
constraint(...) (opcional, junto con typed_constraints)
Declara constraints que la macro no puede inferir — claves foráneas sobre claves naturales, CHECKs con nombres personalizados, índices de migraciones manuales — para que las violaciones se resuelvan a ConstraintError con el campo declarado. Tipos: unique, foreign_key, check. Las entradas personalizadas tienen prioridad sobre las derivadas con el mismo nombre. Requiere typed_constraints.
#[entity(
table = "orders",
typed_constraints,
constraint(name = "orders_currency_fkey", kind = "foreign_key", field = "currency"),
constraint(name = "orders_window_check", kind = "check"),
)]
Upsert transaccional
Con transactions y upsert(...) habilitados, el adaptador {Entity}TransactionRepo expone upsert con la misma semántica SQL que el método del pool, ejecutado sobre el handle de la transacción — para flujos donde el upsert debe compartir atomicidad con sentencias adyacentes.
let mut tx = pool.begin().await?;
sqlx::query("UPDATE users SET username = NULL WHERE ...").execute(&mut *tx).await?;
let user = UserTransactionRepo::new(&mut tx).upsert(dto).await?;
tx.commit().await?;
Ejemplos
Ejemplos prácticos para casos de uso comunes.
Gestión de Usuarios
Entidad de usuario clásica con campos de autenticación:
use entity_derive::Entity;
use uuid::Uuid;
use chrono::{DateTime, Utc};
#[derive(Entity)]
#[entity(table = "users", schema = "auth")]
pub struct User {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub username: String,
#[field(create, update, response)]
pub email: String,
#[field(create, update)] // Acepta pero nunca retorna
pub password_hash: String,
#[field(response)]
pub email_verified: bool,
#[field(update, response)]
pub avatar_url: Option<String>,
#[field(response)]
pub role: String,
#[field(skip)] // Campo de auditoría interno
pub last_login_ip: Option<String>,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
#[auto]
#[field(response)]
pub updated_at: DateTime<Utc>,
}
Uso:
// Crear usuario
let request = CreateUserRequest {
username: "john_doe".into(),
email: "john@example.com".into(),
password_hash: hash_password("secret123"),
};
let user = pool.create(request).await?;
// Actualizar usuario
let update = UpdateUserRequest {
avatar_url: Some(Some("https://cdn.example.com/avatar.jpg".into())),
..Default::default()
};
let user = pool.update(user.id, update).await?;
// La respuesta es segura - sin password_hash, sin last_login_ip
let response = UserResponse::from(&user);
Sistema de Blog
Posts con relación de autor:
#[derive(Entity)]
#[entity(table = "posts", schema = "blog")]
pub struct Post {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub title: String,
#[field(create, update, response)]
pub slug: String,
#[field(create, update, response)]
pub content: String,
#[field(create, update, response)]
pub excerpt: Option<String>,
#[field(create, response)] // Se establece una vez, no se puede cambiar autor
pub author_id: Uuid,
#[field(update, response)]
pub published: bool,
#[field(update, response)]
pub published_at: Option<DateTime<Utc>>,
#[field(response)] // Solo lectura, gestionado por triggers
pub view_count: i64,
#[field(skip)] // Moderación interna
pub moderation_status: String,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
#[auto]
#[field(response)]
pub updated_at: DateTime<Utc>,
}
Categorías con muchos-a-muchos:
#[derive(Entity)]
#[entity(table = "categories", schema = "blog")]
pub struct Category {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub name: String,
#[field(create, update, response)]
pub slug: String,
#[field(create, update, response)]
pub description: Option<String>,
#[field(response)]
pub post_count: i64, // Campo calculado
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
}
E-Commerce
Catálogo de productos:
#[derive(Entity)]
#[entity(table = "products", schema = "catalog")]
pub struct Product {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub name: String,
#[field(create, update, response)]
pub sku: String,
#[field(create, update, response)]
pub description: Option<String>,
#[field(create, update, response)]
pub price_cents: i64,
#[field(create, update, response)]
pub currency: String,
#[field(update, response)]
pub stock_quantity: i32,
#[field(update, response)]
pub is_active: bool,
#[field(create, response)]
pub category_id: Uuid,
#[field(skip)] // Seguimiento interno de costos
pub cost_cents: i64,
#[field(skip)] // Info del proveedor
pub supplier_id: Option<Uuid>,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
#[auto]
#[field(response)]
pub updated_at: DateTime<Utc>,
}
Pedidos con seguimiento de estado:
#[derive(Entity)]
#[entity(table = "orders", schema = "sales")]
pub struct Order {
#[id]
pub id: Uuid,
#[field(create, response)]
pub customer_id: Uuid,
#[field(response)]
pub order_number: String, // Generado por secuencia de BD
#[field(update, response)]
pub status: String,
#[field(create, response)]
pub total_cents: i64,
#[field(create, response)]
pub currency: String,
#[field(create, update, response)]
pub shipping_address: String,
#[field(update, response)]
pub tracking_number: Option<String>,
#[field(skip)] // Datos del procesador de pago
pub payment_intent_id: Option<String>,
#[field(skip)] // Notas internas
pub admin_notes: Option<String>,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
#[auto]
#[field(response)]
pub updated_at: DateTime<Utc>,
}
SaaS Multi-Tenant
Entidades con ámbito de organización:
#[derive(Entity)]
#[entity(table = "organizations", schema = "tenants")]
pub struct Organization {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub name: String,
#[field(create, response)]
pub slug: String, // Inmutable después de creación
#[field(update, response)]
pub plan: String,
#[field(response)]
pub member_count: i32,
#[field(skip)] // Info de facturación
pub stripe_customer_id: Option<String>,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
}
#[derive(Entity)]
#[entity(table = "projects", schema = "tenants")]
pub struct Project {
#[id]
pub id: Uuid,
#[field(create, response)] // Se establece una vez
pub organization_id: Uuid,
#[field(create, update, response)]
pub name: String,
#[field(create, update, response)]
pub description: Option<String>,
#[field(update, response)]
pub archived: bool,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
#[auto]
#[field(response)]
pub updated_at: DateTime<Utc>,
}
Solo DTOs (Sin Base de Datos)
Para contratos de API sin persistencia:
#[derive(Entity)]
#[entity(table = "webhooks", sql = "none")]
pub struct WebhookPayload {
#[id]
pub id: Uuid,
#[field(create, response)]
pub event_type: String,
#[field(create, response)]
pub payload: String,
#[field(create, response)]
pub timestamp: DateTime<Utc>,
#[field(response)]
pub signature: String,
}
Esto genera solo:
CreateWebhookPayloadRequestWebhookPayloadResponse- Implementaciones
From
Sin repositorio, sin SQL, sin estructuras Row/Insertable.
Solo Consultas Personalizadas
Cuando el CRUD estándar no es suficiente:
#[derive(Entity)]
#[entity(table = "analytics_events", schema = "analytics", sql = "trait")]
pub struct AnalyticsEvent {
#[id]
pub id: Uuid,
#[field(create, response)]
pub event_name: String,
#[field(create, response)]
pub user_id: Option<Uuid>,
#[field(create, response)]
pub properties: serde_json::Value,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
}
// Implementa consultas personalizadas tú mismo:
impl AnalyticsEventRepository for PgPool {
type Error = sqlx::Error;
async fn create(&self, dto: CreateAnalyticsEventRequest) -> Result<AnalyticsEvent, Self::Error> {
// Inserción por lotes, tablas particionadas, etc.
}
async fn find_by_id(&self, id: Uuid) -> Result<Option<AnalyticsEvent>, Self::Error> {
// Consulta con particionamiento basado en tiempo
}
// Métodos personalizados más allá del CRUD:
async fn aggregate_by_event(&self, start: DateTime<Utc>, end: DateTime<Utc>)
-> Result<Vec<EventAggregate>, Self::Error> {
// Consulta de agregación compleja
}
}
Ver También
- [[Atributos|Atributos]] — Referencia completa de atributos
- [[SQL Personalizado|SQL-Personalizado]] — Implementación de repositorio personalizado
- [[Frameworks Web|Frameworks-Web]] — Integración con Axum/Actix
Operaciones masivas
Cada repositorio incluye primitivas por lotes: find_by_ids (WHERE id = ANY($1), un solo viaje), create_many atómico (una transacción; una fila fallida revierte todo el lote) y delete_many consciente del soft delete que devuelve el número de filas realmente afectadas. Con entrega de eventos (streams u outbox) se emiten eventos por fila dentro de la misma transacción.
let posts: Vec<Post> = pool.find_by_ids(ids).await?;
let created: Vec<Post> = pool.create_many(dtos).await?;
let removed: u64 = pool.delete_many(stale_ids).await?;
Filtrado
Genera estructuras de consulta type-safe para filtrar entidades. El filtrado permite paginación, búsqueda y consultas de rango con seguridad en tiempo de compilación.
Inicio Rápido
#[derive(Entity)]
#[entity(table = "products")]
pub struct Product {
#[id]
pub id: Uuid,
#[field(create, update, response)]
#[filter]
pub name: String,
#[field(create, update, response)]
#[filter(like)]
pub description: String,
#[field(create, update, response)]
#[filter(range)]
pub price: i64,
#[field(create, response)]
#[filter]
pub category_id: Uuid,
#[field(response)]
#[auto]
#[filter(range)]
pub created_at: DateTime<Utc>,
}
Código Generado
Estructura de Query
/// Parámetros de consulta para filtrar entidades Product.
#[derive(Debug, Clone, Default)]
pub struct ProductQuery {
/// Filtrar por coincidencia exacta de name.
pub name: Option<String>,
/// Filtrar por patrón de description (ILIKE).
pub description: Option<String>,
/// Filtrar por precio mínimo.
pub price_from: Option<i64>,
/// Filtrar por precio máximo.
pub price_to: Option<i64>,
/// Filtrar por coincidencia exacta de category_id.
pub category_id: Option<Uuid>,
/// Filtrar por created_at mínimo.
pub created_at_from: Option<DateTime<Utc>>,
/// Filtrar por created_at máximo.
pub created_at_to: Option<DateTime<Utc>>,
/// Número máximo de resultados.
pub limit: Option<i64>,
/// Número de resultados a omitir.
pub offset: Option<i64>,
}
Método de Repository
#[async_trait]
pub trait ProductRepository: Send + Sync {
// ... métodos CRUD estándar
/// Consultar productos con filtros.
async fn query(&self, query: ProductQuery) -> Result<Vec<Product>, Self::Error>;
}
SQL Generado
SELECT id, name, description, price, category_id, created_at
FROM products
WHERE ($1 IS NULL OR name = $1)
AND ($2 IS NULL OR description ILIKE $2)
AND ($3 IS NULL OR price >= $3)
AND ($4 IS NULL OR price <= $4)
AND ($5 IS NULL OR category_id = $5)
AND ($6 IS NULL OR created_at >= $6)
AND ($7 IS NULL OR created_at <= $7)
ORDER BY created_at DESC
LIMIT $8 OFFSET $9
Tipos de Filtro
Coincidencia Exacta (#[filter] o #[filter(eq)])
Filtra donde el campo es igual al valor proporcionado.
#[filter]
pub status: String,
#[filter(eq)] // Igual que arriba
pub category_id: Uuid,
Generado:
pub status: Option<String>,
pub category_id: Option<Uuid>,
SQL:
WHERE status = $1
AND category_id = $2
Coincidencia de Patrón (#[filter(like)])
Filtra usando coincidencia de patrones case-insensitive (ILIKE).
#[filter(like)]
pub name: String,
#[filter(like)]
pub description: String,
Generado:
pub name: Option<String>,
pub description: Option<String>,
SQL:
WHERE name ILIKE $1
AND description ILIKE $2
Uso:
let query = ProductQuery {
name: Some("%widget%".into()), // Contiene "widget"
description: Some("premium%".into()), // Empieza con "premium"
..Default::default()
};
Filtro de Rango (#[filter(range)])
Filtra dentro de un rango (inclusivo).
#[filter(range)]
pub price: i64,
#[filter(range)]
pub created_at: DateTime<Utc>,
Generado:
pub price_from: Option<i64>,
pub price_to: Option<i64>,
pub created_at_from: Option<DateTime<Utc>>,
pub created_at_to: Option<DateTime<Utc>>,
SQL:
WHERE price >= $1 AND price <= $2
AND created_at >= $3 AND created_at <= $4
Ejemplos de Uso
Filtrado Básico
// Encontrar productos por categoría
let query = ProductQuery {
category_id: Some(electronics_category_id),
..Default::default()
};
let products = repo.query(query).await?;
Paginación
// Obtener página 2 (20 items por página)
let query = ProductQuery {
limit: Some(20),
offset: Some(20),
..Default::default()
};
let products = repo.query(query).await?;
Filtros Combinados
// Buscar electrónicos asequibles
let query = ProductQuery {
category_id: Some(electronics_category_id),
price_from: Some(0),
price_to: Some(10000), // $100.00
name: Some("%phone%".into()),
limit: Some(50),
..Default::default()
};
let products = repo.query(query).await?;
Rango de Fechas
// Obtener productos creados este mes
let now = Utc::now();
let month_start = now.with_day(1).unwrap().date_naive().and_hms_opt(0, 0, 0).unwrap();
let query = ProductQuery {
created_at_from: Some(month_start.and_utc()),
created_at_to: Some(now),
..Default::default()
};
let products = repo.query(query).await?;
Integración con Endpoint API
use axum::{extract::Query, Json};
#[derive(Deserialize)]
pub struct ProductQueryParams {
pub name: Option<String>,
pub category_id: Option<Uuid>,
pub min_price: Option<i64>,
pub max_price: Option<i64>,
pub page: Option<i64>,
pub per_page: Option<i64>,
}
async fn list_products(
Query(params): Query<ProductQueryParams>,
pool: Extension<PgPool>,
) -> Result<Json<Vec<ProductResponse>>, AppError> {
let page = params.page.unwrap_or(1);
let per_page = params.per_page.unwrap_or(20).min(100);
let query = ProductQuery {
name: params.name.map(|n| format!("%{}%", n)),
category_id: params.category_id,
price_from: params.min_price,
price_to: params.max_price,
limit: Some(per_page),
offset: Some((page - 1) * per_page),
..Default::default()
};
let products = pool.query(query).await?;
let responses: Vec<_> = products.into_iter().map(ProductResponse::from).collect();
Ok(Json(responses))
}
Con Borrado Lógico
Cuando soft_delete está habilitado, la consulta excluye automáticamente registros eliminados:
#[derive(Entity)]
#[entity(table = "documents", soft_delete)]
pub struct Document {
#[id]
pub id: Uuid,
#[field(create, response)]
#[filter(like)]
pub title: String,
#[field(skip)]
pub deleted_at: Option<DateTime<Utc>>,
}
SQL Generado:
SELECT * FROM documents
WHERE deleted_at IS NULL
AND ($1 IS NULL OR title ILIKE $1)
LIMIT $2 OFFSET $3
Método adicional para incluir eliminados:
async fn query_with_deleted(&self, query: DocumentQuery) -> Result<Vec<Document>, Self::Error>;
Extensiones de Query Personalizadas
Para consultas complejas, usa sql = "trait" e implementa filtrado personalizado:
#[derive(Entity)]
#[entity(table = "products", sql = "trait")]
pub struct Product { /* ... */ }
pub trait ProductQueryExt {
async fn search_fulltext(&self, term: &str, limit: i64) -> Result<Vec<Product>, sqlx::Error>;
async fn find_by_tags(&self, tags: &[String]) -> Result<Vec<Product>, sqlx::Error>;
}
#[async_trait]
impl ProductQueryExt for PgPool {
async fn search_fulltext(&self, term: &str, limit: i64) -> Result<Vec<Product>, sqlx::Error> {
let rows: Vec<ProductRow> = sqlx::query_as(
r#"
SELECT * FROM products
WHERE to_tsvector('english', name || ' ' || description)
@@ plainto_tsquery('english', $1)
ORDER BY ts_rank(to_tsvector('english', name || ' ' || description),
plainto_tsquery('english', $1)) DESC
LIMIT $2
"#
)
.bind(term)
.bind(limit)
.fetch_all(self)
.await?;
Ok(rows.into_iter().map(Product::from).collect())
}
async fn find_by_tags(&self, tags: &[String]) -> Result<Vec<Product>, sqlx::Error> {
let rows: Vec<ProductRow> = sqlx::query_as(
"SELECT * FROM products WHERE tags && $1"
)
.bind(tags)
.fetch_all(self)
.await?;
Ok(rows.into_iter().map(Product::from).collect())
}
}
Mejores Prácticas
- Paginación por defecto — Siempre aplica límites sensatos para prevenir conjuntos de resultados grandes
- Validar patrones — Sanitiza patrones LIKE para prevenir problemas SQL
- Indexar columnas filtradas — Crea índices de BD para campos filtrados frecuentemente
- Usar filtros específicos — Prefiere coincidencia exacta sobre coincidencia de patrón cuando sea posible
- Combinar con ordenación — Considera agregar campos de ordenación a tu estructura de query
Ver También
- [[Atributos|Atributos]] — Referencia completa de atributos
- [[SQL Personalizado|SQL-Personalizado]] — Consultas personalizadas complejas
- [[Relaciones|Relaciones]] — Filtrado con relaciones
Ordenación y paginación keyset
Marca las columnas ordenables con #[sort]: la estructura Query obtiene un selector con lista blanca {Entity}SortField (una variante Asc/Desc por columna, JSON en snake_case), de modo que la entrada del usuario nunca puede inyectar SQL. Cada repositorio también obtiene paginación keyset list_after: con ids UUIDv7 el recorrido por id es cronológicamente estable y no se degrada en páginas profundas, a diferencia de OFFSET.
#[derive(Entity)]
#[entity(table = "posts")]
pub struct Post {
#[id]
pub id: Uuid,
#[field(create, update, response)]
#[sort]
#[filter(like)]
pub title: String,
#[field(create, response)]
#[sort]
pub views: i64,
}
let query = PostQuery {
sort: Some(PostSortField::ViewsDesc),
limit: Some(20),
..Default::default()
};
let top: Vec<Post> = pool.query(query).await?;
let page: Vec<Post> = pool.list_after(None, 20).await?;
let next: Vec<Post> = pool.list_after(page.last().map(|p| p.id), 20).await?;
Búsqueda por trigramas
#[filter(search)] en una columna de texto añade un filtro difuso de subcadena (col ILIKE '%' || $n || '%', el término va ligado). Con migrations, el índice gin_trgm_ops correspondiente aterriza en MIGRATION_UP y pg_trgm se añade automáticamente a MIGRATION_EXTENSIONS. Verificación en compilación: el campo debe ser String.
#[field(create, update, response)]
#[filter(search)]
pub title: String,
let hits = pool.query(ArticleQuery { title: Some("rust".into()), ..Default::default() }).await?;
Relaciones
Define relaciones entre entidades usando #[belongs_to] y #[has_many]. Las relaciones generan métodos de navegación type-safe en repositorios.
Inicio Rápido
// Entidad padre
#[derive(Entity)]
#[entity(table = "users")]
#[has_many(Post)]
pub struct User {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub name: String,
}
// Entidad hija
#[derive(Entity)]
#[entity(table = "posts")]
pub struct Post {
#[id]
pub id: Uuid,
#[field(create, response)]
#[belongs_to(User)]
pub user_id: Uuid,
#[field(create, update, response)]
pub title: String,
}
Código Generado
Para #[has_many(Post)] en User
#[async_trait]
impl UserRepository for PgPool {
// ... métodos CRUD estándar
/// Encontrar todos los posts de este usuario.
async fn find_posts(&self, user_id: Uuid) -> Result<Vec<Post>, Self::Error> {
let rows: Vec<PostRow> = sqlx::query_as(
"SELECT * FROM posts WHERE user_id = $1 ORDER BY created_at DESC"
)
.bind(&user_id)
.fetch_all(self)
.await?;
Ok(rows.into_iter().map(Post::from).collect())
}
}
Para #[belongs_to(User)] en Post
#[async_trait]
impl PostRepository for PgPool {
// ... métodos CRUD estándar
/// Encontrar el usuario al que pertenece este post.
async fn find_user(&self, id: Uuid) -> Result<Option<User>, Self::Error> {
// Primero obtener el post para encontrar user_id
let post = self.find_by_id(id).await?;
if let Some(post) = post {
let row: Option<UserRow> = sqlx::query_as(
"SELECT * FROM users WHERE id = $1"
)
.bind(&post.user_id)
.fetch_optional(self)
.await?;
Ok(row.map(User::from))
} else {
Ok(None)
}
}
}
Tipos de Relación
belongs_to (Muchos-a-Uno)
Una entidad hija referencia a un padre via clave foránea.
#[derive(Entity)]
#[entity(table = "comments")]
pub struct Comment {
#[id]
pub id: Uuid,
#[field(create, response)]
#[belongs_to(Post)]
pub post_id: Uuid,
#[field(create, response)]
#[belongs_to(User)]
pub author_id: Uuid,
#[field(create, response)]
pub content: String,
}
Métodos generados:
find_post(comment_id)→Option<Post>find_author(comment_id)→Option<User>(nota: nombre derivado del campo sin_id)
has_many (Uno-a-Muchos)
Una entidad padre tiene múltiples hijos.
#[derive(Entity)]
#[entity(table = "users")]
#[has_many(Post)]
#[has_many(Comment)]
pub struct User {
#[id]
pub id: Uuid,
// ...
}
Métodos generados:
find_posts(user_id)→Vec<Post>find_comments(user_id)→Vec<Comment>
Ejemplos de Uso
Cargando Datos Relacionados
// Obtener usuario con sus posts
async fn get_user_with_posts(
pool: &PgPool,
user_id: Uuid,
) -> Result<Option<(User, Vec<Post>)>, sqlx::Error> {
let user = pool.find_by_id(user_id).await?;
if let Some(user) = user {
let posts = pool.find_posts(user_id).await?;
Ok(Some((user, posts)))
} else {
Ok(None)
}
}
// Obtener post con autor
async fn get_post_with_author(
pool: &PgPool,
post_id: Uuid,
) -> Result<Option<(Post, User)>, sqlx::Error> {
let post = pool.find_by_id(post_id).await?;
if let Some(post) = post {
let user = pool.find_user(post.id).await?;
if let Some(user) = user {
return Ok(Some((post, user)));
}
}
Ok(None)
}
Construyendo DTOs de Respuesta
#[derive(Serialize)]
pub struct PostWithAuthor {
#[serde(flatten)]
pub post: PostResponse,
pub author: UserResponse,
}
async fn get_posts_with_authors(
pool: &PgPool,
limit: i64,
) -> Result<Vec<PostWithAuthor>, sqlx::Error> {
let posts = pool.list(limit, 0).await?;
let mut results = Vec::with_capacity(posts.len());
for post in posts {
if let Some(user) = pool.find_user(post.id).await? {
results.push(PostWithAuthor {
post: PostResponse::from(&post),
author: UserResponse::from(&user),
});
}
}
Ok(results)
}
Relaciones Múltiples
Una entidad puede tener múltiples relaciones:
#[derive(Entity)]
#[entity(table = "organizations")]
#[has_many(User)]
#[has_many(Project)]
#[has_many(Team)]
pub struct Organization {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub name: String,
}
#[derive(Entity)]
#[entity(table = "projects")]
pub struct Project {
#[id]
pub id: Uuid,
#[field(create, response)]
#[belongs_to(Organization)]
pub organization_id: Uuid,
#[field(create, response)]
#[belongs_to(User)]
pub owner_id: Uuid,
#[field(create, update, response)]
pub name: String,
}
Generado para Organization:
find_users(org_id)find_projects(org_id)find_teams(org_id)
Generado para Project:
find_organization(project_id)find_owner(project_id)
Joins Personalizados con sql = “trait”
Para consultas complejas con eager loading, usa SQL personalizado:
#[derive(Entity)]
#[entity(table = "posts", sql = "trait")]
pub struct Post { /* ... */ }
pub struct PostWithRelations {
pub post: Post,
pub author: User,
pub comments: Vec<Comment>,
}
pub trait PostRepositoryExt {
async fn find_with_relations(&self, id: Uuid) -> Result<Option<PostWithRelations>, sqlx::Error>;
async fn list_with_authors(&self, limit: i64) -> Result<Vec<(Post, User)>, sqlx::Error>;
}
#[async_trait]
impl PostRepositoryExt for PgPool {
async fn find_with_relations(&self, id: Uuid) -> Result<Option<PostWithRelations>, sqlx::Error> {
// Consulta única con joins
let row = sqlx::query_as::<_, (PostRow, UserRow)>(
r#"
SELECT p.*, u.*
FROM posts p
JOIN users u ON u.id = p.user_id
WHERE p.id = $1
"#
)
.bind(&id)
.fetch_optional(self)
.await?;
if let Some((post_row, user_row)) = row {
let comments: Vec<CommentRow> = sqlx::query_as(
"SELECT * FROM comments WHERE post_id = $1 ORDER BY created_at"
)
.bind(&id)
.fetch_all(self)
.await?;
Ok(Some(PostWithRelations {
post: Post::from(post_row),
author: User::from(user_row),
comments: comments.into_iter().map(Comment::from).collect(),
}))
} else {
Ok(None)
}
}
async fn list_with_authors(&self, limit: i64) -> Result<Vec<(Post, User)>, sqlx::Error> {
let rows = sqlx::query_as::<_, (PostRow, UserRow)>(
r#"
SELECT p.*, u.*
FROM posts p
JOIN users u ON u.id = p.user_id
ORDER BY p.created_at DESC
LIMIT $1
"#
)
.bind(limit)
.fetch_all(self)
.await?;
Ok(rows.into_iter()
.map(|(p, u)| (Post::from(p), User::from(u)))
.collect())
}
}
Con Filtrado
Combina relaciones con filtrado de consultas:
#[derive(Entity)]
#[entity(table = "posts")]
pub struct Post {
#[id]
pub id: Uuid,
#[field(create, response)]
#[belongs_to(User)]
#[filter] // Habilitar filtrado por user_id
pub user_id: Uuid,
#[field(create, update, response)]
#[filter(like)]
pub title: String,
#[field(response)]
#[auto]
#[filter(range)]
pub created_at: DateTime<Utc>,
}
Uso:
// Obtener posts de un usuario específico con filtro de título
let query = PostQuery {
user_id: Some(user_id),
title: Some("%rust%".into()),
limit: Some(20),
..Default::default()
};
let posts = pool.query(query).await?;
Mejores Prácticas
- Evitar consultas N+1 — Usa joins para eager loading al obtener múltiples entidades relacionadas
- Usar paginación — Siempre limita resultados de
has_many - Considerar patrones de acceso a datos — Añade índices en columnas de clave foránea
- Cachear cuando sea apropiado — Cachea datos relacionados accedidos frecuentemente
- Usar proyecciones — Obtén solo campos necesarios para entidades relacionadas
Ver También
- [[Filtrado|Filtrado]] — Filtrado de consultas
- [[SQL Personalizado|SQL-Personalizado]] — Joins y consultas complejas
- [[Mejores Prácticas|Mejores-Prácticas]] — Consejos de rendimiento
has_many through (muchos-a-muchos)
Declara la tabla de unión con through y el repositorio obtiene una consulta con JOIN más gestión de vínculos; migrations emite MIGRATION_JUNCTIONS (clave primaria compuesta sobre ambas claves foráneas, ON DELETE CASCADE en ambos lados).
#[derive(Entity)]
#[entity(table = "teams", migrations)]
#[has_many(User, through = "team_members")]
pub struct Team { /* ... */ }
for ddl in Team::MIGRATION_JUNCTIONS {
sqlx::query(ddl).execute(&pool).await?;
}
pool.add_user(team_id, user_id).await?;
let members: Vec<User> = pool.find_users(team_id).await?;
let linked = pool.has_user(team_id, user_id).await?;
let removed = pool.remove_user(team_id, user_id).await?;
Métodos generados: find_users (INNER JOIN), add_user (idempotente, ON CONFLICT DO NOTHING), remove_user (false si no estaba vinculado), has_user (SELECT EXISTS).
Eventos
Genera eventos de dominio para cambios en el ciclo de vida de entidades. Los eventos permiten registro de auditoría, event sourcing e integración con colas de mensajes.
Inicio Rápido
#[derive(Entity)]
#[entity(table = "orders", events)]
pub struct Order {
#[id]
pub id: Uuid,
#[field(create, response)]
pub customer_id: Uuid,
#[field(create, update, response)]
pub status: String,
#[field(create, response)]
pub total_cents: i64,
#[field(response)]
#[auto]
pub created_at: DateTime<Utc>,
}
Código Generado
El atributo events genera un enum de eventos:
/// Generado por entity-derive
#[derive(Debug, Clone)]
pub enum OrderEvent {
/// Entidad creada.
Created(Order),
/// Entidad actualizada.
Updated {
id: Uuid,
changes: UpdateOrderRequest,
},
/// Entidad eliminada.
Deleted(Uuid),
}
Ejemplos de Uso
Publicación Básica de Eventos
use async_trait::async_trait;
#[async_trait]
pub trait EventBus: Send + Sync {
async fn publish<E: Send + Sync>(&self, event: E);
}
async fn create_order(
repo: &impl OrderRepository,
bus: &impl EventBus,
dto: CreateOrderRequest,
) -> Result<Order, sqlx::Error> {
let order = repo.create(dto).await?;
// Publicar evento después de creación exitosa
bus.publish(OrderEvent::Created(order.clone())).await;
Ok(order)
}
async fn update_order(
repo: &impl OrderRepository,
bus: &impl EventBus,
id: Uuid,
dto: UpdateOrderRequest,
) -> Result<Order, sqlx::Error> {
let order = repo.update(id, dto.clone()).await?;
bus.publish(OrderEvent::Updated { id, changes: dto }).await;
Ok(order)
}
async fn delete_order(
repo: &impl OrderRepository,
bus: &impl EventBus,
id: Uuid,
) -> Result<bool, sqlx::Error> {
let deleted = repo.delete(id).await?;
if deleted {
bus.publish(OrderEvent::Deleted(id)).await;
}
Ok(deleted)
}
Registro de Auditoría
struct AuditLogger {
pool: PgPool,
}
#[async_trait]
impl EventHandler<OrderEvent> for AuditLogger {
async fn handle(&self, event: OrderEvent) {
let (action, entity_id, details) = match &event {
OrderEvent::Created(order) => (
"created",
order.id,
serde_json::to_string(order).unwrap(),
),
OrderEvent::Updated { id, changes } => (
"updated",
*id,
serde_json::to_string(changes).unwrap(),
),
OrderEvent::Deleted(id) => (
"deleted",
*id,
String::new(),
),
};
sqlx::query(
"INSERT INTO audit_log (entity_type, entity_id, action, details, created_at)
VALUES ('order', $1, $2, $3, NOW())"
)
.bind(entity_id)
.bind(action)
.bind(details)
.execute(&self.pool)
.await
.ok();
}
}
Integración con Cola de Mensajes
use rdkafka::producer::FutureProducer;
struct KafkaEventBus {
producer: FutureProducer,
topic: String,
}
#[async_trait]
impl EventBus for KafkaEventBus {
async fn publish<E: Serialize + Send + Sync>(&self, event: E) {
let payload = serde_json::to_vec(&event).unwrap();
self.producer
.send(
FutureRecord::to(&self.topic)
.payload(&payload)
.key(&Uuid::new_v4().to_string()),
Duration::from_secs(5),
)
.await
.ok();
}
}
Patrón Event Sourcing
struct OrderAggregate {
events: Vec<OrderEvent>,
current_state: Option<Order>,
}
impl OrderAggregate {
fn apply(&mut self, event: OrderEvent) {
match &event {
OrderEvent::Created(order) => {
self.current_state = Some(order.clone());
}
OrderEvent::Updated { changes, .. } => {
if let Some(ref mut order) = self.current_state {
if let Some(status) = &changes.status {
order.status = status.clone();
}
}
}
OrderEvent::Deleted(_) => {
self.current_state = None;
}
}
self.events.push(event);
}
fn replay(events: Vec<OrderEvent>) -> Self {
let mut aggregate = Self {
events: Vec::new(),
current_state: None,
};
for event in events {
aggregate.apply(event);
}
aggregate
}
}
Con Borrado Lógico
Cuando soft_delete está habilitado, se generan eventos adicionales:
#[derive(Entity)]
#[entity(table = "documents", events, soft_delete)]
pub struct Document {
#[id]
pub id: Uuid,
#[field(create, response)]
pub title: String,
#[field(skip)]
pub deleted_at: Option<DateTime<Utc>>,
}
Generado:
pub enum DocumentEvent {
Created(Document),
Updated { id: Uuid, changes: UpdateDocumentRequest },
Deleted(Uuid), // Borrado lógico
Restored(Uuid), // Restaurado de borrado lógico
HardDeleted(Uuid), // Eliminación permanente
}
Mejores Prácticas
- Publicar después del commit — Solo publicar eventos después de que la transacción de BD tenga éxito
- Handlers idempotentes — Los handlers de eventos deben ser idempotentes para entrega at-least-once
- Incluir contexto — Considera agregar metadatos (user_id, timestamp, correlation_id)
- Procesamiento async — Usa workers en segundo plano para procesamiento pesado de eventos
- Cola de errores — Maneja eventos fallidos de forma elegante
Combinando con Hooks
Eventos y hooks funcionan bien juntos:
#[derive(Entity)]
#[entity(table = "orders", events, hooks)]
pub struct Order { /* ... */ }
struct OrderService {
repo: PgPool,
bus: EventBus,
}
#[async_trait]
impl OrderHooks for OrderService {
type Error = AppError;
async fn after_create(&self, entity: &Order) -> Result<(), Self::Error> {
// Publicar evento en hook
self.bus.publish(OrderEvent::Created(entity.clone())).await;
Ok(())
}
async fn after_update(&self, entity: &Order) -> Result<(), Self::Error> {
// Los eventos también pueden publicarse aquí
Ok(())
}
async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error> {
self.bus.publish(OrderEvent::Deleted(*id)).await;
Ok(())
}
}
Ver También
- [[Hooks|Ganchos]] — Ejecutar lógica personalizada en eventos del ciclo de vida
- [[Comandos|Comandos]] — Patrón CQRS con eventos de comandos
- [[Mejores Prácticas|Mejores-Prácticas]] — Consejos de producción
Streams
Suscríbete a cambios de entidades en tiempo real usando Postgres LISTEN/NOTIFY. Los streams permiten dashboards en vivo, notificaciones instantáneas, invalidación de caché y arquitecturas orientadas a eventos.
Inicio Rápido
#[derive(Entity, Serialize, Deserialize)]
#[entity(table = "orders", events, streams)]
pub struct Order {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub status: String,
#[field(create, response)]
pub customer_id: Uuid,
}
Requisitos:
- La entidad debe derivar
SerializeyDeserialize(para payloads JSON) - Se requieren ambos atributos
eventsystreams - Habilita la feature
streamsen Cargo.toml
[dependencies]
entity-derive = { version = "0.3", features = ["postgres", "streams"] }
serde = { version = "1", features = ["derive"] }
Código Generado
El atributo streams genera:
Constante de Canal
impl Order {
/// Nombre del canal Postgres NOTIFY.
pub const CHANNEL: &'static str = "entity_orders";
}
Estructura Subscriber
/// Suscriptor para cambios de Order en tiempo real.
pub struct OrderSubscriber {
listener: PgListener,
}
impl OrderSubscriber {
/// Conectar y suscribirse al canal.
pub async fn new(pool: &PgPool) -> Result<Self, sqlx::Error>;
/// Esperar el próximo evento (bloqueante).
pub async fn recv(&mut self) -> Result<OrderEvent, StreamError<sqlx::Error>>;
/// Verificar evento sin bloquear.
pub async fn try_recv(&mut self) -> Result<Option<OrderEvent>, StreamError<sqlx::Error>>;
}
Notificaciones Automáticas
Las operaciones CRUD emiten eventos automáticamente:
// En el método create() generado:
async fn create(&self, dto: CreateOrderRequest) -> Result<Order, Self::Error> {
let order = /* insert */;
// Notificación auto-generada
let event = OrderEvent::created(order.clone());
let payload = serde_json::to_string(&event)?;
sqlx::query("SELECT pg_notify($1, $2)")
.bind(Order::CHANNEL)
.bind(&payload)
.execute(self)
.await?;
Ok(order)
}
Ejemplos de Uso
Suscripción Básica
use entity_derive::StreamError;
async fn watch_orders(pool: &PgPool) -> Result<(), Box<dyn std::error::Error>> {
let mut subscriber = OrderSubscriber::new(pool).await?;
loop {
match subscriber.recv().await {
Ok(event) => {
match event {
OrderEvent::Created(order) => {
println!("Nueva orden: {}", order.id);
}
OrderEvent::Updated { old, new } => {
println!("Orden {} actualizada: {} -> {}", new.id, old.status, new.status);
}
OrderEvent::HardDeleted { id } => {
println!("Orden {} eliminada", id);
}
_ => {}
}
}
Err(StreamError::Database(e)) => {
eprintln!("Error de base de datos: {}", e);
break;
}
Err(StreamError::Deserialize(e)) => {
eprintln!("Payload de evento inválido: {}", e);
}
}
}
Ok(())
}
Dashboard en Tiempo Real (Axum WebSocket)
use axum::{
extract::{State, WebSocketUpgrade, ws::{Message, WebSocket}},
response::IntoResponse,
};
async fn ws_handler(
ws: WebSocketUpgrade,
State(pool): State<PgPool>,
) -> impl IntoResponse {
ws.on_upgrade(|socket| handle_socket(socket, pool))
}
async fn handle_socket(mut socket: WebSocket, pool: PgPool) {
let mut subscriber = match OrderSubscriber::new(&pool).await {
Ok(s) => s,
Err(_) => return,
};
loop {
match subscriber.recv().await {
Ok(event) => {
let json = serde_json::to_string(&event).unwrap();
if socket.send(Message::Text(json)).await.is_err() {
break;
}
}
Err(_) => break,
}
}
}
Invalidación de Caché
struct CacheInvalidator {
cache: Redis,
pool: PgPool,
}
impl CacheInvalidator {
async fn run(&self) -> Result<(), StreamError<sqlx::Error>> {
let mut subscriber = OrderSubscriber::new(&self.pool).await
.map_err(StreamError::Database)?;
loop {
let event = subscriber.recv().await?;
let key = format!("order:{}", event.entity_id());
match event {
OrderEvent::Created(_) | OrderEvent::Updated { .. } => {
self.cache.del(&key).await.ok();
}
OrderEvent::HardDeleted { id } | OrderEvent::SoftDeleted { id } => {
self.cache.del(&format!("order:{}", id)).await.ok();
}
_ => {}
}
}
}
}
Worker en Segundo Plano con Graceful Shutdown
use tokio::sync::watch;
async fn notification_worker(
pool: PgPool,
mut shutdown: watch::Receiver<bool>,
) {
let mut subscriber = OrderSubscriber::new(&pool).await.unwrap();
loop {
tokio::select! {
result = subscriber.recv() => {
match result {
Ok(event) => process_event(event).await,
Err(e) => {
eprintln!("Error de stream: {:?}", e);
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
_ = shutdown.changed() => {
println!("Apagando worker de notificaciones");
break;
}
}
}
}
Manejo de Errores
use entity_derive::StreamError;
match subscriber.recv().await {
Ok(event) => { /* procesar */ }
Err(StreamError::Database(sqlx_error)) => {
// Conexión perdida, query fallido, etc.
// El subscriber se reconectará automáticamente en el próximo recv()
}
Err(StreamError::Deserialize(message)) => {
// Payload JSON inválido
// Loguear y continuar - no romper el loop
}
}
Arquitectura
Operación CRUD (create/update/delete)
│
▼
pg_notify(channel, event_json)
│
▼
Postgres NOTIFY
│
┌────┴────┐
▼ ▼
Subscriber Subscriber (múltiples listeners)
│ │
▼ ▼
WebSocket Invalidador
Dashboard de Caché
Mejores Prácticas
- Reconexión — PgListener se reconecta automáticamente; diseña tu loop para manejar fallos temporales
- Idempotencia — Los eventos pueden entregarse múltiples veces; los handlers deben ser idempotentes
- Tamaño del payload — Mantén las entidades pequeñas; payloads grandes pueden exceder límites de Postgres
- Pools separados — Usa un pool de conexiones dedicado para listeners
- Monitoreo — Loguea errores de stream y rastrea la latencia de procesamiento de eventos
- Graceful shutdown — Usa select! con señal de shutdown para limpiar recursos
Con Soft Delete
Cuando soft_delete está habilitado, hay eventos adicionales disponibles:
#[derive(Entity, Serialize, Deserialize)]
#[entity(table = "documents", events, streams, soft_delete)]
pub struct Document {
#[id]
pub id: Uuid,
#[field(create, response)]
pub title: String,
#[field(skip)]
pub deleted_at: Option<DateTime<Utc>>,
}
// Los eventos incluyen:
// - DocumentEvent::SoftDeleted { id }
// - DocumentEvent::Restored { id }
// - DocumentEvent::HardDeleted { id }
Ver También
- [[Eventos|Eventos]] — Enum de eventos sin streaming en tiempo real
- [[Hooks]] — Ejecutar lógica personalizada en eventos del ciclo de vida
- [[Mejores Prácticas|Mejores-Prácticas]] — Tips para producción
Hooks
Ejecuta lógica personalizada antes y después de operaciones de entidad. Los hooks permiten validación, normalización, efectos secundarios y autorización.
Inicio Rápido
#[derive(Entity)]
#[entity(table = "users", hooks)]
pub struct User {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub email: String,
#[field(create, response)]
pub name: String,
#[field(skip)]
pub password_hash: String,
#[field(response)]
#[auto]
pub created_at: DateTime<Utc>,
}
Código Generado
El atributo hooks genera un trait async:
/// Generado por entity-derive
#[async_trait]
pub trait UserHooks: Send + Sync {
type Error: std::error::Error + Send + Sync;
/// Llamado antes de crear una nueva entidad.
/// Modifica el DTO o devuelve error para abortar.
async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error>;
/// Llamado después de la creación de entidad.
async fn after_create(&self, entity: &User) -> Result<(), Self::Error>;
/// Llamado antes de actualizar una entidad.
/// Modifica el DTO o devuelve error para abortar.
async fn before_update(&self, id: &Uuid, dto: &mut UpdateUserRequest) -> Result<(), Self::Error>;
/// Llamado después de actualizar entidad.
async fn after_update(&self, entity: &User) -> Result<(), Self::Error>;
/// Llamado antes de eliminar una entidad.
/// Devuelve error para abortar.
async fn before_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
/// Llamado después de eliminar entidad.
async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
}
Ejemplo de Implementación
use async_trait::async_trait;
struct UserService {
pool: PgPool,
cache: RedisPool,
email_sender: EmailService,
}
#[async_trait]
impl UserHooks for UserService {
type Error = AppError;
async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error> {
// Normalizar email
dto.email = dto.email.trim().to_lowercase();
// Validar formato de email
if !dto.email.contains('@') {
return Err(AppError::Validation("Formato de email inválido".into()));
}
// Verificar email duplicado
let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)"
)
.bind(&dto.email)
.fetch_one(&self.pool)
.await?;
if exists {
return Err(AppError::Conflict("Email ya registrado".into()));
}
Ok(())
}
async fn after_create(&self, entity: &User) -> Result<(), Self::Error> {
// Enviar email de bienvenida
self.email_sender
.send_welcome(&entity.email, &entity.name)
.await?;
// Cachear el nuevo usuario
self.cache.set(&format!("user:{}", entity.id), entity).await?;
Ok(())
}
async fn before_update(&self, id: &Uuid, dto: &mut UpdateUserRequest) -> Result<(), Self::Error> {
// Normalizar email si se proporciona
if let Some(ref mut email) = dto.email {
*email = email.trim().to_lowercase();
// Verificar duplicado (excluyendo usuario actual)
let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM users WHERE email = $1 AND id != $2)"
)
.bind(&*email)
.bind(id)
.fetch_one(&self.pool)
.await?;
if exists {
return Err(AppError::Conflict("Email ya en uso".into()));
}
}
Ok(())
}
async fn after_update(&self, entity: &User) -> Result<(), Self::Error> {
// Invalidar caché
self.cache.del(&format!("user:{}", entity.id)).await?;
Ok(())
}
async fn before_delete(&self, id: &Uuid) -> Result<(), Self::Error> {
// Verificar si el usuario puede ser eliminado
let has_orders = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM orders WHERE user_id = $1 AND status = 'pending')"
)
.bind(id)
.fetch_one(&self.pool)
.await?;
if has_orders {
return Err(AppError::Forbidden("No se puede eliminar usuario con pedidos pendientes".into()));
}
Ok(())
}
async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error> {
// Invalidar caché
self.cache.del(&format!("user:{}", id)).await?;
// Limpiar datos relacionados
sqlx::query("DELETE FROM user_sessions WHERE user_id = $1")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
}
Casos de Uso
Validación
async fn before_create(&self, dto: &mut CreateProductRequest) -> Result<(), Self::Error> {
// Validación de precio
if dto.price_cents <= 0 {
return Err(AppError::Validation("El precio debe ser positivo".into()));
}
// Validación de formato SKU
if !dto.sku.chars().all(|c| c.is_alphanumeric() || c == '-') {
return Err(AppError::Validation("Formato de SKU inválido".into()));
}
Ok(())
}
Normalización
async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error> {
// Normalizar email
dto.email = dto.email.trim().to_lowercase();
// Normalizar nombre
dto.name = dto.name.trim().to_string();
// Capitalizar primera letra de cada palabra
dto.name = dto.name
.split_whitespace()
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
})
.collect::<Vec<_>>()
.join(" ");
Ok(())
}
Autorización
async fn before_update(&self, id: &Uuid, _dto: &mut UpdatePostRequest) -> Result<(), Self::Error> {
// Obtener usuario actual del contexto
let current_user = self.current_user()?;
// Verificar propiedad
let post = sqlx::query_as::<_, Post>(
"SELECT * FROM posts WHERE id = $1"
)
.bind(id)
.fetch_optional(&self.pool)
.await?
.ok_or(AppError::NotFound)?;
if post.author_id != current_user.id && !current_user.is_admin {
return Err(AppError::Forbidden("No puede editar posts de otros usuarios".into()));
}
Ok(())
}
Efectos Secundarios
async fn after_create(&self, entity: &Order) -> Result<(), Self::Error> {
// Actualizar inventario
for item in &entity.items {
sqlx::query(
"UPDATE products SET stock = stock - $1 WHERE id = $2"
)
.bind(item.quantity)
.bind(item.product_id)
.execute(&self.pool)
.await?;
}
// Enviar notificación
self.notifications.send_order_confirmation(entity).await?;
// Programar trabajo de fulfillment
self.job_queue.enqueue(FulfillOrderJob { order_id: entity.id }).await?;
Ok(())
}
Con Borrado Lógico
Cuando soft_delete está habilitado, se generan hooks adicionales:
#[derive(Entity)]
#[entity(table = "documents", hooks, soft_delete)]
pub struct Document { /* ... */ }
Hooks generados:
#[async_trait]
pub trait DocumentHooks: Send + Sync {
type Error: std::error::Error + Send + Sync;
// Hooks CRUD estándar...
async fn before_create(&self, dto: &mut CreateDocumentRequest) -> Result<(), Self::Error>;
async fn after_create(&self, entity: &Document) -> Result<(), Self::Error>;
async fn before_update(&self, id: &Uuid, dto: &mut UpdateDocumentRequest) -> Result<(), Self::Error>;
async fn after_update(&self, entity: &Document) -> Result<(), Self::Error>;
async fn before_delete(&self, id: &Uuid) -> Result<(), Self::Error>; // Borrado lógico
async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
// Hooks específicos de borrado lógico
async fn before_restore(&self, id: &Uuid) -> Result<(), Self::Error>;
async fn after_restore(&self, entity: &Document) -> Result<(), Self::Error>;
async fn before_hard_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
async fn after_hard_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
}
Con Comandos
Cuando commands y hooks están habilitados, se generan hooks de comandos:
#[derive(Entity)]
#[entity(table = "orders", hooks, commands)]
#[command(Place)]
#[command(Cancel, requires_id)]
pub struct Order { /* ... */ }
Hooks adicionales:
#[async_trait]
pub trait OrderHooks: Send + Sync {
type Error: std::error::Error + Send + Sync;
// Hooks CRUD estándar...
// Hooks de comandos
async fn before_command(&self, cmd: &OrderCommand) -> Result<(), Self::Error>;
async fn after_command(&self, cmd: &OrderCommand, result: &OrderCommandResult) -> Result<(), Self::Error>;
}
Mejores Prácticas
- Mantén los hooks rápidos — Las operaciones largas deben ser trabajos async
- Usa transacciones — Envuelve hook + llamada a repository en una transacción
- Maneja errores elegantemente — Devuelve tipos de error significativos
- No dupliques lógica — Usa hooks para concerns transversales
- Prueba hooks independientemente — Pruebas unitarias para implementaciones de hooks
Patrón de Manejo de Errores
#[derive(Debug)]
pub enum HookError {
Validation(String),
Authorization(String),
Conflict(String),
Database(sqlx::Error),
}
impl std::error::Error for HookError {}
impl std::fmt::Display for HookError { /* ... */ }
impl From<sqlx::Error> for HookError {
fn from(err: sqlx::Error) -> Self {
HookError::Database(err)
}
}
#[async_trait]
impl UserHooks for UserService {
type Error = HookError;
async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error> {
if dto.email.is_empty() {
return Err(HookError::Validation("Email requerido".into()));
}
Ok(())
}
}
Ver También
- [[Eventos|Eventos]] — Eventos del ciclo de vida para auditoría
- [[Comandos|Comandos]] — Patrón CQRS con hooks de comandos
- [[Mejores Prácticas|Mejores-Prácticas]] — Consejos de producción
Comandos
Define comandos orientados al negocio en lugar de CRUD genérico. Los comandos traen el lenguaje del dominio a tu API y habilitan el patrón Command Query Responsibility Segregation (CQRS).
Inicio Rápido
#[derive(Entity)]
#[entity(table = "users", commands)]
#[command(Register)]
#[command(UpdateEmail: email)]
#[command(Deactivate, requires_id)]
pub struct User {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub email: String,
#[field(create, response)]
pub name: String,
#[field(response)]
pub active: bool,
}
Código Generado
Estructuras de Comando
/// Payload de comando para operación Register en User.
#[derive(Debug, Clone)]
pub struct RegisterUser {
pub email: String,
pub name: String,
}
/// Payload de comando para operación UpdateEmail en User.
#[derive(Debug, Clone)]
pub struct UpdateEmailUser {
pub id: Uuid,
pub email: String,
}
/// Payload de comando para operación Deactivate en User.
#[derive(Debug, Clone)]
pub struct DeactivateUser {
pub id: Uuid,
}
Enum de Comando
/// Enum de comando para entidad User.
#[derive(Debug, Clone)]
pub enum UserCommand {
Register(RegisterUser),
UpdateEmail(UpdateEmailUser),
Deactivate(DeactivateUser),
}
impl EntityCommand for UserCommand {
fn kind(&self) -> CommandKind {
match self {
UserCommand::Register(_) => CommandKind::Create,
UserCommand::UpdateEmail(_) => CommandKind::Update,
UserCommand::Deactivate(_) => CommandKind::Custom,
}
}
fn name(&self) -> &'static str {
match self {
UserCommand::Register(_) => "Register",
UserCommand::UpdateEmail(_) => "UpdateEmail",
UserCommand::Deactivate(_) => "Deactivate",
}
}
}
Enum de Resultado
/// Enum de resultado para ejecución de comando User.
#[derive(Debug, Clone)]
pub enum UserCommandResult {
Register(User),
UpdateEmail(User),
Deactivate,
}
Trait de Handler
/// Trait async para manejar comandos User.
#[async_trait]
pub trait UserCommandHandler: Send + Sync {
type Error: std::error::Error + Send + Sync;
type Context: Send + Sync;
/// Despachar comando al handler apropiado.
async fn handle(&self, cmd: UserCommand, ctx: &Self::Context)
-> Result<UserCommandResult, Self::Error>;
/// Manejar comando Register.
async fn handle_register(&self, cmd: RegisterUser, ctx: &Self::Context)
-> Result<User, Self::Error>;
/// Manejar comando UpdateEmail.
async fn handle_update_email(&self, cmd: UpdateEmailUser, ctx: &Self::Context)
-> Result<User, Self::Error>;
/// Manejar comando Deactivate.
async fn handle_deactivate(&self, cmd: DeactivateUser, ctx: &Self::Context)
-> Result<(), Self::Error>;
}
Referencia de Sintaxis de Comandos
Comando Básico
Usa todos los campos #[field(create)]:
#[command(Register)]
// Generado: RegisterUser { email, name }
Campos Específicos
Usa solo campos especificados (añade requires_id automáticamente):
#[command(UpdateEmail: email)]
// Generado: UpdateEmailUser { id, email }
#[command(UpdateProfile: name, bio, avatar)]
// Generado: UpdateProfileUser { id, name, bio, avatar }
Comando Solo ID
Añade solo el campo ID:
#[command(Deactivate, requires_id)]
// Generado: DeactivateUser { id }
#[command(Delete, requires_id, kind = "delete")]
// Generado: DeleteUser { id }, devuelve ()
Payload Personalizado
Usa una estructura externa:
pub struct TransferPayload {
pub from_account: Uuid,
pub to_account: Uuid,
pub amount: i64,
}
#[command(Transfer, payload = "TransferPayload")]
// Usa TransferPayload directamente
Resultado Personalizado
Usa un tipo de resultado personalizado:
pub struct TransferResult {
pub transaction_id: Uuid,
pub success: bool,
}
#[command(Transfer, payload = "TransferPayload", result = "TransferResult")]
// Devuelve TransferResult en lugar de entidad
Opciones de Source
Controla qué campos se usan:
#[command(Create, source = "create")] // Usa campos #[field(create)] (defecto)
#[command(Modify, source = "update")] // Usa campos #[field(update)] (opcional)
#[command(Ping, source = "none")] // Sin campos de payload
Hints de Kind
Afectan la inferencia del tipo de resultado:
#[command(Create, kind = "create")] // Devuelve entidad (defecto)
#[command(Update, kind = "update")] // Devuelve entidad
#[command(Remove, kind = "delete")] // Devuelve ()
#[command(Process, kind = "custom")] // Inferido de source
Ejemplo de Implementación
use async_trait::async_trait;
struct UserHandler {
pool: PgPool,
email_service: EmailService,
}
struct RequestContext {
user_id: Option<Uuid>,
correlation_id: Uuid,
}
#[async_trait]
impl UserCommandHandler for UserHandler {
type Error = AppError;
type Context = RequestContext;
async fn handle(&self, cmd: UserCommand, ctx: &Self::Context)
-> Result<UserCommandResult, Self::Error>
{
match cmd {
UserCommand::Register(c) => {
let user = self.handle_register(c, ctx).await?;
Ok(UserCommandResult::Register(user))
}
UserCommand::UpdateEmail(c) => {
let user = self.handle_update_email(c, ctx).await?;
Ok(UserCommandResult::UpdateEmail(user))
}
UserCommand::Deactivate(c) => {
self.handle_deactivate(c, ctx).await?;
Ok(UserCommandResult::Deactivate)
}
}
}
async fn handle_register(&self, cmd: RegisterUser, ctx: &Self::Context)
-> Result<User, Self::Error>
{
// Validar
if cmd.email.is_empty() {
return Err(AppError::Validation("Email requerido".into()));
}
// Crear usuario
let user = User {
id: Uuid::now_v7(),
email: cmd.email.to_lowercase(),
name: cmd.name,
active: true,
};
// Persistir
sqlx::query(
"INSERT INTO users (id, email, name, active) VALUES ($1, $2, $3, $4)"
)
.bind(user.id)
.bind(&user.email)
.bind(&user.name)
.bind(user.active)
.execute(&self.pool)
.await?;
// Efectos secundarios
self.email_service.send_welcome(&user.email).await?;
Ok(user)
}
async fn handle_update_email(&self, cmd: UpdateEmailUser, ctx: &Self::Context)
-> Result<User, Self::Error>
{
// Verificar autorización
if ctx.user_id != Some(cmd.id) {
return Err(AppError::Forbidden("No puede actualizar email de otro usuario".into()));
}
// Actualizar
let user: User = sqlx::query_as(
"UPDATE users SET email = $1 WHERE id = $2 RETURNING *"
)
.bind(&cmd.email.to_lowercase())
.bind(cmd.id)
.fetch_one(&self.pool)
.await?;
// Enviar verificación
self.email_service.send_verification(&user.email).await?;
Ok(user)
}
async fn handle_deactivate(&self, cmd: DeactivateUser, ctx: &Self::Context)
-> Result<(), Self::Error>
{
sqlx::query("UPDATE users SET active = false WHERE id = $1")
.bind(cmd.id)
.execute(&self.pool)
.await?;
Ok(())
}
}
Usando Comandos
async fn register_user(
handler: &impl UserCommandHandler,
email: String,
name: String,
) -> Result<User, AppError> {
let cmd = RegisterUser { email, name };
let ctx = RequestContext {
user_id: None,
correlation_id: Uuid::new_v4(),
};
match handler.handle(UserCommand::Register(cmd), &ctx).await? {
UserCommandResult::Register(user) => Ok(user),
_ => unreachable!(),
}
}
// O llamar handler específico directamente
async fn update_email(
handler: &impl UserCommandHandler,
user_id: Uuid,
new_email: String,
ctx: &RequestContext,
) -> Result<User, AppError> {
let cmd = UpdateEmailUser {
id: user_id,
email: new_email,
};
handler.handle_update_email(cmd, ctx).await
}
Trait EntityCommand
Todos los enums de comando implementan el trait EntityCommand:
use entity_derive::{EntityCommand, CommandKind};
let cmd = UserCommand::Register(register_data);
// Obtener metadatos del comando
assert_eq!(cmd.name(), "Register");
assert!(matches!(cmd.kind(), CommandKind::Create));
// Pattern matching
match cmd.kind() {
CommandKind::Create => println!("Creando entidad"),
CommandKind::Update => println!("Actualizando entidad"),
CommandKind::Delete => println!("Eliminando entidad"),
CommandKind::Custom => println!("Operación personalizada"),
}
Hooks de Comandos
Cuando commands y hooks están habilitados:
#[derive(Entity)]
#[entity(table = "orders", commands, hooks)]
#[command(Place)]
#[command(Cancel, requires_id)]
pub struct Order { /* ... */ }
Hooks generados:
#[async_trait]
pub trait OrderHooks: Send + Sync {
type Error: std::error::Error + Send + Sync;
// Hooks CRUD estándar...
// Hooks específicos de comandos
async fn before_command(&self, cmd: &OrderCommand) -> Result<(), Self::Error>;
async fn after_command(&self, cmd: &OrderCommand, result: &OrderCommandResult) -> Result<(), Self::Error>;
}
Mejores Prácticas
- Lenguaje de dominio — Usa términos de negocio:
RegisterUsernoCreateUser - Responsabilidad única — Un comando = una operación de negocio
- Intención explícita — Los nombres de comandos deben describir la acción
- Validación en handlers — Mantén la lógica de validación en los handlers
- Idempotente cuando sea posible — Diseña comandos para reintentos seguros
- Usa contexto — Pasa metadatos de request (usuario, ID de correlación) vía contexto
Patrón CQRS
Los comandos son una mitad de CQRS. Combina con proyecciones para el lado de consultas:
#[derive(Entity)]
#[entity(table = "orders", commands)]
#[projection(Summary: id, status, total_cents, created_at)]
#[projection(Details: id, status, items, shipping_address, total_cents)]
#[command(Place)]
#[command(Ship, requires_id)]
#[command(Cancel, requires_id)]
pub struct Order { /* ... */ }
// Comandos (lado de escritura)
let result = handler.handle(OrderCommand::Place(place_order), &ctx).await?;
// Consultas (lado de lectura)
let summary = repo.find_by_id_summary(order_id).await?;
let details = repo.find_by_id_details(order_id).await?;
Ver También
- [[Hooks|Ganchos]] — Hooks del ciclo de vida incluyendo hooks de comandos
- [[Eventos|Eventos]] — Generación de eventos para auditoría
- [[Atributos|Atributos]] — Referencia completa de atributos
Transacciones
Transacciones multi-entidad con tipado seguro y commit/rollback automatico.
Que son las transacciones?
Una transaccion de base de datos es una forma de agrupar multiples operaciones de base de datos en una sola unidad atomica. Esto significa:
- Todo o nada: O TODAS las operaciones tienen exito, o NINGUNA se aplica
- Rollback automatico: Si alguna operacion falla, todos los cambios anteriores se deshacen automaticamente
- Consistencia de datos: Tu base de datos nunca termina en un estado inconsistente
Por que necesitas transacciones?
Imagina que estas construyendo una app bancaria y necesitas transferir dinero entre cuentas:
1. Restar $100 de la Cuenta A
2. Sumar $100 a la Cuenta B
Sin transacciones, si el paso 1 tiene exito pero el paso 2 falla (error de red, caida de BD, etc.), acabas de perder $100! El dinero se resto de A pero nunca se agrego a B.
Con transacciones, si el paso 2 falla, el paso 1 se revierte automaticamente. El dinero permanece en la Cuenta A como si nada hubiera pasado.
Habilitando Transacciones
Agrega el atributo transactions a tu entidad:
use entity_derive::Entity;
use uuid::Uuid;
#[derive(Entity)]
#[entity(table = "accounts", transactions)] // <- Agrega esto
pub struct Account {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub user_id: Uuid,
#[field(create, update, response)]
pub balance: i64,
}
Que se genera?
Para una entidad Account con #[entity(transactions)], el macro genera:
1. Adaptador de Repositorio para Transacciones
pub struct AccountTransactionRepo<'t> {
tx: &'t mut sqlx::Transaction<'static, sqlx::Postgres>,
}
Es como tu repositorio regular, pero todas las operaciones ocurren dentro de la transaccion.
2. Trait de Extension del Builder
pub trait TransactionWithAccount<'p> {
fn with_accounts(self) -> Transaction<'p, PgPool, AccountTransactionRepo<'static>>;
}
Esto agrega el metodo with_accounts() al builder de transacciones.
Metodos Disponibles
Dentro de una transaccion, tienes acceso a estos metodos:
| Metodo | Firma | Descripcion |
|---|---|---|
create | create(dto) -> Result<Entity, Error> | Insertar nuevo registro |
find_by_id | find_by_id(id) -> Result<Option<Entity>, Error> | Buscar por clave primaria |
update | update(id, dto) -> Result<Entity, Error> | Actualizar registro existente |
delete | delete(id) -> Result<bool, Error> | Eliminar registro (o soft-delete) |
list | list(limit, offset) -> Result<Vec<Entity>, Error> | Lista paginada |
Ejemplo Basico
use entity_core::prelude::*;
async fn create_account(pool: &PgPool, user_id: Uuid) -> Result<Account, AppError> {
Transaction::new(pool) // 1. Comenzar a construir transaccion
.with_accounts() // 2. Agregar repositorio Account
.run(|mut ctx| async move { // 3. Ejecutar operaciones
let account = ctx.accounts().create(CreateAccountRequest {
user_id,
balance: 0,
}).await?;
Ok(account) // 4. Retornar resultado (auto-commit)
})
.await
}
Paso a Paso:
Transaction::new(pool)- Crea un nuevo builder de transaccion con tu pool de base de datos.with_accounts()- Agrega el repositorio Account al contexto de transaccion.run(|mut ctx| async move { ... })- Ejecuta tus operaciones dentro de la transaccionOk(account)- RetornarOkhace commit de la transaccion. RetornarErrhace rollback.
Ejemplo Completo: Transferencia de Dinero
Este ejemplo muestra todo el poder de las transacciones:
use entity_core::prelude::*;
use uuid::Uuid;
#[derive(Debug)]
pub enum TransferError {
Database(sqlx::Error),
AccountNotFound(Uuid),
InsufficientFunds { available: i64, requested: i64 },
}
impl From<sqlx::Error> for TransferError {
fn from(e: sqlx::Error) -> Self {
TransferError::Database(e)
}
}
impl From<TransactionError<sqlx::Error>> for TransferError {
fn from(e: TransactionError<sqlx::Error>) -> Self {
TransferError::Database(e.into_inner())
}
}
/// Transferir dinero entre dos cuentas atomicamente.
///
/// Si CUALQUIER paso falla, todos los cambios se revierten automaticamente.
pub async fn transfer(
pool: &PgPool,
from_id: Uuid,
to_id: Uuid,
amount: i64,
) -> Result<(), TransferError> {
Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move {
// Paso 1: Obtener cuenta origen
let from = ctx.accounts()
.find_by_id(from_id)
.await?
.ok_or(TransferError::AccountNotFound(from_id))?;
// Paso 2: Verificar si tiene suficiente dinero
if from.balance < amount {
return Err(TransferError::InsufficientFunds {
available: from.balance,
requested: amount,
});
}
// Paso 3: Obtener cuenta destino
let to = ctx.accounts()
.find_by_id(to_id)
.await?
.ok_or(TransferError::AccountNotFound(to_id))?;
// Paso 4: Restar del origen
// Si esto tiene exito pero el paso 5 falla, esto se REVERTIRA
ctx.accounts().update(from_id, UpdateAccountRequest {
balance: Some(from.balance - amount),
user_id: None,
}).await?;
// Paso 5: Sumar al destino
ctx.accounts().update(to_id, UpdateAccountRequest {
balance: Some(to.balance + amount),
user_id: None,
}).await?;
// Todas las operaciones exitosas - la transaccion hara COMMIT
Ok(())
})
.await
}
Que pasa en diferentes escenarios:
| Escenario | Resultado |
|---|---|
| Ambas actualizaciones exitosas | Transaccion hace commit, dinero transferido |
| Cuenta origen no encontrada | Transaccion hace rollback (sin cambios) |
| Fondos insuficientes | Transaccion hace rollback (sin cambios) |
| Primera actualizacion exitosa, segunda falla | Transaccion hace rollback (primera actualizacion deshecha!) |
| Error de red a mitad de transaccion | Transaccion hace rollback (sin cambios parciales) |
Multiples Entidades en Una Transaccion
Puedes operar en multiples entidades atomicamente:
#[derive(Entity)]
#[entity(table = "accounts", transactions)]
pub struct Account {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub balance: i64,
}
#[derive(Entity)]
#[entity(table = "transfer_logs", transactions)]
pub struct TransferLog {
#[id]
pub id: Uuid,
#[field(create, response)]
pub from_account_id: Uuid,
#[field(create, response)]
pub to_account_id: Uuid,
#[field(create, response)]
pub amount: i64,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
}
async fn transfer_with_logging(
pool: &PgPool,
from_id: Uuid,
to_id: Uuid,
amount: i64,
) -> Result<TransferLog, AppError> {
Transaction::new(pool)
.with_accounts() // Agregar repo Account
.with_transfer_logs() // Agregar repo TransferLog
.run(|mut ctx| async move {
// Actualizar saldos
let from = ctx.accounts().find_by_id(from_id).await?
.ok_or(AppError::NotFound)?;
ctx.accounts().update(from_id, UpdateAccountRequest {
balance: Some(from.balance - amount),
}).await?;
let to = ctx.accounts().find_by_id(to_id).await?
.ok_or(AppError::NotFound)?;
ctx.accounts().update(to_id, UpdateAccountRequest {
balance: Some(to.balance + amount),
}).await?;
// Crear entrada de log - todo en la misma transaccion!
let log = ctx.transfer_logs().create(CreateTransferLogRequest {
from_account_id: from_id,
to_account_id: to_id,
amount,
}).await?;
Ok(log)
})
.await
}
Si la creacion del log falla, ambas actualizaciones de cuentas se revierten!
Manejo de Errores
Rollback Automatico
Cualquier error retornado desde el closure dispara un rollback:
Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move {
ctx.accounts().update(id, dto).await?; // Exito
// Alguna validacion falla
if amount < 0 {
return Err(AppError::InvalidAmount); // <- Dispara rollback!
}
// Esto nunca se ejecuta, y la actualizacion de arriba se deshace
ctx.accounts().update(other_id, other_dto).await?;
Ok(())
})
.await
Tipos de Error de Transaccion
El enum TransactionError te dice que salio mal:
use entity_core::transaction::TransactionError;
let result = Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move { /* ... */ })
.await;
match result {
Ok(value) => {
println!("Exito: {:?}", value);
}
Err(e) => {
if e.is_begin() {
println!("Fallo al iniciar transaccion");
} else if e.is_operation() {
println!("Operacion fallo: {}", e);
} else if e.is_commit() {
println!("Fallo al hacer commit");
} else if e.is_rollback() {
println!("Fallo al hacer rollback");
}
// Obtener error interno de base de datos
let db_error: sqlx::Error = e.into_inner();
}
}
Con Soft Delete
Las transacciones respetan el atributo soft_delete:
#[derive(Entity)]
#[entity(table = "documents", transactions, soft_delete)]
pub struct Document {
#[id]
pub id: Uuid,
#[field(create, response)]
pub title: String,
#[field(skip)]
pub deleted_at: Option<DateTime<Utc>>, // Requerido para soft_delete
}
async fn archive_document(pool: &PgPool, id: Uuid) -> Result<bool, AppError> {
Transaction::new(pool)
.with_documents()
.run(|mut ctx| async move {
// Esto establece deleted_at = NOW() en lugar de DELETE
let deleted = ctx.documents().delete(id).await?;
Ok(deleted)
})
.await
}
Mejores Practicas
1. Manten las Transacciones Cortas
Malo: Transacciones largas
Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move {
let account = ctx.accounts().find_by_id(id).await?;
// NO: Llamar APIs externas dentro de transacciones
let rate = external_api.get_exchange_rate().await?; // <- LENTO!
ctx.accounts().update(id, dto).await?;
Ok(())
})
.await
Bueno: Operaciones lentas afuera
// Obtener datos externos ANTES de iniciar transaccion
let rate = external_api.get_exchange_rate().await?;
Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move {
ctx.accounts().update(id, UpdateAccountRequest {
balance: Some(calculate_new_balance(rate)),
}).await?;
Ok(())
})
.await
2. No Uses Transacciones para Operaciones Simples
Innecesario:
Transaction::new(pool)
.with_users()
.run(|mut ctx| async move {
ctx.users().find_by_id(id).await // Solo una operacion!
})
.await
Mejor: Usa repositorio regular
pool.find_by_id(id).await // No se necesita transaccion
3. Maneja Todos los Errores Correctamente
Siempre propaga errores con ?:
Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move {
let result = ctx.accounts().update(id, dto).await;
// NO: Tragarse errores
if let Err(e) = result {
log::error!("Update fallo: {}", e);
// La transaccion no hara rollback correctamente!
}
// SI: Propagar errores
ctx.accounts().update(id, dto).await?; // <- Usa ?
Ok(())
})
.await
Patrones Comunes
Verificar-Luego-Actualizar
Transaction::new(pool)
.with_products()
.run(|mut ctx| async move {
let product = ctx.products().find_by_id(id).await?
.ok_or(AppError::NotFound)?;
if product.stock < quantity {
return Err(AppError::OutOfStock);
}
ctx.products().update(id, UpdateProductRequest {
stock: Some(product.stock - quantity),
..Default::default()
}).await?;
Ok(product)
})
.await
Crear Multiples Registros Relacionados
Transaction::new(pool)
.with_orders()
.with_order_items()
.run(|mut ctx| async move {
// Crear padre
let order = ctx.orders().create(CreateOrderRequest {
customer_id,
status: "pending".to_string(),
}).await?;
// Crear hijos
for item in items {
ctx.order_items().create(CreateOrderItemRequest {
order_id: order.id,
product_id: item.product_id,
quantity: item.quantity,
}).await?;
}
Ok(order)
})
.await
Ver Tambien
- [[Atributos|Referencia de Atributos]] - Documentacion completa de atributos
- [[Ganchos|Hooks de Ciclo de Vida]] - Ejecutar codigo antes/despues de operaciones
- [[Comandos|Comandos]] - Patron de comandos CQRS
- [[Eventos|Eventos]] - Rastrear cambios de entidades
SQL Personalizado
Cuando el SQL generado automáticamente no es suficiente, usa sql = "trait" para control total.
Cuándo Usar SQL Personalizado
- Joins — Entidades relacionadas en una sola consulta
- CTEs — Consultas recursivas o de múltiples pasos
- Búsqueda full-text — PostgreSQL
tsvector/tsquery - Agregaciones —
GROUP BY,HAVING, funciones de ventana - Tablas particionadas — Particiones basadas en tiempo o rango
- Operaciones bulk — Inserciones/actualizaciones por lotes
- Borrado lógico — Lógica de eliminación personalizada
- Bloqueo optimista — Concurrencia basada en versión
Configuración Básica
#[derive(Entity)]
#[entity(table = "posts", schema = "blog", sql = "trait")]
pub struct Post {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub title: String,
#[field(create, response)]
pub author_id: Uuid,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
}
Esto genera:
- Todos los DTOs (
CreatePostRequest,UpdatePostRequest,PostResponse) PostRoweInsertablePost- Trait
PostRepository - Todas las implementaciones
From
Pero no genera impl PostRepository for PgPool.
Implementando el Repository
use async_trait::async_trait;
use sqlx::PgPool;
#[async_trait]
impl PostRepository for PgPool {
type Error = sqlx::Error;
async fn create(&self, dto: CreatePostRequest) -> Result<Post, Self::Error> {
let entity = Post::from(dto);
let insertable = InsertablePost::from(&entity);
sqlx::query(
r#"
INSERT INTO blog.posts (id, title, author_id, created_at)
VALUES ($1, $2, $3, $4)
"#
)
.bind(insertable.id)
.bind(&insertable.title)
.bind(insertable.author_id)
.bind(insertable.created_at)
.execute(self)
.await?;
Ok(entity)
}
async fn find_by_id(&self, id: Uuid) -> Result<Option<Post>, Self::Error> {
let row: Option<PostRow> = sqlx::query_as(
"SELECT id, title, author_id, created_at FROM blog.posts WHERE id = $1"
)
.bind(&id)
.fetch_optional(self)
.await?;
Ok(row.map(Post::from))
}
async fn update(&self, id: Uuid, dto: UpdatePostRequest) -> Result<Post, Self::Error> {
// Tu lógica de actualización personalizada
todo!()
}
async fn delete(&self, id: Uuid) -> Result<bool, Self::Error> {
let result = sqlx::query("DELETE FROM blog.posts WHERE id = $1")
.bind(&id)
.execute(self)
.await?;
Ok(result.rows_affected() > 0)
}
async fn list(&self, limit: i64, offset: i64) -> Result<Vec<Post>, Self::Error> {
let rows: Vec<PostRow> = sqlx::query_as(
"SELECT id, title, author_id, created_at FROM blog.posts ORDER BY created_at DESC LIMIT $1 OFFSET $2"
)
.bind(limit)
.bind(offset)
.fetch_all(self)
.await?;
Ok(rows.into_iter().map(Post::from).collect())
}
}
Ejemplo: Posts con Join de Autor
// Respuesta extendida con datos del autor
pub struct PostWithAuthor {
pub post: Post,
pub author: User,
}
// Extensión de repository personalizada
pub trait PostRepositoryExt: PostRepository {
async fn find_with_author(&self, id: Uuid) -> Result<Option<PostWithAuthor>, Self::Error>;
async fn list_with_authors(&self, limit: i64, offset: i64) -> Result<Vec<PostWithAuthor>, Self::Error>;
}
#[async_trait]
impl PostRepositoryExt for PgPool {
async fn find_with_author(&self, id: Uuid) -> Result<Option<PostWithAuthor>, Self::Error> {
let row = sqlx::query_as::<_, (PostRow, UserRow)>(
r#"
SELECT
p.id, p.title, p.author_id, p.created_at,
u.id, u.username, u.email, u.created_at
FROM blog.posts p
JOIN auth.users u ON u.id = p.author_id
WHERE p.id = $1
"#
)
.bind(&id)
.fetch_optional(self)
.await?;
Ok(row.map(|(p, u)| PostWithAuthor {
post: Post::from(p),
author: User::from(u),
}))
}
async fn list_with_authors(&self, limit: i64, offset: i64) -> Result<Vec<PostWithAuthor>, Self::Error> {
// Consulta similar con join y paginación
todo!()
}
}
Ejemplo: Búsqueda Full-Text
pub trait PostSearchRepository {
async fn search(&self, query: &str, limit: i64) -> Result<Vec<Post>, sqlx::Error>;
}
#[async_trait]
impl PostSearchRepository for PgPool {
async fn search(&self, query: &str, limit: i64) -> Result<Vec<Post>, sqlx::Error> {
let rows: Vec<PostRow> = sqlx::query_as(
r#"
SELECT id, title, author_id, created_at
FROM blog.posts
WHERE to_tsvector('english', title || ' ' || content) @@ plainto_tsquery('english', $1)
ORDER BY ts_rank(to_tsvector('english', title || ' ' || content), plainto_tsquery('english', $1)) DESC
LIMIT $2
"#
)
.bind(query)
.bind(limit)
.fetch_all(self)
.await?;
Ok(rows.into_iter().map(Post::from).collect())
}
}
Ejemplo: Borrado Lógico
#[derive(Entity)]
#[entity(table = "posts", sql = "trait")]
pub struct Post {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub title: String,
#[field(response)]
pub deleted_at: Option<DateTime<Utc>>,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
}
#[async_trait]
impl PostRepository for PgPool {
// ... otros métodos
async fn delete(&self, id: Uuid) -> Result<bool, Self::Error> {
// Borrado lógico en lugar de borrado físico
let result = sqlx::query(
"UPDATE blog.posts SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL"
)
.bind(&id)
.execute(self)
.await?;
Ok(result.rows_affected() > 0)
}
async fn list(&self, limit: i64, offset: i64) -> Result<Vec<Post>, Self::Error> {
// Excluir borrados lógicos
let rows: Vec<PostRow> = sqlx::query_as(
r#"
SELECT id, title, deleted_at, created_at
FROM blog.posts
WHERE deleted_at IS NULL
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
"#
)
.bind(limit)
.bind(offset)
.fetch_all(self)
.await?;
Ok(rows.into_iter().map(Post::from).collect())
}
}
// Método adicional para administradores
pub trait PostAdminRepository {
async fn restore(&self, id: Uuid) -> Result<bool, sqlx::Error>;
async fn hard_delete(&self, id: Uuid) -> Result<bool, sqlx::Error>;
async fn list_deleted(&self, limit: i64, offset: i64) -> Result<Vec<Post>, sqlx::Error>;
}
Ejemplo: Bloqueo Optimista
#[derive(Entity)]
#[entity(table = "documents", sql = "trait")]
pub struct Document {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub content: String,
#[field(response)]
pub version: i64,
#[auto]
#[field(response)]
pub updated_at: DateTime<Utc>,
}
#[derive(Debug)]
pub enum DocumentError {
Sqlx(sqlx::Error),
ConcurrentModification,
}
#[async_trait]
impl DocumentRepository for PgPool {
type Error = DocumentError;
async fn update(&self, id: Uuid, dto: UpdateDocumentRequest) -> Result<Document, Self::Error> {
// Requiere versión actual para bloqueo optimista
let expected_version = dto.version.ok_or(DocumentError::ConcurrentModification)?;
let row: Option<DocumentRow> = sqlx::query_as(
r#"
UPDATE documents
SET content = COALESCE($1, content),
version = version + 1,
updated_at = NOW()
WHERE id = $2 AND version = $3
RETURNING id, content, version, updated_at
"#
)
.bind(&dto.content)
.bind(&id)
.bind(expected_version)
.fetch_optional(self)
.await
.map_err(DocumentError::Sqlx)?;
row.map(Document::from)
.ok_or(DocumentError::ConcurrentModification)
}
// ... otros métodos
}
Mejores Prácticas para SQL Personalizado
- Usa
query_ascon estructuras Row — Mapeo type-safe - Vincula todos los parámetros — Nunca interpoles strings
- Retorna Row, convierte a Entity — Usa las impl
Fromgeneradas - Extiende, no reemplaces — Añade traits personalizados junto a
Repository - Prueba con base de datos real — Los tests de integración son esenciales
Ver También
- [[Atributos|Atributos]] — Referencia completa de atributos
- [[Ejemplos|Ejemplos]] — Ejemplos del mundo real
- [[Mejores Prácticas|Mejores-Prácticas]] — Consejos de rendimiento
Frameworks Web
Cómo usar entity-derive con frameworks web populares de Rust.
Axum
Estructura del Proyecto
src/
├── main.rs
├── entities/
│ ├── mod.rs
│ └── user.rs
├── handlers/
│ ├── mod.rs
│ └── users.rs
└── routes.rs
Definición de Entidad
// src/entities/user.rs
use entity_derive::Entity;
use uuid::Uuid;
use chrono::{DateTime, Utc};
#[derive(Entity, Clone)]
#[entity(table = "users", schema = "auth")]
pub struct User {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub username: String,
#[field(create, update, response)]
pub email: String,
#[field(skip)]
pub password_hash: String,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
}
Handlers
// src/handlers/users.rs
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use sqlx::PgPool;
use uuid::Uuid;
use crate::entities::user::*;
pub async fn create_user(
State(pool): State<PgPool>,
Json(payload): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<UserResponse>), StatusCode> {
let user = pool
.create(payload)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok((StatusCode::CREATED, Json(UserResponse::from(&user))))
}
pub async fn get_user(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<UserResponse>, StatusCode> {
let user = pool
.find_by_id(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(UserResponse::from(&user)))
}
pub async fn update_user(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateUserRequest>,
) -> Result<Json<UserResponse>, StatusCode> {
let user = pool
.update(id, payload)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(UserResponse::from(&user)))
}
pub async fn delete_user(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, StatusCode> {
let deleted = pool
.delete(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if deleted {
Ok(StatusCode::NO_CONTENT)
} else {
Err(StatusCode::NOT_FOUND)
}
}
pub async fn list_users(
State(pool): State<PgPool>,
) -> Result<Json<Vec<UserResponse>>, StatusCode> {
let users = pool
.list(100, 0)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let responses: Vec<UserResponse> = users.iter().map(UserResponse::from).collect();
Ok(Json(responses))
}
Rutas
// src/routes.rs
use axum::{
routing::{get, post, put, delete},
Router,
};
use sqlx::PgPool;
use crate::handlers::users;
pub fn create_router(pool: PgPool) -> Router {
Router::new()
.route("/users", post(users::create_user))
.route("/users", get(users::list_users))
.route("/users/:id", get(users::get_user))
.route("/users/:id", put(users::update_user))
.route("/users/:id", delete(users::delete_user))
.with_state(pool)
}
Main
// src/main.rs
use sqlx::postgres::PgPoolOptions;
use std::net::SocketAddr;
mod entities;
mod handlers;
mod routes;
#[tokio::main]
async fn main() {
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to create pool");
let app = routes::create_router(pool);
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
println!("Listening on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Actix Web
Handlers
// src/handlers/users.rs
use actix_web::{web, HttpResponse, Responder};
use sqlx::PgPool;
use uuid::Uuid;
use crate::entities::user::*;
pub async fn create_user(
pool: web::Data<PgPool>,
payload: web::Json<CreateUserRequest>,
) -> impl Responder {
match pool.create(payload.into_inner()).await {
Ok(user) => HttpResponse::Created().json(UserResponse::from(&user)),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
pub async fn get_user(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
) -> impl Responder {
let id = path.into_inner();
match pool.find_by_id(id).await {
Ok(Some(user)) => HttpResponse::Ok().json(UserResponse::from(&user)),
Ok(None) => HttpResponse::NotFound().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
pub async fn update_user(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
payload: web::Json<UpdateUserRequest>,
) -> impl Responder {
let id = path.into_inner();
match pool.update(id, payload.into_inner()).await {
Ok(user) => HttpResponse::Ok().json(UserResponse::from(&user)),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
pub async fn delete_user(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
) -> impl Responder {
let id = path.into_inner();
match pool.delete(id).await {
Ok(true) => HttpResponse::NoContent().finish(),
Ok(false) => HttpResponse::NotFound().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
pub async fn list_users(pool: web::Data<PgPool>) -> impl Responder {
match pool.list(100, 0).await {
Ok(users) => {
let responses: Vec<UserResponse> = users.iter().map(UserResponse::from).collect();
HttpResponse::Ok().json(responses)
}
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
Rutas
// src/routes.rs
use actix_web::web;
use crate::handlers::users;
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/users")
.route("", web::post().to(users::create_user))
.route("", web::get().to(users::list_users))
.route("/{id}", web::get().to(users::get_user))
.route("/{id}", web::put().to(users::update_user))
.route("/{id}", web::delete().to(users::delete_user)),
);
}
Main
// src/main.rs
use actix_web::{App, HttpServer, web};
use sqlx::postgres::PgPoolOptions;
mod entities;
mod handlers;
mod routes;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to create pool");
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(pool.clone()))
.configure(routes::configure)
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
Manejo de Errores
Mejor manejo de errores con tipos personalizados:
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub enum AppError {
NotFound,
Database(sqlx::Error),
Validation(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::NotFound => (StatusCode::NOT_FOUND, "Recurso no encontrado"),
AppError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Error de base de datos"),
AppError::Validation(msg) => (StatusCode::BAD_REQUEST, msg.as_str()),
};
(status, Json(json!({ "error": message }))).into_response()
}
}
impl From<sqlx::Error> for AppError {
fn from(err: sqlx::Error) -> Self {
AppError::Database(err)
}
}
// Uso en handler:
pub async fn get_user(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<UserResponse>, AppError> {
let user = pool
.find_by_id(id)
.await?
.ok_or(AppError::NotFound)?;
Ok(Json(UserResponse::from(&user)))
}
Validación
Añade validación con el crate validator:
use validator::Validate;
// Añade manualmente el derive Validate a la estructura generada
// o valida antes de llamar métodos del repositorio
pub async fn create_user(
State(pool): State<PgPool>,
Json(payload): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<UserResponse>), AppError> {
// Validación manual
if payload.username.len() < 3 {
return Err(AppError::Validation("Nombre de usuario muy corto".into()));
}
if !payload.email.contains('@') {
return Err(AppError::Validation("Email inválido".into()));
}
let user = pool.create(payload).await?;
Ok((StatusCode::CREATED, Json(UserResponse::from(&user))))
}
Paginación
Ejemplo de helper de paginación:
use serde::Deserialize;
#[derive(Deserialize)]
pub struct Pagination {
#[serde(default = "default_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
}
fn default_limit() -> i64 {
20
}
pub async fn list_users(
State(pool): State<PgPool>,
Query(pagination): Query<Pagination>,
) -> Result<Json<Vec<UserResponse>>, AppError> {
let users = pool
.list(pagination.limit.min(100), pagination.offset)
.await?;
let responses: Vec<UserResponse> = users.iter().map(UserResponse::from).collect();
Ok(Json(responses))
}
Ver También
- [[Ejemplos|Ejemplos]] — Ejemplos del mundo real
- [[Mejores Prácticas|Mejores-Prácticas]] — Guías de producción
- [[Atributos|Atributos]] — Referencia completa de atributos
Mejores Prácticas
Guías para usar entity-derive efectivamente en producción.
Diseño de Entidades
Mantén las Entidades Enfocadas
Una entidad por tabla de base de datos. No intentes modelar relaciones complejas en una sola entidad.
// Bueno: Entidades separadas
#[derive(Entity)]
#[entity(table = "users")]
pub struct User {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub name: String,
}
#[derive(Entity)]
#[entity(table = "posts")]
pub struct Post {
#[id]
pub id: Uuid,
#[field(create, response)]
pub author_id: Uuid, // Referencia, no embeber
#[field(create, update, response)]
pub title: String,
}
// Malo: Intentar embeber relaciones
pub struct User {
pub id: Uuid,
pub posts: Vec<Post>, // No hagas esto
}
Usa Atributos de Campo Significativos
Sé explícito sobre el propósito de cada campo:
// Bueno: Intención clara
#[field(create, response)] // Se establece una vez, siempre visible
pub email: String,
#[field(update, response)] // Puede cambiar, siempre visible
pub display_name: Option<String>,
#[field(response)] // Solo lectura, calculado/gestionado externamente
pub post_count: i64,
#[field(skip)] // Nunca expuesto
pub password_hash: String,
// Malo: Todo en todas partes
#[field(create, update, response)] // ¿Es realmente necesario para todo?
pub internal_id: String,
Prefiere Option para Campos Nulables
Haz coincidir con tu esquema de base de datos:
// Base de datos: email VARCHAR NOT NULL
#[field(create, update, response)]
pub email: String,
// Base de datos: bio TEXT NULL
#[field(update, response)]
pub bio: Option<String>,
Seguridad
Siempre Usa #[field(skip)] para Datos Sensibles
// Contraseñas
#[field(skip)]
pub password_hash: String,
// Claves API
#[field(skip)]
pub api_key: String,
// Tokens internos
#[field(skip)]
pub refresh_token: Option<String>,
// PII que no debería estar en respuestas
#[field(skip)]
pub ssn: String,
// Datos de auditoría interna
#[field(skip)]
pub created_by_ip: String,
Separa Entidades Internas y Externas
Para datos solo de administrador, considera entidades separadas:
// Entidad pública
#[derive(Entity)]
#[entity(table = "users")]
pub struct User {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub name: String,
#[field(skip)]
pub admin_notes: Option<String>,
}
// Entidad solo admin (misma tabla, vista diferente)
#[derive(Entity)]
#[entity(table = "users", sql = "trait")]
pub struct AdminUser {
#[id]
pub id: Uuid,
#[field(response)]
pub name: String,
#[field(update, response)] // Ahora visible y editable
pub admin_notes: Option<String>,
#[field(response)]
pub last_login_ip: Option<String>,
}
Rendimiento
Usa sql = "trait" para Consultas Complejas
No luches contra el SQL generado. Si necesitas joins o lógica compleja, impleméntalo tú mismo:
// CRUD simple - usa generación completa
#[entity(table = "categories", sql = "full")]
// Consultas complejas necesarias - implementa tú mismo
#[entity(table = "posts", sql = "trait")]
Operaciones por Lotes
Para inserciones masivas, implementa métodos personalizados:
#[entity(table = "events", sql = "trait")]
pub struct Event { /* ... */ }
pub trait EventBatchRepository {
async fn create_batch(&self, events: Vec<CreateEventRequest>) -> Result<(), sqlx::Error>;
}
#[async_trait]
impl EventBatchRepository for PgPool {
async fn create_batch(&self, events: Vec<CreateEventRequest>) -> Result<(), sqlx::Error> {
let mut tx = self.begin().await?;
for event in events {
let entity = Event::from(event);
let insertable = InsertableEvent::from(&entity);
// Insertar dentro de transacción
}
tx.commit().await?;
Ok(())
}
}
Evita Consultas N+1
Usa joins en lugar de cargar entidades relacionadas una por una:
// Malo: Consultas N+1
let posts = pool.list(100, 0).await?;
for post in &posts {
let author = pool.find_user_by_id(post.author_id).await?; // ¡N consultas!
}
// Bueno: Una sola consulta con join
let posts_with_authors = pool.list_with_authors(100, 0).await?; // 1 consulta
Testing
Usa Base de Datos de Prueba Separada
#[cfg(test)]
mod tests {
use sqlx::PgPool;
async fn setup_test_db() -> PgPool {
let url = std::env::var("TEST_DATABASE_URL")
.expect("TEST_DATABASE_URL must be set");
let pool = PgPool::connect(&url).await.unwrap();
// Ejecutar migraciones
sqlx::migrate!("./migrations")
.run(&pool)
.await
.unwrap();
pool
}
#[tokio::test]
async fn test_create_user() {
let pool = setup_test_db().await;
let request = CreateUserRequest {
username: "test_user".into(),
email: "test@example.com".into(),
};
let user = pool.create(request).await.unwrap();
assert_eq!(user.username, "test_user");
}
}
Prueba DTOs Separadamente
#[test]
fn test_user_response_excludes_password() {
let user = User {
id: Uuid::new_v4(),
username: "test".into(),
email: "test@example.com".into(),
password_hash: "secret_hash".into(),
created_at: Utc::now(),
};
let response = UserResponse::from(&user);
// password_hash no está en UserResponse
assert_eq!(response.username, "test");
// No hay forma de acceder a password_hash a través de response
}
#[test]
fn test_update_request_is_partial() {
let update = UpdateUserRequest {
username: Some("new_name".into()),
email: None, // No actualizando email
};
assert!(update.username.is_some());
assert!(update.email.is_none());
}
Organización del Proyecto
Estructura Recomendada
src/
├── entities/ # Definiciones de entidades
│ ├── mod.rs
│ ├── user.rs
│ ├── post.rs
│ └── comment.rs
├── repositories/ # Extensiones de repository personalizadas
│ ├── mod.rs
│ └── post_search.rs
├── handlers/ # Handlers HTTP
│ ├── mod.rs
│ ├── users.rs
│ └── posts.rs
├── services/ # Lógica de negocio
│ ├── mod.rs
│ └── auth.rs
└── main.rs
Re-exporta Tipos Generados
// src/entities/mod.rs
mod user;
mod post;
pub use user::*;
pub use post::*;
Agrupa Entidades Relacionadas
// src/entities/auth/mod.rs
mod user;
mod session;
mod api_key;
pub use user::*;
pub use session::*;
pub use api_key::*;
Errores Comunes
1. Olvidar #[field(skip)] en Campos Sensibles
// Incorrecto: password_hash estará en Response!
pub struct User {
pub password_hash: String,
}
// Correcto
#[field(skip)]
pub password_hash: String,
2. Usar sql = "full" Cuando Necesitas Joins
Si necesitas datos relacionados, usa sql = "trait" e implementa tú mismo.
3. No Manejar Actualizaciones Opcionales
Recuerda: los campos de UpdateRequest son Option<T>. Verifica antes de aplicar:
// UpdateUserRequest generado tiene Option<String> para name
// Tu lógica de actualización debería manejar None (sin cambio) vs Some (cambio)
4. Duplicar Lógica de Negocio
Pon validación y reglas de negocio en una capa de servicio, no en handlers:
// Bueno: Capa de servicio
impl UserService {
pub async fn create_user(&self, request: CreateUserRequest) -> Result<User, AppError> {
self.validate_email(&request.email)?;
self.check_username_available(&request.username).await?;
self.pool.create(request).await.map_err(Into::into)
}
}
// Malo: Lógica dispersa en handlers
pub async fn create_user(pool: State<PgPool>, request: Json<CreateUserRequest>) -> ... {
// Validación aquí
// Reglas de negocio aquí
// Llamada al repository aquí
// Todo mezclado
}
Lista de Verificación
Antes de desplegar:
- Todos los campos sensibles tienen
#[field(skip)] - Los DTOs coinciden con las expectativas del contrato API
- Las consultas complejas usan
sql = "trait" - Los tests de integración cubren métodos del repository
- El manejo de errores es consistente
- La paginación está implementada para endpoints de lista
- Existen índices de base de datos para patrones de consulta
Ver También
- [[Atributos|Atributos]] — Referencia completa de atributos
- [[Ejemplos|Ejemplos]] — Ejemplos del mundo real
- [[Frameworks Web|Frameworks-Web]] — Integración con frameworks