ormate 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.
- ormate-0.1.0/.gitignore +20 -0
- ormate-0.1.0/.python-version +1 -0
- ormate-0.1.0/CHANGELOG.md +12 -0
- ormate-0.1.0/LICENSE +22 -0
- ormate-0.1.0/PKG-INFO +279 -0
- ormate-0.1.0/README.md +211 -0
- ormate-0.1.0/docs/publishing.md +33 -0
- ormate-0.1.0/examples/async_sqlalchemy.py +41 -0
- ormate-0.1.0/examples/async_sqlmodel.py +36 -0
- ormate-0.1.0/examples/elasticsearch.py +89 -0
- ormate-0.1.0/examples/shared_transaction.py +43 -0
- ormate-0.1.0/examples/sync_sqlalchemy.py +21 -0
- ormate-0.1.0/examples/web_middleware.py +16 -0
- ormate-0.1.0/pyproject.toml +65 -0
- ormate-0.1.0/src/ormate/__init__.py +12 -0
- ormate-0.1.0/src/ormate/adapters/__init__.py +5 -0
- ormate-0.1.0/src/ormate/adapters/base.py +35 -0
- ormate-0.1.0/src/ormate/adapters/elasticsearch.py +133 -0
- ormate-0.1.0/src/ormate/adapters/sqlalchemy.py +147 -0
- ormate-0.1.0/src/ormate/adapters/sqlmodel.py +6 -0
- ormate-0.1.0/src/ormate/elasticsearch/__init__.py +12 -0
- ormate-0.1.0/src/ormate/elasticsearch/document.py +20 -0
- ormate-0.1.0/src/ormate/protocols.py +17 -0
- ormate-0.1.0/src/ormate/py.typed +1 -0
- ormate-0.1.0/src/ormate/repository.py +142 -0
- ormate-0.1.0/src/ormate/sqlalchemy/__init__.py +16 -0
- ormate-0.1.0/src/ormate/sqlalchemy/database.py +174 -0
- ormate-0.1.0/src/ormate/sqlalchemy/sessions.py +90 -0
- ormate-0.1.0/src/ormate/web/__init__.py +8 -0
- ormate-0.1.0/src/ormate/web/middleware.py +43 -0
- ormate-0.1.0/tests/__init__.py +1 -0
- ormate-0.1.0/tests/conftest.py +31 -0
- ormate-0.1.0/tests/models.py +47 -0
- ormate-0.1.0/tests/test_async_database.py +40 -0
- ormate-0.1.0/tests/test_async_repository.py +31 -0
- ormate-0.1.0/tests/test_elasticsearch.py +75 -0
- ormate-0.1.0/tests/test_model_separation.py +14 -0
- ormate-0.1.0/tests/test_sqlmodel.py +48 -0
- ormate-0.1.0/tests/test_sync_database.py +20 -0
- ormate-0.1.0/tests/test_sync_repository.py +8 -0
- ormate-0.1.0/tests/test_transactions.py +24 -0
- ormate-0.1.0/tests/test_web_middleware.py +27 -0
- ormate-0.1.0/uv.lock +1621 -0
ormate-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Python-generated files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[oc]
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
wheels/
|
|
7
|
+
*.egg-info
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv
|
|
11
|
+
.venv/
|
|
12
|
+
dist/
|
|
13
|
+
build/
|
|
14
|
+
*.egg-info/
|
|
15
|
+
__pycache__/
|
|
16
|
+
*.py[cod]
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
.coverage
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - Unreleased
|
|
4
|
+
|
|
5
|
+
- 提供同步和异步 SQLAlchemy 会话作用域。
|
|
6
|
+
- 提供基于 `StorageAdapter` 的统一 `ModelRepository`。
|
|
7
|
+
- 提供 `SQLAlchemyAdapter`,SQLAlchemy 与 SQLModel 表模型共享同一套接口。
|
|
8
|
+
- 提供直接继承 `SQLAlchemyAdapter` 的 `SQLModelAdapter` 语义入口,不干预业务模型的基础字段定义。
|
|
9
|
+
- 通过 Adapter 的 `storage_name()` 统一解析 SQL 表名与 Elasticsearch 索引名。
|
|
10
|
+
- 提供可选 `ElasticsearchAdapter` 与 `ElasticsearchDocument`,支持原生 Query DSL、全文检索入口和聚合。
|
|
11
|
+
- 支持独立 Create、Update、Read 模型、批量主键操作、复合主键、计数和存在性检查。
|
|
12
|
+
- 提供并发安全的会话作用域、ASGI 中间件和数据库驱动 extras。
|
ormate-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zhangzhanqi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
ormate-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ormate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async repositories with separate read/write models and pluggable storage adapters
|
|
5
|
+
Author: zhangzhanqi
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 zhangzhanqi
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Keywords: asyncio,database,elasticsearch,repository,sqlalchemy,sqlmodel
|
|
30
|
+
Classifier: Development Status :: 3 - Alpha
|
|
31
|
+
Classifier: Framework :: AsyncIO
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Operating System :: OS Independent
|
|
34
|
+
Classifier: Programming Language :: Python :: 3
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
36
|
+
Classifier: Typing :: Typed
|
|
37
|
+
Requires-Python: >=3.13
|
|
38
|
+
Requires-Dist: sqlalchemy<3,>=2.0.36
|
|
39
|
+
Provides-Extra: dev
|
|
40
|
+
Requires-Dist: aiosqlite>=0.20; extra == 'dev'
|
|
41
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
42
|
+
Requires-Dist: elasticsearch[async]<10,>=8.17; extra == 'dev'
|
|
43
|
+
Requires-Dist: httpx>=0.28; extra == 'dev'
|
|
44
|
+
Requires-Dist: mypy>=1.13; extra == 'dev'
|
|
45
|
+
Requires-Dist: pydantic>=2.10; extra == 'dev'
|
|
46
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
47
|
+
Requires-Dist: pytest>=8.3; extra == 'dev'
|
|
48
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
49
|
+
Requires-Dist: sqlmodel>=0.0.22; extra == 'dev'
|
|
50
|
+
Requires-Dist: starlette>=0.41; extra == 'dev'
|
|
51
|
+
Requires-Dist: twine>=6.0; extra == 'dev'
|
|
52
|
+
Provides-Extra: elasticsearch
|
|
53
|
+
Requires-Dist: elasticsearch[async]<10,>=8.17; extra == 'elasticsearch'
|
|
54
|
+
Requires-Dist: pydantic>=2.10; extra == 'elasticsearch'
|
|
55
|
+
Provides-Extra: mysql
|
|
56
|
+
Requires-Dist: aiomysql>=0.2; extra == 'mysql'
|
|
57
|
+
Requires-Dist: pymysql>=1.1; extra == 'mysql'
|
|
58
|
+
Provides-Extra: postgresql
|
|
59
|
+
Requires-Dist: asyncpg>=0.30; extra == 'postgresql'
|
|
60
|
+
Requires-Dist: psycopg[binary]>=3.2; extra == 'postgresql'
|
|
61
|
+
Provides-Extra: sqlite
|
|
62
|
+
Requires-Dist: aiosqlite>=0.20; extra == 'sqlite'
|
|
63
|
+
Provides-Extra: sqlmodel
|
|
64
|
+
Requires-Dist: sqlmodel>=0.0.22; extra == 'sqlmodel'
|
|
65
|
+
Provides-Extra: web
|
|
66
|
+
Requires-Dist: starlette>=0.41; extra == 'web'
|
|
67
|
+
Description-Content-Type: text/markdown
|
|
68
|
+
|
|
69
|
+
# ormate
|
|
70
|
+
|
|
71
|
+
ormate 是一个异步 Repository 工具包,用同一套 CRUD 接口访问 SQLAlchemy、SQLModel 和 Elasticsearch。Repository 负责输入输出模型转换,Adapter 负责具体的存储操作和查询语法。
|
|
72
|
+
|
|
73
|
+
项目目前处于 `0.1.0` 阶段,API 在 `1.0` 前仍可能调整。
|
|
74
|
+
|
|
75
|
+
## 安装
|
|
76
|
+
|
|
77
|
+
需要 Python 3.13 或更高版本。核心包只依赖 SQLAlchemy:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pip install ormate
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
其他依赖按需安装:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pip install "ormate[sqlite]"
|
|
87
|
+
pip install "ormate[sqlmodel]"
|
|
88
|
+
pip install "ormate[postgresql]"
|
|
89
|
+
pip install "ormate[mysql]"
|
|
90
|
+
pip install "ormate[elasticsearch]"
|
|
91
|
+
pip install "ormate[web]"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## SQLAlchemy
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from pydantic import BaseModel
|
|
98
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
99
|
+
|
|
100
|
+
from ormate import AsyncDatabase, ModelRepository, SQLAlchemyAdapter
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Base(DeclarativeBase):
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class User(Base):
|
|
108
|
+
__tablename__ = "users"
|
|
109
|
+
|
|
110
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
111
|
+
name: Mapped[str]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class UserCreate(BaseModel):
|
|
115
|
+
id: int
|
|
116
|
+
name: str
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class UserRead(BaseModel):
|
|
120
|
+
id: int
|
|
121
|
+
name: str
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
db = AsyncDatabase.create("sqlite+aiosqlite:///app.db")
|
|
125
|
+
repository = ModelRepository(SQLAlchemyAdapter(db), User, UserRead)
|
|
126
|
+
|
|
127
|
+
async with db.engine.begin() as connection:
|
|
128
|
+
await connection.run_sync(Base.metadata.create_all)
|
|
129
|
+
|
|
130
|
+
created = await repository.create_item(UserCreate(id=1, name="Ada"))
|
|
131
|
+
loaded = await repository.read_item_by_primary_key(1)
|
|
132
|
+
updated = await repository.update_item_by_primary_key(1, {"name": "Grace"})
|
|
133
|
+
deleted = await repository.delete_item_by_primary_key(1)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Create 和 Update 参数可以是字典,也可以是实现了 `model_dump(exclude_unset=True)` 的 Pydantic/SQLModel 对象。传入 ReadModel 时,该模型需要提供 `model_validate()`;不传则返回存储模型。
|
|
137
|
+
|
|
138
|
+
SQLAlchemy 条件查询直接使用 SQLAlchemy 表达式:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
users = await repository.read_items(User.name.contains("Ada"), limit=20)
|
|
142
|
+
total = await repository.count(User.name.contains("Ada"))
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## SQLModel 与事务
|
|
146
|
+
|
|
147
|
+
`SQLModelAdapter` 继承 `SQLAlchemyAdapter`,两者使用相同的 Session 和事务实现。绑定到同一个 `Database` 后,SQLAlchemy 和 SQLModel 的操作可以放在同一个事务中:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
audit_logs = ModelRepository(SQLAlchemyAdapter(db), AuditLog)
|
|
151
|
+
tasks = ModelRepository(SQLModelAdapter(db), Task)
|
|
152
|
+
|
|
153
|
+
async with db:
|
|
154
|
+
await audit_logs.create_item({"id": 1, "message": "task created"})
|
|
155
|
+
await tasks.create_item({"title": "publish package"})
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
作用域正常退出时提交,发生异常时回滚。同步代码可以使用 `Database` 和 `with db:`。
|
|
159
|
+
|
|
160
|
+
相关示例:
|
|
161
|
+
|
|
162
|
+
- `examples/async_sqlalchemy.py`
|
|
163
|
+
- `examples/sync_sqlalchemy.py`
|
|
164
|
+
- `examples/async_sqlmodel.py`
|
|
165
|
+
- `examples/shared_transaction.py`
|
|
166
|
+
|
|
167
|
+
## Elasticsearch
|
|
168
|
+
|
|
169
|
+
Elasticsearch 使用 Pydantic 文档模型,查询参数保持原生 Query DSL:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from ormate import ModelRepository
|
|
173
|
+
from ormate.elasticsearch import ElasticsearchAdapter, ElasticsearchDocument
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class ArticleDocument(ElasticsearchDocument):
|
|
177
|
+
index_name = "articles"
|
|
178
|
+
|
|
179
|
+
title: str
|
|
180
|
+
content: str
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
adapter = ElasticsearchAdapter(client, refresh="wait_for")
|
|
184
|
+
articles = ModelRepository(adapter, ArticleDocument)
|
|
185
|
+
|
|
186
|
+
created = await articles.create_item(
|
|
187
|
+
{"id": "quickstart", "title": "ormate", "content": "pluggable adapters"}
|
|
188
|
+
)
|
|
189
|
+
matched = await articles.read_items({"match": {"content": "adapters"}}, limit=10)
|
|
190
|
+
updated = await articles.update_item_by_primary_key("quickstart", {"title": "ormate 0.1"})
|
|
191
|
+
deleted = await articles.delete_item_by_primary_key("quickstart")
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
完整的索引初始化、CRUD、计数、聚合和客户端关闭示例在 `examples/elasticsearch.py`:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
ELASTICSEARCH_URL=http://localhost:9200 uv run python examples/elasticsearch.py
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
全文检索和聚合等 ES 专属操作直接通过 `ElasticsearchAdapter` 调用。Elasticsearch 不参与 SQL 事务;SQL 与 ES 双写需要使用 outbox、消息队列或补偿机制。
|
|
201
|
+
|
|
202
|
+
## Repository 和 Adapter
|
|
203
|
+
|
|
204
|
+
`ModelRepository` 提供以下通用操作:
|
|
205
|
+
|
|
206
|
+
- 单项和批量创建
|
|
207
|
+
- 条件查询、主键查询和批量主键查询
|
|
208
|
+
- 条件更新和删除
|
|
209
|
+
- `count`、`exists`、`limit`、`offset`
|
|
210
|
+
- 后端原生命令执行
|
|
211
|
+
|
|
212
|
+
SQLAlchemy/SQLModel 额外支持复合主键。后端特有的能力不放进通用协议,例如 SQL JOIN 和 ES 聚合分别留在对应 Adapter 中。
|
|
213
|
+
|
|
214
|
+
实现 `StorageAdapter` 协议可以接入其他存储:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
repository = ModelRepository(custom_adapter, CustomModel, CustomRead)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Adapter 负责持久化、查询、主键条件和存储名称。Repository 负责输入转换、`encode_for_storage()`、`decode_from_storage()` 和 ReadModel 构造。
|
|
221
|
+
|
|
222
|
+
ormate 不定义 `id`、`created_at`、`updated_at` 等业务字段。SQLAlchemy/SQLModel 继续使用 `__tablename__`,Elasticsearch 使用 `index_name`,统一名称可以从 Repository 获取:
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
repository.storage_name
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`ElasticsearchDocument.id` 只用于映射 Elasticsearch `_id`。
|
|
229
|
+
|
|
230
|
+
## Web 中间件
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
from ormate.web import DBSessionMiddleware
|
|
234
|
+
|
|
235
|
+
app.add_middleware(
|
|
236
|
+
DBSessionMiddleware,
|
|
237
|
+
db=db,
|
|
238
|
+
rollback_on_http_error=True,
|
|
239
|
+
)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
中间件接受 `AsyncDatabase` 或 `AsyncEngine`,为每个 HTTP/WebSocket 请求建立独立会话作用域。
|
|
243
|
+
|
|
244
|
+
## 当前限制
|
|
245
|
+
|
|
246
|
+
- PostgreSQL、MySQL 和真实 Elasticsearch 集群尚未加入自动化集成测试。
|
|
247
|
+
- Elasticsearch 条件批量更新和删除受 `default_size` 限制,默认最多处理 1000 个匹配文档。
|
|
248
|
+
- ES mapping 迁移、bulk、PIT/search_after 和乐观并发控制尚未实现。
|
|
249
|
+
- 不提供 SQL 与 Elasticsearch 之间的分布式事务。
|
|
250
|
+
|
|
251
|
+
## 路线图
|
|
252
|
+
|
|
253
|
+
`0.1.x` 用于完成首次 PyPI 发布,当前还需要经过 TestPyPI 安装验证。
|
|
254
|
+
|
|
255
|
+
`0.2` 主要完善 Elasticsearch:bulk 写入、PIT/search_after、索引 mapping 管理、并发冲突处理和真实集群测试。
|
|
256
|
+
|
|
257
|
+
`0.3` 主要完善关系型数据库:PostgreSQL/MySQL 测试矩阵、分页结果类型、显式 savepoint API 和批量性能优化。
|
|
258
|
+
|
|
259
|
+
后续会补充 Adapter 契约测试工具,并评估 MongoDB、RedisJSON 以及 SQL 到 ES 的 outbox 示例。到 `1.0` 前会冻结公共 API,补齐迁移指南和性能基准。
|
|
260
|
+
|
|
261
|
+
路线图只表示开发方向,不承诺具体发布日期。
|
|
262
|
+
|
|
263
|
+
## 开发
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
uv sync --all-extras
|
|
267
|
+
uv run ruff check .
|
|
268
|
+
uv run mypy
|
|
269
|
+
uv run pytest
|
|
270
|
+
uv build
|
|
271
|
+
uv run twine check dist/*
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
发布步骤见 `docs/publishing.md`。
|
|
275
|
+
|
|
276
|
+
## License
|
|
277
|
+
|
|
278
|
+
[MIT License](LICENSE)
|
|
279
|
+
|
ormate-0.1.0/README.md
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# ormate
|
|
2
|
+
|
|
3
|
+
ormate 是一个异步 Repository 工具包,用同一套 CRUD 接口访问 SQLAlchemy、SQLModel 和 Elasticsearch。Repository 负责输入输出模型转换,Adapter 负责具体的存储操作和查询语法。
|
|
4
|
+
|
|
5
|
+
项目目前处于 `0.1.0` 阶段,API 在 `1.0` 前仍可能调整。
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
需要 Python 3.13 或更高版本。核心包只依赖 SQLAlchemy:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install ormate
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
其他依赖按需安装:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install "ormate[sqlite]"
|
|
19
|
+
pip install "ormate[sqlmodel]"
|
|
20
|
+
pip install "ormate[postgresql]"
|
|
21
|
+
pip install "ormate[mysql]"
|
|
22
|
+
pip install "ormate[elasticsearch]"
|
|
23
|
+
pip install "ormate[web]"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## SQLAlchemy
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from pydantic import BaseModel
|
|
30
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
31
|
+
|
|
32
|
+
from ormate import AsyncDatabase, ModelRepository, SQLAlchemyAdapter
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Base(DeclarativeBase):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class User(Base):
|
|
40
|
+
__tablename__ = "users"
|
|
41
|
+
|
|
42
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
43
|
+
name: Mapped[str]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class UserCreate(BaseModel):
|
|
47
|
+
id: int
|
|
48
|
+
name: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class UserRead(BaseModel):
|
|
52
|
+
id: int
|
|
53
|
+
name: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
db = AsyncDatabase.create("sqlite+aiosqlite:///app.db")
|
|
57
|
+
repository = ModelRepository(SQLAlchemyAdapter(db), User, UserRead)
|
|
58
|
+
|
|
59
|
+
async with db.engine.begin() as connection:
|
|
60
|
+
await connection.run_sync(Base.metadata.create_all)
|
|
61
|
+
|
|
62
|
+
created = await repository.create_item(UserCreate(id=1, name="Ada"))
|
|
63
|
+
loaded = await repository.read_item_by_primary_key(1)
|
|
64
|
+
updated = await repository.update_item_by_primary_key(1, {"name": "Grace"})
|
|
65
|
+
deleted = await repository.delete_item_by_primary_key(1)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Create 和 Update 参数可以是字典,也可以是实现了 `model_dump(exclude_unset=True)` 的 Pydantic/SQLModel 对象。传入 ReadModel 时,该模型需要提供 `model_validate()`;不传则返回存储模型。
|
|
69
|
+
|
|
70
|
+
SQLAlchemy 条件查询直接使用 SQLAlchemy 表达式:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
users = await repository.read_items(User.name.contains("Ada"), limit=20)
|
|
74
|
+
total = await repository.count(User.name.contains("Ada"))
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## SQLModel 与事务
|
|
78
|
+
|
|
79
|
+
`SQLModelAdapter` 继承 `SQLAlchemyAdapter`,两者使用相同的 Session 和事务实现。绑定到同一个 `Database` 后,SQLAlchemy 和 SQLModel 的操作可以放在同一个事务中:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
audit_logs = ModelRepository(SQLAlchemyAdapter(db), AuditLog)
|
|
83
|
+
tasks = ModelRepository(SQLModelAdapter(db), Task)
|
|
84
|
+
|
|
85
|
+
async with db:
|
|
86
|
+
await audit_logs.create_item({"id": 1, "message": "task created"})
|
|
87
|
+
await tasks.create_item({"title": "publish package"})
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
作用域正常退出时提交,发生异常时回滚。同步代码可以使用 `Database` 和 `with db:`。
|
|
91
|
+
|
|
92
|
+
相关示例:
|
|
93
|
+
|
|
94
|
+
- `examples/async_sqlalchemy.py`
|
|
95
|
+
- `examples/sync_sqlalchemy.py`
|
|
96
|
+
- `examples/async_sqlmodel.py`
|
|
97
|
+
- `examples/shared_transaction.py`
|
|
98
|
+
|
|
99
|
+
## Elasticsearch
|
|
100
|
+
|
|
101
|
+
Elasticsearch 使用 Pydantic 文档模型,查询参数保持原生 Query DSL:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from ormate import ModelRepository
|
|
105
|
+
from ormate.elasticsearch import ElasticsearchAdapter, ElasticsearchDocument
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ArticleDocument(ElasticsearchDocument):
|
|
109
|
+
index_name = "articles"
|
|
110
|
+
|
|
111
|
+
title: str
|
|
112
|
+
content: str
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
adapter = ElasticsearchAdapter(client, refresh="wait_for")
|
|
116
|
+
articles = ModelRepository(adapter, ArticleDocument)
|
|
117
|
+
|
|
118
|
+
created = await articles.create_item(
|
|
119
|
+
{"id": "quickstart", "title": "ormate", "content": "pluggable adapters"}
|
|
120
|
+
)
|
|
121
|
+
matched = await articles.read_items({"match": {"content": "adapters"}}, limit=10)
|
|
122
|
+
updated = await articles.update_item_by_primary_key("quickstart", {"title": "ormate 0.1"})
|
|
123
|
+
deleted = await articles.delete_item_by_primary_key("quickstart")
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
完整的索引初始化、CRUD、计数、聚合和客户端关闭示例在 `examples/elasticsearch.py`:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
ELASTICSEARCH_URL=http://localhost:9200 uv run python examples/elasticsearch.py
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
全文检索和聚合等 ES 专属操作直接通过 `ElasticsearchAdapter` 调用。Elasticsearch 不参与 SQL 事务;SQL 与 ES 双写需要使用 outbox、消息队列或补偿机制。
|
|
133
|
+
|
|
134
|
+
## Repository 和 Adapter
|
|
135
|
+
|
|
136
|
+
`ModelRepository` 提供以下通用操作:
|
|
137
|
+
|
|
138
|
+
- 单项和批量创建
|
|
139
|
+
- 条件查询、主键查询和批量主键查询
|
|
140
|
+
- 条件更新和删除
|
|
141
|
+
- `count`、`exists`、`limit`、`offset`
|
|
142
|
+
- 后端原生命令执行
|
|
143
|
+
|
|
144
|
+
SQLAlchemy/SQLModel 额外支持复合主键。后端特有的能力不放进通用协议,例如 SQL JOIN 和 ES 聚合分别留在对应 Adapter 中。
|
|
145
|
+
|
|
146
|
+
实现 `StorageAdapter` 协议可以接入其他存储:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
repository = ModelRepository(custom_adapter, CustomModel, CustomRead)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Adapter 负责持久化、查询、主键条件和存储名称。Repository 负责输入转换、`encode_for_storage()`、`decode_from_storage()` 和 ReadModel 构造。
|
|
153
|
+
|
|
154
|
+
ormate 不定义 `id`、`created_at`、`updated_at` 等业务字段。SQLAlchemy/SQLModel 继续使用 `__tablename__`,Elasticsearch 使用 `index_name`,统一名称可以从 Repository 获取:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
repository.storage_name
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`ElasticsearchDocument.id` 只用于映射 Elasticsearch `_id`。
|
|
161
|
+
|
|
162
|
+
## Web 中间件
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from ormate.web import DBSessionMiddleware
|
|
166
|
+
|
|
167
|
+
app.add_middleware(
|
|
168
|
+
DBSessionMiddleware,
|
|
169
|
+
db=db,
|
|
170
|
+
rollback_on_http_error=True,
|
|
171
|
+
)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
中间件接受 `AsyncDatabase` 或 `AsyncEngine`,为每个 HTTP/WebSocket 请求建立独立会话作用域。
|
|
175
|
+
|
|
176
|
+
## 当前限制
|
|
177
|
+
|
|
178
|
+
- PostgreSQL、MySQL 和真实 Elasticsearch 集群尚未加入自动化集成测试。
|
|
179
|
+
- Elasticsearch 条件批量更新和删除受 `default_size` 限制,默认最多处理 1000 个匹配文档。
|
|
180
|
+
- ES mapping 迁移、bulk、PIT/search_after 和乐观并发控制尚未实现。
|
|
181
|
+
- 不提供 SQL 与 Elasticsearch 之间的分布式事务。
|
|
182
|
+
|
|
183
|
+
## 路线图
|
|
184
|
+
|
|
185
|
+
`0.1.x` 用于完成首次 PyPI 发布,当前还需要经过 TestPyPI 安装验证。
|
|
186
|
+
|
|
187
|
+
`0.2` 主要完善 Elasticsearch:bulk 写入、PIT/search_after、索引 mapping 管理、并发冲突处理和真实集群测试。
|
|
188
|
+
|
|
189
|
+
`0.3` 主要完善关系型数据库:PostgreSQL/MySQL 测试矩阵、分页结果类型、显式 savepoint API 和批量性能优化。
|
|
190
|
+
|
|
191
|
+
后续会补充 Adapter 契约测试工具,并评估 MongoDB、RedisJSON 以及 SQL 到 ES 的 outbox 示例。到 `1.0` 前会冻结公共 API,补齐迁移指南和性能基准。
|
|
192
|
+
|
|
193
|
+
路线图只表示开发方向,不承诺具体发布日期。
|
|
194
|
+
|
|
195
|
+
## 开发
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
uv sync --all-extras
|
|
199
|
+
uv run ruff check .
|
|
200
|
+
uv run mypy
|
|
201
|
+
uv run pytest
|
|
202
|
+
uv build
|
|
203
|
+
uv run twine check dist/*
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
发布步骤见 `docs/publishing.md`。
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
[MIT License](LICENSE)
|
|
211
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# 发布 ormate
|
|
2
|
+
|
|
3
|
+
## 发布前检查
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
uv sync --all-extras
|
|
7
|
+
uv run ruff check .
|
|
8
|
+
uv run mypy
|
|
9
|
+
uv run pytest
|
|
10
|
+
uv build
|
|
11
|
+
uv run twine check dist/*
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
版本号必须同时更新 `pyproject.toml` 和 `CHANGELOG.md`。发布前确认 PyPI 上发行名仍可用。
|
|
15
|
+
|
|
16
|
+
## TestPyPI 验证
|
|
17
|
+
|
|
18
|
+
不要把令牌写入仓库。令牌通过环境变量或交互式提示提供。
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
uv publish --publish-url https://test.pypi.org/legacy/ dist/*
|
|
22
|
+
uv run --isolated --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ --with ormate python -c "import ormate; print(ormate.__all__)"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## PyPI 发布
|
|
26
|
+
|
|
27
|
+
TestPyPI 安装和冒烟验证通过后执行:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uv publish dist/*
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
长期维护建议在代码托管平台配置 PyPI Trusted Publishing,避免持久化 API Token。
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
5
|
+
|
|
6
|
+
from ormate import AsyncDatabase, ModelRepository, SQLAlchemyAdapter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Base(DeclarativeBase):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Note(Base):
|
|
14
|
+
__tablename__ = "notes"
|
|
15
|
+
|
|
16
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
17
|
+
text: Mapped[str]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NoteRead(BaseModel):
|
|
21
|
+
id: int
|
|
22
|
+
text: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def main() -> None:
|
|
26
|
+
db = AsyncDatabase.create("sqlite+aiosqlite:///notes.db")
|
|
27
|
+
async with db.engine.begin() as connection:
|
|
28
|
+
await connection.run_sync(Base.metadata.create_all)
|
|
29
|
+
|
|
30
|
+
notes = ModelRepository(SQLAlchemyAdapter(db), Note, NoteRead)
|
|
31
|
+
async with db:
|
|
32
|
+
await notes.create_item({"id": 1, "text": "first"})
|
|
33
|
+
await notes.create_item({"id": 2, "text": "second"})
|
|
34
|
+
|
|
35
|
+
print(await notes.read_items())
|
|
36
|
+
await db.dispose()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
asyncio.run(main())
|
|
41
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from sqlmodel import Field, SQLModel
|
|
4
|
+
|
|
5
|
+
from ormate import AsyncDatabase, ModelRepository, SQLModelAdapter
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NoteRead(SQLModel):
|
|
9
|
+
id: int
|
|
10
|
+
text: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Note(SQLModel, table=True):
|
|
14
|
+
__tablename__ = "notes"
|
|
15
|
+
|
|
16
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
17
|
+
text: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def main() -> None:
|
|
21
|
+
db = AsyncDatabase.create("sqlite+aiosqlite:///notes.db")
|
|
22
|
+
async with db.engine.begin() as connection:
|
|
23
|
+
await connection.run_sync(SQLModel.metadata.create_all)
|
|
24
|
+
adapter = SQLModelAdapter(db)
|
|
25
|
+
notes = ModelRepository(adapter, Note, NoteRead)
|
|
26
|
+
|
|
27
|
+
async with db:
|
|
28
|
+
await notes.create_item({"text": "first"})
|
|
29
|
+
await notes.create_item({"text": "second"})
|
|
30
|
+
|
|
31
|
+
print(await notes.read_items())
|
|
32
|
+
await db.dispose()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
asyncio.run(main())
|