Rust in Everything
基于 sea-orm 2.0.4

find_also_related 的右边全是 None

主线位置:查询主线的最后一步,QueryResult → FromQueryResult。find_also_related 的每一行由 SelectTwoModel 拆成两个 Model,左边用 from_query_result,右边用 from_query_result_optional;机制见 join 与 find_related 的「拆回两个 Model」。这一页只讲后者把错误吞掉的后果和怎么防。

flowchart TB
  row["一行 A_ 列 + B_ 列"] --> left["左边 from_query_result"]
  row --> right["右边 from_query_result_optional"]
  left -->|解码失败| err["Err ColumnDecode"]
  right -->|全是 NULL| none1["None:没有关联"]
  right -->|解码失败| none2["None:错误被吞"]
  none1 --> same["调用方分不出来"]
  none2 --> same

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

现象

列表页上每条记录都显示「无作者」,库里明明有。没有错误,没有日志,all() 返回 Ok,行数也对。

最小复现

表 post 的 likes 是 int,post_bad 是映射到同一张表的实体,只是把 likes 写成了 String,相当于改了列类型、忘了改实体(或者反过来)。ann 有两篇文章,bob 没有,cid 有一篇。src/bin/also_swallow.rs:

let rows = author::Entity::find()
  .find_also_related(post_bad::Entity)
  .order_by_asc(author::Column::Id)
  .all(&db)
  .await?;
// ...
let rows = author::Entity::find().find_with_related(post_bad::Entity).all(&db).await?;
// ...
let r = post_bad::Entity::find().find_also_related(author::Entity).all(&db).await;

2.0.4 的输出(sqlx 日志只留第一条):

--- find_also_related(post_bad): likes is int4, field is String
summary="SELECT \"author\".\"id\" AS \"A_id\", …" db.statement="\n\nSELECT \"author\".\"id\" AS \"A_id\", \"author\".\"name\" AS \"A_name\", \"post\".\"id\" AS \"B_id\", \"post\".\"title\" AS \"B_title\", \"post\".\"likes\" AS \"B_likes\", \"post\".\"author_id\" AS \"B_author_id\" FROM \"author\" LEFT JOIN \"post\" ON \"author\".\"id\" = \"post\".\"author_id\" ORDER BY \"author\".\"id\" ASC\n" rows_affected=4 rows_returned=4 elapsed=2.927875ms elapsed_secs=0.002927875
  ann -> None
  ann -> None
  bob -> None
  cid -> None
--- find_with_related(post_bad)
...
  ann -> 0 posts
  bob -> 0 posts
  cid -> 0 posts
--- the same entity on the left: post_bad::Entity::find().find_also_related(author)
...
  Err(Query(SqlxError(ColumnDecode { index: "\"A_likes\"", source: "mismatched types; Rust type `core::option::Option<alloc::string::String>` (as SQL type `TEXT`) is not compatible with SQL type `INT4`" })))
--- the correct entity
...
  ann -> Some("a1")
  ann -> Some("a2")
  bob -> None
  cid -> Some("c1")

服务端返回了 4 行,ann 的两行都在,B_ 那几列都有值。同一个类型错误,放在左边是 ColumnDecode,放在右边是 None;find_with_related 把每个 None 丢掉,三个作者都是 0 篇。用 1.1.20 跑同一个程序,四段输出逐行相同。

出处

