## 1 引言

因为工作关系，需要开发支持众多方言的 SQL 编辑器，所以复习了一下编译原理相关知识。

相比编译原理专家，我们只需要了解部分编译原理即可实现 SQL 编辑器，所以这是一篇写给前端的编译原理文章。

解析 SQL 可以分为如下四步：

1. 词法分析，将 SQL 字符串拆分成包含关键词识别的字符段（Tokens）。
2. 语法分析，利用自顶向下或自底向上的算法，将 Tokens 解析为 AST，可以手动，也可以自动。
3. 错误检测、恢复、提示推断，都需要利用语法分析产生的 AST。
4. 语义分析，做完这一步就可以执行 SQL 语句了，不过对前端而言，不需要深入到这一步，可以跳过。

## 2 精读

词法分析就像刀削面的过程，拿着一段字符串（面条）一端不断下刀，当面条被切完也就完成了词法分析，所以词法分析是 字符串 -> 一堆字符段 的过程。

流程很简单，难点就在下刀的分寸了，每次砍几厘米呢？

回到词法分析，为了准备切分，我们需要定义 SQL 的 Token 有哪些类型，即 Token 分类。

### Token 分类

SQL 的 Token 可以分为如下几类：

- 注释。
- 关键字（`SELECT`、`CREATE`）。
- 操作符（`+`、`-`、`>=`）。
- 开闭合标志（`(`、`CASE`）。
- 占位符（`?`）。
- 空格。
- 引号包裹的文本、数字、字段。
- 方言语法（`${variable}`）。

可以看到，在词法分析阶段，我们的 Tokens 不需要关心关键词是什么，只要识别是不是关键词即可，因为关键词的辨认会留到语法分析时处理。涉及到语意处理就要考虑上下文，而这都不是词法分析阶段要考虑的。

同样，操作符、空格、文本、占位符等构成了 SQL 语句的其他部分，最后通过开闭合标志比如左括号和右括号，让 SQL 支持子语句。

再强调一次，虽然 SQL 支持子语句，但并不是放在任何位置都是合理的，其他类型 Token 同理，但是词法分析不需要考虑 Token 是否合理，只要切分即可。

### 用正则逐段分词

像大多数语言一样，SQL 为了方便人类阅读，采用从左到右的书写方式，因此**分词方向也从左到右**。

我们为每个 Token 类型写一个函数，比如匹配空格的匹配函数：

```typescript
function getTokenWhitespace(restStr: string) {
  const matches = restStr.match(/^(\s+)/);

  if (matches) {
    return { type, value: matches[1] };
  }
}
```

`restStr` 表示掐去头部剩下的 SQL 字符串，所有匹配函数都拿 `restStr` 进行匹配，已经匹配的不需要再处理。

通过正则 `/^(\s+)/` 匹配到第一个以空格开头的空格（读起来有点别扭），匹配时必须保证以你要匹配的内容开头，而且只匹配一次，这样才不会在切词时发生遗漏。

同理匹配 `/**/` 类型注释时，也能通过正则轻而易举的实现：

```typescript
function getTokenBlockComment(restStr: string) {
  const matches = restStr.match(/^(\/\*[^]*?(?:\*\/|$))/);

  if (matches) {
    return { type, value: matches[1] };
  }
}
```

其中 `(?:\*/\)` 表示匹配到以 `*/` 结尾处，而 `(?:\*\/|$)` 后面的 `|$` 表示或者直接匹配到结尾（如果一直没有遇到 `*/` 那后面全部当作注释）。

所以只要 Token 分类得当，并且能为每一个分类写一个头匹配正则，分词功能就实现了 90%。

### 方言拓展

为了支持某些方言，需要从分词时就开始做考虑。比如 `${variable}` 作为一种变量用法时，我们需要在普通字段的正则匹配中，加入一项 `\$\{[a-zA-Z0-9]+\}` 匹配。

如果要支持纯中文作为字段，可以再补充 `|\u4e00-\u9fa5`。

### 分词主流程

有了一个个分词函数，再补充一个不断匹配、切割字符串、再匹配的主函数即可，这一步更简单：

```typescript
while (sqlStr) {
  token =
    getTokenWhitespace(sqlStr, token) || getTokenBlockComment(sqlStr, token);

  sqlStr = sqlStr.substring(token.value.length);

  tokens.push(token);
}
```

上面的函数每取一次 Token，都将取到的 Token 长度丢掉，继续匹配剩下的字符串，直到字符串被切分完为止。

有些特殊情况需要拿到上次的 Token 才能判断下一个 Token 该如何切割，所以将 Token 传给每一个下一步 Match 函数。

最后，执行这个主函数，分词就完成了！

## 3 总结

分词比较简单，到这里就全部结束了。后面即将进入深水区语法分析，敬请期待。

## 4 更多讨论

> 讨论地址是：[精读《手写 SQL 编译器 - 词法分析》 · Issue #93 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/93)

**如果你想参与讨论，请[点击这里](https://github.com/dt-fe/weekly)，每周都有新的主题，周末或周一发布。**
