SQLx 源码深度解析
第11章 query! 编译期校验宏:在线连接与离线缓存的分裂
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_NULLflag,SQLite 走 VDBEEXPLAIN推断 +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);
}
}
// ... 错误处理 ...
}
五步:
- 读
CARGO_MANIFEST_DIR环境变量(Cargo 编译时总会设)。 - 根据
SQLX_OFFLINE/DATABASE_URL决定 data_source 是 Live 还是 Cached。 - Cached 模式下查三个可能的
.sqlx/路径(.env里的SQLX_OFFLINE_DIR> crate 本地 > workspace 根)。 - 用
data_source.matches_driver(driver)找对应驱动(按 URL scheme 或 db_name)。 - 调
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 内部做的事:
- 在
crate::block_on里从缓存 HashMap 取或新建一个 PgConnection(block_on定义在sqlx-macros-core/src/lib.rs:54-82,启用 tokio 时是一个惰性创建、全程复用的 current-thread runtime)。 - 发
Parse+Describe::Statement消息。 - 接收
RowDescription(列信息)和ParameterDescription(参数信息)。 - 再查
pg_catalog.pg_attribute并跑一次EXPLAIN (VERBOSE, FORMAT JSON)推断可空性(§11.7)。 - 返回
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):
- 先建好 / 清空缓存目录(只删
query-*.json,避免误删用户文件),再设置一次"最小重编译"让用到 sqlx-macros 的 crate 重新展开宏。 - 起一个子进程跑
cargo check,带上三个环境变量:SQLX_TMP(临时目录)、SQLX_OFFLINE=false(强制走在线 describe)、SQLX_OFFLINE_DIR=<.sqlx 目录>;有--database-url时再补一个DATABASE_URL。 - 宏这边照常生成 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)。 - 结果:
.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/ 的维护工作流
标准流程:
- 开发者改了 SQL(例如新加一个
query!)。 - 本地
DATABASE_URL=... cargo sqlx prepare——生成 / 更新 JSON。 git add .sqlx/ && git commit——JSON 和 SQL 改动一起提交。- 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 featurexxx";否则提示"没有内建映射,可能需要类型 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-<hash>.json"]
Cached["QueryDataSource::Cached"]
Match["matches_driver 选驱动"]
Expand["driver.expand = expand_with::<DB>"]
DBConn["Postgres::describe_blocking"]
DBMsg["Parse + Describe 消息 → Postgres"]
DBResp["RowDesc + ParamDesc + pg_attribute + EXPLAIN"]
Describe1["Describe<DB>"]
Describe2["Describe<DB> 反序列化"]
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
这张图的五条关键信息:
- 两条并行路径——Live 和 Cached,根据 env 决定走哪条。
- Live 路径有网络——要连 DB、发协议消息、等响应。
- Cached 路径纯文件 I/O——读 JSON 文件、反序列化。
- 两条路径汇聚到 Describe
<DB>——之后共享同样的代码生成。 - 最终产物是 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! 宏的"编译期触达外部世界"机制完整拆开:
- 三个宏
query!/query_as!/query_scalar!共享同一个expand_input入口(§11.2)。三个宏通过RecordTypeenum 区分模式。query_file!让 SQL 从外部文件读。 - expand_input 指挥中心(§11.3)——读 env 决定 data source、查
.sqlx/三个路径、调用 QueryDriver 的 expand 函数指针。 - QueryDriver 是 const fn 构造的 vtable(§11.3.1)——类似 AnyDriver 但工作在编译期。
FOSS_DRIVERS是三家驱动的静态数组。 - 在线模式用 describe_blocking(§11.4)—— Postgres / MySQL 在编译期用 block_on 跑 describe,连接进程级缓存(§11.4.1)让同一个 rustc 进程里、同一个 DATABASE_URL 下的所有 query! 只建一个连接;SQLite 同步 describe、每次新开。
- 离线模式读 .sqlx/query-
<hash>.json(§11.5)—— 每条 query 一个 JSON 文件。cargo sqlx prepare生成、git 提交、CI 下SQLX_OFFLINE=true用。 - columns_to_rust + quote_query_as(§11.6)—— 把 Describe 翻成
try_get_unchecked::<T, _>(idx)链 + struct 字面量。按位置取值、跳过运行时的compatible检查。 - Nullability 推断(§11.7)—— Postgres 查
pg_attribute再用EXPLAIN计划打补丁;MySQL 只有协议的NOT_NULLflag;SQLite 靠 VDBEEXPLAIN推断,再以sqlite3_table_column_metadata兜底。 - 类型 override 三语法(§11.8)——
as "x: T"/as "x!: T"/as "x?: T"覆盖自动推断。 - 六条限制(§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;同版把offlinefeature 从 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 章详细。