Hotdry.
compilers

PRQL 管道式查询语言语法设计与 SQL 编译管线

解析 PRQL 如何通过多层级中间表示实现从管道式语法到 SQL 的编译转换,涵盖词法分析、语义分析与 SQL 生成的核心设计。

当我们谈论数据库查询语言时,SQL 几乎是唯一的主角。然而,SQL 的设计诞生于上世纪七十年代,其语法继承了关系代数的声明式风格,却在表达现代数据变换流程时显得冗长且不够直观。PRQL(Pipelined Relational Query Language)试图填补这一空白,它采用管道式的语法设计,让数据转换像 Unix 管道一样线性流动,同时通过完整的编译管线将这种高级语法转换为目标 SQL 方言。本文将深入解析 PRQL 的管道式语法设计与 SQL 编译管线的核心技术实现。

管道式语法的设计哲学

传统 SQL 采用声明式语法,查询语句通常以 SELECT 开头,后跟列表达式、FROM 子句指定数据源、WHERE 条件过滤、GROUP BY 分组聚合等。这种语法顺序与人类思考数据流动的顺序并不一致 —— 我们通常先想到「从哪些数据开始」,再想到「要过滤掉什么」,然后「如何分组」以及「最后输出什么」。PRQL 的管道式语法正是为了解决这一认知错位而设计。

一段典型的 PRQL 代码如下:

from employees
filter start_date > @2020-01-01
derive full_name = f"{first_name} {last_name}"
group department (
  aggregate [ total_salary = sum salary ]
)
sort -total_salary
take 10

这种语法让每个变换操作占据一行,数据从左到右依次流经 fromfilterderivegroupsorttake 等步骤,最终得到结果。每一行都对前一行输出的数据进行处理,形成一条清晰的数据流水线。与 SQL 相比,PRQL 不需要记住子句的固定顺序 ——filter 永远在 derive 之前,group 永远在 sort 之前,这种顺序强制了良好的查询结构。

管道式语法的另一个优势在于其可读性。即使是复杂的查询,也能通过自上而下的阅读顺序理解数据的变换路径,而无需在脑海中重新排列 SQL 子句的顺序。对于需要频繁编写数据查询的分析师和工程师来说,这种语法显著降低了认知负担。

编译管线的整体架构

PRQL 编译器采用经典的多阶段降低(lowering)架构,将高层次的管道式语法逐步转换为目标 SQL。整个编译管线可以划分为以下几个主要阶段:词法分析与解析、语义分析、管道切分、以及 SQL 生成。

编译器的顶层入口接收 PRQL 源代码字符串,首先经过词法分析器将字符流转换为标记流(Token Stream),然后由解析器根据 PRQL 的语法规则构建抽象语法树(AST)。这一阶段产生的 AST 称为 PR(Parser Representation),它忠实地反映了源代码的语法结构,包括各种语法糖和简写形式。

解析完成后,AST 进入语义分析阶段。语义分析是 PRQL 编译管线中最复杂的部分,它负责名字解析、类型推断、作用域管理等工作。这个阶段将 PR 转换为 PL(Pipelined Language),一种更加规范化、更加面向管道的中间表示。在 PL 中,语法糖被展开为更基础的变换组合,标识符被解析为完全限定名,类型信息被标注在各个表达式上。

接下来,PL 被降低为 RQ(Resolved Query),即解析后的关系查询表示。RQ 是一种更加接近关系代数的中间表示,它将管道式的变换拆解为具体的关系操作符,如投影、选择、聚合、连接等。在这个层次上,编译器已经不再关注 PRQL 的语法细节,而是专注于如何将数据流建模为关系运算的组合。

最后,RQ 进入 SQL 生成阶段。在这个阶段,编译器首先将 RQ 切分为多个原子管道(Atomic Pipeline),每个原子管道对应一个独立的 SQL SELECT 语句。然后,这些原子管道被转换为标准 SQL 的抽象语法树,再经过代码生成器输出为目标方言的 SQL 字符串。整个过程如同一条流水线,原材料(PRQL 代码)经过多道工序的加工,最终产出成品(SQL 语句)。

语义分析的核心机制

语义分析是编译器确保代码正确性的关键环节。PRQL 的语义分析器承担着多重职责:名字解析负责将用户定义的标识符映射到具体的声明;类型检查确保操作符和函数的参数类型匹配;作用域管理维护嵌套作用域中的可见性规则;帧(Frame)计算则追踪每个管道步骤后可用的列集合。

名字解析是语义分析的第一步。当编译器遇到一个标识符时,它需要在当前作用域及其父作用域中查找该标识符的声明。PRQL 的模块系统允许用户定义命名空间,编译器需要处理从根模块到子模块的完整路径解析。例如,std::date::today 这样的完全限定名需要正确解析到标准库中的日期函数。解析结果是一个唯一标识符,它在整个程序范围内具有唯一的引用。

