SQLx 源码深度解析

第11章 query! 编译期校验宏:在线连接与离线缓存的分裂

作者 杨艺韬 · 12,225 字 · 发布于 · 更新于

query! 把编译期校验接到了真实数据库上——要么连一条在线连接,要么读 .sqlx/ 里的离线缓存。这条分裂贯穿全章。

本章要点

  • query! 宏家族有三个成员:query!(输出匿名 record)、query_as!(输出用户指定 struct)、query_scalar!(输出单值)。入口全部在 sqlx-macros/src/lib.rs,委托给 sqlx-macros-core::query::expand_input。
  • 两种数据源(sqlx-macros-core/src/query/mod.rs:45-51):QueryDataSource::Live {database_url}(连真实 DB)和 QueryDataSource::Cached(DynQueryData)(从 .sqlx/ 读 JSON)。metadata 决定用哪条——DATABASE_URL 在且 SQLX_OFFLINE != true 走在线、否则走离线。
  • 在线路径 DB::describe_blocking(sql, url)—— sqlx-macros-core::database::DatabaseExt::describe_blocking 在 Postgres / MySQL 上用一个 block_on 把异步 describe 跑完(SQLite 直接同步打开数据库做 describe),返回 Describe<DB>(参数类型 + 列类型 + 可空性)。
  • 离线缓存文件 .sqlx/query-<hash>.json——每条 query 一个文件,文件名是 SQL 字符串的哈希。由 cargo sqlx prepare 生成、提交到 git、CI 下 SQLX_OFFLINE=true 读取。
  • QueryDriver(sqlx-macros-core/src/query/mod.rs:27-44,QueryDriver::new 在 33-44)是一个 const fn 可构造的 vtable—— db_name + url_schemes + expand: fn(...) -> crate::Result<TokenStream>。FOSS_DRIVERS 是三家驱动的静态数组(第 2 章 §2.3.1)。
  • 输出构造(sqlx-macros-core/src/query/output.rs:127-192 的 quote_query_as)用 try_get_unchecked::<PgType, _>(idx) 生成带具体类型的取值代码——比运行时 try_get 少一次 compatible 检查。
  • Nullability 推断(Describe::nullable)在 Postgres 上分两步:先按 relation_id / relation_attribute_no 查 pg_catalog.pg_attribute 的 attnotnull,再用 EXPLAIN (VERBOSE, FORMAT JSON) 的查询计划打补丁——外连接内侧的 NOT NULL 列因此被提升成 Option<T>(sqlx-postgres/src/connection/describe.rs:424-510)。MySQL 只有协议里的 NOT_NULL flag,SQLite 走 VDBE EXPLAIN 推断 + sqlite3_table_column_metadata。
  • 类型 override 语法:as "x: MyType"(强制用 MyType 解码)、as "x!: T"(强制非空)、as "x?: T"(强制可空)——覆盖宏的自动推断。

11.1 问题引入:把真实 DB schema 带进类型系统

考虑两条看似等价的代码:

版本 A:query()

let user = sqlx::query("SELECT id, name, email FROM users WHERE id = $1")
    .bind(42_i32)
    .fetch_one(&pool).await?;
let id: i32 = user.try_get("id")?;
let name: String = user.try_get("name")?;
let email: String = user.try_get("email")?;

版本 B:query!

let user = sqlx::query!(
    "SELECT id, name, email FROM users WHERE id = $1",
    42_i32
).fetch_one(&pool).await?;
let id: i32 = user.id;
let name: String = user.name;
let email: String = user.email;  // 编译错!如果 email 列可空

版本 A 的运行时错误:SQL 拼错了(列名 emial)——cargo check 过、第一次执行才由数据库报错(Error::Database)。类型对不上(用 i64 解码 INT4)——运行时 ColumnDecode。

版本 B 的编译错误:SQL 拼错了——cargo check 就失败,错误消息 column "emial" does not exist。类型对不上——同样编译失败。而且,如果 email 在 DB schema 里是 nullable 但你声明 String——编译期提示类型应该是 Option<String>。

版本 B 的魔力来自 query! 宏在编译期就连数据库做了一次 DESCRIBE——它知道 users 表的 email 列可空,强制要求 Rust 侧用 Option<String>。

这是 Rust 生态里几乎独一无二的能力——proc macro 触达外部世界。绝大多数 Rust 库的 proc macro 都是"纯输入"——只读取用户代码的 TokenStream、不做网络 I/O。sqlx 的 query! 打破这条界限——在 compile 时发 SQL 到 DB、拿回 schema、用这信息生成 Rust 代码。

这章拆开这条"编译期触达外部"的完整机制——从宏入口、到数据源选择、到离线缓存、到类型生成。理解这章后你会知道为什么 sqlx 的这个特性又强大又麻烦——强大在类型安全、麻烦在"编译时需要数据库 / 需要维护 .sqlx/ 缓存"的工程复杂性。

11.2 query! 宏家族的三个成员

sqlx 顶层对外导出三个宏。真正的 #[proc_macro] 只有一个,在 sqlx-macros/src/lib.rs:7-23:

#[cfg(feature = "macros")]
#[proc_macro]
pub fn expand_query(input: TokenStream) -> TokenStream {
    let input = syn::parse_macro_input!(input as query::QueryMacroInput);

    match query::expand_input(input, FOSS_DRIVERS) {
        Ok(ts) => ts.into(),
        Err(e) => {
            if let Some(parse_err) = e.downcast_ref::<syn::Error>() {
                parse_err.to_compile_error().into()
            } else {
                let msg = e.to_string();
                quote!(::std::compile_error!(#msg)).into()
            }
        }
    }
}

用户直接写的 query! / query_as! / query_scalar! 是 facade crate 里的 macro_rules!,定义在 sqlx/src/macros/mod.rs(分别在 314、571、669 行附近),全都转发到上面这个 expand_query:

// sqlx/src/macros/mod.rs 节选
macro_rules! query (
    ($query:expr) => ({
        $crate::sqlx_macros::expand_query!(source = $query)
    });
    ($query:expr, $($args:tt)*) => ({
        $crate::sqlx_macros::expand_query!(source = $query, args = [$($args)*])
    })
);

macro_rules! query_as (
    ($out_struct:path, $query:expr) => ({
        $crate::sqlx_macros::expand_query!(record = $out_struct, source = $query)
    });
    ($out_struct:path, $query:expr, $($args:tt)*) => ({
        $crate::sqlx_macros::expand_query!(record = $out_struct, source = $query, args = [$($args)*])
    })
);

macro_rules! query_scalar (
    ($query:expr) => (
        $crate::sqlx_macros::expand_query!(scalar = _, source = $query)
    );
    ($query:expr, $($args:tt)*) => (
        $crate::sqlx_macros::expand_query!(scalar = _, source = $query, args = [$($args)*])
    )
);

注意参数位置用的是 $($args:tt)* 而不是 $($args:expr),*——源码里的注释解释了原因:1.45 时代 expr 片段的 hygiene 会被改写,这是留下来的权宜之计。

三个宏都展开成同一个 expand_query 调用,只是传入的"模式"不同——record = UserType / scalar = _ / 不指定(默认匿名 record)。

QueryMacroInput(sqlx-macros-core/src/query/input.rs:10-22)持有解析后的所有字段:

pub struct QueryMacroInput {
    pub(super) sql: String,
    pub(super) src_span: Span,
    pub(super) record_type: RecordType,
    pub(super) arg_exprs: Vec<Expr>,
    pub(super) checked: bool,
    pub(super) file_path: Option<String>,
}

pub enum RecordType {
    Given(Type),    // query_as! 路径
    Scalar,         // query_scalar! 路径
    Generated,      // query! 路径,宏生成匿名 record
}

三个宏共享同一个 input 类型、同一个 expand 入口——差异只在 record_type 这个 enum。这种"一个入口、多种模式"的设计让三个宏共用几乎全部实现代码(RecordType 定义在 sqlx-macros-core/src/query/input.rs:29-33)。

11.2.1 query_file! 变体

sqlx-macros-core/src/query/input.rs:60-62 还支持 source_file 语法:

} else if key == "source_file" {
    let lit_str = input.parse::<LitStr>()?;
    query_src = Some((QuerySrc::File(lit_str.value()), lit_str.span()));
}

让 SQL 从外部文件读:

let user = sqlx::query_file!("queries/get_user.sql", 42)
    .fetch_one(&pool).await?;

queries/get_user.sql 是独立的 .sql 文件(路径相对 crate 根,即 CARGO_MANIFEST_DIR)——SQL 工程师可以在不接触 Rust 代码的情况下改 SQL。宏展开时用 fs::read_to_string 把它读进来做 describe(sqlx-macros-core/src/query/input.rs:142-155),query_file! / query_file_as! 生成的代码里再用 include_str! 引用这个文件,让编译器知道 SQL 的来处(sqlx-macros-core/src/query/output.rs:175-181)。对大型项目里"把 SQL 当一等资源维护"的团队友好。

11.3 宏展开的总入口 expand_input

sqlx-macros-core/src/query/mod.rs:137-211 的 expand_input 是整条宏路径的指挥中心:

pub fn expand_input<'a>(
    input: QueryMacroInput,
    drivers: impl IntoIterator<Item = &'a QueryDriver>,
) -> crate::Result<TokenStream> {
    let manifest_dir = env("CARGO_MANIFEST_DIR").expect("`CARGO_MANIFEST_DIR` must be set");
    // ... 获取 metadata(简化:省略加锁;下面的 let-else 报错也改写成了 ok_or_else)...

    let data_source = match &metadata {
        Metadata { offline: false, database_url: Some(db_url), .. } =>
            QueryDataSource::live(db_url)?,
        Metadata { offline, .. } => {
            let filename = format!("query-{}.json", hash_string(&input.sql));
            let dirs = [
                |meta: &Metadata| meta.offline_dir.as_deref().map(PathBuf::from),
                |meta: &Metadata| Some(meta.manifest_dir.join(".sqlx")),
                |meta: &Metadata| Some(meta.workspace_root().join(".sqlx")),
            ];
            // 查三个路径,找到一个存在的用它
            let data_file_path = dirs.iter().filter_map(|path| path(metadata))
                .map(|path| path.join(&filename)).find(|path| path.exists())
                .ok_or_else(|| "...")?;
            QueryDataSource::Cached(DynQueryData::from_data_file(&data_file_path, &input.sql)?)
        }
    };

    for driver in drivers {
        if data_source.matches_driver(driver) {
            return (driver.expand)(input, data_source);
        }
    }
    // ... 错误处理 ...
}

