暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

Apache Calcite SQL解析及语法扩展

大数据见闻 2022-07-03
3875

本文是Apache Calcite原理与实践系列的第二篇, 将会详细介绍Calcite的SQL解析器的实现原理. 最后讲述如何通过扩展Calcite的SQL解析器来实现自定义SQL语法的解析, 比如解析Flink中的CREATE TABLE (...) WITH (...)语法等.

如果读者对Calcite不甚了解, 建议先阅读本系列的第一篇文章, 可以对Calcite的功能和处理流程有一个整体的把握.

Calcite SQL解析

SQL解析器构建

在第一篇文章中已经说到, Calcite的处理流程类似于编译器的处理流程, 第一步就是对SQL字符串进行词法和语法分析, 将其转化为AST. 在现代化的编译器构建中, 一般会借助解析器生成器工具(如Yacc, Anltr, JavaCC等)来自动生成解析器实现词法和语法分析并构建AST. Calcite的SQL解析器同样是基于JavaCC实现的, 要使用JavaCC生成SQL解析器就要提供一个描述SQL词法和语法的Parser.jj
文件. 我们当然可以手动编写该文件, 不过Calcite为了方便用户对SQL解析器进行扩展, 使用了FMPP来生成Parser.jj
. 这样用就只需要在相关的配置文件中更改或添加新的SQL语法, FMPP就会为我们生成相应的Parser.jj
文件, 而无需在扩展时复制整个Parser.jj
再进行更改. Calcite解析器的生成流程如下图所示.


对上述流程的具体说明如下:

  • compoundIdentifier.ftl
    parserImpls.ftl
    是扩展文件, 里面可以添加自定义的SQL语法规则, config.fmpp
    是FMPP的配置文件, 指定需要包含哪些扩展文件.
  • 模板Parser.jj
    是一个模板文件, 里面引用了compoundIdentifier.ftl
    parserImpls.ftl
    中的内容, 注意模板Parser.jj
    并不能直接输入JavaCC.
  • 上述文件输入FMPP后, 会组合生成一个可用的Parser.jj
    文件, 这就是Calcite的SQL解析器语法规则文件, 里面包含预定义的SQL语法规则, 也包含用户新增的规则.
  • Parser.jj
    文件输入JavaCC后就会生成一个继承自SqlAbstractParserImpl
    SqlParserImpl
    类, 它就是Calcite中真正负责解析SQL语句并生成SqlNode
    树的类. 当然解析器的类名是可以自定义的.

上述文件都可以在Calcite core模块的codegen文件夹下找到. 以下是其目录结构, 其中default_config.fmpp
是一个默认的config.fmpp
文件, 可以仿照其中的格式新增相关内容. 关于这些文件的具体内容在后文SQL语法扩展部分还会进一步讲解, 现在只需要知道这些文件都是用来生成Parser.jj
文件的, 之所以要使用FMPP是为了方便用户扩展.

codegen
├── config.fmpp
├── default_config.fmpp
├── includes
│   ├── compoundIdentifier.ftl
│   └── parserImpls.ftl
└── templates
└── Parser.jj # 模板Parser.jj

SQL解析树相关概念

在Calcite中, 把SQL解析后的结果称为解析树(Parse tree), 实际上就是我们之前说过的SqlNode
树. SqlNode
是解析树中节点的抽象基类, 不同类型的节点有不同的实现类. 为了更好地理解解析树的结构, 这里先介绍一下SqlNode
的相关实现.

SqlNode
子类如下图所示.

CREATE TABLE t (
  ca INT,
  cb DOUBLE,
  cc VARCHAR
);

SELECT ca, cb, cc FROM t WHERE ca = 10;

