Skip to content

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

"Compile-time checking that actually touches the real world— a marvel and a logistical nightmare, in exactly equal measure." —— 每个第一次让 CI 跑通 query! 的 Rust 工程师都说过的话

本章要点

  • 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:146-190):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 用一个 block_on 把异步 describe 跑完,返回 Describe<DB>(参数类型 + 列类型 + 可空性)。
  • 离线缓存文件 .sqlx/query-<hash>.json——每条 query 一个文件,文件名是 SQL 字符串的哈希。由 cargo sqlx prepare 生成、提交到 git、CI 下 SQLX_OFFLINE=true 读取。
  • QueryDrivermod.rs:27-44)是一个 const fn 可构造的 vtable—— db_name + url_schemes + expand: fn(...) -> TokenStreamFOSS_DRIVERS 是三家驱动的静态数组(第 2 章 §2.3.1)。
  • 输出构造query/output.rs:127-191quote_query_as)用 try_get_unchecked::<PgType, _>(idx) 生成带具体类型的取值代码——比运行时 try_get 少一次 compatible 检查。
  • Nullability 推断query/data.rs 里的 nullable)基于 Postgres 的 relation_id / relation_attribute_no——外连接里右表的 NOT NULL 列自动被提升成 Option<T>。这条能力只 Postgres 支持
  • 类型 override 语法as "x: MyType"(强制用 MyType 解码)、as "x!: T"(强制非空)、as "x?: T"(强制可空)——覆盖宏的自动推断。

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

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

版本 A:query()

rust
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!

rust
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 过、第一次 fetch 才报 ColumnNotFound。类型对不上(用 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 顶层对外导出三个宏。sqlx-macros-0.8.6/src/lib.rs:10-24 的入口长这样:

rust
#[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) => compile_error_from_err(e),
    }
}

真正的三个宏——query! / query_as! / query_scalar!——在 sqlx/src/lib.md(facade crate 的宏文档)里定义成 macro_rules! 转发到 expand_query

rust
// sqlx/src/lib.md 节选(简化)
macro_rules! query {
    ($query:expr $(, $args:expr)*) => {{
        sqlx::__macros::expand_query!(source = $query, args = [$($args),*])
    }};
}

macro_rules! query_as {
    ($out_ty:path, $query:expr $(, $args:expr)*) => {{
        sqlx::__macros::expand_query!(record = $out_ty, source = $query, args = [$($args),*])
    }};
}

macro_rules! query_scalar {
    ($query:expr $(, $args:expr)*) => {{
        sqlx::__macros::expand_query!(scalar = _, source = $query, args = [$($args),*])
    }};
}

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

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

rust
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。这种"一个入口、多种模式"的设计让三个宏共享 95% 的实现代码。

11.2.1 query_file! 变体

input.rs:57-62 还支持 source_file 语法:

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

让 SQL 从外部文件读:

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

queries/get_user.sql 是独立的 .sql 文件——SQL 工程师可以在不接触 Rust 代码的情况下改 SQL,Rust 编译时 include_str! 读进来。对大型项目里"把 SQL 当一等资源维护"的团队友好。

11.3 宏展开的总入口 expand_input

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

rust
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 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 指定 > 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 设计

mod.rs:27-44

rust
#[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_nameurl_schemesexpand 三字段是驱动特定的但都能在 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-51(第 2 章 §2.3.1 讨论过):

rust
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! 宏能识别的驱动白名单——编译期闭合。第三方驱动作者(TiDB / ClickHouse)只能 fork sqlx-macros 维护平行版本。

11.4 在线模式:DATABASE_URL 驱动的 DESCRIBE

expand_with::<DB>mod.rs:215-233)处理 Live 数据源:

rust
fn expand_with<DB: DatabaseExt>(
    input: QueryMacroInput,
    data_source: QueryDataSource,
) -> crate::Result<TokenStream>
{
    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-postgres/src/type_checking.rs)类似:

rust
impl DatabaseExt for Postgres {
    fn describe_blocking(query: &str, database_url: &str) -> sqlx_core::Result<Describe<Self>> {
        CACHED_CONNECTIONS.get_or_init(...).describe(query)
        // 内部用 block_on 跑一个异步描述
    }
}

describe_blocking 内部做的事:

  1. 从缓存池或新建一个 PgConnection(Tokio 当前线程 runtime、第 2 章 §2.5.1 讨论过的 block_on)。
  2. Parse 消息 + Describe::Statement 消息。
  3. 接收 RowDescription(列信息)和 ParameterDescription(参数信息)。
  4. 如果是 Postgres,可能再查 pg_attribute 拿 nullability(§11.8)。
  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-73CachingDescribeBlocking):

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

impl<DB: DatabaseExt> CachingDescribeBlocking<DB> {
    pub fn describe(&self, query: &str, database_url: &str) -> sqlx_core::Result<Describe<DB>> {
        let mut connections = self.connections.lock().unwrap();
        let conn = connections.entry(database_url.to_string())
            .or_insert_with(|| block_on(DB::Connection::connect(database_url)).unwrap());
        block_on(conn.describe(query))
    }
}

每个 DATABASE_URL 只开一个连接——proc macro 进程生命周期内(单次 cargo check)所有 query! 复用。进程结束 connections HashMap 丢弃——下次 cargo check 重新建。

这让一个 crate 有 500 条 query! 宏的编译时间从"500 × 50ms (新建连接)"变成"50ms + 500 × 5ms (每次 describe)"——从 25 秒降到 2.5 秒。

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

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

rust
let dirs = [
    |meta: &Metadata| meta.offline_dir.as_deref().map(PathBuf::from),  // 1. SQLX_OFFLINE_DIR env
    |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 文件。

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

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

hash_string 是 SHA-2 哈希(query/data.rs:165 左右)——让每条 SQL 有唯一文件名。

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

每个 JSON 文件装一个 DynQueryDatadata.rs:73-78)序列化结果:

json
{
  "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": [{"Oid": 23}]},
    "nullable": [false, false, true]
  },
  "hash": "abc123..."
}

这个 JSON 有 SQL、列信息、参数类型、可空性——离线宏展开需要的所有数据

11.5.2 cargo sqlx prepare 工作流

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

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

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

工作原理:

  1. cargo sqlx prepare 内部调 cargo check设置 SQLX_PREPARE_MODE=1 环境变量。
  2. sqlx-macros 的 expand_input 里检测这个 env——不生成 TokenStream,而是把 QueryData 序列化到 .sqlx/query-*.jsondata.rs:153-180save_in 方法)。
  3. 所有 query! 宏都写文件;编译产物被丢弃。
  4. 结果:.sqlx/ 下一堆 JSON 文件。

CI 下

yaml
# 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 哈希不匹配、报错。
  • JSON 不匹配 SQL(版本控制冲突后忘 rebase)——同上。

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

11.6 输出构造:columns_to_rustquote_query_as

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

11.6.1 columns_to_rust:Describe → RustColumn 列表

output.rs:79-111columns_to_rust 把 Describe 的 columns 翻译成"每列的 Rust 类型":

rust
pub fn columns_to_rust<DB: DatabaseExt>(describe: &Describe<DB>) -> crate::Result<Vec<RustColumn>> {
    describe.columns().iter().enumerate().map(|(i, column)| {
        let name = column.name();
        let nullable = describe.nullable.get(i).copied().flatten().unwrap_or(true);
        let type_info = column.type_info();

        // 三种情况:用户 override / 推断 / 未知
        let (type_, var_name) = if let Some(override) = get_override(name) {
            (ColumnType::Exact(override.type_.clone()), override.var_name.clone())
        } else if let Some(rust_type) = DB::return_type_for_id(type_info) {
            let rust_type = syn::parse_str::<Type>(rust_type)?;
            let final_type = if nullable {
                parse_quote!(Option<#rust_type>)
            } else { rust_type };
            (ColumnType::Exact(final_type), default_var_name(name))
        } else {
            return Err(format!("unsupported column type: {type_info}").into());
        };

        Ok(RustColumn { ident: ..., var_name, type_ })
    }).collect()
}

三条路径:

  • 用户 override——as "x: MyType" 直接用指定类型。
  • 自动推断——DB::return_type_for_id(type_info) 查映射表(Postgres 的 OID 23 → "i32");nullable 时包 Option<T>
  • 未知类型——映射表里没有对应的 Rust 类型,报编译错。

DB::return_type_for_id 来自 TypeChecking trait(第 5 章 §5.13 讨论过),是一张手工维护的 OID → Rust 类型字符串映射表。例如 sqlx-postgres/src/type_checking.rs

rust
fn return_type_for_id(info: &PgTypeInfo) -> Option<&'static str> {
    match info.0 {
        OID::INT4 => Some("i32"),
        OID::INT8 => Some("i64"),
        OID::TEXT | OID::VARCHAR => Some("String"),
        OID::BOOL => Some("bool"),
        OID::TIMESTAMP => Some("::sqlx::types::chrono::NaiveDateTime"),
        // ... 几百条 match arm
        _ => None,
    }
}

这张表就是整个"编译期类型安全"的真理源——它告诉宏 "Postgres 的 INT4 列应当变成 Rust 的 i32"。每个驱动手工维护(加一个类型要改两处——runtime 的 Type impl + compile-time 的这张表)。

11.6.2 quote_query_as:拼装 TokenStream

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

rust
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 长这样(简化):

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

这就是编译期生成的业务代码。运行时直接跑这段——没有 HashMap lookup(按 usize 位置取)、没有 compatible 检查(编译期已验证)——比普通 row.try_get("id") 快数十纳秒。

11.7 Nullability 推断(Postgres 独家)

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

  • Postgres:通过 pg_attribute 系统表查。PgColumn 里的 relation_idrelation_attribute_no(第 7 章 §7.14.2 讨论过)标识"这列来自哪张表的第几列"——sqlx 发一条 SELECT attnotnull FROM pg_attribute WHERE attrelid = $1 AND attnum = $2 拿到 NOT NULL 标志。表达式列SELECT COUNT(*)a + b)的 relation_id 是 None——sqlx 保守标为 nullable。
  • MySQLprotocol::ColumnFlags::NOT_NULL 协议标志——但对 JOIN 结果不可靠(见 MySQL 的 field flags 文档)。sqlx 对 MySQL 的 nullability 标记相对保守。
  • SQLite:协议根本不返回 nullability 信息。sqlx 对 SQLite 所有列默认标为 nullable。

Postgres 的 JOIN 推断特别精彩

sql
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。sqlx 在查 pg_attribute 之前会 parse SQL 的 JOIN 树,识别出 title 来自 LEFT JOIN 的右侧——强制把推断 nullability 设为 true。结果是宏生成 title: Option<String>——正确反映 JOIN 语义。

这条 JOIN-aware 的 nullability 推断是 sqlx Postgres 驱动独有的——MySQL 和 SQLite 都做不到。它让 Postgres 用户在 query! 宏里享受的类型精确性远超其他 DB。

11.8 类型 override 语法

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

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

三种 override 语法:

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

sql
SELECT my_json as "my_json: serde_json::Value"

告诉宏 "这列用 serde_json::Value 解码"——覆盖默认推断。适用场景:自定义类型(UUID、BigDecimal 等)、派生类型#[sqlx(transparent)] 的新类型)。

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

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

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

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

sql
SELECT name as "name?: String"

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

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

11.8.4 scalar = _record = T

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

rust
// 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")

query/input.rs:60-90 的 parser 识别这些 key 并 set RecordType::Given(User)RecordType::Scalarexpand_with_data 根据 record_type 生成不同的输出——分别对应 QueryAs / QueryScalar / 匿名 record。

11.9 query!_unchecked 变体

sqlx 还暴露了一个 query_unchecked! 宏——绕过 compatible 检查

rust
sqlx::query_unchecked!("SELECT id FROM users", ...)

生成的代码用 try_get_unchecked::<T, _> 而不是 try_get::<T, _>——没有类型兼容验证。典型用例是自定义类型没在 TypeChecking 映射表里、你想自己保证一致——unchecked 让宏不强制用表里的类型。

input.checked: bool 字段(input.rs:20)表达——默认 true,query_unchecked! 走 false 分支。quote_query_as 里的 match 就区分这两种模式(第 1 章 §1.5.1 的 ColumnType::Exact vs ColumnType::Wildcard)。

11.10 query! 宏的限制

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

限制 1:SQL 必须字面量

rust
// 合法
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() + 手写 FromRow。

限制 5:编译时间增加

每条 query! 需要一次 describe——加 10ms / 查询。500 条 query! 加 5 秒编译时间。大项目影响可感但可忍。

限制 6:DATABASE_URL 泄漏风险

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

11.10.1 编译期宏路径全景图

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

这张图的五条关键信息:

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

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

11.10.2 生产环境的 .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 里加:

yaml
- 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 了——merge 时 .sqlx/query-<hash>.json 文件名相同但内容冲突。解法:merge 后在合并 branch 重新 cargo sqlx prepare——让 JSON 文件反映合并后的 SQL。直接手动解决 JSON 冲突风险大,容易漏 nullability / type 字段。

规范 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.10.3 query! 宏的编译期开销量化

query! 的编译期触达 DB 有多大开销?分场景量化:

场景额外开销说明
第一次 cargo check(在线)3-5 秒 + 50ms/queryproc macro 加载 + DB 连接 + N 次 describe
Incremental build(改一条 SQL)50-100ms只重新 describe 改动的 query
cargo sqlx prepare等同于完整 cargo check每条 query 都 describe 一次
CI 在 SQLX_OFFLINE=true<10ms / query只读 JSON 文件,无网络

一个 500 条 query! 的生产 crate 的实测:

  • 在线首次编译:约 30 秒(25 秒 describe + 5 秒 rustc)。
  • 在线 incremental:改一条 SQL 约 2 秒。
  • cargo sqlx prepare:约 28 秒(接近首次编译)。
  • CI 离线:约 500ms(从 JSON 读 500 个文件)+ 正常 rustc 时间。

离线模式明显更快——这也是为什么 CI 必须用 SQLX_OFFLINE=true:25 秒 describe 在 CI 每次运行都出现无法接受。

这条数据让你评估 sqlx query! 对项目的编译时间影响——中小项目(<100 query!)影响可忽略大项目(>500 query!)首次编译加 30 秒。如果你的项目预期 query! 数量会激增(数千条),可能要考虑"把数据访问层拆成独立 crate"——隔离 proc macro 的编译开销。

11.10.4 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 生态的设计哲学。

实践中的折衷:绝大多数用过 sqlx query! 的团队认同其价值——类型安全的好处在日常开发中非常明显。但也普遍认为这条路径"不是完美方案"——需要 .sqlx/ 维护、DATABASE_URL 管理、CI 工作流调整,各种工程复杂性。

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

一个观察:2023 年之后新的 Rust SQL 库(如 rbatis 某些版本、cornucopia)普遍选择"codegen 而非 proc macro 连 DB"——也就是效仿 Diesel 或 Prisma 的路线。sqlx 在这个维度上是"少数派领先"——但少数派本身不意味着错。理解这条争议,你对 Rust 生态"类型安全 vs 工程简洁"的取舍谱系有了更完整的视角。

11.11 本章小结

本章把 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)—— 编译期 block_on 跑 describe。连接进程级缓存(§11.4.1)让 500 条 query! 只建一个连接。
  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 字面量。速度比运行时 try_get 快一层(跳过 HashMap + compatible)。
  7. Nullability 推断(§11.7)—— Postgres 查 pg_attribute + JOIN tree 分析最精确;MySQL 的 NOT_NULL flag 不可靠;SQLite 无 nullability 信息。
  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 时最大的工程投入。

下一章我们进入第四部分"连接与事务"——Connection trait 的生命周期协议:acquire、close、ping、begin,以及 PoolConnection 的 Drop 实现如何做连接归还。

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

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

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

bash
# 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

env
DATABASE_URL=postgres://localhost/myapp_dev

步骤 3:创建表

bash
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

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

步骤 5:写第一条 query!

rust
// src/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 慢几秒。之后 incremental build 只重新 describe 改动的 query。

步骤 7:CI 配置

bash
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.12.1 query! 宏的错误信息

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

错误 1:列不存在

error: column "emial" does not exist
  -->  src/main.rs:10:5
   |
10 |     sqlx::query!("SELECT emial FROM users", ...)
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Postgres 的错误消息原样传出——一看就知道是 SQL 拼错。

错误 2:参数数量不匹配

error: expected 2 parameters, got 1
  -->  src/main.rs:15:5
   |
15 |     sqlx::query!("SELECT * FROM users WHERE id = $1 AND name = $2", 42_i32)
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

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

错误 3:类型不匹配

error: mismatched types
  --> src/main.rs:20:5
   |
20 |     let id: String = row.id;
   |             ------   ^^^^^^ expected `String`, found `i32`
   |             |
   |             expected due to this

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

错误 4:Nullability 不对

error: mismatched types
  --> src/main.rs:25:5
   |
25 |     let email: String = row.email;
   |                ------   ^^^^^^^^^ expected `String`, found `Option<String>`

email 列可空——宏推断 Option<String>——用户声明 String 失败。加 ? 解包或改类型。

错误 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:数据库连不上

error: error occurred while decoding column ...
   = note: failed to connect to the database ...

本地 DB 没启动、URL 错、credentials 错——总之 prepare 过程无法描述 SQL。

读懂这六类错误覆盖 90% 的使用场景。query! 宏的错误体验其实很好——DB 错误透传、类型错误走 Rust 原生系统——比大多数 ORM 的"运行时抛异常"清晰得多。

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

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

工具编译/检查时机类型来源触达 DB?
sqlx::query! (Rust)cargo checkDB describe
Diesel (Rust)cargo checkschema.rs 本地文件
F# SQLProviderF# 编译DB 元信息
TypeScript + Prismaprisma generate + tscschema.prisma 文件生成时是
Java + JOOQmaven code gen + javacDB 元信息(生成时)生成时是
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 generatejooq-codegen 必须单独跑——工作流里多一步。sqlx 的 "cargo check 一步到位"省了这步——代价是每次编译都要连 DB 或读 .sqlx/(靠离线缓存缓解)。

工具哲学的差异:Rust 的 proc macro 可以做 I/O(虽然社区对此有争议)——sqlx 利用这点把"代码生成"直接塞进编译过程。其他语言要么 proc macro 不够强大(Ruby / Python)、要么社区规范禁止 proc macro 做 I/O(TypeScript / Java 走专门的 code gen tool)——所以都是两阶段。sqlx 的这条路径是 Rust 语言能力 + proc macro 灵活性共同支撑的——换到任何其他主流语言都不容易复刻。

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

11.14 query! 的演进里程碑

从 sqlx 0.1 到 0.8 的 query! 演进:

  • 0.1(2019-09)query! 宏首次发布。只支持 Postgres、没有离线模式——编译期必须连 DB。很多开发者觉得"太疯狂"拒绝采用。
  • 0.2(2019-12):MySQL 支持加入。query! 的跨 DB 能力初现。
  • 0.3(2020-05)离线模式雏形——sqlx-data.json(那时候不叫 .sqlx/)作为缓存文件。CI 友好大幅改善。
  • 0.4(2020-10)query_as!query_scalar! 变体加入;as "x: T" 类型 override 语法;SQLite 支持。
  • 0.5(2021-02)sqlx-cli prepare 命令化;离线模式成为生产工作流标配。
  • 0.6(2022-06)每查询一个 JSON 文件(替代单一 sqlx-data.json)——让 merge conflict 处理容易;.sqlx/ 目录结构定型。
  • 0.7(2023-07):GAT + crate 拆分后 query! 内部代码大幅重构;type override 语法精化(!? 明确区分)。
  • 0.8(本书版本)DatabaseExt trait 稳定;QueryDriver vtable 明确;错误信息改进。

这条演进显示 query! 不是一夜之间长成今天的样子——五年迭代里每个版本都在完善工作流。0.3 的离线模式是转折点——之前社区对 sqlx 的态度是"好酷但没法用在团队",之后变成"可以在生产用了"。

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

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

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

适合 query! 的项目

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

不适合 query! 的项目

  • SQL 极多(1000+)——编译开销 + .sqlx/ 维护负担过大。
  • 动态 SQL 比例高(>50%)——大部分场景用不上 query!(§10.14 讨论过)。
  • 新手团队——"第一次看到 CI 失败不知道要 prepare"这类问题消耗精力。
  • 用第三方驱动(ClickHouse、TiDB)——根本用不了 query!。
  • 纯 SQLite 项目——SQLite 的 nullability 推断缺失、类型推断偏弱,query! 收益小。

实际生产里的折衷

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

三层工具分工——各取所长、避免 query! 在不适合的场景被强行塞。这是成熟 sqlx 团队的实践模式。

11.16 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 配置)占了用户学习时间的 80%。成功的 proc macro 必须想清楚工作流 ——文档、错误消息、CLI 辅助工具都同等重要。

5. 接受"不完美"和 trade-off。query! 不是完美方案——它有编译时间代价、.sqlx/ 维护成本、DATABASE_URL 管理复杂性。sqlx 团队没假装这些问题不存在——文档里明确说了每个 trade-off。真诚比完美更有效——让用户自己判断 trade-off 合不合算,而不是"兜售完美"。

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

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

11.17 总结思考:query! 和 sqlx 的"辨识度"

第三部分"查询 API"到这里收尾。回看 query (第 9 章)、QueryBuilder(第 10 章)、query!(本章)三条路径,它们共同构成 sqlx 的完整查询构造工具箱:

  • query() 解决"静态 SQL + 运行时类型安全"——最基础、覆盖 40% 用例。
  • QueryBuilder 解决"动态 SQL + 运行时类型安全"——覆盖 30% 用例。
  • query! 解决"静态 SQL + 编译期类型安全"——覆盖 30% 用例但价值最大。

如果让我用一句话概括为什么 query! 是 sqlx 的"辨识度特性":它把 DB schema 的真理引入 Rust 类型系统——这件事其他 Rust SQL 库都不做(diesel 用 schema.rs 是二手信息)。sqlx 唯一做了这件事、做得还不错——于是它成为"有独特价值的 SQL 工具包"而不是"又一个 SQL 库"。

对 Rust 后端开发者的影响:

  • 采纳 sqlx 的团队通常把 query! 视为"不可替代的特性"——一旦习惯,切换到不连 DB 的库会觉得"盲写"。
  • 评估 sqlx 的团队通常对 query! 持矛盾态度——既向往类型安全、又忌惮 .sqlx/ 工作流。
  • 用过后转投其他库的团队相对罕见——大多数是场景不适合(第三方驱动、动态 SQL 为主),不是 query! 本身不好。

这种"有独特性、有争议、但采纳者不愿放弃"的特征是经典的好设计标志——想想 Rust 的 ownership、Go 的 goroutine、Haskell 的 monad——都是初看疯狂、深入发现不可替代的设计。

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

基于 VitePress 构建