写点什么

Apache Calcite SQL 验证

  • 2022 年 5 月 12 日
  • 本文字数:5377 字

    阅读完需:约 18 分钟

原文链接: Apache Calcite SQL验证, 查看本文最近更新及作者最新文章请点击原文链接.

本文已收录在专栏Apche Calcite原理与实践中.


本文是 Apache Calcite 原理与实践系列的第三篇, 上一篇文章介绍了 Calcite 解析器的实现原理, 本文将介绍如何对解析器输出的 SQL 解析树进行语义分析, 如表名, 字段名, 函数名和数据类型的检查. 相对于解析器, SQL 验证部分的内容扩展需求较少, 所以本文重点介绍 Calcite 中 Schema 相关的接口(用于提供元数据), 以及 SQL 验证相关的概念, 最后以SELECT语句为例, 介绍验证过程中的重要步骤.

Calcite 元数据接口

SQL 解析阶段只要 SQL 语句中没有语法错误便可解析成功, 而不会关注 SQL 语句的具体语义是否正确, 比如表是否存在, 字段是否存在等. SQL 验证阶段就会检查 SQL 语句的语义是否正确, 这就需要依赖于外部提供的元数据信息. 为此, Calcite 提供了一系列用于提供元数据信息的接口. 这里我们将第一篇文章中与 SQL 验证相关的部分复制过来, 以便对照讲述.

// 创建Schema, 一个Schema中包含多个表. Calcite中的Schema类似于RDBMS中的DatabaseSimpleTable userTable = SimpleTable.newBuilder("users")    .addField("id", SqlTypeName.VARCHAR)    .addField("name", SqlTypeName.VARCHAR)    .addField("age", SqlTypeName.INTEGER)    .withFilePath("/path/to/user.csv")    .withRowCount(10)    .build();SimpleTable orderTable = SimpleTable.newBuilder("orders")    .addField("id", SqlTypeName.VARCHAR)    .addField("user_id", SqlTypeName.VARCHAR)    .addField("goods", SqlTypeName.VARCHAR)    .addField("price", SqlTypeName.DECIMAL)    .withFilePath("/path/to/order.csv")    .withRowCount(10)    .build();SimpleSchema schema = SimpleSchema.newBuilder("s")    .addTable(userTable)    .addTable(orderTable)    .build();    CalciteSchema rootSchema = CalciteSchema.createRootSchema(false, false);rootSchema.add(schema.getSchemaName(), schema);
RelDataTypeFactory typeFactory = new JavaTypeFactoryImpl();
// 创建CatalogReader, 用于指示如何读取Schema信息Prepare.CatalogReader catalogReader = new CalciteCatalogReader( rootSchema, Collections.singletonList(schema.getSchemaName()), typeFactory, config);// 创建SqlValidator, 用于执行SQL验证SqlValidator.Config validatorConfig = SqlValidator.Config.DEFAULT .withLenientOperatorLookup(config.lenientOperatorLookup()) .withSqlConformance(config.conformance()) .withDefaultNullCollation(config.defaultNullCollation()) .withIdentifierExpansion(true);SqlValidator validator = SqlValidatorUtil.newValidator( SqlStdOperatorTable.instance(), catalogReader, typeFactory, validatorConfig);// 执行SQL验证SqlNode validateSqlNode = validator.validate(node);
复制代码


从上述代码中可以看到, 有三个与元数据信息有关的重要接口: Schema(SimpleSchema继承自Schema), CalciteSchemaCatalogReader. 下面我们将分别介绍.


Schema是用于描述数据库结构(如包含哪些表, 函数等)的一个接口. 在关系型数据库管理系统中通常有 Catalog, Schema, Table 这样的层级结构来管理表和数据库, 不过并非每中 RDBMS 都完全这么实现, 比如 MySQL 不支持 Catalog, 并且用 Database 来替代 Schema 的位置. Calcite 的Schema接口可以表示 Catalog 或 Schema, 因为Schema接口支持嵌套, 这样就可以用来表示 Schema 嵌套在 Catalog 里这种层级结构. Schema接口的实现类如下图所示.


这里介绍几个重要的实现类:

  • SchemaPlus接口是Schema接口的扩展, 增加了一些新的方法, 比如添加表. SchemaPlus不应该由用户创建.

  • AbstractSchema是一个默认实现, 我们代码中的SimpleSchema就继承自该类.

  • DelegatingSchema是一个简单的代理类, 将所有的操作交给内部的一个Schema实现.


CalciteSchema接口是Schema接口的包装类, 可以嵌套多个Schema实体, 并提供了一些工具方法, 比如plus()方法可以将内部Schema包装为SchemaPlus后返回. 它有两个实现类, 在我们的代码中使用的是SimpleCalciteSchema.


