Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

entity-derive

Один макрос, чтобы править всеми

English Русский 한국어 Español 中文


Что это?

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, UserResponse
  • UserRepository с типобезопасным SQL
  • UserEvent::Created, Updated, Deleted
  • UserHooks для бизнес-логики
  • 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, PostResponse
  • PostRepository с 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() вместо DELETE
  • hard_delete() — Окончательно удаляет запись
  • restore() — Устанавливает deleted_at = NULL
  • find_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) — Найти сущность по ID
  • update(id, dto) — Обновить сущность
  • delete(id) — Удалить сущность (учитывает soft_delete)
  • list(limit, offset) — Список сущностей

Особенности:

  • Автоматический откат при ошибке или панике
  • Типобезопасный паттерн builder
  • Полная поддержка CRUD в транзакции

Атрибуты уровня поля

Применяются к отдельным полям.

#[id]

Отмечает поле первичного ключа.

Поведение:

  • Автоматически генерирует UUID (v7 по умолчанию, настраивается атрибутом uuid)
  • Всегда включается в Response DTO
  • Исключается из 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_TYPE enum’а в {Entity}::MIGRATION_TYPES — выполняйте их до MIGRATION_UP
  • Указанное имя сверяется с константой PG_TYPE enum’а на этапе компиляции; несовпадение ломает сборку
  • Опциональный флаг 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]
Использовать случайный UUIDuuid = "v4" на сущности
Использовать упорядоченный по времени UUIDuuid = "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,
}

Генерируется только:

  • CreateWebhookPayloadRequest
  • WebhookPayloadResponse
  • Реализации 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())
    }
}

Лучшие практики

  1. Пагинация по умолчанию — Всегда применяйте разумные лимиты для предотвращения больших результатов
  2. Валидация шаблонов — Санитизируйте шаблоны LIKE для предотвращения проблем с SQL
  3. Индексация фильтруемых колонок — Создавайте индексы БД для часто фильтруемых полей
  4. Используйте конкретные фильтры — Предпочитайте точное совпадение шаблонному где возможно
  5. Комбинируйте с сортировкой — Рассмотрите добавление полей сортировки в структуру запроса

См. также

  • [[Атрибуты|Атрибуты]] — Полный справочник атрибутов
  • Кастомный 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?;

Лучшие практики

  1. Избегайте N+1 запросов — Используйте JOIN для жадной загрузки нескольких связанных сущностей
  2. Используйте пагинацию — Всегда ограничивайте результаты has_many
  3. Учитывайте паттерны доступа — Добавляйте индексы на колонки внешних ключей
  4. Кэшируйте при необходимости — Кэшируйте часто запрашиваемые связанные данные
  5. Используйте проекции — Запрашивайте только нужные поля для связанных сущностей

См. также

  • [[Фильтрация|Фильтрация]] — Фильтрация запросов
  • Кастомный 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),   // Окончательное удаление
}

Лучшие практики

  1. Публикация после коммита — Публикуйте события только после успешной транзакции в БД
  2. Идемпотентные обработчики — Обработчики событий должны быть идемпотентными для семантики at-least-once
  3. Включайте контекст — Рассмотрите добавление метаданных (user_id, timestamp, correlation_id)
  4. Асинхронная обработка — Используйте фоновые воркеры для тяжёлой обработки событий
  5. 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   Инвалидатор
 Дашборд     кэша

Лучшие практики

  1. Переподключение — PgListener автоматически переподключается; проектируйте цикл с учётом временных сбоев
  2. Идемпотентность — События могут доставляться несколько раз; обработчики должны быть идемпотентными
  3. Размер полезной нагрузки — Держите сущности компактными; большие данные могут превысить лимиты Postgres
  4. Отдельные пулы — Используйте выделенный пул соединений для слушателей
  5. Мониторинг — Логируйте ошибки потока и отслеживайте задержку обработки событий
  6. 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(())
}

Лучшие практики

  1. Быстрые хуки — Длительные операции выносите в фоновые задачи
  2. Используйте транзакции — Оборачивайте хук + вызов репозитория в транзакцию
  3. Обработка ошибок — Возвращайте осмысленные типы ошибок
  4. Не дублируйте логику — Используйте хуки для сквозной функциональности
  5. Тестируйте независимо — 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(())
}

Лучшие практики

  1. Доменный язык — Используйте бизнес-термины: RegisterUser вместо CreateUser
  2. Единая ответственность — Одна команда = одна бизнес-операция
  3. Явное намерение — Имена команд должны описывать действие
  4. Валидация в обработчиках — Логика валидации в обработчиках команд
  5. Идемпотентность — Проектируйте команды для безопасного повторного выполнения
  6. Используйте контекст — Передавайте метаданные запроса (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() к билдеру транзакции.

Доступные методы

Внутри транзакции доступны следующие методы:

МетодСигнатураОписание
createcreate(dto) -> Result<Entity, Error>Вставить новую запись
find_by_idfind_by_id(id) -> Result<Option<Entity>, Error>Найти по первичному ключу
updateupdate(id, dto) -> Result<Entity, Error>Обновить существующую запись
deletedelete(id) -> Result<bool, Error>Удалить запись (или мягкое удаление)
listlist(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
}

Пошагово:

  1. Transaction::new(pool) — Создаёт билдер транзакции с пулом соединений
  2. .with_accounts() — Добавляет репозиторий Account в контекст транзакции
  3. .run(|mut ctx| async move { ... }) — Выполняет операции внутри транзакции
  4. 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

  1. Используйте query_as с Row-структурами — Типобезопасное отображение
  2. Привязывайте все параметры — Никогда не интерполируйте строки
  3. Возвращайте Row, преобразуйте в Entity — Используйте сгенерированные From реализации
  4. Расширяйте, не заменяйте — Добавляйте пользовательские трейты рядом с Repository
  5. Тестируйте с реальной БД — Интеграционные тесты необходимы

Веб-фреймворки

Как использовать 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 схемы
handlerscreate, get, update, delete, listResponse, Create, Update
handlers(get, list)get, listResponse
handlers(create, get)create, getResponse, 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-эндпоинтов
  • Существуют индексы БД для паттернов запросов