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。
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。