· 7 分钟阅读

我们为什么为 DynamoDB 手写了一个 PartiQL 解析器

DynamoDB 只接受 中很窄的一小块,其余一切都在请求时被拒绝。GROUP BYValidationException。语句级的 LIMITValidationException* 运算符、CAST、一个子查询?它们在你脑子里都解析得好好的,沿着网络传了出去,然后死在服务端。而这份知识唯一存在的地方就是 AWS 文档和那些错误消息 —— 这意味着每一个面向 DynamoDB 的编辑器(包括我们的,有一段时间)都会乐呵呵地让你写出一条引擎注定会拒绝的语句。

我们希望这次拒绝发生在编辑器里、发生在敲键的那一刻,在出问题的那个子句下画上红色波浪线,并在存在改写方案时给出一键修复。这个编辑器需求最终变成了一个为 DynamoDB 的 PartiQL 方言手写的词法分析器和 CST 解析器,而本周我们把它开源了:dynamodb-partiql-parser,纯 TypeScript,零依赖,MIT 协议,配套的 CodeMirror 接线则单独发布为 codemirror-lang-partiql。这篇文章讲的是它为什么是手写的、第一版检查器错在哪里,以及那两个只有在有人粘贴了垃圾输入时才会现身的 bug。

正则曾经够用,直到它不再够用

DynoTable 里的第一版 PartiQL 检查器大约是 650 行正则与 token 扫描,而且它是真的有用:十九项各自独立的检查,为常见陷阱提供快速修复(IN (...) 改成 [...]LIKE 改成 contains()IS NULL 改成 attribute_not_exists())。它发布了,它抓到了真实的错误,用户不再为它覆盖到的那些情形提交“为什么我的查询会失败”的工单。

但一个正则检查器认识的是模式,不是结构。它看不出 SELECT price * quantity 里的 * 是 DynamoDB 会拒绝的算术运算,因为 * 也表示“所有列”,而要分辨这两者就得真的去做解析。它的诊断范围只是近似值 —— 足以指向某一行,却粗到无法驱动一次按精确偏移量拼接文本的快速修复。而且每加一项新检查,这堆东西就更脆一分,因为每条正则都得防着其他每条正则的假设。

“检查器需要结构”这件事的解法是一个解析器。问题在于用哪一个。

没人造过这么一个

在工作台的真 SQL 那一侧,我们已经经历过这件事:一个欺骗了我们的现成 SQL 解析器,被 sql-parser-cst 替换掉,后者在每个节点上都携带源码范围,并且保留了带引号与不带引号标识符的区别。那段经历为 PartiQL 这一侧划定了标准 —— 要的是一棵无损的具体语法树(CST),而不是一棵有损的 AST。

但在对解析器而言要紧的那些地方,PartiQL 并不是 SQL。DynamoDB 的方言用方括号写 IN 列表(WHERE OrderID IN [100, 300, 234])、有包字面量(<<'a', 'b'>>)、有带引号键的映射字面量({'rating': 5})、有一个 MISSING 字面量、有带列表下标的文档路径(Devices.FireStick.DateWatched[0]),还有 RETURNING ALL OLD * —— 这些没有一样是 SQL 语法认识的。反过来,它又缺了 SQL 语法坚持要有的一半东西。当时 npm 上的那些解析器,是 AWS 那个面向通用 PartiQL 的 Rust 实现的 WebAssembly 构建,完全没有“DynamoDB 具体会拒绝什么”的概念。

于是我们自己写了一个:一个小小的词法分析器加一个递归下降解析器,形态照着 sql-parser-cst 教会我们去期待的样子来做。每个节点都携带自己的字节范围。整个东西没有任何运行时依赖 —— 这是 CI 如今会断言的一项性质,因为正是它让这个解析器可以被嵌进任何地方,包括浏览器,包括你的项目。

语法是容易的那一半。一个检查器的解析器,一辈子都在解析_坏掉的_代码。敲到一半的键、半条语句、第三个子句里的一个笔误。碰到第一个错误就停下来会让编辑器变得毫无用处,所以这个解析器是容错的:它记录一条诊断、重新同步,然后继续往下走,于是在第二个子句还不完整时,第四个子句照样能被检查。

在飞机不落地的情况下换掉引擎

等到解析器就绪的时候,正则检查器的那四个函数已经在整个编辑器里承重了 —— 其中包括决定一条语句是否可以安全自动执行的那一个。悄无声息地改变那个行为,表现出来就是“编辑器不肯运行我的查询”,而这类 bug 用户与其说会来报告,不如说会直接走人。

