PRQL 的 Raku 语法实现:从 Grammars 定义到形状断言测试的完整指南
2026/9/24 7:44:09 网站建设 项目流程
  • 后端

【免费下载链接】prql

PRQL is a modern language for transforming data — a simple, powerful, pipelined SQL replacement

项目地址:https://gitcode.com/gh_mirrors/pr/prql
点击查看免费下载

PRQL(Pipelined Relational Query Language)在仓库中维护了面向多种编辑器和语言生态的语法定义,grammars/raku/ 正是其中的 Raku 实现:一个用 Raku 原生grammar机制书写的 PRQL 解析器,附带一套以"解析树形状"为断言对象的测试体系。本文将带你从快速上手、安装、测试运行,一路深入到grammar的规则定义、测试辅助模块的实现细节与已知的设计边界,读完即可自行解析 PRQL 查询、运行并扩展这套测试语料。

背景:grammars/raku/在 PRQL 仓库中的定位

PRQL 项目将各生态的语法定义集中存放在 grammars/ 目录下,作为"语法/语法高亮"定义的索引页,覆盖 CotEditor、GtkSourceView、KSyntaxHighlighting、emacs、nano、Lezer、Raku 等实现。与主要用于编辑器高亮的词法定义不同,grammars/raku/提供的是一套完整的 Raku Grammars 解析器——它不仅能识别 token,还能把整条 PRQL 查询解析成一棵带命名的捕获树,并配套了断言这棵树结构的测试基础设施。

该目录结构如下:

  • lib/prql.rakumod:grammar PRQL的完整定义,即解析器本体;
  • t/lib/PRQLTest.rakumod:测试辅助模块,提供parses-toshape等工具;
  • t/:按语法主题拆分的 10 个.rakutest测试文件;
  • META6.json:Raku 生态(zef)的模块元数据。

快速上手:解析一段 PRQL 查询

README 给出了最简用法。在当前目录下执行(模块源码就在本地lib/下,无需安装即可使用):

use lib '.'; use prql; say PRQL.parse('from employees'); say PRQL.parsefile('employees.prql');

第一行把当前目录加入模块搜索路径,第二行通过use prql;载入 lib/prql.rakumod(模块名prql由 META6.json 的provides字段映射到该文件),随后即可用 Raku 内置的Grammar.parse/Grammar.parsefile方法解析字符串或文件。

PRQL.parse返回的是 Raku 的Match对象:解析成功时它携带完整的命名捕获树,这正是后面"形状断言"测试所依赖的对象;解析失败则返回Nil

从源码安装

如果要把这个语法作为正式的 Raku 模块安装进环境,README 说明从源码安装只需一条命令:

zef install .

zef是 Raku 生态的模块安装工具,.表示安装当前目录下的模块。安装依据是 META6.json,其关键字段如下:

字段说明
namePRQL模块名
version0.0.1版本号
provides{ "prql": "lib/prql.rakumod" }模块prql指向解析器源文件
raku6.*兼容 Raku 6.x
licenseArtistic-2.0开源许可
tagsdata, grammar, pipeline, sql检索标签
productionfalse当前非生产发布状态(从元数据看仍为早期版本)

运行测试:单文件与全量两种方式

测试采用 Raku 官方的Test框架(.rakutest)。README 说明可以指定文件名运行单个测试文件:

raku t/arithmetics.rakutest

要运行整个t/目录的全部测试,需要先安装prove6(Raku 对 Perlprove的移植),再以库模式执行:

zef install App::Prove6 prove6 --lib t/

--lib让测试可以use prql;找到 lib/prql.rakumod,而测试文件自身通过use lib $?FILE.IO.parent.add('lib').Str;把同级的t/lib/加入搜索路径,从而加载 PRQLTest.rakumod。

核心测试理念:断言"解析树的形状",而非仅仅"能否解析"

README 强调了这个实现最值得关注的设计:每个测试断言的是语法构建出的树的结构(shape),而不只是查询能否被解析。示例:

