Skip to content

Commit 0442eaa

Browse files
committed
initial
0 parents  commit 0442eaa

10 files changed

Lines changed: 339 additions & 0 deletions

File tree

.github/workflows/publish-pypi.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Publish to PyPI
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*' # 根据语义化版本标签触发工作流,例如 v1.0.0
7+
8+
jobs:
9+
deploy:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- name: Checkout code
13+
uses: actions/checkout@v3
14+
15+
- name: Set up Python
16+
uses: actions/setup-python@v4
17+
with:
18+
python-version: "3.x"
19+
20+
- name: Install dependencies
21+
run: |
22+
pip install setuptools wheel twine
23+
24+
- name: Build package
25+
run: |
26+
python setup.py sdist bdist_wheel
27+
28+
- name: Publish package to PyPI
29+
uses: pypa/gh-action-pypi-publish@release/v1
30+
with:
31+
password: ${{ secrets.PYPI_API_TOKEN }}

.gitignore

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Editor directories and files
2+
.idea/
3+
.idea
4+
.vscode
5+
*.suo
6+
*.ntvs*
7+
*.njsproj
8+
*.sln
9+
logs/*
10+
!logs/.gitkeep
11+
temp/*
12+
!temp/.gitkeep
13+
static/*
14+
!static/redoc_ui
15+
!static/swagger_ui
16+
!static/system/favicon.ico
17+
!static/system/logo.png
18+
!alembic/versions/.gitkeep
19+
20+
# dotenv
21+
.env
22+
23+
# virtualenv
24+
venv/
25+
.venv/
26+
ENV/
27+
mongodb-data/
28+
29+
# Spyder project settings
30+
.spyderproject
31+
32+
# Rope project settings
33+
.ropeproject
34+
*.db
35+
.DS_Store
36+
__pycache__
37+
!migrations/__init__.py
38+
*.pyc
39+
/wheelhouse
40+
logs/
41+
sql_xml_executor.egg-info/

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# 🧩 Custom Query Executor
2+
3+
A lightweight dynamic SQL executor based on XML configuration files for **FastAPI** and **SQLAlchemy (Async)**.
4+
5+
## ✅ Features
6+
7+
- Support for dynamic SQL via XML tags like `<if>`, `<where>`, etc.
8+
- Compatible with FastAPI and SQLAlchemy async sessions.
9+
- Easy to integrate into existing projects.
10+
- Can be used to separate SQL logic from business logic.
11+
12+
---
13+
14+
## 🚀 Installation
15+
16+
```bash
17+
pip install sql_xml_executor
18+

examples/example_user_stats.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import asyncio
2+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
3+
from sqlalchemy.orm import sessionmaker
4+
from sql_xml_executor.executor import SqlXmlExecutor
5+
6+
DATABASE_URL = "mysql+asyncmy://root:123456@127.0.0.1:3306/test"
7+
8+
engine = create_async_engine(DATABASE_URL, echo=True)
9+
10+
AsyncSessionLocal = sessionmaker(
11+
bind=engine,
12+
class_=AsyncSession,
13+
expire_on_commit=False
14+
)
15+
16+
async def main():
17+
async with AsyncSessionLocal() as db:
18+
query_executor = SqlXmlExecutor(db)
19+
user_api_result = await query_executor.execute(
20+
module="user_stats",
21+
query_id="getUserDailyGrowth",
22+
params={
23+
"start_time": "2024-01-01 00:00:00",
24+
"end_time": "2026-01-01 00:00:00",
25+
}
26+
)
27+
28+
print(user_api_result)
29+
30+
31+
if __name__ == "__main__":
32+
asyncio.run(main())

mapper/user_stats.xml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<queries>
2+
<!-- 用户每日增长统计 -->
3+
<query id="getUserDailyGrowth">
4+
SELECT DATE_FORMAT(create_at, '%Y-%m-%d') AS create_date, COUNT(*) AS count
5+
FROM user
6+
WHERE 1=1
7+
<where>
8+
<if test="start_time">AND create_at &gt;= :start_time</if>
9+
<if test="end_time">AND create_at &lt;= :end_time</if>
10+
</where>
11+
GROUP BY create_date
12+
</query>
13+
</queries>

requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
sqlalchemy>=2.0
2+
fastapi>=0.68.0
3+
asyncpg>=0.27.0
4+
python-dotenv>=0.19.0

setup.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# setup.py
2+
from setuptools import setup, find_packages
3+
4+
with open("README.md", "r", encoding="utf-8") as fh:
5+
long_description = fh.read()
6+
7+
setup(
8+
name="sql_xml_executor",
9+
version="0.1.0",
10+
author="Yao Hengfeng",
11+
author_email="yaohengfeng98@gmail.com",
12+
description="A dynamic SQL query executor using XML configuration for SQLAlchemy and FastAPI.",
13+
long_description=long_description,
14+
long_description_content_type="text/markdown",
15+
url="https://github.com/yhf98/sql_xml_executor",
16+
packages=find_packages(),
17+
classifiers=[
18+
"Programming Language :: Python :: 3",
19+
"License :: OSI Approved :: MIT License",
20+
"Operating System :: OS Independent",
21+
],
22+
python_requires='>=3.7',
23+
install_requires=[
24+
"sqlalchemy>=2.0",
25+
"fastapi>=0.68.0",
26+
"asyncpg>=0.27.0",
27+
"python-dotenv>=0.19.0",
28+
],
29+
)

sql_xml_executor/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .executor import SqlXmlExecutor
2+
3+
__version__ = "0.1.0"
4+
__all__ = ["SqlXmlExecutor"]

sql_xml_executor/executor.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
2+
import os
3+
import re
4+
import logging
5+
from sqlalchemy.ext.asyncio import AsyncSession
6+
from xml.etree import ElementTree as ET
7+
from typing import Dict, Any, List, Optional
8+
from sqlalchemy import text
9+
from sqlalchemy.ext.asyncio import AsyncSession
10+
from fastapi.encoders import jsonable_encoder
11+
from typing import Dict, Any, List, Optional, Union
12+
13+
logging.basicConfig(level=logging.DEBUG)
14+
logger = logging.getLogger(__name__)
15+
16+
class SqlXmlExecutor:
17+
def __init__(self, db: AsyncSession, mapper_dir: str = "mapper"):
18+
self.db = db
19+
self.queries = self.load_queries(mapper_dir)
20+
21+
def load_queries(self, dir_path: str) -> Dict[str, Dict[str, str]]:
22+
queries = {}
23+
for filename in os.listdir(dir_path):
24+
if filename.endswith('.xml'):
25+
module = filename.split('.')[0]
26+
file_path = os.path.join(dir_path, filename)
27+
tree = ET.parse(file_path)
28+
root = tree.getroot()
29+
queries[module] = {}
30+
for query in root.findall('query'):
31+
query_id = query.get('id')
32+
# 提取整个 <query> 标签内的完整内容(含子标签)
33+
query_text = self._get_full_query_text(query).strip()
34+
queries[module][query_id] = query_text
35+
return queries
36+
37+
def _get_full_query_text(self, element):
38+
"""
39+
递归获取元素及其所有子元素的文本内容
40+
"""
41+
text = element.text or ""
42+
for child in element:
43+
text += self._get_full_query_text(child)
44+
text += element.tail or ""
45+
return text
46+
47+
def parse_xml_query(self, xml_query: str, params: dict) -> str:
48+
wrapped = f"<root>{xml_query}</root>"
49+
try:
50+
root = ET.fromstring(wrapped)
51+
except ET.ParseError as e:
52+
raise ValueError(f"XML 解析失败: {e}")
53+
54+
def process_node(node):
55+
sql_parts = []
56+
for child in node:
57+
if child.tag == "if":
58+
condition = child.attrib["test"]
59+
if eval_condition(condition, params):
60+
content = child.text.strip() if child.text else ""
61+
sql_parts.append(content)
62+
elif child.tag == "where":
63+
where_sql = process_node(child)
64+
if where_sql:
65+
sql_parts.append("WHERE " + where_sql)
66+
elif child.tag == "choose":
67+
for when in child.findall("when"):
68+
cond = when.attrib["test"]
69+
if eval_condition(cond, params):
70+
content = when.text.strip() if when.text else ""
71+
sql_parts.append(content)
72+
break
73+
else:
74+
inner = process_node(child)
75+
if inner:
76+
sql_parts.append(inner)
77+
return "\n".join(sql_parts)
78+
79+
def eval_condition(condition: str, params: dict) -> bool:
80+
return condition in params and params[condition] is not None
81+
82+
raw_sql = re.sub(r'\s+AND\s', '\n AND ', process_node(root), flags=re.IGNORECASE).strip()
83+
return raw_sql.replace("&gt;", ">").replace("&lt;", "<")
84+
85+
async def execute(
86+
self,
87+
module: str,
88+
query_id: str,
89+
params: Optional[Dict[str, Any]] = None,
90+
single_row: bool = False,
91+
v_return_obj: bool = True,
92+
schema: Any = None
93+
) -> Union[List[Dict], Dict, None]:
94+
if module not in self.queries or query_id not in self.queries[module]:
95+
raise ValueError(f"Query ID '{query_id}' not found in module '{module}'")
96+
97+
raw_xml = self.queries[module][query_id]
98+
99+
# 如果没有 <if>、<where> 等标签,直接执行原始 SQL
100+
if "<if" not in raw_xml and "<where" not in raw_xml:
101+
final_sql = raw_xml.replace("&gt;", ">").replace("&lt;", "<")
102+
103+
# 🔍 打印 SQL 和参数
104+
logger.info(f"[SQL Query] Module: {module}, Query ID: {query_id}")
105+
logger.info(f"Final SQL:\n{final_sql}")
106+
logger.info(f"Params: {params}")
107+
108+
result = await self.db.execute(text(final_sql), params or {})
109+
rows = result.mappings().all()
110+
if not rows:
111+
return None
112+
113+
data = [dict(row) for row in rows]
114+
if v_return_obj and schema:
115+
data = [schema(**item) for item in data]
116+
return data[0] if single_row else data
117+
118+
# 否则才走 XML 动态解析逻辑(如果需要的话)
119+
final_sql = self.parse_xml_query(raw_xml, params or {})
120+
121+
# 🔍 打印解析后的 SQL 和参数
122+
logger.info(f"[SQL Query] Module: {module}, Query ID: {query_id}")
123+
logger.info(f"Parsed SQL:\n{final_sql}")
124+
logger.info(f"Params: {params}")
125+
126+
result = await self.db.execute(text(final_sql), params or {})
127+
rows = result.mappings().all()
128+
129+
if not rows:
130+
return None
131+
132+
data = [dict(row) for row in rows]
133+
if v_return_obj and schema:
134+
data = [schema(**item) for item in data]
135+
return data[0] if single_row else data

tests/test_executor.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import asyncio
2+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
3+
from sqlalchemy.orm import sessionmaker
4+
from sql_xml_executor.executor import SqlXmlExecutor
5+
6+
DATABASE_URL = "mysql+asyncmy://root:123456@127.0.0.1:3306/test"
7+
8+
engine = create_async_engine(DATABASE_URL, echo=True)
9+
10+
AsyncSessionLocal = sessionmaker(
11+
bind=engine,
12+
class_=AsyncSession,
13+
expire_on_commit=False
14+
)
15+
16+
async def main():
17+
async with AsyncSessionLocal() as db:
18+
query_executor = SqlXmlExecutor(db)
19+
user_api_result = await query_executor.execute(
20+
module="user_stats",
21+
query_id="getUserDailyGrowth",
22+
params={
23+
"start_time": "2024-01-01 00:00:00",
24+
"end_time": "2026-01-01 00:00:00",
25+
}
26+
)
27+
28+
print(user_api_result)
29+
30+
31+
if __name__ == "__main__":
32+
asyncio.run(main())

0 commit comments

Comments
 (0)