Calcite SQL 解析、语法扩展、元数据验证原理与实战(下)

1,784 阅读5分钟

上一篇文章我们介绍了 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<String, SqlOperator> opMap = ArrayListMultimap.create();
    //所有的函数列表
    private final List<SqlOperator> operators = new ArrayList<>();

    @Override
    public void lookupOperatorOverloads(SqlIdentifier opName, @Nullable SqlFunctionCategory sqlFunctionCategory, SqlSyntax syntax, List<SqlOperator> operatorList, SqlNameMatcher nameMatcher)
{
        //首先查找Calcite原生的函数列表
        stdOperatorTable.lookupOperatorOverloads(opName, sqlFunctionCategory, syntax, operatorList, SqlNameMatchers.withCaseSensitive(false));

        //如果Calcite中没有找到,则在我们自己的函数列表中查找
        if (operatorList.isEmpty() && syntax == SqlSyntax.FUNCTION && opName.isSimple()) {
            List<SqlOperator> ops = opMap.get(opName.getSimple().toUpperCase(Locale.US));
            if (ops != null) {
                operatorList.addAll(ops);
            }
        }
    }

    @Override
    public List<SqlOperator> getOperatorList()
{
        return operators;
    }
}

第二步向 ListMultimap<String, SqlOperator> opMap 中注册函数。新建 DemoSqlOperatorImpl 类,继承 org.apache.calcite.sql.SqlFunction ,作为自定义函数的实现类。

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<SqlTypeFamily> 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 层、元数据和多源异构引擎集成等相关工作。