query! 的流水线
主线位置:编译期检查主线的开头。
cargo build编译到一个query!时,rustc 调起过程宏expand_query,它先决定类型信息从哪来(编译时连库,或者读.sqlx里的 JSON),再交给某个驱动展开。这一页讲到「拿到Describe」为止:输入怎么解析、环境怎么读、数据源和驱动怎么选、编译期的数据库连接怎么缓存。Describe里的可空性怎么推断在 describe 与可空性推断,拿到之后生成什么代码在生成的代码,离线的那一支在离线模式与 sqlx.toml。
flowchart TB
user["query! 等 macro_rules"] --> pm["expand_query 过程宏"]
pm --> parse["解析 QueryMacroInput"]
parse --> meta["读 sqlx.toml 和 .env"]
meta --> src{"有 URL 且未离线"}
src -->|是| live["Live"]
src -->|否| cached["Cached 读 .sqlx"]
live --> drv["按 URL scheme 选驱动"]
cached --> drv2["按 db_name 选驱动"]
drv --> desc["describe_blocking"]
desc --> gen["expand_with_data"]
drv2 --> gen
代码在 sqlx-macros-core/src/query/,七个文件约 1.6k 行,加上 database/ 里的 179 行。sqlx-macros 这个过程宏 crate 只是转发(见源码地图)。本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上跑,依赖 sqlx = "=0.9.0",表建在自己的 schema macros 里:
create table author (id int8 primary key, name text not null, bio text);
create table post (id int8 primary key, author_id int8 not null references author, title text not null,
created_at timestamptz not null default now());
十几个宏,一个入口
用户写的 query!、query_as! 这些都不是过程宏,而是门面 crate 里的 macro_rules!。以 query! 为例:
macro_rules! query (
// in Rust 1.45 we can now invoke proc macros in expression position
($query:expr) => ({
$crate::sqlx_macros::expand_query!(source = $query)
});
// ...
($query:expr, $($args:tt)*) => ({
$crate::sqlx_macros::expand_query!(source = $query, args = [$($args)*])
})
);
其他变体只是换了键值选项:
| 宏 | 传给 expand_query! 的选项 |
|---|---|
query! | source = ... |
query_as!(T, ...) | record = T, source = ... |
query_scalar! | scalar = _, source = ... |
query_file! / query_file_as! / query_file_scalar! | source_file = "..." 代替 source |
以上任一个的 _unchecked 版本 | 再加 checked = false |
一共 12 个 macro_rules!,全部落到同一个 expand_query。参数用 $($args:tt)* 原样包进 args = [...],注释解释了为什么不写成 $($args:expr),*:Rust 1.45 时 expr 片段的卫生性有问题。
QueryMacroInput 的 Parse 实现是一个「键 = 值」循环,认六个键:source(一个或多个用 + 连起来的字符串字面量)、source_file、args(一个数组表达式)、record(一个类型)、scalar(只接受 _)、checked(布尔)。record 和 scalar 互斥,决定 RecordType 是 Given(T)、Scalar 还是默认的 Generated(生成匿名 Record)。source 只收字面量,所以 SQL 必须在编译时就写死,这也是宏能把它拿去 describe 的前提。
source_file 在解析时就读文件。路径经 resolve_path 处理:绝对路径拒绝;只有文件名、不带目录的路径也拒绝;其余拼到 CARGO_MANIFEST_DIR 后面。第二条规则的报错容易误读,src/bin/bad_file.rs 里的 author.sql 就放在 crate 根目录:
let a = sqlx::query_file!("queries/author.sql", 1_i64).fetch_one(&pool).await?;
let b = sqlx::query_file!("author.sql", 1_i64).fetch_one(&pool).await?;
error: paths relative to the current file's directory are not currently supported
--> src/bin/bad_file.rs:6:29
|
6 | let b = sqlx::query_file!("author.sql", 1_i64).fetch_one(&pool).await?;
| ^^^^^^^^^^^^
第一行没有报错,queries/author.sql 相对的是 crate 根目录,不是当前源文件;第二行被拒绝,与文件在哪无关。源码注释说相对当前文件要等 proc_macro::SourceFile::path() 稳定。
读环境:Metadata 和 MacrosEnv
expand_input 第一步是 metadata::try_for_crate:按 CARGO_MANIFEST_DIR 取一份 Metadata,里面是 $CARGO_MANIFEST_DIR/sqlx.toml 解析出的 Config(没有这个文件就用默认值)。第二步 metadata.env() 得到 MacrosEnv 的三个值:
pub struct MacrosEnv {
pub database_url: Option<String>,
pub offline_dir: Option<PathBuf>,
pub offline: Option<bool>,
}
load_env 从 crate 目录开始,沿 manifest_dir.ancestors() 一路往上找 .env,每个找到的文件里只认三个名字:SQLX_OFFLINE_DIR、SQLX_OFFLINE,以及 config.common.database_url_var()(默认 DATABASE_URL,sqlx.toml 可以改名)。最后真实的环境变量优先,.env 里的值只做后备。SQLX_OFFLINE 只有 true(不分大小写)和 1 算真(is_truthy_bool)。
这两份结果都放在 MtimeCache 里。它是一个「值 + 一组文件的修改时间」:get_or_try_init 每次都重新 stat 登记过的文件,任何一个的 mtime 变了(或者原来不存在、现在出现了)就重新计算。登记的文件是 sqlx.toml、找到的每个 .env,没找到 .env 的目录本身也登记,这样新建一个 .env 能被发现。同一个 rustc 进程里,一个 crate 有一百个 query!,sqlx.toml 和 .env 只读一次。
这个缓存管的是宏进程内的重复读取,管不了 cargo 要不要重新编译。cargo 只看源文件,add_path 只有在 sqlx_macros_unstable cfg 下才调 proc_macro::tracked::path 把文件报给编译器。所以只改 sqlx.toml 或 .env、不碰源码时,cargo build 直接说 Finished,宏根本不会再跑(实验在离线模式与 sqlx.toml)。改了数据库 schema 也一样:编译过一次之后执行 alter table author alter column name drop not null 之后再 cargo build,输出只有一行 Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.07s。
选数据源
expand_input 用一个 match 决定数据源:
let data_source = match &*metadata_env {
MacrosEnv {
offline: None | Some(false),
database_url: Some(db_url),
..
}
// Allow `DATABASE_URL=''`
if !db_url.is_empty() => QueryDataSource::live(db_url)?,
MacrosEnv {
offline,
offline_dir,
..
} => {
// Try load the cached query metadata file.
let filename = format!("query-{}.json", hash_string(&input.sql));
// ...
只有「SQLX_OFFLINE 没设或为假」并且「URL 非空」时才是 QueryDataSource 的 Live。其余情况都去找 query-<SQL 的 SHA-256>.json,依次看 SQLX_OFFLINE_DIR、本 crate 的 .sqlx、workspace 根的 .sqlx(workspace 根要跑一次 cargo metadata 才知道;三处写成三个闭包,迭代器是惰性的,前两处找到了就不会跑它)。三处都没有时报错,措辞按 SQLX_OFFLINE 分两种:
| 情况 | 报错 |
|---|---|
SQLX_OFFLINE=true | `SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE` |
| 其余(没有 URL、URL 为空) | set `DATABASE_URL` to use query macros online, or run `cargo sqlx prepare` to update the query cache |
第二条里的 DATABASE_URL 是写死的字符串,sqlx.toml 改了变量名也照样这么说。看到它时怎么排查,在常见坑 database-url-not-set。
DATABASE_URL='' 被当成「没有」(注释 Allow DATABASE_URL='')。环境变量优先于 .env,所以设一个空的 DATABASE_URL,就能让 .env 里写着的 URL 失效、走离线。SQLX_OFFLINE=true 的作用也在这里:URL 存在时强制不连库。反过来,URL 不存在时根本用不着 SQLX_OFFLINE,宏自己就会去找 .sqlx。
选驱动
数据源定了,再在 FOSS_DRIVERS 里挑驱动。每个 QueryDriver 记着数据库名、它认的 URL scheme 和一个 expand 函数指针(expand_with::<DB>)。matches_driver 对 Live 比 URL 的 scheme(Postgres 是 postgres、postgresql),对 Cached 比 JSON 里的 db_name("PostgreSQL")。都对不上时报 no database driver found matching URL scheme ...; the corresponding Cargo feature may need to be enabled:URL 是 Postgres 的,但 sqlx 没开 postgres feature。
驱动是在宏运行时按字符串选的,而生成的代码要用具体类型(sqlx::postgres::Postgres、PgRow),所以 expand_with 是泛型的,FOSS_DRIVERS 在编译 sqlx-macros-core 时就把每个开了 feature 的驱动单态化好了。这也是 sqlx-macros-core 必须直接依赖三个驱动 crate 的原因。
编译时连库:每个 crate 一条连接
expand_with 对 Live 调 DB::describe_blocking(&input.sql, database_url, &config.drivers)。Postgres 的实现由 impl_describe_blocking! 生成:
fn describe_blocking(
query: &str,
database_url: &str,
driver_config: &sqlx_core::config::drivers::Config,
) -> sqlx_core::Result<sqlx_core::describe::Describe<Self>> {
use $crate::database::CachingDescribeBlocking;
// This can't be a provided method because the `static` can't reference `Self`.
static CACHE: CachingDescribeBlocking<$database> = CachingDescribeBlocking::new();
CACHE.describe(query, database_url, driver_config)
}
CachingDescribeBlocking 名字里的「Caching」缓存的是连接,不是 describe 结果:一个 Mutex<HashMap<String, DB::Connection>>,键是 URL。describe 在 block_on 里(tokio 下是一个进程级的 current-thread 运行时)查表:没有就 connect,Postgres 的新连接先设 plan_cache_mode(为什么设,见 describe);有就直接用;然后对这条 SQL 调 Executor::describe。
static 活多久,缓存就活多久。过程宏是一个动态库,被 rustc 加载进自己的进程;一个 crate(一个 lib、一个 bin、一个集成测试)的编译是一个 rustc 进程,进程里所有 query! 共用这些 static,进程退出连接就断。
实验:两个 crate、五个宏
src/bin/proxyd.rs 在 127.0.0.1:15433 起一个打印协议消息的代理(代码同一次查询的全过程),编译时把 DATABASE_URL 指过去。src/bin/many_queries.rs 里有四个宏,第一个和第四个 SQL 一字不差:
let a = sqlx::query!("select id, name from author where id = $1", 1_i64).fetch_one(pool).await?;
let p = sqlx::query!("select id, title from post").fetch_all(pool).await?;
let n = sqlx::query_scalar!("select count(*) from post").fetch_one(pool).await?;
let b = sqlx::query!("select id, name from author where id = $1", 2_i64).fetch_one(pool).await?;
src/bin/other_crate.rs 只有一个宏,SQL 和上面第一个相同。一条命令编译两个 bin:
$ DATABASE_URL='postgres://lab:lab@127.0.0.1:15433/lab?sslmode=disable&options=-c%20search_path%3Dmacros' \
cargo build --offline --bin many_queries --bin other_crate
$ grep -aE 'StartupMessage|Terminate' macros-proxy.log
[conn 1] C->S StartupMessage(v3.0)
[conn 2] C->S StartupMessage(v3.0)
两个 bin 是两个 rustc 进程,各连一次;many_queries 里的四个宏共用 conn 1。两条连接都没有发 Terminate:没人关它们,rustc 退出时 TCP 连接随进程消失。
只编译 many_queries 时,conn 1 的前几行:
[conn 1] C->S StartupMessage(v3.0)
...
[conn 1] S->C Authentication, Authentication, ParameterStatus, ..., BackendKeyData, ReadyForQuery(I)
[conn 1] C->S Query("\n DO $$\n BEGIN\n ...")
[conn 1] S->C CommandComplete("DO"), ReadyForQuery(I)
[conn 1] C->S Parse(sqlx_s_1, "select id, name from author where id = $1"), Describe(S, sqlx_s_1), Sync
[conn 1] S->C ParseComplete, ParameterDescription, RowDescription, ReadyForQuery(I)
[conn 1] C->S Query("SELECT\nattrelid table_oid,\nattrelid::regclass::text table_name,\n...")
...
[conn 1] C->S Parse(sqlx_s_3, "EXPLAIN (VERBOSE, FORMAT JSON) EXECUTE sqlx_s_1(NULL)"), Describe(S, sqlx_s_3), Sync
...
[conn 1] C->S Parse(sqlx_s_4, "select id, title from post"), Describe(S, sqlx_s_4), Sync
...
DO $$ ... $$ 只在建连接时发一次;之后每个宏就是一次完整的 describe(prepare、列来源、attnotnull、EXPLAIN,各步的含义见 describe)。因为是同一条 PgConnection,语句名一路递增(sqlx_s_1、sqlx_s_4……),连接上的语句缓存也一直在。
M2 Max、macOS、rustc 1.99.0、Postgres 16.14,sqlx 0.9.0。many_queries.rs 第四个宏的 SQL 和第一个完全相同。CachingDescribeBlocking 不缓存 describe 结果,可连接上有语句缓存。编译时,第四个宏在 conn 1 上发了几条 Parse、一共几个来回?
describe 出错时,describe 的最后一段决定这条连接还要不要:
match conn
.describe(AssertSqlSafe(query.to_string()).into_sql_str())
.await
{
Ok(describe) => Ok(describe),
Err(e) => {
if matches!(e, sqlx_core::Error::Io(_) | sqlx_core::Error::Protocol(_)) {
cache.remove(database_url);
}
Err(e)
}
}
SQL 写错(Error::Database)时连接照用,下一个宏不必重连;只有 I/O 错误和协议错误才把连接从表里删掉。
这段 cache.remove 是 #4009 加的(首次发布于 0.9.0),修的是 #3939:Losing connection to the database results in query! macros returning errors permanently in rust-analyzer。之前连接一旦进了表就永远留着。PR 说明写道:Once a connection to the database is lost all future macro evaluations will fail. This is fine for normal compilation since it tends to be short but causes issues with rust-analyzer since it keeps the macro binaries loaded for a long time. 改法是 drop the cached connection when it encounters an IO or protocol error,并解释了为什么只挑这两种:In practice these seem to be the errors that show up when the connection is lost and dumping the connection on every error would have unnecessary overhead. 这和上面「static 活多久缓存就活多久」是一回事:对 cargo build 是几秒,对一直开着的 rust-analyzer 是整个编辑会话。
拿到 Describe 以后,expand_with 把它包成 QueryData(Live 时还带上 SQLX_OFFLINE_DIR,用来把它存成 JSON),交给 expand_with_data。从这里起在线和离线走同一段代码,见生成的代码。
动脑筋答案
many_queries.rs 只编译它自己时,代理日志的最后两个来回就是第四个宏:
[conn 1] C->S Bind(portal unnamed, stmt sqlx_s_2), Execute, Close(P, unnamed), Sync
[conn 1] S->C BindComplete, DataRow(列长 1), DataRow(列长 1), CommandComplete("SELECT 2"), CloseComplete, ReadyForQuery(I)
[conn 1] C->S Bind(portal unnamed, stmt sqlx_s_3), Execute, Close(P, unnamed), Sync
[conn 1] S->C BindComplete, DataRow(列长 450), CommandComplete("EXPLAIN"), CloseComplete, ReadyForQuery(I)
0 条 Parse,2 个来回。describe 的四步里,两步被连接上的缓存省掉:
- 用户 SQL 的
Parse+Describe:get_or_prepare在语句缓存里命中sqlx_s_1,直接返回上次存下的元数据,省掉。 - 列来源的那条简单查询:缓存的元数据里已经带着列来源,也省掉。
attnotnull查询:SQL 只取决于列数,两列的版本已经是sqlx_s_2,只剩Bind+Execute。EXPLAIN (VERBOSE, FORMAT JSON) EXECUTE sqlx_s_1(NULL):同一条语句名,同一个sqlx_s_3,也只剩Bind+Execute。
第一个宏用了 6 个来回,第四个 2 个。可空性推断那两步每次都真的执行,因为宏这一层没有按 SQL 记住结果;第三个宏 select count(*) from post 只有一列,attnotnull 查询换成了一列的版本(sqlx_s_7),要重新 Parse。
小结
- 12 个
query*!都是macro_rules!,展开成同一个过程宏expand_query,差别只在record、scalar、source_file、checked这几个选项。query_file!的路径相对 crate 根目录,而且必须带目录。 expand_input读$CARGO_MANIFEST_DIR/sqlx.toml和沿目录往上的.env,环境变量优先;MtimeCache只省掉进程内的重复读取,cargo 不会因为这些文件变了而重新编译。- 「URL 非空且
SQLX_OFFLINE不为真」才连库,否则按SQLX_OFFLINE_DIR、crate 的.sqlx、workspace 的.sqlx找query-<sha256>.json;驱动按 URL scheme 或 JSON 里的db_name选。 CachingDescribeBlocking缓存的是连接:按 URL 一条,活在 rustc 进程的static里,所以每个 crate 一条;describe 结果不缓存,重复的 SQL 靠连接上的语句缓存省掉Parse,可空性的两步照跑。- I/O 或协议错误时丢掉缓存的连接(#4009,0.9.0),为的是 rust-analyzer 这种长寿的宏进程。