Appearance
第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 值写进ArgumentBuffer、Decode<'r, DB>从ValueRef读回 Rust 值。 - 三分而非合一是刻意设计——让外部类型(比如
Uuid、BigDecimal)可以只实现需要的那部分(比如只要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::Yes;Decode对is_null的 ValueRef 返回Ok(None)。这是整套类型系统对 SQL NULL 的核心承载。#[derive(Type)]一次性生成三个 trait 的实现——sqlx-macros-core/src/derives/type.rs:14按newtype/record/weak enum/strong enum四种形态分派,每种形态的 Encode/Decode 策略不同。- Postgres 独有的
PgHasArrayType(sqlx-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 有三条重要约束关系:
关键约束:
bind(x)要求T: Encode + Type——你既要能编码出字节(Encode),也要能告诉 DB 这些字节是什么类型(Type 的type_info())。try_get::<T>(i)要求T: Decode + Type——你既要能从字节解码(Decode),也要能检查 DB 返回的列类型和你期望的兼容(Type 的compatible())。- Type 不被 Encode 或 Decode 强制 require——外部类型可以只 impl Decode 不 impl Type(罕见但合法),但一旦你想让它进入
bind或try_get,就需要同时有 Type。
这三个 trait 共同构成类型映射的最小完备集。从 arguments.rs:19-22 的 add 方法签名能直接看到这条要求:
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-22 里 str 的 Type 实现:
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 吃下这种情况而不是报错。
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 做成静态方法让类型信息和编码彻底解耦。
例外在 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_hint让ArgumentBuffer预分配容量。默认用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_hint 和 Arguments::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——types 和 buffer。
变长类型通常要覆盖 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(比如 QueryBuilder 的 push_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。
这个简单的签名背后有几条设计决定:
Decode::decode没有&self——类似Type::type_info,它是类型级构造函数。输入 ValueRef,输出Self。- 返回
Result<Self, BoxDynError>——decode 可能失败(UTF-8 错误、整数溢出、不合法日期),用盒装 error trait object 统一表达。具体 Error 类型由实现决定。 '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 合起来实现:
Type(sqlx-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。
Encode(encode.rs:86-131 的 impl_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-postgres、sqlx-mysql、sqlx-sqlite)分别展开一次具体实现。
这个宏展开被每个驱动的 lib.rs 调用:impl_encode_for_option!(Postgres);。展开后就是具体的 impl<'q, T> Encode<'q, Postgres> for Option<T> where ...——编译器不再需要 lazy normalization,一切清爽。
Decode(decode.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> 没有。为什么?
- Postgres 的
Vec<T>对应数组类型(例如INT4[]的 OID 1007)——编码格式特殊(维度头 + 元素 OID + 数据),不是简单的元素编码拼接。这部分在sqlx-postgres/src/types/array.rs里约 300 行专门处理。 - MySQL 没有数组类型——
Vec<T>在 MySQL 下根本不可编码。 - 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 的内部流程:
三步合一:
- Type 声明——
<i32 as Type<Postgres>>::type_info()返回PgTypeInfo::INT4。这个 TypeInfo 被 push 到PgArguments::types向量,后面 Postgres 驱动发 Bind 消息时从这里读 OID。 - Encode 编码——
<i32 as Encode<Postgres>>::encode_by_ref(&42, buf)把 4 字节大端写进PgArgumentBuffer::buffer。 - 记录长度——
PgArguments内部还要记录每个参数在 buffer 里的偏移,Postgres Bind 消息里要发"长度 + 数据"。
这条路径在 sqlx-postgres/src/arguments.rs:75-115 的 PgArguments::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 失败概率更低(参数编码更简单)。
这个序列也显示了为什么 Encode 和 Type 必须在 add 的 trait bound 里一起出现——缺任何一个这条路径都串不起来。
5.7 一个完整案例:i32 在 Postgres 下的全套实现
把 sqlx-postgres/src/types/int.rs 里 i32 的三 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。
这套实现的精妙之处在于分层:
int_decode返回i64——"最大的整数类型,能装下所有小整数"。- 具体类型(i16/i32/i64)各自
try_into()——自动检查范围。如果 Postgres 返回的是INT8但你请求i32,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。核心思路是:
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 | 中等——INT 和 MEDIUMINT 能互通 | 宽松——整数列能存任意 INT |
| 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::Decode("mismatched types")。
sqlx 在所有路径上都不做"悄悄转换"——即便 i64 能容纳任何 i32 值,也不自动把 INT4 decode 成 i64。理由有二:
- 一致性:反方向(INT8 → i32)会溢出,有方向性不对称会让 API 行为变得难以记忆。
- 可预测性:如果允许 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 最常见的派生需求是让自己的类型能参与 bind 和 try_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
注意前面 bool 和 i32 的实现里都有:
rust
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 变体默认也生成 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 调 FromStrText<T> 的 Type 声明自己是 TEXT,Encode 用 self.0.to_string().encode_by_ref(buf),Decode 用 T::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 到 TJson<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 就能直接 bind 和 try_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> | 0 | Wrapper 类型 | 由 FromStr 保证 |
B: #[derive(Type) transparent] | 1 行派生 | 强(独立类型) | 无(直接构造) |
| C: 手写三 trait | 约 30 行 | 强 | 有(Decode 里校验) |
业务代码里的选择几乎永远是 B——如果你信任数据库的数据干净。如果你要在迁移期间容忍脏数据并保证读到脏数据时报错,就用 C。A 一般只在"临时探索"或"只查询不保存"的场景合适。
这一节是本书"理论到实践"的缩影:三个 trait 的抽象背后是工程选择——多 30 行代码换一次额外的运行时校验,值不值取决于你的数据质量和"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 的 TypeChecking:
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_id 和 return_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 类型映射的三位一体完整拆开:
- 三个独立 trait(§5.2)——
Type<DB>(类型声明)、Encode<'q, DB>(写出)、Decode<'r, DB>(读入)。三分而非合一的理由是外部类型的灵活性(可以只实现需要的一部分)以及 Rust orphan rule 的兼容。 Type的compatible(§5.3.1)——str接受 6 种 Postgres 类型(TEXT/NAME/BPCHAR/VARCHAR/UNKNOWN/citext)。这个方法决定了"同一个 DB 列能 decode 成哪些 Rust 类型"。IsNull枚举(§5.4.1)——SQL 三值逻辑在 trait 签名的投影。IsNull::Yes只在Option::None出现;IsNull::No不保证有字节写入(空字符串合法)。#[must_use]强制编译器检查返回值。Encode for &T的 blanket impl(§5.4.2)——让bind(&value)和bind(value)都合法。两条方法的互相引用不是无限递归,链条在底层类型 T 的encode_by_ref终止。- Decode 的 format 分发(§5.5.1)——Postgres 独有的复杂度,Binary 和 Text 两种格式都要支持。MySQL / SQLite 没有这个维度。
- 零拷贝 vs 拥有所有权(§5.5.2)——
&'r str/String/Cow<'r, str>三种策略对应不同生命周期和性能权衡,用户透明掌控。 Option<T>的统一承载(§5.6)—— 三 trait 各自的 blanket impl 让 SQL NULL 无缝映射到Option::None。Encode 侧用宏而非 blanket impl 是因为 GAT 的 lazy normalization 限制;Decode 侧直接 blanket impl 是因为 GAT 在函数参数位置不触发这条限制。- 三家 DB 的类型系统差异(§5.8)——Postgres OID + 大端、MySQL FieldType + 小端、SQLite C API 直接传值。sqlx-core 不抽象这层;每个驱动独立实现三 trait。
#[derive(Type)](§5.9)——一次派生生成三个 trait,按 transparent/weak-enum/strong-enum/record 四种形态分派。PgHasArrayType(§5.10)——Postgres 独有的扩展 trait,声明Vec<T>的数组 OID。这是"方言差异通过扩展 trait 表达"的案例。Text<T>和Json<T>(§5.11)——两个 wrapper 把"任意 FromStr/Display 类型"和"serde 类型"一次性接入 SQL。- 业务领域类型接入实战(§5.12)—— A(
Text<T>零代码)、B(#[derive(Type) transparent]一行)、C(手写三 trait 加校验)三条路线对应不同的"数据质量 vs 代码量"权衡。大多数业务选 B。 TypeCheckingtrait(§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 是 &self,add 就得写一条 "value 在 bind 时已 move 走、但我还需要 type_info" 这种别扭的约束。例外情况(Postgres 的 JSON 版本字节)用 Encode::produces 开值级别 override 口子——只给少数类型付代价,多数类型走静态路径。
Q2:Encode 为什么要两个方法(encode 和 encode_by_ref)而不是一个?
A:优化所有权。String 的 encode 能 into_bytes() 零拷贝移走 buffer,encode_by_ref 必须 .as_bytes().to_vec() 做一次 clone。两个方法给用户用哪种都行——默认实现 encode 走 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 / MySQL 的线路协议用长度字段编码 NULL(Postgres 是 -1),如果 buffer 有字节写入但参数标记为 NULL,协议层就会把这些字节当下一个参数的数据,整个 bind 错乱。encode 实现必须遵守"返回 Yes 就不写 buffer"的契约。
Q5:TypeChecking 和 Type 的映射为什么单向?
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::Point、uuid::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 承担一件不可混淆的职责。