SQL语句格式化看起来只是把关键字放到行首,但真正处理子查询、CASE WHEN和注释时会迅速变得复杂。直接用正则替换或者按空格拆词,往往会在字符串里的FROM和注释处的WHERE上错误断行。要在Go里得到一个稳健的格式化器,合理的路径不是增加更多替换规则,而是先做词法分析,再根据括号深度和语法层级决定换行与缩进。本文会从一个最小实现开始,逐步补全到能处理嵌套结构。

一、从关键字换行开始:最小实现为什么不够用
一个直观思路是遍历关键字列表,在关键字前插入换行符。下面的函数使用strings.ReplaceAll完成这个操作。它假设关键字前后都有空格,能处理SELECT、FROM、WHERE等常见子句。对于只有单层查询的SQL,输出确实比原始字符串可读性更好。
package main
import (
"strings"
)
func formatBasic(sql string) string {
keywords := []string{"select", "from", "where", "group by", "order by", "limit", "and", "or"}
out := sql
for _, kw := range keywords {
out = strings.ReplaceAll(out, " "+kw+" ", "\n"+strings.ToUpper(kw)+" ")
}
return out
}
但这个实现有三个明显问题。第一,字符串内容会被误伤,例如SELECT 'from' AS source会被错误替换成SELECT后换行再接FROM;第二,关键字大小写不敏感时无法统一,原始SQL可能是小写、大写或混合大小写;第三,嵌套子查询没有缩进,当括号内出现另一个SELECT时,所有内容仍然堆在左侧。更关键的是,这种方案完全无法判断当前词素是否处于注释中。
如果继续沿着字符串替换的方向修补,就要不断加入排除条件:跳过引号内内容、跳过注释、判断括号深度。这些条件堆到最后,维护成本已经超过重新实现一个简单的词法分析器。因此更好的做法是先把SQL拆成token,再针对token设计格式化规则。
二、先做词法分析:把关键字、字符串、注释分开
词法分析器的任务是将SQL文本切分为有类型的token,至少要区分普通单词、字符串、注释和符号。其中单词可以再判断是否为关键字,符号主要指括号、逗号、分号。下面是一个轻量实现,利用[]rune遍历输入,避免直接处理多字节字符时出错。
package main
import (
"fmt"
"strings"
"unicode"
)
type tokType int
const (
tokWord tokType = iota
tokString
tokComment
tokSymbol
tokEOF
)
type token struct {
typ tokType
val string
}
type lexer struct {
src []rune
pos int
}
func (l *lexer) next() (token, bool) {
for l.pos < len(l.src) && unicode.IsSpace(l.src[l.pos]) {
l.pos++
}
if l.pos >= len(l.src) {
return token{typ: tokEOF}, false
}
c := l.src[l.pos]
if c == '\'' {
start := l.pos
l.pos++
for l.pos < len(l.src) {
if l.src[l.pos] == '\'' {
if l.pos+1 < len(l.src) && l.src[l.pos+1] == '\'' {
l.pos += 2
continue
}
l.pos++
break
}
l.pos++
}
return token{typ: tokString, val: string(l.src[start:l.pos])}, true
}
if c == '-' && l.pos+1 < len(l.src) && l.src[l.pos+1] == '-' {
start := l.pos
for l.pos < len(l.src) && l.src[l.pos] != '\n' {
l.pos++
}
return token{typ: tokComment, val: string(l.src[start:l.pos])}, true
}
if c == '/' && l.pos+1 < len(l.src) && l.src[l.pos+1] == '*' {
start := l.pos
l.pos += 2
for l.pos+1 < len(l.src) && !(l.src[l.pos] == '*' && l.src[l.pos+1] == '/') {
l.pos++
}
if l.pos+1 < len(l.src) {
l.pos += 2
}
return token{typ: tokComment, val: string(l.src[start:l.pos])}, true
}
if strings.ContainsRune("(),;", c) {
l.pos++
return token{typ: tokSymbol, val: string(c)}, true
}
start := l.pos
for l.pos < len(l.src) && !unicode.IsSpace(l.src[l.pos]) &&
!strings.ContainsRune("(),;'", l.src[l.pos]) {
if l.src[l.pos] == '-' && l.pos+1 < len(l.src) && l.src[l.pos+1] == '-' {
break
}
l.pos++
}
if l.pos == start {
l.pos++
}
return token{typ: tokWord, val: string(l.src[start:l.pos])}, true
}
这个扫描器在遇到单引号时会进入字符串扫描,并处理连续两个单引号的转义;遇到--和/* */时会把注释整体当作一个token;括号、逗号、分号则识别为符号。普通单词会一直读取到空白、括号、逗号、分号或单引号为止。
把SQL拆成token之后,格式化器就不再面对原始字符串,而是面对带有类型的信息流。此时字符串里的select不会被当成关键字,注释里的FROM也不会触发换行。这是后续实现嵌套缩进的基础,也让整个工具的行为更加可预测。
三、括号深度与缩进:让子查询自动换行
有了token流,就可以在遍历时维护一个括号深度。每当遇到左括号,深度加一;遇到右括号,深度减一。缩进使用两个空格乘以当前深度。对于SELECT、FROM、WHERE、JOIN、ON等顶层关键字,在它们之前插入换行,并按当前深度补齐缩进。
func formatTokens(tokens []token) string {
var b strings.Builder
depth := 0
newLine := false
topKw := map[string]bool{
"SELECT": true, "FROM": true, "WHERE": true, "GROUP": true,
"ORDER": true, "HAVING": true, "LIMIT": true, "OFFSET": true,
"JOIN": true, "LEFT": true, "RIGHT": true, "INNER": true,
"OUTER": true, "ON": true, "UNION": true, "AND": true,
"OR": true,
}
for _, tk := range tokens {
switch tk.typ {
case tokString:
if newLine {
b.WriteString(strings.Repeat(" ", depth))
newLine = false
}
b.WriteString(tk.val)
b.WriteByte(' ')
case tokComment:
if !newLine {
b.WriteString(" ")
} else {
b.WriteString(strings.Repeat(" ", depth))
newLine = false
}
b.WriteString(tk.val)
b.WriteByte('\n')
newLine = true
case tokSymbol:
switch tk.val {
case "(":
b.WriteString(" (")
depth++
newLine = true
case ")":
depth--
if !newLine {
b.WriteByte('\n')
b.WriteString(strings.Repeat(" ", depth))
} else {
b.WriteString(strings.Repeat(" ", depth))
}
b.WriteString(")")
newLine = true
case ",":
b.WriteString(",")
newLine = true
case ";":
b.WriteString(";")
newLine = true
}
default:
upper := strings.ToUpper(tk.val)
if topKw[upper] {
if !newLine {
b.WriteByte('\n')
}
b.WriteString(strings.Repeat(" ", depth))
b.WriteString(upper)
b.WriteByte(' ')
newLine = false
} else {
if newLine {
b.WriteString(strings.Repeat(" ", depth))
newLine = false
}
b.WriteString(tk.val)
b.WriteByte(' ')
}
}
}
return strings.TrimSpace(b.String())
}
这个函数的关键点在于:字符串和注释不会触发新的换行,只会按需要补缩进;左括号会把深度加一,并让后续内容从新行开始;右括号会先降低深度,再输出右括号本身。这样如果一个子查询位于括号中,它内部的SELECT会自动比外层多一级缩进。
例如select u.id from users u where u.status=1 and (select count(*) from logs l where l.user_id=u.id)>3 order by u.id desc,会被格式化成SELECT、FROM、WHERE各自成行,子查询的括号内容再缩进一层。虽然这个规则还比较简单,但已经能覆盖大量日常查询。
把上面的lexer和formatTokens组装起来,可以得到一个可调用的入口:
func Pretty(sql string) string {
lexer := &lexer{src: []rune(sql)}
var tokens []token
for {
tk, ok := lexer.next()
if !ok {
break
}
tokens = append(tokens, tk)
}
return formatTokens(tokens)
}
需要说明的是,这段代码把AND和OR也放进了topKw,因此在WHERE条件较长时,它们同样会换行。这种风格比较接近常见SQL美化工具的输出,但如果希望条件运算符紧跟上一行,也可以将AND、OR从topKw中移除,改成逗号一样的行尾处理。
四、实用扩展与边界处理
真实项目中的SQL往往比示例复杂得多。CTE的WITH子句、CASE WHEN表达式、多重JOIN和UNION都会增加格式化难度。对于CTE,可以把WITH加入关键字集合,让每个CTE定义独占一行;对于CASE,可以将WHEN、THEN、ELSE、END也纳入关键字,但要注意缩进级别是否需要单独增加。
注释保留是另一个容易忽略的问题。生产环境中的SQL经常带有标注,例如解释查询意图、标明修改人,或者提示某个索引的使用原因。格式化工具不能因为美化就丢弃这些信息。上面的实现会保留行注释和块注释,并尽量让注释出现在独立行中。如果注释原本在代码行尾部,格式化后可能被调整到新的一行,但只要内容完整,可读性通常不会下降。
多语句边界也需要处理。对于包含分号的批量SQL,分号后应强制换行,并把缩进重置为零。字符串里的分号因为已经被识别为字符串token,所以不会触发这个逻辑。类似地,括号内的逗号会换行,而函数参数中的逗号是否换行取决于具体风格。如果不想让COUNT(a, b)这类短函数调用被拆散,可以增加一个判断:只有括号深度大于零且括号内token数量较多时才在逗号后换行。
性能方面,使用strings.Builder已经能避免反复拼接字符串带来的内存分配。对于日志和调试场景,这样的格式化器足够快,不必引入解析器级别的完整语法树。相比之下,完整的SQL解析库虽然能提供更精确的结构信息,但依赖和复杂度会明显增加。如果只是做日志美化、慢查询展示或控制台输出,纯标准库实现是更轻量的选择。
不同的数据库方言也可能带来新的边界条件,例如MySQL的反引号标识符、PostgreSQL的美元引用字符串、SQL Server的方括号标识符。本文的lexer可以根据需要扩展,关键原则不变:先识别出完整字符串和注释,再处理结构符号,最后才判断关键字。这个顺序保证了格式化器不会破坏SQL语义,也让后续增加新规则更加安全。
总的来说,从简单关键字换行升级到词法分析和括号深度控制,投入的代码量并不大,但能显著提升输出稳定性。对于需要经常阅读动态拼接SQL的场景,一个可控的格式化器比盲目引入重依赖更实用,也更容易根据团队风格调整缩进、关键字大小写和注释策略。