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 的字符信息. 而语法分析则使用这个结构体作为规约的过程, exprstatement 用于构建 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 实际上是无法区分标识符与关键字的.

posted @ 2025-04-02 10:45  虾野百鹤  阅读(30)  评论(0)    收藏  举报