五步:

  1. 读 CARGO_MANIFEST_DIR 环境变量(Cargo 编译时总会设)。
  2. 根据 SQLX_OFFLINE / DATABASE_URL 决定 data_source 是 Live 还是 Cached。
  3. Cached 模式下查三个可能的 .sqlx/ 路径(.env 里的 SQLX_OFFLINE_DIR > crate 本地 > workspace 根)。
  4. 用 data_source.matches_driver(driver) 找对应驱动(按 URL scheme 或 db_name)。
  5. 调 driver.expand(input, data_source) 生成 TokenStream。

这条路径把"宏展开的前置工作"(找 driver、加载 describe 数据)从"实际代码生成"(driver.expand 内部的 quote_query_as 等)清晰分开。前者在 mod.rs、后者在 output.rs。

11.3.1 QueryDriver 的 vtable 设计

sqlx-macros-core/src/query/mod.rs:27-44:

#[derive(Copy, Clone)]
pub struct QueryDriver {
    db_name: &'static str,
    url_schemes: &'static [&'static str],
    expand: fn(QueryMacroInput, QueryDataSource) -> crate::Result<TokenStream>,
}

impl QueryDriver {
    pub const fn new<DB: DatabaseExt>() -> Self
    where Describe<DB>: serde::Serialize + serde::de::DeserializeOwned,
    {
        QueryDriver {
            db_name: DB::NAME,
            url_schemes: DB::URL_SCHEMES,
            expand: expand_with::<DB>,
        }
    }
}

QueryDriver 是一个 const fn 可构造的 vtable——db_name、url_schemes、expand 三字段是驱动特定的但都能在 const context 拿到。expand_with::<DB> 是泛型函数,monomorphize 后产出具体的 expand_with::<Postgres> / expand_with::<MySql> / expand_with::<Sqlite> 三个函数指针,存进 QueryDriver 常量。

这条路径和第 2 章 §2.6.5 讨论过的 AnyDriver 如出一辙——用 const fn + 函数指针做类型擦除,在运行时用 url_schemes.contains(&scheme) 做分发。区别是 QueryDriver 工作在编译期(proc macro 执行时)而不是运行时。

FOSS_DRIVERS 这个常量在 sqlx-macros-core/src/lib.rs:45-52(第 2 章 §2.3.1 讨论过):

pub const FOSS_DRIVERS: &[QueryDriver] = &[
    #[cfg(feature = "mysql")]
    QueryDriver::new::<sqlx_mysql::MySql>(),
    #[cfg(feature = "postgres")]
    QueryDriver::new::<sqlx_postgres::Postgres>(),
    #[cfg(feature = "_sqlite")]
    QueryDriver::new::<sqlx_sqlite::Sqlite>(),
];

这是 query! 宏能识别的驱动白名单——编译期闭合。DatabaseExt 所在的 database 模块在 sqlx-macros-core 里是私有的(sqlx-macros-core/src/lib.rs:30),外部无法为自己的数据库实现它;第三方驱动作者(例如 ClickHouse)只能 fork sqlx-macros-core / sqlx-macros 维护平行版本。

11.4 在线模式:DATABASE_URL 驱动的 DESCRIBE

expand_with::<DB>(sqlx-macros-core/src/query/mod.rs:213-229)处理 Live 数据源:

fn expand_with<DB: DatabaseExt>(
    input: QueryMacroInput,
    data_source: QueryDataSource,
) -> crate::Result<TokenStream>
where
    Describe<DB>: DescribeExt,
{
    let (query_data, offline): (QueryData<DB>, bool) = match data_source {
        QueryDataSource::Cached(dyn_data) =>
            (QueryData::from_dyn_data(dyn_data)?, true),
        QueryDataSource::Live { database_url, .. } => {
            let describe = DB::describe_blocking(&input.sql, database_url)?;
            (QueryData::from_describe(&input.sql, describe), false)
        }
    };
    expand_with_data(input, query_data, offline)
}

Live 分支调 DB::describe_blocking(&input.sql, database_url)——这是 DatabaseExt trait(sqlx-macros-core/src/database/mod.rs:16-28)的方法。

三家驱动的 impl 不写在驱动 crate 里,而是由 impl_database_ext! / impl_describe_blocking! 宏统一生成,默认实现走一个进程内静态缓存(sqlx-macros-core/src/database/impls.rs:17-27):

        fn describe_blocking(
            query: &str,
            database_url: &str,
        ) -> 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)
        }

MySQL 和 Postgres 用这条默认路径;SQLite 在同一个文件里被显式指定成 describe-blocking: sqlx_sqlite::describe_blocking——注释说明了原因:"proc-macro 不受益于 async,直接做一次 describe 调用,顺便保证无论出错与否数据库都会关闭"。

以 Postgres 为例,describe_blocking 内部做的事:

  1. 在 crate::block_on 里从缓存 HashMap 取或新建一个 PgConnection(block_on 定义在 sqlx-macros-core/src/lib.rs:54-82,启用 tokio 时是一个惰性创建、全程复用的 current-thread runtime)。
  2. 发 Parse + Describe::Statement 消息。
  3. 接收 RowDescription(列信息)和 ParameterDescription(参数信息)。
  4. 再查 pg_catalog.pg_attribute 并跑一次 EXPLAIN (VERBOSE, FORMAT JSON) 推断可空性(§11.7)。
  5. 返回 Describe<Postgres>——包含 columns / parameters / nullable 三个字段。

这一整套发生在用户 cargo check 运行 proc macro 的时刻——rustc 加载 sqlx-macros 动态库、调用 expand_query、expand_query 连数据库、拿回 schema、生成 TokenStream。所有这些发生在 cargo check 完成之前。

