尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SQL解析利器sqlparse:从格式化到AST遍历的Python实战指南

SQL解析利器sqlparse:从格式化到AST遍历的Python实战指南 做数据开发这几年我越来越觉得sqlparse就是那种平时不起眼、但真到用的时候能救命的小工具。它是Python生态里最常用的SQL解析库不依赖任何第三方库纯Python实现做的事情很专一帮你把SQL语句拆开、理顺、格式化甚至按语法结构一层层剥开来看。最近好几个项目——一个是自动化报表平台的SQL审计一个是数据脱敏工具——都靠它撑起来的。这篇文章我就把sqlparse的完整玩法、踩过的坑、以及怎么把它嵌进真实工程里的经验一次性讲透希望能给同样在处理SQL文本、SQL脚本的读者一点参考。在动手写业务代码之前先聊清楚一个问题为什么我们不自己写字符串处理非要用一个解析库这个问题的答案得从SQL文本的复杂性说起。想想你平时面对的SQL长什么样有换行、有缩进、有注释、有字符串常量、有多层嵌套的子查询还有各种奇奇怪怪的写法。如果只用正则或split去切很容易翻车。比如按分号切分但字符串或者注释里也有分号比如按空格提取关键词但函数参数里的空格要怎么处理再比如注释里的内容长得像SQL却要被识别成注释而不是语句。这些都是边界问题自己造轮子写正则写完一轮又一轮总有覆盖不完整的地方。sqlparse解决的核心问题就是对SQL做词法分析和语法解析。词法分析就是把字符流拆成有意义的“单词”也就是Token语法解析就是把这些Token按SQL语法规则组合成有层级关系的结构。有了这个能力你就可以做很多事把乱糟糟的SQL格式化得漂漂亮亮把一条长脚本拆成一条条语句或者遍历解析出的Token树提取表名、字段、WHERE条件做静态分析和审计。它的解析是非验证性的——也就是说它不检查你的SQL是不是符合所有语法规则、表是否存在它只管“能不能读出来”。这个特点既是优点也是坑后面我会单独讲。适合用sqlparse的人我总结下来主要有几类一是写SQL客户端的后端开发要美化用户输入的SQL、做语法高亮的预处理二是做数据平台或可视化工具的人比如Superset、Redash这类开源项目里就内嵌了它三是写数据校验、脱敏、审计脚本的工程师四是想深入理解SQL结构、但不想自己手写解析器的同学。这篇文章的内容不挑基础我会从安装到深层API一路讲下去新手可以跟着操作老手则可以看看第四节里的遍历技巧和工程化注意点。1. sqlparse的定位它不是一个“校验器”先把最容易被误会的地方说清楚。很多人看到“SQL Parser”这个名字以为它能帮你检查SQL写得对不对。实际上sqlparse的设计目标并不是验证SQL语法正确性而是理解SQL的结构。官方文档说得明白它只做非验证性的解析。这意味着即使你给它一段明显有语法错误的SQL它也能解析出一个结果来只是那个结果可能不符合你的预期。这个定位的好处是解析速度快、容错性好能处理各种方言、各种不标准写法甚至能解析残缺的SQL片段。坏处是千万别拿它当语法校验工具用。我见过有同事在自动化测试里用sqlparse来判断SQL是否正确结果把错误SQL放过了。要校验SQL语法正确性应该用特定数据库的解析器比如PostgreSQL的pg_query、MySQL的sqlglot结合方言模式或者干脆把SQL丢给数据库EXPLAIN一下。另一个容易混淆的点是sqlparse也不会执行SQL它输入输出都是字符串或内存中的对象不连数据库也不做元数据解析。所以它极轻量整个库就是几个模块几乎不占依赖体积。下面是sqlparse的几个核心模块名称和作用先建立一个整体地图模块/对象作用sqlparse.lexer词法分析器把字符串拆成Token流sqlparse.parser语法解析器把Token流组合成语句结构sqlparse.sql定义Token、TokenList、Identifier、Statement等核心类sqlparse.tokensToken类型常量比如Keyword、Name、Operator、Whitespacesqlparse.filters过滤器框架用于格式化、脱敏、去注释等sqlparse.formatter封装了格式化入口内部通过Filters实现sqlparse.engine分组聚合逻辑把Token组织成更上层的结构sqlparse.keywordsSQL关键字定义集合供词法分析使用这种模块设计思路值得学。它把“切词”“组句”“结构抽象”“行为处理”四个层次分开每层只做一件事。你平时要是自己写文本解析工具也可以借鉴这种分层先有lexer产出原子单位再通过parser组装成中间结构最后用filters做变换而不是把所有逻辑堆在一个函数里。2. 安装与环境准备sqlparse的安装非常简单它没有那些令人头大的编译依赖不需要编译C扩展不挑操作系统。直接一句话pip install sqlparse如果你机器上有多个Python环境比如conda、pyenv或系统自带的Python要小心pip装到哪个环境里了。我建议在项目目录里用虚拟环境管理python -m venv venv source venv/bin/activate # Windows上用 venv\Scripts\activate pip install sqlparse python -c import sqlparse; print(sqlparse.__version__)这里有一个我在实操里遇到过的细节部分老旧教程会推荐pip install sqlparse0.2.4这样固定版本。如果你只是做基础格式化老版本也能用但如果你要用get_type()、get_name()这类方法或者要处理一些边缘语法我强烈建议装到0.4.x以上版本因为0.4.0之后修复了大量语句分组问题walk()方法的行为也稳定了很多。我当前环境里跑的是0.4.4下面所有代码都以这个版本为基准。装完之后快速验证一下能不能用跑这个import sqlparse sql select a,b,c from test_table where id1 print(sqlparse.format(sql, reindentTrue, keyword_caseupper))正常情况下你应该会看到一条被规范化的SQL输出。如果这一步就报错了大概率是Python版本或者pip源的问题。建议升级pippython -m pip install --upgrade pip然后把下载源切到国内镜像比如清华源。3. 三个最常用的入口format、split、parsesqlparse对绝大多数人来说只需要掌握三个顶层函数format()、split()、parse()。很多高阶玩法都是围绕它们展开的所以我把这三个函数的参数和返回结果彻底讲明白。3.1 format格式化SQLsqlparse.format(sql, **options)接受的第一个参数是原始SQL字符串然后用关键字参数控制格式化行为。我最常用的几个参数keyword_caseupper或lower统一关键词大小写。SQL习惯上关键词大写但很多开发写代码时习惯小写这个参数能一键统一。identifier_case统一表名、字段名大小写可选upper或lower谨慎使用因为数据库里的表名可能区分大小写。strip_commentsTrue删除所有注释包括行注释和块注释。reindentTrue重新缩进格式化层级关系。indent_width4缩进几个空格默认4也可以indent_tabsTrue改成Tab。indent_columnsTrue对列名进行对齐缩进多列时看起来更整齐。wrap_line_length80超过长度自动换行适合放入代码审查或日志系统。compactTrue去掉多余空格把语句压得更紧凑适合做存储优化或日志压缩。举个完整的例子看看效果import sqlparse ugly_sql SELECT order_id, customer_name, sum(amount) as total_amount from orders o join users u on o.user_idu.id where o.statusPAID and u.vip_level2 group by order_id,customer_name having sum(amount)1000 order by total_amount desc; good_sql sqlparse.format( ugly_sql, reindentTrue, keyword_caseupper, strip_commentsTrue, indent_width4, ) print(good_sql)输出大概是SELECT order_id, customer_name, sum(amount) AS total_amount FROM orders o JOIN users u ON o.user_id u.id WHERE o.status PAID AND u.vip_level 2 GROUP BY order_id, customer_name HAVING sum(amount) 1000 ORDER BY total_amount DESC;这里我的私人经验是不要把keyword_case和identifier_case同时设置成upper。因为有的数据库里表名或字段名是大小写敏感的尤其是用引号包起来的那种字段名一旦被统一成大写实际执行时会报“对象不存在”。格式化这种操作最好是“能不改业务语义就不改”。我一般只开启reindent和keyword_case其他参数按需开。3.2 split拆分SQL脚本sqlparse.split(sql)的返回结果是一个字符串列表每个元素是一条独立的SQL语句。它的拆分依据是分号但智能地绕开了注释、字符串和某些函数体中的分号。import sqlparse script -- 创建用户表 create table users ( id int primary key, name varchar(64) not null ); insert into users(id, name) values (1, a;b); select * from users; -- 这一行末尾有注释 statement_list sqlparse.split(script) for i, stmt in enumerate(statement_list, 1): print(f语句{i}: {stmt.strip()})输出会按三条语句剥离出来。注意第二条values (1, a;b)里的分号在字符串内部不会被误切这就是词法分析比字符串split强的地方。但要注意一个边界split()返回的是字符串不是Statement对象。如果你需要拿到语句类型比如区分“这是INSERT还是SELECT”得用parse()代替。3.3 parse返回可遍历的Statement对象sqlparse.parse(sql)是最核心的入口它返回一个Statement列表。Statement本质上是一个TokenList你可以遍历它、打印它、访问它的各种属性。同一个SQL用split()和parse()得到的东西一个相当于“切好的菜”原始文本块一个相当于“摆好结构的菜”可编程对象。import sqlparse sql select id, name from users where id 1 statements sqlparse.parse(sql) stmt statements[0] print(type(stmt)) print(stmt.get_type()) # 返回 DML 类型比如 SELECT print(stmt.get_name()) # 对某些语句返回对象名 print(type(stmt.tokens)) # 子token列表get_type()是我用得最多的方法。它能返回SELECT、INSERT、UPDATE、DELETE、CREATE等语句类型。有了它你在做SQL审计的时候就能快速把增删改查分类。get_name()则在一些场景里能直接帮你提取出主要操作对象不过它依赖语句结构具体行为我建议在目标SQL上实测一下不要想当然。4. 深入解析理解Token和TokenList如果你只是想格式化SQL看完上一节就够了。但如果你想写自定义的SQL分析、脱敏、提取工具就必须理解sqlparse的“内部语言”Token和TokenList。4.1 Token是什么在sqlparse的世界里一条SQL被拆解成一个一个的Token。Token代表的是“不可再拆分的原子片段”比如一个关键字、一个操作符、一个数字、一个字符串常量、一段空白。每个Token有两个关键属性ttypeToken的类型和value实际文本。import sqlparse sql select id from users where age 18 stmt sqlparse.parse(sql)[0] for token in stmt.tokens: print(repr(token.ttype), |, repr(token.value))输出可以看到类似TokenType.Name.Builtin | select Whitespace | TokenType.Name | id Whitespace | TokenType.Keyword | from ...ttype是判断Token角色的关键。比如sqlparse.tokens.Keyword.DML表示增删改查这类DML关键字sqlparse.tokens.Keyword.DDL表示建表删表这类DDL关键字sqlparse.tokens.Name表示标识符sqlparse.tokens.Whitespace表示空白。你可以用判断来进行精细过滤。实际开发中直接遍历stmt.tokens是远远不够的因为很多Token不是孤立的它们会被组合成更高层的结构比如Identifier标识符、Function函数、Where条件等。这些结构本身又是TokenList里面还可以嵌套其他TokenList。相当于从“单词级”上升到了“短语级”的语法分析。4.2 使用walk遍历整棵树要遍历一棵完整的解析树最方便的方法是TokenList.walk()。它采用深度优先遍历把所有直接和间接的子Token都“摊平”出来你可以过滤出你感兴趣的类型。一个很经典的场景是提取SQL里所有涉及的表名。先说明为什么不能简单地用“from后面跟着的字符串”来提取。因为SQL里可能有别名、可能有子查询、可能有JOIN、可能FROM跟的是括号内的子查询而不是真实表名。手工处理这些情况非常痛苦用Tree结构会清晰很多。我常用的实现是这样import sqlparse from sqlparse.sql import Identifier, IdentifierList from sqlparse.tokens import Keyword, Punctuation def extract_tables(sql): tables set() stmt sqlparse.parse(sql)[0] for token in stmt.tokens: if token.ttype is Keyword: # 只处理 FROM 和 JOIN 这些关键位置 if token.value.upper() in (FROM, JOIN, UPDATE, INTO, TABLE): token_idx stmt.token_index(token) # 取这个关键字之后的第一个非空白Token next_token None for t in stmt.tokens[token_idx1:]: if not t.is_whitespace: next_token t break if isinstance(next_token, Identifier): tables.add(next_token.get_real_name()) elif isinstance(next_token, IdentifierList): for ident in next_token.get_identifiers(): tables.add(ident.get_real_name()) # 如果是INSERT需要额外处理 elif token.ttype is Keyword.DML and token.value.upper() INSERT: # 找INTO后面的表 pass return tables为了不让这段代码过于复杂我只做了基本处理。但通过这个例子你可以看到核心思路先定位关键字Token再找到它后面紧跟的结构化对象再从对象中提取真实表名get_real_name()会去掉别名返回原始表名。如果是JOIN位置还要处理后面可能是子查询的情况这时Identifier可能代表一个子查询别名get_real_name()可能返回None需要进一步判断里面有没有嵌套SELECT。我的经验是提取表名永远是“再简单也比想象的复杂”的活。最好准备一套针对常见SQL模式的测试用例至少覆盖单表查询、JOIN多表、子查询、INSERT INTO、UPDATE多表、带别名的查询。只有测试用例跑过了你才敢放到生产环境的分析管道里。4.3 修改SQL基于树的操作Tree结构还有一个强力好处你可以原地修改Token生成一个新的SQL字符串。比如数据脱敏场景要把SELECT语句里的敏感字段名替换掉或者打码。做法是先找到目标Identifier替换它的Token值再调用str(token_list)得到完整SQL。import sqlparse from sqlparse.sql import Identifier def mask_columns(sql, sensitive_columns, replacement***): stmt sqlparse.parse(sql)[0] for token in stmt.tokens: if isinstance(token, Identifier): # 这里简单判断列名 real_name token.get_real_name() if real_name in sensitive_columns: # 用 replacement 替换原 token 的 value for sub in token.flatten(): if sub.ttype is sqlparse.tokens.Name: sub.value replacement return str(stmt)这段代码演示了修改Token值然后重新拼回SQL的思路。但必须提醒SQL脱敏是高风险操作改错了轻则业务逻辑出问题重则数据泄露。所以写脱敏工具时我建议先用解析出来的结构做一个“脱敏日志”记录每个替换发生的位置和原值再在测试环境跑一遍脱敏前后的SQL比对。另外要注意不要简单地把字段名替换成***因为SQL里引号、类型推断都会出问题更稳妥的做法是把查询改成SELECT NULL AS col_name或者改写为CASE WHEN ... THEN MASKED END这种结构。5. 真实场景如何把sqlparse嵌进工程这一节我会介绍三个真实工程里我用sqlparse完成的典型需求每个需求都附有实现思路和代码骨架。5.1 场景一慢SQL日志自动分类线上数据库慢查询日志里有大量重复形态的SQL只是参数值不同。为了快速分析哪些“形态”的SQL最慢我们需要把SQL去变量化Parameterize再做聚簇统计。sqlparse在这里起到关键作用先解析SQL然后把类型是Token.Literal.Number.Integer、Token.Literal.String.Single这类值的Token统一替换成占位符?。import sqlparse from sqlparse.tokens import Literal def parameterize_sql(sql): stmt sqlparse.parse(sql)[0] for token in stmt.tokens: if token.ttype in (Literal.Number.Integer, Literal.String.Single): token.value ? return str(stmt)这样一个“慢SQL聚合分析”脚本就有了基础结构。配合正则把所有空白归一然后按哈希分组就能得到同一形态SQL的执行次数、平均耗时。使用sqlparse而不是纯正则的好处是对字符串内的内容免疫不会把WHERE name 张三里的张三漏掉也不会误伤字符串里恰好长得像数字的部分。5.2 场景二查询日志中的库表引用审计另一个场景是审计业务系统访问了哪些核心表。这个需求要求从每天数万条SQL日志中提取表名清单。sqlparse虽然性能不算极致但处理单条SQL的速度极快实测下来每秒几千条没问题瓶颈通常在日志读取和IO上。我封装了一个提取器类import sqlparse class TableExtractor: def __init__(self): self.tables set() def extract(self, sql): stmts sqlparse.parse(sql) for stmt in stmts: for token in stmt.tokens: if isinstance(token, sqlparse.sql.Identifier): real_name token.get_real_name() if real_name: self.tables.add(real_name) def get_tables(self): return sorted(self.tables)这里要注意直接遍历stmt.tokens只能拿到顶层Token会漏掉FROM后面的联合子查询内部的表名。要更全面可以用walk()def extract_tables_advanced(sql): tables set() stmt sqlparse.parse(sql)[0] for token in stmt.walk(): if isinstance(token, sqlparse.sql.Identifier): # 只考虑FROM/JOIN/INTO等上下文中的Identifier parent token.parent if parent is not None: grandparent parent.parent if grandparent is not None: # 这里要用上下文判断 pass return tables用walk()虽然能把所有层级的Identifier都遍历出来但会带来一个新问题WHERE条件中的字段名也是IdentifierFROM子句里的表名也是Identifier你怎么区分最常见的方法是检查这个Identifier的父节点上下文或者检查它后面所跟的Token。我自己的经验是做提取时不要硬刚语法分析先用get_type()判断语句类型再去特定位置找这样准确率更高。5.3 场景三SQL格式化服务化我还做过一个内部API服务接收用户提交的随意SQL返回美化后的格式化版本供开发在缺陷单或文档中展示。这个服务直接用sqlparse.format()实现但加了几个工程细节from flask import Flask, request, jsonify import sqlparse app Flask(__name__) app.post(/format_sql) def format_sql(): raw_sql request.json.get(sql, ) if not raw_sql.strip(): return jsonify({error: empty sql}) try: formatted sqlparse.format( raw_sql, reindentTrue, keyword_caseupper, strip_commentsFalse, indent_width2, wrap_line_length100, ) return jsonify({formatted: formatted}) except Exception as e: return jsonify({error: str(e)}), 400工程上要加三个防线一是限制单条SQL长度避免超大文本压垮服务二是限制并发因为format是纯CPU计算高并发会打满CPU最好用线程池或异步队列控速三是对格式化结果做截断防止返回超长文本导致网络阻塞。这些小坑都是我线上踩过的。6. 常见问题与排查技巧sqlparse很好用但在使用过程中因为理解不到位也会出现不少“看起来奇怪”的现象。这里我整理了几个高频问题帮你少走弯路。6.1 为什么split后语句不完整很多人会用split()先拆语句再逐条处理。遇到的情况是语句末尾注释或分号不见了或者第一条语句开头多了些奇怪东西。这通常是因为SQL文本本身不干净比如前面有BOM头、换行、不可见字符。建议在进入sqlparse之前先做一层文本清洗sql sql.strip() sql sql.lstrip(\ufeff) # 去掉BOM头另外如果SQL里包含存储过程或触发器的BEGIN...END;块split()会因为分号出现在END内部而拆到不应该拆的地方。这种场景推荐用parse()后逐个拿Statement因为Statement的分组逻辑会更高级一点但仍然无法完美处理过程型SQL可能需要正则辅助。6.2 为什么get_name()返回Noneget_name()不是对所有语句都有效的。比如INSERT INTO users ...里它可能返回NoneCREATE TABLE users ...里能返回usersSELECT ...里它可能返回第一个Identifier。这个行为依赖sqlparse对语句类型的解析。建议不要依赖get_name()而是自己写提取逻辑。拿到Statement后先看get_type()再对不同类型的语句走不同的提取分支。6.3 为什么格式化后语义变了有几种情况会导致格式化后SQL语义发生变化第一注释被误删。如果你设置了strip_commentsTrue那么片段里的业务说明就被删掉了。如果只是展示用问题不大但如果要回放或执行删除注释通常不影响语义但有可能影响某些数据库对hint的识别。比如MySQL的/* ... */优化器hint也是注释被删掉之后执行计划就可能变化。所以生产环境回放SQL时我绝不加strip_comments这个参数。第二关键字大小写改变。前文提过某些数据库对大小写敏感如果字段名恰好是小写并且被统一成了大写执行时可能查不到列。虽然这种情况少但一旦出现排查起来也很耗时。第三缩进导致的换行。有些SQL语句特别长格式化成多行后截断位置如果发生在字符串中间或注释中间就有问题了。sqlparse一般不会在字符串内部换行但如果你使用了wrap_line_length还是建议把输出结果再做一次单测校验。6.4 性能与内存优化建议sqlparse是纯Python实现的性能天然比不了C扩展解析器。如果你要批量处理百万行SQL日志我的建议是不要频繁调用format或parse的单条接口那会有很大的函数调用开销。用生成器和批处理配合import sqlparse def batch_parse(sql_iter, batch_size10000): buffer [] for sql in sql_iter: buffer.append(sql) if len(buffer) batch_size: yield sqlparse.parse(\n.join(buffer)) buffer [] if buffer: yield sqlparse.parse(\n.join(buffer))当然如果你对性能的要求到了毫秒级比如在网关层对每个请求的SQL做实时解析那可能需要考虑C扩展版本或Rust、Go等语言的解析库。但在绝大多数后台任务场景sqlparse的性能完全够用。一个参考数据我本地处理20万条平凡SQL的提取表名任务总耗时大约50秒左右单条在0.2毫秒量级。6.5 版本更新带来的兼容性问题sqlparse的API在过去几年发生过一些调整有些方法在旧版本不可用有些行为在新版本有变化。如果你遇到“莫名的报错”优先检查版本。我遇到过的典型差异是0.2.x里parse()可能返回包含空语句的列表0.4.x里对空输入返回空列表还有Identifier.get_real_name()在旧版本里对一些带引号标识符的处理有明显bug。所以项目里最好锁定一个大版本比如用sqlparse0.4.3,0.5。6.6 关于中文编码如果你的SQL里包含中文注释或中文表名一定要确保传入Python的字符串是str类型并且终端环境的编码是UTF-8。sqlparse本身对Unicode的支持没问题但如果你从文件读取时用了错误的编码会导致解析时出现奇怪的UnicodeDecodeError。读取SQL文件我一般这样写with open(script.sql, r, encodingutf-8) as f: sql f.read()如果遇到历史遗留的GBK文件建议先转换编码再处理不要在链路上混用两种编码。7. 扩展思路sqlparse能做的六件小事最后分享几个我用sqlparse实现过的小工具思路每个都是几十行代码就能搞定的。这些例子也许能激发你自己的玩法。第一SQL关键词高亮。解析出Token根据Token类型给文本包上HTML标签或ANSI颜色码就能做终端或网页里的高亮展示。第二SQL敏感信息打码。遍历Token把字符串常量xxx统一替换为***用于日志脱敏。比正则更安全的是它天然区分了字符串常量和注释。第三DDL反向生成JSON模型。解析CREATE TABLE语句提取表名和字段列表转换成JSON结构用于自动生成文档或表结构对比。第四SQL拆分后逐条执行。在数据库迁移脚本中用split拆出每条语句再逐条执行并记录执行状态比整批执行更容易定位报错位置。第五查询特征指纹。把SQL的常量值替换为?关键字统一大写空白归一再计算哈希用于同一语法树的SQL聚类分析。第六基于sqlparse的自定义Lint。检查SQL里是否存在某些危险模式比如禁用SELECT *、禁止完全没有WHERE条件的DELETE。只需在解析结果里遍历TokenList即可。最后再分享一点我自己的体会用sqlparse这几年我最大的感受是解析类库的核心价值不在于它提供了一个完美的结果而在于它替你承担了词法分析和基础语法分组的复杂度把“从字符串到结构”这一层难度整个抹平了。但代价是你要理解它“非验证型解析器”的边界知道它哪里能帮上忙、哪里会给你挖坑。正则表达式处理文本结构问题时总有“管中窥豹”的感觉而以树形结构去处理SQL才真正拥有了全局视野。如果你正在做任何需要理解SQL语句内部结构的工作花一个下午把sqlparse的Token类型、Identifier结构和walk遍历摸熟绝对是一笔划算的投资。最后一个小技巧调试解析结果时别急着打印str(stmt)先调用stmt._pprint_tree()是的这个私有方法很好用把Token树完整打印出来你一眼就能看出解析分组和你预想的是否一致。
返回列表