Skip to content

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

"A type system is not a straitjacket — it's a safety net that knows what lies on the other side of the wire." —— 类型映射设计的基本信条

本章要点

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

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_column_int 函数直接传递,没有字节序问题。

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

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

5.2 三位一体的分工

先看 sqlx 的顶层 re-export(sqlx-0.8.6/src/lib.rs:16-19, 37):

rust
pub use sqlx_core::decode::Decode;
pub use sqlx_core::encode::{Encode, IsNull};
pub use sqlx_core::types::Type;

三个 trait 各自的 one-liner:

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

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

关键约束:

  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(罕见但合法),但一旦你想让它进入 bindtry_get,就需要同时有 Type。

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

rust
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-235

rust
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:10-22strType 实现:

rust
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 类型——Postgres 的某些表达式(比如字面量 'foo' 不带显式类型转换)会返回 UNKNOWN,sqlx 让 str 吃下这种情况而不是报错。

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

5.3.2 为什么 type_info 是静态方法

type_info() 没有 &self,它是关联函数。这个决定的后果是:Rust 类型必须静态决定它的 SQL 类型——同一个 Rust 类型不能根据值的大小选择 INT2 / INT4 / INT8。1_i64100_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 做成静态方法让类型信息和编码彻底解耦。

例外在 Postgres 有一条——Encode::produces(§5.4.1)是值级别的类型覆盖,专为"同一个 Rust 类型需要根据值选不同 OID"的少数场景(例如 JSONB 带版本字节)。

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

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

rust
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——移动所有权进来。允许 encode 利用值的所有权做优化(例如 String 可以直接 into_bytes() 而不是 clone)。默认实现是调 encode_by_ref
  • encode_by_ref 借用 self——最基础也最常用。必须实现。
  • produces值级别的 TypeInfo 覆盖——默认返回 None,表示"用 Type::type_info() 就行"。极少数场景覆盖它:比如 Postgres 的 Json<T> 会根据"是 JSON 还是 JSONB"返回不同 OID。
  • size_hintArgumentBuffer 预分配容量。默认用 size_of_val(self)——对 POD 类型够用,对 String 这种变长类型需要覆盖成实际字节长度。

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

encode.rs:8-16

rust
#[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。调用方(Arguments::add)据此在线路协议里发 NULL 参数标记——Postgres 的 Bind 消息里对应一个 -1 长度字段、MySQL 对应 null-bitmap 的对应位置 1。

#[must_use] 是关键——编译器强制每个 encode_by_ref 的返回值都被检查,不能随手丢弃。这避免了"忘记处理 NULL"这种低级错误。

5.4.2 Encode for &T 的 blanket impl

encode.rs:58-83 有一条优雅的 blanket impl:

rust
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,后者走值路径。实际上大多数 sqlx 用户都是 bind(&my_value)——因为用户代码往往不想把参数所有权交出去。

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

5.4.3 size_hintArguments::reserve 的配合

Encode::size_hint 的默认实现是 mem::size_of_val(self)——对于 i32 返回 4、对 bool 返回 1。这个值会参与 Arguments::reserve 的计算,让 buffer 一次性分配到位。

Arguments::reserve 的签名(arguments.rs:16-18):

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

additional 是待加入的参数个数、size 是估算的总字节数。这两个数用来预分配两个 Vec——typesbuffer

变长类型通常要覆盖 size_hint——例如 String 的 size_hint 返回 self.len()(字符串字节数)而不是 size_of::<String>()(32 字节 header)。sqlx-postgres/src/types/str.rs 的相关实现里就覆盖了这一点:

rust
impl Encode<'_, Postgres> for String {
    fn size_hint(&self) -> usize { self.len() }
    // ...
}

这个覆盖看似小,但在大批量 bind(比如 QueryBuilderpush_values 批插)时影响可见——一个 VARCHAR(1000) 列批插 1000 行,每行 500 字节字符串,默认 size_hint 会让 buffer 从 1KB 开始扩,边插边 realloc;覆盖后 buffer 直接分配 512 KB,零 realloc。

