生成的代码
主线位置:编译期检查主线的最后一步。流水线拿到
Describe(编译时连库得来,或者从.sqlx读出来),expand_with_data用它生成代码。生成的代码在运行时走的就是查询主线:query_with_result造一个Query,try_map把每行变成结构体。这一页用cargo expand看展开结果,讲参数检查怎么让 rustc 报错、结果类型怎么定、列名后缀怎么覆盖推断。
flowchart TB
data["QueryData 含 Describe"] --> arity{"参数个数对吗"}
arity -->|不对| err["编译错误"]
arity -->|对| args["quote_args 参数检查"]
args --> kind{"有结果列吗"}
kind -->|没有| exec["__query_with_result"]
kind -->|有| rt{"RecordType"}
rt -->|Generated| rec["Record 加 quote_query_as"]
rt -->|Given| qa["quote_query_as"]
rt -->|Scalar| qs["quote_query_scalar"]
本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上跑,sqlx = "=0.9.0",cargo-expand 1.0.104,表和 流水线 一页相同(author(id int8 primary key, name text not null, bio text)、post)。
外层
expand_with_data 先做唯一一项不靠 rustc 的检查:describe 给出的参数个数和宏收到的参数表达式个数要相等。src/bin/bad_arity.rs 写了 $1 却不传参数:
error: expected 1 parameters, got 0
--> src/bin/bad_arity.rs:5:11
|
5 | let r = sqlx::query!("select name from author where id = $1").fetch_one(&pool).await?;
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
然后按结果列分两路:所有列的类型都是 void(insert / update 这类没有结果列的语句,Postgres 的 Describe 里列表为空,all 对空列表为真),只生成 ::sqlx::__query_with_result::<Postgres, _>(sql, query_args);否则按 RecordType 的三种情况生成结构体和映射闭包,或者 query_scalar! 的标量查询。最后整段包进一个块:
let ret_tokens = quote! {
{
#[allow(clippy::all)]
{
use ::sqlx::Arguments as _;
#warnings_out
#args_tokens
#output
}
}
};
warnings_out 只在类型映射有歧义时出现(同时开了 chrono 和 time,或 bigdecimal 和 rust_decimal),生成一个函数调用,这个函数在两个 feature 同时打开时带 #[deprecated],借 rustc 的弃用警告提醒你在 sqlx.toml 里选一个(见离线模式与 sqlx.toml)。
用 cargo expand 看一条 query!
src/bin/expand_demo.rs:
async fn run(pool: &PgPool, name: &str) -> anyhow::Result<()> {
let r = sqlx::query!("select id, bio from author where name = $1", name).fetch_one(pool).await?;
println!("{} {:?}", r.id, r.bio);
// ...
}
cargo expand --offline --bin expand_demo 的关键行(缩进是 cargo expand 的):
use ::sqlx::Arguments as _;
let arg0 = &(name);
#[allow(clippy::missing_panics_doc, clippy::unreachable)]
if false {
use ::sqlx::ty_match::{WrapSameExt as _, MatchBorrowExt as _};
let expr = ::sqlx::ty_match::dupe_value(arg0);
let ty_check = ::sqlx::ty_match::WrapSame::<&str, _>::new(&expr)
.wrap_same();
let (mut _ty_check, match_borrow) = ::sqlx::ty_match::MatchBorrow::new(
ty_check,
&expr,
);
_ty_check = match_borrow.match_borrow();
::core::panicking::panic("internal error: entered unreachable code");
}
let mut query_args = <sqlx::postgres::Postgres as ::sqlx::database::Database>::Arguments::default();
query_args
.reserve(
1usize,
0
+ ::sqlx::encode::Encode::<
sqlx::postgres::Postgres,
>::size_hint(arg0),
);
let query_args = ::core::result::Result::<
_,
::sqlx::error::BoxDynError,
>::Ok(query_args)
.and_then(move |mut query_args| {
query_args.add(arg0).map(move |()| query_args)
});
#[allow(non_snake_case)]
struct Record {
id: i64,
bio: ::std::option::Option<String>,
}
// ... impl Debug for Record
::sqlx::__query_with_result::<
sqlx::postgres::Postgres,
_,
>("select id, bio from author where name = $1", query_args)
.try_map(|row: sqlx::postgres::PgRow| {
use ::sqlx::Row as _;
#[allow(non_snake_case)]
let sqlx_query_as_id = row
.try_get_unchecked::<i64, _>(0usize)?
.into();
#[allow(non_snake_case)]
let sqlx_query_as_bio = row
.try_get_unchecked::<
::std::option::Option<String>,
_,
>(1usize)?
.into();
::std::result::Result::Ok(Record {
id: sqlx_query_as_id,
bio: sqlx_query_as_bio,
})
})
三段:if false 里的参数检查;query_args 的构造(和手写 query(..).bind(..) 做的事一样,只是 add 的错误包在 Result 里,留到执行时抛出,见 Query、QueryAs 与 SqlSafeStr);Record 结构体和 try_map 闭包。__query_with_result 就是 sqlx_core::query::query_with_result 的重新导出,try_map 返回 Map,后面的 .fetch_one(pool) 走的是普通的执行路径。下面逐段看。
参数:只为报错而存在的代码
参数检查由 quote_args 生成,每个参数一段:
Ok(quote_spanned!(expr.span() =>
// this shouldn't actually run
#[allow(clippy::missing_panics_doc, clippy::unreachable)]
if false {
use ::sqlx::ty_match::{WrapSameExt as _, MatchBorrowExt as _};
// evaluate the expression only once in case it contains moves
let expr = ::sqlx::ty_match::dupe_value(#name);
// if `expr` is `Option<T>`, get `Option<$ty>`, otherwise `$ty`
let ty_check = ::sqlx::ty_match::WrapSame::<#param_ty, _>::new(&expr).wrap_same();
// if `expr` is `&str`, convert `String` to `&str`
let (mut _ty_check, match_borrow) = ::sqlx::ty_match::MatchBorrow::new(ty_check, &expr);
_ty_check = match_borrow.match_borrow();
// this causes move-analysis to effectively ignore this block
::std::unreachable!();
}
))
#param_ty 是 get_param_type 按参数的 SQL 类型查出来的「期望的 Rust 类型」:先看 sqlx.toml 的 [macros.type-overrides],再问驱动的 param_type_for_id(Postgres 的 TEXT 参数给 &str,INT8 给 i64)。整段的目的是让 _ty_check(期望类型)和 match_borrow.match_borrow()(从实际参数推出来的类型)赋值一次,类型对不上 rustc 就报错。
两个辅助类型在门面 crate 的 src/ty_match.rs 里,靠方法调用的自动引用挑实现(文件开头的注释说这是 a hacky form of specialization):
WrapSame<T, U>:实际参数U是Option<_>时,wrap_same()给Option<T>,否则给T。于是Option<i64>也能绑到INT8参数上,None就是 NULL。MatchBorrow<T, U>:处理借用。期望&str时给String也行(&[u8]和Vec<u8>同理),期望T时给&T或&&T都行。
dupe_value 从 &T 造出一个 T(函数体是 panic!()),这样检查代码不会把参数 move 走;末尾的 unreachable!() 让借用检查器把整个块当成不会走完,块里的类型只参与推断。
quote_spanned!(expr.span() => ...) 把整段的位置标成参数表达式本身,报错就指向用户写的那个参数。src/bin/bad_param.rs 把 &str 绑到 int8 参数上:
let id = "1";
let r = sqlx::query!("select name from author where id = $1", id).fetch_one(&pool).await?;
error[E0308]: mismatched types
--> src/bin/bad_param.rs:6:65
|
6 | let r = sqlx::query!("select name from author where id = $1", id).fetch_one(&pool).await?;
| ^^
| |
| expected `i64`, found `&str`
| expected due to the type of this binding
expected due to the type of this binding 说的就是 _ty_check:它的类型是 i64,match_borrow() 推出来的是 &str。
问: 为什么不直接生成一个 fn check<T: Encode<Postgres> + Type<Postgres>>(_: &T) 的约束检查?
答: 那检查的是「这个类型能不能编码」,不是「是不是这一列要的类型」。String 实现了 Encode<Postgres>,绑到 INT8 参数上照样满足约束,错误要到运行时才由服务端报出来(下面 as _ 的实验就是这个结果)。宏要的是更强的条件:参数的 Rust 类型必须是这个 SQL 类型对应的那一个(或它的借用、Option)。ty_match.rs 的注释还提到另一点:用 trait 约束做「兼容」检查,想要好的报错就得用 #[on_unimplemented],而它不稳定;赋值不匹配的报错是 rustc 现成的 expected i64, found &str。
关掉参数检查
参数表达式写成 expr as T 时,get_type_override 认出 Expr::Cast,这个参数不生成检查代码(注释:cast will fail to compile if the type does not match)。as _ 更进一步,strip_wildcard 把它剥掉,等于原样传入、不做任何检查。src/bin/escape_hatches.rs 的后半:
let id = "1";
let res = sqlx::query!("select name from author where id = $1", id as _).fetch_one(&pool).await;
println!("id as _ -> {:?}", res.map(|r| r.name));
id as _ -> Err(Database(PgDatabaseError { severity: Error, code: "42883", message: "operator does not exist: bigint = 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(34)), 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") }))
编译通过,运行时服务端报错:运行时的 Parse 带着参数的实际类型(&str 是 TEXT),id = $1 成了 bigint = text。编译期 describe 时参数类型是让服务端推断的(INT8),运行时是客户端指定的,两者不一样,检查就失去了意义。as _ 适合的是宏推不出、但你确定编码兼容的场合,比如自定义类型。
id: i64参数的类型覆盖最早用的是类型标注语法 (expr): Type。#2483(首次发布于 0.7.0)给它加了弃用警告,PR 说明:Adds a deprecation notice when type ascription pattern is used, preparing grounds for when we drop support for the pattern, keeping compatibility with Rust's supported syntax, as decided on [RFC 3307]. 0.7.0 的 CHANGELOG 解释了原因:parse support for it has been removed in syn 2.0,This can be replaced with type overrides using casting syntax (as). 0.8.0 升级 syn 2.0(#2482)时,CHANGELOG 记下 Deprecated type ascription syntax in the query macros was removed. 所以今天的 get_type_override 只认 Expr::Cast,strip_wildcard 只处理 as _;被删掉的那个分支是 Expr::Type(ascription) => Some((&ascription.ty, true))。
结果:Record 和逐列 try_get_unchecked
每一列由 column_to_rust 变成一个 RustColumn:字段名、局部变量名、类型。可空性这样定:
let nullable = match nullability {
ColumnNullabilityOverride::NonNull => false,
ColumnNullabilityOverride::Nullable => true,
ColumnNullabilityOverride::None => describe.nullable(i).unwrap_or(true),
};
没有覆盖时用 describe 的推断,推断不出(None,比如 count(*))一律当成可空,生成 Option<T>。T 由 get_column_type 查:先看 sqlx.toml 里按「表.列」的 [macros.table-overrides](靠 describe 里的列来源),再看按 SQL 类型名的 [macros.type-overrides],最后问驱动的 return_type_for_id。
quote_query_as 为每列生成一行:
(true, ColumnType::Exact(type_)) => quote! {
// binding to a `let` avoids confusing errors about
// "try expression alternatives have incompatible types"
// it doesn't seem to hurt inference in the other branches
#[allow(non_snake_case)]
let #var_name = row.try_get_unchecked::<#type_, _>(#i)?.into();
},
几个选择各有原因:
- 不用
FromRow。query_as!(Author, ..)不要求Author实现FromRow,宏自己按列下标逐个取值、最后写一个结构体字面量Author { id: .., name: .. }。所以Author的字段名必须和列名一一对应,否则这个结构体字面量编译不过。 try_get_unchecked。不调Type::compatible,因为类型在编译期已经对着数据库查过(try_get和它的区别在 Row 与 FromRow)。- 按下标取。
#i是列序号,不按名字查找。 .into()。取出来的是推断的类型,再into成字段的类型。对生成的Record这是恒等转换;对query_as!的结构体,字段可以是任何实现了From<推断类型>的类型。反过来,不能转换时报的是From的错,src/bin/bad_field.rs把int8列放进id: i32:
error[E0277]: the trait bound `i32: From<i64>` is not satisfied
--> src/bin/bad_field.rs:11:11
|
11 | let a = sqlx::query_as!(Author, "select id, name from author where id = 1").fetch_one(&pool).await?;
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `From<i64>` is not implemented for `i32`
|
= help: `i32` implements trait `From<T>`:
From<SignalKind>
From<bool>
From<errno::Errno>
From<i16>
From<i8>
From<socket2::Domain>
From<socket2::Protocol>
From<socket2::Type>
and 2 others
= note: required for `i64` to implement `Into<i32>`
报错的位置是整个宏调用,不是结构体的字段,因为这行代码没有 quote_spanned!。
局部变量名加了前缀 sqlx_query_as_,源码注释指向 #1322:作用域里有一个和列名同名的单元结构体时(报告里是 actix-web 的路由宏生成的 password),let password = .. 会被当成对那个结构体的模式匹配,报类型错误。字段名经 parse_ident 加上 r# 前缀,所以列名是 type、match 这类关键字也能当字段名(cargo expand 打印时把 r#id 显示成 id)。
_unchecked 变体(checked = false)走 (false, _) 分支,生成不带类型参数的 row.try_get_unchecked(#i)?,类型完全由结构体字段推断,参数也不检查。
列名覆盖
推断不对时,在 SQL 里给列起别名,别名里带后缀。ColumnDecl::parse 找第一个 :、! 或 ?,前面是字段名,后面交给 ColumnOverride 的 Parse:先可选的 ! 或 ?,再可选的 : 类型。
| 别名 | 可空性 | 类型 | 生成 |
|---|---|---|---|
"n!" | 强制非空 | 推断 | T |
"name?" | 强制可空 | 推断 | Option<T> |
"aid: AuthorId" | 推断 | AuthorId | 推断非空时 AuthorId,否则 Option<AuthorId> |
"c!: Count" | 强制非空 | Count | Count |
"id: _" | 推断 | 通配 | 由结构体字段决定,运行时用 try_get 检查 |
src/bin/overrides.rs 对一条 LEFT JOIN 加 GROUP BY 用了前四种,再打印每个字段的类型名:
let r = sqlx::query!(
r#"select a.id, a.bio, count(p.id), count(p.id) as "n!", a.name as "name?",
a.id as "aid: AuthorId", a.bio as "bio2: String",
count(p.id) as "c!: Count"
from author a left join post p on p.author_id = a.id
where a.id = 1 group by a.id"#
)
.fetch_one(&pool)
.await?;
id i64
bio core::option::Option<alloc::string::String>
count core::option::Option<i64>
n i64
name core::option::Option<alloc::string::String>
aid overrides::AuthorId
bio2 core::option::Option<alloc::string::String>
c overrides::Count
Record { id: 1, bio: None, count: Some(1), n: 1, name: Some("ann"), aid: AuthorId(1), bio2: None, c: Count(1) }
AuthorId 和 Count 都是 #[sqlx(transparent)] 的 newtype。逐行对照:count(p.id) 推断不出可空性,是 Option<i64>,"n!" 去掉了 Option;a.name 本来非空,"name?" 加上了 Option;"aid: AuthorId" 只换类型,a.id 推断非空,所以不带 Option;"bio2: String" 同样只换类型,bio 可空,于是是 Option<String>,类型覆盖和可空性是两件事;"c!: Count" 两个都改。
别名要用双引号,是因为 Postgres 会把不加引号的标识符转成小写,!、?、: 也不是合法的标识符字符。SQL 里写 r#"..."# 是 Rust 的原始字符串,免得转义这些双引号。
: _ 通配只在 query_as! 系列里能用:query! 要生成 Record 的字段类型,_ 写不进结构体定义,expand_with_data 遇到会报 wildcard overrides are only allowed with an explicit record type, e.g. `query_as!()` and its variants。
: T 的 T 直接成了 try_get_unchecked::<T, _> 的类型参数,宏不检查 T 和列的 SQL 类型合不合得上,只要 T: Decode<Postgres> 就能编译。src/bin/escape_hatches.rs 的前半就是这么写的:
// int8 column read as String: the macro uses try_get_unchecked, no compatible() check
let r = sqlx::query!(r#"select id as "id: String" from author where id = 1"#).fetch_one(&pool).await?;
println!("id: String -> {:?}", r.id);
M2 Max、macOS、rustc 1.99.0、Postgres 16.14,sqlx 0.9.0。author 里 id = 1 的那一行存在。上面这段能编译吗?能的话,println! 打印什么?把 query! 换成 query_scalar!,结果一样吗?
query_scalar!
quote_query_scalar 要求恰好一列(否则报 expected exactly 1 column, got N),同样先试列名覆盖,没有覆盖就按推断的类型和可空性,生成:
Ok(quote! {
::sqlx::__query_scalar_with_result::<#db, #ty, _>(#query, #bind_args)
})
这里没有 try_map 闭包,query_scalar_with_result 返回普通的 QueryScalar,解码走 (T,): FromRow,也就是带 compatible 检查的 Row::try_get。三个宏里只有它在运行期还检查列类型。
动脑筋答案
src/bin/scalar_check.rs 把两种写法放在一起:
let r = sqlx::query!(r#"select id as "id: String" from author where id = 1"#).fetch_one(&pool).await?;
println!("query! -> {:?}", r.id);
let s = sqlx::query_scalar!(r#"select id as "id: String" from author where id = 1"#).fetch_one(&pool).await;
println!("query_scalar! -> {s:?}");
query! -> "\0\0\0\0\0\0\0\u{1}"
query_scalar! -> Err(ColumnDecode { index: "0", source: "mismatched types; Rust type `alloc::string::String` (as SQL type `TEXT`) is not compatible with SQL type `INT8`" })
能编译,也能运行,不报错。id = 1 以二进制格式传回来是 8 个字节 00 00 00 00 00 00 00 01,String 的 Decode 只做一次 UTF-8 校验,这 8 个字节恰好都是合法的 UTF-8(七个 NUL 加一个 U+0001),于是得到一个 8 个字符的字符串。try_get_unchecked 没有 compatible 这一关,: T 写错类型就是静默的垃圾值。
query_scalar! 生成的是 QueryScalar,解码经过 Row::try_get,compatible 发现 TEXT 和 INT8 不兼容,报 mismatched types。同样的覆盖,换一个宏,从静默出错变成运行时报错。
小结
expand_with_data自己只查参数个数;参数类型交给 rustc:每个参数一段if false { .. unreachable!() },用WrapSame/MatchBorrow推出参数的类型,赋给期望类型的变量,错了就是expected i64, found &str,位置指向参数本身。expr as T关掉这个参数的检查,as _原样传入;运行时的参数类型由 Rust 类型决定,和编译期describe时服务端推断的可能不同。参数覆盖原来是expr: T,0.7 弃用、0.8 删除。- 结果是匿名
Record(query!)或你的结构体(query_as!),不用FromRow:按列下标try_get_unchecked::<推断类型>,再.into()成字段类型;推断不出可空性的列是Option。 - 列名后缀:
!非空、?可空、: T换类型、!: T两个都改、: _通配(只限query_as!)。类型覆盖不影响可空性。 : T不在运行期检查,写错类型可能静默得到垃圾值;query_scalar!例外,它走try_get。