类型检查确保 PRQL 代码在类型层面是合法的。PRQL 采用表达式级别的类型推断,编译器根据字面量、函数签名、操作符类型等上下文信息推断每个表达式的类型。当用户尝试对不兼容的类型进行操作时 —— 比如对字符串使用数值聚合函数 —— 编译器会报错并指出问题所在。这种静态类型检查能够在编译时捕获大量潜在错误,避免它们在运行时才被发现。

帧计算是 PRQL 编译器的一个独特机制。由于 PRQL 是管道式的,每个变换步骤都会改变输出数据的结构。帧记录了某个管道位置可用的列集合及其类型信息。当执行 derive 操作时,编译器需要知道输入帧中有哪些列可用;当执行 group 操作时,编译器需要知道分组键是否存在于当前帧中。通过追踪帧的变化,编译器能够验证每个变换操作是否合法,并在生成 SQL 时正确地构建列映射。

语义分析的结果是带有完整语义标注的 AST,每个节点都关联了类型信息、解析后的名字、所在帧的上下文。这些标注如同交通标志,为后续的编译阶段提供方向指引。

管道切分与 SQL 生成

将管道式的 PRQL 转换为 SQL 并非简单的一对一映射。SQL 的语法结构要求特定的子句出现在特定位置,而且某些 SQL 特性(如窗口函数)只能在特定上下文中使用。因此,编译器需要分析管道的依赖关系,将长管道切分为多个可独立执行的 SQL 语句块。

管道切分的核心算法逆向遍历管道,从输出端向前推进,确定每个表达式可以在哪个位置计算。当遇到不能在当前位置计算的表达式时 —— 比如在 WHERE 子句中引用窗口函数的结果 —— 编译器就会在此处插入一个切分点,形成新的原子管道。每个原子管道对应一个独立的 SELECT 语句,它们通过 CTE(Common Table Expression)或子查询串联起来。

例如,考虑以下 PRQL:

from orders
derive order_value = quantity * unit_price
filter order_value > 1000
sort order_date

这个管道可以直接映射为一个 SELECT 语句。但如果我们在 filter 之后再添加一个窗口函数:

from orders
derive order_value = quantity * unit_price
filter order_value > 1000
sort order_date
derive rank = rank order_value

由于窗口函数 rank 需要在排序后的数据上计算,而排序本身不能在同一个 SELECT 的 WHERE 子句中使用,编译器会将这个管道切分为两个原子管道:第一个计算 order_value 并过滤,第二个在 CTEs 的基础上进行排序并计算排名。最终生成的 SQL 类似:

WITH subq0 AS (
  SELECT *, quantity * unit_price AS order_value
  FROM orders
  WHERE quantity * unit_price > 1000
)
SELECT *, rank() OVER (ORDER BY order_date) AS rank
FROM subq0
ORDER BY order_date

SQL 生成阶段的最后一步是将中间表示转换为目标方言的 SQL 字符串。PRQL 支持多种 SQL 方言,包括 PostgreSQL、MySQL、SQLite、BigQuery 等。不同方言在字面量语法、函数名称、引号规则等方面存在差异,编译器在生成阶段处理这些差异,确保输出的 SQL 能够在目标数据库上正确执行。

与数据库引擎的本质区别

值得强调的是,PRQL 作为一种查询语言编译器,其设计与数据库事务引擎的 ACID 实现属于完全不同的技术层面。ACID 关注的是数据持久化、并发控制、故障恢复等运行时保障机制,这些是由数据库引擎在执行阶段处理的。而 PRQL 解决的是查询的表达问题 —— 如何让用户以更直观的方式编写查询,然后将这些高级表达式转换为数据库能够理解的 SQL。

换句话说,PRQL 位于数据库系统的更上层,它是一种领域特定语言(DSL),编译输出的 SQL 会被发送到数据库引擎执行。数据库引擎看到的仍然是标准的 SQL 语句,它不知道这条 SQL 是由 PRQL 生成的,还是由用户直接编写的。因此,PRQL 并不涉及事务、锁、日志等底层数据库技术,它的核心价值在于提升查询表达的生产力和可维护性。

这种分层设计也是现代数据工具链的常见模式:用户使用友好的高级抽象,编译器负责转换为底层的标准形式,而数据库引擎专注于高效执行。这种关注点分离让每一层都可以独立演进 ——PRQL 可以改进语法和编译优化,而数据库可以优化执行引擎,双方通过 SQL 这一标准接口解耦。

总结

PRQL 通过管道式语法重新设计了数据查询的表达方式,让数据变换流程清晰可见。其编译管线采用多层级中间表示的设计,从解析后的 AST 到规范化的管道语言,再到关系查询表示,最后通过管道切分算法生成目标 SQL。整个过程涉及词法分析、语义分析、代码优化和目标代码生成等多个经典编译器阶段。对于理解现代查询语言的设计和编译器实现原理,PRQL 是一个值得关注的研究案例。

资料来源:PRQL 官方文档(prql-lang.org)及 GitHub 仓库中的架构设计文档。

查看归档