size_hint"性能分析 trait 方法"的典型例子——它不参与语义正确性(算错了只是慢一点),但对吞吐量关键的场景可以显著加速。这条设计也让 sqlx 避免了一个常见反模式:在 trait 里强制要求精确的大小信息——那会让实现负担大增。留一个可选的 size_hint、默认给保守估计,优秀实现可以选择精确化——这是 Rust 生态 trait 设计的经典妥协。

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

sqlx-core/src/decode.rs:70-74

rust
pub trait Decode<'r, DB: Database>: Sized {
    fn decode(value: DB::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 实现当前是哪种:

rust
// sqlx-postgres/src/types/bool.rs:26-40
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 格式,所以必须两边都支持。

这种"按 format 分发"的 decode 是 Postgres 独有的复杂度。MySQL 的预处理语句永远 Binary,simple query 永远 Text;SQLite 没有这个维度(直接通过 C API 传值)。第 16 章 Postgres 驱动会更详细讨论这两种格式的协议层差异。

5.5.2 零拷贝 vs 拥有所有权

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

Rust 类型拷贝策略生命周期
&'r str无拷贝——直接借用 ValueRef 的 buffer活到 row 被 drop
String一次堆分配 + memcpy独立生存
Cow<'r, str>按需——Postgres 可借用,SQLite 必须拷贝Borrowed 时同 &'r str,Owned 时独立

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

rust
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 或返回值:

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

Cow<'r, str> 适合"如果驱动能零拷贝就零拷贝,否则拷贝"的通用库代码——sqlx 对 Postgres 实现是 Cow::Borrowed,对 SQLite 实现是 Cow::Owned,对用户透明。

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

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

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

Typesqlx-core/src/types/mod.rs:245-253):

rust
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

Encodeencode.rs:86-131impl_encode_for_option! 宏):

rust
#[macro_export]
macro_rules! impl_encode_for_option {
    ($DB:ident) => {
        impl<'q, T> Encode<'q, $DB> for Option<T>
        where T: Encode<'q, $DB> + Type<$DB> + 'q,
        {
            fn produces(&self) -> Option<<$DB as Database>::TypeInfo> {
                if let Some(v) = self { v.produces() } else { T::type_info().into() }
            }
            fn encode(self, buf: &mut <$DB as Database>::ArgumentBuffer<'q>) -> Result<IsNull, BoxDynError> {
                if let Some(v) = self { v.encode(buf) } else { Ok(IsNull::Yes) }
            }
            fn encode_by_ref(&self, buf: &mut <$DB as Database>::ArgumentBuffer<'q>) -> Result<IsNull, BoxDynError> {
                if let Some(v) = self { v.encode_by_ref(buf) } else { Ok(IsNull::Yes) }
            }
            fn size_hint(&self) -> usize { self.as_ref().map_or(0, Encode::size_hint) }
        }
    };
}

为什么这里用宏而不是 blanket impl?因为**ArgumentBuffer<'q> 是 GAT**——关联类型带生命周期。在 impl<'q, T, DB: Database> Encode<'q, DB> for Option<T> 里,编译器推导 DB::ArgumentBuffer<'q> 会触发本书第 4 章讨论过的"lazy normalization"问题。解法是把 DB 从泛型变成具体类型——通过宏让每个驱动(sqlx-postgressqlx-mysqlsqlx-sqlite)分别展开一次具体实现。

这个宏展开被每个驱动的 lib.rs 调用:impl_encode_for_option!(Postgres);。展开后就是具体的 impl<'q, T> Encode<'q, Postgres> for Option<T> where ...——编译器不再需要 lazy normalization,一切清爽。

Decodedecode.rs:78-88):

rust
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——因为 DB::ValueRef<'r> 虽然也是 GAT,但它作为 函数参数而不是 函数返回值,lazy normalization 在这个位置不触发。这是 GAT 限制的一个细微区别,sqlx 团队通过不同的 impl 策略绕过。

