Rust in Everything
基于 sea-orm 2.0.4

列与值

主线位置:查询主线的两端。开头,find_by_id(42) 里的 col.eq(v) 把一个 Rust 值包成 sea-query 的 Value,再和列一起组成一个 Expr;结尾,Model::from_query_result 对每一列调 TryGetable,把 sqlx 的一列读回 Rust 值。定义主线上,每一列还带一个 ColumnDef。这一页讲这三样:ColumnDef、条件方法和它们产出的 SQL、Value 与 TryGetable。Expr 怎么被写成 SQL 文本是 QueryBuilder 的事,Value 怎么绑成 sqlx 参数在 绑定到 sqlx。

flowchart TB
  rv["Rust 值"] -->|Into Value| val[Value]
  val --> eqm["Column::X.eq"]
  eqm --> sa["save_as 可能加 CAST"]
  sa --> expr["Expr::Binary"]
  expr --> qb["QueryBuilder 写 $N"]
  qb --> sqlx["sqlx 发出"]
  sqlx --> row[QueryResult]
  row --> tg["TryGetable::try_get"]
  tg --> rv

实验的实体和表沿用前两页的 post(dense 写法,id int4、title text not null、body text),表里三行:(1, 'hello', 'first')、(2, '100% rust', NULL)、(3, 'world', 'x_y')。平台 M2 Max、macOS、rustc 1.99.0、Postgres 16.14,依赖 sea-orm = "=2.0.4"(sqlx-postgres、runtime-tokio-rustls、macros、with-chrono、with-json、with-uuid、postgres-array),表在自己的 schema 里。

ColumnDef:给建表看的列描述

ColumnDef 是 ColumnTrait::def() 的返回值:

pub struct ColumnDef {
    pub(crate) col_type: ColumnType,
    pub(crate) null: bool,
    pub(crate) unique: bool,
    pub(crate) indexed: bool,
    pub(crate) default: Option<SimpleExpr>,
    pub(crate) comment: Option<String>,
    pub(crate) unique_key: Option<String>,
    pub(crate) renamed_from: Option<String>,
    pub(crate) extra: Option<String>,
    pub(crate) seaography: SeaographyColumnAttr,
}

ColumnType 是 sea-query 的类型(src/entity/column_def.rs 开头的注释:原来的 sea_orm::ColumnType 在 0.11.0 被换成了 sea-query 的,这里重新导出以保留名字)。宏为每列生成 ColumnTypeTrait::def(<T as ValueType>::column_type()),起点是全部为假、没有默认值的 ColumnDef,再按字段属性链式调用 .nullable()、.unique() 等(见 DeriveEntityModel 与 dense 格式)。post 的 body 打出来是:

Body.def() = ColumnDef { col_type: String(None), null: true, unique: false, indexed: false, default: None, comment: None, unique_key: None, renamed_from: None, extra: None, seaography: SeaographyColumnAttr { ignore: false } }

String(None) 是不限长度的字符串;null: true 来自 Option<String>。这些字段给 Schema::create_table_from_entity 和 schema 同步 用。查询路径上只看 col_type 一项:save_as / select_as 的默认实现 cast_enum_as 检查它是不是 enum(是就给值加 CAST(.. AS 枚举名))或 JSON 数组。所以每次 eq 都会调一次 def(),构造一个 ColumnDef 再丢掉。

条件方法:从 Rust 值到 Expr

ColumnTrait::eq 收任何 Into<Value>:

    fn eq<V>(&self, v: V) -> Expr
    where
        V: Into<Value>,
    {
        let v = v.into();
        if v == v.as_null() {
            Expr::col(self.as_column_ref()).is_null()
        } else {
            let expr = self.save_as(Expr::val(v));
            Expr::col(self.as_column_ref()).eq(expr)
        }
    }

左边是 Expr::col((表, 列)),右边是 Expr::val(v) 经过 save_as。开头那个 if v == v.as_null() 分支是 2.0 才有的。

📝NOTE
冷知识:eq(None) 在 1.x 里查不到 NULL 的行

sea-orm 1.1.20 的 eq 由 bind_oper!(eq, Equal) 生成,不看值是不是 NULL。Column::Body.eq(None::<String>) 在本站用的 1.1.20 下,先 to_string(),再 build 并执行(exp-seaorm-entity1-old/src/bin/null_old.rs):

