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 后端项目,包含约10个实体意味着:

组件代码行数问题
DTO(Create、Update、Response)每个实体约60行手动同步,遗漏字段
Repository trait + impl每个实体约150行运行时SQL错误,复制粘贴
Entity ↔ DTO 映射每个实体约40行数据泄露(Response中的password_hash
验证和钩子分散在服务中重复,没有单一来源
事件/审计缺失或临时方案没有变更历史

总计:10个实体约2500行样板代码。每次架构变更都需要在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行 → 完整的领域层:

  • CreateUserRequestUpdateUserRequestUserResponse
  • 类型安全SQL的 UserRepository
  • UserEvent::CreatedUpdatedDeleted
  • 业务逻辑的 UserHooks
  • RegisterUserDeactivateUser 命令
  • 用于过滤的 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,
}

完成。 你现在拥有:

  • CreatePostRequestUpdatePostRequestPostResponse
  • 带有 create()find_by_id()update()delete()list()PostRepository
  • PostgreSQL的类型安全SQL
  • 一切开箱即用

没有魔法。 运行 cargo expand — 你会看到你自己会写的代码。只是没有错误,而且只需几秒。


为什么需要事件?

问题: CRUD应用没有历史记录。谁修改了记录?什么时候?之前是什么?审计需要单独的基础设施和纪律。

解决方案: #[entity(events)] 生成类型化事件:

pub enum UserEvent {
    Created(User),
    Updated { id: Uuid, changes: UpdateUserRequest },
    Deleted(Uuid),
}

优势:

  • 开箱即用的审计 — 订阅事件,保存到日志
  • 事件溯源 — 可以从事件历史恢复状态
  • 集成 — Kafka、WebSocket通知、缓存失效
  • 调试 — 每个实体的完整变更历史

为什么需要钩子?

问题: 业务逻辑分散各处。邮箱验证 — 在控制器中。密码哈希 — 在服务中。发送邮件 — 在单独的worker中。用户创建逻辑在哪里找?

解决方案: #[entity(hooks)] 集中生命周期:

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 — 停用?更改邮箱?封禁?

解决方案: #[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 — 命令名称 = 业务词汇
  • 不同逻辑DeactivateBan 可以有不同的副作用
  • CQRS就绪 — 命令易于路由、记录、重试
  • 类型安全 — 编译器验证命令存在

为什么需要类型安全过滤?

问题: 字符串查询参数是运行时错误的来源:

GET /users?stauts=active  // 拼写错误 — 静默忽略
GET /users?created_at=tomorrow  // 无效日期 — 运行时panic

解决方案: #[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?;

优势:

  • 编译时检查 — 字段名拼写错误 = 编译错误
  • 类型安全 — 不能用 String 比较 DateTime
  • 自动补全 — IDE提示可用过滤器
  • SQL注入保护 — 参数绑定,不是拼接

透明性

宏不隐藏逻辑。所有生成的都是普通Rust代码,你可以:

  • 阅读cargo expand 显示所有生成的代码
  • 理解 — 没有运行时反射,只有结构体和trait
  • 覆盖sql = "trait" 然后写你自己的SQL
  • 调试 — 编译器错误指向你的代码,不是宏内部
// 想了解生成了什么?
cargo expand --lib | grep -A 50 "impl UserRepository"

零魔法。 如果宏坏了 — 你总是可以手写代码。没有锁定。


Rust的全部力量

编译时保证

#[field(skip)]
pub password_hash: String,

这不是运行时检查“不要序列化这个字段“。这是字段在 UserResponse 结构中物理上不存在。不可能意外返回 — 字段根本不存在。

零成本抽象

生成的代码是:

  • 没有Box/dyn的普通 struct
  • 直接调用sqlx,没有中间层
  • 热路径上的 #[inline]
  • 没有超出必要的分配

基准测试: 生成的repository运行速度与手写相同。因为它就是相同的代码。

