Rust in Everything
基于 sea-orm 2.0.4

insert_many: null value in column "qty" violates not-null constraint

主线位置:写入主线的 insert 分支,多行版本。单行 Insert::one 只写 Set / Unchanged 的列,NotSet 的列不出现在语句里、由数据库填默认值;insert_many 要把每行不同的列集合拼进同一条 VALUES (..), (..),缺的位置只能填点什么。机制见 insert 的「insert_many:列取并集,缺的补 NULL」。

flowchart TB
  rows["两行 ActiveModel"] --> union["列取并集"]
  union --> fill["某行 NotSet 的列"]
  fill --> nullv["补带类型的 NULL"]
  nullv --> nn{"列是 NOT NULL"}
  nn -->|是| e23502["23502 整条失败"]
  nn -->|否| silent["写进 NULL,不是默认值"]

本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行,sea-orm 2.0.4,以及本站用的 1.1.20,同一份代码。SQL 取自 sqlx 的语句日志(sqlx=info)。

现象

单条 insert 一直正常,改成批量 insert_many 后整批失败,报一个没写过 NULL 的列:

Query Error: error returned from database: null value in column "qty" of relation "item" violates not-null constraint at line 2057

或者更糟:没有报错,但某些行的一列不是表上的默认值,而是 NULL。

最小复现

两张表:item.qty 是 int NOT NULL DEFAULT 0;gadget.color 是 text DEFAULT 'red',可空。每批两行,只有第二行设了这一列。src/bin/insert_many.rs:

let r = item::Entity::insert_many([it("m1", None), it("m2", Some(9))]).exec(&db).await;
// ...
gadget::Entity::insert_many([gd("g1", None), gd("g2", Some("blue"))]).exec(&db).await?;
println!("--- the same rows one by one");
gadget::Entity::insert(gd("g3", None)).exec(&db).await?;
gadget::Entity::insert(gd("g4", Some("blue"))).exec(&db).await?;

it(sku, None) / gd(name, None) 不设那一列(NotSet)。2.0.4 的输出:

--- item: qty NOT NULL DEFAULT 0, only the second row sets qty
summary="INSERT INTO \"item\" (\"sku\", …" db.statement="\n\nINSERT INTO \"item\" (\"sku\", \"name\", \"qty\") VALUES ($1, $2, $3), ($4, $5, $6) RETURNING \"id\"\n" rows_affected=0 rows_returned=0 elapsed=1.691791ms elapsed_secs=0.001691791
=> Err Query(SqlxError(Database(PgDatabaseError { severity: Error, code: "23502", message: "null value in column \"qty\" of relation \"item\" violates not-null constraint", detail: Some("Failing row contains (4, m1, m1, null, null)."), hint: None, position: None, where: None, schema: Some("so_pit2"), table: Some("item"), column: Some("qty"), data_type: None, constraint: None, file: Some("execMain.c"), line: Some(2057), routine: Some("ExecConstraints") })))
   Display: Query Error: error returned from database: null value in column "qty" of relation "item" violates not-null constraint at line 2057
--- gadget: color DEFAULT 'red', only the second row sets color
summary="INSERT INTO \"gadget\" (\"name\", …" db.statement="\n\nINSERT INTO \"gadget\" (\"name\", \"color\") VALUES ($1, $2), ($3, $4) RETURNING \"id\"\n" rows_affected=2 rows_returned=2 elapsed=632.958µs elapsed_secs=0.000632958
--- the same rows one by one
summary="INSERT INTO \"gadget\" (\"name\") …" db.statement="\n\nINSERT INTO \"gadget\" (\"name\") VALUES ($1) RETURNING \"id\"\n" rows_affected=1 rows_returned=1 elapsed=412.292µs elapsed_secs=0.000412292
summary="INSERT INTO \"gadget\" (\"name\", …" db.statement="\n\nINSERT INTO \"gadget\" (\"name\", \"color\") VALUES ($1, $2) RETURNING \"id\"\n" rows_affected=1 rows_returned=1 elapsed=392.333µs elapsed_secs=0.000392333
...
  g1 None
  g2 Some("blue")
  g3 Some("red")
  g4 Some("blue")