eq(None): SELECT "post"."id", "post"."title", "post"."body" FROM "post" WHERE "post"."body" = NULL
eq(None) built: SELECT "post"."id", "post"."title", "post"."body" FROM "post" WHERE "post"."body" = $1 [String(None)] -> rows Ok(0)

实际发出的是 = $1 绑一个 String(None)。和 NULL 比较的结果永远是 NULL,表里明明有一行 body 为 NULL,却一行也查不到,也不报错。issue #2826 提出:「Since inserting None produces null in the DB, I feel like it would be a great addition to allow querying by Option::None.」修复的提交是「Automatically convert eq(None) to IS NULL fixes #2826」,changelog/2.0.0-rc.20.md 记为「eq(None) is now correctly translated to IS NULL in SQL.」,首次发布于 2.0.0-rc.20。修法就是上面 eq 里那个 if v == v.as_null(),ne 同理换成 IS NOT NULL。gt(None) 之类没有改,仍然是和一个 NULL 参数比较(WHERE "post"."id" > $1,值 [Int(None)],src/bin/like_escape.rs)。

eq 之外,gt、gte、lt、lte 由 bind_oper! 生成,形状相同但没有 NULL 判断;is_in / is_not_in 由 bind_vec_func! 生成,对每个元素各做一次 save_as。like 系列直接转给 sea-query,contains / starts_with / ends_with 只是拼一个模式:

    fn contains<T>(&self, s: T) -> Expr
    where
        T: Into<String>,
    {
        let pattern = format!("%{}%", s.into());
        Expr::col(self.as_column_ref()).like(pattern)
    }

eq_any 要开 postgres-array feature,把所有值收进一个数组参数:

    #[cfg(feature = "postgres-array")]
    fn eq_any<V, I>(&self, v: I) -> Expr
    where
        V: Into<Value> + sea_query::postgres_array::NotU8,
        I: IntoIterator<Item = V>,
    {
        use sea_query::extension::postgres::PgFunc;

        let values: Vec<Value> = v.into_iter().map(|v| v.into()).collect();

        if let Some(first) = values.first() {
            let array = Value::Array(first.array_type(), Some(Box::new(values)));
            Expr::col(self.as_column_ref()).eq(PgFunc::any(self.save_array_as(Expr::val(array))))
        } else {
            Expr::col(self.as_column_ref()).is_in(std::iter::empty::<V>())
        }
    }

数组的元素类型取自第一个值的 array_type(),所以空列表无从知道类型,只能退回 is_in 的空列表。NotU8 排除 u8:Vec<u8> 在 sea-query 里是 Bytes,不是数组。

📝NOTE
没有蠢问题

问: 既然 eq_any 只用一个参数、SQL 文本不随长度变,为什么 is_in 不直接生成 = ANY($1)?

答: is_in 是三种后端共用的,MySQL 和 SQLite 没有数组参数,只能展开成 IN (?, ?, ...)。eq_any 整个方法挂在 #[cfg(feature = "postgres-array")] 下,CHANGELOG 加它时就写着「Added ColumnTrait::eq_any as a shorthand for the = ANY operator. Postgres only.」。在 Postgres 上,列表长度会变的条件用 eq_any 更好:语句缓存里只有一条,参数个数也不会碰到 65535 的上限。sqlx 一侧同一个问题的讨论见 operator does not exist: bigint = bigint[]。

实验:每种条件发出什么

对每个条件,先 build(DbBackend::Postgres) 打印 SQL 的 WHERE 部分和参数值,再真的执行一次,打印查到的 id(src/bin/cols_sql.rs):

use exp_seaorm_entity1::post::{self, COLUMN, Column, Entity as Post};
use sea_orm::sea_query::Expr;
use sea_orm::{ColumnTrait, DatabaseConnection, DbBackend, EntityTrait, QueryFilter, QueryTrait};

// Build the statement the way the executor does, print it, then run it.
async fn show(db: &DatabaseConnection, label: &str, cond: Expr) -> Result<(), sea_orm::DbErr> {
  let select = Post::find().filter(cond);
  let stmt = select.build(DbBackend::Postgres);
  let where_part = stmt.sql.split(" WHERE ").nth(1).unwrap_or("");
  let ids: Vec<i32> = select.all(db).await?.into_iter().map(|m: post::Model| m.id).collect();
  println!("{label:<24} WHERE {where_part}");
  println!("{:<24} values={:?} -> ids {ids:?}", "", stmt.values.map(|v| v.0).unwrap_or_default());
  Ok(())
}

