Rust in Everything
基于 sea-orm 2.0.4

部分列与自定义结果

主线位置:查询主线的最后一步。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 落进非 OptionDbErr::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 是位置而不是列名。

📝NOTE
没有蠢问题

问: 已经用 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.」。

❗IMPORTANT
动脑筋: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 变字符串,认不出的类型整列静默丢掉。

💬 关联讨论

发起讨论
加载中...