拖拽式离线建模有一种欺骗性:页面看起来很直观,于是后端似乎只需要“按顺序把节点翻译成 SQL”。

我一开始也是这么做的。

当时项目更关注的是先把链路跑通:用户在画布上拖拽数据源、过滤、字段选择、关联、聚合等节点,后端接收画布 JSON 后,根据节点关系做一次遍历,然后按照节点类型拼接 SQL 片段。只要节点不复杂,这种方式确实能快速产出结果,也很适合早期验证产品流程。

比如数据源节点生成 FROM,过滤节点追加 WHERE,字段选择节点生成 SELECT,聚合节点再补 GROUP BY。对于一条简单链路来说,这种实现成本低,效果也直观。

但问题也很快暴露出来。

画布不是顺序结构。它允许分叉、合并、多个输入,也允许用户画出环、断开的节点和不完整的配置。SQL 也不是简单的字符串相加,它有字段作用域、表别名、连接条件、聚合规则和嵌套层级。

简单遍历能解决“从 A 到 B 到 C”的场景,但一旦遇到 Join、多个 Where 条件、聚合后字段变化、节点复用,就会变得越来越脆弱。

当时为什么先用简单拼接

从工程落地角度看,早期直接遍历节点拼 SQL 并不是完全错误。

项目初期最重要的是验证业务闭环:画布配置能保存,节点关系能解析,SQL 能生成,任务能执行,结果能返回。如果一开始就设计完整 AST、字段血缘、作用域检查和 SQL Builder,很容易把首版做得过重,反而拖慢验证速度。

所以当时的实现更像一个轻量转换器:先按照画布中的连线关系找到执行路径,再根据节点类型拼接对应 SQL 片段。它解决的是“能不能跑起来”的问题,而不是“能不能长期稳定扩展”的问题。

这类实现的优势很明显:开发快、调试直观、对简单场景足够有效。

但它的代价也同样明显:复杂度不是消失了,而是被压进了字符串拼接逻辑里。

真正困难的不是生成 SQL,而是理解图

如果时间更充足,我会把第一步从“生成 SQL”改成“构造图模型”。

收到画布 JSON 后,后端不应该马上遍历节点拼字符串,而是先把节点和连线转换成明确的图结构。

type Node struct {
ID string
Type string
Config json.RawMessage
Inputs []string
Outputs []string
}

有了图模型之后,先做静态检查:

连线引用的节点是否存在;
必须有输入的节点是否悬空;
输出节点是否唯一或符合业务约束;
图中是否存在环;
Join 节点是否拥有足够输入;
聚合节点引用的字段是否来自上游;
字段选择节点是否引用了不存在的字段;
过滤条件里的字段是否仍在当前作用域中。

这些检查越早做,用户体验越好。

如果错误拖到数据库执行阶段才暴露,用户看到的往往是一条很难理解的 SQL 报错,比如字段不存在、语法错误、分组字段非法。它并不会告诉用户“你的过滤节点引用了上游已经被投影掉的字段”。

对可视化建模来说,错误最好能回到节点本身,而不是停留在最终 SQL 上。

中间表示比最终字符串更重要

如果重新做一次,我不会让每个节点直接返回 SQL 字符串,而是让每个节点返回结构化的中间结果。

比如:

Source
columns: [id, name, amount]
from: orders

Filter
condition: amount > 100

Project
columns: [id, amount]

Aggregate
groupBy: [id]
metrics: [sum(amount) as total_amount]

每个节点转换器只负责理解自己的配置。

数据源节点负责提供表名和字段集合;过滤节点负责增加条件;字段选择节点负责收窄字段;Join 节点负责合并两个输入的字段作用域;聚合节点负责改变输出字段集合。

最终由统一的渲染器决定 SQL 该怎么生成:是直接拼成一层查询,还是使用子查询,还是使用 CTE。

这样做比直接拼字符串多了一层,但它能把复杂度放在正确的位置上。

节点层负责业务语义,渲染层负责 SQL 语法。两者分开以后,很多问题会更容易定位,也更容易测试。

