Rust in Everything
基于 sqlx 0.9.0

unexpected null; try decoding as an Option

主线位置:查询主线的最后一步。DataRow 里这一列的长度是 -1(NULL),解码到一个不是 Option 的 Rust 类型时报这句。手写查询时是 try_get 在运行期报;query! 时是编译期检查主线里的可空性推断给了「不可空」,生成的代码在运行期才撞上 NULL。解码的机制见类型的「Option:NULL 的唯一表达」,推断的机制见 describe 与可空性推断。

flowchart TB
  val["列值是 NULL"] --> opt{"读成 Option 吗"}
  opt -->|是| none["None"]
  opt -->|否| who{"类型谁定的"}
  who -->|"手写 query_as"| e1["写窄了"]
  who -->|"query! 推断"| e2["推断说不可空"]
  e1 --> err["unexpected null"]
  e2 --> err

本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行,依赖 sqlx = "=0.9.0"。表在自己的 schema 里:

create table author (id int8 primary key, name text not null, bio text);
create table post (id int8 primary key, author_id int8 not null references author, title text not null);
create table writer (id int8 primary key, name text not null);
create table article (id int8 primary key, writer_id int8 not null references writer, title text not null);
insert into writer select g, 'w' || g from generate_series(1, 1000) g;
insert into article select g, g, 't' || g from generate_series(1, 10) g;
analyze writer;
analyze article;
-- item(id, name, price numeric, created_at timestamp, note text),note 前两行是 NULL

author / post 是两张没有数据、没 analyze 过的空表;writer / article 有数据、有统计信息,1000 个作者里只有前 10 个有文章。

现象

src/bin/unexpected_null.rs:

#[derive(Debug, sqlx::FromRow)]
struct Item {
  id: i64,
  note: String,
}

// ...
  // runtime: query_as / FromRow / query_scalar
  let r = sqlx::query_as::<_, (i64, String)>("select id, note from item order by id").fetch_all(&pool).await;
  println!("tuple      -> {}", r.unwrap_err());
  let r = sqlx::query_as::<_, Item>("select id, note from item order by id").fetch_all(&pool).await;
  println!("FromRow    -> {}", r.unwrap_err());
  let r = sqlx::query_scalar::<_, i64>("select max(id) from post").fetch_one(&pool).await;
  println!("max(empty) -> {}", r.unwrap_err());

  // query!: author/post are empty and never analyzed -> Hash Right Join
  sqlx::query("insert into author values (1, 'ann', null)").execute(&pool).await?;
  let r = sqlx::query!("select a.id, p.title from author a left join post p on p.author_id = a.id")
    .fetch_one(&pool)
    .await;
  println!("right join -> {}", r.unwrap_err());

  // query!: Limit on top of a Hash Left Join; writer 1000 has no article
  let r = sqlx::query!("select w.id, a.title from writer w left join article a on a.writer_id = w.id order by w.id desc limit 1")
    .fetch_one(&pool)
    .await;
  println!("limit      -> {}", r.unwrap_err());
tuple      -> error occurred while decoding column 1: unexpected null; try decoding as an `Option`
FromRow    -> error occurred while decoding column "note": unexpected null; try decoding as an `Option`
max(empty) -> error occurred while decoding column 0: unexpected null; try decoding as an `Option`
right join -> error occurred while decoding column 1: unexpected null; try decoding as an `Option`
limit      -> error occurred while decoding column 1: unexpected null; try decoding as an `Option`
  • 前两行是最直接的情况:note 没有 not null 约束,类型却写成 String。元组按下标取列,所以报 column 1;#[derive(FromRow)] 按名字取,报 column "note"。
  • max(id):id 是主键,可空表上的聚合返回一行 NULL。count(*) 之外的聚合(max、min、sum、avg)都是这样。
  • 后两行用的是 query!,编译通过了,说明宏认为 title 不可能是 NULL,生成的字段类型是 String。可两条都是 LEFT JOIN,没有文章的作者那一行 title 就是 NULL。

出处

两种情况最后都走到同一个地方。Row::try_get 先检查类型兼容(值是 NULL 时跳过),再调 T::decode;String、i64 等 Postgres 类型的 decode 第一步都是 PgValueRef::as_bytes:

    pub fn as_bytes(&self) -> Result<&'r [u8], BoxDynError> {
        match &self.value {
            Some(v) => Ok(v),
            None => Err(UnexpectedNullError.into()),
        }
    }

UnexpectedNullError 的文字就是 unexpected null; try decoding as an `Option` ,外面再包一层 Error::ColumnDecode,加上 error occurred while decoding column {index}: 。Option<T> 的 Decode 先问 is_null(),是 NULL 就直接 None,不会走到 as_bytes。

query! 的不同在于类型是谁定的。宏在编译时对 SQL 调 describe,拿回每列的 nullable(i),在 output.rs 里决定包不包 Option:

    let nullable = match nullability {
        ColumnNullabilityOverride::NonNull => false,
        ColumnNullabilityOverride::Nullable => true,
        ColumnNullabilityOverride::None => describe.nullable(i).unwrap_or(true),
    };

