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.
Files changed (29) hide show
  1. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/CHANGELOG.md +17 -0
  2. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/PKG-INFO +2 -1
  3. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/ormkit/__init__.py +30 -1
  4. s_ormkit-0.1.0/ormkit/pagination.py +131 -0
  5. s_ormkit-0.1.0/ormkit/rls.py +146 -0
  6. s_ormkit-0.1.0/ormkit/search.py +104 -0
  7. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/pyproject.toml +2 -2
  8. s_ormkit-0.1.0/tests/unit/test_infra_helpers.py +143 -0
  9. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/tests/unit/test_schema.py +1 -1
  10. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/.gitignore +0 -0
  11. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/LICENSE +0 -0
  12. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/README.md +0 -0
  13. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/ormkit/base.py +0 -0
  14. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/ormkit/engine.py +0 -0
  15. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/ormkit/exceptions.py +0 -0
  16. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/ormkit/protocols.py +0 -0
  17. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/ormkit/repository.py +0 -0
  18. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/ormkit/unit_of_work.py +0 -0
  19. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/tests/__init__.py +0 -0
  20. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/tests/conftest.py +0 -0
  21. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/tests/integration/__init__.py +0 -0
  22. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/tests/integration/test_foreign_keys.py +0 -0
  23. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/tests/integration/test_repository.py +0 -0
  24. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/tests/integration/test_timestamps.py +0 -0
  25. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/tests/integration/test_unit_of_work.py +0 -0
  26. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/tests/unit/__init__.py +0 -0
  27. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/tests/unit/test_engine.py +0 -0
  28. {s_ormkit-0.0.1 → s_ormkit-0.1.0}/tests/unit/test_protocols.py +0 -0
  29. {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.1
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.1"
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.1"
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))
@@ -43,4 +43,4 @@ def test_public_api_exports():
43
43
 
44
44
  @pytest.mark.unit
45
45
  def test_version():
46
- assert ormkit.__version__ == "0.0.1"
46
+ assert ormkit.__version__ == "0.1.0"
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