Appearance
第8章 FromRow:派生宏、rename、flatten 与 default
"A derive macro is a contract between you and the compiler— the fewer surprises you introduce, the longer you can maintain it." —— 每一个用过 #[derive] 的 Rust 工程师的共识
本章要点
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:44-224。- 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 + JSON 非null、后者 SQL 可 NULL + JSON 非null。映射到不同的Json<T>/Option<Json<T>>解码路径。- tuple 的 FromRow blanket impl(
from_row.rs:326-514)通过宏展开 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 }——太啰嗦。典型业务代码是:
rust
#[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:
rust
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 实现(from_row.rs:313-321):
rust
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:基准参照
虽然 95% 用户都用 #[derive(FromRow)],理解它生成什么需要先看手写版本作参照。同一个 User struct,手写版:
rust
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:14-37 的 expand_derive_from_row 入口按结构类型分派:
rust
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 —— 不支持,编译时报错。
大多数用户都在主分支下。expand_derive_from_row_struct(row.rs:40-224)的工作分五步:
- 解析生命周期 —— 如果 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 一个完整的简单展开例
把基础派生跑一次看生成代码的具体形态。输入:
rust
#[derive(sqlx::FromRow)]
struct User {
id: i32,
name: String,
email: Option<String>,
}sqlx-macros-core/src/derives/row.rs 的 expand_derive_from_row_struct 会生成(简化后):
rust
#[automatically_derived]
impl<'a, R> ::sqlx::FromRow<'a, R> for User
where
R: ::sqlx::Row,
&'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 定义生成的。row.rs:63-74 的生命周期处理(有没有用户提供的 'a)、row.rs:78-81 的 R: Row 泛型插入、row.rs:82-84 的 ColumnIndex predicate 添加——全都是为了生成上面这段 50 行展开。
可以用 cargo expand 验证任意 FromRow 派生的实际展开:
bash
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:
rust
#[derive(sqlx::FromRow)]
struct User {
id: i32,
created_at: OffsetDateTime, // 只有 time feature 启用才有 Postgres 的 impl
}这个 struct 在 runtime-tokio-rustls + postgres + time feature 组合下能 derive FromRow(因为 OffsetDateTime 对 Postgres 的 Decode + Type 实现启用了);但如果只启用 mysql feature 不启用 time,就不行(OffsetDateTime 对 MySql 需要 time feature)。
sqlx 没有特殊处理这种跨 DB 情况——编译错误由泛型 where 子句自然报出。用户看到 "OffsetDateTime: Type<MySql> is not satisfied" 就知道缺哪个 feature。这种"通过 trait bound 自然报错"比显式 #[cfg(feature = "postgres")] 标注每个字段清爽得多。
8.4.3 attribute 分派的 match 结构
sqlx-macros-core/src/derives/row.rs:100 附近有一段长 match,是整个派生宏的分派核心:
rust
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 画出来:
两条非法组合(红叉路径):
flatten + json——flatten 要递归 FromRow,json 要求字段是 JSON 列。两者语义正交但 sqlx 禁止同时使用,避免语义混淆。try_from + json(nullable)——try_from 要 JSON 解出 RawType 然后转 TargetType,nullable 又要 Option 外层——两层 Option 会让类型推导歧义。sqlx 选择禁止。
这些显式错误比"让编译器报意义模糊的 trait bound 错误"对用户友好——它们用 panic! 生成 compile error,给明确的描述。
8.5 rename 和 rename_all
最简单的两个 attribute——字段重命名。
8.5.1 #[sqlx(rename = "...")]:字段级
rust
#[derive(sqlx::FromRow)]
struct User {
id: i32,
#[sqlx(rename = "description")]
about_me: String,
}row.rs:89-98 处理这个 attribute:
rust
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 = "...")]:容器级
rust
#[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 | userID → USER_I_D |
kebab-case | user_id → user-id |
转换由 heck crate 完成。heck 的 word boundary 规则比较"直觉"——大写字母开始新词、下划线 / 连字符分割词、数字不 作为 boundary(Foo1 → foo1,不是 foo_1)。
字段级 rename 优先于容器级 rename_all——你可以混用:
rust
#[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)]
rust
#[derive(sqlx::FromRow)]
struct User {
id: i32,
name: String,
#[sqlx(default)]
preferences: Option<String>, // 列不存在就用 Option::default() == None
}展开后(row.rs:200-207):
rust
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 / ColumnIndexOutOfBounds)照常上抛。这让"列不存在用默认、列存在但解码失败报错"成为一致行为。
8.6.2 容器级 #[sqlx(default)]
rust
#[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();,见 row.rs:67-73):
rust
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)。
一个字段可以被两个级别同时覆盖——字段级优先:
rust
#[derive(FromRow)]
#[sqlx(default)] // 容器级
struct Mixed {
id: i32,
#[sqlx(default)] // 字段级(本字段走 Default::default() 而非容器级 __default.name)
name: String,
}8.7 flatten:嵌套展开
最强大的 attribute。让一个子 struct 的所有字段"摊平"到父 struct 级别:
rust
#[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 两列的组合"。
展开后(row.rs:109-113):
rust
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 的典型用法是复用通用字段集:
rust
#[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 —— 把"谁创建 / 谁修改 / 什么时候"这些跨表通用字段抽出来:
rust
#[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 的嵌套也合法:
rust
#[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 做这件事:
rust
#[derive(sqlx::FromRow)]
struct User {
id: i32,
#[sqlx(try_from = "String")]
email: EmailAddress, // DB 里是 TEXT,Rust 想要验证过的 EmailAddress
}展开(row.rs:134-155):
rust
let email: EmailAddress = __row.try_get::<String, _>("email")
.and_then(|v| {
<EmailAddress as TryFrom<String>>::try_from(v)
.map_err(|e| Error::ColumnDecode {
index: "email".to_string(),
source: Box::new(e),
})
})?;两步:
try_get::<String, _>("email")—— 按 String 类型从 row 取值。EmailAddress::try_from(s)—— 转成 Rust 侧类型,失败映射成ColumnDecode。
这和第 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 的组合
一个有意思的交叉(row.rs:155-175):
rust
#[derive(sqlx::FromRow)]
struct User {
id: i32,
#[sqlx(try_from = "RawSettings")]
#[sqlx(json)]
settings: Settings, // JSON 解出 RawSettings、然后 try_from 转 Settings
}展开:
rust
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:
rust
#[derive(sqlx::FromRow)]
struct User {
id: i32,
#[sqlx(json)]
metadata: Metadata, // 列是 JSON / JSONB,用 serde_json 解码
}展开(row.rs:183-190):
rust
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 列可空,但 JSON 值本身非 "null"——两种 NULL 区分:
rust
#[derive(FromRow)]
struct User {
id: i32,
#[sqlx(json(nullable))]
metadata: Option<Metadata>,
}展开(row.rs:191-197):
rust
let metadata: Option<Metadata> = __row.try_get::<Option<Json<_>>, _>("metadata")
.map(|x| x.and_then(|y| y.0))?;和 #[sqlx(json)] metadata: Option<Metadata> 的区别:
json+Option<Metadata>:会把 JSONnull字面量解码为Metadata::None——如果 Metadata 有 serde-deserialize 的 None 语义就可能 panic 或返回 Some 但字段全默认。json(nullable)+Option<Metadata>:SQL NULL → Rust None;JSONnull字面量解码 fail(触发 ColumnDecode)——严格区分两种 NULL。
这条区分在业务里有时关键——"字段没填(SQL NULL)"和"字段明确存了 null(JSON null)" 语义不同。严谨业务用 json(nullable)。
8.10 skip:不解码
rust
#[derive(sqlx::FromRow)]
struct User {
id: i32,
name: String,
#[sqlx(skip)]
computed: String, // 不从 row 取,用 Default::default()
}展开(row.rs:83-87):
rust
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 |
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 让字段不从 row 取) |
两条"合法但很少见"的组合值得记住:
- flatten + try_from:子 struct 用 FromRow 构造,然后 TryFrom 转成最终类型。适合"子 struct 需要后处理"。
- try_from + json:JSON 解 RawType,TryFrom 转目标类型。典型 schema 迁移场景。
8.11 Tuple 的 FromRow:按位置索引
除了 struct 派生,sqlx 给 1-16 元组做了 blanket impl(from_row.rs:326-514):
rust
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...)取每个元组元素。用法:
rust
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(或者 Vec,如果所有列同类型)。这条上限对应 Rust 生态里对 tuple 的通用默认——serde 对 tuple 的支持也是 1-16、std 的 Tuple trait 也是 1-12。
8.12 与 serde::Deserialize 的对照
FromRow 和 serde::Deserialize 都是"把外部数据反序列化成 struct"的派生宏,但设计走向完全不同。
| 维度 | FromRow | Deserialize |
|---|---|---|
| 输入 | &'r R: Row(具体 Row 类型) | Deserializer trait object |
| 数据访问方式 | try_get(name) 按索引 | Visitor 模式(visit_str、visit_i32 等) |
| 字段 attribute | rename / default / flatten / ... | rename / default / flatten / skip / ... |
| 容器 attribute | rename_all / default | rename_all / default / tag / untagged 等 |
| 错误类型 | sqlx::Error | 任意 de::Error |
| enum 支持 | 不支持 | 支持(tagged / untagged 多种形态) |
| 生成代码大小 | 每字段一条 try_get? 链 | Visitor impl 大数百行 |
最大差异在 enum 支持。serde 支持 enum(这是它最复杂的部分——tagged / untagged / adjacent / internal 四种 encoding),sqlx 的 FromRow 不支持——因为 SQL 行不是树状数据,没有 enum 的自然对应。
attribute 交集很大——rename、default、flatten、skip 的语义在两个宏里几乎一致。这不是巧合——sqlx 的派生显然借鉴了 serde 的命名习惯,让从 serde 过来的用户几乎零学习成本。
错误类型不同——sqlx 的错误都是 sqlx::Error(ColumnDecode / ColumnNotFound 等),serde 的错误是数据格式自己定义的 trait object。这反映了目标领域的差异——sqlx 只处理 SQL 一种数据源,错误类型枚举够用;serde 要处理任意格式,错误必须 trait 化。
8.12.1 与 diesel::Queryable 的对照
Diesel 的 Queryable 和 sqlx 的 FromRow 解决同一问题但做法差别很大:
rust
// 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!宏另做的一套检查。 - Queryable 派生要求
#[derive(Queryable)]字段类型按顺序和 schema.rs 对应 —— 错一个编译失败。sqlx 的#[derive(FromRow)]按字段名 try_get,顺序不重要、列可以比 struct 多。 - Queryable 不支持 flatten —— Diesel 用另一套
Associations+joinable!宏处理嵌套关系。sqlx flatten 简单直接。
这两种设计反映的是两个不同的"真理来源":
- 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.12.2 历史演进
FromRow 的关键演进节点:
- 0.3:首次引入 FromRow,只有基本字段映射,无派生。
- 0.4:引入
#[derive(FromRow)]派生;支持rename。 - 0.5:添加
flattenattribute;tuple 的 FromRow 从 1-12 扩到 1-16。 - 0.6:
default的字段级实现合入;容器级default在 0.7 加入。 - 0.7:GAT 之后
FromRow<'r, R>的'r生命周期参数清晰化;rename_all加入;支持 7 种 case 风格。 - 0.8(本书版本):
try_from/json/json(nullable)/skip四个 attribute 全部齐备;容器级default和字段级default的组合语义稳定。
每次加 attribute 都是"从生产反馈里长出来的需求"——rename 是最早的(列名和字段名不一致是常见痛点),flatten 是中期(复用通用字段集),json 是后期(serde 集成流行起来之后)。这种"以需求驱动的增量演进"是良好开源项目的标志——不预想所有 attribute 全开发,而是每版本加一两个真正有用的。
8.12.3 派生宏里的 lifetime 处理细节
row.rs:44-66 有一段看似平淡但很重要的代码——如何处理 struct 带不带生命周期参数:
rust
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 没带生命周期
rust
#[derive(FromRow)]
struct User { id: i32, name: String }provided = true —— 派生宏添加一个默认 'a。生成:
rust
impl<'a, R: Row> FromRow<'a, R> for User { ... }情况 B:用户 struct 自己带生命周期
rust
#[derive(FromRow)]
struct UserRef<'a> { id: i32, name: &'a str }provided = false —— 复用用户的 'a。生成:
rust
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 传到 Row: 'a 位置,让 try_get::<&'a str, _> 能从 row 借出字节。这种零拷贝 fetch在特定场景(解析大量只读数据)比 owned String 快两倍以上。
8.13 本章小结
本章把 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)—— 前者非 NULL + 非 null;后者 NULL 和 null 严格区分。
- skip(§8.10)—— 最简单,直接
Default::default()。 - tuple 的 1-16 blanket impl(§8.11)—— 按 usize 位置索引;超过 16 字段必须用 struct。
- 与 serde::Deserialize 的对照(§8.12)—— attribute 集合近似(有意借鉴);enum 支持 / 错误类型 / 数据源方向上有根本差异。
8.14 实战:常见 FromRow 问题与排查
问题 1:派生失败 "trait bound T: Decode not satisfied"
常见原因:字段类型在启用的 feature 下没有 Decode/Type 实现。比如 chrono::DateTime<Utc> 需要 chrono feature,Uuid 需要 uuid feature。
排查:Cargo.toml 里加对应 feature;或者改字段类型为驱动原生支持的(比如 OffsetDateTime 换成 PrimitiveDateTime)。
问题 2:运行时 Error::ColumnNotFound("field_name")
常见原因:SQL 里没 SELECT 那一列、或列名和字段名不一致(大小写 / snake-case vs camelCase / 有没有表前缀)。
排查:看 row.columns() 实际列名;加 #[sqlx(rename = "...")] 或 #[sqlx(rename_all = ...)] 修正。
问题 3:ColumnDecode 但类型看起来一致
常见原因:Postgres 的 NUMERIC 不对应 f64(对应 BigDecimal);TIMESTAMP 和 TIMESTAMPTZ 的 Rust 类型不同;MySQL 的 BIT(1) 不能直接 decode 成 bool(需要先 decode 成 Vec<u8> 再转)。
排查:看 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 日常 80% 的坑。根本方法永远是:读错误类型 + 看 row.columns() 的实际 schema。
8.14.1 FromRow 设计的通用启示
跳出 sqlx 看这个派生宏的设计,有几条可以迁移到其他 Rust 项目的通用启示:
1. 派生宏是"契约代码生成器"——让它失败而不是让它蒙混
sqlx 对 flatten + json、try_from + json(nullable) 两种非法组合直接 panic! 产生编译错误——而不是"忽略一个 attribute 让展开看起来正常"。后者会让用户写出看似合法的代码跑到运行时才发现行为不对。让编译期报错永远优于让运行时蒙混——这条原则在所有派生宏设计里适用。
2. 默认路径要是最简单的,attribute 加一个改变一点
基础派生(无任何 attribute)展开是"每字段 try_get"——极简,新手零学习成本。每个 attribute 都是对这条基线的局部修改——加 rename 改列名、加 default 包一层 or_else、加 flatten 换成递归 from_row。没有哪个 attribute 是"翻天覆地重构展开形态"。这种"渐进式修改基线"的设计让宏的每种组合都可预测。
3. 复杂组合用 match 枚举而不是 if-else 链
7 种 attribute 的 3²=9 种组合(flatten × try_from × json),sqlx 在 row.rs:100 用一个显式 match 枚举所有合法组合和两个非法组合。相比于一串 if-else,match 保证exhaustiveness——编译器会在你新增一个枚举 variant 时提醒漏了哪个分支。这条技巧在"你要处理 N 个维度的组合"场景值得借鉴。
4. 命名和其他生态库对齐
rename / default / flatten / skip 这套命名直接借自 serde——让从 serde 迁移来的用户零学习成本。Rust 生态里好的派生宏都在互相"偷"命名(serde / thiserror / clap 的 attribute 集有大量交叉)——这不是抄袭,是生态一致性。
读完本章,你对如何设计"一个合理、可维护、对用户友好的 derive macro"应该有了一套直觉。sqlx 的 FromRow 是这类设计的教科书样本——从 0.3 的简单版本渐进扩展到 0.8 的丰富 attribute 集,每一步都没破坏向后兼容、没引入魔法行为。
8.14.2 手写 FromRow 的合法场景
虽然 #[derive(FromRow)] 覆盖了 95% 场景,手写 FromRow 仍然有它的位置。典型三种情况:
1. 字段需要跨列计算
rust
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. 有条件字段(某列在或不在时行为不同)
rust
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 lookup 约 20ns。如果你的热点代码 per row 用掉微秒级——这 20ns 乘字段数 × 行数累计可观。手写可以按 try_get(0) 位置索引,省 HashMap——但换来"列顺序必须和 SQL 匹配"的脆弱性。
这三种都是 derived FromRow 覆盖不到的小众场景——大多数业务代码不需要,但当你需要时知道"派生宏不够用时我可以直接手写"是重要的。
8.15 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.16 实战:组合使用各 attribute 的大型例子
最后给一个"尽量多用 attribute"的真实工程例子——帮助你把本章内容融成一个整体。假设某 SaaS 系统的 User 聚合视图:
rust
#[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 vs JSON null
#[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:
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)] 的 7 种 attribute 覆盖了 95% 的业务场景——剩下 5% 手写 FromRow(§8.14.2 讨论过)。
学会合理组合这些 attribute,意味着你不用再为"怎么把 SQL 行映射到 Rust struct"单独写 mapper 函数——#[derive(FromRow)] 把这份重复劳动收走,让你专注于业务逻辑。
8.16.1 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 字面量。
rust
// 用 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.16.15 FromRow 的小惊喜:() 和 tuple 的默认实现
除了 derive 生成的 struct impl 和 macro 生成的 tuple impl,FromRow 还有两个零成本的预置实现值得一提:
impl FromRow for ()(from_row.rs:313-321)让 "不关心结果的查询能用 query_as::<(), _>" ——虽然这种场景下 execute 通常更合适,但在 channel 风格的代码里(如 tokio::select! 的某个臂只要知道 fetch 成功)偶尔用到。
impl FromRow for (T,)——单元素元组——这是容易被忽视但很常用的技巧。当你只要一列值时:
rust
// 用单元素 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.16.2 FromRow 和 Trait 生态的互动
sqlx 的 FromRow 不是孤立的——它和 Rust 社区几个常用 trait 有深度互动:
Default trait:容器级 #[sqlx(default)] 要求 struct 实现 Default。对不含 Option 的 struct 手写 Default 一般简单:
rust
#[derive(Default, sqlx::FromRow)]
#[sqlx(default)]
struct Config { timeout_ms: u32, max_retries: u32, enabled: bool }
// Default::default() = Config { 0, 0, false }如果字段类型自己都实现了 Default(数字 = 0、String = 空、Option = None),#[derive(Default)] 就够了。复杂业务 struct 可能要手写 Default 明确每个字段的默认值。
TryFrom trait:#[sqlx(try_from = "String")] 要求目标类型实现 TryFrom<String>。新手常见错误是把 From<String> 当 TryFrom<String> 用——From 永远成功、没有 Result,不能表达"转换可能失败"。sqlx 故意只支持 TryFrom——强制用户思考"这个转换可能失败的分支是什么"。
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.16.3 和《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 支持数据格式任意顺序(JSON key 可以乱序)、sqlx 的 Row 是列式顺序固定。
这条对照让两本书互为脚注——读过《Serde》第 13 章的读者几乎秒懂 sqlx derive;反过来也成立。这是 Rust derive 宏生态统一的惯用法——理解一个就理解一类。
8.17 下一章指路
下一章我们开始第三部分"查询 API"——query() 和 query_as() 两个顶层函数、Query<'q, DB, A> 结构、从"字符串 SQL"到"可执行 Future"的完整类型转换链。从 FromRow 这个"最后一公里"再往前走一步:如何通过一个 Rust 函数调用,把"字符串 SQL + bind 参数 + FromRow 结构"组装成一个可以送给 Executor 执行的 Query 对象。这条路径是 sqlx 顶层 API 的起点——用户代码里每一次 sqlx::query(...) 都从那里出发。