describe 与可空性推断
主线位置:编译期检查主线的中段。
query!在编译时连上库以后,对 SQL 调一次Executor::describe,拿回参数类型、列类型和每列的「可不可能是 NULL」,再据此生成代码。这一页讲驱动这一侧:describe发了什么、可空性怎么推断、哪里会推错。宏怎么把Option<bool>变成Option<T>或T、列名后缀怎么覆盖推断,在生成的代码。
flowchart TB
desc["describe(sql)"] --> prep["get_or_prepare"]
prep --> pd["Parse Describe Sync"]
pd --> meta["列类型和列来源"]
meta --> attq["查 attnotnull"]
attq --> avail{"支持 EXPLAIN"}
avail -->|是| expl["EXPLAIN EXECUTE"]
expl --> visit["visit_plan 标可空"]
visit --> merge["patch.or(attnotnull)"]
avail -->|否| out
merge --> out["Vec Option bool"]
本页实验都在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上跑,依赖 sqlx = "=0.9.0";对照组是同一个程序换成 0.8.6。表建在自己的 schema pg2 里:
create table author (id int8 primary key, name text not null, bio text);
create table post (id int8 primary key, author_id int8 not null references author, title text not null);
Executor::describe
describe 是 Executor 的方法,只在 offline feature 下存在(macros 会打开它,见 Executor 与 Acquire)。Postgres 的实现很短:
Box::pin(async move {
self.wait_until_ready().await?;
let (stmt_id, metadata) = self
.get_or_prepare(sql.as_str(), &[], true, None, true)
.await?;
let nullable = self.get_nullable_for_columns(stmt_id, &metadata).await?;
Ok(crate::describe::Describe {
columns: metadata.columns.clone(),
nullable,
parameters: Some(Either::Left(metadata.parameters.clone())),
})
})
参数和列的类型就是 prepare 的副产品:Parse + Describe + Sync 换回 ParameterDescription 和 RowDescription,OID 再按 Postgres 类型里讲的办法变成 PgTypeInfo。get_or_prepare 的参数有两处和普通执行不同:参数类型给的是空切片 &[],全让服务端推断;最后一个参数 resolve_column_origin 是 true,于是 resolve_statement_metadata 还会用 ColumnResolver 查出每列来自哪张表的哪一列,填进 PgColumn 的 origin。ColumnOrigin 是 0.9 随 sqlx.toml(#3383)加的,query! 用它实现 [macros.table-overrides] 的按列类型覆盖。persistent 也固定是 true:后面的 EXPLAIN EXECUTE 要按名字引用这条语句。
返回的 Describe 有三个字段,nullable 是每列一个 Option<bool>:Some(true) 可能是 NULL,Some(false) 不可能是 NULL,None 不知道。
实验:一次 describe 发了什么
src/bin/describe.rs 的最后一段,通过代理对一条 LEFT JOIN 调 describe(表里有数据):
let d = (&mut conn)
.describe("select a.id, p.title from author a left join post p on p.author_id = a.id".into_sql_str())
.await?;
[conn 1] C->S Parse(sqlx_s_1, "select a.id, p.title from author a left join post p on p.aut…"), Describe(S, sqlx_s_1), Sync
[conn 1] S->C ParseComplete, ParameterDescription, RowDescription, ReadyForQuery(I)
[conn 1] C->S Query("SELECT attrelid table_oid, attrelid::regclass::text table_na…")
[conn 1] S->C RowDescription, DataRow(列长 5/6/10), DataRow(列长 5/4/13), CommandComplete("SELECT 2"), ReadyForQuery(I)
[conn 1] C->S Parse(sqlx_s_2, "SELECT NOT attnotnull FROM ( ( SELECT $1::int4 AS idx, $2::i…"), Describe(S, sqlx_s_2), Sync
[conn 1] S->C ParseComplete, ParameterDescription, RowDescription, ReadyForQuery(I)
[conn 1] C->S Bind(portal unnamed, stmt sqlx_s_2; [4] 00 00 00 00; [4] 00 00 40 b8; [2] 00 01; [4] 00 00 00 01; [4] 00 00 40 bf; [2] 00 03), Execute, Close(P, unnamed), Sync
[conn 1] S->C BindComplete, DataRow(列长 1), DataRow(列长 1), CommandComplete("SELECT 2"), CloseComplete, ReadyForQuery(I)
[conn 1] C->S Parse(sqlx_s_3, "EXPLAIN (VERBOSE, FORMAT JSON) EXECUTE sqlx_s_1"), Describe(S, sqlx_s_3), Sync
[conn 1] S->C ParseComplete, ParameterDescription, RowDescription, ReadyForQuery(I)
[conn 1] C->S Bind(portal unnamed, stmt sqlx_s_3), Execute, Close(P, unnamed), Sync
[conn 1] S->C BindComplete, DataRow(列长 1643), CommandComplete("EXPLAIN"), CloseComplete, ReadyForQuery(I)
parameters Some(Left([]))
column id origin Table(TableColumn { table: "author", name: "id" })
column title origin Table(TableColumn { table: "post", name: "title" })
六个来回,四件事:prepare;ColumnResolver 的一条简单查询(DataRow 的第三列是 {"(1,id)"}、{"(3,title)"} 这样的文本数组);可空性的第一步,一条带 6 个参数的预备语句;第二步,EXPLAIN。后两条用的是普通的 query_scalar / query_as,所以和用户的语句一样进了语句缓存(sqlx_s_2、sqlx_s_3)。0.8.6 跑同一段,少了 ColumnResolver 那一来回,其余一样。
第一步:attnotnull
get_nullable_for_columns 先问系统表。RowDescription 里每列带着两个字段:表的 OID 和列号(relation_id、relation_attribute_no),列直接来自某张表时有值,来自表达式时是 0(sqlx 解成 None)。函数用 QueryBuilder 把每列拼成一个 UNION ALL 的分支:
let mut nullable_query = QueryBuilder::new("SELECT NOT attnotnull FROM ( ");
let mut separated = nullable_query.separated("UNION ALL ");
let mut column_iter = meta.columns.iter().zip(0i32..);
if let Some((column, i)) = column_iter.next() {
separated.push("( SELECT ");
separated
.push_bind_unseparated(i)
.push_unseparated("::int4 AS idx, ");
separated
.push_bind_unseparated(column.relation_id)
.push_unseparated("::int4 AS table_id, ");
separated
.push_bind_unseparated(column.relation_attribute_no)
.push_unseparated("::int2 AS col_idx ) ");
}
// ...
nullable_query.push(
") AS col LEFT JOIN pg_catalog.pg_attribute \
ON table_id IS NOT NULL \
AND attrelid = table_id \
AND attnum = col_idx \
ORDER BY idx",
);
每列三个参数:序号、表 OID、列号。上面的 Bind 里就是 0, 0x40b8 (author), 1 (id) 和 1, 0x40bf (post), 3 (title)。LEFT JOIN pg_attribute 之后:
- 列来自表:得到
NOT attnotnull,声明了not null(或者是主键)的列是Some(false),否则Some(true)。 - 列来自表达式:
table_id是 NULL,连接不上,NOT attnotnull是 NULL,解成None。注释说这样写「less code」:不在 Rust 里把表达式列挑出来,交给 Postgres 返回 NULL。
只看 attnotnull 有一个明显的漏洞:post.title 声明了 not null,可 LEFT JOIN 没匹配上时它照样是 NULL。第二步就是补这个洞。
第二步:EXPLAIN 只负责标「可空」
服务端支持 EXPLAIN 时(is_explain_available 看 ParameterStatus 里有没有 CockroachDB、Materialize、QuestDB 的版本号),结果被 EXPLAIN 修正:
for (nullable, patch) in nullables.iter_mut().zip(nullable_patch) {
*nullable = patch.or(*nullable);
}
nullables_from_explain 拼出 EXPLAIN (VERBOSE, FORMAT JSON) EXECUTE sqlx_s_N,有参数时每个参数填 NULL(注释:fill the arguments list with NULL, which should theoretically be valid),把 JSON 解成只留四个字段的 Plan(Join Type、Parent Relationship、Output、Plans),然后从根节点开始 visit_plan:
fn visit_plan(plan: &Plan, outputs: &[String], nullables: &mut Vec<Option<bool>>) {
if let Some(plan_outputs) = &plan.output {
// all outputs of a Full Join must be marked nullable
// otherwise, all outputs of the inner half of an outer join must be marked nullable
if plan.join_type.as_deref() == Some("Full")
|| plan.parent_relation.as_deref() == Some("Inner")
{
for output in plan_outputs {
if let Some(i) = outputs.iter().position(|o| o == output) {
// N.B. this may produce false positives but those don't cause runtime errors
nullables[i] = Some(true);
}
}
}
}
if let Some(plans) = &plan.plans {
if let Some("Left") | Some("Right") = plan.join_type.as_deref() {
for plan in plans {
visit_plan(plan, outputs, nullables);
}
}
}
}
outputs 是根节点的 Output,也就是查询的每一列在计划里的写法(a.id、p.title)。规则只有两条:Full 连接的所有输出都可空;外连接下面、Parent Relationship 是 Inner 的那个子节点,它输出的列可空。visit_plan 只会把列标成 Some(true),从不标 Some(false);配上 patch.or(*nullable),EXPLAIN 只能把「不可空」改成「可空」,不能反过来。注释说宁可误报:多标一个 Option 不会在运行时出错。
实验:五种列
src/bin/describe.rs 对六条 SQL 调 describe,打印每列的名字、类型和 nullable(i)。表里先插 1000 个作者、10 篇文章,analyze 一遍,在默认的 plan_cache_mode = auto 和 force_generic_plan 下各跑一次:
const QUERIES: &[(&str, &str)] = &[
("普通列", "select id, name, bio from author"),
("count(*)", "select count(*) from post"),
("coalesce", "select coalesce(bio, '') from author"),
("表达式", "select id + 1 as next, name || '!' as shout from author"),
("LEFT JOIN", "select a.id, p.title from author a left join post p on p.author_id = a.id"),
(
"LEFT JOIN+LIMIT",
"select a.id, p.title from author a left join post p on p.author_id = a.id limit 10",
),
(
"LEFT JOIN + $1",
"select a.id, p.title from author a left join post p on p.author_id = a.id where a.id = $1",
),
];
=== 有数据,plan_cache_mode = auto ===
普通列 id INT8 Some(false) | name TEXT Some(false) | bio TEXT Some(true)
count(*) count INT8 None
coalesce coalesce TEXT None
表达式 next INT8 None | shout TEXT None
LEFT JOIN id INT8 Some(false) | title TEXT Some(true)
LEFT JOIN+LIMIT id INT8 Some(false) | title TEXT Some(false)
LEFT JOIN + $1 id INT8 Some(false) | title TEXT Some(false)
=== 有数据,plan_cache_mode = force_generic_plan ===
普通列 id INT8 Some(false) | name TEXT Some(false) | bio TEXT Some(true)
count(*) count INT8 None
coalesce coalesce TEXT None
表达式 next INT8 None | shout TEXT None
LEFT JOIN id INT8 Some(false) | title TEXT Some(true)
LEFT JOIN+LIMIT id INT8 Some(false) | title TEXT Some(false)
LEFT JOIN + $1 id INT8 Some(false) | title TEXT Some(true)
前五行在两种模式下一样:
- 普通列:全靠第一步。
id是主键、name声明了not null,Some(false);bio没有约束,Some(true)。 count(*)、coalesce、表达式:没有表 OID,第一步给None;计划里也没有外连接,第二步还是None。count(*)永远不会是 NULL,coalesce(bio, '')也不会,但 sqlx 不分析表达式本身,一律「不知道」。- LEFT JOIN:
psql里看计划,是Hash Left Join,post那一侧在Hash节点下面,Parent Relationship是Inner,p.title被第二步改成Some(true):
Hash Left Join
Output: a.id, p.title
Hash Cond: (a.id = p.author_id)
-> Seq Scan on pg2.author a
Output: a.id, a.name, a.bio
-> Hash
Output: p.title, p.author_id
-> Seq Scan on pg2.post p
Output: p.title, p.author_id
后两行是推断出错的两种情况,title 可能是 NULL,结果却是 Some(false)。
问: count(*) 明明不可能是 NULL,None 到了 query! 那里会变成什么?
答: 变成 Option<i64>。宏把 None 和 Some(true) 一样当作可空,只有 Some(false) 才生成非 Option 的类型。用 let () = r.count; 故意制造一个类型错误,rustc 报的是 expected `Option<i64>`, found `()`。不想要 Option 就在列名上写 as "count!",覆盖语法见生成的代码。默认偏向 Option 的理由和 visit_plan 的注释一样:多一层 Option 只是麻烦,少一层会在运行时报 unexpected null。
顶层是 Limit:加上 limit 10,计划的根变成 Limit,Hash Left Join 成了它的子节点。visit_plan 只在当前节点自己是 Left / Right 连接时才往下走,根节点 Limit 没有 Join Type,递归在第一层就停了,p.title 保留第一步的 Some(false)。ORDER BY 带来的 Sort 盖在连接上面时也一样:src/bin/describe_sort.rs 给同一条 LEFT JOIN 加上 order by p.title,nullable 从 [Some(false), Some(true)] 变成 [Some(false), Some(false)]。
带参数的 LEFT JOIN:auto 下 Some(false),force_generic_plan 下 Some(true)。原因在 EXECUTE sqlx_s_N(NULL) 这个 NULL 上,下一节讲。
参数填 NULL 和 generic plan
psql 里手动重现 EXPLAIN EXECUTE ... (NULL):
prepare s1(int8) as select a.id, p.title from author a left join post p on p.author_id = a.id where a.id = $1;
explain (verbose) execute s1(null);
set plan_cache_mode = force_generic_plan;
explain (verbose) execute s1(null);
QUERY PLAN
-------------------------------------------
Result (cost=0.00..0.00 rows=0 width=40)
Output: a.id, p.title
One-Time Filter: false
(3 rows)
SET
QUERY PLAN
-------------------------------------------------------------------------------------------
Nested Loop Left Join (cost=0.15..31.59 rows=5 width=40)
Output: a.id, p.title
-> Index Only Scan using author_pkey on pg2.author a (cost=0.15..8.17 rows=1 width=8)
Output: a.id
Index Cond: (a.id = $1)
-> Seq Scan on pg2.post p (cost=0.00..23.38 rows=5 width=40)
Output: p.id, p.author_id, p.title
Filter: (p.author_id = $1)
(8 rows)
默认模式下,前几次 EXECUTE 用的是按实参定制的 custom plan。实参是 NULL,a.id = NULL 恒为假,规划器把整个连接折叠成一个 One-Time Filter: false 的 Result 节点,计划树里没有连接了,visit_plan 无从标起。force_generic_plan 让规划器不看实参,$1 原样留在计划里,Nested Loop Left Join 和它的 Inner 子节点都在。
Executor::describe 本身不改 plan_cache_mode,上面的 auto 组就是证据;0.8.6 的 describe 在同样两种模式下输出完全一样。0.9 的改动在宏里:CachingDescribeBlocking::describe 每次为一个 DATABASE_URL 建好连接,先执行一段:
#[cfg(feature = "postgres")]
if DB::NAME == sqlx_postgres::Postgres::NAME {
conn.execute(
"
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_settings
WHERE name = 'plan_cache_mode'
) THEN
SET SESSION plan_cache_mode = 'force_generic_plan';
END IF;
END $$;
",
)
.await?;
}
先查 pg_settings,是因为不是每个版本的服务端都有这个设置(提交记录:only set force_generic_plan for versions that support it)。建好的连接按 URL 存在一个 static 的 CachingDescribeBlocking 里,同一个宏进程里之后的 describe 都复用它,设置一次就够。同一条 SQL 写进 query!,0.9.0 和 0.8.6 生成的类型不同(src/bin/macro_types.rs,let () = r.title;):
error[E0308]: mismatched types
--> src/bin/macro_types.rs:12:7
|
12 | let () = r.title;
| ^^ ------- this expression has type `Option<std::string::String>`
| |
| expected `Option<String>`, found `()`
0.8.6 的同一处:
--> src/bin/macro_types.rs:12:7
|
12 | let () = r.title;
| ^^ ------- this expression has type `std::string::String`
| |
| expected `String`, found `()`
#3541(首次发布于 0.9.0)修的是 #3539:Left join with a WHERE condition falsely marks columns as non-nullable,复现正是上面这种 LEFT JOIN ... WHERE foo.id = $1。PR 说明给出了原因:If a prepared statement has query parameters it needs to fill then in with a valid argument and uses NULL for every one because it is always a valid argument. This causes the query planner to give a query plan back that is optimized when the function arguments are all NULL, this is not always correct. 改法是 forces postgres to use a generic execution plan when describing a prepared statement with function arguments。合并时的提交记录显示几经调整(set plan_cache_mode in transaction、only set force_generic_plan for versions that support it),最后一条是 set generic execution plan only in query macro:设置放在 sqlx-macros-core 的连接上,Executor::describe 不受影响。CHANGELOG 把它列为破坏性改动:may alter the output of the query!() macros for certain queries in Postgres。
推断不出来的时候
两步推断都是启发式的,结果取决于编译时那个库里的计划。总结成表:
| 列 | 第一步 attnotnull | 第二步 EXPLAIN | 结果 |
|---|---|---|---|
表的列,有 not null | Some(false) | 不在外连接内侧时不动 | Some(false) |
| 表的列,没有约束 | Some(true) | 不动 | Some(true) |
表达式、聚合、coalesce | None | 不在外连接内侧时不动 | None(宏当可空) |
| 外连接内侧的列,计划树里认得出来 | 任意 | Some(true) | Some(true) |
| 外连接内侧的列,但根节点不是连接 | Some(false) | 没走到 | Some(false),错 |
最后一行是会在运行时出错的那种:query! 生成了 String,库里却来了 NULL,fetch 报 unexpected null; try decoding as an `Option`。遇到时在列名上加 ?(p.title as "title?")强制可空。
M2 Max、macOS、rustc 1.99.0、Postgres 16.14。把上面的 author、post 换成刚建好、一行数据都没有、也没 analyze 过的两张表,同样对 select a.id, p.title from author a left join post p on p.author_id = a.id 调 describe。a.id 和 p.title 的 nullable 分别是什么?
动脑筋答案
src/bin/describe.rs 的前一半就是在空表上跑的:
=== 空表,plan_cache_mode = auto ===
普通列 id INT8 Some(false) | name TEXT Some(false) | bio TEXT Some(true)
count(*) count INT8 None
coalesce coalesce TEXT None
表达式 next INT8 None | shout TEXT None
LEFT JOIN id INT8 Some(true) | title TEXT Some(false)
LEFT JOIN+LIMIT id INT8 Some(false) | title TEXT Some(false)
LEFT JOIN + $1 id INT8 Some(false) | title TEXT Some(false)
=== 空表,plan_cache_mode = force_generic_plan ===
...
LEFT JOIN id INT8 Some(true) | title TEXT Some(false)
...
LEFT JOIN + $1 id INT8 Some(false) | title TEXT Some(true)
正好反了:a.id 被标成可空,p.title 不可空。空表没有统计信息,规划器按默认估计选了另一种计划:
Hash Right Join
Output: a.id, p.title
Inner Unique: true
Hash Cond: (p.author_id = a.id)
-> Seq Scan on pg2.post p
Output: p.id, p.author_id, p.title
-> Hash
Output: a.id
-> Seq Scan on pg2.author a
Output: a.id
LEFT JOIN 被改写成以 post 为外侧的 Right 连接,author 进了 Hash,成了 Inner 子节点。visit_plan 对 Left 和 Right 一视同仁,都去标 Inner 那一侧;可 Right 连接里补 NULL 的是外侧(post)。于是 a.id 被误标成 Some(true)(多一层 Option,无害),p.title 留着 attnotnull 的 Some(false)(有害)。上游 #3202 报告的计划里也是一个 "Join Type": "Right",至今未关闭。
后果在 query! 上看得到。对空表编译(src/bin/macro_types.rs),r.title 的类型是 std::string::String;然后插一个没有文章的作者再执行(src/bin/right_join_null.rs):
错误 error occurred while decoding column 1: unexpected null; try decoding as an `Option`
同样的表结构,有数据并 analyze 之后计划变回 Hash Left Join,推断就对了。生成的类型取决于编译时库里的统计信息,这是可空性推断依赖 EXPLAIN 的代价。
小结
Executor::describe(offlinefeature)=prepare(拿参数和列的类型,0.9 起还有列来源)+get_nullable_for_columns,一共六个来回;可空性用的两条查询也进语句缓存。- 第一步按
RowDescription里的表 OID 和列号查pg_attribute.attnotnull,表达式列得到None。第二步解析EXPLAIN (VERBOSE, FORMAT JSON),只把Full连接和外连接Inner子节点的输出标成可空,只能改成「可空」,不能改成「不可空」。 count(*)、coalesce、算术表达式都是None,宏当成可空,生成Option;要非空用"col!"。- 盲区:根节点不是连接(
Limit、Sort、Aggregate)时不往下走;Right连接标错一侧;结果取决于编译时的计划和统计信息。推错时用"col?"。 - 有参数时
EXPLAIN EXECUTE给每个参数填NULL,custom plan 可能把连接折叠掉。0.9(#3541)在宏的连接上设plan_cache_mode = force_generic_plan;Executor::describe自己不设,和 0.8.6 输出一样。