字段作用域是最容易被低估的问题

简单拼接 SQL 时,很容易忽略字段作用域。

比如 Source 节点输出了 id、name、amount,Project 节点只保留了 id、amount,后面的 Filter 节点如果再引用 name,理论上应该在生成 SQL 前就报错。

Join 场景更明显。两个输入表都可能有 id 字段,如果没有明确别名和字段来源,后面再引用 id 时就会出现歧义。

聚合节点也会改变字段规则。聚合之后,原始明细字段通常不能再随便出现在 SELECT 或 WHERE 中,否则 SQL 即使生成出来,也可能无法执行。

所以更稳妥的设计,是给每类节点建立“输入字段—输出字段”的契约。

Source:
input: none
output: table columns

Project:
input: upstream columns
output: selected columns

Filter:
input: upstream columns
output: upstream columns

Join:
input: left columns + right columns
output: merged columns with alias

Aggregate:
input: upstream columns
output: group fields + metric fields

只要这个契约清晰,很多问题就能在节点转换阶段提前发现。

以 Join 节点为例,它不能只负责拼出一段 JOIN SQL。

它首先要拿到左右两个输入节点的字段集合,检查连接条件中的字段是否真实存在;如果左右两侧存在同名字段,还要在输出字段中补充来源或别名,避免下游节点引用时产生歧义。

比如 orders 和 users 都有 id 字段,Join 之后如果仍然只暴露一个 id,下游 Filter 或 Project 节点再引用 id 时,系统就无法判断它指的是订单 ID 还是用户 ID。更稳妥的做法是让 Join 节点输出 order_id、user_id 这类明确字段,或者在字段元信息中保留来源表和别名。

这样一来,很多错误不用等 SQL 执行时才暴露。字段不存在、字段歧义、Join 条件不完整、下游引用非法字段,都可以在节点转换阶段提前发现,并且错误可以直接定位到具体节点。

为什么还需要保留手写 SQL

可视化 DAG 并不天然优于 SQL。

对于熟悉 SQL 的开发者,拖拽有时反而更慢。某些窗口函数、复杂子查询、数据库方言特性,也很难及时抽象成节点。

所以更合理的方式不是让 DAG 模式取代 SQL 模式,而是让它们在入口层保持不同,在运行时汇合。

用户可以选择拖拽生成,也可以直接写 SQL。但两种方式最终都应该进入同一套任务定义、执行实例、调度配置、运行日志和状态反馈。

入口可以不同,运行时最好不要分裂。

否则系统后期会出现两套任务模型、两套执行逻辑、两套日志结构,维护成本会越来越高。

如果有充足时间,我会怎么做

如果有更多时间重构这部分,我会按几个阶段演进。

第一阶段,保留现有节点遍历逻辑,但补齐图校验。先解决环、悬空节点、非法连线、输出节点不唯一这类基础问题。

第二阶段,引入结构化中间表示。让节点不再直接返回 SQL 字符串,而是返回字段、条件、来源、聚合、排序等结构化信息。

第三阶段,把 WHERE、JOIN、SELECT、GROUP BY 等部分抽象成更接近 AST 的结构,由统一渲染器生成 SQL。

第四阶段,为每种节点补充契约测试,重点验证输入字段、输出字段、非法配置和复杂组合场景。

第五阶段,再考虑引入成熟 SQL Builder 或 AST 库,减少手写 SQL 方言和字符串转义带来的风险。

这样演进的好处是不会推翻早期成果,也不会为了架构正确而影响业务验证。它更像是从一个能跑的转换器,逐步升级成一个可维护、可扩展的编译器雏形。

结语

现在回头看,当时用简单节点遍历拼接 SQL,是一个适合早期落地的选择。它让功能先跑了起来,也让业务流程得到了验证。

但从长期维护看,可视化 DAG 到 SQL 的转换,本质上不是字符串拼接问题,而是一个小型编译问题。

画布是源代码,节点是语法单元,连线是依赖关系,中间表示是 IR,最终 SQL 只是目标代码。

越早承认这一点,系统后面付出的维护成本就越低。