如果数据库连不上(DATABASE_URL 指向不存在的 host)或 SQL 写错(列不存在)——cargo check 编译失败,错误信息是宏的 compile_error! 输出的文本。

11.4.1 连接缓存避免每条 query 新建连接

一个 crate 里可能有几百条 query! 宏——每条都 describe 一次。如果每条都新建 PgConnection(三次握手 + 认证),编译时间会炸。

sqlx 的解法:进程级连接缓存(sqlx-macros-core/src/database/mod.rs:32-64 的 CachingDescribeBlocking):

pub struct CachingDescribeBlocking<DB: DatabaseExt> {
    connections: Lazy<Mutex<HashMap<String, DB::Connection>>>,
}

#[allow(dead_code)]
impl<DB: DatabaseExt> CachingDescribeBlocking<DB> {
    pub const fn new() -> Self {
        CachingDescribeBlocking {
            connections: Lazy::new(|| Mutex::new(HashMap::new())),
        }
    }

    pub fn describe(&self, query: &str, database_url: &str) -> sqlx_core::Result<Describe<DB>>
    where
        for<'a> &'a mut DB::Connection: Executor<'a, Database = DB>,
    {
        let mut cache = self
            .connections
            .lock()
            .expect("previous panic in describe call");

        crate::block_on(async {
            let conn = match cache.entry(database_url.to_string()) {
                hash_map::Entry::Occupied(hit) => hit.into_mut(),
                hash_map::Entry::Vacant(miss) => {
                    miss.insert(DB::Connection::connect(database_url).await?)
                }
            };

            conn.describe(query).await
        })
    }
}

每个 DATABASE_URL 只开一个连接——proc macro 动态库加载在 rustc 进程里,同一个 rustc 进程(即一次编译中的一个 crate)内的所有 query! 复用它;Mutex 让这个 static 满足 Sync,也保证同一时刻只有一次 describe 在用连接。rustc 进程结束,connections HashMap 随之丢弃——下次编译重新建。SQLite 不走这层缓存(见上文),每次 describe 都新开、用完即关。

省掉的正是"每条 query 一次 TCP 握手 + 认证"这笔固定开销——query! 数量越多,这层缓存越关键。

11.5 离线模式:.sqlx/ 缓存文件

离线模式是 query! 宏CI 友好的关键。expand_input 的 Cached 分支(sqlx-macros-core/src/query/mod.rs:163-186)查三个可能的 .sqlx/ 路径:

let dirs = [
    |meta: &Metadata| meta.offline_dir.as_deref().map(PathBuf::from),  // 1. .env 里的 SQLX_OFFLINE_DIR
    |meta: &Metadata| Some(meta.manifest_dir.join(".sqlx")),             // 2. crate 本地 .sqlx/
    |meta: &Metadata| Some(meta.workspace_root().join(".sqlx")),         // 3. workspace 根 .sqlx/
];

按优先级查——.env 指定 > crate 本地 > workspace 根。找到第一个存在的 .sqlx/query-<hash>.json 文件。

注意第一项的 offline_dir 只来自 load_dot_env 读到的 .env 文件(sqlx-macros-core/src/query/mod.rs:115-135、:389-436);进程环境变量里的 SQLX_OFFLINE_DIR 只在写缓存时生效(§11.5.2),读缓存时不看。实测只在环境变量里设 SQLX_OFFLINE_DIR 并开 SQLX_OFFLINE=true,照样报 no cached data;把同一行写进 .env 就能编译通过。

文件名里的 <hash> 是SQL 字符串的哈希:

let filename = format!("query-{}.json", hash_string(&input.sql));

hash_string(sqlx-macros-core/src/query/data.rs:193-198)是 SHA-256 的十六进制串——hex::encode(Sha256::digest(query.as_bytes()));源码注释说选 sha2 是因为"它本来就在 MySQL 和 Postgres 的依赖树里"。SQL 变一个字符,文件名就变——所以改 SQL 而不重跑 prepare,离线编译只会报"没有这条 query 的缓存",不会拿旧数据蒙混过关。

11.5.1 .sqlx/query-*.json 文件内容

每个 JSON 文件装一个 DynQueryData(sqlx-macros-core/src/query/data.rs:73-78)序列化结果:

{
  "db_name": "PostgreSQL",
  "query": "SELECT id, name, email FROM users WHERE id = $1",
  "describe": {
    "columns": [
      {"ordinal": 0, "name": "id", "type_info": "Int4"},
      {"ordinal": 1, "name": "name", "type_info": "Text"},
      {"ordinal": 2, "name": "email", "type_info": "Text"}
    ],
    "parameters": {"Left": ["Int4"]},
    "nullable": [false, false, true]
  },
  "hash": "…SHA-256…"
}

四个字段一一对应 QueryData 的 db_name / query / describe / hash(db_name 就是 Database::NAME,Postgres 是 "PostgreSQL")。describe 内部是 Describe<DB> 的 serde 表示——columns / parameters / nullable 三个字段;PgColumn 的 relation_id 和 relation_attribute_no 标了 #[serde(skip)],所以不会出现在文件里(可空性已经在 nullable 里定下来了)。总之:离线宏展开需要的所有数据都在这个文件里。

11.5.2 cargo sqlx prepare 工作流

生成 .sqlx/ 的工具是 sqlx-cli 的 prepare 子命令:

# 本地开发环境:有 DATABASE_URL
DATABASE_URL=postgres://... cargo sqlx prepare

# 生成 / 更新 .sqlx/ 下所有 query!* 的 JSON 文件
# 然后 git add .sqlx && git commit

工作原理(sqlx-cli/src/prepare.rs 的 run_prepare_step):

  1. 先建好 / 清空缓存目录(只删 query-*.json,避免误删用户文件),再设置一次"最小重编译"让用到 sqlx-macros 的 crate 重新展开宏。
  2. 起一个子进程跑 cargo check,带上三个环境变量:SQLX_TMP(临时目录)、SQLX_OFFLINE=false(强制走在线 describe)、SQLX_OFFLINE_DIR=<.sqlx 目录>;有 --database-url 时再补一个 DATABASE_URL。
  3. 宏这边照常生成 TokenStream,只是在 expand_with_data 结尾多做一件事(sqlx-macros-core/src/query/mod.rs:341-371):当本次构建是在线的(!offline)且 SQLX_OFFLINE_DIR 有值时,把 QueryData 用 save_in 写成 .sqlx/query-<hash>.json(sqlx-macros-core/src/query/data.rs:153-190)。
  4. 结果:.sqlx/ 下一条 query 一个 JSON 文件。

注意这里没有什么"prepare 专用模式"——SQLX_OFFLINE_DIR 就是开关,你手动设它跑普通 cargo check 也会落盘。

CI 下:

# GitHub Actions 片段
- name: Build
  env:
    SQLX_OFFLINE: true
  run: cargo build

SQLX_OFFLINE=true 强制走 Cached 路径——读 .sqlx/ JSON 文件、不连数据库。只要 JSON 文件和 SQL 保持同步,CI 不需要 DB。

11.5.3 .sqlx/ 的维护工作流

标准流程:

  1. 开发者改了 SQL(例如新加一个 query!)。
  2. 本地 DATABASE_URL=... cargo sqlx prepare——生成 / 更新 JSON。
  3. git add .sqlx/ && git commit——JSON 和 SQL 改动一起提交。
  4. CI 在 SQLX_OFFLINE=true 下读 JSON 编译——不需要 DB。

失败路径:

  • 开发者忘了 prepare——CI 编译失败,错误消息 "SQLX_OFFLINE=true but there is no cached data for this query"。
  • JSON 过时(SQL 改了但 prepare 没跑)——文件名是 SQL 的 SHA-256,改过的 SQL 找不到对应文件,报的还是上面那条"no cached data"。
  • 文件内容和 SQL 对不上(哈希撞了,或手改过 JSON)——DynQueryData::from_data_file 里 query != dyn_data.query 会直接报 "hash collision for saved query data"。

常见工具:一些项目有 git pre-commit hook 自动跑 prepare;CI 加一步 cargo sqlx prepare --check(检测 .sqlx/ 是否和 SQL 同步)。

11.6 输出构造:columns_to_rust 和 quote_query_as

