列与值
主线位置:查询主线的两端。开头,
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 才有的。
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,不是数组。
问: 既然 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完全相同,区别只在编译期,见下一节。
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。
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。