SQLx 源码深度解析
第8章 FromRow:派生宏、rename、flatten 与 default
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等),用heckcrate 做转换。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 })
}
}
关键结构:
- 大量 where 子句——每个字段类型都要
Decode + Type,且&str: ColumnIndex<R>(让try_get("name")合法)。 - 每个字段一条 try_get——按列名访问、
?向上传错误。 - 最后 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)的工作分五步:
- 解析生命周期 —— 如果 struct 有
'a就用它,没有就用默认'a。 - 添加泛型参数
R: Row—— 生成的 impl 要对任意 Row 类型工作。 - 添加
&'a str: ColumnIndex<R>where bound —— 让try_get(name)合法。 - 遍历每个字段生成 reads 语句 —— 这是主体,下面每一节都在讲这一步的各个变体。
- 拼装最终 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["<Ty as FromRow>::from_row(row)"]
Flat -->|Some| FL2[from_row then try_from]
NoFlat -->|None| NoFlatJson{json?}
NoFlat -->|Some| NoFlatTry{json?}
NoFlatJson -->|None| N1["try_get(name)"]
NoFlatJson -->|NonNullable| N2["try_get::<Json,_>(name).map"]
NoFlatJson -->|Nullable| N3["try_get::<Option<Json>,_>(name).map"]
NoFlatTry -->|None| T1["try_get<SrcType> then try_from"]
NoFlatTry -->|NonNullable| T2["try_get::<Json,_> then try_from"]
NoFlatTry -->|Nullable| TErr[error: cannot combine]
两条非法组合(图中的 error 节点):
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!分支实际走不到。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("列名字符串")的决定顺序:
- 有字段
#[sqlx(rename = "x")]→ 用"x"。 - 否则取字段名(去掉
r#原始标识符前缀)。 - 如果容器级有
#[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),
}
})
})?;
两步:
try_get::<String, _>("email")—— 按 String 类型从 row 取值。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 anOption<T>because this would be represented as the not NULL (in terms of DB) JSON value ofnull.
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 无关 |
几条观察:
rename字段级 >rename_all容器级优先——字段级显式覆盖容器级隐式。default两级可共存——字段级优先于容器级。flatten和json互斥——前者展开子 struct、后者展开 JSON,语义不可混合。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的值原样使用(对比 commit22e52c11的 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 展开完整拆开:
- FromRow 的单方法契约(§8.2)——
fn from_row(row: &'r R) -> Result<Self, Error>。简单到可以手写,复杂在派生。 - 派生展开的五步(§8.4)—— 解析生命周期 → 添加 R: Row 泛型 → 添加 &str: ColumnIndex bound → 生成 reads 语句 → 拼 impl。
- rename / rename_all(§8.5)—— 字段级覆盖容器级;支持 7 种 case 风格。
- default 两级(§8.6)—— 字段级用
Default::default(),容器级用Self::default().field。两者都只对 ColumnNotFound 错误回退。 - flatten(§8.7)——子 struct 的所有字段摊平到父级;实现靠递归调用子类型的 from_row。
- try_from(§8.8)——
try_get::<SrcType, _>+TryFrom::try_from,适合 schema 演进和业务类型校验。 - try_from + json 组合(§8.8.1)—— JSON 解出
RawSettings再 try_from 转Settings,用于 JSON schema 迁移。 - json / json(nullable)(§8.9)—— 前者假设列在 SQL 层非 NULL,
None只能来自 JSON 字面量null;后者在外面多包一层Option,让列本身可以是 SQL NULL。 - skip(§8.10)—— 最简单,直接
Default::default()。 - tuple 的 1-16 blanket impl(§8.11)—— 按 usize 位置索引;超过 16 字段必须用 struct。
- 与 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(...) 都从那里出发。