【实战一】构建作用域:如何搞定 FROM 后面的 5 种奇葩数据源?

0 阅读5分钟

前言

在上一篇文章中,我们确定了“双遍历”架构。其中第一阶段 ScopeBuilderListener 的唯一任务,就是构建作用域(Scope) ,即将所有的表、别名、层级关系全部登记造册。

💡开源项目地址::Hermes-SQL

什么是“作用域(Scope)”?

什么是作用域? 简单来说,它就是一个 “词典”。当 SQL 被解析到 SELECT 列时,遇到一个裸字段 id,引擎必须能立刻通过 Scope 回答两个问题:

  1. 这个 id 属于哪张表?
  2. 如果多张表都有 id,是不是该报歧义错误了?

为了回答这两个问题,作用域中就必须要有:

  • aliasToTableMap:表别名(或表名)与表信息的映射。
  • columnToTableMap:纯字段名与表信息列表的映射(列表是为了检测多表同名字段的歧义)。

image.png

每个 SQL 语句均对应一个 Scope 作用域。因为 SQL 语句存在嵌套关系,所以 Scope 也必须存在层级关系:parentScope 记录父子关系,scopeLevel 记录层级深度。

FROM 后面的 5 种奇葩数据源解析

image.png

在 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 字段拆分问题,直接提取字段级血缘!敬请期待😁。