Skip to content

第3章 Database trait 家族:用 GAT 收束异构驱动

"A trait is a contract between the implementor and the user— and a good contract says exactly what's needed, no more, no less." —— 任何一个 trait 的第一次 code review

本章要点

  • Database trait(sqlx-core/src/database.rs:72)是整个 sqlx 类型体系的收束点——把驱动特有的 11 种类型(7 个普通关联类型 + 4 个 GAT)全部声明为关联类型,再加两个 const(NAME / URL_SCHEMES),让上层 API 只需要 DB: Database 一个泛型参数。
  • 其中四个是泛型关联类型(GAT)type ValueRef<'r>type Arguments<'q>type ArgumentBuffer<'q>type Statement<'q>——它们带生命周期参数。GAT 在 Rust 1.65(2022)才稳定,sqlx 0.7 是第一个利用 GAT 的版本。
  • 没有 GAT 的 0.6 时代,sqlx 用 HasValueRef<'r> 这样的"带生命周期的 helper trait"绕过——这种 workaround 让 trait 边界变得极其冗长,每个 where 子句都要重复写三到五个约束。
  • NAMEURL_SCHEMES 两个关联常量让 Any 驱动能在运行时按 URL scheme 分发(见第 2 章)——这不是显眼的特性,但决定了"同一个 AnyConnection 能连所有 DB"这件事能不能做到。
  • HasStatementCache 是一个无方法的 marker trait——Postgres / MySQL 实现它,SQLite 不实现。上层 API(Query::persistentConnection::cached_statements_size)通过 where DB: HasStatementCache 在类型系统层面把"语句缓存"这个能力锁进类型。
  • 对照 Postgres / MySQL / SQLite 三家的 impl Database,你会发现每一行的具体类型都不同但结构完全一样——这就是 trait 家族设计要达成的"等价替换"形态。

3.1 问题引入:一个 DB 要扛 11 个关联类型

上一章画过这行代码:

rust
let user: User = sqlx::query_as!(User, "SELECT id, name FROM users WHERE id = $1", id)
    .fetch_one(&pool)
    .await?;

以及它展开后生成的那段匿名 Future:

rust
::sqlx::__query_with_result::<::sqlx::Postgres, _>(...)
    .try_map(|row: ::sqlx::postgres::PgRow| { ... })

注意第二行里的两个类型——PostgresPgRow。如果你把 Postgres 换成 MySql,那第二行的 PgRow 必须同步换成 MySqlRow——这是 sqlx-core 的 API 在声明时就约束好的。sqlx::query_as 不知道具体是哪家数据库、不知道具体的 Row 类型长什么样——它只知道"你给我一个 DB: Database,我从 DB::Row 里解码"。

问题来了:这个"只知道 DB"的设计要求 RowColumnValueArguments 等所有具体类型都能从 DB 这一个泛型参数推出来。Rust 里唯一能做到这件事的机制是关联类型(associated type)

rust
pub trait Database {
    type Row;
    type Column;
    type Value;
    // ...
}

fn fetch_one<DB: Database>(...) -> <DB as Database>::Row { ... }

但 sqlx 的 11 个关联类型里有四个带生命周期参数

rust
type ValueRef<'r>: ValueRef<'r, Database = Self>;
type Arguments<'q>: Arguments<'q, Database = Self>;
type ArgumentBuffer<'q>;
type Statement<'q>: Statement<'q, Database = Self>;

type ValueRef<'r> 不是普通关联类型——它是泛型关联类型(GAT)。GAT 在 Rust 1.65(2022 年 11 月)才稳定,sqlx 0.7(2023 年 7 月)是第一个大版本全面用 GAT 的。0.6 及之前的 sqlx 用了一套非常笨拙的绕法——本章第 3.3 节会详细看那段历史。

这一章的任务,就是把 Database trait 的每一个关联类型、每一个 trait 边界拆开,解释它们为什么必须这样写、GAT 消除了哪些痛苦、HasStatementCache 这种"无方法标记 trait"怎么当作类型系统里的能力开关。读完本章,你对 DB: Database 这个约束在上层 API 的每一处出现都能立刻在脑子里展开成完整的关联类型集合。

3.2 Database trait 的全貌

sqlx-core/src/database.rs:72-113 的原文(本章后续讨论都以此为锚):

rust
pub trait Database: 'static + Sized + Send + Debug {
    type Connection: Connection<Database = Self>;
    type TransactionManager: TransactionManager<Database = Self>;

    type Row: Row<Database = Self>;
    type QueryResult: 'static + Sized + Send + Sync + Default + Extend<Self::QueryResult>;
    type Column: Column<Database = Self>;
    type TypeInfo: TypeInfo;

    type Value: Value<Database = Self> + 'static;
    type ValueRef<'r>: ValueRef<'r, Database = Self>;

    type Arguments<'q>: Arguments<'q, Database = Self>;
    type ArgumentBuffer<'q>;

    type Statement<'q>: Statement<'q, Database = Self>;

    const NAME: &'static str;
    const URL_SCHEMES: &'static [&'static str];
}

