传统 ORM 框架在便利性与安全性之间始终存在张力。开发者通过链式 API 或字符串模板构建 SQL 查询时,往往在运行时才能发现字段名拼写错误或类型不匹配,而字符串拼接更是 SQL 注入的温床。Prela 采用了一条截然不同的路径:基于 Tarski 关系代数的纯代数组合子,将查询表达为宿主语言中的普通函数组合,从而在编译期完成类型检查与语义验证。
关系代数作为类型安全的基石
Prela 的核心设计哲学源于 Tarski 的关系代数:一切皆为二元关系。与传统关系模型中宽表存储不同,Prela 将每个 k 列表 "切碎" 为 k 个二元表,每表映射主键到单一属性值。这种极端范式使查询操作统一为关系间的代数运算,而非针对特定表的 CRUD 操作。
浅层嵌入(shallow embedding)是实现类型安全的关键技术选择。Prela 的操作符并非外部 DSL 的语法构造,而是宿主语言(如 Julia)中的普通函数。这意味着:
- 类型推导复用:宿主语言的类型系统直接作用于关系组合,类型错误在编译期暴露
- 函数即查询:用户定义的过滤函数、聚合逻辑与内置组合子享有同等类型保障
- 无字符串生成:查询全程不涉及 SQL 字符串拼接,从根本上消除注入攻击面
核心组合子的类型语义
Prela 定义了一套精简而表达力完备的组合子集合,每个操作符对应明确的关系类型转换:
限制组合子 :(restriction)实现左半连接语义。表达式 movie : (production_year > 2008) 中,: 将左侧关系的末列与右侧关系的初列进行匹配过滤,返回类型保持为 Movie → Movie。由于>被重载为接受关系并返回关系的函数,整个谓词链的类型在编译期即可验证。
关系组合 →(composition)是查询构建的主力。它将两个二元关系 X → Y 与 Y → Z 组合为 X → Z,数学上对应存在量词约束的连接操作。在 Prela 中,这被实现为函数组合的自然推广:先应用第一个关系获取中间值集合,再对每个中间值应用第二个关系。
积组合子 × 将同域的两个关系 X → Y 与 X → Z 合并为 X → (Y, Z),对应 SQL 的 SELECT 多列输出。值得注意的是,×并非 Tarski 原始代数的组成部分,而是为实用性引入的受限扩展 —— 元组作为不透明值存储,不破坏 "一切皆关系" 的代数结构。
聚合组合子 ▷ 提供分组聚合能力。表达式 (order ← Li.supplier) ▷ n_distinct > 1 先通过左组合 ←(即逆关系组合)建立 Order 到 Supplier 的映射,再按 Order 分组计算 Supplier 的不重复计数,最后过滤保留多供应商的订单。整个管道中的类型流转清晰可追踪。
CPS 转换与执行效率
类型安全往往以运行时开销为代价,但 Prela 通过 Continuation-Passing Style(CPS)实现了零开销抽象。在 CPS 解释器中,每个组合子不直接返回关系数据,而是接收并调用 continuation 函数。这种设计允许 Julia 的 JIT 编译器将查询管道内联为单一循环,消除中间结果物化。
更关键的是,CPS 转换自动将代数风格的查询计划转换为列式执行。当物理存储采用列式布局时,组合子的嵌套调用结构被展开为对连续内存块的向量化操作,无需显式的向量化引擎即可达到与专用列存数据库相当的性能。在 Join Order Benchmark 的 113 个查询上,Prela 以 15.9 秒完成,相比 DuckDB 单线程的 29.6 秒有 1.9 倍优势。
工程实践考量
Prela 的查询即计划(query-as-plan)特性意味着没有独立的查询优化器。开发者编写的组合子序列直接对应执行路径,这对熟悉关系代数的工程师是透明可控的,但对习惯声明式 SQL 的用户需要思维转换。
当前 Julia 实现已验证核心概念,Rust 与 Zig 的实验性移植正在进行。对于追求类型安全的数据密集型应用,Prela 展示了嵌入式 DSL 的潜力:不牺牲性能的前提下,将查询正确性验证前移至编译期。其设计提示我们,数据库接口的安全性可以从语言层面系统性地解决,而非依赖运行时的参数转义或存储过程。
参考来源
- Prela GitHub 仓库: https://github.com/remysucre/Prela
- Tarski, A. (1941). On the Calculus of Relations
内容声明:本文无广告投放、无付费植入。
如有事实性问题,欢迎发送勘误至 i@hotdrydog.com。