Tiny_SQL_Parser_by_Goyacc
TinySQL Goyacc Parser
TinySQL 中使用 Goyacc 语法解析器生成器来生成一个语法解析器, Goyacc 用来根据你定义的语法规则生成一个语法解析器, 这个语法规则就写在文件 parser.y
中. 它会生成一个 Go 语言源文件, 里面包含了一个 LALR(1) 的语法分析器. LALR(1) 语法分析器在这里就不过多叙述了, 如果了解过编译器的肯定很熟悉, 简单点说就是用一个状态机(解析表)来读取输入, 并维护一个栈来记录当前的解析状态, 每次读取一个 Token, 根据语法解析器的语法规则进行判断, 每次会有两个步骤:
- Shift(将 Token 移入栈中)
- Reduce(规约): 如果将 Token 移入栈中之后, 当前栈顶符合某个语法规则的右边, 就规约为该规则的左边符号, 这个规则就是我们定义的语法规则.
Goyacc 的语法规则的定义
parser.y
的第一部分是Token类型与优先级的定义, 以 %token
开头的都是终结符, 终结符可能是标识符, 关键字, 运算符等. %type
类型则是非终结符, 例如表达式等.
/* 这部分的 token 是 ident 类型 */
%token <ident>
...
add "ADD"
all "ALL"
alter "ALTER"
analyze "ANALYZE"
and "AND"
as "AS"
asc "ASC"
between "BETWEEN"
bigIntType "BIGINT"
...
/* 这部分的 token 是 item 类型 */
%token <item>
/*yy:token "1.%d" */ floatLit "floating-point literal"
/*yy:token "1.%d" */ decLit "decimal literal"
/*yy:token "%d" */ intLit "integer literal"
/*yy:token "%x" */ hexLit "hexadecimal literal"
/*yy:token "%b" */ bitLit "bit literal"
andnot "&^"
assignmentEq ":="
eq "="
ge ">="
...
/* 非终结符按照类型分别定义 */
%type <expr>
Expression "expression"
BoolPri "boolean primary expression"
ExprOrDefault "expression or default"
PredicateExpr "Predicate expression factor"
SetExpr "Set variable statement value's expression"
...
%type <statement>
AdminStmt "Check table statement or show ddl statement"
AlterTableStmt "Alter table statement"
AlterUserStmt "Alter user statement"
AnalyzeTableStmt "Analyze table statement"
BeginTransactionStmt "BEGIN TRANSACTION statement"
BinlogStmt "Binlog base64 statement"
...
%type <item>
AlterTableOptionListOpt "alter table option list opt"
AlterTableSpec "Alter table specification"
AlterTableSpecList "Alter table specification list"
AnyOrAll "Any or All for subquery"
Assignment "assignment"
...
%type <ident>
KeyOrIndex "{KEY|INDEX}"
ColumnKeywordOpt "Column keyword or empty"
PrimaryOpt "Optional primary keyword"
NowSym "CURRENT_TIMESTAMP/LOCALTIME/LOCALTIMESTAMP"
NowSymFunc "CURRENT_TIMESTAMP/LOCALTIME/LOCALTIMESTAMP/NOW"
...
第一部分的最后是对优先级和结合性的定义, 优先级在规约的过程中很重要, 因为要根据栈顶的状态与优先级来判断规约的步骤.
...
%precedence sqlCache sqlNoCache
%precedence lowerThanIntervalKeyword
%precedence interval
%precedence lowerThanStringLitToken
%precedence stringLit
...
%right assignmentEq
%left pipes or pipesAsOr
%left xor
%left andand and
%left between
...
语法规则用到的主要的地方就是规约的时候如何生成表达式, 以及表达式如何规约为新的表达式. 这一部分在生成 parser.go
的时候并不会显示的生成代码, 而是作用在规约的过程中.
Goyacc 中语法规则的实现
Goyacc 中, 我们将语法规则写在 parser.y
文件中, 那么从最开始的 SQL 语法, 到 parser.y
中的规则语法, 最后到 parser.go
中规约过程, 最后生成的语法解析器的执行步骤是怎样的呢?
我们使用 SELECT
语句的语法规则进行描述与说明.
官网描述的 SQL 语句
SELECT
[ALL | DISTINCT | DISTINCTROW ]
[HIGH_PRIORITY]
[STRAIGHT_JOIN]
[SQL_SMALL_RESULT] [SQL_BIG_RESULT] [SQL_BUFFER_RESULT]
[SQL_CACHE | SQL_NO_CACHE] [SQL_CALC_FOUND_ROWS]
select_expr [, select_expr] ...
[into_option]
[FROM table_references
[PARTITION partition_list]]
[WHERE where_condition]
[GROUP BY {col_name | expr | position}
[ASC | DESC], ... [WITH ROLLUP]]
[HAVING where_condition]
[ORDER BY {col_name | expr | position}
[ASC | DESC], ...]
[LIMIT {[offset,] row_count | row_count OFFSET offset}]
[PROCEDURE procedure_name(argument_list)]
[into_option]
[FOR UPDATE | LOCK IN SHARE MODE]
into_option: {
INTO OUTFILE 'file_name'
[CHARACTER SET charset_name]
export_options
| INTO DUMPFILE 'file_name'
| INTO var_name [, var_name] ...
}
export_options:
[{FIELDS | COLUMNS}
[TERMINATED BY 'string']
[[OPTIONALLY] ENCLOSED BY 'char']
[ESCAPED BY 'char']
]
[LINES
[STARTING BY 'string']
[TERMINATED BY 'string']
]
官网描述的时候使用的是 BNF(巴科斯范式)的一种简化形式, 用来描述一门语言的语法结构, 例如:
SELECT
[ALL | DISTINCT]
select_expr [, select_expr] ...
FROM table_references
🧩 分段解释: MySQL SELECT 语法含义
我们按功能区块来讲解:
查询模式选项(可选):
[ALL | DISTINCT | DISTINCTROW ]
[HIGH_PRIORITY]
[STRAIGHT_JOIN]
[SQL_SMALL_RESULT] [SQL_BIG_RESULT] [SQL_BUFFER_RESULT]
[SQL_CACHE | SQL_NO_CACHE]
[SQL_CALC_FOUND_ROWS]
这些都是对 查询行为 的优化或控制选项:
关键字 | 含义 |
---|---|
ALL |
默认行为,返回所有行(即使有重复) |
DISTINCT / DISTINCTROW |
去重(两者几乎等价) |
HIGH_PRIORITY |
优先执行查询,阻止更新操作 |
STRAIGHT_JOIN |
告诉优化器保持表的连接顺序 |
SQL_SMALL_RESULT |
提示优化器返回结果较小(用于 GROUP BY) |
SQL_BIG_RESULT |
提示结果较大 |
SQL_BUFFER_RESULT |
把结果缓存在临时表中,释放锁 |
SQL_CACHE / SQL_NO_CACHE |
使用查询缓存与否(已废弃) |
SQL_CALC_FOUND_ROWS |
返回所有行总数(即使 LIMIT 被用) |
选择字段:
select_expr [, select_expr] ...
意思是: 选择的字段或表达式,可以是:
SELECT name, age + 1, COUNT(*), ...
select_expr
是一个“表达式”占位符,代表可以放任何有效的 SQL 表达式。
INTO 子句(可选):
[into_option]
用来将结果导出:
INTO OUTFILE 'file.csv'
INTO var_name [, var_name]
在存储过程里也可以写成:
SELECT col INTO @a, @b;
FROM 子句:
FROM table_references
[PARTITION partition_list]
指定从哪个表/子查询取数据,table_references
可以是多个表加 JOIN。
PARTITION
是用于分区表的查询。
WHERE 条件(可选):
[WHERE where_condition]
条件筛选,比如:
WHERE age > 18 AND status = 'active'
GROUP BY(可选):
GROUP BY {col_name | expr | position}
[ASC | DESC], ...
[WITH ROLLUP]
- 用于分组聚合
WITH ROLLUP
表示再加一行小计/总计position
可以用列索引:GROUP BY 1
HAVING(可选):
HAVING where_condition
和 WHERE
类似,但作用在 GROUP BY
分组后的结果上。
ORDER BY(可选):
ORDER BY {col_name | expr | position}
[ASC | DESC], ...
指定结果排序方式,默认升序。
LIMIT(可选):
LIMIT {[offset,] row_count | row_count OFFSET offset}
控制返回的结果行数:
LIMIT 10
→ 返回前 10 行LIMIT 5, 10
→ 从第 6 行开始,返回 10 行LIMIT 10 OFFSET 5
→ 同上
PROCEDURE(可选):
PROCEDURE procedure_name(argument_list)
- 用于指定存储过程,基本上很少用。
锁定方式(可选):
[FOR UPDATE | LOCK IN SHARE MODE]
FOR UPDATE
: 加写锁(用于事务)LOCK IN SHARE MODE
: 加共享读锁
Goyacc 中 SELECT 对应的语法规则
MySQL 官网中, SQL 语法是使用 BNF 范式来表示, 实际的 parser.y
中的语法规则的定义也是可以通过程序解析 BNF 范式获得, 这里我们主要介绍一下在 parser.y
中语法表达式的描述与 MySQL 官网中 SQL 语法描述之间的对应关系.
在 parser.y
中可以看到 SELECT
语句定义的语法规则为:
SelectStmt:
"SELECT" SelectStmtOpts SelectStmtFieldList OrderByOptional SelectStmtLimit SelectLockOpt
{ ... }
| "SELECT" SelectStmtOpts SelectStmtFieldList FromDual WhereClauseOptional SelectStmtLimit SelectLockOpt
{ ... }
| "SELECT" SelectStmtOpts SelectStmtFieldList "FROM"
TableRefsClause WhereClauseOptional SelectStmtGroup HavingClause OrderByOptional
SelectStmtLimit SelectLockOpt
{ ... }
这里我们是总结与概括了一些中间的表达式, 实际的语法规则遵循 LALR(1) 自底向上的规约规则. 例如, 在 parser.y
中, 实际定义 SelectStmt
如下:
我们仅看第一种语法规则:
SelectStmt:
SelectStmtBasic OrderByOptional SelectStmtLimit
{
// 将 SelectStmtBasic 断言为 *ast.SelectStmt 类型, 赋值给 st, 准备在其基础上构建完整的 SELECT 语句
// SelectStmtBasic 本身也是一棵 AST, 所以是 SelectStmt 的一部分
st := $1.(*ast.SelectStmt)
// 取出 SELECT 语句中 字段列表的最后一个字段, 比如在 SELECT id, name FROM ... 中, 这里就是 name 对应的 SelectField
lastField := st.Fields.Fields[len(st.Fields.Fields)-1]
// 如果这个字段是一个表达式(不是通配符 *), 并且 没有指定别名, 那么它可能是用户写的表达式,
// 比如:SELECT 1+1 FROM ...,这种情况下, 我们想保留它的原始 SQL 文本
if lastField.Expr != nil && lastField.AsName.O == "" {
// 获取原始文本
src := parser.src
var lastEnd int
// 这里是获取原始文本的位置, yyS 是规约过程中的结构体栈
if $2 != nil {
// OrderByOptional 不为空, 栈中的倒数第二个元素就是 OrderByOptional 类型的节点, 找到在 SQL 语句中对应的位置信息
lastEnd = yyS[yypt-1].offset-1
} else if $3 != nil {
// 同理, SelectStmtLimit 如果不为空, 那么栈顶的元素就是 SelectStmtLimit 类型的节点
lastEnd = yyS[yypt-0].offset-1
} else {
lastEnd = len(src)
if src[lastEnd-1] == ';' {
lastEnd--
}
}
// 从原来的 SQL 语句中设置最后的字符串
lastField.SetText(src[lastField.Offset:lastEnd])
}
if $2 != nil {
st.OrderBy = $2.(*ast.OrderByClause)
}
if $3 != nil {
st.Limit = $3.(*ast.Limit)
}
$$ = st
}
上述的语法规则对应的原始的 SQL 语法是:
SELECT
select_expr [, select_expr] ...
...
[ORDER BY {col_name | expr | position} [ASC | DESC], ...]
[LIMIT {[offset,] row_count | row_count OFFSET offset}]
我们可以总结一下上述的过程做了哪些事情, 以及如何描述这些事情:
SelectStmtBasic OrderByOptional SelectStmtLimit
这三部分表示语法规则的右边的符号, 这里这三个符号都是非终结符, 这三个非终结符也对应着 AST 中的三个节点.- 下面打括号中的内容是语法规则规约的实际步骤, 其中
$1
,$2
,$3
分别对应着上述的三个节点, 如何以及按照何种方式用这三个节点规约到一个新的节点呢, 就是打括号中的步骤. - 设置复杂字段的方式我也用注释标注出来了, 这部分是为了还原复杂的表达式类型.
- 最后将
OrderByClause
和Limit
这两个字节点规约到 AST 树的SelectStmt
类型的节点中.
Goyacc 生成的语法解析器
那么上面 SELECT
语句生成的语法解析器又是什么样子的呢? 也就是上述的 parser.y
文件中的对 SELECT
语句的描述生成的 parser.go
文件的内容应该是什么样子的呢?
我们可以在代码中找到这部分, 如下:
case 837:
{
// 此时栈顶的元素分别是 SelectStmtBasic OrderByOptional SelectStmtLimit
// 所以将倒数第三个元素 SelectStmtBasic 用于构建新的 AST 节点
st := yyS[yypt-2].item.(*ast.SelectStmt)
// 获取 SELECT 语句的最后一个字段
lastField := st.Fields.Fields[len(st.Fields.Fields)-1]
if lastField.Expr != nil && lastField.AsName.O == "" {
src := parser.src
var lastEnd int
if yyS[yypt-1].item != nil {
lastEnd = yyS[yypt-1].offset - 1
} else if yyS[yypt-0].item != nil {
lastEnd = yyS[yypt-0].offset - 1
} else {
lastEnd = len(src)
if src[lastEnd-1] == ';' {
lastEnd--
}
}
// 从原来的 SQL 语句中设置最后的字符串
lastField.SetText(src[lastField.Offset:lastEnd])
}
if yyS[yypt-1].item != nil {
st.OrderBy = yyS[yypt-1].item.(*ast.OrderByClause)
}
if yyS[yypt-0].item != nil {
st.Limit = yyS[yypt-0].item.(*ast.Limit)
}
// 存储最近一次规约规则的结果, yyVAL is used to store the value of the last rule matched
parser.yyVAL.statement = st
}
可以看到这个翻译过程还是比较直接, 简单明了的. 这部分翻译的代码在 Goyacc 生成的语法解析器的语法解析主体函数 func yyParse(yylex yyLexer, parser *Parser)
中, 这个函数的参数分别就是词法分析器与语法分析器.
我们可以回看一下语法分析器 Parser 的结构, 如下:
// Parser represents a parser instance. Some temporary objects are stored in it to reduce object allocation during Parse function.
type Parser struct {
charset string
collation string
result []ast.StmtNode
src string
lexer Scanner
// the following fields are used by yyParse to reduce allocation.
// 规约过程中使用的栈, 用于存储临时信息
cache []yySymType
// 临时存储词法分析器读取的每一个 Token 的值
yylval yySymType
// yyVAL is used to store the value of the last rule matched. 用于存储规约的结果, 也就是某条语法规则解析的结果
// 这是一个指针, 在规约的过程中它会指向 cache 中的一个元素, 通过修改这个指针指向的内容可以间接的实现压栈操作
yyVAL *yySymType
}
翻译后的部分的代码实际上就是实现了一个 LALR(1) 的语法解析器, 在 LALR(1) 语法解析器中, 有限状态机可以执行的步骤有下列三种:
Shift(移进): 读取一个 token, 并将它和当前状态压入栈.
Reduce(归约): 根据某个产生式规则, 将栈顶若干项归约为一个非终结符.
Goto: 归约完成后, 跳转到一个新状态, 继续解析.
继续往下阅读, 我们分阶段介绍这部分的代码逻辑:
初始化词法分析器与语法分析器:
const yyError = 737
// 获取词法分析器, yyEx 是词法分析器
yyEx, _ := yylex.(yyLexerEx)
var yyn int
// 初始化读取到的 Token
parser.yylval = yySymType{}
// yyS 是规约过程中使用的栈
yyS := parser.cache
// 初始化报错信息
Nerrs := 0 /* number of errors */
Errflag := 0 /* error recovery flag */
yyerrok := func() {
if yyDebug >= 2 {
__yyfmt__.Printf("yyerrok()\n")
}
Errflag = 0
}
_ = yyerrok
栈初始化:
yyS := parser.cache // 语法分析栈
yyp := -1 // 栈顶指针
yystate := 0 // 当前状态
yychar := -1 // 当前 token(初始为未读取)
主循环(状态转移):
- 主循环描述了 LALR(1) 自底向上的语法分析的基本流程
- yystack 这里是实现了间接压栈的步骤, 基本思想是
parser.yyVAL
是指向栈顶下一个元素的指针, 我们在shift
或者reduce
的过程中会膝盖这个指针, 修改之后, 在yystack
中会将栈帧向前移动, 此时parser.yyVAL
就指向了栈顶, 也就是间接的实现了压栈的操作. - yynewstate 这是状态转移的核心步骤, 根据词法分析读取的 token 信息以及当前状态, 使用
yyXLAT
,yyParseTab
这两个数组获取新的状态, 这两个数组是 Goyacc 自动生成的数组.
yystack:
/* put a state and value onto the stack */
yyp++
if yyp+1 >= len(yyS) {
nyys := make([]yySymType, len(yyS)*2)
copy(nyys, yyS)
yyS = nyys
parser.cache = yyS
}
parser.yyVAL = &yyS[yyp+1]
yyS[yyp].yys = yystate
yynewstate:
if yychar < 0 {
// 读取下一个 token(调用词法分析器), yylval 存储词法分析器读取的 Token 信息
yychar = yylex1(yylex, &parser.yylval)
}
row := yyParseTab[yystate] // 获取当前状态对应的转移表
yyn = 0
// 更新 yyn 的状态, 这一步很关键, 规约过程就是根据这个状态选择规约步骤的
if yyxchar < len(row) {
// 通过状态转移表得到规约应该执行的动作
if yyn = int(row[yyxchar]); yyn != 0 {
yyn += yyTabOfs
}
}
if yyn > 0 {
// shift 动作
yychar = -1
*parser.yyVAL = parser.yylval // shift 直接将词法分析的结果写入栈中, yyVAL 是指向栈顶的 yySymType 结构体
yystate = yyn // 转移状态
goto yystack
} else if yyn < 0 {
// reduce 动作
} else if yystate == 1 {
// 接受语法
goto ret0
}
Reduce 动作:
这部分是规约的核心步骤, 可以分解为下面几个步骤:
- yyn 在
yynewstate
中记录的是新的状态, 下面的 r 则是对应的规约规则编号 - 获取规约的信息, 规约的信息包含使用多少个栈中的非终结符进行规约, 需要从栈中弹出对应的元素个数
parser.yyVAL = &yyS[yyp+1]
, 设置指向新的栈顶的指针, 规约的结果存储到这个指针指向的结构体中.- 规约之后转移到新的状态
r := -yyn // 获取归约规则编号
x0 := yyReductions[r] // 获取该规则的信息
x, n := x0.xsym, x0.components // x 是归约结果的非终结符,n 是右侧符号数量
...
yyp -= n // 从栈中弹出 n 个状态
...
// 状态转移:根据 goto 表决定归约后的新状态
yystate = int(yyParseTab[yyS[yyp].yys][x]) + yyTabOfs
执行语义动作:
这一步就是对于每一种规约, 执行具体的规约步骤, 例如:
switch r {
case 2:
{
specs := yyS[yypt-0].item.([]*ast.AlterTableSpec)
parser.yyVAL.statement = &ast.AlterTableStmt{
Table: yyS[yypt-1].item.(*ast.TableName),
Specs: specs,
}
}
case 837:
{
// 就是你提到的 SELECT 相关语义动作
}
TinySQL Project2 Answer
Project2 中需要实现的是 SQL 中的 Join 语法, 也就是需要补充完成下面的部分:
JoinTable:
/* Use %prec to evaluate production TableRef before cross join */
TableRef CrossOpt TableRef %prec tableRefPriority
{
$$ = &ast.Join{Left: $1.(ast.ResultSetNode), Right: $3.(ast.ResultSetNode), Tp: ast.CrossJoin}
}
/* Project 2: your code here.
* You can see details about JoinTable in https://dev.mysql.com/doc/refman/8.0/en/join.html
*
* joined_table: {
* table_reference {[INNER | CROSS] JOIN | STRAIGHT_JOIN} table_factor [join_specification]
* | table_reference {LEFT|RIGHT} [OUTER] JOIN table_reference join_specification
* | table_reference NATURAL [INNER | {LEFT|RIGHT} [OUTER]] JOIN table_factor
* }
*
*/
但是实际上 TinySQL 并不支持所有的 SQL 语法, 所以我们只需要去 TestDMLStmt
测试案例中找出我们需要支持哪些语法即可. 从测试案例中我们可以发现 JOIN 类型的语句需要支持的语法有下列几种情况:
Table1 JOIN Table2;
Table1 Inner JOIN Table2;
Table1 JOIN Table2 ON ID;
Table1 LEFT JOIN Table2 ON ID;
Table1 RIGHT JOIN Table2 ON ID;
因此按照之前 SELECT 语法类似的方式修改 parser.y 文件即可, 支持上述几种语法即可:
我的实现如下:
JoinTable:
/* Use %prec to evaluate production TableRef before cross join */
TableRef CrossOpt TableRef %prec tableRefPriority
{
$$ = &ast.Join{Left: $1.(ast.ResultSetNode), Right: $3.(ast.ResultSetNode), Tp: ast.CrossJoin}
}
| TableRef CrossOpt TableRef "ON" Expression
{
on := &ast.OnCondition{Expr: $5}
$$ = &ast.Join{Left: $1.(ast.ResultSetNode), Right: $3.(ast.ResultSetNode), Tp: ast.CrossJoin, On: on}
}
| TableRef JoinType OuterOpt "JOIN" TableRef "ON" Expression
{
on := &ast.OnCondition{Expr: $7}
$$ = &ast.Join{Left: $1.(ast.ResultSetNode), Right: $5.(ast.ResultSetNode), Tp: $2.(ast.JoinType), On: on}
}