开箱即用的Async

// 一切都是async,一切都是Send + Sync
let user = pool.find_by_id(id).await?;
let users = pool.list(100, 0).await?;

与tokio、async-std、任何async运行时完全兼容。

严格类型

// 编译错误:没有这个字段
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 — 生命周期逻辑

Infrastructure Layer (你的代码)
├── Repository Impl — PgPool自动或自定义
├── Event Handlers — 事件订阅
├── Command Handlers — 业务逻辑实现
└── External Services — 集成

清晰分离。Domain不知道HTTP、数据库、Kafka。这些都是实现细节。

CQRS/事件溯源就绪

// 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?启用 commandsevents。架构随项目成长。

可扩展性

级别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个实体一小时而不是一天
可靠性一切的编译时验证
安全性不可能意外泄露数据
性能零成本,像手写代码
清晰度透明生成,没有魔法
灵活性一个属性从简单CRUD到CQRS
可扩展性架构随项目成长
可维护性单一真相来源,更少bug

文档

主题描述
[[属性|属性]]完整属性参考
[[过滤|过滤]]类型安全查询过滤
[[关系|关系]]belongs_tohas_many
[[事件|事件]]生命周期事件
[[钩子|钩子]]Before/after钩子
[[命令|命令]]CQRS模式
[[自定义SQL|自定义SQL]]复杂查询
[[示例|案例]]真实用例
[[Web框架|Web框架]]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
)]
pub struct User { /* ... */ }

快速参考

属性必需默认值描述
table数据库表名
schema"public"数据库模式
sql"full"SQL生成级别
dialect"postgres"数据库方言
uuid"v7"ID生成的UUID版本
soft_deletefalse启用软删除
returning"full"RETURNING子句模式
upsert(...)生成基于 INSERT ... ON CONFLICT 的 upsert 方法
api(guard = "...")在生成的 handler 中强制执行的 FromRequestParts 守卫
errorsqlx::Error自定义错误类型
eventsfalse生成生命周期事件
hooksfalse生成生命周期钩子trait
commandsfalse启用CQRS命令模式

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 TraitPgPool实现用例
"full"标准CRUD实体
"trait"自定义查询(joins、CTEs)
"none"仅DTO,无数据库
#[entity(table = "users", sql = "full")]   // 完全自动化(默认)
#[entity(table = "users", sql = "trait")]  // 仅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?

  • 时间排序:按创建时间自然排序
  • 更好的数据库索引性能
  • 无需协调(不像序列)
  • 在分布式系统中全局唯一

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 *获取所有字段包括DB生成的(默认)
"id"RETURNING id确认插入,返回预构建的实体
"none"(无RETURNING)即发即弃,最快选项
"col1, col2"RETURNING col1, col2返回特定列
#[entity(table = "logs", returning = "none")]              // 最快
#[entity(table = "users", returning = "full")]             // 获取DB生成的值
#[entity(table = "events", returning = "id, created_at")]  // 自定义列

events(outbox)(可选)

通过事务性 outbox 实现持久事件投递。单独的 events 只生成枚举;streams 的 NOTIFY 是即发即弃。events(outbox) 使每个生成的写操作在同一事务中将序列化事件插入 entity_outbox 表,OutboxDrainer 运行时(entity-core,outbox 特性)以 FOR UPDATE SKIP LOCKED、指数退避、超过 max_attempts 后搁置的方式投递行。至少一次投递——处理器必须幂等。可与 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(...)(可选)

生成基于 INSERT ... ON CONFLICTupsert 仓库方法。

#[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 = "...")(可选)

在生成的 handler 中强制执行认证。security = "..." 仅在 OpenAPI 中做文档标注;guard 会将 axum 的 FromRequestParts extractor 作为每个生成的 CRUD 和命令 handler 的首个参数注入——提取失败会在 handler 主体执行前拒绝请求。

pub struct RequireAuth;

