s-ormkit 0.0.1__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.
- s_ormkit-0.0.1/.gitignore +6 -0
- s_ormkit-0.0.1/CHANGELOG.md +36 -0
- s_ormkit-0.0.1/LICENSE +21 -0
- s_ormkit-0.0.1/PKG-INFO +174 -0
- s_ormkit-0.0.1/README.md +158 -0
- s_ormkit-0.0.1/ormkit/__init__.py +56 -0
- s_ormkit-0.0.1/ormkit/base.py +50 -0
- s_ormkit-0.0.1/ormkit/engine.py +86 -0
- s_ormkit-0.0.1/ormkit/exceptions.py +20 -0
- s_ormkit-0.0.1/ormkit/protocols.py +54 -0
- s_ormkit-0.0.1/ormkit/repository.py +117 -0
- s_ormkit-0.0.1/ormkit/unit_of_work.py +57 -0
- s_ormkit-0.0.1/pyproject.toml +56 -0
- s_ormkit-0.0.1/tests/__init__.py +1 -0
- s_ormkit-0.0.1/tests/conftest.py +66 -0
- s_ormkit-0.0.1/tests/integration/__init__.py +0 -0
- s_ormkit-0.0.1/tests/integration/test_foreign_keys.py +41 -0
- s_ormkit-0.0.1/tests/integration/test_repository.py +115 -0
- s_ormkit-0.0.1/tests/integration/test_timestamps.py +30 -0
- s_ormkit-0.0.1/tests/integration/test_unit_of_work.py +69 -0
- s_ormkit-0.0.1/tests/unit/__init__.py +0 -0
- s_ormkit-0.0.1/tests/unit/test_engine.py +70 -0
- s_ormkit-0.0.1/tests/unit/test_protocols.py +28 -0
- s_ormkit-0.0.1/tests/unit/test_schema.py +46 -0
- s_ormkit-0.0.1/uv.lock +458 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Все значительные изменения этого проекта будут документироваться в этом файле.
|
|
4
|
+
|
|
5
|
+
Формат основан на [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
и этот проект соответствует [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.0.1] - 2026-07-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Declarative-база и миксины** (`Base`, `IntPkMixin`, `TimestampMixin`): общий
|
|
13
|
+
SQLAlchemy 2.0 declarative base и переиспользуемые миксины (int-PK, диалект-нейтральные
|
|
14
|
+
временные метки через `func.now()`)
|
|
15
|
+
- **Движок из DB-URL** (`make_engine`, `make_session_factory`, `init_schema`, `drop_schema`,
|
|
16
|
+
`dispose_engine`): резолв URL (аргумент → env `ORMKIT_DB_URL` → default), ленивое
|
|
17
|
+
создание движка (postgres-URL без коннекта), автоматический `PRAGMA foreign_keys=ON` для
|
|
18
|
+
sqlite
|
|
19
|
+
- **Repository-паттерн** (`BaseRepository[TModel, TDomain]`): generic CRUD
|
|
20
|
+
(`add / get / get_or_none / list / update / remove / count`) с маппингом ORM <-> domain
|
|
21
|
+
через переопределяемые хуки `to_domain` / `to_orm`
|
|
22
|
+
- **Транзакционная граница** (`UnitOfWork`): контекст-менеджер с rollback при исключении,
|
|
23
|
+
гарантированным `close` и явным `commit`
|
|
24
|
+
- **DIP-контракты** (`RepositoryProtocol`, `UnitOfWorkProtocol`): Protocol'ы для зависимости
|
|
25
|
+
прикладного слоя от абстракций, а не от `BaseRepository` / SQLAlchemy
|
|
26
|
+
- **Исключения** (`OrmKitError`, `NotFoundError`)
|
|
27
|
+
- **Тесты** (unit + integration, coverage ≥ 80%): резолв DB-URL, FK enforcement на sqlite,
|
|
28
|
+
round-trip CRUD, транзакции UoW, диалект-нейтральность временных меток; демо-модели
|
|
29
|
+
объявлены в самих тестах
|
|
30
|
+
|
|
31
|
+
### Notes
|
|
32
|
+
|
|
33
|
+
- Первый релиз кита `s-ormkit` как generic БД/ORM-инфраструктуры для экосистемы S-kits
|
|
34
|
+
- 0 завязок на конкретное приложение: только чистая инфраструктура
|
|
35
|
+
- Зависит ТОЛЬКО от SQLAlchemy>=2.0
|
|
36
|
+
- Диалект-нейтрально: движок заменяется через DB-URL (sqlite → postgres → mysql) без правок кода
|
s_ormkit-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dmitry
|
|
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.
|
s_ormkit-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: s-ormkit
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Generic БД/ORM-инфраструктура на SQLAlchemy 2.0: диалект-нейтральный движок из DB-URL, Repository + UnitOfWork + DIP-протоколы. 0 завязок на конкретное приложение.
|
|
5
|
+
Author: Dmitry
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest-cov>=6.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# s-ormkit
|
|
18
|
+
|
|
19
|
+
Generic БД/ORM-инфраструктура для экосистемы S-kits на SQLAlchemy 2.0: диалект-нейтральный
|
|
20
|
+
движок из DB-URL, Repository + UnitOfWork + DIP-протоколы.
|
|
21
|
+
|
|
22
|
+
**Версия:** 0.0.1 (первый релиз)
|
|
23
|
+
**Лицензия:** MIT
|
|
24
|
+
**Зависимости:** `SQLAlchemy>=2.0` (и ничего больше)
|
|
25
|
+
|
|
26
|
+
Кит НЕ знает ни о каком приложении. Прикладная логика получает БД-слой через ORM так,
|
|
27
|
+
что не зависит от sqlite/диалекта: движок конфигурируется через DB-URL и заменяется на
|
|
28
|
+
`postgresql://.../mysql://...` **без изменения кода**.
|
|
29
|
+
|
|
30
|
+
## Что входит
|
|
31
|
+
|
|
32
|
+
### Declarative-база и миксины (`base.py`)
|
|
33
|
+
- `Base` — общий declarative base для моделей-наследников
|
|
34
|
+
- `IntPkMixin` — целочисленный автоинкрементный первичный ключ
|
|
35
|
+
- `TimestampMixin` — `created_at` / `updated_at` через `func.now()` (диалект-нейтрально)
|
|
36
|
+
|
|
37
|
+
### Движок из DB-URL (`engine.py`)
|
|
38
|
+
- `make_engine` — резолв URL (аргумент → env → default), для sqlite включает
|
|
39
|
+
`PRAGMA foreign_keys=ON`; `create_engine` ленив (postgres-URL создаётся без коннекта)
|
|
40
|
+
- `make_session_factory` — `sessionmaker(expire_on_commit=False)`
|
|
41
|
+
- `init_schema` / `drop_schema` — создать/удалить таблицы
|
|
42
|
+
- `dispose_engine` — освободить пул соединений
|
|
43
|
+
|
|
44
|
+
### Repository-паттерн (`repository.py`)
|
|
45
|
+
- `BaseRepository[TModel, TDomain]` — generic CRUD: `add / get / get_or_none / list /
|
|
46
|
+
update / remove / count`
|
|
47
|
+
- Маппинг ORM <-> domain через переопределяемые хуки `to_domain` / `to_orm`
|
|
48
|
+
- Не коммитит сам (это делает `UnitOfWork`) — только `flush` для получения id
|
|
49
|
+
|
|
50
|
+
### Транзакционная граница (`unit_of_work.py`)
|
|
51
|
+
- `UnitOfWork` — контекст-менеджер: rollback при исключении, `close` всегда, `commit` явный
|
|
52
|
+
|
|
53
|
+
### DIP-контракты (`protocols.py`)
|
|
54
|
+
- `RepositoryProtocol` / `UnitOfWorkProtocol` — чтобы сервисы зависели от абстракций,
|
|
55
|
+
а не от `BaseRepository` / SQLAlchemy
|
|
56
|
+
|
|
57
|
+
## Быстрый старт
|
|
58
|
+
|
|
59
|
+
Кит generic — вы объявляете **свои** модели поверх `Base` и миксинов:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from dataclasses import dataclass
|
|
63
|
+
|
|
64
|
+
from sqlalchemy import String
|
|
65
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
66
|
+
|
|
67
|
+
from ormkit import (
|
|
68
|
+
Base, IntPkMixin, TimestampMixin,
|
|
69
|
+
BaseRepository, UnitOfWork,
|
|
70
|
+
make_engine, make_session_factory, init_schema,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# 1. Свои ORM-модели
|
|
75
|
+
class User(Base, IntPkMixin, TimestampMixin):
|
|
76
|
+
__tablename__ = "users"
|
|
77
|
+
name: Mapped[str] = mapped_column(String(100))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# 2. Свой доменный объект (dataclass / pydantic / dict — кит не знает тип)
|
|
81
|
+
@dataclass
|
|
82
|
+
class UserDTO:
|
|
83
|
+
name: str
|
|
84
|
+
id: int | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# 3. Свой репозиторий — переопределяем ТОЛЬКО хуки маппинга
|
|
88
|
+
class UserRepository(BaseRepository[User, UserDTO]):
|
|
89
|
+
def to_domain(self, obj: User) -> UserDTO:
|
|
90
|
+
return UserDTO(id=obj.id, name=obj.name)
|
|
91
|
+
|
|
92
|
+
def to_orm(self, domain: UserDTO) -> User:
|
|
93
|
+
return User(name=domain.name)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Конфигурация движка через DB-URL
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
# sqlite по умолчанию (для локали / тестов), FK enforcement включён автоматически
|
|
100
|
+
engine = make_engine("sqlite:///app.db")
|
|
101
|
+
|
|
102
|
+
# та же строка кода на проде — просто другой URL, коду всё равно:
|
|
103
|
+
engine = make_engine(default="postgresql://user:pass@host/db")
|
|
104
|
+
# или через переменную окружения ORMKIT_DB_URL:
|
|
105
|
+
engine = make_engine()
|
|
106
|
+
|
|
107
|
+
init_schema(engine)
|
|
108
|
+
session_factory = make_session_factory(engine)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Работа через UnitOfWork
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
with UnitOfWork(session_factory) as uow:
|
|
115
|
+
repo = UserRepository(uow.session, User)
|
|
116
|
+
|
|
117
|
+
saved = repo.add(UserDTO(name="Алиса")) # flush -> id проставлен
|
|
118
|
+
found = repo.get(saved.id) # NotFoundError, если нет
|
|
119
|
+
everyone = repo.list() # фильтр: repo.list(name="Алиса")
|
|
120
|
+
repo.update(saved.id, name="Алиса Смит")
|
|
121
|
+
total = repo.count()
|
|
122
|
+
|
|
123
|
+
uow.commit() # без commit транзакция не персистится
|
|
124
|
+
# исключение в блоке -> автоматический rollback; сессия всегда закрывается
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### DIP: сервис зависит от абстракции
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from ormkit import RepositoryProtocol
|
|
131
|
+
|
|
132
|
+
def register_user(repo: RepositoryProtocol[UserDTO], name: str) -> UserDTO:
|
|
133
|
+
return repo.add(UserDTO(name=name))
|
|
134
|
+
# в проде — UserRepository, в тестах — in-memory фейк, реализующий тот же протокол
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Архитектура
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
ormkit/
|
|
141
|
+
├── __init__.py # Public API + __version__
|
|
142
|
+
├── exceptions.py # OrmKitError, NotFoundError
|
|
143
|
+
├── base.py # Base, IntPkMixin, TimestampMixin
|
|
144
|
+
├── engine.py # make_engine, make_session_factory, init/drop_schema, dispose
|
|
145
|
+
├── repository.py # BaseRepository[TModel, TDomain]
|
|
146
|
+
├── unit_of_work.py # UnitOfWork
|
|
147
|
+
└── protocols.py # RepositoryProtocol, UnitOfWorkProtocol
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Тестирование
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
uv sync --extra dev
|
|
154
|
+
uv run pytest -q # зелёный, coverage >= 80%
|
|
155
|
+
uv run ruff check . # чисто
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Тесты объявляют **собственные** демо-модели (`_User`, `_Post`) прямо в `conftest.py` —
|
|
159
|
+
это доказывает, что кит generic и не тянет никакого приложения.
|
|
160
|
+
|
|
161
|
+
## Диалект-нейтральность
|
|
162
|
+
|
|
163
|
+
- **Один код — любая СУБД.** Меняется только DB-URL: `sqlite://` ↔ `postgresql://` ↔
|
|
164
|
+
`mysql://`. Прикладной код не трогается.
|
|
165
|
+
- **sqlite FK enforcement.** Для sqlite `make_engine` навешивает `PRAGMA foreign_keys=ON`
|
|
166
|
+
(иначе sqlite молча игнорирует внешние ключи). Отключается флагом `foreign_keys=False`.
|
|
167
|
+
- **Временные метки.** `TimestampMixin` использует `func.now()`, работающий и в sqlite,
|
|
168
|
+
и в postgres/mysql.
|
|
169
|
+
- **Ленивое создание.** `make_engine("postgresql://...")` не коннектится — движок создаётся
|
|
170
|
+
без живого сервера.
|
|
171
|
+
|
|
172
|
+
## Лицензия
|
|
173
|
+
|
|
174
|
+
MIT
|
s_ormkit-0.0.1/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# s-ormkit
|
|
2
|
+
|
|
3
|
+
Generic БД/ORM-инфраструктура для экосистемы S-kits на SQLAlchemy 2.0: диалект-нейтральный
|
|
4
|
+
движок из DB-URL, Repository + UnitOfWork + DIP-протоколы.
|
|
5
|
+
|
|
6
|
+
**Версия:** 0.0.1 (первый релиз)
|
|
7
|
+
**Лицензия:** MIT
|
|
8
|
+
**Зависимости:** `SQLAlchemy>=2.0` (и ничего больше)
|
|
9
|
+
|
|
10
|
+
Кит НЕ знает ни о каком приложении. Прикладная логика получает БД-слой через ORM так,
|
|
11
|
+
что не зависит от sqlite/диалекта: движок конфигурируется через DB-URL и заменяется на
|
|
12
|
+
`postgresql://.../mysql://...` **без изменения кода**.
|
|
13
|
+
|
|
14
|
+
## Что входит
|
|
15
|
+
|
|
16
|
+
### Declarative-база и миксины (`base.py`)
|
|
17
|
+
- `Base` — общий declarative base для моделей-наследников
|
|
18
|
+
- `IntPkMixin` — целочисленный автоинкрементный первичный ключ
|
|
19
|
+
- `TimestampMixin` — `created_at` / `updated_at` через `func.now()` (диалект-нейтрально)
|
|
20
|
+
|
|
21
|
+
### Движок из DB-URL (`engine.py`)
|
|
22
|
+
- `make_engine` — резолв URL (аргумент → env → default), для sqlite включает
|
|
23
|
+
`PRAGMA foreign_keys=ON`; `create_engine` ленив (postgres-URL создаётся без коннекта)
|
|
24
|
+
- `make_session_factory` — `sessionmaker(expire_on_commit=False)`
|
|
25
|
+
- `init_schema` / `drop_schema` — создать/удалить таблицы
|
|
26
|
+
- `dispose_engine` — освободить пул соединений
|
|
27
|
+
|
|
28
|
+
### Repository-паттерн (`repository.py`)
|
|
29
|
+
- `BaseRepository[TModel, TDomain]` — generic CRUD: `add / get / get_or_none / list /
|
|
30
|
+
update / remove / count`
|
|
31
|
+
- Маппинг ORM <-> domain через переопределяемые хуки `to_domain` / `to_orm`
|
|
32
|
+
- Не коммитит сам (это делает `UnitOfWork`) — только `flush` для получения id
|
|
33
|
+
|
|
34
|
+
### Транзакционная граница (`unit_of_work.py`)
|
|
35
|
+
- `UnitOfWork` — контекст-менеджер: rollback при исключении, `close` всегда, `commit` явный
|
|
36
|
+
|
|
37
|
+
### DIP-контракты (`protocols.py`)
|
|
38
|
+
- `RepositoryProtocol` / `UnitOfWorkProtocol` — чтобы сервисы зависели от абстракций,
|
|
39
|
+
а не от `BaseRepository` / SQLAlchemy
|
|
40
|
+
|
|
41
|
+
## Быстрый старт
|
|
42
|
+
|
|
43
|
+
Кит generic — вы объявляете **свои** модели поверх `Base` и миксинов:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from dataclasses import dataclass
|
|
47
|
+
|
|
48
|
+
from sqlalchemy import String
|
|
49
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
50
|
+
|
|
51
|
+
from ormkit import (
|
|
52
|
+
Base, IntPkMixin, TimestampMixin,
|
|
53
|
+
BaseRepository, UnitOfWork,
|
|
54
|
+
make_engine, make_session_factory, init_schema,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# 1. Свои ORM-модели
|
|
59
|
+
class User(Base, IntPkMixin, TimestampMixin):
|
|
60
|
+
__tablename__ = "users"
|
|
61
|
+
name: Mapped[str] = mapped_column(String(100))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# 2. Свой доменный объект (dataclass / pydantic / dict — кит не знает тип)
|
|
65
|
+
@dataclass
|
|
66
|
+
class UserDTO:
|
|
67
|
+
name: str
|
|
68
|
+
id: int | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# 3. Свой репозиторий — переопределяем ТОЛЬКО хуки маппинга
|
|
72
|
+
class UserRepository(BaseRepository[User, UserDTO]):
|
|
73
|
+
def to_domain(self, obj: User) -> UserDTO:
|
|
74
|
+
return UserDTO(id=obj.id, name=obj.name)
|
|
75
|
+
|
|
76
|
+
def to_orm(self, domain: UserDTO) -> User:
|
|
77
|
+
return User(name=domain.name)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Конфигурация движка через DB-URL
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
# sqlite по умолчанию (для локали / тестов), FK enforcement включён автоматически
|
|
84
|
+
engine = make_engine("sqlite:///app.db")
|
|
85
|
+
|
|
86
|
+
# та же строка кода на проде — просто другой URL, коду всё равно:
|
|
87
|
+
engine = make_engine(default="postgresql://user:pass@host/db")
|
|
88
|
+
# или через переменную окружения ORMKIT_DB_URL:
|
|
89
|
+
engine = make_engine()
|
|
90
|
+
|
|
91
|
+
init_schema(engine)
|
|
92
|
+
session_factory = make_session_factory(engine)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Работа через UnitOfWork
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
with UnitOfWork(session_factory) as uow:
|
|
99
|
+
repo = UserRepository(uow.session, User)
|
|
100
|
+
|
|
101
|
+
saved = repo.add(UserDTO(name="Алиса")) # flush -> id проставлен
|
|
102
|
+
found = repo.get(saved.id) # NotFoundError, если нет
|
|
103
|
+
everyone = repo.list() # фильтр: repo.list(name="Алиса")
|
|
104
|
+
repo.update(saved.id, name="Алиса Смит")
|
|
105
|
+
total = repo.count()
|
|
106
|
+
|
|
107
|
+
uow.commit() # без commit транзакция не персистится
|
|
108
|
+
# исключение в блоке -> автоматический rollback; сессия всегда закрывается
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### DIP: сервис зависит от абстракции
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from ormkit import RepositoryProtocol
|
|
115
|
+
|
|
116
|
+
def register_user(repo: RepositoryProtocol[UserDTO], name: str) -> UserDTO:
|
|
117
|
+
return repo.add(UserDTO(name=name))
|
|
118
|
+
# в проде — UserRepository, в тестах — in-memory фейк, реализующий тот же протокол
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Архитектура
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
ormkit/
|
|
125
|
+
├── __init__.py # Public API + __version__
|
|
126
|
+
├── exceptions.py # OrmKitError, NotFoundError
|
|
127
|
+
├── base.py # Base, IntPkMixin, TimestampMixin
|
|
128
|
+
├── engine.py # make_engine, make_session_factory, init/drop_schema, dispose
|
|
129
|
+
├── repository.py # BaseRepository[TModel, TDomain]
|
|
130
|
+
├── unit_of_work.py # UnitOfWork
|
|
131
|
+
└── protocols.py # RepositoryProtocol, UnitOfWorkProtocol
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Тестирование
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
uv sync --extra dev
|
|
138
|
+
uv run pytest -q # зелёный, coverage >= 80%
|
|
139
|
+
uv run ruff check . # чисто
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Тесты объявляют **собственные** демо-модели (`_User`, `_Post`) прямо в `conftest.py` —
|
|
143
|
+
это доказывает, что кит generic и не тянет никакого приложения.
|
|
144
|
+
|
|
145
|
+
## Диалект-нейтральность
|
|
146
|
+
|
|
147
|
+
- **Один код — любая СУБД.** Меняется только DB-URL: `sqlite://` ↔ `postgresql://` ↔
|
|
148
|
+
`mysql://`. Прикладной код не трогается.
|
|
149
|
+
- **sqlite FK enforcement.** Для sqlite `make_engine` навешивает `PRAGMA foreign_keys=ON`
|
|
150
|
+
(иначе sqlite молча игнорирует внешние ключи). Отключается флагом `foreign_keys=False`.
|
|
151
|
+
- **Временные метки.** `TimestampMixin` использует `func.now()`, работающий и в sqlite,
|
|
152
|
+
и в postgres/mysql.
|
|
153
|
+
- **Ленивое создание.** `make_engine("postgresql://...")` не коннектится — движок создаётся
|
|
154
|
+
без живого сервера.
|
|
155
|
+
|
|
156
|
+
## Лицензия
|
|
157
|
+
|
|
158
|
+
MIT
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""s-ormkit — generic БД/ORM-инфраструктура для экосистемы S-kits.
|
|
2
|
+
|
|
3
|
+
Чистый инфраструктурный кит на SQLAlchemy 2.0 БЕЗ завязок на конкретное приложение.
|
|
4
|
+
Задача — дать прикладной логике БД-слой через ORM так, чтобы она НЕ знала про
|
|
5
|
+
sqlite/диалект: движок конфигурируется через DB-URL и заменяется на
|
|
6
|
+
`postgresql://.../mysql://...` без изменения кода.
|
|
7
|
+
|
|
8
|
+
**Ядро (v0.0.1):**
|
|
9
|
+
- Declarative-база и миксины: `Base`, `IntPkMixin`, `TimestampMixin`
|
|
10
|
+
- Движок из DB-URL, диалект-нейтрально: `make_engine`, `make_session_factory`,
|
|
11
|
+
`init_schema`, `drop_schema`, `dispose_engine`
|
|
12
|
+
- Repository-паттерн: `BaseRepository` (маппинг ORM <-> domain через хуки)
|
|
13
|
+
- Транзакционная граница: `UnitOfWork`
|
|
14
|
+
- DIP-контракты: `RepositoryProtocol`, `UnitOfWorkProtocol`
|
|
15
|
+
|
|
16
|
+
Зависимости: SQLAlchemy>=2.0 (и ничего больше).
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from ormkit.base import Base, IntPkMixin, TimestampMixin
|
|
21
|
+
from ormkit.engine import (
|
|
22
|
+
dispose_engine,
|
|
23
|
+
drop_schema,
|
|
24
|
+
init_schema,
|
|
25
|
+
make_engine,
|
|
26
|
+
make_session_factory,
|
|
27
|
+
)
|
|
28
|
+
from ormkit.exceptions import NotFoundError, OrmKitError
|
|
29
|
+
from ormkit.protocols import RepositoryProtocol, UnitOfWorkProtocol
|
|
30
|
+
from ormkit.repository import BaseRepository
|
|
31
|
+
from ormkit.unit_of_work import UnitOfWork
|
|
32
|
+
|
|
33
|
+
__version__ = "0.0.1"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
# base
|
|
37
|
+
"Base",
|
|
38
|
+
"IntPkMixin",
|
|
39
|
+
"TimestampMixin",
|
|
40
|
+
# engine
|
|
41
|
+
"make_engine",
|
|
42
|
+
"make_session_factory",
|
|
43
|
+
"init_schema",
|
|
44
|
+
"drop_schema",
|
|
45
|
+
"dispose_engine",
|
|
46
|
+
# repository
|
|
47
|
+
"BaseRepository",
|
|
48
|
+
# unit of work
|
|
49
|
+
"UnitOfWork",
|
|
50
|
+
# protocols
|
|
51
|
+
"RepositoryProtocol",
|
|
52
|
+
"UnitOfWorkProtocol",
|
|
53
|
+
# exceptions
|
|
54
|
+
"OrmKitError",
|
|
55
|
+
"NotFoundError",
|
|
56
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Declarative-база SQLAlchemy 2.0 и переиспользуемые миксины для моделей.
|
|
2
|
+
|
|
3
|
+
Диалект-нейтрально: `func.now()` работает и в sqlite, и в postgres/mysql, поэтому
|
|
4
|
+
модели наследников не завязаны на конкретную СУБД.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
from sqlalchemy import DateTime, func
|
|
11
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Base(DeclarativeBase):
|
|
15
|
+
"""Общий declarative base для всех моделей-наследников кита."""
|
|
16
|
+
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class IntPkMixin:
|
|
21
|
+
"""Миксин целочисленного автоинкрементного первичного ключа."""
|
|
22
|
+
|
|
23
|
+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TimestampMixin:
|
|
27
|
+
"""Миксин временных меток создания/обновления.
|
|
28
|
+
|
|
29
|
+
`created_at` проставляется БД на INSERT, `updated_at` — на INSERT и на UPDATE.
|
|
30
|
+
Использует `func.now()`, что диалект-нейтрально.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
34
|
+
DateTime(timezone=True),
|
|
35
|
+
server_default=func.now(),
|
|
36
|
+
)
|
|
37
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
38
|
+
DateTime(timezone=True),
|
|
39
|
+
server_default=func.now(),
|
|
40
|
+
onupdate=func.now(),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"Base",
|
|
46
|
+
"IntPkMixin",
|
|
47
|
+
"TimestampMixin",
|
|
48
|
+
"Mapped",
|
|
49
|
+
"mapped_column",
|
|
50
|
+
]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Создание движка и фабрики сессий из DB-URL, диалект-нейтрально.
|
|
2
|
+
|
|
3
|
+
Движок конфигурируется через DB-URL: по умолчанию можно подать sqlite, заменяемо на
|
|
4
|
+
`postgresql://.../mysql://...` БЕЗ изменения прикладного кода. `create_engine` ленив —
|
|
5
|
+
движок к postgres/mysql создаётся без живого сервера и без коннекта.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import Engine, create_engine, event
|
|
12
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
13
|
+
|
|
14
|
+
from ormkit.base import Base
|
|
15
|
+
from ormkit.exceptions import OrmKitError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def make_engine(
|
|
19
|
+
url: str | None = None,
|
|
20
|
+
*,
|
|
21
|
+
env_var: str = "ORMKIT_DB_URL",
|
|
22
|
+
default: str | None = None,
|
|
23
|
+
echo: bool = False,
|
|
24
|
+
foreign_keys: bool = True,
|
|
25
|
+
) -> Engine:
|
|
26
|
+
"""Создать движок из DB-URL.
|
|
27
|
+
|
|
28
|
+
Резолв URL по приоритету: явный аргумент → `os.environ[env_var]` → `default`.
|
|
29
|
+
Если ничего не задано — `OrmKitError`.
|
|
30
|
+
|
|
31
|
+
Для sqlite при `foreign_keys=True` навешивается обработчик `connect`, выполняющий
|
|
32
|
+
`PRAGMA foreign_keys=ON` (иначе sqlite молча игнорирует внешние ключи). Для прочих
|
|
33
|
+
диалектов PRAGMA не применяется.
|
|
34
|
+
|
|
35
|
+
`create_engine` ленив: движок к недоступному postgres/mysql создаётся без коннекта.
|
|
36
|
+
"""
|
|
37
|
+
resolved = url if url is not None else os.environ.get(env_var, default)
|
|
38
|
+
if not resolved:
|
|
39
|
+
raise OrmKitError(
|
|
40
|
+
"Не задан DB-URL: передайте url аргументом, "
|
|
41
|
+
f"установите переменную окружения {env_var} или задайте default."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
engine = create_engine(resolved, echo=echo)
|
|
45
|
+
|
|
46
|
+
if foreign_keys and engine.dialect.name == "sqlite":
|
|
47
|
+
|
|
48
|
+
@event.listens_for(engine, "connect")
|
|
49
|
+
def _enable_sqlite_fk(dbapi_connection, connection_record):
|
|
50
|
+
cursor = dbapi_connection.cursor()
|
|
51
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
52
|
+
cursor.close()
|
|
53
|
+
|
|
54
|
+
return engine
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def make_session_factory(engine: Engine) -> sessionmaker[Session]:
|
|
58
|
+
"""Создать фабрику сессий, привязанную к движку.
|
|
59
|
+
|
|
60
|
+
`expire_on_commit=False`, чтобы объекты оставались доступны после commit.
|
|
61
|
+
"""
|
|
62
|
+
return sessionmaker(bind=engine, expire_on_commit=False)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def init_schema(engine: Engine, base: type = Base) -> None:
|
|
66
|
+
"""Создать все таблицы, зарегистрированные в metadata указанного base."""
|
|
67
|
+
base.metadata.create_all(engine)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def drop_schema(engine: Engine, base: type = Base) -> None:
|
|
71
|
+
"""Удалить все таблицы, зарегистрированные в metadata указанного base."""
|
|
72
|
+
base.metadata.drop_all(engine)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def dispose_engine(engine: Engine) -> None:
|
|
76
|
+
"""Освободить пул соединений движка."""
|
|
77
|
+
engine.dispose()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
__all__ = [
|
|
81
|
+
"make_engine",
|
|
82
|
+
"make_session_factory",
|
|
83
|
+
"init_schema",
|
|
84
|
+
"drop_schema",
|
|
85
|
+
"dispose_engine",
|
|
86
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Исключения кита ormkit."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class OrmKitError(Exception):
|
|
6
|
+
"""Базовое исключение кита ormkit."""
|
|
7
|
+
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NotFoundError(OrmKitError):
|
|
12
|
+
"""Сущность не найдена по первичному ключу."""
|
|
13
|
+
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"OrmKitError",
|
|
19
|
+
"NotFoundError",
|
|
20
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""DIP-контракты: прикладные сервисы зависят от абстракций, а не от BaseRepository/SQLAlchemy.
|
|
2
|
+
|
|
3
|
+
Protocol'ы позволяют внедрять любую реализацию репозитория/UoW (в т.ч. in-memory фейки
|
|
4
|
+
для тестов), не завязываясь на конкретный класс кита.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Any, Protocol, TypeVar, runtime_checkable
|
|
10
|
+
|
|
11
|
+
TDomain = TypeVar("TDomain")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@runtime_checkable
|
|
15
|
+
class RepositoryProtocol(Protocol[TDomain]):
|
|
16
|
+
"""Контракт репозитория для прикладного слоя."""
|
|
17
|
+
|
|
18
|
+
def add(self, domain: TDomain) -> TDomain: ...
|
|
19
|
+
|
|
20
|
+
def get(self, id: Any) -> TDomain: ...
|
|
21
|
+
|
|
22
|
+
def get_or_none(self, id: Any) -> TDomain | None: ...
|
|
23
|
+
|
|
24
|
+
def list(self, **filters: Any) -> list[TDomain]: ...
|
|
25
|
+
|
|
26
|
+
def update(self, id: Any, **changes: Any) -> TDomain: ...
|
|
27
|
+
|
|
28
|
+
def remove(self, id: Any) -> None: ...
|
|
29
|
+
|
|
30
|
+
def count(self, **filters: Any) -> int: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@runtime_checkable
|
|
34
|
+
class UnitOfWorkProtocol(Protocol):
|
|
35
|
+
"""Контракт транзакционной границы для прикладного слоя."""
|
|
36
|
+
|
|
37
|
+
def __enter__(self) -> UnitOfWorkProtocol: ...
|
|
38
|
+
|
|
39
|
+
def __exit__(
|
|
40
|
+
self,
|
|
41
|
+
exc_type: type[BaseException] | None,
|
|
42
|
+
exc: BaseException | None,
|
|
43
|
+
tb: TracebackType | None,
|
|
44
|
+
) -> None: ...
|
|
45
|
+
|
|
46
|
+
def commit(self) -> None: ...
|
|
47
|
+
|
|
48
|
+
def rollback(self) -> None: ...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"RepositoryProtocol",
|
|
53
|
+
"UnitOfWorkProtocol",
|
|
54
|
+
]
|