所以这次替换走的是绞杀者模式:旧检查器被改名、冻结,并留在代码树里。新的解析器驱动的检查器重新导出了完全相同的那四个函数。而一套一致性对照语料把每一条夹具同时喂给_两个_检查器,并把两边的输出相互钉死 —— 正则版本产出的每一条诊断,解析器版本都必须同样产出,然后才被允许产出更多。旧检查器至今还在那里,冻结着,作为这次替换所许下承诺的可执行文档。

只有垃圾输入才能找出的 bug

有两个故障从未在任何真实查询里出现过,而它们中的任何一个都会把编辑器搞垮。

CodeMirror 的检查器是在文档上同步运行的,每次变更都跑一遍,上方没有任何错误接收器。一个未捕获的异常不会只是让一次检查失败 —— 它会让编辑器白屏。而递归下降解析器天生就内建了一个未捕获异常的来源:调用栈。粘贴一个嵌套几千层深的 [[[[[[…,或者一条 NOT NOT NOT … 链,每一层嵌套就是一个栈帧;V8 最终会抛出 RangeError: Maximum call stack size exceeded,直接穿过检查器。

修复方式是刻意做得无聊的。表达式递归有一个硬性的深度上限 —— 五百层,远超任何人手写得出来的东西,又远低于栈的预算 —— 越过之后解析器发出一条诊断,而不是抛异常。而那些在粘贴中现实里真的会连成串的构造,比如几千个分支长的 A UNION B UNION C …,被从递归改写成了扁平列表:一个 parseSelect 栈帧加上一个集合操作的数组,而不是每个分支一个栈帧。如今的压力测试套件会在每次构建时粘进 100 KB 的垃圾和 30000 层深的运算符链,而公开发布的包把整条流水线包进了一个绝不抛异常的 lint() 入口,因为下一个嵌入它的编辑器会遇到跟我们一样的“没有错误接收器”的问题。

一套可以对照 AWS 文档来审计的测试套件

那些方言规则 —— DynamoDB 接受什么、拒绝什么、哪种改写能修好哪种问题 —— 全都来自 AWS 的 PartiQL 参考文档。从文档推导出来的行为有一种特定的失效模式:文档变了,代码没变,而且没人发现。

所以这套语料是照着文档来组织的。两百零八条夹具,每一条的开头都写着这条规则所出自的那个 AWS 文档页面的 URL。一张覆盖率表把每一条有文档记载的规则映射到它的夹具,一旦某条规则丢了夹具,测试套件就会失败。当 AWS 改动方言时,那个 diff 就是一个带着引用出处的夹具 diff。

这份纪律在我们开源那一周就把自己赚了回来。检查器的 IN 列表警告引用了两个上限:分区键列上 50 个值,非键列上 100 个。在发布前重新核对每一个数字时,我们能在 AWS 当前的文档里确认那个 100 —— 却在任何一份现行文档里都找不到那个 50。它在博客文章和老论坛回帖里四处存活着,但一手来源早已翻篇。检查器是碰巧对的(它只在超过 100 时才警告,因为在不知道你的 schema 的情况下,它无法判断适用哪一种情形),而如今那条注释明确写出了这个说法里哪一半有文档、哪一半是江湖流传。

如果你也要造一个,有哪些经验可以迁移

  • 为一个小方言手写一个递归下降解析器,是几天的活,不是几个月,而且每一条错误消息都归你自己所有。“写个解析器”那个吓人的版本,预设的是一套庞大的语法。
  • 构建 CST,而不是 AST。每个节点上的字节范围,正是把诊断变成快速修复的东西;一棵有损的树没法拼接文本。
  • 如果解析器要喂给一个检查器,容错就是功能本身。恢复并继续;一个在第一个错误处停下的解析器,之后的一切都检查不到。
  • 在一个冻结的接口背后替换引擎,并用一致性对照语料把新旧两边钉在一起。旧实现就是你早已同意过的那份规范。
  • 只要输入能嵌套,就会有人粘进来一个嵌套得离谱的东西。给递归设深度上限、把链条扁平化;用垃圾输入去测试,而不只是用查询。
  • 在测试里注明你的出处。一条写明了自己所编码的文档页面的夹具,是一个在文档变动时可以被审计的测试 —— 而它一定会变。

这个解析器在 GitHub 和 npm 上(npm install dynamodb-partiql-parser),编辑器集成在 codemirror-lang-partiql。如果你想要的是方言本身而不是解析器,PartiQL 与 SQL 对比讲了 DynamoDB 这个子集能做什么、不能做什么,而 PartiQL 示例是实操走查;这一切为之而造的那个编辑器就在 DynoTable 里,你可以免费试用

无需控制台即可使用 DynamoDB

一款快速的 DynamoDB 桌面客户端,可运行 DynamoDB 无法执行的真正 SQL——JOINs、GROUP BY、聚合——并支持可视化编辑和运行在你自己的 Bedrock 密钥上的 AI agent。

30 天免费试用,无需信用卡 — 之后为无时间限制的免费版。