SQLx 源码深度解析

第5章 Encode / Decode / Type:双向映射的三位一体

作者 杨艺韬 · 11,785 字 · 发布于 · 更新于

Rust 类型与数据库类型的映射被拆成三个独立 trait:Type 声明「我是什么 SQL 类型」、Encode 把值写进参数缓冲、Decode 从值引用读回来。三分而非合一,是为了让外部类型只实现自己需要的那一部分。

本章要点

  • sqlx 用三个独立 trait 描述 Rust 类型 ↔ 数据库类型的双向映射:Type<DB> 声明"我是什么 SQL 类型"、Encode<'q, DB> 把 Rust 值写进 ArgumentBuffer、Decode<'r, DB> 从 ValueRef 读回 Rust 值。
  • 三分而非合一是刻意设计——让外部类型(比如 Uuid、BigDecimal)可以只实现需要的那部分(比如只要 Decode 不要 Encode)。
  • IsNull 枚举(sqlx-core/src/encode.rs:10)区分 "值本身是 NULL" 和 "值成功写入了零字节"——Option::<T>::None 只在前者。这个区分是 SQL 三值逻辑(true/false/null)在 trait 签名里的投影。
  • compatible 方法(sqlx-core/src/types/mod.rs:228)是运行时类型兼容检查——i32 默认只接受 INT4,但 str 实现里扩展成接受 TEXT / NAME / BPCHAR / VARCHAR / UNKNOWN / citext 六种(sqlx-postgres/src/types/str.rs:14-24)。这条方法决定了 row.try_get::<String, _>(0) 能不能成功。
  • Option<T> 对三个 trait 都有 blanket impl——Type 走内层 T 的实现但 compatible 多一条 "ty.is_null() 也接受";Encode 对 None 直接返回 IsNull::Yes;Decode 对 is_null 的 ValueRef 返回 Ok(None)。这是整套类型系统对 SQL NULL 的核心承载。
  • #[derive(Type)] 一次性生成三个 trait 的实现——入口是 sqlx-macros-core/src/derives/mod.rs:17 的 expand_derive_type_encode_decode,它把 expand_derive_encode / expand_derive_decode / expand_derive_type 三份 TokenStream 拼在一起;其中 sqlx-macros-core/src/derives/type.rs:14 负责按 newtype / record / weak enum / strong enum 四种形态分派,每种形态的 Encode/Decode 策略不同。
  • Postgres 独有的 PgHasArrayType(定义在 sqlx-postgres/src/types/array.rs:51,bool 的实现见 sqlx-postgres/src/types/bool.rs:13-17)——数组的 OID 要单独声明,因为 Vec<T> 对应的数组 OID 不能从 T::type_info() 自动推导。

5.1 问题引入:一个 i32 要穿越几道关卡

上一章讲了 Executor::fetch_one 的整个流程——但跳过了一个关键细节:row.try_get::<i32, _>(0) 这一步里,i32 的 4 字节是怎么从 Postgres 的 DataRow 消息里解出来的?

反过来:.bind(42_i32) 这一步里,42 是怎么被编码成一串字节,最终通过 Postgres 协议的 Bind 消息发送到服务端的?

这两个方向的数据转换本质上是一组双向的类型映射:

  • Rust 侧:i32 是 4 字节小端(或大端,取决于平台)的 32 位整数。
  • Postgres 侧:INT4 类型在线路协议里是 4 字节大端整数(Postgres 协议永远大端)。
  • MySQL 侧:INT 类型在二进制协议里是 4 字节小端整数。
  • SQLite 侧:INTEGER 类型通过 C API 直接传递——绑定走 sqlite3_bind_int,读取走 sqlite3_value_int64 再 try_into 成 i32——没有字节序问题。

同一个 i32 发到不同 DB,字节表达形态不同。同一个 TEXT 列回到 Rust,可以是 &str(零拷贝借用)、String(堆分配)、Cow<'_, str>(按需)——Rust 侧对应多个类型,DB 侧是同一个。这两个"多对多"映射就是本章要建立的框架。

sqlx 用三个独立 trait 描述这套映射。为什么是三个?有办法融合成两个甚至一个吗?本章回答的就是这个问题——以及为什么三分是"既不多也不少"的最优切割。

5.2 三位一体的分工

先看 sqlx 的顶层 re-export(sqlx-0.8.6/src/lib.rs:35、134、145):

pub use sqlx_core::types::Type;
pub use sqlx_core::encode::{Encode, IsNull}; // 位于 pub mod encode 内,再由 pub use self::encode::Encode 提到顶层
pub use sqlx_core::decode::Decode;           // 位于 pub mod decode 内,同样再提到顶层

三个 trait 各自的 one-liner:

  • Type<DB> —— "我是哪个 SQL 类型?"(声明)
  • Encode<'q, DB> —— "把我这个值写进参数缓冲区。"(写出)
  • Decode<'r, DB> —— "从数据库的字节里构造出我。"(读入)

三个 trait 有三条重要约束关系:

