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 的同一个方法一字不差。
.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 还没改。