博客

AST Visitor API 迁移,怎么证明 AI 没改坏 SQL 语义?

avatarcoldWater7月 14, 2026
AST Visitor API 迁移,怎么证明 AI 没改坏 SQL 语义?

AST 的旧 visitor API 不太好用,我们希望换成一套新的 API。

单看工程实现,这件事并不算难。AST 节点虽然很多,但大部分迁移代码模式重复,适合批量生成,也适合由 AI 完成大量样板工作。

真正让人不放心的不是代码能不能写出来,而是写完以后怎样确认它没有静悄悄地改变语义。

在数据库内核里,AST Visitor 是一层基础设施。它可能服务于 parser、binder、optimizer、formatter、权限检查、表达式重写等多个下游环节。迁移一旦出现偏差,未必会马上表现为编译错误,更可能是某个节点不再被访问、某个字段多访问了一次,或者原本稳定的遍历顺序发生变化。代码表面上仍然合理,真正的影响却可能在更靠后的逻辑中暴露。

最初我们关心的风险主要有两类:

  1. 漏项或重复访问:某个节点、字段或分支没有被遍历到,或者被意外遍历了多次。

  2. 顺序变化:需要保持先后关系的 hook 改变了调用顺序。

这类问题很难只靠 code review 兜住。节点数量太多,人工很难在脑子里展开整棵 AST;大量生成代码又高度相似,review 很容易退化成对样板代码的走读。

因此,我们开始尝试建立一套可执行的验证机制,让正确性更多地由程序化证据约束,而不是依赖人逐行确认。

最终形成的系统大致如下:

Image

这个闭环不是一次搭好的。为了讲清各部分的边界,下面先完整说明由源码扫描、运行时观测、独立比较和失败定位组成的内核,再说明真实输入和覆盖率反馈如何接入。

从源码采集 AST 结构

要让后面的验证可信,不是写一套更复杂的逻辑来描述 AST,而是尽量直接地从源码中提取结构。类型、variant 和字段已经写在源码里,采集工具只需要机械地把它们展开,作为后续代码生成的输入。

这里最重要的约束是少写逻辑。如果为了确认 visitor 有没有漏项,又手工实现一套节点分类、递归关系和特殊规则,那么只是把原来的审计问题搬进了验证工具:要相信验证结果,就得先完整审计这套同样复杂的逻辑。

因此,源码分析应该保持短而直接:沿用语法分析得到的类型定义,枚举 struct、enum、variant 和字段,并让产出能够回到对应的源码位置。一个声明对应一条结构记录,必要的排除项显式列出,不在采集阶段推断遍历语义,也不复制 visitor 的特殊顺序。

这种做法的可信度来自映射关系足够简单。需要审计的是少量通用的源码提取规则,而不是另一套与 AST 同规模的手写模型。它提供的是一份可追溯、可审计的结构输入;visitor 实际怎样遍历这些结构,则交给运行时观测和后续比较回答。

把运行时观测和等价性判断分开

扫描源码以后,下一件事是把 visitor 的实际运行行为记录下来。这里最重要的设计,是把观测和比较分开,不要生成代码同时承担正确性判断。

生成代码只负责产生 trace,忠实记录进入了哪个 hook、看到了哪个节点以及事件发生的次数和顺序。它不判断某个事件是否应该出现,也不在记录时排序、去重或过滤结果。

等价性判断由另一套独立的比较器完成。比较器读取新旧 visitor 的 trace,再根据遍历契约判断访问项目、次数和关键顺序是否一致。

这两个部分的关系是:

Image

这样,大量生成代码可以保持机械,正确性规则则集中在少量手写代码中。同一份原始 trace 也能继续用于覆盖率统计和失败定位。

这部分的原则可以概括为:

生成事实,手写规则;先完整观测,再独立比较。

这个分层很关键。AI 可以生成大量迁移代码,也可以生成一部分观测代码,但判断正确性的规则应该足够集中、明确、可审计。

明确 Trace 应该怎样比较

观测层能够稳定地产生数据以后,下一个问题才真正具体起来:什么样的差异算 visitor 语义变化?

最初最容易想到的是比较“访问到了哪些项目”。这可以发现某个节点或 hook 在一边出现、另一边没有出现。

但单纯比较集合不够。集合会丢掉访问次数:一个节点访问一次和访问两次,在集合里看起来完全相同。因此,检查漏项和重复访问时,至少需要比较多重集,或者直接比较保留次数的事件记录。

顺序则是另一类约束。有些 visitor 逻辑只关心最终是否见过某些节点,顺序并不重要;另一些逻辑却依赖隐含的遍历顺序,例如:

  • 先见到定义,再处理引用;

  • 按进入顺序进行归一化;

  • 遇到第一个匹配项便提前退出;

  • 依靠 enter 和 exit 的嵌套关系维护状态。

所以比较器不能只有一种全局规则。它需要根据 hook 的语义分别检查:

  • 访问项目是否一致;

  • 访问次数是否一致;

  • 对顺序敏感的事件序列是否一致;

  • enter、exit、skip 和提前退出等控制行为是否一致。