graph LR
    T[Type&lt;DB&gt;]
    E[Encode&lt;'q, DB&gt;]
    D[Decode&lt;'r, DB&gt;]
    Bind[arguments.add&lt;T&gt;: T: Encode + Type]
    Get[row.try_get::&lt;T,_&gt;: T: Decode + Type]
    Bind -.bound.-> E
    Bind -.bound.-> T
    Get -.bound.-> D
    Get -.bound.-> T
    T -.type_info&#40;&#41; supplies TypeInfo.-> E
    T -.compatible&#40;&#41; guards.-> D

关键约束:

  1. bind(x) 要求 T: Encode + Type——你既要能编码出字节(Encode),也要能告诉 DB 这些字节是什么类型(Type 的 type_info())。
  2. try_get::<T>(i) 要求 T: Decode + Type——你既要能从字节解码(Decode),也要能检查 DB 返回的列类型和你期望的兼容(Type 的 compatible())。
  3. Type 不被 Encode 或 Decode 强制 require——外部类型可以只 impl Decode 不 impl Type(罕见但合法),但一旦你想让它进入 bind 或 try_get,就需要同时有 Type。

这三个 trait 共同构成类型映射的最小完备集。从 sqlx-core/src/arguments.rs:20-22 的 add 方法签名能直接看到这条要求:

fn add<T>(&mut self, value: T) -> Result<(), BoxDynError>
where T: 'q + Encode<'q, Self::Database> + Type<Self::Database>;

Encode + Type 这对 trait bound 刚好合起来构成"能 bind 的 Rust 类型"。对称地,Row::try_get::<T> 的 bound 是 Decode + Type。

5.3 Type<DB> trait:类型声明

sqlx-core/src/types/mod.rs:209-231:

pub trait Type<DB: Database> {
    /// Returns the canonical SQL type for this Rust type.
    fn type_info() -> DB::TypeInfo;

    /// Determines if this Rust type is compatible with the given SQL type.
    fn compatible(ty: &DB::TypeInfo) -> bool {
        Self::type_info().type_compatible(ty)
    }
}

只有两个方法:

  • type_info() 返回"这个 Rust 类型的规范 SQL 类型"——i32::type_info() 在 Postgres 下返回 PgTypeInfo::INT4。没有 &self 参数——它是一个类型级函数,值还没被构造就能调用。
  • compatible(ty) 回答"DB 告诉我列是 ty,我能不能解码"——默认走 type_info().type_compatible(ty),但派生类型常常覆盖它扩展兼容范围。

5.3.1 compatible 不是简单的 ==

最有意思的是 compatible 的默认实现对比自定义实现。sqlx-postgres/src/types/str.rs:9-25 里 str 的 Type 实现:

impl Type<Postgres> for str {
    fn type_info() -> PgTypeInfo { PgTypeInfo::TEXT }

    fn compatible(ty: &PgTypeInfo) -> bool {
        [
            PgTypeInfo::TEXT,
            PgTypeInfo::NAME,
            PgTypeInfo::BPCHAR,
            PgTypeInfo::VARCHAR,
            PgTypeInfo::UNKNOWN,
            PgTypeInfo::with_name("citext"),
        ]
        .contains(ty)
    }
}

str 声明自己是 TEXT,但接受 TEXT / NAME / BPCHAR / VARCHAR / UNKNOWN / citext 这 6 种 Postgres 类型。这是为什么你能用 row.try_get::<String, _>(0) 读一列 VARCHAR(50)——Type<Postgres> 告诉 sqlx "是的,VARCHAR 的字节我能解码成 String"。

这条机制同样适用于解码 UNKNOWN 类型——服务器在少数场景下会把列类型报告为 UNKNOWN(PostgreSQL 10 之前,不带类型转换的字面量 'foo' 出现在 SELECT 输出列里就是如此;10 起这类字面量会被解析为 text),sqlx 让 str 吃下这种情况而不是报错。

i32 的 compatible 则严格得多——它只接受 INT4,连 INT2 或 INT8 都不认(尽管 Rust 的 i32 能装下 i16 的值)。原因是"安全的隐式转换很少"——让 i32::decode 从 INT8 字节流强转可能溢出,不如直接报错让用户显式 cast。

5.3.2 为什么 type_info 是静态方法

type_info() 没有 &self,它是关联函数。这个决定的后果是:Rust 类型必须静态决定它的 SQL 类型——同一个 Rust 类型不能根据值的大小选择 INT2 / INT4 / INT8。1_i64 和 100_000_000_000_i64 都对应 INT8,不会 "小值省字节"。

这条选择避免了一条复杂性:type_info() 要是 &self 方法,Arguments::add 的签名会变成 fn add<T>(&mut self, v: T) where T: Encode + Type——其中 value.type_info() 依赖值、但 bind 的时候可能 v 已经被 move 走了。把 type_info 做成静态方法让类型信息和编码彻底解耦。

例外口子是 Encode::produces(§5.4)——它是值级别的类型覆盖,专为"类型信息要在拿到值之后才给出"的少数场景。0.8.6 里 Postgres 的几何类型和 cube 用它返回按名字解析的类型(如 sqlx-postgres/src/types/geometry/point.rs:54 的 PgTypeInfo::with_name("point")),Option<T> 的 None 用它报出内层类型;按值选不同类型的写法则见源码测试 tests/postgres/postgres.rs:1792。

5.4 Encode<'q, DB> trait:写入

sqlx-core/src/encode.rs:27-55(省略文档):

pub trait Encode<'q, DB: Database> {
    fn encode(self, buf: &mut DB::ArgumentBuffer<'q>) -> Result<IsNull, BoxDynError>
    where Self: Sized
    {
        self.encode_by_ref(buf)
    }

    fn encode_by_ref(&self, buf: &mut DB::ArgumentBuffer<'q>) -> Result<IsNull, BoxDynError>;

    fn produces(&self) -> Option<DB::TypeInfo> {
        None
    }

    fn size_hint(&self) -> usize {
        mem::size_of_val(self)
    }
}

四个方法,只有 encode_by_ref 必须实现,其它三个有默认实现或可选。

  • encode 消费 self——移动所有权进来,允许实现利用所有权省掉一次拷贝。0.8.6 里真正吃到这份红利的是 SQLite:sqlx-sqlite/src/types/str.rs 的 impl Encode<'q, Sqlite> for String 两条路差别很直白——encode 是 args.push(SqliteArgumentValue::Text(Cow::Owned(self)))(直接移进去),encode_by_ref 只能 Cow::Owned(self.clone())。Postgres 那边没有这种差别:PgArgumentBuffer 就是一段共享的 Vec<u8>,无论哪条路都要 buf.extend(self.as_bytes()) 拷一次,所以 sqlx-postgres/src/types/str.rs:120-124 的 String 干脆只实现了 encode_by_ref。默认实现是调 encode_by_ref。
  • encode_by_ref 借用 self——最基础也最常用。必须实现。
  • produces 是值级别的 TypeInfo 覆盖——默认返回 None,表示"用 Type::type_info() 就行"。极少数场景覆盖它:比如 Postgres 的几何类型返回 PgTypeInfo::with_name("point") 这类按名字解析的类型。注意 Postgres 的 Json<T> 没有覆盖它——"JSON 还是 JSONB"是在编码时登记一个 buf.patch 回调、等服务端给出参数类型后再回填首字节解决的(sqlx-postgres/src/types/json.rs:61-77)。
  • size_hint 给出编码后字节数的估计。默认用 size_of_val(self)——对 POD 类型够用,对 String 这种变长类型只量到句柄大小(§5.4.3)。

5.4.1 IsNull 枚举:SQL 三值逻辑的投影

sqlx-core/src/encode.rs:8-18:

#[must_use]
pub enum IsNull {
    /// The value is null; no data was written.
    Yes,

    /// The value is not null.
    ///
    /// This does not mean that data was written.
    No,
}

注释要精读:"The value is not null. This does not mean that data was written."

这句话的意思是:IsNull::No 只保证 值不是 NULL,不保证 buffer 里有字节。一个空字符串 "" encode 后 IsNull::No 但零字节写入——这是合法的,因为 Postgres 的 TEXT 类型允许空字符串(不等于 NULL)。

IsNull::Yes 只在一个场景出现:Option::<T>::None。此时 encode 直接返回 Ok(IsNull::Yes),不写 buffer。调用方(各驱动的参数缓冲逻辑)据此在线路协议里发 NULL 参数标记——Postgres 的 Bind 消息里对应一个 -1 长度字段、MySQL 对应 null-bitmap 的对应位置 1。

#[must_use] 是一道保险——? 拆开 Result 之后拿到的 IsNull 如果被随手丢弃,编译器会报 unused_must_use 警告(是警告而不是硬错误)。这能提醒你别犯"忘记处理 NULL"这种低级错误。

5.4.2 Encode for &T 的 blanket impl

sqlx-core/src/encode.rs:57-83 有一条优雅的 blanket impl:

impl<'q, T, DB: Database> Encode<'q, DB> for &'_ T
where T: Encode<'q, DB>,
{
    fn encode(self, buf: &mut DB::ArgumentBuffer<'q>) -> Result<IsNull, BoxDynError> {
        <T as Encode<DB>>::encode_by_ref(self, buf)
    }
    fn encode_by_ref(&self, buf: &mut DB::ArgumentBuffer<'q>) -> Result<IsNull, BoxDynError> {
        <&T as Encode<DB>>::encode(self, buf)
    }
    fn produces(&self) -> Option<DB::TypeInfo> { (**self).produces() }
    fn size_hint(&self) -> usize { (**self).size_hint() }
}

这条 impl 让你永远能对引用和值都 .bind()——bind(&my_value) 和 bind(my_value) 都合法,前者走引用路径走 encode_by_ref,后者走值路径。不想把参数所有权交出去时,bind(&my_value) 就是现成的写法。

注意 encode 的定义是"调 encode_by_ref",encode_by_ref 的定义是"调 <&T as Encode>::encode"——这两条互相引用不是无限递归。encode_by_ref 里的 <&T as Encode>::encode 匹配到的是这条 blanket impl 上面的 encode(路径显式写成 <&T as Encode>,实参 self: &&T 经自动解引用成 &T),而那个 encode 的定义是直接转到 <T as Encode>::encode_by_ref——也就是底层类型 T 的实现。链条终止。

5.4.3 size_hint 和 Arguments::reserve 的配合

Encode::size_hint 的默认实现是 mem::size_of_val(self)——对于 i32 返回 4、对 bool 返回 1。0.8.6 里这个值只有两个消费者:query! 宏展开时把各参数的 size_hint 求和传给 Arguments::reserve(sqlx-macros-core/src/query/args.rs:113-116),让 buffer 尽量一次分配到位;Postgres 的 PgArgumentBuffer::encode 在编码前拿它粗查一次长度有没有超出 i32(sqlx-postgres/src/arguments.rs:172)。运行时的 query().bind() 和 QueryBuilder 都不调用 reserve。

Arguments::reserve 的签名(sqlx-core/src/arguments.rs:17):

fn reserve(&mut self, additional: usize, size: usize);

additional 是待加入的参数个数、size 是估算的总字节数。Postgres 的实现用这两个数分别预分配 types 和 buffer(sqlx-postgres/src/arguments.rs:144-147);SQLite 的实现忽略 size,只预留参数个数。

默认实现是 mem::size_of_val(self)(sqlx-core/src/encode.rs:51-54)——对变长类型它给的是句柄的大小,不是内容的大小。String 走这条默认路径时量的是那个 24 字节的 header(指针 + 长度 + 容量),&str 量的是 16 字节的胖指针,跟字符串里到底装了多少字节没有关系。

值得注意的是 sqlx-postgres/src/types/str.rs:97-124 并没有覆盖它——&str、String、Cow<str>、Box<str> 四个 impl 都只写了 encode_by_ref:

impl Encode<'_, Postgres> for &'_ str {
    fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> Result<IsNull, BoxDynError> {
        buf.extend(self.as_bytes());

        Ok(IsNull::No)
    }
}

于是 query! 绑定长字符串时预估会偏小:一个 500 字节的 String 参数,size 里只算进 24 字节,buffer 仍要在编码时扩容。这不是个已经解决的问题,而是留在那里的一个优化空间。

真正覆盖了 size_hint 的是那些能精确算出编码后长度的类型,例如 sqlx-postgres/src/types/bigdecimal.rs:191-193:

fn size_hint(&self) -> usize {
    PgNumeric::size_hint(self.digits())
}

mac_address.rs、time_tz.rs、chrono 与 time 的几个日期类型也各自覆盖了一份——它们的共同点是编码后的字节数由值本身决定且可以直接算,不像字符串那样需要 impl 作者主动想到。

size_hint 是"性能提示型" trait 方法的典型例子——它基本不参与语义正确性(算错了只是多几次扩容),精确的实现能在 query! 路径上省掉这些扩容。这条设计也让 sqlx 避免了一个常见反模式:在 trait 里强制要求精确的大小信息——那会让实现负担大增。留一个可选的 size_hint、默认给保守估计,优秀实现可以选择精确化——这是 Rust 生态 trait 设计的经典妥协。

5.5 Decode<'r, DB> trait:读取

sqlx-core/src/decode.rs:61-64:

pub trait Decode<'r, DB: Database>: Sized {
    /// Decode a new value of this type using a raw value from the database.
    fn decode(value: <DB as Database>::ValueRef<'r>) -> Result<Self, BoxDynError>;
}

一个方法,简单到让人惊讶。'r 生命周期是 ValueRef 的借用——decode 里拿到的字节是借用 row 的 buffer的,不是 owned。

这个简单的签名背后有几条设计决定:

  1. Decode::decode 没有 &self——类似 Type::type_info,它是类型级构造函数。输入 ValueRef,输出 Self。
  2. 返回 Result<Self, BoxDynError>——decode 可能失败(UTF-8 错误、整数溢出、不合法日期),用盒装 error trait object 统一表达。具体 Error 类型由实现决定。
  3. 'r 生命周期——decode 出来的 Self 可以借用 ValueRef<'r>。例如 &'r str 可以 decode 成 &str 不拷贝——PgValueRef<'r>::as_str() 直接返回 &'r str。

5.5.1 按 Format 分发:Postgres 的 Binary / Text

Postgres 的协议同时支持 Binary 和 Text 两种列值格式。PgValueRef 有一个 format() 方法告诉 Decode 实现当前是哪种:

// sqlx-postgres/src/types/bool.rs:27-42
impl Decode<'_, Postgres> for bool {
    fn decode(value: PgValueRef<'_>) -> Result<Self, BoxDynError> {
        Ok(match value.format() {
            PgValueFormat::Binary => value.as_bytes()?[0] != 0,
            PgValueFormat::Text => match value.as_str()? {
                "t" => true,
                "f" => false,
                s => return Err(format!("unexpected value {s:?} for boolean").into()),
            },
        })
    }
}

Binary 模式下 bool 是一个字节(0 或 1);Text 模式下是字符串 "t" 或 "f"。sqlx 的 Postgres 驱动默认用 Binary 格式——快、省字节;但 simple query protocol 里服务端只发 Text 格式,所以必须两边都支持。

MySQL 也有同样的维度:预处理语句走二进制协议、文本查询走文本协议,所以 MySQL 驱动的整数解码同样按 MySqlValueFormat::Text / Binary 分发(sqlx-mysql/src/types/int.rs:94-113,Binary 分支用的是 LittleEndian::read_int)。SQLite 没有这个维度(直接通过 C API 传值)。第 16 章 Postgres 驱动会更详细讨论这两种格式的协议层差异。

5.5.2 零拷贝 vs 拥有所有权

同一个 TEXT 列可以 decode 成三种 Rust 类型:

Rust 类型 拷贝策略 生命周期
&'r str 无拷贝——直接借用 ValueRef 的 buffer 活到 row 被 drop
String 一次堆分配 + memcpy 独立生存
Cow<'r, str> 解码出来总是 Cow::Borrowed(三家驱动都如此),之后需要改写时才拷贝 Borrowed 时同 &'r str,Owned 时独立

&'r str 的典型用法是 handler 内短暂使用:

let row = query("SELECT name FROM users").fetch_one(&pool).await?;
let name: &str = row.try_get(0)?;
println!("Hello, {name}!");
// row drop 之后 name 失效

String 的典型用法是跨 await 或返回值:

async fn get_name(pool: &PgPool) -> Result<String, sqlx::Error> {
    let row = query("SELECT name FROM users").fetch_one(pool).await?;
    row.try_get::<String, _>(0)
    // row drop,但 String 独立存在
}

Cow<'r, str> 适合"先借用、需要改写或留存时再拷贝"的通用库代码——0.8.6 里 Postgres、MySQL、SQLite 三家的 Decode 都返回 Cow::Borrowed(sqlx-postgres/src/types/str.rs:132-136、sqlx-mysql/src/types/str.rs:112-116、sqlx-sqlite/src/types/str.rs:120-124;SQLite 借用的是 SqliteRow 自己持有的那份值),对用户透明。

这三种选项让 sqlx 的类型系统在"性能友好"和"生命周期友好"之间给用户完全的掌控。但也意味着用户要理解自己选的类型的代价——这是第 7 章的详细主题。

5.6 Option<T>:SQL NULL 的统一承载

SQL NULL 在 sqlx 的 Rust 表达就是 Option<T>::None。这条统一承载由三个 trait 各自的 blanket impl 合起来实现:

Type(sqlx-core/src/types/mod.rs:245-253):

impl<T: Type<DB>, DB: Database> Type<DB> for Option<T> {
    fn type_info() -> DB::TypeInfo {
        <T as Type<DB>>::type_info()
    }

    fn compatible(ty: &DB::TypeInfo) -> bool {
        ty.is_null() || <T as Type<DB>>::compatible(ty)
    }
}

注意 compatible 多了 ty.is_null() || ...——意味着 Option<i32> 接受任何 NULL 列(无论原列类型是什么),以及 i32 能接受的所有类型。这让 row.try_get::<Option<i32>, _>(0) 能从一列可空的 INT4 成功 decode——拿到 NULL 时 is_null 为真,decode 返回 None。

Encode(sqlx-core/src/encode.rs:85-131 的 impl_encode_for_option! 宏):

#[macro_export]
macro_rules! impl_encode_for_option {
    ($DB:ident) => {
        impl<'q, T> $crate::encode::Encode<'q, $DB> for Option<T>
        where
            T: $crate::encode::Encode<'q, $DB> + $crate::types::Type<$DB> + 'q,
        {
            #[inline]
            fn produces(&self) -> Option<<$DB as $crate::database::Database>::TypeInfo> {
                if let Some(v) = self {
                    v.produces()
                } else {
                    T::type_info().into()
                }
            }

            #[inline]
            fn encode(
                self,
                buf: &mut <$DB as $crate::database::Database>::ArgumentBuffer<'q>,
            ) -> Result<$crate::encode::IsNull, $crate::error::BoxDynError> {
                if let Some(v) = self {
                    v.encode(buf)
                } else {
                    Ok($crate::encode::IsNull::Yes)
                }
            }

            #[inline]
            fn encode_by_ref(
                &self,
                buf: &mut <$DB as $crate::database::Database>::ArgumentBuffer<'q>,
            ) -> Result<$crate::encode::IsNull, $crate::error::BoxDynError> {
                if let Some(v) = self {
                    v.encode_by_ref(buf)
                } else {
                    Ok($crate::encode::IsNull::Yes)
                }
            }

            #[inline]
            fn size_hint(&self) -> usize {
                self.as_ref().map_or(0, $crate::encode::Encode::size_hint)
            }
        }
    };
}

为什么这里是宏而不是 blanket impl?驱动里展开这个宏的地方留着一行注释:// required because some databases have a different handling of NULL(sqlx-mysql/src/lib.rs:72、sqlx-sqlite/src/lib.rs:120)。

git 历史能把这句话落实(以下两处引语都来自历史提交,0.8.6 的源码树里已经没有):这个宏由提交 ef2527ff 引入,提交标题是 "feat(mssql): fix a few bugs and implement Connection::describe"(2020-06-07,首个包含它的发布是 v0.4.0-beta.1)。在那之前 sqlx-core 里就是一条泛型 impl<'q, T, DB> Encode<'q, DB> for Option<T>;MSSQL 驱动要求 NULL 参数带一个专门的 NULL 类型 ID(git show ef2527ff 里 MSSQL 自己那份 impl 的注释原话是 "MSSQL requires a special NULL type ID"),泛型 blanket impl 会和它重叠,于是改成宏、由各驱动自己决定展开与否。

MSSQL 驱动在 0.7.0 被移出源码树(CHANGELOG 0.7.0),但 0.8.6 里仍有一家不用这个宏:Any 驱动在 sqlx-core/src/any/mod.rs:67-83 手写了自己的 Option<T> impl,None 时往缓冲里推一个带类型的 AnyValueKind::Null(T::type_info().kind)——这正是"不同数据库对 NULL 的处理不同"的现存例子。所以"用宏"和 GAT 无关:git show v0.6.0:sqlx-core/src/encode.rs 里同名宏已经在了,只是那时缓冲区写成 <$DB as HasArguments<'q>>::ArgumentBuffer(第 3 章 §3.3 讲的 helper trait 时代),0.8 的 GAT 迁移只是把宏体里的写法换成了 ArgumentBuffer<'q>。

这个宏由三个驱动各自的 lib.rs 展开一次:sqlx-postgres/src/lib.rs:83、sqlx-mysql/src/lib.rs:73、sqlx-sqlite/src/lib.rs:121 各有一行 impl_encode_for_option!(Xxx);。展开后就是具体的 impl<'q, T> Encode<'q, Postgres> for Option<T> where ...。

Decode(sqlx-core/src/decode.rs:67-79):

impl<'r, DB, T> Decode<'r, DB> for Option<T>
where DB: Database, T: Decode<'r, DB>,
{
    fn decode(value: DB::ValueRef<'r>) -> Result<Self, BoxDynError> {
        if value.is_null() {
            Ok(None)
        } else {
            Ok(Some(T::decode(value)?))
        }
    }
}

Decode 这边确实是一条泛型 blanket impl(sqlx-core/src/decode.rs:67),没有走宏——上面那行注释针对的是编码一侧的 NULL 表达,解码一侧各驱动都通过 ValueRef::is_null() 报告 NULL,没有需要分家的地方。可以确定的是它不是"参数位 vs 返回位"的区别:Encode::encode_by_ref 的 buf: &mut DB::ArgumentBuffer<'q> 和 Decode::decode 的 value: DB::ValueRef<'r> 都在参数位置。两份 impl 唯一的可见差异在 bound——Encode 那份额外要求 T: Type<DB> + 'q,Decode 那份只要 T: Decode<'r, DB>。

这三个 Option<T> 的 blanket impl 就是 SQL NULL 的 Rust 表达。用户永远不需要手写"如何表达 NULL"——用 Option<T>,三 trait 自动串起来。

5.6.1 Vec<T> 的类似 blanket?不存在

对比 Option<T> 有三 trait 的 blanket,Vec<T> 没有。为什么?

  1. Postgres 的 Vec<T> 对应数组类型(例如 INT4[] 的 OID 1007)——编码格式特殊(维度头 + 元素 OID + 数据),不是简单的元素编码拼接。这部分在 sqlx-postgres/src/types/array.rs 里约 350 行专门处理。
  2. MySQL 没有数组类型——除了当作 BLOB 的 Vec<u8>,Vec<T> 在 MySQL 下不可编码。
  3. SQLite 也没有——同上(同样只有 Vec<u8> 对应 BLOB)。

所以泛型的 Vec<T> 数组映射只在 Postgres 下存在,并且通过 PgHasArrayType 这个扩展 trait(§5.10)决定元素类型对应的数组 OID。sqlx-core 没有 blanket——因为跨 DB 的"数组"概念本身就不统一。

这条对比能让你更准确地理解 Option<T> 的特殊性:NULL 是 SQL 标准所有 DB 都有的东西,能抽象;数组不是,所以不抽象。抽象的边界等于共同性的边界。

5.6.2 Arguments::add 的类型检查流

把 Encode 和 Type 在 add 方法里的协作用一张序列图钉在纸上。假设用户代码是:

use sqlx::{Execute, Postgres}; // take_arguments 是 Execute trait 的方法

let args: PgArguments = sqlx::query::<Postgres>("SELECT ... WHERE id = $1 AND created > $2")
    .bind(42_i32)
    .bind(OffsetDateTime::now_utc())
    .take_arguments()
    .unwrap().unwrap();

每次 .bind(x) 最终会调 Arguments::add(x)。add 的内部流程:

sequenceDiagram
    participant U as 用户 bind 42
    participant Q as Query
    participant A as PgArguments
    participant T as Type trait
    participant E as Encode trait
    participant B as PgArgumentBuffer
    U->>Q: bind 42 i32
    Q->>A: args.add(42)
    A->>T: type_info returns INT4
    A->>A: push OID 23 into types vec
    A->>E: encode 42 into buffer
    E->>B: buffer.extend 0 0 0 42
    E-->>A: Ok IsNull No
    A->>A: count += 1
    A-->>Q: Ok
    Q-->>U: ret &mut Query for chaining

三步合一:

  1. Type 声明——<i32 as Type<Postgres>>::type_info() 返回 PgTypeInfo::INT4。这个 TypeInfo 被 push 到 PgArguments::types 向量,后面 Postgres 驱动发 Bind 消息时从这里读 OID。
  2. Encode 编码——<i32 as Encode<Postgres>>::encode_by_ref(&42, buf) 把 4 字节大端写进 PgArgumentBuffer::buffer。
  3. 记录长度——Postgres Bind 消息里每个参数是"长度 + 数据",所以 PgArgumentBuffer::encode 先占 4 字节长度位、编码完再把实际长度回填进去。

这条路径在 sqlx-postgres/src/arguments.rs:84-106 的 PgArguments::add 方法里完整实现:

pub(crate) fn add<'q, T>(&mut self, value: T) -> Result<(), BoxDynError>
where
    T: Encode<'q, Postgres> + Type<Postgres>,
{
    let type_info = value.produces().unwrap_or_else(T::type_info);

    let buffer_snapshot = self.buffer.snapshot();

    // encode the value into our buffer
    if let Err(error) = self.buffer.encode(value) {
        // reset the value buffer to its previous value if encoding failed,
        // so we don't leave a half-encoded value behind
        self.buffer.reset_to_snapshot(buffer_snapshot);
        return Err(error);
    };

    // remember the type information for this value
    self.types.push(type_info);
    // increment the number of arguments we are tracking
    self.buffer.count += 1;

    Ok(())
}

注意 add 自己并不直接看 IsNull——它把编码委托给 PgArgumentBuffer::encode(sqlx-postgres/src/arguments.rs:167-195),后者先占 4 字节长度位、再看 value.encode(self)? 的返回:IsNull::No 就回填实际长度,IsNull::Yes 就回填 -1(并用 debug_assert_eq! 断言 encode 确实一个字节都没写)。

关键细节是 buffer_snapshot / reset_to_snapshot——如果 Encode 失败,要回滚 buffer 到 add 之前的状态,避免留下半截编码。这是第 3 章 §3.5.2 讨论过的 PgArgumentBuffer 带 "patches" 设计的一个副产品:buffer 需要支持快照和回滚,快照里除了字节长度还要记下 count、patches、type_holes 三处长度(sqlx-postgres/src/arguments.rs:234-263)。MySQL / SQLite 同样会回滚,只是它们的缓冲是一个平坦的 Vec,编码前记下长度、失败时 truncate 即可(sqlx-mysql/src/arguments.rs:23-31、sqlx-sqlite/src/arguments.rs:32-42)。

这个序列也显示了为什么 Encode 和 Type 必须在 add 的 trait bound 里一起出现——缺任何一个这条路径都串不起来。

5.7 一个完整案例:i32 在 Postgres 下的全套实现

把 sqlx-postgres/src/types/int.rs 里 i32 的三 trait 实现合一起看:

// Type(types/int.rs:108-112)
impl Type<Postgres> for i32 {
    fn type_info() -> PgTypeInfo {
        PgTypeInfo::INT4
    }
}

// Encode(types/int.rs:120-126)
impl Encode<'_, Postgres> for i32 {
    fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> Result<IsNull, BoxDynError> {
        buf.extend(&self.to_be_bytes());  // 4 字节大端
        Ok(IsNull::No)
    }
}

// Decode(types/int.rs:128-132)
impl Decode<'_, Postgres> for i32 {
    fn decode(value: PgValueRef<'_>) -> Result<Self, BoxDynError> {
        int_decode(value)?.try_into().map_err(Into::into)
    }
}

三个加起来 17 行代码——就完成了 Rust i32 与 Postgres INT4 的双向映射。int_decode 是同文件顶部的一个 helper(sqlx-postgres/src/types/int.rs:10-33),它按 format 分发:Text 格式用 str::parse(),Binary 格式用 BigEndian::read_int,最后统一返回 i64,再由具体类型 try_into() 成 i16/i32/i64。

这套实现的精妙之处在于分层:

  1. int_decode 返回 i64——"最大的整数类型,能装下所有小整数"。
  2. 具体类型(i16/i32/i64)各自 try_into()——自动检查范围。走 try_get 时,INT8 列请求 i32 会先被 compatible 拦下(§5.3.1);但绕过兼容检查的路径(try_get_unchecked、Text 格式里超出范围的数字)到了这一步,try_into 会返回 TryFromIntError,decode 整体失败,而不是静默截断。

这条 int_decode → try_into 的链既消除了重复代码(三种整数公用一个 decode helper),又保留了范围检查(每个具体类型自己验证)。

5.7.1 NonZeroI32:类型系统对 "0 非法" 的表达

sqlx-core/src/types/non_zero.rs 里给 NonZeroI32 / NonZeroI64 等类型实现了三 trait。核心思路是:

impl<DB: Database> Type<DB> for NonZeroI32 where i32: Type<DB> {
    fn type_info() -> DB::TypeInfo { <i32 as Type<DB>>::type_info() }
    fn compatible(ty: &DB::TypeInfo) -> bool { <i32 as Type<DB>>::compatible(ty) }
}

