PATCH /users/{id} 这类部分更新接口里,一个字段通常不只有“有值”和“没值”两种状态。以 name 为例,客户端可能发送:

{}
{"name": null}
{"name": "Alice"}

它们的业务含义分别是:不修改 name、将 name 清空为数据库 NULL、将 name 更新为 Alice

问题的核心不是 Rust 有没有 nil,而是 API 输入确实有三种独立语义。把它们明确建模,才能避免把“字段没传”误当成“清空字段”。

三层语义

Option<Option<T>> 可以表达这三个状态:

JSON 输入Rust 值数据库操作
字段缺失None不修改列
"field": nullSome(None)SET field = NULL
"field": valueSome(Some(value))SET field = value

外层 Option 表示字段有没有出现在请求中,内层 Option 表示该字段出现后是不是 JSON null

这个类型也和 SeaORM 的 ActiveValue 一一对应:

请求 DTOSeaORM ActiveModel含义
NoneNotSet不出现在 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

下面假设 nameageemailactive 在数据库中都是允许 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),而没有出现的 nameageactive 都保持 NotSet。SeaORM 生成的更新只会包含被标记为 Set 的列,效果相当于:

UPDATE "user" SET "email" = NULL WHERE "id" = 42;

不需要自己拼接列名和 SQL 字符串,也不会误更新未传字段。active_model 中的主键由 id 提供,SeaORM 用它定位要更新的记录。

非空列要单独建模

如果数据库的 nameNOT 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,同时每一步都能被编译器检查。

参考: