前言
在上一篇文章中,我们确定了“双遍历”架构。其中第一阶段 ScopeBuilderListener 的唯一任务,就是构建作用域(Scope) ,即将所有的表、别名、层级关系全部登记造册。
💡开源项目地址::Hermes-SQL
什么是“作用域(Scope)”?
什么是作用域? 简单来说,它就是一个 “词典”。当 SQL 被解析到 SELECT 列时,遇到一个裸字段 id,引擎必须能立刻通过 Scope 回答两个问题:
- 这个
id属于哪张表? - 如果多张表都有
id,是不是该报歧义错误了?
为了回答这两个问题,作用域中就必须要有:
aliasToTableMap:表别名(或表名)与表信息的映射。columnToTableMap:纯字段名与表信息列表的映射(列表是为了检测多表同名字段的歧义)。
每个 SQL 语句均对应一个 Scope 作用域。因为 SQL 语句存在嵌套关系,所以 Scope 也必须存在层级关系:parentScope 记录父子关系,scopeLevel 记录层级深度。
FROM 后面的 5 种奇葩数据源解析
在 Hive SQL 语法中,FROM 后面共支持 5 种数据源。下面我们结合真实业务场景,重点攻克这 5 类数据结构。
📍 第一关:基础物理表与FROM 子查询
先从最简单的开始:物理表(tableSource) 。
拿到表名、别名、库名后,直接注册到 Scope 的 aliasToTableMap 中即可。
但这里有一个极其容易被忽略的工程问题:解析器怎么知道物理表里有哪些列?
ANTLR 只能解析 SQL 文本,它没有连数据库。因此,我们需要引入一个MetadataProvider(元数据服务)。在注册物理表时,主动去查询元数据(如本地模拟的 YAML/SQLite),拿到该表的所有列,然后将其注册到columnToTableMap中。
稍微升级一点的是 FROM 子查询(subQuerySource) 。
内层子查询有自己的 SELECT 和 FROM,外层有一个别名。这里出现了 SQL 语句的嵌套,因此我们的 Scope 也要进行嵌套。
内外两层作用域相互之间是隔离的,它们各司其职,互不干扰。遇到 FROM (SELECT ...) sub 时,必须为内层子查询创建一个全新的 SqlInfo 和 Scope,并分别压入 sqlInfoStack 栈和 scopeStack 栈中。外层通过别名 sub 去引用内层的子查询结果。
// 核心代码:遇到子查询时,入栈新作用域
if (ctx.subQuerySource() != null) {
Scope subScope = new Scope();
// 关联父级Scope
subScope.parentScope = currentScope;
scopeStack.push(subScope); // 压入作用域栈
}
📍 第二关:非常规数据源 —— 虚拟表 (VALUES)
虚拟表(virtualTableSource) ,没有物理表,属于直接硬编码。比如:
SELECT * FROM (VALUES (1,'A'), (2,'B')) AS t(id, name)
这种场景下,表别名与表字段都是外显的。怎么处理?
不需要查任何元数据,直接在 AST 里把括号里的 id, name 提取出来,作为虚拟表的输出列,强行塞进对应 Scope 的 columnToTableMap 中即可。
📍 第三关(高能预警):UDTF 虚拟列 —— LATERAL VIEW explode
UDTF 虚拟列(lateralView) ,这是数仓/银行开发最常用的语法,也是解析器的“噩梦”。
比如业务场景:SELECT id, score FROM user LATERAL VIEW explode(scores) t AS score。
奇葩点在哪?
表函数explode 输出的处理结果 score 列在原始表里根本不存在,它是运行过程中生成的虚拟列。这就导致如果你用传统的查表方式,永远找不到 score 的来源
怎么解?
引入 leftSourceStack(左输入源栈)。所有的 LATERAL VIEW 表函数,其数据来源一定依赖于它左边紧挨着的那张表(比如上面的 user 表),在遍历时,我们将左表压入左输入源栈,当遇到 LATERAL VIEW 时,直接从栈顶取出来作为它的 inputSourceTable。
然后强行把生成的虚拟列 score 注册进 对应的Scope作用域 中,并打上 FUNCTION 标签,表示这是表函数的处理结果。
// 核心代码:提取 UDTF 的输出列并强行注册
// 拿到 'score'
String outputColName = ctx.lateralView().id_().getText();
TableInfo udtfTable = new TableInfo();
udtfTable.setTableType(TableType.FUNCTION);
// 绑定左边的 user 表
udtfTable.setInputSourceTable(leftSourceStack.peek());
udtfTable.setOutputColumns(List.of(outputColName));
//将字段注册进对应的Scope作用域
currentScope.getColumnToTableMap().put(outputColName, List.of(udtfTable));
📍 第四关(罕见杀手):分区表函数 PTF
分区表函数(partitionedTableFunction) ,这是 Hive SQL 的独有语法,其本质就是:以表为参数,输出另一张表的内置函数。
这种数据源的语法嵌套很深,其输入源可以是物理表,也可以是子查询,甚至是另一个PTF嵌套。
这种数据源的语法嵌套极深,其输入源可以是物理表,也可以是子查询,甚至是另一个 PTF(套娃)。为了追踪这种复杂关系,我使用了 functionOutputStack 去记录内层函数的输出,防止数据的上下文污染外层作用域。
📍 踩坑实录:括号包裹的 JoinSource
开发过程中,我遇到一个极度诡异的 Bug:一旦 FROM 后面加了括号,比如 FROM (t1 JOIN t2) ...,整个作用域栈就崩了。
后来我仔细看了官方生成的 AST 结构,发现括号包裹会产生一个多余的虚拟节点,导致我原本设计的层级判断全部错位。
我写了一个 isParenWrapped 方法,通过检测父节点 AtomjoinSourceContext,再通过 getChild(i) 去寻找它前后的兄弟节点是不是左括号 LPAREN 和右括号 RPAREN。如果是,我直接跳过这个层级的入栈操作,成功避开了这个坑。
private boolean isParenWrapped(HiveParser.JoinSourceContext ctx) {
// 判断父节点是否是 AtomjoinSourceContext
if (ctx.getParent() instanceof HiveParser.AtomjoinSourceContext) {
HiveParser.AtomjoinSourceContext parent = (HiveParser.AtomjoinSourceContext) ctx.getParent();
for (int i = 0; i < parent.getChildCount(); i++) {
if (parent.getChild(i) != ctx) continue; // 定位到当前节点
if (i == 0) return false; // 没有前一个兄弟节点
ParseTree lbrother = parent.getChild(i - 1);
if (lbrother instanceof TerminalNode) {
TerminalNode prevToken = (TerminalNode) lbrother;
// 判断前一个节点是不是左括号
if (prevToken.getSymbol().getType() == HiveParser.LPAREN) {
return true;
}
}
}
}
return false;
}
结语与预告
至此,无论多么复杂的 FROM 语句,在我们的引擎里都被拆解成了清晰的作用域对象,就像建好了一个完美的“仓库登记表”。
有了这个地基,下一篇文章,我们正式进入第二阶段的深水区:顺着线索去查真实的物理字段,处理折磨人的 AST 字段拆分问题,直接提取字段级血缘!敬请期待😁。