几个月前,我在重构一个数据分析服务的时候,被一堆手写 SQL 折磨得够呛。二三十个筛选条件、七八个 JOIN、还有各种嵌套子查询,代码里散落着字符串拼接,团队里每个人的写法风格都不统一,review 起来非常痛苦。那时我就在想一个问题:能不能用更高级、更抽象的方式来描述查询意图,然后让工具自动把它编译成可执行的 SQL?
这个想法最终变成了一个实验项目——一门编译为 SQL 查询的范畴论编程语言。简单说,就是用范畴论(Category Theory)里的抽象概念去建模数据库查询过程,用户写的是纯函数式的表达式,编译器负责把表达式转换成干净的 SQL。这篇文章我想完整复盘一下这个项目的设计思路、编译链路、实现细节,以及我在实操中踩过的坑,希望能给对函数式编程、DSL 设计和 SQL 优化感兴趣的朋友一些参考。
1. 项目概述:从"写 SQL"到"描述查询意图"
1.1 为什么会想到用范畴论来建模 SQL
先说说最直接的问题:SQL 本身已经很成熟了,为什么还要绕一大圈用范畴论来搞一套新东西?
我的答案是:SQL 缺少可组合性。你写一个查询,经常会遇到"这段逻辑我要在另一个查询里复用它"的需求,但 SQL 并不能优美地做到这一点——你只能复制粘贴,或者借助视图、CTE、临时表来变通。而且手写 SQL 非常容易出现类型错误:把字符串列当成数字去聚合、在 WHERE 里对 NULL 做判断、JOIN 条件写错造成笛卡尔积爆炸……这些问题在编译期是无法被发现的,只能在运行时让你看到一堆莫名其妙的结果。
范畴论恰好提供了一个优雅的组合模型。它研究的是"对象"和"对象之间的箭头"(态射),而数据库查询本质上就是"关系"到"关系"的变换。如果把表看作对象、查询看作态射,那么查询的组装就是态射的复合,天然支持模块化和复用。更进一步,范畴论中的函子(Functor)、自然变换(Natural Transformation)、单子(Monad)这些概念,恰好可以一一对应到 SQL 中的投影、连接、子查询等核心操作。
本质上,这个项目的目标不是取代 SQL,而是把 SQL 隐藏起来,让开发者用一套更安全、更可组合的抽象来表达查询意图,最终交给编译器去生成"人写得出来的那种好 SQL"。
1.2 这个项目能解决什么实际场景
从实际应用角度,这个编译器的使用场景非常明确:
场景一:复杂报表查询。 一张报表往往要聚合多个维度的数据,还要做各种筛选、排位、同比环比。手写这种 SQL 很痛苦,而且后续改动需求一多就熵增爆炸。用范畴论语言写,逻辑被拆成一个个小的态射组合,每个组合单元可以单独测试。
场景二:数据权限控制。 系统里不同角色能看的数据范围不一样,最常见的手段就是在查询末尾拼接 WHERE org_id IN (...)。但这种拼接特别容易出错,经常拼出语法错误。在这门语言里,权限过滤可以被建模为一个固定的"过滤器函子",编译器在生成 SQL 时自动把过滤条件追加到最终语句上,杜绝了注入和拼接错误。
场景三:多数据库方言适配。 靠字符串拼 SQL 的应用,换数据库等于重写一遍。而编译器的中间表示(IR)是关系代数级别的,和具体数据库无关,需要支持 PostgreSQL、MySQL、SQLite 时,只需要改后端翻译模块。
我在项目早期定了一个目标:任何"范畴论程序"最终必须编译成一条(或一组)SQL 语句,并且生成的 SQL 要保证语义等价、性能不差于手写。这个目标听起来简单,实际上非常难,后面会详细展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心原理:范畴论概念如何映射到查询
2.1 把数据库表看作一个范畴
在展开设计前,先花点篇幅讲清楚范畴论在这里到底怎么用。别被"范畴论"这三个字吓到,它的核心思想其实就一句话:研究事物以及事物之间的联系。
一个范畴由三部分组成:对象(Objects)、态射(Morphisms)、态射复合(Composition)。通俗地理解,对象是"东西",态射是"东西之间的箭头",复合表示"两个箭头首尾相连变成一个新箭头"。
对应到数据库领域:
- 表(Table) 是一个对象。更严谨地说,表的结构(Schema)才是对象,表里的数据行是这个对象的实例。
- 查询(Query) 是从一个表到另一个表(或者多个表到一张结果表)的态射。
- 查询的组合 就是先把 A 表查出一个结果,再把这个结果作为输入去查 B 表——这就是态射的复合。
举个例子,你有两张表:customers 和 orders。从 customers 出发,通过外键 customer_id 去关联订单,这个关联动作就是一个态射。多个这样的态射串联起来,就能实现"查客户->查客户的所有订单->查每笔订单的商品明细"。这就是一个典型的查询管道。
为什么这个视角有用?因为在范畴论中,态射可以自由地复合,并且复合满足结合律。这意味着我们可以把一个大查询拆分成无数个小函数,然后像搭积木一样组合起来,而组合得到的语义是确定的、唯一的。这是"可组合性"的数学保证。
2.2 函子、自然变换与 JOIN 的关系
有了对象和态射,下一步是引入"函子"。函子是范畴之间的映射,它把对象映射到对象、态射映射到态射,并保持复合关系。这个概念听起来抽象,但在 SQL 场景里非常具体。
函子对应 SQL 的投影(Projection)。假设你有一个查询 SELECT name, email FROM customers,这个查询把"客户表"映射到"客户姓名和邮箱的结果集",同时保留了表的结构特性。这就是一个典型的函子映射:它把每一行转换成新的形状,这个过程是"受结构约束的"。
自然变换对应 SQL 的 JOIN。自然变换是两个函子之间的函数,它在不改变已有结构的情况下,把一种"结构容器"转换成另一种。在关系型数据库里,JOIN 本质上是把两张表"合并"成一个更宽的关系,而且它不破坏每张表原有的列结构。这个性质非常像自然变换的"自然性条件"。
实际编码中,我定义一个 Query 类型时,对用户暴露的接口就刻意模仿了函子的 map 操作:
typescript复制query<Customer>(customers)
.map(c => ({ name: c.name, email: c.email }))
// 这里 map 就是函子的 fmap
接着是 JOIN 的抽象。在实现中,我把 JOIN 定义为一个函数,它接收两张表的查询对象,以及一个连接条件表达式,返回一个新的查询对象:
typescript复制query<Order>(orders)
.join(query<Customer>(customers), (o, c) => o.customerId === c.id)
// 返回的查询对象包含两张表的字段
从用户视角看,join 就像一条通道,把两个独立的查询管线接通了。正是这种"通道感"让我意识到,JOIN 的抽象应该向自然变换靠拢,而不是像 SQL 那样线性地把表拼在一起。
2.3 单子(Monad)与嵌套子查询的表达
前面这些抽象在关系型数据库里都不难找到对应物,真正难的是嵌套子查询。
SQL 中的 WHERE EXISTS (SELECT 1 FROM ...)、NOT IN (SELECT ...)、FROM (SELECT ...) AS t 都涉及嵌套查询。嵌套查询的核心特征是:内层查询依赖外层查询的值。这个特征在函数式编程里有一个非常成熟的概念——Monad。
Monad 的核心操作是 bind(也叫 flatMap),它的作用是:把一个返回"容器"的函数应用到容器上,并把结果压平。放到查询场景里:
typescript复制query<Customer>(customers)
.flatMap(c =>
query<Order>(orders)
.filter(o => o.customerId === c.id)
// 内