为了有更直观的感受, 我们配合以上SQL语句来讲解SqlNode
各个子类所代表的含义.

  • SqlIdentifier
    代表标识符, 上述SELECT
    语句中ca
    , cb
    , cc
    以及t
    在解析树中都是一个SqlIdentifier
    实例.
  • SqlLiteral
    代表常量, 上述SELECT
    语句中10
    在解析树中就是一个SqlLiteral
    实例, 它的具体实现类是SqlNumericLiteral
    , 表示数字常量.
  • SqlNodeList
    表示SqlNode
    列表, 上述SELECT
    语句中ca
    , cb
    , cc
    会共同组成一个SqlNodeList
    实例.
  • SqlCall
    是对SqlOperator
    的调用. (SqlOperator
    可以用来描述任何语法结构, 所以实际上SQL解析树中的每个非叶节点都是某种SqlCall
    ). 上述整个SELECT
    语句就是一个SqlCall
    实例, 它的具体实现类是SqlSelect
    .
  • SqlDataTypeSpec
    表示解析树中的SQL数据类型, 上述CREATE
    语句中的INT
    , DOUBLE
    , VARCHAR
    在解析树中都是一个SqlDataTypeSpec
    实例.
  • SqlIntervalQualifier
    代表时间间隔限定符, 比如SQL中的INTERVAL '1:23:45.678' HOUR TO SECOND
    在解析树中就是一个SqlIntervalQualifier
    实例.
  • SqlDynamicParam
    表示SQL语句中的动态参数标记.

SqlNode
的子类中, SqlLiteral
SqlCall
有各自的实现类. 我们先分析简单的SqlLiteral
及其实现类, 它的类继承结构如下图所示. 其实每种实现类就代表了一种特定的常量类型, 比如字符串, 数字, 时间, 时间间隔. 根据类名即可望文生义, 这里不再过多介绍.

由于SqlCall
的实现类较多, 这里我们仅选择部分有代表性的实现类进行详细介绍.

  • SqlSelect
    表示整个SELECT
    语句的解析结果, 内部有from
    , where
    , group by
    等成员变量保存对应字句内的解析结果.
  • SqlOrderBy
    表示带有ORDER BY
    SELECT
    语句的解析结果.
  • SqlInsert
    SqlDelete
    分别代表INSERT
    DELETE
    语句的解析结果.
  • SqlJoin
    表示JOIN
    子句的解析结果.
  • SqlBasicCall
    表示一个基本的计算单元, 持有操作符和操作数, 如WHERE
    子句中的一个谓词表达式就会被解析为SqlBasicCall
    .
  • SqlDdl
    是DDL语句解析结果的基类. 以CREATE TABLE
    语句为例, 它就会被解析成SqlCreateTable
    实例.
image.png

