news 2026/9/26 7:56:24

手写SQL解析器:词法分析、AST与生产级选型实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写SQL解析器:词法分析、AST与生产级选型实践

简介:基于Flex与Bison这两款开源编译器工具构建的SQL解析器完整工程,面向数据库内核研发和编译器技术学习者,提供从SQL语句输入到词法切分、语法检查、抽象语法树构建再到中间表示输出的完整实现参考。压缩包共包含11个文件,以四个C源文件、两个头文件、一份Bison语法文件、一份Flex词法文件、两份SQL测试脚本以及一份AST模块文件组成,整体仅54KB,体积小巧但模块划分清晰。已有690人学习浏览,适合作为教学演示和自定义数据库解析模块的基础。深入阅读源码后,读者能够掌握词法规则中关键字、标识符、操作符的识别方式,语法规则中查询、插入、更新、删除语句的解析流程,以及错误处理与恢复机制;同时可对照Bison生成的状态栈和移进归约过程,理解编译原理中的LR分析与抽象语法树构建,真正将理论落地为可运行的代码。

1. 为什么现在还有人在找SQL解析器的完整代码

做数据中间件的人迟早会撞上一个坎:想审计、改写、分表路由一条SQL,必须先能理解这条SQL。SQL解析器干的事就是把一段SQL文本拆成可编程的AST,上层业务再来做权限校验、字段级治理和慢查询改写。一个反直觉的结论是:生产环境里真正难的不是“把SQL解析出来”,而是解析结果怎么和你的业务模型对上;这也是很多人拿到ANTLR生成代码后依然在公司里内耗几个星期的原因。这篇笔记面向做SQL审核平台、数据网关、逻辑Schema映射,以及被前端动态拼SQL折磨的后端。我会给出一个能解析常见SELECT的最小Python实现,再把生产级方案选型和踩过的坑一并讲透。

2. 拆解SQL解析器的四个骨架:词法、语法、AST与方言适配

一个能用的SQL解析器,无论手写还是用生成器产出,都逃不过四层结构:词法分析、语法分析、AST构建和方言处理。我习惯把AST单独拿出来和语法分析并列,因为AST的schema决定了上层所有业务代码的写法。下面按顺序把这四层讲透,每层都给出选择理由和边界。

2.1 词法分析:输入是字符串,输出是token流

词法分析是最机械的一步,负责把SELECT a, b FROM t1 WHERE a > 1切成SELECT、a、逗号、b、FROM、t1、WHERE、a、>、1这样的token。每个token至少带两样东西:一是类型,包括关键字、标识符、数字、字符串、运算符;二是原始文本,因为后面做诊断和SQL还原都要用。

SQL词法有它独特的麻烦。第一,关键字必须大小写不敏感,select和SELECT都要识别成同一个token;第二,字符串里出现的关键字不能算关键字,比如'where'就是一个普通字符串;第三,注释要完全跳过,但注释里的单引号同样不应该开启字符串。处理这三件事最简单的办法,是把字符串正则排在注释正则前面,正则按顺序尝试,先匹配到谁算谁。我后面给的代码用的就是这种顺序,靠正则收割而不是逐字符硬啃,能省掉一半边界bug。

标识符和关键字的区分方式直接影响后续复杂度。常见的做法是先按标识符切出来,再查一张关键字集合,命中就把token类型换成关键字。这样词法器只需要维护一个集合,规则上“先标识符、后查表”,比正则直接写死每个关键字好用得多。参数上有个提示:如果未来要支持prepared statement,记得在token类型里留一个占位类型,比如?或$1,不要让它在运算符匹配里被吞掉。MySQL和PostgreSQL的占位写法不一样,这里向下兼容是很容易踩坑的地方。

2.2 语法分析:把token流拼成有层次的AST

语法分析决定解析器的“品性”。常见路线有两条:手写递归下降,或者用生成器产出parser。

手写递归下降的核心,就是用一个函数对应一个语法非终结符。parse_select、parse_from、parse_expr各管一段,函数之间互相调用,用调用栈天然表达语法嵌套。它的最大优点在于错误信息可控、调试直观、没有生成步骤;缺点是代码量会随着语法规模线性增长,SQL这种几百条规则的语言,手写全量是很重的体力活。生成器路线以ANTLR为代表,语法规则用g4文件声明式书写,工具自动生成词法器和语法分析器,语法规则改起来快;但生成代码像个黑盒,出错信息差,构建链也更长。

