SQLx 源码深度解析

第7章 Row 与 Column:动态结果集的最小稳定面

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

Row 是整个 fetch 路径的交付面:两个必实现方法撑起另外八个默认实现,而 try_get 的每一步失败都对应一个具体的错误变体。

本章要点

  • Row trait(sqlx-core/src/row.rs:14)是整个 fetch 路径的交付面——fetch_many 流里流出的就是 DB::Row,之后所有解码都从 Row 上 try_get。
  • try_get_raw + columns 是 Row 的两个必实现方法——其它 8 个方法(is_empty / len / column / try_column / get / get_unchecked / try_get / try_get_unchecked)都是基于这两个原语的默认实现。trait 用少数必实现原语 + 默认方法表达丰富接口,和第 4 章 Executor 同一思路。
  • try_get 的三层路径(sqlx-core/src/row.rs:111-133):try_get_raw → value.type_info → T::compatible → T::decode——取值失败返回 ColumnNotFound / ColumnIndexOutOfBounds,类型不兼容和解码失败返回 Error::ColumnDecode。
  • ColumnIndex trait 让 try_get(0) 和 try_get("name") 共用同一个方法签名——usize 和 &str 各自实现 ColumnIndex<Row>,index() 返回 Result<usize, Error>。
  • PgRow 的存储(sqlx-postgres/src/row.rs:14-18)—— DataRow(持有服务端返回字节的 Bytes 共享引用)+ PgValueFormat(Binary 或 Text)+ Arc<PgStatementMetadata>(列信息共享)。
  • MySqlRow 结构类似但字段名不同;SqliteRow 不借用 statement——它把每个值单独 SqliteValue::new 拷贝出来(因为 SQLite 的 sqlite3_column_* 指针在下次 step 时失效)。
  • SqliteRow 的 unsafe impl Send + Sync(sqlx-sqlite/src/row.rs:27-28)——早年 SqliteRow 直接引用 statement 时留下的;0.8.6 的 SqliteRow 已不碰 statement,字段本身就是 Send + Sync,这两行实为历史遗留。
  • Row: 'static(sqlx-core/src/row.rs:14)—— Row 这个 trait 本身不借用任何外部数据,因此可以跨越 await 点安全存活。但 try_get_raw(sqlx-core/src/row.rs:175)返回 ValueRef<'_>——借用 row 的内部 buffer,生命周期绑到 &'r self。

7.1 问题引入:fetch 后的世界

第 4 章讲完了 Executor::fetch_many 返回 BoxStream<Either<QueryResult, Row>>;第 5-6 章讲完了参数如何进、值如何解码。但中间有一个类型从头到尾伴随着每一次查询——Row。

考虑下面这段典型用法:

let row = sqlx::query("SELECT id, name, email, created FROM users WHERE id = $1")
    .bind(42_i32)
    .fetch_one(&pool).await?;

let id: i32 = row.try_get("id")?;
let name: String = row.try_get("name")?;
let email: Option<String> = row.try_get("email")?;
let created: OffsetDateTime = row.try_get(3)?;  // 按位置

这里用户代码做了几件事:

  1. fetch_one 返回一个 Row。
  2. 对 Row 做四次 try_get——三次按列名、一次按位置。
  3. 每个 try_get 返回不同的 Rust 类型——i32 / String / Option<String> / OffsetDateTime——类型由用户声明决定。

这几条能成立的前提是 Row 类型能做到三件事:

  • 保存足够的信息(列名、类型、字节)以供后续 try_get。
  • 按字符串或整数索引定位到一列。
  • 返回一个 ValueRef,交给 Decode 做类型转换。

这章讲 Row 怎么做这三件事——以及为什么三家 DB 的 Row 内部存储完全不同。

7.2 Row trait 的形态

sqlx-core/src/row.rs:14-178 的 Row trait 一共定义了 10 个方法,其中只有两个必实现——columns(line 54)和 try_get_raw(line 175-177)。其它 8 个(is_empty / len / column / try_column / get / get_unchecked / try_get / try_get_unchecked)都是基于这两个的默认实现。

// 摘要:DB 即 Self::Database;方法按"必实现 / 默认"重排,与源码顺序不同
pub trait Row: Unpin + Send + Sync + 'static {
    type Database: Database<Row = Self>;

    // 必实现
    fn columns(&self) -> &[<Self::Database as Database>::Column];
    fn try_get_raw<I>(&self, index: I) -> Result<DB::ValueRef<'_>, Error>
    where I: ColumnIndex<Self>;

    // 默认方法
    fn is_empty(&self) -> bool { self.len() == 0 }
    fn len(&self) -> usize { self.columns().len() }
    fn column<I>(&self, index: I) -> &DB::Column where I: ColumnIndex<Self> { ... }
    fn try_column<I>(&self, index: I) -> Result<&DB::Column, Error> where I: ColumnIndex<Self> { ... }
    fn get<'r, T, I>(&'r self, index: I) -> T
    where I: ColumnIndex<Self>, T: Decode<'r, DB> + Type<DB>;
    fn try_get<'r, T, I>(&'r self, index: I) -> Result<T, Error>
    where I: ColumnIndex<Self>, T: Decode<'r, DB> + Type<DB>;
    fn try_get_unchecked<'r, T, I>(&'r self, index: I) -> Result<T, Error>
    where I: ColumnIndex<Self>, T: Decode<'r, DB>;
    fn get_unchecked<'r, T, I>(&'r self, index: I) -> T
    where I: ColumnIndex<Self>, T: Decode<'r, DB>;
}

必实现方法只有 columns 和 try_get_raw 这件事值得单独强调——这和第 4 章 Executor 是同一思路(Executor 是 4 个必实现方法):用少数最小原语表达丰富接口。try_get_raw 负责"按索引取 ValueRef",columns 负责"列出所有列"。其它方法全是组合——try_get 是 try_get_raw + compatible + decode、try_column 是 columns()[index]、len 是 columns().len()、get* 是 try_get*().unwrap()。

super-trait bound Unpin + Send + Sync + 'static 是第 3 章 §3.4.1 讨论过的:

  • Unpin:可以在 BoxStream 里自由移动。
  • Send + Sync:可以跨 tokio task 共享。
  • 'static:Row 不能借用外部数据——所有字节必须 owned 或来自 Arc 共享。

这条 'static bound 是 Row 整个设计的基石——它让 Row 可以随便 Vec::collect、放进 channel、跨 await。但它也约束 Row 必须自持数据——Postgres 的 DataRow 内部不是 &[u8](借用 TCP buffer)而是 Bytes(引用计数共享的字节缓冲,clone 只加引用计数),SQLite 的 row 是 Box<[SqliteValue]>(独立分配)。

7.3 try_get 的三层路径

try_get 是用户最常用的方法,也是最值得精读的。sqlx-core/src/row.rs:111-133:

fn try_get<'r, T, I>(&'r self, index: I) -> Result<T, Error>
where I: ColumnIndex<Self>, T: Decode<'r, Self::Database> + Type<Self::Database>,
{
    let value = self.try_get_raw(&index)?;

    if !value.is_null() {
        let ty = value.type_info();

        if !ty.is_null() && !T::compatible(&ty) {
            return Err(Error::ColumnDecode {
                index: format!("{index:?}"),
                source: mismatched_types::<Self::Database, T>(&ty),
            });
        }
    }

    T::decode(value).map_err(|source| Error::ColumnDecode {
        index: format!("{index:?}"),
        source,
    })
}

三层路径:

第一层 — try_get_raw(&index):把 I 转 usize 下标(通过 ColumnIndex::index),从 row 里取一个 ValueRef<'_>。失败返回 Error::ColumnNotFound 或 ColumnIndexOutOfBounds。

第二层 — 类型兼容检查:如果 value 非 NULL,取它的 type_info(),调用 T::compatible(&ty)。不兼容返回 Error::ColumnDecode 带 mismatched_types 描述。