#[tokio::main]
async fn main() -> Result<(), sea_orm::DbErr> {
  let db = exp_seaorm_entity1::connect(false).await?;
  exp_seaorm_entity1::reset(&db).await?;
  println!("{}", Post::find().build(DbBackend::Postgres).sql);
  show(&db, "eq(2)", Column::Id.eq(2)).await?;
  show(&db, "eq(None)", Column::Body.eq(None::<String>)).await?;
  show(&db, "is_in([1, 3])", Column::Id.is_in([1, 3])).await?;
  show(&db, "is_in([])", Column::Id.is_in(Vec::<i32>::new())).await?;
  show(&db, "eq_any([1, 3])", Column::Id.eq_any([1, 3])).await?;
  show(&db, "eq_any([])", Column::Id.eq_any(Vec::<i32>::new())).await?;
  show(&db, "between(1, 2)", Column::Id.between(1, 2)).await?;
  show(&db, "contains(\"100%\")", Column::Title.contains("100%")).await?;
  show(&db, "contains(\"_\")", Column::Body.contains("_")).await?;
  show(&db, "COLUMN.id.eq(2)", COLUMN.id.eq(2)).await?;
  show(&db, "COLUMN.body.eq(None)", COLUMN.body.eq(None::<String>)).await?;
  let inlined = Post::find().filter(Column::Title.contains("100%")).build(DbBackend::Postgres).to_string();
  println!("to_string: {inlined}");
  Ok(())
}
SELECT "post"."id", "post"."title", "post"."body" FROM "post"
eq(2)                    WHERE "post"."id" = $1
                         values=[Int(Some(2))] -> ids [2]
eq(None)                 WHERE "post"."body" IS NULL
                         values=[] -> ids [2]
is_in([1, 3])            WHERE "post"."id" IN ($1, $2)
                         values=[Int(Some(1)), Int(Some(3))] -> ids [1, 3]
is_in([])                WHERE $1 = $2
                         values=[Int(Some(1)), Int(Some(2))] -> ids []
eq_any([1, 3])           WHERE "post"."id" = ANY($1)
                         values=[Array(Int, Some([Int(Some(1)), Int(Some(3))]))] -> ids [1, 3]
eq_any([])               WHERE $1 = $2
                         values=[Int(Some(1)), Int(Some(2))] -> ids []
between(1, 2)            WHERE "post"."id" BETWEEN $1 AND $2
                         values=[Int(Some(1)), Int(Some(2))] -> ids [1, 2]
contains("100%")         WHERE "post"."title" LIKE $1
                         values=[String(Some("%100%%"))] -> ids [2]
...
COLUMN.id.eq(2)          WHERE "post"."id" = $1
                         values=[Int(Some(2))] -> ids [2]
COLUMN.body.eq(None)     WHERE "post"."body" IS NULL
                         values=[] -> ids [2]
to_string: SELECT "post"."id", "post"."title", "post"."body" FROM "post" WHERE "post"."title" LIKE '%100%%'

(... 处删掉了两行,留给本节末尾的动脑筋。)build 得到的 Statement 就是交给 sqlx 的东西:SQL 里是 $N,值另放。只有最后一行的 to_string() 把值内联进了 SQL,那是给人看的,不会发出去。用 sqlx 的日志确认实际发出的文本(src/bin/sent_log.rs,sqlx_logging(true) 加一个 sqlx=info 的 tracing subscriber):

 INFO summary="SELECT \"post\".\"id\", \"post\".\"title\", \"post\".\"body\" …" db.statement="\n\nSELECT \"post\".\"id\", \"post\".\"title\", \"post\".\"body\" FROM \"post\" WHERE \"post\".\"id\" = ANY($1)\n" rows_affected=2 rows_returned=2 elapsed=2.876291ms elapsed_secs=0.002876291
 INFO summary="SELECT \"post\".\"id\", \"post\".\"title\", \"post\".\"body\" …" db.statement="\n\nSELECT \"post\".\"id\", \"post\".\"title\", \"post\".\"body\" FROM \"post\" WHERE $1 = $2\n" rows_affected=0 rows_returned=0 elapsed=471.25µs elapsed_secs=0.00047125