expand_with_data 里最关键的一步是把 Describe 翻译成 TokenStream。这由 sqlx-macros-core/src/query/output.rs 里的 quote_query_as 做(第 1 章 §1.5.1 已经分析过,这里补充细节)。

11.6.1 columns_to_rust:Describe → RustColumn 列表

sqlx-macros-core/src/query/output.rs:79-83 的 columns_to_rust 本身只有四行——逐列委托给 column_to_rust:

pub fn columns_to_rust<DB: DatabaseExt>(describe: &Describe<DB>) -> crate::Result<Vec<RustColumn>> {
    (0..describe.columns().len())
        .map(|i| column_to_rust(describe, i))
        .collect::<crate::Result<Vec<_>>>()
}

真正的翻译在 column_to_rust(sqlx-macros-core/src/query/output.rs:85-125)——列名先被当成一小段 DSL 解析(ColumnDecl::parse,因为 as "x!: T" 这种 override 是写在列别名里的),再按"可空性 × 类型"两维决定最终 Rust 类型:

fn column_to_rust<DB: DatabaseExt>(describe: &Describe<DB>, i: usize) -> crate::Result<RustColumn> {
    let column = &describe.columns()[i];

    let decl = ColumnDecl::parse(column.name())
        .map_err(|e| format!("column name {:?} is invalid: {}", column.name(), e))?;

    let ColumnOverride { nullability, type_ } = decl.r#override;

    let nullable = match nullability {
        ColumnNullabilityOverride::NonNull => false,
        ColumnNullabilityOverride::Nullable => true,
        ColumnNullabilityOverride::None => describe.nullable(i).unwrap_or(true),
    };
    let type_ = match (type_, nullable) {
        (ColumnTypeOverride::Exact(type_), false) => ColumnType::Exact(type_.to_token_stream()),
        (ColumnTypeOverride::Exact(type_), true) =>
            ColumnType::Exact(quote! { ::std::option::Option<#type_> }),

        (ColumnTypeOverride::Wildcard, false) => ColumnType::Wildcard,
        (ColumnTypeOverride::Wildcard, true) => ColumnType::OptWildcard,

        (ColumnTypeOverride::None, _) => {
            let type_ = get_column_type::<DB>(i, column);
            if !nullable { ColumnType::Exact(type_) }
            else { ColumnType::Exact(quote! { ::std::option::Option<#type_> }) }
        }
    };

    Ok(RustColumn {
        // 变量名加前缀,避开 https://github.com/launchbadge/sqlx/issues/1322
        var_name: quote::format_ident!("sqlx_query_as_{}", decl.ident),
        ident: decl.ident,
        type_,
    })
}

几条要点:

  • 可空性有三个来源——用户写 ! / 写 ? / 都没写时才落到 describe.nullable(i);连 describe 都没给答案(None)就保守当作可空(.unwrap_or(true))。
  • 类型也有三种——写死类型(Exact)、写通配(Wildcard,即 as "x: _")、没写(走 get_column_type 推断)。可空时统一包一层 ::std::option::Option<...>。
  • 推断失败不是返回 Err——get_column_type(sqlx-macros-core/src/query/output.rs:231-262)在映射表查不到时返回一段 compile_error! 的 TokenStream,并且会区分两种消息:如果这个类型属于某个未启用的 feature(get_feature_gate),提示"需要开启 SQLx feature xxx";否则提示"没有内建映射,可能需要类型 override"。

DB::return_type_for_id 来自 TypeChecking trait(第 5 章 §5.13 讨论过)。它不是手写的 OID 表,而是由 sqlx-core/src/type_checking.rs 的 impl_type_checking! 宏从一张 Rust 类型清单生成出来的——驱动那边只维护清单,例如 sqlx-postgres/src/type_checking.rs:

impl_type_checking!(
    Postgres {
        (),
        bool,
        String | &str,
        i8, i16, i32, i64,
        f32, f64,
        Vec<u8> | &[u8],
        sqlx::postgres::types::Oid,
        sqlx::postgres::types::PgInterval,
        // ... 以及 feature gate 后的 chrono / uuid / json 等
    },
    ParamChecking::Strong,
    feature-types: info => info.__type_feature_gate(),
);

宏展开出来的 return_type_for_id 是这样反查的——先按 Type::type_info() == *info 精确匹配、再按 Type::compatible(info) 兼容匹配,命中就返回 stringify!($ty),也就是那个 Rust 类型的字面文本:

fn return_type_for_id(info: &Self::TypeInfo) -> Option<&'static str> {
    match () {
        _ if <$ty as Type<$database>>::type_info() == *info => Some(stringify!($ty)),
        // ...(先走全部精确匹配,再走全部 compatible 匹配)
        _ => None
    }
}

这张清单就是整个"编译期类型安全"的真理源——它告诉宏"Postgres 的 Int4 列应当变成 Rust 的 i32",而且答案由运行时那套 Type impl 直接推出来,不会和运行时解码规则脱节。加一个新类型要动两处:Type / Encode / Decode 的 impl,加上这张清单里的一行。

11.6.2 quote_query_as:拼装 TokenStream

拿到 Vec<RustColumn> 后,quote_query_as(第 1 章 §1.5.1 贴过)把每列包成 try_get_unchecked 调用,再组合成 struct literal:

quote! {
    ::sqlx::__query_with_result::<#db_path, _>(#sql, #bind_args).try_map(|row: #row_path| {
        use ::sqlx::Row as _;
        #(#instantiations)*
        ::std::result::Result::Ok(#out_ty { #(#ident: #var_name),* })
    })
}

每个 #instantiation 长这样(简化):

let sqlx_query_as_id = row.try_get_unchecked::<i32, _>(0usize)?.into();

这就是编译期生成的业务代码。运行时直接跑这段——没有按列名查索引这一步(直接按 usize 位置取)、没有 compatible 类型检查(编译期已经验证过,所以用的是 try_get_unchecked)。省掉的是每行每列的两次小检查,不是数量级的差别,但它是"编译期验证过"这件事在运行时的直接兑现。

11.7 Nullability 推断:三家来源各不相同

Describe<DB> 里的 nullable: Vec<Option<bool>> 字段告诉宏每列是否可空——但数据来源因 DB 而异:

  • Postgres(get_nullable_for_columns,sqlx-postgres/src/connection/describe.rs:424-510):分两步。第一步用 PgColumn 的 relation_id 和 relation_attribute_no(第 7 章 §7.17 讨论过)——它们标识"这列来自哪张表的第几列"——拼一条 SELECT NOT attnotnull FROM (...) AS col LEFT JOIN pg_catalog.pg_attribute ... 批量查回 NOT NULL 标志。表达式列(SELECT COUNT(*)、a + b)没有 relation_id,这一步查不到,保守当作可空。
  • MySQL(sqlx-mysql/src/connection/executor.rs:349-355):只有协议里的 ColumnFlags::NOT_NULL 一个来源——nullable = !flags.contains(NOT_NULL),没有额外的计划分析。
  • SQLite(sqlx-sqlite/src/connection/describe.rs):两个来源——解析 EXPLAIN 出来的 VDBE 指令序列做推断,以及 sqlite3_table_column_metadata(经 column_nullable)拿表列的 NOT NULL;EXPLAIN 给出结论时优先采用,否则才用表列元数据(exp_nullable.or(col_nullable))。所以 SQLite 并非"完全没有可空性信息":实测 LEFT JOIN 内侧的 NOT NULL 列推断为 Option<String>,COALESCE(email, 'x') 推断为 String。

Postgres 的第二步是查询计划分析(nullables_from_explain,sqlx-postgres/src/connection/describe.rs:517-562):

SELECT u.id, u.email, p.title
FROM users u LEFT JOIN posts p ON u.id = p.author_id

posts.title 列本身 NOT NULL,但 LEFT JOIN 让它变 nullable——如果 user 没有 post,title 是 NULL。第一步查 pg_attribute 只会说"NOT NULL",答案是错的。于是 sqlx 对已 prepare 的语句再发一条 EXPLAIN (VERBOSE, FORMAT JSON) EXECUTE <stmt>,用 visit_plan 遍历返回的计划树——Full Join 的所有输出、以及外连接内侧那一半的输出统统标成可空,然后覆盖第一步的结论。结果是宏生成 title: Option<String>——正确反映 JOIN 语义。

注意这一步依赖服务端支持 EXPLAIN:is_explain_available 从握手时的 ParameterStatus 识别出 CockroachDB、Materialize、QuestDB 时就跳过这一步(最早为 CockroachDB 加上,见 issue #1248),只保留第一步的结果。"系统表 + 计划树"两步合用是 Postgres 驱动的做法;MySQL 完全没有计划分析,SQLite 则是在 VDBE 字节码层面做推断。

11.8 类型 override 语法

query! 宏的 SQL 里可以写列别名带类型注解:

query!(
    r#"SELECT id, name as "name: String", created as "created!: OffsetDateTime" FROM users"#,
)

注意 override 写在双引号包起来的列别名里(SQL 标准的 quoted identifier)——Postgres 里单引号是字符串字面量,写成 as 'name: String' 是另一回事。所以 Rust 侧通常用 r#"..."# 原始字符串,省得转义。

三种 override 语法:

11.8.1 as "x: T":强制用 T 解码

SELECT my_json as "my_json: serde_json::Value"

告诉宏 "这列用 serde_json::Value 解码"——覆盖默认推断。适用场景:自定义类型(自己实现 Type / Decode 的类型,如 #[derive(sqlx::Type)] 的枚举)、派生类型(#[sqlx(transparent)] 的新类型)。

11.8.2 as "x!: T":强制非空

SELECT COALESCE(last_login, NOW()) as "last_seen!: OffsetDateTime"

宏默认把 COALESCE(...) 标为 nullable(保守);! 强制非空。适用 SQL 语义保证非空但 sqlx 推断错的情况。

11.8.3 as "x?: T":强制可空

SELECT name as "name?: String"

宏默认推断非空,? 强制可空。极少见——大多数时候你要相反方向。

三种 override 语法的优先级:用户 override > 自动推断。sqlx 完全信任用户——即使推断不同,override 会覆盖。

11.8.4 scalar = _ 和 record = T

query! 本身生成匿名 record(struct 字段就是列名);query_as! 和 query_scalar! 通过宏入口的 key-value 参数走:

// query_as!: record = User
sqlx::query_as!(User, "SELECT id, name FROM users WHERE id = $1", 42)

// query_scalar!: scalar = _
sqlx::query_scalar!("SELECT COUNT(*) FROM users")

sqlx-macros-core/src/query/input.rs:66-81 的 parser 识别这两个 key 并 set RecordType::Given(User) 或 RecordType::Scalar——两者互斥,同时给会报 "colliding scalarorrecord key";scalar 目前只接受 scalar = _ 这一种写法(源码注释说,与其做 query_as_scalar!,不如直接在 SQL 里 override 列类型)。expand_with_data 根据 record_type 生成不同的输出——分别是用户 struct 的 try_map、__query_scalar_with_result(QueryScalar)、匿名 Record 加 try_map。

11.9 query!_unchecked 变体

sqlx 还给每个宏各配了一个 _unchecked 变体——query_unchecked!、query_as_unchecked!、query_scalar_unchecked!,以及 query_file_unchecked! 等文件版本。源码文档的说法是"不检查输入和输出类型,但仍让数据库解析这条 SQL":

sqlx::query_unchecked!("SELECT id FROM users WHERE id = $1", some_id)

典型用例是列或参数的类型不在 TypeChecking 的类型清单里、你想自己保证一致。unchecked 省掉的是两处类型核对:参数一侧,args.rs 在 !input.checked 时不生成参数类型检查代码(sqlx-macros-core/src/query/args.rs:41-44),参数个数照查;输出一侧,取值时不写类型,由接收它的字段类型推断,错了就在运行时解码失败。

差别落在 quote_query_as 里的 match (input.checked, type_)(sqlx-macros-core/src/query/output.rs:140-165)——四个分支:

// checked + 已定类型:跳过运行时检查,用带类型的 try_get_unchecked
(true, ColumnType::Exact(type_)) =>
    quote!(let #var_name = row.try_get_unchecked::<#type_, _>(#i)?.into();),
// checked + 通配(as "x: _"):退回运行时检查的 try_get
(true, ColumnType::Wildcard) => quote!(let #var_name = row.try_get(#i)?;),
(true, ColumnType::OptWildcard) =>
    quote!(let #var_name = row.try_get::<::std::option::Option<_>, _>(#i)?;),
// _unchecked!() 变体:类型交给推断,错了就在解码时炸
(false, _) => quote!(let #var_name = row.try_get_unchecked(#i)?;),

也就是说 try_get_unchecked 在 checked 模式下也在用——它表达的是"类型已在编译期核对过、不必再运行时核对";unchecked 变体的区别是连类型都不写。

要留意 query_unchecked! 生成的匿名 Record 结构体,字段类型照样来自 columns_to_rust 的推断(实测展开为 id: i64, name: String),所以列类型不在清单里时它照样报错;真正能绕开这条限制的是 query_as_unchecked!——字段类型取自你给的 struct。

模式本身由 input.checked: bool 字段(sqlx-macros-core/src/query/input.rs:19)表达——默认 true,query_unchecked! 展开时带上 checked = false(sqlx-macros-core/src/query/input.rs:82-84 解析这个 key)。

11.10 query! 宏的限制

宏强大但有明确限制——了解它们决定你什么时候该用 QueryBuilder 替代:

限制 1:SQL 必须字面量

// 合法
query!("SELECT * FROM users WHERE id = $1", 42)

// 非法
let sql = format!("SELECT * FROM {}", table);
query!(sql, ...)  // 编译错

宏解析 SQL 必须在编译期——format! 的结果编译期未知。动态 SQL 必须用 QueryBuilder(第 10 章)或普通 query()。

限制 2:需要 DATABASE_URL 或 .sqlx/

无 DB 连接且 .sqlx/ 不存在——编译失败。这对第一次 clone 仓库的新开发者不友好——必须先搭数据库或下载 .sqlx/。

限制 3:一个 crate 绑定一个 DB 方言

DATABASE_URL=postgres://... 的 crate 里所有 query! 都按 Postgres 校验。想在同一 crate 里混用 Postgres 和 MySQL query——做不到(除非把两部分拆成不同 crate)。

限制 4:第三方驱动不可用

只有 FOSS_DRIVERS(Postgres、MySQL、SQLite)能用 query!。ClickHouse 之类第三方驱动只能用 query() / query_as() 这些运行时 API。

限制 5:编译时间增加

每条 query! 在在线模式下都要往数据库发一次 describe(离线模式则是一次文件读 + JSON 反序列化)。连接本身有缓存(§11.4.1),但 describe 的往返省不掉——query! 越多,cargo check 的这段固定成本越明显。

限制 6:DATABASE_URL 泄漏风险

.env 文件或 CI 环境里的 DATABASE_URL 有时会意外包含生产 DB 的 credentials——开发者在本地 cargo sqlx prepare 时可能连上生产库。规范团队约定"prepare 只用 shadow / staging DB"。

11.11 编译期宏路径全景图

把前面讨论的所有步骤用一张 mermaid 串起来:

flowchart TD
    U["用户代码: query!(SQL, args)"]
    P["sqlx-macros 解析 TokenStream"]
    EI["expand_input"]
    EnvCheck{"SQLX_OFFLINE / DATABASE_URL<br/>(环境变量或 .env)"}
    Live["QueryDataSource::Live"]
    ReadJson[".sqlx/query-&lt;hash&gt;.json"]
    Cached["QueryDataSource::Cached"]
    Match["matches_driver 选驱动"]
    Expand["driver.expand = expand_with::&lt;DB&gt;"]
    DBConn["Postgres::describe_blocking"]
    DBMsg["Parse + Describe 消息 → Postgres"]
    DBResp["RowDesc + ParamDesc + pg_attribute + EXPLAIN"]
    Describe1["Describe&lt;DB&gt;"]
    Describe2["Describe&lt;DB&gt; 反序列化"]
    Columns["expand_with_data → columns_to_rust"]
    QQA["quote_query_as"]
    TS["TokenStream 返回 rustc"]
    U --> P --> EI --> EnvCheck
    EnvCheck -->|SQLX_OFFLINE 非 true 且有 DATABASE_URL| Live
    EnvCheck -->|SQLX_OFFLINE=true 或无 DATABASE_URL| ReadJson --> Cached
    Live --> Match
    Cached --> Match
    Match --> Expand
    Expand -->|Live| DBConn --> DBMsg --> DBResp --> Describe1
    Expand -->|Cached| Describe2
    Describe1 --> Columns
    Describe2 --> Columns
    Columns --> QQA --> TS

这张图的五条关键信息:

  1. 两条并行路径——Live 和 Cached,根据 env 决定走哪条。
  2. Live 路径有网络——要连 DB、发协议消息、等响应。
  3. Cached 路径纯文件 I/O——读 JSON 文件、反序列化。
  4. 两条路径汇聚到 Describe<DB>——之后共享同样的代码生成。
  5. 最终产物是 TokenStream 返回 rustc——宏扩展结束。

这条图是"一条 query! 宏的生命"——从用户写代码到 rustc 收到展开结果。理解这条全景,后面讨论的每一个细节都能找到对应位置。

11.12 生产环境的 .sqlx/ 维护规范

一个团队决定采用 sqlx query! 宏时,最好先建立 .sqlx/ 的维护规范——否则会遇到各种"CI 构建失败但本地没事"的诡异情况。

规范 1:.sqlx/ 必须提交到 git

很多团队第一反应是 ".sqlx/ 是生成产物、加 .gitignore"——错误。.sqlx/ 是生产依赖——CI 读它、每个 clone 仓库的开发者也要读它(即使他不跑 prepare)。gitignore 它等于让 CI 和新成员都跑不起来。

规范 2:改 SQL 后必须跑 prepare

开发者在 branch 改了 SQL 后,本地运行 cargo sqlx prepare——.sqlx/ 下会出现新 / 改动的 JSON 文件。提交时一并 commit。PR review 时 reviewer 应该同时 review SQL 和对应的 JSON 改动——SQL 改了但 JSON 没改意味着开发者忘了 prepare,CI 必然失败。

规范 3:CI 加一步 prepare --check

在 CI workflow 里加:

- name: Verify .sqlx/ is up to date
  env:
    DATABASE_URL: postgres://ci_db/test
  run: cargo sqlx prepare --check

这步确认"当前 .sqlx/ 和代码里的 SQL 同步"——如果开发者忘了 prepare 就 push,这步失败。比等到 SQLX_OFFLINE 编译失败早一步。

规范 4:prepare 只用 shadow 或 staging DB

严禁本地 prepare 连生产 DB——虽然 prepare 只发 DESCRIBE(只读),但 DATABASE_URL 泄漏到 .env / shell history 是常见事故。规范是 .env 里 DATABASE_URL 永远是本地 dev DB 或 staging DB;生产 DB 凭证绝不出现。

规范 5:处理 merge conflict

两个 branch 都改了同一条 SQL、各自 prepare 了——文件名是 SQL 的哈希,两边改出的 SQL 不同,就各自新增了一个 .sqlx/query-<hash>.json,git 不会报冲突;但解决完 .rs 里的冲突后,合并结果只保留一种 SQL,另一边的 JSON 成了孤儿,或者合并出的第三种写法根本没有对应文件。解法:merge 后在合并 branch 重新 cargo sqlx prepare——它会先删掉目录里的 query-*.json 再重新生成,让 .sqlx/ 精确反映合并后的 SQL。

规范 6:schema 变更协调

开发者 A 加了一条 query!、依赖新字段 users.phone。开发者 B 从 main 拉 A 的代码但没跑 migration——自己 DB schema 里没 phone 列。A 提交的 .sqlx/*.json 正确(反映 schema 已迁移的 DB),但 B 本地 prepare 会失败。规范是:schema 变更(migration)和 query! 改动同 PR 合入——CI 先跑 migration 再跑 check,保证顺序。

这六条规范在采纳 sqlx query! 的团队里是必备知识。没有规范的团队会发现"CI 时好时坏"、"为啥我改了 SQL CI 挂了"这类问题——根因都在 .sqlx/ 维护上。

11.13 query! 宏的编译期开销来自哪里

query! 的编译期触达 DB 有多大开销?源码能告诉你开销由哪些项构成,具体秒数取决于你的 SQL 数量、网络延迟和数据库,本专栏不给没有出处的数字。按场景拆:

场景 每条 query 的额外工作 一次性成本
在线 cargo check 一次 Parse+Describe 往返;Postgres 还要多两条查询(pg_attribute + EXPLAIN) Postgres / MySQL 每个 DATABASE_URL 一次建连(§11.4.1 的缓存);SQLite 每条 query 各开一次库
Incremental build 所在 crate 一旦重编译,其中的 query! 全部重新展开、重新 describe 同上
cargo sqlx prepare 等同一次完整在线 check,外加把 JSON 写盘 清空 .sqlx/ + 触发最小重编译
CI 下 SQLX_OFFLINE=true 一次文件读 + serde_json 反序列化(同路径的文件还有进程内缓存,见 OFFLINE_DATA_CACHE) 无网络、无建连

三条能从源码直接读出来的结论:

  • 离线路径没有任何网络往返——这就是 CI 必须用 SQLX_OFFLINE=true 的原因,也是 .sqlx/ 存在的全部意义。
  • 在线路径的往返数和 query 数成正比,但 Postgres / MySQL 的建连数不随之增长(CachingDescribeBlocking 按 URL 复用连接)。
  • Postgres 的可空性推断额外收费——每条语句多一条 pg_attribute 查询和一条 EXPLAIN,换来的是三家里最准的 Option<T> 推断。

如果你的项目 query! 数量会涨到很大,值得考虑"把数据访问层拆成独立 crate"——让改业务代码时不必重新展开这些宏。

11.14 proc-macro 触达外部世界的争议

sqlx query! 的"编译期连 DB"在 Rust 社区是有争议的特性——技术上合法但理念上有分歧。

争议点:

  • Proc macro 应当是纯函数。按一种常见主张,proc macro 只接收 TokenStream、输出 TokenStream——不应有 I/O、不应触达文件系统或网络。这条共识的目的是确定性构建——同样的输入、同样的输出、不受外部状态影响。
  • sqlx 的做法违反了这条——连 DB 意味着编译结果取决于当前 DB 的 schema state。两个开发者在不同时间 cargo check,DB schema 如果改了、编译结果可能不同——构建不再确定。
  • sqlx 的辩护:.sqlx/ 离线缓存提供了确定性——CI 和分发构建都走缓存、只有 local prepare 时触达 DB。而且触达是只读 DESCRIBE、没有副作用。

社区分歧:

  • 支持派(包括 sqlx 用户):编译期类型安全的价值 >> 确定性构建的代价。.sqlx/ 是 checked-in 的——和 Cargo.lock 类似作为"外部真相的快照"提交进仓库,构建确定性由此恢复。
  • 反对派(包括 Diesel 用户):proc macro 应当保持纯函数属性。Diesel 的 schema.rs 是独立的 codegen 产物、不在 proc macro 内部触达 DB——这条路径更符合 Rust 生态的设计哲学。

实践中的折衷:query! 带来的类型安全在日常开发中收益明显,但这条路径也"不是完美方案"——需要 .sqlx/ 维护、DATABASE_URL 管理、CI 工作流调整,各种工程复杂性。

这场争议没有对错——它反映的是"静态类型系统能延伸到哪一步"的语言社区思辨。sqlx 走到 DB schema、Diesel 止步于 schema.rs 文件——两条路线都合理、各自取舍不同。

一个观察:Rust 生态里做"SQL 到类型"的另一批工具(例如 cornucopia)走的是"独立 codegen 步骤而非在 proc macro 里连 DB"的路线——更接近 Diesel 或 Prisma。sqlx 在这个维度上是少数派——但少数派本身不意味着错。理解这条争议,你对 Rust 生态"类型安全 vs 工程简洁"的取舍谱系有了更完整的视角。

11.15 本章小结

本章把 query! 宏的"编译期触达外部世界"机制完整拆开:

  1. 三个宏 query! / query_as! / query_scalar! 共享同一个 expand_input 入口(§11.2)。三个宏通过 RecordType enum 区分模式。query_file! 让 SQL 从外部文件读。
  2. expand_input 指挥中心(§11.3)——读 env 决定 data source、查 .sqlx/ 三个路径、调用 QueryDriver 的 expand 函数指针。
  3. QueryDriver 是 const fn 构造的 vtable(§11.3.1)——类似 AnyDriver 但工作在编译期。FOSS_DRIVERS 是三家驱动的静态数组。
  4. 在线模式用 describe_blocking(§11.4)—— Postgres / MySQL 在编译期用 block_on 跑 describe,连接进程级缓存(§11.4.1)让同一个 rustc 进程里、同一个 DATABASE_URL 下的所有 query! 只建一个连接;SQLite 同步 describe、每次新开。
  5. 离线模式读 .sqlx/query-<hash>.json(§11.5)—— 每条 query 一个 JSON 文件。cargo sqlx prepare 生成、git 提交、CI 下 SQLX_OFFLINE=true 用。
  6. columns_to_rust + quote_query_as(§11.6)—— 把 Describe 翻成 try_get_unchecked::<T, _>(idx) 链 + struct 字面量。按位置取值、跳过运行时的 compatible 检查。
  7. Nullability 推断(§11.7)—— Postgres 查 pg_attribute 再用 EXPLAIN 计划打补丁;MySQL 只有协议的 NOT_NULL flag;SQLite 靠 VDBE EXPLAIN 推断,再以 sqlite3_table_column_metadata 兜底。
  8. 类型 override 三语法(§11.8)—— as "x: T" / as "x!: T" / as "x?: T" 覆盖自动推断。
  9. 六条限制(§11.10)—— SQL 必须字面量、需要 DATABASE_URL 或 .sqlx/、一 crate 一 DB、第三方驱动不支持、编译时间增加、DATABASE_URL 泄漏风险。

读完本章你对"编译期连数据库"这条 sqlx 标志性特性有了完整理解——它既强大(类型安全触达 DB schema)又复杂(.sqlx/ 维护、CI 工作流)。这条路径是 sqlx 相对其他 Rust SQL 库的核心辨识度——也是团队采纳 sqlx 时最大的工程投入。

11.16 实战:第一次使用 query! 的完整配置步骤

给从没用过 query! 的读者一份最小可行配置——跑通第一条 query! 的完整流程:

步骤 1:本地装 Postgres 和 sqlx-cli

# Postgres(以 macOS 为例)
brew install postgresql@16
brew services start postgresql@16
createdb myapp_dev

# sqlx-cli
cargo install sqlx-cli --version 0.8.6 --no-default-features --features postgres,rustls

步骤 2:在 crate 根 创建 .env

DATABASE_URL=postgres://localhost/myapp_dev

步骤 3:创建表

sqlx migrate add create_users
# 编辑 migrations/20240424120000_create_users.sql:
cat > migrations/*_create_users.sql <<SQL
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    email TEXT
);
SQL
sqlx migrate run

步骤 4:Cargo.toml

[dependencies]
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "macros"] }
tokio = { version = "1", features = ["full"] }

步骤 5:写第一条 query!

// main.rs
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
    let pool = sqlx::PgPool::connect("postgres://localhost/myapp_dev").await?;

    let user = sqlx::query!("SELECT id, name, email FROM users WHERE id = $1", 1_i32)
        .fetch_optional(&pool).await?;

    if let Some(u) = user {
        println!("User: {:?}", (u.id, u.name, u.email));
    } else {
        println!("Not found");
    }
    Ok(())
}

步骤 6:cargo check

如果所有配置对——cargo check 成功。展开宏时 rustc 加载 sqlx-macros、连数据库、describe SQL、生成代码——比普通 crate 多出连库和往返的时间。之后只要这个 crate 重新编译,其中的 query! 就会全部重新展开、重新 describe(proc macro 的展开结果不进增量缓存)。

步骤 7:CI 配置

DATABASE_URL=postgres://localhost/myapp_dev cargo sqlx prepare
git add .sqlx/
git commit -m "Prepare sqlx query data"

CI workflow 里设 SQLX_OFFLINE=true——CI 不需要 Postgres。

这七步涵盖从 0 到第一条 query! 的完整配置——足够把 sqlx 跑起来。踩过这些坑你就会理解为什么社区反复抱怨"sqlx 上手门槛"——它的编译期特性确实好,但第一次搭起来需要下几道功夫。

11.17 query! 宏的错误信息

新手最容易卡的是 query! 宏报错时看不懂错误信息——因为错误是从 DB 发回的英文、套在 Rust 编译错误里。几种常见模式:

错误 1:列不存在

error: error returned from database: column "emial" does not exist
  --> src/main.rs:10:15
   |
10 |     let row = sqlx::query!("SELECT emial FROM users").fetch_one(&pool).await?;
   |               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info)

Postgres 的错误消息原样传出,前面套一层 sqlx 的 error returned from database: (sqlx-core/src/error.rs:44)——一看就知道是 SQL 拼错。(本机无 Postgres,上面按源码拼出、未实跑;报错的形态取自 SQLite 实测,SQLite 下这一行是 error: error returned from database: (code: 1) no such column: emial。)

错误 2:参数数量不匹配

error: expected 2 parameters, got 1
  --> src/main.rs:15:15
   |
15 |     let row = sqlx::query!("SELECT * FROM users WHERE id = $1 AND name = $2", 42_i32).fetch_one(&pool).await?;
   |               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info)

SQL 有两个 $1 / $2,但只传了一个参数——编译失败。

错误 3:类型不匹配

error[E0308]: mismatched types
  --> src/main.rs:20:22
   |
20 |     let id: String = row.id;
   |             ------   ^^^^^^ expected `String`, found `i32`
   |             |
   |             expected due to this
   |
help: try using a conversion method
   |
20 |     let id: String = row.id.to_string();
   |                            ++++++++++++

SQL 的 id 是 INT4,宏推断 i32——用户声明 String 引发 Rust 类型错。

错误 4:Nullability 不对

error[E0308]: mismatched types
  --> src/main.rs:25:25
   |
25 |     let email: String = row.email;
   |                ------   ^^^^^^^^^ expected `String`, found `Option<String>`
   |                |
   |                expected due to this
   |
   = note: expected struct `std::string::String`
                found enum `Option<std::string::String>`
help: consider using `Option::expect` to unwrap the `Option<std::string::String>` value, panicking if the value is an `Option::None`
   |
25 |     let email: String = row.email.expect("REASON");
   |                                  +++++++++++++++++

email 列可空——宏推断 Option<String>——用户声明 String 失败。改成 Option<String>、用 unwrap_or_default() 之类处理 None,或者确信它非空时在 SQL 里写 email as "email!"。

错误 5:离线模式缺缓存

error: `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`

CI 下最常见——开发者改了 SQL 没 prepare。按提示跑 prepare + commit .sqlx/。

错误 6:数据库连不上

宏把底层连接错误原样包成 compile_error!,形如 "error: error communicating with database: Connection refused (os error 111)"。本地 DB 没启动、URL 错、credentials 错——总之宏无法完成 describe。

这六类基本涵盖日常会撞见的报错。query! 宏的错误体验其实很好——DB 错误透传、类型错误走 Rust 原生系统——比大多数 ORM 的"运行时抛异常"清晰得多。

11.18 对比:query! vs 其他语言的 ORM

把 sqlx 的 query! 放到其他语言 ORM 的类型安全谱系里:

工具 编译/检查时机 类型来源 触达 DB?
sqlx::query! (Rust) cargo check DB describe 是
Diesel (Rust) cargo check schema.rs 本地文件 否
F# SQLProvider F# 编译 DB 元信息 是
TypeScript + Prisma prisma generate + tsc schema.prisma 文件 否(prisma db pull 才连库)
Java + JOOQ maven code gen + javac DB 元信息(生成时) 生成时是
Ruby / ActiveRecord 运行时 DB at startup 是(运行时)
Python SQLAlchemy (typed) mypy 检查 声明式 ORM 类型 否

sqlx::query! 和 F# SQLProvider 是最贴近的"compile-time with real DB touch"设计——在编译期真正发 describe 查询。其他大多数是"生成时 + 编译时"两阶段(Prisma / JOOQ)或"不触 DB"(Diesel / SQLAlchemy)。

两阶段的代价是 prisma generate 或 jooq-codegen 必须单独跑——工作流里多一步。sqlx 的 "cargo check 一步到位"省了这步——代价是每次编译都要连 DB 或读 .sqlx/(靠离线缓存缓解)。

工具哲学的差异:Rust 的 proc macro 可以做 I/O(虽然社区对此有争议)——sqlx 利用这点把"代码生成"直接塞进编译过程。其他语言要么没有编译期这一环(Ruby / Python),要么没有同等的编译期宏、惯例上把代码生成放在独立工具里(TypeScript / Java)——所以多是两阶段。F# 的 type provider 是少数同样在编译期连库的例子。

这条对比也解释了为什么 sqlx 在 Rust 生态外少有直接对标——它是语言能力的产物。学会欣赏这一点,你就能理解为什么 sqlx 值得投入时间精通——它不是"另一个 SQL 库",而是一个利用 Rust 独特能力的工程实验。

11.19 query! 的演进里程碑

从 sqlx 0.1 到 0.8 的 query! 演进:

以下里程碑取自仓库 CHANGELOG.md:

  • 0.1.x(2020-01 前后):query! 宏随最早的几个版本发布,Postgres 和 MySQL 都已在列。当时没有离线模式——编译期必须连 DB。
  • 0.3.0(2020-03-29):SQLite 支持加入(PR #129),query! 覆盖到第三家数据库。
  • 0.4.0(2020-11-12,beta.1 在 2020-07-27 就宣布了):离线模式落地——"从缓存的 metadata 做编译期类型检查,使得在没有开发数据库的环境(Docker、CI)里也能构建";同一版还带来了"用户自定义类型 / 覆盖 SQL 推断出的 Rust 类型",也就是 as "x: T" 这套 override 语法。缓存那时是单个 sqlx-data.json。
  • 0.4.2(2020-12-19):SQLX_OFFLINE 开始把 1 也识别为 true(PR #874)——今天源码里的 s.eq_ignore_ascii_case("true") || s == "1" 就是这条。
  • 0.7.0(2023-06-30):离线缓存改成每条 query 一个文件的 .sqlx/ 目录(PR #2363),大幅缓解 merge conflict;同版把 offline feature 从 facade crate 移除、改为无条件启用。
  • 0.7.0 同时完成了驱动拆分(PR #2039),QueryDriver / FOSS_DRIVERS 这套驱动表就是那次引入的;0.8(本专栏基准) 沿用这套形态。

这条演进显示 query! 不是一夜之间长成今天的样子——几年迭代里每个版本都在完善工作流。0.4 的离线模式是转折点——从此没有开发数据库的环境(Docker、CI)也能构建;0.7 的一查询一文件则缓解了单个 sqlx-data.json 带来的合并冲突。

这条演进给开源项目作者的启示:大胆的设计(编译期连 DB)一开始被社区质疑很正常——只要核心价值成立、你持续优化工作流(离线缓存、CI 友好、错误信息)——几年后会成为"这个库的核心特征"。sqlx 的 query! 从"疯狂实验"变成"生产级工具"的轨迹值得学习。

11.20 一条实战判断:什么项目该用 query!

不是所有项目都值得上 query!——给几条判断规则:

适合 query! 的项目:

  • SQL 数量中等,可以接受 .sqlx/ 维护。
  • 团队对类型安全高度重视——愿意为类型保护投入额外工作流。
  • 有 DBA 或后端工程师熟悉 SQL、看得懂报错里的 DB 输出。
  • Postgres 生产(因为 nullability 推断最强)。

不适合 query! 的项目:

  • SQL 极多——编译开销 + .sqlx/ 维护负担过大。
  • 动态 SQL 占多数——大部分场景用不上 query!(§10.20 讨论过)。
  • 新手团队——"第一次看到 CI 失败不知道要 prepare"这类问题消耗精力。
  • 用第三方驱动(如 ClickHouse)——根本用不了 query!。
  • 纯 SQLite 项目——SQLite 列类型是动态的,表达式列的类型和可空性只能靠 VDBE EXPLAIN 推断,覆盖不到时得靠 override 补,query! 收益打折扣。

实际生产里的折衷:

  • core business logic 用 query!(数据库写操作、核心查询)——享受类型安全。
  • dashboard 查询 / 报表 SQL 用 QueryBuilder + 手写 FromRow(大量动态 SQL)。
  • migration / DDL 用 raw_sql()(无需类型检查、走 simple query)。

三层工具分工——各取所长、避免 query! 在不适合的场景被强行塞。

11.21 query! 宏的设计启示

跳出 sqlx 看 query! 作为 proc macro 的设计,有几条通用启示:

1. 让魔法有出口。query! 是"编译期连 DB"这种偏离规范的魔法——但它给了 SQLX_OFFLINE=true 作为出口、给了 .sqlx/ 作为缓存。任何"偏离规范"的设计都应该提供退出机制——让用户在不方便的场景能绕过魔法。这让采纳决策从"要么全吃要么不用"变成"大部分吃、少数场景退回"——极大降低采纳门槛。

2. 让编译错误带着运行时上下文。query! 的编译错误里经常出现 DB 的原始错误消息("column xxx does not exist")——这比"抽象的 trait bound 错误"对调试有用得多。Proc macro 作者经常忘了这一点——自己包装所有错误成"nicer" 形态,反而让用户更难定位问题。直接 pass-through underlying error 是好做法。

3. const fn 是 proc macro 内部的 vtable 工具。QueryDriver::new::<DB>() 用 const fn monomorphize 成具体 driver 常量——让 FOSS_DRIVERS: &[QueryDriver] 是编译期决定的静态数组。这条手法让 proc macro 运行时(实际上是编译期)也能享受 Rust 的类型擦除便利——不需要 trait object 的运行时成本。

4. 工作流 matters 不亚于 API 设计。query! 的 API 只是两三个宏调用——但它的工作流(.sqlx/ 维护、prepare 命令、CI 配置)才是用户学习时间的大头。成功的 proc macro 必须想清楚工作流 ——文档、错误消息、CLI 辅助工具都同等重要。

5. 接受"不完美"和 trade-off。query! 不是完美方案——它有编译时间代价、.sqlx/ 维护成本、DATABASE_URL 管理复杂性。sqlx 团队没假装这些问题不存在——query! 的文档里写明了 DATABASE_URL 的要求和离线模式的用法(sqlx-0.8.6/src/macros/mod.rs:54-58、:288-298)。真诚比完美更有效——让用户自己判断 trade-off 合不合算,而不是"兜售完美"。

这五条原则对任何尝试"让类型系统做更多事"的 Rust 项目都适用。Rust 的 proc macro 能力边界比多数人想象的大——sqlx 的 query! 是在这条边界上走得很远的一个例子。

和《Serde 元编程》专栏第 10 章的互参——serde_derive 的入口 lib.rs 只有 127 行(见《Serde 元编程》专栏第 10 章 §10.3)、把 DeriveInput 经 Container::from_ast(同章 §10.6)转换成中间 AST、再交给 ser.rs / de.rs 做 codegen(同章 §10.7 与该专栏第 13 章 §13.2)——和 sqlx query! 的驱动注册 + Macro 输入解析 + describe 调用 + TokenStream 拼装是同一个 proc-macro 三段式管线。两者一个在编译期查 DB、一个在编译期查 AST——但"如何从宏输入到生成代码"的架构是共享的。如果你对 sqlx query! 的展开流程还有模糊——读一遍《Serde 元编程》专栏第 10 章 §10.4 的 "DeriveInput → Container → TokenStream" 数据流会立刻打通。

下一章我们走出"查询 API"的讨论,进入第四部分"连接与事务"——sqlx 的底层资源管理:Connection 的生命周期、Pool 的 Acquire/Release、Transaction 的 RAII 保护。查询 API 产出的 Query 对象最终要落到 Executor 上;Executor 的具体 impl(Pool 和 Connection)如何获取连接、归还连接、处理事务——第 12-15 章详细。