第三层 — T::decode(value):调 Decode 实现做字节解码。失败返回 Error::ColumnDecode 带源错误。

关键细节 1:NULL 不触发兼容检查。if !value.is_null() 让 NULL 列直接跳到 decode——让 try_get::<Option<i32>, _>(null_column) 成功返回 None,而不是因为 "NULL 和 INT4 不兼容" 报错。Decode 实现里 Option<T> 的 blanket(第 5 章 §5.6)会接住 is_null 的 ValueRef 返回 Ok(None)。

关键细节 2:!ty.is_null() && ... 这个短路判断——如果 type_info() 自己是 NULL 类型,也跳过兼容检查。这条只对 MySQL(ColumnType::Null)和 SQLite(DataType::Null)起作用;Postgres 的 TypeInfo::is_null 恒返回 false(sqlx-postgres/src/type_info.rs:1045-1047),不走这条短路。

Postgres 对 UNKNOWN 类型的宽容写在各类型的 compatible 列表里——例如 str 的 compatible 就列了 PgTypeInfo::UNKNOWN(sqlx-postgres/src/types/str.rs:14-24,第 5 章 §5.3.1)。

关键细节 3:错误信息包含 index——format!("{index:?}") 把用户传入的索引(整数或字符串)格式化到错误里。这让 try_get::<i32, _>("foo") 在解码失败时你能看到 ColumnDecode { index: "\"foo\"", ... }——精确定位是哪列出问题。

7.3.1 try_get 能报哪些 Error

try_get 可能产生四类错误,了解它们对调试很有帮助:

错误 触发场景 来源
Error::ColumnNotFound(name) try_get("foo") 列不存在 ColumnIndex::<&str> 返回
Error::ColumnIndexOutOfBounds try_get(99) 超出列数 ColumnIndex::<usize> 返回
Error::ColumnDecode { mismatched } try_get::<i32>("name") 但 name 是 TEXT compatible 检查失败
Error::ColumnDecode { source } try_get::<i32>("big"):SQLite 的 INTEGER 列存了超出 i32 范围的值 Decode::decode 返回 Err

所有 ColumnDecode 错误都带 index 字段——format!("{index:?}") 把用户传入的索引(整数或带引号的字符串)格式化进去。一个常见困扰是字符串索引在错误里有引号:"\"foo\"" 而不是 "foo"——这是 format!("{index:?}") 用 Debug 格式化索引的结果;若用 Display 格式化索引就没有引号,但也看不出"用户传的是字符串"。另外,{:?} 打印整个错误时这层引号还会再被转义一次;用 {} 打印看到的是 error occurred while decoding column "foo": ...(SQLite 实测)。

实际排查 decode 错误的标准步骤:

  1. 看错误类型——ColumnNotFound 是列名拼错,OutOfBounds 是位置越界,ColumnDecode 是解码问题。
  2. ColumnDecode 里再看 source——如果是 mismatched_types,Rust 类型和列类型不对;如果是其它,decode 内部失败(字符串 UTF-8、整数溢出等)。
  3. 用 row.column(index).type_info() 确认列实际类型——和你期望的对不对。
  4. 看 row.columns().iter().map(|c| c.name()).collect::<Vec<_>>() 确认有哪些列——用来定位拼写错误。

7.3.2 try_get_unchecked 跳过兼容检查

对比 try_get,try_get_unchecked(sqlx-core/src/row.rs:152-163)少了中间一层:

fn try_get_unchecked<'r, T, I>(&'r self, index: I) -> Result<T, Error>
where I: ColumnIndex<Self>, T: Decode<'r, Self::Database>,
{
    let value = self.try_get_raw(&index)?;
    T::decode(value).map_err(|source| Error::ColumnDecode {
        index: format!("{index:?}"),
        source,
    })
}
  • 没有 T::compatible 检查——直接 decode。
  • trait bound 少 Type<DB>——不需要类型声明。

谁用 unchecked?query! 宏生成的代码。第 1 章 §1.5.1 讨论过——宏在编译期已经通过 describe 验证了 SQL 列类型和 Rust 类型兼容,运行时再检查一遍是重复劳动。所以生成代码用 try_get_unchecked 跳过 compatible——只有用 "col: _" 这类通配覆盖了类型的列,才退回带检查的 try_get(sqlx-macros-core/src/query/output.rs:140-164)。

直接调用 try_get_unchecked 的代价是解码错误的信息可能不清晰——compatible 检查失败给"类型不匹配",decode 里失败给"字节解析失败"。但对宏生成代码,这个代价不存在(编译期保证过了)。对手写代码,优先用 try_get 除非你确定要跳过。

7.4 ColumnIndex:统一字符串和整数索引

try_get<T, I: ColumnIndex<Self>> 的 I 参数是统一索引类型。ColumnIndex 让 try_get(0) 和 try_get("name") 共用同一个方法。

sqlx-core/src/column.rs:36-43:

pub trait ColumnIndex<T: ?Sized>: Debug {
    fn index(&self, container: &T) -> Result<usize, Error>;
}

一个方法:接受对容器(Row 或 Statement)的引用、返回有效的 usize 下标或错误。

三个基本实现分布在三个地方:

usize 的实现(sqlx-core/src/column.rs:55-65,在 impl_column_index_for_row! 宏体里)。 下面是宏体原文($R 是宏参数,即具体的 Row 类型;只去掉了空行、加了行号注释):

impl $crate::column::ColumnIndex<$R> for usize {   // 源码 55 行
    fn index(&self, row: &$R) -> Result<usize, $crate::error::Error> {
        let len = $crate::row::Row::len(row);
        if *self >= len {
            return Err($crate::error::Error::ColumnIndexOutOfBounds { len, index: *self });
        }
        Ok(*self)
    }
}

usize 就是自己——只做越界检查。

&str 的实现(每个驱动各自,例如 sqlx-postgres/src/row.rs:44-52):

impl ColumnIndex<PgRow> for &'_ str {
    fn index(&self, row: &PgRow) -> Result<usize, Error> {
        row.metadata
            .column_names
            .get(*self)
            .ok_or_else(|| Error::ColumnNotFound((*self).into()))
            .copied()
    }
}

字符串索引查 column_names: HashMap<UStr, usize>——O(1) lookup。找不到返回 ColumnNotFound。

&I where I: ColumnIndex<T>(sqlx-core/src/column.rs:45-50)的 blanket:

impl<T: ?Sized, I: ColumnIndex<T> + ?Sized> ColumnIndex<T> for &'_ I {
    fn index(&self, row: &T) -> Result<usize, Error> {
        (**self).index(row)
    }
}

让 try_get(&"foo") 和 try_get("foo") 都合法——引用和值都可以。sqlx 自己也依赖它:try_get 调的是 self.try_get_raw(&index),传的是引用,index 本身留下来给错误信息做 format!("{index:?}")。

column_names 的预构建是 Row 类型性能的关键——PgRow 的 metadata 是 Arc<PgStatementMetadata>,statement metadata 在 PREPARE 时就已经把列名哈希表建好,row 直接共享。每行查字符串都是 O(1)——没有 O(n) 的列表线性扫描。

7.5 Column trait 的极简

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

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

三个方法——ordinal(列序号)、name(列名或别名)、type_info(列类型)。就这么简单。

为什么只有三个方法? 因为 Column 的使命是"列元数据"——不是"列数据"。它告诉你这列叫什么、是第几列、类型是什么,但不持有任何值。值在 Row 的 ValueRef 里。

super-trait bound 'static + Send + Sync + Debug——Column 独立生存、跨线程共享、可打印。实际三家驱动的 PgColumn / MySqlColumn / SqliteColumn 都是 small struct,实现 Clone,经常通过 Arc 共享。

每家驱动的 Column 还有扩展字段——比如 PgColumn(sqlx-postgres/src/column.rs:8-16):