parses-to 'filter 10 * 10', 'statement(pipeline-statement(pipeline(call-expression(identifier«filter»,test(test-inner(binary-test(expression(number(integer«10»)),arith-op«*»,expression(number(integer«10»)))))))))';

这段断言的含义是:filter 10 * 10应当解析为一棵statement → pipeline-statement → pipeline → call-expression的树,其中filter是叶子捕获identifier,其参数是一个由binary-test连接两个number(integer)的二元测试,运算符是arith-op捕获的*

parses-to的来源:t/lib/PRQLTest.rakumod

parses-to 定义在测试辅助模块中,签名是:

sub parses-to(Str:D $source, Str:D $expected, Str :$desc --> Bool) is export is test-assertion

其工作流程是:

  1. PRQL.parse($source)解析源文本;若返回Nil则直接flunk并输出did not parse: $source
  2. shape($match)Match渲染成规范的形状字符串;
  3. ok $got eq $expected对比;失败时通过diag输出got:expected:两份按深度缩进、每捕获一行的树形诊断。

形状是如何渲染的

shape与内部递归函数childrenMatch渲染成rule(child,child)的形式:只使用语法中命名捕获的名字;一个没有命名子捕获的叶子捕获,会把自身匹配的文本放进«…»中展示。语法中用于分组和量化的非命名括号组是"透明"的,其命名子捕获直接上浮到父级位置。

这样设计带来一个精妙的测试特性:叶子文本参与形状断言。例如五个比较运算符、四种字符串前缀(f/r/s/无前缀)在形状中各自不同,测试不会因为共享同一个字符串而误通过。

indented子程序则负责把单行形状拆成每捕获一行的缩进树,用于失败诊断——README 提到整条查询的断言形状超过 2000 字符,单行对比几乎不可读,而«…»内的文本会被原样保留、不参与缩进计算,避免匹配文本中的(,),,干扰树形排版。

写断言前先预览形状

在编写新测试之前,可以用 README 提供的命令查看某条查询实际生成的形状:

raku -I lib -I t/lib -e 'use PRQLTest; use prql; say shape(PRQL.parse("filter 1 + 1"))'

-I lib -I t/lib把解析器和测试辅助模块都加入搜索路径,输出类似:

statement(pipeline-statement(pipeline(call-expression(identifier«filter»,test(test-inner(binary-test(expression(number(integer«1»)),arith-op«+»,expression(number(integer«1»)))))))))

拿到这个输出即可直接作为parses-to的期望值,形成"先观察、后固化"的 TDD 式工作流。

断言细节:is test-assertion的意义

parses-to被标记为is test-assertion,这会让Test框架在断言失败时把定位信息回溯到.rakutest测试文件的调用处,而不是模块内部的那行ok。源码注释说明:若没有这个标记,全部 81 个测试的失败都会指向PRQLTest.rakumod内部,无法定位真正失败的测试。

解析器源码剖析:grammar PRQL的规则体系

理解了测试机制后,再来看 lib/prql.rakumod 中解析器本体的结构。整个语法是一个标准的 Rakugrammar,用token/rule组合出 PRQL 的完整文法。

顶层结构:语句与管线

TOP规则是零个或多个statement

token TOP { <statement>* }

statement覆盖 PRQL 的各类顶层构件:文档块、注释、prql版本声明(query-definition)、模块、注解、变量声明,以及(可带尾随注释的)管线语句:

rule statement { | <doc-block> | <comment> | <query-definition> | <module> | <annotation> | <variable-declaration> | <pipeline-statement> <comment>? }

管线(pipeline)是 PRQL 的语义核心,这里用 Raku 的%量词表达"以|分隔的一个或多个调用表达式",或"表达式|标识符"(即把函数当作管道目标):

rule pipeline { | <call-expression>+ % '|' | <expression> '|' <identifier> }

query-definition匹配prql关键字加若干命名参数(对应 PRQL 查询头部的版本/方言声明);modulemodule 名称 { ... }包裹一组语句;annotation对应@{...}形式的注解语法。

调用与绑定

call-expression是 PRQL 函数调用的核心,一个标识符后跟若干命名参数、绑定声明或测试表达式:

rule call-expression { <identifier> ( | <named-arg> | <declaration> | <test> )+ }

配套规则包括named-arg标识符: 表达式)、declarationdeclaration-tuple标识符 = 表达式)、case-branch表达式 => 表达式)与case-expressioncase加元组表达式)、nested-pipeline(括号包裹的管线)。

表达式体系

expression是一个token,通过选择分支覆盖 PRQL 的全部表达式种类,包括关键字this/that/null、二元/一元表达式、数组、元组、嵌套管线、case 表达式、日期时间、参数($1)、括号表达式、区间(10..20)、标识符、布尔值、时间单位、数字以及四类字符串(普通、frs):

token expression { | 'this' | 'that' | 'null' | <binary-expression> | <unary-expression> | <array-expression> | <tuple-expression> | <nested-pipeline> | <case-expression> | <date-time> | <parameter> | <parenthesized-expression> | <range-expression> | <identifier> | <boolean> | <time-unit> | <number> | <string> | <f-string> | <r-string> | <s-string> }

值得注意的细节:

  • identifier[. [ident | '*']]*支持点分名称与通配,如c.customer_idtable.*
  • unary-expression支持+/-前缀(用于sort {-sum_income})以及==前缀(用于 join 的(==customer_id)写法);
  • binary-expression左操作数只接受标识符(见下文边界讨论)。

数字与进制

number分浮点与整数。integer支持十进制(可带_分隔符与e科学计数)、0x十六进制、0b二进制、0o八进制:

token integer { | <.digit> [<.digit> | '_']* ['e' ['+' | '-']? <integer>]? | '0x' [<.xdigit> | '_']+ | '0b' <[01_]>+ | '0o' <[0..7_]>+ }

float要求整数部分、小数点与小数部分齐备,同样支持_与可选的e指数。

字符串、转义与注释

字符串规则覆盖单/双引号、三引号、以及f/r/s三种前缀。三引号分支用<!before '"""'>否定前瞻让正文一直运行到第一个闭合三引号——因此"""I said "hello world"!"""这种内含引号的写法能被正确吸收(源码注释明确这是文档化的形式)。

转义规则escape接受\xNN\u{...},以及任意单个字符作为兜底。源码注释解释了这一设计:PRQL 的词法器对无法识别的转义会保留反斜杠后的字符而不是拒绝字符串,所以语法也放宽到任意字符,从而自然覆盖\\\'\"等情形。

注释与文档块使用##!前缀,正文.+? $$到行尾(Raku 中$$匹配行尾)。

日期时间与时间单位

date-time统一以@开头,支持三种形态:日期T时间加可选时区(Z±HH:MM)、纯日期(@1970-01-01)、纯时间(@08:30@12:00:00.500)。time-unit由数字加维度组成,dimension枚举了microsecondsyears九种时间单位,并以<!ww>阻止词边界之后的继续匹配(避免把5yearsfoo误收)。

变量、lambda 与类型注解

variable-declaration对应 PRQL 的let绑定,右侧可以是嵌套管线或 lambda;lambda是零个或多个参数后跟->与表达式;lambda-paramtype-name支持<int32>形式的类型注解以及(|)联合类型语法,例如测试中的arg1<int32> -> arg1

运算符全集

三个token汇总了全部运算符:

  • arith-op+-*/%//**
  • compare-op==!=~=>=<=><in
  • logic-op&&||??

测试语料:十个主题文件与典型断言

t/目录按语法主题拆分测试,每个文件用plan N声明用例数。综合来看覆盖范围如下:

测试文件用例数覆盖内容
arithmetics.rakutest6加减乘除、幂运算、多运算符组合
arrays.rakutest数组表达式
datetime.rakutest7日期、时间、带小数秒、日期时间、时区后缀
full_queries.rakutest1官网示例级完整查询的整树形状
identifiers.rakutest3基础标识符、下划线数字、Unicode 标识符
misc.rakutest23布尔、null、this/that、注解、区间、注释/文档块、let 与 lambda、类型注解、参数、括号、管线、derive、嵌套管线、制表符、模块、元组内管线
numbers.rakutest9整数、下划线分隔、小数、科学计数、时间单位、二/八/十六进制
operators.rakutest10比较、逻辑、??合并、一元-==
strings.rakutest22四类字符串、三引号、各类转义、未识别转义
tuples.rakutest元组表达式

