Rust in Everything
基于 sqlx 0.9.0

离线模式与 sqlx.toml

主线位置:编译期检查主线的另一条岔路。流水线里,URL 为空或 SQLX_OFFLINE=true 时 query! 不连库,改读 .sqlx 目录下的 JSON,之后的代码生成和在线时完全一样。这一页讲这些 JSON 怎么来(cargo sqlx prepare)、里面是什么、怎么读回来、怎么检查它没过期,以及 sqlx.toml 能改宏的哪些行为。

flowchart TB
  prep["cargo sqlx prepare"] --> chk["cargo check 带环境变量"]
  chk --> live["query! 在线 describe"]
  live --> save["save_in 写 JSON"]
  save --> dir[".sqlx 目录"]
  dir --> off["之后的构建 没有 URL"]
  off --> load["from_data_file 读回"]
  load --> gen["expand_with_data"]
  live --> gen

本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上跑,sqlx = "=0.9.0",sqlx-cli 0.9.0(带 sqlx-toml feature)。实验 crate offline-demo 只有一个 main.rs,两个宏:

let a = sqlx::query!("select id, name, bio from author where id = $1", 1_i64).fetch_one(&pool).await?;
let n = sqlx::query_scalar!("select count(*) from post").fetch_one(&pool).await?;
println!("{} {:?} {:?} {:?}", a.id, a.name, a.bio, n);

命令里的 -- --offline 是传给 cargo check 的参数(实验机器不联网),平时不需要。

cargo sqlx prepare 做了什么

sqlx-cli 不依赖 sqlx-macros-core,它不会自己 describe,而是让你的 crate 再编译一遍,由宏写文件(见源码地图)。run_prepare_step 做四件事:

  1. 建好目标目录,删掉里面的 query-*.json(注释:Only delete sqlx-*.json files to avoid accidentally deleting any user data)。
  2. 建 target/sqlx-tmp。
  3. 让该重编的 crate 重编:setup_minimal_project_recompile 把当前 package 源文件的 mtime 改成现在;加 --workspace 时改的是所有依赖 sqlx-macros 的 workspace 成员,再加 --all 还会 cargo clean -p 掉 workspace 外依赖 sqlx-macros 的包(sqlx 自己除外)。宏只在 crate 重新编译时才跑,不重编就什么也不会写。
  4. 跑 cargo check,带上环境变量:
        check_command
            .arg("check")
            .args(&ctx.cargo_args)
            .env("SQLX_TMP", tmp_dir)
            .env("SQLX_OFFLINE", "false")
            .env("SQLX_OFFLINE_DIR", cache_dir);

        if let Some(database_url) = &ctx.connect_opts.database_url {
            check_command.env("DATABASE_URL", database_url);
        }

SQLX_OFFLINE=false 保证宏连库;SQLX_OFFLINE_DIR 告诉宏把结果存到哪。宏这一侧,expand_with 在线 describe 之后把 SQLX_OFFLINE_DIR 作为保存目录交给 expand_with_data,生成完代码再调 save_in。

$ cargo sqlx prepare -- --offline
...
   Compiling sqlx-macros v0.9.0
    Checking sqlx v0.9.0
    Checking offline-demo v0.1.0 (.../exp-sqlx-macros/offline-demo)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 16.00s
query data written to .sqlx in the current directory; please check this into version control
$ ls .sqlx
query-9138cb233222904443cec7573befff097160324b6afe5acac0b3ade20d658b50.json
query-d69293fdd7117a2d3160722af85dcfc7de72c10fb4b7bcbc6926b4f1af47a29b.json

写文件的条件只是「在线、并且设了 SQLX_OFFLINE_DIR」,和 cargo sqlx prepare 无关。普通的 cargo build 带上这个变量也会写:

$ SQLX_OFFLINE_DIR=$PWD/../offline-dir-test cargo build -q --offline
$ ls ../offline-dir-test
query-9138cb233222904443cec7573befff097160324b6afe5acac0b3ade20d658b50.json
query-d69293fdd7117a2d3160722af85dcfc7de72c10fb4b7bcbc6926b4f1af47a29b.json

目录不存在时每个宏报一次 error: sqlx offline path does not exist: "...",宏不会替你建目录。

query-<hash>.json

文件名里的 hash 是 SQL 文本的 SHA-256(hash_string,注释说选 sha2 是因为 MySQL 和 Postgres 的依赖树里本来就有它):

$ printf '%s' 'select count(*) from post' | shasum -a 256
d69293fdd7117a2d3160722af85dcfc7de72c10fb4b7bcbc6926b4f1af47a29b  -

