离线模式与 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 做四件事:
- 建好目标目录,删掉里面的
query-*.json(注释:Only delete sqlx-*.json files to avoid accidentally deleting any user data)。 - 建
target/sqlx-tmp。 - 让该重编的 crate 重编:
setup_minimal_project_recompile把当前 package 源文件的 mtime 改成现在;加--workspace时改的是所有依赖sqlx-macros的 workspace 成员,再加--all还会cargo clean -p掉 workspace 外依赖sqlx-macros的包(sqlx自己除外)。宏只在 crate 重新编译时才跑,不重编就什么也不会写。 - 跑
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。
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] | 宏、CLI | database-url-var:URL 从哪个环境变量读 |
[drivers] | 宏、CLI | Postgres 目前没有字段;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"
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 重编才生效。