pub struct PgColumn {
    pub(crate) ordinal: usize,
    pub(crate) name: UStr,
    pub(crate) type_info: PgTypeInfo,
    pub(crate) relation_id: Option<Oid>,          // 表的 OID
    pub(crate) relation_attribute_no: Option<i16>, // 列在表里的 1-based 位置
}

后两个字段是 Postgres 独有的——描述这列"来自哪张表的第几列"。表达式列(例如 SELECT a + b FROM t)的两个字段都是 None。这些元数据用于编译期 nullability 推断(query! 宏的 pg_attribute 查询会用到)——第 11 章详细。

MySQL 和 SQLite 的 Column 没有这两个字段:MySqlColumn 多的是一个 flags: Option<ColumnFlags>(NOT_NULL 等列标志,sqlx-mysql/src/column.rs:8-15),SqliteColumn 只有 name / ordinal / type_info 三个字段。

7.6 PgRow:借用 DataRow 字节

sqlx-postgres/src/row.rs:14-18:

pub struct PgRow {
    pub(crate) data: DataRow,
    pub(crate) format: PgValueFormat,
    pub(crate) metadata: Arc<PgStatementMetadata>,
}

三个字段:

  • data: DataRow——Postgres 协议返回的原始行数据。DataRow 内部是 storage: Bytes(bytes crate 的引用计数字节缓冲,clone 只加引用计数)+ 一个列偏移表。
  • format: PgValueFormat——Binary 或 Text。整行统一,不按列变。
  • metadata: Arc<PgStatementMetadata>——列信息、列名哈希表。多行共享(同一个 statement 执行出的所有 row 共享同一 metadata)。

try_get_raw 实现(sqlx-postgres/src/row.rs:27-42):

fn try_get_raw<I>(&self, index: I) -> Result<PgValueRef<'_>, Error>
where I: ColumnIndex<Self>,
{
    let index = index.index(self)?;
    let column = &self.metadata.columns[index];
    let value = self.data.get(index);

    Ok(PgValueRef {
        format: self.format,
        row: Some(&self.data.storage),
        type_info: column.type_info.clone(),
        value,
    })
}

步骤:

  1. 通过 ColumnIndex::index 把 I 转成 usize。
  2. 从 metadata.columns 取 PgColumn。
  3. 从 data.get(index) 取 Option<&[u8]>——列的字节切片(或 None 表示 NULL)。
  4. 构造 PgValueRef 打包返回。

PgValueRef::row: Some(&self.data.storage) 这个字段是什么?它是对整行 Bytes 的借用。零拷贝的 as_bytes() 用不到它——那走的是 value: Option<&[u8]> 字段;row 是留给 to_owned() 的(sqlx-postgres/src/value.rs:101-115)。

为什么要借用整行而不是单列?因为 PgValueRef::to_owned() 在 §3.4.3 讨论过——"Postgres 的 to_owned 是 O(1) 引用计数递增"——它用 row.slice_ref(value) 从 data.storage 切出共享视图作为 PgValue 内部的 Bytes;没有 row 时只能 Bytes::copy_from_slice 拷一份。多个 PgValue 可以同时存在、共享同一份 DataRow 的底层字节、整体 O(1)——这条优化依赖整行 Bytes 的共享引用。

7.6.1 DataRow 的内部:存储 + 偏移表

PgRow::data 的类型 DataRow 在 sqlx-postgres/src/message/data_row.rs:10-17:

pub struct DataRow {
    pub(crate) storage: Bytes,
    pub(crate) values: Vec<Option<Range<u32>>>,
}

两个字段:

  • storage: Bytes——整条 Postgres DataRow 消息的原始字节缓冲。
  • values: Vec<Option<Range<u32>>>——每列一个 Option<Range<u32>>:None 表示 NULL,Some(start..end) 表示该列在 storage 里的字节偏移范围。

用 Range<u32> 而不是 Range<usize> 是省内存的刻意选择——源码注释写得很清楚:"This uses u32 instead of usize to reduce the size of this type. Values cannot be larger than i32 in postgres"——Postgres 协议的单值不会超过 i32::MAX 字节,所以 u32 够用,Option<Range<u32>> 比 Option<Range<usize>> 小一半。宽表批量 fetch 时,这个 values 向量是每行都要新建的,省下来的就是每行每列的固定开销。

DataRow::get(sqlx-postgres/src/message/data_row.rs:21-25):

pub(crate) fn get(&self, index: usize) -> Option<&'_ [u8]> {
    self.values[index]
        .as_ref()
        .map(|col| &self.storage[(col.start as usize)..(col.end as usize)])
}

get(index) 按 index 索引 values 向量、读 Range、从 storage 切片出字节。零拷贝——切片 &[u8] 的生命周期绑到 &self(即 DataRow)。

DataRow::decode_body(sqlx-postgres/src/message/data_row.rs:31 起)是协议解析——从 Postgres 发来的原始字节按"列数 u16 + 每列 (长度 i32 + N 字节数据)"格式逐列解析,构建 values 向量。这个解析本身不复制数据,只记录 Range。真正持有字节的是 storage: Bytes——它由 sqlx 自己的 BufferedSocket 从连接上读出来后按消息切分交过来,Bytes 的切分是引用计数共享而不是拷贝。

7.6.2 PgValueFormat 的一行决定

format: PgValueFormat 这一字段看似小但影响巨大:

  • Binary:每列字节按 Postgres 二进制格式(整数大端、浮点 IEEE 754、字符串 UTF-8 直接存、时间戳按 microseconds 相对 2000-01-01)。
  • Text:每列字节是 UTF-8 文本(整数用 ASCII、时间戳用 YYYY-MM-DD HH:MM:SS.mmmuuu±TZ 格式)。

整个 Row 所有列共享一个 format——由查询使用的协议模式决定:

  • Extended Query(默认,用 prepared statement)→ Binary。
  • Simple Query(SELECT ... 裸字符串发过去)→ Text。

sqlx 默认用 Extended Query(走 Bind + Execute),所以大多数场景是 Binary。Text 模式出现在不带参数、走 Simple Query 的场景:sqlx::raw_sql(),或直接把 &str 交给 executor(如 conn.execute("..."))——这两者的 take_arguments 都返回 None(sqlx-core/src/raw_sql.rs:130-132、sqlx-core/src/executor.rs:227-228),驱动据此走 Simple Query。

这条差异让每个 Decode 实现必须处理两种格式——第 5 章 §5.5.1 的 bool Decode 就是例子,要 match 两种 format 分别解析。对实现者来说多一倍工作量,但对用户透明。

7.7 MySqlRow:和 Postgres 神似

sqlx-mysql/src/row.rs:13-18:

pub struct MySqlRow {
    pub(crate) row: protocol::Row,
    pub(crate) format: MySqlValueFormat,
    pub(crate) columns: Arc<Vec<MySqlColumn>>,
    pub(crate) column_names: Arc<HashMap<UStr, usize>>,
}

结构和 PgRow 几乎一样——row(协议字节)+ format(Binary / Text)+ columns(Arc 共享的列信息)+ column_names(Arc 共享的哈希表)。

唯一结构差异是 columns 和 column_names 分开存——PgRow 用 Arc<PgStatementMetadata> 把它们装一起。这只是 bookkeeping 差异,语义一致。

try_get_raw(sqlx-mysql/src/row.rs:27-41)几乎逐行对应 PgRow 的实现——拿 index、拿 column、拿 value 字节、打包成 MySqlValueRef。

MySQL 的 format 同样有两种:

  • Binary:prepared statement 的 COM_STMT_EXECUTE 结果。
  • Text:COM_QUERY 的裸 SQL 结果。

这组二元差异是 sqlx Postgres / MySQL 驱动共同面对的复杂度——每个 Decode 实现都要 match 两种格式。SQLite 没有这个维度(§7.8)。

