控制台查询编辑器中的多个查询
Rockset Console 的查询编辑器允许用户在集合上键入和运行查询。然而,直到现在,在编辑器中输入的任何内容都作为单个查询运行和解析。这意味着,对于用户来说,在我们的编辑器中切换多个查询并不容易。他们将不得不注释掉他们不想运行的查询,或者将所有查询保存在一个单独的文本文件中,并一次复制他们想要运行的查询。
为了更容易地在多个查询之间切换,我们决定在我们的编辑器中允许多个查询,用分号分隔,一个 SQL 语句终止符。为了实现这一点,我们的编辑器必须了解查询的开始和结束位置。我们天真的最初的方法是用分号(或者分号 + 换行)分割编辑器的整个文本的字符串,并将由此分割产生的每个字符串理解为一个单独的查询。当然,这种幼稚的方法并不能完全证明,因为分号可能存在于字符串中或 SQL 查询中的注释中。我们不想在这样的分号上拆分。
对分号的幼稚拆分还不够好。我们的编辑器必须了解 SQL 注释和字符串,才能知道其中一个分号何时出现在其中,而不是用来表示语句的结束。那时,为了实现对编辑器文本的更透彻的理解,我们决定在前端使用 ANTLR。
ANTLR - SQL 查询的基本理解
ANTLR 是一个强大的语言解析工具。根据指定的语法(一组规则),ANTLR 生成词法分析器和解析器,它们一起可以根据输入(在我们的示例中为 SQL 字符串)构建一棵树,以及一个可以在访问该树时执行逻辑的侦听器。事实上,我们已经在后端使用ANTLR来解析SQL语句,我们从中产生的树是用来理解和执行用户SQL语句的!
语法
让我们从头开始。下载ANTLR后,我们需要制作语法文件。例如,查看Presto 的 SQL ANTLR 语法。Rockset 的后端查询解析语法其实就是这个 Presto 语法的修改版。但是,对于前端的查询拆分,我们最感兴趣的是注释和字符串的语法规则:
STRING : '\'' ( ~'\'' | '\'\'' )* '\'' ; COMMENT : SIMPLE_COMMENT | BRACKETED_COMMENT ; fragment SIMPLE_COMMENT : '--' ~[\r\n]* '\r'? '\n'? ; fragment BRACKETED_COMMENT : '/*' .*? '*/' ;
这里发生的事情的关键是指定哪些字符串字符构成规则的序列,即字符串或注释。这些语法规则完全从我们的后端语法文件中复制而来,因此我们可以确保前端的查询拆分语法以与后端系统完全相同的方式理解注释和字符串。看看我们其余的语法规则:
queriesText
: statement* EOF
;
statement
: ';'* (CHAR | STRING | COMMENT)+ ';'* ; CHAR : ~';' ;
我们可以看到整个查询文本只是被定义为一系列 0 或更多语句,并且这些语句被定义为字符(在我们的例子中,除了分号之外的任何东西)、注释和位于分号之间的字符串的混合。这里的关键是分号可以放在注释和字符串中,并作为语句的一部分。
这是包含上述所有规则的完整语法文件。
建造树
ANTLR 的强大功能是从这个语法生成文件,我们可以用它来做我们的逻辑。
antlr4 -Dlanguage=JavaScript <GrammarFileName>.g4
是用于指定我们的目标语言是 JavaScript 的命令,运行它会生成大量文件供我们在 JavaScript 中使用,包括词法分析器、解析器和侦听器。有关更多详细信息,请参阅此文档。让我们看看我们最终使用所有这些文件编写的函数,以了解它们在做什么。
从'antlr4'导入*作为 antlr4 ; | |
从'./QuerySeparationGrammarLexer'导入{ QuerySeparationGrammarLexer } ; | |
从'./QuerySeparationGrammarParser'导入{ QuerySeparationGrammarParser } ; | |
从'./QuerySeparationGrammarListener'导入{ QuerySeparationGrammarListener } ; | |
从'./CustomListener'导入{ CustomListener } ; | |
导出 const SplitQueries = (输入) => { | |
const chars = 新的 antlr4 。的InputStream (输入); | |
const lexer = new QuerySeparationGrammarLexer (字符); | |
const 令牌 = 新的 antlr4 。CommonTokenStream (词法分析器); | |
const parser = new QuerySeparationGrammarParser ( tokens ) ; | |
解析器。buildParseTrees = true ; | |
const 树 = 解析器。查询文本( ) ; // 我要走的那棵树 | |
常量 结果 = [ ] ; | |
const listener = new CustomListener ( result ) ; // 自定义侦听器建立在空结果数组上 | |
蚂蚁金服4 。树。解析树行者。默认。走(听者, 树); | |
返回 监听器。结果; // 此函数返回开始和停止索引。 | |
} ; |
在这里,我们可以看到词法分析器和解析器帮助我们使用我们的语法从字符串输入构建一棵树。以‘[statement1][statement2][statement3]’
字符串输入为例,其中每一个[statement]
都是字符串中匹配语句的语法规则的一部分,例如'abc/*comment;;*/def;'
. 包含三个语句的输入字符串将被解析为如下所示的树:
树的每个节点都基于输入字符串中出现的语法规则,并且该节点的子节点是该节点包含的组件(基于其他语法规则)。根节点基于 queryText 语法规则,它作为整个字符串存在于输入字符串中。所以根节点代表整个输入字符串。它的孩子是组成这个节点的组件。我们可以从querysText 语法规则中看出,一个querysText 由一系列语句组成。所以在树中,querysText 根节点的子节点是每个语句的节点。同样,每个语句节点的子节点是该语句的组件,即该语句中的特定字符、字符串和注释。
这里要注意的重要一点是,根据我们的语法,输入树中存在的语句被成功解析为树的单独节点。输入字符串中语句的分离是我们最初的目标。
聆听者(走在树上)
现在我们有了一个可行的树,是时候对其进行逻辑处理了。听众帮助我们做到这一点。回到 SplitQueries 函数,我们可以看到我们通过使用 CustomListener 遍历树来“使用”树。
从'antlr4'导入*作为 antlr4 ; | |
从'./QuerySeparationGrammarLexer'导入{ QuerySeparationGrammarLexer } ; | |
从'./QuerySeparationGrammarParser'导入{ QuerySeparationGrammarParser } ; | |
从'./QuerySeparationGrammarListener'导入{ QuerySeparationGrammarListener } ; | |
从'./CustomListener'导入{ CustomListener } ; | |
导出 const SplitQueries = (输入) => { | |
const chars = 新的 antlr4 。的InputStream (输入); | |
const lexer = new QuerySeparationGrammarLexer (字符); | |
const 令牌 = 新的 antlr4 。CommonTokenStream (词法分析器); | |
const parser = new QuerySeparationGrammarParser ( tokens ) ; | |
解析器。buildParseTrees = true ; | |
const 树 = 解析器。查询文本( ) ; // 我要走的那棵树 | |
常量 结果 = [ ] ; | |
const listener = new CustomListener ( result ) ; // 自定义侦听器建立在空结果数组上 | |
蚂蚁金服4 。树。解析树行者。默认。走(听者, 树); | |
返回 监听器。结果; // 此函数返回开始和停止索引。 | |
} ; |
CustomListener 是我们编写的文件,继承自生成的侦听器文件。生成的基本侦听器为位于树中的每个解析节点提供一个enter
和exit
函数。正是这些函数在树的遍历中执行。它是这样工作的:
在树的遍历过程中,进入/退出每个节点会调用该节点对应的进入/退出函数,在我们的例子中是 CustomListener(我们用来遍历树的监听器)的监听器。生成的监听器基类中的进入/退出函数是空的,不做任何事情,所以如果我们想在行走过程中进入或退出节点时执行逻辑,我们需要在CustomListener中覆盖这些函数,而我们的自定义函数将被执行。
我们需要在 CustomListener 中覆盖的唯一函数是exitStatement
函数,该函数在退出树中的语句节点时调用。请记住,由于我们的语法,树中的语句节点准确地代表了我们输入文本中的语句。退出语句节点后,我们希望获取该语句在输入文本字符串中的位置。幸运的是,侦听器函数将ctx
节点的参数作为参数,其中包含很多信息,但对我们特别有用,ctx 包含输入字符串中该节点的开始和停止索引。
因此,该exitStatement
函数的实现变得非常简单:在退出时记录并存储每个语句的开始和停止索引,以便在遍历结束时,我们拥有所有开始和停止索引的集合,告诉我们在哪里每个查询语句都以输入字符串开始和结束。
这是我们的 CustomListener,具有以下exitStatement
功能:
var QuerySeparationGrammarListener = require ( './QuerySeparationGrammarListener' ) 。QuerySeparationGrammarListener ; | |
var CustomListener = 函数(结果) { | |
这个。结果 = 结果; | |
QuerySeparationGrammarListener 。调用(这个); // 继承默认监听器 | |
返回 这个; | |
} ; | |
// 继续继承默认监听器 | |
自定义监听器。原型 = 对象。创建(QuerySeparationGrammarListener 。原型); | |
自定义监听器。原型。构造函数 = 自定义 监听器; | |
// 覆盖默认监听器行为 | |
自定义监听器。原型。退出语句 = 函数(ctx ) { | |
这个。结果。推([ ctx 。开始。开始, ctx 。停止。停止] ); // 存储每条语句的开始和停止索引 | |
} ; | |
出口。自定义监听器 = 自定义 监听器; |
SplitQueries 函数
结束我们对 ANTLR 的使用,让我们最后看一下SplitQueries
函数。首先,我们构建定义简单 SQL 语句和其中的一些基本组件的语法。然后,根据这个语法,我们生成词法分析器、解析器和侦听器。在我们的SplitQueries
函数中,我们接受一个字符串输入,并使用词法分析器和解析器使用语法规则从中构建一棵树。仍然在SplitQueries
函数内部,然后我们使用 customListener 遍历树,它记录每个语句的开始和停止索引。SplitQueries
返回这组索引。
从我们的输入文本中,我们得到了我们单独的 SQL 语句的索引位置的输出,这些 SQL 语句以分号结尾,但可以将它们包含在它们的注释和字符串中,如 ANTLR 语法所定义的那样。
使用 SplitQueries 和光标位置选择用户查询
将 ANTLR 语法和解析功能打包到该SplitQueries
函数中后,是时候通过使用此功能从控制台查询编辑器中的多个查询的文本中选择单个用户查询来结束该项目了。我们为此所做的是:
- 对用户的输入文本使用 SplitQueries 以获取该文本中每个查询的开始和停止索引。
- 选择开始和停止索引包含用户光标位置的查询,这意味着我们选择用户单击的查询。(在某些特殊情况下,比如选择一个空行,我们会选择一个相邻的查询。)
通过这样做,我们能够选择用分号分隔的用户查询,即使该查询在其字符串或注释中有分号,这都是因为SplitQueries
在计算查询边界时我们的 ANTLR 语法规则会通知它。
结论
我们很高兴我们在前端使用 ANTLR 进行严格的语法分析。尝试在Rockset Console 中输入多个查询以查看该功能的工作原理!随着 ANTLR 现在融入我们的前端,我们也期待在控制台中进行不同类型的解析。
JavaScript 中的 ANTLR 资源:
这是Rockset Console 中使用的 ANTLR/js 代码的副本。这包括语法、生成的文件、CustomListener 和 SplitQueries 函数。随意修改语法并使用以下方法重新生成文件:
antlr4 -Dlanguage=JavaScript QuerySeparationGrammar.g4
您可以在自己的字符串输入上运行 SplitQueries 脚本!