如何在Python中根据运行时修改业务SQL代码?
1.缘起
最近项目在准备搞SASS化,SASS化有一个特点就是多租户,且每个租户之间的数据都要隔离,对于数据库的隔离方案常见的有数据库隔离,表隔离,字段隔离,目前我只用到表隔离和字段隔离(数据库隔离的原理也是差不多)。 对于字段隔离比较简单,就是查询条件不同而已,比如像下面的SQL查询:
SELECT * FROM t_demo WHERE tenant_id='xxx' AND is_del=0
但是为了严谨,需求上需要在执行SQL之前检查对应的表是否带上tenant_id
的查询字段。
对于表隔离就麻烦了一些,他需要做到在运行的时候根据对应的租户ID来处理某个数据表,举个例子,假如有下面这样的一条SQL查询:
SELECT * FROM t_demo WHERE is_del=0
在遇到租户A时,SQL查询将变为:
SELECT * FROM t_demo_a WHERE is_del=0
在遇到租户B时,SQL查询将变为:
SELECT * FROM t_demo_b WHERE is_del=0
如果商户数量固定时,一般在代码里编写if-else
来判断就可以了,但是常见的SASS化应用的商户是会一直新增的,那么对于这个SQL逻辑就会变成这样:
def sql_handle(tenant_id: str): table_name: str = f"t_demo_{tenant_id}" sql: str = f"SELECT * FROM {table_name} WHERE is_del=0"
但是这有几个问题,对于ORM来说,一开始只创建一个t_demo
对应的表对象就可以了,现在却要根据多个商户创建多个表对象,这是不现实的,其次如果是裸写SQL,一般会使用IDE的检查,而对于这样的SQL:
sql: str = f"SELECT * FROM {table_name} WHERE is_del=0"
IDE是没办法进行检查的,当然还有一个最为严重的问题,就是当前的项目已经非常庞大了,如果每个相关表的调用都进行适配更改的话,那工程量就非常庞大了,所以最好的方案就是在引擎库得到用户传过来的SQL语句后且还没发送到MySQL
服务器之前自动的根据商户ID更改SQL, 而要达到这样的效果,就必须侵入到我们使用的MySQL
的引擎库,修改里面的方法来兼容我们的需求。
不管是使用
dbutils
还是sqlalchemy
,都可以指定一个引擎库,目前常用的引擎库是pymysql
,所以下文都将以pymysql
为例进行阐述。
2.侵入库
由于必须侵入到我们使用的引擎库,所以我们应该先判断我们需要修改引擎库的哪个方法,在经过源码阅读后,我判定只要更改pymysql.cursors.Cursor
的mogrify
方法:
def mogrify(self, query, args=None): """ Returns the exact string that is sent to the database by calling the execute() method. This method follows the extension to the DB API 2.0 followed by Psycopg. """ conn = self._get_db() if args is not None: query = query % self._escape_args(args, conn) return query
这个方法的作用就是把用户传过来的SQL和参数进行整合,生成一个最终的SQL,刚好符合我们的需求,于是可以通过继承的思路来创建一个新的属于我们自己的Cursor
类:
import pymysql class Cursor(pymysql.cursors.Cursor): def mogrify(self, query: str, args: Union[None, list, dict, tuple] = None) -> str: # 在此可以编写处理还合成的SQL逻辑 mogrify_sql: str = super().mogrify(query, args) # 在此可以编写处理合成后的SQL逻辑 return mogrify_sql class DictCursor(pymysql.cursors.DictCursorMixin, Cursor): """A cursor which returns results as a dictionary""" # 直接修改Cursor类的`mogrify`方法并不会影响到`DictCursor`类,所以我们也要创建一个新的`Cursor`类。
创建好了Cursor
类后,就需要考虑如何在pymysql
中应用我们自定义的Cursor
类了,一般的Mysql
连接库都支持我们传入自定义的Cursor
类,比如pymysql
:
import pymysql.cursors # Connect to the database connection = pymysql.connect( host='localhost', user='user', password='passwd', database='db', charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor )
我们可以通过cursorclass
来指定我们的Cursor
类,如果使用的库不支持或者是其它原因则需要使用猴子补丁的方法,具体的使用方法见Python探针完成调用库的数据提取。
3.获取商户ID
现在我们已经搞定了在何处修改SQL的问题了,接下来就要思考如何在mogrify
方法获取到商户ID以及那些表要进行替换,一般我们在进行一段代码调用时,有两种传参数的方法, 一种是传数组类型的参数:
with conn.cursor() as cursor: cursor.execute("SELECT * FROM t_demo WHERE is_del=%s", (0, ))
一种是传字典类型的参数:
with conn.cursor() as cursor: cursor.execute("SELECT * FROM t_demo WHERE is_del=%(is_del)s", {"is_del": 0})
目前大多数的项目都存在这两种类型的编写习惯,而引擎库在执行execute
时会经过处理后才把参数sql
和args
传给了mogrify
,如果我们是使用字典类型的参数,那么可以在里面嵌入我们需要的参数,并在mogrify
里面提取出来,但是使用了数组类型的参数或者是ORM库的话就比较难传递参数给mogrify
方法了,这时可以通过context
隐式的把参数传给mogrify
方法,具体的分析和原理可见:python如何使用contextvars模块源码分析。
context
的使用方法很简单, 首先是创建一个context
封装的类:
from contextvars import ContextVar, Token from typing import Any, Dict, Optional, Set context: ContextVar[Dict[str, Any]] = ContextVar("context", default={}) class Context(object): """基础的context调用,支持Type Hints检查""" tenant_id: str replace_table_set: Set[str] def __getattr__(self, key: str) -> Any: value: Any = context.get().get(key) return value def __setattr__(self, key: str, value: Any) -> None: context.get()[key] = value class WithContext(Context): """简单的处理reset token逻辑,和context管理,只用在业务代码""" def __init__(self) -> None: self._token: Optional[Token] = None def __enter__(self) -> "WithContext": self._token = context.set({}) return self def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: if self._token: context.reset(self._token) self._token = None
接下来在业务代码中,通过context传入当前业务对应的参数:
with WithContext as context: context.tenant_id = "xxx" context.replace_table_set = {"t_demo"} with conn.cursor() as cursor: cursor.execute("SELECT * FROM t_demo WHERE is_del=%s", (0, ))
然后在mogrify
中通过调用context
即可获得对应的参数了:
import pymysql class Cursor(pymysql.cursors.Cursor): def mogrify(self, query: str, args: Union[None, list, dict, tuple] = None) -> str: tenant_id: str = context.tenant_id replace_table_set: Set[str] = context.replace_table_set # 在此可以编写处理还合成的SQL逻辑 mogrify_sql: str = super().mogrify(query, args) # 在此可以编写处理合成后的SQL逻辑 return mogrify_sql
4.修改SQL
现在,万事俱备,只剩下修改SQL的逻辑,之前在做别的项目的时候,建的表都是十分的规范,它们是以t_xxx
的格式给表命名,这样一来替换表名十分方便,只要进行两次替换就可以兼容大多数情况了,代码如下:
import pymysql class Cursor(pymysql.cursors.Cursor): def mogrify(self, query: str, args: Union[None, list, dict, tuple] = None) -> str: tenant_id: str = context.tenant_id replace_table_set: Set[str] = context.replace_table_set # 简单示例,实际上正则的效率会更好 for replace_table in replace_table_set: if replace_table in query: # 替换表名 query = query.replace(f" {replace_table} ", f" {replace_table}_{tenant_id} ") # 替换查询条件中带有表名的 query = query.replace(f" {replace_table}.", f" {replace_table}_{tenant_id}.") mogrify_sql: str = super().mogrify(query, args) # 在此可以编写处理合成后的SQL逻辑 return mogrify_sql
但是现在项目的SQL规范并不是很好,有些表名还是MySQL
的关键字,所以靠简单的替换是行不通的,同时这个需求中,一些表只需要字段隔离,需要确保有带上对应的字段查询,这就意味着必须有一个库可以来解析SQL
,并返回一些数据使我们可以比较方便的知道SQL
中哪些是表名,哪些是查询字段了。
目前在Python中有一个比较知名的SQL
解析库--sqlparse,它可以通过解析引擎把SQL解析成一个Python对象
,之后我们就可以通过一些语法来判断哪些是SQL
关键字, 哪些是表名,哪些是查询条件等等。但是这个库只实现一些底层的API,我们需要对他和SQL比较了解之后才能实现一些比较完备的功能,比如下面3种常见的SQL:
SELECT * FROM t_demo SELECT * FROM t_demo as demo SELECT * FROM t_other as other LEFT JOIN t_demo demo on demo.xxx==other.xxx
如果我们要通过sqlparse
来提取表名的话就需要处理这3种情况,而我们如果要每一个情况都编写出来的话,那将会非常费心费力,同时也可能存在遗漏的情况,这时就需要用到另外一个库--sql_metadata,这个库是基于sqlparse
和正则的解析库,同时提供了大量的常见使用方法的封装,我们通过直接调用对应的函数就能知道SQL
中有哪些表名,查询字段是什么了。
目前已知这个库有一个缺陷,就是会自动去掉字段的符号, 比如表名为关键字时,我们需要使用`符号把它包起来:
SELECT * FROM `case`
但在经过sql_metadata
解析后得到的表名是case
而不是`case`,需要人为的处理,但是我并不觉得这是一个BUG,自己不按规范创建表,能怪谁呢。
接下来就可以通过sql_metadata
的方法来实现我需要的功能了,在根据需求修改后,代码长这样(说明见注释):
from typing import Dict, Set, Tuple, Union import pymysql import sql_metadata class Cursor(pymysql.cursors.Cursor): def mogrify(self, query: str, args: Union[None, list, dict, tuple] = None) -> str: tenant_id: str = context.tenant_id # 生成一个解析完成的SQL对象 sql_parse: sql_metadata.Parser = sql_metadata.Parser(query) # 新加的一个属性,这里存下需要校验查询条件的表名 check_flag = False where_table_set: Set[str] = context.where_table_set # 该方法会获取到SQL对应的table,返回的是一个table的数组 for table_name in sql_parse.tables: if table_name in where_table_set: if sql_parse.columns_dict: # 该方法会返回SQL对应的字段,其中分为select, join, where等,这里只用到了where for where_column in sql_parse.columns_dict.get("where", []): # 如果连表,里面存的是类似于t_demo.tenant_id,所以要兼容这一个情况 if "tenant_id" in where_column.lower().split("."): check_flag = True break if not check_flag: # 检查不通过就抛错 raise RuntimeError() # 更换表名的逻辑 replace_table_set: Set[str] = context.replace_table_set new_query: str = query for table_name in sql_parse.tables: if table_name in replace_table_set: new_query = "" # tokens存放着解析完的数据,比如SELECT * FROM t_demo解析后是 # [SELECT, *, FROM, t_demo]四个token for token in sql_parse.tokens: # 判断token是否是表名 if token.is_potential_table_name: # 提取规范的表名 parse_table_name: str = token.stringified_token.strip() if parse_table_name in replace_table_set: new_table_name: str = f" {parse_table_name}_{tenant_id}" # next_token代表SQL的下一个字段 if token.next_token.normalized != "AS": # 如果当前表没有设置别名 # 通过AS把替换前的表名设置为新表名的别名,这样一来后面的表名即使没进行更改,也是能读到对应商户ID的表 new_table_name += f" AS {parse_table_name}" query += new_table_name continue # 通过stringified_token获取的数据会自动带空格,比如`FROM`得到的会是` FROM`,这样拼接的时候就不用考虑是否加空格了 new_query += token.stringified_token mogrify_sql: str = super().mogrify(new_query, args) # 在此可以编写处理合成后的SQL逻辑 return mogrify_sql
这份代码十分简单,它只做简单介绍,事实上这段逻辑会应用到所有的SQL
查询中,我们应该要保证这段代码是没问题的,同时不要有太多的性能浪费,所以在使用的时候要考虑到代码拆分和优化。 比如在使用的过程中可以发现,我们的SQL
转换和检查都是在父类的Cursor.mogrify
之前进行的,这就意味着不管我们代码逻辑里cursor.execute
传的参数是什么,对于同一个代码逻辑来说,传过来的query
值是保持不变的,比如下面的代码:
def get_user_info(uid: str) -> Dict[str, Any]: with conn.cursor() as cursor: cursor.execute("SELECT * FROM t_user WHERE uid=%(uid)s", {"uid": uid}) return cursor.fetchone() or {}
这段代码中传到Cursor.mogrify
的query永远为SELECT * FROM t_user WHERE uid=%(uid)s,有变化的只是args中uid的不同。 有了这样的一个前提条件,那么我们就可以把query
的校验结果和转换结果缓存下来,减少每次都需要解析SQL
再校验造成的性能浪费。至于如何实现缓存则需要根据自己的项目来决定,比如项目中只有几百个SQL
执行,那么直接用Python
的dict
来存放就可以了,如果项目中执行的SQL
很多,同时有些执行的频率非常的高,有些执行的频率非常的低,那么可以考虑使用LRU
来缓存。
以上是如何在Python中根据运行时修改业务SQL代码?的详细内容。更多信息请关注PHP中文网其他相关文章!

热AI工具

Undresser.AI Undress
人工智能驱动的应用程序,用于创建逼真的裸体照片

AI Clothes Remover
用于从照片中去除衣服的在线人工智能工具。

Undress AI Tool
免费脱衣服图片

Clothoff.io
AI脱衣机

AI Hentai Generator
免费生成ai无尽的。

热门文章

热工具

记事本++7.3.1
好用且免费的代码编辑器

SublimeText3汉化版
中文版,非常好用

禅工作室 13.0.1
功能强大的PHP集成开发环境

Dreamweaver CS6
视觉化网页开发工具

SublimeText3 Mac版
神级代码编辑软件(SublimeText3)

热门话题

VS Code 可用于编写 Python,并提供许多功能,使其成为开发 Python 应用程序的理想工具。它允许用户:安装 Python 扩展,以获得代码补全、语法高亮和调试等功能。使用调试器逐步跟踪代码,查找和修复错误。集成 Git,进行版本控制。使用代码格式化工具,保持代码一致性。使用 Linting 工具,提前发现潜在问题。

在 VS Code 中,可以通过以下步骤在终端运行程序:准备代码和打开集成终端确保代码目录与终端工作目录一致根据编程语言选择运行命令(如 Python 的 python your_file_name.py)检查是否成功运行并解决错误利用调试器提升调试效率

VS Code 扩展存在恶意风险,例如隐藏恶意代码、利用漏洞、伪装成合法扩展。识别恶意扩展的方法包括:检查发布者、阅读评论、检查代码、谨慎安装。安全措施还包括:安全意识、良好习惯、定期更新和杀毒软件。

Python在自动化、脚本编写和任务管理中表现出色。1)自动化:通过标准库如os、shutil实现文件备份。2)脚本编写:使用psutil库监控系统资源。3)任务管理:利用schedule库调度任务。Python的易用性和丰富库支持使其在这些领域中成为首选工具。

VS Code可以在Windows 8上运行,但体验可能不佳。首先确保系统已更新到最新补丁,然后下载与系统架构匹配的VS Code安装包,按照提示安装。安装后,注意某些扩展程序可能与Windows 8不兼容,需要寻找替代扩展或在虚拟机中使用更新的Windows系统。安装必要的扩展,检查是否正常工作。尽管VS Code在Windows 8上可行,但建议升级到更新的Windows系统以获得更好的开发体验和安全保障。

VS Code 全称 Visual Studio Code,是一个由微软开发的免费开源跨平台代码编辑器和开发环境。它支持广泛的编程语言,提供语法高亮、代码自动补全、代码片段和智能提示等功能以提高开发效率。通过丰富的扩展生态系统,用户可以针对特定需求和语言添加扩展程序,例如调试器、代码格式化工具和 Git 集成。VS Code 还包含直观的调试器,有助于快速查找和解决代码中的 bug。

是的,VS Code 可以运行 Python 代码。为在 VS Code 中高效运行 Python,请完成以下步骤:安装 Python 解释器并配置环境变量。安装 VS Code 中的 Python 扩展。通过命令行在 VS Code 的终端中运行 Python 代码。利用 VS Code 的调试功能和代码格式化来提高开发效率。采用良好的编程习惯并使用性能分析工具优化代码性能。

Visual Studio Code (VSCode) 是一款跨平台、开源且免费的代码编辑器,由微软开发。它以轻量、可扩展性和对众多编程语言的支持而著称。要安装 VSCode,请访问官方网站下载并运行安装程序。使用 VSCode 时,可以创建新项目、编辑代码、调试代码、导航项目、扩展 VSCode 和管理设置。VSCode 适用于 Windows、macOS 和 Linux,支持多种编程语言,并通过 Marketplace 提供各种扩展。它的优势包括轻量、可扩展性、广泛的语言支持、丰富的功能和版