7.8 SqliteRow:为什么必须独立拷贝

sqlx-sqlite/src/row.rs:15-19:

pub struct SqliteRow {
    pub(crate) values: Box<[SqliteValue]>,
    pub(crate) columns: Arc<Vec<SqliteColumn>>,
    pub(crate) column_names: Arc<HashMap<UStr, usize>>,
}

values: Box<[SqliteValue]> ——独立持有每列的 value。不是借用外部 buffer。

为什么和 Postgres/MySQL 截然不同?因为 SQLite 的 sqlite3_column_* 函数返回的指针在下次 sqlite3_step 时失效。SQLite 不是流式协议——每次 step 覆盖上一行的列数据,想在两行之间同时持有两行数据必须拷贝。

SqliteRow::current(sqlx-sqlite/src/row.rs:31-52)的构造:

pub(crate) fn current(
    statement: &StatementHandle,
    columns: &Arc<Vec<SqliteColumn>>,
    column_names: &Arc<HashMap<UStr, usize>>,
) -> Self {
    let size = statement.column_count();
    let mut values = Vec::with_capacity(size);

    for i in 0..size {
        values.push(unsafe {
            let raw = statement.column_value(i);
            SqliteValue::new(raw, columns[i].type_info.clone())
        });
    }

    Self {
        values: values.into_boxed_slice(),
        columns: Arc::clone(columns),
        column_names: Arc::clone(column_names),
    }
}

每个列都 SqliteValue::new——这个 unsafe fn 内部调 SQLite 自己的 sqlite3_value_dup(sqlx-sqlite/src/value.rs:200-205),让 SQLite 复制出一个独立的 sqlite3_value 对象,再包进 Arc<ValueHandle>(free_on_drop: true,drop 时 sqlite3_value_free)。每次 step 产出一行都 dup 一次所有列。

代价:SQLite 的 fetch 比 Postgres / MySQL 多一次 memcpy。批量 fetch 大量 blob 时这个代价可观。

收益:SqliteRow 可以独立生存——你可以 fetch_all 收集 1000 行,它们之间互不影响。如果 sqlx 选择不拷贝(让 SqliteRow 借用 statement),用户就不能收集——每拿一行就失效。这个设计是保用户 API 一致性的权衡。

7.8.1 手动 unsafe impl Send + Sync

sqlx-sqlite/src/row.rs:27-28:

unsafe impl Send for SqliteRow {}
unsafe impl Sync for SqliteRow {}

这两行是 sqlx-sqlite 里少数的手动 unsafe。正上方有一段解释注释(sqlx-sqlite/src/row.rs:21-25):

Accessing values from the statement object is
safe across threads as long as we don't call [sqlite3_step]

we block ourselves from doing that by only exposing
a set interface on [StatementHandle]

注释说的是"从 statement 对象上读值"——这对应的是早年的 SqliteRow:那时它直接持有 statement: StatementHandle,靠"不在别的线程上 sqlite3_step"这条不变量担保线程安全。0.5.10 的 #1551(commit 63ca2ccc,2021-12-29)把 SqliteRow 改成现在的 values: Box<[SqliteValue]>,不再引用 statement,但这两行 unsafe impl 和注释原样留了下来。

按 0.8.6 的实际结构,这两行已是冗余:裸指针 NonNull<sqlite3_value> 包在 ValueHandle 里,线程安全的担保写在那一层(sqlx-sqlite/src/value.rs:110-112,注释是 "SAFE: only protected value objects are stored in SqliteValue");SqliteValue 是 Arc<ValueHandle>,SqliteColumn 由 UStr / usize / SqliteTypeInfo 组成,SqliteRow 的三个字段本身都是 Send + Sync,去掉这两行编译器也会自动推出来。读这段代码时要分清:真正的 unsafe 承诺在 ValueHandle,不在 SqliteRow。

7.9 生命周期骨架:Row 的 'static vs ValueRef 的 'r

本章开头提到 Row: 'static——但 try_get_raw 返回的 ValueRef<'_> 借用 row 的内部。这两条怎么协同?

关键是 ValueRef<'_> 里的 _ 是 &'r self 的生命周期——不是 'static。翻看 try_get_raw 的签名:

fn try_get_raw<I>(&self, index: I) -> Result<<Self::Database as Database>::ValueRef<'_>, Error>
where I: ColumnIndex<Self>;

ValueRef<'_> 的 '_ 因为 Rust 生命周期省略规则,等价于 ValueRef<'r> where 'r = lifetime of &self——borrower 的生命周期绑到 &self 上。也就是说:

  • Row 自身是 'static 生命的值(可以放进 channel、Vec、tokio::spawn)。
  • 从 Row 借出来的 ValueRef 只能在 &row 借用的作用域内活。
  • decode 出 String / i32 等 owned 类型后,ValueRef 可以丢弃,Row 仍然活着。
  • decode 出 &'r str 等借用类型,那个 &str 的生命周期 tie 到 row——row drop 就失效。
graph LR
    R[Row: 'static<br/>独立生存]
    VR["ValueRef&lt;'r&gt;<br/>借 row 活"]
    OWN["i32 / String<br/>owned<br/>独立"]
    BOR["&'r str<br/>借 row 活"]
    DROP[row drop]
    R -->|try_get_raw| VR
    VR -->|decode Int/String| OWN
    VR -->|decode &str| BOR
    R -.drop 后.-> DROP
    DROP -.-> BOR
    OWN -->|不影响| DROP

这条生命周期架构让 Row 用起来既灵活(能跨 await)、又零拷贝(按需借用)。用户可以选:

// 独立生存的 String——能跨 await 返回
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
}

// 借用 row 的 &str——不能跨越 row 的作用域
let row = query(...).fetch_one(&pool).await?;
let name: &str = row.try_get(0)?;
log::info!("{name}");  // OK
drop(row);
// log::info!("{name}");  // 放开这行,上一行 drop(row) 就报 E0505:cannot move out of `row` because it is borrowed

两种都合法、都有代价——独立 String 多一次 memcpy、&str 受 row 生命周期约束。用户按场景选。

7.10 三家对照表

把三家 Row 的内部结构并排:

字段 / 行为 PgRow MySqlRow SqliteRow
核心数据 data: DataRow(Bytes 共享) row: protocol::Row(Bytes 共享) values: Box<[SqliteValue]>(独立拷贝)
列元数据 metadata: Arc<PgStatementMetadata> columns: Arc<Vec<MySqlColumn>> columns: Arc<Vec<SqliteColumn>>
列名哈希 metadata.column_names 内 column_names: Arc<HashMap> column_names: Arc<HashMap>
Binary/Text 格式 format: PgValueFormat format: MySqlValueFormat 无(C API 直接按类型访问)
to_owned 代价 O(1) Bytes clone O(1) Bytes clone O(1) Arc<ValueHandle> clone(真正的拷贝已在 SqliteRow::current 里由 sqlite3_value_dup 付掉)
fetch 时开销 几乎零 几乎零 每行每列一次拷贝
unsafe impl Send/Sync 不需要(结构自动 Send/Sync) 不需要 源码里写了,但属历史遗留(NonNull<sqlite3_value> 的担保在 ValueHandle 上)
代码量 ~75 行 ~51 行 ~89 行

几条读表观察:

  1. PgRow / MySqlRow 几乎同构——持有协议返回的共享字节(Bytes)。差异只在字段命名和 MySQL 多一个独立 column_names。
  2. SqliteRow 形态本质不同——独立持有 value,构造时拷贝。这是 SQLite C API 的约束倒逼的设计。
  3. unsafe 只出现在 SQLite——它是三家里唯一经由 C API 裸指针持有值的驱动;不过真正必要的 unsafe 担保在 ValueHandle 上,SqliteRow 上那两行是历史遗留(§7.8.1)。
  4. 代码量 PgRow 反而不是最多——SqliteRow 更长主要是 current 方法要手动拷贝构造,外加 unsafe impl 的注释和一段注释掉的 AnyRow 转换代码。

7.11 column_names 的哈希表:O(1) 名字查找

三家都用同一个数据结构表达"列名 → 下标" 查询:HashMap<UStr, usize>(或等价)。

UStr(sqlx-core/src/ext/ustr.rs:11-14)是 sqlx 内部的可哈希共享字符串——一个两变体枚举:Static(&'static str) 装内置的常量名(零分配),Shared(Arc<str>) 装运行时拿到的列名。源码注释写着 "U meaning micro ... this guarantees these are cheap to clone everywhere"。相比 String:

  • 字符串内容通过 Arc 共享(或者干脆是 &'static str)——多个 Row 引用同一列名不重复拷贝。
  • 可以作为 HashMap 的 key(实现 Hash + Eq)。
  • Clone 是 O(1)(增加 refcount)。

HashMap 的构造发生在拿到列描述的那一刻——Postgres 在 prepare 收到 RowDescription 时由 handle_row_description 建(sqlx-postgres/src/connection/describe.rs:100-137),SQLite 在 prepare 语句时建(sqlx-sqlite/src/statement/virtual.rs:95-109),MySQL 在收到结果集列定义时建(sqlx-mysql/src/connection/executor.rs:403-425)——一条 SQL 的 column_names 建好后,后续所有 row 共享同一份 Arc。fetch_all 出 1000 行时,这 1000 行的 column_names 都指向同一个 HashMap 实例——只增加 Arc 的 refcount,不重新构建。

O(1) lookup 对高频按名访问至关重要。考虑 #[derive(FromRow)] 的展开(第 8 章):

// #[derive(FromRow)] struct User { id: i32, name: String } 展开大致为:
User {
    id: row.try_get("id")?,
    name: row.try_get("name")?,
}

每次 try_get("id") 和 try_get("name") 都做一次 HashMap 查询——如果是 O(n) 线性扫描列名列表,对 30 列宽表做一次 FromRow 解码是 O(n²)。HashMap 把这个降到 O(n)。实际生产里这条优化在长列表场景下不可或缺。

7.11.1 UStr 的内部与替代选择

为什么不直接用 String 做 HashMap 的 key?UStr 解决的是三件事:

1. 同一个列名在多处共享。多行之间共享靠的是外层的 Arc<HashMap>,和 key 的类型无关;UStr 省的是另一处:建表时同一个名字既要放进 PgColumn::name,又要作 column_names 的 key,源码写的是 name: name.clone() 和 column_names.insert(name, index)(sqlx-postgres/src/connection/describe.rs:119-134)——Shared(Arc<str>) 的 clone 只增 refcount,不再分配一份字符串。

2. 字符串内容不可变。HashMap 的 key 按 Rust 语义不能 mut borrow,但 String 的 capacity 可变——Arc<str> 是不可变字符串的标准表达,语义更贴合 key 场景。

3. 实现 Hash + Eq + Borrow<str>。UStr 手写的 Hash 只哈希字符串内容、不含枚举判别值(sqlx-core/src/ext/ustr.rs:42-49),再加上 Borrow<str>,hashmap.get("foo") 就能直接用 &str 查询、不需要分配——这一点和 HashMap<String, V> 一样好用。

替代方案有:

  • Cow<'static, str>——适合"混合字面量和运行时字符串"的场景,但共享语义不如 Arc 明确。
  • smartstring::SmartString——短字符串内联优化(≤23 字节不堆分配)。适合列名大多很短的场景,但 sqlx 没引入这个依赖。
  • compact_str::CompactString——类似 smartstring。

sqlx 选了最简单的 Static | Shared(Arc<str>) 两变体——没有 inline 小字符串优化,但代码量最小、行为最清晰,而且驱动内部大量出现的固定名字走 Static 分支时连 Arc 都省了。

7.12 PgRow 的 Debug:为什么 Debug 输出值而不是字节

sqlx-postgres/src/row.rs:54-75 有一个有意思的 Debug 实现:

impl Debug for PgRow {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "PgRow ")?;
        let mut debug_map = f.debug_map();
        for (index, column) in self.columns().iter().enumerate() {
            match self.try_get_raw(index) {
                Ok(value) => {
                    debug_map.entry(
                        &column.name,
                        &Postgres::fmt_value_debug(&<PgValueRef as ValueRef>::to_owned(&value)),
                    );
                }
                Err(error) => {
                    debug_map.entry(&column.name, &format!("decode error: {error:?}"));
                }
            }
        }
        debug_map.finish()
    }
}

println!("{row:?}") 输出的形如 PgRow {id: 42, name: "Alice", email: "alice@example.com"}——实际解码后的值而不是原始字节。

键不带引号,是因为列名是 UStr,它的 Debug 用 f.pad 输出(sqlx-core/src/ext/ustr.rs:84-89);值则按解码出的 Rust 类型的 Debug 打印。

这个 Debug 实现的代价是"每次 format 都做一次 decode"——大行(比如一列 10MB 的 JSONB)的 Debug 会重新分配并解码。实际生产里几乎只在 tracing::debug!("{row:?}") 打印行时会触发——但那条日志本身已经在诊断场景了,多付点 Debug 开销可以接受。

更精妙的是 Postgres::fmt_value_debug——它来自 TypeChecking trait(第 5 章 §5.13),在 impl_type_checking! 的类型清单里找第一个 compatible 接受该列类型的 Rust 类型,解码成它再用它的 Debug 打印(sqlx-core/src/type_checking.rs:64-88、179-191)。比如 bytea 列解码成 Vec<u8>、按字节数组打印;timestamptz 列按开启的 feature 解码成 chrono 或 time 的时间类型再打印;对应 feature 没开,就打印 (unknown SQL type ...: SQLx feature ... not enabled);NULL 打印成 NULL——都比原始字节友好得多。

MySQL 和 SQLite 的 Row 没有类似的 Debug 实现——MySqlRow 只有 #[derive(Debug)] 默认的字段级打印,不递归 decode;SqliteRow 在 0.8.6 里干脆没实现 Debug(println!("{row:?}") 实测报 E0277:SqliteRow doesn't implement std::fmt::Debug)。这是 Postgres Row 相对"友好"的一处——也是它稍复杂的代价。

7.13 阶段小结

前面几节把 Row 和 Column 从 trait 定义到三家具体实现完整打开:

  1. Row trait 的最小原语(§7.2)——columns + try_get_raw 是必实现的两个方法,其它 8 个方法都基于它们派生。少数原语 + 默认方法是 Executor trait 和 Row trait 共同的设计手法。
  2. try_get 的三层路径(§7.3)——raw → compatible → decode,每一层有明确的失败分支。值为 NULL 时跳过兼容检查,让 Option<T> 能接住 NULL;Postgres 对 UNKNOWN 的宽容则写在各类型的 compatible 列表里。
  3. try_get_unchecked 跳过 compatible(§7.3.2)——给 query! 宏用,因为编译期已验证过。手写代码优先用 checked 版本。
  4. ColumnIndex 统一索引(§7.4)—— usize 和 &str 实现同一 trait,让 try_get(0) 和 try_get("name") 同一签名。blanket impl 让 &I 也合法。
  5. Column trait 只有三方法(§7.5)——ordinal / name / type_info。每家驱动有扩展字段(PgColumn 的 relation_id / relation_attribute_no 用于 pg_attribute 查询)。
  6. PgRow 持有 DataRow 的 Bytes(§7.6)——data.storage 是引用计数的 Bytes,to_owned 出的多个 PgValue 可以共享同一份字节。
  7. PgValueFormat Binary/Text 分发(§7.6.2)——整行统一 format,由协议模式决定。Extended Query → Binary,Simple Query → Text。
  8. MySqlRow 结构近似(§7.7)——字段组织略有不同但语义一致。
  9. SqliteRow 独立拷贝(§7.8)——C API 约束导致每行构造时拷贝所有 value,换来跨步存活能力。
  10. unsafe impl Send + Sync(§7.8.1)——SqliteRow 上的两行是早年持有 statement 时的遗留,真正的线程安全担保在 ValueHandle 上。
  11. Row 'static / ValueRef 'r(§7.9)——Row 独立生存可跨 await,ValueRef 借用 Row。decode 出 owned 类型跳出借用链,decode 出 &str 被 row 生命周期约束。
  12. column_names O(1) lookup(§7.11)——HashMap<UStr, usize> 通过 Arc 共享,prepare 时一次构建,每次 fetch_all 零重复。对 FromRow 解码性能至关重要。
  13. PgRow 的 Debug 实现解码值(§7.12)——{row:?} 输出可读字符串而不是原字节,靠 TypeChecking::fmt_value_debug 做类型感知格式化。