impl<S: Send + Sync> FromRequestParts<S> for RequireAuth {
    type Rejection = StatusCode;
    async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
        parts.headers.contains_key("authorization")
            .then_some(Self)
            .ok_or(StatusCode::UNAUTHORIZED)
    }
}

#[derive(Entity)]
#[entity(table = "users", api(tag = "Users", handlers, guard = "RequireAuth", guard(list = "none")))]
pub struct User { /* ... */ }

按操作覆盖:guard(create = "Admin", list = "none", ...),操作包括 creategetupdatedeletelistcommands;字面量 "none" 禁用守卫。列在 public = [...] 中的命令不会收到守卫。

error(可选)

repository的自定义错误类型。默认: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 { /* ... */ }

// 生成的repository使用AppError:
// impl UserRepository for PgPool {
//     type Error = AppError;
//     ...
// }

events(可选)

生成生命周期事件枚举。详见 [[事件|事件]]。

#[entity(table = "orders", events)]

生成:

pub enum OrderEvent {
    Created(Order),
    Updated { id: Uuid, changes: UpdateOrderRequest },
    Deleted(Uuid),
}

hooks(可选)

生成生命周期钩子trait。详见 [[钩子|钩子]]。

#[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)]

字段级属性

应用于单个字段。

#[id]

标记主键字段。

行为:

  • 自动生成UUID(默认v7,可用uuid属性配置)
  • 始终包含在 Response DTO中
  • CreateRequestUpdateRequest 中排除
#[id]
pub id: Uuid,

#[auto]

标记自动生成的字段(时间戳、序列)。

行为:

  • From<CreateRequest> 中获取 Default::default()
  • CreateRequestUpdateRequest 中排除
  • 可通过 #[field(response)] 包含在 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 = "...")]

ValueObject Postgres 枚举接入 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 列类型(否则枚举字段回退为 TEXT)
  • 将枚举的幂等 PG_CREATE_TYPE DDL 注册到 {Entity}::MIGRATION_TYPES — 请在 MIGRATION_UP 之前执行
  • 声明的名称在编译期与枚举的 PG_TYPE 常量核对,不一致会导致构建失败
  • ValueObject 的可选 sqlx 标志会生成 sqlx::Type / Encode / Decode 实现;若已自行 derive sqlx::Type 则省略

#[owner]

行级所有权范围限定。标记承载所有者 id 的列后,仓库将获得范围限定方法——绝不泄露某行是否属于其他所有者,并遵循 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_scopedlist_by_ownerupdate_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,

生成: repository中的 find_user() 方法。

#[has_many(Entity)]

一对多关系(实体级)。详见 [[关系|关系]]。

#[has_many(Post)]
pub struct User { /* ... */ }

生成: repository中的 find_posts() 方法。

#[projection(Name: fields)]

生成部分视图结构(实体级)。

#[projection(Public: id, name, avatar)]
#[projection(Admin: id, name, email, role)]
pub struct User { /* ... */ }

生成:

  • UserPublic { id, name, avatar }
  • UserAdmin { id, name, email, role }
  • From<User> 实现
  • find_by_id_public()find_by_id_admin() 方法

命令属性

通过 #[command(...)] 应用于实体级。

快速参考

语法效果
#[command(Name)]使用所有 #[field(create)] 字段
#[command(Name: field1, field2)]仅使用指定字段(添加 requires_id
#[command(Name, requires_id)]添加ID字段,无其他字段
#[command(Name, source = "create")]显式使用create字段(默认)
#[command(Name, source = "update")]使用update字段(可选,添加 requires_id
#[command(Name, source = "none")]无payload字段
#[command(Name, payload = "Type")]使用自定义payload结构
#[command(Name, result = "Type")]使用自定义结果类型
#[command(Name, kind = "create")]提示:创建实体(默认)
#[command(Name, kind = "update")]提示:修改实体
#[command(Name, kind = "delete")]提示:删除实体(返回 ()
#[command(Name, kind = "custom")]提示:自定义操作

