Turso sqltest 快照测试实战指南:用 EXPLAIN 输出守护查询执行计划
【免费下载链接】tursoA SQL database in Rust: SQLite-compatible, now also speaking Postgres (experimental). The LLVM of databases.项目地址: https://gitcode.com/GitHub_Trending/tu/turso
导读
本文围绕 Turso/Limbo 仓库中 sqltest 测试运行器的快照测试能力展开。sqltest 是仓库自带的 SQL 测试框架(位于testing/sqltest),其中的快照测试用于捕获并校验 SQL 查询的EXPLAIN QUERY PLAN(查询计划)与EXPLAIN(VDBE 字节码)输出,从而在数据库引擎演进过程中守护执行计划的稳定性。读完本文,你将掌握快照测试文件的编写语法、四种快照更新模式(auto/new/always/no)的取舍、快照文件的格式与命名规范、CI 集成方式,以及从源码层面理解快照的生成、比对与格式化原理。
快照测试是什么,为什么需要它
快照测试(Snapshot Testing)捕获 SQL 查询的两种 EXPLAIN 输出:
EXPLAIN QUERY PLAN:查询优化器选出的执行计划树;EXPLAIN:查询被编译成的 VDBE 字节码指令序列。
与普通测试只比较"查询结果"不同,快照测试验证的是查询的执行方式是否保持一致。它能够帮助检测:
- 查询计划回归(如从索引扫描退化为全表扫描);
- 索引使用情况的意外变化;
- 字节码生成差异。
在 testing/sqltest/src/snapshot/mod.rs 的模块注释中,这一设计被明确描述为"为 SQL EXPLAIN 输出提供 insta 兼容的快照测试能力,用于验证 SQL EXPLAIN 输出保持一致"。
注意:快照测试目前只在 Rust 后端运行。其他后端(CLI、JS、PG)会自动跳过快照测试,原因在于不同后端的 EXPLAIN 输出格式可能存在差异。这一点在 testing/sqltest/docs/dsl-spec.md 的 Snapshot Cases 一节中有同样说明。
快速开始:四步跑通第一个快照测试
1. 编写一个快照测试
创建一个.sqltest文件,声明内存数据库、定义 setup 块,再用snapshot关键字声明快照用例:
@database :memory: setup schema { CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT); CREATE INDEX idx_users_name ON users(name); } @setup schema snapshot my-query-plan { SELECT * FROM users WHERE id = 1; }仓库中提供了一个可直接运行的示例文件 testing/sqltest/examples/snapshot_example.sqltest,其中既有普通test ... expect用例,也有snapshot query-plan-by-id、snapshot query-plan-by-name两个快照用例,可用于对照学习。
2. 运行测试生成快照
首次运行会生成.snap.new文件供人工审查:
# 通过 Makefile 运行(CLI 后端) make -C sqlite/conformance run-cli # 或直接运行 sqltest cargo run --bin sqltest -- run sqlite/conformance/sqlite-sqltests/my-test.sqltest注意:示例文件建议配合 Rust 后端运行快照测试:
cargo run --bin sqltest -- run testing/sqltest/examples/ --backend rust3. 接受快照
人工审查.snap.new内容无误后,以 always 模式接受:
cargo run --bin sqltest -- run sqlite/conformance/sqlite-sqltests/ --snapshot-mode=always4. 提交快照文件
git add sqlite/conformance/sqlite-sqltests/snapshots/ git commit -m "Add query plan snapshots"快照更新模式:auto / new / always / no
--snapshot-mode标志控制快照的更新行为,四种模式对比如下:
| 模式 | 行为 | 适用场景 |
|---|---|---|
auto | CI 中表现为no,本地表现为new(默认) | 日常开发 |
new | 写入.snap.new文件供审查 | 接受变更前的人工审查 |
always | 直接写入.snap文件 | 接受所有变更 |
no | 只读,不写任何文件 | CI 校验 |
auto(默认)
自动检测运行环境:
- 在 CI(GitHub Actions、Travis、CircleCI 等)中:行为等同
no; - 本地:行为等同
new。
CI 检测通过检查环境变量实现。在 testing/sqltest/src/snapshot/mod.rs 中可以看到完整的检测列表,共 9 个变量:
const CI_ENV_NAMES: [&str; 9] = [ "CI", "GITHUB_ACTIONS", "TRAVIS", "CIRCLECI", "GITLAB_CI", "JENKINS_URL", "BUILDKITE", "TF_BUILD", // Azure Pipelines "CODEBUILD_BUILD_ID", // AWS CodeBuild ];IS_CI是一个惰性初始化的静态布尔值,只要任一变量存在且其值为 true("1"或大小写不敏感的"true"),即判定为 CI 环境。SnapshotUpdateMode::resolve()方法负责把Auto解析为实际模式:CI 中解析为No,本地解析为New。
new
在现有快照旁生成.snap.new文件,便于审查变更:
sqlite/conformance/sqlite-sqltests/snapshots/ my-test__query-plan.snap # 现有快照 my-test__query-plan.snap.new # 新增/变更的快照手动审查 diff 后,再用--snapshot-mode=always接受。
从源码看,new模式在比对不匹配时调用write_pending()写入.snap.new文件,并返回SnapshotResult::Mismatch(包含 expected、actual 和 diff 字段);当快照尚不存在时返回SnapshotResult::New。
always
不经过.snap.new中间态,直接更新.snap文件。适用于已经审查过变更、准备接受的情况。源码中always模式在匹配时会清理陈旧的.snap.new文件(remove_pending),在不匹配或不存在时直接调用write_snapshot()写入,并返回Updated或New结果。
no
只读模式,不写任何快照文件。以下情况会导致测试失败:
- 快照不存在;
- 快照内容不匹配。
这是 CI 使用的模式,确保所有快照变更都已被显式提交。
快照文件格式与元数据
快照文件由 YAML frontmatter 加捕获的输出正文组成:
--- source: my-test.sqltest expression: SELECT * FROM users WHERE id = 1; info: statement_type: SELECT tables: - users setup_blocks: - schema database: ':memory:' --- QUERY PLAN `--SEARCH users USING INTEGER PRIMARY KEY (rowid=?) BYTECODE addr opcode p1 p2 p3 p4 p5 comment 0 Init 0 8 0 0 Start at 8 1 OpenRead 0 2 0 k(3,B,B,B) 0 table=users, root=2, iDb=0 ...元数据字段
| 字段 | 描述 |
|---|---|
source | 测试文件名 |
expression | 被快照的 SQL 查询 |
info.statement_type | 自动检测的语句类型:SELECT、INSERT、UPDATE、DELETE 等 |
info.tables | 从查询中自动提取的表名 |
info.setup_blocks | 快照执行前应用的 setup 块 |
info.database | 使用的数据库类型 |
info.line | 测试文件中的行号(可选,序列化时仅在存在时输出) |
源码中的SnapshotMetadata/SnapshotInfo结构体与split_frontmatter()解析函数位于 testing/sqltest/src/snapshot/mod.rs,其中tables、setup_blocks、database、line均通过#[serde(default, skip_serializing_if = ...)]在为空时省略输出。
仓库中真实生成的快照文件可以参考 testing/sqltest/examples/snapshots/snapshot_example__query-plan-by-id.snap 与 testing/sqltest/examples/snapshots/snapshot_example__query-plan-by-name.snap,后者展示了走idx_users_name索引时SeekGE/IdxGT/DeferredSeek等指令组成的字节码序列。
元数据如何自动生成
create_snapshot()在写入时自动完成两件事:
- 提取表名:通过一组正则模式匹配
FROM、JOIN、INSERT INTO、UPDATE、DELETE FROM、CREATE TABLE、DROP TABLE、ALTER TABLE、CREATE INDEX ... ON等子句,并使用is_sql_keyword()过滤SELECT、WHERE等 SQL 关键字,结果以BTreeSet去重排序; - 检测语句类型:
detect_statement_type()依据 SQL 前缀判断,支持SELECT、INSERT、UPDATE、DELETE、CREATE TABLE、CREATE INDEX、CREATE、DROP、ALTER、WITH (CTE)与兜底的OTHER。
文件组织与命名约定
快照文件存放在测试文件同级的snapshots/目录中:
sqlite/conformance/sqlite-sqltests/ queries.sqltest aggregates.sqltest snapshots/ queries__select-by-id.snap queries__select-by-name.snap aggregates__count-all.snap命名约定:{test-file-stem}__{snapshot-name}.snap。
这一约定由SnapshotManager::snapshot_path()实现:取测试文件的 stem,与快照名拼接为{stem}__{name}.snap,放入同目录snapshots/子目录;pending 文件则在相同位置以.snap.new结尾(pending_path())。
CLI 命令详解
运行带快照的测试
# 默认模式(auto) cargo run --bin sqltest -- run sqlite/conformance/sqlite-sqltests/ # 接受所有快照变更 cargo run --bin sqltest -- run sqlite/conformance/sqlite-sqltests/ --snapshot-mode=always # 审查模式(生成 .snap.new 文件) cargo run --bin sqltest -- run sqlite/conformance/sqlite-sqltests/ --snapshot-mode=new # 只读模式(CI) cargo run --bin sqltest -- run sqlite/conformance/sqlite-sqltests/ --snapshot-mode=no # 过滤特定快照 cargo run --bin sqltest -- run sqlite/conformance/sqlite-sqltests/ --snapshot-filter="query-plan*"在 testing/sqltest/src/main.rs 中,run子命令还支持--backend(rust/cli/js/pg,默认 rust)、--binary、--filter、--jobs、--output(pretty/json)、--timeout、--mvcc等选项;--snapshot-filter与--filter相互独立,前者只筛选快照用例。若路径不存在,sqltest 会自动尝试补上.sqltest扩展名后再解析。
检查待处理快照
cargo run --bin sqltest -- check sqlite/conformance/sqlite-sqltests/该命令会:
- 校验测试文件语法;
- 检测待处理的
.snap.new文件; - 若存在任何待处理快照则失败(适合 CI)。
从 testing/sqltest/src/main.rs 的check_files()看,它通过find_all_pending_snapshots()递归扫描目标目录(跳过snapshots目录本身),只要发现扩展名为new且 stem 以.snap结尾的文件即报错;随后对目录内所有*.sqltest文件做语法解析,解析错误同样导致非零退出码。语法检查通过时会打印形如select.sqltest - OK (1 databases, 2 setups, 5 tests, 2 snapshots)的摘要。
使用 Makefile
sqlite/conformance/Makefile 封装了常用入口:
# 运行全部测试(含快照) make -C sqlite/conformance run-cli # 运行示例(含快照示例) make -C sqlite/conformance run-examples # 检查语法与待处理快照 make -C sqlite/conformance check此外还提供run-rust(原生 Rust 后端,快照测试的推荐后端)、run-js、run-filter、run-one FILE=...等目标;Makefile 会根据是否定义CI自动切换 release/debug 构建,并支持MVCC=1、BACKEND=rust、CROSS_CHECK_BINARY=path等变量。
快照测试 DSL 语法
基本快照
@database :memory: snapshot query-plan { SELECT * FROM users; }带 setup 块的快照
@database :memory: setup schema { CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT); } setup data { INSERT INTO users VALUES (1, 'Alice'); } @setup schema @setup data snapshot query-plan-with-data { SELECT * FROM users WHERE id = 1; }setup 块可以重复应用,@setup装饰器可以连续出现多次。值得注意:快照正文中的 SQL 会原样存入expression元数据,而info.tables则通过正则提取;因此 setup 块中建立索引、插入数据会直接影响生成计划的形态(例如是否走索引)。
跳过快照
# 无条件跳过 @skip "query plan not stable yet" snapshot unstable-plan { SELECT * FROM complex_view; } # 条件跳过(MVCC 模式) @skip-if mvcc "different plan in MVCC mode" snapshot standard-plan { SELECT * FROM users; }@skip-if的条件可以是mvcc、sqlite等运行时条件,用于在特定模式下计划不稳定的场景。
后端限定
由于快照测试只在 Rust 后端运行,通常不需要@backend装饰器;但可以显式声明以强调依赖:
# 显式要求 Rust 后端(可选,因为只有该后端会运行快照) @backend rust snapshot turso-query-plan { SELECT * FROM users WHERE id = 1; }能力要求
# 快照需要触发器支持 @requires trigger "query plan involves triggers" @setup schema-with-triggers snapshot trigger-query-plan { INSERT INTO audit_log SELECT * FROM events; }支持的装饰器汇总
快照支持测试的全部装饰器:
| 装饰器 | 描述 |
|---|---|
@setup <name> | 在快照前应用某个 setup 块 |
@skip "reason" | 无条件跳过该快照 |
@skip-if <cond> "reason" | 条件跳过(如mvcc、sqlite) |
@backend <name> | 仅在指定后端运行(快照仅在rust上运行) |
@requires <cap> "reason" | 仅当后端支持该能力时运行 |
文件级指令(@skip-file、@skip-file-if、@requires-file)同样作用于快照。完整的 DSL 语法(含snapshot_case = { decorator } "snapshot" IDENTIFIER block的产生式)见 testing/sqltest/docs/dsl-spec.md。
输出格式:QUERY PLAN 与 BYTECODE
每个快照捕获两段内容。
QUERY PLAN
EXPLAIN QUERY PLAN的输出被格式化为树形结构:
QUERY PLAN `--SEARCH users USING INTEGER PRIMARY KEY (rowid=?)含子查询或 JOIN 的复杂查询:
QUERY PLAN |--SCAN users `--SEARCH orders USING INDEX idx_orders_user (user_id=?)源码中的format_explain_query_plan_output()读取 EXPLAIN QUERY PLAN 返回的id/parent/notused/detail四列,仅保留 detail 列做展示,利用 id/parent 关系构建树,递归输出|--与`--分支符号。
BYTECODE
EXPLAIN的输出被格式化为对齐的表格:
BYTECODE addr opcode p1 p2 p3 p4 p5 comment 0 Init 0 8 0 0 Start at 8 1 OpenRead 0 2 0 k(3,B,B,B) 0 table=users, root=2, iDb=0 2 SeekRowid 0 4 7 0 if (r[4]!=cursor 0...) goto 7format_explain_output()的实现细节值得关注:
- 列定义固定为
addr(右对齐)、opcode(左对齐)、p1/p2/p3(右对齐)、p4(左对齐)、p5(右对齐)、comment(左对齐),与 SQLite 官方 EXPLAIN 输出保持一致; - 循环缩进:根据指令的跳转关系计算每行缩进。
AZ_NEXT指令集(Next、Prev、VPrev、VNext、SorterNext、Return)会把其p2跳转目标与自身之间的指令整体缩进;Goto仅在向后跳转(p2 < addr)且目标属于AZ_YIELD(Yield、SeekLT、SeekGT、RowSetRead、Rewind)或p1非零时才产生缩进; - 嵌套循环会形成多层缩进(每层两个空格),与 CLI 的 EXPLAIN 输出行为一致。
calculate_row_indents()先收集所有循环区间(start, end),再统计每条指令落入多少个区间得到缩进层级;snapshot/mod.rs内附有单层循环、嵌套循环、空结果等场景的单元测试(如test_format_explain_output_with_loop_indentation、test_format_explain_output_nested_loops),验证Column/ResultRow位于循环体内被缩进、Next/Halt不被缩进的行为。
CI 集成
推荐的 CI 配置
# .github/workflows/test.yml - name: Run SQL tests run: | cargo run --bin sqltest -- run sqlite/conformance/sqlite-sqltests/ --snapshot-mode=no - name: Check for pending snapshots run: | cargo run --bin sqltest -- check sqlite/conformance/sqlite-sqltests/check命令在存在任何.snap.new文件时会失败,从而保证所有快照变更都已被提交。此外,即使不显式传--snapshot-mode=no,默认的auto模式在 CI 环境(检测到CI、GITHUB_ACTIONS等环境变量)下也会自动退化为只读,双保险防止 CI 被意外写入文件。
更新快照的工作流
- 修改影响查询计划的代码;
- 本地运行测试(生成
.snap.new文件); - 审查变更:
diff sqlite/conformance/sqlite-sqltests/snapshots/*.snap sqlite/conformance/sqlite-sqltests/snapshots/*.snap.new; - 接受变更:
--snapshot-mode=always; - 提交更新后的
.snap文件; - 推送到 CI。
与 cargo-insta 的差异
工作流与 cargo-insta 类似,但 sqltest 使用自定义快照实现:
| 特性 | sqltest | cargo-insta |
|---|---|---|
| 文件格式 | YAML frontmatter + 内容 | YAML frontmatter + 内容 |
| 审查工具 | 手动 diff /--snapshot-mode | cargo insta review |
| CI 模式 | --snapshot-mode=no | --check |
| 接受全部 | --snapshot-mode=always | cargo insta accept |
| 元数据 | SQL 专属(表名、语句类型) | 通用 |
sqltest 的元数据(info.statement_type、info.tables、info.setup_blocks、info.database)是 SQL 领域专属的,这一点在比对与排查时能提供更多上下文。比对差异时使用similarcrate 生成统一 diff(generate_diff()),并以SnapshotResult::Mismatch的形式把 expected/actual/diff 一并返回给上层报告。
故障排查
CI 中提示 "Snapshot mismatch"
- 本地运行测试生成
.snap.new文件; - 审查差异;
- 用
--snapshot-mode=always接受; - 提交更新后的
.snap文件。
提示 "Found pending snapshot files"
check命令发现了.snap.new文件。两种处理方式:
- 接受它们:运行
--snapshot-mode=always; - 删除它们:
rm sqlite/conformance/sqlite-sqltests/snapshots/*.snap.new。
多次运行结果不一致
查询计划可能因以下因素变化:
- 数据库统计信息;
- 索引可用性;
- SQLite/Turso 版本。
请确保 setup 块创建了稳定的 schema 与数据。
快照没有更新
确认使用了--snapshot-mode=always或--snapshot-mode=new。默认的auto模式在 CI 环境中表现为no(只读),这是最常见的原因。此外注意:快照只在 Rust 后端运行,若用--backend cli或--backend js运行,快照会被自动跳过,也就不会生成新文件。
最佳实践
- 使用描述性快照名——
query-plan-user-by-id远好于test1; - 归类相关快照—— 把相似功能的快照放在同一个测试文件中;
- 包含必要的 setup—— 快照需要索引与数据才能产生有意义的计划;
- 接受前务必审查—— 不要盲目接受快照变更,要理解计划为何变化(例如是否意外丢失了索引);
- 随代码变更一起提交快照—— 修改查询逻辑时,在同一提交中更新快照;
- 对不稳定计划使用 skip—— 若计划在不同环境间波动,先用
@skip跳过,待稳定后再启用。
延伸阅读
- DSL 完整语法:testing/sqltest/docs/dsl-spec.md
- sqltest CLI 用法:testing/sqltest/docs/cli-usage.md
- 快照实现源码:testing/sqltest/src/snapshot/mod.rs
- CLI 入口与参数定义:testing/sqltest/src/main.rs
- 可运行的示例:testing/sqltest/examples/snapshot_example.sqltest 及其快照目录 testing/sqltest/examples/snapshots/
- 测试语料库与 Makefile 封装:sqlite/conformance/Makefile
【免费下载链接】tursoA SQL database in Rust: SQLite-compatible, now also speaking Postgres (experimental). The LLVM of databases.项目地址: https://gitcode.com/GitHub_Trending/tu/turso
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考