导读:欢迎来到 StarRocks 源码解析系列文章,我们将为你全方位揭晓 StarRocks 背后的技术原理和实践细节,助你逐步了解这款明星开源数据库产品。本期将主要介绍 StarRocks Parser 源码。
作者:刘航源 StarRocks Committer
概述
Analyzer 是数据库实现中的重要组成模块,主要用于进行 SQL 的语义解析,承担了多个重要功能。而绝大部分的 SQL 正确性检查和语法报错逻辑,也均集中在 Analyzer 中。
本文将着重介绍 StarRocks 中 Analyzer 的实现逻辑,以及如何根据 StarRocks 中的元数据信息,完成AST 的 Analyze 逻辑。
Analyzer 的主要功能
首先,我们需要了解一下 Analyzer 在 StarRocks 中负责的功能,主要包括五个方面,如下。
- 名称解析:解析表名、列名,以及其他名称引用是否合法、是否存在。
- 类型确定:推导函数与表达式的类型信息。比如 a+b 算数表达式,会根据运算符类型和列类型,推导出相应的表达式类型。
- 类型检查:如果有语义上的错误,则会报错,比如 bitmap 类型不能计算 SUM。
- SQL 合法性检查:检查是否存在无意义的 SQL。比如包含聚合的 SQL,如果查询列不在聚合函数中,则必须包含在 group by 后。
- View:Common Table Expression 的解析。
了解了 Analyzer 的主要功能,接下来就看看在 StarRocks 中,如何通过 Analyzer 实现上述的功能需求。
Analyzer主流程
Analyzer 的前置模块是 Parser,在之前的 StarRocks Parser 源码解析中有介绍。
Analyzer 的输入是 Parser 得到的 AST,输出同样是 AST,不过输出的 AST 中已经完成了语义解析,并在 AST 中包含了后续流程所需要的信息。
下面就来介绍下 Analyzer 的主流程:
public class Analyzer {
public static void analyze(StatementBase statement, ConnectContext session) {
new AnalyzerVisitor().analyze(statement, session);
}
private static class AnalyzerVisitor extends AstVisitor<Void, ConnectContext> {
public void analyze(StatementBase statement, ConnectContext session) {
visit(statement, session);
}
//...
@Override
public Void visitQueryStatement(QueryStatement stmt, ConnectContext context) {
QueryAnalyzer(session).analyze(stmt, context);
}
@Override
public Void visitLoadStmt(LoadStmt statement, ConnectContext context) {
LoadStmtAnalyzer.analyze(statement, context);
}
}
}
Analyzer 类是所有语句的语义解析的主入口,采用了 Visitor 设计模式,会根据处理的语句类型的不同,调用不同语句的 Analyzer。不同语句的处理逻辑会包含在单独的语句 Analyzer 中,交由不同的 Analyzer 处理。
如上代码中,QueryStatement 将交由 QueryAnalyzer 负责解析,LoadStmt 将由单独的LoadStmtAnalyzer 负责解析。这样做的优点是,可以将 Statement 的定义文件和 Analyze 的处理逻辑分开,并且同一类型的语句也交由特定的 Analyzer 处理,做到不同功能之间代码的解耦合。
QueryAnalyzer
QueryAnalyzer 是所有类型的 Analyzer 中逻辑最复杂的,主要用于解析查询类语句。这里顺便提一句,查询类语句包括 Select statement 和 SetOp statement (UNION、EXCEPT、INTERSECT)。
QueryAnalyzer 的一个重要功能就是名称的解析,比如如何判断一个被使用的命名在所在空间中是否合法,如何判断是否存在可用的 CTE,都需要在名称解析阶段处理。而名称解析则主要依赖 StarRocks 中的一个内部类 Scope,来表示层级关系的解析空间。
下面我们就来看下 Scope 的逻辑。
名称解析空间
首先介绍 Scope 相关的主要结构:
public class Scope {
private Scope parent;
private final RelationId relationId;
private final RelationFields relationFields;
private final Map<String, CTERelation> cteQueries;
}
public class RelationFields {
private final List<Field> allFields;
}
public class Field {
private final String name;
private Type type;
//shadow column is not visible, eg. schema change column and materialized column
private final boolean visible;
/**
* TableName of field
* relationAlias is origin table which table name is explicit, such as t0.a
* Field come from scope is resolved by scope relation alias,
* such as subquery alias and table relation name
*/
private final TableName relationAlias;
}
RelationFields 表示 Scope 上可解析的名称属性,即对外可见的名称。如查询select c1, c2 from cte
会被解析为 SelectRelation,它所对应的 relationField 应该为[cte.c1,cte.c2] 。再向外拓展,这个查询被包裹在子查询 t 中 xxx from (select c1, c2 from cte) t
,这个 SubqueryRelation 对外的 relationFields 就应该为 [t.c1,t.c2]。
名称空间的解析存在一个层级的过程,比如示例中的相关子查询,那么在解析这个查询语句时,常规流程是 where 后面的命名应该由 table 2 所对应的 Scope 中解析而来。但是在相关子查询时,这个规律被打破,这时就要解析外层的 Scope 了。
因为解析的过程是在 AST 树上自上而下的解析,即对 SQL 由外向内的解析。所以此时就需要将外层的SubqueryRelation 对应的 Scope,作为内部 Scope 的 parent 进行解析,这样才能保证解析的正确进行。
而 Scope 中另一个重要的属性 Map<String, CTERelation> cteQueries,则覆盖了名称解析中另一个重要的功能,即 CTE 的层级关系。如 SQL with a as (select * from t1), b as (select * from t2) select * from (with a as (select * from t3) select * from a, b) t
。
这个 SQL 中外层的 Scope 声明了两个 CTE,a 和 b 分别对应表 t1 和 t2。而内部的子查询中,又重新声明了 CTE a,指向查询表 t3 的数据。这时就需要在内层解析空间中,使用内部的 CTE a 覆盖 scope 的CTE a。当然,这同样需要 scope 具有属性层级关系,也就是先解析内层 CTE 空间,再解析外层 CTE 空间。
Relation
SQL 是用于表达关系型数据的一种语言,因此单个 SQL 可以被分解为多个关系,即 Relation。一个查询语句,可以看作在一张二维的关系表上不断进行的数据操作与变换。
with cte as (select c1, c2 from table1) select t.c1 from (select c1, c2 from cte) t where t.c2 =(select table2.c1 from table2 where t.c1 = table2.c2)
如上 SQL,table1 会被解析成 TableRelation,名称子查询 t 会被解析成为 SubqueryRelation,而整个select 语句将被解析成 SelectRelation。它们之间是层级关系,所以相应的 Relation 会生成一个对应的Scope 用于解析。
下面可以通过 SelectRelation 的解析过程来具体说明。
visitSelect 中会首先解析 SelectRelation 中存储的子 Relation,这里根据 SQL 的不同,可以是多种类型的 Relation,如上例中的 SQL 则是别名“t”解析出的 SubqueryRelation,而 SubqueryRelation 则会层层递归解析,最终返回一份 SubqueryRelation 对上层可见的命名空间,在代码中被称为sourceScope
。
如下面的代码所示,在第6行,可以看到代码外部传入的 Scope 作为 sourceScope 的 parent,也依次构建出了 Scope 的层级解析关系。之后我们会将 sourceScope 作为参数传入 SelectAnalyzer 中,解析一条 query 语句的其他部分(project、filter、agg、sort 等部分)。
@Override
public Scope visitSelect(SelectRelation selectRelation, Scope scope) {
Relation resolvedRelation = resolveTableRef(selectRelation.getRelation(), scope, aliasSet);
selectRelation.setRelation(resolvedRelation);
Scope sourceScope = process(resolvedRelation, scope);
sourceScope.setParent(scope);
SelectAnalyzer selectAnalyzer = new SelectAnalyzer(session);
selectAnalyzer.analyze(
analyzeState,
selectRelation.getSelectList(),
selectRelation.getRelation(),
sourceScope,
selectRelation.getGroupByClause(),
selectRelation.getHavingClause(),
selectRelation.getWhereClause(),
selectRelation.getOrderBy(),
selectRelation.getLimit());
...
}
CTE 处理
在 QueryAnalyzer 的解析代码中,可以看到会优先解析属于本 QueryRelation 的 CTE 信息,然后再解析相应的 Relation 信息。在解析时,会将 CTE 的 Scope 代入其中,作为 parent Scope。代码如下:
@Override
public Scope visitQueryStatement(QueryStatement node, Scope parent) {
//...
return visitQueryRelation(node.getQueryRelation(), parent);
}
@Override
public Scope visitQueryRelation(QueryRelation node, Scope parent) {
Scope scope = analyzeCTE(node, parent);
return process(node, scope);
}
SelectAnalyzer
Query SQL 语句首先会将 selectRelation 中被解析出的 from 表,转化为与其对应的 Relation。然后将sourceScope 作为参数传递给 SelectAnalyzer,用于解析 SQL 中的其他部分。顺便补充一下,在 SelectAnalyzer 中,主要进行单表的名称检查,SQL 合法性的校验,以及 select * 的表达式拓展。
ExpressionAnalyzer
了解了 Statement 体系下的 Analyzer,我们再来了解下负责 Expression 相关结构的语义解析器。
与负责 Statement 体系语义解析的 StatementAnalyzer 相对应,Expression 数据结构下也存在相应的语义解析器,也就是对表达式解析的 ExpressionAnalyzer。对于表达式的解析,主要功能是对类型的确认、校验,以及查找 Function 的执行函数签名。
ExpressionAnalyzer 同样使用了 Visitor 设计模式,不同的表达式类型会由不同的 visit 函数处理。这里我们挑选两个相对复杂的表达式类型,来介绍下 ExpressionAnalyzer 的功能。
SlotRef
SlotRef 对应于 SQL 中的一列,一般作为表达式树中的叶子节点出现。如下代码,主要介绍 SlotRef 的主要语义分析流程。对 Column 引用的 SlotRef 表达式,会首先在 Scope 中解析,查看该 Scope 是否包含 SlotRef 所引用的列名。然后根据解析后的列名,对这个 SlotRef 的类型进行确定与赋值。
@Override
public Void visitSlot(SlotRef node, Scope scope) {
ResolvedField resolvedField = scope.resolveField(node);
node.setType(resolvedField.getField().getType());
node.setTblName(resolvedField.getField().getRelationAlias());
handleResolvedField(node, resolvedField);
return null;
}
FunctionCall
下面我们来介绍函数的主要解析流程。visitFunctionCall 会根据 Parser 解析出的表达式 FunctionCallExpr,去 Catalog 中解析是否存在符合用户指定的参数和名称的函数。如果可以查到,则在表达式中补充后续执行所需要的其他信息,比如函数需要的类型;如果和用户输入类型不符,后续表达式优化阶段,则需要添加类型的隐式转换。代码如下:
@Override
public Void visitFunctionCall(FunctionCallExpr node, Scope scope) {
Type[] argumentTypes = node.getChildren().stream().map(Expr::getType).toArray(Type[]::new);
...
fn = Expr.getBuiltinFunction(fnName, argumentTypes, Function.CompareMode.IS_NONSTRICT_SUPERTYPE_OF);
node.setFn(fn);
node.setType(fn.getReturnType());
FunctionAnalyzer.analyze(node);
return null;
}
视图支持
视图作为一个需要固化的查询语句,同样需要语义解析,去判断查询是否符合语义。
视图在 StarRocks 中支持,需要将已解析的 AST 树转化为 sql,并且存储起来,其主要代码逻辑位于ViewDefBuilder 中。ViewDefBuilder 中的一个重要功能就是将 select * 拓展成相应的查询表的所有列,这样才能保证对应表的列发生变化时,视图的语义和逻辑不变。
小结
本文主要介绍了 StarRocks 的 Analyzer 模块的主要功能,以及代码框架的结构,对细节感兴趣的朋友可以翻阅 StarRocks 代码包 com.starrocks.sql.analyzer 下的 analyzer 相关代码。
本期 StarRocks 源码解析到这就结束了,好学的你肯定学会了一些新东西,又产生了一些新困惑,不妨留言评论或者加入我们的社区一起交流(StarRocks 小助手微信号)。下一篇 StarRocks 源码解析,我们将为你带来 StarRocks 优化器代码导读源码解析。