impl<'q, DB: Database> Encode<'q, DB> for NonZeroI32 where i32: Encode<'q, DB> {
    fn encode_by_ref(&self, buf: &mut DB::ArgumentBuffer<'q>) -> Result<IsNull, BoxDynError> {
        self.get().encode_by_ref(buf)
    }
}

impl<'r, DB: Database> Decode<'r, DB> for NonZeroI32 where i32: Decode<'r, DB> {
    fn decode(value: DB::ValueRef<'r>) -> Result<Self, BoxDynError> {
        let int = <i32 as Decode<DB>>::decode(value)?;
        Ok(Self::try_from(int)?)
    }
}

Encode 和 Type 是直接 delegate 到 i32——对数据库看是普通 INT4。关键在 Decode——如果数据库返回的整数是 0,NonZeroI32::try_from(0) 返回 TryFromIntError,decode 整体失败(sqlx-core/src/types/non_zero.rs:15-65 的 impl_non_zero! 宏对 i8/u8/i16/u16/i32/u32/i64/u64 八对类型统一展开)。

这个实现是类型系统对业务约束的编码。你把某个字段设计成 "ID,不能为 0"(符合"0 值作为 sentinel 的遗留约定"的反面教材修复),Rust 侧把它声明为 NonZeroI32,数据库侧用 INT4 NOT NULL CHECK (id > 0)——两端分别负责自己那一半的约束,中间 sqlx 桥接。如果哪天数据库侧的 CHECK 被误删,脏数据写入后再被 Rust 读到,try_get::<NonZeroI32, _>(0) 会 fail loud——而不是静默把 0 放进一个号称"非零"的类型里。