上文说到SqlCall
其实是对SqlOperator
的调用, 因此我们有必要进一步看一下SqlOperator
的实现. SqlOperator
其实可以表达SQL语句中的任意运算, 它包括函数, 操作符(如=
)和语法结构(如case
语句). SqlOperator
可以表示查询级表达式(如SqlSelectOperator
或行级表达式(如SqlBinaryOperator
). 由于SqlOperator
的实现类较多, 这里我们同样仅挑选几个有代表性的类进行说明.

  • SqlFunction
    表示SQL语句中的函数调用, 如SqlCastFunction
    表示cast
    函数, 在解析阶段所有自定义函数都会被表示为SqlUnresolvedFunction
    , 在验证阶段才会转化为对应的SqlUserDefinedFunction
    .
  • SqlSelectOperator
    表示整个SELECT
    查询语句.
  • SqlBinaryOperator
    表示二元运算, 如WHERE
    子句中的=
    运算.
image.png

SQL解析流程

有了上一节的介绍, 相信读者对SQL解析树的组成结构已经有了了解, 接下来我们再来讲述Calcite是如何解析SQL字符串, 并将其组成为解析树的.

在上一篇文章中我们是使用SqlParser
作为入口来解析SQL语句的, 只不过当时我们使用了默认的配置, 实际上等同于以下代码.

SqlParser.Config config = SqlParser.config()
    .withParserFactory(SqlParserImpl.FACTORY);
SqlParser parser = SqlParser.create(sql, config);
SqlNode sqlNode = parser.parseStmt();

SqlParserImpl.FACTORY
静态成员变量是定义在Paser.jj
中的, 因此会生成到SqlParserImpl
类中. 它的定义如下, 调用其getParser
函数就会得到一个SqlParserImpl
实例.

public static final SqlParserImplFactory FACTORY = new SqlParserImplFactory() {
    public SqlAbstractParserImpl getParser(Reader reader) {
        final SqlParserImpl parser = new SqlParserImpl(reader);
        if (reader instanceof SourceStringReader) {
            final String sql =
                ((SourceStringReader) reader).getSourceString();
            parser.setOriginalSql(sql);
        }
        return parser;
    }
};

SqlParserImpl.FACTORY
SqlParser.create
中会被用到, SqlParser
中的相关代码如下. 可以看到, SqlParser
中实际包含了一个SqlParserImpl
, 当我们调用SqlParser.parseStmt
解析SQL语句时, 内部其实会调用SqlParserImpl.parseSqlStmtEof
, 这个函数是定义在Parser.jj
中的.

public static SqlParser create(String sql, Config config) {
    return create(new SourceStringReader(sql), config);
}

public static SqlParser create(Reader reader, Config config) {
    SqlAbstractParserImpl parser = config.parserFactory().getParser(reader);
    return new SqlParser(parser, config);
}

public SqlNode parseStmt() throws SqlParseException {
    return parseQuery();
}

public SqlNode parseQuery() throws SqlParseException {
    try {
        return parser.parseSqlStmtEof();
    } catch (Throwable ex) {
        throw handleException(ex);
    }
}

现在我们终于来到Parser.jj
中了, 由于SqlParserImpl
是由Parser.jj
自动生成的, 比较难阅读, 又因为两者间的函数其实是一一对应的, 所以我们这里主要分析Parser.jj
中的代码. 只不过Parser.jj
中的函数是用扩展的巴科斯范式(EBNF)以及JavaCC的action描述的, 如果对相关内容不熟悉建议先阅读笔者之前的博文编译原理实践 - JavaCC解析表达式并生成抽象语法树, 以快速了解Parser.jj
的相关语法.

parseSqlStmtEof
函数的调用链是比较长的, 其到SELECT
语句解析的调用链如下. 这里我们只具体讲述调用链中的两个重要函数SqlStmt()
SqlSelect()
.

parseSqlStmtEof()
SqlStmtEof()
SqlStmt() // 解析各类语句的总入口, 如INSERT, DELETE, UPDATE, SELECT等
OrderedQueryOrExpr(ExprContext exprContext)
QueryOrExpr(ExprContext exprContext)
LeafQueryOrExpr(ExprContext exprContext)
LeafQuery(ExprContext exprContext)
SqlSelect() // 真正开始解析SELECT语句
SqlSelectKeywords(List<SqlLiteral> keywords)
FromClause()
WhereOpt()
HavingOpt()
WindowOpt()

SqlStmt()
的定义如下, 可以看到这是一个解析各类SQL语句的总入口, |
表示或. 由于查询语句相对复杂, 会在OrderedQueryOrExpr
实现.

SqlNode SqlStmt() :  // 会生成SqlParserImpl中的SqlStmt()函数
{
    SqlNode stmt; // Java代码, 定义临时变量
}
{
    (
        stmt = SqlSetOption(Span.of(), null)
|  stmt = SqlAlter()
|  stmt = OrderedQueryOrExpr(ExprContext.ACCEPT_QUERY)
|  stmt = SqlExplain()
|  stmt = SqlDescribe()
|  stmt = SqlInsert()
|  stmt = SqlDelete()
|  stmt = SqlUpdate()
|  stmt = SqlMerge()
|  stmt = SqlProcedureCall()
    )
    { return stmt; } // Java代码, 返回解析结果
}

SELECT
语句最终会通过SqlSelect()
来解析, 其详细代码如下, 即使不了解JavaCC的EBNF语法, 只要了解正则表达式, 详细配合注释也能大致理解以下代码.

SqlSelect SqlSelect() :
{
    final List<SqlLiteral> keywords = new ArrayList<SqlLiteral>();
    final SqlNodeList keywordList;
    List<SqlNode> selectList;
    final SqlNode fromClause;
    final SqlNode where;
    final SqlNodeList groupBy;
    final SqlNode having;
    final SqlNodeList windowDecls;
    final List<SqlNode> hints = new ArrayList<SqlNode>();
    final Span s;
}
{
    <SELECT> { s = span(); }
    [ <HINT_BEG> CommaSeparatedSqlHints(hints) <COMMENT_END> ]
    SqlSelectKeywords(keywords)  
    (
        <STREAM> { keywords.add(SqlSelectKeyword.STREAM.symbol(getPos())); }
    )?
    (
        <DISTINCT> { keywords.add(SqlSelectKeyword.DISTINCT.symbol(getPos())); }
    |   <ALL> { keywords.add(SqlSelectKeyword.ALL.symbol(getPos())); }
    )?
    { keywordList = new SqlNodeList(keywords, s.addAll(keywords).pos()); }
    selectList = SelectList() // 解析SELECT后的列
    (
        <FROM> fromClause = FromClause() // 解析FROM子句
        where = WhereOpt()     // 解析WHERE子句
        groupBy = GroupByOpt()    // 解析GROUP BY子句
        having = HavingOpt()    // 解析HAVING子句
        windowDecls = WindowOpt()
    |
        E() {
            fromClause = null;
            where = null;
            groupBy = null;
            having = null;
            windowDecls = null;
        }
    )
    {
        return new SqlSelect(s.end(this), keywordList,
            new SqlNodeList(selectList, Span.of(selectList).pos()),
            fromClause, where, groupBy, having, windowDecls, nullnullnull,
            new SqlNodeList(hints, getPos()));
    }
}

Calcite的Parser.jj
文件内容是比较多的, 默认实现下总共有八千多行, 不过也没有必要阅读所有的代码, 只要在需要时通过调用链路阅读关键代码即可.

Calcite SQL语法扩展

上文介绍了Calcite SQL解析器的实现原理, 并具体介绍了SELECT
语句是如何解析的. 尽管Calcite已经提供了SQL语言的一个超集, 但是底层系统丰富多样, 实践中我们仍可能需要扩展一些自定义的SQL语法来支持特定功能. 比如Flink和Spark在使用SQL创建表时, 需要一些额外信息用于指定数据源的类型, 位置和格式. 本文以Flink的CREATE TABLE (...) WITH (...)语法为例, 介绍如何扩展Calcite的SQL解析器.

上文已经介绍过Calcite的解析器是如何构建的, 在扩展时我们也需要准备相应的文件. 一般来说, 我们会使用与Calcite类似的目录组织, 在codegen文件夹下放置相关的扩展文件, 目录结构如下所示. 这里的目录结构借鉴自Flink, 增加了Parser.tdd
文件, 用于简化config.fmpp
的编写. 这里我们不需要复制Calcite的模板Parser.jj
文件, 因为该文件不需要修改, 在编译时可以从Calcite的JAR包中自动提取.

codegen
├── config.fmpp
├── data
│   └── Parser.tdd
└── includes
├── compoundIdentifier.ftl
└── parserImpls.ftl

下面我们来具体介绍一下各个文件中的内容. config.fmpp
文件中的内容如下, 通过引入Parser.tdd
文件, 我们可以把data
部分的内容转移到Parser.tdd
中, 从而使config.fmpp
文件更加简洁.

data: {
parser: tdd(../data/Parser.tdd)
}

freemarkerLinks: {
includes: includes/
}

这里需要注意的一点是, Calcite的core模块并未提供DDL语法的解析, 这部分是在server模块中扩展的, 当我们需要扩展DDL语法时最简单的做法是将server模块中的实现先复制过来, 再进行更改. 操作步骤如下:

  1. 将Calcite server模块中parserImpls.ftl文件中的内容复制到我们自己的parserImpls.ftl
    文件中.
  2. 将Calcite server模块中config.fmpp文件中data
    部分的内容复制到我们自己的Parser.tdd
    文件中.

经过上述操作之后, 其实我们就可以编译生成可以解析DDL的解析器了, 当然我们需要在pom.xml
文件中引入一些插件并做一些配置, 来自动生成解析器, 详细配置可参考这里. 编译完成之后我们可以通过如下代码解析DDL, 注意这里使用的是SqlDdlParserImpl.FACTORY
而不再是SqlParserImpl.FACTORY
.

SqlParser.Config config = SqlParser.config()
.withParserFactory(SqlDdlParserImpl.FACTORY);
SqlParser parser = SqlParser.create(ddl, config);
SqlNode sqlNode = parser.parseStmt();

经过上述准备, 我们已经可以在自己的工程中生成可以解析DDL语句的解析器了. 为了实现CREATE TABLE (...) WITH (...)
语法, 我们只需要在现有基础上进行一些修改即可.

首先介绍Parser.tdd
文件, 其主要内容如下, 这里面配置了生成的解析器类名, 以及需要引入的新的关键字以及语法规则等.

{
package: "org.apache.calcite.sql.parser.impl", # 解析器的包名, 可自定义
class: "CustomSqlParserImpl", # 解析器的类名, 可自定义

imports: [
"org.apache.calcite.sql.SqlCreate" # 需要导入的Java类
]

keywords: [ # 新增关键字
"IF"
]

nonReservedKeywords: [ ] # 上述keywords中的非保留字

nonReservedKeywordsToAdd: [
"IF"
]

nonReservedKeywordsToRemove: [ ]

statementParserMethods: [ ] # 新增的用于解析SQL语句的方法, 例如SqlShowTables()

literalParserMethods: [ ]

dataTypeParserMethods: [ ]

builtinFunctionCallMethods: [ ]

alterStatementParserMethods: [ ]

createStatementParserMethods: [ # 新增的用于解析CREATE语句的方法
"SqlCreateTable"
]

dropStatementParserMethods: [ # 新增的用于解析DROP语句的方法
"SqlDropTable"
]

binaryOperatorsTokens: [ ]

extraBinaryExpressions: [ ]

implementationFiles: [ # 方法的实现文件
"parserImpls.ftl"
]

joinTypes: [ ]

includePosixOperators: false
includeCompoundIdentifier: true
includeBraces: true
includeAdditionalDeclarations: false
}

真正实现解析CREATE TABLE
语句的是parserImpls.ftl
中的SqlCreateTable
方法, 编译时它会合并到Parser.jj
文件中, 它的默认实现如下. 可以看到默认试下是不支持WITH
选项的.

SqlCreate SqlCreateTable(Span s, boolean replace) :
{
    final boolean ifNotExists;
    final SqlIdentifier id;
    SqlNodeList tableElementList = null;
    SqlNode query = null;
}
{
    <TABLE> ifNotExists = IfNotExistsOpt() id = CompoundIdentifier()
    [ tableElementList = TableElementList() ]
    [ <AS> query = OrderedQueryOrExpr(ExprContext.ACCEPT_QUERY) ]
    {
        return SqlDdlNodes.createTable(s.end(this), replace, ifNotExists, id,
            tableElementList, query);
    }
}

为了支持WITH
选项, 我们对SqlCreateTable
方法做如下修改.

SqlCreate SqlCreateTable(Span s, boolean replace) :
{
    final boolean ifNotExists;
    final SqlIdentifier id;
    SqlNodeList tableElementList = null;
    SqlNodeList propertyList = null;
    SqlNode query = null;
}
{
    <TABLE> ifNotExists = IfNotExistsOpt() id = CompoundIdentifier()
    [ tableElementList = TableElementList() ]
    [ <WITH> propertyList = TableProperties() ] // 用于解析WITH选项
    [ <AS> query = OrderedQueryOrExpr(ExprContext.ACCEPT_QUERY) ]
    {
        return new SqlCreateTable(s.end(this), replace, ifNotExists, id,
            tableElementList, propertyList, query);
    }
}

// 解析WITH选择中的各个键值对
SqlNodeList TableProperties():
{
    SqlNode property;
    final List<SqlNode> proList = new ArrayList<SqlNode>();
    final Span span;
}
{
    <LPAREN> { span = span(); }
    [
        property = TableOption() { proList.add(property); }
        (
            <COMMA> property = TableOption() { proList.add(property); }
        )*
    ]
    <RPAREN>
    {  return new SqlNodeList(proList, span.end(this)); }
}

// 解析键值对
SqlNode TableOption() :
{
    SqlNode key;
    SqlNode value;
    SqlParserPos pos;
}
{
    key = StringLiteral()
    { pos = getPos(); }
    <EQ> value = StringLiteral()
    { return new SqlTableOption(key, value, getPos()); }
}

在上述函数中, 我们引入了一个新的类SqlTableOption
, 这个类是需要我们自己定义的. 另外由于引入了WITH
语句, 我们也需要对SqlCreateTable
进行修改, 在其中增加一个SqlNodeList
类型的成员变量用于保存WITH
语句中的键值对. 核心代码如下.

public class SqlCreateTable extends SqlCreate {
  public final SqlIdentifier name;
  public final SqlNodeList columnList;
  public final SqlNodeList propertyList; // 保存WITH语句中的键值对
  public final SqlNode query;

  public SqlCreateTable(SqlParserPos pos, boolean replace, boolean ifNotExists,
                           SqlIdentifier name, SqlNodeList columnList, SqlNodeList propertyList,
                           SqlNode query)
 
{
    super(OPERATOR, pos, replace, ifNotExists);
    this.name = Objects.requireNonNull(name);
    this.columnList = columnList; // may be null
    this.propertyList = propertyList; // may be null
    this.query = query; // for "CREATE TABLE ... AS query"; may be null
  }
}

到这里为止, 我们就完成了CREATE TABLE (...) WITH (...)
语法的扩展, 完整的代码在这里. 读者可以下载相关代码进行体验, mvn clean package
编译整个项目后, 运行CalciteSQLParser即可. 在CalciteSQLParser
中, 我们使用了扩展后的解析器, 主要传入的工厂类是CustomSqlParserImpl.FACTORY
.

String ddl = "CREATE TABLE aa (id INT) WITH ('connector' = 'file')";

SqlParser.Config config = SqlParser.config()
    .withParserFactory(CustomSqlParserImpl.FACTORY);
SqlParser parser = SqlParser.create(ddl, config);
SqlNode sqlNode = parser.parseStmt();

总结

Calcite的解析器是基于JavaCC构建的, 要真正理解解析器的实现原理, JavaCC相关的知识肯定是越多越好. 如果熟悉类似的解析器生成工具如Antlr等, 相信可以很快掌握JavaCC的语法. SQL解析的过程其实就是编译器前端的工作, 都是为了生成AST, 只不过AST的结构有所区别, 如果对这方面不太了解的可以参考笔者之前的博文编译原理实践 - JavaCC解析表达式并生成抽象语法树, 以表达式为例, 讲述如何通过JavaCC将其解析为AST并计算.

Calcite的强大之处就在于其扩展性, 我们可以通过JavaCC的EBNF语法快速实现自定义语法的解析. 本文的案例给出了如何扩展SQL语法的模板, 读者可以依葫芦画瓢实现自己的SQL语法.

文章转载自大数据见闻,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论