SQLx 源码深度解析

第8章 FromRow:派生宏、rename、flatten 与 default

作者 杨艺韬 · 12,343 字 · 发布于 · 更新于

FromRow 只有一个方法。#[derive(FromRow)] 展开后就是一串按字段排开的 try_get——七种 attribute 的全部复杂度,都落在这串代码怎么生成上。

本章要点

  • FromRow<'r, R: Row> trait(sqlx-core/src/from_row.rs:309)只有一个方法 from_row(row: &'r R) -> Result<Self, Error>——把 Row 一次性解码成 struct 的契约。
  • #[derive(FromRow)] 展开后是一串 let field: Ty = row.try_get(col_name)?;——每个字段一条 try_get,按顺序填进 struct 字面量构造。展开代码在 sqlx-macros-core/src/derives/row.rs:39-243。
  • 7 种 field/container attribute 组合:rename / rename_all / default(容器/字段两级)/ flatten / try_from / json / skip——每种对应一种生成策略。最复杂的交叉(flatten + try_from + json 三选二)在源码里用 match 枚举严格区分。
  • rename_all 支持 7 种 case 风格(snake_case / camelCase 等),用 heck crate 做转换。rename 字段级覆盖优先于 rename_all。
  • default 容器级给结构整体保底,字段级给单字段保底——两者都靠 .or_else 捕获 ColumnNotFound 错误后回退到默认值。
  • flatten 把一个子 struct 的所有字段展开到父 struct 级——内部直接委托给子类型的 FromRow::from_row,不是 try_get。
  • try_from 让字段类型和数据库类型中间有一层转换——try_get::<SrcType> + TryFrom::try_from 组合成一步。
  • json / json(nullable) 两种形态——前者要求列在 SQL 层非 NULL,后者多包一层 Option 接住 SQL NULL。映射到不同的 Json<T> / Option<Json<T>> 解码路径。
  • tuple 的 FromRow blanket impl(sqlx-core/src/from_row.rs:326-524)通过宏展开 1-16 元组——用 usize 位置索引取值,不走列名。这是 query_as("SELECT a, b FROM t").fetch_one::<(i32, String)> 能工作的基础。

8.1 问题引入:最后一公里

到这一章为止我们已经讲完了从 SQL 到 Row 的完整路径:

  • SQL 字符串 → 经 Executor 发送 → Postgres 返回 DataRow → PgRow 构造。
  • PgRow 通过 try_get::<T>(index) 暴露每一列的 Decoded 值。

但用户业务代码里几乎没人直接写 let id = row.try_get(0)?; let name = row.try_get(1)?; User { id, name }——太啰嗦。典型业务代码是:

#[derive(sqlx::FromRow)]
struct User {
    id: i32,
    name: String,
    email: Option<String>,
    created: OffsetDateTime,
}

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

query_as::<_, User> + #[derive(FromRow)] 两个配合让一条 SQL 直接变成 User 对象。这条"最后一公里"——从 Row 到 struct——就是本章的主题。

本章的核心是 FromRow trait 的一方法契约和 #[derive(FromRow)] 宏的七种 attribute 展开。表面上 FromRow 只是"一个方法",底下的派生宏却是 sqlx 最精巧的一段代码——要处理字段命名、默认值、嵌套展开、类型转换、JSON 支持、字段跳过六个维度的组合。

8.2 FromRow trait:一方法契约

sqlx-core/src/from_row.rs:309-311:

pub trait FromRow<'r, R: Row>: Sized {
    fn from_row(row: &'r R) -> Result<Self, Error>;
}

