拖拽式离线建模有一种欺骗性:页面看起来很直观,于是后端似乎只需要“按顺序把节点翻译成 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 只是目标代码。
越早承认这一点,系统后面付出的维护成本就越低。