作为长期在数据工程和编程语言理论之间来回折腾的人,我一直在纠结一个问题:为什么写 SQL 这么容易出错,而写普通程序却可以借助类型系统规避大量低级失误?三年前我偶然接触到范畴论,发现它和关系型数据库的查询模型之间存在一种非常自然的对应关系,于是动手设计了一门内部称为 CataQuery 的实验语言——它是一门编译为 SQL 查询的范畴论编程语言。这篇文章把完整的设计思路、核心机制、实操步骤和踩过的坑都整理出来,希望能给同样对“用更抽象的模型驾驭复杂查询”感兴趣的朋友一些参考。
这个语言解决的核心问题很明确:传统 ORM(对象关系映射)把对象模型强加在关系模型之上,导致查询组合性差、类型不安全和隐式 N+1 问题频发;而手写 SQL 虽然灵活,但字符串拼接的写法在复杂查询中几乎无法维护。 我的想法是,用范畴论中的函子(Functor)、自然变换(Natural Transformation)和单子(Monad)等概念作为中间层,让开发者用函数式风格描述查询意图,再由编译器负责把范畴结构忠实地翻译成 SQL。适合参考这篇文章的读者包括:被 ORM 和 SQL 折腾过的后端工程师、对编程语言理论感兴趣但不想啃大部头的人、以及所有在“类型安全”和“查询灵活”之间寻找平衡点的数据从业者。
1. 项目概述:为什么我会想用范畴论写 SQL
1.1 这个语言要解决什么问题
先交代一下背景。我所在的项目组维护着一个中等规模的数据分析平台,后端是标准的 PostgreSQL 集群,前端业务代码经过了三代技术栈变迁:最早直接拼 SQL 字符串,后来引入了一个轻量级 ORM,再后来换成了带 QueryBuilder 的框架。每一代方案都有自己的问题。
拼 SQL 字符串的方式在查询少于五条的时候挺爽,但随着业务逻辑复杂化,一旦某个查询需要根据用户角色、时间范围、数据权限动态拼装,代码就开始失控。最典型的一个例子:统计报表模块里有一条“按月统计各渠道收入”的查询,为了兼容三种不同粒度的权限过滤逻辑,最终拼出来的 SQL 超过两百行,里面塞满了 CASE WHEN 和动态拼接的 AND 条件,每次改需求都像在拆炸弹。
ORM 解决了字符串拼接的部分问题,但引入了更深的麻烦。对象模型和关系模型之间根本不是一一对应的,一个 User 对象可能对应三张表,一个聚合查询可能要跨越六张表,这些映射关系写起来繁琐不说,生成的 SQL 还经常不是最优的。最恶心的是,ORM 的懒加载机制会隐蔽地产生 N+1 查询——表面上你写了一行“获取所有用户及其订单”,实际执行时先查了用户表,然后对每个用户又发一条订单查询,总共 101 条 SQL 来回跑,数据库连接池直接被打爆。
我想要的是一种更底层的抽象:它既能保留 SQL 的声明式表达能力,又能像强类型编程语言那样在编译期捕捉错误,还能提供一种简便的机制来做查询组合。找了一圈现成方案,没发现完全满意的,于是决定自己动手做一个 DSL(领域特定语言)。当时我正在读 Mac Lane 的《Categories for the Working Mathematician》,读着读着突然意识到,数据库查询的很多操作其实就是范畴论里的标准构造。
1.2 范畴论与数据库的对应关系
很多人一听到范畴论就头疼,觉得这是纯数学里最抽象的分支之一。实际上,范畴论的基础概念并不复杂。一个范畴(Category)由三样东西组成:一组对象(Objects)、对象之间的态射(Morphisms,也叫箭头)、以及态射的复合运算。打个比方,把城市当作对象,把航班当作城市之间的箭头,从 A 城市飞 B 城市、再从 B 城市飞 C 城市,可以合成一张 A 到 C 的联程机票——这就是复合。
当我把这张“城市和航班”的地图往关系型数据库上套的时候,发现对应关系惊人地自然:
- 数据库表可以看作是范畴中的对象,每张表代表一种实体类型。
- 表之间的关系(外键)是态射,它把一个表中的行映射到另一个表中的行。
- 查询本身也是态射,一条
SELECT就是一个从输入表到输出表的变换。 - 表连接(JOIN)对应范畴论中的极限构造,它把多张表的信息汇聚成一个统一的结果集。
这些对应关系意味着:我们在编程语言里熟悉的那套关于“复合”和“抽象”的数学规律,可以直接套用在查询构建上。最核心的好处是复合性——如果一个查询是一个态射 A -> B,另一个查询是态射 B -> C,那么我天然可以把它们合成为 A -> C,而且编译器能保证这个合成过程是类型安全的。这在传统的字符串拼接方案里是想都不敢想的。
1.3 适合谁来参考
如果你属于下面这几类人,这篇文章值得你花十分钟读完:
- 后端工程师:天天写数据访问层,被 ORM 的性能问题和 SQL 拼装的维护成本折磨得够呛,想看看有没有新思路。
- 编程语言爱好者:对类型系统、编译原理、DSL 设计感兴趣,想看到一个相对完整的小型编译器是怎么从零搭建的。
- 数据分析师/数据工程师:工作中经常要把业务语言翻译成 SQL,希望找到一个更好地表达“查询意图”的中间描述层。
理论上,读者不需要提前掌握范畴论的完整体系,我会在文中把每个用到的概念都用生活化的类比解释清楚。但如果你对 Functor、Monad 这些词完全没概念,建议先花半小时看一下 Haskell 的入门教程里关于 fmap 和 bind 的部分,再看这篇文章会顺畅得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心设计思路:从范畴到 SQL 的映射
2.1 为什么选择范畴论而不是其他抽象
在决定采用范畴论之前,我认真评估过几类备选方案:流式 API(像 Java 的 Stream)、LINQ 风格的查询表达式、以及 XML/JSON 中间描述语言。它们各有千秋,但都有一个共同的问题——组合性不够强。
以流式 API 为例,stream.filter(...).map(...) 的链式调用确实简洁,但当你需要把两段独立的查询条件组合成一个整体时,往往需要引入额外的 Predicate 对象或者 Specification 模式,最后还是回到拼字符串的老路。LINQ 做得好一些,但它和 .NET 的类型系统深度绑定,很难平移到其他语言环境。
范畴论的优势在于它提供了一个通用且可组合的抽象框架。范畴中的复合运算是满足结合律的:(f ∘ g) ∘ h = f ∘ (g ∘ h)。这看起来像一句废话,但在查询场景下意味着:不管你是先把三个查询条件合在一起再执行,还是先执行两个再嵌套执行第三个,最终结果应该完全一致。有了这个数学保证,编译器就可以自由地进行查询重写和优化,而不必担心改变语义。
我当时还做了一个关键决策:不把范畴论当作纯理论框架,而是把它映射到具体的编程语言类型系统上。也就是说,范畴里的每个对象都对应一个具体的类型(User 类型、Order 类型),范畴里的每个态射都对应一个带类型的函数(例如 getOrdersByUserId :: UserId -> Query Order)。这样一来,所有范畴论的抽象结构都可以在编译期进行类型检查,一旦发现连接不匹配或类型不兼容,直接报编译错误,而不是等运行时才发现 SQL 报错。
2.2 核心概念映射表
为了让后续的实操演示更容易理解,我先把 CataQuery 中最重要的几个范畴论概念和 SQL 元素之间的映射关系列出来。
| 范畴论概念 | 通俗解释 | SQL 对应物 | CataQuery 中的表示 |
|---|---|---|---|
| 对象(Object) | 某种实体的集合 | 表(Table)或子查询的结果集 | 类型(Type) |
| 态射(Morphism) | 从一个集合到另一个集合的映射 | SELECT 的投影、WHERE 的过滤 | 类型化的函数 |
| 复合(Composition) | 先做第一步再做第二步 | 子查询嵌套、CTE 链式扩展 | .then() 或 >> 操作符 |
| 函子(Functor) | 保持结构的映射 | 对表进行统一变换(如给所有行加一个计算列) | fmap 操作 |
| 自然变换(Natural Transformation) | 两种映射方式之间的桥梁 | 表连接(JOIN)在不同条件下的转换 | join 操作 |
| 单子(Monad) | 支持按需序贯计算的容器 | 带参数的动态查询、依赖前一步结果的子查询 | bind / for 语法 |
这里最容易被忽视的是“函子”和“自然变换”的区别。粗略理解:函子把“对每个对象做的操作”提升到“对整个结构做的操作”,它保持结构不变;自然变换则是在两个不同的函子之间建立联系。在 SQL 里,你可以把 fmap 理解为给每一行都应用同一个表达式(例如 price * 1.1),而 join 则是把两张表按某种关联条件融合在一起。举个更直白的例子:fmap 类似给一列数字统一加 10,join 类似把两本通讯录按照姓名合并成一本。
2.3 编译器的整体架构
CataQuery 的编译器分成四个阶段:解析(Parsing)→ 类型检查与范畴验证(Type Checking & Categorical Verification)→ 查询优化(Optimization)→ SQL 代码生成(Code Generation)。
解析阶段负责把源代码文本转换成抽象语法树(AST),这部分和普通语言的 parser 没有本质区别,我采用的是手写递归下降解析器,因为文法相对简单,手写比引入 Parser Generator(如 ANTLR)更可控。
类型检查和范畴验证是这套系统最核心的环节。编译器不仅要检查类型是否匹配(比如不能把 User 表当作 Order 表来投影),还要检查范畴论中的结构是否合法:态射的源对象和目标对象是否一致、复合是否满足结合律、函子是否保持了对象的同一态射等等。这些检查很多都可以在类型推导的过程中顺带完成,不需要额外的独立验证阶段。
查询优化阶段主要是做等价变换。因为范畴论保证了复合是结合的,我可以自由地把一个嵌套很深的查询展开成扁平结构,或者把一个扁平的查询折叠成嵌套结构,根据实际情况选择代价更低的执行计划。目前实现了一批基于谓词下推(Predicate Pushdown)和投影剪枝(Projection Pruning)的重写规则。SQL 生成阶段则相对机械,它把经过优化后的范畴表达式转成 SQL 字符串,同时处理参数绑定和标识符转义。
3. 实操演示:从源代码到 SQL
3.1 环境准备与快速开始
CataQuery 目前以 Python 库的形式提供,方便和数据生态集成。需要 Python 3.10 以上版本,安装命令很简单:
bash复制pip install cataquery
安装完成后,你可以通过下面的方式来编译一个查询:
python复制from cataquery import compile_query, Table, Column, Filter, Join
users = Table("users")
orders = Table("orders")
query = (
users
.join(orders, on=users["id"] == orders["user_id"])
.filter(orders["amount"] > 100)
.select(users["name"], orders["amount"])
)
sql = compile_query(query)
print(sql)
这段代码的输出是:
sql复制SELECT users.name, orders.amount
FROM users
INNER JOIN orders ON users.id = orders.user_id
WHERE orders.amount > 100
如果你接触过 SQLAlchemy Core 或者其他 Query Builder,应该会觉得这个 API 很眼熟。不过 CataQuery 与它们的本质区别是:join、filter、select 这些操作不再只是链式方法,而是范畴论意义下真正的复合操作,编译器能够利用范畴定律对它们进行重写和优化。
3.2 第一个查询:基础筛选与投影
我们先从一个简单的场景开始:查询所有年龄大于 18 岁的用户,只需要返回姓名和邮箱。
在 CataQuery 里可以这样写:
python复制adult_users = (
users
.filter(users["age"] > 18)
.select(users["name"], users["email"])
)
编译得到的 SQL 是:
sql复制SELECT users.name, users.email
FROM users
WHERE users.age > 18
这里有一个值得注意的细节:在 CataQuery 中,filter 和 select 的顺序无论怎么写,编译器都会自动把它们重写到正确的位置。你甚至可以故意把 select 写在 filter 前面:
python复制users
.select(users["name"], users["email"])
.filter(users["age"] > 18)
编译器依然会生成同样的 SQL。因为在范畴论的语义下,select 和 filter 是两类不同的态射:select 是投影态射,filter 是子对象选择态射。它们的复合顺序虽然会影响中间结果,但在“信守同一组数据”的前提下,最终查询效果是等价的。这个特性其实是从“函子保持交换图”的性质推导出来的,但在使用者那里就体现为“查询的书写顺序可以自由调整,编译器帮你兜底”。
3.3 JOIN 与聚合的写法
连接是关系型数据库里最常用的操作,也是最容易写错的地方。CataQuery 中的 join 操作设计得尽可能靠近范畴论里的“纤维积”(Fiber Product)概念:把两个表沿着一个公共的“连接条件”融合起来。
来看一个多表连接的例子。假设我们有 users、orders、order_items 三张表,要查询每个用户购买的每个商品的名称和数量。
python复制items = Table("order_items")
query = (
users
.join(orders, on=users["id"] == orders["user_id"])
.join(items, on=orders["id"] == items["order_id"])
.select(users["name"], items["product_name"], items["quantity"])
)
生成的 SQL:
sql复制SELECT users.name, order_items.product_name, order_items.quantity
FROM users
INNER JOIN orders ON users.id = orders.user_id
INNER JOIN order_items ON orders.id = order_items.order_id
聚合查询则需要引入一个 group_by 构造。CataQuery 的设计思路是:先做连接和过滤,再对结果集做分组聚合。这里的哲学是“先缩小数据范围再聚合”,因为这通常比“先聚合再缩小范围”效率高得多。
python复制query = (
orders
.filter(orders["status"] == "paid")
.group_by(orders["user_id"])
.aggregate(total_amount=Sum(orders["amount"]))
)
生成的 SQL 是:
sql复制SELECT orders.user_id, SUM(orders.amount) AS total_amount
FROM orders
WHERE orders.status = 'paid'
GROUP BY orders.user_id
3.4 子查询与 CTE:把查询当作可复合的模块
CataQuery 最强大的地方在于,任何查询都可以作为一个“虚拟表”继续参与后续的复合。这个特性直接来自范畴论中的“对象可以是任何东西,包括态的复合结果”。
举例来说,我们要查询每个用户的订单总金额,只保留总金额超过 1000 的用户,并关联他们的用户信息:
python复制# 第一步:计算每个用户的订单总金额
order_totals = (
orders
.group_by(orders["user_id"])
.aggregate(total=Sum(orders["amount"]))
)
# 第二步:把这个结果当作虚拟表,继续做连接
query = (
users
.join(order_totals, on=users["id"] == order_totals["user_id"])
.filter(order_totals["total"] > 1000)
.select(users["name"], order_totals["total"])
)
编译之后生成的 SQL 有两种可能:如果优化器决定把子查询内联,会生成:
sql复制SELECT users.name, totals.total
FROM users
INNER JOIN (
SELECT orders.user_id, SUM(orders.amount) AS total
FROM orders
GROUP BY orders.user_id
) AS totals ON users.id = totals.user_id
WHERE totals.total > 1000
如果优化器认为使用公共表表达式(CTE,即 Common Table Expression,用 WITH 子句命名的临时结果集)更清晰,会生成:
sql复制WITH totals AS (
SELECT orders.user_id, SUM(orders.amount) AS total
FROM orders
GROUP BY orders.user_id
)
SELECT users.name, totals.total
FROM users
INNER JOIN totals ON users.id = totals.user_id
WHERE totals.total > 1000
这两种 SQL 在语义上是等价的,但执行计划可能不同。CataQuery 的优化器会根据传入的数据库类型(PostgreSQL、MySQL、SQLite 等)决定采用哪种形式。MySQL 对派生表(Derived Table)的处理在某些版本下有性能坑,所以默认会选择 CTE;而 SQLite 对 CTE 的处理则可能不如内联子查询高效。这个“根据目标数据库自动选择合适的 SQL 表达”的能力,是手工编写跨数据库查询时很难实现的功能。
4. 编译过程的深入解析
4.1 类型检查与范畴验证
编译器的前两个阶段——解析和类型检查——是确保查询安全的核心关口。在 CataQuery 中,每个 Table 对象都携带完整的模式信息(Schema),包括列名和列类型。当你在 select(users["name"], orders["amount"]) 中引用列时,编译器会验证这些列确实存在于对应的表对象中。如果打错了列名,比如把 users["namee"] 传给编译器,它会直接抛出编译错误(严格来说是宏展开阶段的错误),而不是等数据库返回 column "namee" does not exist。
类型检查的过程中还隐藏着范畴论的验证。具体来说,每个查询表达式都会携带一个“源表集合”和一个“目标列集合”。filter 操作的源表集合必须与目标列集合一致,否则就说明在范畴意义上这个态射的域和陪域不匹配。join 操作则要求两个参与者的连接条件必须引用双方各自的列,且列的类型必须是可比较的(比如不能拿一个 VARCHAR 列和一个 INTEGER 列做相等比较,除非显式声明类型转换)。
我一开始设计的时候,范畴验证和类型检查是分开的两个 pass,后来发现很多范畴结构错误本质上就是类型不匹配。举个例子,如果你试图 join 一张表和它本身,但没有指定别名,编译器会报“来自不同上下文的相同对象”错误——这其实是在范畴论中“同一个态射不能自发地拥有两个不同的身份”的规则在类型层面的体现。后来我就把这两个阶段合并成了一个统一的“语义检查”阶段,代码量反而减少了。
4.2 查询优化:利用范畴论定律做等价变换
传统的 SQL 优化器(比如 PostgreSQL 的规划器)做的是基于代价的物理优化:枚举不同的连接顺序、选择索引扫描还是全表扫描、决定是否使用物化节点等。CataQuery 的优化器则主要做逻辑层优化——在生成 SQL 之前,先对范畴表达式本身做等价变换,把查询结构变得更加“容易被数据库优化”。
最典型的一个变换是谓词下推。考虑下面这个表达式:
python复制q1 = users.join(orders, on=users["id"] == orders["user_id"])
q2 = q1.filter(users["age"] > 18)
在范畴论语义下,join 得到一个复合对象,filter 作用在这个复合对象上。如果直接翻译,会生成“先连接,再过滤”的查询。但根据“函子保持子对象结构”的规律,filter 可以沿着连接关系向前推进:
python复制q1' = users.filter(users["age"] > 18)
q2' = q1'.join(orders, on=users["id"] == orders["user_id"])
这个变换是合法的,因为 age > 18 这个条件只依赖 users 表的列,不依赖 orders 表。把它推下去之后,参与 join 的行数大幅减少,连接成本显著降低。这个规则在关系代数里叫“选择下推”(Selection Pushdown),但在 CataQuery 里它不需要单独实现——它只是范畴论中“函子保持拉回(Pullback)”这个数学性质的推论。
类似的变换还有投影剪枝:如果最终只需要 users.name 和 orders.amount 两列,优化器会尝试把 select 尽量往前推,让 join 阶段只需要处理必要的列,减少数据传输量。
4.3 SQL 生成细节
SQL 生成阶段虽然“机械”,但藏着很多容易让人栽跟头的细节。首先是标识符的转义。不同的数据库对保留字(Reserved Keywords)的处理不一样:user 在 PostgreSQL 里是保留字,在 SQLite 里不是。如果一张表恰好叫 user,直接生成 FROM user 在 PostgreSQL 里会报语法错误,必须写成 FROM "user"。CataQuery 内部维护了一个针对不同方言的保留字表,生成 SQL 时自动判断是否需要加反引号(MySQL)或双引号(PostgreSQL/SQL standard)。
其次是参数绑定。CataQuery 生成的所有过滤值都不会直接嵌入 SQL 字符串,而是生成一个带 %s 占位符的模板和对应的参数列表。例如:
python复制query = users.filter(users["age"] > 18)
在 PostgreSQL 方言下会生成:
sql复制SELECT * FROM users WHERE users.age > %s
参数列表为 [18]。这样做有两个好处:一是避免 SQL 注入风险;二是让数据库能够缓存相同结构的查询计划,提高执行效率。
关于 SQL 注入,这是每次生成 SQL 时必须谈的问题。CataQuery 从架构上就杜绝了字符串拼接注入的可能——开发者没有办法直接把一段字符串插进查询的 WHERE 子句里,所有过滤条件都必须通过类型化的 Column 对象构造。这样一来,即便用户输入是恶意的 ' OR 1=1 --,它也只是一个普通的字符串值,会被当作参数传给数据库,而不是被解析成 SQL 代码。
5. 常见问题与排查技巧实录
5.1 类型错误排查:从编译报错到快速修复
CataQuery 的报错信息整体上比 SQL 的报错友好得多,但新手仍然会遇到一些让人摸不着头脑的情况。最常见的错误是“Ambiguous Column Reference”(列引用不明确)。在 SQL 中,如果你在两表连接的查询里直接写 SELECT name,而两张表都有 name 列,数据库会报错。CataQuery 会在编译期就抓住这个问题。
报错信息长这样:
code复制CataQueryTypeError: Ambiguous column reference 'name' in select clause.
Available columns: [users.name, orders.name]
Hint: Use users["name"] or orders["name"] to disambiguate.
这种错误通常源自对表结构不熟悉。建议在写查询之前,先用 print(users.schema) 查看表的完整模式,确认每个列名的准确拼写和所属表。
5.2 N+1 查询的避免和检测
N+1 查询是 ORM 最常见的性能杀手,CataQuery 在设计上尽量规避了这个问题。因为 CataQuery 的查询模型是显式的——你要连接哪些表、提取哪些列都明明白白写在表达式里,编译器会生成一条完整的 SQL,不存在“用的时候才去查”的懒加载机制。
但在实际项目中,我发现一个隐蔽的 N+1 变种:开发者为了代码复用,把多个查询分别封装成函数,然后在业务代码里循环调用。例如:
python复制def get_user(user_id):
return users.filter(users["id"] == user_id).select(users["name"])
user_ids = [1, 2, 3, 4, 5]
for uid in user_ids:
print(compile_query(get_user(uid)))
这段代码虽然每次都调用了编译器,但最终会生成 5 条独立的 SQL 查询。要解决这个问题,需要把“按 ID 列表查询”抽象成单个查询。CataQuery 提供了 contains 操作:
python复制query = users.filter(users["id"].contains(user_ids)).select(users["name"])
编译后会生成 WHERE users.id IN (1, 2, 3, 4, 5),一次查询搞定。
5.3 性能分析与优化建议
CataQuery 生成的 SQL 不保证每次都最优,但它提供了几个工具帮助分析。第一是 explain(query) 函数,它会生成带 EXPLAIN 前缀的 SQL,方便你直接放到数据库里查看执行计划。第二是 verbose=True 编译开关,打开后会输出编译器各阶段的耗时和优化规则应用日志:
python复制sql, meta = compile_query(query, verbose=True)
print(meta.optimization_log)
# [
# "Applied predicate pushdown on filter(users.age > 18)",
# "Applied projection pruning on select(users.name, orders.amount)",
# ]
通过这些日志,你可以看到优化器到底对查询做了哪些改动。如果发现某个查询没有被优化,通常是因为过滤条件同时引用了多张表的列,导致谓词下推的条件不成立。这时候你需要考虑是否调整表结构——比如把冗余字段直接存储到主表中,减少连接的必要性。
5.4 多数据库方言兼容的“暗坑”
CataQuery 虽然支持 PostgreSQL、MySQL、SQLite 等多种数据库,但“支持”和“完美支持”之间还是有距离的。我实际测试后遇到的最大坑是分页语法的差异。PostgreSQL 和 SQLite 支持 LIMIT 10 OFFSET 20,而 SQL Server 采用的是 OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY。CataQuery 内置了方言适配器,会自动处理这种差异,但如果你在自定义函数里写死了包含 LIMIT 的语句,那么在切换到 SQL Server 时就会报错。
另一个容易被忽略的问题是布尔类型的字面量。PostgreSQL 用 TRUE 和 FALSE,MySQL 更习惯用 1 和 0,SQLite 则两种都接受。CataQuery 在序列化布尔常量时会根据目标方言做转换,但如果你在自定义原生 SQL 片段中混入布尔字面量,就会遇到兼容性问题。我的建议是:尽量不要使用自定义原生 SQL 片段功能,除非你明确知道目标数据库的方言差异。
6. 再分享几个设计迭代中的经验
项目发展到这个阶段,我最大的感触是:用范畴论设计查询语言,最大的收获不是炫酷的数学背景,而是它强迫我从“查询到底是什么”这个根本问题出发去思考。
刚开始做原型时,我陷入了“用范畴论重新发明 ORM”的误区——把大量精力花在如何用函子包装各种 CRUD 操作上,结果做出来的东西只是一个换皮的对象关系映射器。后来我意识到,范畴论的价值不在于为每个数据库操作找一个数学名词,而在于提供一种约束和指导:复合必须是结合的、态射必须保持某些结构、函子必须满足一致性。这些约束在实际编程中表现为“编译器能在编译期发现更多的错误”“优化器能安全地进行更多的变换”。
如果你也想尝试类似的方向,我建议从一个非常小的场景切入,不要一开始就想做全功能的 SQL DSL。比如先只支持单表查询,实现 filter 和 select 的复合,然后逐步加入 join、聚合、子查询。每一次扩展都要重新审视范畴论的基本定律是否仍然成立——如果不成立,往往意味着你的设计在某处违背了范畴结构的本质,需要重新考量。
最后再分享一个小技巧:在开发编译器时,一定要准备一个“查询回归测试集”——包含上百个典型的查询模式,每个模式都有对应的期望 SQL(按方言分别存储)。每当你修改了优化规则或 SQL 生成逻辑,就跑一遍全部测试,确保之前能正确编译的查询没有被破坏。这个测试集在后期帮了我大忙,有好几次重构差点引入严重的生成错误,全靠它拦了下来。
CataQuery 目前还在持续迭代中,后续我计划加入对窗口函数(Window Functions)和递归 CTE 的支持,这两个特性在范畴论中也有对应的结构,只是实现复杂度更高。如果你对这门语言感兴趣,或者想深入讨论范畴论在数据查询中的应用,欢迎在评论区留言交流。