从测试中看到的语法行为与设计边界

测试注释里保留了若干对当前语法行为的如实记录,是理解实现取舍的第一手材料:

  • 二元表达式不嵌套binary-expression左操作数只接受标识符,因此filter 10 + 10 + 10不会嵌套成一颗树,而是被记成filter的两个参数——10 + 10和一个一元+ 10。测试注释明确写道:形状记录的是语法当前构建出的结果。
  • 换行不构成管线分隔pipeline只在|上分隔步骤,而rule会把换行当普通空白跳过,所以多行查询中换行分隔的步骤会全部收进第一个call-expression的参数列表,而不是各自成为管线步骤。full_queries.rakutestmisc.rakutest中"注释位于两条语句之间"的用例都体现了这一点,注释还对比了 lezer 语料期望的单PipelineCallExpression结构。
  • 形状断言对"未命名组"的内容不敏感,但解析仍锚定全串:时区后缀(+01:00/Z)位于date-time内部一个未命名分组中,一元运算符位于unary-expression的未命名分组中,所以它们的形状与无后缀/无运算符版本相同。但测试依然对这些内容敏感——因为.parse锚定整个字符串,一旦语法停止接受+01:00-name,解析会整体失败,测试照样能发现回归。
  • 形状断言能抓住"成功但错误"的解析strings.rakutest中三引号内含引号的用例说明,旧语法会把"""I said "hello world"!"""从内部引号处截断、让"分支匹配空串、filter再把剩余部分收成五个参数——这仍然是一次成功的解析,只有断言形状才能发现结构错了。

完整查询用例:一窥真实形状

full_queries.rakutest 用一个接近官网示例的查询(from invoicesfilterderivefiltergroup/aggregatesorttakejoinderive f"..."selectderive s"version()")断言了整棵树的形状,其中可以看到:

  • f"{c.last_name}, {c.first_name}"被捕获为f-string叶子;
  • s"version()"被捕获为s-string
  • join c=customers (==customer_id)c=customers成为declaration捕获,(==customer_id)成为带unary-expression的括号表达式;
  • group customer_id ( aggregate {...} )中的括号部分成为nested-pipeline

模块文档与进一步学习路径

lib/prql.rakumod 自带 Raku Pod 文档(=head1 NAMESYNOPSISDESCRIPTION),其中把 PRQL 描述为"a modern language for transforming data — a simple, powerful, pipelined SQL replacement",与项目整体定位一致。README 的 Documentation 一节指向 Raku 官方 Grammars 教程与语言参考(docs.raku.org 的language/grammar_tutoriallanguage/grammars),对不熟悉 Rakugrammar/token/rule/捕获机制、想深入理解本实现底层的读者是最佳入口。本仓库中还有 grammars/ 索引页可横向对比其他生态(Lezer、Tree-Sitter、Monarch 等)对同一 PRQL 文法的不同表达方式。

结语

grammars/raku/是一份小而完整的"PRQL 文法 + 测试基建"参考实现:grammar PRQL用 Raku 原生机制覆盖了 PRQL 的管线、函数调用、let/lambda、四类字符串、多种进制数字、日期时间、注解与模块等全部核心语法;PRQLTest的"形状断言"测试法把断言对象从"能否解析"提升到"解析成什么结构",既能锁定预期结构、又能捕获那些解析成功但结构错误的回归;十个主题测试文件则为每一种语法特性留下了可复现的行为快照。无论是想在 Raku 中集成 PRQL,还是想为其他语言移植这套测试思路,这里都是可以直接对照源码研读的起点。

  • 后端

【免费下载链接】prql

PRQL is a modern language for transforming data — a simple, powerful, pipelined SQL replacement

项目地址:https://gitcode.com/gh_mirrors/pr/prql
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询