sqlalchemy-lite 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqlalchemy_lite-0.1.0/PKG-INFO +186 -0
- sqlalchemy_lite-0.1.0/README.md +159 -0
- sqlalchemy_lite-0.1.0/pyproject.toml +54 -0
- sqlalchemy_lite-0.1.0/src/sqlalchemy_lite/__init__.py +15 -0
- sqlalchemy_lite-0.1.0/src/sqlalchemy_lite/engine.py +56 -0
- sqlalchemy_lite-0.1.0/src/sqlalchemy_lite/ext.py +40 -0
- sqlalchemy_lite-0.1.0/src/sqlalchemy_lite/proxy.py +97 -0
- sqlalchemy_lite-0.1.0/src/sqlalchemy_lite/py.typed +0 -0
- sqlalchemy_lite-0.1.0/src/sqlalchemy_lite/types.py +32 -0
- sqlalchemy_lite-0.1.0/src/sqlalchemy_lite/utils.py +52 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: sqlalchemy-lite
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A future-proof, greenlet-free adapter for SQLAlchemy 2.0 and databases.
|
|
5
|
+
Keywords: sqlalchemy,asyncio,pydantic,databases,sqlite,mysql,postgresql,embedded,arm,lite
|
|
6
|
+
Author: gnakw
|
|
7
|
+
Author-email: gnakw <gnakw@outlook.com>
|
|
8
|
+
Requires-Dist: aiosqlite>=0.22.1
|
|
9
|
+
Requires-Dist: databases>=0.9.0
|
|
10
|
+
Requires-Dist: pydantic>=2.12.5
|
|
11
|
+
Requires-Dist: sqlalchemy>=2.0.46
|
|
12
|
+
Requires-Dist: aiomysql ; extra == 'mysql'
|
|
13
|
+
Requires-Dist: pymysql ; extra == 'mysql'
|
|
14
|
+
Requires-Dist: asyncpg ; extra == 'postgres'
|
|
15
|
+
Requires-Dist: psycopg2-binary ; extra == 'postgres'
|
|
16
|
+
Requires-Dist: aiosqlite ; extra == 'sqlite'
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Project-URL: Homepage, https://github.com/gnakw/sqlalchemy-lite
|
|
19
|
+
Project-URL: Documentation, https://github.com/gnakw/sqlalchemy-lite#readme
|
|
20
|
+
Project-URL: Repository, https://github.com/gnakw/sqlalchemy-lite.git
|
|
21
|
+
Project-URL: Bug Tracker, https://github.com/gnakw/sqlalchemy-lite/issues
|
|
22
|
+
Project-URL: Changelog, https://github.com/gnakw/sqlalchemy-lite/releases
|
|
23
|
+
Provides-Extra: mysql
|
|
24
|
+
Provides-Extra: postgres
|
|
25
|
+
Provides-Extra: sqlite
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# SQLAlchemy-Lite 🚀
|
|
29
|
+
|
|
30
|
+
**SQLAlchemy-Lite** 是一个专为受限环境(如老旧 ARM 设备、嵌入式系统)设计的轻量级异步数据库适配层。
|
|
31
|
+
|
|
32
|
+
> **核心定位**:A future-proof, greenlet-free adapter for SQLAlchemy 2.0 and databases.
|
|
33
|
+
|
|
34
|
+
它通过缝合 **SQLAlchemy 2.0 的表达式能力**、**databases 的异步驱动桥接** 以及 **Pydantic 的数据校验**,在彻底摆脱 `greenlet` 依赖的同时,提供了一套现代化的开发体验。
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## 🌟 核心特性
|
|
39
|
+
|
|
40
|
+
- **去 Greenlet 化**: 彻底避开原生 `AsyncSession` 对 `greenlet` 的硬依赖,解决在特定硬件上无法编译或运行的问题。
|
|
41
|
+
- **Schema 驱动查询**: 配合 `select_for` 工具,自动根据 Pydantic 模型生成精简的 SQL 投影,仅查询所需字段,极大压榨老旧设备的 IO 性能。
|
|
42
|
+
- **2.0 风格语法**: 100% 兼容 SQLAlchemy 2.0 的 `select`, `insert`, `update`, `delete` 表达式构造。
|
|
43
|
+
- **多数据库适配**: 原生支持 **SQLite**, **MySQL**, 及 **PostgreSQL**,支持连接池管理。
|
|
44
|
+
- **类型安全**: 内置 `py.typed`,对 Mypy 和 IDE 自动补全友好。
|
|
45
|
+
- **原生分页支持**:内置 `fetch_page` 异步工具,支持物理分页与总数自动统计,并提供包含 `total_pages`、`has_next` 等智能属性的返回容器。
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 📦 安装
|
|
50
|
+
|
|
51
|
+
使用 [uv](https://github.com/astral-sh/uv) 或 pip 进行安装:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# 基础安装 (含核心逻辑)
|
|
55
|
+
uv add sqlalchemy-lite
|
|
56
|
+
|
|
57
|
+
# 根据需求安装数据库驱动扩展
|
|
58
|
+
uv add "sqlalchemy-lite[sqlite]" # 默认 SQLite
|
|
59
|
+
uv add "sqlalchemy-lite[mysql]" # MySQL 支持
|
|
60
|
+
uv add "sqlalchemy-lite[postgres]" # PostgreSQL 支持
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## 🛠️ 快速上手
|
|
67
|
+
|
|
68
|
+
### 1. 定义数据结构
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
72
|
+
from pydantic import BaseModel
|
|
73
|
+
|
|
74
|
+
class Base(DeclarativeBase): pass
|
|
75
|
+
|
|
76
|
+
# 数据库模型
|
|
77
|
+
class User(Base):
|
|
78
|
+
__tablename__ = "users"
|
|
79
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
80
|
+
username: Mapped[str] = mapped_column()
|
|
81
|
+
email: Mapped[str] = mapped_column()
|
|
82
|
+
bio: Mapped[str] = mapped_column() # 大字段,非必要不查询
|
|
83
|
+
|
|
84
|
+
# 业务视图模型
|
|
85
|
+
class UserSimple(BaseModel):
|
|
86
|
+
username: str
|
|
87
|
+
email: str
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 2. 核心查询示例
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from sqlalchemy_lite import Engine, select_for
|
|
95
|
+
|
|
96
|
+
async def main():
|
|
97
|
+
db = Engine("sqlite+aiosqlite:///app.db")
|
|
98
|
+
db.init_db(Base.metadata)
|
|
99
|
+
|
|
100
|
+
await db.connect()
|
|
101
|
+
|
|
102
|
+
async with db.session() as sess:
|
|
103
|
+
# 自动生成精简 SQL: SELECT username, email FROM users
|
|
104
|
+
stmt = select_for(User, UserSimple)
|
|
105
|
+
result = await sess.execute(stmt)
|
|
106
|
+
|
|
107
|
+
# 映射为 Pydantic 对象列表
|
|
108
|
+
users = [UserSimple.model_validate(m) for m in result.mappings()]
|
|
109
|
+
|
|
110
|
+
await db.disconnect()
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## 📖 标准业务服务模板 (Best Practice)
|
|
117
|
+
|
|
118
|
+
为了确保代码的健壮性与可移植性,推荐采用以下模式:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from sqlalchemy_lite import auto_query, PageResult, select_for, fetch_page
|
|
122
|
+
|
|
123
|
+
class UserService:
|
|
124
|
+
def __init__(self, db: Engine):
|
|
125
|
+
self.db = db
|
|
126
|
+
|
|
127
|
+
@auto_query(User, UserSimple, single=True)
|
|
128
|
+
async def get_by_name(self, stmt, name: str):
|
|
129
|
+
"""使用装饰器:自动处理 session 开启、SQL 投影与模型验证"""
|
|
130
|
+
return stmt.where(User.username == name)
|
|
131
|
+
|
|
132
|
+
async def list_paged(self, page: int, size: int) -> PageResult[UserSimple]:
|
|
133
|
+
"""标准分页:计算总数 + 物理分页 + 结果包装"""
|
|
134
|
+
async with self.db.session() as sess:
|
|
135
|
+
base_stmt = select_for(User, UserSimple)
|
|
136
|
+
return await fetch_page(sess, base_stmt, UserSimple, page, size)
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## 🛡️ 模板关键原则 (The Principles)
|
|
143
|
+
|
|
144
|
+
| 维度 | 推荐做法 (未来证明) | 禁忌做法 |
|
|
145
|
+
| --- | --- | --- |
|
|
146
|
+
| **查询列** | 使用 `select_for` 或明确指定列 | 严禁 `select(User)` (全实体查询) |
|
|
147
|
+
| **单条转换** | `Schema.model_validate(dict(row))` | 严禁依赖 ORM 的延迟加载属性 |
|
|
148
|
+
| **结果访问** | 使用 `result.mappings()` 或 `result.scalar()` | 严禁依赖 `result.scalars().all()` 获取整个对象 |
|
|
149
|
+
| **事务** | 始终使用 `async with session.begin():` | 手动显式调用 `commit()` |
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## 🔗 高级配置与连接池
|
|
154
|
+
|
|
155
|
+
对于 **MySQL** 或 **PostgreSQL**,建议配置连接池以提升性能:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
db = Engine(
|
|
159
|
+
url="mysql+aiomysql://root:pass@localhost/db",
|
|
160
|
+
min_size=5,
|
|
161
|
+
max_size=20,
|
|
162
|
+
pool_recycle=3600
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## 💎 未来证明 (Future-Proofing)
|
|
170
|
+
|
|
171
|
+
**SQLAlchemy-Lite** 的设计理念是“不产生负担”。当你不再受限于硬件环境,想要迁移回官方的 SQLAlchemy `AsyncSession` 时:
|
|
172
|
+
|
|
173
|
+
1. **零逻辑修改**: 由于 `select_for` 生成的是标准 SQLAlchemy 语句,你的业务函数体无需任何修改。
|
|
174
|
+
2. **零迁移成本**: 我们的 `Session` 和 `Result` 接口高度模拟了官方 API。你只需将 `Engine` 替换为 `create_async_engine`,并调整 `Session` 获取方式即可。
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## ⚖️ 开源协议
|
|
179
|
+
|
|
180
|
+
本项目采用 **MIT** 协议。
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
## Acknowledgment
|
|
184
|
+
|
|
185
|
+
This project was developed with the assistance of AI (Gemini). While the core architecture and logic were human-steered and rigorously reviewed to ensure security and compliance with SQLAlchemy 2.0 standards, this collaboration allowed for a more rapid exploration of lite-weight patterns for restricted environments.
|
|
186
|
+
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# SQLAlchemy-Lite 🚀
|
|
2
|
+
|
|
3
|
+
**SQLAlchemy-Lite** 是一个专为受限环境(如老旧 ARM 设备、嵌入式系统)设计的轻量级异步数据库适配层。
|
|
4
|
+
|
|
5
|
+
> **核心定位**:A future-proof, greenlet-free adapter for SQLAlchemy 2.0 and databases.
|
|
6
|
+
|
|
7
|
+
它通过缝合 **SQLAlchemy 2.0 的表达式能力**、**databases 的异步驱动桥接** 以及 **Pydantic 的数据校验**,在彻底摆脱 `greenlet` 依赖的同时,提供了一套现代化的开发体验。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 🌟 核心特性
|
|
12
|
+
|
|
13
|
+
- **去 Greenlet 化**: 彻底避开原生 `AsyncSession` 对 `greenlet` 的硬依赖,解决在特定硬件上无法编译或运行的问题。
|
|
14
|
+
- **Schema 驱动查询**: 配合 `select_for` 工具,自动根据 Pydantic 模型生成精简的 SQL 投影,仅查询所需字段,极大压榨老旧设备的 IO 性能。
|
|
15
|
+
- **2.0 风格语法**: 100% 兼容 SQLAlchemy 2.0 的 `select`, `insert`, `update`, `delete` 表达式构造。
|
|
16
|
+
- **多数据库适配**: 原生支持 **SQLite**, **MySQL**, 及 **PostgreSQL**,支持连接池管理。
|
|
17
|
+
- **类型安全**: 内置 `py.typed`,对 Mypy 和 IDE 自动补全友好。
|
|
18
|
+
- **原生分页支持**:内置 `fetch_page` 异步工具,支持物理分页与总数自动统计,并提供包含 `total_pages`、`has_next` 等智能属性的返回容器。
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 📦 安装
|
|
23
|
+
|
|
24
|
+
使用 [uv](https://github.com/astral-sh/uv) 或 pip 进行安装:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# 基础安装 (含核心逻辑)
|
|
28
|
+
uv add sqlalchemy-lite
|
|
29
|
+
|
|
30
|
+
# 根据需求安装数据库驱动扩展
|
|
31
|
+
uv add "sqlalchemy-lite[sqlite]" # 默认 SQLite
|
|
32
|
+
uv add "sqlalchemy-lite[mysql]" # MySQL 支持
|
|
33
|
+
uv add "sqlalchemy-lite[postgres]" # PostgreSQL 支持
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## 🛠️ 快速上手
|
|
40
|
+
|
|
41
|
+
### 1. 定义数据结构
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
45
|
+
from pydantic import BaseModel
|
|
46
|
+
|
|
47
|
+
class Base(DeclarativeBase): pass
|
|
48
|
+
|
|
49
|
+
# 数据库模型
|
|
50
|
+
class User(Base):
|
|
51
|
+
__tablename__ = "users"
|
|
52
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
53
|
+
username: Mapped[str] = mapped_column()
|
|
54
|
+
email: Mapped[str] = mapped_column()
|
|
55
|
+
bio: Mapped[str] = mapped_column() # 大字段,非必要不查询
|
|
56
|
+
|
|
57
|
+
# 业务视图模型
|
|
58
|
+
class UserSimple(BaseModel):
|
|
59
|
+
username: str
|
|
60
|
+
email: str
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 2. 核心查询示例
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from sqlalchemy_lite import Engine, select_for
|
|
68
|
+
|
|
69
|
+
async def main():
|
|
70
|
+
db = Engine("sqlite+aiosqlite:///app.db")
|
|
71
|
+
db.init_db(Base.metadata)
|
|
72
|
+
|
|
73
|
+
await db.connect()
|
|
74
|
+
|
|
75
|
+
async with db.session() as sess:
|
|
76
|
+
# 自动生成精简 SQL: SELECT username, email FROM users
|
|
77
|
+
stmt = select_for(User, UserSimple)
|
|
78
|
+
result = await sess.execute(stmt)
|
|
79
|
+
|
|
80
|
+
# 映射为 Pydantic 对象列表
|
|
81
|
+
users = [UserSimple.model_validate(m) for m in result.mappings()]
|
|
82
|
+
|
|
83
|
+
await db.disconnect()
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## 📖 标准业务服务模板 (Best Practice)
|
|
90
|
+
|
|
91
|
+
为了确保代码的健壮性与可移植性,推荐采用以下模式:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from sqlalchemy_lite import auto_query, PageResult, select_for, fetch_page
|
|
95
|
+
|
|
96
|
+
class UserService:
|
|
97
|
+
def __init__(self, db: Engine):
|
|
98
|
+
self.db = db
|
|
99
|
+
|
|
100
|
+
@auto_query(User, UserSimple, single=True)
|
|
101
|
+
async def get_by_name(self, stmt, name: str):
|
|
102
|
+
"""使用装饰器:自动处理 session 开启、SQL 投影与模型验证"""
|
|
103
|
+
return stmt.where(User.username == name)
|
|
104
|
+
|
|
105
|
+
async def list_paged(self, page: int, size: int) -> PageResult[UserSimple]:
|
|
106
|
+
"""标准分页:计算总数 + 物理分页 + 结果包装"""
|
|
107
|
+
async with self.db.session() as sess:
|
|
108
|
+
base_stmt = select_for(User, UserSimple)
|
|
109
|
+
return await fetch_page(sess, base_stmt, UserSimple, page, size)
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## 🛡️ 模板关键原则 (The Principles)
|
|
116
|
+
|
|
117
|
+
| 维度 | 推荐做法 (未来证明) | 禁忌做法 |
|
|
118
|
+
| --- | --- | --- |
|
|
119
|
+
| **查询列** | 使用 `select_for` 或明确指定列 | 严禁 `select(User)` (全实体查询) |
|
|
120
|
+
| **单条转换** | `Schema.model_validate(dict(row))` | 严禁依赖 ORM 的延迟加载属性 |
|
|
121
|
+
| **结果访问** | 使用 `result.mappings()` 或 `result.scalar()` | 严禁依赖 `result.scalars().all()` 获取整个对象 |
|
|
122
|
+
| **事务** | 始终使用 `async with session.begin():` | 手动显式调用 `commit()` |
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## 🔗 高级配置与连接池
|
|
127
|
+
|
|
128
|
+
对于 **MySQL** 或 **PostgreSQL**,建议配置连接池以提升性能:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
db = Engine(
|
|
132
|
+
url="mysql+aiomysql://root:pass@localhost/db",
|
|
133
|
+
min_size=5,
|
|
134
|
+
max_size=20,
|
|
135
|
+
pool_recycle=3600
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## 💎 未来证明 (Future-Proofing)
|
|
143
|
+
|
|
144
|
+
**SQLAlchemy-Lite** 的设计理念是“不产生负担”。当你不再受限于硬件环境,想要迁移回官方的 SQLAlchemy `AsyncSession` 时:
|
|
145
|
+
|
|
146
|
+
1. **零逻辑修改**: 由于 `select_for` 生成的是标准 SQLAlchemy 语句,你的业务函数体无需任何修改。
|
|
147
|
+
2. **零迁移成本**: 我们的 `Session` 和 `Result` 接口高度模拟了官方 API。你只需将 `Engine` 替换为 `create_async_engine`,并调整 `Session` 获取方式即可。
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## ⚖️ 开源协议
|
|
152
|
+
|
|
153
|
+
本项目采用 **MIT** 协议。
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
## Acknowledgment
|
|
157
|
+
|
|
158
|
+
This project was developed with the assistance of AI (Gemini). While the core architecture and logic were human-steered and rigorously reviewed to ensure security and compliance with SQLAlchemy 2.0 standards, this collaboration allowed for a more rapid exploration of lite-weight patterns for restricted environments.
|
|
159
|
+
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sqlalchemy-lite"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A future-proof, greenlet-free adapter for SQLAlchemy 2.0 and databases."
|
|
5
|
+
keywords = [
|
|
6
|
+
"sqlalchemy",
|
|
7
|
+
"asyncio",
|
|
8
|
+
"pydantic",
|
|
9
|
+
"databases",
|
|
10
|
+
"sqlite",
|
|
11
|
+
"mysql",
|
|
12
|
+
"postgresql",
|
|
13
|
+
"embedded",
|
|
14
|
+
"arm",
|
|
15
|
+
"lite"
|
|
16
|
+
]
|
|
17
|
+
readme = "README.md"
|
|
18
|
+
authors = [
|
|
19
|
+
{ name = "gnakw", email = "gnakw@outlook.com" }
|
|
20
|
+
]
|
|
21
|
+
requires-python = ">=3.11"
|
|
22
|
+
dependencies = [
|
|
23
|
+
"aiosqlite>=0.22.1",
|
|
24
|
+
"databases>=0.9.0",
|
|
25
|
+
"pydantic>=2.12.5",
|
|
26
|
+
"sqlalchemy>=2.0.46",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
sqlite = ["aiosqlite"]
|
|
31
|
+
mysql = ["aiomysql", "pymysql"]
|
|
32
|
+
postgres = ["asyncpg", "psycopg2-binary"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
"Homepage" = "https://github.com/gnakw/sqlalchemy-lite"
|
|
36
|
+
"Documentation" = "https://github.com/gnakw/sqlalchemy-lite#readme"
|
|
37
|
+
"Repository" = "https://github.com/gnakw/sqlalchemy-lite.git"
|
|
38
|
+
"Bug Tracker" = "https://github.com/gnakw/sqlalchemy-lite/issues"
|
|
39
|
+
"Changelog" = "https://github.com/gnakw/sqlalchemy-lite/releases"
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["uv_build>=0.9.29,<0.10.0"]
|
|
43
|
+
build-backend = "uv_build"
|
|
44
|
+
|
|
45
|
+
[dependency-groups]
|
|
46
|
+
dev = [
|
|
47
|
+
"aiosqlite>=0.22.1",
|
|
48
|
+
"pytest>=9.0.2",
|
|
49
|
+
"pytest-asyncio>=1.3.0",
|
|
50
|
+
"ruff>=0.15.0",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint]
|
|
54
|
+
select = ["I", "E", "F"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .engine import Engine
|
|
2
|
+
from .ext import auto_query
|
|
3
|
+
from .proxy import Result, Session
|
|
4
|
+
from .types import PageResult
|
|
5
|
+
from .utils import fetch_page, select_for
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Engine",
|
|
9
|
+
"Session",
|
|
10
|
+
"Result",
|
|
11
|
+
"PageResult",
|
|
12
|
+
"auto_query",
|
|
13
|
+
"select_for",
|
|
14
|
+
"fetch_page",
|
|
15
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import TypeVar
|
|
3
|
+
|
|
4
|
+
import sqlalchemy
|
|
5
|
+
from databases import Database
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
from sqlalchemy import create_engine
|
|
8
|
+
|
|
9
|
+
from .proxy import Session
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
T = TypeVar("T", bound=BaseModel)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Engine:
|
|
16
|
+
def __init__(self, url: str, **kwargs):
|
|
17
|
+
self.url = url
|
|
18
|
+
self.db = Database(url, **kwargs)
|
|
19
|
+
|
|
20
|
+
sync_url = (
|
|
21
|
+
url.replace("+aiosqlite", "")
|
|
22
|
+
.replace("+aiomysql", "+pymysql") # MySQL 异步转同步
|
|
23
|
+
.replace("+asyncpg", "") # PostgreSQL 异步转同步
|
|
24
|
+
)
|
|
25
|
+
self._sync_engine = create_engine(sync_url)
|
|
26
|
+
|
|
27
|
+
def init_db(self, metadata: sqlalchemy.MetaData):
|
|
28
|
+
"""
|
|
29
|
+
根据定义的 Base.metadata 自动在数据库中创建所有缺失的表。
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
metadata.create_all(self._sync_engine)
|
|
33
|
+
logger.info(">>> [SQLAlchemy-Lite] 数据库表结构初始化成功")
|
|
34
|
+
except Exception as e:
|
|
35
|
+
logger.exception(f">>> [SQLAlchemy-Lite] 数据库初始化失败: {e}")
|
|
36
|
+
raise
|
|
37
|
+
|
|
38
|
+
async def connect(self):
|
|
39
|
+
await self.db.connect()
|
|
40
|
+
|
|
41
|
+
async def disconnect(self):
|
|
42
|
+
await self.db.disconnect()
|
|
43
|
+
|
|
44
|
+
from contextlib import asynccontextmanager
|
|
45
|
+
|
|
46
|
+
@asynccontextmanager
|
|
47
|
+
async def session(self):
|
|
48
|
+
async with self.db.connection() as conn:
|
|
49
|
+
yield Session(conn)
|
|
50
|
+
|
|
51
|
+
# def select_for(self, model: Any, schema: Type[T]):
|
|
52
|
+
# """根据 Pydantic Schema 自动投影列"""
|
|
53
|
+
# cols = [
|
|
54
|
+
# getattr(model, f) for f in schema.model_fields.keys() if hasattr(model, f)
|
|
55
|
+
# ]
|
|
56
|
+
# return select(*(cols or [model]))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
from typing import Type
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from sqlalchemy_lite.utils import select_for
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def auto_query(db_model, schema: Type[BaseModel], single: bool = False):
|
|
10
|
+
"""业务级装饰器, 自动查询
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
db_model (_type_): SQLAlchemy 模型
|
|
14
|
+
schema (Type[BaseModel]): Pydantic 模型 (用于 select_for)
|
|
15
|
+
single (bool, optional): 是否只返回单条数据 (result.first vs result.all)
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def decorator(func):
|
|
20
|
+
@wraps(func)
|
|
21
|
+
async def wrapper(self, *args, **kwargs):
|
|
22
|
+
# 1. 自动根据 Schema 构建基础语句
|
|
23
|
+
base_stmt = select_for(db_model, schema)
|
|
24
|
+
|
|
25
|
+
# 2. 执行原函数获取过滤条件 (如 .where())
|
|
26
|
+
final_stmt = await func(self, base_stmt, *args, **kwargs)
|
|
27
|
+
|
|
28
|
+
# 3. 统一处理 Session 执行逻辑
|
|
29
|
+
async with self.db.session() as sess:
|
|
30
|
+
result = await sess.execute(final_stmt)
|
|
31
|
+
|
|
32
|
+
if single:
|
|
33
|
+
row = result.first()
|
|
34
|
+
return schema.model_validate(dict(row)) if row else None
|
|
35
|
+
|
|
36
|
+
return [schema.model_validate(m) for m in result.mappings()]
|
|
37
|
+
|
|
38
|
+
return wrapper
|
|
39
|
+
|
|
40
|
+
return decorator
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional, Union
|
|
2
|
+
|
|
3
|
+
from databases.interfaces import Record
|
|
4
|
+
from sqlalchemy.exc import MultipleResultsFound, NoResultFound
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ScalarResult:
|
|
8
|
+
"""处理 .scalars() 的降维结果集"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, rows: List[Record]):
|
|
11
|
+
self._data = [row[0] for row in rows] if rows else []
|
|
12
|
+
|
|
13
|
+
def all(self) -> List[Any]:
|
|
14
|
+
return self._data
|
|
15
|
+
|
|
16
|
+
def first(self) -> Optional[Any]:
|
|
17
|
+
return self._data[0] if self._data else None
|
|
18
|
+
|
|
19
|
+
def __iter__(self):
|
|
20
|
+
return iter(self._data)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Result:
|
|
24
|
+
"""模拟 SQLAlchemy 2.0 Result 接口"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, rows: Union[List[Record], Any]):
|
|
27
|
+
self._rows = (
|
|
28
|
+
rows if isinstance(rows, list) else ([rows] if rows is not None else [])
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def all(self) -> List[Record]:
|
|
32
|
+
return self._rows
|
|
33
|
+
|
|
34
|
+
def first(self) -> Optional[Record]:
|
|
35
|
+
return self._rows[0] if self._rows else None
|
|
36
|
+
|
|
37
|
+
def one_or_none(self):
|
|
38
|
+
"""取第一行,如果有第二行则报错"""
|
|
39
|
+
if len(self._rows) > 1:
|
|
40
|
+
raise MultipleResultsFound(
|
|
41
|
+
f"Expected at most one result, got {len(self._rows)}"
|
|
42
|
+
)
|
|
43
|
+
return self._rows[0] if self._rows else None
|
|
44
|
+
|
|
45
|
+
def one(self):
|
|
46
|
+
"""必须有一行,且只能有一行"""
|
|
47
|
+
if not self._rows:
|
|
48
|
+
raise NoResultFound("No result found")
|
|
49
|
+
if len(self._rows) > 1:
|
|
50
|
+
raise MultipleResultsFound(
|
|
51
|
+
f"Expected exactly one result, got {len(self._rows)}"
|
|
52
|
+
)
|
|
53
|
+
return self._rows[0]
|
|
54
|
+
|
|
55
|
+
def scalar(self) -> Any:
|
|
56
|
+
return self._rows[0][0] if self._rows and len(self._rows[0]) > 0 else None
|
|
57
|
+
|
|
58
|
+
def scalar_one_or_none(self):
|
|
59
|
+
"""安全返回单值"""
|
|
60
|
+
row = self.one_or_none()
|
|
61
|
+
return row[0] if row else None
|
|
62
|
+
|
|
63
|
+
def scalars(self) -> ScalarResult:
|
|
64
|
+
return ScalarResult(self._rows)
|
|
65
|
+
|
|
66
|
+
def mappings(self) -> List[Dict[str, Any]]:
|
|
67
|
+
return [dict(row) for row in self._rows]
|
|
68
|
+
|
|
69
|
+
def __iter__(self):
|
|
70
|
+
return iter(self._rows)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Session:
|
|
74
|
+
"""映射 SQLAlchemy Session 到 databases.Connection"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, conn):
|
|
77
|
+
self._conn = conn
|
|
78
|
+
|
|
79
|
+
from contextlib import asynccontextmanager
|
|
80
|
+
|
|
81
|
+
@asynccontextmanager
|
|
82
|
+
async def begin(self):
|
|
83
|
+
async with self._conn.transaction():
|
|
84
|
+
yield self
|
|
85
|
+
|
|
86
|
+
async def execute(self, statement: Any) -> Result:
|
|
87
|
+
raw_sql = str(statement).strip().upper()
|
|
88
|
+
# 简单判别 SELECT 逻辑,亦可扩展更严谨的判断
|
|
89
|
+
if raw_sql.startswith("SELECT") or "RETURNING" in raw_sql:
|
|
90
|
+
rows = await self._conn.fetch_all(statement)
|
|
91
|
+
return Result(rows)
|
|
92
|
+
res = await self._conn.execute(statement)
|
|
93
|
+
return Result(res)
|
|
94
|
+
|
|
95
|
+
async def scalar(self, statement):
|
|
96
|
+
res = await self.execute(statement)
|
|
97
|
+
return res.scalar()
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from typing import Generic, List, TypeVar
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, computed_field
|
|
5
|
+
|
|
6
|
+
T = TypeVar("T")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PageResult(BaseModel, Generic[T]):
|
|
10
|
+
items: List[T]
|
|
11
|
+
total: int
|
|
12
|
+
page: int
|
|
13
|
+
size: int
|
|
14
|
+
|
|
15
|
+
@computed_field
|
|
16
|
+
@property
|
|
17
|
+
def total_pages(self) -> int:
|
|
18
|
+
if self.size <= 0:
|
|
19
|
+
return 0
|
|
20
|
+
return math.ceil(self.total / self.size)
|
|
21
|
+
|
|
22
|
+
@computed_field
|
|
23
|
+
@property
|
|
24
|
+
def has_next(self) -> bool:
|
|
25
|
+
return self.page < self.total_pages
|
|
26
|
+
|
|
27
|
+
@computed_field
|
|
28
|
+
@property
|
|
29
|
+
def has_prev(self) -> bool:
|
|
30
|
+
return self.page > 1
|
|
31
|
+
|
|
32
|
+
model_config = ConfigDict(from_attributes=True)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from typing import Any, Type, TypeVar
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from sqlalchemy import func, select
|
|
5
|
+
|
|
6
|
+
from sqlalchemy_lite.types import PageResult
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T", bound=BaseModel)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_select_columns(db_model: Any, schema: Type[T]):
|
|
12
|
+
"""
|
|
13
|
+
根据 Pydantic Schema 提取 SQLAlchemy Model 的 Column 对象
|
|
14
|
+
"""
|
|
15
|
+
selected_columns = [
|
|
16
|
+
getattr(db_model, field_name)
|
|
17
|
+
for field_name in schema.model_fields.keys()
|
|
18
|
+
if hasattr(db_model, field_name)
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
# 如果没有匹配到任何列,默认返回模型本身 (SELECT *)
|
|
22
|
+
return selected_columns if selected_columns else [db_model]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def select_for(db_model: Any, schema: Type[T]):
|
|
26
|
+
"""
|
|
27
|
+
根据 Pydantic 模型自动生成精简字段的SQLAlchemy select 语句
|
|
28
|
+
"""
|
|
29
|
+
columns = get_select_columns(db_model, schema)
|
|
30
|
+
return select(*columns)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def fetch_page(
|
|
34
|
+
session, stmt, schema: Type[T], page: int, size: int
|
|
35
|
+
) -> PageResult[T]:
|
|
36
|
+
safe_page = max(1, page)
|
|
37
|
+
safe_size = max(1, size)
|
|
38
|
+
|
|
39
|
+
count_stmt = select(func.count()).select_from(stmt.subquery())
|
|
40
|
+
total = await session.scalar(count_stmt) or 0
|
|
41
|
+
|
|
42
|
+
total_pages = (total + safe_size - 1) // safe_size if total > 0 else 1
|
|
43
|
+
|
|
44
|
+
if safe_page > total_pages or total == 0:
|
|
45
|
+
return PageResult(items=[], total=total, page=safe_page, size=safe_size)
|
|
46
|
+
|
|
47
|
+
offset = (safe_page - 1) * safe_size
|
|
48
|
+
paged_stmt = stmt.offset(offset).limit(safe_size)
|
|
49
|
+
result = await session.execute(paged_stmt)
|
|
50
|
+
|
|
51
|
+
items = [schema.model_validate(m) for m in result.mappings()]
|
|
52
|
+
return PageResult(items=items, total=total, page=safe_page, size=safe_size)
|