一共十一个关联类型 + 两个关联常量 + 四条 super-trait bound('static + Sized + Send + Debug)。把这十一个类型按功能分成四组:

后文的 3.4–3.7 节按这四组依次展开。

先看几条 super-trait bound:

  • 'staticDatabase 实现者(Postgres / MySql / Sqlite)都是零大小 unit struct——pub struct Postgres;——所以没有非 'static 引用。但 'static 不是自动推出来的,它是给下游 API 用的:"凡是 DB: Database,我就能把 DB 放进 Arc 里、跨线程传递、跑在 tokio::spawn 里"。没有它,上层所有 where 'static 的泛型边界都会断掉。
  • Sized:意味着 DB 不能是 dyn Database 形式——这和 Any 驱动形成对比:Any 用 Box<dyn AnyConnectionBackend> 绕开 Sized 约束,但它不是 Database 而是自己的 Any unit type 实现 Database
  • Send:必须能跨线程——因为 Pool<DB>PoolInner 会被多个 tokio task 共享。
  • Debug:为了让错误信息、tracing span 能打印出驱动名字。

这四条 bound 加起来把 Database 实现者钉死为"一个零开销的类型 token"——它不持有任何状态,只是类型系统里的一个标识,告诉 Executor::fetch 等泛型方法应该去找哪一套具体的 Row / Column / Arguments 实现。

3.2.1 对照 diesel 的 Backend trait

Rust 生态里做"驱动抽象"的另一条路线是 diesel。把 diesel 2.x 的 Backend trait 和 sqlx 的 Database 放在一起比,能看出两种哲学的差异:

rust
// diesel 2.x 的 Backend trait(简化)
pub trait Backend: Sized + Send {
    type QueryBuilder: QueryBuilder<Self>;
    type RawValue<'a>;
    type BindCollector<'a>: BindCollector<'a, Self>;
}

// 和
pub trait SqlDialect: Backend {
    type ReturningClause;
    type OnConflictClause;
    type InsertWithDefaultKeyword;
    type BatchInsertSupport;
    // ...
}

几条对比:

  1. diesel 的关联类型数量相当QueryBuilder / RawValue / BindCollector + SqlDialect 里七八个),但语义完全不同——diesel 的关联类型描述"方言特性"(是否支持 RETURNING、ON CONFLICT),sqlx 的关联类型描述"数据形态"(Row、Column、Value)。
  2. diesel 没有 sqlx 的 Row / Column——因为 diesel 的结果映射是通过 FromSqlRow 派生宏按 DSL 表达式静态展开的,不需要运行时的 Row 抽象。
  3. diesel 有 sqlx 没有的 QueryBuilder——这是 diesel DSL 的 SQL 字符串生成器。sqlx 不需要,因为用户写的 SQL 就是原始字符串,不经过构造器翻译。

两种设计的本质差异可以浓缩成一句话:diesel 的 Backend trait 描述的是"如何从 Rust 表达式生成 SQL",sqlx 的 Database trait 描述的是"如何把 SQL 字节流和连接池接入 Rust 的泛型系统"。前者是"出"的问题,后者是"入"的问题——两种工具包的方向完全不同。

这个对照也解释了为什么 sqlx 的 Any 驱动能存在而 diesel 没有等价物——sqlx 抽象的"数据形态"在三家 DB 下大致等价,运行时擦除一层并不太失真;而 diesel 抽象的"方言特性"在三家 DB 下差异巨大(Postgres 的 RETURNING、SQLite 的 RETURNING-via-triggers、MySQL 的 last_insert_id),运行时无法擦除得干净。

3.3 为什么是 GAT:没有泛型关联类型之前的痛苦

要解释 GAT 的必要性,先看没有 GAT 时 sqlx 0.6 是怎么写的。

0.6 版本(以 sqlx-core 0.6.3 为代表)的 Database trait 大致长这样:

rust
// 0.6 时代的简化版
pub trait Database:
    for<'r> HasValueRef<'r, Database = Self>
    + for<'q> HasArguments<'q, Database = Self>
    + for<'q> HasStatement<'q, Database = Self>
{
    type Connection: Connection<Database = Self>;
    type Row: Row<Database = Self>;
    // ...
}

pub trait HasValueRef<'r> {
    type Database: Database;
    type ValueRef: ValueRef<'r, Database = Self::Database>;
}

pub trait HasArguments<'q> {
    type Database: Database;
    type Arguments: Arguments<'q, Database = Self::Database>;
    type ArgumentBuffer;
}

pub trait HasStatement<'q> {
    type Database: Database;
    type Statement: Statement<'q, Database = Self::Database>;
}

注意这三个 "Has*" trait:每个都带一个生命周期参数,把原本应该是 GAT 的类型变成"HKT 仿真"——通过 for<'r> 全称量化 bound 给 Database trait 附加约束。

这种写法的代价是,上层 API 每次用 DB::Arguments<'q> 都要写一长串 bound。比如 0.6 版的 Query::execute 要这样声明:

rust
pub fn execute<'e, 'q, E, A>(
    self,
    executor: E,
) -> impl Future<Output = Result<DB::QueryResult, Error>> + Send + 'e
where
    'q: 'e,
    E: Executor<'e, Database = DB>,
    DB: Database + for<'r> HasValueRef<'r, Database = DB>,
    A: 'q + IntoArguments<'q, DB>,
    DB: for<'q2> HasArguments<'q2, Database = DB>,
    // ... 还要再写两三条

每次用到 DB::Arguments<'q> 都要追加一条 DB: HasArguments<'q2, ...>——因为编译器不会自动帮你"把 type Arguments 这个从属投影出来"(这就是 higher-kinded type 不直接支持的核心痛点)。社区把这种模式戏称为"HRTB soup"(for<'r> ... bound 的一碗汤)。

GAT 稳定之后,这一切变成:

rust
pub trait Database: 'static + Sized + Send + Debug {
    type ValueRef<'r>: ValueRef<'r, Database = Self>;
    type Arguments<'q>: Arguments<'q, Database = Self>;
    // ...
}

上层 API 可以直接写 fn execute<'q, DB: Database>(args: DB::Arguments<'q>)——编译器自动投影,不再需要 HasArguments 这种中间层。sqlx-core 0.7Query 类型(query.rs:72-96)能够写得远比 0.6 干净:

rust
pub struct Query<'q, DB: Database, A> {
    pub(crate) statement: Either<&'q str, &'q DB::Statement<'q>>,
    pub(crate) arguments: Option<Result<A, BoxDynError>>,
    pub(crate) database: PhantomData<DB>,
    pub(crate) persistent: bool,
}

&'q DB::Statement<'q> 这个类型在 0.6 时代需要约 5 行 where 才能表达,GAT 之后直接写在字段位置——这是 GAT 对 sqlx 的最大价值

从 0.6 升到 0.7 的 migration 指南里有一段直接说明了动因(见 sqlx 0.7 的 CHANGELOG):

The internal type system has been rewritten to use generic associated types (GATs), eliminating the need for the HasValueRef / HasArguments / HasStatement trait soup. Downstream drivers and users of custom Database impls will need Rust 1.65+.