这三个 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 里约 300 行专门处理。
  2. MySQL 没有数组类型——Vec<T> 在 MySQL 下根本不可编码。
  3. SQLite 也没有——同上。

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

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

5.6.2 Arguments::add 的类型检查流

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

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

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

三步合一:

  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. 记录长度——PgArguments 内部还要记录每个参数在 buffer 里的偏移,Postgres Bind 消息里要发"长度 + 数据"。

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

rust
pub fn add<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();
    match value.encode(&mut self.buffer) {
        Ok(IsNull::No) => { self.types.push(type_info); self.buffer.count += 1; Ok(()) }
        Ok(IsNull::Yes) => { self.buffer.restore(buffer_snapshot); self.types.push(type_info); self.buffer.count += 1; /* 标记 NULL */ Ok(()) }
        Err(e) => { self.buffer.restore(buffer_snapshot); Err(e) }
    }
}

关键细节是 buffer_snapshot / restore——如果 Encode 失败或返回 NULL,要回滚 buffer 到 add 之前的状态。这是第 3 章 §3.5.2 讨论过的 PgArgumentBuffer 带 "patches" 设计的一个副产品:buffer 需要支持快照和回滚。MySQL / SQLite 的 ArgumentBuffer 没有这种复杂度,因为它们的 add 失败概率更低(参数编码更简单)。

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

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

sqlx-postgres/src/types/int.rsi32 的三 trait 实现合一起看:

rust
// 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)
    }
}

三个加起来 13 行代码——就完成了 Rust i32 与 Postgres INT4 的双向映射。int_decode 是同文件顶部的一个 helper(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()——自动检查范围。如果 Postgres 返回的是 INT8 但你请求 i32try_into 会返回 TryFromIntError,decode 整体失败。

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

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

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

rust
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 n = <i32 as Decode<DB>>::decode(value)?;
        NonZeroI32::new(n).ok_or_else(|| "zero encountered for NonZero type".into())
    }
}

Encode 和 Type 是直接 delegate 到 i32——对数据库看是普通 INT4。关键在 Decode——如果数据库返回的整数是 0,NonZeroI32::new 返回 None,decode 转成错误。

这个实现是类型系统对业务约束的编码。你把某个字段设计成 "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)C API sqlite3_bind_int
类型标识OID 23(静态常量)FieldType::Long(枚举变体)DataType::Int
兼容性严格——INT4 只接 INT4中等——INTMEDIUMINT 能互通宽松——整数列能存任意 INT
NULL 编码长度字段 -1null-bitmap 的对应位 1sqlite3_bind_null
sqlx 源文件sqlx-postgres/src/types/int.rssqlx-mysql/src/types/int.rssqlx-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-postgresrow.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::Decode("mismatched types")

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

  1. 一致性:反方向(INT8 → i32)会溢出,有方向性不对称会让 API 行为变得难以记忆。
  2. 可预测性:如果允许 INT4 → i64 的隐式转换,某天表的列类型从 INT8 改成了 INT4,代码仍然编译通过但内存用量翻倍——用户看不见这个变化。显式转换(row.try_get::<i32, _>(0) as i64)强制让每次转换留下可 grep 的痕迹。

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

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

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

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

rust
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 到内部类型

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

展开后大致:

rust
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)] + 整数映射

rust
#[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:字符串映射

rust
#[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 反过来——match s.as_str() { "red" => Ok(Color::Red), ... }

