Tiny_SQL_Parser_by_Goyacc

TinySQL Goyacc Parser

TinySQL 中使用 Goyacc 语法解析器生成器来生成一个语法解析器, Goyacc 用来根据你定义的语法规则生成一个语法解析器, 这个语法规则就写在文件 parser.y 中. 它会生成一个 Go 语言源文件, 里面包含了一个 LALR(1) 的语法分析器. LALR(1) 语法分析器在这里就不过多叙述了, 如果了解过编译器的肯定很熟悉, 简单点说就是用一个状态机(解析表)来读取输入, 并维护一个栈来记录当前的解析状态, 每次读取一个 Token, 根据语法解析器的语法规则进行判断, 每次会有两个步骤:

  1. Shift(将 Token 移入栈中)
  2. 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}]

我们可以总结一下上述的过程做了哪些事情, 以及如何描述这些事情:

  1. SelectStmtBasic OrderByOptional SelectStmtLimit 这三部分表示语法规则的右边的符号, 这里这三个符号都是非终结符, 这三个非终结符也对应着 AST 中的三个节点.
  2. 下面打括号中的内容是语法规则规约的实际步骤, 其中 $1, $2, $3 分别对应着上述的三个节点, 如何以及按照何种方式用这三个节点规约到一个新的节点呢, 就是打括号中的步骤.
  3. 设置复杂字段的方式我也用注释标注出来了, 这部分是为了还原复杂的表达式类型.
  4. 最后将 OrderByClauseLimit 这两个字节点规约到 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(初始为未读取)

主循环(状态转移):

  1. 主循环描述了 LALR(1) 自底向上的语法分析的基本流程
  2. yystack 这里是实现了间接压栈的步骤, 基本思想是 parser.yyVAL 是指向栈顶下一个元素的指针, 我们在 shift 或者 reduce 的过程中会膝盖这个指针, 修改之后, 在 yystack 中会将栈帧向前移动, 此时 parser.yyVAL 就指向了栈顶, 也就是间接的实现了压栈的操作.
  3. 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 动作:

这部分是规约的核心步骤, 可以分解为下面几个步骤:

  1. yyn 在 yynewstate 中记录的是新的状态, 下面的 r 则是对应的规约规则编号
  2. 获取规约的信息, 规约的信息包含使用多少个栈中的非终结符进行规约, 需要从栈中弹出对应的元素个数
  3. parser.yyVAL = &yyS[yyp+1], 设置指向新的栈顶的指针, 规约的结果存储到这个指针指向的结构体中.
  4. 规约之后转移到新的状态
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}
	}
posted @ 2025-04-07 16:22  虾野百鹤  阅读(48)  评论(0)    收藏  举报