这是 sqlx 最低 Rust 版本从 1.60 跳到 1.65 的直接原因——它吃了一个 MSRV bump 来换 trait 家族的清爽。这条 trade-off 放在 2023 年的 Rust 生态里相当合理,因为那时候主流项目已经普遍在 1.70+ 了。

3.3.1 GAT 带来的新代价

GAT 不是免费午餐。sqlx 的实际使用里有两类新问题:

问题 1:lifetime inference 失败。GAT 下 DB::Arguments<'q>'q 必须由编译器从上下文推导。在一些复杂的异步调用链里,编译器会推不出来,报出 "implementation is not general enough" 之类的错误。sqlx 0.7 的 Issue tracker 里有若干这类帖子——用户需要手动标注 'q,或者用 type alias 把 GAT 投影固化。

问题 2:Object safety 丢失。带 GAT 的 trait 是 non-object-safe 的——你不能 dyn Database。这正是为什么 Any 驱动需要自己的 AnyConnectionBackend trait(所有方法用 BoxFuture 返回,无 GAT)而不能直接 dyn Database。第 2.6 节讨论的"运行时多态叛徒"在类型系统层面的另一种解释就是:GAT + object-safety 天然不兼容,Any 必须另起炉灶

这两个代价对 sqlx 的影响都是可控的——第一个问题靠文档和错误信息缓解,第二个问题通过 Any 驱动的独立 trait 绕开。GAT 总体是净收益。

3.3.2 如何读懂 sqlx 的 trait 边界编译错

GAT + trait 家族的一个直接副作用:编译错信息会变长。新手常常遇到这种错:

error[E0277]: the trait bound `&mut Transaction<'_, Postgres>: Executor<'_>`
              is not satisfied
   --> src/main.rs:42:5
    |
 42 |     sqlx::query!("SELECT ...").fetch_one(&mut tx).await?;
    |                                ^^^^^^^^^ the trait `Executor<'_>`
    |                                          is not implemented for
    |                                          `&mut Transaction<'_, Postgres>`
    |
    = help: the following other types implement trait `Executor<'c>`:
            &'c Pool<DB>
            &'c mut <DB as Database>::Connection

这条错误在第 1 章"让步二·附"提到过——0.8 之后 &mut Transaction 不再自动是 Executor。但错误信息里藏着第二条线索:help 里列出的候选实现是用关联类型 <DB as Database>::Connection 描述的。读懂它需要先读懂 Database trait 的关联类型投影语法。

三个常见 sqlx 错误信息模式:

错误模式含义解决
trait bound <X as Database>::Row: Row not met某个 Row 关联类型的 super-trait bound 缺失一般是 impl 顺序问题
implementation is not general enoughGAT 生命周期推断失败手动标注 'q
<X as Database>::ValueRef<'_> doesn't implement ...ValueRef 的 GAT 投影缺少某个 trait 实现检查 Decode<DB> 的覆盖

读 sqlx 错误信息的第一步永远是<X as Database>::Y 翻译成具体类型——<Postgres as Database>::Row 就是 PgRow<Postgres as Database>::ValueRef<'_> 就是 PgValueRef<'_>。一旦翻译过来,问题就从抽象 trait bound 变成具体类型缺 impl,修起来容易得多。

3.4 读取链:Row / Column / Value / ValueRef / QueryResult

这是 Database trait 里最大的一组关联类型——五个。它们共同描述"从数据库读回一行,怎么拆开、怎么解码"。

3.4.1 Row:一行数据的抽象

sqlx-core/src/row.rs:14-16Row trait:

rust
pub trait Row: Unpin + Send + Sync + 'static {
    type Database: Database<Row = Self>;
    // ...
}

Unpin + Send + Sync + 'static 四条约束——这比 Database 本身还严格。每一条都有直接用途:

  • Unpin:允许 Row 在异步流里自由移动(BoxStream<Row> 的要求)。
  • Send + Sync:Row 经常在 tokio task 之间流转。
  • 'static:Row 不能借用外部数据。注意这和 ValueRef<'r> 形成对比——Row 持有值,ValueRef 持有引用。

Row::Database = Self:这是自反关联类型约束。它说"我这个 Row 的 Database 关联类型,指回去得是能产生我的那个 Database"。展开成具体:PgRow::Database = Postgres,而 Postgres::Row = PgRow——两者形成闭环。这条约束让编译器能在 fn take<R: Row>(r: R) -> R::Database::Row 这种泛型代码里做等价替换。

Row trait 的方法包括 try_get<T>(index) / try_get_raw(index) / columns() / try_column(index) / len()——全部都返回泛型或关联类型的值。具体实现由驱动自己给出:PgRow 持有 Postgres 线路协议解析后的 DataRow 字节,MySqlRow 持有 MySQL 的字段值数组,SqliteRow 持有 SQLite statement handle 的 column() 指针快照。三家的存储格式完全不同,但都满足 Row trait。

3.4.2 Column:列元数据

sqlx-core/src/column.rs:6-22

rust
pub trait Column: 'static + Send + Sync + Debug {
    type Database: Database<Column = Self>;
    fn ordinal(&self) -> usize;
    fn name(&self) -> &str;
    fn type_info(&self) -> &<Self::Database as Database>::TypeInfo;
}

Column 是列元数据——它不持有数据值,只持有"这一列叫什么、类型是什么、顺序是多少"。和 Row 的关系是:"每一 Row 里有一组 Column"——Row::columns() 返回 &[DB::Column]

一个很重要的观察:Columntype_info 返回的是 &DB::TypeInfo 而不是 DB::TypeInfo 的克隆。这是因为列类型信息在一个预处理语句生命周期内是不变的(PostgreSQL 的 RowDescription 在 Parse 阶段就被服务端返回了),后续每次读行都引用同一份。把 TypeInfo 设计成"按引用传"避免了每行一次的克隆开销。

3.4.3 Value 与 ValueRef:数据持有权的分裂

这一对是整个读取链最微妙的设计。

sqlx-core/src/value.rs:9

rust
pub trait Value {
    type Database: Database<Value = Self>;
    fn as_ref(&self) -> <Self::Database as Database>::ValueRef<'_>;
    fn type_info(&self) -> Cow<'_, <Self::Database as Database>::TypeInfo>;
    fn is_null(&self) -> bool;
    // decode / try_decode 等方法
}