7.14 三家 fetch 的性能差异

把三家的单行 fetch 路径开销放一起对比:

阶段 PgRow MySqlRow SqliteRow
读网络 / C API async read from TCP → Bytes async read from TCP → Bytes sqlite3_step (同步 C call)
协议解析 / 构造 values 计算 Vec<Option<Range<u32>>> 类似 循环 sqlite3_column_value + SqliteValue::new
构造 Row 三个字段(data / format / metadata) 四个字段(row / format / columns / column_names) 三个字段(values / columns / column_names)
try_get_raw(i) ValueRef 构造:O(1) index + borrow slice 类似 ValueRef 构造:O(1) from pre-built SqliteValue
try_get_raw("name") HashMap O(1) + 以上 同 同
decode i32 Binary:大端 read_int 后 try_into Binary:小端 read_int 后 try_into sqlite3_value_int64 后 try_into
decode String memcpy 字节 → String memcpy 字节 → String 取 &str 后 memcpy 成 String

实际性能差异几何? 本专栏不给没有基准测试支撑的数字,但从源码结构能读出几条结构性判断:

  • PgRow / MySqlRow 的 try_get 只做 HashMap lookup + 字节切片 + decode——全程不涉及系统调用,也不额外分配(除非 decode 出的类型自己要分配)。
  • SqliteRow 的 try_get 更短——值在构造 row 时已经 dup 好,取值只是索引一个 Box<[SqliteValue]>。
  • 真正的量级差异不在这一层——Postgres / MySQL 每次 fetch 要等一次网络往返,SQLite 是同一个进程里的一次 C 函数调用。这两者不在一个数量级上,本地解码的差别相形之下可以忽略。

SQLite 的真正优势不是解码快,是完全没有网络往返,也没有协议序列化/反序列化。

这也回答了一个常见问题"为什么 sqlx 的 Postgres fetch 比 SQLite 慢"——瓶颈在往返而不是 sqlx。想验证的话就自己在目标环境里跑基准(sqlx-bench/ 目录下有一套 criterion 骨架,不过它在 0.8.6 已被移出 workspace,只有 pg_pool 与 sqlite_fetch_all 两组),不要照搬任何文章里的绝对数字。

7.15 实战:读一行宽表的完整路径

把本章所有内容放进一次真实查询里。假设你有一张 users 表 7 列,查询:

let row = sqlx::query(
    "SELECT id, email, created_at, is_active, settings, last_login, avatar_url
     FROM users WHERE id = $1"
).bind(42).fetch_one(&pool).await?;

let user = User {
    id: row.try_get("id")?,
    email: row.try_get::<String, _>("email")?,
    created_at: row.try_get::<OffsetDateTime, _>("created_at")?,
    is_active: row.try_get::<bool, _>("is_active")?,
    settings: row.try_get::<Json<Settings>, _>("settings")?,
    last_login: row.try_get::<Option<OffsetDateTime>, _>("last_login")?,
    avatar_url: row.try_get::<Option<String>, _>("avatar_url")?,
};

这段代码在 Postgres 下完整路径(一次 fetch + 七次 try_get):

fetch 阶段:

  1. 服务端返回 DataRow 消息,约 300 字节(假设 email 50 字节、json settings 100 字节、其他 150 字节)。
  2. sqlx 的 Postgres 驱动从读缓冲里 split_to(..).freeze() 切出这条消息(sqlx-postgres/src/connection/stream.rs:111),得到的字节缓冲是引用计数共享的,不拷贝。
  3. 按列解析出 values: Vec<Option<Range<u32>>>——7 个 Option<Range<u32>>,每个 12 字节(64 位平台实测),共 84 字节,外加 Vec 自身 24 字节的头。
  4. 构造 PgRow { data: DataRow, format: Binary, metadata: Arc<...> }——三个字段:DataRow 本身(Bytes + Vec)、一个单字节的 format 枚举、一个 Arc 指针。

try_get 阶段(按 "id" 为例):

  1. "id".index(&row) → HashMap lookup → Ok(0)。
  2. try_get_raw(0) → PgValueRef { row: Some(&storage), value: Some(&storage[6..10]), ... }(storage 开头是 2 字节列数和第一列 4 字节长度,第一列的值从偏移 6 开始)。
  3. <i32 as Type<Postgres>>::compatible(&INT4) → true。
  4. <i32 as Decode<Postgres>>::decode(value) → 先调 int_decode(sqlx-postgres/src/types/int.rs:10-30)按大端读出整数,再转换成 i32(同文件 129-131 行)→ 42。

这条路径全程没有堆分配——HashMap 查一次、切一段 &[u8]、大端读 4 字节。

整行七次 try_get 的代价结构(按分配行为分类,而不是编造纳秒数):

  • 4 次基本类型(id / is_active / created_at / last_login)——纯栈值,零分配。
  • 2 次 String(email / avatar_url)——各一次堆分配 + 一次 memcpy。
  • 1 次 Json<Settings>——一次 serde_json::from_slice,是这一行里最贵的一步(要走完整的 JSON 解析和 Settings 的反序列化)。

想知道自己场景下的真实数字,只能在自己的机器和数据上量。一个可操作的判断是:如果 fetch 单行的耗时明显超出这几次分配能解释的范围,问题基本不在 sqlx 的解码路径上,先去看网络、服务端计划和连接池排队。

和直接用 tokio-postgres 对比:tokio-postgres 的 Row::get 是类似路径,同样先做类型检查——T::accepts(ty) 不通过就返回 WrongType 错误(tokio-postgres-0.7.9/tokio-postgres/src/row.rs:174-180)。差别在按名查列:tokio-postgres 对列数组线性扫描,找不到还会再做一遍 ASCII 大小写不敏感匹配(tokio-postgres-0.7.9/tokio-postgres/src/row.rs:63-79);sqlx 查的是预建的 HashMap,而且大小写敏感。如果你确实量出解码是热点(比如 ETL 场景),可以用 try_get_unchecked 跳过 compatible——但先量,再改。

7.16 常见 Row 使用陷阱及调试路径

在生产里遇到 Row 相关问题时,下面这几种情况最常见:

陷阱 1:Error::ColumnNotFound("foo"),但表里明明有 foo 列

排查方向:

  1. 列名是否区分大小写?Postgres 非引号列名被 lowercase 化,SELECT FOO 返回列名是 foo;SELECT "Foo" 返回 Foo。MySQL/SQLite 略有差异。
  2. JOIN 下是否有同名列?三家驱动建 column_names 时都是逐列 insert,同名的后一列会覆盖前一列(SELECT 1 AS a, 2 AS a 在 SQLite 上实测 get("a") 得 2)——按名字只能取到最后一个,需要用别名区分或按位置取。
  3. 是否用了别名?SELECT foo AS bar——那就得 try_get("bar")。

debug 技巧:先 row.columns().iter().map(|c| c.name()).collect::<Vec<_>>() 看实际列名。