数据库侧是 TEXT 或 Postgres 的 ENUM 类型(#[sqlx(type_name = "color")] 告诉宏对应 Postgres 用户自定义 enum 类型名)。这是 Postgres 用户最喜欢的模式——数据库侧用 CREATE TYPE 定义 enum、Rust 侧派生同名枚举、编译期类型安全

5.9.4 Record:Postgres 独有的复合类型

rust
#[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 的 SQL 标准没有复合类型。

5.10 Postgres 独有的 PgHasArrayType

注意前面 booli32 的实现里都有:

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

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

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

#[derive(Type)] 的 transparent 变体默认也生成 PgHasArrayType impl(§5.2 的 types/mod.rs 文档示例讨论过)——但它要求内层类型也实现 PgHasArrayType。如果内层是 Vec<i64>,多维数组 Postgres 不支持,派生会 #[sqlx(no_pg_array)] 关掉这项生成。

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 文本

rust
use sqlx::types::Text;

let (point,): (Text<geo::Point>,) = sqlx::query_as("SELECT '10,20'").fetch_one(&pool).await?;
// Text<geo::Point> 的 Encode 调 geo::Point 的 Display,Decode 调 FromStr

Text<T>Type 声明自己是 TEXTEncodeself.0.to_string().encode_by_ref(buf)DecodeT::from_str(&s)。这对你不想为某个类型手写 Encode/Decode 但它已有 Display + FromStr 的场景非常顺手。

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

rust
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> deref 到 T

Json<T> 的三 trait 用 serde_json:Encode 是 serde_json::to_vec(&self.0),Decode 是 serde_json::from_slice(bytes)。数据库侧对应 Postgres 的 JSONB、MySQL 的 JSON、SQLite 的 TEXT

Json<T> 是 sqlx 和 serde 生态的胶水——让任何 #[derive(Serialize, Deserialize)] 的类型直接能作为 SQL 列值。这在《Serde 元编程》第 8 章的"自定义 format 实现"讨论过——sqlx 的 Json<T> 就是"用 serde_json 做后端的特化 format"。

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

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

rust
use std::str::FromStr;

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

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> 零代码派生

rust
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)]

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

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

方案 C:手写 Type + Encode + Decode

rust
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 _)
    }
}

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

三个方案的权衡:

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

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

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

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

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

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

rust
// 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_idreturn_type_for_id 都返回 Option<&'static str>——Rust 类型的字符串表示(例如 "i32""String""Option<Uuid>")。这些字符串最终被宏嵌进生成代码里 try_get_unchecked::<i32, _>(0)

这个 trait 的实现是大量的 match 表——sqlx-postgres/src/type_checking.rs 里手工维护"OID 23 → i32"、"OID 16 → bool"、"OID 25 → String"、"OID 2950 → Uuid"(如果启用 uuid feature)等的对应。维护者要保证这张表和 types/ 下每个 impl 的 Type::type_info() 完全一致。

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

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

如果 sqlx 把这两条映射合一成一个 trait,会让驱动作者手写双倍的 match 表、并且强制维持双向一致。现在的设计是接受轻微重复以换取两个方向的独立演进——新增一个可选的 feature 类型(比如 bigdecimal)只要更新一侧的表,不会拉上另一侧。

TypeChecking 这条 trait 放在 sqlx-core 而不是 sqlx-macros-core,是因为派生宏的 #[derive(Type)] 也需要它——它要能在用户类型(UserId / Color 之类)上做类型名字推导。这也是第 2 章 §2.3.1 讨论过的 DatabaseExt: Database + TypeChecking 这条超 trait bound 的由来。