item 那批:qty 进了列表,第一行的 $3 绑的是 NULL,Failing row contains (4, m1, m1, null, null),两行都没插进去。gadget 那批成功了,g1 的 color 是 NULL;同样的 ActiveModel 单独插(g3),color 不在列表里,得到默认值 red。

1.1.20 上两段结果相同,只是 Display 末尾没有 at line 2057(那是 sqlx 0.9 的 PgDatabaseError 的 Display 加上的,数字是服务端报错位置在 Postgres 源码 execMain.c 里的行号,和 SQL 无关)。

出处

2.0.4 的 InsertMany::many 扫每一行:Set / Unchanged 的列标记为「用到了」,并记下 value.as_null()(同类型的 NULL,例如 Int(None));NotSet 先放一个 Keyword::Null 占位。拼 VALUES 时,用到的列里遇到占位,就换成记下的那个 NULL 值,绑成参数。没有一处会生成 SQL 的 DEFAULT。这是有意的:EntityTrait::insert_many 的文档示例就是 VALUES (2, NULL), (NULL, 3)。1.1.20 的 insert_many 调 Insert::add_many,循环体一样。

📝NOTE
冷知识:以前是 panic「columns mismatch」

1.1.3 之前,insert_many 对每一行调 Insert::add,第二行的列集合和第一行不同就 panic!("columns mismatch")。issue #1407(0.10.7 上报的)标题是「Panic in insert_many if ActiveModels have different columns set」,正文说:「This seems like it should be supported or explicitly disallowed.」PR #2433(「Insert many allow active models to have different column set」,1.1.3)选了「支持」,补位用的是 sea-query 新加的 Value::as_null(PR 正文:「This requires a new feature in sea-query, Value::as_null」)。旧实现改名为 add_multi 并标成 deprecated。所以从 1.1.3 起,这个错误从一次 panic 变成了数据库的 23502,或者一列静默的 NULL。

修法

每一行设同样的列。 表上的默认值要在 Rust 里再写一遍,让每行都是 Set。同一个程序的最后一段:

let r = item::Entity::insert_many([it("m1", Some(0)), it("m2", Some(9))]).exec(&db).await?;
--- fix: every row sets qty
summary="INSERT INTO \"item\" (\"sku\", …" db.statement="\n\nINSERT INTO \"item\" (\"sku\", \"name\", \"qty\") VALUES ($1, $2, $3), ($4, $5, $6) RETURNING \"id\"\n" rows_affected=2 rows_returned=2 elapsed=265.291µs elapsed_secs=0.000265291
=> Ok Some(6)

或者按列集合分组。 列集合相同的行放进同一个 insert_many,不同的分开插;最简单的极端是像 g3、g4 那样逐行 insert,每行只写自己设了的列,默认值由数据库填。要几批一起成功或失败,放进一个事务。

怎么确认没中招

  • 找所有 insert_many(以及 2.0 的 InsertMany),看构造 ActiveModel 的代码里有没有「某些条件下才 Set」的字段(if let Some(..) { am.x = Set(..) }、..Default::default() 加可选字段)。有就是隐患。本站目前不用 insert_many。
  • 表里带 DEFAULT 的列,查一下批量导入的数据里有没有不该有的 NULL:SELECT count(*) FROM gadget WHERE color IS NULL。
  • 在测试里用 build(DbBackend::Postgres) 打印批量语句的列列表:列表里出现了某行没设的列,就会补 NULL。

小结

  • insert_many 的列是所有行的并集,某行 NotSet 的列补带类型的 NULL,不是 DEFAULT。
  • 列是 NOT NULL DEFAULT .. 时整批报 23502;列可空时静默写入 NULL,单行 insert 却会得到默认值。
  • 1.1.3(PR #2433)之前这是 panic columns mismatch;2.0.4 和 1.1.20 行为相同。
  • 修法:每行设同样的列(默认值在 Rust 里写明),或按列集合分组插入。

💬 关联讨论

发起讨论
加载中...