Что это?
entity-derive — процедурный макрос Rust, который генерирует полноценный доменный слой из одного определения сущности. Не просто CRUD — архитектурный фреймворк с событиями, хуками, командами и типобезопасной фильтрацией.
Проблема
Типичный Rust backend на ~10 сущностей — это:
| Компонент | Строк кода | Проблемы |
|---|---|---|
| DTO (Create, Update, Response) | ~60 на сущность | Ручная синхронизация, забытые поля |
| Repository trait + impl | ~150 на сущность | SQL-ошибки в runtime, copy-paste |
| Маппинг Entity ↔ DTO | ~40 на сущность | Утечки данных (password_hash в Response) |
| Валидация и хуки | Разбросаны по сервисам | Дублирование, нет единого места |
| События/аудит | Отсутствуют или adhoc | Нет истории изменений |
Итого: ~2500 строк boilerplate для 10 сущностей. И каждое изменение схемы — ручная правка в 5+ местах.
Решение
#[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)] // Никогда не утечёт в API
pub password_hash: String,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
}
15 строк → полный доменный слой:
CreateUserRequest,UpdateUserRequest,UserResponseUserRepositoryс типобезопасным SQLUserEvent::Created,Updated,DeletedUserHooksдля бизнес-логикиRegisterUser,DeactivateUserкомандыUserQueryдля фильтрации
Просто для начинающих
Минимальный пример — 10 строк:
#[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,
}
Готово. У тебя есть:
CreatePostRequest,UpdatePostRequest,PostResponsePostRepositoryсcreate(),find_by_id(),update(),delete(),list()- Типобезопасный SQL для PostgreSQL
- Всё работает из коробки
Никакой магии. Открой cargo expand — увидишь ровно тот код, который написал бы сам. Только без ошибок и за секунды.
Почему события?
Проблема: В CRUD-приложениях нет истории. Кто изменил запись? Когда? Что было до этого? Аудит требует отдельной инфраструктуры и дисциплины.
Решение: #[entity(events)] генерирует типизированные события:
pub enum UserEvent {
Created(User),
Updated { id: Uuid, changes: UpdateUserRequest },
Deleted(Uuid),
}
Что это даёт:
- Аудит из коробки — подпишись на события, сохраняй в лог
- Event Sourcing — можно восстановить состояние из истории событий
- Интеграции — Kafka, WebSocket уведомления, инвалидация кэша
- Отладка — полная история изменений каждой сущности
Почему хуки?
Проблема: Бизнес-логика размазана. Валидация email — в контроллере. Хэширование пароля — в сервисе. Отправка письма — в отдельном воркере. Где искать логику создания пользователя?
Решение: #[entity(hooks)] централизует lifecycle:
impl UserHooks for MyHooks {
async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Error> {
dto.email = dto.email.to_lowercase(); // Нормализация
validate_email(&dto.email)?; // Валидация
Ok(())
}
async fn after_create(&self, user: &User) -> Result<(), Error> {
self.mailer.send_welcome(user).await?; // Бизнес-действие
Ok(())
}
}
Что это даёт:
- Единое место — вся логика сущности рядом с определением
- Предсказуемость — ясно когда что выполняется
- Тестируемость — хуки можно мокать и тестировать изолированно
- Композиция — разные реализации для разных контекстов
Почему команды?
Проблема: REST API скрывает намерение. POST /users — это регистрация? Создание админом? Импорт из CSV? PATCH /users/123 — деактивация? Смена email? Бан?
Решение: #[command(...)] выражает бизнес-домен:
#[command(Register)] // Самостоятельная регистрация
#[command(Invite)] // Приглашение админом
#[command(Deactivate, requires_id)] // Деактивация аккаунта
#[command(Ban, requires_id)] // Бан за нарушения
Сравни:
// CRUD (что происходит?)
pool.update(user_id, UpdateUserRequest { active: Some(false), ..default() }).await?;
// Команды (ясное намерение)
handler.handle(DeactivateUser { id: user_id }).await?;
Что это даёт:
- Самодокументируемый API — названия команд = бизнес-словарь
- Разная логика —
DeactivateиBanмогут иметь разные side-effects - CQRS готовность — команды легко маршрутизировать, логировать, ретраить
- Типобезопасность — компилятор проверяет что команда существует
Почему типобезопасная фильтрация?
Проблема: Строковые query params — источник runtime-ошибок:
GET /users?stauts=active // Опечатка — молча игнорируется
GET /users?created_at=tomorrow // Невалидная дата — panic в runtime
Решение: #[filter] генерирует типизированную структуру:
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?;
Что это даёт:
- Compile-time проверка — опечатка в имени поля = ошибка компиляции
- Типобезопасность —
DateTimeнельзя сравнить соString - Автодополнение — IDE подсказывает доступные фильтры
- SQL-injection защита — параметры биндятся, не конкатенируются
Прозрачность
Макрос не прячет логику. Всё что генерируется — это обычный Rust-код который ты можешь:
- Прочитать —
cargo expandпоказывает весь сгенерированный код - Понять — никаких runtime-рефлексий, только структуры и трейты
- Переопределить —
sql = "trait"и пиши свой SQL - Отладить — ошибки компилятора указывают на твой код, не на внутренности макроса
// Хочешь понять что генерируется?
cargo expand --lib | grep -A 50 "impl UserRepository"
Zero magic. Если макрос сломается — ты всегда можешь написать код руками. Он не создаёт lock-in.
Вся мощь Rust
Compile-time гарантии
#[field(skip)]
pub password_hash: String,
Это не runtime-проверка “не сериализуй это поле”. Это физическое отсутствие поля в структуре UserResponse. Невозможно случайно вернуть — поля просто нет.
Zero-cost abstractions
Сгенерированный код — это:
- Обычные
structбез Box/dyn - Прямые вызовы sqlx без промежуточных слоёв
#[inline]на горячих путях- Никаких аллокаций сверх необходимого
Benchmark: сгенерированный репозиторий работает с той же скоростью, что и написанный вручную. Потому что это и есть тот же код.
Async из коробки
// Всё async, всё Send + Sync
let user = pool.find_by_id(id).await?;
let users = pool.list(100, 0).await?;
Полная совместимость с tokio, async-std, любым async runtime.
Строгая типизация
// Ошибка компиляции: нет такого поля
let query = UserQuery { naem: "test".into(), ..default() };
^^^^ unknown field
// Ошибка компиляции: неверный тип
let query = UserQuery { created_at_min: "yesterday".into(), ..default() };
^^^^^^^^^^^^ expected DateTime<Utc>
Если код скомпилировался — он работает правильно.
Профессиональная архитектура
Clean Architecture готовность
Domain Layer (entity-derive)
├── Entities — #[derive(Entity)]
├── DTOs — CreateRequest, UpdateRequest, Response
├── Repository Trait — абстракция хранилища
├── Events — доменные события
├── Commands — бизнес-операции
└── Hooks — lifecycle логика
Infrastructure Layer (твой код)
├── Repository Impl — PgPool автоматически или свой
├── Event Handlers — подписка на события
├── Command Handlers — реализация бизнес-логики
└── External Services — интеграции
Чистое разделение. Domain не знает про HTTP, базу данных, Kafka. Всё это — детали реализации.
CQRS/Event Sourcing готовность
// 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?,
_ => {}
}
Хочешь простой CRUD? Получи. Хочешь полноценный CQRS? Включи commands и events. Архитектура растёт вместе с проектом.
Расширяемость
Уровень 1: Базовый CRUD
#[entity(table = "users")]
Уровень 2: + Фильтрация
#[entity(table = "users")]
// + #[filter] на полях
Уровень 3: + События и хуки
#[entity(table = "users", events, hooks)]
Уровень 4: + CQRS команды
#[entity(table = "users", events, hooks, commands)]
#[command(Register)]
#[command(Deactivate, requires_id)]
Уровень 5: Полный контроль
#[entity(table = "users", sql = "trait", events, hooks, commands)]
// Свой SQL, своя логика, но с сохранением всех DTO и типов
Начни просто. Добавляй фичи по мере роста. Не переписывай — расширяй.
Безопасность
#[field(skip)]
pub password_hash: String,
skip означает: это поле никогда не появится в:
CreateUserRequest(нельзя передать извне)UpdateUserRequest(нельзя изменить через API)UserResponse(нельзя случайно вернуть клиенту)
Единственный способ работать с password_hash — напрямую через entity в коде. Утечка невозможна по дизайну.
Почему это круто
| Аспект | Что получаешь |
|---|---|
| Скорость разработки | 10 сущностей за час вместо дня |
| Надёжность | Compile-time проверка всего |
| Безопасность | Невозможно случайно утечь данные |
| Производительность | Zero-cost, как написанный вручную код |
| Понятность | Прозрачная генерация, никакой магии |
| Гибкость | От простого CRUD до CQRS за один атрибут |
| Масштабируемость | Архитектура растёт с проектом |
| Поддерживаемость | Один источник истины, меньше багов |
Документация
| Тема | Описание |
|---|---|
| [[Атрибуты|Атрибуты]] | Полный справочник атрибутов |
| [[Фильтрация|Фильтрация]] | Типобезопасная фильтрация запросов |
| [[Связи|Связи]] | belongs_to и has_many |
| [[События|События]] | События жизненного цикла |
| [[Хуки|Хуки]] | Before/after хуки |
| [[Команды|Команды]] | CQRS-паттерн |
| [[Кастомный SQL|Кастомный-SQL]] | Сложные запросы |
| [[Примеры|Примеры]] | Реальные сценарии использования |
| [[Веб-фреймворки|Веб-фреймворки]] | Интеграция с Axum, Actix |
| [[Лучшие практики|Лучшие-практики]] | Рекомендации для продакшена |
Это не фреймворк который диктует как жить. Это инструмент который убирает рутину и даёт строить правильно.
Атрибуты
Полное руководство по всем атрибутам, поддерживаемым entity-derive.
Атрибуты уровня сущности
Применяются к структуре с помощью #[entity(...)]:
#[derive(Entity)]
#[entity(
table = "users",
schema = "core",
sql = "full",
dialect = "postgres",
uuid = "v7",
soft_delete,
returning = "full",
error = "AppError",
events,
hooks,
commands,
transactions
)]
pub struct User { /* ... */ }
Краткий справочник
| Атрибут | Обязательный | По умолчанию | Описание |
|---|---|---|---|
table | Да | — | Имя таблицы БД |
schema | Нет | "public" | Схема БД |
sql | Нет | "full" | Уровень генерации SQL |
dialect | Нет | "postgres" | Диалект БД |
uuid | Нет | "v7" | Версия UUID для генерации ID |
soft_delete | Нет | false | Включить мягкое удаление |
returning | Нет | "full" | Режим RETURNING |
upsert(...) | Нет | — | Генерация upsert-метода через INSERT ... ON CONFLICT |
api(guard = "...") | Нет | — | Принудительный FromRequestParts-гвард в генерируемых хендлерах |
error | Нет | sqlx::Error | Пользовательский тип ошибки |
events | Нет | false | Генерировать события жизненного цикла |
hooks | Нет | false | Генерировать трейт хуков |
commands | Нет | false | Включить паттерн CQRS-команд |
transactions | Нет | false | Генерировать адаптер для транзакций |
table (обязательный)
Имя таблицы базы данных.
#[entity(table = "users")] // → FROM users
#[entity(table = "user_profiles")] // → FROM user_profiles
schema (опциональный)
Схема базы данных. По умолчанию: "public".
#[entity(table = "users")] // → FROM public.users
#[entity(table = "users", schema = "core")] // → FROM core.users
#[entity(table = "users", schema = "auth")] // → FROM auth.users
sql (опциональный)
Уровень генерации SQL. По умолчанию: "full".
| Значение | Repository Trait | Реализация PgPool | Применение |
|---|---|---|---|
"full" | Да | Да | Стандартные CRUD-сущности |
"trait" | Да | Нет | Пользовательские запросы (joins, CTE) |
"none" | Нет | Нет | Только DTO, без БД |
#[entity(table = "users", sql = "full")] // Полная автоматизация (по умолчанию)
#[entity(table = "users", sql = "trait")] // Только трейт, SQL реализуете сами
#[entity(table = "users", sql = "none")] // Нет слоя БД вообще
dialect (опциональный)
Диалект БД для генерации SQL. По умолчанию: "postgres".
| Диалект | Алиасы | Тип клиента | Статус |
|---|---|---|---|
"postgres" | "pg", "postgresql" | sqlx::PgPool | Стабильно |
"clickhouse" | "ch" | clickhouse::Client | Планируется |
"mongodb" | "mongo" | mongodb::Client | Планируется |
uuid (опциональный)
Версия UUID для автоматически генерируемых первичных ключей. По умолчанию: "v7".
| Версия | Метод | Свойства |
|---|---|---|
"v7" | Uuid::now_v7() | Упорядочен по времени, сортируемый (рекомендуется) |
"v4" | Uuid::new_v4() | Случайный, широко совместимый |
#[entity(table = "users", uuid = "v7")] // Упорядоченный по времени (по умолчанию)
#[entity(table = "sessions", uuid = "v4")] // Случайный UUID
Почему UUID v7?
- Упорядочен по времени: естественная сортировка по времени создания
- Лучшая производительность индексов БД
- Не требует координации (в отличие от sequences)
- Глобально уникален в распределённых системах
soft_delete (опциональный)
Включает мягкое удаление для пометки записей как удалённых вместо их удаления.
#[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>>, // Обязательное поле
}
Генерируемые методы:
delete()— Устанавливаетdeleted_at = NOW()вместо DELETEhard_delete()— Окончательно удаляет записьrestore()— Устанавливаетdeleted_at = NULLfind_by_id()/list()— Автоматически фильтрует удалённые записиfind_by_id_with_deleted()/list_with_deleted()— Включает удалённые записи
returning (опциональный)
Управляет тем, какие данные возвращаются после INSERT/UPDATE. По умолчанию: "full".
| Режим | SQL-клауза | Применение |
|---|---|---|
"full" | RETURNING * | Получить все поля включая сгенерированные БД (по умолчанию) |
"id" | RETURNING id | Подтвердить вставку, вернуть готовую сущность |
"none" | (без RETURNING) | Fire-and-forget, самый быстрый вариант |
"col1, col2" | RETURNING col1, col2 | Вернуть определённые колонки |
#[entity(table = "logs", returning = "none")] // Самый быстрый
#[entity(table = "users", returning = "full")] // Получить сгенерированные БД значения
#[entity(table = "events", returning = "id, created_at")] // Пользовательские колонки
events(outbox) (опциональный)
Надёжная доставка событий через транзакционный outbox. Просто events лишь генерирует enum; со streams NOTIFY — fire-and-forget. events(outbox) заставляет каждую генерируемую запись вставлять сериализованное событие в таблицу entity_outbox в той же транзакции, а рантайм OutboxDrainer (entity-core, фича outbox) доставляет строки через FOR UPDATE SKIP LOCKED с экспоненциальным backoff и парковкой после max_attempts. At-least-once — обработчики должны быть идемпотентными. Совместимо со 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(...) (опциональный)
Генерирует метод репозитория upsert на основе 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,
}
| Опция | Обязательна | По умолчанию | Описание |
|---|---|---|---|
conflict | Да | — | Колонки конфликта через запятую |
action | Нет | "update" | "update" (DO UPDATE) или "nothing" (DO NOTHING) |
Генерируется:
action = "update"→async fn upsert(&self, dto: CreateUserRequest) -> Result<User, Error>— перезаписывает все неконфликтные колонки (DO UPDATE SET col = EXCLUDED.col) и возвращает сохранённую строкуaction = "nothing"→async fn upsert(&self, dto: CreateUserRequest) -> Result<Option<User>, Error>— существующая строка не изменяется;Noneозначает, что конфликтующая строка уже существовала
Проверки на этапе компиляции:
- колонки конфликта должны существовать и иметь гарантию уникальности (
#[id],#[column(unique)]или подходящийunique_index(...)) - требуется
returning = "full"(значение по умолчанию) - для
action = "update"нужна хотя бы одна неконфликтная обновляемая колонка
При включённых streams upsert публикует уведомление Created для каждой возвращённой строки.
api(guard = "...") (опциональный)
Принудительная аутентификация в генерируемых хендлерах. security = "..." лишь документирует auth в OpenAPI; guard внедряет axum-extractor (FromRequestParts) первым аргументом каждого генерируемого CRUD- и командного хендлера — неудачная экстракция отклоняет запрос до выполнения тела хендлера.
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 { /* ... */ }
Пооперационные оверрайды: guard(create = "Admin", list = "none", ...) с операциями create, get, update, delete, list, commands; литерал "none" отключает гвард. Команды из public = [...] гвард не получают.
error (опциональный)
Пользовательский тип ошибки для репозитория. По умолчанию: 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 { /* ... */ }
// Обязательно: преобразование из 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 { /* ... */ }
// Сгенерированный репозиторий использует AppError:
// impl UserRepository for PgPool {
// type Error = AppError;
// ...
// }
events (опциональный)
Генерирует enum событий жизненного цикла. Подробнее см. [[События|События]].
#[entity(table = "orders", events)]
Генерируется:
pub enum OrderEvent {
Created(Order),
Updated { id: Uuid, changes: UpdateOrderRequest },
Deleted(Uuid),
}
hooks (опциональный)
Генерирует трейт хуков жизненного цикла. Подробнее см. [[Хуки|Хуки]].
#[entity(table = "users", hooks)]
Генерируется:
#[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 (опциональный)
Включает паттерн CQRS-команд. Подробнее см. [[Команды|Команды]].
#[entity(table = "users", commands)]
#[command(Register)]
#[command(Deactivate, requires_id)]
transactions (опциональный)
Генерирует адаптер репозитория для типобезопасных транзакций с несколькими сущностями.
#[derive(Entity)]
#[entity(table = "accounts", transactions)]
pub struct Account {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub balance: i64,
}
Генерируется:
AccountTransactionRepo<'t>— Адаптер репозитория для контекста транзакции- Трейт
TransactionWithAccountс методомwith_accounts()
Использование:
use entity_core::prelude::*;
async fn transfer(pool: &PgPool, from: Uuid, to: Uuid, amount: i64) -> Result<(), AppError> {
Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move {
let from_acc = ctx.accounts().find_by_id(from).await?
.ok_or(AppError::NotFound)?;
ctx.accounts().update(from, UpdateAccountRequest {
balance: Some(from_acc.balance - amount),
}).await?;
ctx.accounts().update(to, UpdateAccountRequest {
balance: Some(to_acc.balance + amount),
}).await?;
Ok(())
})
.await
}
Методы транзакции:
create(dto)— Создать сущность в транзакцииfind_by_id(id)— Найти сущность по IDupdate(id, dto)— Обновить сущностьdelete(id)— Удалить сущность (учитываетsoft_delete)list(limit, offset)— Список сущностей
Особенности:
- Автоматический откат при ошибке или панике
- Типобезопасный паттерн builder
- Полная поддержка CRUD в транзакции
Атрибуты уровня поля
Применяются к отдельным полям.
#[id]
Отмечает поле первичного ключа.
Поведение:
- Автоматически генерирует UUID (v7 по умолчанию, настраивается атрибутом
uuid) - Всегда включается в
ResponseDTO - Исключается из
CreateRequestиUpdateRequest
#[id]
pub id: Uuid,
#[auto]
Отмечает автоматически генерируемые поля (timestamps, sequences).
Поведение:
- Получает
Default::default()вFrom<CreateRequest> - Исключается из
CreateRequestиUpdateRequest - Может включаться в
Responseс#[field(response)]
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
#[field(...)]
Управляет включением в DTO. Комбинируйте несколько опций:
#[field(create)] // Только в CreateRequest
#[field(update)] // Только в UpdateRequest
#[field(response)] // Только в Response
#[field(create, response)] // В Create и Response
#[field(create, update, response)] // Во всех трёх
#[field(skip)] // Исключено из всех DTO
create
Включает поле в CreateRequest DTO.
#[field(create)]
pub email: String,
// Генерируется:
pub struct CreateUserRequest {
pub email: String,
}
update
Включает поле в UpdateRequest DTO.
Важно: Необязательные поля автоматически оборачиваются в Option<T> для частичных обновлений.
#[field(update)]
pub name: String, // Не Option
// Генерируется:
pub struct UpdateUserRequest {
pub name: Option<String>, // Обёрнуто автоматически
}
response
Включает поле в Response DTO.
#[field(response)]
pub email: String,
// Генерируется:
pub struct UserResponse {
pub id: Uuid, // Всегда включено (имеет #[id])
pub email: String, // Включено
}
skip
Исключает поле из всех DTO. Используйте для конфиденциальных данных.
#[field(skip)]
pub password_hash: String,
Важно: skip перекрывает все другие опции поля. Поле будет существовать только в:
- Оригинальной структуре сущности
- Структуре
Row(для чтения из БД) - Структуре
Insertable(для записи в БД)
#[column(pg_enum = "...")]
Подключает Postgres-enum (ValueObject) к генерации 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?;
- Задаёт тип колонки в DDL (иначе enum-поля откатываются к TEXT)
- Регистрирует идемпотентный
PG_CREATE_TYPEenum’а в{Entity}::MIGRATION_TYPES— выполняйте их доMIGRATION_UP - Указанное имя сверяется с константой
PG_TYPEenum’а на этапе компиляции; несовпадение ломает сборку - Опциональный флаг
sqlxуValueObjectгенерирует импелыsqlx::Type/Encode/Decode; не указывайте его, если уже деривитеsqlx::Type
#[owner]
Скоупинг строк по владельцу. Помечает колонку с id владельца; репозиторий получает scoped-методы, которые не раскрывают существование чужих строк и учитывают 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?;
Генерируется: find_by_id_scoped, list_by_owner, update_scoped (при наличии update-полей; None, если строка не принадлежит владельцу), delete_scoped. Максимум одно поле #[owner]; сочетание с #[id] отклоняется на этапе компиляции.
#[filter] / #[filter(...)]
Генерирует поля фильтрации запросов. Подробнее см. [[Фильтрация|Фильтрация]].
#[filter] // Точное совпадение: WHERE field = $n
#[filter(eq)] // То же самое
#[filter(like)] // Совпадение по шаблону: WHERE field ILIKE $n
#[filter(range)] // Диапазон: WHERE field >= $n AND field <= $m
#[belongs_to(Entity)]
Связь по внешнему ключу. Подробнее см. [[Связи|Связи]].
#[belongs_to(User)]
pub user_id: Uuid,
Генерируется: метод find_user() в репозитории.
#[has_many(Entity)]
Связь один-ко-многим (на уровне сущности). Подробнее см. [[Связи|Связи]].
#[has_many(Post)]
pub struct User { /* ... */ }
Генерируется: метод find_posts() в репозитории.
#[projection(Name: fields)]
Генерирует структуру частичного представления (на уровне сущности).
#[projection(Public: id, name, avatar)]
#[projection(Admin: id, name, email, role)]
pub struct User { /* ... */ }
Генерируется:
UserPublic { id, name, avatar }UserAdmin { id, name, email, role }- Реализации
From<User> - Методы
find_by_id_public(),find_by_id_admin()
Атрибуты команд
Применяются на уровне сущности с помощью #[command(...)].
Краткий справочник
| Синтаксис | Эффект |
|---|---|
#[command(Name)] | Использует все поля #[field(create)] |
#[command(Name: field1, field2)] | Использует только указанные поля (добавляет requires_id) |
#[command(Name, requires_id)] | Добавляет поле ID, без других полей |
#[command(Name, source = "create")] | Явно использовать create-поля (по умолчанию) |
#[command(Name, source = "update")] | Использовать update-поля (опциональные, добавляет requires_id) |
#[command(Name, source = "none")] | Без полей payload |
#[command(Name, payload = "Type")] | Использует пользовательскую структуру payload |
#[command(Name, result = "Type")] | Использует пользовательский тип результата |
#[command(Name, kind = "create")] | Подсказка: создаёт сущность (по умолчанию) |
#[command(Name, kind = "update")] | Подсказка: изменяет сущность |
#[command(Name, kind = "delete")] | Подсказка: удаляет сущность (возвращает ()) |
#[command(Name, kind = "custom")] | Подсказка: пользовательская операция |
Подробнее см. [[Команды|Команды]].
Полный пример
#[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>,
}
Матрица решений
| Я хочу… | Атрибуты |
|---|---|
| Автогенерировать первичный ключ | #[id] |
| Использовать случайный UUID | uuid = "v4" на сущности |
| Использовать упорядоченный по времени UUID | uuid = "v7" (по умолчанию) |
| Принимать в теле POST | #[field(create)] |
| Принимать в теле PATCH | #[field(update)] |
| Возвращать в ответе API | #[field(response)] |
| Принимать и возвращать | #[field(create, update, response)] |
| Скрыть от всех API | #[field(skip)] |
| Автогенерировать timestamp | #[auto] + #[field(response)] |
| Только для чтения (управляет БД) | только #[field(response)] |
| Только для записи (без возврата) | только #[field(create)] |
| Пользовательские SQL-запросы | sql = "trait" |
| Только DTO, без БД | sql = "none" |
| Мягкое удаление записей | soft_delete на сущности |
| Пользовательский тип ошибки | error = "MyError" на сущности |
| Фильтровать по точному значению | #[filter] на поле |
| Фильтровать по шаблону | #[filter(like)] на поле |
| Фильтровать по диапазону | #[filter(range)] на поле |
| Отслеживать изменения сущности | events на сущности |
| Выполнять код при жизненном цикле | hooks на сущности |
| Использовать доменные команды | commands на сущности + #[command(...)] |
| Использовать транзакции с несколькими сущностями | transactions на сущности |
| Определить связь | #[belongs_to(Entity)] или #[has_many(Entity)] |
| Частичное представление сущности | #[projection(Name: fields)] |
Update DTO: семантика PATCH
Генерируемые обновления — настоящие частичные патчи: SET-клауза строится в рантайме из реально присутствующих полей, пропущенные поля не трогаются. Nullable-колонки используют двойной Option (None = не менять, Some(None) = записать NULL, Some(Some(v)) = записать v) через 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?;
Опции migrations(...)
Помимо простого флага, migrations принимает DDL-опции: touch_updated_at (общая plpgsql-функция + BEFORE UPDATE триггер на таблицу, обновляющий updated_at; требует поля updated_at, проверяется на компиляции), audit (таблица entity_audit_log + триггер с диффами to_jsonb(OLD/NEW)) и extensions = "pg_trgm, pgcrypto" (идемпотентные CREATE EXTENSION). Новые константы: MIGRATION_TRIGGERS (выполнять после MIGRATION_UP) и MIGRATION_EXTENSIONS (до).
#[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]
Оптимистичная блокировка. Помечает целочисленную колонку (i16/i32/i64); Update DTO получает обязательное поле expected_version, генерируемый UPDATE инкрементирует колонку и применяется только пока версия в базе совпадает — устаревшая запись падает с ошибкой конфликта вместо перезаписи свежих данных. DDL по умолчанию INTEGER NOT NULL DEFAULT 0. Работает в обычном, scoped и транзакционном update.
#[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 (опциональный)
Макрос знает каждый constraint, который создаёт. С этим флагом генерируемые write-методы резолвят имена нарушенных constraint’ов (unique-колонки, внешние ключи belongs_to, check’и колонок, имена unique_index) и отдают entity_core::ConstraintError { kind, constraint, field } вместо сырой ошибки драйвера. Требует кастомного типа error с From<ConstraintError>; без флага поведение не меняется.
#[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(...))]
Разворачивает value object в плоские колонки с префиксом. DDL, Row-структура, CRUD SQL и динамический PATCH работают с price_amount_cents / price_currency, а DTO и сущность несут саму структуру. Объявленная форма деструктурируется против реальной структуры на этапе компиляции — переименованное, перетипизированное, отсутствующее или лишнее поле ломает сборку. Родители Option<T> пока не поддерживаются.
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,
}
Фича garde (бэкенд валидации)
Включает garde::Validate на генерируемых DTO как поддерживаемую альтернативу validator. Правила #[validate(...)] (length, range, email, url, pattern) транслируются в синтаксис garde; поля без ограничений получают garde(skip); Option-обёртки Update DTO валидируют внутреннее значение через inner(...). При включении обеих фич приоритет у validate.
#[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(...) (опциональный, вместе с typed_constraints)
Объявляет constraint’ы, которые макрос не может вывести — внешние ключи по натуральным ключам, CHECK’и с кастомными именами, индексы из ручных миграций — чтобы нарушения резолвились в ConstraintError с указанным полем. Виды: unique, foreign_key, check. Кастомные записи имеют приоритет над выведенными с тем же именем. Требует 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
При включённых transactions и upsert(...) адаптер {Entity}TransactionRepo получает upsert с той же SQL-семантикой, что и метод пула, но на хендле транзакции — для потоков, где upsert должен быть атомарен с соседними стейтментами.
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?;
Примеры
Практические примеры для распространённых случаев использования.
Управление пользователями
Классическая сущность пользователя с полями аутентификации:
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)] // Принимаем, но никогда не возвращаем
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)] // Внутреннее поле аудита
pub last_login_ip: Option<String>,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
#[auto]
#[field(response)]
pub updated_at: DateTime<Utc>,
}
Использование:
// Создание пользователя
let request = CreateUserRequest {
username: "john_doe".into(),
email: "john@example.com".into(),
password_hash: hash_password("secret123"),
};
let user = pool.create(request).await?;
// Обновление пользователя
let update = UpdateUserRequest {
avatar_url: Some(Some("https://cdn.example.com/avatar.jpg".into())),
..Default::default()
};
let user = pool.update(user.id, update).await?;
// Response безопасен - без password_hash, без last_login_ip
let response = UserResponse::from(&user);
Блог-система
Посты со связью с автором:
#[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)] // Задаётся один раз, нельзя сменить автора
pub author_id: Uuid,
#[field(update, response)]
pub published: bool,
#[field(update, response)]
pub published_at: Option<DateTime<Utc>>,
#[field(response)] // Только чтение, управляется триггерами
pub view_count: i64,
#[field(skip)] // Внутренняя модерация
pub moderation_status: String,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
#[auto]
#[field(response)]
pub updated_at: DateTime<Utc>,
}
Категории со связью многие-ко-многим:
#[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, // Вычисляемое поле
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
}
E-Commerce
Каталог продуктов:
#[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)] // Внутренний учёт себестоимости
pub cost_cents: i64,
#[field(skip)] // Информация о поставщике
pub supplier_id: Option<Uuid>,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
#[auto]
#[field(response)]
pub updated_at: DateTime<Utc>,
}
Заказы с отслеживанием статуса:
#[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, // Генерируется sequence БД
#[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)] // Данные платёжного процессора
pub payment_intent_id: Option<String>,
#[field(skip)] // Внутренние заметки
pub admin_notes: Option<String>,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
#[auto]
#[field(response)]
pub updated_at: DateTime<Utc>,
}
Многотенантный SaaS
Сущности с привязкой к организации:
#[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, // Неизменяемый после создания
#[field(update, response)]
pub plan: String,
#[field(response)]
pub member_count: i32,
#[field(skip)] // Платёжная информация
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)] // Задаётся один раз
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>,
}
Только DTO (без базы данных)
Для API-контрактов без персистенции:
#[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,
}
Генерируется только:
CreateWebhookPayloadRequestWebhookPayloadResponse- Реализации
From
Без репозитория, без SQL, без Row/Insertable структур.
Только пользовательские запросы
Когда стандартного CRUD недостаточно:
#[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>,
}
// Реализуйте пользовательские запросы самостоятельно:
impl AnalyticsEventRepository for PgPool {
type Error = sqlx::Error;
async fn create(&self, dto: CreateAnalyticsEventRequest) -> Result<AnalyticsEvent, Self::Error> {
// Пакетная вставка, партиционированные таблицы и т.д.
}
async fn find_by_id(&self, id: Uuid) -> Result<Option<AnalyticsEvent>, Self::Error> {
// Запрос с учётом временного партиционирования
}
// Пользовательские методы за пределами CRUD:
async fn aggregate_by_event(&self, start: DateTime<Utc>, end: DateTime<Utc>)
-> Result<Vec<EventAggregate>, Self::Error> {
// Сложный агрегационный запрос
}
}
Массовые операции
Каждый репозиторий получает батч-примитивы: find_by_ids (WHERE id = ANY($1), один запрос), атомарный create_many (одна транзакция, сбой любой строки откатывает весь батч) и delete_many с учётом soft delete, возвращающий число реально затронутых строк. При включённой доставке событий (streams или outbox) события эмитятся на каждую строку в той же транзакции.
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?;
Фильтрация
Генерация типобезопасных структур запросов для фильтрации сущностей. Фильтрация позволяет реализовать пагинацию, поиск и запросы по диапазонам с безопасностью на этапе компиляции.
Быстрый старт
#[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>,
}
Генерируемый код
Структура запроса
/// Параметры запроса для фильтрации сущностей Product.
#[derive(Debug, Clone, Default)]
pub struct ProductQuery {
/// Фильтр по точному совпадению name.
pub name: Option<String>,
/// Фильтр по шаблону description (ILIKE).
pub description: Option<String>,
/// Фильтр по минимальной цене.
pub price_from: Option<i64>,
/// Фильтр по максимальной цене.
pub price_to: Option<i64>,
/// Фильтр по точному совпадению category_id.
pub category_id: Option<Uuid>,
/// Фильтр по минимальному created_at.
pub created_at_from: Option<DateTime<Utc>>,
/// Фильтр по максимальному created_at.
pub created_at_to: Option<DateTime<Utc>>,
/// Максимальное количество результатов.
pub limit: Option<i64>,
/// Количество пропускаемых результатов.
pub offset: Option<i64>,
}
Метод репозитория
#[async_trait]
pub trait ProductRepository: Send + Sync {
// ... стандартные CRUD-методы
/// Запрос продуктов с фильтрами.
async fn query(&self, query: ProductQuery) -> Result<Vec<Product>, Self::Error>;
}
Генерируемый SQL
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
Типы фильтров
Точное совпадение (#[filter] или #[filter(eq)])
Фильтрует записи, где поле равно указанному значению.
#[filter]
pub status: String,
#[filter(eq)] // То же самое
pub category_id: Uuid,
Генерируется:
pub status: Option<String>,
pub category_id: Option<Uuid>,
SQL:
WHERE status = $1
AND category_id = $2
Совпадение по шаблону (#[filter(like)])
Фильтрует с использованием регистронезависимого сопоставления по шаблону (ILIKE).
#[filter(like)]
pub name: String,
#[filter(like)]
pub description: String,
Генерируется:
pub name: Option<String>,
pub description: Option<String>,
SQL:
WHERE name ILIKE $1
AND description ILIKE $2
Использование:
let query = ProductQuery {
name: Some("%widget%".into()), // Содержит "widget"
description: Some("premium%".into()), // Начинается с "premium"
..Default::default()
};
Фильтр по диапазону (#[filter(range)])
Фильтрует в пределах диапазона (включительно).
#[filter(range)]
pub price: i64,
#[filter(range)]
pub created_at: DateTime<Utc>,
Генерируется:
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
Примеры использования
Базовая фильтрация
// Поиск продуктов по категории
let query = ProductQuery {
category_id: Some(electronics_category_id),
..Default::default()
};
let products = repo.query(query).await?;
Пагинация
// Получение страницы 2 (20 элементов на страницу)
let query = ProductQuery {
limit: Some(20),
offset: Some(20),
..Default::default()
};
let products = repo.query(query).await?;
Комбинированные фильтры
// Поиск недорогой электроники
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?;
Диапазон дат
// Получение продуктов, созданных в этом месяце
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?;
Интеграция с 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))
}
С мягким удалением
При включённом soft_delete запрос автоматически исключает удалённые записи:
#[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:
SELECT * FROM documents
WHERE deleted_at IS NULL
AND ($1 IS NULL OR title ILIKE $1)
LIMIT $2 OFFSET $3
Дополнительный метод для включения удалённых:
async fn query_with_deleted(&self, query: DocumentQuery) -> Result<Vec<Document>, Self::Error>;
Расширения пользовательских запросов
Для сложных запросов используйте sql = "trait" и реализуйте пользовательскую фильтрацию:
#[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())
}
}
Лучшие практики
- Пагинация по умолчанию — Всегда применяйте разумные лимиты для предотвращения больших результатов
- Валидация шаблонов — Санитизируйте шаблоны LIKE для предотвращения проблем с SQL
- Индексация фильтруемых колонок — Создавайте индексы БД для часто фильтруемых полей
- Используйте конкретные фильтры — Предпочитайте точное совпадение шаблонному где возможно
- Комбинируйте с сортировкой — Рассмотрите добавление полей сортировки в структуру запроса
См. также
- [[Атрибуты|Атрибуты]] — Полный справочник атрибутов
- Кастомный SQL — Сложные пользовательские запросы
- [[Связи|Связи]] — Фильтрация со связями
Сортировка и keyset-пагинация
Пометьте сортируемые колонки #[sort]: Query-структура получает whitelist-селектор {Entity}SortField (по варианту Asc/Desc на колонку, snake_case в JSON) — пользовательский ввод не может инъецировать SQL. Каждый репозиторий также получает keyset-пагинацию list_after: с UUIDv7 по умолчанию обход по id хронологически стабилен и не деградирует на глубоких страницах, в отличие от 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?;
Trigram-поиск
#[filter(search)] на текстовой колонке добавляет нечёткий substring-фильтр (col ILIKE '%' || $n || '%', термин биндится). При включённых migrations соответствующий индекс gin_trgm_ops попадает в MIGRATION_UP, а pg_trgm автоматически добавляется в MIGRATION_EXTENSIONS. Проверка на компиляции: поле должно быть String.
#[field(create, update, response)]
#[filter(search)]
pub title: String,
let hits = pool.query(ArticleQuery { title: Some("rust".into()), ..Default::default() }).await?;
Связи
Определение связей между сущностями с помощью #[belongs_to] и #[has_many]. Связи генерируют типобезопасные методы навигации в репозиториях.
Быстрый старт
// Родительская сущность
#[derive(Entity)]
#[entity(table = "users")]
#[has_many(Post)]
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)]
#[belongs_to(User)]
pub user_id: Uuid,
#[field(create, update, response)]
pub title: String,
}
Генерируемый код
Для #[has_many(Post)] на User
#[async_trait]
impl UserRepository for PgPool {
// ... стандартные CRUD-методы
/// Найти все посты, принадлежащие этому пользователю.
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())
}
}
Для #[belongs_to(User)] на Post
#[async_trait]
impl PostRepository for PgPool {
// ... стандартные CRUD-методы
/// Найти пользователя, которому принадлежит этот пост.
async fn find_user(&self, id: Uuid) -> Result<Option<User>, Self::Error> {
// Сначала получаем пост для нахождения 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)
}
}
}
Типы связей
belongs_to (Многие-к-одному)
Дочерняя сущность ссылается на родительскую через внешний ключ.
#[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,
}
Генерируемые методы:
find_post(comment_id)→Option<Post>find_author(comment_id)→Option<User>(примечание: имя метода выводится из имени поля без_id)
has_many (Один-ко-многим)
Родительская сущность имеет множество дочерних.
#[derive(Entity)]
#[entity(table = "users")]
#[has_many(Post)]
#[has_many(Comment)]
pub struct User {
#[id]
pub id: Uuid,
// ...
}
Генерируемые методы:
find_posts(user_id)→Vec<Post>find_comments(user_id)→Vec<Comment>
Примеры использования
Загрузка связанных данных
// Получение пользователя с его постами
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)
}
}
// Получение поста с автором
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)
}
Построение Response DTO
#[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)
}
Вложенная загрузка с API
use axum::{extract::Path, Json};
#[derive(Serialize)]
pub struct UserProfile {
pub user: UserResponse,
pub posts: Vec<PostResponse>,
pub post_count: usize,
}
async fn get_user_profile(
Path(user_id): Path<Uuid>,
pool: Extension<PgPool>,
) -> Result<Json<UserProfile>, AppError> {
let user = pool.find_by_id(user_id).await?
.ok_or(AppError::NotFound)?;
let posts = pool.find_posts(user_id).await?;
Ok(Json(UserProfile {
user: UserResponse::from(&user),
post_count: posts.len(),
posts: posts.into_iter().map(PostResponse::from).collect(),
}))
}
Множественные связи
Сущность может иметь несколько связей:
#[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,
}
Генерируется для Organization:
find_users(org_id)find_projects(org_id)find_teams(org_id)
Генерируется для Project:
find_organization(project_id)find_owner(project_id)
Пользовательские JOIN с sql = “trait”
Для сложных запросов с жадной загрузкой используйте пользовательский SQL:
#[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> {
// Один запрос с JOIN
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())
}
}
С фильтрацией
Комбинируйте связи с фильтрацией запросов:
#[derive(Entity)]
#[entity(table = "posts")]
pub struct Post {
#[id]
pub id: Uuid,
#[field(create, response)]
#[belongs_to(User)]
#[filter] // Включить фильтрацию по 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>,
}
Использование:
// Получение постов конкретного пользователя с фильтром по заголовку
let query = PostQuery {
user_id: Some(user_id),
title: Some("%rust%".into()),
limit: Some(20),
..Default::default()
};
let posts = pool.query(query).await?;
Лучшие практики
- Избегайте N+1 запросов — Используйте JOIN для жадной загрузки нескольких связанных сущностей
- Используйте пагинацию — Всегда ограничивайте результаты
has_many - Учитывайте паттерны доступа — Добавляйте индексы на колонки внешних ключей
- Кэшируйте при необходимости — Кэшируйте часто запрашиваемые связанные данные
- Используйте проекции — Запрашивайте только нужные поля для связанных сущностей
См. также
- [[Фильтрация|Фильтрация]] — Фильтрация запросов
- Кастомный SQL — Сложные JOIN и запросы
- [[Лучшие-практики|Лучшие практики]] — Советы по производительности
has_many through (многие-ко-многим)
Укажите junction-таблицу через through — репозиторий получит JOIN-выборку и управление связями; migrations генерирует MIGRATION_JUNCTIONS (композитный первичный ключ по обоим внешним ключам, ON DELETE CASCADE с обеих сторон).
#[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?;
Генерируемые методы: find_users (INNER JOIN), add_user (идемпотентно, ON CONFLICT DO NOTHING), remove_user (false, если связи не было), has_user (SELECT EXISTS).
События
Генерация доменных событий для изменений жизненного цикла сущности. События позволяют вести журнал аудита, реализовывать Event Sourcing и интегрироваться с очередями сообщений.
Быстрый старт
#[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>,
}
Генерируемый код
Атрибут events генерирует enum событий:
/// Сгенерировано entity-derive
#[derive(Debug, Clone)]
pub enum OrderEvent {
/// Сущность была создана.
Created(Order),
/// Сущность была обновлена.
Updated {
id: Uuid,
changes: UpdateOrderRequest,
},
/// Сущность была удалена.
Deleted(Uuid),
}
Примеры использования
Базовая публикация событий
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?;
// Публикация события после успешного создания
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)
}
Журнал аудита
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();
}
}
Интеграция с очередью сообщений
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();
}
}
Паттерн 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
}
}
С мягким удалением
При включённом soft_delete генерируются дополнительные события:
#[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>>,
}
Генерируется:
pub enum DocumentEvent {
Created(Document),
Updated { id: Uuid, changes: UpdateDocumentRequest },
Deleted(Uuid), // Мягкое удаление
Restored(Uuid), // Восстановление из мягкого удаления
HardDeleted(Uuid), // Окончательное удаление
}
Лучшие практики
- Публикация после коммита — Публикуйте события только после успешной транзакции в БД
- Идемпотентные обработчики — Обработчики событий должны быть идемпотентными для семантики at-least-once
- Включайте контекст — Рассмотрите добавление метаданных (user_id, timestamp, correlation_id)
- Асинхронная обработка — Используйте фоновые воркеры для тяжёлой обработки событий
- Dead letter queue — Обрабатывайте неудачные события корректно
Комбинация с хуками
События и хуки хорошо работают вместе:
#[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> {
// Публикация события в хуке
self.bus.publish(OrderEvent::Created(entity.clone())).await;
Ok(())
}
async fn after_update(&self, entity: &Order) -> Result<(), Self::Error> {
// События также можно публиковать здесь
Ok(())
}
async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error> {
self.bus.publish(OrderEvent::Deleted(*id)).await;
Ok(())
}
}
См. также
- [[Хуки|Хуки]] — Выполнение пользовательской логики при событиях жизненного цикла
- [[Команды|Команды]] — Паттерн CQRS с событиями команд
- [[Лучшие-практики|Лучшие практики]] — Советы для продакшена
Потоки
Подписка на изменения сущностей в реальном времени через Postgres LISTEN/NOTIFY. Потоки позволяют создавать живые дашборды, мгновенные уведомления, инвалидацию кэша и событийно-ориентированные архитектуры.
Быстрый старт
#[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,
}
Требования:
- Сущность должна реализовывать
SerializeиDeserialize(для JSON-полезной нагрузки) - Обязательны оба атрибута:
eventsиstreams - Включите feature
streamsв Cargo.toml
[dependencies]
entity-derive = { version = "0.3", features = ["postgres", "streams"] }
serde = { version = "1", features = ["derive"] }
Генерируемый код
Атрибут streams генерирует:
Константа канала
impl Order {
/// Имя канала Postgres NOTIFY.
pub const CHANNEL: &'static str = "entity_orders";
}
Структура подписчика
/// Подписчик на изменения Order в реальном времени.
pub struct OrderSubscriber {
listener: PgListener,
}
impl OrderSubscriber {
/// Подключиться и подписаться на канал.
pub async fn new(pool: &PgPool) -> Result<Self, sqlx::Error>;
/// Ожидать следующее событие (блокирующий).
pub async fn recv(&mut self) -> Result<OrderEvent, StreamError<sqlx::Error>>;
/// Проверить наличие события без блокировки.
pub async fn try_recv(&mut self) -> Result<Option<OrderEvent>, StreamError<sqlx::Error>>;
}
Автоматические уведомления
CRUD-операции автоматически отправляют события:
// В сгенерированном методе create():
async fn create(&self, dto: CreateOrderRequest) -> Result<Order, Self::Error> {
let order = /* insert */;
// Автоматически сгенерированное уведомление
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)
}
Примеры использования
Базовая подписка
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!("Новый заказ: {}", order.id);
}
OrderEvent::Updated { old, new } => {
println!("Заказ {} обновлён: {} -> {}", new.id, old.status, new.status);
}
OrderEvent::HardDeleted { id } => {
println!("Заказ {} удалён", id);
}
_ => {}
}
}
Err(StreamError::Database(e)) => {
eprintln!("Ошибка БД: {}", e);
break;
}
Err(StreamError::Deserialize(e)) => {
eprintln!("Неверная полезная нагрузка: {}", e);
}
}
}
Ok(())
}
Дашборд в реальном времени (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,
}
}
}
Инвалидация кэша
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();
}
_ => {}
}
}
}
}
Фоновый воркер с 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!("Ошибка потока: {:?}", e);
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
_ = shutdown.changed() => {
println!("Завершение воркера уведомлений");
break;
}
}
}
}
Обработка ошибок
use entity_derive::StreamError;
match subscriber.recv().await {
Ok(event) => { /* обработка */ }
Err(StreamError::Database(sqlx_error)) => {
// Потеря соединения, ошибка запроса и т.д.
// Подписчик автоматически переподключится при следующем recv()
}
Err(StreamError::Deserialize(message)) => {
// Неверный JSON
// Логируем и продолжаем - не крашим цикл
}
}
Архитектура
CRUD-операция (create/update/delete)
│
▼
pg_notify(channel, event_json)
│
▼
Postgres NOTIFY
│
┌────┴────┐
▼ ▼
Подписчик Подписчик (несколько слушателей)
│ │
▼ ▼
WebSocket Инвалидатор
Дашборд кэша
Лучшие практики
- Переподключение — PgListener автоматически переподключается; проектируйте цикл с учётом временных сбоев
- Идемпотентность — События могут доставляться несколько раз; обработчики должны быть идемпотентными
- Размер полезной нагрузки — Держите сущности компактными; большие данные могут превысить лимиты Postgres
- Отдельные пулы — Используйте выделенный пул соединений для слушателей
- Мониторинг — Логируйте ошибки потока и отслеживайте задержку обработки событий
- Graceful shutdown — Используйте select! с сигналом завершения для освобождения ресурсов
С мягким удалением
При включённом soft_delete доступны дополнительные события:
#[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>>,
}
// События включают:
// - DocumentEvent::SoftDeleted { id }
// - DocumentEvent::Restored { id }
// - DocumentEvent::HardDeleted { id }
См. также
- [[События|События]] — Перечисление событий без потоковой передачи
- [[Хуки|Хуки]] — Выполнение кастомной логики при событиях жизненного цикла
- [[Лучшие практики|Лучшие-практики]] — Советы для продакшена
Хуки
Выполнение пользовательской логики до и после операций с сущностями. Хуки позволяют реализовать валидацию, нормализацию, побочные эффекты и авторизацию.
Быстрый старт
#[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>,
}
Генерируемый код
Атрибут hooks генерирует асинхронный трейт:
/// Сгенерировано entity-derive
#[async_trait]
pub trait UserHooks: Send + Sync {
type Error: std::error::Error + Send + Sync;
/// Вызывается перед созданием новой сущности.
/// Модифицируйте DTO или верните ошибку для отмены.
async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error>;
/// Вызывается после создания сущности.
async fn after_create(&self, entity: &User) -> Result<(), Self::Error>;
/// Вызывается перед обновлением сущности.
/// Модифицируйте DTO или верните ошибку для отмены.
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>;
}
Пример реализации
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> {
// Нормализация email
dto.email = dto.email.trim().to_lowercase();
// Валидация формата email
if !dto.email.contains('@') {
return Err(AppError::Validation("Неверный формат email".into()));
}
// Проверка на дубликат email
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 уже зарегистрирован".into()));
}
Ok(())
}
async fn after_create(&self, entity: &User) -> Result<(), Self::Error> {
// Отправка приветственного email
self.email_sender
.send_welcome(&entity.email, &entity.name)
.await?;
// Кэширование нового пользователя
self.cache.set(&format!("user:{}", entity.id), entity).await?;
Ok(())
}
async fn before_update(&self, id: &Uuid, dto: &mut UpdateUserRequest) -> Result<(), Self::Error> {
// Нормализация email если предоставлен
if let Some(ref mut email) = dto.email {
*email = email.trim().to_lowercase();
// Проверка на дубликат (исключая текущего пользователя)
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 уже используется".into()));
}
}
Ok(())
}
async fn after_update(&self, entity: &User) -> Result<(), Self::Error> {
// Инвалидация кэша
self.cache.del(&format!("user:{}", entity.id)).await?;
Ok(())
}
async fn before_delete(&self, id: &Uuid) -> Result<(), Self::Error> {
// Проверка возможности удаления
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("Нельзя удалить пользователя с активными заказами".into()));
}
Ok(())
}
async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error> {
// Инвалидация кэша
self.cache.del(&format!("user:{}", id)).await?;
// Очистка связанных данных
sqlx::query("DELETE FROM user_sessions WHERE user_id = $1")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
}
Случаи использования
Валидация
async fn before_create(&self, dto: &mut CreateProductRequest) -> Result<(), Self::Error> {
// Валидация цены
if dto.price_cents <= 0 {
return Err(AppError::Validation("Цена должна быть положительной".into()));
}
// Валидация формата SKU
if !dto.sku.chars().all(|c| c.is_alphanumeric() || c == '-') {
return Err(AppError::Validation("Неверный формат SKU".into()));
}
Ok(())
}
Нормализация
async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error> {
// Нормализация email
dto.email = dto.email.trim().to_lowercase();
// Нормализация имени
dto.name = dto.name.trim().to_string();
// Заглавная буква для каждого слова
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(())
}
Авторизация
async fn before_update(&self, id: &Uuid, _dto: &mut UpdatePostRequest) -> Result<(), Self::Error> {
// Получение текущего пользователя из контекста
let current_user = self.current_user()?;
// Проверка владения
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("Нельзя редактировать чужие посты".into()));
}
Ok(())
}
Побочные эффекты
async fn after_create(&self, entity: &Order) -> Result<(), Self::Error> {
// Обновление инвентаря
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?;
}
// Отправка уведомления
self.notifications.send_order_confirmation(entity).await?;
// Планирование задачи выполнения
self.job_queue.enqueue(FulfillOrderJob { order_id: entity.id }).await?;
Ok(())
}
Журнал аудита
async fn after_update(&self, entity: &User) -> Result<(), Self::Error> {
sqlx::query(
"INSERT INTO audit_log (entity_type, entity_id, action, performed_by, performed_at)
VALUES ('user', $1, 'update', $2, NOW())"
)
.bind(entity.id)
.bind(self.current_user_id())
.execute(&self.pool)
.await?;
Ok(())
}
С мягким удалением
При включённом soft_delete генерируются дополнительные хуки:
#[derive(Entity)]
#[entity(table = "documents", hooks, soft_delete)]
pub struct Document { /* ... */ }
Генерируемые хуки:
#[async_trait]
pub trait DocumentHooks: Send + Sync {
type Error: std::error::Error + Send + Sync;
// Стандартные CRUD-хуки...
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>; // Мягкое удаление
async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
// Хуки мягкого удаления
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>;
}
С командами
При одновременном включении commands и hooks генерируются хуки команд:
#[derive(Entity)]
#[entity(table = "orders", hooks, commands)]
#[command(Place)]
#[command(Cancel, requires_id)]
pub struct Order { /* ... */ }
Дополнительные хуки:
#[async_trait]
pub trait OrderHooks: Send + Sync {
type Error: std::error::Error + Send + Sync;
// Стандартные CRUD-хуки...
// Хуки команд
async fn before_command(&self, cmd: &OrderCommand) -> Result<(), Self::Error>;
async fn after_command(&self, cmd: &OrderCommand, result: &OrderCommandResult) -> Result<(), Self::Error>;
}
Использование:
async fn before_command(&self, cmd: &OrderCommand) -> Result<(), Self::Error> {
match cmd {
OrderCommand::Place(place) => {
// Валидация возможности размещения заказа
if place.items.is_empty() {
return Err(AppError::Validation("Заказ должен содержать товары".into()));
}
}
OrderCommand::Cancel(cancel) => {
// Проверка возможности отмены
let order = self.find_order(cancel.id).await?;
if order.status == "shipped" {
return Err(AppError::Forbidden("Нельзя отменить отправленный заказ".into()));
}
}
}
Ok(())
}
async fn after_command(&self, cmd: &OrderCommand, result: &OrderCommandResult) -> Result<(), Self::Error> {
match (cmd, result) {
(OrderCommand::Place(_), OrderCommandResult::Place(order)) => {
self.send_order_confirmation(order).await?;
}
(OrderCommand::Cancel(_), OrderCommandResult::Cancel) => {
// Логика возврата средств
}
}
Ok(())
}
Лучшие практики
- Быстрые хуки — Длительные операции выносите в фоновые задачи
- Используйте транзакции — Оборачивайте хук + вызов репозитория в транзакцию
- Обработка ошибок — Возвращайте осмысленные типы ошибок
- Не дублируйте логику — Используйте хуки для сквозной функциональности
- Тестируйте независимо — Unit-тестируйте реализации хуков
Паттерн обработки ошибок
#[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 обязателен".into()));
}
Ok(())
}
}
См. также
- [[События|События]] — События жизненного цикла для журнала аудита
- [[Команды|Команды]] — Паттерн CQRS с хуками команд
- [[Лучшие-практики|Лучшие практики]] — Советы для продакшена
Команды
Определение бизнес-ориентированных команд вместо обобщённого CRUD. Команды привносят доменный язык в API и реализуют паттерн Command Query Responsibility Segregation (CQRS).
Быстрый старт
#[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,
}
Генерируемый код
Структуры команд
/// Payload команды Register для User.
#[derive(Debug, Clone)]
pub struct RegisterUser {
pub email: String,
pub name: String,
}
/// Payload команды UpdateEmail для User.
#[derive(Debug, Clone)]
pub struct UpdateEmailUser {
pub id: Uuid,
pub email: String,
}
/// Payload команды Deactivate для User.
#[derive(Debug, Clone)]
pub struct DeactivateUser {
pub id: Uuid,
}
Enum команд
/// Enum команд для сущности 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 результатов
/// Enum результатов выполнения команд User.
#[derive(Debug, Clone)]
pub enum UserCommandResult {
Register(User),
UpdateEmail(User),
Deactivate,
}
Трейт обработчика
/// Асинхронный трейт для обработки команд User.
#[async_trait]
pub trait UserCommandHandler: Send + Sync {
type Error: std::error::Error + Send + Sync;
type Context: Send + Sync;
/// Диспетчеризация команды к соответствующему обработчику.
async fn handle(&self, cmd: UserCommand, ctx: &Self::Context)
-> Result<UserCommandResult, Self::Error>;
/// Обработка команды Register.
async fn handle_register(&self, cmd: RegisterUser, ctx: &Self::Context)
-> Result<User, Self::Error>;
/// Обработка команды UpdateEmail.
async fn handle_update_email(&self, cmd: UpdateEmailUser, ctx: &Self::Context)
-> Result<User, Self::Error>;
/// Обработка команды Deactivate.
async fn handle_deactivate(&self, cmd: DeactivateUser, ctx: &Self::Context)
-> Result<(), Self::Error>;
}
Справочник синтаксиса команд
Базовая команда
Использует все поля #[field(create)]:
#[command(Register)]
// Генерируется: RegisterUser { email, name }
Конкретные поля
Использует только указанные поля (автоматически добавляет requires_id):
#[command(UpdateEmail: email)]
// Генерируется: UpdateEmailUser { id, email }
#[command(UpdateProfile: name, bio, avatar)]
// Генерируется: UpdateProfileUser { id, name, bio, avatar }
Команда только с ID
Добавляет только поле ID:
#[command(Deactivate, requires_id)]
// Генерируется: DeactivateUser { id }
#[command(Delete, requires_id, kind = "delete")]
// Генерируется: DeleteUser { id }, возвращает ()
Пользовательский payload
Использует внешнюю структуру:
pub struct TransferPayload {
pub from_account: Uuid,
pub to_account: Uuid,
pub amount: i64,
}
#[command(Transfer, payload = "TransferPayload")]
// Использует TransferPayload напрямую
Пользовательский результат
Использует пользовательский тип результата:
pub struct TransferResult {
pub transaction_id: Uuid,
pub success: bool,
}
#[command(Transfer, payload = "TransferPayload", result = "TransferResult")]
// Возвращает TransferResult вместо сущности
Опции источника
Управление используемыми полями:
#[command(Create, source = "create")] // Использует поля #[field(create)] (по умолчанию)
#[command(Modify, source = "update")] // Использует поля #[field(update)] (опциональные)
#[command(Ping, source = "none")] // Без полей payload
Подсказки типа
Влияют на определение типа результата:
#[command(Create, kind = "create")] // Возвращает сущность (по умолчанию)
#[command(Update, kind = "update")] // Возвращает сущность
#[command(Remove, kind = "delete")] // Возвращает ()
#[command(Process, kind = "custom")] // Определяется из source
Пример реализации
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>
{
// Валидация
if cmd.email.is_empty() {
return Err(AppError::Validation("Email обязателен".into()));
}
// Создание пользователя
let user = User {
id: Uuid::now_v7(),
email: cmd.email.to_lowercase(),
name: cmd.name,
active: true,
};
// Сохранение
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?;
// Побочные эффекты
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>
{
// Проверка авторизации
if ctx.user_id != Some(cmd.id) {
return Err(AppError::Forbidden("Нельзя обновить чужой email".into()));
}
// Обновление
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?;
// Отправка верификации
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(())
}
}
Использование команд
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!(),
}
}
// Или вызов конкретного обработчика напрямую
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
}
Трейт EntityCommand
Все enum команд реализуют трейт EntityCommand:
use entity_derive::{EntityCommand, CommandKind};
let cmd = UserCommand::Register(register_data);
// Получение метаданных команды
assert_eq!(cmd.name(), "Register");
assert!(matches!(cmd.kind(), CommandKind::Create));
// Сопоставление с образцом
match cmd.kind() {
CommandKind::Create => println!("Создание сущности"),
CommandKind::Update => println!("Обновление сущности"),
CommandKind::Delete => println!("Удаление сущности"),
CommandKind::Custom => println!("Пользовательская операция"),
}
Хуки команд
При одновременном включении commands и hooks:
#[derive(Entity)]
#[entity(table = "orders", commands, hooks)]
#[command(Place)]
#[command(Cancel, requires_id)]
pub struct Order { /* ... */ }
Генерируемые хуки:
#[async_trait]
pub trait OrderHooks: Send + Sync {
type Error: std::error::Error + Send + Sync;
// Стандартные CRUD-хуки...
// Хуки команд
async fn before_command(&self, cmd: &OrderCommand) -> Result<(), Self::Error>;
async fn after_command(&self, cmd: &OrderCommand, result: &OrderCommandResult) -> Result<(), Self::Error>;
}
Использование:
async fn before_command(&self, cmd: &OrderCommand) -> Result<(), Self::Error> {
// Логирование команды
tracing::info!(command = cmd.name(), "Обработка команды");
// Авторизация
match cmd {
OrderCommand::Cancel(c) => {
let order = self.find_order(c.id).await?;
if order.status == "shipped" {
return Err(AppError::Forbidden("Нельзя отменить отправленный заказ".into()));
}
}
_ => {}
}
Ok(())
}
async fn after_command(&self, cmd: &OrderCommand, result: &OrderCommandResult) -> Result<(), Self::Error> {
// Журнал аудита
match (cmd, result) {
(OrderCommand::Place(c), OrderCommandResult::Place(order)) => {
self.audit_log("order_placed", order.id).await?;
}
(OrderCommand::Cancel(c), OrderCommandResult::Cancel) => {
self.audit_log("order_cancelled", c.id).await?;
}
}
Ok(())
}
Лучшие практики
- Доменный язык — Используйте бизнес-термины:
RegisterUserвместоCreateUser - Единая ответственность — Одна команда = одна бизнес-операция
- Явное намерение — Имена команд должны описывать действие
- Валидация в обработчиках — Логика валидации в обработчиках команд
- Идемпотентность — Проектируйте команды для безопасного повторного выполнения
- Используйте контекст — Передавайте метаданные запроса (user, correlation ID) через контекст
Паттерн CQRS
Команды — одна половина CQRS. Комбинируйте с проекциями для стороны запросов:
#[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 { /* ... */ }
// Команды (сторона записи)
let result = handler.handle(OrderCommand::Place(place_order), &ctx).await?;
// Запросы (сторона чтения)
let summary = repo.find_by_id_summary(order_id).await?;
let details = repo.find_by_id_details(order_id).await?;
См. также
- [[Хуки|Хуки]] — Хуки жизненного цикла включая хуки команд
- [[События|События]] — Генерация событий для журнала аудита
- [[Атрибуты|Атрибуты]] — Полный справочник атрибутов
Транзакции
Типобезопасные транзакции с несколькими сущностями и автоматическим commit/rollback.
Что такое транзакции?
Транзакция базы данных — это способ объединить несколько операций с базой данных в одну атомарную единицу. Это означает:
- Всё или ничего: Либо ВСЕ операции успешны, либо НИ ОДНА не применяется
- Автоматический откат: Если любая операция падает, все предыдущие изменения автоматически отменяются
- Согласованность данных: База данных никогда не окажется в несогласованном состоянии
Зачем нужны транзакции?
Представьте, что вы разрабатываете банковское приложение и нужно перевести деньги между счетами:
1. Списать 100₽ со Счёта A
2. Зачислить 100₽ на Счёт B
Без транзакций, если шаг 1 успешен, но шаг 2 падает (сетевая ошибка, сбой БД и т.д.), вы только что потеряли 100₽! Деньги списаны с A, но не зачислены на B.
С транзакциями, если шаг 2 падает, шаг 1 автоматически откатывается. Деньги остаются на Счёте A, как будто ничего не произошло.
Включение транзакций
Добавьте атрибут transactions к сущности:
use entity_derive::Entity;
use uuid::Uuid;
#[derive(Entity)]
#[entity(table = "accounts", transactions)] // ← Добавьте это
pub struct Account {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub user_id: Uuid,
#[field(create, update, response)]
pub balance: i64,
}
Что генерируется?
Для сущности Account с #[entity(transactions)] макрос генерирует:
1. Адаптер репозитория для транзакций
pub struct AccountTransactionRepo<'t> {
tx: &'t mut sqlx::Transaction<'static, sqlx::Postgres>,
}
Это как обычный репозиторий, но все операции происходят внутри транзакции.
2. Трейт-расширение для билдера
pub trait TransactionWithAccount<'p> {
fn with_accounts(self) -> Transaction<'p, PgPool, AccountTransactionRepo<'static>>;
}
Это добавляет метод with_accounts() к билдеру транзакции.
Доступные методы
Внутри транзакции доступны следующие методы:
| Метод | Сигнатура | Описание |
|---|---|---|
create | create(dto) -> Result<Entity, Error> | Вставить новую запись |
find_by_id | find_by_id(id) -> Result<Option<Entity>, Error> | Найти по первичному ключу |
update | update(id, dto) -> Result<Entity, Error> | Обновить существующую запись |
delete | delete(id) -> Result<bool, Error> | Удалить запись (или мягкое удаление) |
list | list(limit, offset) -> Result<Vec<Entity>, Error> | Список с пагинацией |
Простой пример
use entity_core::prelude::*;
async fn create_account(pool: &PgPool, user_id: Uuid) -> Result<Account, AppError> {
Transaction::new(pool) // 1. Начать создание транзакции
.with_accounts() // 2. Добавить репозиторий Account
.run(|mut ctx| async move { // 3. Выполнить операции
let account = ctx.accounts().create(CreateAccountRequest {
user_id,
balance: 0,
}).await?;
Ok(account) // 4. Вернуть результат (автоматический commit)
})
.await
}
Пошагово:
Transaction::new(pool)— Создаёт билдер транзакции с пулом соединений.with_accounts()— Добавляет репозиторий Account в контекст транзакции.run(|mut ctx| async move { ... })— Выполняет операции внутри транзакцииOk(account)— ВозвратOkкоммитит транзакцию. ВозвратErrоткатывает её.
Полный пример: Перевод денег
Этот пример показывает всю мощь транзакций:
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())
}
}
/// Атомарный перевод денег между двумя счетами.
///
/// Если ЛЮБОЙ шаг падает, все изменения автоматически откатываются.
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 {
// Шаг 1: Получить исходный счёт
let from = ctx.accounts()
.find_by_id(from_id)
.await?
.ok_or(TransferError::AccountNotFound(from_id))?;
// Шаг 2: Проверить достаточно ли денег
if from.balance < amount {
return Err(TransferError::InsufficientFunds {
available: from.balance,
requested: amount,
});
}
// Шаг 3: Получить целевой счёт
let to = ctx.accounts()
.find_by_id(to_id)
.await?
.ok_or(TransferError::AccountNotFound(to_id))?;
// Шаг 4: Списать с источника
// Если это успешно, но шаг 5 падает, это будет ОТКАЧЕНО
ctx.accounts().update(from_id, UpdateAccountRequest {
balance: Some(from.balance - amount),
user_id: None, // Не меняем user_id
}).await?;
// Шаг 5: Зачислить на целевой
ctx.accounts().update(to_id, UpdateAccountRequest {
balance: Some(to.balance + amount),
user_id: None,
}).await?;
// Все операции успешны - транзакция ЗАКОММИТИТСЯ
Ok(())
})
.await
}
Что происходит в разных сценариях:
| Сценарий | Результат |
|---|---|
| Оба обновления успешны | Транзакция коммитится, деньги переведены |
| Исходный счёт не найден | Транзакция откатывается (без изменений) |
| Недостаточно средств | Транзакция откатывается (без изменений) |
| Первое обновление успешно, второе падает | Транзакция откатывается (первое обновление отменено!) |
| Сетевая ошибка посреди транзакции | Транзакция откатывается (без частичных изменений) |
Несколько сущностей в одной транзакции
Можно работать с несколькими сущностями атомарно:
#[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() // Добавить репозиторий Account
.with_transfer_logs() // Добавить репозиторий TransferLog
.run(|mut ctx| async move {
// Обновить балансы
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?;
// Создать запись лога - всё в одной транзакции!
let log = ctx.transfer_logs().create(CreateTransferLogRequest {
from_account_id: from_id,
to_account_id: to_id,
amount,
}).await?;
Ok(log)
})
.await
}
Если создание лога падает, оба обновления счетов откатываются!
Обработка ошибок
Автоматический откат
Любая ошибка, возвращённая из замыкания, вызывает откат:
Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move {
ctx.accounts().update(id, dto).await?; // Успешно
// Какая-то валидация падает
if amount < 0 {
return Err(AppError::InvalidAmount); // ← Вызывает откат!
}
// Это никогда не выполнится, и обновление выше отменится
ctx.accounts().update(other_id, other_dto).await?;
Ok(())
})
.await
Типы ошибок транзакций
Enum TransactionError сообщает что пошло не так:
use entity_core::transaction::TransactionError;
let result = Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move { /* ... */ })
.await;
match result {
Ok(value) => {
println!("Успех: {:?}", value);
}
Err(e) => {
// Проверить тип ошибки
if e.is_begin() {
println!("Не удалось начать транзакцию");
} else if e.is_operation() {
println!("Операция упала: {}", e);
} else if e.is_commit() {
println!("Не удалось закоммитить");
} else if e.is_rollback() {
println!("Не удалось откатить");
}
// Получить внутреннюю ошибку БД
let db_error: sqlx::Error = e.into_inner();
}
}
С мягким удалением
Транзакции учитывают атрибут 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>>, // Обязательно для soft_delete
}
async fn archive_document(pool: &PgPool, id: Uuid) -> Result<bool, AppError> {
Transaction::new(pool)
.with_documents()
.run(|mut ctx| async move {
// Это устанавливает deleted_at = NOW() вместо DELETE
let deleted = ctx.documents().delete(id).await?;
Ok(deleted)
})
.await
}
Лучшие практики
1. Держите транзакции короткими
❌ Плохо: Долгие транзакции
Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move {
let account = ctx.accounts().find_by_id(id).await?;
// НЕ ДЕЛАЙТЕ: Вызов внешних API внутри транзакций
let rate = external_api.get_exchange_rate().await?; // ← МЕДЛЕННО!
ctx.accounts().update(id, dto).await?;
Ok(())
})
.await
✅ Хорошо: Медленные операции снаружи
// Получить внешние данные ДО начала транзакции
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. Не используйте транзакции для одиночных операций
❌ Лишнее:
Transaction::new(pool)
.with_users()
.run(|mut ctx| async move {
ctx.users().find_by_id(id).await // Всего одна операция!
})
.await
✅ Лучше: Используйте обычный репозиторий
pool.find_by_id(id).await // Транзакция не нужна
3. Обрабатывайте все ошибки правильно
Всегда пробрасывайте ошибки с ?:
Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move {
let result = ctx.accounts().update(id, dto).await;
// НЕ ДЕЛАЙТЕ: Глотать ошибки
if let Err(e) = result {
log::error!("Обновление упало: {}", e);
// Транзакция не откатится правильно!
}
// ДЕЛАЙТЕ: Пробрасывать ошибки
ctx.accounts().update(id, dto).await?; // ← Используйте ?
Ok(())
})
.await
Типичные паттерны
Проверить-затем-обновить
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
Создать несколько связанных записей
Transaction::new(pool)
.with_orders()
.with_order_items()
.run(|mut ctx| async move {
// Создать родителя
let order = ctx.orders().create(CreateOrderRequest {
customer_id,
status: "pending".to_string(),
}).await?;
// Создать дочерние записи
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
См. также
- [[Атрибуты|Справочник атрибутов]] — Полная документация по атрибутам
- [[Хуки|Хуки жизненного цикла]] — Выполнение кода до/после операций
- [[Команды|Команды]] — CQRS паттерн команд
- [[События|События]] — Отслеживание изменений сущностей
Кастомный SQL
Когда автоматически сгенерированного SQL недостаточно, используйте sql = "trait" для полного контроля.
Когда использовать пользовательский SQL
- JOIN’ы — Связанные сущности в одном запросе
- CTE — Сложные рекурсивные или многоэтапные запросы
- Полнотекстовый поиск — PostgreSQL
tsvector/tsquery - Агрегации —
GROUP BY,HAVING, оконные функции - Партиционированные таблицы — Временное или диапазонное партиционирование
- Пакетные операции — Массовые вставки/обновления
- Мягкое удаление — Пользовательская логика удаления
- Оптимистичная блокировка — Контроль конкурентности на основе версий
Базовая настройка
#[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>,
}
Это генерирует:
- Все DTO (
CreatePostRequest,UpdatePostRequest,PostResponse) PostRowиInsertablePost- Трейт
PostRepository - Все реализации
From
Но не impl PostRepository for PgPool.
Реализация репозитория
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> {
// Ваша пользовательская логика обновления
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())
}
}
Пример: Посты с JOIN автора
// Расширенный response с данными автора
pub struct PostWithAuthor {
pub post: Post,
pub author: User,
}
// Расширение пользовательского репозитория
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> {
// Аналогичный запрос с JOIN и пагинацией
todo!()
}
}
Пример: Полнотекстовый поиск
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())
}
}
Пример: Мягкое удаление
#[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 {
// ... другие методы
async fn delete(&self, id: Uuid) -> Result<bool, Self::Error> {
// Мягкое удаление вместо полного
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> {
// Исключаем мягко удалённые
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())
}
}
// Дополнительные методы для администраторов
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>;
}
Пример: Оптимистичная блокировка
#[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> {
// Требуется текущая версия для оптимистичной блокировки
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)
}
// ... другие методы
}
Лучшие практики для пользовательского SQL
- Используйте
query_asс Row-структурами — Типобезопасное отображение - Привязывайте все параметры — Никогда не интерполируйте строки
- Возвращайте Row, преобразуйте в Entity — Используйте сгенерированные
Fromреализации - Расширяйте, не заменяйте — Добавляйте пользовательские трейты рядом с
Repository - Тестируйте с реальной БД — Интеграционные тесты необходимы
Веб-фреймворки
Как использовать entity-derive с популярными Rust веб-фреймворками.
Автогенерация обработчиков (рекомендуется)
Самый простой способ создать REST API — использовать атрибут api(handlers), который автоматически генерирует все CRUD-обработчики, роутер и OpenAPI-документацию.
Полный CRUD API
use entity_derive::Entity;
use uuid::Uuid;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, Entity)]
#[entity(
table = "users",
schema = "public",
api(
tag = "Users",
security = "cookie", // или "bearer", "api_key"
handlers, // генерирует все 5 CRUD-обработчиков
title = "User Service API",
description = "RESTful API для управления пользователями",
api_version = "1.0.0"
)
)]
pub struct User {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub name: String,
#[field(create, update, response)]
pub email: String,
#[field(create, skip)] // только создание, не в ответе
pub password_hash: String,
#[field(response)]
#[auto]
pub created_at: DateTime<Utc>,
}
// Генерируется:
// - create_user() - POST /users
// - get_user() - GET /users/{id}
// - update_user() - PATCH /users/{id}
// - delete_user() - DELETE /users/{id}
// - list_user() - GET /users
// - user_router() - axum Router со всеми маршрутами
// - UserApi - структура OpenAPI документации
Использование с Axum
use std::sync::Arc;
use axum::Router;
use sqlx::PgPool;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
fn app(pool: Arc<PgPool>) -> Router {
Router::new()
.merge(user_router::<PgPool>())
.merge(SwaggerUi::new("/swagger-ui")
.url("/api-docs/openapi.json", UserApi::openapi()))
.with_state(pool)
}
Выборочные обработчики
Генерация только определённых обработчиков через handlers(...):
// Read-only API (без create, update, delete)
#[entity(api(tag = "Products", handlers(get, list)))]
// Только создание
#[entity(api(tag = "Orders", handlers(create)))]
// Без удаления
#[entity(api(tag = "Users", handlers(create, get, update, list)))]
| Синтаксис | Генерируемые обработчики | OpenAPI схемы |
|---|---|---|
handlers | create, get, update, delete, list | Response, Create, Update |
handlers(get, list) | get, list | Response |
handlers(create, get) | create, get | Response, Create |
Настройка OpenAPI Info
#[entity(api(
tag = "Users",
handlers,
// OpenAPI Info
title = "My API",
description = "Описание API с поддержкой **markdown**",
api_version = "1.0.0",
// Лицензия
license = "MIT",
license_url = "https://opensource.org/licenses/MIT",
// Контакты
contact_name = "API Support",
contact_email = "support@example.com",
contact_url = "https://example.com/support"
))]
Варианты безопасности
// Cookie-авторизация (JWT в httpOnly cookie)
#[entity(api(tag = "Users", security = "cookie", handlers))]
// Bearer token
#[entity(api(tag = "Users", security = "bearer", handlers))]
// API key в заголовке
#[entity(api(tag = "Users", security = "api_key", handlers))]
// Без авторизации
#[entity(api(tag = "Public", handlers))]
Ручные обработчики
Для большего контроля можно писать обработчики вручную, используя сгенерированные DTO и трейт репозитория.
Axum
Структура проекта
src/
├── main.rs
├── entities/
│ ├── mod.rs
│ └── user.rs
├── handlers/
│ ├── mod.rs
│ └── users.rs
└── routes.rs
Определение сущности
// 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>,
}
Обработчики
// 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))
}
Маршруты
// 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
Обработчики
// 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(),
}
}
Маршруты
// 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
}
Обработка ошибок
Улучшенная обработка ошибок с пользовательскими типами:
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, "Ресурс не найден"),
AppError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Ошибка базы данных"),
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)
}
}
// Использование в обработчике:
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)))
}
Валидация
Добавьте валидацию с крейтом validator:
use validator::Validate;
// Вручную добавьте derive Validate к сгенерированной структуре
// или валидируйте перед вызовом методов репозитория
pub async fn create_user(
State(pool): State<PgPool>,
Json(payload): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<UserResponse>), AppError> {
// Ручная валидация
if payload.username.len() < 3 {
return Err(AppError::Validation("Имя пользователя слишком короткое".into()));
}
if !payload.email.contains('@') {
return Err(AppError::Validation("Неверный email".into()));
}
let user = pool.create(payload).await?;
Ok((StatusCode::CREATED, Json(UserResponse::from(&user))))
}
Пагинация
Пример хелпера пагинации:
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))
}
Лучшие практики
Рекомендации по эффективному использованию entity-derive в продакшене.
Проектирование сущностей
Сохраняйте фокус сущностей
Одна сущность на таблицу БД. Не пытайтесь моделировать сложные связи в одной сущности.
// Хорошо: Отдельные сущности
#[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, // Ссылка, не встраивание
#[field(create, update, response)]
pub title: String,
}
// Плохо: Попытка встроить связи
pub struct User {
pub id: Uuid,
pub posts: Vec<Post>, // Не делайте так
}
Используйте осмысленные атрибуты полей
Будьте явными в отношении назначения каждого поля:
// Хорошо: Ясное намерение
#[field(create, response)] // Задаётся один раз, всегда видно
pub email: String,
#[field(update, response)] // Можно изменять, всегда видно
pub display_name: Option<String>,
#[field(response)] // Только чтение, вычисляется/управляется извне
pub post_count: i64,
#[field(skip)] // Никогда не раскрывается
pub password_hash: String,
// Плохо: Всё везде
#[field(create, update, response)] // Это действительно нужно для всех?
pub internal_id: String,
Предпочитайте Option для nullable полей
Соответствуйте схеме БД:
// База данных: email VARCHAR NOT NULL
#[field(create, update, response)]
pub email: String,
// База данных: bio TEXT NULL
#[field(update, response)]
pub bio: Option<String>,
Безопасность
Всегда используйте #[field(skip)] для конфиденциальных данных
// Пароли
#[field(skip)]
pub password_hash: String,
// API-ключи
#[field(skip)]
pub api_key: String,
// Внутренние токены
#[field(skip)]
pub refresh_token: Option<String>,
// Персональные данные, которые не должны быть в ответах
#[field(skip)]
pub ssn: String,
// Внутренние данные аудита
#[field(skip)]
pub created_by_ip: String,
Разделяйте внутренние и внешние сущности
Для данных только для администраторов рассмотрите отдельные сущности:
// Публичная сущность
#[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>,
}
// Сущность только для администраторов (та же таблица, другое представление)
#[derive(Entity)]
#[entity(table = "users", sql = "trait")]
pub struct AdminUser {
#[id]
pub id: Uuid,
#[field(response)]
pub name: String,
#[field(update, response)] // Теперь видно и редактируемо
pub admin_notes: Option<String>,
#[field(response)]
pub last_login_ip: Option<String>,
}
Производительность
Используйте sql = "trait" для сложных запросов
Не боритесь со сгенерированным SQL. Если нужны JOIN или сложная логика, реализуйте самостоятельно:
// Простой CRUD - используйте полную генерацию
#[entity(table = "categories", sql = "full")]
// Нужны сложные запросы - реализуйте сами
#[entity(table = "posts", sql = "trait")]
Пакетные операции
Для массовых вставок реализуйте пользовательские методы:
#[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);
// Вставка внутри транзакции
}
tx.commit().await?;
Ok(())
}
}
Избегайте N+1 запросов
Используйте JOIN вместо поочерёдной загрузки связанных сущностей:
// Плохо: 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 запросов!
}
// Хорошо: Один запрос с JOIN
let posts_with_authors = pool.list_with_authors(100, 0).await?; // 1 запрос
Тестирование
Используйте отдельную тестовую БД
#[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();
// Запуск миграций
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");
}
}
Тестируйте DTO отдельно
#[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 отсутствует в UserResponse
assert_eq!(response.username, "test");
// Нет способа получить password_hash через response
}
#[test]
fn test_update_request_is_partial() {
let update = UpdateUserRequest {
username: Some("new_name".into()),
email: None, // Не обновляем email
};
assert!(update.username.is_some());
assert!(update.email.is_none());
}
Организация проекта
Рекомендуемая структура
src/
├── entities/ # Определения сущностей
│ ├── mod.rs
│ ├── user.rs
│ ├── post.rs
│ └── comment.rs
├── repositories/ # Расширения пользовательских репозиториев
│ ├── mod.rs
│ └── post_search.rs
├── handlers/ # HTTP-обработчики
│ ├── mod.rs
│ ├── users.rs
│ └── posts.rs
├── services/ # Бизнес-логика
│ ├── mod.rs
│ └── auth.rs
└── main.rs
Реэкспортируйте сгенерированные типы
// src/entities/mod.rs
mod user;
mod post;
pub use user::*;
pub use post::*;
Группируйте связанные сущности
// src/entities/auth/mod.rs
mod user;
mod session;
mod api_key;
pub use user::*;
pub use session::*;
pub use api_key::*;
Распространённые ошибки
1. Забыли #[field(skip)] для конфиденциальных полей
// Неправильно: password_hash будет в Response!
pub struct User {
pub password_hash: String,
}
// Правильно
#[field(skip)]
pub password_hash: String,
2. Использование sql = "full" когда нужны JOIN
Если нужны связанные данные, используйте sql = "trait" и реализуйте самостоятельно.
3. Неправильная обработка опциональных обновлений
Помните: поля UpdateRequest — это Option<T>. Проверяйте перед применением:
// Сгенерированный UpdateUserRequest имеет Option<String> для name
// Ваша логика обновления должна обрабатывать None (без изменений) vs Some (изменение)
4. Дублирование бизнес-логики
Помещайте валидацию и бизнес-правила в сервисный слой, не в обработчики:
// Хорошо: Сервисный слой
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)
}
}
// Плохо: Логика разбросана в обработчиках
pub async fn create_user(pool: State<PgPool>, request: Json<CreateUserRequest>) -> ... {
// Валидация здесь
// Бизнес-правила здесь
// Вызов репозитория здесь
// Всё перемешано
}
Чек-лист
Перед деплоем:
- Все конфиденциальные поля имеют
#[field(skip)] - DTO соответствуют ожиданиям API-контракта
- Сложные запросы используют
sql = "trait" - Интеграционные тесты покрывают методы репозитория
- Обработка ошибок единообразна
- Пагинация реализована для list-эндпоинтов
- Существуют индексы БД для паттернов запросов