详见 [[命令|命令]]。

完整示例

#[derive(Entity)]
#[entity(
    table = "posts",
    schema = "blog",
    sql = "full",
    dialect = "postgres",
    uuid = "v7",
    soft_delete,
    returning = "full",
    events,
    hooks,
    commands
)]
#[has_many(Comment)]
#[projection(Summary: id, title, author_id, created_at)]
#[command(Publish)]
#[command(Archive, requires_id)]
pub struct Post {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    #[filter(like)]
    pub title: String,

    #[field(create, update, response)]
    pub content: String,

    #[field(create, response)]
    #[belongs_to(User)]
    #[filter]
    pub author_id: Uuid,

    #[field(update, response)]
    pub published: bool,

    #[field(response)]
    #[filter(range)]
    pub view_count: i64,

    #[field(skip)]
    pub moderation_notes: String,

    #[field(skip)]
    pub deleted_at: Option<DateTime<Utc>>,

    #[auto]
    #[field(response)]
    #[filter(range)]
    pub created_at: DateTime<Utc>,

    #[auto]
    #[field(response)]
    pub updated_at: DateTime<Utc>,
}

决策矩阵

我想要…属性
自动生成主键#[id]
使用随机UUID实体上 uuid = "v4"
使用时间排序UUIDuuid = "v7"(默认)
在POST请求体中接受#[field(create)]
在PATCH请求体中接受#[field(update)]
在API响应中返回#[field(response)]
接受并返回#[field(create, update, response)]
从所有API隐藏#[field(skip)]
自动生成时间戳#[auto] + #[field(response)]
只读(DB管理)#[field(response)]
只写(不返回)#[field(create)]
自定义SQL查询sql = "trait"
仅DTO,无DBsql = "none"
软删除记录实体上 soft_delete
自定义错误类型实体上 error = "MyError"
按精确值过滤字段上 #[filter]
按模式过滤字段上 #[filter(like)]
按范围过滤字段上 #[filter(range)]
跟踪实体变更实体上 events
在生命周期运行代码实体上 hooks
使用领域命令实体上 commands + #[command(...)]
定义关系#[belongs_to(Entity)]#[has_many(Entity)]
实体部分视图#[projection(Name: fields)]

更新 DTO:PATCH 语义

生成的更新是真正的部分补丁:SET 子句在运行时由实际存在的字段构建,省略的字段保持不变。可空列通过 entity_core::serde_helpers::double_option 使用双重 OptionNone = 保留,Some(None) = 置 NULL,Some(Some(v)) = 置 v)。

// {}                   → 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 字段,编译期校验)、auditentity_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)后,更新 DTO 获得必填的 expected_version 字段,生成的 UPDATE 会递增该列,且仅在存储的版本仍匹配时生效——过期的写入会以冲突错误失败,而不是覆盖较新的数据。DDL 默认 INTEGER NOT NULL DEFAULT 0。适用于普通、范围限定和事务更新。

#[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(可选)

宏了解自己创建的每个约束。启用此标志后,生成的写入方法会解析被违反的约束名称(unique 列、belongs_to 外键、列 check、unique_index 名称),并返回 entity_core::ConstraintError { kind, constraint, field },而不是原始驱动错误。需要实现 From<ConstraintError> 的自定义 error 类型;不启用标志则行为不变。

#[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(...))]

将值对象展开为带前缀的平面标量列。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 特性(验证后端)

在生成的 DTO 上启用 garde::Validate,作为 validator 的持续维护替代。字段的 #[validate(...)] 规则(lengthrangeemailurlpattern)会翻译为 garde 语法;无约束字段获得 garde(skip);更新 DTO 的 Option 包装通过 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