逐行看:

  • eq(2):一个参数,Int(Some(2))。Int 这个分支决定了 sqlx 把它声明成 INT4。
  • eq(None):没有参数,直接 IS NULL。v == v.as_null() 判断「这个值是不是它那种类型的 NULL」,这个判断是 2.0 加的(见上面的冷知识)。ne(None) 对应 IS NOT NULL。
  • is_in([1, 3]):每个元素一个参数。列表长度不同,SQL 文本就不同,每种长度在 sqlx 的语句缓存里各占一条(见 扩展查询协议与语句缓存)。
  • is_in([]):sea-query 的 QueryBuilder 把空的 IN 写成 1 = 2(NOT IN 空列表写成 1 = 1),而 1 和 2 也作为参数绑定,于是实际发出的是 $1 = $2。语义正确,查不到任何行,但它仍然是一次真实的往返。
  • eq_any([1, 3]):一个数组参数 = ANY($1),不管多少个元素都是同一条 SQL。eq_any([]) 退回 is_in 的空列表。
  • between:两个参数。between 没有经过 save_as,enum 列上不会加 CAST。
  • contains("100%"):参数是 %100%%,用户输入里的 % 原样进了模式,没有转义。这里碰巧只匹配到一行。
  • COLUMN.id.eq(2)、COLUMN.body.eq(None):强类型列产出的 SQL 和值与 Column::X 完全相同,区别只在编译期,见下一节。
❗IMPORTANT
动脑筋:contains("_") 查到哪几行?

body 列的三行分别是 'first'、NULL、'x_y'。上面的程序里还有一行 show(&db, "contains(\"_\")", Column::Body.contains("_"))(M2 Max、macOS、rustc 1.99.0、Postgres 16.14),它的输出被删掉了。查到的 id 是哪几个?

强类型列:COLUMN.x 和 Column::X

Column::X.eq(v) 收任何 Into<Value>,值的类型对不对要到数据库才知道。dense 格式生成的 COLUMN 把每一列包进一个按类别区分的新类型(NumericColumn、StringColumn、StringColumnNullable、BoolColumn、DateTimeLikeColumn……,选哪种见 上一页)。包装类型的方法由 bind_oper! 生成,比 ColumnTrait 的版本多一个约束:

    macro_rules! bind_oper {
        ($vis:vis $op:ident, $bind_op:ident, trait $value_ty:path) => {
            $vis fn $op<V>(&self, v: V) -> Expr
            where
                V: Into<Value> + $value_ty,
            {
                self.0.$bind_op(v)
            }
        };
        // ...
    }

方法体仍然转给 self.0,也就是里面那个 Column,所以生成的 SQL 完全一样(上面的实验)。约束按类别给:

macro_rules! impl_numeric_column {
    ($ty:ident, $trait:ident) => {
    impl<E: EntityTrait> $ty<E> {
        boilerplate!(pub);

        bind_oper!(pub eq, eq, trait $trait);
        bind_oper!(pub ne, ne, trait $trait);
        bind_oper!(pub gt, gt, trait NumericValue);
        // ...
    }
};}
impl_numeric_column!(NumericColumn, NumericValue);
impl_numeric_column!(NumericColumnNullable, NumericValueNullable);

StringColumn 的 eq 要 Into<String>,StringColumnNullable 的要 IntoOption<String>(既收 &str / String,也收 Option<..>)。NumericColumn 上根本没有 like / contains,StringColumn 上没有 add / sub。四种写错的方式(src/bin/typed_err.rs):

fn main() {
  let _ = Post::find().filter(COLUMN.id.eq("2"));
  let _ = Post::find().filter(COLUMN.title.eq(2));
  let _ = Post::find().filter(COLUMN.id.contains("2"));
  let _ = Post::find().filter(COLUMN.title.eq(None::<String>));
  let _ = Post::find().filter(COLUMN.id.eq(2_i64)); // compiles
}
error[E0277]: the trait bound `&str: NumericValue` is not satisfied
   --> src/bin/typed_err.rs:5:44
    |
  5 |   let _ = Post::find().filter(COLUMN.id.eq("2"));
    |                                         -- ^^^ the trait `NumericValue` is not implemented for `&str`
    |                                         |
    |                                         required by a bound introduced by this call
    |
    = help: the following other types implement trait `NumericValue`:
              f32
              f64
              i16
              i32
              i64
              i8
              sea_orm::prelude::Decimal
              u16
            and 3 others