语法分析阶段最花时间的是处理歧义。SQL里一个token流可能对应多种解释,比如SELECT a FROM t中的a,既可以视作列名,也可能被误解为别名。手写实现的解法是靠“向前看”:解析到FROM之前,只把a当作select item;一旦遇到FROM,自然知道前面是表引用。递归下降里一个peek就能处理这类情况,这也是我不建议一上来就上LR状态机的原因。

表达式优先级是递归下降最经典的用武之地。把优先级从低到高排成函数调用链:or → and → not → 比较 → 加减 → 乘除 → 括号。SQL里a OR b AND c为什么等价于a OR (b AND c),就是在parse_and把parse_or的右操作数“包住”时固化的。谁在调用链上层,谁的优先级就低,这个次序写反了,整个WHERE解析都会跟着乱。

2.3 AST设计:别急着调试,先把AST格式定下来

AST是整个解析器最重要的契约。它不该照着SQL的文本结构做,而应该照着“你的业务要消费什么”做。我至少会定义这几类节点:Select、SelectItems、ColumnRef、Literal、TableRef、Join、BinaryExpr、Limit。每类节点只用type、value、children三个字段描述,尽量不引入特殊字段,这样序列化和遍历都只需写一个递归函数。

动手解析前,最好先定两件事。一是AST的可读输出格式。我一般给ASTNode实现一个__repr__,把嵌套结构打印成Select[all](...)这样的文本,调试时直接print出来对照预期;二是toDict、fromDict的序列化方案。因为后面要做AST持久化或跨语言调用,比如把Python解析出来的AST丢给Java消费,JSON互通最省事。

还有一点经常被忽略:AST节点要不要保存原始位置,也就是行号和列号。解析诊断和慢SQL溯源都依赖它,但保存位置会让节点变得很重。我的折衷是只在ColumnRef和BinaryExpr上记录offset,其他节点不记录;等真要处理语法高亮或错误定位时再补。这个决定只有等你真的做审核平台时才会有体感,但提前想好比事后全量改动便宜得多。

2.4 方言层:一个解析器打不了天下

SQL解析器做得多了,你会接受一个现实:不存在一个“标准SQL”让所有数据库都听它的话。分页就是一个典型例子。MySQL和SQLite用LIMIT,PostgreSQL在LIMIT之外还有OFFSET和FETCH FIRST,SQL Server用SELECT TOP,老Oracle只能靠ROWNUM。方言差异甚至会侵入词法——PostgreSQL里的双引号是标识符,MySQL里的双引号却默认是字符串;同样的SELECT "a" FROM t,在两种数据库里含义完全不同。

所以做解析器的第一步,不是写代码,而是先问自己:我要解析哪个数据库的SQL。只做MySQL就把它当唯一方言,用语法文件约束好字符串引号、反引号表名和ON DUPLICATE KEY UPDATE;要做多方言,就得在AST之后加一层“方言归一化”,把LIMIT、TOP、ROWNUM统一成分页节点,再做输出。很多公司最后做成“入口多方言、出口统一AST”,原因就在这里。

方言问题还体现在关键字集合上。rank、over、window在SQL Server里是保留字,在MySQL老版本里却是普通列名。别想用一个全量关键字集合覆盖所有库,否则你的词法器会在合法SQL上误伤。我的做法是关键字集合做成参数,解析MySQL时用一个集合、解析PostgreSQL时用另一个,两套集合之间只共享基础的那几十个。

3. 用Python写一个最小可运行的SQL SELECT解析器:完整代码分三段讲

这一章给出一份能直接跑的完整实现。代码按词法、语法、表达式拆成三段讲,最终拼起来能解析SELECT、FROM、JOIN、ON、WHERE、LIMIT和OFFSET。示例SQL故意控制在小范围,但每一处扩展点我都会标出来。

3.1 词法核心:正则表的顺序就是边界控制

先看词法部分。这里最重要的不是识别规则多全,而是TOKEN_REGEX的排列顺序:

import re KEYWORDS = { "select", "from", "where", "limit", "and", "or", "not", "null", "join", "left", "right", "inner", "on", "as", "group", "by", "having", "order", "desc", "asc", "distinct", "insert", "into", "values", "update", "set", "delete", "like", "in", "exists", "between", "case", "when", "then", "else", "end", "offset", } TOKEN_REGEX = [ ("SPACE", r"\s+"), ("STRING", r"'(\\.|[^'\\])*'|\"(\\.|[^\"\\])*\""), ("COMMENT", r"--[^\n]*|/\*[\s\S]*?\*/"), ("NUMBER", r"\d+(\.\d+)?"), ("IDENT", r"[A-Za-z_][A-Za-z0-9_$]*"), ("OP", r"<=|>=|<>|!=|=|<|>|\(|\)|,|\*|;|\+|-|/"), ] def tokenize(sql: str): tokens = [] pos = 0 while pos < len(sql): matched = False for ttype, pattern in TOKEN_REGEX: m = re.match(pattern, sql[pos:]) if not m: continue val = m.group(0) pos += len(val) matched = True if ttype in ("SPACE", "COMMENT"): break if ttype == "IDENT" and val.lower() in KEYWORDS: tokens.append(("KEYWORD", val.lower())) else: tokens.append((ttype, val)) break if not matched: raise SyntaxError(f"无法识别的字符,位置 {pos}: {sql[pos:pos + 20]!r}") tokens.append(("EOF", "")) return tokens

这段代码里的顺序很关键。STRING排在COMMENT前面,是为了避免注释里的单引号被当成字符串起点;如果你把注释排到前面,-- it's not a comment会被切得七零八落。COMMENT排在SPACE后面,用空白符先吃掉缩进,不会影响行号统计。IDENT的正则覆盖绝大多数表名和列名,之后查KEYWORDS集合决定是否降级成关键字,这样大小写不敏感的处理只发生在一个地方。

