- 后端
【免费下载链接】prql
PRQL is a modern language for transforming data — a simple, powerful, pipelined SQL replacement
PRQL 是一门面向数据转换的现代语言,其编译器本质上是把 PRQL 翻译成 SQL 的转译器;但官方语言设计指南明确指出,语言设计绝不能以"这个特性怎么翻译成 SQL"为出发点,而应以"这个特性如何作用于关系(relation)"为中心。本文以 language-design.md 为核心骨架,结合 prqlc 编译器的真实源码与测试,剖析这一设计原则的来龙去脉,帮助你建立正确的 PRQL 语言设计视角——无论你是想为 PRQL 提交新特性提案,还是想深入理解编译器内部如何把一条条流水线翻译成最终 SQL。
一、背景:PRQL 首先是一个"转译器"
PRQL 在项目中的定位是"SQL 的流水线式替代品"(a simple, powerful, pipelined SQL replacement)。在实现层面,它确实是一个把 PRQL 文本编译成 SQL 文本的转译器:prqlc提供的顶层入口 compile 函数 接收一段 PRQL 字符串,依次经过prql_to_pl(解析成 PL AST)、pl_to_rq(语义分析与降级)、rq_to_sql(生成 SQL)三个阶段后返回 SQL 字符串。
正因为"最终要产出 SQL"这一事实如此显眼,语言设计很容易滑向一种以 SQL 为中心的思维惯性,把每个 PRQL 特性都先想象成"它对应哪段 SQL":
PRQL feature -> SQL feature -> relational result官方语言设计指南明确指出这种思路是有缺陷的,原因有两点:
- 它无法很好地建模特性之间的交互。两个特性单独看都能翻译成合理的 SQL,但当它们组合在一起时,翻译结果可能出现语义偏差,而"逐个特性翻译"的思维框架根本看不到这一点;
- SQL 行为本身有时具有误导性,甚至在不同方言之间不一致。例如子查询中的排序不会在父查询中保留;集合运算(UNION、INTERSECT、EXCEPT)在方言之间也存在行为差异。
二、为什么"以 SQL 为中心"是错的:来自源码的佐证
2.1 特性交互:sort、group、join的组合陷阱
官方文档指出"它不能很好地建模特性之间的交互"。仓库里恰好有一个回归测试可以当作活教材。在 lib.rs 的test_sort_not_propagated_after_join测试中,注释直接引用了 PRQL 规范:
Per PRQL spec,
groupresets the order. Thesortinside a group is for row selection (which row to keep), not output ordering. After the group, there is no defined order, so it should not appear in the outer query.
翻译过来:按照 PRQL 语义,group会重置排序;group内部的sort只用于"选哪一行保留"(即行选择),而不是输出排序;group之后没有定义好的顺序,所以它不应该泄漏到外层查询。
看这条测试对应的 PRQL 与生成 SQL 的对照:
prql target:sql.postgres from tracks group media_type_id ( sort name take 1 ) join media_types (== media_type_id) select { tracks.track_id, media_types.name }生成的 SQL 中,sort name只出现在 CTE 内部的DISTINCT ON行选择逻辑里,外层JOIN之后的 SELECT 没有任何ORDER BY:
WITH table_0 AS ( SELECT DISTINCT ON (media_type_id) track_id, media_type_id, name FROM tracks ORDER BY media_type_id, name ) SELECT table_0.track_id, media_types.name FROM table_0 INNER JOIN media_types ON table_0.media_type_id = media_types.media_type_id而 同文件中的另一个测试test_explicit_sort_after_distinct_on_preserved则验证了相反的情况:如果用户在group之后显式sort media_type_id,这个新引入的顺序就应该穿透join一直传播到最终输出。这两条测试一正一反,正是"特性交互必须用关系语义来定义、而不能靠逐个翻译 SQL 来凑"的绝佳证据——如果设计者脑子里只有"sort → ORDER BY"这一条映射,就根本无法回答"group内的 sort 和group外的 sort 到底该落在生成的哪一层"。
2.2 SQL 行为的误导性:子查询的顺序不会保留
官方文档指出"SQL 行为有时会误导人:子查询中的顺序不会在父查询中保留"。这可以从编译器的 RQ(Relational Query)中间表示中得到印证:在 RQ 层面,排序是关系的一种显式属性,Sort是关系变换列表中的一个独立变换;而在 SQL 层面,子查询里的ORDER BY只有在LIMIT/OFFSET伴随下才有确定语义,一旦进入父查询,子查询的排序约定就会丢失。因此编译器不能在"翻译到最后一步"之前就依赖 SQL 子查询的排序行为,而必须在语义层先把顺序表达为关系上的确定属性,再由 SQL 生成阶段决定把它物化到哪里(CTE、窗口函数还是最外层ORDER BY)。
2.3 方言差异:集合运算与DISTINCT ON
官方文档还指出 SQL 行为"在不同方言之间不一致(集合运算)"。prqlc 对此的应对是:把方言相关的决策尽量推迟到编译管线最末端的 SQL 后端阶段,而不是让语言语义去迁就某个方言。仓库中的 sql/dialect.rs 定义了Dialect与SupportLevel枚举,SQL 后端针对不同方言采用不同的生成策略;例如上面测试里出现的DISTINCT ON就是 PostgreSQL 方言特有的写法,它只在target:sql.postgres下才会被生成,其他方言会走各自的等价实现。语言本身不关心这些差异——关系语义只有一个,方言差异是"最后一公里"的事。
三、正确的视角:PRQL 特性作用于关系
官方文档给出了替代方案:我们应该思考 PRQL 特性如何影响 PRQL 表达式——在绝大多数情况下,也就是如何影响关系:
PRQL feature -> relation | v PRQL feature -> relation | v PRQL feature -> relation | v relational result这条流水线式的图示与 PRQL 语言本身的形态完全同构:PRQL 的一条查询就是由from、select、filter、group、join等变换(transform)构成的管线,每一个变换都接收一个关系、产出一个新关系,最终得到关系结果。语言特性(比如sort、take、window)首先应该被理解为"对当前关系的某种操作",而不是"对 SQL 的某种翻译"。
3.1 源码中的"关系":RQ AST
这条原则在编译器中间表示里体现得极为直接。RQ(Relational Query)是编译器在语义分析后得到的"严格类型化、用于描述关系查询"的 AST(见 ir/rq/mod.rs)。其核心结构是:
RelationalQuery:由def(查询定义)、tables(表声明集合)与relation(主关系)组成;Relation:包含kind与columns(列定义,即该关系对外暴露的接口);RelationKind:关系的种类,其中Pipeline(Vec<Transform>)表示一条由多个变换组成的关系管线。
而变换本身是一组封闭的、与 SQL 无关的语义操作,见 ir/rq/transform.rs 中的Transform枚举:
pub enum Transform { From(TableRef), Compute(Compute), Select(Vec<CId>), Filter(Expr), Aggregate { partition: Vec<CId>, compute: Vec<CId> }, Sort(Vec<ColumnSort<CId>>), Take(Take), Join { side: JoinSide, with: TableRef, filter: Expr }, Append(TableRef), Loop(Vec<Transform>), }注意这里没有任何"SELECT 关键字""ORDER BY 子句"之类的 SQL 词汇——Filter、Sort、Take、Aggregate、Join描述的都是对关系的变换,这正是"PRQL 特性 → 关系"原则在实现层的直接体现。
3.2 语义层的关系状态:Lineage(谱系)
在语义分析阶段(PL 层面),编译器为每一个变换维护"当前关系"的完整描述,这就是 ir/pl/lineage.rs 中定义的Lineage。它的文档注释写得很直白:
Represents the object that is manipulated by the pipeline transforms. Similar to a view in a database or a data frame.
——"表示被管线变换所操作的对象,类似于数据库中的视图或一个数据框。"Lineage 记录了当前关系的列集合:每个列要么是Single(单个列,带有名字与定义它的表达式 id),要么是All(来自某个输入的整组列,支持except排除集,对应foo_table.*语法)。语义解析器(semantic/mod.rs 中的resolve)在逐条处理语句时会同步更新这个 Lineage——一个变换作用于关系、产生新关系,然后下一个变换再作用于这个新关系,这与官方文档第二张图的链条完全一一对应。
3.3 从源码注释到设计共识
在 lowering.rs 的模块注释中,编译器明确写下了降级(PL → RQ)过程要保证的语义不变量:
- transforms 不会被嵌套(每个变换都是关系层面的一层操作);
- transforms 具有正确的 partition、window 与 sort 设置;
- 不存在未解析的表达式。
这组不变量本质上就是"以关系为中心"的工程化落地:语言特性必须在语义层就被规范化成对关系的确定操作,而不是把问题留给 SQL 生成阶段去"碰运气"。
四、SQL 只在最后一步介入
官方文档的结论是:"只有在最后一步——当关系(或者说关系表达式)被翻译成 SQL 表达式时——才需要开始思考 SQL。"这一论断与 prqlc 的编译架构(ARCHITECTURE.md)完全吻合。编译器划分为三大阶段:
| 阶段 | 子阶段 | 使用的 AST |
|---|---|---|
| parse | lexer / parser | LR(Lexer Representation)→ PR(Parser Representation) |
| semantic | ast_expand / resolver / flatten / lowering | PR → PL → RQ |
| sql | preprocess / pq-compiler / postprocess / sql-compiler / codegen | RQ → PQ →sqlparser::ast→ string |
三个阶段里,只有最后一个sql阶段才接触 SQL。semantic阶段的全部工作(名称解析、声明提取、类型推断、Lineage 计算、PL 降级为 RQ)都不依赖任何 SQL 概念;RQ 之后,sql/mod.rs 的compile函数才接手:把 RQ 中的每个关系转换为一个 SQL 查询,把管线在合适位置切分成可以用单个 SELECT 表达的小段(即"AtomicPipelines"),最后按方言生成文本。
这带来了一个重要的实践含义:语言设计者在思考新特性时,应当先在"关系"这一层把语义定义清楚,SQL 只是最后的表达手段。只要关系语义是确定的、自洽的,无论最终要适配哪个 SQL 方言、是生成 CTE 还是嵌套子查询,都只是sql阶段的技术决策,而不会反过来推翻语言设计。
4.1 一个例子:名称解析的"最后一公里"
参考文档 name-resolution.md 的 "Translating to SQL" 一节是这条原则的绝佳注脚:PRQL 的作用域规则完全是关系层面的——from或join引入的表会作为命名空间注入作用域,命名空间里是该表的所有列,此外还有一个无名的特殊命名空间("frame")装着当前变换正在操作的关系的列。
至于"生成的 SQL 里列名要不要带表前缀",完全是 SQL 翻译层的决定:当查询只引用一张表时,不需要前缀(select first_name直接生成SELECT first_name);当存在多张表且无法确知所有列归属时,才给所有列名加上表前缀以防歧义。也就是说,"有没有前缀"是 SQL 生成阶段根据歧义风险做出的工程取舍,并不改变 PRQL 语言本身的语义——语言层面,first_name始终通过作用域解析到唯一确定的列。
五、把原则落到实践:评估一个新 PRQL 特性
把官方设计指南转化为可操作的设计流程,大致如下:
- 问关系层面的问题:这个特性输入一个关系时,输出什么关系?它改变列(如
derive、select)、改变行(如filter、take)、改变分组与聚合状态(如group、aggregate),还是改变顺序(如sort)?它和前后相邻的变换如何组合? - 用 PRQL 语义先定义清楚,例如
group内sort只用于行选择、不决定输出顺序,这是关系语义层的规定(详见 group 标准库文档 与相关回归测试); - 最后才考虑 SQL 表达:这个关系语义在目标方言下如何落地?是直接映射、用 CTE,还是需要拆分管线(如窗口函数在 WHERE 子句中无法物化时触发拆分,见 ARCHITECTURE.md 中关于 anchoring 的说明);
- 用反例检验:如果按"逐特性翻译成 SQL"的思路会得出错误组合结果,说明这个特性还没有被定义清楚,应该回到关系层面继续推敲。
六、参与 PRQL 语言设计的正确姿势
如果你希望亲自参与 PRQL 的语言设计,仓库的贡献指南给出了一套与本文原则呼应的做法:
- 新语言特性在 GitHub issue 中决策,通常挂有
language-design标签;讨论的核心就是"这个特性如何作用于关系"这类语义问题,而不是 SQL 怎么写; - 发现编译器产生错误结果时提交 bug 报告,可以使用仓库内的 playground 前端(
web/playground目录)快速复现查询并导出生成 SQL; - 收集难以用 PRQL 表达的查询示例(尤其是比 SQL 更难表达的情况),并贴到相关 issue 中——官方强调,脱离示例的建议很难被认真对待,任何建议都应当锚定在具体示例上;
- 想动手写编译器代码的话,可以沿 development.md 与 ARCHITECTURE.md 入手,编译器(
prqlccrate,位于 prqlc/prqlc)用 Rust 编写,其 PL/RQ 中间表示正体现了本文所述的设计思想。
七、总结
PRQL 语言设计指南的核心只有一句话:把每个特性想成"对关系的操作",而不是"对 SQL 的翻译"。以 SQL 为中心的视角会遮蔽特性间的交互、被 SQL 本身的误导性行为带偏、并且无法统一处理方言差异;而以关系为中心的视角让语言语义保持自洽,SQL 只在编译管线的最末端作为表达手段登场。prqlc 的架构(PL 语义层维护 Lineage、RQ 用Pipeline(Vec<Transform>)描述关系变换、SQL 后端在最后阶段才处理方言与物化策略)正是这一设计哲学的工程实现。带着这个视角去阅读 language-design.md、去 review 新的语言特性提案,你会发现 PRQL 的设计讨论始终围绕一个清晰的问题展开:这个特性,让关系发生了怎样的变化?
- 后端
【免费下载链接】prql
PRQL is a modern language for transforming data — a simple, powerful, pipelined SQL replacement
相关推荐
english-note哲学:语言与思维关系
english note哲学:语言与思维关系 还在为英语语法头疼不已?每次看到复杂的语法规则就望而却步?english note项目通过独特的哲学视角,揭示了语
文档知识库教程终极指南:从Python到Elixir的编程语言设计哲学解析
终极指南:从Python到Elixir的编程语言设计哲学解析 编程语言哲学是开发者理解代码世界的钥匙。不同的语言设计思想不仅影响着代码的编写方式,更塑造了程序员
文档教程技术博客知识库VideoMAE模型库全面解析:从ViT-S到ViT-H的性能对比与应用场景
VideoMAE模型库全面解析:从ViT S到ViT H的性能对比与应用场景 VideoMAE是NeurIPS 2022 Spotlight收录的创新视频自监督
深度学习计算机视觉
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考