只有 Some(false) 会生成非 Option 的类型;生成的取值代码调的是 row.try_get_unchecked::<String, _>(...),运行期不再检查类型,NULL 照样交给 String 的 decode。这两条查询的推断结果可以在 cargo sqlx prepare 写出的 .sqlx/query-*.json 里看到(describe.nullable,下标对应列):

select a.id, p.title from author a left join post p on p.author_id = a.id [True, False]
select w.id, a.title from writer w left join article a on a.writer_id = w.id ord [False, False]

title 都是 False。推错的原因在 EXPLAIN 的计划树上,describe 与可空性推断的「实验:五种列」和动脑筋答案逐条跑过,这里只列结论:

查询编译时的计划推断
两张空表 LEFT JOINHash Right Join,author 成了 Inner 那一侧id 误标可空(无害),title 不可空(错)
有统计信息的表 LEFT JOIN ... LIMIT根节点是 Limit,连接在它下面visit_plan 不往下走,title 不可空(错)
有统计信息的表 LEFT JOIN,没有 LIMIT / ORDER BY根节点是 Hash Left Jointitle 可空(对)

第一行是上游 #3202,到 0.9.0 仍开着。同一条 SQL 生成什么类型,取决于编译时那个库里的统计信息:开发库是空的、生产库有数据,就可能本地推成 String,换个库重新编译又变成 Option<String>。

📝NOTE
没有蠢问题

问: query! 不是在编译期对着数据库检查过了吗,运行时怎么还会报 unexpected null?

答: 编译期检查的是类型(TEXT 对 String),可空性只是推断。Postgres 不在 RowDescription 里告诉客户端一列能不能是 NULL,sqlx 只能拿 pg_attribute.attnotnull 和 EXPLAIN 的计划树去猜,猜不出时给 Option,猜错时没有任何提示。生成的代码用 try_get_unchecked,运行期也不会再核对一次。宏的文档自己也说遇到推错的情况要用 ? 覆盖,并请用户报 issue。

修法

手写查询:类型写成 Option<T>,或者在 SQL 里保证不出 NULL(coalesce(note, ''))。max() 读成 Option<i64>:

  let r = sqlx::query_scalar::<_, Option<i64>>("select max(id) from post").fetch_one(&pool).await;
  println!("Option     -> {:?}", r);

query!:在列名后面加 ?,强制可空。同一个程序的另外两段:

  let r = sqlx::query!(r#"select a.id, p.title as "title?" from author a left join post p on p.author_id = a.id"#)
    .fetch_one(&pool)
    .await?;
  println!("title?     -> id {:?} title {:?}", r.id, r.title);
  // ...
  let r = sqlx::query!(r#"select w.id, a.title as "title?" from writer w left join article a on a.writer_id = w.id order by w.id desc limit 1"#)
    .fetch_one(&pool)
    .await?;
  println!("title?     -> id {:?} title {:?}", r.id, r.title);
Option     -> Ok(None)
title?     -> id Some(1) title None
title?     -> id 1000 title None

第一条里 id 打印成 Some(1):Right Join 把 a.id 误标成可空,宏给了 Option<i64>。这一侧是多了一层 Option,不会出错;不想要可以写 a.id as "id!"。? 和 ! 只改可空性,不改类型;后缀语法和生成的代码见生成的代码。

.sqlx 里记的仍是 describe 的原始推断,as "title?" 那两条的 nullable 也是 [True, False] / [False, False]:覆盖发生在生成代码时,不改缓存文件。

怎么确认没中招

  • 手写查询:对每个读成非 Option 的列,看它在表上有没有 not null;表达式列(聚合、case、外连接的另一侧)按可空处理。
  • query!:外连接(LEFT / RIGHT / FULL JOIN)补 NULL 那一侧的每一列,都显式写 "col?",不依赖推断。带 LIMIT、ORDER BY、GROUP BY 的外连接尤其如此。
  • 用 cargo sqlx prepare 生成 .sqlx,在 JSON 里搜 join,对照 describe.nullable:外连接内侧的列是 false 就是推错了。
  • 编译用的库要有和生产相近的数据和统计信息(至少跑过 analyze),否则空表会让规划器选出和生产不同的计划。

小结

  • 报错来自 PgValueRef::as_bytes:值是 NULL、目标类型不是 Option。column 1 是下标,column "note" 是列名。
  • 手写查询的常见原因:没有 not null 的列、外连接的另一侧、空集上的 max / sum。修法是 Option<T> 或 coalesce。
  • query! 编译通过不代表可空性对:推断依赖编译时的 EXPLAIN,空表上的 Hash Right Join(#3202)和盖在连接上面的 Limit / Sort 都会把外连接内侧的列推成不可空。
  • 修法是 "col?";外连接内侧的列一律显式加上,不要等它在运行时报错。

💬 关联讨论

发起讨论
加载中...