note: required by a bound in `NumericColumn::<E>::eq`
...
error[E0277]: the trait bound `std::string::String: From<{integer}>` is not satisfied
   --> src/bin/typed_err.rs:6:47
    |
  6 |   let _ = Post::find().filter(COLUMN.title.eq(2));
    |                                            -- ^ the trait `From<{integer}>` is not implemented for `std::string::String`
...
error[E0599]: no method named `contains` found for struct `NumericColumn<E>` in the current scope
 --> src/bin/typed_err.rs:7:41
  |
7 |   let _ = Post::find().filter(COLUMN.id.contains("2"));
  |                                         ^^^^^^^^ method not found in `NumericColumn<exp_seaorm_entity1::post::Entity>`

error[E0277]: the trait bound `std::string::String: From<Option<std::string::String>>` is not satisfied
   --> src/bin/typed_err.rs:8:47
    |
  8 |   let _ = Post::find().filter(COLUMN.title.eq(None::<String>));
    |                                            -- ^^^^^^^^^^^^^^ the trait `From<Option<std::string::String>>` is not implemented for `std::string::String`
...
error: could not compile `exp-seaorm-entity1` (bin "typed_err") due to 4 previous errors

第四个错误说明强类型列也区分了可空:title 是 NOT NULL 的 String,COLUMN.title.eq(None) 不让写;body 是 Option<String>,COLUMN.body.eq(None) 能写(上面的实验)。同样的 Column::Id.eq("2") 能编译,错误推迟到 Postgres(src/bin/untyped_err.rs):

Err(Query(SqlxError(Database(PgDatabaseError { severity: Error, code: "42883", message: "operator does not exist: integer = text", detail: None, hint: Some("No operator matches the given name and argument types. You might need to add explicit type casts."), position: Some(Original(81)), where: None, schema: None, table: None, column: None, data_type: None, constraint: None, file: Some("parse_oper.c"), line: Some(647), routine: Some("op_error") }))))

"2" 变成 Value::String,sqlx 把参数声明成 TEXT,Postgres 解析 int4 = text 时找不到运算符。

检查的是类别,不是宽度

typed_err.rs 的最后一行 COLUMN.id.eq(2_i64) 编译通过了。NumericValue(定义)是 sea-query 的一个空 trait,所有整数、浮点、Decimal、BigDecimal 都实现了它,强类型列只挡住「字符串当数字」这一类错误,不检查 i64 能不能放进 int4。这一点比 find_by_id 宽:find_by_id 要 Into<i32>,2_i64 编译不过(实体的几个 trait)。

i64 变成 Value::BigInt,参数声明为 INT8,int4 = int8 在 Postgres 里本身是合法的。问题出在 sqlx 的语句缓存:缓存的键只有 SQL 文本,同一条连接上,同一段 SQL 先用 INT8 参数预备过,再拿 i32 去绑,sqlx 直接复用缓存里的语句,不重新 Parse(src/bin/int_width.rs,max_connections(1)):

// Same SQL text, different integer widths, one connection (max_connections = 1).
#[tokio::main]
async fn main() -> Result<(), sea_orm::DbErr> {
  let db = exp_seaorm_entity1::connect(false).await?;
  exp_seaorm_entity1::reset(&db).await?;
  let a = Post::find().filter(COLUMN.id.eq(2_i64)).all(&db).await.map(|v| v.len());
  println!("1st, i64: {a:?}");
  let b = Post::find().filter(Column::Id.eq(2_i32)).all(&db).await.map(|v| v.len());
  println!("2nd, i32: {b:?}");
  let c = Post::find().filter(Column::Id.eq(2_i64)).all(&db).await.map(|v| v.len());
  println!("3rd, i64: {c:?}");
  Ok(())
}
1st, i64: Ok(1)
2nd, i32: Err(Query(SqlxError(Database(PgDatabaseError { severity: Error, code: "08P01", message: "insufficient data left in message", detail: None, hint: None, position: None, where: Some("unnamed portal parameter $1"), schema: None, table: None, column: None, data_type: None, constraint: None, file: Some("pqformat.c"), line: Some(534), routine: Some("pq_copymsgbytes") }))))
3rd, i64: Ok(1)

