上一篇文章我们介绍了 Calcite SQL 解析的原理以及如何扩展 SQL 语法,本篇我们将进入 SQL 执行的下一个阶段:元数据验证。
二、Calcite 元数据验证
SQL 成功解析为抽象语法树后,接下来需要对整条 SQL 的语义和元数据进行验证,判断 SQL 是否满足下一步计算的要求。
判断一句 SQL 是否正确至少包含以下几点:
- 查询的库表列是否存在。
- SQL 的语义是否正确。
- 验证操作符,判断使用的操作符或者函数是否存在,并被正确地使用,例如数据类型、参数个数等是否正确。
我们可以用以下 SQL 为例来探究 Sql 验证的过程。
SELECT col1, avg(col2)
FROM t1,
(SELECT col3 FROM t2) AS s1
WHERE col4 > col5
GROUP BY col1;
在 Calcite 中验证 SQL 是否合法使用 SqlValidator,它的初始化方式如下:
protected SqlValidatorImpl(
SqlOperatorTable opTab,
SqlValidatorCatalogReader catalogReader,
RelDataTypeFactory typeFactory,
Config config)
SqlOperatorTable 提供 SQL 验证所需要的所有操作符,函数、'>'、'<' 等都属于操作符;SqlValidatorCatalogReader 提供验证需要的元数据信息,包括 Catalog、Schema等;RelDataTypeFactory 提供验证过程中字段类型的相互转换、例如函数返回值类型和精度信息等。Config 可以自定义一些配置,例如是否开启类型强制转换、是否开启 SQL 重写等等。
2.1 验证库表列是否存在
库和表的验证比较好理解,由于大多数的 OLAP 引擎有多个数据源和数据空间,并且支持联邦查询,所以 SQL 中库表往往都是全名称形式存在(或者使用 use 语句指定),形如 catalog.schema.table(例如 hive.tpcds.store),这样一来,我们就能确定唯一一张表的位置。此外,在 Calcite 验证器中有两个重要的抽象,作用域(Scope)和命名空间(Namespace)。
- 作用域
Scope 用来表示一个字段或者一个表达式的作用域,简单理解就是验证时知道 select 的字段可能出现在哪张表中,这样便能校验这个字段是否存在。以开头的 SQL 语句为例,表达式的作用域如下:
col1, col2 可能出现在 t1, s1
col3 只能出现在 t2 中
col4, col5 可能出现在 t1, s1
当解析验证一个表达式的时候,如果验证通过,就会将表达式包装成一个 Namespace 返回。在验证 SELECT * FROM hive.tpcds.store 表达式时,会解析和重组 hive.tpcds.store 的每级 schema,通过 CatalogReader 的 getTable() 方法获取元数据信息,判断表和字段是否存在。第一步,Calcite 会验证 RootSchema,也就是 Catalog 是否存在,然后依次向上拼接 schema,验证 hive.tpcds 是否存在,最后验证 hive.tpcds.store。如果全部验证通过返回一个SqlValidatorNamespace 。
- 命名空间
Namespace 通常用来表示一个表、视图或者子查询,Namespace 继承关系如下:
Calcite 会将 from 后面的表、视图或者子查询封装成 Namespace,除此以外在继承关系中也可以看到 JoinNamespace, SchemaNamespace 这样更细分的 Namespace,Calcite 会将这些关系也封装成 Namespace。
以开头的 SQL 语句为例,会将他们封装成5个 Namespace 存在 map 中,分别是:
electNamespace: SELECT col1, avg(col2) FROM t1, (SELECT col3 FROM t2) AS s1 WHERE col4 > col5 GROUP BY col1
SelectNamespace: SELECT col3 FROM t2
IdentifierNamespace: t1
IdentifierNamespace: t2
JoinNamespace: t1 join s1
在最终的验证阶段我们调用Namespace 的 validateImpl() 方法进行验证。整个验证过程的关键步骤时序图如下:
2.2 SQL 语义的验证
SQL 语义验证包括查询的字段名是否存在歧义、和聚合语句是否正确等等。
在检查查询的字段名是否存在歧义时,会将这些字段名进行补全,拼接其库名和表名,来验证这些语句是否存在二义性,如果 col1 即在 t1 中存在也在 t2 中存在,我们查询时就必须在语句中指定 col1 的库表,否则语句是存在歧义的。Calcite 会在 org.apache.calcite.sql.validate.SqlValidatorScope#fullyQualify 方法中进行字段名称补全。
Calcite 还会检查语法的正确性,例如与聚合函数一起查询的字段是否包含在 group by 中 ,比如 SQL 语句:select c1, avg(c2) from t1; 那么 c1 必须在 group by 中。
这些验证逻辑就隐含在每个 Scope 对象中,上述例子就会在 AggregatingSelectScope 中做检查,所以 Scope 对象是描述了字段的作用域信息。
2.3 验证操作符
Calcite 中操作符既包含"<", ">"这些操作符,也包含函数,在初始化 SqlValidator 的时候会传入SqlOperatorTable ,这个类的作用就是提供操作符列表和按名称查找操作符的能力。
Calcite 也提供了默认实现:SqlStdOperatorTable.instance() ,继承 SqlOperatorTable 接口,其中两个核心方法 lookupOperatorOverloads() : 检索具有给定名称和语法的操作符列表,例如传入"sum"和"SqlSyntax.Function",则返回名称为"sum"的函数列表。getOperatorList() :检索所有函数和运算符的列表。
在 Calcite 验证函数时,就会调用 lookupOperatorOverloads() 方法获取函数的实现。
- 扩展函数
那我们如何新增一个 Calcite 中没有的函数呢?
首先我们可以继承 SqlOperatorTable 接口,实现自己的 lookupOperatorOverloads() 方法,其次,在初始化 SqlValidator 的时候传入我们自己的 SqlOperatorTable 即可,下面通过新增一个 Presto 中的函数"SUBSTR"来演示如何在 Calcite 中新增函数。以下为继承 SqlOperatorTable ,实现 lookupOperatorOverloads() 的方法:
public class DemoOperatorTable
implements SqlOperatorTable
{
//Calcite 默认的操作符表
private static final SqlOperatorTable stdOperatorTable = SqlStdOperatorTable.instance();
//用于存储自定义的函数名和实现的映射
private final ListMultimap opMap = ArrayListMultimap.create();
//所有的函数列表
private final List operators = new ArrayList<>();
@Override
public void lookupOperatorOverloads(SqlIdentifier opName, @Nullable SqlFunctionCategory sqlFunctionCategory, SqlSyntax syntax, List operatorList, SqlNameMatcher nameMatcher)
{
//首先查找Calcite原生的函数列表
stdOperatorTable.lookupOperatorOverloads(opName, sqlFunctionCategory, syntax, operatorList, SqlNameMatchers.withCaseSensitive(false));
//如果Calcite中没有找到,则在我们自己的函数列表中查找
if (operatorList.isEmpty() && syntax == SqlSyntax.FUNCTION && opName.isSimple()) {
List ops = opMap.get(opName.getSimple().toUpperCase(Locale.US));
if (ops != null) {
operatorList.addAll(ops);
}
}
}
@Override
public List getOperatorList()
{
return operators;
}
}
第二步向 ListMultimap
public class DemoSqlOperatorImpl
extends SqlFunction
{
private final boolean isDeterministic;
private final boolean isDynamic;
private final SqlSyntax syntax;
public DemoSqlOperatorImpl(String name,
boolean isDeterministic,
boolean isDynamic,
SqlReturnTypeInference sqlReturnTypeInference,
SqlSyntax syntax,
SqlOperandTypeChecker operandTypeChecker)
{
super(name,
new SqlIdentifier(name, SqlParserPos.ZERO),
SqlKind.OTHER_FUNCTION,
sqlReturnTypeInference,
null,
operandTypeChecker,
SqlFunctionCategory.USER_DEFINED_FUNCTION);
this.isDeterministic = isDeterministic;
this.isDynamic = isDynamic;
this.syntax = syntax;
}
@Override
public SqlSyntax getSyntax()
{
return syntax;
}
@Override
public boolean isDeterministic()
{
return isDeterministic;
}
@Override
public boolean isDynamicFunction()
{
return isDynamic;
}
}
初始化函数名、函数入参和返回值等基本信息,new 出 org.apache.calcite.sql.SqlFunction ,添加到"opMap"中。
public void registerFunction()
{
String functionName = "SUBSTR";
boolean isDeterministic = true;
boolean isDynamic = false;
//生成函数的入参
//Presto SUBSTR 函数有多种实现,这里只注册了 substr(string, start) → varchar 这种实现
List argumentTypes = ImmutableList.of(SqlTypeFamily.CHARACTER, SqlTypeFamily.NUMERIC);
//用于函数入参匹配和检查
SqlOperandTypeChecker checker = OperandTypes.family(argumentTypes);、
//函数返回值
SqlReturnTypeInference returnTypeInference = ReturnTypes.CHAR;
org.apache.calcite.sql.SqlFunction sqlFunction = new DemoSqlOperatorImpl(functionName,
isDeterministic,
isDynamic,
returnTypeInference,
SqlSyntax.FUNCTION,
checker);
opMap.put(functionName, sqlFunction);
operators.add(sqlFunction);
}
至此验证阶段的函数扩展就完成了,可以通过简单的验证:
String sql = "select substr('abcdefg', 2)";
SqlParser parser = SqlParser.create(sql, SqlParser.config());
SqlNode sqlNode = parser.parseQuery();
SqlValidator sqlValidator = SqlValidatorUtil.newValidator(demoOperatorTable, catalogReader, javaTypeFactoryImpl, SqlValidator.Config.DEFAULT);
sqlValidator.validate(sqlNode);
整个函数注册和校验流程大致如下图:
细节部分可以跟踪 Calcite 源码:org.apache.calcite.sql.validate.SqlValidator#validate。
上面例子只是非常简单的函数验证过程的扩展,在实际的生产环境中还会面临以下问题:
- 自定义的函数如何执行?如果是个常量如何进行常量折叠?
- 如果函数的入参类型 Calcite 不支持,如何在 Calcite 中自定义数据类型?
这些问题解决起来会更加复杂,并且还会对Calcite 核心的关系代数改写阶段产生一定影响。
总结
Calcite 是一个庞大且复杂的数据管理框架,SqlParser 和 SqlValidator 只是其中一小部分,并不是“重头戏”,Calcite 在此着墨不多,留下了许多扩展的途径。上文通过两个 demo 简单介绍了 SqlParser 和 SqlValidator 的原理与扩展。这部分绝大情况下只在 Calcite 的基础上进行扩展即可,不需要修改源码,对 Calcite 本身的影响较小。
后续我们还会介绍更多 Calcite 原理技术与实战,钱可以带来快乐,玩技术也可以!如果你对Calcite 原理技术、数据虚拟化、湖仓平台、数据库、SQL 优化器等相关技术感兴趣的话,欢迎关注“Aloudata技术团队”公众号。
✎ 本文作者/ 淳译,Aloudata OLAP 引擎开发工程师,参与 Aloudata AIR Engine 的多个核心模块开发,目前负责 Aloudata 数据虚拟化引擎的 SQL 层、元数据和多源异构引擎集成等相关工作。