这时,“等价”的定义才逐渐变得准确:不是要求新旧代码实现相同,而是要求它们在约定的可观察遍历行为上没有未解释差异。

旧 API 在这里是历史行为基线,但不必被假定为天然正确。如果新实现有意修复旧行为,或者新 API 明确改变了某项契约,这类差异应该显式记录,而不是为了让比较通过而被悄悄过滤。

让失败能够直接定位

比较器能够报出不一致以后,新的问题又出现了:只知道两份 trace 不相等,对修复迁移帮助有限。

如果失败信息只是一个布尔值,开发者仍然需要重新翻阅大量 visitor 代码,寻找究竟是哪一个节点或 hook 产生了偏差。这样虽然有了自动检查,定位成本却仍然很高。

因此,trace 和比较器继续增加了面向诊断的信息:

  • 触发问题的输入或测试名称;

  • 缺失或重复的是哪个事件;

  • 哪个 hook 的顺序发生了变化;

  • 事件对应的 AST 节点路径;

  • 新旧 trace 第一次产生分歧的位置;

  • 差异前后的少量上下文。

这项工作看起来发生在比较器完成之后,实际上会反过来影响观测层。要报告 AST 路径,记录事件时就必须携带路径;要定位第一个顺序差异,就不能提前把 trace 排序;要发现重复访问,就不能在观测阶段去重。

这也是为什么观测层应该尽量保留事实,而把规范化和判断留给比较器。失败定位不是最终输出上的装饰,而是 trace 协议需要从一开始支持的能力。

到这里,最初的可用内核才算完整:源码扫描提供 AST 结构,观测层把实际遍历行为记录成 trace,比较层根据遍历契约判断新旧行为是否一致,差异报告则让失败能够直接进入修复。

只要给定一棵 AST,这个内核就已经能够运行。

Image

后面的工作不再是在补齐这个内核的基本组成,而是在扩大它能处理的输入,并让验证边界持续可见。

接入真实 SQL 语料:让验证面对真实输入形状

有了可用内核,并不意味着验证已经充分。它只能比较实际送进去的 AST。

如果依靠手工构造 AST,很快会遇到两个现实问题:

  • AST 构造代码本身很重;

  • 人工样例容易只覆盖编写者刚好想到的结构。

因此,我们开始复用 parser 已有的 SQL 语料:

  1. 用现有 SQL 解析出 AST;

  2. 对同一棵 AST 运行旧 visitor;

  3. 运行新 visitor;

  4. 收集双方的 trace;

  5. 由独立比较器检查差异。

parser golden 是一个自然的输入来源。一条 SQL 往往能同时覆盖多个节点和嵌套关系,这些语料也是项目已经认可的真实输入形状。相比专门维护一批庞大的手写 AST,它们更接近 visitor 实际会遇到的数据。

但这里需要把结论说准确:这种方法证明的是“在当前 SQL 语料实际覆盖到的 AST 上,新旧遍历行为一致”,而不是整个 AST 状态空间都已经被穷举。

用覆盖率暴露验证盲区:测试通过不等于空间已覆盖

运行时等价比较始终依赖一个前提:输入必须真正触达我们关心的 AST 分支。

最初复用 parser SQL 语料时,我们只能知道测试运行了,却不知道它究竟覆盖了多少手写 walk 分支。如果某个 enum variant、可选字段或特殊路径从未出现,那么新旧 visitor 即使在所有测试上完全一致,也不能说明那部分迁移得到了验证。

这时,覆盖率成为验证输入是否充分的反馈信号:

  1. 用现有 SQL 语料运行新旧 visitor 的等价比较;

  2. 统计手写 walk 和相关分支的覆盖情况;

  3. 找出一直没有执行到的 AST 路径;

  4. 反推缺少什么样的 SQL;

  5. 补充相应语料;

  6. 再运行等价比较和覆盖率。

覆盖率在这里不负责证明代码正确。它回答的是另一个问题:我们用来比较新旧行为的输入,实际触达了多少待验证空间。

这样就形成了两个相互配合但不能互相替代的证据:

  • trace 比较说明已触达输入上的行为是否一致;

  • 覆盖率说明还有哪些行为尚未被输入触达。

发现缺口后,能够由真实 SQL 表达的,优先补到 parser 上游,使语料同时服务 parser、AST 构造和 visitor;难以通过 SQL 稳定构造的边界状态,再用局部定向测试覆盖。

这不是五个步骤,而是一个持续反馈系统

回头看,这套系统包含源码扫描、运行时 trace、独立比较、失败定位、真实 SQL 输入和覆盖率反馈,但它们承担的角色并不相同。

前四项构成基本可用的验证内核:给定一棵 AST,它们能够运行比较并把差异直接交给修复。真实 SQL 输入扩大了内核的适用范围,覆盖率则建立了继续扩展验证范围的反馈机制。