陷阱 2:ColumnDecode { mismatched },列类型和 Rust 类型看起来一致

排查方向:

  1. Postgres 的 NUMERIC(10,2) 对应 Rust 的 BigDecimal(需要 bigdecimal feature)或 Decimal(需要 rust_decimal feature)——不是 f64。
  2. Postgres 的 TIMESTAMP 和 TIMESTAMPTZ 对应不同 Rust 类型——PrimitiveDateTime vs OffsetDateTime。
  3. MySQL 的无符号列:INT UNSIGNED 这类列和 i32 等有符号类型不兼容(int_compatible 排除了 UNSIGNED 标志,sqlx-mysql/src/types/int.rs:10-19),要用 u32 等无符号类型。反过来,TINYINT(1) 可以直接读成 bool——bool 的 compatible 接受 TINYINT 等整数列(sqlx-mysql/src/types/bool.rs:20-30)。

debug 技巧:row.column(i).type_info().name() 看服务端声明的类型名。

陷阱 3:try_get("x") 返回 Option::None,但业务希望获取默认值

排查方向:

  1. 期望 "列不存在时用默认值"?—— try_get 不能做这个;FromRow 的 #[sqlx(default)] attribute 可以(第 8 章)。
  2. 期望 "列为 NULL 时返回默认值"?——unwrap_or_default() 或 unwrap_or(fallback) 自己处理。
  3. 期望 "列为空字符串时用默认值"?——SQL 层面先 COALESCE。

陷阱 4:fetch 返回的行数和期望不一致

和 Row 本身无关,但经常和 Row 一起误诊。检查:

  1. fetch_one 只取第一行:没有行时返回 Error::RowNotFound,多出的行不报错、直接丢弃(SQLite 实测 SELECT 1 UNION ALL SELECT 2 返回 1)——要容忍没有行用 fetch_optional,要全部行用 fetch_all,要确保恰好一行得自己检查。
  2. LIMIT 1 服务端限制 vs .take(1) 客户端——前者少传数据,后者可能多拉。
  3. 分页——OFFSET 对大表慢,改用 keyset pagination。

这些陷阱多数都不是 sqlx 本身的问题——是 SQL / 协议 / 类型系统在"边界条件"上的表达差异。Row 作为"DB 到 Rust 的交付面",所有这些差异都最终落到 try_get 的返回上。学会读 Error 和用 columns() 探查实际情况,是成为 sqlx 熟手的必经之路。

7.17 relation_id 与编译期 nullability 推断

PgColumn::relation_id 和 relation_attribute_no 是 sqlx 的编译期 nullability 推断(第 11 章的主题之一)的数据源——这两个字段值得单独讲一下。

Postgres 的 Describe 消息不直接告诉你"这列是否可空"——Describe::Statement 只返回 ParameterDescription 和 RowDescription(或 NoData),后者里每列有 table_oid 和 attribute_number 但不含 NOT NULL 约束。如果想知道可空性,要另查 pg_catalog.pg_attribute。sqlx 在 sqlx-postgres/src/connection/describe.rs:449-484 用 QueryBuilder 拼出一条一次问完所有列的查询——把每列的 (idx, relation_id, relation_attribute_no) 用 UNION ALL 串成一张临时表,再 LEFT JOIN 到 pg_catalog.pg_attribute 上:

SELECT NOT attnotnull FROM (
  ( SELECT $1::int4 AS idx, $2::int4 AS table_id, $3::int2 AS col_idx )
  UNION ALL ( SELECT $4::int4, $5::int4, $6::int2 )
  ...
) AS col LEFT JOIN pg_catalog.pg_attribute
    ON table_id IS NOT NULL
   AND attrelid = table_id
   AND attnum = col_idx
ORDER BY idx

(源码注释还解释了为什么用 UNION ALL 而不是 VALUES:兼容那些实现了 pgwire 协议但不支持 VALUES 的数据库。)拿到这批 notnull 标志之后:

  • 列 notnull——Rust 类型是 i32 / String 等。
  • 列本身 nullable,或者 relation_id 为 None(表达式列)——Rust 类型是 Option<T>。
  • 列 notnull 但处在 LEFT JOIN 的可空一侧——这一层靠另一条路径修正:nullables_from_explain(sqlx-postgres/src/connection/describe.rs:517-563)对已 prepare 的语句跑一次 EXPLAIN (VERBOSE, FORMAT JSON) EXECUTE <stmt>(参数全填 NULL),再用 visit_plan 递归遍历计划树——凡是 join_type 为 Full 的节点,或者 parent_relation 为 Inner(即处在外连接的内侧)的节点,其 output 列一律标成可空。源码注释自己承认 "this may produce false positives but those don't cause runtime errors"——多标一个 Option 只是啰嗦,漏标才会在运行时炸。

这条自动 nullability 推断是 query! 宏的关键便利——你写 SELECT u.id, u.email, p.title FROM users u LEFT JOIN posts p ON ...,宏会正确判断 title: Option<String>(因为 LEFT JOIN 右侧行可能缺失),即便 posts.title 本身 NOT NULL。

表达式列(比如 SELECT COUNT(*)、SELECT a + b)的 relation_id 是 None——LEFT JOIN 匹配不上任何 pg_attribute 行,结果就是 NULL,sqlx 只能保守地标记为 nullable(即 Option<T>)。用户可以用 SELECT COUNT(*) AS "count!: i64" 这种 override 语法强制非空。

MySQL 走的是另一条更简单的路——直接读结果集列定义里的 ColumnFlags::NOT_NULL 位(sqlx-mysql/src/protocol/text/column.rs:18,用在 sqlx-mysql/src/connection/executor.rs:353),没有 Postgres 那种查系统目录 + 读执行计划的两段式推断。SQLite 则另有一套基于 EXPLAIN 字节码的分析(第 11 章)。三家的推断精度并不对等——这也是 sqlx 的 query! 宏在 Postgres 上体验最好的原因之一。

7.18 Row trait 相关的版本变更