服务端按 INT8 去读 8 个字节,客户端只发了 4 个。反过来(先 i32 预备、再绑 i64)报的是 22P03 incorrect binary data format in bind parameter 1。本站的 1.1.20 / sqlx 0.8.6 行为相同。get_or_prepare 按 SQL 文本查缓存的代码见 扩展查询协议与语句缓存。对一列始终用同一个 Rust 类型的值(就是 Model 里那个字段的类型),就不会碰到。

Value:每种类型一个分支

条件和写入里的值最终都是 sea-query 的 Value:

pub enum Value {
    Bool(Option<bool>),
    TinyInt(Option<i8>),
    SmallInt(Option<i16>),
    Int(Option<i32>),
    BigInt(Option<i64>),
    // ...
    String(Option<String>),
    Enum(OptionEnum),
    Char(Option<char>),

    #[allow(clippy::box_collection)]
    Bytes(Option<Vec<u8>>),

    #[cfg(feature = "with-json")]
    #[cfg_attr(docsrs, doc(cfg(feature = "with-json")))]
    Json(Option<Box<Json>>),
    // ...
    #[cfg(feature = "postgres-array")]
    #[cfg_attr(docsrs, doc(cfg(feature = "postgres-array")))]
    Array(ArrayType, Option<Box<Vec<Value>>>),
    // ...
}

NULL 不是一个单独的分支,而是每个分支里的 None:Value::Int(None) 是「一个 INT4 类型的 NULL」。这样 NULL 也带着类型,sea-query-sqlx 绑参数时照样能告诉 sqlx 它是 INT4;eq 里的 v == v.as_null() 也是靠这一点判断「同类型的 NULL」。Option<T> 转 Value 的实现(From<Option<T>>)在 None 时调 T::null(),每个类型的 null() 返回自己那个分支的 None。

Value 的大小有一个编译期上限:VALUE_SIZE 由 check_value_size() 计算,超过 32 字节就在编译期 panic。所以 Json、BigDecimal 这类大的都装在 Box 里,String 则按注释所说「We bumped it to 32 such that String can be unboxed」。Enum 是 sea-query 1.0 新加的分支,ActiveEnum 怎么用它见 ActiveEnum 与 ValueType。

反方向的转换是 ValueType:try_from(Value) -> Result<Self, ValueTypeErr> 从 Value 取回 Rust 值,column_type() / array_type() 给出列类型和数组元素类型。宏生成的 ColumnTrait::def 用 column_type(),ModelTrait::try_set 用 try_from。读数据库的结果不走 ValueType,走下面的 TryGetable。

TryGetable:把一列读回来

TryGetable 从 QueryResult 里按列名或下标取一列:

pub trait TryGetable: Sized {
    /// Decode the value at the column named or positioned by `index`.
    fn try_get_by<I: ColIdx>(res: &QueryResult, index: I) -> Result<Self, TryGetError>;

    // ...
    fn try_get(res: &QueryResult, pre: &str, col: &str) -> Result<Self, TryGetError> {
        if pre.is_empty() {
            Self::try_get_by(res, col)
        } else {
            Self::try_get_by(res, format!("{pre}{col}").as_str())
        }
    }

基本类型的实现由 try_getable_all! 生成,Postgres 那个分支是:

                    #[cfg(feature = "sqlx-postgres")]
                    QueryResultRow::SqlxPostgres(row) => row
                        .try_get::<Option<$type>, _>(idx.as_sqlx_postgres_index())
                        .map_err(|e| sqlx_error_to_query_err(e).into())
                        .and_then(|opt| opt.ok_or_else(|| err_null_idx_col(idx))),

总是向 sqlx 要 Option<T>,这样 NULL 不会变成 sqlx 的 UnexpectedNullError(unexpected null),而是由 sea-orm 自己变成 TryGetError::Null。类型不兼容仍然是 sqlx 的错误,原样包进 DbErr::Query(mismatched types)。TryGetError 有两个分支:

pub enum TryGetError {
    /// A database error was encountered as defined in [crate::DbErr]
    DbErr(DbErr),
    /// A null value was encountered
    Null(String),
}

转成 DbErr 时(From<TryGetError>),Null(s) 变成 DbErr::Type("A null value was encountered while decoding {s}")。Option<T> 的实现把 Null 变成 Ok(None),还把 sqlx 的 ColumnNotFound 也变成 Ok(None):

impl<T: TryGetable> TryGetable for Option<T> {
    fn try_get_by<I: ColIdx>(res: &QueryResult, index: I) -> Result<Self, TryGetError> {
        match T::try_get_by(res, index) {
            Ok(v) => Ok(Some(v)),
            Err(TryGetError::Null(_)) => Ok(None),
            #[cfg(feature = "sqlx-dep")]
            Err(TryGetError::DbErr(DbErr::Query(crate::RuntimeErr::SqlxError(err)))) => {
                use std::ops::Deref;
                match err.deref() {
                    sqlx::Error::ColumnNotFound(_) => Ok(None),
                    // ...
                }
            }
            Err(e) => Err(e),
        }
    }
}

实验:NULL 和缺列各报什么

同一张表,另定义一个把 body 写成非 Option 的实体 strict,再分别手工读一列、漏选一列、读一行全是 NULL 的结果(src/bin/null_decode.rs):

mod strict {
  use sea_orm::entity::prelude::*;

  #[sea_orm::model]
  #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
  #[sea_orm(table_name = "post")]
  pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub title: String,
    pub body: String,
  }

  impl ActiveModelBehavior for ActiveModel {}
}

#[tokio::main]
async fn main() -> Result<(), sea_orm::DbErr> {
  let db = exp_seaorm_entity1::connect(false).await?;
  exp_seaorm_entity1::reset(&db).await?;

  // 1. row 2 has body = NULL
  let r = strict::Entity::find_by_id(2).one(&db).await;
  println!("1 model, NULL body : {r:?}");

  // 2. the same column read by hand
  let row = db
    .query_one_raw(Statement::from_string(DbBackend::Postgres, "select body from post where id = 2"))
    .await?
    .expect("one row");
  println!("2 try_get::<String>: {:?}", row.try_get::<String>("", "body"));
  println!("  try_get::<Option<String>>: {:?}", row.try_get::<Option<String>>("", "body"));
  println!("  try_get::<String>(\"nope\"): {:?}", row.try_get::<String>("", "nope"));

  // 3. a column left out of the SELECT list
  let r = Post::find().select_only().column(Column::Id).one(&db).await;
  println!("3 missing column   : {r:?}");

  // 4. every column NULL
  let r = exp_seaorm_entity1::post::Model::find_by_statement(Statement::from_string(
    DbBackend::Postgres,
    "select null::int4 as id, null::text as title, null::text as body",
  ))
  .one(&db)
  .await;
  println!("4 all NULL         : {r:?}");
  Ok(())
}
1 model, NULL body : Err(Type("Missing value for column 'body'"))
2 try_get::<String>: Err(Type("A null value was encountered while decoding \"body\""))
  try_get::<Option<String>>: Ok(None)
  try_get::<String>("nope"): Err(Query(SqlxError(ColumnNotFound("nope"))))
3 missing column   : Err(Type("Missing value for column 'title'"))
4 all NULL         : Err(Type("A null value was encountered while decoding All fields of nested model are null"))
  • 1:经过实体读,报的是 Missing value for column 'body',Display 出来是 Type Error: Missing value for column 'body'(src/bin/null_display.rs,展开写法的实体,结果相同)。原因是宏生成的 from_query_result 先把每列读成 Option<T>,NULL 得到 None,再在 ok_or_else 里报缺值(生成代码见 DeriveEntityModel 与 dense 格式)。
  • 2:手工 try_get::<String> 走 try_getable_all! 那条路,报 A null value was encountered while decoding "body","body" 是 ColIdx 的 Debug 输出。缺列时是 sqlx 的 ColumnNotFound。
  • 3:select_only 只选了 id,title 不在结果里。Option<T> 把 ColumnNotFound 变成了 None,于是和 NULL 一样报 Missing value for column 'title'。从报错文字分不出「列是 NULL」和「列根本没选」。
  • 4:每列都是 NULL,生成代码的全空检查先触发,返回 TryGetError::Null("All fields of nested model are null")。顶层的 one() 不是 Option<Model> 的嵌套读取,它把这个 Null 转成 DbErr,于是得到一句拼接起来的 A null value was encountered while decoding All fields of nested model are null。

