s-ormkit 0.0.1__tar.gz → 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.
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/CHANGELOG.md +17 -0
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/PKG-INFO +2 -1
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/ormkit/__init__.py +30 -1
- s_ormkit-0.1.0/ormkit/pagination.py +131 -0
- s_ormkit-0.1.0/ormkit/rls.py +146 -0
- s_ormkit-0.1.0/ormkit/search.py +104 -0
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/pyproject.toml +2 -2
- s_ormkit-0.1.0/tests/unit/test_infra_helpers.py +143 -0
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/tests/unit/test_schema.py +1 -1
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/.gitignore +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/LICENSE +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/README.md +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/ormkit/base.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/ormkit/engine.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/ormkit/exceptions.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/ormkit/protocols.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/ormkit/repository.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/ormkit/unit_of_work.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/tests/__init__.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/tests/conftest.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/tests/integration/__init__.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/tests/integration/test_foreign_keys.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/tests/integration/test_repository.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/tests/integration/test_timestamps.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/tests/integration/test_unit_of_work.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/tests/unit/__init__.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/tests/unit/test_engine.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/tests/unit/test_protocols.py +0 -0
- {s_ormkit-0.0.1 → s_ormkit-0.1.0}/uv.lock +0 -0
|
@@ -5,6 +5,23 @@
|
|
|
5
5
|
Формат основан на [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
и этот проект соответствует [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.1.0] - 2026-07-07
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`ormkit.pagination`** — offset-пагинация: чистые `Page[T]` / `page_to_offset`
|
|
13
|
+
+ SQL-исполнитель `paginate_stmt(session, stmt, limit, offset, count_stmt=,
|
|
14
|
+
scalars=)` (limit/offset + авто-count, либо custom count для JOIN/DISTINCT).
|
|
15
|
+
- **`ormkit.search`** — `trigram_filter(stmt, cols, q, dialect=, threshold=)`:
|
|
16
|
+
fuzzy-поиск (PostgreSQL `word_similarity`/pg_trgm с exact-boost, ILIKE-fallback
|
|
17
|
+
для остальных диалектов); возвращает `(stmt_with_where, rank_expr)`.
|
|
18
|
+
- **`ormkit.rls`** — tenant Row-Level Security (defense-in-depth): `TenantContext`,
|
|
19
|
+
ContextVar-хелперы (`set`/`reset`/`current_tenant_context`), `rls_bypass`
|
|
20
|
+
контекст-менеджер, `apply_tenant_guc` (transaction-local GUC `app.rls_enforce`/
|
|
21
|
+
`app.rls_bypass`/`app.current_company`; no-op на не-PostgreSQL и при `ctx=None`).
|
|
22
|
+
|
|
23
|
+
Все три модуля вынесены из skills-hub (generic SQLAlchemy, 0 завязок на приложение).
|
|
24
|
+
|
|
8
25
|
## [0.0.1] - 2026-07-04
|
|
9
26
|
|
|
10
27
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: s-ormkit
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 0.1.0
|
|
4
4
|
Summary: Generic БД/ORM-инфраструктура на SQLAlchemy 2.0: диалект-нейтральный движок из DB-URL, Repository + UnitOfWork + DIP-протоколы. 0 завязок на конкретное приложение.
|
|
5
5
|
Author: Dmitry
|
|
6
6
|
License: MIT
|
|
@@ -8,6 +8,7 @@ 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'
|
|
11
12
|
Requires-Dist: psycopg[binary]>=3.1; extra == 'dev'
|
|
12
13
|
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
13
14
|
Requires-Dist: pytest-cov>=6.0; extra == 'dev'
|
|
@@ -13,6 +13,12 @@ sqlite/диалект: движок конфигурируется через DB
|
|
|
13
13
|
- Транзакционная граница: `UnitOfWork`
|
|
14
14
|
- DIP-контракты: `RepositoryProtocol`, `UnitOfWorkProtocol`
|
|
15
15
|
|
|
16
|
+
**Инфра-хелперы (v0.1.0):**
|
|
17
|
+
- Пагинация: `Page`, `page_to_offset`, `paginate_stmt` (limit/offset + count)
|
|
18
|
+
- Fuzzy-поиск: `trigram_filter` (pg_trgm word_similarity / ILIKE fallback)
|
|
19
|
+
- Tenant RLS: `TenantContext`, `apply_tenant_guc`, `rls_bypass`,
|
|
20
|
+
`set_tenant_context` / `reset_tenant_context` / `current_tenant_context`
|
|
21
|
+
|
|
16
22
|
Зависимости: SQLAlchemy>=2.0 (и ничего больше).
|
|
17
23
|
"""
|
|
18
24
|
from __future__ import annotations
|
|
@@ -26,11 +32,21 @@ from ormkit.engine import (
|
|
|
26
32
|
make_session_factory,
|
|
27
33
|
)
|
|
28
34
|
from ormkit.exceptions import NotFoundError, OrmKitError
|
|
35
|
+
from ormkit.pagination import Page, page_to_offset, paginate_stmt
|
|
29
36
|
from ormkit.protocols import RepositoryProtocol, UnitOfWorkProtocol
|
|
30
37
|
from ormkit.repository import BaseRepository
|
|
38
|
+
from ormkit.rls import (
|
|
39
|
+
TenantContext,
|
|
40
|
+
apply_tenant_guc,
|
|
41
|
+
current_tenant_context,
|
|
42
|
+
reset_tenant_context,
|
|
43
|
+
rls_bypass,
|
|
44
|
+
set_tenant_context,
|
|
45
|
+
)
|
|
46
|
+
from ormkit.search import trigram_filter
|
|
31
47
|
from ormkit.unit_of_work import UnitOfWork
|
|
32
48
|
|
|
33
|
-
__version__ = "0.0
|
|
49
|
+
__version__ = "0.1.0"
|
|
34
50
|
|
|
35
51
|
__all__ = [
|
|
36
52
|
# base
|
|
@@ -53,4 +69,17 @@ __all__ = [
|
|
|
53
69
|
# exceptions
|
|
54
70
|
"OrmKitError",
|
|
55
71
|
"NotFoundError",
|
|
72
|
+
# pagination
|
|
73
|
+
"Page",
|
|
74
|
+
"page_to_offset",
|
|
75
|
+
"paginate_stmt",
|
|
76
|
+
# search
|
|
77
|
+
"trigram_filter",
|
|
78
|
+
# rls (tenant Row-Level Security)
|
|
79
|
+
"TenantContext",
|
|
80
|
+
"apply_tenant_guc",
|
|
81
|
+
"rls_bypass",
|
|
82
|
+
"set_tenant_context",
|
|
83
|
+
"reset_tenant_context",
|
|
84
|
+
"current_tenant_context",
|
|
56
85
|
]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Offset-пагинация: чистый контракт (``Page`` / ``page_to_offset``) + SQL-исполнитель.
|
|
2
|
+
|
|
3
|
+
Единый инструмент offset-пагинации вместо копипасты ``.limit().offset()`` + отдельный
|
|
4
|
+
``select(func.count())`` в каждом репозитории.
|
|
5
|
+
|
|
6
|
+
Разделение:
|
|
7
|
+
- :class:`Page` и :func:`page_to_offset` — ЧИСТАЯ логика (без SQLAlchemy); ими может
|
|
8
|
+
пользоваться и application-слой, не зная про ORM.
|
|
9
|
+
- :func:`paginate_stmt` — SQL-исполнитель (нужна ``AsyncSession``).
|
|
10
|
+
|
|
11
|
+
Паттерн:
|
|
12
|
+
offset = page_to_offset(page=2, size=20) # → 20
|
|
13
|
+
|
|
14
|
+
# Простой случай: count ≈ simple COUNT() по тем же WHERE-условиям.
|
|
15
|
+
items, total = await paginate_stmt(session, stmt, limit=20, offset=offset)
|
|
16
|
+
|
|
17
|
+
# Сложный случай: счётчик отличается (JOIN-мультипликация, DISTINCT).
|
|
18
|
+
custom = select(func.count(distinct(User.id))).where(...)
|
|
19
|
+
items, total = await paginate_stmt(
|
|
20
|
+
session, stmt, limit=20, offset=offset, count_stmt=custom
|
|
21
|
+
)
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import TYPE_CHECKING, Generic, TypeVar
|
|
27
|
+
|
|
28
|
+
from sqlalchemy import func, select
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
32
|
+
from sqlalchemy.sql.selectable import Select
|
|
33
|
+
|
|
34
|
+
T = TypeVar("T")
|
|
35
|
+
|
|
36
|
+
# Тип строки для пагинации без scalars (кортежи вместо ORM).
|
|
37
|
+
ResultRow = tuple
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class Page(Generic[T]):
|
|
42
|
+
"""Страница результатов пагинации.
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
items: кортеж элементов на этой странице.
|
|
46
|
+
total: общее количество элементов (для всех фильтров).
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
items: tuple[T, ...]
|
|
50
|
+
total: int
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def page_to_offset(page: int, size: int) -> int:
|
|
54
|
+
"""Преобразуй номер страницы (1-индексированная) в SQL offset.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
page: номер страницы, от 1. page=1 → offset=0, page=2 → offset=size.
|
|
58
|
+
size: размер страницы (количество элементов на странице).
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
offset для SQL ``.offset()``.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ValueError: если ``page < 1`` или ``size < 1``.
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
>>> page_to_offset(1, 20)
|
|
68
|
+
0
|
|
69
|
+
>>> page_to_offset(3, 20)
|
|
70
|
+
40
|
|
71
|
+
"""
|
|
72
|
+
if page < 1:
|
|
73
|
+
raise ValueError(f"page must be >= 1, got {page}")
|
|
74
|
+
if size < 1:
|
|
75
|
+
raise ValueError(f"size must be >= 1, got {size}")
|
|
76
|
+
return (page - 1) * size
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def paginate_stmt(
|
|
80
|
+
session: AsyncSession,
|
|
81
|
+
stmt: Select, # type: ignore[type-arg]
|
|
82
|
+
*,
|
|
83
|
+
limit: int,
|
|
84
|
+
offset: int,
|
|
85
|
+
count_stmt: Select | None = None, # type: ignore[type-arg]
|
|
86
|
+
scalars: bool = True,
|
|
87
|
+
) -> tuple[list, int]:
|
|
88
|
+
"""Выполни пагинированный SELECT с автоматическим count.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
session: AsyncSession для выполнения запросов.
|
|
92
|
+
stmt: SQLAlchemy select-выражение для данных.
|
|
93
|
+
limit: количество строк на странице.
|
|
94
|
+
offset: смещение (сколько пропустить строк с начала).
|
|
95
|
+
count_stmt: опциональный custom COUNT-запрос. Если None, строится
|
|
96
|
+
автоматически как ``select(func.count())`` по ``stmt`` с убранными
|
|
97
|
+
ORDER BY, LIMIT, OFFSET (изолируем условия FROM/WHERE). Передавай,
|
|
98
|
+
когда счётчик отличается (JOIN-мультипликация, DISTINCT).
|
|
99
|
+
scalars: если True (default), результат — список ORM-объектов или
|
|
100
|
+
скаляров. Если False — список кортежей (для raw SELECT с
|
|
101
|
+
множественными колонками).
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Кортеж ``(items, total)``: список результатов и общее количество строк
|
|
105
|
+
(учитывающее все фильтры).
|
|
106
|
+
|
|
107
|
+
Example:
|
|
108
|
+
>>> items, total = await paginate_stmt(
|
|
109
|
+
... session, select(User).where(User.active), limit=20, offset=0
|
|
110
|
+
... )
|
|
111
|
+
"""
|
|
112
|
+
# Выполни data-запрос с limit/offset.
|
|
113
|
+
if scalars:
|
|
114
|
+
rows = list((await session.execute(stmt.limit(limit).offset(offset))).scalars().all()) # type: ignore[attr-defined]
|
|
115
|
+
else:
|
|
116
|
+
rows = list((await session.execute(stmt.limit(limit).offset(offset))).all()) # type: ignore[attr-defined]
|
|
117
|
+
|
|
118
|
+
# Если count_stmt не передан, строим его автоматически.
|
|
119
|
+
if count_stmt is None:
|
|
120
|
+
# Изолируем условия: убираем ORDER BY, LIMIT, OFFSET; грузим COUNT().
|
|
121
|
+
count_stmt = (
|
|
122
|
+
select(func.count()) # type: ignore[arg-type]
|
|
123
|
+
.select_from(
|
|
124
|
+
stmt.order_by(None).limit(None).offset(None).subquery() # type: ignore[attr-defined]
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Выполни count-запрос.
|
|
129
|
+
total = int((await session.execute(count_stmt)).scalar_one()) # type: ignore[arg-type]
|
|
130
|
+
|
|
131
|
+
return rows, total
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Request-scoped tenant-контекст для PostgreSQL Row-Level Security (RLS).
|
|
2
|
+
|
|
3
|
+
**Defense-in-depth** поверх app-слоя tenant-изоляции: RLS — safety-net на случай
|
|
4
|
+
«один забытый ``WHERE company_id = …`` = межтенантная утечка». Даже если запрос
|
|
5
|
+
в коде забыл скоуп, БД физически не вернёт чужие строки.
|
|
6
|
+
|
|
7
|
+
## Как это работает
|
|
8
|
+
|
|
9
|
+
1. Приложение кладёт :class:`TenantContext` в process-wide ContextVar на входе
|
|
10
|
+
запроса (например, из FastAPI-зависимости) — обычно только когда RLS включён
|
|
11
|
+
флагом окружения.
|
|
12
|
+
2. Транзакционная граница (UnitOfWork) в начале транзакции читает ContextVar и
|
|
13
|
+
через :func:`apply_tenant_guc` выставляет **transaction-local GUC**
|
|
14
|
+
(``SET LOCAL``-семантика через ``set_config(…, true)``):
|
|
15
|
+
- ``app.rls_enforce='on'`` — включает enforcement для текущей транзакции;
|
|
16
|
+
- либо ``app.rls_bypass='on'`` (привилегированный актор — видит все компании),
|
|
17
|
+
- либо ``app.current_company=<id>`` (tenant — видит свою компанию + global).
|
|
18
|
+
3. RLS-политика в БД (создаётся отдельной миграцией потребителя) сверяет
|
|
19
|
+
``company_id`` строки с этими GUC.
|
|
20
|
+
|
|
21
|
+
## Почему безопасно включать постепенно
|
|
22
|
+
|
|
23
|
+
Проектируй политику **permissive, пока ``app.rls_enforce`` не выставлен в 'on'**
|
|
24
|
+
(``IS DISTINCT FROM 'on'`` → TRUE при отсутствии GUC). Тогда:
|
|
25
|
+
|
|
26
|
+
- GUC никогда не ставится (RLS выключен) → политика всегда permissive → ноль
|
|
27
|
+
изменений поведения (применение миграции безопасно).
|
|
28
|
+
- Системные контексты (seed, ETL, миграции, фон без токена) идут БЕЗ ContextVar
|
|
29
|
+
→ :func:`apply_tenant_guc` получает ``None`` → GUC не ставится → permissive.
|
|
30
|
+
|
|
31
|
+
Только аутентифицированный tenant-запрос получает enforcement. GUC-имена
|
|
32
|
+
(``app.rls_enforce`` / ``app.rls_bypass`` / ``app.current_company``) — контракт
|
|
33
|
+
между этим модулем и SQL-политикой потребителя.
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from collections.abc import Iterator
|
|
38
|
+
from contextlib import contextmanager
|
|
39
|
+
from contextvars import ContextVar, Token
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
|
|
42
|
+
from sqlalchemy import text
|
|
43
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class TenantContext:
|
|
48
|
+
"""Tenant-скоуп текущего запроса для RLS.
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
company_id: id компании актора (``None`` — актор без company-scope).
|
|
52
|
+
bypass: ``True`` для привилегированного / системного контекста — видит
|
|
53
|
+
все компании (RLS-bypass). Имеет приоритет над ``company_id``.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
company_id: int | None
|
|
57
|
+
bypass: bool = False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_TENANT_CONTEXT: ContextVar[TenantContext | None] = ContextVar(
|
|
61
|
+
"ormkit_tenant_context", default=None
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def set_tenant_context(ctx: TenantContext | None) -> Token[TenantContext | None]:
|
|
66
|
+
"""Устанавливает tenant-контекст; вернёт token для :func:`reset_tenant_context`."""
|
|
67
|
+
return _TENANT_CONTEXT.set(ctx)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def reset_tenant_context(token: Token[TenantContext | None]) -> None:
|
|
71
|
+
"""Откатывает ContextVar к предыдущему значению (cleanup после запроса)."""
|
|
72
|
+
_TENANT_CONTEXT.reset(token)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@contextmanager
|
|
76
|
+
def rls_bypass() -> Iterator[None]:
|
|
77
|
+
"""Контекст-менеджер: на время блока выставляет tenant-контекст с bypass=True
|
|
78
|
+
(RLS не enforce'ит). Канон для ПРИВИЛЕГИРОВАННЫХ операций, которые легитимно
|
|
79
|
+
пишут/читают кросс-компанийно (вступление по приглашению, чтение всех
|
|
80
|
+
membership'ов актора и т.п.).
|
|
81
|
+
|
|
82
|
+
Пример:
|
|
83
|
+
with rls_bypass():
|
|
84
|
+
await use_case.execute(...)
|
|
85
|
+
"""
|
|
86
|
+
token = set_tenant_context(TenantContext(company_id=None, bypass=True))
|
|
87
|
+
try:
|
|
88
|
+
yield
|
|
89
|
+
finally:
|
|
90
|
+
reset_tenant_context(token)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def current_tenant_context() -> TenantContext | None:
|
|
94
|
+
"""Текущий tenant-контекст (``None`` вне запроса / при анонимном акторе)."""
|
|
95
|
+
return _TENANT_CONTEXT.get()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def apply_tenant_guc(
|
|
99
|
+
session: AsyncSession, ctx: TenantContext | None
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Выставляет transaction-local GUC для RLS внутри открытой транзакции.
|
|
102
|
+
|
|
103
|
+
Должна вызываться ПОСЛЕ ``session.begin()`` (GUC через ``set_config(…, true)``
|
|
104
|
+
живёт до конца транзакции). На не-PostgreSQL диалектах (sqlite-тесты) и при
|
|
105
|
+
``ctx is None`` (системный/анонимный контекст) — **no-op** (политика остаётся
|
|
106
|
+
permissive).
|
|
107
|
+
"""
|
|
108
|
+
bind = session.bind
|
|
109
|
+
dialect = getattr(bind, "dialect", None)
|
|
110
|
+
if dialect is None or dialect.name != "postgresql":
|
|
111
|
+
return
|
|
112
|
+
# ВАЖНО (leak-guard): transaction-local GUC от ПРЕДЫДУЩЕЙ транзакции может
|
|
113
|
+
# «протечь» на checkout'е pooled-соединения. Поэтому КАЖДУЮ транзакцию
|
|
114
|
+
# начинаем с ЧИСТОГО baseline — иначе system/анонимный запрос (login, ETL;
|
|
115
|
+
# ctx=None) унаследовал бы чужой rls_enforce='on' / rls_bypass='on' /
|
|
116
|
+
# current_company и отфильтровал бы (или упал на ''::bigint).
|
|
117
|
+
await session.execute(
|
|
118
|
+
text("SELECT set_config('app.rls_bypass', '', true)")
|
|
119
|
+
)
|
|
120
|
+
await session.execute(
|
|
121
|
+
text("SELECT set_config('app.current_company', '', true)")
|
|
122
|
+
)
|
|
123
|
+
if ctx is None:
|
|
124
|
+
# Система/аноним → permissive (enforcement выключен).
|
|
125
|
+
await session.execute(
|
|
126
|
+
text("SELECT set_config('app.rls_enforce', '', true)")
|
|
127
|
+
)
|
|
128
|
+
return
|
|
129
|
+
# Tenant-запрос → enforcement on.
|
|
130
|
+
await session.execute(
|
|
131
|
+
text("SELECT set_config('app.rls_enforce', 'on', true)")
|
|
132
|
+
)
|
|
133
|
+
if ctx.bypass:
|
|
134
|
+
await session.execute(
|
|
135
|
+
text("SELECT set_config('app.rls_bypass', 'on', true)")
|
|
136
|
+
)
|
|
137
|
+
elif ctx.company_id is not None and str(ctx.company_id).strip() != "":
|
|
138
|
+
# Пустая строка ("" — company-less токен) НЕ ставится: current_company
|
|
139
|
+
# остаётся '' (== NULL через NULLIF в политике) → видны только
|
|
140
|
+
# global-строки. Непустой id → tenant-скоуп.
|
|
141
|
+
await session.execute(
|
|
142
|
+
text("SELECT set_config('app.current_company', :cid, true)"),
|
|
143
|
+
{"cid": str(ctx.company_id)},
|
|
144
|
+
)
|
|
145
|
+
# else: company-less, не bypass → current_company='' → NULLIF→NULL → только
|
|
146
|
+
# global-строки. Корректно.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Единый fuzzy-поиск по любой сущности (search-gateway).
|
|
2
|
+
|
|
3
|
+
Функция :func:`trigram_filter` принимает SQLAlchemy-стейтмент, список колонок
|
|
4
|
+
и строку запроса, добавляет WHERE-условие и возвращает выражение ранжирования.
|
|
5
|
+
|
|
6
|
+
**Диалект PostgreSQL** — использует ``word_similarity`` из расширения ``pg_trgm``.
|
|
7
|
+
⚠️ Внимание: ``pg_trgm`` и GIN-индексы создаются отдельной миграцией
|
|
8
|
+
потребителя (не в этом модуле). Без расширения PG-ветка бросит
|
|
9
|
+
``ProgrammingError`` только при реальном выполнении запроса — импорт модуля
|
|
10
|
+
безопасен в любом случае.
|
|
11
|
+
|
|
12
|
+
**Остальные диалекты** (SQLite и пр.) — простой ILIKE (``lower(col) LIKE '%q%'``),
|
|
13
|
+
ранжирование не применяется (``rank_expr = None``).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from sqlalchemy import case, func, or_
|
|
20
|
+
from sqlalchemy.sql import Select
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def trigram_filter(
|
|
24
|
+
stmt: Select,
|
|
25
|
+
cols: list[Any],
|
|
26
|
+
q: str,
|
|
27
|
+
*,
|
|
28
|
+
dialect: str,
|
|
29
|
+
threshold: float = 0.3,
|
|
30
|
+
) -> tuple[Select, Any | None]:
|
|
31
|
+
"""Добавляет fuzzy-фильтр по строке ``q`` к стейтменту ``stmt``.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
stmt: Исходный SELECT-стейтмент (не модифицируется на месте,
|
|
35
|
+
возвращается новый с добавленным WHERE).
|
|
36
|
+
cols: Список SQLAlchemy-колонок/выражений, по которым ищем.
|
|
37
|
+
q: Строка поиска. Пустая / только пробелы — стейтмент
|
|
38
|
+
возвращается без изменений, ``rank_expr = None``.
|
|
39
|
+
dialect: Имя диалекта SQLAlchemy (``engine.dialect.name``):
|
|
40
|
+
``'postgresql'`` → word_similarity + точный буст,
|
|
41
|
+
всё остальное → ILIKE без ранжирования.
|
|
42
|
+
threshold: Минимальное значение word_similarity для PG-ветки
|
|
43
|
+
(0.0–1.0, дефолт 0.3). Игнорируется для не-PG диалектов.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
``(stmt_with_where, rank_expr)``
|
|
47
|
+
|
|
48
|
+
- ``rank_expr`` — SQLAlchemy-выражение для ``ORDER BY rank_expr DESC``.
|
|
49
|
+
``None`` для не-PG ветки или пустого ``q``.
|
|
50
|
+
"""
|
|
51
|
+
# Пустой / whitespace q — не фильтруем, ранжирование не нужно.
|
|
52
|
+
q_stripped = q.strip() if q else ""
|
|
53
|
+
if not q_stripped:
|
|
54
|
+
return stmt, None
|
|
55
|
+
|
|
56
|
+
if dialect == "postgresql":
|
|
57
|
+
return _pg_trigram(stmt, cols, q_stripped, threshold)
|
|
58
|
+
else:
|
|
59
|
+
return _sqlite_ilike(stmt, cols, q_stripped)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _pg_trigram(
|
|
63
|
+
stmt: Select,
|
|
64
|
+
cols: list[Any],
|
|
65
|
+
q: str,
|
|
66
|
+
threshold: float,
|
|
67
|
+
) -> tuple[Select, Any]:
|
|
68
|
+
"""PostgreSQL-ветка: word_similarity с точным бустом до 2.0.
|
|
69
|
+
|
|
70
|
+
ВАЖНО: и запрос, и колонка приводятся к ``lower()`` — это (1) делает поиск
|
|
71
|
+
регистронезависимым (как прежний ``lower(col) LIKE``), и (2) совпадает с
|
|
72
|
+
выражением GIN-индекса ``lower(col) gin_trgm_ops`` → планировщик может
|
|
73
|
+
использовать индекс.
|
|
74
|
+
"""
|
|
75
|
+
q_lower = q.lower()
|
|
76
|
+
|
|
77
|
+
# WHERE: хотя бы одна колонка имеет достаточную схожесть (по lower).
|
|
78
|
+
where_clause = or_(
|
|
79
|
+
*(func.word_similarity(q_lower, func.lower(col)) > threshold for col in cols)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# rank_expr: exact-match буcтится до 2.0, иначе — word_similarity (по lower).
|
|
83
|
+
# GREATEST берёт наибольшее значение по всем колонкам.
|
|
84
|
+
rank_parts = [
|
|
85
|
+
case(
|
|
86
|
+
(func.lower(col) == q_lower, 2.0),
|
|
87
|
+
else_=func.word_similarity(q_lower, func.lower(col)),
|
|
88
|
+
)
|
|
89
|
+
for col in cols
|
|
90
|
+
]
|
|
91
|
+
rank_expr = func.greatest(*rank_parts)
|
|
92
|
+
|
|
93
|
+
return stmt.where(where_clause), rank_expr
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _sqlite_ilike(
|
|
97
|
+
stmt: Select,
|
|
98
|
+
cols: list[Any],
|
|
99
|
+
q: str,
|
|
100
|
+
) -> tuple[Select, None]:
|
|
101
|
+
"""SQLite/fallback-ветка: ILIKE через lower(...) LIKE '%q%'."""
|
|
102
|
+
pattern = f"%{q.lower()}%"
|
|
103
|
+
where_clause = or_(*(func.lower(col).like(pattern) for col in cols))
|
|
104
|
+
return stmt.where(where_clause), None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "s-ormkit"
|
|
3
|
-
version = "0.0
|
|
3
|
+
version = "0.1.0"
|
|
4
4
|
description = "Generic БД/ORM-инфраструктура на SQLAlchemy 2.0: диалект-нейтральный движок из DB-URL, Repository + UnitOfWork + DIP-протоколы. 0 завязок на конкретное приложение."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -13,7 +13,7 @@ dependencies = [
|
|
|
13
13
|
[project.optional-dependencies]
|
|
14
14
|
# psycopg (v3) — ТОЛЬКО для теста ленивого создания postgres-движка без живого сервера.
|
|
15
15
|
# В runtime кит зависит исключительно от SQLAlchemy.
|
|
16
|
-
dev = ["pytest>=8", "pytest-asyncio>=0.24", "pytest-cov>=6.0", "psycopg[binary]>=3.1"]
|
|
16
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24", "pytest-cov>=6.0", "psycopg[binary]>=3.1", "aiosqlite>=0.20"]
|
|
17
17
|
|
|
18
18
|
[build-system]
|
|
19
19
|
requires = ["hatchling"]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Тесты инфра-хелперов ormkit v0.1.0: pagination, search, rls."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from sqlalchemy import Integer, String, select
|
|
6
|
+
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
7
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
8
|
+
|
|
9
|
+
from ormkit.pagination import Page, page_to_offset, paginate_stmt
|
|
10
|
+
from ormkit.rls import (
|
|
11
|
+
TenantContext,
|
|
12
|
+
apply_tenant_guc,
|
|
13
|
+
current_tenant_context,
|
|
14
|
+
reset_tenant_context,
|
|
15
|
+
rls_bypass,
|
|
16
|
+
set_tenant_context,
|
|
17
|
+
)
|
|
18
|
+
from ormkit.search import trigram_filter
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# --- Демо-модель для async-тестов (принадлежит тестам, не киту) ---
|
|
22
|
+
class _Base(DeclarativeBase):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _Widget(_Base):
|
|
27
|
+
__tablename__ = "_widgets"
|
|
28
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
29
|
+
name: Mapped[str] = mapped_column(String(100))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture
|
|
33
|
+
async def async_session():
|
|
34
|
+
engine = create_async_engine("sqlite+aiosqlite://")
|
|
35
|
+
async with engine.begin() as conn:
|
|
36
|
+
await conn.run_sync(_Base.metadata.create_all)
|
|
37
|
+
factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
38
|
+
async with factory() as s:
|
|
39
|
+
for i in range(25):
|
|
40
|
+
s.add(_Widget(name=f"w{i:02d}"))
|
|
41
|
+
await s.commit()
|
|
42
|
+
yield s
|
|
43
|
+
await engine.dispose()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --- pagination: чистое ---
|
|
47
|
+
def test_page_to_offset() -> None:
|
|
48
|
+
assert page_to_offset(1, 20) == 0
|
|
49
|
+
assert page_to_offset(2, 20) == 20
|
|
50
|
+
assert page_to_offset(3, 20) == 40
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.parametrize("bad", [(0, 20), (1, 0), (-1, 5)])
|
|
54
|
+
def test_page_to_offset_validates(bad: tuple[int, int]) -> None:
|
|
55
|
+
with pytest.raises(ValueError):
|
|
56
|
+
page_to_offset(*bad)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_page_dataclass_frozen() -> None:
|
|
60
|
+
p: Page[int] = Page(items=(1, 2, 3), total=99)
|
|
61
|
+
assert p.items == (1, 2, 3)
|
|
62
|
+
assert p.total == 99
|
|
63
|
+
with pytest.raises(Exception):
|
|
64
|
+
p.total = 5 # type: ignore[misc]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --- pagination: SQL-исполнитель ---
|
|
68
|
+
async def test_paginate_stmt_returns_page_and_total(async_session) -> None:
|
|
69
|
+
stmt = select(_Widget).order_by(_Widget.id)
|
|
70
|
+
items, total = await paginate_stmt(async_session, stmt, limit=10, offset=0)
|
|
71
|
+
assert total == 25
|
|
72
|
+
assert len(items) == 10
|
|
73
|
+
assert items[0].name == "w00"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def test_paginate_stmt_offset(async_session) -> None:
|
|
77
|
+
stmt = select(_Widget).order_by(_Widget.id)
|
|
78
|
+
items, total = await paginate_stmt(async_session, stmt, limit=10, offset=20)
|
|
79
|
+
assert total == 25
|
|
80
|
+
assert len(items) == 5 # хвост
|
|
81
|
+
assert items[0].name == "w20"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def test_paginate_stmt_scalars_false(async_session) -> None:
|
|
85
|
+
stmt = select(_Widget.id, _Widget.name).order_by(_Widget.id)
|
|
86
|
+
rows, total = await paginate_stmt(
|
|
87
|
+
async_session, stmt, limit=3, offset=0, scalars=False
|
|
88
|
+
)
|
|
89
|
+
assert total == 25
|
|
90
|
+
assert rows[0] == (1, "w00")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --- search ---
|
|
94
|
+
def test_trigram_filter_empty_query_noop() -> None:
|
|
95
|
+
stmt = select(_Widget)
|
|
96
|
+
out, rank = trigram_filter(stmt, [_Widget.name], " ", dialect="postgresql")
|
|
97
|
+
assert out is stmt
|
|
98
|
+
assert rank is None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_trigram_filter_sqlite_ilike_no_rank() -> None:
|
|
102
|
+
stmt = select(_Widget)
|
|
103
|
+
out, rank = trigram_filter(stmt, [_Widget.name], "foo", dialect="sqlite")
|
|
104
|
+
assert rank is None
|
|
105
|
+
assert "lower" in str(out).lower()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_trigram_filter_pg_has_rank() -> None:
|
|
109
|
+
stmt = select(_Widget)
|
|
110
|
+
out, rank = trigram_filter(stmt, [_Widget.name], "foo", dialect="postgresql")
|
|
111
|
+
assert rank is not None
|
|
112
|
+
compiled = str(out).lower()
|
|
113
|
+
assert "word_similarity" in compiled
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def test_trigram_filter_sqlite_executes(async_session) -> None:
|
|
117
|
+
stmt = select(_Widget)
|
|
118
|
+
out, _ = trigram_filter(stmt, [_Widget.name], "w1", dialect="sqlite")
|
|
119
|
+
found = (await async_session.execute(out)).scalars().all()
|
|
120
|
+
# w10..w19 содержат "w1"
|
|
121
|
+
assert {w.name for w in found} == {f"w1{d}" for d in range(10)}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# --- rls ---
|
|
125
|
+
def test_tenant_context_var_set_reset() -> None:
|
|
126
|
+
assert current_tenant_context() is None
|
|
127
|
+
tok = set_tenant_context(TenantContext(company_id=42))
|
|
128
|
+
assert current_tenant_context() == TenantContext(company_id=42, bypass=False)
|
|
129
|
+
reset_tenant_context(tok)
|
|
130
|
+
assert current_tenant_context() is None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_rls_bypass_contextmanager() -> None:
|
|
134
|
+
with rls_bypass():
|
|
135
|
+
ctx = current_tenant_context()
|
|
136
|
+
assert ctx is not None and ctx.bypass is True
|
|
137
|
+
assert current_tenant_context() is None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def test_apply_tenant_guc_noop_on_sqlite(async_session) -> None:
|
|
141
|
+
# На не-PostgreSQL — no-op, без ошибок (для обоих ctx).
|
|
142
|
+
await apply_tenant_guc(async_session, None)
|
|
143
|
+
await apply_tenant_guc(async_session, TenantContext(company_id=7))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|