Rust in Everything
基于 sea-orm 2.0.4

save 插进了一条新行,而不是更新

主线位置:写入主线的入口。save 在 insert 和 update 之间选一条路,依据只有一条:主键列里有没有 NotSet。机制见 save 与 ActiveModelBehavior 的「save:只看主键里有没有 NotSet」,from_json 怎么决定 Set / NotSet 见 ActiveValue 与 ActiveModel。

flowchart TB
  req["PUT /articles/1 请求体"] --> am["ActiveModel"]
  am --> pk{"主键是 NotSet"}
  pk -->|是| ins["INSERT:新行 id 2"]
  pk -->|否| upd["UPDATE WHERE id = 1"]
  url["URL 里的 1"] -.->|没放进去| am

本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行,sea-orm 2.0.4,以及本站用的 1.1.20,同一份代码。表 article (id serial PRIMARY KEY, title text NOT NULL) 里有一行 (1, 'hello')。SQL 取自 sqlx 的语句日志(sqlx=info)。

现象

「编辑文章」接口返回成功,列表里却多了一篇同样标题的文章,原来那篇没变。没有错误。

最小复现

请求是 PUT /articles/1,请求体 {"title": "hello, edited"},主键在 URL 里。src/bin/save_insert.rs 里两种常见写法:

// 1. 请求体直接 from_json
let am = article::ActiveModel::from_json(body.clone())?;
am.save(&db).await?;
// 2. 从表单结构体手写,其余字段 ..Default::default()
let am = article::ActiveModel { title: Set("hello, edited".into()), ..Default::default() };
am.save(&db).await?;

