SQLx 源码深度解析
第10章 QueryBuilder:运行时拼 SQL 的类型安全做法
运行时拼 SQL 是类型安全的最后一公里。
QueryBuilder的三个字段给出的答案是:裸 SQL 由你负责,参数占位符由它按驱动生成。
本章要点
QueryBuilder<'args, DB>(sqlx-core/src/query_builder.rs:24-31)是 sqlx 对"运行时动态拼 SQL"的官方答案——三字段:query: String(SQL 字符串 buffer)、init_len: usize(原始前缀长度)、arguments: Option<DB::Arguments<'args>>。push(impl Display)追加裸 SQL——由你负责避免注入。用来写列名、表名、关键字这种"可以用 Display 表达、参数无法表示"的部分。push_bind<T>(value)追加参数占位符与绑定值——自动调Arguments::format_placeholder(第 6 章 §6.7)生成 Postgres 的$N或 MySQL/SQLite 的?,跨 DB 完全透明。separated(sep)返回Separated<Sep>——一个装饰器,让多次 push/push_bind 自动在中间插入分隔符。IN (?, ?, ?)列表最常用。push_values(iter, |row, item| ...)—— 批量 VALUES 构造器。每个 item 生成一个(?, ?, ?)子 tuple。生产里批量 INSERT 的主力。push_tuples(iter, |tuple, item| ...)—— 类似 push_values 但生成WHERE (col1, col2) IN ((v1,v2), ...)形态。build()返回Query<'_, DB, Arguments>——拼好的 QueryBuilder 转回普通 Query,之后一切回到第 9 章的路径。build_query_as/build_query_scalar是带输出映射的变体。- QueryBuilder 不会校验 SQL——
push("SELECT ;")能编译、执行时 DB 报语法错。类型安全只在参数绑定层。
10.1 问题引入:动态 SQL 的三种挑战
一条典型的动态查询需求:
"用户可以按 name / email / role 任意组合过滤,还要支持按任意列排序。"
翻译成 SQL:
SELECT * FROM users
WHERE 1=1
AND name ILIKE $1 -- 可选
AND email ILIKE $2 -- 可选
AND role = $3 -- 可选
ORDER BY created_at DESC -- 列名也可变
LIMIT $4
这条 SQL 里有三种动态:
- WHERE 子句可变——有没有 name / email / role 过滤、三者组合。
- ORDER BY 列可变——用户能选按 created_at / last_login / name 排。
- 参数位置可变——WHERE 缺一条,后面所有
$N编号都要减 1。
第 9 章的 query() + .bind() 无法优雅处理这种动态——SQL 字符串是静态字面量,参数编号跟位置硬绑定。两条常见错误路径:
format!拼 SQL:format!("SELECT ... WHERE name = '{}'", name)——SQL 注入大门敞开。- 条件 if 拼字符串:逐段
if let Some(..) = name { sql.push_str(...) }——冗长易错、占位符编号手动维护。
sqlx 的答案是 QueryBuilder——一个专门处理动态 SQL 的结构:
let mut qb = QueryBuilder::<Postgres>::new("SELECT * FROM users WHERE 1=1");
if let Some(name) = name_filter {
qb.push(" AND name ILIKE ").push_bind(name);
}
if let Some(email) = email_filter {
qb.push(" AND email ILIKE ").push_bind(email);
}
qb.push(" ORDER BY ").push(order_col) // order_col 由白名单校验过
.push(" DESC LIMIT ").push_bind(limit);
let users: Vec<User> = qb.build_query_as().fetch_all(&pool).await?;
每个 push_bind 自动管理占位符编号、每个 push 追加 SQL 片段、最后 build 转回 Query——既保留动态拼接的灵活性,又避免 SQL 注入。这章就是把 QueryBuilder 的每一条设计决定拆开。
10.2 QueryBuilder 的类型数据流
把 QueryBuilder 的主要操作画一张数据流图:
flowchart TD
N["QueryBuilder::new init"]
P["push(sql)"]
PB["push_bind(value)"]
SEP["separated(sep) → Separated"]
PV["push_values(iter, closure)"]
PT["push_tuples(iter, closure)"]
B["build() → Query"]
BQA["build_query_as::<T>() → QueryAs"]
BQS["build_query_scalar::<T>() → QueryScalar"]
R["reset()"]
N --> P & PB & SEP & PV & PT
P -->|链式 self| P
PB -->|链式 self| PB
P --> PB
PB --> P
SEP --> PV & PT
P --> B & BQA & BQS
PB --> B & BQA & BQS
B --> EX[Executor API]
BQA --> EX
BQS --> EX
B -.可选.-> R
R --> P & PB
这张图展示了 QueryBuilder 的三阶段生命周期:
- 构造阶段——
new初始化。 - 拼接阶段——
push/push_bind/push_values/push_tuples/separated任意组合、链式调用。 - 输出阶段——
build/build_query_as/build_query_scalar转回 Query 类型。
reset 让"构造好的 QueryBuilder 可以循环复用"——从输出阶段回到拼接阶段的起点。除了 reset 这一条,流程没有别的回环——一旦 build 被调过、必须 reset 才能继续 push。
10.3 QueryBuilder<'args, DB> 的结构
sqlx-core/src/query_builder.rs:24-31:
pub struct QueryBuilder<'args, DB>
where DB: Database,
{
query: String,
init_len: usize,
arguments: Option<<DB as Database>::Arguments<'args>>,
}
三个字段:
query: String——正在被拼接的 SQL 文本。不是&'a str——QueryBuilder 拥有这段字符串的所有权(避免第 9 章 §9.27 讨论过的生命周期脆弱问题)。init_len: usize——构造时的初始长度。reset() 会把 query 截回这个长度——让 QueryBuilder 可以在同一初始 SQL 基础上多次构建查询、复用字符串 buffer。arguments: Option<DB::Arguments<'args>>——参数集合,和第 6 章的 Arguments 机制完全一致。Option是因为build()会把它 take 出来转交 Query。
'args 生命周期和 Arguments 的 'q 等价——对应参数里可能的借用(SQLite 的 Cow<'q, str>)。PgArguments / MySqlArguments 本身不带生命周期(type Arguments<'q> = PgArguments,sqlx-postgres/src/database.rs:30),但这不等于 'args 可以忽略:push_bind 的 T: 'args 约束对所有驱动都生效,§10.6.2 会看到它的后果。
除了 new(init),还有两个构造入口:Default(空 SQL,0.7.1 起)和 with_arguments(init, arguments)(sqlx-core/src/query_builder.rs:67-79,接收一个已有的 Arguments)。QueryBuilder 自己从不调 Arguments::reserve,也不对外暴露 arguments 字段;想预分配参数容量,就先建好 Arguments、reserve 之后交给 with_arguments。注意 with_arguments 把 init_len 设为 init 的长度、参数却在 reset() 时被换成空的默认值:实测:init 里已含 $1、args 里有 1 个值时,reset() 之后再 push_bind,新占位符又从 $1 编起、参数只剩新加的 1 个——init 里本身带占位符时不要依赖 reset。
注意 QueryBuilder 没有 PhantomData<DB>——DB 直接出现在 arguments: Option<<DB as Database>::Arguments<'args>> 这个字段里。而第 9 章的 Query<'q, DB, A> 多带一个 database: PhantomData<DB>(sqlx-core/src/query.rs:20)——尽管它的 statement: Either<&'q str, &'q DB::Statement<'q>> 字段(sqlx-core/src/query.rs:18)同样含有 DB 的投影,从编译器角度这个 PhantomData 并非必需(只在关联类型投影里出现的类型参数也算"被使用",实测不报 E0392)。源码没有为这条差异留注释,本节只陈述这个可观察的事实,不替作者补动机。
10.4 push vs push_bind:核心二元
QueryBuilder 的灵魂是两个 push——追加 SQL 片段有两种语义:
10.4.1 push(impl Display):裸 SQL 片段
sqlx-core/src/query_builder.rs:116-122:
pub fn push(&mut self, sql: impl Display) -> &mut Self {
self.sanity_check();
write!(self.query, "{sql}").expect("error formatting `sql`");
self
}
只调 write! 把 Display 格式化结果追加到 query——不做任何转义、不生成占位符。参数类型是 impl Display 而不是 &str——让你可以传字符串、数字、任何能 Display 的类型。
push 适用场景:
- 关键字:
qb.push(" AND "),qb.push(" ORDER BY ")。 - 列名 / 表名:
qb.push(order_col)——order_col必须来自白名单,否则注入风险。 - 数字常量:
qb.push(limit as i64)—— 也可以,但通常用 push_bind 更安全。
push 不适用(会注入):
- 用户输入的字符串值:
qb.push(user_input)—— 任意'; DROP TABLE --都会被拼进 SQL。永远用 push_bind。
10.4.2 push_bind<T>(value):参数化
sqlx-core/src/query_builder.rs:148-165:
pub fn push_bind<T>(&mut self, value: T) -> &mut Self
where T: 'args + Encode<'args, DB> + Type<DB>,
{
self.sanity_check();
let arguments = self.arguments.as_mut().expect("BUG: Arguments taken already");
arguments.add(value).expect("Failed to add argument");
arguments.format_placeholder(&mut self.query).expect("error in format_placeholder");
self
}
三步:
arguments.add(value)—— 把值消化进 Arguments(第 6 章 §6.2):Postgres / MySQL 立即 encode 成字节,SQLite 存成SqliteArgumentValue。arguments.format_placeholder(&mut self.query)—— 往 query 写占位符。- 返回
&mut self供链式。
关键:add 和 format_placeholder 的调用顺序。add 先把参数计入 count,format_placeholder 再读 count 生成占位符——保证 Postgres 的 $1 / $2 / ... 编号和添加顺序一致(第 6 章 §6.7 讨论过这条顺序的必要性)。
注入防御:参数值永远通过 Arguments 传——进到服务端时是独立的参数槽、不参与 SQL 解析。user_input = "'; DROP TABLE --" 在 push_bind(user_input) 之后字符串完整保留、作为参数值发过去、WHERE 条件里就是"name = '; DROP TABLE --'"(完整字面量比较)。
10.4.3 push 和 push_bind 的典型搭配
qb.push(" WHERE name = ") // 裸 SQL
.push_bind(name) // 参数
.push(" AND role IN (") // 裸 SQL
.push_bind(role1) // 参数
.push(", ") // 裸 SQL 分隔符
.push_bind(role2) // 参数
.push(")"); // 裸 SQL
读起来的节奏——关键字 / 操作符 / 括号用 push、值用 push_bind。这条规则的例外是"列名和表名"——它们既不是关键字也不是值,必须 push。但 push 的内容必须是白名单校验过的。
白名单校验看起来像这样:
let order_col = match user_order.as_str() {
"name" | "created_at" | "email" => user_order,
_ => return Err(Error::InvalidOrder),
};
qb.push(" ORDER BY ").push(order_col);
用 match 锁死可接受的列名——user_order 不管来自哪,最终进 push 的 order_col 必然是三者之一。
10.5 separated(sep):列表分隔符
手动拼 (?, ?, ?) 的分隔符很麻烦——第一项前不加逗号、后续项都加。Separated 装饰器解决这个:
sqlx-core/src/query_builder.rs:195-207:
pub fn separated<'qb, Sep>(&'qb mut self, separator: Sep) -> Separated<'qb, 'args, DB, Sep>
where 'args: 'qb, Sep: Display,
{
self.sanity_check();
Separated {
query_builder: self,
separator,
push_separator: false,
}
}
返回一个 Separated<'qb, ...> ——借用 query_builder 的装饰器。Separated 提供四个方法(sqlx-core/src/query_builder.rs:544-599):
push(sql)—— 如果push_separator=true,先写 separator;再写 sql;标志置 true。push_bind(value)—— 同上,但中间部分是 push_bind。push_unseparated(sql)—— 不写 separator、直接写 sql。push_bind_unseparated(value)—— 同上但绑定。
push_separator 标志初始 false —— 第一次 push 不写 separator、之后全写。典型用法:
let ids = vec![1, 2, 3, 4];
let mut qb = QueryBuilder::<Postgres>::new("SELECT * FROM users WHERE id IN (");
let mut sep = qb.separated(", ");
for id in &ids {
sep.push_bind(id); // 第一个产 "$1",后续产 ", $2" / ", $3" / ", $4"
}
qb.push(")");
结果 SQL:SELECT * FROM users WHERE id IN ($1, $2, $3, $4)。
Separated 的设计是 Rust 里 "fluent 装饰器"的经典例子——通过借用 + 内部标志实现"第一次不同、后续一致"的迭代语义。
10.5.1 push_unseparated 的用处
let mut sep = qb.separated(", ");
sep.push_bind(1);
sep.push_bind(2);
sep.push_unseparated(")"); // 不想要 ", )" 这种尾部分隔符
没有 push_unseparated,你得先写完所有值、再从 qb 本身 push 闭括号——代码跳出 sep scope。push_unseparated 让闭括号在 sep 的范围内写完——略优雅。
10.6 push_values:批量 VALUES
sqlx-core/src/query_builder.rs:306-332:
pub fn push_values<I, F>(&mut self, tuples: I, mut push_tuple: F) -> &mut Self
where
I: IntoIterator,
F: FnMut(Separated<'_, 'args, DB, &'static str>, I::Item),
{
self.sanity_check();
self.push("VALUES ");
let mut separated = self.separated(", ");
for tuple in tuples {
separated.push("(");
// use a `Separated` with a separate (hah) internal state
push_tuple(separated.query_builder.separated(", "), tuple);
separated.push_unseparated(")");
}
debug_assert!(
separated.push_separator,
"No value being pushed. QueryBuilder may not build correct sql query!"
);
separated.query_builder
}
核心是两层 Separated 嵌套——外层用 , 分隔 tuple、内层(传给 closure)用 , 分隔 tuple 内的字段。
典型用法:
let users = vec![("Alice", 30), ("Bob", 25), ("Carol", 35)];
let mut qb = QueryBuilder::<Postgres>::new("INSERT INTO users (name, age) ");
qb.push_values(users.iter(), |mut row, u| {
row.push_bind(u.0).push_bind(u.1);
});
展开后的 SQL:
INSERT INTO users (name, age) VALUES ($1, $2), ($3, $4), ($5, $6)
6 个参数(3 行 × 2 列)。一次 round-trip 插入多行——省掉了逐行 INSERT 的多次往返,行数越多省得越明显。
行数不固定时要留意语句缓存:每种行数都是一条不同的 SQL,build() 出来的 Query 默认 persistent: true,会在连接的预处理语句缓存里各占一格。源码注释(sqlx-core/src/query_builder.rs:226-228)建议这种情况在 build() 之后调 .persistent(false)。
10.6.1 参数上限
sqlx-core/src/query_builder.rs:126-147 的注释列出各家数据库的参数上限,下表逐条补上一手依据:
| 数据库 | 单条 SQL 参数上限 | 依据 |
|---|---|---|
| Postgres | 65535 | 官方文档附录 K 限制表 "query parameters 65,535";sqlx 发送前做 u16::try_from(arguments.len()),超出直接返回协议错误(sqlx-postgres/src/connection/executor.rs:219-224) |
| MySQL | 65535 | COM_STMT_PREPARE_OK 里参数个数是 2 字节字段(sqlx-mysql/src/protocol/statement/prepare_ok.rs:36 按 u16 读);超出时服务端报 1390 ER_PS_MANY_PARAM |
| SQLite 3.32.0+ | 32766 | sqlite.org/limits.html 的 SQLITE_MAX_VARIABLE_NUMBER 默认值;sqlx 的 sqlite feature 捆绑 libsqlite3-sys 0.30.1 里的 SQLite 3.46.0,实测 32766 个参数可执行、32767 个报 too many SQL variables |
| SQLite <3.32.0 | 999 | 同上 |
| MSSQL | 2100 | 只出现在这段注释里;sqlx 0.7.0 起已删除 MSSQL 驱动(CHANGELOG 0.7.0) |
SQLite 这一项可以改:捆绑编译时 libsqlite3-sys 读取 SQLITE_MAX_VARIABLE_NUMBER 环境变量(见 libsqlite3-sys 0.30.1 的构建脚本),运行时则只能用 sqlite3_limit 调低、不能调高。超限的结果也因驱动而异:Postgres 在 sqlx 这一侧就返回错误,MySQL / SQLite 由数据库返回错误。
实际意义:假设你要批量插入 10 万行、每行 4 列,总参数数 40 万——超过任何 DB 的单语句上限。正确做法是按 batch 拆:
const BATCH_SIZE: usize = 10_000; // 4 列 × 10000 = 40000 参数,低于 65535
for chunk in users.chunks(BATCH_SIZE) {
let mut qb = QueryBuilder::<Postgres>::new("INSERT INTO users (name, age, email, role) ");
qb.push_values(chunk, |mut row, u| {
row.push_bind(&u.name).push_bind(u.age).push_bind(&u.email).push_bind(&u.role);
});
qb.build().execute(&pool).await?;
}
10 万行按每批 10000 行切,共 10 次 execute,每次都在参数上限之下。
10.6.2 'args 与按引用绑定
它的文档注释里有一段提示(sqlx-core/src/query_builder.rs:230-234):
Because the
ArgumentsAPI has a lifetime that must live longer thanSelf, you cannot bind by-reference from an iterator unless that iterator yields references that live longer thanSelf, even if the specificArgumentsimplementation doesn't actually borrow the values (likeMySqlArgumentsandPgArgumentsimmediately encode the arguments and don't borrow them past the.add()call).
意思是:即使 Postgres/MySQL 的 Encode 已经立即拷贝字节,push_bind 进去的值(包括从 I::Item 借出的引用)仍需满足 T: 'args、活过 QueryBuilder 的最后一次使用。这是 Arguments trait 的 'args bound 导致的——类型系统不知道具体 Encode 实现立即拷贝,所以保守地要求生命周期对齐。
上游文档给的办法(同文件 :236-239)是用 .iter() 而不是 .into_iter()——让闭包接到 &User。要补一条它没写全的前提:被借的数据必须一直活到 QueryBuilder 最后一次被使用(build、执行、reset 都算)之后。
let users = load_users();
let mut qb = QueryBuilder::<Postgres>::new("INSERT INTO users (name) ");
qb.push_values(users.iter(), |mut r, u| { r.push_bind(&u.name); });
qb.build().execute(&pool).await?; // users 此时仍然活着
users 和 qb 的声明先后倒不要紧——QueryBuilder 没有 Drop 实现,实测把两行对调,Postgres 和 SQLite 都能编译通过。真正会出错的是数据先于 builder 的最后一次使用被释放:数据若是循环里临时产生的,而 builder 放在循环外反复 reset 复用,batch.iter() 就过不了(实测 E0597 `batch` does not live long enough,报错指向下一轮的 qb.reset())。这种场合按值绑(into_iter()、或者 clone() / to_owned())才是稳的写法——owned 值对任何 'args 都成立。
这条细节让 push_values 在某些场景比表面看起来更啰嗦——但一旦你理解"为什么这样",就能正确写。
10.7 push_tuples:批量多列 IN
sqlx-core/src/query_builder.rs:418-439 的 push_tuples 结构和 push_values 几乎一样,生成的 SQL 形态是:
SELECT * FROM users WHERE (id, email) IN ((1, 'a@ex.com'), (2, 'b@ex.com'), ...)
典型用法:查询"这批 (id, tenant_id) 组合"的行:
let pairs = vec![(1, 100), (2, 101), (3, 102)];
let mut qb = QueryBuilder::<Postgres>::new("SELECT * FROM users WHERE (id, tenant_id) IN");
qb.push_tuples(pairs, |mut t, (id, tid)| {
t.push_bind(id).push_bind(tid);
});
push_tuples 相对 push_values 的差异在两个 wrapper 字符串——开头的 "VALUES " 换成 " (",结尾多一个 push_unseparated(") ")——以及 push_tuples 没有空集合的 debug_assert。核心逻辑完全一样。
实际用途上,push_values 是常用的那个(INSERT 批量)、push_tuples 少见(复合 key 查询)——前者是高频写操作、后者是偶发读操作。
10.8 build / build_query_as / build_query_scalar:转回 Query
sqlx-core/src/query_builder.rs:453-462:
pub fn build(&mut self) -> Query<'_, DB, <DB as Database>::Arguments<'args>> {
self.sanity_check();
Query {
statement: Either::Left(&self.query),
arguments: self.arguments.take().map(Ok),
database: PhantomData,
persistent: true,
}
}
把 QueryBuilder 的三字段组装成第 9 章的 Query 结构:
statement: Either::Left(&self.query)—— SQL 字符串借用(不 clone!QueryBuilder 自身保留字符串所有权)。arguments.take()—— 把 arguments 从 QueryBuilder 取走、转进 Query。database: PhantomData+persistent: true—— 初始状态。
这个 build 借 self 返回 Query<'_, ...> —— '_ 是 &mut self 的生命周期。意味着返回的 Query 不能活过 QueryBuilder——一旦 QueryBuilder drop,query 字符串失效、Query 也就失效。
典型用法:
let mut qb = QueryBuilder::<Postgres>::new("...");
qb.push(...).push_bind(...);
let query = qb.build(); // query 借 qb
query.fetch_all(&pool).await?; // await 过程中 qb 必须在 scope
实际代码里几乎永远是 qb.build().fetch_all(...) 链式——qb 在链式表达式的作用域里活够长。问题出现在你把 query 传给其他地方(比如 spawn 给 task),那时候 qb 的生命周期不够——直接的办法是把 QueryBuilder 本身 move 进 task、在 task 里再 build(参数都是 owned 值时它就是 QueryBuilder<'static, DB>,SQLite 实测可行)。.into_sql() 只拿得到 SQL 文本,参数会丢掉。
10.8.1 build_query_as 和 build_query_scalar
pub fn build_query_as<'q, T: FromRow<'q, DB::Row>>(&'q mut self)
-> QueryAs<'q, DB, T, DB::Arguments<'args>>
{
QueryAs { inner: self.build(), output: PhantomData }
}
build + QueryAs 包装——相当于 sqlx::query_as::<_, T>(&sql).bind(...).bind(...) 但中间过了 QueryBuilder。用法:
let users: Vec<User> = qb.build_query_as::<User>().fetch_all(&pool).await?;
这是最常用的动态 SQL 路径——拼 + 类型安全映射一站式。
build_query_scalar 类似——返回 QueryScalar<T>,取单列。
这三个 build 方法让 QueryBuilder 无缝接入第 9 章的 Query API——拼完后的所有操作(fetch_one / fetch_all / execute / persistent)都和普通 Query 一致。QueryBuilder 只是多一层"构造期"的装饰。
10.9 init_len / reset / sanity_check:复用机制
sqlx-core/src/query_builder.rs:513-528:
pub fn reset(&mut self) -> &mut Self {
self.query.truncate(self.init_len);
self.arguments = Some(Default::default());
self
}
pub fn sql(&self) -> &str { &self.query }
pub fn into_sql(self) -> String { self.query }
reset 把 query 截回 init_len、arguments 换成一个新的默认空实例——但 String 的 capacity 保留(truncate 只改长度不还内存)。让 QueryBuilder 在循环里复用:
let mut qb = QueryBuilder::<Postgres>::new("INSERT INTO logs (msg, level) ");
for batch in batches {
qb.reset(); // 恢复到 init,保留 capacity
qb.push_values(batch, |mut row, item| { ... });
qb.build().execute(&pool).await?;
}
reset 的收益是避免每次 loop 重新分配 SQL 字符串 buffer(arguments 那一侧仍然是新建的)——源码文档注释把这条收益写成 "amortize the allocation overhead of the query string"。
sanity_check(sqlx-core/src/query_builder.rs:82-87)用一条 assert! 检查 "arguments is Some",失败消息是 "QueryBuilder must be reset before reuse after `.build()`":build 会 .take() arguments 变成 None,之后再调 push / push_bind / separated / push_values / push_tuples 或任何一个 build 方法都会触发 sanity_check 的 panic(实测)——防止误用。reset()、sql()、into_sql() 不做这项检查,build 之后照常可用。正确使用是build 后立即 execute / fetch,或者先 reset 再继续。
10.10 占位符跨 DB 自动适配
QueryBuilder 最优雅的地方在占位符生成完全透明。同一段 Rust 代码:
let mut qb = QueryBuilder::<DB>::new("SELECT * FROM t WHERE a = ");
qb.push_bind(x).push(" AND b = ").push_bind(y);
在不同 DB 下产出不同 SQL:
- Postgres:
SELECT * FROM t WHERE a = $1 AND b = $2 - MySQL / SQLite:
SELECT * FROM t WHERE a = ? AND b = ?
这条自动适配靠 Arguments::format_placeholder 方法(第 6 章 §6.7)——Postgres 覆盖写 ${count}、MySQL / SQLite 走默认 ?。QueryBuilder 自己不知道 DB 用哪种占位符——它只是把占位符生成完全委托给 Arguments。
这让跨 DB 迁移变得简单——你的业务代码用 QueryBuilder 拼 SQL,换 DB 只要改 QueryBuilder<DB> / Pool<DB> 的类型参数,占位符自动跟着变。例外是 Any 驱动:AnyArguments 没有覆盖 format_placeholder(全树只有 Postgres 一处覆盖),QueryBuilder<Any> 一律生成 ?(实测),连到 Postgres 时就对不上 $N。前提是你不在 push() 里写 DB 特有语法(Postgres 的 RETURNING、MySQL 的 ON DUPLICATE KEY UPDATE 等)——这些方言差异 QueryBuilder 不帮你处理。
10.11 QueryBuilder 的局限与 Postgres UNNEST 替代
QueryBuilder 不是万能的。sqlx-core/src/query_builder.rs:20-23 文档注释提醒:
Note, however, that with Postgres you can get much better performance by using arrays and
UNNEST(). See our FAQ for details.
为什么 UNNEST 更快?Postgres 下批量 INSERT 用 push_values 产生的 SQL 是:
INSERT INTO users (name, age) VALUES ($1, $2), ($3, $4), ..., ($99, $100)
服务端要解析的 VALUES tuple 数量随行数线性增长,SQL 文本本身也随行数变长——几十行时无所谓,几千行时解析开销就开始占比。
UNNEST 方案用数组传:
let names: Vec<&str> = users.iter().map(|u| u.name.as_str()).collect();
let ages: Vec<i32> = users.iter().map(|u| u.age).collect();
sqlx::query("INSERT INTO users (name, age) SELECT * FROM UNNEST($1::text[], $2::int4[])")
.bind(names)
.bind(ages)
.execute(&pool)
.await?;
SQL 文本长度和行数无关(永远是这一条固定语句、两个参数)、数据在两个数组参数里一次性发——服务端用 UNNEST 展开数组回归行集。sqlx 自己的 FAQ(FAQ.md,query_builder.rs 顶部文档注释链接过去)就是这么推荐 Postgres 批量插入的。
UNNEST 是 Postgres 的数组函数,MySQL / SQLite 没有它,这条路只在 Postgres 上走得通。
实战建议:日常批量插入用 QueryBuilder 的 push_values(可读性好、跨 DB);Postgres 上极致性能场景用 UNNEST(写死 Postgres 依赖、换来吞吐)。
10.12 push_bind 的参数编号生成示例
把跨 DB 的占位符生成用一个具体例子展示。同一段代码:
let mut qb = QueryBuilder::<DB>::new("SELECT * FROM users WHERE id = ");
qb.push_bind(1).push(" AND tenant = ").push_bind("acme").push(" AND active = ").push_bind(true);
let sql = qb.sql();
Postgres 下 sql 的内容:
SELECT * FROM users WHERE id = $1 AND tenant = $2 AND active = $3
每次 push_bind 后 count 自增、format_placeholder 写 $count——1、2、3。
MySQL / SQLite 下:
SELECT * FROM users WHERE id = ? AND tenant = ? AND active = ?
都是 ?——format_placeholder 的默认实现直接写。count 虽然在增,但 ? 本身不需要编号。
这条"同一 Rust 代码生成不同 DB 方言 SQL"的能力对跨 DB 应用(比如一份代码要能跑 Postgres 测试 + MySQL 生产)非常关键。只要你不在 push() 里用方言特有语法(CONCAT、DATE_TRUNC 的 Postgres 形式等),业务 DAL 的跨 DB 迁移就主要是"换数据库类型参数"加"改几条方言 SQL"。
换句话说,一份 MySQL 代码迁到 Postgres 时,纯粹由 push / push_bind 拼出来的那部分通常零改动——只换 MySql 到 Postgres,占位符自动从 ? 变 $N;真正要动的是方言相关的片段(ON DUPLICATE KEY UPDATE / RETURNING / JSON_EXTRACT 等)。这份"占位符差异不进业务代码"的收益,来自 QueryBuilder 把生成职责下沉到 Arguments。
10.13 动态查询的实战模式
把前面的内容合进一个实战例子——动态查询 + 批量插入结合:
pub struct UserFilter {
pub name: Option<String>,
pub email: Option<String>,
pub roles: Option<Vec<String>>,
pub order_by: Option<String>, // 白名单校验过
pub limit: i64,
}
pub async fn search_users(pool: &PgPool, f: UserFilter) -> Result<Vec<User>, Error> {
let mut qb = QueryBuilder::<Postgres>::new("SELECT * FROM users WHERE 1=1");
if let Some(name) = f.name {
qb.push(" AND name ILIKE ").push_bind(format!("%{}%", name));
}
if let Some(email) = f.email {
qb.push(" AND email ILIKE ").push_bind(format!("%{}%", email));
}
if let Some(roles) = f.roles {
// 注意两点:
// 1) `role = ANY(...)` 的括号里必须是**一个数组**,不能是 `$1, $2, $3`
// 这样的占位符列表——那是 `role IN (...)` 的写法,两者不能混。
// 这里直接 bind 整个 Vec,Postgres 侧就是一个 text[] 参数。
// 2) push_bind 的 bound 是 `T: 'args`,`'args` 要活得比 builder 长;
// `roles` 是 if 块里的局部变量、比 `qb` 先析构,所以这里**按值**
// move 进去,不要写 `.push_bind(&roles)`。
qb.push(" AND role = ANY(").push_bind(roles).push(")");
}
if let Some(col) = f.order_by {
let col = match col.as_str() {
"name" | "created_at" | "email" => col,
_ => return Err(Error::InvalidOrder),
};
qb.push(" ORDER BY ").push(col);
}
qb.push(" LIMIT ").push_bind(f.limit);
Ok(qb.build_query_as::<User>().fetch_all(pool).await?) // 需要 impl From<sqlx::Error> for Error
}
这个例子展示 QueryBuilder 的典型模式——逐段条件 push / push_bind、最后 build_query_as 接回类型安全 API。几十行代码完成一个"多维动态过滤 + 白名单 ORDER BY + 限制 LIMIT"的查询——换成手写字符串拼接 + 手动维护参数编号,代码量和出错面都要大得多。
10.14 QueryBuilder 与事务的配合
QueryBuilder 和事务一起用的常见模式:
pub async fn transfer(pool: &PgPool, from: i32, to: i32, amount: i64) -> Result<()> {
let mut tx = pool.begin().await?;
// 动态 UPDATE(有条件校验)
let mut qb = QueryBuilder::<Postgres>::new("UPDATE accounts SET balance = balance - ");
qb.push_bind(amount)
.push(" WHERE id = ").push_bind(from)
.push(" AND balance >= ").push_bind(amount);
let affected = qb.build().execute(&mut *tx).await?.rows_affected();
if affected != 1 {
return Err(Error::InsufficientFunds);
}
// 另一条静态 UPDATE
sqlx::query("UPDATE accounts SET balance = balance + $1 WHERE id = $2")
.bind(amount).bind(to)
.execute(&mut *tx).await?;
tx.commit().await?;
Ok(())
}
几条观察:
- QueryBuilder 和普通 query 混用合法——同一个 tx 里既
build().execute(&mut *tx)又sqlx::query(...).execute(&mut *tx)。两者最终都是 Query,Executor 视角等价。 &mut *tx的 DerefMut 对 QueryBuilder 的 Query 同样适用——第 4 章 §4.6 讨论过的规则。- 条件回滚靠 affected 检查——QueryBuilder 让"执行 + 检查影响行数"的 pattern 和普通 query 完全一致。
事务里的动态 SQL 尤其常见——"先动态查出要操作的 ID 列表、然后按 ID 批量 UPDATE"是电商、库存、消息队列等业务的基础模式。QueryBuilder + Transaction 的组合让这类代码既安全又灵活。
10.15 开销分析:QueryBuilder vs 字符串拼接
从源码看,QueryBuilder 相对"手动 format! + query + bind"没有引入任何额外机制——逐个方法对照就能确认:
new(init)—— 一次String分配(init.into()),和你自己String::from(...)等价。push(sql)—— 一次write!到已有 String,等价于push_str/write!。push_bind(v)——arguments.add(v)(就是query().bind(v)走的同一条路,见第 6 章)加一次format_placeholder写占位符;手写方案里"拼占位符"这一步你自己也得做。build()—— 一次Option::take加一次结构体构造,SQL 字符串是借用(Either::Left(&self.query))不 clone。
QueryBuilder 的本质是薄包装——它只是给"字符串拼 + 参数绑定"一个组织良好的 API,没有隐藏的分配或拷贝。价值在代码质量和注入防御,而不是省或费多少 CPU;真正的开销在 DB round-trip 上。
10.16 阶段小结
前面各节把 QueryBuilder 的动态 SQL 构造机制拆开:
- QueryBuilder 三字段(§10.3)——query + init_len + arguments。init_len 让 reset 能截回起点。
- push vs push_bind 的核心二元(§10.4)——前者裸 SQL(你负责安全)、后者参数化(类型安全 + 注入防御)。
- 典型搭配规则——关键字/操作符用 push、值用 push_bind、列名用白名单校验过的 push。
- separated 装饰器(§10.5)——"第一次不同、后续一致"的分隔符迭代。push_unseparated 做例外。
- push_values 批量 VALUES(§10.6)—— 两层 Separated 嵌套构造
(?, ?), (?, ?), ...。注意参数上限(Postgres 65535)。 - push_values 的
'args约束(§10.6.2)—— 按引用绑定要用 iter(),且数据要活过 builder 的最后一次使用;做不到就 into_iter() 按值绑。 - push_tuples(§10.7)—— 批量多列 IN 子查询形态。
- build 转回 Query(§10.8)—— 借 self、零拷贝 SQL 字符串引用。build_query_as / build_query_scalar 是带输出映射的变体。
- reset 复用机制(§10.9)—— init_len 截断 + arguments 换新,省掉 SQL 字符串 buffer 的重复分配。
- 占位符跨 DB 自动适配(§10.10)—— 靠 Arguments::format_placeholder 完全透明。
- UNNEST 替代(§10.11)—— Postgres 极致性能场景用数组 bind,跳过 O(N) 的 SQL 解析。
- 动态查询实战模式(§10.13)—— 逐段条件 + 白名单 ORDER BY + build_query_as 接回类型安全。
10.17 QueryBuilder 的几类典型用法
QueryBuilder 的 API 面很窄,落到业务代码里基本就是下面几类形态:
| 用法 | 用到的 API | 典型场景 |
|---|---|---|
| 动态 WHERE 过滤 | push + push_bind 交替 | search / list API |
| 批量 INSERT | push_values | ETL、bulk import |
| 动态 ORDER BY / LIMIT | push(白名单列名)+ push_bind | 排序可选的列表接口 |
IN (?, ?, ?) 列表 |
separated + push_unseparated | batch delete、cache invalidation |
| 复合 key IN 查询 | push_tuples | 多列组合过滤 |
| 动态 count / 单值 | build_query_scalar | 分页总数 |
动态 WHERE 是 QueryBuilder 最独特的价值——query! 宏无法表达(SQL 必须是字面量)、手动 format! 容易注入,QueryBuilder 是既动态又安全的那条路径。
批量 INSERT(push_values)是另一个高频场景——虽然 Postgres 下 UNNEST 通常更划算(§10.11),但要跨 DB 兼容的代码往往还是选 push_values。
剩下几种(ORDER BY、IN 列表、复合 key、单值 count)是业务代码的偶发需求——QueryBuilder 各自都有恰好合适的 API(push、separated、push_tuples、build_query_scalar),不需要绕。
10.18 QueryBuilder 的并发使用模式
QueryBuilder 设计为用完即弃的局部对象——每个拼接方法都要 &mut self。典型用法在 handler 里:
async fn handler(State(pool): State<PgPool>, Query(f): Query<UserFilter>) -> Result<Json<Vec<User>>> {
let mut qb = QueryBuilder::<Postgres>::new("SELECT * FROM users WHERE 1=1");
// 拼 SQL
qb.push(...).push_bind(...);
let result = qb.build_query_as::<User>().fetch_all(&pool).await?;
Ok(Json(result))
}
每个 handler 请求自己构造一个 QueryBuilder——不共享。handler 结束 QueryBuilder drop、下一个请求再造。
避免的模式:把 QueryBuilder 放进全局 static 或 Arc 共享。push / push_bind / build 全都要 &mut self,共享引用拿不到可变借用,想拼就得再套一层 Mutex——而这既没必要(一次 String 分配而已)又把并发请求串行化了。
高频循环场景才需要考虑 reset:
async fn batch_insert(pool: &PgPool, batches: Vec<Vec<User>>) -> Result<()> {
let mut qb = QueryBuilder::<Postgres>::new("INSERT INTO users (...) ");
for batch in batches {
qb.reset(); // 保留 buffer
// batch 每轮结束就释放,而 qb 跨轮复用:只能按值绑(见 §10.6.2)
qb.push_values(batch, |mut row, u| { ... });
qb.build().execute(pool).await?;
}
Ok(())
}
一个 QueryBuilder 处理多批——避免每批重新分配一次 SQL 字符串 buffer。批次越多、SQL 越长,这条复用越值。
10.19 QueryBuilder 和 diesel DSL 的对比
Diesel 的 DSL 和 sqlx QueryBuilder 解决同一问题——动态构造 SQL——但路径完全不同:
// Diesel DSL
use diesel::prelude::*;
use crate::schema::users::dsl::*;
let query = users.into_boxed();
let query = if let Some(n) = name_filter {
query.filter(name.ilike(format!("%{}%", n)))
} else { query };
let query = query.order(created_at.desc()).limit(50);
let results: Vec<User> = query.load(conn)?;
// sqlx QueryBuilder
let mut qb = QueryBuilder::<Postgres>::new("SELECT * FROM users WHERE 1=1");
if let Some(name) = name_filter {
qb.push(" AND name ILIKE ").push_bind(format!("%{}%", name));
}
qb.push(" ORDER BY created_at DESC LIMIT ").push_bind(50);
let results: Vec<User> = qb.build_query_as().fetch_all(pool).await?;
核心差异:
- Diesel 的 DSL 是类型系统里的表达式树——
.filter(id.eq(x))返回带具体类型的查询构造器。编译通过就意味着列名、类型与schema.rs的声明一致。 - sqlx QueryBuilder 是字符串拼接器——
.push(col_name)编译过但运行时 DB 可能报列不存在。类型系统只保证参数类型安全,SQL 文本正确性靠你。
Diesel 的代价:表达复杂 SQL(CTE、窗口函数、方言特有语法)需要查 Diesel DSL 文档找对应 API——有时候找不到只能 fallback 到 sql_query 字符串。
sqlx 的代价:运行时才能发现 SQL 拼错(列名、语法)——但 SQL 本身就是你亲手写的,通常不会偏离业务。
哪个更好?没有统一答案。Diesel 的 DSL 适合"代码库里 SQL 数量巨大、团队想要把列名和类型的正确性交给编译器";sqlx QueryBuilder 适合"SQL 相对简单、偶尔动态、团队已经熟 SQL"。sqlx 自己的定位偏向后者——README 的 "SQLx is not an ORM!" 一节明说它不提供构造查询的 Rust API 或 DSL,而是直接收普通 SQL。
10.20 QueryBuilder 和 query! 宏的选择
sqlx 两条主要的 SQL 构造路径——query! 宏(第 11 章)和 QueryBuilder——适用场景不同:
| 维度 | query! 宏 |
QueryBuilder |
|---|---|---|
| SQL 形态 | 必须是字面量 | 运行时构造,任意字符串 |
| 编译期校验 | 强(列名 / 类型 / 可空性) | 无(由你负责) |
| 参数编号 | 在 SQL 字面量里手写($1 / ?) |
自动(通过 format_placeholder) |
| 注入防御 | 编译期保证(没法拼字符串 + 参数) | 靠 push_bind + 白名单 push |
| 动态 WHERE | 不支持 | 完美支持 |
| 批量 VALUES | 不支持 | push_values 原生 |
| 编译时连数据库 | 需要 | 不需要 |
选择规则:
- SQL 是字面量且结构固定 →
query!(享受编译期校验)。 - SQL 有任何动态部分(WHERE / ORDER BY / VALUES 批量)→ QueryBuilder。
同一个业务的两种查询可以都存在——比如 "获取用户 by id" 用 query!,"按动态 filter 搜索用户" 用 QueryBuilder。这两条路径并不互斥,互补使用是生产代码的常态。
一个 subtle 混合场景:有时候你想"大部分 SQL 是字面量、只有一小段是动态"——这种情况 QueryBuilder 用起来有点繁琐(全部从头拼),query! 又不支持动态。sqlx 0.8 没有直接的解法——实际做法通常是写成几个 query! 分支(按动态条件 match 到不同 SQL 常量)或全用 QueryBuilder。
10.21 QueryBuilder 实战判断题
Q1:push_bind 为什么不支持 Option 语义?
A:支持(通过 Encode 的 Option blanket)但要小心。push_bind(Some("x")) 和 push_bind(None::<&str>) 都合法——前者 encode "x"、后者 encode NULL。但你写的 SQL WHERE name = ? 碰上 NULL 参数时不会匹配 NULL 值(SQL 语义:x = NULL 的结果是 NULL 而不是 true,WHERE 不会选中)。要查 NULL 得用 WHERE name IS NULL——QueryBuilder 不会自动帮你生成。所以动态 NULL 查询要自己判断:
match name {
Some(n) => qb.push(" AND name = ").push_bind(n),
None => qb.push(" AND name IS NULL"),
};
Q2:push(x) 传 String 会不会注入?
A:取决于 x 来源。push("literal")、push(safe_int)、push(validated_col) 都安全;push(user_input) 就是注入大门。QueryBuilder 不做校验——push 的安全责任全在调用方。典型做法是限制 push 只接受 compile-time 已知或白名单校验过的内容——这是团队应该约定的 code review 准则。
Q3:QueryBuilder 里 bind 的参数还有编译期类型检查吗?
A:有,但不如 query!。push_bind<T: Encode + Type>(value) 要求 T 实现两个 trait——编译期检查"这个 T 能被 encode、声明了对应 DB 类型"。但 QueryBuilder 不知道你写的 SQL 里 ? 对应什么列——类型和列的匹配是运行时 DB 层的检查。相比 query! 编译期 describe SQL 拿到每个 $N 的期望类型、和你 bind 的类型匹配——QueryBuilder 少一层。
10.22 QueryBuilder 的版本演进
QueryBuilder 是相对晚期加入的 API——0.5 系列的末尾才出现(以下版本归属查自仓库 CHANGELOG.md 与各 tag 下的 query_builder.rs):
- 0.3 / 0.4:还没有 QueryBuilder,用户只能用
format!拼 SQL +query().bind()手动编号——体验很差。 - 0.5.12 / 0.5.13(2022-04,0.5.12 已撤回;PR #1780 实现 + #1790 补文档):引入 QueryBuilder,一上来就带齐
push/push_bind/separated/push_values/build/reset。 - 0.6.1(2022-08):
push_tuples(PR #1954,处理(a, b) IN ((..))场景)、Separated::push_bind_unseparated(PR #1985)、build_query_as与sql/into_sql(PR #2005)。 - 0.7.0:
with_arguments(用已有 SQL + Arguments 构造,PR #2506)。 - 0.7.1(2023-07):
build_query_scalar(PR #2551)、impl Default(PR #2605)。 - 0.8(本专栏基准):API 面没有新增;0.8.4 给
push_values加了"空集合"的debug_assert(PR #3734)。
演进方向是填补动态 SQL 的每一种场景——动态 WHERE、动态 ORDER BY、批量 INSERT、复合 IN——每一种真实需求都长出一个 method,其中多数是外部贡献者提的 PR(#1790、#2005 出自维护者 Austin Bonander)。这种"从实际痛点增量演进"的轨迹和第 8 章 FromRow 的演进一致。
10.23 QueryBuilder 的常见陷阱
用户在 code review 里经常看到的 QueryBuilder 错误用法:
陷阱 1:push(format!(...)) 里嵌入用户输入
qb.push(format!("AND name = '{}'", user_name)); // SQL 注入!
正确:qb.push(" AND name = ").push_bind(user_name);
陷阱 2:忘记第一个条件的占位逻辑
let mut qb = QueryBuilder::<Postgres>::new("SELECT * FROM users");
if let Some(name) = name_filter {
qb.push(" WHERE name = ").push_bind(name); // 第一个条件是 WHERE
}
if let Some(email) = email_filter {
qb.push(" AND email = ").push_bind(email); // 如果前面没 WHERE,这就错了
}
正确:用 "WHERE 1=1" 占位,后续全部 AND:
let mut qb = QueryBuilder::<Postgres>::new("SELECT * FROM users WHERE 1=1");
if let Some(name) = name_filter { qb.push(" AND name = ").push_bind(name); }
if let Some(email) = email_filter { qb.push(" AND email = ").push_bind(email); }
或者用 vec 收集条件最后拼:
let mut conditions: Vec<Box<dyn Fn(&mut QueryBuilder<Postgres>)>> = vec![];
if let Some(n) = name_filter {
// 不能 push_bind(&n):借的是闭包自己的捕获,满足不了 `'args`(实测报 lifetime may not live long enough)
conditions.push(Box::new(move |q| { q.push(" name = ").push_bind(n.clone()); }));
}
if !conditions.is_empty() {
qb.push(" WHERE ");
// 用 separated 加 AND
}
后者灵活但啰嗦,大多数代码用前者的 "WHERE 1=1" 技巧。
陷阱 3:按值迭代却按引用绑定
let users = get_users(); // Vec<User>
qb.push_values(users, |mut row, u| {
row.push_bind(&u.name).push_bind(u.age); // 编译错:E0597 `u.name` does not live long enough
});
闭包参数 u 在每次调用结束就被 drop,借它的 &u.name 满足不了 T: 'args。两种改法:要么按值绑 row.push_bind(u.name)(push_values(users, ...) 本身没问题,实测可编译);要么 users.iter() 让闭包拿 &User,name 用 &u.name 借用:
qb.push_values(users.iter(), |mut row, u| {
row.push_bind(&u.name).push_bind(u.age); // &str 通过 String 借用 + i32 复制
});
陷阱 4:空列表 push_values
let users: Vec<User> = vec![]; // 空
qb.push_values(users.iter(), |mut row, u| { ... });
// 产 SQL "INSERT INTO ... VALUES "——语法错;debug 构建下更早一步,
// push_values 里的 debug_assert 直接 panic:"No value being pushed. QueryBuilder may not build correct sql query!"
正确:push_values 前检查:
if !users.is_empty() {
qb.push_values(users.iter(), |mut row, u| { ... });
qb.build().execute(pool).await?;
}
或者按"空批量不执行"把整段 if let Some 包起来。
10.24 QueryBuilder 的设计启示
本章从 QueryBuilder 里能萃取出几条可迁移的 API 设计原则:
1. 二元方法比多态参数更清晰。push / push_bind 是两个具名的不同方法——不是"push with escape flag"的单方法两变种。名字就说明了语义差异——new 一个 builder 的人不需要查文档。
2. 装饰器比嵌套 builder 灵活。Separated 借用 QueryBuilder 而不是嵌入 QueryBuilder——用完就 drop,QueryBuilder 继续用。借用装饰器比"builder 有自己的 SeparatedMode"省代码。
3. 闭包传入 builder 让嵌套构造可读。push_values(iter, |row, item| ...) 的闭包参数接收新的 Separated——用户只管填内部 tuple,不管外层括号 / 分隔符。这条"外部结构封装、内部细节交给 callback"是 Rust API 里的inversion-of-control 模式的典型。
4. 类型参数带 PhantomData 不是代码臭味。QueryBuilder 没有 PhantomData<DB>(DB 出现在 arguments 字段);Query 带着 PhantomData<DB>,QueryAs 带着 PhantomData<O>。当一个类型参数不出现在任何字段里时,必须用 PhantomData 把它保持在结构体上,否则编译器报 E0392(parameter is never used);QueryAs 的输出类型 O 就是这种情况。
5. 默认 panic 而不是 Result。push_bind 的参数 encode 失败会 expect("Failed to add argument") panic。源码没写理由;一种解读是 QueryBuilder 的 API 形状(&mut self 链式)不便返回 Result——这是"链式 API 选 panic-on-error"的常见权衡,用户想要 Result 就不要用链式 builder。
这五条和第 9 章 §9.28 列出的 Query API 原则互补——一套是"装饰结构 + 薄方法"、这一套是"构造期的 API 风格"。两章合起来给你一个sqlx 级别的 API 设计范本。
10.25 QueryBuilder 的实现量观察
读 sqlx-core/src/query_builder.rs 源码(600 行)有几条值得注意的观察:
1. 没有一行 unsafe。QueryBuilder 是 String + usize + Option 的组合——完全安全的 Rust。这也不是个例:sqlx-core 在 sqlx-core/src/lib.rs:20 用 #![forbid(unsafe_code)] 整个禁掉了 unsafe,按同处注释的说法,sqlx 里的 unsafe 只用于和 SQLite 这类原生 API 交互(sqlx-sqlite/src/lib.rs:40 才 allow(unsafe_code))。
2. 每个公共方法都有文档注释。separated / push_values / push_tuples 还带可运行的 doctest 和示例 SQL 输出,push / push_bind / build 写明了注入风险、参数上限、复用规则。
3. 重度依赖 expect 而不是 unwrap。expect("BUG: Arguments taken already") / expect("error in format_placeholder") ——每个 panic 都带消息。其中带 "BUG:" 前缀的那条,是在告诉调试者"走到这里是库内部 bug、不是用户代码问题"。这是给故障调查留证据的好习惯。
4. sanity_check 贯穿所有会读写参数的方法。push、push_bind、separated、push_values、push_tuples、build 开头都调 self.sanity_check()——统一检查"build 已经 take 走 arguments" 这条 invariant。用单个 helper 而不是每个方法散写——DRY 在 API 一致性上的应用。
5. Generic bound 最小化。QueryBuilder 只要求 DB: Database——没有强制 HasStatementCache 或 for<'r> &'r str: ColumnIndex。给不实现 HasStatementCache 的第三方驱动也留了口——QueryBuilder 本身不依赖语句缓存能力。这是"按需要约束、不多约束"的原则。
这五点观察是"读源码学工程品味"的好例子——单个 method 看不到太多,整个文件看下来能提炼出作者的设计习惯。
10.26 QueryBuilder 的整体定位
回看第 9 章 Query 和第 10 章 QueryBuilder 的关系:
- Query 是"静态 SQL + 参数绑定"的最简路径。
- QueryBuilder 是"动态 SQL 构造"的标准路径。
- 两者通过
.build()方法串联——QueryBuilder 是 Query 的构造期前置。
从工程视角看,sqlx 的 SQL 构造 API 分层非常清晰:
用户意图
↓
query!(literal) ← 编译期校验(第 11 章)
query(str) ← 运行时静态 SQL(第 9 章)
QueryBuilder ← 运行时动态 SQL(本章)
Arguments + push ← 最底层构造原语(第 6 章)
↓
Executor.fetch_many ← 执行(第 4 章)
每一层都有明确的适用场景、可以单独学习、层层递进。你看到一条具体需求:"给用户 X 查最近 N 条订单 + 按某列排序 + 可选类型过滤"——立刻能判断 "这是动态 SQL、用 QueryBuilder 而不是 query 或 query!"。这种选 API 的直觉来自理解每一层的边界。
一个经验法则:如果你觉得"手上在用 QueryBuilder 但代码越写越复杂"——停下来考虑是不是该拆成几个独立的 query! 分支(按场景分流)。QueryBuilder 适合"几条分支线性拼接",不适合"数十种组合的决策树"——后者拆成多个 query! 更清爽,每个分支类型安全。
query! 宏的展开最终也建立在 Query 之上,和本章的 QueryBuilder.build() 殊途同归。
10.27 一条实战思考:什么时候该放弃 QueryBuilder
QueryBuilder 强大但不是万能。如果你的 SQL 构造逻辑满足任一条件,考虑换成别的方案:
1. 条件分支多到组合爆炸——意味着 SQL 不是"动态拼接"而是"多种完全不同 SQL"。把它拆成几个独立的 query! 或 query() 调用,按条件分派到不同分支——比一个巨大 QueryBuilder 可读得多。
2. 涉及子查询嵌套——QueryBuilder 对 CTE、子查询、UNION 这些嵌套结构的支持"能用但难看"。遇到这些复杂 SQL,通常直接写 raw 字符串(用 query() 或 query!)更清晰。
3. 需要"这一条 SQL 的一段由另一处代码决定"——比如权限过滤逻辑单独一个 crate、插入到每条查询。这种 composition 用 QueryBuilder 写出来是闭包嵌闭包——可读性差。更好的方案是生成 SQL 片段字符串(不是 Query 对象)、最后拼到顶层 QueryBuilder 里。
4. 需要把构造好的 Query 存起来异步执行——QueryBuilder 的 build() 返回的 Query 借 QueryBuilder——生命周期短。如果要把 Query 丢进 tokio::spawn、需要 owned——得把整个 QueryBuilder move 进 task 再 build;into_sql() 只拿得到 SQL 文本,会丢掉 arguments。这一条有现成解法,不必为此放弃 QueryBuilder。
遇到前三种情况之一,QueryBuilder 不是合适工具——你在为"工具本身的限制"写额外代码。换成其他方案(多个 query! 分支、raw 字符串、SQL 片段函数)更合适。
QueryBuilder 是动态 SQL 场景的首选,但在上面这几类复杂 SQL 场景里不是最佳选择——知道边界、才能真正用好它。