这种 "用 Rust 的类型系统特性重新校验数据库值" 的模式在设计业务层类型时非常有用。NonZeroI32 是标准库例子,你也可以用同样的模式给 Percent(0-100 范围)、EmailAddress(§5.12.C 方案)、Username(长度约束)等业务类型加 Decode 侧的校验。

5.8 三家 DB 的类型系统差异

相同的 i32 映射在三家 DB 下长什么样?

维度 Postgres (INT4) MySQL (INT) SQLite (INTEGER)
线路格式 4 字节大端(binary 模式) 4 字节小端(binary protocol) 先入 SqliteArgumentValue::Int 队列,绑定时走 C API sqlite3_bind_int
类型标识 OID 23(PgTypeInfo::INT4) ColumnType::Long(枚举变体) DataType::Int4
兼容性 严格——INT4 只接 INT4 宽松——int_compatible 放行 Tiny/Short/Long/Int24/LongLong 五种有符号整数列 中等——只接 Int4 和 Integer
NULL 编码 长度字段 -1 null-bitmap 的对应位 1 sqlite3_bind_null
sqlx 源文件 sqlx-postgres/src/types/int.rs sqlx-mysql/src/types/int.rs sqlx-sqlite/src/types/int.rs

三个驱动各自实现三个 trait——完全独立的代码路径。这是 sqlx-core 不做抽象的一处:线路格式的差异太大,硬抽象会让每家的实现都被迫做无用转换(比如 Postgres 先转小端再转大端)。

不过Rust 侧的 API 完全一样——bind(42_i32) 三家用法相同、try_get::<i32, _>(0) 三家返回 i32。差异被完全封装在 driver crate 里。这就是 sqlx-core 的 trait 家族带来的价值:驱动可以任意切换、用户代码不变。

5.8.1 sqlx 对"悄悄转换"的立场

对比 sqlx 和其它数据库工具处理类型不匹配的默认行为:

  • tokio-postgres:row.get::<i64, _>(0) 读一列 INT4——panic 或错误。
  • diesel:编译期拒绝——schema.rs 的类型和 Queryable 不匹配时 .load() 编译不通过。
  • sqlx query! 宏:编译期拒绝(和 diesel 一样)——describe() 返回 OID 23 (INT4) 但你写 i64,宏生成的 try_get_unchecked::<i32, _> 和你期望的 i64 类型不匹配,编译失败。
  • sqlx 运行时 query():运行时拒绝——row.try_get::<i64, _>(0) 对 INT4 列的兼容检查 <i64 as Type<Postgres>>::compatible(&INT4) 返回 false,直接报错 Error::ColumnDecode { index, source },source 是一条 "mismatched types" 错误(sqlx-core/src/row.rs:121-126)。

