Rust in Everything
基于 sqlx 0.9.0

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)。

📝NOTE
没有蠢问题

问: 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 `()`
📝NOTE
冷知识:generic plan 只在宏的连接上打开

#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 nullSome(false)不在外连接内侧时不动Some(false)
表的列,没有约束Some(true)不动Some(true)
表达式、聚合、coalesceNone不在外连接内侧时不动None(宏当可空)
外连接内侧的列,计划树里认得出来任意Some(true)Some(true)
外连接内侧的列,但根节点不是连接Some(false)没走到Some(false),错

最后一行是会在运行时出错的那种:query! 生成了 String,库里却来了 NULL,fetch 报 unexpected null; try decoding as an `Option`。遇到时在列名上加 ?(p.title as "title?")强制可空。

❗IMPORTANT
动脑筋:两张空表做 LEFT JOIN

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(offline feature)= 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 输出一样。

💬 关联讨论

发起讨论
加载中...