CatalogReader用于读取表的元数据信息, 在绝大多数情况下只需使用其默认实现CalciteCatalogReader即可, 如有特殊需求, 可继承CalciteCatalogReader进行实现. 这里重点介绍一下CalciteCatalogReader构造函数中需要的RelDataTypeFactory. 它是一个数据类型描述符的工厂, 定义了用于实例化和组合 SQL, Java 和集合类型的方法. 在我们代码的SimpleTable.getRowType()函数中会用到.


可以看到 Calcite 中用于提供元数据的接口还是有点绕的, 特别是 Schema 相关的接口. 不过好在在实践中我们通常只需要实现相应的Schema接口, 用于读取特定数据源的元数据信息即可.

Calcite SQL 验证相关概念

在介绍 SQL 验证流程之前, 我们先介绍一下 Calcite 为实现 SQL 验证引入的一些概念.


在通用编程语言中都有作用域的概念, 只能使用当前作用域或父作用域内的函数或变量, 比如 C 语言的函数是一个作用域, 函数内部只能使用函数内定义的局部变量, 或定义在全局作用域内的全局变量或函数, 但是不能使用定义在其他函数内部的局部变量. SQL 语言中同样有作用域, 在 Calcite 中称为 Scope.

SELECT expr1FROM t1,     t2,     (SELECT expr2 FROM t3) AS q3WHERE c1 IN (SELECT expr3 FROM t4)ORDER BY expr4
复制代码


我们以上述 SQL 语句来说明 Calcite 中关于 Scope 的概念. 在查询的各个位置可用的作用域如下:

  • expr1只能看见t1, t2q3, 也就是说expr1只能使用t1, t2, q3中存在的列名.

  • expr2只能看见t3.

  • expr3只能看见t4.

  • expr4只能看见t1, t2, q3, 加上SELECT子句中定义的任何别名.


在 Calcite 中 Scope 由SqlValidateScope表示, 其类继承图如下.


SQL 语句需要从一个源中获取数据, Calcite 将数据源抽象为命名空间 Namespace. Namespace 是一个抽象概念, 它既可以表示一个表, 也可以是视图或子查询. 上述 SQL 语句中有 4 个 Namespace: t1, t2, (SELECT expr2 FROM t3) AS q3(SELECT expr3 FROM t4). Calcite 中使用SqlValidatorNamespace表示 Namespace, 它的类继承图如下.

Calcite SQL 验证流程

在 SQL 语句中, DDL 语句是不需要验证的, DQL 和 DML 语句都需要验证. 由于各类语句的验证流程在细节上存在差别, 这里以最常见的SELECT语句为例, 讲述其验证过程. 整个SELECT语句验证流程中的函数调用链如下.

SqlValidator.validate()    // SQL验证入口  SqlValidatorImpl.validate()    // Calicte提供的SqlValidator默认实现    SqlValidatorImpl.validateScopedExpression()      SqlValidatorImpl.performUnconditionalRewrites()    // 对SQL语句进行重写      SqlValidatorImpl.registerQuery()    // 注册Scope和Namespace      SqlSelect.validate()    // 开始验证SELECT语句        SqlValidatorImpl.validateQuery()          SqlValidatorImpl.validateNamespace()            AbstractNamespace.validate()              SelectNamespace.validateImpl()                SqlValidatorImpl.validateSelect()                  SqlValidatorImpl.validateFrom()                    SqlValidatorImpl.validateQuery()                      SqlValidatorImpl.validateNamespace()                        AbstractNamespace.validate()                          IdentifierNamespace.validateImpl()                            IdentifierNamespace.resolveImpl()                              DelegatingScope.resolveTable()                                EmptyScope.resolveTable()                                  EmptyScope.resolve_()    // 在这个函数中判断表名是否在Schema中                SqlValidatorImpl.validateWhereClause()                SqlValidatorImpl.validateGroupClause()                SqlValidatorImpl.validateHavingClause()                SqlValidatorImpl.validateWindowClause()                SqlValidatorImpl.validateSelectList()
复制代码


在验证流程中有三个主要的函数: SqlValidatorImpl.performUnconditionalRewrites(), SqlValidatorImpl.registerQuery()SqlSelect.validate(), 下文将对他们进行详细的说明.

SQL 重写

SQL 重写用于将解析阶段得到的解析树重写为统一的格式, 方便下一步的处理. 由于SqlValidatorImpl.performUnconditionalRewrites()函数的实现十分复杂, 这里也就不详细分析重写的过程了. 而是通过两个具体的例子来说明重写的效果.


第一个例子是带有ORDER BYSELECT语句, 在解析阶段这类语句会被解析为SqlOrderBy节点, 不过SqlOrderBy是一个纯语法节点, 在重写阶段会转化为SqlSelect节点. 其实我们看一下这两个类的定义就能明白这种转化是如何进行的, 核心代码如下. 对于SELECT ... ORDER BY ...语句, 在解析阶段生成的SqlOrderBy节点中query变量保存了SELECT ...部分的解析结果, 它其实是一个SqlSelect实例, ORDER BY后面的字段列表保存在orderList中. 在重写之后, SqlOrderBy中的orderList被移动到SqlSelectorderBy中.