内容是 QueryData 用 serde_json::to_writer_pretty 序列化的结果,四个字段:db_name、query、describe、hash。describe 就是 Describe 本身(offline feature 给它和 PgTypeInfo、PgColumn 加上了 serde 的 derive,serde 怎么生成这些代码见 serde 卷)。count(*) 那个文件:

{
  "db_name": "PostgreSQL",
  "query": "select count(*) from post",
  "describe": {
    "columns": [
      {
        "ordinal": 0,
        "name": "count",
        "type_info": "Int8",
        "origin": "Expression"
      }
    ],
    "parameters": {
      "Left": []
    },
    "nullable": [
      null
    ]
  },
  "hash": "d69293fdd7117a2d3160722af85dcfc7de72c10fb4b7bcbc6926b4f1af47a29b"
}

另一个文件里,三列的 origin 是 {"Table": {"table": "author", "name": "id"}} 这样的列来源,parameters 是 {"Left": ["Int8"]},nullable 是 [false, false, true]。nullable 里的 null 就是推断不出的 None,宏照样当可空处理。文件里存的是推断的结果,离线构建不会、也没法再推断一次:编译时库里的计划和统计信息决定了 nullable,这一刻被冻结进了 JSON。

save_in 写文件的方式是先删、再 create_new:

        // Prevent tearing from concurrent invocations possibly trying to write the same file
        // by using the existence of the file itself as a mutex.
        //
        // By deleting the file first and then using `.create_new(true)`,
        // we guarantee that this only succeeds if another invocation hasn't concurrently
        // re-created the file.

两个 crate 里写了同一条 SQL 时,两个 rustc 进程可能同时写同一个文件;create_new 失败(AlreadyExists)的一方直接返回 Ok(()),算对方写过了。末尾补一个换行,to avoid accidental modification by IDE and make github diff tool happier。

📝NOTE
冷知识:SQLX_TMP 设了,但没人读

