Tiny_SQL_Lexical_Analyzer
Tiny_SQL 中 词法分析器的实现
词法分析的任务是将 SQL 语句的文本拆解成 Token(标记), 每个 Token 代表 SQL 语法中的一个基本单元, 例如关键字(SELECT)、标识符(表名、列名)、操作符(=)、数字等.
示例
假设输入 SQL 语句如下:
SELECT id, name FROM users WHERE age > 18;
词法分析器(Lexer)会把它拆解成以下 Token 序列:
Token | 类型 |
---|---|
SELECT |
关键字 (Keyword) |
id |
标识符 (Identifier) |
, |
分隔符 (Separator) |
name |
标识符 (Identifier) |
FROM |
关键字 (Keyword) |
users |
标识符 (Identifier) |
WHERE |
关键字 (Keyword) |
age |
标识符 (Identifier) |
> |
操作符 (Operator) |
18 |
数字 (Numeric Literal) |
; |
结束符 (End of Statement) |
在这个过程中, 词法分析器使用 有限状态机(Finite State Machine, FSM) 来读取输入字符, 并逐步将它们组合成 Token. 例如:
- 看到
S
后继续读取E
,L
,E
,C
,T
识别为SELECT
关键字 - 看到
users
发现不匹配任何关键字, 因此归类为 标识符 - 看到
>
识别为 操作符
TinySQL 中词法分析器的实现
yyLexer 接口
TinySQL 使用了 Goyacc 的语法分析器, 因此需要构建出符合 Goyacc 语法分析器的接口, 也就是需要实现下列的接口:
type yyLexer interface {
// 读取一个 token 的信息, 并且将这个 token 的信息写入到 yySymType 结构体中
// 这个结构体存储的是词法分析的 token 信息
Lex(lval *yySymType) int
Error(e string)
}
或者
type yyLexerEx interface {
yyLexer
// 这是可选的规约的过程
Reduced(rule, state int, lval *yySymType) (stop bool) // Client should copy *lval.
}
yySymType
结构体的定义如下, 这个结构体是衔接了词法分析与语法分析, 词法分析将词法分析的一个 Token 的信息存储到这个结构体中, 例如使用 offset
存储位置信息, 使用 ident
存储 Token 的字符信息. 而语法分析则使用这个结构体作为规约的过程, expr
与 statement
用于构建 AST(抽象语法树).
type yySymType struct {
yys int // Goyacc 内部使用的 状态信息
offset int // 记录 当前 token 在 SQL 语句中的位置(用于错误提示)
item interface{} // 通用存储字段, 可存放任何类型的数据
ident string // 存放标识符(Identifier), 如表名、列名
expr ast.ExprNode // 存放表达式(Expression), 用于构建 AST
statement ast.StmtNode // 存放完整 SQL 语句(Statement), 用于 AST 根节点
}
在 parser.y
生成的 parser.go
中, 也就是 Goyacc 生成的语法解析器中使用了这部分, 其中 expr
是规约生成的中间的表达式类型, statement
是规约生成的 SQL 语句类型.
我们可以看到 Parser
解析器使用结构体 yySymType
的地方, 如下:
// 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. 用于存储规约的结果, 也就是某条语法规则解析的结果
yyVAL *yySymType
}
总之, yySymType
结构体是用来衔接词法解析器与 Goyacc 语法解析器的. 在词法解析的过程中, Lex(lval *yySymType)
存储解析到的 Token, 例如标识符, 关键字等. 在语法解析器中, 使用 yySymType
类型的结构体 yyVAL
存储语法解析规约过程中的中间结果和最终结果.
词法分析中 yyLexer 接口的实现
TinySQL 使用结构体 Scanner
来实现上述的接口, 这个结构体的定义如下:
// Scanner implements the yyLexer interface.
type Scanner struct {
// 用来记录读取的 SQL 语句, 其中 s 保存了原始的 SQL 语句
r reader
// buf 用于临时存储 Scan 到的 SQL String
buf bytes.Buffer
errs []error
warns []error
// stmtStartPos is the start position of current statement.
stmtStartPos int
// For scanning such kind of comment: /*! MySQL-specific code */ or /*+ optimizer hint */
specialComment specialCommentScanner
// SQL 代码字符串的解析模式
sqlMode mysql.SQLMode
// lastScanOffset indicates last offset returned by scan().
// It's used to substring sql in syntax error message.
lastScanOffset int
}
我们来看一下 Lex
接口具体是如何实现的,
// ! Scanner satisfies yyLexer interface.
// 0 and invalid are special token id this function would return:
// return 0 tells parser that scanner meets EOF,
// return invalid tells parser that scanner meets illegal character.
func (s *Scanner) Lex(v *yySymType) int {
tok, pos, lit := s.scan()
s.lastScanOffset = pos.Offset
v.offset = pos.Offset
v.ident = lit
if tok == identifier {
tok = handleIdent(v)
}
if tok == identifier {
// 判断 lit 是一个普通标识符还是关键字 KeyWord
if tok1 := s.isTokenIdentifier(lit, pos.Offset); tok1 != 0 {
tok = tok1
}
}
// 特殊处理双引号
if s.sqlMode.HasANSIQuotesMode() &&
tok == stringLit &&
s.r.s[v.offset] == '"' {
tok = identifier
}
// 特殊处理 || 符号
if tok == pipes && !(s.sqlMode.HasPipesAsConcatMode()) {
return pipesAsOr
}
// 特殊处理 AND 和 NOT 的优先级
if tok == not && s.sqlMode.HasHighNotPrecedenceMode() {
return not2
}
switch tok {
case intLit:
return toInt(s, v, lit)
case floatLit:
return toFloat(s, v, lit)
case decLit:
return toDecimal(s, v, lit)
case hexLit:
return toHex(s, v, lit)
case bitLit:
return toBit(s, v, lit)
case singleAtIdentifier, doubleAtIdentifier, cast, extract:
v.item = lit
return tok
case null:
v.item = nil
case quotedIdentifier:
tok = identifier
}
if tok == unicode.ReplacementChar {
return invalid
}
return tok
}
Lex
是词法分析的分析过程, 它的作用是从 SQL 代码中读取 Token, 并且返回 Token 的类型, 例如标识符, 关键字等, 还有位置信息, 这些信息通过 yySymType
结构体的指针写入到一个 yySymType
类型的结构体中, 然后传给语法解析器.
其中最重要的是 scan()
函数, 这个函数从 SQL 语句中读取一个 Token, 例如关键字 SELECT, 数据库属性的标识符等, 但是并没有完全处理完, 在 Lex()
函数中, 还会继续处理.
func (s *Scanner) scan() (tok int, pos Pos, lit string) {
if s.specialComment != nil {
// Enter specialComment scan mode.
// for scanning such kind of comment: /*! MySQL-specific code */
specialComment := s.specialComment
tok, pos, lit = specialComment.scan()
if tok != 0 {
// return the specialComment scan result as the result
return
}
// leave specialComment scan mode after all stream consumed.
s.specialComment = nil
}
// normal scan mode.
// read the first char
// if the first char is a space, skip it.
// if the first char is EOF, return 0.
// if the first char is not EOF, check whether it's a special char.
// if it's a special char, return the token.
// if it's not a special char, check whether it's an identifier.
// if it's an identifier, return the token.
ch0 := s.r.peek()
if unicode.IsSpace(ch0) {
ch0 = s.skipWhitespace()
}
pos = s.r.pos()
if s.r.eof() {
// when scanner meets EOF, the returned token should be 0,
// because 0 is a special token id to remind the parser that stream is end.
return 0, pos, ""
}
// 读取标识符, 例如表名, 列名, 变量名等, 如果 isIdentExtend, 表示一定是标识符, 而不是关键字
if !s.r.eof() && isIdentExtend(ch0) {
return scanIdentifier(s)
}
// search a trie to get a token. 在字典树中解析关键字或者匹配运算符
node := &ruleTable
for ch0 >= 0 && ch0 <= 255 {
// 不在字典树中, 应该是一个标识符
if node.childs[ch0] == nil || s.r.eof() {
break
}
node = node.childs[ch0]
// 当走到一个前缀的位置时候, 调用对应的前缀函数, 例如读取到 Xx, 调用 startWithXx 函数,
// 使用 startWithXx 继续往后读, 解析读取到的 token
if node.fn != nil {
return node.fn(s)
}
// read the next char
s.r.inc()
ch0 = s.r.peek()
}
// 获取 Token 的类型和字符串, 如果在字典树中没有找到, 那么就返回一个标识符
// 如果在字典树中能够找到, 返回对应的关键字的 token
tok, lit = node.token, s.r.data(&pos)
return
}
scan
函数的实现如上所示, 它最重要的是依赖于 Scanner.reader
中读取字符串的部分, scan
中使用了字典树进行运算符与特殊 SQL 语句部分的匹配.
这个字典树就是 var ruleTable trieNode
.
scan
并不能直接匹配所有的关键字, 它主要匹配一些运算符以及特殊的 SQL 语句, 例如:
读取到 SQL 语句的一行以 Nn
开头, 表示读取到国家字符集,
-- 普通字符串
SELECT '你好';
-- Unicode 字符串(N 前缀)
SELECT N'你好';
因此当读取到 N
开头的字符串的时候, 需要调用函数 startWithNn
.
func startWithNn(s *Scanner) (tok int, pos Pos, lit string) {
tok, pos, lit = scanIdentifier(s)
// The National Character Set, N'some text' or n'some test'.
// See https://dev.mysql.com/doc/refman/5.7/en/string-literals.html
// and https://dev.mysql.com/doc/refman/5.7/en/charset-national.html
if lit == "N" || lit == "n" {
if s.r.peek() == '\'' {
tok = underscoreCS
lit = "utf8"
}
}
return
}
字典树 ruleTable
中, 在初始化的时候就设置了这些函数, 如下:
initTokenFunc("@", startWithAt)
initTokenFunc("/", startWithSlash)
initTokenFunc("-", startWithDash)
initTokenFunc("#", startWithSharp)
initTokenFunc("Xx", startWithXx)
initTokenFunc("Nn", startWithNn)
initTokenFunc("Bb", startWithBb)
initTokenFunc(".", startWithDot)
initTokenFunc("_$ACDEFGHIJKLMOPQRSTUVWYZacdefghijklmopqrstuvwyz", scanIdentifier)
initTokenFunc("`", scanQuotedIdent)
initTokenFunc("0123456789", startWithNumber)
initTokenFunc("'\"", startString)
总结来说就是:
在 ruleTable
的初始化函数中, 添加了特殊字符以及特殊处理函数读取的部分, 在 tokenMap
中存储了关键字的类别以及对应的数字, 这些类别与数字还会在语法解析器中使用到. 而这些关键字的判断就是在 Lex
中调用的函数 scanIdentifier
中, 因为 scan
实际上是无法区分标识符与关键字的.