5.14 本章小结

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

  1. 三个独立 trait(§5.2)——Type<DB>(类型声明)、Encode<'q, DB>(写出)、Decode<'r, DB>(读入)。三分而非合一的理由是外部类型的灵活性(可以只实现需要的一部分)以及 Rust orphan rule 的兼容。
  2. Typecompatible(§5.3.1)—— str 接受 6 种 Postgres 类型(TEXT / NAME / BPCHAR / VARCHAR / UNKNOWN / citext)。这个方法决定了"同一个 DB 列能 decode 成哪些 Rust 类型"。
  3. IsNull 枚举(§5.4.1)——SQL 三值逻辑在 trait 签名的投影。IsNull::Yes 只在 Option::None 出现;IsNull::No 不保证有字节写入(空字符串合法)。#[must_use] 强制编译器检查返回值。
  4. Encode for &T 的 blanket impl(§5.4.2)——让 bind(&value)bind(value) 都合法。两条方法的互相引用不是无限递归,链条在底层类型 T 的 encode_by_ref 终止。
  5. Decode 的 format 分发(§5.5.1)——Postgres 独有的复杂度,Binary 和 Text 两种格式都要支持。MySQL / SQLite 没有这个维度。
  6. 零拷贝 vs 拥有所有权(§5.5.2)—— &'r str / String / Cow<'r, str> 三种策略对应不同生命周期和性能权衡,用户透明掌控。
  7. Option<T> 的统一承载(§5.6)—— 三 trait 各自的 blanket impl 让 SQL NULL 无缝映射到 Option::None。Encode 侧用宏而非 blanket impl 是因为 GAT 的 lazy normalization 限制;Decode 侧直接 blanket impl 是因为 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)——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)—— 编译期使用的 DB → Rust 类型名映射,和运行期的 Type::type_info()(Rust → DB)构成双向不对称的一对 trait。这条拆分让编译期和运行期的映射可以独立演进。

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

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

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

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

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

A:优化所有权。Stringencodeinto_bytes() 零拷贝移走 buffer,encode_by_ref 必须 .as_bytes().to_vec() 做一次 clone。两个方法给用户用哪种都行——默认实现 encodeencode_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 / MySQL 的线路协议用长度字段编码 NULL(Postgres 是 -1),如果 buffer 有字节写入但参数标记为 NULL,协议层就会把这些字节当下一个参数的数据,整个 bind 错乱。encode 实现必须遵守"返回 Yes 就不写 buffer"的契约。

Q5:TypeCheckingType 的映射为什么单向?

A:使用场景不对称。编译期只需要 DB → Rust(从 describe 的返回推 Rust 类型);运行期只需要 Rust → DB(bind 时声明类型)。双向合一会让驱动作者手写两份一致的 match 表,新增一个类型(比如 pgvector)要同时改两处,维护成本加倍。接受轻微重复换独立演进——这是工程权衡。

5.16 下一章指路

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

5.17 总结这套三位一体的设计价值

最后退一步看这三个 trait 作为一个整体给 sqlx 带来的价值:

可扩展性。任何第三方 crate(比如 geo::Pointuuid::Uuid 的新版本、一个自家的 PhoneNumber)都可以为自己加 sqlx 类型支持——只要 impl 三个 trait。不需要改 sqlx-core,不需要发 sqlx 新版本。bigdecimal / rust_decimal / ipnet / ipnetwork 这些可选 feature 就是通过这条机制接入的。

类型安全Arguments::add<T: Encode + Type> 这条 trait bound 让"能 bind 的类型"有一个明确的契约——编译期就能判断 bind(some_custom_type) 是否合法。如果 custom_type 只实现了 Encode 不实现 Type,编译器会明确告诉你"缺 Type impl"——相比传统 ORM 的 "运行时找不到合适的 mapper" 要友好得多。

性能可控encode 能吃所有权(零拷贝 String.into_bytes())、encode_by_ref 能接引用(避免不必要的 clone)、size_hint 能精确化(避免 buffer realloc)——三个方法层次让性能优化有空间。用户不需要为这些优化写特殊代码,只要 Rust 类型的 impl 写得好就自动受益。

语义清晰IsNull 枚举把"值是 NULL"这件事从 Option<T> 的表达提升到 trait 签名层——这对 NULL 处理格外重要的 SQL 世界来说值得。运行时层 compatible 做宽容兼容(TEXT / VARCHAR 互通)、编译期层 type_info 严格匹配——两层各司其职。

如果把这三个 trait 合一成单个 SqlType<DB> trait(用几个必实现方法囊括 TypeInfo、encode、decode),看似简洁,但会失去扩展点的精细度:外部 crate 只能"全实现或全不实现",不能只加 Decode 不加 Encode;性能优化的 encode/encode_by_ref 二选一机制也塌陷。三分是刻意的拆——每一个 trait 承担一件不可混淆的职责。

基于 VitePress 构建