public class SqlOrderBy extends SqlCall {    public final SqlNode query;    public final SqlNodeList orderList;    ...}
public class SqlSelect extends SqlCall { SqlNodeList keywordList; SqlNodeList selectList; SqlNode from; SqlNode where; SqlNodeList groupBy; SqlNode having; SqlNodeList windowDecls; SqlNodeList orderBy; ...}
复制代码


第二个例子是DELETE语句, 在解析阶段这类语句会被解析为SqlDelete节点, 其关键属性如以下代码所示. 在解析阶段生成的SqlDeletesourceSelectnull, 在重写阶段会生成sourceSelect用于指示如何从表中查询需要删除的记录. 比如DELET FROM users WHERE id > 1对应的sourceSelect就是SELECT * FROM users WHERE id > 1.

public class SqlDelete extends SqlCall {    SqlNode targetTable;  // 目标表    SqlNode condition;    // 过滤条件    SqlSelect sourceSelect;  // 指示如何查询要删除的记录    ...}
复制代码


通过以上例子可以看到重写阶段所作的工作就是对解析树进行一些轻微的调整, 一般情况下这一阶段也不需要任何改动, 只要大概了解其流程即可. 其实如果在解析阶段就生成标准的格式, 就不需要重写了, 只不过这样会让解析器的代码变得冗长, 这应该也是 Calcite 把一些重写工作放到验证阶段的原因.

注册 Scope 和 Namespace

在真正进行验证之前, 还需要调用SqlValidatorImpl.registerQuery()注册 Scope 和 Namespace. 在这一步 Calcite 会先遍历一遍整个 SQL 语句, 为解析树的各个部分生成对应的 Scope 和 Namespace. 解析的结果保存在SqlValidatorImpl的成员变量中, 以下是与解析结果相关的核心代码.

public class SqlValidatorImpl implements SqlValidatorWithHints {    protected final Map<SqlNode, SqlValidatorScope> scopes =         new IdentityHashMap<>();    private final Map<IdPair<SqlSelect, Clause>, SqlValidatorScope> clauseScopes =         new HashMap<>();    protected final Map<SqlNode, SqlValidatorNamespace> namespaces =         new IdentityHashMap<>();    ...}
复制代码

SQL 语句验证

注册完 Scope 和 Namespace 之后就可以调用SqlNode.validate()方法进行验证了, 这里 Calcite 也使用了 Visitor 模式. 如果读者不了解什么是 Visitor 模式, 可以参考笔者之前的博文. 简单来说, Visitor 模式就是把实现逻辑集中到一个类中, 在这里就是SqlValidatorImpl. 我们可以看下SqlSelect.validate()的实现, 它并没有实现具体的逻辑, 而是调用了传入的SqlValidatorvalidateQuery方法, 也就是SqlValidatorImpl.validateQuery. 这样带来的一个好处是, 如果我们需要修改验证逻辑, 只需要对SqlValidatorImpl进行修改, 而不需要修改SqlNode的实现类.

public abstract class SqlNode implements Cloneable {    public abstract void validate(SqlValidator validator, SqlValidatorScope scope);    ...}
public class SqlSelect extends SqlCall { public void validate(SqlValidator validator, SqlValidatorScope scope) { validator.validateQuery(this, scope, validator.getUnknownType()); }}
public interface SqlValidatorNamespace { void validate(RelDataType targetRowType);}
复制代码


SqlValidatorImpl.validateQuerySELECT语句验证的真正入口, 它主要对 Scope 和 Namespace 进行了验证, 整个调用逻辑比较复杂, 存在很多递归调用. 如果读者有兴趣可以根据上述调用链逐步调试, 这里不在详细分析.

总结

本文梳理了 Calcite SQL 验证相关的概念和流程, 由于这部分的代码较为复杂, 存在大量的递归调用, 所以本文也没有详细的分析所有细节. 好在 SQL 验证这部分不太需要进行修改, 多数情况下只需要理解其整体流程即可. 如果要对扩展的 SQL 语法进行验证, 可以建立一个统一的基类, 让扩展的 SQL 语法节点都继承该类, 这样在验证阶段可以对这类节点单独进行验证, 并不一定要修改SqlValidatorImpl类, 从而简化工作.

发布于: 刚刚阅读数: 5
用户头像

一行代码, 点滴生活! 2019.04.24 加入

专注于大数据存储与计算

评论

发布
暂无评论
Apache Calcite SQL验证_数据库_不穿格子衬衫的程序员_InfoQ写作社区