运算符正则有两条硬规则。一是<=必须列在<前面,<>列在=前面,否则a<=b会被切成一个a、一个<、一个=、一个b。二是整体按长度从长到短排列,这是词法器里很不起眼但特别实在的规则。字符串正则里用了(\\.|[^'\\])*,能识别普通转义,但SQL标准的字符串单引号转义是两个单引号相连,这个正则读起来会把第二个单引号当成字符串结束,生产实现里需要单独补。

3.2 语法分析主体:ASTNode与递归下降Parser

下面这份文件把词法部分一并包含进来,方便你单文件直接运行。ASTNode用type、value、children三个字段描述节点,Parser按递归下降组织,每个语法成分对应一个方法:

import re KEYWORDS = { "select", "from", "where", "limit", "and", "or", "not", "null", "join", "left", "right", "inner", "on", "as", "group", "by", "having", "order", "desc", "asc", "distinct", "insert", "into", "values", "update", "set", "delete", "like", "in", "exists", "between", "case", "when", "then", "else", "end", "offset", } TOKEN_REGEX = [ ("SPACE", r"\s+"), ("STRING", r"'(\\.|[^'\\])*'|\"(\\.|[^\"\\])*\""), ("COMMENT", r"--[^\n]*|/\*[\s\S]*?\*/"), ("NUMBER", r"\d+(\.\d+)?"), ("IDENT", r"[A-Za-z_][A-Za-z0-9_$]*"), ("OP", r"<=|>=|<>|!=|=|<|>|\(|\)|,|\*|;|\+|-|/"), ] def tokenize(sql: str): tokens = [] pos = 0 while pos < len(sql): matched = False for ttype, pattern in TOKEN_REGEX: m = re.match(pattern, sql[pos:]) if not m: continue val = m.group(0) pos += len(val) matched = True if ttype in ("SPACE", "COMMENT"): break if ttype == "IDENT" and val.lower() in KEYWORDS: tokens.append(("KEYWORD", val.lower())) else: tokens.append((ttype, val)) break if not matched: raise SyntaxError(f"无法识别的字符,位置 {pos}: {sql[pos:pos + 20]!r}") tokens.append(("EOF", "")) return tokens class ASTNode: __slots__ = ("type", "value", "children") def __init__(self, type_, value=None, children=None): self.type = type_ self.value = value self.children = children or [] def __repr__(self): if self.children: guts = ", ".join(repr(c) for c in self.children) prefix = f"{self.type}[{self.value}]" if self.value is not None else self.type return f"{prefix}({guts})" return f"{self.type}[{self.value}]" if self.value is not None else f"{self.type}" class Parser: def __init__(self, tokens): self.tokens = tokens self.pos = 0 def peek(self): return self.tokens[self.pos] def next(self): t = self.tokens[self.pos] self.pos += 1 return t def match(self, type_, value=None): t = self.peek() if t[0] != type_: return False if value is not None and t[1].lower() != value.lower(): return False return True def expect(self, type_, value=None): t = self.next() if t[0] != type_: raise SyntaxError(f"期望 {type_},实际得到 {t}") if value is not None and t[1].lower() != value.lower(): raise SyntaxError(f"期望 {value},实际得到 {t[1]}") return t def parse(self): stmt = self.parse_select() self.expect("EOF") return stmt def parse_select(self): self.expect("KEYWORD", "select") distinct = self.match("KEYWORD", "distinct") node = ASTNode("Select", "distinct" if distinct else "all") if distinct: self.next() node.children.append(self.parse_select_items()) if self.match("KEYWORD", "from"): self.next() node.children.append(self.parse_from()) if self.match("KEYWORD", "where"): self.next() node.children.append(self.parse_expr()) if self.match("KEYWORD", "limit"): self.next() node.children.append(self.parse_limit()) return node def parse_select_items(self): items = ASTNode("SelectItems") while True: if self.match("OP", "*"): self.next() items.children.append(ASTNode("Star")) if self.match("OP", ","): self.next() continue break col = self.parse_additive() if self.match("KEYWORD", "as"): self.next() alias = self.expect("IDENT")[1] col = ASTNode("AliasedExpr", alias, [col]) items.children.append(col) if self.match("OP", ","): self.next() continue break return items def parse_from(self): node = ASTNode("From") node.children.append(self.parse_table_ref()) while True: join_type = None if self.match("KEYWORD", "join"): self.next() join_type = "join" elif self.match("KEYWORD", "left"): self.next() self.expect("KEYWORD", "join") join_type = "left join" elif self.match("KEYWORD", "right"): self.next() self.expect("KEYWORD", "join") join_type = "right join" elif self.match("KEYWORD", "inner"): self.next() self.expect("KEYWORD", "join") join_type = "inner join" else: break table = self.parse_table_ref() on_expr = None if self.match("KEYWORD", "on"): self.next() on_expr = self.parse_expr() join_node = ASTNode("Join", join_type, [table] + ([on_expr] if on_expr else [])) node.children.append(join_node) return node def parse_table_ref(self): t = self.next() if t[0] != "IDENT": raise SyntaxError(f"期望表名,实际得到 {t}") alias = None if self.match("KEYWORD", "as"): self.next() alias = self.expect("IDENT")[1] elif self.peek()[0] == "IDENT" and self.peek()[1].lower() not in { "where", "limit", "join", "left", "right", "inner", "on", "group", "order", "having", "offset"}: alias = self.next()[1] return ASTNode("TableRef", t[1], [ASTNode("Alias", alias)] if alias else []) def parse_limit(self): num = self.next() if num[0] != "NUMBER": raise SyntaxError(f"LIMIT 后期望数字,实际得到 {num}") offset = None if self.match("KEYWORD", "offset"): self.next() off = self.next() if off[0] != "NUMBER": raise SyntaxError(f"OFFSET 后期望数字,实际得到 {off}") offset = off[1] return ASTNode("Limit", num[1], [ASTNode("Offset", offset)] if offset else [])

ASTNode的三个字段里,children有时是表达式节点,有时是附加信息节点,比如TableRef的Alias子节点。这种“统一字段”的设计适合小规模解析器,但到大语法时建议引入更明确的类型体系,否则visitor里全靠type字符串做分支,容易写成长串if-else。Parser的peek、next、match、expect四个方法就是递归下降的通用基础设施,match只做预判不消费token,expect消费并检查类型,这一个组合能覆盖绝大多数LL(1)场景。

parse_table_ref里那段“连续IDENT判断别名”是手写SQL解析器最常见的trick:在FROM子句里,一个IDENT后面如果紧跟另一个IDENT,且后者不是WHERE、LIMIT、JOIN等子句关键字,那它大概率就是别名。这个启发式判断能省掉大量的语法分支,但前提是关键字集合覆盖够准。如果你解析的对象是PostgreSQL,还要额外处理AS带引号标识符的情况。

3.3 表达式递归下降与运行入口

WHERE里的条件表达式需要单独一组方法,优先级从低到高依次是or、and、not、比较、加减、乘除、括号。这段代码补在Parser类里,覆盖最常见的条件组合:

def parse_expr(self): return self.parse_or() def parse_or(self): left = self.parse_and() while self.match("KEYWORD", "or"): self.next() left = ASTNode("BinaryExpr", "or", [left, self.parse_and()]) return left def parse_and(self): left = self.parse_not() while self.match("KEYWORD", "and"): self.next() left = ASTNode("BinaryExpr", "and", [left, self.parse_not()]) return left def parse_not(self): if self.match("KEYWORD", "not"): self.next() return ASTNode("Not", None, [self.parse_not()]) return self.parse_compare() def parse_compare(self): left = self.parse_additive() if self.match("OP") and self.peek()[1] in ("=", "!=", "<>", "<", ">", "<=", ">="): op = self.next()[1] return ASTNode("BinaryExpr", op, [left, self.parse_additive()]) return left def parse_additive(self): left = self.parse_multiplicative() while self.match("OP") and self.peek()[1] in ("+", "-"): op = self.next()[1] left = ASTNode("BinaryExpr", op, [left, self.parse_multiplicative()]) return left def parse_multiplicative(self): left = self.parse_primary() while self.match("OP") and self.peek()[1] in ("*", "/"): op = self.next()[1] left = ASTNode("BinaryExpr", op, [left, self.parse_primary()]) return left def parse_primary(self): t = self.next() if t[0] in ("NUMBER", "STRING"): return ASTNode("Literal", t[1]) if t[0] == "KEYWORD" and t[1] == "null": return ASTNode("Literal", "null") if t[0] == "IDENT": if self.match("OP") and self.peek()[1] == ".": self.next() col = self.next() if col[0] != "IDENT": raise SyntaxError(f"点号后期望列名,实际得到 {col}") return ASTNode("ColumnRef", f"{t[1]}.{col[1]}") return ASTNode("ColumnRef", t[1]) if t[0] == "OP" and t[1] == "(": expr = self.parse_expr() self.expect("OP", ")") return expr raise SyntaxError(f"无法解析的表达式起点: {t}") def parse_sql(sql: str): return Parser(tokenize(sql)).parse() if __name__ == "__main__": sql = ("SELECT name, salary * 1.1 AS new_salary, e.dept_id " "FROM employees AS e " "JOIN departments d ON e.dept_id = d.id " "WHERE salary > 5000 AND e.dept_id = 3 " "LIMIT 10 OFFSET 20") ast = parse_sql(sql) print(ast)

这一段把比较表达式接在additive层之后,所以salary * 1.1能够正确拼成乘法节点,再参与比较;如果直接让parse_primary处理所有操作符,1 + 2 > 3就会解析出错。parse_primary里顺便支持了t.col这种带表前缀的列名,这是JOIN场景里最常用的写法,也是后面做表名溯源的基础。

提示:这份代码不需要任何第三方库,Python 3.8以上可以直接运行。当前实现只覆盖SELECT子集,但每层递归函数都是独立扩展点,想加GROUP BY就在parse_select里照抄LIMIT的处理再加一圈判断即可。

运行上面这段入口,输出的AST大致长这样:

Select[all](SelectItems(ColumnRef[name], AliasedExpr[new_salary](BinaryExpr[*](ColumnRef[salary], Literal[1.1])), ColumnRef[e.dept_id]), From(TableRef[employees](Alias[e]), Join[join](TableRef[departments](Alias[d]), BinaryExpr[=](ColumnRef[e.dept_id], ColumnRef[d.id]))), BinaryExpr[and](BinaryExpr[>](ColumnRef[salary], Literal[5000]), BinaryExpr[=](ColumnRef[e.dept_id], Literal[3])), Limit[10](Offset[20]))

看到这个输出,解析器基本算成形了。下一步才是真正的分水岭:拿它去解析线上真实SQL。

4. 生产级选型再判断:ANTLR生成器、pg_query与Rust解析器怎么挑

手写解析器适合教学和体量小的场景,但到生产环境,团队通常会在ANTLR、pg_query和sqlparser-rs(Rust系)里做选择。这一章我把三类方案的适用边界讲清楚,帮你少走弯路。

4.1 ANTLR4:上限最高,但成本藏在grammar之外

ANTLR把语法规则写进g4文件,然后用工具生成词法器和解析器。我见过团队选ANTLR,大多是为了“一个语法文件支持多语言输出”——同一份g4生成Java、Python、Go三份解析代码,这是它最大的卖点。

但ANTLR项目的真实难点不是生成,而是三件事。第一,SQL的grammar文件要自己攒,网上能找到的MySQL、PostgreSQL grammar大多只覆盖常用子集,一跑生产SQL就崩;第二,ANTLR的默认错误恢复策略是“吞token找同步点”,报错位置经常让人摸不着头脑;第三,生成的解析树是监听器模式,你要自己写visitor把它转换成业务AST,这一步的工作量比写一个基础语法分析器还大。

什么时候选它?团队需要多语言复用、语法规则频繁变更,或者要做SQL格式化这类强语法工程时,ANTLR是值得的。否则,为一个审核平台引入ANTLR,等于给自己造了一个需要长期维护的SDK。

4.2 pg_query:PostgreSQL语义最准,方言绑定最死

pg_query 是把PostgreSQL的C解析器封装出来,返回JSON格式的AST。它最大的价值在于“语义正确”——PostgreSQL官方解析器对类型、关键字、操作符的处理,是所有开源解析器里最接近真实数据库行为的,做审计平台时这一点能省很多折腾。

代价是它只认PostgreSQL。MySQL的ON DUPLICATE KEY UPDATE、反引号、LIMIT n, m这些语法都会直接报错。如果你公司的主力库是PG,用它做解析层是最省心的;一旦要多库兼容,就得上多个解析器然后做AST归一化,工作量和坑都会翻倍。

4.3 sqlparser-rs:方言广、性能好,但值得先做一次POC

Rust系的sqlparser库近年热度很高,宣称支持十几种SQL方言,从SQLite、MySQL到Snowflake都有覆盖。我实际用的感受是:覆盖度确实比ANTLR攒的grammar强很多,性能也稳,用FFI给Java、Go、Python调用都没问题。

不满意的点也很明确:错误诊断依旧粗糙,碰到它没见过的语法,抛出的错误信息对业务方很不友好;另外它对存储过程、多层嵌套WITH这类复杂语法的支持还时常翻车。我的建议是先拿公司真实SQL做一轮POC,统计通过率,不要看README支持列表就拍板。

方案典型语言方言覆盖语义准确性集成成本适用场景
手写递归下降自选自己控制自己负责低学习、私有语法、轻量中间件
ANTLR4多语言依赖grammar中高多语言复用、语法工程
pg_queryC/绑定PostgreSQL为主高中PG生态的数据平台
sqlparser-rsRust多数据库中高中多方言场景,适合先POC

4.4 我的选型顺序

在不知道具体业务背景的情况下,我给一个通用的决策顺序:先确认主力数据库,再确认是“必须原生支持”还是“AST层归一化”,最后看团队语言。

流程通常是这样的:如果是PG独苗,pg_query直接上;如果公司以MySQL为主且业务偏OLTP,手写一个覆盖常用SELECT、INSERT、UPDATE、DELETE的解析器完全够用,我见过不少SQL审核平台就这么活得很稳;如果要兼容十几种数据源,那不用犹豫,直接拿sqlparser-rs做POC,通过率达标就定它。

反过来,如果团队没有专门的解析器维护人力,别一上来就考虑“完美支持所有语法”。圈定业务使用的SQL子集,把解析器做窄、做强,比堆方言覆盖率划算得多。解析器这种组件,活得久比功能全重要。

5. 解析器避坑指南:五个让我翻过车的细节

以下五条现象都来自真实解析场景。每条按“现象、原因、解决”展开,专门写给第一次把解析器应用到生产的人。

5.1 关键字被当成标识符:user、order、rank全倒

现象:解析SELECT order FROM t时把order当成了关键字报错,或者反过来,解析SELECT user FROM t时把user当列名后,在WHERE关键字剔除环节又漏了。

原因:词法器在“标识符vs关键字”的判断上做得太死。SQL关键字是上下文相关的,order在ORDER BY里是关键字,作为列名时又是合法标识符;user在MySQL里则始终不是保留字。用一张静态表一刀切,是行不通的。

解决:词法器只负责把IDENT切出来并打上“原文”标记,关键字判断推迟到语法层。解析时按位置区分——在select items、表名别名位置,只要看到IDENT就当作标识符接受;在需要出现ORDER BY的位置才要求KEYWORD。换句话说,关键字集合不能只做一次查表,而要配合语法规则动态取用。

5.2 字符串里的关键字和注释相互干扰

现象:一条SQL里有-- 这一行说明,'where'不算字符串,词法器把它切得七零八落;或者反过来,字符串字面量里出现了--,被识别成注释开头,把语句拦腰截断。

原因:词法器用单条正则从头到尾扫,没有优先级。注释表达式写前面,会吃掉字符串里的内容;字符串表达式写前面,又会误吞注释里的引号。这两类规则天然有交集,必须显式定顺序。

解决:按“长规则优先、字符串先于注释”的顺序排正则表,且每条正则用非贪婪匹配收尾。我在第3章的TOKEN_REGEX里就是把STRING放在COMMENT之前,实测能覆盖绝大多数整行注释和块注释混用场景。如果还扛不住,就该把词法器升级成自动机:维护一个in_string、in_comment的状态栈,逐字符切换,但那是另一个工作量层级了。

5.3 表达式优先级里最隐蔽的坑:BETWEEN和AND

现象:WHERE a BETWEEN 1 AND 10 OR b = 2被判成WHERE a BETWEEN (1 AND 10) OR b = 2,数据库引擎直接报错或给出完全不同的语义。

原因:BETWEEN语法在标准SQL里是expr BETWEEN low AND high,其中的AND优先级和普通AND重叠。大多数递归下降实现默认AND在OR之上,还没处理BETWEEN就把它交给parse_and去拼,于是AND被当成二元表达式的一部分。

解决:在parse_compare内部单独做分支:见到BETWEEN,先解析low,再expect关键字AND,再解析high,把三部分组装成BetweenExpr节点。不要让它流到parse_and通用逻辑里。同理,IN (...)、EXISTS (...)这类带关键字边界的表达式,都得在比较层一个一个显式写,不能偷懒。

5.4 递归深度失控:一万层括号就是一万次函数调用

现象:一条恶意SQL带几千层嵌套括号或连续AND,解析器抛RecursionError;在C++实现里更直接,进程栈溢出崩溃。

原因:递归下降解析器天然一层函数调用一层栈,深度不受控制时,栈空间无法无限增长。SQL语句的嵌套深度来自用户输入,恶意构造的成本极低。

解决:在Parser里加一个max_depth,parse_primary递归进括号时深度加一,超过阈值比如500,直接抛语法错误。生产环境还要给输入的SQL文本长度设硬上限。别把这种SQL放到解析器后面才拦截,词法层就能用token总数做第一道闸。

5.5 token位置信息缺失,诊断全靠瞎猜

现象:语法报错只给“第xx个token附近”,生产SQL几千个token,运维拿着报错没法定位到具体列。

原因:AST节点没有保存原始位置,错误只抛了token类型没带offset。词法器切完就把位置丢了,等到语法层想报行号时已经无据可查。

解决:token结构里带上start和end两个整数偏移量,AST关键节点从token拷一份位置。报错时输出“位置、错误”两个信息。这个字段一加,后续做SQL格式化、语法高亮、审计定位全都能顺带受益。

6. 让解析器真正落地:AST遍历、查询改写与回归验证

手写解析器最容易死在“写完就不动了”。我建议立刻做三件事:AST遍历、查询改写、回归测试。

先写一个visitor。以下是提取SELECT语句涉及的所有表名的代码:

def extract_tables(node, tables=None): if tables is None: tables = set() if node.type == "TableRef": tables.add(node.value) for child in node.children: extract_tables(child, tables) return tables print(extract_tables(ast))

思路是深度优先遍历,命中TableRef就把value收进集合。这段代码直接解决了审核平台最常见的需求:统计这条SQL动了哪些表。

改写是AST能力的试金石。比如给所有没有LIMIT的查询自动补LIMIT 2000,防止线上大查询把资源打满:

def enforce_limit(node, limit=2000): if node.type == "Select": if not any(child.type == "Limit" for child in node.children): node.children.append(ASTNode("Limit", str(limit))) for child in node.children: enforce_limit(child, limit) enforce_limit(ast) print(ast)

这个函数在纯AST层面工作,不碰SQL文本,所以改写永远不会碰上字符串拼接的转义问题。这也是“解析器比正则靠谱”最直接的体现。

回归验证不能靠手写SQL。我的做法是准备三批SQL:业务日志里的真实SQL(脱敏后)、手工构造的边界SQL(超长嵌套、奇怪关键字、空字符串)、以及带各种注释和换行的脏SQL。每改一次词法或语法规则,全量跑一遍,把失败SQL的错误信息留下来看差异。另外还建议做“往返验证”:解析AST后打印回SQL,对比和原文的token流是否一致,能自动揪出信息丢失。

这套东西做完,解析器才算真的能交给同事和线上业务用。我自己吃过一个亏:当年自研的SQL审核平台上线第一周,就被一个WHERE a BETWEEN 1 AND 10 OR b = 2式的慢查询漏审了,原因正是优先级处理少写了一个分支。从那以后,每条新规则都必须先落在回归集里再上线。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 7:56:22

金融科技系统建设实战:账户、对账与风控合规的关键设计

金融科技这个圈子很有意思&#xff1a;很多人以为“financial-services”项目就是做个App、接个支付、挂个行情&#xff0c;但真正从0到1做过的人都知道&#xff0c;这个领域最难的从来不是界面和功能&#xff0c;而是账怎么记、钱怎么动、风险怎么拦、审计怎么过。我在过去几年…

作者头像 李华
网站建设 2026/9/26 7:56:10

MySQL教材源码包使用指南:从环境配置到数据导入的完整教程

简介&#xff1a;面向MySQL数据库初学者的配套源代码包&#xff0c;围绕《MySQL数据库基础实例教程&#xff08;第3版&#xff09;&#xff08;微课版&#xff09;》设计&#xff0c;按例题、案例、实训、实战四个模块组织&#xff0c;覆盖从基础建表到综合项目开发的完整练习路…

作者头像 李华
网站建设 2026/9/26 7:55:01

Selenium自动化测试框架核心原理与工程实践:从WebDriver到Page Object

1. 为什么我最终选择了Selenium作为自动化测试的起点做自动化测试这些年&#xff0c;身边总有人问我&#xff1a;市面上那么多工具&#xff0c;Cypress、Playwright、Appium&#xff0c;为什么你最终扎根在Selenium上&#xff1f;这个问题其实挺有意思的&#xff0c;我得从一次…

作者头像 李华
网站建设 2026/9/26 7:54:55

VFP缓冲表入门:用CURSORSETPROP与TableUpdate把增删改做稳

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 7:54:18

课堂笔记全流程拆解:从记录到复盘的高效笔记法

今天整理笔记的时候&#xff0c;翻到了2026年1月8日那天的课堂记录&#xff0c;仔细读完发现那天讲的东西其实特别系统&#xff0c;正好把“怎么记笔记、怎么用笔记”这条线从头到尾捋了一遍。很多同学总觉得课堂笔记就是把老师说的每句话都抄下来&#xff0c;或者期末前找份学…

作者头像 李华
网站建设 2026/9/26 7:53:48

openClaw Windows安装实战:接入DeepSeek和Discord

先交代一下背景&#xff1a;我这次折腾的是 openClaw 3.8 在 Windows 上的完整安装&#xff0c;模型后端接了 DeepSeek&#xff0c;消息渠道选了 Discord。这套组合跑通之后&#xff0c;相当于给自己养了一个 7x24 小时待在 Discord 里的 AI 助手——你在频道里喊它&#xff0c…

作者头像 李华