上面 cargo check 带的 SQLX_TMP,在 0.9.0 的 sqlx-macros-core 里一处都没用到;target/sqlx-tmp 也一直是空目录。它是 0.7.0 引入一个文件一条查询(#2363)时的设计:PR 说明写的是 expand_with_data in sqlx-macros saves the query metadata to a temporary file, before moving it to the SQLX_OFFLINE_DIR path (the move/rename operation should be atomic)。问题出在 rename 不能跨文件系统:#2663 报告 CARGO_TARGET_DIR 和项目不在同一个文件系统时,cargo sqlx prepare 失败于 failed to move query file: PersistError(Os { code: 18, kind: CrossesDevices, message: "Invalid cross-device link" })。#2803(标题 Don't use separate temp dir for query jsons,首次发布于 0.7.3)去掉了临时文件,换成今天这种「先删再 create_new」,CLI 那边设变量、建目录的代码留了下来。

#2363 之前,所有查询存在一个 sqlx-data.json 里。0.7.0 的 CHANGELOG 说拆成每条一个文件 is intended to reduce the number of conflicts when merging branches in a project that both modified queries,--merged 也随之改名为 --workspace(决定写到 crate 的 .sqlx 还是 workspace 根的 .sqlx)。

读回来

没有 URL(或 SQLX_OFFLINE=true)时,expand_input 按 SQLX_OFFLINE_DIR、crate 的 .sqlx、workspace 的 .sqlx 的顺序找文件(见流水线),找到后调 DynQueryData::from_data_file。它先按文件路径查一个进程内的 MtimeCache,文件没变就不重新解析;解析后核对 JSON 里的 query 和宏里的 SQL 是否一字不差,不同就报 hash collision for saved query data。DynQueryData 的 describe 是 serde_json::Value,因为这时还不知道是哪个数据库;选好驱动以后 from_dyn_data 才把它解成 Describe<Postgres>。从缓存读出来的数据不会再存一遍(注释:the cache is our input so it's pointless to also write data for it)。

三种环境组合,对 offline-demo 各编一次(每次先 touch src/main.rs):

--- no DATABASE_URL, no SQLX_OFFLINE
   Compiling offline-demo v0.1.0 (...)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 9.33s
--- SQLX_OFFLINE=true + DATABASE_URL pointing at a dead port
   Compiling offline-demo v0.1.0 (...)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.35s
--- DATABASE_URL dead port, no SQLX_OFFLINE

error: error communicating with database: Connection refused (os error 61)
 --> src/main.rs:6:11
6 |   let n = sqlx::query_scalar!("select count(*) from post").fetch_one(&pool).await?;

error: could not compile `offline-demo` (bin "offline-demo") due to 2 previous errors

没有 URL 时什么都不用设,宏自己就读 .sqlx。SQLX_OFFLINE=true 的用处是第二种:URL 存在(比如 .env 里写着开发库),但构建环境连不上它,比如 Docker 构建。第三种 URL 在、库连不上,宏不会退回到 .sqlx,每个宏报一次连接错误。

prepare --check

.sqlx 是某一时刻的快照。SQL 改了、schema 改了,它都可能过期。prepare_check 不碰 .sqlx,把同样的 run_prepare_step 跑到 target/sqlx-prepare-check 里,再比两边:

  • target/sqlx-prepare-check 有、.sqlx 没有的文件名:失败,.sqlx is missing one or more queries。
  • .sqlx 有、新生成的没有:只是警告 potentially unused queries found in .sqlx。
  • 两边都有的文件:解析成 serde_json::Value 比较(to ignore superficial differences,空白和键的顺序不算),不同就失败。

先改 SQL:把 select count(*) from post 改成 select count(*) from post where id > 0,hash 变了,新文件在 .sqlx 里没有:

$ cargo sqlx prepare --check -- --offline
    Checking offline-demo v0.1.0 (...)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.24s
error: prepare check failed: .sqlx is missing one or more queries; you should re-run sqlx prepare

这种情况下离线构建本来就会失败,报的是另一句(第二种情况的原文在常见坑 database-url-not-set):

error: `SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE`
 --> src/main.rs:6:11
  |
6 |   let n = sqlx::query_scalar!("select count(*) from post where id > 0").fetch_one(&pool).await?;
  |           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

--check 真正多出来的价值在 SQL 没变、schema 变了的时候。把 SQL 改回去,重新 prepare,再在库里 alter table author alter column name drop not null:

$ cargo sqlx prepare --check -- --offline
error: prepare check failed: one or more query files differ (query-9138cb233222904443cec7573befff097160324b6afe5acac0b3ade20d658b50.json); you should re-run sqlx prepare

差别只有一处,nullable 的第二项从 false 变成了 true。这时离线构建不会失败:hash 只看 SQL 文本,文件还在,宏照旧生成 name: String,而真实的库里这一列已经可以是 NULL,运行时第一次遇到 NULL 才报 unexpected null(同样的报错在 describe 一页的实验里出现过)。所以 CI 里要连一个跑完迁移的库做 prepare --check,光有离线构建不够。注意 main.rs 里 a.name 用的是 {:?}:要是写 {},name 变成 Option<String> 后 cargo check 自己就编不过,prepare --check 报的是 `cargo check` failed with status: exit status: 101。

sqlx.toml

sqlx.toml 要打开 sqlx-toml feature(不在默认集里,原因见源码地图)。宏只读 $CARGO_MANIFEST_DIR/sqlx.toml,不往上找,workspace 里每个 crate 要有自己的一份。没开 feature 却放了这个文件,read_from 返回 ParseDisabled 错误,而不是悄悄忽略。Config 有四节,deny_unknown_fields,拼错的键直接报错:

节谁读内容
[common]宏、CLIdatabase-url-var:URL 从哪个环境变量读
[drivers]宏、CLIPostgres 目前没有字段;SQLite 能加载扩展
[macros]宏preferred-crates、type-overrides、table-overrides
[migrate]migrate!、CLI见迁移

全部键的写法在仓库里的 reference.toml。实验 crate toml-demo 用的是:

[common]
database-url-var = "LAB_DATABASE_URL"

[macros.preferred-crates]
date-time = "chrono"

[macros.type-overrides]
'TIMESTAMPTZ' = "crate::Ts"

[macros.table-overrides.'author']
'id' = "crate::AuthorId"

Ts 和 AuthorId 是 #[sqlx(transparent)] 的 newtype。查询 select a.id, p.id as post_id, p.created_at from author a join post p on p.author_id = a.id,打印字段类型:

--- only DATABASE_URL
error: set `DATABASE_URL` to use query macros online, or run `cargo sqlx prepare` to update the query cache
  --> src/main.rs:15:11
   |
--- LAB_DATABASE_URL
id         toml_demo::AuthorId
post_id    i64
created_at toml_demo::Ts
  • database-url-var:改名以后宏只认 LAB_DATABASE_URL,DATABASE_URL 设了也没用;报错的措辞却还是 set DATABASE_URL,那是写死的字符串。
  • table-overrides:a.id 和 p.id 都是 INT8,只有来自 author.id 的那列变成了 AuthorId。它靠 describe 里的列来源(origin),表名是 regclass::text 的结果,表在 search_path 里时不带 schema,所以这里写 'author'。
  • type-overrides:按 SQL 类型名全局替换,TIMESTAMPTZ 一律成了 Ts,参数也受它影响(get_param_type 先查它)。type_override 是普通的 HashMap::get,源码里留着 // TODO: make this case-insensitive,内置类型要写大写。两种覆盖都不影响可空性。

改名对 CLI 和 #[sqlx::test] 的效果不一样。sqlx-cli 的 populate_db_url 认改过的名字;可上面那段 run_prepare_step 把 URL 以 DATABASE_URL 的名字传给 cargo check,宏又只认 LAB_DATABASE_URL。环境里有 LAB_DATABASE_URL 时子进程继承了它,没事;只用 --database-url 传 URL 时,宏拿不到,而 prepare 已经先删了 .sqlx 里的旧文件,宏也没有缓存可退:

$ cargo sqlx prepare --database-url "$U" -- --offline
Found `sqlx.toml` in current directory; reading...
error: set `DATABASE_URL` to use query macros online, or run `cargo sqlx prepare` to update the query cache
  --> src/main.rs:15:11

这是 0.9.0 里 CLI 和宏之间的不一致,按 database-url-var prepare、database_url_var、sqlx.toml DATABASE_URL prepare 搜 transact-rs/sqlx 的 issue 都没有找到。#[sqlx::test] 则完全不看这个设置,见 #[sqlx::test]。

preferred-crates 解决的是「同一个 SQL 类型,两个 crate 都能映射」:date-time 在 chrono 和 time 之间选,numeric 在 bigdecimal 和 rust_decimal 之间选。默认 inferred,两个都开时 time、bigdecimal 优先,并发出上一页说的弃用警告。指定了一个没开 feature 的 crate 会直接报错。把 toml-demo 的 date-time 改成 "time"(同时注释掉 TIMESTAMPTZ 的覆盖,否则覆盖先生效):

[macros.preferred-crates]
date-time = "time"

[macros.type-overrides]
# 'TIMESTAMPTZ' = "crate::Ts"
❗IMPORTANT
动脑筋:只改 sqlx.toml

M2 Max、macOS、rustc 1.99.0、Postgres 16.14,sqlx 0.9.0。toml-demo 刚刚编译成功过,sqlx 只开了 chrono,没开 time。现在只改 sqlx.toml(如上),源码一个字不动,再跑 LAB_DATABASE_URL=... cargo build --offline。会看到什么?

动脑筋答案

--- edit sqlx.toml only
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.07s
--- touch main.rs
   Compiling toml-demo v0.1.0 (...)
error: SQLx feature `time` required for type TIMESTAMPTZ of column #3 ("created_at") (configured by `macros.preferred-crates.date-time` in sqlx.toml)
  --> src/main.rs:15:11
   |
15 |     let r = sqlx::query!(
   |  ___________^
16 | |     "select a.id, p.id as post_id, p.created_at from author a join post p on p.author_id = a.id"
17 | |   )
   | |___^

什么都没发生:0.07 秒,Finished。cargo 判断要不要重编只看源文件和依赖,它不知道宏读了 sqlx.toml(报告文件依赖的 proc_macro::tracked::path 只在 sqlx_macros_unstable 下调用),crate 没重编,宏没跑,新配置没人读。touch 一下源文件,宏才重新跑,报出预期的错误:time feature 没开。宏里的 MtimeCache 确实会发现 sqlx.toml 变了,可那只在宏真的被调用时才有用。改 .env、改数据库 schema 都是同样的情况;cargo sqlx prepare 每次先改源文件的 mtime,就是为了绕开这一点。

小结

  • cargo sqlx prepare 自己不 describe:删旧文件,改源文件 mtime 逼 crate 重编,带 SQLX_OFFLINE=false、SQLX_OFFLINE_DIR 跑 cargo check,由宏把每条查询的 QueryData 写成 query-<SQL 的 sha256>.json。任何设了 SQLX_OFFLINE_DIR 的在线构建都会写。
  • JSON 里是 db_name、query、describe(列类型、列来源、参数类型、nullable)、hash;可空性是编译那一刻推断的结果,离线构建原样使用。SQLX_TMP 是 0.7.3(#2803)以后的遗留,没人读。
  • 没有 URL 时宏自动读 .sqlx;SQLX_OFFLINE=true 用于「URL 在但连不上」的构建环境;URL 在而库连不上时不会退回 .sqlx。
  • prepare --check 重新生成到 target/sqlx-prepare-check 再比较:缺文件或内容不同就失败。schema 变了而 SQL 没变时,离线构建照样通过,只有 --check 能发现。
  • sqlx.toml 只从 crate 目录读、要开 sqlx-toml:database-url-var 改 URL 变量名(prepare --database-url 和 #[sqlx::test] 不跟着改),type-overrides / table-overrides 换类型不换可空性,preferred-crates 选日期和小数库。改了它要让 crate 重编才生效。

💬 关联讨论

发起讨论
加载中...