2.0.4 的输出(1b 是同一张表的另一个实体,主键上有 #[serde(skip_deserializing)],下一节用得到):

--- 1. from_json(body).save
from_json: ActiveModel { id: NotSet, title: Set("hello, edited") }
summary="INSERT INTO \"article\" (\"title\") …" db.statement="\n\nINSERT INTO \"article\" (\"title\") VALUES ($1) RETURNING \"id\", \"title\"\n" rows_affected=1 rows_returned=1 elapsed=2.742708ms elapsed_secs=0.002742708
=> ActiveModel { id: Unchanged(2), title: Unchanged("hello, edited") }
--- 1b. id has #[serde(skip_deserializing)]: from_json(body).save
from_json: ActiveModel { id: NotSet, title: Set("hello, edited") }
summary="INSERT INTO \"article\" (\"title\") …" db.statement="\n\nINSERT INTO \"article\" (\"title\") VALUES ($1) RETURNING \"id\", \"title\"\n" rows_affected=1 rows_returned=1 elapsed=451.167µs elapsed_secs=0.000451167
=> ActiveModel { id: Unchanged(3), title: Unchanged("hello, edited") }
--- 2. a struct literal with ..Default::default(), save
summary="INSERT INTO \"article\" (\"title\") …" db.statement="\n\nINSERT INTO \"article\" (\"title\") VALUES ($1) RETURNING \"id\", \"title\"\n" rows_affected=1 rows_returned=1 elapsed=467.917µs elapsed_secs=0.000467917
=> ActiveModel { id: Unchanged(4), title: Unchanged("hello, edited") }
--- table now
...
  Model { id: 1, title: "hello" }
  Model { id: 2, title: "hello, edited" }
  Model { id: 3, title: "hello, edited" }
  Model { id: 4, title: "hello, edited" }

三次「编辑」,三条新行,1 号没变。

1.1.20 上第 1 种写法会失败,另外两种和 2.0.4 一样插入新行:

--- 1. from_json(body).save
from_json: Err(Json("missing field `id`"))
--- 1b. id has #[serde(skip_deserializing)]: from_json(body).save
from_json: ActiveModel { id: NotSet, title: Set("hello, edited") }
...
=> ActiveModel { id: Unchanged(2), title: Unchanged("hello, edited") }
--- 2. a struct literal with ..Default::default(), save
...
=> ActiveModel { id: Unchanged(3), title: Unchanged("hello, edited") }

出处

ActiveModelTrait::save 是 if !self.is_update() { insert } else { update },is_update 只要有一个主键列 NotSet 就返回 false。它不查库,也不看别的列。

from_json 的规则是 JSON 里有的键变 Set,没有的变 NotSet。2.0 里它先用 default_values() 给缺失的字段垫上值再反序列化,所以请求体不带 id 也能成功。1.1.20 的 from_json 直接把 JSON 反序列化成 Model,缺 id 就是 serde 的 missing field;只有在 id 上加了 #[serde(skip_deserializing)](1.1.20 仓库里 axum_example、actix_example 等示例的 post 实体就是这样写的,让请求体可以不带主键)才会走到 save,然后同样插入。..Default::default() 在两个版本里都是全部 NotSet。

📝NOTE
冷知识:2.0 让 from_json 更宽容,也让这个坑更近了一步

PR #2599(「Improve ActiveModel::from_json」)的说明是:「make ActiveModel::from_json more useful, because it will not error out of missing fields」。CHANGELOG 的 2.0.0 条目举的正是本页的情形:对 id 非空的 cake,from_json(json!({ "name": "Apple Pie" }))「Previously … would result in error "missing field id"」,现在得到 id: NotSet。仓库里最早包含它的 tag 是 2.0.0-rc.28。1.1.20 上那个 missing field 错误顺带挡住了「请求体没主键就 save」;升到 2.0 后,没加 skip_deserializing 的实体也会静默插入新行。

修法

把 URL 里的主键放进去,再 save:

let mut am = article_skip::ActiveModel::from_json(body.clone())?;
am.id = Set(id_from_url);
println!("=> {:?}", am.save(&db).await);
--- fix 1: put the id from the URL into the ActiveModel
summary="UPDATE \"article\" SET \"title\" …" db.statement="\n\nUPDATE \"article\" SET \"title\" = $1 WHERE \"article\".\"id\" = $2 RETURNING \"id\", \"title\"\n" rows_affected=1 rows_returned=1 elapsed=535.792µs elapsed_secs=0.000535792
=> Ok(ActiveModel { id: Unchanged(1), title: Unchanged("hello, edited") })

本意是更新时,直接调 update。 主键 NotSet 时它不会去插入,而是失败:2.0.4 返回错误,1.1.20 panic。

--- fix 2: call update, not save, when you mean update
=> Err(PrimaryKeyNotSet { ctx: "UpdateOne" })
--- fix 2: call update, not save, when you mean update

thread 'tokio-rt-worker' (4044656) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sea-orm-1.1.20/src/query/update.rs:99:40:
PrimaryKey is not set
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
=> task panicked: true

(第一段是 2.0.4,第二段是 1.1.20;程序把调用放进 tokio::spawn 才接住了 panic。)响亮的失败比多出一行好查。更稳的做法是先按 URL 的主键读出那一行,into_active_model() 后用 set_from_json 合并请求体,它保留主键,见 save 与 ActiveModelBehavior 的「从 JSON 来的 ActiveModel」。

反过来:主键由调用方给出的表

save 的判断对复合主键和手填主键(auto_increment = false,例如应用里生成的 UUID)也一样,只是方向反了:新行的主键已经设好,save 判成 update。同一个程序的最后一段,pair (a, b) 是复合主键:

--- the other way round: composite key, both columns Set, row does not exist
summary="UPDATE \"pair\" SET \"v\" …" db.statement="\n\nUPDATE \"pair\" SET \"v\" = $1 WHERE \"pair\".\"a\" = $2 AND \"pair\".\"b\" = $3 RETURNING \"a\", \"b\", \"v\"\n" rows_affected=0 rows_returned=0 elapsed=397.041µs elapsed_secs=0.000397041
=> Err(RecordNotUpdated)
summary="INSERT INTO \"pair\" (\"a\", …" db.statement="\n\nINSERT INTO \"pair\" (\"a\", \"v\") VALUES ($1, $2) RETURNING \"a\", \"b\", \"v\"\n" rows_affected=0 rows_returned=0 elapsed=389.166µs elapsed_secs=0.000389166
=> Err("Query Error: error returned from database: null value in column \"b\" of relation \"pair\" violates not-null constraint at line 2057")

主键全设上,save 发 UPDATE,得到 RecordNotUpdated(见 None of the records are updated);只设一列,save 发 INSERT,23502。这种表新建用 insert,「有则改、无则插」用 on_conflict。2.0 的 ActiveModelEx 嵌套保存用的是同一个 is_update,上游有一个还开着的 issue #3183(「Nested ActiveModelEx::save() fails to insert a new has_many child with a composite, non-auto-increment primary key」)。1.1.20 上这两段的结果相同(Display 末尾没有 at line 2057)。

怎么确认没中招

  • 搜 .save(,看每一处的 ActiveModel 是从哪来的:来自 find + into_active_model() 的没问题;来自 from_json、表单结构体、..Default::default() 的,要确认主键被显式设上了。本站目前不用 ActiveModel::save。
  • 「更新」接口的测试里,调用前后数一次表的行数,行数不应该变。
  • 主键由应用生成的实体,不要对新行用 save。

小结

  • save 只看主键:有 NotSet 就 INSERT,否则 UPDATE;不查库。
  • 请求体反序列化或 ..Default::default() 得到的 ActiveModel 没有主键,save 静默插入新行。
  • 2.0 的 from_json 不再因缺主键报 missing field(PR #2599),1.1.20 只有主键上加了 skip_deserializing 才会走到这一步。
  • 修法:把主键设上再 save,或者明确调用 update(主键缺失时 2.0 报 PrimaryKeyNotSet,1.1.20 panic)。
  • 反方向:复合或手填主键的新行,save 走 UPDATE,得到 RecordNotUpdated。

💬 关联讨论

发起讨论
加载中...