Row 的接口在 0.8.x 里非常稳定。0.8.0 以来的 CHANGELOG 里,和本章内容直接相关的改动有三条(更早的 0.3.0 里 Row 还曾带过一个绑定连接的生命周期 'c,那是另一段历史):

  • 0.8.0(2024-07-22):[#2973] Generic Associated Types in Database, replacing HasValueRef, HasArguments, HasStatement —— try_get_raw 的返回类型从绕道 HasValueRef<'_, DB>::ValueRef 变成直接的 <DB as Database>::ValueRef<'_>,也就是本章 §7.9 看到的那个签名。这一步之后 Row 相关的生命周期终于能一眼读懂。
  • 0.8.0:[#2917] Add Debug impl for PgRow —— 就是 §7.12 那个逐列解码打印的 Debug,是驱动侧的实现,没有动 Row trait。
  • 0.8.4(2025-04-13):[#3672] add #[track_caller] to Row::get() —— get 是 try_get().unwrap() 的 panic 版本,加上 #[track_caller](sqlx-core/src/row.rs:67)之后,panic 的位置指向用户的调用点而不是 sqlx 内部的那行 unwrap。这是纯粹的可诊断性改进,不改变任何语义。

接口没变化本身就是信息:一个从 0.8.0 到 0.8.6 只加了一个 #[track_caller] 的 trait(git diff v0.8.0 v0.8.6 -- sqlx-core/src/row.rs 只有这一行),说明"两个必实现原语 + 一堆默认方法"这个切分确实找对了位置——新需求(更好的错误信息、更好的 Debug 输出)都能在默认方法、错误类型和驱动侧实现里消化,不需要动必实现的那两个。

7.19 三道判断题

Q1:为什么 Row::try_get 的返回类型是 T 而不是 &'r T?

A:Decode 契约决定的。Decode::decode 返回 Result<Self, BoxDynError>——Self 是具体类型,可以是 owned(String)也可以是 borrowed(&'r str)。try_get<T> 的 T 由用户声明——用户写 try_get::<String, _> 就拿 owned,写 try_get::<&str, _> 就拿 borrowed。trait 不强制方向,由 Decode impl 决定。

Q2:Row::columns 为什么返回 &[Column] 而不是 &[&Column] 或 Vec<Column>?

A:零拷贝访问。columns() 调用很频繁(按位置的 try_get 做越界检查时要走 Row::len(),也就是 columns().len()),返回 &[Column] 让调用方零分配且能用索引访问。&[&Column] 多一层引用、Vec<Column> 要 clone——都比 &[Column] 昂贵。

Q3:为什么 PgRow 的 Debug 实现要解码值、而不是打印原始字节?

A:实用性优先。tracing::debug!("{row:?}") 的典型用户是在诊断问题——原始字节对应不上 SQL 查询的直观表达("12 34 56 78" vs "12345678")。解码后用户能立刻识别数据。代价是 Debug 慢——但 Debug 本来就不追求高性能(也没人对着 Debug 做基准测试)。这是"正确优先于快"的 API 设计。

7.20 Row 设计的整体价值

跳出方法细节,看 Row trait 在 sqlx 整体架构里的角色。

Row 承担了 "ORM-free 数据映射"的全部重量。sqlx 不做 ORM——它不自动把 Row 变成 User 对象。但 sqlx 提供了一个非常稳定的中间接口(Row + try_get),让用户可以:

  • 手写映射——每字段 try_get 然后构造 struct。显式、可控、零开销。
  • 用 FromRow 派生——宏生成同样的 try_get 链,少几行代码。
  • 用 query_as! 宏——编译期生成带类型的 try_get_unchecked 链,跳过运行时检查。
  • 交给上层库——比如 SeaORM,或者自定义的 ORM。

这条设计让 sqlx 成为"生态基础设施"——SeaORM 就建在 sqlx 之上,各种业务特化的类型映射层也都可以在 Row 之上造轮子。Row 是一层不漏细节的抽象:底下的字节 / 协议 / 格式全部封装,上层只需"给我一个索引我给你一个 ValueRef"。

对比 diesel 的 Row——diesel 也有 Row / Field(diesel-2.3.9/diesel/src/row.rs:40 与 :99),field.value() 也能拿到原始字节,但这套 trait 的 super-trait 里带 private::RowSealed,外部 crate 不开 i-implement-a-third-party-backend-and-opt-into-breaking-changes feature 就无法实现;而且 Row::get_value::<ST, T, I> 要你先报出 SQL 类型 ST(diesel-2.3.9/diesel/src/row.rs:77-83),SQL 类型由调用方静态给出,而不是像 sqlx 的 ValueRef 那样自带 type_info、在运行时由 compatible 核对。换句话说 diesel 的 Row 是 FromSqlRow 的内部辅助,不是给第三方造同级库用的"公共基座"。sqlx 用相反的路线——Row 公开、未密封、简洁——换来整个生态的可组合性。

这条设计哲学值得记住:当你设计一个基础库时,问一句"我的核心抽象是不是稳定到让别人能在其上造同级库"——如果是,你就有生态;如果不是,你就是个工具。sqlx 的 Row 是前者的例子。

7.21 Row 与 tokio::spawn 的交互

Row 的 'static bound 让它能跨 async task 传递——这条能力在实战里非常好用。一个典型模式:

let rows: Vec<PgRow> = query("SELECT ...").fetch_all(&pool).await?;

let handles: Vec<_> = rows.into_iter().map(|row| {
    tokio::spawn(async move {
        let id: i32 = row.try_get("id").unwrap();
        let data = fetch_external(id).await.unwrap();
        (id, data)
    })
}).collect();

let results = futures::future::join_all(handles).await;

每个 Row 被 move 进独立的 spawn task——这要求 Row 是 Send + 'static(tokio::spawn 的约束)。PgRow / MySqlRow / SqliteRow 都满足。

这条能力让"fetch 一批行后并发处理"成为零样板代码的常见模式。如果 Row 不是 'static——比如借用了 connection 的 buffer——你就必须先 decode 再 spawn,因为 Row 里的 &conn 不能跨 task。sqlx 用 Bytes 和 Arc<PgStatementMetadata> 这类引用计数类型把外部依赖全部 owned 化,换来了这条 "fetch + 并发处理" 的便利。

代价是每个 Row 都要自持数据——但 PgRow / MySqlRow 持有的是 Bytes 和 Arc<...>,clone 只是加引用计数,行与行之间共享同一份底层字节和同一份列元数据。真正为此付出额外拷贝代价的只有 SQLite(§7.8)。

7.22 本章小结

本章把 Row 和 Column 这一对"查询结果交付面"完整打开:

  1. Row trait 最小原语——只有 columns(sqlx-0.8.6/sqlx-core/src/row.rs:54)和 try_get_raw(sqlx-0.8.6/sqlx-core/src/row.rs:175)必实现;其他 8 个方法(is_empty/len/column/try_column/get/get_unchecked/try_get/try_get_unchecked,依次在 sqlx-0.8.6/sqlx-core/src/row.rs 的 19/25/38/46/68/88/111/152 行)都是默认实现。少数必实现原语 + 默认方法派生 是 Row 与 Executor 共同的风格。
  2. try_get 的三层路径(sqlx-0.8.6/sqlx-core/src/row.rs:111-132)——raw → compatible → decode。三层的错误不是同一个变体:第一层 try_get_raw 失败给的是 Error::ColumnNotFound 或 Error::ColumnIndexOutOfBounds,后两层才是 Error::ColumnDecode。跳过兼容检查的条件写在源码里是 if !value.is_null() { if !ty.is_null() && !T::compatible(&ty) { .. } }——值为 NULL 时跳过;至于 ty.is_null(),MySQL(ColumnType::Null)和 SQLite(DataType::Null)会命中,而 Postgres 的 TypeInfo::is_null 直接 false(sqlx-0.8.6/sqlx-postgres/src/type_info.rs:1045-1047),它对 UNKNOWN 的宽容是写在各类型的 compatible 列表里的(§5.3.1),不是走这条短路。
  3. try_get_unchecked 跳过 compatible(sqlx-0.8.6/sqlx-core/src/row.rs:152-162,函数体只有 try_get_raw + T::decode 两句)——供 query! 宏生成代码使用,手写代码优先用 checked。
  4. ColumnIndex 统一索引—— usize 和 &str 共用同一方法签名,&I 的 blanket 让引用也合法。
  5. Column 的三字段极简——ordinal / name / type_info。PgColumn 额外有 relation_id / relation_attribute_no,用于 query! 的 nullability 推断。
  6. PgRow / MySqlRow 持有 Bytes——零拷贝访问 DataRow / protocol::Row 的底层字节。
  7. SqliteRow 独立拷贝——C API 约束下每行构造时拷贝所有 value;SqliteRow 上的 unsafe impl Send/Sync 是历史遗留。
  8. Row 'static + ValueRef 'r—— Row 能跨 await 传递(甚至 tokio::spawn),ValueRef 借用 row。
  9. column_names O(1) lookup + Arc 共享——用 UStr 做 key、多行共享同一 HashMap。
  10. 生态基座——Row 是 sqlx 最稳定的公共接口,SeaORM 等二层库建在它之上。

下一章(第 8 章)我们看 FromRow 派生宏——它怎么把 Row 一次性解码成 Rust 结构体、#[sqlx(rename)] / #[sqlx(flatten)] / #[sqlx(default)] 这些 attribute 怎么展开、以及为什么 sqlx 的 FromRow 和 serde 的 Deserialize 在设计上是平行的但实现完全不同。从本章的基石往上走一层:Row 是"按索引取值"的底层接口,FromRow 是"按结构整体映射"的上层接口,两者协作完成 SQL 到 Rust 的结构化转换。