声明宏无法推断的约束——基于自然键的外键、自定义命名的 CHECK 约束、手写迁移中的索引——使违规解析为携带所声明字段的 ConstraintError。类型:uniqueforeign_keycheck。自定义条目优先于同名的派生条目。需要 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

同时启用 transactionsupsert(...) 时,{Entity}TransactionRepo 适配器提供与池方法相同 SQL 语义的 upsert,在事务句柄上执行——适用于 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?;

// 响应是安全的 - 没有 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>,
}

电商系统

产品目录:

#[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,  // 由数据库序列生成

    #[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 实现

没有repository,没有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> {
        // 复杂聚合查询
    }
}

另见

  • [[属性|属性]] — 完整属性参考
  • [[自定义SQL|自定义SQL]] — 自定义repository实现
  • [[Web框架|Web框架]] — Axum/Actix集成

批量操作

每个仓库都提供批量原语:find_by_idsWHERE id = ANY($1),单次往返)、原子的 create_many(单个事务,任一行失败则整批回滚)以及感知软删除的 delete_many(返回实际受影响的行数)。启用事件投递(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>,
}

Repository方法

#[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|自定义SQL]] — 复杂自定义查询
  • [[关系|关系]] — 带关系的过滤

排序与 keyset 分页

#[sort] 标记可排序列:Query 结构体获得白名单选择器 {Entity}SortField(每列一对 Asc/Desc 变体,JSON 为 snake_case),用户输入永远无法注入 SQL。每个仓库还会生成 list_after keyset 分页——使用默认 UUIDv7 id 时,按 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?;

三元组搜索

在文本列上使用 #[filter(search)] 会添加模糊子串过滤(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] 定义实体之间的关系。关系在repository中生成类型安全的导航方法。

快速开始

// 父实体
#[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,
}

生成的代码

User上的 #[has_many(Post)]

#[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())
    }
}

Post上的 #[belongs_to(User)]

#[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)
}

构建响应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)
}

多重关系

一个实体可以有多个关系:

#[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)

使用 sql = “trait” 自定义Join

对于需要eager loading的复杂查询,使用自定义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进行eager loading
  2. 使用分页 — 始终限制 has_many 结果
  3. 考虑数据访问模式 — 在外键列上添加索引
  4. 适当缓存 — 缓存经常访问的关联数据
  5. 使用投影 — 只获取关联实体的必要字段

另见

  • [[过滤|过滤]] — 查询过滤
  • [[自定义SQL|自定义SQL]] — 复杂join和查询
  • [[最佳实践|最佳实践]] — 性能技巧

has_many through(多对多)

通过 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_userSELECT EXISTS)。

事件

为实体生命周期变更生成领域事件。事件支持审计日志、事件溯源和消息队列集成。

快速开始

#[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 属性生成事件枚举:

/// 由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();
    }
}

事件溯源模式

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. 幂等处理器 — 事件处理器应该是幂等的,以支持至少一次交付
  3. 包含上下文 — 考虑添加元数据(user_id、timestamp、correlation_id)
  4. 异步处理 — 使用后台worker处理重型事件
  5. 死信队列 — 优雅地处理失败的事件

与钩子结合

事件和钩子配合良好:

#[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,
}

要求:

  • 实体必须派生 SerializeDeserialize(用于 JSON 载荷)
  • 需要同时使用 eventsstreams 属性
  • 在 Cargo.toml 中启用 streams 特性
[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();
                }
                _ => {}
            }
        }
    }
}

带优雅关闭的后台工作器

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. 优雅关闭 — 使用 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 属性生成异步trait:

/// 由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> {
        // 规范化邮箱
        dto.email = dto.email.trim().to_lowercase();

        // 验证邮箱格式
        if !dto.email.contains('@') {
            return Err(AppError::Validation("邮箱格式无效".into()));
        }

        // 检查重复邮箱
        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("邮箱已注册".into()));
        }

        Ok(())
    }

    async fn after_create(&self, entity: &User) -> Result<(), Self::Error> {
        // 发送欢迎邮件
        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> {
        // 如果提供了邮箱则规范化
        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("邮箱已被使用".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> {
    // 规范化邮箱
    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(())
}

与软删除配合

当启用 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>;
}

与命令配合

当同时启用 commandshooks 时,会生成命令钩子:

#[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>;
}

最佳实践

  1. 保持钩子快速 — 长时间运行的操作应该是异步任务
  2. 使用事务 — 将钩子 + repository调用包装在事务中
  3. 优雅处理错误 — 返回有意义的错误类型
  4. 不要重复逻辑 — 将钩子用于横切关注点
  5. 独立测试钩子 — 对钩子实现进行单元测试

错误处理模式

#[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("邮箱必填".into()));
        }
        Ok(())
    }
}

另见

  • [[事件|事件]] — 用于审计日志的生命周期事件
  • [[命令|命令]] — 带命令钩子的CQRS模式
  • [[最佳实践|最佳实践]] — 生产环境技巧

命令

定义面向业务的命令而不是通用CRUD。命令将领域语言带入你的API,并启用命令查询职责分离(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,
}

生成的代码

命令结构体

/// User上Register操作的命令载荷。
#[derive(Debug, Clone)]
pub struct RegisterUser {
    pub email: String,
    pub name: String,
}

/// User上UpdateEmail操作的命令载荷。
#[derive(Debug, Clone)]
pub struct UpdateEmailUser {
    pub id: Uuid,
    pub email: String,
}

/// User上Deactivate操作的命令载荷。
#[derive(Debug, Clone)]
pub struct DeactivateUser {
    pub id: Uuid,
}

命令枚举

/// 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",
        }
    }
}

结果枚举

/// User命令执行的结果枚举。
#[derive(Debug, Clone)]
pub enum UserCommandResult {
    Register(User),
    UpdateEmail(User),
    Deactivate,
}

Handler Trait

/// 处理User命令的异步trait。
#[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 },返回 ()

自定义载荷

使用外部结构体:

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而不是实体

Source选项

控制使用哪些字段:

#[command(Create, source = "create")]     // 使用#[field(create)]字段(默认)
#[command(Modify, source = "update")]     // 使用#[field(update)]字段(可选)
#[command(Ping, source = "none")]         // 无载荷字段

Kind提示

影响结果类型推断:

#[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("邮箱必填".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("无法更新其他用户的邮箱".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!(),
    }
}

// 或直接调用特定handler
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 Trait

所有命令枚举都实现 EntityCommand trait:

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!("自定义操作"),
}

命令钩子

当同时启用 commandshooks 时:

#[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>;
}

最佳实践

  1. 领域语言 — 使用业务术语:RegisterUser 而不是 CreateUser
  2. 单一职责 — 一个命令 = 一个业务操作
  3. 明确意图 — 命令名应描述操作
  4. 在handler中验证 — 将验证逻辑保留在命令handler中
  5. 尽可能幂等 — 设计命令以便安全重试
  6. 使用上下文 — 通过上下文传递请求元数据(用户、关联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?;

另见

  • [[钩子|钩子]] — 生命周期钩子包括命令钩子
  • [[事件|事件]] — 用于审计日志的事件生成
  • [[属性|属性]] — 完整属性参考

事务

具有自动提交/回滚的类型安全多实体事务。

什么是事务?

数据库事务是一种将多个数据库操作组合成单个原子单元的方法。这意味着:

  • 全有或全无:要么所有操作都成功,要么没有任何操作被应用
  • 自动回滚:如果任何操作失败,所有先前的更改都会自动撤销
  • 数据一致性:您的数据库永远不会处于不一致状态

为什么需要事务?

想象一下您正在构建一个银行应用程序,需要在账户之间转账:

1. 从账户A减去100元
2. 向账户B增加100元

没有事务,如果步骤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,
}

生成了什么?

