Appearance
第10章 QueryBuilder:运行时拼 SQL 的类型安全做法
"Dynamic SQL is the last mile of a type-safe ORM— either you have a builder that knows about placeholders, or you have SQL injection." —— Rust 后端团队在 code review 里反复遇到的判断
本章要点
QueryBuilder<'args, DB>(sqlx-core/src/query_builder.rs:24-32)是 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:
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 的结构:
rust
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.1.1 QueryBuilder 的类型数据流
把 QueryBuilder 的主要操作画一张数据流图:
这张图展示了 QueryBuilder 的三阶段生命周期:
- 构造阶段——
new初始化。 - 拼接阶段——
push/push_bind/push_values/push_tuples/separated任意组合、链式调用。 - 输出阶段——
build/build_query_as/build_query_scalar转回 Query 类型。
reset 让"构造好的 QueryBuilder 可以循环复用"——从输出阶段回到拼接阶段的起点。整条流程没有回环——一旦 build 被调过、必须 reset 才能继续 push。
10.2 QueryBuilder<'args, DB> 的结构
sqlx-core/src/query_builder.rs:24-32:
rust
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.17.8 讨论过的生命周期脆弱问题)。init_len: usize——构造时的初始长度。reset() 会把 query 截回这个长度——让 QueryBuilder 可以在同一初始 SQL 基础上多次构建查询、复用字符串 buffer。arguments: Option<DB::Arguments<'args>>——参数集合,和第 6 章的 Arguments 机制完全一致。Option是因为build()会把它 take 出来转交 Query。
'args 生命周期和 Arguments 的 'q 等价——只约束参数里可能的借用(SQLite 的 Cow)。对 PgArguments / MySqlArguments 这种字节流的 'args 约束几乎不生效。
注意 QueryBuilder 没有 PhantomData<DB>——DB 出现在 arguments 字段里的 <DB as Database>::Arguments<'args>,这就构成 DB 的类型使用。所以 DB 不需要额外的 PhantomData 占位(对比 Query struct 需要 PhantomData 因为它的 DB 只出现在 Arguments 类型参数里,而 Arguments 又被 Option 包起来不能确保"被使用")。
10.3 push vs push_bind:核心二元
QueryBuilder 的灵魂是两个 push——追加 SQL 片段有两种语义:
10.3.1 push(impl Display):裸 SQL 片段
query_builder.rs:116-123:
rust
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.3.2 push_bind<T>(value):参数化
query_builder.rs:148-163:
rust
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),encode 成字节。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.3.3 push 和 push_bind 的典型搭配
rust
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 的内容必须是白名单校验过的。
白名单校验看起来像这样:
rust
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.4 separated(sep):列表分隔符
手动拼 (?, ?, ?) 的分隔符很麻烦——第一项前不加逗号、后续项都加。Separated 装饰器解决这个:
query_builder.rs:195-208:
rust
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 提供四个方法(query_builder.rs:544-620):
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、之后全写。典型用法:
rust
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.4.1 push_unseparated 的用处
rust
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.5 push_values:批量 VALUES
query_builder.rs:306-330:
rust
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("(");
push_tuple(separated.query_builder.separated(", "), tuple);
separated.push_unseparated(")");
}
separated.query_builder
}核心是两层 Separated 嵌套——外层用 , 分隔 tuple、内层(传给 closure)用 , 分隔 tuple 内的字段。
典型用法:
rust
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:
sql
INSERT INTO users (name, age) VALUES ($1, $2), ($3, $4), ($5, $6)6 个参数(3 行 × 2 列)。一次 round-trip 插入多行——比 3 次 INSERT 快数倍(每次 round-trip 省几毫秒延迟)。
10.5.1 参数上限
query_builder.rs:125-145 的注释列出各家数据库的参数上限:
| 数据库 | 单条 SQL 参数上限 |
|---|---|
| Postgres | 65535 |
| MySQL | 65535 |
| SQLite 3.32+ | 32766 |
| SQLite ❤️.32 | 999 |
| MSSQL | 2100 |
实际意义:假设你要批量插入 10 万行、每行 4 列,总参数数 40 万——超过任何 DB 的单语句上限。正确做法是按 batch 拆:
rust
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?;
}Chunks of 10 万行 = 10 次 execute,每次安全地在参数上限之下。
10.5.2 为什么不 iter 一个 clone 不等式
push_values 的文档注释里有一段提示:
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_values 的 closure 参数 I::Item 仍需要至少活到 QueryBuilder drop。这是 Arguments trait 的 'args bound 导致的——类型系统不知道具体 Encode 实现立即拷贝,所以保守地要求生命周期对齐。
解决办法:iter() 而不是 into_iter()——让闭包接收 &User 而不是 User,引用的生命周期匹配 users Vec 的作用域,通常够长。或者把 User 数据 clone 进闭包。
这条细节让 push_values 在某些场景比表面看起来更啰嗦——但一旦你理解"为什么这样",就能正确写。
10.6 push_tuples:批量多列 IN
query_builder.rs:418-441 的 push_tuples 结构和 push_values 几乎一样,生成的 SQL 形态是:
sql
SELECT * FROM users WHERE (id, email) IN ((1, 'a@ex.com'), (2, 'b@ex.com'), ...)典型用法:查询"这批 (id, tenant_id) 组合"的行:
rust
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_values 占 90%(INSERT 批量)、push_tuples 少见(复合 key 查询)——前者是高频写操作、后者是偶发读操作。
10.7 build / build_query_as / build_query_scalar:转回 Query
query_builder.rs:453-462:
rust
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 也就失效。
典型用法:
rust
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 的生命周期不够——需要 .into_sql() 把 SQL 拷贝出来、或者确保 qb 活到 task 结束。
10.7.1 build_query_as 和 build_query_scalar
rust
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。用法:
rust
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.8 init_len / reset / sanity_check:复用机制
query_builder.rs:513-523:
rust
pub fn reset(&mut self) -> &mut Self {
self.query.truncate(self.init_len);
self.arguments.get_or_insert_with(Default::default).clear();
self
}
pub fn sql(&self) -> &str { &self.query }
pub fn into_sql(self) -> String { self.query }reset 把 query 截回 init_len、arguments 清空——但 String 的 capacity 保留。让 QueryBuilder 在循环里复用:
rust
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 重新分配 String buffer——对高频批量操作有可观节省。
sanity_check(query_builder.rs:519 的引用方法)检查 "arguments is Some":build 会 .take() arguments 变成 None,之后调用任何方法(push / push_bind 等)都会触发 sanity_check 的 panic——防止误用。正确使用是build 后立即 execute / fetch,或者先 reset 再继续。
10.9 占位符跨 DB 自动适配
QueryBuilder 最优雅的地方在占位符生成完全透明。同一段 Rust 代码:
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 只要改 Pool<DB> 类型参数,占位符自动跟着变。前提是你不在 push() 里写 DB 特有语法(Postgres 的 RETURNING、MySQL 的 ON DUPLICATE KEY UPDATE 等)——这些方言差异 QueryBuilder 不帮你处理。
10.10 QueryBuilder 的局限与 Postgres UNNEST 替代
QueryBuilder 不是万能的。query_builder.rs:19-22 文档注释提醒:
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 是:
sql
INSERT INTO users (name, age) VALUES ($1, $2), ($3, $4), ..., ($99, $100)服务端要解析 50 个 VALUES tuple——解析成本 O(N)。50 行 O(50) 成本还好,5000 行 O(5000) 就有可感延迟。
UNNEST 方案用数组传:
rust
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?Postgres 解析 SQL 是 O(1)(一条固定语句)、数据在两个数组参数里一次性发——服务端用 UNNEST 展开数组回归行集。5000 行插入:push_values 方案约 20ms(SQL 解析占大头),UNNEST 方案约 5ms。
UNNEST 只适合 Postgres(MySQL / SQLite 没有等价函数)——也只适合列数少(2-5)的批量;列数多(10+)时两个方案差距变小。
实战建议:日常批量插入用 QueryBuilder 的 push_values(可读性好、跨 DB);Postgres 上极致性能场景用 UNNEST(写死 Postgres 依赖、换来吞吐)。
10.10.1 push_bind 的参数编号生成示例
把跨 DB 的占位符生成用一个具体例子展示。同一段代码:
rust
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 迁移就主要是"换 Pool<DB> 类型参数"加"改几条方言 SQL"。
一个实际例子:某公司从 MySQL 迁到 Postgres。他们 500 个 QueryBuilder 用法里 450 个零改动——只换 MySql 到 Postgres,push_bind 自动从 ? 变 $N。剩下 50 个是涉及方言的(ON DUPLICATE KEY UPDATE / RETURNING / JSON_EXTRACT 等),单独改。这种"80% 零成本迁移"是 QueryBuilder + sqlx-core trait 家族抽象带来的生态收益。
10.11 动态查询的实战模式
把前面的内容合进一个实战例子——动态查询 + 批量插入结合:
rust
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 {
qb.push(" AND role = ANY(");
let mut sep = qb.separated(", ");
for role in &roles { sep.push_bind(role); }
qb.push(")");
// 更优的写法:.push_bind(&roles)——Postgres 支持数组 bind
}
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);
qb.build_query_as::<User>().fetch_all(pool).await
}这个例子展示 QueryBuilder 的典型模式——逐段条件 push / push_bind、最后 build_query_as 接回类型安全 API。35 行代码完成一个"多维动态过滤 + 白名单 ORDER BY + 限制 LIMIT"的查询——换成字符串拼接 + &[&dyn ToSql] 手动管理要 60+ 行且容易出错。
10.11.1 QueryBuilder 与事务的配合
QueryBuilder 和事务一起用的常见模式:
rust
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.11.2 性能测量:QueryBuilder vs 字符串拼接
QueryBuilder 相对"手动 format! + query + bind" 的性能差异几乎为零——它本身只是 String 操作 + 把参数存进 Arguments:
| 操作 | QueryBuilder 用时 | format! + query + bind | 差异 |
|---|---|---|---|
| 构造空 QueryBuilder | 10-20ns | 10-20ns(String::new) | 几乎相同 |
| 一次 push(5 字符) | 30ns | 30ns(字符串 push_str) | 相同 |
| 一次 push_bind | 100-150ns | 100-150ns(bind + 拼占位符) | 相同 |
| build → Query | 50ns | N/A(直接 query 构造) | 一次额外 take |
QueryBuilder 的本质是薄包装——它只是给"字符串拼 + 参数绑定"一个组织良好的 API。性能和手写等同;价值在代码质量和注入防御。
这个测量让你不用担心 QueryBuilder 带来隐藏开销——大胆用、享受抽象带来的正确性。真正的性能瓶颈在 DB round-trip(毫秒级)、不在 QueryBuilder 的构造(纳秒级)。
10.12 本章小结
本章把 QueryBuilder 的动态 SQL 构造机制拆开:
- QueryBuilder 三字段(§10.2)——query + init_len + arguments。init_len 让 reset 能截回起点。
- push vs push_bind 的核心二元(§10.3)——前者裸 SQL(你负责安全)、后者参数化(类型安全 + 注入防御)。
- 典型搭配规则——关键字/操作符用 push、值用 push_bind、列名用白名单校验过的 push。
- separated 装饰器(§10.4)——"第一次不同、后续一致"的分隔符迭代。push_unseparated 做例外。
- push_values 批量 VALUES(§10.5)—— 两层 Separated 嵌套构造
(?, ?), (?, ?), ...。注意参数上限(Postgres 65535)。 - push_values 的
'args约束(§10.5.2)—— iter() 比 into_iter() 更常用,因为闭包需要生命周期匹配。 - push_tuples(§10.6)—— 批量多列 IN 子查询形态。
- build 转回 Query(§10.7)—— 借 self、零拷贝 SQL 字符串引用。build_query_as / build_query_scalar 是带输出映射的变体。
- reset 复用机制(§10.8)—— init_len 截断 + arguments clear,避免循环分配。
- 占位符跨 DB 自动适配(§10.9)—— 靠 Arguments::format_placeholder 完全透明。
- UNNEST 替代(§10.10)—— Postgres 极致性能场景用数组 bind,跳过 O(N) 的 SQL 解析。
- 动态查询实战模式(§10.11)—— 逐段条件 + 白名单 ORDER BY + build_query_as 接回类型安全。
下一章我们进入 query! 编译期校验宏——sqlx 最有辨识度的特性。它在 compile 时连真实数据库做 DESCRIBE、反推出带类型的代码。
10.12.1 sqlx 生态里的 QueryBuilder 使用分布
观察 GitHub 上 sqlx 项目的 usage pattern(基于抽样而非精确统计),能看到 QueryBuilder 的几种典型用法出现频率:
| 用法 | 出现频率(抽样) | 典型场景 |
|---|---|---|
| 动态 WHERE 过滤(push + push_bind 交替) | 40% | search / list API |
| 批量 INSERT(push_values) | 30% | ETL、bulk import |
| 动态 ORDER BY / LIMIT | 15% | 排序可选的列表接口 |
| IN (?, ?, ?) 列表(separated) | 10% | batch delete、cache invalidation |
| 复合 key IN 查询(push_tuples) | 3% | 复杂多列过滤 |
其它(build_query_scalar 动态 count 等) | 2% | 少见 |
动态 WHERE 是第一大用户——这也是 QueryBuilder 最独特的价值。query! 宏无法表达、手动 format! 容易注入——QueryBuilder 是唯一安全的路径。
批量 INSERT(push_values)是第二大用户——虽然 Postgres 下 UNNEST 更快(§10.10),但多 DB 兼容的代码往往选 push_values。
后面三种(ORDER BY、IN 列表、复合 key)是业务代码偶发需求——QueryBuilder 各自都有恰好合适的 API(push、separated、push_tuples)。
这份分布给你使用指南——如果你写的 sqlx 代码里 QueryBuilder 出现率 <5%,说明你的业务以简单 CRUD 为主;如果 >30%,说明你在做大量动态查询(搜索、分析、ETL),QueryBuilder 是主力工具。
10.13 QueryBuilder 的并发使用模式
QueryBuilder 设计为单线程复用——没有实现 Sync。典型用法在 handler 里:
rust
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——不共享、不需要 Sync。handler 结束 QueryBuilder drop、下一个请求再造。
避免的模式:尝试把 QueryBuilder 作为全局 static 或 Arc 共享——编译不过(QueryBuilder 是 !Sync)而且也没必要(String 分配成本极低)。
高频循环场景才需要考虑 reset:
rust
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
qb.push_values(batch.iter(), |mut row, u| { ... });
qb.build().execute(pool).await?;
}
Ok(())
}一个 QueryBuilder 处理多批——避免每批一个 String::new() 分配。对于 10000 批每批 100 行的场景有可观节省(几 MB 的 alloc 差异)。
10.13.1 QueryBuilder 和 diesel DSL 的对比
Diesel 的 DSL 和 sqlx QueryBuilder 解决同一问题——动态构造 SQL——但路径完全不同:
rust
// Diesel DSL
use diesel::prelude::*;
use crate::schema::users::dsl::*;
let query = users.into_boxed();
let query = if let Some(name) = name_filter {
query.filter(name.ilike(format!("%{}%", name)))
} else { query };
let query = query.order(created_at.desc()).limit(50);
let results: Vec<User> = query.load(conn)?;rust
// 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))返回带具体类型的查询构造器。类型系统保证列名 / 类型完全正确(编译失败意味着 SQL 必然错)。 - sqlx QueryBuilder 是字符串拼接器——
.push(col_name)编译过但运行时 DB 可能报列不存在。类型系统只保证参数类型安全,SQL 文本正确性靠你。
Diesel 的代价:表达复杂 SQL(CTE、窗口函数、方言特有语法)需要查 Diesel DSL 文档找对应 API——有时候找不到只能 fallback 到 sql_query 字符串。 sqlx 的代价:运行时才能发现 SQL 拼错(列名、语法)——但 SQL 本身就是你亲手写的,通常不会偏离业务。
哪个更好?没有统一答案。Diesel 的 DSL 适合"代码库里 SQL 数量巨大、团队想要编译期验证 90% 正确性";sqlx QueryBuilder 适合"SQL 相对简单、偶尔动态、团队已经熟 SQL"。sqlx 用户群倾向后者——"写 SQL 就好、别再发明 DSL 了"是 sqlx 生态的共识。
10.14 QueryBuilder 和 query! 宏的选择
sqlx 两条主要的 SQL 构造路径——query! 宏(第 11 章)和 QueryBuilder——适用场景不同:
| 维度 | query! 宏 | QueryBuilder |
|---|---|---|
| SQL 形态 | 必须是字面量 | 运行时构造,任意字符串 |
| 编译期校验 | 强(列名 / 类型 / 可空性) | 无(由你负责) |
| 参数编号 | 宏自动处理 | 自动(通过 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 没有直接的解法——实际做法通常是写成几个 query! 分支(按动态条件 match 到不同 SQL 常量)或全用 QueryBuilder。0.9 可能会加入"字面量 SQL + 动态插点"的混合机制,但本书锁定的 0.8 还没有。
10.15 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 永远 false)。要查 NULL 得用 WHERE name IS NULL——QueryBuilder 不会自动帮你生成。所以动态 NULL 查询要自己判断:
rust
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.16 QueryBuilder 的版本演进
QueryBuilder 是相对晚期加入的 API——0.5 才独立:
- 0.3/0.4:用户只能用
format!拼 SQL +query().bind()手动编号——体验很差。 - 0.5:引入 QueryBuilder,有 push / push_bind / build——基本形态齐备。
- 0.6:加
separated装饰器,push_values批量 VALUES。 - 0.7:GAT 之后生命周期清爽;
push_tuples加入(处理(a, b) IN ((..))场景)。 - 0.8(本书版本):
build_query_as/build_query_scalar加入,让 QueryBuilder 无缝接回类型化 API。
演进方向是填补动态 SQL 的每一种场景——动态 WHERE、动态 ORDER BY、批量 INSERT、复合 IN——每一种真实需求都长出一个 method。这种"从实际痛点增量演进"的轨迹和第 8 章 FromRow 的演进一致。
10.16.1 QueryBuilder 的常见陷阱
用户在 code review 里经常看到的 QueryBuilder 错误用法:
陷阱 1:push(format!(...)) 里嵌入用户输入
rust
qb.push(format!("AND name = '{}'", user_name)); // SQL 注入!正确:qb.push(" AND name = ").push_bind(user_name);
陷阱 2:忘记第一个条件的占位逻辑
rust
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:
rust
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 收集条件最后拼:
rust
let mut conditions: Vec<Box<dyn Fn(&mut QueryBuilder<Postgres>)>> = vec![];
if let Some(n) = name_filter {
conditions.push(Box::new(move |q| { q.push(" name = ").push_bind(&n); }));
}
if !conditions.is_empty() {
qb.push(" WHERE ");
// 用 separated 加 AND
}后者灵活但啰嗦,大多数代码用前者的 "WHERE 1=1" 技巧。
陷阱 3:push_values 的 iter 生命周期错
rust
let users = get_users(); // Vec<User>
qb.push_values(users, |mut row, u| {
row.push_bind(u.name); // 编译错:User move 进 closure,name 无法 push_bind
});正确:users.iter() 让闭包拿 &User,name 用 &u.name 借用:
rust
qb.push_values(users.iter(), |mut row, u| {
row.push_bind(&u.name).push_bind(u.age); // &str 通过 String 借用 + i32 复制
});陷阱 4:空列表 push_values
rust
let users: Vec<User> = vec![]; // 空
qb.push_values(users.iter(), |mut row, u| { ... });
// 产 SQL "INSERT INTO ... VALUES "——语法错正确:push_values 前检查:
rust
if !users.is_empty() {
qb.push_values(users.iter(), |mut row, u| { ... });
qb.build().execute(pool).await?;
}或者按"空批量不执行"把整段 if let Some 包起来。
这四条陷阱覆盖 QueryBuilder 用户最常见的失误——记住它们可以避免一半的 code review 来回。
10.17 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 / QueryAs 都有。用 PhantomData 把类型参数保持在签名上避免"这个参数没被用过"的 warning,让类型系统能做约束推导。
5. 默认 panic 而不是 Result。push_bind 的参数 encode 失败会 expect("Failed to add argument") panic——因为 QueryBuilder 的 API 形状(&mut self 链式)不适合返回 Result。这是 Rust 里 "链式 API 常选 panic-on-error"的典型权衡——用户想要 Result 就不要用链式 builder。
这五条和第 9 章 §9.17.9 列出的 Query API 原则互补——一套是"装饰结构 + 薄方法"、这一套是"构造期的 API 风格"。两章合起来给你一个sqlx 级别的 API 设计范本。
10.17.1 QueryBuilder 的实现量观察
读 sqlx-core/src/query_builder.rs 源码(600 行)有几条值得注意的观察:
1. 没有一行 unsafe。QueryBuilder 是 String + Option + PhantomData 的组合——完全安全的 Rust。这是 sqlx-core 的常态——整个 core crate 几乎零 unsafe,复杂度全在驱动 crate 里(SQLite 的 C FFI 处理)。
2. 每个公共方法都有文档注释。每个方法有 example code、约束说明、示例 SQL 输出——这是 sqlx 对用户友好的承诺。对比某些库"公共 API 只有签名"的做法,sqlx 的文档质量在 Rust 生态里属上乘。
3. 重度依赖 expect 而不是 unwrap。expect("BUG: Arguments taken already") / expect("error in format_placeholder") ——每个 panic 都带消息。一旦真的 panic,日志里就有"BUG: xxx"——告诉调试者"这是库内部 bug、不是用户代码问题"。这是给故障调查留证据的好习惯。
4. sanity_check 贯穿每个公共方法。push、push_bind、separated、push_values 开头都调 self.sanity_check()——统一检查"build 已经 take 走 arguments" 这条 invariant。用单个 helper 而不是每个方法散写——DRY 在 API 一致性上的应用。
5. Generic bound 最小化。QueryBuilder 只要求 DB: Database——没有强制 HasStatementCache 或 for<'r> &'r str: ColumnIndex。给 SQLite 也留了口——虽然 SQLite 没有 cache,但 QueryBuilder 本身不依赖 cache 能力。这是"按需要约束、不多约束"的原则。
这五点观察是"读源码学工程品味"的好例子——单个 method 看不到太多,整个文件看下来能 subtract 出作者的设计习惯。
10.18 QueryBuilder 的整体定位
第三部分"查询 API"到这里暂告段落——回看第 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! 宏——sqlx 最有辨识度的特性。本章是它的铺垫——因为 query! 宏的展开最终也产出 Query 类型,和本章的 QueryBuilder.build() 殊途同归。
10.19 总结:QueryBuilder 的三个核心贡献
本章结尾用三句话总结 QueryBuilder 对 sqlx 生态的贡献:
1. 让"动态 SQL + 类型安全"同时可行。在 QueryBuilder 之前的 sqlx 用户只能在"动态能力(format!)牺牲安全" vs "安全(静态 SQL)牺牲动态"之间二选一。QueryBuilder 让两者兼得——push_bind 的自动占位符 + 类型安全绑定让动态拼接也能避开注入。
2. 让跨 DB 代码真正可行。占位符的方言差异($1 vs ?)在静态 SQL 时代是代码级别的差异——换 DB 必然改 SQL。QueryBuilder 通过 format_placeholder 把这条差异下沉到 Arguments 层——业务代码用同一个 .push_bind() 调用,不同 DB 跑得都对。
3. 让批量操作不再繁琐。在 QueryBuilder 的 push_values 之前,"插入 1000 行"要么写 1000 次 INSERT、要么手动拼 VALUES (?,?), (?,?), ...。push_values 把这件事收到一个闭包里——iter + 每项生成 tuple 的统一模式。
这三条贡献对应 sqlx 的**"动态 + 跨 DB + 批量"三个痛点场景**——QueryBuilder 是对这三个场景的一次统一答案。0.5 引入 QueryBuilder 的时候,sqlx 真正完成了从"玩具级异步 SQL 库"到"生产级数据库工具包"的跨越。
读完本章你应该能:
- 看到"动态 WHERE / ORDER BY"需求立即想到 QueryBuilder。
- 写出不注入的动态 SQL(push + push_bind 分工)。
- 用 push_values 做批量插入、知道参数上限如何 chunk。
- 用 reset 在循环里复用 QueryBuilder。
- 和第 9 章 Query 类型配合,让动态拼接最终接回类型化 API(build_query_as)。
这些能力覆盖 sqlx 用户 30% 的日常需求(基于 §10.12.1 的抽样分布)。第 11 章进入剩下 70% 的核心——query! 宏。
10.20 一条实战思考:什么时候该放弃 QueryBuilder
QueryBuilder 强大但不是万能。如果你的 SQL 构造逻辑满足任一条件,考虑换成别的方案:
1. 条件分支超过 10 个——意味着 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——可能要 into_sql() 拿 String、再 sqlx::query(&sql)(但这会丢掉 arguments)。
遇到这四种情况之一,QueryBuilder 不是合适工具——你在为"工具本身的限制"写额外代码。换成其他方案(多个 query! 分支、raw 字符串、SQL 片段函数)更合适。
学会"什么时候不用"比"怎么用"更有价值——这是所有工具的使用智慧。QueryBuilder 在 30% 的动态 SQL 场景里无可替代,但在 5% 的复杂 SQL 场景里不是最佳选择——知道边界、才能真正用好它。
这条"知道工具的边界"的思维方式也适用于 sqlx 本身——不是所有数据库访问都该用 sqlx(底层组件用 tokio-postgres、复杂实体 CRUD 用 sea-orm 更合适)。工具选型就是"评估当前场景 vs 工具边界"的持续决策。读完本章你多了一项工具(QueryBuilder)、也多了一份边界直觉——这是每次深入一个 API 的双重收益。
本章的最后一个想法:QueryBuilder 的 700 行源码里没有一行让人觉得"多余"——每个方法都对应一个真实场景、每条文档注释都解释清楚约束。这种"刚刚好的设计"很难做到——作者必须先经历"加了又删"的迭代,才能长出这种简约。sqlx 的 QueryBuilder 从 0.5 到 0.8 的演进里每个 method 都是加进去、用一阵后才稳定下来——这是"敏捷 + 用户反馈驱动"的工程典范。学 API 不只是学"什么方法做什么"——更要学**"这个方法为什么存在"**。