s-ormkit 0.0.1__tar.gz → 0.2.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.2.1/AGENTS.md +45 -0
- s_ormkit-0.2.1/CHANGELOG.md +121 -0
- {s_ormkit-0.0.1 → s_ormkit-0.2.1}/PKG-INFO +116 -6
- {s_ormkit-0.0.1 → s_ormkit-0.2.1}/README.md +109 -4
- s_ormkit-0.2.1/docs/adr/0000-template.md +20 -0
- s_ormkit-0.2.1/docs/adr/README.md +30 -0
- s_ormkit-0.2.1/ormkit/__init__.py +150 -0
- s_ormkit-0.2.1/ormkit/__main__.py +81 -0
- s_ormkit-0.2.1/ormkit/async_engine.py +126 -0
- s_ormkit-0.2.1/ormkit/async_repository.py +129 -0
- s_ormkit-0.2.1/ormkit/async_unit_of_work.py +57 -0
- {s_ormkit-0.0.1 → s_ormkit-0.2.1}/ormkit/engine.py +4 -10
- s_ormkit-0.2.1/ormkit/loading.py +169 -0
- s_ormkit-0.2.1/ormkit/migrations.py +271 -0
- s_ormkit-0.2.1/ormkit/pagination.py +131 -0
- {s_ormkit-0.0.1 → s_ormkit-0.2.1}/ormkit/protocols.py +39 -0
- {s_ormkit-0.0.1 → s_ormkit-0.2.1}/ormkit/repository.py +13 -2
- s_ormkit-0.2.1/ormkit/rls.py +146 -0
- s_ormkit-0.2.1/ormkit/search.py +104 -0
- s_ormkit-0.2.1/ormkit/url.py +94 -0
- {s_ormkit-0.0.1 → s_ormkit-0.2.1}/pyproject.toml +18 -4
- {s_ormkit-0.0.1 → s_ormkit-0.2.1}/tests/conftest.py +113 -66
- s_ormkit-0.2.1/tests/integration/test_async_infra_integration.py +42 -0
- s_ormkit-0.2.1/tests/integration/test_async_repository.py +94 -0
- s_ormkit-0.2.1/tests/integration/test_async_unit_of_work.py +65 -0
- s_ormkit-0.2.1/tests/integration/test_cli.py +35 -0
- s_ormkit-0.2.1/tests/integration/test_loading.py +156 -0
- s_ormkit-0.2.1/tests/integration/test_migrations.py +177 -0
- s_ormkit-0.2.1/tests/unit/test_async_engine.py +173 -0
- {s_ormkit-0.0.1 → s_ormkit-0.2.1}/tests/unit/test_engine.py +8 -0
- s_ormkit-0.2.1/tests/unit/test_infra_helpers.py +143 -0
- s_ormkit-0.2.1/tests/unit/test_protocols.py +55 -0
- s_ormkit-0.2.1/tests/unit/test_public_api.py +62 -0
- {s_ormkit-0.0.1 → s_ormkit-0.2.1}/tests/unit/test_schema.py +1 -1
- s_ormkit-0.2.1/tests/unit/test_url.py +94 -0
- {s_ormkit-0.0.1 → s_ormkit-0.2.1}/uv.lock +169 -2
- s_ormkit-0.0.1/CHANGELOG.md +0 -36
- s_ormkit-0.0.1/ormkit/__init__.py +0 -56
- s_ormkit-0.0.1/tests/unit/test_protocols.py +0 -28
- {s_ormkit-0.0.1 → s_ormkit-0.2.1}/.gitignore +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.2.1}/LICENSE +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.2.1}/ormkit/base.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.2.1}/ormkit/exceptions.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.2.1}/ormkit/unit_of_work.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.2.1}/tests/__init__.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.2.1}/tests/integration/__init__.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.2.1}/tests/integration/test_foreign_keys.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.2.1}/tests/integration/test_repository.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.2.1}/tests/integration/test_timestamps.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.2.1}/tests/integration/test_unit_of_work.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.2.1}/tests/unit/__init__.py +0 -0
s_ormkit-0.2.1/AGENTS.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# AGENTS.md — ormkit
|
|
2
|
+
|
|
3
|
+
> Контекст для AI-ассистентов (Claude Code, ChatGPT, Cursor и т.п.), работающих
|
|
4
|
+
> над этим проектом.
|
|
5
|
+
|
|
6
|
+
## Что это
|
|
7
|
+
|
|
8
|
+
(заполнить one-line)
|
|
9
|
+
|
|
10
|
+
## Atlas
|
|
11
|
+
|
|
12
|
+
Проект зарегистрирован в Atlas-БД (Atlas). Карточка:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
atlas project get ormkit
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Любые изменения метаданных (приоритет, статус, теги) — через atlas CLI:
|
|
19
|
+
|
|
20
|
+
- `atlas project update ormkit --priority P0` — поменять приоритет
|
|
21
|
+
- `atlas project tag add ormkit -t domain:<slug>` — добавить тег
|
|
22
|
+
- `atlas project move ormkit --to-type <type>` — конвертировать тип
|
|
23
|
+
|
|
24
|
+
## Тип / Статус (на момент создания)
|
|
25
|
+
|
|
26
|
+
- type=`kit`, status=`experiment`, priority=`P2`
|
|
27
|
+
|
|
28
|
+
## Правила работы
|
|
29
|
+
|
|
30
|
+
- Все исходные тексты, документы, код проекта — в этом репо.
|
|
31
|
+
- Чувствительные данные (`.env`, токены, ключи) — игнорируются `.gitignore`.
|
|
32
|
+
- AI-ассистенту разрешено: читать, генерировать, редактировать в этом репо.
|
|
33
|
+
|
|
34
|
+
## Канонические команды
|
|
35
|
+
|
|
36
|
+
- `atlas project get ormkit` — карточка проекта
|
|
37
|
+
- `atlas task list --project ormkit` — задачи проекта (когда W7
|
|
38
|
+
волна будет реализована)
|
|
39
|
+
|
|
40
|
+
<!-- atlas:usage:start -->
|
|
41
|
+
## Управление проектом — через Atlas
|
|
42
|
+
|
|
43
|
+
Этот проект ведётся в Atlas (личная PM-система портфеля). Для задач/проектов/эпиков/бэкапов
|
|
44
|
+
используй CLI `atlas` и вызывай навык `atlas` — вся логика и роутинг внутри навыка.
|
|
45
|
+
<!-- atlas:usage:end -->
|
|
@@ -0,0 +1,121 @@
|
|
|
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.2.1] - 2026-08-30
|
|
9
|
+
|
|
10
|
+
### Добавлено
|
|
11
|
+
|
|
12
|
+
- `make_async_engine(..., sqlite_pragmas=...)` — дополнительные PRAGMA на каждое
|
|
13
|
+
подключение sqlite (`journal_mode=WAL`, `busy_timeout`). Нужны там, где одну
|
|
14
|
+
базу держат несколько процессов: по умолчанию писатель блокирует читателей
|
|
15
|
+
целиком, и соседний процесс получает «database is locked» ровно в момент
|
|
16
|
+
чужой записи. Знание общее для любого потребителя sqlite — жило в каждом
|
|
17
|
+
приложении заново.
|
|
18
|
+
- `make_async_engine(..., connect_args=...)` — параметры драйвера как есть. Для
|
|
19
|
+
sqlite важно вместе с `busy_timeout`: `aiosqlite` ждёт очереди по своему
|
|
20
|
+
таймеру (5 с по умолчанию) и отказывает раньше, чем сработает PRAGMA.
|
|
21
|
+
|
|
22
|
+
Своё поведение не изменилось: `foreign_keys=True` по-прежнему включает внешние
|
|
23
|
+
ключи, а свои PRAGMA добавляются к нему, а не вместо.
|
|
24
|
+
|
|
25
|
+
## [0.2.0] - 2026-08-17
|
|
26
|
+
|
|
27
|
+
Кит перестал быть «синхронным ядром с async-хвостами»: асинхронные потребители
|
|
28
|
+
(ChatRelay, в перспективе Skillery) садятся на него без обходных путей.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **Async-слой** (`ormkit.async_engine`): `make_async_engine(url|env|default, echo=,
|
|
33
|
+
foreign_keys=)`, `make_async_session_factory` (`async_sessionmaker`,
|
|
34
|
+
`expire_on_commit=False`), `init_schema_async` / `drop_schema_async` /
|
|
35
|
+
`dispose_engine_async`. Для sqlite `PRAGMA foreign_keys=ON` вешается событием на
|
|
36
|
+
`sync_engine`; `create_async_engine` остаётся ленивым (движок к недоступному
|
|
37
|
+
postgres создаётся без коннекта).
|
|
38
|
+
- **Асинхронные близнецы** с тем же API, что у синхронных: `AsyncUnitOfWork`
|
|
39
|
+
(`async with`, rollback при исключении, явный `commit`), `AsyncBaseRepository`
|
|
40
|
+
(`add / get / get_or_none / list / update / remove / count`), DIP-контракты
|
|
41
|
+
`AsyncRepositoryProtocol` / `AsyncUnitOfWorkProtocol`.
|
|
42
|
+
- **Резолв DB-URL и автоподстановка драйвера** (`ormkit.url`): `resolve_db_url`
|
|
43
|
+
(аргумент → env → default), `async_driver_url` (`sqlite`→`aiosqlite`,
|
|
44
|
+
`postgresql`→`asyncpg`, `mysql`→`aiomysql`), `sync_driver_url`
|
|
45
|
+
(`postgresql`→`psycopg`). Явно заданный драйвер не трогается; `postgres://`
|
|
46
|
+
канонизируется в `postgresql://`.
|
|
47
|
+
- **Миграции** (`ormkit.migrations`, extra `[migrations]`): `scaffold_alembic(...)`
|
|
48
|
+
генерирует в проекте-потребителе `alembic.ini` + `alembic/env.py` (async-движок,
|
|
49
|
+
`render_as_batch=True`, metadata по ссылке `pkg.module:attr`) + `script.py.mako`;
|
|
50
|
+
URL резолвится тем же способом, что и в `make_engine` (`migration_url`).
|
|
51
|
+
CLI: `python -m ormkit migrations init . --metadata myapp.models:Base`
|
|
52
|
+
(и console script `ormkit`). Без установленного alembic — понятная ошибка
|
|
53
|
+
`require_alembic` с подсказкой про extra.
|
|
54
|
+
- **Защита от N+1** (`ormkit.loading`): `LoadSpec(selectin=, joined=)` с вложенными
|
|
55
|
+
путями через точку + `apply_load_spec`; строгий режим на `raiseload("*")` —
|
|
56
|
+
`strict_loading` (один запрос), `enable_strict_loading` (сессия, с возвратом
|
|
57
|
+
выключателя), `make_strict_session_factory` / `make_strict_async_session_factory`
|
|
58
|
+
(фабрики для тестов и CI). Оба репозитория применяют `load_spec` ко всем чтениям.
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- `make_engine` использует общий резолвер URL и подставляет синхронный драйвер
|
|
63
|
+
(`postgresql://` → `postgresql+psycopg://`).
|
|
64
|
+
- `BaseRepository` читает через внутренний `_select()` (точка расширения + место
|
|
65
|
+
применения `load_spec`); публичное поведение не изменилось.
|
|
66
|
+
|
|
67
|
+
### Notes
|
|
68
|
+
|
|
69
|
+
- **Обратная совместимость сохранена**: весь синхронный API v0.1.0 на месте,
|
|
70
|
+
`load_spec` по умолчанию `None` (запросы такие же, как раньше).
|
|
71
|
+
- Alembic НЕ добавлен в обязательные зависимости: `pip install s-ormkit[migrations]`.
|
|
72
|
+
- Тесты постгреса не требуют живого сервера: проверяются резолв URL, ленивое
|
|
73
|
+
создание движка и offline-рендер миграции (`alembic upgrade head --sql`).
|
|
74
|
+
Реальный прогон миграций (autogenerate → upgrade → downgrade) — на sqlite+aiosqlite.
|
|
75
|
+
|
|
76
|
+
## [0.1.0] - 2026-07-07
|
|
77
|
+
|
|
78
|
+
### Added
|
|
79
|
+
|
|
80
|
+
- **`ormkit.pagination`** — offset-пагинация: чистые `Page[T]` / `page_to_offset`
|
|
81
|
+
+ SQL-исполнитель `paginate_stmt(session, stmt, limit, offset, count_stmt=,
|
|
82
|
+
scalars=)` (limit/offset + авто-count, либо custom count для JOIN/DISTINCT).
|
|
83
|
+
- **`ormkit.search`** — `trigram_filter(stmt, cols, q, dialect=, threshold=)`:
|
|
84
|
+
fuzzy-поиск (PostgreSQL `word_similarity`/pg_trgm с exact-boost, ILIKE-fallback
|
|
85
|
+
для остальных диалектов); возвращает `(stmt_with_where, rank_expr)`.
|
|
86
|
+
- **`ormkit.rls`** — tenant Row-Level Security (defense-in-depth): `TenantContext`,
|
|
87
|
+
ContextVar-хелперы (`set`/`reset`/`current_tenant_context`), `rls_bypass`
|
|
88
|
+
контекст-менеджер, `apply_tenant_guc` (transaction-local GUC `app.rls_enforce`/
|
|
89
|
+
`app.rls_bypass`/`app.current_company`; no-op на не-PostgreSQL и при `ctx=None`).
|
|
90
|
+
|
|
91
|
+
Все три модуля вынесены из skills-hub (generic SQLAlchemy, 0 завязок на приложение).
|
|
92
|
+
|
|
93
|
+
## [0.0.1] - 2026-07-04
|
|
94
|
+
|
|
95
|
+
### Added
|
|
96
|
+
|
|
97
|
+
- **Declarative-база и миксины** (`Base`, `IntPkMixin`, `TimestampMixin`): общий
|
|
98
|
+
SQLAlchemy 2.0 declarative base и переиспользуемые миксины (int-PK, диалект-нейтральные
|
|
99
|
+
временные метки через `func.now()`)
|
|
100
|
+
- **Движок из DB-URL** (`make_engine`, `make_session_factory`, `init_schema`, `drop_schema`,
|
|
101
|
+
`dispose_engine`): резолв URL (аргумент → env `ORMKIT_DB_URL` → default), ленивое
|
|
102
|
+
создание движка (postgres-URL без коннекта), автоматический `PRAGMA foreign_keys=ON` для
|
|
103
|
+
sqlite
|
|
104
|
+
- **Repository-паттерн** (`BaseRepository[TModel, TDomain]`): generic CRUD
|
|
105
|
+
(`add / get / get_or_none / list / update / remove / count`) с маппингом ORM <-> domain
|
|
106
|
+
через переопределяемые хуки `to_domain` / `to_orm`
|
|
107
|
+
- **Транзакционная граница** (`UnitOfWork`): контекст-менеджер с rollback при исключении,
|
|
108
|
+
гарантированным `close` и явным `commit`
|
|
109
|
+
- **DIP-контракты** (`RepositoryProtocol`, `UnitOfWorkProtocol`): Protocol'ы для зависимости
|
|
110
|
+
прикладного слоя от абстракций, а не от `BaseRepository` / SQLAlchemy
|
|
111
|
+
- **Исключения** (`OrmKitError`, `NotFoundError`)
|
|
112
|
+
- **Тесты** (unit + integration, coverage ≥ 80%): резолв DB-URL, FK enforcement на sqlite,
|
|
113
|
+
round-trip CRUD, транзакции UoW, диалект-нейтральность временных меток; демо-модели
|
|
114
|
+
объявлены в самих тестах
|
|
115
|
+
|
|
116
|
+
### Notes
|
|
117
|
+
|
|
118
|
+
- Первый релиз кита `s-ormkit` как generic БД/ORM-инфраструктуры для экосистемы S-kits
|
|
119
|
+
- 0 завязок на конкретное приложение: только чистая инфраструктура
|
|
120
|
+
- Зависит ТОЛЬКО от SQLAlchemy>=2.0
|
|
121
|
+
- Диалект-нейтрально: движок заменяется через DB-URL (sqlite → postgres → mysql) без правок кода
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: s-ormkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Generic БД/ORM-инфраструктура на SQLAlchemy 2.0: диалект-нейтральный движок из DB-URL, Repository + UnitOfWork + DIP-протоколы. 0 завязок на конкретное приложение.
|
|
5
5
|
Author: Dmitry
|
|
6
6
|
License: MIT
|
|
@@ -8,10 +8,15 @@ License-File: LICENSE
|
|
|
8
8
|
Requires-Python: >=3.11
|
|
9
9
|
Requires-Dist: sqlalchemy>=2.0
|
|
10
10
|
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: aiosqlite>=0.20; extra == 'dev'
|
|
12
|
+
Requires-Dist: alembic>=1.13; extra == 'dev'
|
|
13
|
+
Requires-Dist: asyncpg>=0.29; extra == 'dev'
|
|
11
14
|
Requires-Dist: psycopg[binary]>=3.1; extra == 'dev'
|
|
12
15
|
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
13
16
|
Requires-Dist: pytest-cov>=6.0; extra == 'dev'
|
|
14
17
|
Requires-Dist: pytest>=8; extra == 'dev'
|
|
18
|
+
Provides-Extra: migrations
|
|
19
|
+
Requires-Dist: alembic>=1.13; extra == 'migrations'
|
|
15
20
|
Description-Content-Type: text/markdown
|
|
16
21
|
|
|
17
22
|
# s-ormkit
|
|
@@ -19,9 +24,9 @@ Description-Content-Type: text/markdown
|
|
|
19
24
|
Generic БД/ORM-инфраструктура для экосистемы S-kits на SQLAlchemy 2.0: диалект-нейтральный
|
|
20
25
|
движок из DB-URL, Repository + UnitOfWork + DIP-протоколы.
|
|
21
26
|
|
|
22
|
-
**Версия:** 0.0
|
|
27
|
+
**Версия:** 0.2.0
|
|
23
28
|
**Лицензия:** MIT
|
|
24
|
-
**Зависимости:** `SQLAlchemy>=2.0` (
|
|
29
|
+
**Зависимости:** `SQLAlchemy>=2.0` (alembic — опционально: `s-ormkit[migrations]`)
|
|
25
30
|
|
|
26
31
|
Кит НЕ знает ни о каком приложении. Прикладная логика получает БД-слой через ORM так,
|
|
27
32
|
что не зависит от sqlite/диалекта: движок конфигурируется через DB-URL и заменяется на
|
|
@@ -53,6 +58,30 @@ Generic БД/ORM-инфраструктура для экосистемы S-kits
|
|
|
53
58
|
### DIP-контракты (`protocols.py`)
|
|
54
59
|
- `RepositoryProtocol` / `UnitOfWorkProtocol` — чтобы сервисы зависели от абстракций,
|
|
55
60
|
а не от `BaseRepository` / SQLAlchemy
|
|
61
|
+
- `AsyncRepositoryProtocol` / `AsyncUnitOfWorkProtocol` — то же для async-слоя
|
|
62
|
+
|
|
63
|
+
### Async-слой (`async_engine.py`, `async_repository.py`, `async_unit_of_work.py`)
|
|
64
|
+
- `make_async_engine` / `make_async_session_factory` — асинхронный движок и
|
|
65
|
+
`async_sessionmaker(expire_on_commit=False)`; для sqlite так же включается
|
|
66
|
+
`PRAGMA foreign_keys=ON`, `create_async_engine` так же ленив
|
|
67
|
+
- `init_schema_async` / `drop_schema_async` / `dispose_engine_async`
|
|
68
|
+
- `AsyncUnitOfWork` / `AsyncBaseRepository` — асинхронные близнецы с ТЕМ ЖЕ API
|
|
69
|
+
|
|
70
|
+
### Резолв DB-URL (`url.py`)
|
|
71
|
+
- `resolve_db_url` — аргумент → переменная окружения → default (общий для sync, async
|
|
72
|
+
и миграций)
|
|
73
|
+
- `async_driver_url` / `sync_driver_url` — автоподстановка драйвера
|
|
74
|
+
(`postgresql://` → `postgresql+asyncpg://` или `postgresql+psycopg://`)
|
|
75
|
+
|
|
76
|
+
### Защита от N+1 (`loading.py`)
|
|
77
|
+
- `LoadSpec(selectin=, joined=)` + `apply_load_spec` — декларация связей, которые
|
|
78
|
+
грузятся вместе с сущностью (в т.ч. вложенные пути `"posts.comments"`)
|
|
79
|
+
- `strict_loading` / `enable_strict_loading` / `make_strict_async_session_factory` —
|
|
80
|
+
строгий режим `raiseload("*")`: незадекларированная ленивая подгрузка падает
|
|
81
|
+
|
|
82
|
+
### Миграции (`migrations.py`, extra `[migrations]`)
|
|
83
|
+
- `scaffold_alembic` и CLI `python -m ormkit migrations init` — генерация рабочего
|
|
84
|
+
alembic-окружения на async-движке кита
|
|
56
85
|
|
|
57
86
|
## Быстрый старт
|
|
58
87
|
|
|
@@ -134,17 +163,96 @@ def register_user(repo: RepositoryProtocol[UserDTO], name: str) -> UserDTO:
|
|
|
134
163
|
# в проде — UserRepository, в тестах — in-memory фейк, реализующий тот же протокол
|
|
135
164
|
```
|
|
136
165
|
|
|
166
|
+
### Асинхронное приложение
|
|
167
|
+
|
|
168
|
+
Тот же код, но на `async/await` — форма вызова другая, структура та же:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from ormkit import (
|
|
172
|
+
AsyncBaseRepository, AsyncUnitOfWork,
|
|
173
|
+
init_schema_async, make_async_engine, make_async_session_factory,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class UserRepository(AsyncBaseRepository[User, UserDTO]):
|
|
178
|
+
def to_domain(self, obj: User) -> UserDTO:
|
|
179
|
+
return UserDTO(id=obj.id, name=obj.name)
|
|
180
|
+
|
|
181
|
+
def to_orm(self, domain: UserDTO) -> User:
|
|
182
|
+
return User(name=domain.name)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# sqlite:// → sqlite+aiosqlite://, postgresql:// → postgresql+asyncpg:// (автоматически)
|
|
186
|
+
engine = make_async_engine("postgresql://user:pass@host/db")
|
|
187
|
+
await init_schema_async(engine) # в проде схему катает alembic, см. ниже
|
|
188
|
+
session_factory = make_async_session_factory(engine)
|
|
189
|
+
|
|
190
|
+
async with AsyncUnitOfWork(session_factory) as uow:
|
|
191
|
+
repo = UserRepository(uow.session, User)
|
|
192
|
+
saved = await repo.add(UserDTO(name="Алиса"))
|
|
193
|
+
await uow.commit()
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Миграции (alembic)
|
|
197
|
+
|
|
198
|
+
`create_all` годится до первого изменения схемы на живых данных — дальше нужны миграции:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
pip install "s-ormkit[migrations]"
|
|
202
|
+
python -m ormkit migrations init . --metadata myapp.models:Base
|
|
203
|
+
# дальше — штатный alembic, URL берётся из ORMKIT_DB_URL (как и у движка приложения):
|
|
204
|
+
alembic revision --autogenerate -m "init"
|
|
205
|
+
alembic upgrade head
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Сгенерированный `env.py` работает на async-движке кита и с `render_as_batch=True`
|
|
209
|
+
(без него sqlite не переживает ALTER). Ссылка на metadata (`pkg.module:attr`) —
|
|
210
|
+
единственное, что нужно указать.
|
|
211
|
+
|
|
212
|
+
### Защита от N+1
|
|
213
|
+
|
|
214
|
+
Репозиторий ДЕКЛАРИРУЕТ, что грузится вместе с сущностью, а строгий режим
|
|
215
|
+
не даёт незаметно уехать в ленивую подгрузку:
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
from ormkit import LoadSpec, enable_strict_loading, make_strict_async_session_factory
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class UserRepository(AsyncBaseRepository[User, UserDTO]):
|
|
222
|
+
load_spec = LoadSpec(selectin=("posts.comments",), joined=("profile",))
|
|
223
|
+
...
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# в conftest тестов / в CI: любая НЕобъявленная ленивая подгрузка → InvalidRequestError
|
|
227
|
+
session_factory = make_strict_async_session_factory(engine)
|
|
228
|
+
# либо точечно на уже открытой сессии (возвращает выключатель):
|
|
229
|
+
disable = enable_strict_loading(session)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`selectin` — для коллекций (отдельный IN-запрос), `joined` — для «многие-к-одному»
|
|
233
|
+
(один JOIN). N+1 падает на этапе тестов, а не в проде.
|
|
234
|
+
|
|
137
235
|
## Архитектура
|
|
138
236
|
|
|
139
237
|
```
|
|
140
238
|
ormkit/
|
|
141
239
|
├── __init__.py # Public API + __version__
|
|
240
|
+
├── __main__.py # CLI: python -m ormkit migrations init
|
|
142
241
|
├── exceptions.py # OrmKitError, NotFoundError
|
|
143
242
|
├── base.py # Base, IntPkMixin, TimestampMixin
|
|
243
|
+
├── url.py # resolve_db_url, async_driver_url, sync_driver_url
|
|
144
244
|
├── engine.py # make_engine, make_session_factory, init/drop_schema, dispose
|
|
245
|
+
├── async_engine.py # make_async_engine, make_async_session_factory, *_async
|
|
145
246
|
├── repository.py # BaseRepository[TModel, TDomain]
|
|
247
|
+
├── async_repository.py # AsyncBaseRepository[TModel, TDomain]
|
|
146
248
|
├── unit_of_work.py # UnitOfWork
|
|
147
|
-
|
|
249
|
+
├── async_unit_of_work.py# AsyncUnitOfWork
|
|
250
|
+
├── loading.py # LoadSpec, apply_load_spec, строгий режим (raiseload)
|
|
251
|
+
├── migrations.py # scaffold_alembic, migration_url, require_alembic
|
|
252
|
+
├── pagination.py # Page, page_to_offset, paginate_stmt
|
|
253
|
+
├── search.py # trigram_filter
|
|
254
|
+
├── rls.py # TenantContext, apply_tenant_guc, rls_bypass
|
|
255
|
+
└── protocols.py # RepositoryProtocol, UnitOfWorkProtocol (+ async)
|
|
148
256
|
```
|
|
149
257
|
|
|
150
258
|
## Тестирование
|
|
@@ -167,7 +275,9 @@ uv run ruff check . # чисто
|
|
|
167
275
|
- **Временные метки.** `TimestampMixin` использует `func.now()`, работающий и в sqlite,
|
|
168
276
|
и в postgres/mysql.
|
|
169
277
|
- **Ленивое создание.** `make_engine("postgresql://...")` не коннектится — движок создаётся
|
|
170
|
-
без живого сервера.
|
|
278
|
+
без живого сервера. То же верно для `make_async_engine`.
|
|
279
|
+
- **Драйвер подставляется сам.** `postgresql://` становится `postgresql+asyncpg://` в
|
|
280
|
+
async-контексте и `postgresql+psycopg://` в синхронном; явно заданный драйвер не трогается.
|
|
171
281
|
|
|
172
282
|
## Лицензия
|
|
173
283
|
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
Generic БД/ORM-инфраструктура для экосистемы S-kits на SQLAlchemy 2.0: диалект-нейтральный
|
|
4
4
|
движок из DB-URL, Repository + UnitOfWork + DIP-протоколы.
|
|
5
5
|
|
|
6
|
-
**Версия:** 0.0
|
|
6
|
+
**Версия:** 0.2.0
|
|
7
7
|
**Лицензия:** MIT
|
|
8
|
-
**Зависимости:** `SQLAlchemy>=2.0` (
|
|
8
|
+
**Зависимости:** `SQLAlchemy>=2.0` (alembic — опционально: `s-ormkit[migrations]`)
|
|
9
9
|
|
|
10
10
|
Кит НЕ знает ни о каком приложении. Прикладная логика получает БД-слой через ORM так,
|
|
11
11
|
что не зависит от sqlite/диалекта: движок конфигурируется через DB-URL и заменяется на
|
|
@@ -37,6 +37,30 @@ Generic БД/ORM-инфраструктура для экосистемы S-kits
|
|
|
37
37
|
### DIP-контракты (`protocols.py`)
|
|
38
38
|
- `RepositoryProtocol` / `UnitOfWorkProtocol` — чтобы сервисы зависели от абстракций,
|
|
39
39
|
а не от `BaseRepository` / SQLAlchemy
|
|
40
|
+
- `AsyncRepositoryProtocol` / `AsyncUnitOfWorkProtocol` — то же для async-слоя
|
|
41
|
+
|
|
42
|
+
### Async-слой (`async_engine.py`, `async_repository.py`, `async_unit_of_work.py`)
|
|
43
|
+
- `make_async_engine` / `make_async_session_factory` — асинхронный движок и
|
|
44
|
+
`async_sessionmaker(expire_on_commit=False)`; для sqlite так же включается
|
|
45
|
+
`PRAGMA foreign_keys=ON`, `create_async_engine` так же ленив
|
|
46
|
+
- `init_schema_async` / `drop_schema_async` / `dispose_engine_async`
|
|
47
|
+
- `AsyncUnitOfWork` / `AsyncBaseRepository` — асинхронные близнецы с ТЕМ ЖЕ API
|
|
48
|
+
|
|
49
|
+
### Резолв DB-URL (`url.py`)
|
|
50
|
+
- `resolve_db_url` — аргумент → переменная окружения → default (общий для sync, async
|
|
51
|
+
и миграций)
|
|
52
|
+
- `async_driver_url` / `sync_driver_url` — автоподстановка драйвера
|
|
53
|
+
(`postgresql://` → `postgresql+asyncpg://` или `postgresql+psycopg://`)
|
|
54
|
+
|
|
55
|
+
### Защита от N+1 (`loading.py`)
|
|
56
|
+
- `LoadSpec(selectin=, joined=)` + `apply_load_spec` — декларация связей, которые
|
|
57
|
+
грузятся вместе с сущностью (в т.ч. вложенные пути `"posts.comments"`)
|
|
58
|
+
- `strict_loading` / `enable_strict_loading` / `make_strict_async_session_factory` —
|
|
59
|
+
строгий режим `raiseload("*")`: незадекларированная ленивая подгрузка падает
|
|
60
|
+
|
|
61
|
+
### Миграции (`migrations.py`, extra `[migrations]`)
|
|
62
|
+
- `scaffold_alembic` и CLI `python -m ormkit migrations init` — генерация рабочего
|
|
63
|
+
alembic-окружения на async-движке кита
|
|
40
64
|
|
|
41
65
|
## Быстрый старт
|
|
42
66
|
|
|
@@ -118,17 +142,96 @@ def register_user(repo: RepositoryProtocol[UserDTO], name: str) -> UserDTO:
|
|
|
118
142
|
# в проде — UserRepository, в тестах — in-memory фейк, реализующий тот же протокол
|
|
119
143
|
```
|
|
120
144
|
|
|
145
|
+
### Асинхронное приложение
|
|
146
|
+
|
|
147
|
+
Тот же код, но на `async/await` — форма вызова другая, структура та же:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from ormkit import (
|
|
151
|
+
AsyncBaseRepository, AsyncUnitOfWork,
|
|
152
|
+
init_schema_async, make_async_engine, make_async_session_factory,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class UserRepository(AsyncBaseRepository[User, UserDTO]):
|
|
157
|
+
def to_domain(self, obj: User) -> UserDTO:
|
|
158
|
+
return UserDTO(id=obj.id, name=obj.name)
|
|
159
|
+
|
|
160
|
+
def to_orm(self, domain: UserDTO) -> User:
|
|
161
|
+
return User(name=domain.name)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# sqlite:// → sqlite+aiosqlite://, postgresql:// → postgresql+asyncpg:// (автоматически)
|
|
165
|
+
engine = make_async_engine("postgresql://user:pass@host/db")
|
|
166
|
+
await init_schema_async(engine) # в проде схему катает alembic, см. ниже
|
|
167
|
+
session_factory = make_async_session_factory(engine)
|
|
168
|
+
|
|
169
|
+
async with AsyncUnitOfWork(session_factory) as uow:
|
|
170
|
+
repo = UserRepository(uow.session, User)
|
|
171
|
+
saved = await repo.add(UserDTO(name="Алиса"))
|
|
172
|
+
await uow.commit()
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Миграции (alembic)
|
|
176
|
+
|
|
177
|
+
`create_all` годится до первого изменения схемы на живых данных — дальше нужны миграции:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
pip install "s-ormkit[migrations]"
|
|
181
|
+
python -m ormkit migrations init . --metadata myapp.models:Base
|
|
182
|
+
# дальше — штатный alembic, URL берётся из ORMKIT_DB_URL (как и у движка приложения):
|
|
183
|
+
alembic revision --autogenerate -m "init"
|
|
184
|
+
alembic upgrade head
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Сгенерированный `env.py` работает на async-движке кита и с `render_as_batch=True`
|
|
188
|
+
(без него sqlite не переживает ALTER). Ссылка на metadata (`pkg.module:attr`) —
|
|
189
|
+
единственное, что нужно указать.
|
|
190
|
+
|
|
191
|
+
### Защита от N+1
|
|
192
|
+
|
|
193
|
+
Репозиторий ДЕКЛАРИРУЕТ, что грузится вместе с сущностью, а строгий режим
|
|
194
|
+
не даёт незаметно уехать в ленивую подгрузку:
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
from ormkit import LoadSpec, enable_strict_loading, make_strict_async_session_factory
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class UserRepository(AsyncBaseRepository[User, UserDTO]):
|
|
201
|
+
load_spec = LoadSpec(selectin=("posts.comments",), joined=("profile",))
|
|
202
|
+
...
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# в conftest тестов / в CI: любая НЕобъявленная ленивая подгрузка → InvalidRequestError
|
|
206
|
+
session_factory = make_strict_async_session_factory(engine)
|
|
207
|
+
# либо точечно на уже открытой сессии (возвращает выключатель):
|
|
208
|
+
disable = enable_strict_loading(session)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`selectin` — для коллекций (отдельный IN-запрос), `joined` — для «многие-к-одному»
|
|
212
|
+
(один JOIN). N+1 падает на этапе тестов, а не в проде.
|
|
213
|
+
|
|
121
214
|
## Архитектура
|
|
122
215
|
|
|
123
216
|
```
|
|
124
217
|
ormkit/
|
|
125
218
|
├── __init__.py # Public API + __version__
|
|
219
|
+
├── __main__.py # CLI: python -m ormkit migrations init
|
|
126
220
|
├── exceptions.py # OrmKitError, NotFoundError
|
|
127
221
|
├── base.py # Base, IntPkMixin, TimestampMixin
|
|
222
|
+
├── url.py # resolve_db_url, async_driver_url, sync_driver_url
|
|
128
223
|
├── engine.py # make_engine, make_session_factory, init/drop_schema, dispose
|
|
224
|
+
├── async_engine.py # make_async_engine, make_async_session_factory, *_async
|
|
129
225
|
├── repository.py # BaseRepository[TModel, TDomain]
|
|
226
|
+
├── async_repository.py # AsyncBaseRepository[TModel, TDomain]
|
|
130
227
|
├── unit_of_work.py # UnitOfWork
|
|
131
|
-
|
|
228
|
+
├── async_unit_of_work.py# AsyncUnitOfWork
|
|
229
|
+
├── loading.py # LoadSpec, apply_load_spec, строгий режим (raiseload)
|
|
230
|
+
├── migrations.py # scaffold_alembic, migration_url, require_alembic
|
|
231
|
+
├── pagination.py # Page, page_to_offset, paginate_stmt
|
|
232
|
+
├── search.py # trigram_filter
|
|
233
|
+
├── rls.py # TenantContext, apply_tenant_guc, rls_bypass
|
|
234
|
+
└── protocols.py # RepositoryProtocol, UnitOfWorkProtocol (+ async)
|
|
132
235
|
```
|
|
133
236
|
|
|
134
237
|
## Тестирование
|
|
@@ -151,7 +254,9 @@ uv run ruff check . # чисто
|
|
|
151
254
|
- **Временные метки.** `TimestampMixin` использует `func.now()`, работающий и в sqlite,
|
|
152
255
|
и в postgres/mysql.
|
|
153
256
|
- **Ленивое создание.** `make_engine("postgresql://...")` не коннектится — движок создаётся
|
|
154
|
-
без живого сервера.
|
|
257
|
+
без живого сервера. То же верно для `make_async_engine`.
|
|
258
|
+
- **Драйвер подставляется сам.** `postgresql://` становится `postgresql+asyncpg://` в
|
|
259
|
+
async-контексте и `postgresql+psycopg://` в синхронном; явно заданный драйвер не трогается.
|
|
155
260
|
|
|
156
261
|
## Лицензия
|
|
157
262
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# NNNN. Краткий заголовок решения
|
|
2
|
+
|
|
3
|
+
- **Статус:** proposed | accepted | superseded (→ ADR-NNNN) | deprecated
|
|
4
|
+
- **Дата:** ГГГГ-ММ-ДД
|
|
5
|
+
|
|
6
|
+
## Контекст
|
|
7
|
+
|
|
8
|
+
Какую ситуацию/проблему решаем. Что вынуждает принять решение сейчас: ограничения, требования, силы, которые тянут в разные стороны. Достаточно, чтобы понять «почему» без внешнего контекста.
|
|
9
|
+
|
|
10
|
+
## Решение
|
|
11
|
+
|
|
12
|
+
Что именно решили — одной-двумя фразами, в настоящем времени: «Делаем X». Конкретно и проверяемо.
|
|
13
|
+
|
|
14
|
+
## Последствия
|
|
15
|
+
|
|
16
|
+
Что становится проще и что — сложнее/дороже после принятия. Плюсы, минусы, новые обязательства и ограничения, которые теперь придётся держать.
|
|
17
|
+
|
|
18
|
+
## Альтернативы
|
|
19
|
+
|
|
20
|
+
Какие варианты рассматривали и **почему отклонили** (в этом ценность записи). Кратко: вариант → причина отказа / отложено до условия.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# ADR — журнал архитектурных решений
|
|
2
|
+
|
|
3
|
+
**Что это.** ADR (Architecture Decision Record) — короткий документ на **одно** архитектурное решение: контекст → что решили → почему → последствия. Лежат здесь, в `docs/adr/`, со **сквозной нумерацией** (`0001-…`, `0002-…`). Шаблон — [`0000-template.md`](0000-template.md).
|
|
4
|
+
|
|
5
|
+
**Зачем.** «Почему» проекта нельзя держать в комментариях к задачам и переписке — оно теряется. ADR — дистиллят решения: агент читает его **перед** правкой и не «чинит» то, что было выбрано сознательно. Дёшев по токенам: одно решение прочитать проще, чем реконструировать его из истории коммитов.
|
|
6
|
+
|
|
7
|
+
## Когда заводить ADR — когда сошлись 3 условия
|
|
8
|
+
|
|
9
|
+
1. решение **дорого откатить** (влияет на структуру, данные, контракты);
|
|
10
|
+
2. без контекста оно **выглядит странно** (кто-то потом захочет «починить»);
|
|
11
|
+
3. были **реальные альтернативы** и выбор — осознанный трейдофф.
|
|
12
|
+
|
|
13
|
+
Если хотя бы одно условие не выполнено — ADR не нужен, хватит `comment` на задаче в Atlas.
|
|
14
|
+
|
|
15
|
+
## Правила ведения
|
|
16
|
+
|
|
17
|
+
- **Нумерация сквозная**, файл — `NNNN-краткий-слаг.md` (`0001-operating-model-foundamentals.md`).
|
|
18
|
+
- **Не редактировать задним числом.** Принятое решение — исторический факт. Если решение отменено новым — старому ставим `Статус: superseded` (со ссылкой на новое ADR), а не переписываем его.
|
|
19
|
+
- **«Схлопывание».** Когда цепочка `superseded` разрослась, допустимо свести её к одному ADR с текущим состоянием — ради экономии контекста агента. Прежние помечаем как схлопнутые.
|
|
20
|
+
- **Статусы:** `proposed` → `accepted` → (`superseded` / `deprecated`).
|
|
21
|
+
|
|
22
|
+
## Что НЕ идёт в ADR
|
|
23
|
+
|
|
24
|
+
- **состояние** (ветка/worktree, стадия) — это тег/поле задачи в Atlas;
|
|
25
|
+
- **ход работы / handoff** — `comment` на задаче в Atlas;
|
|
26
|
+
- **черновой план реализации** — scratchpad, в `.gitignore`, в репо не коммитится.
|
|
27
|
+
|
|
28
|
+
Полная модель фиксации артефактов (4 слоя) — в `ai-ecosystem-core/AGENT-OPERATING-MODEL.md`, раздел «Модель фиксации артефактов».
|
|
29
|
+
|
|
30
|
+
**Методология:** практика ADR (Michael Nygard), компактный формат Matt Pocock (`github.com/mattpocock/skills`, grill-with-docs), примеры в NATS / AG2.
|