sqlx-core/src/value.rs:99

rust
pub trait ValueRef<'r>: Sized {
    type Database: Database;
    fn to_owned(&self) -> <Self::Database as Database>::Value;
    fn type_info(&self) -> Cow<'_, <Self::Database as Database>::TypeInfo>;
    fn is_null(&self) -> bool;
}

Value拥有所有权的值——它独立存在,不借用 Row。ValueRef<'r>借用的值——生命周期 'r 挂到了 Row 上,意味着"只要 Row 还活着,我这个 ValueRef 就有效"。

为什么要两个? 想象 row.try_get::<i32, _>(0) 这个调用的流程:

  1. try_get 需要从第 0 列拿到一个原始值。
  2. 原始值在 PostgreSQL 里可能只是 PgDataRow 缓冲区里的几个字节——拷贝出来当 owned value 是浪费。
  3. 所以 Row::try_get_raw 先返回一个 ValueRef<'r>——只是一个指针 + 长度,生命周期绑到 row 上。
  4. 真正 decode 成 i32 的时候,Decode::decode 接收的是 ValueRef,不是 Value

换句话说,ValueRef 是 decode 路径上的默认形态,Value 只在需要"把值抽出来跨行生存"时用。后者的典型用例是 query_scalar! 宏返回单列值——它内部会 ValueRef::to_owned() 成 Value,因为返回的值不能借用即将析构的 Row。

ValueRef::to_owned 的实现根据数据库有重大差异:

  • Postgres / MySQL:"基本是一次引用计数递增,O(1)"(原文见 value.rs:105)——因为底层 buffer 用 Arc<[u8]>bytes::Bytes 共享。
  • SQLite:必须真实拷贝——SQLite 的 sqlite3_column_blob 指针在 row 步进后就失效,没法靠引用计数保留。

这条差异很重要。它意味着你在 Postgres 驱动下做 let v: Value = row.value(0).to_owned(); 几乎零成本,在 SQLite 下则是一次 memcpy。第 7 章会专门讲这个差异如何影响 query! 的内部选择。

3.4.5 一次 row.try_get::<i32, _>(0) 的类型流

把前面几个关联类型串起来,看 let id: i32 = row.try_get(0)?; 这一行背后的类型动线:

关键节点:

  1. try_get_raw 先返回 ValueRef<'_>——'_ 生命周期绑到 &self(row)。编译器在这一步强制"ValueRef 不能活过 row"。
  2. Type::<i32, Postgres>::compatible 做类型兼容检查——i32 声明自己对应 "INT4" / "INT" / "SERIAL" 等,检查通过才进入 decode。这步失败会返回 Error::Decode("mismatched type")
  3. Decode::<i32, Postgres>::decodeValueRef 读字节——Postgres 的 i32::decodevalue.as_bytes_buffer().read_i32::<BigEndian>()(按大端读四字节)。
  4. 整条路径零 owned 分配——ValueRef 借用 row 的 buffer,i32 是栈上 4 字节。对比如果你写 let id: String = row.try_get(0)?,Decode 实现会 ValueRef::as_str()?.to_owned() 真做一次堆分配。

这张图解释了为什么 sqlx 号称"零拷贝解码"——不是指所有类型都没有拷贝,而是指只有必要时才拷贝(值类型零、引用类型 / 字符串按需)。这条路径的精妙处是它全部发生在 &self 的借用期内,不需要跨 await——这是 ValueRef 'r 生命周期的真正价值。

3.4.4 QueryResult:写操作的结果

rust
type QueryResult: 'static + Sized + Send + Sync + Default + Extend<Self::QueryResult>;

这是唯一没有独立 trait 的关联类型——它是一个值类型(不是 trait bound)。PgQueryResult 是一个 struct 带 rows_affected: u64,MySQL 多一个 last_insert_id: u64,SQLite 多一个 changes: u64。没有公共 trait,只有公共的值蓝图。

关键是 Extend<Self::QueryResult> 这条 super-trait:PgQueryResult 实现 Extend<PgQueryResult> 意味着多个 QueryResult 可以"累加"成一个——这是 Executor::execute_many 流式执行多条语句时的合并需求。翻 sqlx-postgres/src/query_result.rs 能看到 impl Extend<PgQueryResult> for PgQueryResult 的实现——只是把 rows_affected 加起来。

3.5 写入链:Arguments / ArgumentBuffer / Statement

读取链完了,看写入链。

3.5.1 Arguments:参数集合

sqlx-core/src/arguments.rs:12

rust
pub trait Arguments<'q>: Send + Sized + Default {
    type Database: Database;
    fn reserve(&mut self, additional: usize, size: usize);
    fn add<T>(&mut self, value: T) -> Result<(), BoxDynError>
    where T: 'q + Encode<'q, Self::Database> + Type<Self::Database>;
    fn len(&self) -> usize;
    fn format_placeholder<W: Write>(&self, writer: &mut W) -> fmt::Result { ... }
}

Arguments<'q>一组待发送的参数'q 生命周期挂在参数的潜在借用上——如果你 .bind(&some_string),那 some_string 的生命周期至少活到查询执行完,这条 bound 由 'q 守护。

注意 add 方法的 where T: 'q + Encode<'q, Self::Database> + Type<Self::Database> ——把 Encode 和 Type 绑在一起。这意味着你加一个参数时,系统同时要求"你能 encode 成字节"且"你声明了对应的数据库类型"。我们会在第 5 章详细看 EncodeType 这一对。

format_placeholder 的默认实现是 ?——MySQL 和 SQLite 都用 ?,Postgres 要自定义为 $1 / $2 / ...(按参数位置带编号)。这条看似无关紧要的 API 决定了"同一份 Rust 代码能不能跨 DB 复用"——第 10 章 QueryBuilder 就靠它。

3.5.2 ArgumentBuffer:编码缓冲区

type ArgumentBuffer<'q>;唯一没有 trait bound 的关联类型——它没有 impl X<'q> 之类的约束。这意味着每家驱动可以完全自由地选择缓冲区形态:

  • PostgresPgArgumentBuffersqlx-postgres/src/arguments.rs:26-48)——一个带 patchestype_holes 的复杂结构:

    rust
    pub struct PgArgumentBuffer {
        buffer: Vec<u8>,
        count: usize,
        patches: Vec<Patch>,             // 延迟补写(比如 `JSONB` 的变长前缀)
        type_holes: Vec<(usize, HoleKind)>, // OID 还没解析时先留洞,之后回填
    }
  • MySQLtype ArgumentBuffer<'q> = Vec<u8>; —— 一个裸字节缓冲。没有复杂结构,因为 MySQL 的参数编码是一次性决定的(预处理阶段类型已经锁死)。

  • SQLitetype ArgumentBuffer<'q> = Vec<SqliteArgumentValue<'q>>; —— 一个带生命周期的值向量(SQLite 的参数不是按字节编码的,是按类型 tag + 值的变体存的)。

三家的 ArgumentBuffer 形态截然不同——这就是为什么没有共同 trait 约束。但这个关联类型仍然存在于 Database trait 里,因为 Arguments<'q>::ArgumentBuffer<'q>Encode 实现引用:Encode::encode_by_ref(&self, buf: &mut DB::ArgumentBuffer<'_>)——每个 Encode 实现需要知道"我应该往哪种容器里写"。

3.5.3 Statement:预处理语句的抽象

sqlx-core/src/statement.rs:19

rust
pub trait Statement<'q>: Send + Sync {
    type Database: Database;
    fn to_owned(&self) -> <Self::Database as Database>::Statement<'static>;
    fn sql(&self) -> &str;
    fn parameters(&self) -> Option<Either<&[<Self::Database as Database>::TypeInfo], usize>>;
    fn columns(&self) -> &[<Self::Database as Database>::Column];
    // ...
}

Statement 代表已经被驱动 prepare 过的 SQL 语句——它缓存了 SQL 文本、参数类型、列元数据。Connection::prepare_with 返回的就是 Statement<'q>

parameters 返回 Option<Either<&[TypeInfo], usize>> 这个怪异的类型,是因为不同数据库对"参数类型"的支持水平不同

  • Postgres:完整的参数类型——服务端在 Parse 响应里返回每个 $1$2 的 OID。所以是 Either::Left(&[PgTypeInfo])
  • SQLite:只有参数个数——sqlite3_bind_parameter_count(stmt) 告诉你有几个 ?,但不告诉你每个 ? 期望什么类型。所以是 Either::Right(usize)
  • MySQL:介于两者之间——服务端说得模糊,所以 sqlx MySQL 驱动通常也返回 Either::Right(usize)

这个 Either 类型是 sqlx 表达"能力差异"的一种手法——不是把每个驱动的 parameters 签名分叉,而是让统一签名返回一个枚举,调用方按 arm 分别处理。query! 宏的编译期校验就用这个差异:Postgres 下能检查每个参数的 Rust 类型匹不匹配 OID,SQLite 下只能检查参数个数匹不匹配。

3.6 元数据:TypeInfo

sqlx-core/src/type_info.rs:4

rust
pub trait TypeInfo: Debug + Display + Clone + PartialEq<Self> + Send + Sync {
    fn is_null(&self) -> bool;
    fn name(&self) -> &str;
    fn type_compatible(&self, other: &Self) -> bool
    where Self: Sized { self == other }
    fn is_void(&self) -> bool { false }
}

TypeInfo 是数据库方的类型信息——"这一列是 INT4 还是 TEXT 还是 VARCHAR(255)"这种。不要和 Rust 侧的 Type<DB> trait 混淆——后者讲的是"Rust 的 i32 对应 DB 的哪个 TypeInfo"。这两者构成一对:

  • TypeInfo:DB → 自己对自己的描述。
  • Type<DB>(第 5 章):Rust 类型 → DB TypeInfo 的映射。

ClonePartialEq 是关键 super-trait——TypeInfo 经常需要按值传递、互相比较(比如"这一列的 TypeInfo 和我期望的是不是一致")。

具体实现差异:

  • PostgresPgTypeInfo 内部是 PgType 枚举 + 可选的 OID。PgType::Int4 / PgType::Text / PgType::Custom(UStr)(用户自定义类型)。
  • MySQLMySqlTypeInfoColumnType + char_set——因为 MySQL 的 VARCHAR 分字符集。
  • SQLiteSqliteTypeInfoDataType 枚举(Null/Int/Real/Text/Blob)——SQLite 动态类型系统,只有五种。

is_void 方法默认 false,唯一的例外是 Postgres 的 void 类型(SELECT pg_sleep(1) 的返回)——这个方法让 query! 宏知道"这列是 void 就别给 Rust 建一个字段"。

3.7 外层锚点:Connection / TransactionManager

这两个关联类型都在上一章已经见过——它们是 Database trait 引用另外两套 trait 家族的锚点。

  • type Connection: Connection<Database = Self> ——PgConnection / MySqlConnection / SqliteConnection。第 12 章详细。
  • type TransactionManager: TransactionManager<Database = Self> ——PgTransactionManager / MySqlTransactionManager / SqliteTransactionManager。第 15 章详细。

它们的存在让 Database trait 不仅描述"数据格式",还描述"操作入口"——你从 DB::Connection 能获取连接,从 DB::TransactionManager 能开事务。所以 Database trait 是整个 driver 的类型级 manifest,读它就知道"这家驱动能提供什么"。

3.7.1 ConnectionTransactionManager 的约束反转

这两个关联类型的约束用的是反向闭环

rust
type Connection: Connection<Database = Self>;
type TransactionManager: TransactionManager<Database = Self>;

第一行读作"我这个 DatabaseConnection 关联类型,必须指向一个具体的 PgConnection 之类,而那个 Connection 的 Database 关联类型又必须等于我"。

为什么这么绕?因为如果不加反向约束,下面这种病态实现就能编译:

rust
// 假设没有 Database = Self 约束
impl Database for Postgres {
    type Connection = MySqlConnection;  // 反手实现一个 MySQL 的 Connection
    // ...
}

那当用户调用 Pool::<Postgres>::acquire,底层调用 Postgres::Connection::open(),就会得到一个 MySQL 连接——类型系统彻底破产。Connection<Database = Self> 这条约束杜绝这种跨驱动张冠李戴。

这种"trait A 引用 trait B 的实现,要求 B 的关联类型回指 A"的模式叫做等式关联类型约束(associated type equality constraint),是 Rust trait 家族设计的基石。sqlx 的 11 个关联类型里每一个有 trait bound 的都带 Database = Self 约束,包括 Row<Database = Self>Column<Database = Self>Value<Database = Self>——这条约束像胶水把 11 个关联类型粘成一个不可拆的类型包

TransactionManager 的反向闭环同理。它的 trait 签名(sqlx-core/src/transaction.rs:15):

rust
pub trait TransactionManager {
    type Database: Database;
    fn begin(conn: &mut <Self::Database as Database>::Connection) -> BoxFuture<'_, Result<(), Error>>;
    fn commit(...) -> BoxFuture<'_, Result<(), Error>>;
    fn rollback(...) -> BoxFuture<'_, Result<(), Error>>;
    fn start_rollback(conn: &mut <Self::Database as Database>::Connection);
}

注意 begin 的参数类型是 &mut <Self::Database as Database>::Connection——它从 TransactionManager::Database 跳回 Database::Connection。这条类型遍历经过两次关联类型投影Database 拿到 Self::Database,再拿 Connection)。GAT 之前这会是一个 for<'c> HasConnection<'c> 之类的 helper trait,0.7 之后直接一行表达。

3.7.2 'static + Send + Debug 的传播

回到 Database 本身 trait Database: 'static + Sized + Send + Debug 的这四条 super-trait bound,它们通过关联类型系统传播到所有下游代码。举个具体的传播链:

  • Database: Send → 要求 DB::Connection: Connection<Database = Self>Connection: Send(来自 connection.rs:14pub trait Connection: Send)→ 要求 PgConnection: Send
  • Database: 'staticRow: 'static(来自 row.rs:14pub trait Row: Unpin + Send + Sync + 'static)→ PgRow: 'static
  • Database: Debug → 出现在 tracing::error!("query on {DB:?} failed: {err}"){DB:?} 里。

这四条 bound 的作用是"告诉编译器:任何持有 DB: Database 的泛型代码都可以自动获得 DB::Connection: Send 等派生能力"——让你在 fn with_conn<DB: Database>(pool: &Pool<DB>) 里直接 tokio::spawn(pool.acquire()),而不用手写十条 DB::Connection: Send + 'static 的 where 子句。这是 super-trait bound 的传递性给上层带来的"一次声明,处处可用"收益。

3.8 关联常量:NAMEURL_SCHEMES

被绝大多数读者忽略的两行:

rust
const NAME: &'static str;
const URL_SCHEMES: &'static [&'static str];
  • Postgres::NAME = "PostgreSQL"URL_SCHEMES = &["postgres", "postgresql"]
  • MySql::NAME = "MySQL"URL_SCHEMES = &["mysql", "mariadb"]
  • Sqlite::NAME = "SQLite"URL_SCHEMES = &["sqlite"]

这两个常量的用途有二:

  1. Any 驱动的 URL scheme 分发(第 2.6 节):install_drivers 注册的 AnyDriver 结构体就持有这两个值,AnyConnection::connect("postgres://...") 路径里的 scheme 匹配直接用 URL_SCHEMES.contains(&scheme)
  2. 错误信息 / tracing spanNAME 出现在 Error::Database 的格式化输出里、tracing::info_span!("query", db = DB::NAME, ...) 里。

这两个常量以"关联常量"而不是"关联类型"的形态存在,是因为它们的值不随实现方变化而必须同名——Postgres 的名字必须叫 "PostgreSQL"(URL scheme 依赖),不能叫别的。关联常量能在 const fn 里使用(这对 Any 驱动的 AnyDriver::without_migrate::<DB>() 是必要的,见 2.6.5 节)。

3.9 HasStatementCache:无方法标记 trait

sqlx-core/src/database.rs:113-114

rust
/// A [`Database`] that maintains a client-side cache of prepared statements.
pub trait HasStatementCache {}

一个 没有方法的 trait。Postgres 和 MySQL 实现它(impl HasStatementCache for Postgres {}impl HasStatementCache for MySql {}),SQLite 不实现——因为 SQLite 驱动不做跨 fetch 的语句缓存,每次 sqlite3_prepare_v2 都新建。

这个 "标记 trait"(marker trait)的作用是把一项能力编码进类型系统。上层 API 用 where DB: HasStatementCache 守护"只有支持缓存的 DB 才能用这些方法"。

具体例子 1:Connection::cached_statements_sizeconnection.rs:126-132):

rust
fn cached_statements_size(&self) -> usize
where
    Self::Database: HasStatementCache,
{
    0
}

这条 where 子句让 SqliteConnection::cached_statements_size() 编译期就不存在——你在 SQLite 连接上调用这个方法,编译器直接报"method not found",而不是运行时返回 0 后你疑惑为什么缓存永远 0。

具体例子 2:Query::persistentquery.rs:124-139):

rust
impl<'q, DB, A> Query<'q, DB, A>
where
    DB: Database + HasStatementCache,
{
    pub fn persistent(mut self, value: bool) -> Self { ... }
}

persistent 方法只在 DB: HasStatementCache 时存在。SQLite 下你写 query("...").persistent(true) 会编译失败——因为 SQLite 不实现 HasStatementCache

这种"把运行时能力差异搬到类型系统"的做法,是 Rust trait 家族设计里最精妙的一招。它避免了"在 SQLite 连接上传一个 persistent=true 被悄悄忽略"这类隐式降级——错误提前到编译期。

顺便提一下,Any 驱动(sqlx-core/src/any/database.rs:39)实现了 HasStatementCache——这不是因为 Any 总能缓存,而是 Any 需要保持和它"可能包装任一驱动"的泛用性一致,实际行为取决于内层具体驱动。

3.9.1 Rust 生态里的 marker trait 家谱

HasStatementCache 这种"无方法 trait 作为能力开关"的做法在 Rust 生态里有一整条谱系。把 sqlx 放进来对照:

Marker trait属于哪个库表达的能力使用形态
Sendstd可以跨线程转移所有权where T: Send
Syncstd&T 可以跨线程共享where T: Sync
Unpinstd (通过 Pin)不受固定语义约束where T: Unpin
Copystd按位拷贝即可复制where T: Copy
DerefMutstd可以派生可变引用where T: DerefMut
HasStatementCachesqlx-core支持客户端预处理语句缓存where DB: HasStatementCache
DatabaseExtsqlx-macros-core参与 query! 的编译期校验where DB: DatabaseExt
TypeCheckingsqlx-core能把 DB 类型映射到 Rust 类型字符串where DB: TypeChecking
Service (空 marker 版本)tower(某些变体)某些中间件约束不是真空 trait,这里只是形态对照
http_body::Bodyhyper能当 HTTP body 流where B: Body

观察几条规律:

  1. 无方法 marker 大多表达"能力开关"或"抽象断言"——它们不是告诉你"应该调什么方法",而是告诉类型系统"我有这项能力"。HasStatementCache 精确属于这一类。
  2. sqlx 的三个 markerHasStatementCache / DatabaseExt / TypeChecking)分布在两个 crate——HasStatementCache 在 sqlx-core(用户可见),后两个在 sqlx-macros-core(宏内部)。这个拆分和 §2.3.1 讨论的 crate 边界一致。
  3. 没有"方法"的 trait 也可以继承有方法的 trait——比如 DatabaseExt: Database + TypeChecking(§2.3.1),它自身有 3 个方法但主要用法是作为"组合能力断言"。marker trait 的边界其实是模糊的:只要这个 trait 的主要使用方式是作 bound 而不是调方法,就属于这一族。

学会用 marker trait 表达能力差异,是 Rust trait 家族设计的基本功。HasStatementCache 这一行短短的 pub trait HasStatementCache {} 背后,代表的是一种"把动态 bool 搬到静态类型系统"的思路——从 SQLite 的 capability 缺失直接推导出"persistent(true) 在 SQLite 上编译不过"。

3.9.2 如果缺了某个关联类型会怎样

Database 的每一个关联类型做减法实验,看看少了它会坏在哪:

  • 少了 Row:整条 Executor::fetch 路径断裂——BoxStream<'e, Result<DB::Row, Error>> 签名写不出来。
  • 少了 ColumnStatement::columns 返回类型丢失,query! 的列类型映射没处落脚。
  • 少了 TypeInfoType<DB> trait 的 type_info() 返回类型丢失;更严重的是第 5 章的 Encode/Decode 整套断了。
  • 少了 Valuerow.value(0) 返回什么?无法抽取一个能跨行生存的值。
  • 少了 ValueRef<'r>try_get_raw 断——必须先做 to_owned() 再 decode,所有解码都变成按值传递,性能大幅下降。
  • 少了 Arguments<'q>query(sql).bind(x) 无处存值,Query 类型整个需要重做。
  • 少了 ArgumentBuffer<'q>Encode::encode_by_ref 的 buf 参数类型丢失。
  • 少了 Statement<'q>Connection::prepare_with 断,所有"用相同 SQL 多次查询"的优化消失。
  • 少了 QueryResultexecute 无法告诉用户"影响了几行"。
  • 少了 TransactionManager:没法开事务,Connection::begin 断。
  • 少了 NAME:错误信息和 tracing span 缺数据库名——可以重建但麻烦。
  • 少了 URL_SCHEMES:Any 驱动彻底废——无法按 URL scheme 分发。

这个减法实验本身不算新知识,但它证明了每一个关联类型都不是装饰——都对应着上层某一条具体的 API 路径或用户场景。这也回答了"为什么是 11 个不多不少"——sqlx 团队在 0.1 到 0.8 的演进里逐个加上来,每一个都因具体需求而生。

3.10 三家对照:Postgres / MySQL / SQLite 的 impl Database

把三家的 impl Database 并排摆一起(源文件分别是 sqlx-postgres/src/database.rs:14sqlx-mysql/src/database.rs:12sqlx-sqlite/src/database.rs:13):

关联类型PostgresMySQLSQLite
ConnectionPgConnectionMySqlConnectionSqliteConnection
TransactionManagerPgTransactionManagerMySqlTransactionManagerSqliteTransactionManager
RowPgRowMySqlRowSqliteRow
QueryResultPgQueryResultMySqlQueryResultSqliteQueryResult
ColumnPgColumnMySqlColumnSqliteColumn
TypeInfoPgTypeInfoMySqlTypeInfoSqliteTypeInfo
ValuePgValueMySqlValueSqliteValue
ValueRef<'r>PgValueRef<'r>MySqlValueRef<'r>SqliteValueRef<'r>
Arguments<'q>PgArgumentsMySqlArgumentsSqliteArguments<'q>
ArgumentBuffer<'q>PgArgumentBufferVec<u8>Vec<SqliteArgumentValue<'q>>
Statement<'q>PgStatement<'q>MySqlStatement<'q>SqliteStatement<'q>
NAME"PostgreSQL""MySQL""SQLite"
URL_SCHEMES&["postgres", "postgresql"]&["mysql", "mariadb"]&["sqlite"]
HasStatementCache实现实现不实现

几条对比观察:

  1. 12 行关联类型 + 2 行常量 + 1 行 marker trait——三家结构完全平行,只是每个位置的具体类型不同。这就是 trait 家族设计的目标形态:等价替换
  2. Arguments<'q> 的生命周期参数:Postgres 和 MySQL 是 PgArgumentsMySqlArguments(无 'q),因为它们编码后就是字节 buffer 自包含了;SQLite 是 SqliteArguments<'q> 保留生命周期——因为 SQLite 的参数值(SqliteArgumentValue::Text(&str))可能借用外部字符串,不会预先序列化。
  3. ArgumentBuffer<'q> 的形态差异最大——Postgres 的 PgArgumentBuffer 有 patch 和 type-hole 机制(延迟回填 OID),MySQL 直接裸字节,SQLite 是值向量。这揭示了三家协议的根本差异:Postgres 有 OID 的运行时查表、MySQL 有 COM_STMT_EXECUTE 的二进制格式、SQLite 有 C API 的"值按 tag 传"。后续第 16-18 章分别展开。
  4. SQLite 唯一不实现 HasStatementCache——这决定了 query("...").persistent(true) 只在 Postgres / MySQL 下可用。

这张表不是让你背下来的——它是让你在后面读具体驱动实现时脑子里有一张"同构映射"的参照。当你读 sqlx-postgres/src/row.rs 看到 PgRow 时,你立刻知道它的位置对应 DB::Row,功能和 SqliteRow 等价——只是载荷格式不同。

3.11 本章小结

本章把 sqlx 整个类型体系的"收束点"Database trait 拆开,可以带走以下关键判断:

  1. 11 个关联类型 + 2 个关联常量 + 4 条 super-trait bound 共同描述了"一个 sqlx 驱动应该提供什么"——读取链(Row/Column/Value/ValueRef/QueryResult)、写入链(Arguments/ArgumentBuffer/Statement)、元数据(TypeInfo)、外层锚点(Connection/TransactionManager)、名字识别(NAME/URL_SCHEMES)。
  2. GAT(泛型关联类型)是让这套设计成立的语言前提(§3.3)。没有 GAT 的 sqlx 0.6 必须用 HasValueRef<'r> / HasArguments<'q> / HasStatement<'q> 三个 helper trait + for<'r> HRTB 绕过;0.7 吃了一个 MSRV bump 到 1.65 来换得清爽的 trait 家族。
  3. GAT 的新代价是 object-safety 丢失——你不能 dyn Database。Any 驱动(§2.6)必须另起炉灶用 AnyConnectionBackend trait 绕过,这条代价预期之内。
  4. Value 与 ValueRef 的分裂(§3.4.3)反映了"数据持有权"的工程现实——ValueRef 是 decode 路径默认形态(零拷贝),Value 用于值需要跨 row 生存的场景。Postgres 的 to_owned 是 O(1) 引用计数,SQLite 的 to_owned 必须 memcpy——差异在上层 API 不可见但性能可感。
  5. ArgumentBuffer 没有共同 trait(§3.5.2)——三家形态截然不同:Postgres 的 PgArgumentBuffer 带 patch/hole、MySQL 是 Vec<u8>、SQLite 是 Vec<SqliteArgumentValue<'q>>。这是刻意不抽象的一处:协议差异太大,硬抽象只会让每家都被迫做运行时检查。
  6. HasStatementCache 是把能力编码进类型系统的标记 trait(§3.9)——Postgres / MySQL 实现,SQLite 不实现。Query::persistentConnection::cached_statements_sizewhere DB: HasStatementCache 让"在 SQLite 下调这些方法"变成编译错误而不是运行时静默。
  7. NAME 与 URL_SCHEMES 两个常量(§3.8)让 Any 驱动可以在运行时按 URL scheme 分发,也让错误信息和 tracing span 能携带驱动名字。
  8. 三家对照表(§3.10)显示整个 trait 家族的"等价替换"结构——12 行平行,每家只是具体类型不同。这就是 trait 家族设计的终极目标。

3.11.1 判断题:如果换你设计

最后做几道"设计选择"判断题——把本章的理解直接转成工程直觉:

Q1:如果你要加一个 type Transaction<'c> 关联类型专门表示事务,让用户写 DB::Transaction<'c>::commit() 而不是 conn.begin().await?.commit().await?——这算好设计吗?

A:不算。Transaction<'c, DB> 作为一个具体 struct 已经够用(sqlx-core/src/transaction.rs:86),它本身不带驱动特有行为——三家 DB 的区别被 TransactionManager trait 消化。再加一层 type Transaction<'c> 关联类型会变成空壳,让 trait 家族膨胀无收益。判断原则:只有当"具体类型在不同 DB 下形态不同"时才值得加关联类型——Transaction 本身是泛型 struct,不满足这条。

Q2:如果把 NAME: &'static str 换成 fn name() -> &'static str 方法,行为等价吗?

A:等价,但 NAME 的关联常量形态能在 const fn 里用。回想 §2.6.5 里 AnyDriver::without_migrate::<DB>() 是 const fn——它要读 DB::NAMEDB::URL_SCHEMES。如果改成 fn name(),这条 const fn 路径会断(或者 Rust 的 const fn 能力要往前走一大步)。所以选择关联常量是被 Any 驱动的 const 注册路径倒推出来的。

Q3:ArgumentBuffer<'q> 无 trait bound 是偷懒吗?

A:不是偷懒,是不硬抽。这个缓冲区的形态在 Postgres / MySQL / SQLite 下差异是本质的——硬抽一个 trait Buffer { fn write_bytes(...); } 会让 SQLite 的 Vec<SqliteArgumentValue<'q>> 必须做一次 serialize-to-bytes-then-parse 的无谓开销。不抽,把表达权交给驱动,反而让每家 encode 路径最优。判断原则:抽象应当消除重复、而不是掩盖差异——后者往往导致泄漏。

Q4:为什么 TypeInfo 不叫 DbTypeColumnType

A:因为 TypeInfo 承担的不只是"列类型"——它也被 Arguments::add 的类型检查、Value::type_infoStatement::parameters 共享。ColumnType 会狭化其语义;DbType 歧义——Rust 侧的 Type<DB> trait 也算"类型"。TypeInfo 这个名字精确对应"数据库方提供的类型描述信息",和 Rust 侧的 Type 一目了然区分。好的 trait 命名像好的变量命名——多花十分钟,长期收益巨大。

做完这四道题,你对 sqlx Database trait 的每一处选择就有了自己能解释的直觉,而不只是"背下来"。下一章进入 Executor 时,你会发现同样的设计权衡在不同 trait 上反复出现——这就是 trait 家族设计的整体性。

3.12 下一章指路

下一章,我们进入 Executor trait——Database trait 描述"驱动应该提供什么类型",Executor 描述"连接、Pool、Transaction 应该共同满足什么操作接口"。我们会看到 fetch / fetch_many / execute 这些方法如何围绕 DB: Database 构建、为什么 0.8 不再支持 impl Executor for &mut Transaction(本书第 1 章已经预告过,到第 4 章我们会从 trait 边界视角再看一遍)。

基于 VitePress 构建