在11行代码中实现更好的SQL
摘要
本文介绍了Prela,一种基于二元关系的新查询语言,并提供了Python教程来构建一个玩具版本,展示了其相对于SQL的简洁性。
暂无内容
查看缓存全文
缓存时间: 2026/08/29 21:39
# 11行代码实现更优SQL
来源:https://prela-lang.org/tutorial/
Prela (https://prela-lang.org/) 是 UCLA REPL (https://repl.la/) 正在开发的一种新型查询语言。该语言与SQL差异显著,但其核心思想非常简单。本简短教程将指导您用Python构建一个Prela的玩具版本,以理解其核心原理。学完本教程后,您将明白以下查询的工作机制:
```
movie.where(company.s(country).eq("[us]") &
keyword.eq("character-name-in-title"))
.select(title & cast.s(person).s(alias).s(text))
```
您可能已经能猜到它的功能:该查询查找所有由美国公司制作且标题中包含角色名称的电影,并输出标题及每位演员的别名。请注意,等效的SQL查询 (https://github.com/gregrahn/join-order-benchmark/blob/master/16b.sql) 长度超过20行。
Prela的首个特殊之处在于它仅包含*二元关系*,即只有两列的表。初看这似乎非常受限,但实际上可以轻松地将多列宽表"二元化"。假设我们有一个电影表:
ID title year
646 The Godfather 1972
478 Seven Samurai 1954
583 Casablanca 1942
我们可以将这个3列的表分解为3个二元关系 1 (https://prela-lang.org/tutorial/#fn1),每个关系将行号映射到列值:
```
movie = Rel([(646, 0),
(478, 1),
(583, 2)])
title = Rel([(0, "The Godfather"),
(1, "Seven Samurai"),
(2, "Casablanca")])
year = Rel([(0, 1972),
(1, 1954),
(2, 1942)])
```
**提示**
本教程使用 snip (https://remy.wang/snip/) 将代码单元格连接成类似笔记本的环境 2 (https://prela-lang.org/tutorial/#fn2),在一个单元格中的修改会反映在后续单元格中。
上面的 `movie`、`title` 和 `year` 关系分别表示原始表的 `ID`、`title` 和 `year` 列。请注意行号在 `title` 和 `year` 中排在第一位,但在 `movie` 中排在第二位(`movie` 也没有命名为 `ID`)。原因稍后会清楚。
专注于二元关系的动机在于它们可以泛化函数。函数之所以强大,是因为它们能够*组合*,从而成为程序的构建模块。函数将每个输入映射到唯一的输出,而二元关系可以将一个输入映射到多个不同的输出。从某种意义上说,二元关系可以被视为*非确定性*函数,并且我们可以像组合函数一样组合它们。
以上内容非常抽象,让我们回到示例。为简化起见,我们将重点关注那些将每个输入恰好映射到一个输出的关系,即它们恰好都是函数。"调用"一个关系则简化为将该关系转换为字典并查找值:
```
print(dict(movie)[646], dict(title)[0], dict(year)[0])
```
现在我们准备介绍Prela中第一个也是最重要的操作符:关系组合。函数组合的工作原理是先应用一个函数,然后将另一个函数应用于输出。两个关系 `r` 和 `s` 的组合本身是一个关系,首先通过 `r` 将 `x` 映射为某个 `y`,然后通过 `s` 将 `y` 映射为最终的"输出"。这可以通过将 `s` 转换为字典 `d`,遍历 `r` 中的 `(x, y)` 对,最后如果 `y` 在 `d` 中则输出 `(x, d[y])` 来实现:
```
def select(r, s):
d = dict(s)
return [ (x, d[y]) for x, y in r if y in d ]
```
使用我们的示例,下面的查询将 `movie` 与 `title` 组合,得到一个将每个电影ID映射到其标题的关系:3 (https://prela-lang.org/tutorial/#fn3)
```
print(movie.select(title))
```
尝试将 `title` 改为 `year`,看看结果如何。组合的威力在我们链式调用多个 `.select` 时真正显现出来。假设我们添加一个外键列,将每部电影映射到其制作公司,以及另一个电影公司的表:
ID title year company ... ... ... ... 0 ... ... ... ... 1 ... ... ... ... 2
ID name country
0 Paramount [us]
1 Toho [jp]
2 Warner Bros. [us]
以同样方式分解会给我们带来另外四个关系:
```
company = Rel([(0, 0),
(1, 1),
(2, 2)])
id2row = Rel([(0, 0),
(1, 1),
(2, 2)])
name = Rel([(0, "Paramount"),
(1, "Toho"),
(2, "Warner Bros.")])
country = Rel([(0, "[us]"),
(1, "[jp]"),
(2, "[us]")])
```
然后,我们可以通过链式 `.select` 调用(此处用 `.s` 缩写)来找到电影制作公司的国家:
```
print(movie.s(company).s(id2row).s(country))
```
因为通过外键连接几乎总是需要"解析"一个ID到一行,Prela会自动插入该步骤,因此可以这样写 4 (https://prela-lang.org/tutorial/#fn4),读起来就像"一部电影的公司的国家"!
```
print(movie.s(company).s(country))
```
这也是教程开头代码片段最后一行 `cast.s(person).s(alias).s(text)` 发生的情况。
到目前为止,每个查询都返回了单列值。为了选择*多个*属性,我们引入了 `&` 操作符。
其中 `.select` 将 `r` 的第二列与 `s` 的第一列进行匹配,`&` 则在*两者*的第一列上连接 `r` 和 `s`,然后将它们的第二列配对:
```
def and_(r, s):
d = dict(s)
return [ (x, (y, d[x])) for x, y in r if x in d ]
```
因此 `title & year` 将每个电影行同时映射到它的两个属性:
请注意结果仍然是一个二元关系,`&` 只是将值嵌套成一个元组。这意味着我们可以像处理任何其他关系一样继续组合它,这就是一个查询返回多列的方式:
```
print(movie.select(title & year))
```
接下来,我们需要一种方法来指定想要*哪些*行。谓词 `.eq(v)` 过滤一个关系,只保留第二列等于 `v` 的对:
```
def eq(r, v):
return [ (x, y) for x, y in r if y == v ]
```
单独使用时,`.eq` 仅缩小其应用的关系范围。下面的查询仍然将电影行映射到国家,只是不再是所有行:
```
print(company.s(country).eq("[us]"))
```
最后,*限制*操作符 `.where` 接受类似上面的谓词并用它来过滤另一个关系。
```
def where(r, s):
d = dict(s)
return [ (x, y) for x, y in r if y in d ]
```
将我们的谓词传递给 `.where` 将其转换为对电影的过滤:
```
print(movie.where(company.s(country).eq("[us]")))
```
这直接从代码中读出来:"公司国家为 [us] 的电影"。
查询变长了,让我们重构一下:
```
american = company.s(country).eq("[us]")
print(movie.where(american))
```
等等,我们刚刚用普通的Python变量创建了一个CTE (https://www.postgresql.org/docs/current/queries-with.html) 吗?是的!这是可能的,因为Prela查询由操作符组成,并且每个子表达式都是一个有效的查询。
我们如何有多个条件?一个幸运的巧合是,因为 `&` 连接其参数,一旦嵌套在 `.where` 内部,它就兼作逻辑与:
```
print(movie.where(american & year.eq(1942)))
```
只有《卡萨布兰卡》既是美国的*又*是1942年的。将所有部分组合起来,`.select` 然后为通过过滤器的电影获取我们想要查看的任何列:
```
print(movie.where(american & year.eq(1942)).select(title & year))
```
我们甚至可以将谓词推入 `select` 子句以获得更清晰的查询:
```
print(movie.where(american).select(title & year.eq(1942)))
```
这就是这门语言的全部内容!Prela还支持分组和聚合,以及其他常见操作符。我们正在编写该语言的完整文档,因此目前您可以参考我们的论文 (https://arxiv.org/abs/2607.26356) 以获取更多细节。作为练习 5 (https://prela-lang.org/tutorial/#fn5),您可以尝试定义必要的关系,使开头的代码片段能够运行。
```
# keyword = ...
# ...
print(movie.where(company.s(country).eq("[us]") &
keyword.eq("character-name-in-title"))
.select(title & cast.s(person).s(alias).s(text)))
```
我们玩具版Prela的独立Python程序可以在这里 (https://github.com/remysucre/prela/blob/main/tutorial/prela.py) 找到。
---
1. 这也被称为6NF (https://en.wikipedia.org/wiki/Sixth_normal_form) 分解。如果您担心这会引入开销,请查看这篇帖子 (https://remy.wang/blog/cps.html) 了解Prela如何使用CPS (continuation-passing style) 编译消除间接层。↩︎ (https://prela-lang.org/tutorial/#fnref1)
2. 与例如Jupyter不同,snip总是从头开始执行以避免状态损坏。↩︎ (https://prela-lang.org/tutorial/#fnref2)
3. `.select` 方法语法使用了将 `Rel.select` 转发给 `select()` 的相同技巧。↩︎ (https://prela-lang.org/tutorial/#fnref3)
4. 这里我们使用行号作为公司ID来作弊。↩︎ (https://prela-lang.org/tutorial/#fnref4)
5. 解决方案*隐藏*在此页面的某个地方 ;\)↩︎ (https://prela-lang.org/tutorial/#fnref5)
相似文章
在现代关系查询语言中我想要的特性
这篇文章针对现代关系查询语言,讨论了其期望的功能,批评了SQL的不足,并基于函数式编程、更好的语法和用户定义类型提出了改进建议。
使用 Egg 编写 SQL 优化器(2023)
本文介绍了如何使用 Rust 中的 Egg 框架构建 SQL 优化器,涵盖常量折叠、谓词下推、连接重排序等技术,并采用基于规则和基于成本的优化,代码量不到一千行。
@thomasp85:我激动万分,终于可以揭开我们2026年所做工作的神秘面纱:请认识 ggsql!一个全新的扩展……
Posit 宣布推出 ggsql 的 alpha 版本。ggsql 是一种新的 SQL 语言扩展,将图形语法风格的数据可视化引入 SQL,兼容 Quarto、Jupyter notebooks、Positron 和 VS Code。用户可以使用受 ggplot2 启发的熟悉 SQL 语法,创建分层、结构化的可视化图表。
一种受SQL启发、专为事件溯源设计的查询语言(2025年)
EventQL是一种受SQL启发、专为事件溯源设计的查询语言,它提供对事件属性、主题层级结构的一流支持,并具备类似SQL的表达能力,以便高效查询事件流。
关系建模与 APL
作者探讨了利用约束逻辑和等式重写规则,将关系建模与 APL 风格的数组语言相结合,并讨论了如何将属性定义为双向推导,而非简单的赋值。