部分列与自定义结果
主线位置:查询主线的最后一步。
find_by_id(..).one(&db)里,into_model::<E::Model>()把Select换成Selector<SelectModel<M>>,每一行由M::from_query_result(&row, "")解码(一次 find 的全过程)。这一页把M换掉:换成自己的 struct、换成从字段推出列的部分模型、换成元组或 JSON。语句怎么发出去不变,变的是SELECT列表和每一行怎么拆。
flowchart TB sel["Select E"] --> im["into_model M"] sel --> ipm["into_partial_model M"] sel --> it["into_tuple T"] sel --> ij["into_json"] ipm -->|"select_only 加列"| im im --> sm["SelectModel M"] ij --> smj["SelectModel JsonValue"] it --> gt["SelectGetableTuple T"] sm --> byname["按列名取"] smj --> bytype["按列类型猜"] gt --> byidx["按位置取"]
实验 crate 依赖 sea-orm =2.0.4(sqlx-postgres、runtime-tokio-rustls、macros、with-json、postgres-array、serde,加上默认 feature;默认 feature 里有 with-chrono、with-rust_decimal、with-time、with-uuid,into_json 的结果和它们有关),平台 M2 Max、macOS、rustc 1.99.0、Postgres 16.14。表和数据与 Loader 与 N+1 相同:users(ann、bob、cat)、profile(cat 没有)、post(a1、a2 属于 ann,b1 属于 bob)。
Selector 的类型参数
Select<E> 的几个 into_* 都不碰语句,只换 Selector<S> 的类型参数(into_model、into_json、into_tuple 的函数体都是 Selector { query: self.query, selector: PhantomData })。唯一改语句的是 into_partial_model。每一行怎么解码看 SelectorTrait 的实现:
impl<T> SelectorTrait for SelectGetableTuple<T>
where
T: TryGetableMany,
{
type Item = T;
fn from_raw_query_result(res: QueryResult) -> Result<Self::Item, DbErr> {
T::try_get_many_by_index(&res).map_err(Into::into)
}
}
impl<M> SelectorTrait for SelectModel<M>
where
M: FromQueryResult + Sized,
{
type Item = M;
fn from_raw_query_result(res: QueryResult) -> Result<Self::Item, DbErr> {
M::from_query_result(&res, "")
}
}
| 方法 | S | 一行怎么拆 | 改 SQL 吗 |
|---|---|---|---|
into_model::<M>() | SelectModel<M> | M::from_query_result(row, ""),按列名 | 否 |
into_partial_model::<M>() | SelectModel<M> | 同上 | select_only() 再按 M 的字段加列 |
into_tuple::<T>() | SelectGetableTuple<T> | try_get_by_index(0..n),按位置 | 否 |
into_values::<T, C>() | SelectGetableValue<T, C> | 按枚举 C 的每个变体名取列 | 否 |
into_json() | SelectModel<JsonValue> | 遍历行里的每一列,按列类型试 | 否 |
Selector 本身、select_only / column / column_as 在 Select 与过滤。from_query_result 的第二个参数 pre 是列名前缀:TryGetable::try_get 在 pre 非空时取的是 format!("{pre}{col}")。单表查询传 "";join 时是 A_ / B_(见 join 与 find_related),嵌套部分模型时是 author_ 这样的字段名前缀(后面讲)。
into_model 与 #[derive(FromQueryResult)]
自己的 struct 派生 FromQueryResult,就能接 into_model:
#[derive(Debug, FromQueryResult)]
struct TitleOnly {
id: i32,
title: String,
}
let v = post::Entity::find().order_by_asc(post::Column::Id).into_model::<TitleOnly>().all(&db).await?;
INFO sqlx::query: summary="SELECT \"post\".\"id\", \"post\".\"user_id\", \"post\".\"title\" …" db.statement="\n\nSELECT \"post\".\"id\", \"post\".\"user_id\", \"post\".\"title\" FROM \"post\" ORDER BY \"post\".\"id\" ASC\n" rows_affected=3 rows_returned=3 elapsed=1.764167ms elapsed_secs=0.001764167
[TitleOnly { id: 1, title: "a1" }, TitleOnly { id: 2, title: "a2" }, TitleOnly { id: 3, title: "b1" }]
into_model 不改 SELECT,三列全取回来,user_id 在解码时被忽略。要少取列得自己 select_only().column(..),或者用下一节的 DerivePartialModel。
宏生成的代码在 TryFromQueryResultCheck 和 impl_from_query_result 里。对普通字段,每个字段生成:
tokens.extend(quote! {
let #ident = match #row.try_get_nullable(#pre, #name) {
Err(v @ sea_orm::TryGetError::DbErr(_)) => {
return Err(v);
}
v => v,
};
});
最后 Ok(Self { #ident: #ident?, .. })。列名 #name 默认是字段名,#[sea_orm(from_alias = "..")](或 alias)可以改;#[sea_orm(skip)] 填 Default::default()。逻辑是:真正的数据库错误(列不存在、类型不对)立刻返回;NULL 先留着(TryGetError::Null),等到组装时 ? 才变成错误。之所以分两步,是为了让外层包了一层 Option 的嵌套结构能把「整组都是 NULL」解释成 None,而不吞掉其他错误。from_query_result 本身是 Ok(Self::from_query_result_nullable(row, pre)?),TryGetError 经 From<TryGetError> for DbErr 转成 DbErr。
取不到、类型不对、NULL:各报什么
src/bin/partial_err.rs 对 post 的 id 1 这一行逐个试:
let one = || post::Entity::find().filter(post::Column::Id.eq(1));
// a. title 没选
one().select_only().column(post::Column::Id).into_model::<TitleOnly>().one(&db).await;
// b. struct WideId { id: i64, title: String },id 列是 int4
one().into_model::<WideId>().one(&db).await;
// c. title 是 NULL
one().select_only().column(post::Column::Id)
.column_as(Expr::cust("NULL::text"), "title").into_model::<TitleOnly>().one(&db).await;
// c'. 同一条语句,用实体的 Model 解码
one().select_only().column(post::Column::Id)
.column_as(Expr::cust("NULL::text"), "title").one(&db).await;
a. missing column : Err(Query(SqlxError(ColumnNotFound("title"))))
Display : Query Error: no column found for name: title
b. i64 on int4 : Err(Query(SqlxError(ColumnDecode { index: "\"id\"", source: "mismatched types; Rust type `core::option::Option<i64>` (as SQL type `INT8`) is not compatible with SQL type `INT4`" })))
c. NULL into String : Err(Type("A null value was encountered while decoding \"title\""))
c'. same via Model : Err(Type("Missing value for column 'user_id'"))
| 情况 | 自己派生的 FromQueryResult | 实体的 Model |
|---|---|---|
| 列没选 | DbErr::Query(SqlxError(ColumnNotFound("title"))) | DbErr::Type("Missing value for column '..'") |
| 类型不兼容 | ColumnDecode { .. mismatched types .. } | 同左 |
NULL 落进非 Option | DbErr::Type("A null value was encountered while decoding \"title\"") | DbErr::Type("Missing value for column '..'") |
两边不一样,原因在生成的代码:实体 Model 的 from_query_result 先把每列读成 Option<T>,ColumnNotFound 和 NULL 都变成 None,最后统一报 Missing value for column(列与值 里有实验和来历);自己派生的 FromQueryResult 直接按字段类型读,缺列是 sqlx 的 ColumnNotFound,NULL 是 TryGetError::Null 的文字。c' 报的是 user_id 而不是 title:select_only 之后 user_id 也没选,Model 按字段顺序先碰到它。b 的错误来自 sqlx 的类型检查,Option<i64> 是因为 sea-orm 总是向 sqlx 要 Option<T>;读 int4 要用 i32,见 sqlx 卷的 mismatched types。
DerivePartialModel:从字段推出 SELECT 列表
#[derive(DerivePartialModel)] 加 #[sea_orm(entity = "..")],字段默认对应实体的同名列:
#[derive(Debug, DerivePartialModel)]
#[sea_orm(entity = "post::Entity")]
struct PostCard {
id: i32,
title: String,
#[sea_orm(from_col = "user_id")]
author_id: i32,
#[sea_orm(from_expr = "Expr::cust(\"upper(\\\"post\\\".\\\"title\\\")\")")]
shout: String,
}
let v = post::Entity::find().order_by_asc(post::Column::Id).into_partial_model::<PostCard>().all(&db).await?;
INFO sqlx::query: summary="SELECT \"post\".\"id\" AS \"id\", …" db.statement="\n\nSELECT \"post\".\"id\" AS \"id\", \"post\".\"title\" AS \"title\", \"post\".\"user_id\" AS \"author_id\", upper(\"post\".\"title\") AS \"shout\" FROM \"post\" ORDER BY \"post\".\"id\" ASC\n" rows_affected=3 rows_returned=3 elapsed=543.5µs elapsed_secs=0.0005435
[PostCard { id: 1, title: "a1", author_id: 1, shout: "A1" }, PostCard { id: 2, title: "a2", author_id: 1, shout: "A2" }, PostCard { id: 3, title: "b1", author_id: 2, shout: "B1" }]
into_partial_model 只有一行:
M::select_cols(QuerySelect::select_only(self)).into_model::<M>()
先清空原来的列,再让 M 自己加,然后照常 into_model。加列的是 PartialModelTrait 的 select_cols_nested(select, prefix, alias),宏在 impl_partial_model 里为每个字段写一段。每列都写成 列 AS "字段名"(有前缀时是 "前缀字段名"),所以解码时按字段名一定取得到;from_col 换的是列,别名仍是字段名;from_expr 把任意 Expr 放进去。
普通字段走的是 QuerySelect::column_as(select, Column::X, alias),而 column_as 收的是 ColumnAsExpr,对实体列会套上这一列的 select_as:
impl<C> ColumnAsExpr for C
where
C: ColumnTrait,
{
fn into_column_as_expr(self) -> SimpleExpr {
self.select_as(self.as_column_ref().into_column_ref().into())
}
}
所以带 select_as 的列(包括 Postgres enum 列默认的 CAST(.. AS "text"))在部分模型里也照样转换,和 Entity::find() 选出来的一样。select_as / save_as 本身见 DeriveEntityModel 与 dense 格式 和 ActiveEnum 与 ValueType。
字段名对不上实体的列时,宏生成的 Column::Headline 不存在,编译期就报(src/bin/pm_bad_field.rs):
error[E0599]: no variant, associated function, or constant named `Headline` found for enum `exp_seaorm_select2::ent::post::Column` in the current scope
--> src/bin/pm_bad_field.rs:4:17
|
4 | #[derive(Debug, DerivePartialModel)]
| ^^^^^^^^^^^^^^^^^^ variant, associated function, or constant not found in `exp_seaorm_select2::ent::post::Column`
|
= note: this error originates in the derive macro `DerivePartialModel` (in Nightly builds, run with -Z macro-backtrace for more info)
这是部分模型相对手写 FromQueryResult + select_only().column(..) 的主要好处:列表和字段由同一份声明产生,漏列、拼错名字都到不了运行期。
2.0 起自带 FromQueryResult
1.x 里要写 #[derive(DerivePartialModel, FromQueryResult)]。2.0 的 DerivePartialModel 默认就生成 FromQueryResult(DerivePartialModel::expand 里复用 DeriveFromQueryResult 的 impl_from_query_result(true)),两个一起写就冲突(src/bin/pm_both.rs):
error[E0119]: conflicting implementations of trait `FromQueryResult` for type `PostTitle`
--> src/bin/pm_both.rs:5:37
|
5 | #[derive(Debug, DerivePartialModel, FromQueryResult)]
| ------------------ ^^^^^^^^^^^^^^^ conflicting implementation for `PostTitle`
| |
| first implementation here
|
= note: this error originates in the derive macro `FromQueryResult` (in Nightly builds, run with -Z macro-backtrace for more info)
这条改动是 #2653(「DerivePartialModel implement FromQueryResult by default」),CHANGELOG 把它列在 2.0.0 的破坏性改动里:「Remove FromQueryResult in these cases」。确实要自己实现时,写 #[sea_orm(from_query_result = "false")] 关掉(宏里 from_query_result 默认是 true,只认字符串 "false")。升级时把 FromQueryResult 从 derive 列表里删掉即可,生成的代码对普通字段和 #[derive(FromQueryResult)] 一样。
嵌套
字段加 #[sea_orm(nested)],类型是另一个部分模型(或一个完整的实体 Model,#2642 起实体 Model 都实现了 PartialModelTrait)。嵌套的列会带上 字段名_ 前缀,cargo expand --bin partial 里 PostWithAuthor 的两段:
let select = <UserName as sea_orm::PartialModelTrait>::select_cols_nested(
select,
Some(
&if let Some(prefix) = pre {
::alloc::__export::must_use({
::alloc::fmt::format(format_args!("{0}{1}_", prefix, "author"))
})
} else {
::alloc::__export::must_use({
::alloc::fmt::format(format_args!("{0}_", "author"))
})
},
),
None,
);
// ...
let author = match sea_orm::FromQueryResult::from_query_result_nullable(
row,
&::alloc::__export::must_use({
::alloc::fmt::format(format_args!("{0}{1}_", pre, "author"))
}),
) {
选列和解码用同一个前缀规则,多层嵌套就是前缀一层层拼起来(a_b_name)。nested(prefix = "..") 可以换掉默认前缀,alias = ".." 让嵌套的列从一个表别名上取(Expr::col((alias, col))),表在 join 时起了别名就要用它。
DerivePartialModel 只负责 SELECT 列表,不负责 join。实验:
#[derive(Debug, DerivePartialModel)]
#[sea_orm(entity = "user::Entity")]
struct UserName {
name: String,
}
#[derive(Debug, DerivePartialModel)]
#[sea_orm(entity = "post::Entity")]
struct PostWithAuthor {
title: String,
#[sea_orm(nested)]
author: UserName,
}
#[derive(Debug, DerivePartialModel)]
#[sea_orm(entity = "user::Entity")]
struct UserWithProfile {
name: String,
#[sea_orm(nested)]
profile: Option<profile::Model>,
}
post::Entity::find().inner_join(user::Entity).order_by_asc(post::Column::Id)
.into_partial_model::<PostWithAuthor>().all(&db).await?;
user::Entity::find().left_join(profile::Entity).order_by_asc(user::Column::Id)
.into_partial_model::<UserWithProfile>().all(&db).await?;
INFO sqlx::query: summary="SELECT \"post\".\"title\" AS \"title\", …" db.statement="\n\nSELECT \"post\".\"title\" AS \"title\", \"users\".\"name\" AS \"author_name\" FROM \"post\" INNER JOIN \"users\" ON \"post\".\"user_id\" = \"users\".\"id\" ORDER BY \"post\".\"id\" ASC\n" rows_affected=3 rows_returned=3 elapsed=1.168542ms elapsed_secs=0.001168542
[PostWithAuthor { title: "a1", author: UserName { name: "ann" } }, PostWithAuthor { title: "a2", author: UserName { name: "ann" } }, PostWithAuthor { title: "b1", author: UserName { name: "bob" } }]
INFO sqlx::query: summary="SELECT \"users\".\"name\" AS \"name\", …" db.statement="\n\nSELECT \"users\".\"name\" AS \"name\", \"profile\".\"id\" AS \"profile_id\", \"profile\".\"picture\" AS \"profile_picture\", \"profile\".\"user_id\" AS \"profile_user_id\" FROM \"users\" LEFT JOIN \"profile\" ON \"users\".\"id\" = \"profile\".\"user_id\" ORDER BY \"users\".\"id\" ASC\n" rows_affected=3 rows_returned=3 elapsed=971.208µs elapsed_secs=0.000971208
[UserWithProfile { name: "ann", profile: Some(Model { id: 1, picture: "ann.png", user_id: 1 }) }, UserWithProfile { name: "bob", profile: Some(Model { id: 2, picture: "bob.png", user_id: 2 }) }, UserWithProfile { name: "cat", profile: None }]
嵌套的列写的是实体的表名("users"."name"),所以前面必须有一个 join 把这张表带进来;忘了 join,src/bin/pm_nojoin.rs 得到的是 Postgres 的报错:
Err(Query(SqlxError(Database(PgDatabaseError { severity: Error, code: "42P01", message: "missing FROM-clause entry for table \"users\"", detail: None, hint: None, position: Some(Original(35)), where: None, schema: None, table: None, column: None, data_type: None, constraint: None, file: Some("parse_relation.c"), line: Some(3657), routine: Some("errorMissingRTE") }))))
Option<profile::Model> 在 LEFT JOIN 没匹配到时得到 None,靠的是两层:实体 Model 的 from_query_result_nullable 在所有列都是 NULL 时返回 TryGetError::Null(列与值 讲过的 #2845),FromQueryResult for Option<T> 再把 Null 变成 None、把其他错误原样返回。嵌套字段不包 Option 时(src/bin/pm_nested_null.rs,profile: profile::Model),cat 这一行让整个 all 失败:
Err(Type("A null value was encountered while decoding All fields of nested model are null"))
「All fields of nested model are null」是实体 Model 放进 TryGetError::Null 的文字,前面那半句来自 From<TryGetError> for DbErr,拼起来读不通,但能看出是哪一种情况。
into_tuple:按位置
元组不需要声明任何 struct,适合聚合查询:
let v = post::Entity::find()
.select_only()
.column(post::Column::UserId)
.column_as(post::Column::Id.count(), "n")
.group_by(post::Column::UserId)
.order_by_asc(post::Column::UserId)
.into_tuple::<(i32, i64)>()
.all(&db)
.await?;
INFO sqlx::query: summary="SELECT \"post\".\"user_id\", COUNT(\"post\".\"id\") AS …" db.statement="\n\nSELECT \"post\".\"user_id\", COUNT(\"post\".\"id\") AS \"n\" FROM \"post\" GROUP BY \"post\".\"user_id\" ORDER BY \"post\".\"user_id\" ASC\n" rows_affected=2 rows_returned=2 elapsed=650.917µs elapsed_secs=0.000650917
tuple: [(1, 2), (2, 1)]
TryGetableMany 对单个值、一元组手写,2 到 12 元组由宏生成,try_get_many_by_index 就是 ($T::try_get_by_index(res, $N)?),+:第 N 个元素读第 N 列。partial_err.rs 的后三项:
d. count as i32 : Err(Query(SqlxError(ColumnDecode { index: "0", source: "mismatched types; Rust type `core::option::Option<i32>` (as SQL type `INT4`) is not compatible with SQL type `INT8`" })))
e. 3-tuple, 2 cols : Err(Query(SqlxError(ColumnIndexOutOfBounds { index: 2, len: 2 })))
f. swapped order : Err(Query(SqlxError(ColumnDecode { index: "0", source: "mismatched types; Rust type `core::option::Option<i32>` (as SQL type `INT4`) is not compatible with SQL type `TEXT`" })))
d 是 Postgres 的 COUNT 返回 int8,要用 i64;e 是元组比列多;f 是列的顺序(title、id)和元组的顺序(i32、String)反了。三种都是 sqlx 的错误,index 是位置而不是列名。
问: 已经用 column_as(.., "n") 给 COUNT 起了名字,into_tuple 能不能按名字对上,而不用管顺序?
答: 不能。SelectGetableTuple 只调 try_get_many_by_index,名字完全不参与,AS "n" 只对按名字取的 into_model / into_json 有用。f 就是这种情况:两列名字都对,只是顺序反了,照样失败。想按名字取又不想写 struct,可以用 into_values::<T, C>():它用一个列枚举 C 的变体名去取(SelectGetableValue 里 C::iter().map(|col| col.to_string())),元组的第 N 个元素对应 C 的第 N 个变体。
into_json:按列类型猜
FromQueryResult for JsonValue 不知道你想要什么类型,只能看每一列的类型信息挨个试。Postgres 分支:
for column in row.columns() {
let col = if !column.name().starts_with(pre) {
continue;
} else {
column.name().replacen(pre, "", 1)
};
let col_type = column.type_info();
// ...
match_postgres_type!(bool);
match_postgres_type!(i8);
match_postgres_type!(i16);
match_postgres_type!(i32);
match_postgres_type!(i64);
// ...
#[cfg(feature = "with-rust_decimal")]
match_postgres_type!(rust_decimal::Decimal);
#[cfg(feature = "with-json")]
try_get_type!(serde_json::Value, col);
// ...
try_get_type!(String, col);
// ...
try_get_type!(Vec<u8>, col);
}
match_postgres_type! 先比较 sqlx 的 type_info(),相同才读;try_get_type! 直接试读,成功就 map.insert(col, json!(v)) 并 continue。每一列都试不中时,循环什么也不做,这一列就不在结果里。函数上的注释写着「If the database does not provide a data type for a value, that value may be lost.」。
into_json 返回几个键? let v = post::Entity::find()
.select_only()
.column(post::Column::Id)
.column_as(Expr::cust("1.5::numeric"), "price")
.column_as(Expr::cust("now()"), "at")
.column_as(Expr::cust("'10.0.0.1'::inet"), "ip")
.filter(post::Column::Id.eq(1))
.into_json()
.one(&db)
.await?;
四列:int4、numeric、timestamptz、inet。features 如本页开头所列(没有 with-ipnetwork)。返回的 JSON 对象有几个键,price 是数字还是字符串?平台:M2 Max、macOS、rustc 1.99.0、Postgres 16.14。答案在页末。
into_json 适合把查询结果原样转给前端或调试;类型要可靠,就用 into_model 或 into_tuple,让类型不匹配变成错误而不是缺一个键。
动脑筋答案
src/bin/partial.rs 的第 5 段(实际代码用一个 filter_id1() 小扩展方法加上 filter(post::Column::Id.eq(1))):
INFO sqlx::query: summary="SELECT \"post\".\"id\", 1.5::numeric AS …" db.statement="\n\nSELECT \"post\".\"id\", 1.5::numeric AS \"price\", now() AS \"at\", '10.0.0.1'::inet AS \"ip\" FROM \"post\" WHERE \"post\".\"id\" = $1 LIMIT $2\n" rows_affected=1 rows_returned=1 elapsed=956.375µs elapsed_secs=0.000956375
json: Some(Object {"at": String("2026-10-24T11:57:50.246240Z"), "id": Number(1), "price": String("1.5")})
3 个键,ip 没了,price 是字符串。
id:int4命中i32,Number(1)。price:numeric命中rust_decimal::Decimal(默认 feature 带了with-rust_decimal),json!(Decimal)用 rust_decimal 的Serialize,输出字符串"1.5"。前端拿到的不是数字。at:timestamptz命中 chrono / time 的时间类型,序列化成 RFC 3339 字符串。ip:inet只有开了with-ipnetwork才有对应分支;String和Vec<u8>的试读也因为类型不符失败,于是这一列被跳过,没有错误、没有日志。
小结
into_model/into_tuple/into_json只换Selector的类型参数,不改 SQL;只有into_partial_model先select_only()再按字段加列。#[derive(FromQueryResult)]按字段名(加前缀pre)取列:缺列报 sqlx 的ColumnNotFound,NULL 进非Option报A null value was encountered while decoding;实体Model对这两种都报Missing value for column。DerivePartialModel把每个字段写成列 AS "字段名",经column_as套上列的select_as;字段名不是实体的列时编译期报 E0599。2.0 起默认实现FromQueryResult,再 derive 一次报 E0119(#2653)。- 嵌套字段的列带
字段名_前缀,写实体的表名,join 要自己加,否则42P01 missing FROM-clause entry;Option<嵌套>在 LEFT JOIN 全 NULL 时得到None。 into_tuple按位置读,列名不起作用,COUNT要用i64;into_json按列类型逐个试,numeric变字符串,认不出的类型整列静默丢掉。