Hotdry.

Article

使用 PostgreSQL LATERAL 构建类型安全的内嵌 DSL

利用 PostgreSQL LATERAL 关键字在子查询中引用外部表的特性,构建类型安全的内嵌DSL,实现编译期查询验证。

2026-04-29systems

在数据库查询构建领域,ORM 和查询构建器长期以来面临着一个根本性的表达力问题:查询难以组合复用。传统的方案要么通过在函数间传递查询构建器对象来逐个追加表连接和过滤条件,要么让函数返回子查询然后由使用者手动处理连接。这两种方式都存在明显缺陷 —— 前者通常只在动态类型语言中可行,后者的可组合性极差。PostgreSQL 的 LATERAL 关键字为这一问题提供了优雅的解决方案,它允许子查询直接引用前面 FROM 子句中的列,从而使得构建类型安全的内嵌 DSL 成为可能。

LATERAL join 的基本原理

LATERAL 是 PostgreSQL(及其他少数数据库)提供的一种特殊连接类型,它允许在子查询中引用同一 FROM 子句中前面出现的表列。举例来说,假设有用户表和文章表两张表,传统写法是直接内连接:SELECT * FROM users u INNER JOIN posts p ON u.id = p.user_id。使用 LATERAL 可以改写为:SELECT * FROM users u CROSS JOIN LATERAL (SELECT * FROM posts p WHERE u.id = p.user_id) p2。表面上这是笛卡尔积,但子查询中的过滤条件确保每篇文章仍然只与其所属用户配对。有趣的是,这两个查询在 PostgreSQL 中会生成完全相同的查询计划,这说明 LATERAL 在语义上与普通内连接等价。

这种等价的执行计划意味着我们可以获得内连接的性能,同时拥有更灵活的查询表达能力。关键在于 LATERAL 子查询可以访问外层表的列,这一特性使得查询的组合变得极为自然。

构建可组合查询的核心思路

大多数 ORM 和查询构建器的组合技术本质上都是在传递查询构建器对象,每追加一个表连接和过滤条件就修改该对象。这种方式在强类型语言中面临类型系统的挑战 —— 不同表组合会产生完全不同的返回类型,类型推断迅速变得不可维护。另一个常见方案是让函数直接返回子查询,但调用者需要手动处理子查询与主查询的连接,这种方式既繁琐又容易出错。

LATERAL 提供了一种全新的组合思路。在 Haskell 的 Rel8 库中,你可以写出如下的代码:postsForUser userId = do { post <- each postSchema; where_ $ post.userId ==. userId; pure post },以及 usersAndPosts = do { user <- each userSchema; post <- postsForUser user.id; pure (user, post) }。这段代码看起来像是在操作普通数据,但实际上它正在构建一个 SQL 查询。第二段代码中的 do 块每引入一个查询就会在生成的 SQL 中添加一个 CROSS JOIN LATERAL,而 where_ 则向查询中添加 WHERE 条件。关键在于 user.id 实际上并非一个用户 ID 值,而是包含 SQL 表达式 id0_1Expr 类型,它可以在后续的子查询中直接使用。

类型安全与作用域规则的 Rust 实现

在 Rust 中实现类似的库需要处理类型系统和生命周期的双重挑战。rust-rel8 库展示了完整的实现方案,其核心在于利用 Rust 的借用检查器来强制执行查询的作用域规则。query 函数的签名是 pub fn query<'outer, T: ForLifetimeTable + Table + 'outer>(f: impl for<'scope> FnOnce(&mut Q<'scope>) -> T::WithLt<'scope>) -> Query<T>,这里的 for<'scope> 使得传入的闭包必须对所有可能的生命周期都有效,而闭包只能看到被提供的 Q<'scope> 并且必须返回带有该生命周期的类型。一旦 query 处理完查询,生命周期的管理权就移交给调用者,这意味着生成的 Query 是一个独立的、完整的 SELECT 语句,不再需要任何生命周期跟踪。

为了支持用户自定义类型,库中定义了 TableMode 这样一个 GAT trait,它可以根据模式切换字段类型。在 NameMode 下字段是字符串类型的列名,在 ValueMode 下是实际的值,在 ExprMode 下则是表达式类型。例如用户结构体定义为 struct User<'scope, Mode: TableMode = ExprMode> { id: Mode::T<'scope, i32>, name: Mode::T<'scope, String> },这样 User<NameMode> 就是 { id: &str, name: &str },而 User<ExprMode> 则是 { id: Expr<'scope, i32>, name: Expr<'scope, String> },这正是能够在查询中写出 user.id.equals(user_id) 这种自然语法的根本原因。

MapTable trait 则实现了字段类型转换的遍历操作。通过它,库可以从 NameMode 表中提取列名来构建 SELECT 语句,生成 ExprMode 表供查询内部使用;查询执行完毕后,还可以再次使用它将结果从 ExprMode 转换为 ValueMode 进行反序列化。实现 MapTable 只需要用户编写类型映射逻辑,比如 fn map_modes_ref<Mapper, DestMode>(&self, mapper: &mut Mapper) -> Self::InMode<DestMode> 这样的方法,库的内部实现会负责调用这些方法并在不同模式间转换。

聚合查询与可选值处理

LATERAL 的强大之处还体现在聚合操作和可选值处理上。使用 .many() 方法可以将 Query<T> 转换为 Query<ListTable<T>>,从而实现一对多的聚合查询,结果直接以 Vec<Post> 的形式从数据库返回。对于外连接的场景,.optional() 方法可以将查询转换为左外连接,这在获取用户的最新文章等场景中特别有用。

聚合函数的使用也有严格的类型检查 ——.aggregate 构建器强制要求输出中的所有字段都必须经过聚合函数处理,要么作为 GROUP BY 的分组依据,要么作为聚合函数的参数。这确保了生成的 SQL 永远合法有效,避免了手动编写聚合查询时常见的语法错误。

与传统方案的对比

与直接编写 SQL(使用 sqlx 等工具)相比,这种 DSL 方式保留了类型安全,同时提供了更好的可组合性。与 ORM 相比,它避免了关系抽象带来的复杂性,以及在更新操作时需要先加载再修改的低效模式。在强类型语言中,这种方式的优势尤为明显:只要代码能够编译,生成的 SQL 就一定是有效的。借用检查器在编译期就防止了作用域错误,比如在列引入之前就尝试引用它的情况。

PostgreSQL 的 LATERAL 关键字为构建类型安全的查询 DSL 提供了坚实的理论基础。结合现代编程语言的类型系统,可以实现既具有高度表达力、又能保证编译期正确性的查询构建方式。这种方法代表了数据库交互层设计的一个重要方向,值得在需要复杂查询场景的系统中深入探索。

资料来源:https://bensimms.moe/postgres-lateral-makes-quite-a-good-dsl/

systems