一个方法——输入 &'r R,输出 Result<Self, Error>。'r 是 row 的借用生命周期,让 Self 可以(选择性地)持有借自 row 的引用类型(如 &'r str)。

超 trait bound Sized 保证 from_row 的返回可以放进 Result 里。两个 trait 参数 'r 和 R: Row——R 是具体的 Row 类型(PgRow / MySqlRow / SqliteRow),让 FromRow 能跨 DB 工作。

一个空 tuple 的 FromRow 实现(sqlx-core/src/from_row.rs:313-321):

impl<'r, R> FromRow<'r, R> for ()
where R: Row,
{
    #[inline]
    fn from_row(_: &'r R) -> Result<Self, Error> {
        Ok(())
    }
}

空 struct () 永远能从任意 row 构造成功——query_as::<_, ()> 在"只要执行、不要结果"场景偶尔有用(虽然通常用 execute 更合适)。

8.3 手写 FromRow:基准参照

虽然绝大多数用户都用 #[derive(FromRow)],理解它生成什么需要先看手写版本作参照。同一个 User struct,手写版:

impl<'r, R: Row> FromRow<'r, R> for User
where
    i32: Decode<'r, R::Database> + Type<R::Database>,
    String: Decode<'r, R::Database> + Type<R::Database>,
    Option<String>: Decode<'r, R::Database> + Type<R::Database>,
    OffsetDateTime: Decode<'r, R::Database> + Type<R::Database>,
    for<'a> &'a str: ColumnIndex<R>,
{
    fn from_row(row: &'r R) -> Result<Self, Error> {
        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("created")?;
        Ok(User { id, name, email, created })
    }
}

关键结构:

  1. 大量 where 子句——每个字段类型都要 Decode + Type,且 &str: ColumnIndex<R>(让 try_get("name") 合法)。
  2. 每个字段一条 try_get——按列名访问、? 向上传错误。
  3. 最后 struct 字面量构造——按字段名放进去。

派生宏的工作就是生成这段代码,外加根据 attribute 做变体。读懂手写版,就有了对照宏展开的锚点。

8.4 #[derive(FromRow)] 的基础展开

sqlx-macros-core/src/derives/row.rs:13-37 的 expand_derive_from_row 入口按结构类型分派:

pub fn expand_derive_from_row(input: &DeriveInput) -> syn::Result<TokenStream> {
    match &input.data {
        // struct User { ... }  ——命名字段
        Data::Struct(DataStruct { fields: Fields::Named(FieldsNamed { named, .. }), .. }) =>
            expand_derive_from_row_struct(input, named),

        // struct Pair(i32, String) —— 位置字段(tuple struct)
        Data::Struct(DataStruct { fields: Fields::Unnamed(FieldsUnnamed { unnamed, .. }), .. }) =>
            expand_derive_from_row_struct_unnamed(input, unnamed),

        // struct Unit;  —— 不支持
        Data::Struct(DataStruct { fields: Fields::Unit, .. }) =>
            Err(syn::Error::new_spanned(input, "unit structs are not supported")),

        Data::Enum(_) => Err(syn::Error::new_spanned(input, "enums are not supported")),
        Data::Union(_) => Err(syn::Error::new_spanned(input, "unions are not supported")),
    }
}
  • 命名字段 struct —— 主分支,按列名访问。
  • tuple struct —— 按位置 index 访问(0, 1, 2, ...)。
  • unit struct / enum / union —— 不支持,编译时报错。

本章的 sqlx-macros-core/src/derives/row.rs:N 指的都是 sqlx-macros-core/src/derives/row.rs—— 即 #[derive(FromRow)] 的生成器本身(共 298 行)。sqlx 仓库里有九个 叫 row.rs 的文件,只写文件名会指向哪个都说不准,所以一律带上 derives/ 这一层,它在仓库里是唯一的。

大多数用户都在主分支下。expand_derive_from_row_struct(sqlx-macros-core/src/derives/row.rs:39-243)的工作分五步:

  1. 解析生命周期 —— 如果 struct 有 'a 就用它,没有就用默认 'a。
  2. 添加泛型参数 R: Row —— 生成的 impl 要对任意 Row 类型工作。
  3. 添加 &'a str: ColumnIndex<R> where bound —— 让 try_get(name) 合法。
  4. 遍历每个字段生成 reads 语句 —— 这是主体,下面每一节都在讲这一步的各个变体。
  5. 拼装最终 impl —— fn from_row(__row: &'a R) -> Result<Self> { ...reads; Ok(Self { ...names }) }。

8.4.1 一个完整的简单展开例

把基础派生跑一次看生成代码的具体形态。输入:

#[derive(sqlx::FromRow)]
struct User {
    id: i32,
    name: String,
    email: Option<String>,
}

sqlx-macros-core/src/derives/row.rs 的 expand_derive_from_row_struct 会生成(RUSTC_BOOTSTRAP=1 cargo rustc -- -Zunpretty=expanded 实测,仅调整了缩进):

#[automatically_derived]
impl<'a, R: ::sqlx::Row> ::sqlx::FromRow<'a, R> for User
where
    &'a ::std::primitive::str: ::sqlx::ColumnIndex<R>,
    i32: ::sqlx::decode::Decode<'a, R::Database>,
    i32: ::sqlx::types::Type<R::Database>,
    String: ::sqlx::decode::Decode<'a, R::Database>,
    String: ::sqlx::types::Type<R::Database>,
    Option<String>: ::sqlx::decode::Decode<'a, R::Database>,
    Option<String>: ::sqlx::types::Type<R::Database>,
{
    fn from_row(__row: &'a R) -> ::sqlx::Result<Self> {
        let id: i32 = __row.try_get("id")?;
        let name: String = __row.try_get("name")?;
        let email: Option<String> = __row.try_get("email")?;
        ::std::result::Result::Ok(User { id, name, email })
    }
}

每一行都是宏根据输入 struct 定义生成的。sqlx-macros-core/src/derives/row.rs:47-51 的生命周期处理(有没有用户提供的 'a)、sqlx-macros-core/src/derives/row.rs:55-60 的 R: Row 泛型插入、sqlx-macros-core/src/derives/row.rs:62-64 的 ColumnIndex predicate 添加——全都是为了生成上面这段展开。

可以用 cargo expand 验证任意 FromRow 派生的实际展开:

cargo install cargo-expand
cargo expand --package my_project 2>&1 | grep -A 30 "impl.*FromRow.*for User"

看实际生成的代码对理解宏行为极其有用——尤其当你的 struct 有复杂 attribute 组合,不确定到底展开成什么时。

8.4.2 R: Row 泛型 vs 具体 Database 类型

基础派生生成的 impl 对 任意 R: Row 有效——意味着同一个 User struct 可以用 PgRow / MySqlRow / SqliteRow 三家都 decode 成功(前提是字段类型在三家都有 Decode + Type impl)。

反过来,派生宏也没法限定到某个具体 DB——某个字段类型只在部分数据库上有实现时,派生本身照样通过:

#[derive(sqlx::FromRow)]
struct User {
    id: i32,
    created_at: OffsetDateTime,  // 只有 time feature 启用才有 Postgres 的 impl
}

这个 struct 无论启用哪些 feature 都能 derive 成功——生成的 impl 只是把 OffsetDateTime: Decode + Type 写进 where 子句。在 runtime-tokio-rustls + postgres + time 组合下拿它去 query_as 能通过(OffsetDateTime 对 Postgres 的 Decode + Type 实现启用了);如果只启用 mysql、不启用 sqlx 的 time feature(OffsetDateTime 由用户直接依赖 time crate 引入),报错出现在 query_as(...) 的调用处,而不是 derive 处。

sqlx 没有特殊处理这种跨 DB 情况——编译错误由泛型 where 子句自然报出,是一条 E0277。拿 SQLite 和一个没有任何 Decode 实现的字段类型实测,报错原文是 the trait bound `NoType: sqlx::Decode<'_, Sqlite>` is not satisfied,随后的 note 写明 required for `B` to implement `for<'r> FromRow<'r, SqliteRow>` ——看类型和数据库名就知道缺哪个 feature。这种"通过 trait bound 自然报错"比显式 #[cfg(feature = "postgres")] 标注每个字段清爽得多。

8.4.3 attribute 分派的 match 结构

sqlx-macros-core/src/derives/row.rs:100 附近有一段长 match,是整个派生宏的分派核心:

let expr: Expr = match (attributes.flatten, attributes.try_from, attributes.json) {
    // 基本分支:无 attribute
    (false, None, None) => { /* 简单 try_get */ }
    // flatten
    (true, None, None) => { /* 递归 from_row */ }
    // flatten + try_from
    (true, Some(try_from), None) => { /* from_row 然后 try_from */ }
    // flatten + json(非法)
    (true, _, Some(_)) => { panic!("Cannot use both flatten and json") }
    // try_from
    (false, Some(try_from), None) => { /* try_get<SrcType> 然后 try_from */ }
    // try_from + json(非 nullable)
    (false, Some(try_from), Some(JsonAttribute::NonNullable)) => { /* try_get<Json<_>>.0 然后 try_from */ }
    // try_from + json(nullable)(非法)
    (false, Some(_), Some(JsonAttribute::Nullable)) => { panic!("Cannot use both try from and json nullable") }
    // json(非 nullable)
    (false, None, Some(JsonAttribute::NonNullable)) => { /* try_get<Json<_>>.0 */ }
    // json(nullable)
    (false, None, Some(JsonAttribute::Nullable)) => { /* try_get<Option<Json<_>>>.map */ }
};

把这张表用 mermaid 画出来:

flowchart TD
    Start{flatten?}
    Start -->|false| NoFlat{try_from?}
    Start -->|true| FlatJson{json?}
    FlatJson -->|Some| FlatErr[error: cannot combine]
    FlatJson -->|None| Flat{try_from?}
    Flat -->|None| FL1["&lt;Ty as FromRow&gt;::from_row&#40;row&#41;"]
    Flat -->|Some| FL2[from_row then try_from]
    NoFlat -->|None| NoFlatJson{json?}
    NoFlat -->|Some| NoFlatTry{json?}
    NoFlatJson -->|None| N1["try_get&#40;name&#41;"]
    NoFlatJson -->|NonNullable| N2["try_get::&lt;Json,_&gt;&#40;name&#41;.map"]
    NoFlatJson -->|Nullable| N3["try_get::&lt;Option&lt;Json&gt;,_&gt;&#40;name&#41;.map"]
    NoFlatTry -->|None| T1["try_get&lt;SrcType&gt; then try_from"]
    NoFlatTry -->|NonNullable| T2["try_get::&lt;Json,_&gt; then try_from"]
    NoFlatTry -->|Nullable| TErr[error: cannot combine]

两条非法组合(图中的 error 节点):

  1. flatten + json ——flatten 要递归 FromRow,json 要求字段是 JSON 列,sqlx 禁止同时使用。实际拦下它的是更早一步的属性解析:sqlx-macros-core/src/derives/attributes.rs:185-190 发现同一字段既有 json 又有 flatten 就返回错误,所以 sqlx-macros-core/src/derives/row.rs:134 这条 panic! 分支实际走不到。
  2. try_from + json(nullable) ——try_from 要 JSON 解出 RawType 然后转 TargetType,nullable 又要在外面多包一层 Option。源码没有为这个组合写展开,也没有注释说明理由,只留了一个 panic!。

两者最终都表现为派生宏 panic:编译器报 error: proc-macro derive panicked,位置指向 #[derive(FromRow)] 而不是出问题的字段,具体描述在 help: message: 后面(实测 try_from + json(nullable) 是 Cannot use both try from and json nullable;flatten + json 的描述出自 sqlx-macros-core/src/derives/attributes.rs:188,被包成 called `Result::unwrap()` on an `Err` value: Error("Cannot use `json` and `flatten` together on the same field"),因为解析结果在 sqlx-macros-core/src/derives/row.rs:81 被 .unwrap() 了)。这些显式错误仍比"让编译器报意义模糊的 trait bound 错误"对用户友好。

8.5 rename 和 rename_all

最简单的两个 attribute——字段重命名。

8.5.1 #[sqlx(rename = "...")]:字段级

#[derive(sqlx::FromRow)]
struct User {
    id: i32,
    #[sqlx(rename = "description")]
    about_me: String,
}

sqlx-macros-core/src/derives/row.rs:90-98 处理这个 attribute:

let id_s = if let Some(s) = attributes.rename {
    s
} else {
    let s = id.to_string().trim_start_matches("r#").to_owned();
    match container_attributes.rename_all {
        Some(pattern) => rename_all(&s, pattern),
        None => s
    }
};

id_s("列名字符串")的决定顺序:

  1. 有字段 #[sqlx(rename = "x")] → 用 "x"。
  2. 否则取字段名(去掉 r# 原始标识符前缀)。
  3. 如果容器级有 #[sqlx(rename_all = "...")],应用 case 转换。

8.5.2 #[sqlx(rename_all = "...")]:容器级

#[derive(sqlx::FromRow)]
#[sqlx(rename_all = "camelCase")]
struct UserPost {
    id: i32,
    user_id: i32,     // → 映射到列 "userId"
    created_at: OffsetDateTime,  // → "createdAt"
}

支持的 case 转换(来自 sqlx-core/src/from_row.rs 的 rename_all 文档):

策略 示例
snake_case user_id → user_id(无变化)
lowercase UserID → userid
UPPERCASE user_id → USER_ID
camelCase user_id → userId
PascalCase user_id → UserId
SCREAMING_SNAKE_CASE user_id → USER_ID
kebab-case user_id → user-id

转换由 heck crate 完成。heck 的 word boundary 规则比较"直觉"——大写字母开始新词、下划线 / 连字符分割词、数字不 作为 boundary(Foo1 → foo1,不是 foo_1)。

字段级 rename 优先于容器级 rename_all——你可以混用:

#[derive(FromRow)]
#[sqlx(rename_all = "camelCase")]
struct Mixed {
    user_id: i32,                           // → "userId"
    #[sqlx(rename = "email_address")]       // 这个覆盖 rename_all
    email: String,                           // → "email_address"
}

8.6 default:字段级 vs 容器级

default 告诉 FromRow "如果列不在 row 里,用默认值"——一个常见场景是渐进式 schema 迁移:代码里 struct 已经加了新字段,数据库里还没加对应列。

8.6.1 字段级 #[sqlx(default)]

#[derive(sqlx::FromRow)]
struct User {
    id: i32,
    name: String,
    #[sqlx(default)]
    preferences: Option<String>,  // 列不存在就用 Option::default() == None
}

展开后(sqlx-macros-core/src/derives/row.rs:200-207):

let preferences: Option<String> = __row.try_get("preferences").or_else(|e| match e {
    ::sqlx::Error::ColumnNotFound(_) => {
        ::std::result::Result::Ok(Default::default())
    },
    e => ::std::result::Result::Err(e)
})?;

关键是只对 ColumnNotFound 错误回退默认值——其它错误(如 ColumnDecode)照常上抛。这让"列不存在用默认、列存在但解码失败报错"成为一致行为。

8.6.2 容器级 #[sqlx(default)]

#[derive(sqlx::FromRow)]
#[sqlx(default)]
struct User {
    id: i32,
    name: String,
    preferences: Option<String>,  // 列不存在就用 User::default().preferences
}

容器级要求 User: Default。展开后每个字段的 or_else 回退值都是 __default.<field>(来自预先计算的 let __default = User::default();,见 sqlx-macros-core/src/derives/row.rs:68-75):

// 宏展开后的等价代码,不是源码原文
let __default = User::default();
let id: i32 = __row.try_get("id").or_else(|e| match e {
    ::sqlx::Error::ColumnNotFound(_) => Ok(__default.id),
    e => Err(e)
})?;
let name: String = __row.try_get("name").or_else(|e| match e {
    ::sqlx::Error::ColumnNotFound(_) => Ok(__default.name),
    e => Err(e)
})?;
// ...

两者的差异:

  • 字段级:用字段类型自己的 Default::default()。适合 Option / Vec 等有自然默认值的类型。
  • 容器级:用整个 struct 的 Default::default() 的对应字段。适合"struct 有逻辑默认态"(例如 config struct)。

一个字段可以被两个级别同时覆盖——字段级优先:

#[derive(FromRow)]
#[sqlx(default)]  // 容器级
struct Mixed {
    id: i32,
    #[sqlx(default)]  // 字段级(本字段走 Default::default() 而非容器级 __default.name)
    name: String,
}

8.7 flatten:嵌套展开

最强大的 attribute。让一个子 struct 的所有字段"摊平"到父 struct 级别:

#[derive(sqlx::FromRow)]
struct Address {
    street: String,
    city: String,
}

#[derive(sqlx::FromRow)]
struct User {
    id: i32,
    name: String,
    #[sqlx(flatten)]
    address: Address,
}

对应 SQL SELECT id, name, street, city FROM users——不是 SELECT id, name, address FROM users。address 字段不对应一个列,而是"跨了 street / city 两列的组合"。

展开后(生成它的宏分支在 sqlx-macros-core/src/derives/row.rs:110-113):

// 宏展开后的等价代码,不是源码原文
let address: Address = <Address as ::sqlx::FromRow<'a, R>>::from_row(__row)?;

直接递归调用 Address 的 FromRow::from_row——把整个 row 再扔给子类型处理。子类型按自己的字段名 try_get,查 row 里对应列。

关键语义:flatten 要求子 struct 的字段名不和父 struct 其它字段冲突——因为它们共享同一个 row。如果 Address 里有 id: i32、父级也有 id: i32,两者会取到同一列。这个冲突 sqlx 不做校验(因为展开时不知道 row 的真实列)——由用户保证名字不冲突。

flatten 的典型用法是复用通用字段集:

#[derive(FromRow)]
struct Timestamps {
    created_at: OffsetDateTime,
    updated_at: OffsetDateTime,
}

#[derive(FromRow)]
struct Post {
    id: i32,
    title: String,
    #[sqlx(flatten)]
    ts: Timestamps,
}

#[derive(FromRow)]
struct Comment {
    id: i32,
    content: String,
    #[sqlx(flatten)]
    ts: Timestamps,
}

Post 和 Comment 都能复用 Timestamps 的两个字段——每次 SELECT 保证带上 created_at 和 updated_at 就行。

8.7.1 flatten 的实战模式:Audit Trail

flatten 的最强用例之一是 Audit Trail —— 把"谁创建 / 谁修改 / 什么时候"这些跨表通用字段抽出来:

#[derive(FromRow)]
struct Audit {
    created_at: OffsetDateTime,
    created_by: i32,
    updated_at: OffsetDateTime,
    updated_by: i32,
}

#[derive(FromRow)]
struct User {
    id: i32,
    name: String,
    email: String,
    #[sqlx(flatten)]
    audit: Audit,
}

#[derive(FromRow)]
struct Post {
    id: i32,
    title: String,
    #[sqlx(flatten)]
    audit: Audit,
}

每张业务表都带四个 audit 字段,Rust 侧只写一次 Audit struct。SELECT * 带上四个 audit 列就能 fetch 成功。

这种模式在大型 Rust 后端里非常普遍——不只 audit,还有 Timestamps(只需 created_at / updated_at)、SoftDelete(deleted_at / deleted_by)、Versioned(version / is_current)。每个都是一个独立的 flatten struct,组合起来描述业务实体。

flatten 的嵌套也合法:

#[derive(FromRow)]
struct Meta {
    #[sqlx(flatten)]
    audit: Audit,
    tags: String,  // comma-separated
}

#[derive(FromRow)]
struct Post {
    id: i32,
    #[sqlx(flatten)]
    meta: Meta,  // 传递展开 audit
}

Post 用 flatten 展开 Meta,Meta 里再 flatten 展开 Audit——最终 Post 从 id / tags / created_at / ... / updated_by 六列构造。flatten 是递归 from_row 调用,天然支持多层嵌套。

8.7.2 flatten 不适合的场景

flatten 也有它的边界:

  • 子 struct 的字段和父 struct 字段名冲突——如 §8.7 讨论,两者会取同一列值。
  • 子 struct 的字段对应 JSON 嵌套结构——flatten 是"column 级平铺",不是 "JSON 级嵌套"。如果 DB 列是 metadata: JSONB 且你想把它展成 Rust 的几个字段,应该用 #[sqlx(json)] metadata: Metadata 让 serde_json 解;flatten 在这里无效。
  • 相同 flatten struct 出现多次——如 Post { author_audit: Audit, editor_audit: Audit }——两个 audit 都按自己的字段名(created_at 等)取列,会取到同一列。要区分必须手写 FromRow 或 SQL 层用别名。

flatten 的语义本质是"递归调用子类型的 FromRow"——理解这一点就能判断它的适用边界。

8.8 try_from:中间类型转换

有时数据库里存的类型和 Rust 里想要的类型不一致,中间需要一次 TryFrom。try_from attribute 做这件事:

#[derive(sqlx::FromRow)]
struct User {
    id: i32,
    #[sqlx(try_from = "String")]
    email: EmailAddress,  // DB 里是 TEXT,Rust 想要验证过的 EmailAddress
}

展开时套用的模板在 sqlx-macros-core/src/derives/row.rs:137-156:

                (false, Some(try_from), None) => {
                    predicates
                        .push(parse_quote!(#try_from: ::sqlx::decode::Decode<#lifetime, R::Database>));
                    predicates.push(parse_quote!(#try_from: ::sqlx::types::Type<R::Database>)); 

                    parse_quote!(
                        __row.try_get(#id_s)
                            .and_then(|v| {
                                <#ty as ::std::convert::TryFrom::<#try_from>>::try_from(v)
                                    .map_err(|e| {
                                        // Triggers a lint warning if `TryFrom::Err = Infallible`
                                        #[allow(unreachable_code)]
                                        ::sqlx::Error::ColumnDecode {
                                            index: #id_s.to_string(),
                                            source: sqlx::__spec_error!(e),
                                        }
                                    })
                            })
                    )
                }

把 #ty 代成 EmailAddress、#try_from 代成 String、#id_s 代成 "email",展开出来的代码相当于(下面这段是本专栏代入后的写法,不是源码原文):

let email: EmailAddress = __row.try_get::<String, _>("email")
    .and_then(|v| {
        <EmailAddress as TryFrom<String>>::try_from(v)
            .map_err(|e| {
                #[allow(unreachable_code)]
                Error::ColumnDecode {
                    index: "email".to_string(),
                    // __spec_error! 意在用 autoderef specialization(源码注释的叫法)
                    // 把 e 装成 Box<dyn Error + Send + Sync>,兼容各种 TryFrom::Error
                    source: sqlx::__spec_error!(e),
                }
            })
    })?;

两步:

  1. try_get::<String, _>("email") —— 按 String 类型从 row 取值。
  2. EmailAddress::try_from(s) —— 转成 Rust 侧类型,失败映射成 ColumnDecode。

有一处要留意:0.8.6 的 __spec_error!(sqlx-0.8.6/src/spec_error.rs:68-76)是直接在值上调用 wrapper.__sqlx_spec_error(),方法解析只会命中最末那个兜底 impl,前面按 Error / Display / Debug 分级的几个 impl 用不上。实测(SQLite)TryFrom::Error 无论是 String 还是 TryFromIntError,拿到的 source 都是 "unprintable error: (unprintable type)"——原始错误信息丢了。需要把转换失败的原因带出来时,只能手写 FromRow,或者把校验放进 Decode 层(下面的方案 C)。

这和第 5 章 §5.12 的"方案 C 手写三 trait"功能相似但选择不同——try_from 在 FromRow 层做转换,方案 C 在 Encode/Decode 层做。两者都能让脏数据触发错误,区别是:

  • try_from:只影响 FromRow 场景,row.try_get::<EmailAddress, _>("email") 直接调用时不走这条路。
  • 方案 C:在所有 decode 场景都做校验,更彻底但代码量略多。

8.8.1 try_from + json 的组合

一个有意思的交叉(sqlx-macros-core/src/derives/row.rs:158-177):

下面是读者示例,不是 sqlx 源码原文——User 和 settings 都是示例自取的名字。

两个属性叠加时,宏走的是"先按 Json<RawSettings> 解出来、再 TryFrom 转成目标类型"这一条路:

#[derive(sqlx::FromRow)]
struct User {
    id: i32,
    #[sqlx(try_from = "RawSettings")]
    #[sqlx(json)]
    settings: Settings,  // JSON 解出 RawSettings、然后 try_from 转 Settings
}

展开:

let settings: Settings = __row.try_get::<Json<_>, _>("settings")
    .and_then(|v: Json<RawSettings>| {
        <Settings as TryFrom<RawSettings>>::try_from(v.0)
            .map_err(|e| Error::ColumnDecode { ... })
    })?;

典型用例——schema 演进:JSON 结构变了(RawSettings 字段集不同),用 TryFrom 做迁移转换、失败的老 record 报 ColumnDecode 错误。

8.9 json 和 json(nullable)

专门给 JSON 列的简化 attribute:

#[derive(sqlx::FromRow)]
struct User {
    id: i32,
    #[sqlx(json)]
    metadata: Metadata,  // 列是 JSON / JSONB,用 serde_json 解码
}

展开(生成它的宏分支在 sqlx-macros-core/src/derives/row.rs:183-189):

// 宏展开后的等价代码,不是源码原文
let metadata: Metadata = __row.try_get::<Json<_>, _>("metadata").map(|x| x.0)?;

等价于手写 row.try_get::<Json<Metadata>, _>("metadata")?.0,只是少打几个字。

8.9.1 #[sqlx(json(nullable))]

SQL 列本身可以是 NULL 的 JSON 列:

#[derive(FromRow)]
struct User {
    id: i32,
    #[sqlx(json(nullable))]
    metadata: Option<Metadata>,
}

展开(生成它的宏分支在 sqlx-macros-core/src/derives/row.rs:190-196):

// 宏展开后的等价代码,不是源码原文
let metadata: Option<Metadata> = __row.try_get::<Option<Json<_>>, _>("metadata")
    .map(|x| x.and_then(|y| y.0))?;

和 #[sqlx(json)] metadata: Option<Metadata> 的区别,sqlx-core/src/from_row.rs:257-280 的文档说得很直接:

By default the #[sqlx(json)] attribute will assume that the underlying database row is not NULL. This can cause issues when your field type is an Option<T> because this would be represented as the not NULL (in terms of DB) JSON value of null.

  • json + Option<Metadata>:生成的是 try_get::<Json<Option<Metadata>>, _>(...)——它要求这一列在 SQL 层面非 NULL,None 只能来自列里存着 JSON 字面量 null。真遇到 SQL NULL,Json<T> 的 Decode 会失败。
  • json(nullable) + Option<Metadata>:生成的是 try_get::<Option<Json<Option<Metadata>>>, _>(...).map(|x| x.and_then(|y| y.0))——外层 Option 接住 SQL NULL,所以列可空。

注意 json(nullable) 并不会把"字段没填(SQL NULL)"和"字段明确存了 JSON null"区分开:字段类型是 Option<Metadata> 时,SQL NULL 由外层 Option 接住,JSON null 由 Json<Option<Metadata>> 解成 None,两条路最后都是 None(SQLite 实测)。它解决的是另一件事——列可空就得用 json(nullable),否则一行 SQL NULL 就会让整次 fetch 报 ColumnDecode(SQLite 实测,source 是 serde_json 的 EOF while parsing a value)。真要区分这两种 NULL,只能手写 FromRow,用 Option<Json<serde_json::Value>> 之类先取出来再判断。

8.10 skip:不解码

#[derive(sqlx::FromRow)]
struct User {
    id: i32,
    name: String,
    #[sqlx(skip)]
    computed: String,  // 不从 row 取,用 Default::default()
}

展开的模板(sqlx-macros-core/src/derives/row.rs:84-88):

            if attributes.skip {
                return Some(parse_quote!(
                    let #id: #ty = Default::default();
                ));
            }

代入本例就是 let computed: String = Default::default();。

最简单——根本不调 try_get,直接默认值。用在"这个字段运行时赋值,和 row 无关"的场景(比如有一个 computed 字段是从别处算出来的)。

8.10.1 所有 attribute 的对照表

把本章讨论的 7 种 attribute 合一张速查表:

Attribute 位置 展开后形态 典型场景
rename = "col" 字段级 try_get("col") 而非 try_get("field_name") 字段名和列名不一致
rename_all = "camelCase" 容器级 对每个字段按规则转列名 批量规则——Rust snake 对 DB camel
default 字段级 or_else { ColumnNotFound → Default::default() } 渐进 schema 迁移——新字段旧表没列
default 容器级 对每个字段都加 fallback 到 Self::default().<field> struct 有整体默认态
flatten 字段级 递归 <SubType as FromRow>::from_row(row) 复用通用字段集(Timestamps / Audit 等)
try_from = "SrcType" 字段级 try_get::<SrcType, _> + TryFrom::try_from 业务类型校验 / schema 演进
json 字段级 try_get::<Json<_>, _>().map(|x| x.0) JSON/JSONB 列,非 NULL
json(nullable) 字段级 try_get::<Option<Json<_>>, _>().map JSON/JSONB 列,可 NULL
skip 字段级 直接 Default::default() 计算字段——和 row 无关

几条观察:

  1. rename 字段级 > rename_all 容器级优先——字段级显式覆盖容器级隐式。
  2. default 两级可共存——字段级优先于容器级。
  3. flatten 和 json 互斥——前者展开子 struct、后者展开 JSON,语义不可混合。
  4. try_from 可以和 json / flatten 组合——但不能和 json(nullable) 组合(避免类型推导歧义)。

8.10.2 attribute 组合的合法性矩阵

把组合的合法性画成矩阵更直观:

flatten try_from json (non-null) json (nullable)
baseline ✓ ✓ ✓ ✓
default + ? ✓ ✓ ✓ ✓
flatten + ? N/A ✓ ✗ ✗
try_from + ? ✓ N/A ✓ ✗
json + ? ✗ ✓ N/A N/A
skip + ? 忽略 忽略 忽略 忽略

skip 一行写的是"忽略"而不是 ✗:源码里 if attributes.skip 这个分支在 match (flatten, try_from, json) 之前就 return(sqlx-macros-core/src/derives/row.rs:84-88),所以 skip 和其它 attribute 叠加不会报错,只是后者被静默丢弃、字段直接取 Default::default()。同理 #[sqlx(skip)] 配 #[sqlx(default)] 也是 skip 说了算。写这种组合编译器不会拦你——但它多半意味着你的意图和代码对不上。

两条"合法但很少见"的组合值得记住:

  • flatten + try_from:子 struct 用 FromRow 构造,然后 TryFrom 转成最终类型。适合"子 struct 需要后处理"。
  • try_from + json:JSON 解 RawType,TryFrom 转目标类型。典型 schema 迁移场景。

8.11 Tuple 的 FromRow:按位置索引

除了 struct 派生,sqlx 给 1-16 元组做了 blanket impl(sqlx-core/src/from_row.rs:326-524):

macro_rules! impl_from_row_for_tuple {
    ($( ($idx:tt) -> $T:ident );+;) => {
        impl<'r, R, $($T,)+> FromRow<'r, R> for ($($T,)+)
        where
            R: Row,
            usize: crate::column::ColumnIndex<R>,
            $($T: crate::decode::Decode<'r, R::Database> + crate::types::Type<R::Database>,)+
        {
            #[inline]
            fn from_row(row: &'r R) -> Result<Self, Error> {
                Ok(($(row.try_get($idx as usize)?,)+))
            }
        }
    };
}

按 usize 位置索引(0, 1, 2...)取每个元组元素。用法:

let (id, name, email): (i32, String, Option<String>) =
    sqlx::query_as("SELECT id, name, email FROM users WHERE id = $1")
        .bind(42).fetch_one(&pool).await?;

元组 vs struct 的选择:

  • 元组:零派生,临时使用、顺序敏感、字段不多(<= 5)。
  • struct:有派生成本(宏展开编译时间),但字段名有文档价值、列顺序变化时只要 SQL 和 struct 字段名匹配就能用。

生产代码里大多数情况用 struct + FromRow——元组通常出现在快速原型或测试代码。

1-16 是硬上限——16 条 impl_from_row_for_tuple!(...) 宏调用在源码里逐个写出来(sqlx-core/src/from_row.rs 共 524 行、尾部大片都是这些调用)。超过 16 个字段的查询必须用 struct(或手写 FromRow)——sqlx 没有给 Vec 实现 FromRow。16 也是 serde 给 tuple 实现 Serialize / Deserialize 的上限——两边选了同一个数。

8.12 与 serde::Deserialize 的对照

FromRow 和 serde::Deserialize 都是"把外部数据反序列化成 struct"的派生宏,但设计走向完全不同。

维度 FromRow Deserialize
输入 &'r R,R: Row(trait 定义 fn from_row(row: &'r R) -> Result<Self, Error>,见 sqlx-0.8.6/sqlx-core/src/from_row.rs:309-311) 泛型 D: Deserializer<'de>
数据访问方式 try_get(name) 按列名或下标(ColumnIndex 统一两者) Visitor 模式(visit_str、visit_i32 等)
字段 attribute rename / try_from / default / flatten / skip / json(六个,见 sqlx-0.8.6/sqlx-macros-core/src/derives/attributes.rs:156-180) rename / default / flatten / skip / ...
容器 attribute rename_all / default(sqlx-0.8.6/sqlx-macros-core/src/derives/attributes.rs:88-122 的同一个解析函数还接受 transparent / no_pg_array / type_name,但那是给 #[derive(Type)] 用的,FromRow 派生不读) rename_all / default / tag / untagged 等
错误类型 sqlx::Error D::Error(实现 de::Error 即可)
enum 支持 不支持 支持(tagged / untagged 多种形态)
生成代码大小 每字段一条 try_get? 链 Visitor impl + 字段标识 enum,体量大得多

最大差异在 enum 支持。serde 支持 enum(这是它最复杂的部分——externally / internally / adjacently tagged 与 untagged 四种表示),sqlx 的 FromRow 不支持——因为 SQL 行不是树状数据,没有 enum 的自然对应。

attribute 交集很大——rename、default、flatten、skip 的语义在两个宏里几乎一致(sqlx 侧的 skip 落到 sqlx-0.8.6/sqlx-macros-core/src/derives/row.rs:84,直接给该字段填 Default::default());sqlx 另有两个 serde 没有的:try_from(解出来再走一次 TryFrom)和 json / json(nullable)。这不是巧合——sqlx 的派生看得出借鉴了 serde 的命名习惯(sqlx-core/src/from_row.rs:121 的文档就直说容器级 default 的行为与 #[serde(default)] 相似),从 serde 过来的用户上手很快。

错误类型不同——sqlx 的错误都是 sqlx::Error(ColumnDecode / ColumnNotFound 等),serde 的错误是各数据格式自己定义的类型(Deserializer 的关联类型 D::Error,只要求实现 de::Error trait)。这反映了目标领域的差异——sqlx 只处理 SQL 一种数据源,错误类型枚举够用;serde 要处理任意格式,错误必须 trait 化。

8.13 与 diesel::Queryable 的对照

Diesel 的 Queryable 和 sqlx 的 FromRow 解决同一问题但做法差别很大:

// Diesel 2.x 的 Queryable(简化)
pub trait Queryable<ST, DB: Backend> {
    type Row: FromStaticSqlRow<ST, DB>;
    fn build(row: Self::Row) -> deserialize::Result<Self>;
}

几条差异:

  • Queryable 带 ST 泛型参数 —— 这是 SQL 类型元组((Integer, Text, Timestamp))。Diesel 把 SQL 类型编进泛型,编译期保证字段数量与类型。sqlx 完全没有 SQL 类型参数——所有类型匹配都是运行时 compatible 检查或编译期 query! 宏另做的一套检查。
  • #[derive(Queryable)] 按位置匹配——struct 字段要和查询结果的列在顺序、个数上一一对应,字段名不起作用(diesel-2.3.9/diesel_derives/src/lib.rs:529-535 的文档)。类型对不上会编译失败,但两个同类型字段写反了编译器发现不了,文档因此建议配合 #[derive(Selectable)] 做编译期检查。sqlx 的 #[derive(FromRow)] 按字段名 try_get,顺序不重要、列可以比 struct 多。
  • Queryable 本身不支持 flatten —— Diesel 的等价物在另一个 derive 上:#[derive(Selectable)] 的字段属性 #[diesel(embed)] 会把子 struct 的 SelectExpression 内联进来(diesel-2.3.9/diesel_derives/src/selectable.rs:165-201)。sqlx 不分两个 derive,#[sqlx(flatten)] 直接递归调子类型的 FromRow。(Associations + joinable! 是另一回事——那是 has_many / belongs_to 关联,不是字段嵌套。)

这两种设计反映的是两个不同的"真理来源":

  • Diesel 的真理在 schema.rs(通过 diesel print-schema 生成的文件)——编译期所有类型都能从那里推出。
  • sqlx 的真理在 DATABASE_URL(编译期连接数据库 describe)或在 .sqlx/ 离线缓存——两者都不是 Rust 代码的一部分。

Diesel 的 schema.rs 强一致但约束强(每次数据库改动要重跑 print-schema);sqlx 的 describe 路径灵活但要在 compile 时能连数据库或维护离线缓存。两种选择各有成本,在不同团队规模下优劣不同——小团队 sqlx 更快上手,大团队 Diesel 的 schema.rs 作为 checked-in 真理源可能更稳。

8.14 FromRow 的 attribute 是怎么一个一个长出来的

FromRow 的每个 attribute 都能在 CHANGELOG 里找到引入它的那个 PR。按时间排一遍,能清楚看到"需求驱动增量演进"的轨迹(版本号和 PR 号都来自 CHANGELOG.md):

版本 PR 加了什么
0.3.1 [#200] 修正 #[derive(FromRow)] 对原始标识符字段(r#type)的处理
0.3.4 [#227] #[sqlx(rename = "new_name")]
0.4.0-beta.1 [#308] [#495] 字段级 #[sqlx(default)],用来读"部分查询"
0.4.0 [#718] 支持 tuple struct 的 #[derive(FromRow)]
0.4.0 [#755] rename_all 增加 kebab-case(当时 rename_all 只属于 #[derive(Type)],FromRow 到 0.4.2 才用上)
0.4.2 [#860] rename_all 本身,以及 camelCase / PascalCase
0.6.1 [#1959] #[sqlx(flatten)]
0.6.2 [#1081] #[sqlx(try_from = "T")]
0.7.0 [#2115] 允许 try_from 用复杂类型
0.7.0 [#2366] 允许忽略字段(即 #[sqlx(skip)])
0.7.2 [#2121] #[sqlx(json)]
0.7.3 [#2801] 容器级 #[sqlx(default)](一次给所有字段兜底)
0.7.3 [#2827] 给 unit 类型 () 实现 FromRow
0.8.0 [#3337] rename 的值不再被 rename_all 二次转换
0.8.0 [#3356] try_from 失败时返回 Error::ColumnDecode 而不再是 ColumnNotFound;生成代码的参数名由 row 改为 __row,避免与字段名冲突
0.8.4 [#3677] #[sqlx(json(nullable))]

几条值得注意的:

  • rename 最早(0.3.4,2020-04-10),json(nullable) 最晚(0.8.4,2025-04-13)——中间隔了整整五年。列名不匹配是所有人第一天就会撞上的痛点,而"SQL NULL 和 JSON null 要分开"是用得足够深才会碰到的边角。
  • [#3356] 是一次真正的 breaking change——在它之前,try_from 转换失败返回的是 ColumnNotFound,于是 #[sqlx(default)] 的那条 or_else 会把这个错误吞掉(因为它只对 ColumnNotFound 回退)。改成 ColumnDecode 之后,转换失败才会如实报出来。这条改动直接解释了 §8.6 里"只对 ColumnNotFound 回退"这个设计为什么必须配一个精确的错误分类。
  • [#3337] 修的是 rename 被二次转换——在此之前生成列名的写法是 attributes.rename.or_else(|| 字段名).map(|s| rename_all(...)),rename 给出的名字还会再过一遍 rename_all(比如 camelCase 下 rename = "email_address" 实际去找 emailAddress)。0.8.0 起改成 §8.5.1 那段 if let Some(s) = attributes.rename 的写法,rename 的值原样使用(对比 commit 22e52c11 的 diff)。

8.15 派生宏里的 lifetime 处理细节

sqlx-macros-core/src/derives/row.rs:45-60 有一段看似平淡但很重要的代码——如何处理 struct 带不带生命周期参数:

let (lifetime, provided) = generics
    .lifetimes()
    .next()
    .map(|def| (def.lifetime.clone(), false))
    .unwrap_or_else(|| (Lifetime::new("'a", Span::call_site()), true));

// ...

let mut generics = generics.clone();
generics.params.insert(0, parse_quote!(R: ::sqlx::Row));

if provided {
    generics.params.insert(0, parse_quote!(#lifetime));
}

处理两种 struct 形态:

情况 A:用户 struct 没带生命周期

#[derive(FromRow)]
struct User { id: i32, name: String }

provided = true —— 派生宏添加一个默认 'a。生成:

impl<'a, R: Row> FromRow<'a, R> for User { ... }

情况 B:用户 struct 自己带生命周期

#[derive(FromRow)]
struct UserRef<'a> { id: i32, name: &'a str }

provided = false —— 复用用户的 'a。生成:

impl<'a, R: Row> FromRow<'a, R> for UserRef<'a> where &'a str: Decode<'a, R::Database>, ... { ... }

这种根据输入自适应生命周期的派生宏技巧非常通用——serde 的 Deserialize 派生也是类似做法。关键是一个生命周期既是 impl 签名的一部分、又是结构自身的参数——要区分"用户已经给了一个"和"我要引入一个新的"。

这也回答了为什么 FromRow 派生对"带借用字段的 struct"(如 struct UserRef<'a> { name: &'a str })同样工作——派生宏会把用户的 'a 同时用在 from_row(__row: &'a R) 和 &'a str: Decode<'a, R::Database> 上,让 try_get::<&'a str, _> 能从 row 借出字节。这种零拷贝 fetch省掉了每个字符串字段的一次堆分配和一次 memcpy,代价是这些 &str 的生命周期被绑在 row 上(第 7 章 §7.9)。

8.16 本章小结

本章把 FromRow 的 trait 契约和派生宏的所有 attribute 展开完整拆开:

  1. FromRow 的单方法契约(§8.2)——fn from_row(row: &'r R) -> Result<Self, Error>。简单到可以手写,复杂在派生。
  2. 派生展开的五步(§8.4)—— 解析生命周期 → 添加 R: Row 泛型 → 添加 &str: ColumnIndex bound → 生成 reads 语句 → 拼 impl。
  3. rename / rename_all(§8.5)—— 字段级覆盖容器级;支持 7 种 case 风格。
  4. default 两级(§8.6)—— 字段级用 Default::default(),容器级用 Self::default().field。两者都只对 ColumnNotFound 错误回退。
  5. flatten(§8.7)——子 struct 的所有字段摊平到父级;实现靠递归调用子类型的 from_row。
  6. try_from(§8.8)——try_get::<SrcType, _> + TryFrom::try_from,适合 schema 演进和业务类型校验。
  7. try_from + json 组合(§8.8.1)—— JSON 解出 RawSettings 再 try_from 转 Settings,用于 JSON schema 迁移。
  8. json / json(nullable)(§8.9)—— 前者假设列在 SQL 层非 NULL,None 只能来自 JSON 字面量 null;后者在外面多包一层 Option,让列本身可以是 SQL NULL。
  9. skip(§8.10)—— 最简单,直接 Default::default()。
  10. tuple 的 1-16 blanket impl(§8.11)—— 按 usize 位置索引;超过 16 字段必须用 struct。
  11. 与 serde::Deserialize 的对照(§8.12)—— attribute 集合近似(有意借鉴);enum 支持 / 错误类型 / 数据源方向上有根本差异。

8.17 实战:常见 FromRow 问题与排查

问题 1:调用处报 E0277 "the trait bound T: sqlx::Decode<'_, DB> is not satisfied"

常见原因:字段类型在启用的 feature 下没有 Decode/Type 实现。derive 本身不会报错,错误出在 query_as(...).fetch_*() 那一行(§8.4.2)。比如 chrono::DateTime<Utc> 需要 chrono feature,Uuid 需要 uuid feature。

排查:Cargo.toml 里加对应 feature;或者改用已启用 feature 覆盖的类型(比如已经开了 time,时间字段就用 time 的类型,不必再为 chrono::DateTime<Utc> 加 chrono)。

问题 2:运行时 Error::ColumnNotFound("field_name")

常见原因:SQL 里没 SELECT 那一列、或列名和字段名不一致(大小写 / snake-case vs camelCase / 有没有表前缀)。

排查:看 row.columns() 实际列名;加 #[sqlx(rename = "...")] 或 #[sqlx(rename_all = ...)] 修正。

问题 3:ColumnDecode 但类型看起来一致

常见原因:Postgres 的 NUMERIC 不对应 f64(要 BigDecimal 或 Decimal,各自需要 bigdecimal / rust_decimal feature);TIMESTAMP 和 TIMESTAMPTZ 的 Rust 类型不同(PrimitiveDateTime vs OffsetDateTime);MySQL 根本没有真正的 BOOLEAN,bool 的 type_info() 就是 TINYINT(1)(见 sqlx-mysql/src/types/bool.rs:10-18 的注释),bool::compatible 放行 Tiny/Short/Long/Int24/LongLong/Bit 六种列类型。

排查:看 row.column(i).type_info().name() 确认数据库实际类型;查 sqlx 文档的 "Type Mapping" 表。

问题 4:flatten 下字段名冲突

现象:父 struct 和子 struct 都有 id 字段,都来自同一 row 的 id 列——两个字段值相同。

排查:给子 struct 的字段加 prefix rename;或者在 SQL 里用 posts.id AS post_id 显式别名。

问题 5:default 没生效

现象:列存在但值是 NULL,却没拿到默认值——报了 decode error。

排查:default 只对 ColumnNotFound 错误生效——不对 ColumnDecode。想让"NULL → 默认值",字段类型用 Option<T> 然后 unwrap_or_default() 处理。

这五类问题覆盖了 sqlx FromRow 日常遇到的绝大部分坑。根本方法永远是:读错误类型 + 看 row.columns() 的实际 schema。

8.18 FromRow 设计的通用启示

跳出 sqlx 看这个派生宏的设计,有几条可以迁移到其他 Rust 项目的通用启示:

1. 派生宏是"契约代码生成器"——让它失败而不是让它蒙混

sqlx 对 flatten + json、try_from + json(nullable) 两种非法组合直接 panic! 产生编译错误——而不是"忽略一个 attribute 让展开看起来正常"。后者会让用户写出看似合法的代码跑到运行时才发现行为不对。让编译期报错永远优于让运行时蒙混——这条原则在所有派生宏设计里适用。不过 sqlx 自己也没贯彻到底:字段级不认识的开关式 attribute 会被静默跳过(sqlx-macros-core/src/derives/attributes.rs:155-183 的 if-else 链没有兜底分支;实测把 default 拼成 #[sqlx(defualt)] 照常编译,列缺失时才在运行时报 ColumnNotFound),skip 也会静默吞掉同字段的其它 attribute(§8.10.2)。

2. 默认路径要是最简单的,attribute 加一个改变一点

基础派生(无任何 attribute)展开是"每字段 try_get"——极简,新手零学习成本。每个 attribute 都是对这条基线的局部修改——加 rename 改列名、加 default 包一层 or_else、加 flatten 换成递归 from_row。没有哪个 attribute 是"翻天覆地重构展开形态"。这种"渐进式修改基线"的设计让宏的每种组合都可预测。

3. 复杂组合用 match 枚举而不是 if-else 链

flatten(2 种)× try_from(2 种)× json(3 种:无 / 非空 / 可空)的组合,sqlx 在 sqlx-macros-core/src/derives/row.rs:100-197 用一个显式 match 逐条列出——七条合法分支加两条 panic! 的非法分支。相比于一串 if-else,match 保证exhaustiveness——编译器会在你新增一个枚举 variant 时提醒漏了哪个分支。这条技巧在"你要处理 N 个维度的组合"场景值得借鉴。

4. 命名和其他生态库对齐

rename / default / flatten / skip 这套命名直接借自 serde——让从 serde 迁移来的用户零学习成本。Rust 生态里好的派生宏都在互相"偷"命名(serde 与 clap 的 derive 都有 rename_all、skip 这类同名 attribute)——这不是抄袭,是生态一致性。

读完本章,你对如何设计"一个合理、可维护、对用户友好的 derive macro"应该有了一套直觉。sqlx 的 FromRow 是这类设计的样本——从 0.3 的 rename 一路扩展到 0.8.4 的 json(nullable)(§8.14 的时间线),除了 0.8.0 那次刻意修正错误类型的 [#3356](以及同版 [#3337] 对 rename 语义的修正),几乎没有破坏性改动。

8.19 手写 FromRow 的合法场景

虽然 #[derive(FromRow)] 覆盖了绝大多数场景,手写 FromRow 仍然有它的位置。典型三种情况:

1. 字段需要跨列计算

struct FullName { first: String, last: String, combined: String }

impl<'r, R: Row> FromRow<'r, R> for FullName
where
    // ...
{
    fn from_row(row: &'r R) -> Result<Self> {
        let first: String = row.try_get("first_name")?;
        let last: String = row.try_get("last_name")?;
        let combined = format!("{first} {last}");
        Ok(Self { first, last, combined })
    }
}

combined 由 first + last 拼出——派生宏里没对应 attribute 表达"这个字段是其它字段的函数"。手写最直接。

2. 有条件字段(某列在或不在时行为不同)

impl FromRow<'_, PgRow> for DynamicUser {
    fn from_row(row: &PgRow) -> Result<Self> {
        // 检查某列存不存在来决定分支
        if row.columns().iter().any(|c| c.name() == "role") {
            /* 分支 A */
        } else {
            /* 分支 B */
        }
    }
}

派生宏不支持"看当前 row 的 shape 再决定怎么解码"。这种动态 schema 场景必须手写。

3. 性能敏感的位置索引

派生 FromRow 用字符串 try_get("id")——每个字段一次 HashMap 查找。手写可以改成 try_get(0) 的位置索引,省掉这次查找(tuple 的 blanket impl 走的就是这条路,§8.11)——代价是"列顺序必须和 SQL 严格匹配",SQL 里调换两列的位置,只要两列类型互相兼容就会静默取错值。这条优化只有在你实测出 HashMap 查找确实是热点时才值得做。

这三种都是 derived FromRow 覆盖不到的小众场景——大多数业务代码不需要,但当你需要时知道"派生宏不够用时我可以直接手写"是重要的。

8.20 FromRow 三道判断题

Q1:为什么 #[derive(FromRow)] 不支持 enum?

A:SQL 行没有 "variant" 概念。Postgres 的 CREATE TYPE color AS ENUM (...) 是单列枚举——用 #[derive(Type)] + strong/weak enum 模式处理(第 5 章 §5.9)。行级的 "这行代表 Admin、另一行代表 Guest" 这种 tagged-union 语义 SQL 不原生支持,需要 type 列 + 条件 decode——必须手写。

Q2:#[sqlx(default)] 字段级 vs 容器级应该用哪个?

A:字段级更常用——"这个字段没列就用 None / 0 / 空字符串"的语义和 Rust 原生 Default 一致。容器级用于"整个 struct 有逻辑默认态,缺几个字段用对应的默认字段值"——典型是 config struct。大多数业务代码用字段级。

Q3:flatten 是不是降低性能(多一次函数调用)?

A:几乎无开销。展开后是一次 Subtype::from_row(row) ——单次函数调用,内部仍然是一串 try_get。Rust 编译器通常会把这条调用内联——最终生成的机器码和不用 flatten 手动把子字段写进父 struct 基本等同。

8.21 实战:组合使用各 attribute 的大型例子

最后给一个"尽量多用 attribute"的真实工程例子——帮助你把本章内容融成一个整体。假设某 SaaS 系统的 User 聚合视图:

#[derive(serde::Deserialize)]
struct Preferences {
    theme: String,
    notifications: bool,
}

#[derive(sqlx::FromRow)]
struct Audit {
    created_at: OffsetDateTime,
    created_by: i32,
    updated_at: OffsetDateTime,
    updated_by: i32,
}

#[derive(Debug, Clone, PartialEq, sqlx::Type)]
#[sqlx(type_name = "user_status", rename_all = "lowercase")]
enum UserStatus { Active, Suspended, Deleted }

#[derive(sqlx::FromRow)]
#[sqlx(rename_all = "snake_case")]
struct User {
    id: i32,

    // 字段级 rename 覆盖容器级 rename_all
    #[sqlx(rename = "email_address")]
    email: String,

    // 字符串列直接映射
    display_name: String,

    // PG enum 类型 → Rust enum
    status: UserStatus,

    // JSON 列用 serde 解
    #[sqlx(json)]
    preferences: Preferences,

    // 可空 JSON 列:SQL NULL 也能接住(和 JSON null 一样落成 None)
    #[sqlx(json(nullable))]
    legacy_config: Option<LegacyConfig>,

    // 数据库里存 String,Rust 侧想要 EmailDomain(业务类型)
    #[sqlx(try_from = "String")]
    primary_domain: EmailDomain,

    // 列可能不存在(迁移期间)
    #[sqlx(default)]
    referral_code: Option<String>,

    // 跨查询复用的审计字段集
    #[sqlx(flatten)]
    audit: Audit,

    // 计算字段——不从 row 取
    #[sqlx(skip)]
    computed_score: f64,
}

对应的 SQL:

SELECT
  id,
  email_address,     -- 字段级 rename
  display_name,
  status,            -- enum
  preferences,       -- JSON
  legacy_config,     -- JSON nullable
  primary_domain,    -- 原始 String 列
  referral_code,     -- 迁移期可能没有这列
  created_at,        -- flatten Audit
  created_by,
  updated_at,
  updated_by
FROM users WHERE id = $1

这个例子里同时用到 7 种 attribute,每种解决一个实际工程问题:

  • rename_all + rename——命名规则 + 例外。
  • json + json(nullable)——列非空 / 列可空两种 JSON 列。
  • try_from——业务类型校验。
  • default——渐进 schema 迁移。
  • flatten——复用 audit 字段集。
  • skip——计算字段跳过 row。

阅读这个例子的价值不在于"抄下来用"——而在于当你遇到类似需求时,你记得 "哦 sqlx 的 FromRow 有 attribute 能做这件事",去查文档找到合适的组合。#[derive(FromRow)] 的这套 attribute 覆盖了绝大多数业务场景——剩下的手写 FromRow(§8.19 讨论过)。

学会合理组合这些 attribute,意味着你不用再为"怎么把 SQL 行映射到 Rust struct"单独写 mapper 函数——#[derive(FromRow)] 把这份重复劳动收走,让你专注于业务逻辑。

8.22 query_as! 和 #[derive(FromRow)] 的关系

本章一直围绕 query_as(...) + #[derive(FromRow)]——但 sqlx 还有一个 query_as! 宏,关系要讲清楚。

query_as 函数(第 9 章详细):运行时根据 FromRow impl 把 row 映射到 struct。你的 struct 必须 #[derive(FromRow)] 或手写 impl。列名校验发生在运行时 try_get 失败时。

query_as! 宏(第 11 章详细):编译期连数据库 describe,生成带具体位置索引 + 具体类型的代码。你的 struct 不需要 #[derive(FromRow)]——宏绕过 FromRow,直接生成 struct 字面量。

// 用 query_as 函数 + FromRow 派生
#[derive(FromRow)]
struct User { id: i32, name: String }
let user: User = sqlx::query_as("SELECT id, name FROM users")
    .fetch_one(&pool).await?;

// 用 query_as! 宏 —— User 不需要 FromRow 派生
struct User { id: i32, name: String }  // 纯数据 struct
let user = sqlx::query_as!(User, "SELECT id, name FROM users")
    .fetch_one(&pool).await?;

关键差异总结:

维度 query_as + FromRow query_as!
校验时机 运行时(try_get 失败时) 编译期(describe 返回后)
Struct 要求 派生 FromRow 普通 struct,字段名和列名对齐
attribute 支持 全套 7 种 无——宏按列顺序生成
DB 连接 不需要 编译期需要(或用 offline cache)
动态 SQL 支持 不支持(SQL 必须字面量)

什么时候用哪个?

  • SQL 是字面量 + 能编译期连 DB / 有离线缓存:用 query_as!(编译期安全)。
  • SQL 动态拼接(QueryBuilder):用 query_as + FromRow。
  • SQL 字面量但类型要精细控制(rename / flatten / try_from):用 query_as + FromRow 的 attribute。

query_as! 和 FromRow 派生是两条并行路径——都把 row 映射成 struct,但一条走编译期、一条走运行时。生产代码里通常两者都出现——CRUD 主路径用 query_as!(强校验),边缘特殊场景用 query_as + FromRow(灵活性)。

理解这条"两路径并存"是用好 sqlx 的关键。很多新手以为 #[derive(FromRow)] 是给所有查询用的——看完本章和第 11 章就能清楚两条路径各自的适用场景。

8.23 FromRow 的小惊喜:() 和 tuple 的默认实现

除了 derive 生成的 struct impl 和 macro 生成的 tuple impl,FromRow 还有两个零成本的预置实现值得一提:

impl FromRow for ()(sqlx-core/src/from_row.rs:313-321)让 "不关心结果的查询能用 query_as::<_, ()>" ——虽然这种场景下 execute 通常更合适,但在 channel 风格的代码里(如 tokio::select! 的某个臂只要知道 fetch 成功)偶尔用到。

impl FromRow for (T,)——单元素元组——这是容易被忽视但很常用的技巧。当你只要一列值时:

// 用单元素 tuple——最简洁
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
    .fetch_one(&pool).await?;

// 用 query_scalar(功能相同,但更直接)
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
    .fetch_one(&pool).await?;

两者等价——query_scalar 内部就是对单元素 tuple 的特化(第 9 章详细)。"一列返回用 scalar,多列返回用 struct"是清晰的划分。

这两个"小"impl 体现了 sqlx 对API 覆盖完整性的重视——连"没结果"(())和"单值"((T,))这种边界情况都有对应的 FromRow,不留坑让用户自己凑。

8.24 FromRow 和 Trait 生态的互动

sqlx 的 FromRow 不是孤立的——它和 Rust 社区几个常用 trait 有深度互动:

Default trait:容器级 #[sqlx(default)] 要求 struct 实现 Default。对不含 Option 的 struct 手写 Default 一般简单:

#[derive(Default, sqlx::FromRow)]
#[sqlx(default)]
struct Config { timeout_ms: u32, max_retries: u32, enabled: bool }
// Default::default() == Config { timeout_ms: 0, max_retries: 0, enabled: false }

如果字段类型自己都实现了 Default(数字 = 0、String = 空、Option = None),#[derive(Default)] 就够了。复杂业务 struct 可能要手写 Default 明确每个字段的默认值。

TryFrom trait:#[sqlx(try_from = "String")] 要求目标类型实现 TryFrom<String>。只实现了 From<String> 也行:标准库对所有 From 有 TryFrom 的 blanket impl(Error = Infallible),生成代码里那行 #[allow(unreachable_code)] 就是为这种情况准备的(实测只实现 From<String> 的类型可以直接用 try_from = "String")。真正需要 TryFrom 的是"转换可能失败"的类型——失败会变成 ColumnDecode(原始错误信息在 0.8.6 里会丢失,见 §8.8)。

serde::Deserialize trait:#[sqlx(json)] 要求字段类型实现 serde::Deserialize——因为 JSON 解码用 serde_json。这让 sqlx 的 FromRow 天然和 serde 生态联动——任何 #[derive(serde::Deserialize)] 的类型都能作为 JSON 列接入 FromRow。

这三条互动意味着 #[derive(FromRow)] 不是一个独立的宇宙——它是 Rust 生态中间的一个枢纽。你熟悉 Default / TryFrom / serde 这些 trait 的语义,就能用好 FromRow 的相应 attribute。反之不熟悉这些基础 trait——比如不知道 TryFrom::Error 关联类型的意义——使用 try_from 就会磕磕绊绊。

学 sqlx FromRow 的深度关系到你对 Rust trait 生态的整体掌握——这也是 sqlx 作为"Rust 惯用设计的实践教材"的一个切面。读完第 8 章,你应当能对 "trait 组合构建复杂能力" 这件事有更强的直觉。

8.25 和《Serde 元编程》专栏第 13 章的平行:Visitor 与 try_get

sqlx FromRow 展开生成的"逐字段 row.try_get::<Ty, _>("col")"循环,对应《Serde 元编程》专栏第 13 章 §13.4「deserialize_struct:Visitor 模式的落地」——serde 为每个 struct 生成一个 Visitor、在 visit_map 里按 key 逐字段 next_value() 读取。两者架构同构:

  • sqlx 的 row.try_get("name") ↔ serde 的 map.next_value::<String>()——都是 key-driven 取值。
  • sqlx 的 #[sqlx(rename = "...")] ↔ serde 的 #[serde(rename = "...")]——都在 derive 时重写 key。
  • sqlx 的 #[sqlx(default)] ↔ serde 的 #[serde(default)]——都在找不到字段时回退。

差异——sqlx 直接调 try_get(无中间状态机),serde 要构造 Visitor(有 FieldVisitor 辅助 enum)——因为 serde 的 Deserializer 是流式的,只能按数据格式给出的顺序逐个读 key(JSON key 可以乱序),sqlx 的 Row 则可以按列名随机访问。

这条对照让两个专栏互为脚注——读过《Serde 元编程》专栏第 13 章的读者几乎秒懂 sqlx derive;反过来也成立。这是 Rust derive 宏生态统一的惯用法——理解一个就理解一类。

8.26 下一章指路

下一章我们开始第三部分"查询 API"——query() 和 query_as() 两个顶层函数、Query<'q, DB, A> 结构、从"字符串 SQL"到"可执行 Future"的完整类型转换链。从 FromRow 这个"最后一公里"再往前走一步:如何通过一个 Rust 函数调用,把"字符串 SQL + bind 参数 + FromRow 结构"组装成一个可以送给 Executor 执行的 Query 对象。这条路径是 sqlx 顶层 API 的起点——用户代码里每一次 sqlx::query(...) 都从那里出发。