sqlx 在所有路径上都不做"悄悄转换"——即便 i64 能容纳任何 i32 值,也不自动把 INT4 decode 成 i64。理由有二:

  1. 一致性:反方向(INT8 → i32)会溢出,有方向性不对称会让 API 行为变得难以记忆。
  2. 可预测性:如果允许 INT4 → i64 的隐式转换,某天表的列类型从 INT8 改成了 INT4,代码照常运行,列类型变了这件事被静默吞掉——用户看不见这个变化。显式转换(i64::from(row.try_get::<i32, _>(0)?))强制让每次转换留下可 grep 的痕迹。

这条立场是 Rust 生态里"no implicit conversions"精神在 sqlx 的直接落地——和标准库里 u32::from(some_i32) 编译失败、必须显式写 u32::try_from 或 as u32 是同一套哲学。

5.9 #[derive(Type)]:宏如何一次生成三个 trait

用户对 sqlx 最常见的派生需求是让自己的类型能参与 bind 和 try_get。sqlx 提供的派生宏是 #[derive(Type)]——注意虽然叫 Type,但它同时生成 Type、Encode、Decode 三个 impl。

sqlx-macros-core/src/derives/type.rs:14-54 是 Type 那一半的入口,根据数据类型分派到四种生成策略:

pub fn expand_derive_type(input: &DeriveInput) -> syn::Result<TokenStream> {
    match &input.data {
        // 1. 透明 newtype: struct Foo(i32)
        Data::Struct(DataStruct { fields: Fields::Unnamed(FieldsUnnamed { unnamed, .. }), .. }) => {
            if unnamed.len() == 1 {
                expand_derive_has_sql_type_transparent(...)
            } else { Err(...) }
        }
        // 2. 记录 struct: struct Foo { foo: i32, bar: String }(仅 Postgres)
        Data::Struct(DataStruct { fields: Fields::Named(FieldsNamed { named, .. }), .. }) => {
            expand_derive_has_sql_type_struct(...)
        }
        // 3. 弱枚举(#[repr(i32)]): enum Color { Red = 1, Green, Blue }
        Data::Enum(DataEnum { variants, .. }) => match attrs.repr {
            Some(_) => expand_derive_has_sql_type_weak_enum(...),
            // 4. 强枚举(字符串): enum Color { Red, Green, Blue }
            None => expand_derive_has_sql_type_strong_enum(...),
        },
        ...
    }
}

四种形态对应四种 SQL 表达:

5.9.1 Transparent:delegate 到内部类型

#[derive(sqlx::Type)]
#[sqlx(transparent)]
struct UserId(i64);

展开后大致:

impl<DB: sqlx::Database> sqlx::Type<DB> for UserId where i64: sqlx::Type<DB> {
    fn type_info() -> DB::TypeInfo { <i64 as sqlx::Type<DB>>::type_info() }
    fn compatible(ty: &DB::TypeInfo) -> bool { <i64 as sqlx::Type<DB>>::compatible(ty) }
}
// Encode / Decode 类似,全部转发给 i64

这是"零运行时开销的新类型"——UserId 在类型系统里是独立类型(防止 fn f(id: UserId) 误传一个 PostId),但编码解码成字节时完全等同 i64。数据库侧就是一列 BIGINT。

5.9.2 Weak Enum:#[repr(int)] + 整数映射

#[derive(sqlx::Type)]
#[repr(i32)]
enum Color { Red = 1, Green = 2, Blue = 3 }

展开的 Encode 核心:(*self as i32).encode_by_ref(buf)——把 enum 当 i32 发。Decode 核心:let n = i32::decode(v)?; match n { 1 => Ok(Color::Red), 2 => Ok(Color::Green), 3 => Ok(Color::Blue), _ => Err(...) }——从 i32 还原 enum。

数据库侧存 i32;Rust 侧是类型安全的 enum。典型用例:状态字段(pending=0, active=1, deleted=2 这种模式)。

5.9.3 Strong Enum:字符串映射

#[derive(sqlx::Type)]
#[sqlx(type_name = "color", rename_all = "lowercase")]
enum Color { Red, Green, Blue }

展开 Encode:let s = match self { Color::Red => "red", ... }; s.encode_by_ref(buf)。Decode 反过来——先解出 &str,再 match value { "red" => Ok(Color::Red), ... }。