它们分别解决不同的问题:

  • 源码扫描约束 AST 结构是否被纳入考虑;

  • 观测层记录 visitor 实际做了什么;

  • 比较层定义什么叫行为等价;

  • SQL 语料提供真实 AST 输入;

  • 差异报告降低修复和解释成本;

  • 覆盖率暴露输入空间中的空白;

  • 新增语料继续扩大可验证范围。

这些部分在后续迁移中会同时工作,并彼此反馈:

迁移代码变化 ─→ trace 差异 ─→ 修正迁移或记录有意变化

AST 定义变化 ─→ 生成结果变化 ─→ 暴露未处理结构

覆盖率缺口 ─→ 新增 SQL 语料 ─→ 扩大等价比较范围

定位信息不足 ─→ 扩展 trace 协议 ─→ 改善后续差异报告

因此,更准确的说法不是“按照五个步骤完成 visitor 迁移”,而是“围绕迁移不断补齐不同种类的证据,直到这些证据形成可以持续运行的闭环”。

这套验证能证明什么?

最终,我们希望得到的结论包括:

  • 已知 AST 类型、variant 和字段都已被纳入分类;

  • 在当前语料触达的 AST 上,新旧 visitor 的访问项目和次数一致;

  • 对顺序敏感的 hook 没有出现未解释的顺序变化;

  • skip、递归和提前退出行为没有出现未解释差异;

  • 没有覆盖到的结构仍然可见,而不是被一次测试通过掩盖;

  • 有意变化和旧实现已有问题都有明确记录。

它仍然不是对整个 AST 状态空间的数学证明。有限 SQL 语料上的 trace 比较,只能约束实际触达的输入;覆盖率可以暴露没有运行的路径,却不能单独证明已经运行的代码正确;生成器和比较器本身也仍然需要审计。

因此,把它称为“有明确覆盖边界的可执行等价性验证”更加准确。它不承诺一次性证明一切,而是把原本依赖人工信心的迁移,变成一个能够持续发现缺口、补充证据并逐渐收敛的工程过程。

AI 负责生成,人和验证系统负责正确性边界

这套机制最初是为了降低一次 visitor 迁移的风险,但它也形成了一种更清晰的人、AI 和程序之间的分工。

AI 擅长大规模生成重复而结构化的代码,也能快速修正局部 diff。它不擅长的是,在一个规模很大、结构稀疏并且包含隐含契约的 AST 世界里,自发保证全局完备性。

因此,更合适的分工是:

  • AI 负责生成和迁移大量实现代码;

  • 观测系统负责忠实记录运行行为;

  • 比较器负责执行明确的等价规则;

  • 人负责定义遍历契约、解释差异以及决定哪些变化是有意的。

这里的重点不是为 AI 单独设计一套较低的正确性标准。无论代码由 AI 还是人完成,大规模基础设施迁移都应该接受同一套独立验证。AI 只是提高了代码生产速度,也让“不能把正确性寄托在逐行 review 上”这件事更加明显。

对 Databend 数据库工程的启发

Databend 是一个面向现代数据工作负载的云原生数仓。SQL parser、AST、binder、optimizer、表达式系统和执行计划生成,都是支撑复杂查询能力的基础设施。

这类基础设施的长期演进,需要同时满足两个要求:

  • 快速迭代,让新语法、新函数、新查询模式和 AI 辅助开发能够进入工程流程;

  • 保持 correctness,让迁移、重构和 API 演进不会悄悄改变 SQL 语义。

AST Visitor 迁移验证体现的是 Databend 工程体系中更底层的一类能力:不是只追求“代码生成得快”,而是把复杂变更放进可观测、可比较、可诊断、可持续扩展的验证闭环里。

这和 Databend 的用户心智是一致的。作为开放、Rust-native、面向云原生分析场景的数据仓库,Databend 不只是提供 SQL 功能本身,也需要在内核层面保证这些能力可以长期演进、可维护、可验证。

对于正在把 AI 引入工程流程的团队来说,这类实践也提供了一个更实际的判断标准:AI 是否真正提升工程效率,不取决于它能生成多少代码,而取决于系统是否能验证这些代码进入基础设施之后仍然保持正确。

总结

旧 visitor API 换成新 API,真正困难的从来不是批量写出代码,而是建立足够可信的迁移证据。

这套方法也不是一开始就以最终形态出现的。围绕“怎样验证一棵给定 AST 上的新旧行为”,最终形成了四个相互配合的内核能力:源码扫描、运行时观测、独立比较和失败定位。在此之上,真实 SQL 语料扩大了输入范围,覆盖率反馈又让尚未验证的范围保持可见,最终形成完整闭环。

AI 可以加速迁移代码的生产,但它不负责为自己的输出提供可信度。真正让这种大规模基础设施迁移变得可控的,是独立观测、集中判断、真实输入、可诊断差异以及持续可见的覆盖边界。

分享本篇文章

订阅我们的新闻简报

及时了解功能发布、产品规划、支持服务和云服务的最新信息!