对于带有#[entity(transactions)]Account实体,宏会生成:

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. 返回结果(自动提交)
        })
        .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,
            }).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

事务错误类型

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 BYHAVING、窗口函数
  • 分区表 — 基于时间或范围的分区
  • 批量操作 — 批量插入/更新
  • 软删除 — 自定义删除逻辑
  • 乐观锁 — 基于版本的并发控制

基本配置

#[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(CreatePostRequestUpdatePostRequestPostResponse
  • PostRowInsertablePost
  • PostRepository trait
  • 所有 From 实现

不会生成 impl PostRepository for PgPool

实现Repository

use async_trait::async_trait;
use sqlx::PgPool;

#[async_trait]
impl PostRepository for PgPool {
    type Error = sqlx::Error;

    async fn create(&self, dto: CreatePostRequest) -> Result<Post, Self::Error> {
        let entity = Post::from(dto);
        let insertable = InsertablePost::from(&entity);

        sqlx::query(
            r#"
            INSERT INTO blog.posts (id, title, author_id, created_at)
            VALUES ($1, $2, $3, $4)
            "#
        )
        .bind(insertable.id)
        .bind(&insertable.title)
        .bind(insertable.author_id)
        .bind(insertable.created_at)
        .execute(self)
        .await?;

        Ok(entity)
    }

    async fn find_by_id(&self, id: Uuid) -> Result<Option<Post>, Self::Error> {
        let row: Option<PostRow> = sqlx::query_as(
            "SELECT id, title, author_id, created_at FROM blog.posts WHERE id = $1"
        )
        .bind(&id)
        .fetch_optional(self)
        .await?;

        Ok(row.map(Post::from))
    }

    async fn update(&self, id: Uuid, dto: UpdatePostRequest) -> Result<Post, Self::Error> {
        // 你的自定义更新逻辑
        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的文章

// 带作者数据的扩展响应
pub struct PostWithAuthor {
    pub post: Post,
    pub author: User,
}

// 自定义repository扩展
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 旁边添加自定义trait
  5. 使用真实数据库测试 — 集成测试是必不可少的

另见

  • [[属性|属性]] — 完整属性参考
  • [[案例|案例]] — 实际案例
  • [[最佳实践|最佳实践]] — 性能技巧

Web框架

如何在流行的Rust Web框架中使用 entity-derive

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 crate 添加验证:

use validator::Validate;

// 手动为生成的结构体添加 Validate derive
// 或在调用repository方法之前进行验证

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("无效的邮箱".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

与数据库模式匹配:

// 数据库: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>,

// 不应出现在响应中的PII
#[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");
    // 无法通过 response 访问 password_hash
}

#[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/       # 自定义repository扩展
│   ├── 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. 当需要Join时使用 sql = "full"

如果需要关联数据,使用 sql = "trait" 并自己实现。

3. 不处理可选更新

记住:UpdateRequest 字段是 Option<T>。应用前先检查:

// 生成的 UpdateUserRequest 的 name 字段是 Option<String>
// 你的更新逻辑应该处理 None(不更改)vs Some(更改)

4. 重复业务逻辑

将验证和业务规则放在服务层,而不是handlers中:

// 好:服务层
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)
    }
}

// 坏:逻辑分散在handlers中
pub async fn create_user(pool: State<PgPool>, request: Json<CreateUserRequest>) -> ... {
    // 验证在这里
    // 业务规则在这里
    // Repository调用在这里
    // 全部混在一起
}

检查清单

部署前:

  • 所有敏感字段都有 #[field(skip)]
  • DTO符合API契约预期
  • 复杂查询使用 sql = "trait"
  • 集成测试覆盖repository方法
  • 错误处理一致
  • 列表端点实现了分页
  • 查询模式有数据库索引

另见

  • [[属性|属性]] — 完整属性参考
  • [[案例|案例]] — 实际案例
  • [[Web框架|Web框架]] — 框架集成