同一个 strict 实体在本站用的 1.1.20 下(exp-seaorm-entity1-old/src/bin/null_old.rs):

1 NULL body    : Type("A null value was encountered while decoding \"body\"") / Type Error: A null value was encountered while decoding "body"
3 missing column: Err(Query(SqlxError(ColumnNotFound("title"))))

1.x 里两种情况各有各的报错,2.0 都变成了 Missing value for column。

📝NOTE
冷知识:Missing value for column 是为 LEFT JOIN 加的

1.1.20 的 impl_from_query_result 对每个字段直接生成 row.try_get(pre, 列名)?,第一个 NULL 就以 TryGetError::Null 返回。#2845(首次发布于 2.0.0-rc.29)改成了今天的三步:先全部读成 Option,全空返回 Null,否则逐个 ok_or_else 报 Missing value for column。PR 说明的问题是:「When using LEFT JOIN with a nested Option<Model>, code tried to read the missing related row as if it were always present. This caused a type error instead of simply returning None.」修法是「treating all-null fields as a missing record」,生成代码里的注释也写着「When a nested model is loaded via LEFT JOIN, all its fields may be NULL. In that case we interpret it as "no nested row" (i.e., Option::None).」。CHANGELOG 记为「FromQueryResult now supports nullable nested model」。

为了区分「整行为空」和「某一列为空」,每列都必须先读成 Option,非 Option 字段的 NULL 报错因此换成了新的文字,Option<T> 吞掉 ColumnNotFound 的老行为(1.1.20 里就有)也随之落到了 Model 上,这就是上面实验 3 的结果。

动脑筋答案

实验输出里删掉的两行是:

contains("_")            WHERE "post"."body" LIKE $1
                         values=[String(Some("%_%"))] -> ids [1, 3]
  • 模式是 %_%。_ 在 LIKE 里匹配任意一个字符,所以它的意思是「至少一个字符」,'first' 也匹配。contains 只是 format!("%{}%", s),不转义 % 和 _。
  • NULL 的 LIKE 结果是 NULL,不算匹配,id 2 不在里面。
  • 要按字面匹配下划线,得自己转义。Postgres 的 LIKE 默认以反斜杠作转义字符,Column::Body.contains("\\_") 的参数是 "%\\_%"(Debug 输出),只查到 id 3(src/bin/like_escape.rs)。

小结

  • ColumnDef 主要给建表和 schema 同步用;查询时 save_as / select_as 只看其中的列类型,为 enum 列和 JSON 数组加转换。
  • Column::X.eq(v) 是 Expr::col((表, 列)).eq(save_as(Expr::val(v)));值以 $N 参数发出,to_string() 才内联。eq(None) / ne(None) 从 2.0.0-rc.20 起是 IS NULL / IS NOT NULL,1.x 是 = $1 绑 NULL、查不到行;gt(None) 等仍是和 NULL 参数比较。
  • is_in 每个元素一个参数,空列表发出 $1 = $2(值 1、2);eq_any 是一个数组参数 = ANY($1),只在 Postgres 上有,空列表退回 is_in。contains 不转义 % 和 _。
  • 强类型列 COLUMN.x 只在 dense 格式下有,产出的 SQL 和 Column::X 相同,编译期按类别(数字、字符串、可空与否)检查值;Column::X.eq("2") 能编译,到 Postgres 报 operator does not exist: integer = text。
  • 类别检查不管宽度:COLUMN.id.eq(2_i64) 能编译。同一条连接上同一段 SQL 混用 i64 和 i32 参数,sqlx 按 SQL 文本复用预备语句,报 insufficient data left in message。
  • Value 每种类型一个分支,NULL 是分支里的 None,所以 NULL 也有类型;Value 不超过 32 字节,大的分支装箱。
  • TryGetable 总向 sqlx 要 Option<T>,NULL 变成 TryGetError::Null;Option<T> 把 NULL 和缺列都变成 None。经过实体读时,NULL 落进非 Option 字段和 SELECT 漏了列都报 Missing value for column '...',1.x 分别是 A null value was encountered while decoding 和 ColumnNotFound。

💬 关联讨论

发起讨论
加载中...