Rust in Everything
基于 sea-orm 2.0.4

Missing value for column 'body'

主线位置:查询主线的最后一步,Model::from_query_result。DeriveEntityModel 生成的这个函数先把每一列都读成 Option,最后再逐个 ok_or_else;任何一列得到 None,就报这一句。生成代码的全貌见 DeriveEntityModel 与 dense 格式,TryGetable 怎么把 NULL 和缺列都变成 None 见列与值。

flowchart TB
  row["QueryResult 一行"] --> rd["每列 try_get_nullable Option T"]
  rd --> n1["列是 NULL"]
  rd --> n2["列不在结果里"]
  rd --> ok["列有值"]
  n1 --> none["None"]
  n2 --> none
  none --> fld{"字段是 Option 吗"}
  fld -->|是| fine["字段 = None"]
  fld -->|否| err["Missing value for column"]

本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行,依赖 sea-orm = "=2.0.4"(对照组 =1.1.20)。表:

CREATE TABLE post (id serial PRIMARY KEY, title text NOT NULL, body text);
INSERT INTO post (title, body) VALUES ('hello', 'first'), ('draft', NULL);

post 实体把 body 写成 Option<String>;另一个 strict 实体映射同一张表,body 写成 String。两个都是 #[sea_orm::model] 的 dense 写法。

现象

src/bin/missing.rs:

  let r = strict::Entity::find_by_id(2).one(&db).await;
  println!("1 Debug                   : {r:?}");
  show("1 NULL into String        ", r);
  show("2 select_only(id)         ", post::Entity::find().select_only().column(post::Column::Id).one(&db).await);
  show("3 select_except([title])  ", post::Entity::find().select_except([post::Column::Title]).one(&db).await);
1 Debug                   : Err(Type("Missing value for column 'body'"))
1 NULL into String        : Type Error: Missing value for column 'body'
2 select_only(id)         : Type Error: Missing value for column 'title'
3 select_except([title])  : Type Error: Missing value for column 'title'

三种原因,一句报错:

  • 1:id 2 的 body 是 NULL,字段却是 String。
  • 2:select_only().column(Id) 只选了 id,title 不在结果里。
  • 3:select_except([Title]) 是 2.0.4 新加的方法,选除了 title 以外的列,title 同样不在结果里。

sqlx 日志里 2 和 3 实际发出的语句:

 INFO sqlx::query: summary="SELECT \"post\".\"id\" FROM \"post\" …" db.statement="\n\nSELECT \"post\".\"id\" FROM \"post\" LIMIT $1\n" rows_affected=1 rows_returned=1 elapsed=884.167µs elapsed_secs=0.000884167
 INFO sqlx::query: summary="SELECT \"post\".\"id\", \"post\".\"body\" FROM …" db.statement="\n\nSELECT \"post\".\"id\", \"post\".\"body\" FROM \"post\" LIMIT $1\n" rows_affected=1 rows_returned=1 elapsed=846.625µs elapsed_secs=0.000846625

语句都执行成功了,各返回一行,错误出在解码。报错是 DbErr::Type,不是 DbErr::Query,也就没有 SQLSTATE 可查。

出处

impl_from_query_result 给每个不是 ignore 的字段生成一个读取和一个解包:

                let reader = quote! {
                    let #field_ident =
                        #row.try_get_nullable::<Option<#field_type>>(
                            #pre,
                            sea_orm::IdenStatic::as_str(
                                &<<Self as sea_orm::ModelTrait>::Entity
                                    as sea_orm::entity::EntityTrait>::Column::#column_ident
                            ).into()
                        )?;
                };
                let unwrapper = quote! {
                    #field_ident: #field_ident.ok_or_else(|| sea_orm::DbErr::Type(
                        format!(
                            "Missing value for column '{}'",
                            sea_orm::IdenStatic::as_str(
                                &<<Self as sea_orm::ModelTrait>::Entity
                                    as sea_orm::entity::EntityTrait>::Column::#column_ident
                            )
                        )
                    ))?
                };

读的类型是 Option<#field_type>。字段是 String 时读的是 Option<String>,字段本来就是 Option<String> 时读的是 Option<Option<String>>。Option<T> 的 TryGetable 把 TryGetError::Null 和 sqlx 的 ColumnNotFound 都变成 Ok(None),所以「列是 NULL」和「列根本没选」在这里已经分不开了,最后统一由 ok_or_else 报出来。外面那层 Option 是为 LEFT JOIN 的嵌套 Option<Model> 加的「整行全是 NULL 就当没有这一行」的检查,来历见列与值的冷知识(#2845,2.0.0-rc.29)。

报错文字里只有列名,没有表名和行号。Column::#column_ident 的 as_str() 是列名本身,所以带 #[sea_orm(column_name = "..")] 的字段报的是数据库里的列名,不是 Rust 字段名。多个字段同时缺值时,报的是字段顺序里的第一个。

select_except 只改 SELECT 列表,Selector 的类型仍然是整个 Model,它的文档注释直接写着这个后果:

    /// Rows still hydrate into the full `Model`, so only `Option<_>` columns can be
    /// excluded. Excluding a non-nullable column results in a runtime error,
    /// `Missing value for column`. Excluding every column leaves nothing to select
    /// and produces invalid SQL.