SelectTwoModel 的右边调 N::from_query_result_optional,它的默认实现(FromQueryResult::from_query_result_optional)只有一行:

    fn from_query_result_optional(res: &QueryResult, pre: &str) -> Result<Option<Self>, DbErr> {
        Ok(Self::from_query_result(res, pre).ok())

.ok() 把所有错误都变成 None。它要处理的是 LEFT JOIN 没匹配上、B_ 列全是 NULL 的情形,可列类型不对、列不存在(Missing value for column)、非 Option 字段读到 NULL,也都走这条路。DeriveEntityModel 和 derive(FromQueryResult) 都不覆盖这个方法。SelectTwoMany::all 先用同一个 SelectTwoModel 读行,再 consolidate_query_result 合并,None 在合并时被跳过,所以是空 Vec。1.1.20 的同一个方法一字不差。

📝NOTE
冷知识:等一个大版本,大版本来了也没换

.ok() 是 2021 年加 SelectTwoMany 的那个提交写下的,第一个发布版 0.1.0 里就是这样。PR #2508(「Support nested entities」,1.1.7,CHANGELOG 记为「Support nested entities in FromQueryResult」)给 trait 加了能区分「是 NULL」和「是别的错误」的 from_query_result_nullable,并在 .ok() 下面留了一段注释掉的正确写法:

        // would really like to do the following, but can't without version bump:
        // match Self::from_query_result_nullable(res, pre) {
        //     Ok(v) => Ok(Some(v)),
        //     Err(TryGetError::Null(_)) => Ok(None),
        //     Err(TryGetError::DbErr(err)) => Err(err),
        // }

改它要动默认方法的行为,属于破坏性改动,所以要等升大版本。2.0.0 就是那次大版本,2.0.4 里这段注释和 .ok() 都还在原处。在 SeaQL/sea-orm 里搜 from_query_result_optional、find_also_related None、find_also_related error,没有找到报告这个现象的 issue。

修法

2.0:关联一定存在时用 find_both_related。 它是 INNER JOIN(2.0.0-rc.38 加的,见 join 与 find_related),右边用 from_query_result,返回 (E::Model, F::Model)。src/bin/also_both.rs:

--- find_both_related(post_bad)
summary="SELECT \"author\".\"id\" AS \"A_id\", …" db.statement="\n\nSELECT \"author\".\"id\" AS \"A_id\", \"author\".\"name\" AS \"A_name\", \"post\".\"id\" AS \"B_id\", \"post\".\"title\" AS \"B_title\", \"post\".\"likes\" AS \"B_likes\", \"post\".\"author_id\" AS \"B_author_id\" FROM \"author\" INNER JOIN \"post\" ON \"author\".\"id\" = \"post\".\"author_id\"\n" rows_affected=3 rows_returned=3 elapsed=8.116834ms elapsed_secs=0.008116834
  Err(Query(SqlxError(ColumnDecode { index: "\"B_likes\"", source: "mismatched types; Rust type `core::option::Option<alloc::string::String>` (as SQL type `TEXT`) is not compatible with SQL type `INT4`" })))

代价是没有关联的行(bob)不出现。

两个版本都能用:关联可能不存在时改用 Loader。 load_one / load_many 对子表单独发一条 IN 查询,子表的 Model 在左边位置解码,错误照常返回。同一个程序的最后一段:

let authors = author::Entity::find().order_by_asc(author::Column::Id).all(&db).await?;
let r = authors.load_many(post_bad::Entity, &db).await;
--- fix: load_many(post_bad)
summary="SELECT \"author\".\"id\", \"author\".\"name\" FROM …" db.statement="\n\nSELECT \"author\".\"id\", \"author\".\"name\" FROM \"author\" ORDER BY \"author\".\"id\" ASC\n" rows_affected=3 rows_returned=3 elapsed=517.167µs elapsed_secs=0.000517167
summary="SELECT \"post\".\"id\", \"post\".\"title\", \"post\".\"likes\", …" db.statement="\n\nSELECT \"post\".\"id\", \"post\".\"title\", \"post\".\"likes\", \"post\".\"author_id\" FROM \"post\" WHERE (\"post\".\"author_id\") IN (($1), ($2), ($3)) ORDER BY \"post\".\"id\" ASC\n" rows_affected=3 rows_returned=3 elapsed=819.834µs elapsed_secs=0.000819834
  Err(Query(SqlxError(ColumnDecode { index: "\"likes\"", source: "mismatched types; Rust type `core::option::Option<alloc::string::String>` (as SQL type `TEXT`) is not compatible with SQL type `INT4`" })))

1.1.20 的结果相同(1.1.20 的 IN 是 "post"."author_id" IN ($1, $2, $3),不加 ORDER BY)。多一条语句,换来错误不被吞。

怎么确认没中招

  • 本站评论列表用的就是 find_also_related(get_comments),右边 None 时作者显示成「已注销」。如果 users 表的列类型和 user::Model 对不上,所有评论都会显示「已注销」,而接口返回成功。改了 users 的迁移之后,看一眼有作者的评论是否还显示昵称。
  • 测试里对右边的实体单独 Entity::find().all(&db) 一次:类型对不上,它在左边位置报 ColumnDecode。
  • 外键 NOT NULL 且有约束时,右边理论上不可能是 None;在这种查询后面加一个断言(或在 2.0 上换成 find_both_related),比让 None 流到界面上好查。

小结

  • find_also_related / find_with_related 的右边用 from_query_result_optional,默认实现是 .ok(),任何解码错误都变成 None(find_with_related 里是空 Vec)。
  • 同一个错误在左边是 ColumnDecode,所以单独查右边的实体就能暴露它。
  • 2.0 的 find_both_related 是 INNER JOIN,右边错误照常返回;关联可能不存在时用 Loader,两个版本都行。
  • 1.1.7 起源码里就留着「升大版本再改」的注释,2.0.4 还没改。

💬 关联讨论

发起讨论
加载中...