数据库侧在 Postgres 是用户自定义 ENUM 类型(#[sqlx(type_name = "color")] 告诉宏类型名,不写就用 Rust 类型名),在 MySQL 是 ENUM 列,在 SQLite 直接复用 str 的类型信息、按文本存取。三家的类型声明由同一个函数按 feature 分别生成(sqlx-macros-core/src/derives/type.rs 的 expand_derive_has_sql_type_strong_enum)。这是 Postgres 用户最喜欢的模式——数据库侧用 CREATE TYPE 定义 enum、Rust 侧派生同名枚举、编译期类型安全。

5.9.4 Record:Postgres 独有的复合类型

#[derive(sqlx::Type)]
#[sqlx(type_name = "address")]
struct Address {
    street: String,
    city: String,
    zip: String,
}

对应 Postgres 的 CREATE TYPE address AS (street text, city text, zip text)。Encode 按 tuple 顺序写;Decode 按 tuple 顺序读。只 Postgres 支持——因为 MySQL 和 SQLite 都没有复合类型。

5.10 Postgres 独有的 PgHasArrayType

前面 bool 和 i32 的源文件里(sqlx-postgres/src/types/bool.rs:13-17、sqlx-postgres/src/types/int.rs:114-118)其实还各有一段,上面的摘录没有列出:

impl PgHasArrayType for bool {
    fn array_type_info() -> PgTypeInfo {
        PgTypeInfo::BOOL_ARRAY
    }
}

PgHasArrayType 是 Postgres 独有的一个 trait——它告诉 sqlx "Vec<T> 对应的数组 OID 是什么"。

为什么需要?因为 Postgres 的数组类型有自己的 OID:BOOL_ARRAY = 1000、INT4_ARRAY = 1007、TEXT_ARRAY = 1009。Vec<bool> 的 Encode 不能直接用 bool::type_info()——那会把 Vec 声明成 BOOL 而非 BOOL[],Postgres 会 reject。

#[derive(Type)] 的 transparent 变体在启用 postgres feature 时默认也生成 PgHasArrayType impl——但它要求内层类型也实现 PgHasArrayType。如果内层类型本身没有数组 OID(或本来就是数组,Postgres 不支持嵌套数组类型),需要用户自己加 #[sqlx(no_pg_array)] 关掉这项生成(sqlx-macros-core/src/derives/type.rs:100、sqlx-macros-core/src/derives/attributes.rs:60)。

MySQL 和 SQLite 没有内置数组类型,所以这个 trait 只存在于 sqlx-postgres 里。这是又一处"方言差异通过扩展 trait 而不是抽象 trait 表达"的案例——和第 3 章 HasStatementCache 的设计哲学一致。

5.11 Text<T> 和 Json<T>:两个值得单讲的包装类型

最后两个内置类型是 sqlx 提供的 wrapper,解决两类常见需求:

5.11.1 Text<T>:把任意 FromStr / Display 类型当 SQL 文本

use sqlx::types::Text;

use std::net::SocketAddr;

let (addr,): (Text<SocketAddr>,) = sqlx::query_as("SELECT '127.0.0.1:8080'").fetch_one(&pool).await?;
// Text<SocketAddr> 的 Encode 调 SocketAddr 的 Display,Decode 调 FromStr

Text<T> 的 impl 由各驱动分别提供(sqlx-core 里那份 blanket impl 被特意注释掉了——sqlx-core/src/types/text.rs:97,注释写着 "We shouldn't use blanket impls so individual drivers can provide specialized ones")。Postgres 那份在 sqlx-postgres/src/types/text.rs:Type 转发给 String,Encode 直接 write!(**buf, "{}", self.0) 把 Display 输出写进 buffer,Decode 先解出 &str 再 s.parse()。这对你不想为某个类型手写 Encode/Decode 但它已有 Display + FromStr 的场景非常顺手。

5.11.2 Json<T>:透明地序列化/反序列化 JSON

use sqlx::types::Json;
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
struct Settings { theme: String }

let row: (Json<Settings>,) = sqlx::query_as("SELECT settings FROM users WHERE id = $1")
    .bind(user_id).fetch_one(&pool).await?;
let settings: Settings = row.0.0;  // Json<T> 是元组结构体 Json(pub T),.0 取出 T

Json<T> 的三 trait 用 serde_json:Postgres 那份(sqlx-postgres/src/types/json.rs:74、97)Encode 是 serde_json::to_writer(&mut **buf, &self.0),Decode 是 serde_json::from_slice(buf)。数据库侧对应 Postgres 的 JSONB、MySQL 的 JSON、SQLite 的 TEXT。

Json<T> 是 sqlx 和 serde 生态的胶水——让任何 #[derive(Serialize, Deserialize)] 的类型直接能作为 SQL 列值。《Serde 元编程》专栏第 17 章讲的 serde_json 就是这套序列化后端——sqlx 的 Json<T> 相当于把它嵌进一个 SQL 列值里。

5.12 实战:给业务领域类型加类型映射

把前面所有内容用一次完整的实战整理一下。假设你有一个业务类型 EmailAddress:

use std::str::FromStr;

#[derive(Debug, Clone, PartialEq)]
pub struct EmailAddress(String);

#[derive(Debug)]
pub struct InvalidEmail; // 还需实现 Display 与 std::error::Error,方案 C 要把它装进 BoxDynError

impl EmailAddress {
    pub fn new(s: String) -> Result<Self, InvalidEmail> {
        if s.contains('@') { Ok(Self(s)) } else { Err(InvalidEmail) }
    }
}

impl FromStr for EmailAddress { /* ... */ }
impl std::fmt::Display for EmailAddress { /* 输出 self.0 */ }

有三种方案把它接入 sqlx:

方案 A:用 Text<T> 零代码派生

use sqlx::types::Text;

let (email,): (Text<EmailAddress>,) = sqlx::query_as("SELECT email FROM users WHERE id = $1")
    .bind(user_id).fetch_one(&pool).await?;

let addr: EmailAddress = email.0;

零代码——但每条 SQL 都要写 Text<EmailAddress>,不能直接用 EmailAddress。

方案 B:#[derive(sqlx::Type)] #[sqlx(transparent)]

#[derive(Debug, Clone, PartialEq, sqlx::Type)]
#[sqlx(transparent)]
pub struct EmailAddress(String);

派生宏一次性生成 Type + Encode + Decode——用户代码里 EmailAddress 就能直接 bind 和 try_get。但透明派生绕过了 new() 的校验逻辑——Decode 直接把数据库里的字节解码成 EmailAddress(String),不调 EmailAddress::new()。如果数据库里存了不合法邮箱(legacy 脏数据),Rust 侧也会无痛接受。

方案 C:手写 Type + Encode + Decode

impl<DB: sqlx::Database> sqlx::Type<DB> for EmailAddress
where String: sqlx::Type<DB>,
{
    fn type_info() -> DB::TypeInfo { <String as sqlx::Type<DB>>::type_info() }
    fn compatible(ty: &DB::TypeInfo) -> bool { <String as sqlx::Type<DB>>::compatible(ty) }
}

impl<'q, DB: sqlx::Database> sqlx::Encode<'q, DB> for EmailAddress
where String: sqlx::Encode<'q, DB>,
{
    fn encode_by_ref(&self, buf: &mut DB::ArgumentBuffer<'q>) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
        <String as sqlx::Encode<DB>>::encode_by_ref(&self.0, buf)
    }
}

impl<'r, DB: sqlx::Database> sqlx::Decode<'r, DB> for EmailAddress
where String: sqlx::Decode<'r, DB>,
{
    fn decode(value: DB::ValueRef<'r>) -> Result<Self, sqlx::error::BoxDynError> {
        let s = <String as sqlx::Decode<DB>>::decode(value)?;
        EmailAddress::new(s).map_err(|e| Box::new(e) as _)
    }
}

手写的代价是二十来行代码,但 Decode 里调用了 new() 做校验——脏数据会在 try_get 时报错而不是静默通过。

三个方案的权衡:

方案 代码量 Rust 类型安全 数据库读取校验
A: Text<EmailAddress> 0 Wrapper 类型 由 FromStr 保证
B: #[derive(Type) transparent] 1 行派生 强(独立类型) 无(直接构造)
C: 手写三 trait 约 20 行 强 有(Decode 里校验)

业务代码里多数情况下选 B——如果你信任数据库的数据干净。如果你要在迁移期间容忍脏数据并保证读到脏数据时报错,就用 C。A 一般只在"临时探索"或"只查询不保存"的场景合适。

这一节是本专栏"理论到实践"的缩影:三个 trait 的抽象背后是工程选择——多二十来行代码换一次额外的运行时校验,值不值取决于你的数据质量和"fail loud vs fail silent"偏好。

5.13 TypeChecking trait:编译期和运行期的分界

本章所有讨论都围绕运行时的类型映射——bind 和 try_get 在程序跑起来后发生。但 query! 宏在编译期也要做类型检查:它要知道"数据库说这列是 INT4,我在 Rust 端该生成 i32 还是 u32 的 try_get_unchecked?"

这个编译期的"数据库类型名 → Rust 类型字符串"映射由 另一个 trait 承担——sqlx-core/src/type_checking.rs:21 的 TypeChecking:

// TypeChecking trait(简化)
pub trait TypeChecking: Database {
    const PARAM_CHECKING: ParamChecking;

    fn param_type_for_id(id: &Self::TypeInfo) -> Option<&'static str>;
    fn return_type_for_id(id: &Self::TypeInfo) -> Option<&'static str>;
    fn get_feature_gate(id: &Self::TypeInfo) -> Option<&'static str>;
}

param_type_for_id 和 return_type_for_id 都返回 Option<&'static str>——Rust 类型的字符串表示(例如 "i32"、"String"、"sqlx::types::Uuid")。这些字符串最终被宏嵌进生成代码里 try_get_unchecked::<i32, _>(0)。

这个 trait 的实现不是手写的 OID 对照表。sqlx-postgres/src/type_checking.rs 里驱动只列出一串 Rust 类型(bool、String | &str、i32、sqlx::types::Uuid……,可选类型用 #[cfg(feature = ...)] 包起来),交给 impl_type_checking! 宏;宏展开出的 return_type_for_id 对这串类型逐个试探——先找 <T as Type<DB>>::type_info() == *info 的,找不到再找 <T as Type<DB>>::compatible(info) 的——命中就返回 stringify!(T)(sqlx-core/src/type_checking.rs:136-194)。换句话说,"OID 23 → i32"这条对应不是写死的,而是从 i32 的 Type 实现反查出来的。

这条映射是单向的——从 DB 类型到 Rust 类型。正向(Rust 到 DB)由 Type::type_info() 在运行时提供,用于参数类型检查。单向 vs 双向的不对称来自使用场景:

  • 编译期需要从 describe() 的返回(DB TypeInfo)生成 Rust 代码——所以需要 DB → Rust 方向。
  • 运行期需要 bind 参数时声明 SQL 类型——所以需要 Rust → DB 方向(即 Type::type_info())。

两个方向共用同一份事实来源:Type 实现是唯一写着"这个 Rust 类型对应哪个 SQL 类型"的地方,TypeChecking 只是在它上面反查。驱动作者要维护的只是"哪些 Rust 类型参与 query! 推断"这份清单——新增一个可选 feature 类型(比如 bigdecimal),写好它的 Type 实现后在清单里加一行,OID 不必再写第二遍,两个方向也不会漂移。

TypeChecking 这条 trait 放在 sqlx-core 而不是 sqlx-macros-core,是因为实现它的是三个驱动 crate——sqlx-postgres / sqlx-mysql / sqlx-sqlite 各自调 sqlx-core 导出的 impl_type_checking! 宏(sqlx-core/src/type_checking.rs:136)展开出那张 match 表,而驱动 crate 只依赖 sqlx-core、并不依赖 sqlx-macros-core。真正消费这张表的是 query! 的代码生成(sqlx-macros-core/src/query/output.rs:234),#[derive(Type)] 这条路径并不碰它。这也是第 2 章 §2.3.1 讨论过的 DatabaseExt: Database + TypeChecking 这条超 trait bound 的由来。

5.14 本章小结

本章把 sqlx 类型映射的三位一体完整拆开:

  1. 三个独立 trait(§5.2)——Type<DB>(sqlx-0.8.6/sqlx-core/src/types/mod.rs:209,类型声明)、Encode<'q, DB>(sqlx-0.8.6/sqlx-core/src/encode.rs:27,写出)、Decode<'r, DB>(sqlx-0.8.6/sqlx-core/src/decode.rs,读入)。三分而非合一的理由是外部类型的灵活性(可以只实现需要的一部分)。
  2. Type 的 compatible(§5.3.1,默认实现在 sqlx-0.8.6/sqlx-core/src/types/mod.rs:228)—— str 接受 6 种 Postgres 类型(TEXT / NAME / BPCHAR / VARCHAR / UNKNOWN / citext,见 sqlx-0.8.6/sqlx-postgres/src/types/str.rs:14-24)。这个方法决定了"同一个 DB 列能 decode 成哪些 Rust 类型"。
  3. IsNull 枚举(§5.4.1,sqlx-0.8.6/sqlx-core/src/encode.rs:9-18)——SQL 三值逻辑在 trait 签名的投影。IsNull::Yes 只在 Option::None 出现;IsNull::No 不保证有字节写入——源码注释原话就是 "This does not mean that data was written."。枚举上挂着 #[must_use](sqlx-0.8.6/sqlx-core/src/encode.rs:9),强制编译器检查返回值。
  4. Encode for &T 的 blanket impl(§5.4.2,sqlx-0.8.6/sqlx-core/src/encode.rs:57;配套的 Type for &T 在 sqlx-0.8.6/sqlx-core/src/types/mod.rs:234)——让 bind(&value) 和 bind(value) 都合法。两条方法的互相引用不是无限递归,链条在底层类型 T 的 encode_by_ref 终止。
  5. Decode 的 format 分发(§5.5.1)——Postgres 和 MySQL 的 Binary、Text 两种格式都要支持;SQLite 没有这个维度。
  6. 零拷贝 vs 拥有所有权(§5.5.2)—— &'r str / String / Cow<'r, str> 三种策略对应不同生命周期和性能权衡,用户透明掌控。
  7. Option<T> 的统一承载(§5.6)—— Type / Decode 两侧是泛型 blanket impl,Encode 侧是 impl_encode_for_option! 宏(定义在 sqlx-0.8.6/sqlx-core/src/encode.rs:86)、由三个驱动各展开一次(sqlx-0.8.6/sqlx-postgres/src/lib.rs:83、sqlx-0.8.6/sqlx-mysql/src/lib.rs:73、sqlx-0.8.6/sqlx-sqlite/src/lib.rs:121)。用宏的原因写在驱动里那行注释上(§5.6 引过原文,大意是各数据库对 NULL 的处理不同):它在 v0.4.0-beta.1 为 MSSQL 的专用 NULL 类型引入,今天 Any 驱动仍手写自己的一份;这个宏早在 0.6(GAT 之前)就存在,不能归因到 GAT。
  8. 三家 DB 的类型系统差异(§5.8)——Postgres OID + 大端、MySQL FieldType + 小端、SQLite C API 直接传值。sqlx-core 不抽象这层;每个驱动独立实现三 trait。
  9. #[derive(Type)](§5.9)——一次派生生成三个 trait,按 transparent/weak-enum/strong-enum/record 四种形态分派。
  10. PgHasArrayType(§5.10,sqlx-0.8.6/sqlx-postgres/src/types/array.rs:51)——Postgres 独有的扩展 trait,声明 Vec<T> 的数组 OID。这是"方言差异通过扩展 trait 表达"的案例。
  11. Text<T> 和 Json<T>(§5.11)——两个 wrapper 把"任意 FromStr/Display 类型"和"serde 类型"一次性接入 SQL。
  12. 业务领域类型接入实战(§5.12)—— A(Text<T> 零代码)、B(#[derive(Type) transparent] 一行)、C(手写三 trait 加校验)三条路线对应不同的"数据质量 vs 代码量"权衡。大多数业务选 B。
  13. TypeChecking trait(§5.13,sqlx-0.8.6/sqlx-core/src/type_checking.rs:21)—— 编译期使用的 DB → Rust 类型名映射(param_type_for_id / return_type_for_id 参数位、返回位各一个,外加 get_feature_gate 和 fmt_value_debug),和运行期的 Type::type_info()(Rust → DB,sqlx-0.8.6/sqlx-core/src/types/mod.rs:217)方向相反。它由 impl_type_checking! 从各类型的 Type 实现反查出来,两个方向共用同一份事实来源。

5.15 判断题:让直觉转成工程决策

做几道 §3.11.1 风格的判断题把本章收尾:

Q1:Type::type_info 设计成静态方法还是 &self 方法,哪个更好?

A:静态。把 TypeInfo 和具体值解耦的好处:Arguments::add 的签名能把 trait bound 简化成 T: Encode + Type——如果 type_info 是 &self,add 就得写一条 "value 在 bind 时已 move 走、但我还需要 type_info" 这种别扭的约束。例外情况(比如 Postgres 几何类型要报按名字解析的类型)用 Encode::produces 开值级别 override 口子——只给少数类型付代价,多数类型走静态路径。

Q2:Encode 为什么要两个方法(encode 和 encode_by_ref)而不是一个?

A:优化所有权。看 SQLite 那份 String 实现(sqlx-sqlite/src/types/str.rs):encode 把 String 直接 Cow::Owned(self) 推进参数向量,encode_by_ref 只能 Cow::Owned(self.clone()) 多拷一份。默认实现 encode 走 encode_by_ref,所以驱动作者只需实现后者;能省拷贝的类型再额外覆写 encode。注意这份红利不是普遍的——Postgres 的缓冲区是一段共享 Vec<u8>,两条路都得 extend 一次,所以它的 String 只写了 encode_by_ref。

Q3:为什么 Decode 没有 decode_by_ref?

A:decode 的输入是 ValueRef<'r>——已经是引用类型。如果做 decode_by_ref(&self, value),self 根本不存在(decode 是构造函数,不是方法),语义不成立。

Q4:IsNull::Yes 时是否应当允许 buffer 有字节写入?

A:不允许。IsNull::Yes 表示"这个参数是 NULL"——Postgres 的 Bind 消息用长度字段 -1 编码 NULL,MySQL 用 null-bitmap 置位;如果 buffer 有字节写入但参数标记为 NULL,协议层就会把这些字节当下一个参数的数据,整个 bind 错乱。encode 实现必须遵守"返回 Yes 就不写 buffer"的契约,Postgres 在 PgArgumentBuffer::encode 里用 debug_assert_eq! 守着它(sqlx-postgres/src/arguments.rs:185-186,源码注释:"It is illegal for [encode] to write any data")。唯一的例外是 Any 驱动:它的缓冲本身是一串值,None 要推一个 AnyValueKind::Null 进去(sqlx-core/src/any/mod.rs:67-83)。

Q5:TypeChecking 和 Type 的映射为什么单向?

A:使用场景不对称。编译期只需要 DB → Rust(从 describe 的返回推 Rust 类型);运行期只需要 Rust → DB(bind 时声明类型)。但两个方向并没有各写一份表:TypeChecking 由 impl_type_checking! 对驱动列出的 Rust 类型逐个调用 Type::type_info() / compatible() 反查而来,Type 实现是唯一的事实来源。新增一个类型只需写好它的 Type 实现、再在清单里加一行,OID 不会写两遍,也不会漂移。

5.16 下一章指路

下一章进入 Arguments<'q> 和 IsNull 的下一个配合面——ArgumentBuffer 的具体形态(Postgres 的 patch/hole、MySQL 的裸字节、SQLite 的 value tag vector)如何影响参数绑定的生命周期与 bind 链的类型推导。我们会看到 .bind(x).bind(y).bind(z) 这条链背后的"怎么把异构类型塞进同一个 Vec"的类型消化过程。