📝NOTE
冷知识:select_except 发布时就带着这条限制

#3211(首次发布于 2.0.4)加了 Select::select_except,2.0.4 的 release notes 说它是「the inverse of select_only / column」。PR 说明把这个坑写成了已知行为,而不是要修的问题:「Rows still hydrate into the full Model: only Option<_> columns can be excluded; a non-nullable column fails with Missing value for column」,并且加了「non-nullable failure」的集成测试。release notes 里的示例配的是一个 DerivePartialModel(PostPreview { id, title }),也就是下面修法里的第二条路。

本站用的 1.1.20 两种情况各报各的(exp-seaorm-pit1-old/src/bin/missing_old.rs,同一张表,展开写法的实体):

1 NULL into String: Err(Type("A null value was encountered while decoding \"body\""))
  Display         : Type Error: A null value was encountered while decoding "body"
2 select_only(id) : Err(Query(SqlxError(ColumnNotFound("title"))))
  Display         : Query Error: no column found for name: title

1.1.20 的 impl_from_query_result 对每个字段直接生成 row.try_get(pre, 列名)?,按字段的真实类型读,NULL 是 TryGetError::Null,缺列是 sqlx 的 ColumnNotFound。升级到 2.0 之后,日志里按这两句文字做的告警或测试断言会失效。

修法

按原因分开处理。同一个程序的后半段:

  show("4 select_except([body])   ", post::Entity::find().select_except([post::Column::Body]).one(&db).await);
  show("5 Option body, id 2       ", post::Entity::find_by_id(2).one(&db).await);
  show(
    "6 select_only + into_tuple",
    post::Entity::find().select_only().column(post::Column::Id).into_tuple::<i32>().all(&db).await,
  );
4 select_except([body])   : Ok(Some(Model { id: 1, title: "hello", body: None }))
5 Option body, id 2       : Ok(Some(Model { id: 2, title: "draft", body: None }))
6 select_only + into_tuple: Ok([1, 2])
  • 列确实可空(1):字段写成 Option<T>,见 5。不想改实体,就在 SQL 里保证不出 NULL,或者换下面的读法。
  • 只想要部分列(2、3):不要再读成实体的 Model。按位置读用 into_tuple(6),按名字读用 #[derive(DerivePartialModel)] 让 SELECT 列表从结构体的字段推出来,两种做法见部分列与自定义结果。那一页还有一张表:同一个缺列,自己派生的 FromQueryResult 报的是 ColumnNotFound,不是本页这句。
  • 4 是 select_except 唯一能用的情况:被排除的是 Option 列。注意 id 1 的 body 在库里是 'first',读出来却是 None:排除掉的列和 NULL 列在 Model 里长得一样,把这样的 Model 转成 ActiveModel 再写回去之前要想清楚。

怎么确认没中招

  • 看到这句报错时,先打开 sqlx 日志(sqlx_logging(true) 加 sqlx=info)看那条语句的 SELECT 列表里有没有报错的列:没有就是 2、3,有就是 1。
  • 对每个实体,比较字段的可空性和库里的 is_nullable。ColumnTrait::def().is_null() 是实体这一侧(字段是 Option 时为 true),information_schema.columns 是库这一侧(src/bin/nullcheck.rs,对 strict 实体):
  for col in strict::Column::iter() {
    // ... SELECT is_nullable = 'YES' FROM information_schema.columns WHERE ... column_name = $2
    let rust_null = col.def().is_null();
    let mark = if db_null && !rust_null { "  <-- nullable in db, not Option in Rust" } else { "" };
    println!("{table}.{:<6} db nullable={db_null:<5} entity nullable={rust_null:<5}{mark}", col.as_str());
  }
post.id     db nullable=false entity nullable=false
post.title  db nullable=false entity nullable=false
post.body   db nullable=true  entity nullable=false  <-- nullable in db, not Option in Rust

这个检查可以放进连测试库的集成测试,迁移改了列的约束而实体没跟着改时就会报出来。它管不了 LEFT JOIN 补出来的 NULL 和表达式列,那些要按可空处理。

  • 本站的 select_only 用法(admin/src/server.rs 的两个 GROUP BY、forum/src/server.rs 的标签统计)都接着 into_tuple 或自定义的 FromQueryResult,不读成实体 Model,不受 2、3 影响。

小结

  • 2.0 的实体 Model 先把每列读成 Option,NULL 和缺列都变成 None,最后报 Missing value for column '..'(DbErr::Type);三种原因:NULL 落进非 Option 字段、select_only 漏了列、select_except 排除了非空列。
  • 1.1.20 分别报 A null value was encountered while decoding "body" 和 no column found for name: title,升级后文字变了。
  • 可空列写 Option<T>;只要部分列就用 into_tuple 或 DerivePartialModel,不要读成整个 Model。
  • select_except 只能排除 Option 列,排除掉的列读出来是 None,和真的 NULL 分不开。
  • 用 def().is_null() 对照 information_schema.columns.is_nullable,可以在测试里提前发现实体和表的可空性不一致。

💬 关联讨论

发起讨论
加载中...