PAYATHON 2026

Rust QueryBuilder 如何组织可扩展的 AST 遍历与 SQL 渲染?

数据小许

结论

你拆分出的 VisitKind、VisitColumns、VisitFrom 更像是按能力组合的 trait,而非经典 Visitor。这样 SelectAst 只需实现所需行为。不过,accept 仍由 AST 决定调用顺序;新增操作时,通常还要修改 AST 或为它增加新的 trait 实现。

可以根据变化方向选择设计:如果查询节点种类相对稳定,而操作会不断增加,适合使用 Visitor;如果操作较少,并希望 AST 与 SQL 渲染解耦,可以使用枚举 AST 和独立的 renderer;如果只需将 AST 输出为 SQL,也能接受 AST 与 SQL 输出逻辑耦合,则可以让节点实现 write_sql 一类的方法。

为什么拆分 trait 不等于经典 Visitor

经典 Visitor 通常由节点将自身交给 visitor,再由 visitor 决定如何处理节点。例如,节点的 accept 组织遍历顺序,各类操作则由 visitor 提供:

trait Visitor {
    fn visit_select(&mut self, node: &SelectAst);
    fn visit_column(&mut self, node: &ColumnRef);
}

trait Visitable {
    fn accept(&self, visitor: &mut dyn Visitor);
}

在 Rust 中,accept 调用 visitor 的具体方法,这通常就是所说的双重分派思路:节点类型选择对应的 visit_* 方法,运行时的 visitor 实例决定具体操作。使用泛型 visitor 时,也可以采用静态分派;接口结构仍然体现了 Visitor 的设计方式。

你给出的第二种结构中,SelectAst::accept 会直接调用 visit_kind、visit_columns 和 visit_from。这些 trait 表示 SelectAst 具备的能力,输出顺序也由 accept 固定。由于没有独立的 visitor 接收不同节点并执行操作,这种结构与经典 Visitor 不同。

经典 Visitor 的接口包含较多方法,并不意味着它违反了 ISP。关键在于每个 visitor 是否承担一个完整、连贯的操作。例如,SQL 渲染器作为一种完整操作,处理所有 AST 节点是合理的。如果某个实现只需支持部分节点,可以提供默认方法或适配器,避免每个实现都手写无关方法。

方案一:节点类型稳定、操作会增加时使用 Visitor

Visitor 方法应按 AST 节点类型组织,而不是按 SQL 的输出片段或子句拆分。Visitor 可以分别负责渲染、格式化、检查或收集信息。

trait Visitor {
    fn visit_select(&mut self, node: &SelectAst);
    fn visit_column(&mut self, node: &ColumnRef);
}

trait Visitable {
    fn accept(&self, visitor: &mut dyn Visitor);
}

impl Visitable for SelectAst {
    fn accept(&self, visitor: &mut dyn Visitor) {
        visitor.visit_select(self);
    }
}

impl Visitable for ColumnRef {
    fn accept(&self, visitor: &mut dyn Visitor) {
        visitor.visit_column(self);
    }
}

SqlEmitter::visit_select 可以按照 SQL 语法遍历 SELECT 的列、来源表和其他子句。如果 Visitor 接口包含很多方法,可以用带默认实现的基础 trait,让默认方法递归访问子节点,具体 visitor 只需覆盖要改变的行为。也可以将不同阶段拆分为多个 visitor,但应避免要求调用者拼装一组难以保证顺序的访问步骤。

采用这种设计,增加 AST 节点时通常也要更新 visitor 接口及相关实现。它适合节点集合相对稳定、操作类型持续增加的场景。

方案二:用枚举 AST 和独立 renderer 解耦 SQL 输出

如果不希望 AST 节点负责决定 SQL 的生成顺序,可以将节点表示为枚举,并在 renderer 中集中匹配节点。下面是简化示意,省略了具体字段和递归实现:

enum Statement {
    Select(SelectAst),
    Update(UpdateAst),
    Delete(DeleteAst),
}

fn render_statement(stmt: &Statement, out: &mut SqlEmitter) {
    match stmt {
        Statement::Select(select) => render_select(select, out),
        Statement::Update(update) => render_update(update, out),
        Statement::Delete(delete) => render_delete(delete, out),
    }
}

这种结构让 renderer 控制 SQL 的输出顺序,也便于按数据库方言提供不同 renderer。新增 AST 变体时,需要检查并更新匹配它的操作;Rust 的穷尽匹配可以帮助发现遗漏。它适合 AST 类型数量有限、希望集中管理渲染逻辑的项目。

SQL 子句应由语法结构决定

不要因为 SELECT 和 DELETE 都“涉及表”,就假设它们遵循完全相同的 FROM 规则。应根据目标 SQL 方言设计语句和子句的结构,再由 renderer 输出对应语法。如果不同数据库对某种语句的 FROM 支持不同,可以在 renderer 或显式的方言配置中处理差异。

对于 PostgreSQL 等支持 UPDATE ... FROM 的方言,可以在对应的语法结构中显式表示该子句。若某个方言不支持,则由该方言的 renderer 拒绝生成,或采用明确的替代形式。方言相关的语法不应视为所有 UPDATE 都共有的部分。

实现时的注意事项

  • 如果 SqlEmitter 只负责收集 tokens,可以由 AST 遍历逻辑调用它。如果 renderer 还要处理占位符、参数绑定和方言差异,通常将 renderer 保持为独立组件会更容易扩展。
  • 用户输入的值应作为绑定参数处理,不要直接拼入 SQL 字符串。表名和列名通常不能用普通值参数代替,应通过安全的标识符处理规则生成。
  • 对于大型 AST,优先让表达式节点递归遍历其子节点,这样 visitor 和 renderer 都不必分别手动维护每一层结构。
  • 选择泛型 visitor 或 trait object 时,应根据实际需要权衡静态分派、动态分派和对象安全等 Rust 约束。

重点在于你要扩展 AST 操作,还是要将 SQL 渲染与 AST 分离。前者适合按节点类型设计接口的 Visitor;后者适合枚举 AST 配合独立 renderer。按行为拆分 trait 也可以明确组合能力,但这种设计本身不会具备 Visitor 在扩展操作方面的特性。

备注:内容仅供参考。