PATCH /users/{id} 这类部分更新接口里,一个字段通常不只有“有值”和“没值”两种状态。以 name 为例,客户端可能发送:
{}
{"name": null}
{"name": "Alice"}
它们的业务含义分别是:不修改 name、将 name 清空为数据库 NULL、将 name 更新为 Alice。
问题的核心不是 Rust 有没有 nil,而是 API 输入确实有三种独立语义。把它们明确建模,才能避免把“字段没传”误当成“清空字段”。
三层语义
用 Option<Option<T>> 可以表达这三个状态:
| JSON 输入 | Rust 值 | 数据库操作 |
|---|---|---|
| 字段缺失 | None | 不修改列 |
"field": null | Some(None) | SET field = NULL |
"field": value | Some(Some(value)) | SET field = value |
外层 Option 表示字段有没有出现在请求中,内层 Option 表示该字段出现后是不是 JSON null。
这个类型也和 SeaORM 的 ActiveValue 一一对应:
| 请求 DTO | SeaORM ActiveModel | 含义 |
|---|---|---|
None | NotSet | 不出现在 UPDATE SET 中 |
Some(None) | Set(None) | 更新为 NULL |
Some(Some(value)) | Set(Some(value)) | 更新为具体值 |
只写双层 Option 还不够
下面的写法看起来合理,但不能区分字段缺失和 null:
#[derive(serde::Deserialize)]
struct IncorrectUpdateUserReq {
name: Option<Option<String>>,
}
Serde 默认会把缺失字段和 JSON null 都解析成外层的 None。因此,只有 Option<Option<T>> 这个类型本身还不够,还要告诉 Serde 如何处理“字段出现且值为 null”。
serde_with 已经提供了这个适配器。依赖只需要开启正常的 derive 功能:
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_with = "3"
sea-orm = { version = "2", features = ["macros"] }
请求 DTO
下面假设 name、age、email 和 active 在数据库中都是允许 NULL 的列。数据库列是否可空是接口契约的一部分,不能为了复用这套类型而放宽原本的非空约束。
use sea_orm::DeriveIntoActiveModel;
use serde::Deserialize;
#[derive(Debug, Deserialize, DeriveIntoActiveModel)]
#[sea_orm(active_model = "entity::user::ActiveModel")]
pub struct UpdateUserReq {
pub id: i64,
#[serde(default, with = "serde_with::rust::double_option")]
pub name: Option<Option<String>>,
#[serde(default, with = "serde_with::rust::double_option")]
pub age: Option<Option<i32>>,
#[serde(default, with = "serde_with::rust::double_option")]
pub email: Option<Option<String>>,
#[serde(default, with = "serde_with::rust::double_option")]
pub active: Option<Option<bool>>,
}
两个属性都不能省:
default让字段缺失时得到None;with = "serde_with::rust::double_option"让字段存在且值为null时得到Some(None)。
所以三个请求会被解析为:
// {"id": 42}
UpdateUserReq {
id: 42,
name: None,
age: None,
email: None,
active: None,
}
// {"id": 42, "email": null}
UpdateUserReq {
id: 42,
name: None,
age: None,
email: Some(None),
active: None,
}
// {"id": 42, "active": true}
UpdateUserReq {
id: 42,
name: None,
age: None,
email: None,
active: Some(Some(true)),
}
交给 SeaORM 生成更新状态
SeaORM 的 DeriveIntoActiveModel 可以直接利用双层 Option。它将请求 DTO 转成对应实体的 ActiveModel,并按字段生成正确的 ActiveValue:
use sea_orm::{
ActiveModelTrait,
DatabaseConnection,
DbErr,
IntoActiveModel,
};
pub async fn update_user(
db: &DatabaseConnection,
request: UpdateUserReq,
) -> Result<entity::user::Model, DbErr> {
let active_model: entity::user::ActiveModel = request.into_active_model();
active_model.update(db).await
}
例如,{"id": 42, "email": null} 会让 email 变为 Set(None),而没有出现的 name、age、active 都保持 NotSet。SeaORM 生成的更新只会包含被标记为 Set 的列,效果相当于:
UPDATE "user" SET "email" = NULL WHERE "id" = 42;
不需要自己拼接列名和 SQL 字符串,也不会误更新未传字段。active_model 中的主键由 id 提供,SeaORM 用它定位要更新的记录。
非空列要单独建模
如果数据库的 name 是 NOT NULL,它就不应该接受 Some(None)。这时应该让 API 在反序列化或验证阶段明确拒绝 JSON null,而不是让请求一路走到数据库再得到约束错误。
例如,若 name 可省略但一旦提供就不能为空,字段类型应改成 Option<String>:
#[derive(Debug, Deserialize)]
pub struct UpdateRequiredNameReq {
#[serde(default)]
pub name: Option<String>,
}
它仍能表达“未传”与“更新为具体字符串”,但 JSON null 与缺失字段都会解析为 None。如果接口必须把 null 视为非法输入,则需要为该字段写严格的反序列化校验,而不是猜测调用方的意图。
仍然需要的边界校验
三态只解决“字段是否更新”的问题,不代替业务规则。实际接口通常还需要:
- 拒绝一个可更新字段都没有的空 PATCH 请求;
- 校验字符串长度、邮箱格式、年龄范围等字段规则;
- 根据当前登录用户校验能否更新目标用户;
- 为
NULL是否允许、是否有默认值定义清晰的 API 契约。
字段多时,DTO 上的属性确实会变长。这是用显式类型换取 API 语义、数据库可空性和更新行为都可审查的代价。SeaORM 的 DeriveIntoActiveModel 已经把最容易重复、也最容易出错的 Option<Option<T>> -> ActiveValue 映射交给宏生成;剩下的字段声明仍应保持与实体和接口契约一一对应。
总结
对于部分更新接口,推荐把语义固定为:
字段缺失 -> None -> NotSet -> 不修改
字段值为 null -> Some(None) -> Set(None) -> 置为 NULL
字段值为 value -> Some(Some(value)) -> Set(Some(value)) -> 更新
Rust 的价值不在于自动猜测调用者想做什么,而在于把这三种可能性写进类型和转换规则里。配合 Serde 的 double_option 和 SeaORM 的 DeriveIntoActiveModel,这条链路不需要反射、动态代理或手写动态 SQL,同时每一步都能被编译器检查。
参考: