Turso sqltest 快照测试实战指南:用 EXPLAIN 输出守护查询执行计划
2026/9/13 23:16:57 网站建设 项目流程

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-idsnapshot 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 rust

3. 接受快照

人工审查.snap.new内容无误后,以 always 模式接受:

cargo run --bin sqltest -- run sqlite/conformance/sqlite-sqltests/ --snapshot-mode=always

4. 提交快照文件

git add sqlite/conformance/sqlite-sqltests/snapshots/ git commit -m "Add query plan snapshots"

快照更新模式:auto / new / always / no

--snapshot-mode标志控制快照的更新行为,四种模式对比如下:

模式行为适用场景
autoCI 中表现为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()写入,并返回UpdatedNew结果。

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,其中tablessetup_blocksdatabaseline均通过#[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()在写入时自动完成两件事:

  1. 提取表名:通过一组正则模式匹配FROMJOININSERT INTOUPDATEDELETE FROMCREATE TABLEDROP TABLEALTER TABLECREATE INDEX ... ON等子句,并使用is_sql_keyword()过滤SELECTWHERE等 SQL 关键字,结果以BTreeSet去重排序;
  2. 检测语句类型detect_statement_type()依据 SQL 前缀判断,支持SELECTINSERTUPDATEDELETECREATE TABLECREATE INDEXCREATEDROPALTERWITH (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/

该命令会:

  1. 校验测试文件语法;
  2. 检测待处理的.snap.new文件;
  3. 若存在任何待处理快照则失败(适合 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-jsrun-filterrun-one FILE=...等目标;Makefile 会根据是否定义CI自动切换 release/debug 构建,并支持MVCC=1BACKEND=rustCROSS_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的条件可以是mvccsqlite等运行时条件,用于在特定模式下计划不稳定的场景。

后端限定

由于快照测试只在 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"条件跳过(如mvccsqlite
@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 7

format_explain_output()的实现细节值得关注:

  • 列定义固定为addr(右对齐)、opcode(左对齐)、p1/p2/p3(右对齐)、p4(左对齐)、p5(右对齐)、comment(左对齐),与 SQLite 官方 EXPLAIN 输出保持一致;
  • 循环缩进:根据指令的跳转关系计算每行缩进。AZ_NEXT指令集(NextPrevVPrevVNextSorterNextReturn)会把其p2跳转目标与自身之间的指令整体缩进;Goto仅在向后跳转(p2 < addr)且目标属于AZ_YIELDYieldSeekLTSeekGTRowSetReadRewind)或p1非零时才产生缩进;
  • 嵌套循环会形成多层缩进(每层两个空格),与 CLI 的 EXPLAIN 输出行为一致。

calculate_row_indents()先收集所有循环区间(start, end),再统计每条指令落入多少个区间得到缩进层级;snapshot/mod.rs内附有单层循环、嵌套循环、空结果等场景的单元测试(如test_format_explain_output_with_loop_indentationtest_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 环境(检测到CIGITHUB_ACTIONS等环境变量)下也会自动退化为只读,双保险防止 CI 被意外写入文件。

更新快照的工作流

  1. 修改影响查询计划的代码;
  2. 本地运行测试(生成.snap.new文件);
  3. 审查变更:diff sqlite/conformance/sqlite-sqltests/snapshots/*.snap sqlite/conformance/sqlite-sqltests/snapshots/*.snap.new
  4. 接受变更:--snapshot-mode=always
  5. 提交更新后的.snap文件;
  6. 推送到 CI。

与 cargo-insta 的差异

工作流与 cargo-insta 类似,但 sqltest 使用自定义快照实现:

特性sqltestcargo-insta
文件格式YAML frontmatter + 内容YAML frontmatter + 内容
审查工具手动 diff /--snapshot-modecargo insta review
CI 模式--snapshot-mode=no--check
接受全部--snapshot-mode=alwayscargo insta accept
元数据SQL 专属(表名、语句类型)通用

sqltest 的元数据(info.statement_typeinfo.tablesinfo.setup_blocksinfo.database)是 SQL 领域专属的,这一点在比对与排查时能提供更多上下文。比对差异时使用similarcrate 生成统一 diff(generate_diff()),并以SnapshotResult::Mismatch的形式把 expected/actual/diff 一并返回给上层报告。

故障排查

CI 中提示 "Snapshot mismatch"

  1. 本地运行测试生成.snap.new文件;
  2. 审查差异;
  3. --snapshot-mode=always接受;
  4. 提交更新后的.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运行,快照会被自动跳过,也就不会生成新文件。

最佳实践

  1. 使用描述性快照名——query-plan-user-by-id远好于test1
  2. 归类相关快照—— 把相似功能的快照放在同一个测试文件中;
  3. 包含必要的 setup—— 快照需要索引与数据才能产生有意义的计划;
  4. 接受前务必审查—— 不要盲目接受快照变更,要理解计划为何变化(例如是否意外丢失了索引);
  5. 随代码变更一起提交快照—— 修改查询逻辑时,在同一提交中更新快照;
  6. 对不稳定计划使用 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询