Rust in Everything
基于 sqlx 0.9.0

生成的代码

主线位置:编译期检查主线的最后一步。流水线拿到 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。

📝NOTE
没有蠢问题

问: 为什么不直接生成一个 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 _ 适合的是宏推不出、但你确定编码兼容的场合,比如自定义类型。

📝NOTE
冷知识:参数覆盖原来写成 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"强制非空CountCount
"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);
❗IMPORTANT
动脑筋:把 int8 列读成 String

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。

💬 关联讨论

发起讨论
加载中...