pyferro 2.0.0__tar.gz → 2.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.
- {pyferro-2.0.0 → pyferro-2.0.1}/PKG-INFO +11 -1
- {pyferro-2.0.0 → pyferro-2.0.1}/README.md +10 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/core/tabular.py +17 -2
- {pyferro-2.0.0 → pyferro-2.0.1}/db/__init__.py +4 -0
- pyferro-2.0.1/db/pagination.py +333 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/pyferro.egg-info/PKG-INFO +11 -1
- {pyferro-2.0.0 → pyferro-2.0.1}/pyferro.egg-info/SOURCES.txt +3 -1
- {pyferro-2.0.0 → pyferro-2.0.1}/pyproject.toml +1 -1
- pyferro-2.0.1/tests/test_pagination.py +180 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/LICENSE +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/auth/__init__.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/auth/models.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/auth/passwords.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/auth/seeder.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/core/__init__.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/core/constants.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/core/session.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/core/session_data.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/crud/__init__.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/crud/engine.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/crud/rpc.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/crud/spec.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/db/base.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/db/engine.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/files/__init__.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/files/models.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/files/rpc.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/files/storage.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/pyferro.egg-info/dependency_links.txt +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/pyferro.egg-info/requires.txt +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/pyferro.egg-info/top_level.txt +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/rpc/__init__.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/rpc/errors.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/rpc/router.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/server/__init__.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/server/app.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/server/auth_rpc.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/server/test_client.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/server/tokens.py +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/setup.cfg +0 -0
- {pyferro-2.0.0 → pyferro-2.0.1}/tests/test_e2e_lifecycle.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyferro
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.1
|
|
4
4
|
Summary: High-performance reactive fullstack Python framework on Rust RSGI (Granian) with symmetric WSRPC (JSON-RPC 2.0) and Tabular Data Compression.
|
|
5
5
|
Author-email: Alex Titoff <a.tit.off@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -252,3 +252,13 @@ example.com {
|
|
|
252
252
|
| `POST /upload` | Потоковая закачка файлов во временную транзакцию (`O(1)` RAM) |
|
|
253
253
|
| `GET /files/<folder>/<name>` | Защищенная раздача файлов (поддержка `X-Accel-Redirect` и файловых JWT) |
|
|
254
254
|
| `GET /pyferro_admin` | Встроенная панель управления RBAC и CRUD (маршрут `/admin` свободен для вашего приложения) |
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## 6. Табличная пагинация и сжатие трафика ($tabular)
|
|
259
|
+
|
|
260
|
+
PyFerro 2.0 включает высокопроизводительную подсистему пагинации для SQLAlchemy и фронтенда:
|
|
261
|
+
|
|
262
|
+
- **Бэкенд:** `paginate_tabular(db, Model, **params)` — выполняет поиск, фильтры, сортировку, умный `count(*)` и пакует ответ в сжатый протокол `$tabular` за **1 строчку кода**.
|
|
263
|
+
- **Фронтенд:** `createPaginator("Model.read")` + компонент `<Pagination />` — полностью берут на себя состояние, номера страниц `[1, '...', 4, 5, 6, '...', 25]`, стрелки «Назад/Вперед» и живой поиск.
|
|
264
|
+
- **Подробное руководство:** см. [PAGINATION.md](PAGINATION.md).
|
|
@@ -230,3 +230,13 @@ example.com {
|
|
|
230
230
|
| `POST /upload` | Потоковая закачка файлов во временную транзакцию (`O(1)` RAM) |
|
|
231
231
|
| `GET /files/<folder>/<name>` | Защищенная раздача файлов (поддержка `X-Accel-Redirect` и файловых JWT) |
|
|
232
232
|
| `GET /pyferro_admin` | Встроенная панель управления RBAC и CRUD (маршрут `/admin` свободен для вашего приложения) |
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## 6. Табличная пагинация и сжатие трафика ($tabular)
|
|
237
|
+
|
|
238
|
+
PyFerro 2.0 включает высокопроизводительную подсистему пагинации для SQLAlchemy и фронтенда:
|
|
239
|
+
|
|
240
|
+
- **Бэкенд:** `paginate_tabular(db, Model, **params)` — выполняет поиск, фильтры, сортировку, умный `count(*)` и пакует ответ в сжатый протокол `$tabular` за **1 строчку кода**.
|
|
241
|
+
- **Фронтенд:** `createPaginator("Model.read")` + компонент `<Pagination />` — полностью берут на себя состояние, номера страниц `[1, '...', 4, 5, 6, '...', 25]`, стрелки «Назад/Вперед» и живой поиск.
|
|
242
|
+
- **Подробное руководство:** см. [PAGINATION.md](PAGINATION.md).
|
|
@@ -12,11 +12,12 @@ from typing import Any
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def pack_tabular(
|
|
15
|
-
items: Sequence[
|
|
15
|
+
items: Sequence[Any],
|
|
16
16
|
fields: Sequence[str] | None = None,
|
|
17
17
|
) -> dict[str, Any] | Any:
|
|
18
18
|
"""
|
|
19
|
-
Упаковывает список
|
|
19
|
+
Упаковывает список словарей, кортежей или SQLAlchemy Row в табличный пакет
|
|
20
|
+
`{"$tabular": True, "fields": [...], "rows": [...]}`.
|
|
20
21
|
"""
|
|
21
22
|
if not items:
|
|
22
23
|
return {
|
|
@@ -26,6 +27,17 @@ def pack_tabular(
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
first = items[0]
|
|
30
|
+
|
|
31
|
+
# Поддержка SQLAlchemy Row / RowMapping (объектов с атрибутом _mapping)
|
|
32
|
+
if hasattr(first, "_mapping"):
|
|
33
|
+
field_list = list(fields) if fields is not None else list(first._mapping.keys())
|
|
34
|
+
rows = [[getattr(item, "_mapping").get(f) for f in field_list] for item in items]
|
|
35
|
+
return {
|
|
36
|
+
"$tabular": True,
|
|
37
|
+
"fields": field_list,
|
|
38
|
+
"rows": rows,
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
if isinstance(first, dict):
|
|
30
42
|
field_list = list(fields) if fields is not None else list(first.keys())
|
|
31
43
|
rows = [[item.get(f) for f in field_list] for item in items] # type: ignore[union-attr]
|
|
@@ -86,9 +98,12 @@ def is_tabular(data: Any) -> bool:
|
|
|
86
98
|
def apply_tabular_result(result: Any, fields: Sequence[str] | None = None) -> Any:
|
|
87
99
|
"""
|
|
88
100
|
Автоматически упаковывает результат RPC-метода (если включен `@rpc_method(tabular=True)`):
|
|
101
|
+
- Если уже передан готовый `$tabular` пакет -> возвращает как есть.
|
|
89
102
|
- Если возвращен `list[dict]` -> упаковывает сам список.
|
|
90
103
|
- Если возвращен `dict` с ключом `"items"` (или `"rows"`, `"records"`, `"data"`) -> упаковывает вложенный список.
|
|
91
104
|
"""
|
|
105
|
+
if is_tabular(result):
|
|
106
|
+
return result
|
|
92
107
|
if isinstance(result, list):
|
|
93
108
|
return pack_tabular(result, fields=fields)
|
|
94
109
|
if isinstance(result, dict) and not is_tabular(result):
|
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
from pyferro2.db.base import Base, RowSecureModel
|
|
6
6
|
from pyferro2.db.engine import DEFAULT_DB_URL, Database
|
|
7
|
+
from pyferro2.db.pagination import decode_cursor, encode_cursor, paginate_tabular
|
|
7
8
|
|
|
8
9
|
__all__ = [
|
|
9
10
|
"Base",
|
|
10
11
|
"RowSecureModel",
|
|
11
12
|
"Database",
|
|
12
13
|
"DEFAULT_DB_URL",
|
|
14
|
+
"paginate_tabular",
|
|
15
|
+
"encode_cursor",
|
|
16
|
+
"decode_cursor",
|
|
13
17
|
]
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Высокопроизводительная пагинация для SQLAlchemy 2.0 с автоматической упаковкой в Tabular Data ($tabular).
|
|
3
|
+
|
|
4
|
+
Поддерживает:
|
|
5
|
+
- Вызов от модели или `Select`: `paginate_tabular(db, Book, **params)` или `paginate_tabular(db, select(Book).where(...), **params)`.
|
|
6
|
+
- Два режима:
|
|
7
|
+
1. Offset-пагинация (`page=1, limit=20`) с оптимизированным `count(*)` (очистка `order_by`).
|
|
8
|
+
2. Keyset / Cursor-пагинация (`cursor="...", limit=20`) с выборкой $O(1)$ по индексу без подсчета `count`.
|
|
9
|
+
- Авто-трансляцию фильтров (`filters={"genre": "fiction"}`) и поиска (`search="текст"`).
|
|
10
|
+
- Zero-Hydration: извлечение чистых кортежей или атрибутов с конвертацией `datetime`, `UUID`, `Enum`, `Decimal`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from datetime import date, datetime, time
|
|
18
|
+
from decimal import Decimal
|
|
19
|
+
from enum import Enum
|
|
20
|
+
import json
|
|
21
|
+
import math
|
|
22
|
+
from typing import Any, TypeVar
|
|
23
|
+
import uuid
|
|
24
|
+
|
|
25
|
+
from sqlalchemy import Select, String, Text, Unicode, UnicodeText, func, inspect, or_, select
|
|
26
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
27
|
+
|
|
28
|
+
from pyferro2.db.base import Base
|
|
29
|
+
|
|
30
|
+
T = TypeVar("T", bound=Base)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _serialize_val(val: Any) -> Any:
|
|
34
|
+
"""Безопасно преобразует значение ячейки базы данных в сериализуемый JSON-вид."""
|
|
35
|
+
if val is None or isinstance(val, (int, float, str, bool)):
|
|
36
|
+
return val
|
|
37
|
+
if isinstance(val, (datetime, date, time)):
|
|
38
|
+
return val.isoformat()
|
|
39
|
+
if isinstance(val, uuid.UUID):
|
|
40
|
+
return str(val)
|
|
41
|
+
if isinstance(val, Decimal):
|
|
42
|
+
return float(val)
|
|
43
|
+
if isinstance(val, Enum):
|
|
44
|
+
return val.value
|
|
45
|
+
if isinstance(val, (list, tuple)):
|
|
46
|
+
return [_serialize_val(v) for v in val]
|
|
47
|
+
if isinstance(val, dict):
|
|
48
|
+
return {k: _serialize_val(v) for k, v in val.items()}
|
|
49
|
+
return str(val)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def encode_cursor(val: Any, col: str = "id", direction: str = "desc") -> str:
|
|
53
|
+
"""Кодирует значение граничной записи в URL-safe base64 токен курсора."""
|
|
54
|
+
payload = {
|
|
55
|
+
"v": _serialize_val(val),
|
|
56
|
+
"c": col,
|
|
57
|
+
"d": direction.lower(),
|
|
58
|
+
}
|
|
59
|
+
dumped = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
|
60
|
+
return base64.urlsafe_b64encode(dumped.encode("utf-8")).decode("ascii")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def decode_cursor(cursor: str) -> dict[str, Any]:
|
|
64
|
+
"""Декодирует строку курсора обратно в словарь параметров."""
|
|
65
|
+
try:
|
|
66
|
+
raw = base64.urlsafe_b64decode(cursor.encode("ascii")).decode("utf-8")
|
|
67
|
+
data = json.loads(raw)
|
|
68
|
+
if not isinstance(data, dict) or "v" not in data:
|
|
69
|
+
raise ValueError("Некорректная структура курсора")
|
|
70
|
+
return data
|
|
71
|
+
except Exception as e:
|
|
72
|
+
raise ValueError(f"Невалидный токен курсора: {cursor}") from e
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _find_model_class(target: type[Base] | Select) -> type[Base] | None:
|
|
76
|
+
"""Определяет модель DeclarativeBase из аргумента target (класса или Select-запроса)."""
|
|
77
|
+
if isinstance(target, type) and issubclass(target, Base):
|
|
78
|
+
return target
|
|
79
|
+
if isinstance(target, Select):
|
|
80
|
+
for desc in target.column_descriptions:
|
|
81
|
+
entity = desc.get("entity")
|
|
82
|
+
if isinstance(entity, type) and issubclass(entity, Base):
|
|
83
|
+
return entity
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def paginate_tabular(
|
|
88
|
+
db: AsyncSession,
|
|
89
|
+
target: type[Base] | Select,
|
|
90
|
+
*,
|
|
91
|
+
page: int | None = 1,
|
|
92
|
+
limit: int = 20,
|
|
93
|
+
cursor: str | None = None,
|
|
94
|
+
cursor_column: str = "id",
|
|
95
|
+
search: str | None = None,
|
|
96
|
+
search_fields: Sequence[str] | None = None,
|
|
97
|
+
filters: dict[str, Any] | None = None,
|
|
98
|
+
sort_by: str | None = None,
|
|
99
|
+
order: str = "desc",
|
|
100
|
+
fields: Sequence[str] | None = None,
|
|
101
|
+
count: bool = True,
|
|
102
|
+
max_limit: int = 500,
|
|
103
|
+
**extra_params: Any,
|
|
104
|
+
) -> dict[str, Any]:
|
|
105
|
+
"""
|
|
106
|
+
Высокопроизводительная пагинация запроса SQLAlchemy с отдачей сжатого пакета `$tabular`.
|
|
107
|
+
|
|
108
|
+
Параметры:
|
|
109
|
+
- `db`: сессия SQLAlchemy (`AsyncSession`).
|
|
110
|
+
- `target`: модель `Book` или сформированный `select(Book).where(...)`.
|
|
111
|
+
- `page`: номер страницы (1-indexed). Приоритет отдается `cursor`, если он передан.
|
|
112
|
+
- `limit`: максимальное количество строк (по умолчанию 20, ограничено `max_limit`).
|
|
113
|
+
- `cursor`: строковый токен для курсорной пагинации ($O(1)$ по индексу).
|
|
114
|
+
- `cursor_column`: колонка для курсора (по умолчанию "id").
|
|
115
|
+
- `search`: поисковая строка (автоматически транслируется в `ILIKE` по строковым полям).
|
|
116
|
+
- `search_fields`: явный список полей для поиска (если `None` — используются строковые поля модели).
|
|
117
|
+
- `filters`: словарь точных совпадений `{"status": "published", "category_id": 5}`.
|
|
118
|
+
- `sort_by`: имя колонки для сортировки.
|
|
119
|
+
- `order`: направление сортировки (`"asc"` или `"desc"`).
|
|
120
|
+
- `fields`: список желаемых колонок для вывода (если `None` — берутся все колонки модели).
|
|
121
|
+
- `count`: вычислять ли точный `total` в режиме `page` (по умолчанию `True`).
|
|
122
|
+
- `max_limit`: защитный максимум строк на страницу (по умолчанию 500).
|
|
123
|
+
"""
|
|
124
|
+
safe_limit = max(1, min(int(limit or 20), max_limit))
|
|
125
|
+
model_cls = _find_model_class(target)
|
|
126
|
+
|
|
127
|
+
# 1. Приведение к Select
|
|
128
|
+
if isinstance(target, Select):
|
|
129
|
+
stmt = target
|
|
130
|
+
elif model_cls is not None:
|
|
131
|
+
stmt = select(model_cls)
|
|
132
|
+
else:
|
|
133
|
+
raise ValueError("Параметр 'target' должен быть моделью Base или объектом Select")
|
|
134
|
+
|
|
135
|
+
# 2. Авто-фильтрация по точному совпадению
|
|
136
|
+
if filters and isinstance(filters, dict) and model_cls is not None:
|
|
137
|
+
for fname, fval in filters.items():
|
|
138
|
+
if hasattr(model_cls, fname):
|
|
139
|
+
col = getattr(model_cls, fname)
|
|
140
|
+
if hasattr(col, "__clause_element__"):
|
|
141
|
+
if isinstance(fval, (list, tuple, set)):
|
|
142
|
+
stmt = stmt.where(col.in_(list(fval)))
|
|
143
|
+
elif fval is not None:
|
|
144
|
+
stmt = stmt.where(col == fval)
|
|
145
|
+
|
|
146
|
+
# 3. Авто-поиск по подстроке (ILIKE)
|
|
147
|
+
if search and str(search).strip():
|
|
148
|
+
search_term = f"%{str(search).strip()}%"
|
|
149
|
+
cols_to_search = []
|
|
150
|
+
if search_fields:
|
|
151
|
+
cols_to_search = [
|
|
152
|
+
getattr(model_cls, sf)
|
|
153
|
+
for sf in search_fields
|
|
154
|
+
if hasattr(model_cls, sf) and hasattr(getattr(model_cls, sf), "__clause_element__")
|
|
155
|
+
]
|
|
156
|
+
elif model_cls is not None:
|
|
157
|
+
mapper = inspect(model_cls)
|
|
158
|
+
cols_to_search = [
|
|
159
|
+
col
|
|
160
|
+
for col in mapper.columns
|
|
161
|
+
if isinstance(col.type, (String, Text, Unicode, UnicodeText))
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
if cols_to_search:
|
|
165
|
+
stmt = stmt.where(or_(*[c.ilike(search_term) for c in cols_to_search]))
|
|
166
|
+
|
|
167
|
+
# 4. Сортировка
|
|
168
|
+
current_direction = order.lower() if order else "desc"
|
|
169
|
+
active_sort_col_name = cursor_column
|
|
170
|
+
if sort_by and model_cls is not None and hasattr(model_cls, sort_by):
|
|
171
|
+
sort_col = getattr(model_cls, sort_by)
|
|
172
|
+
if hasattr(sort_col, "__clause_element__"):
|
|
173
|
+
active_sort_col_name = sort_by
|
|
174
|
+
stmt = stmt.order_by(sort_col.desc() if current_direction == "desc" else sort_col.asc())
|
|
175
|
+
elif not stmt._order_by_clauses and model_cls is not None and hasattr(model_cls, "id"):
|
|
176
|
+
stmt = stmt.order_by(model_cls.id.desc() if current_direction == "desc" else model_cls.id.asc())
|
|
177
|
+
|
|
178
|
+
# 5. Выполнение пагинации (Cursor vs Offset)
|
|
179
|
+
total: int | None = None
|
|
180
|
+
pages: int | None = None
|
|
181
|
+
has_prev = False
|
|
182
|
+
has_next = False
|
|
183
|
+
next_cursor: str | None = None
|
|
184
|
+
prev_cursor: str | None = None
|
|
185
|
+
safe_page: int | None = None
|
|
186
|
+
|
|
187
|
+
if cursor is not None:
|
|
188
|
+
# ─── РЕЖИМ КУРСОРА (Keyset) ───
|
|
189
|
+
decoded = decode_cursor(cursor)
|
|
190
|
+
c_val = decoded.get("v")
|
|
191
|
+
c_col = decoded.get("c", cursor_column)
|
|
192
|
+
c_dir = decoded.get("d", current_direction)
|
|
193
|
+
|
|
194
|
+
if model_cls is not None and hasattr(model_cls, c_col):
|
|
195
|
+
col_target = getattr(model_cls, c_col)
|
|
196
|
+
if hasattr(col_target, "__clause_element__"):
|
|
197
|
+
if c_dir == "desc":
|
|
198
|
+
stmt = stmt.where(col_target < c_val)
|
|
199
|
+
else:
|
|
200
|
+
stmt = stmt.where(col_target > c_val)
|
|
201
|
+
|
|
202
|
+
# Берем limit + 1 для определения has_next без вызова COUNT
|
|
203
|
+
exec_stmt = stmt.limit(safe_limit + 1)
|
|
204
|
+
res = await db.execute(exec_stmt)
|
|
205
|
+
raw_items = res.scalars().all() if _is_entity_select(stmt) else res.all()
|
|
206
|
+
|
|
207
|
+
has_prev = True
|
|
208
|
+
if len(raw_items) > safe_limit:
|
|
209
|
+
has_next = True
|
|
210
|
+
raw_items = raw_items[:safe_limit]
|
|
211
|
+
else:
|
|
212
|
+
has_next = False
|
|
213
|
+
|
|
214
|
+
if raw_items and has_next:
|
|
215
|
+
last_item = raw_items[-1]
|
|
216
|
+
last_val = _extract_item_value(last_item, c_col)
|
|
217
|
+
if last_val is not None:
|
|
218
|
+
next_cursor = encode_cursor(last_val, col=c_col, direction=c_dir)
|
|
219
|
+
|
|
220
|
+
else:
|
|
221
|
+
# ─── РЕЖИМ OFFSET ───
|
|
222
|
+
safe_page = max(1, int(page or 1))
|
|
223
|
+
offset = (safe_page - 1) * safe_limit
|
|
224
|
+
|
|
225
|
+
if count:
|
|
226
|
+
# Очищаем order_by в подзапросе подсчета для максимальной скорости СУБД
|
|
227
|
+
count_stmt = select(func.count()).select_from(stmt.order_by(None).subquery())
|
|
228
|
+
total = int((await db.execute(count_stmt)).scalar() or 0)
|
|
229
|
+
pages = max(1, math.ceil(total / safe_limit)) if total > 0 else 1
|
|
230
|
+
has_prev = safe_page > 1
|
|
231
|
+
has_next = (offset + safe_limit) < total
|
|
232
|
+
|
|
233
|
+
exec_stmt = stmt.offset(offset).limit(safe_limit)
|
|
234
|
+
res = await db.execute(exec_stmt)
|
|
235
|
+
raw_items = res.scalars().all() if _is_entity_select(stmt) else res.all()
|
|
236
|
+
else:
|
|
237
|
+
has_prev = safe_page > 1
|
|
238
|
+
exec_stmt = stmt.offset(offset).limit(safe_limit + 1)
|
|
239
|
+
res = await db.execute(exec_stmt)
|
|
240
|
+
raw_items = res.scalars().all() if _is_entity_select(stmt) else res.all()
|
|
241
|
+
if len(raw_items) > safe_limit:
|
|
242
|
+
has_next = True
|
|
243
|
+
raw_items = raw_items[:safe_limit]
|
|
244
|
+
else:
|
|
245
|
+
has_next = False
|
|
246
|
+
|
|
247
|
+
if raw_items and has_next and model_cls is not None and hasattr(model_cls, active_sort_col_name):
|
|
248
|
+
last_item = raw_items[-1]
|
|
249
|
+
last_val = _extract_item_value(last_item, active_sort_col_name)
|
|
250
|
+
if last_val is not None:
|
|
251
|
+
next_cursor = encode_cursor(last_val, col=active_sort_col_name, direction=current_direction)
|
|
252
|
+
|
|
253
|
+
# 6. Извлечение колонок и формирование tabular строк
|
|
254
|
+
out_fields, tabular_rows = _format_tabular_rows(raw_items, stmt, model_cls, fields)
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
"$tabular": True,
|
|
258
|
+
"fields": out_fields,
|
|
259
|
+
"rows": tabular_rows,
|
|
260
|
+
"limit": safe_limit,
|
|
261
|
+
"page": safe_page,
|
|
262
|
+
"total": total,
|
|
263
|
+
"pages": pages,
|
|
264
|
+
"has_next": has_next,
|
|
265
|
+
"has_prev": has_prev,
|
|
266
|
+
"next_cursor": next_cursor,
|
|
267
|
+
"prev_cursor": prev_cursor,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _is_entity_select(stmt: Select) -> bool:
|
|
272
|
+
"""Проверяет, выбирает ли Select полноценные ORM-сущности (`scalars`) или плоские колонки."""
|
|
273
|
+
if not stmt.column_descriptions:
|
|
274
|
+
return False
|
|
275
|
+
first = stmt.column_descriptions[0]
|
|
276
|
+
entity = first.get("entity")
|
|
277
|
+
expr = first.get("expr")
|
|
278
|
+
return entity is not None and expr is entity
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _extract_item_value(item: Any, col_name: str) -> Any:
|
|
282
|
+
"""Извлекает значение поля из ORM-инстанса или Row-кортежа."""
|
|
283
|
+
if hasattr(item, col_name):
|
|
284
|
+
return getattr(item, col_name)
|
|
285
|
+
if hasattr(item, "_mapping") and col_name in item._mapping:
|
|
286
|
+
return item._mapping[col_name]
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _format_tabular_rows(
|
|
291
|
+
items: Sequence[Any],
|
|
292
|
+
stmt: Select,
|
|
293
|
+
model_cls: type[Base] | None,
|
|
294
|
+
desired_fields: Sequence[str] | None,
|
|
295
|
+
) -> tuple[list[str], list[list[Any]]]:
|
|
296
|
+
"""Формирует итоговый список полей и сериализованные массивы значений."""
|
|
297
|
+
if not items:
|
|
298
|
+
if desired_fields:
|
|
299
|
+
return list(desired_fields), []
|
|
300
|
+
if model_cls is not None:
|
|
301
|
+
mapper = inspect(model_cls)
|
|
302
|
+
return [col.key for col in mapper.columns], []
|
|
303
|
+
if stmt.column_descriptions:
|
|
304
|
+
return [desc["name"] for desc in stmt.column_descriptions], []
|
|
305
|
+
return [], []
|
|
306
|
+
|
|
307
|
+
first = items[0]
|
|
308
|
+
|
|
309
|
+
# Вариант 1: Строки Row (когда делался select(Book.id, Book.title))
|
|
310
|
+
if hasattr(first, "_mapping"):
|
|
311
|
+
row_fields = list(first._mapping.keys())
|
|
312
|
+
out_fields = list(desired_fields) if desired_fields else row_fields
|
|
313
|
+
tabular_rows = [
|
|
314
|
+
[_serialize_val(row._mapping.get(f)) for f in out_fields]
|
|
315
|
+
for row in items
|
|
316
|
+
]
|
|
317
|
+
return out_fields, tabular_rows
|
|
318
|
+
|
|
319
|
+
# Вариант 2: ORM-инстансы (когда делался select(Book) или передан класс Book)
|
|
320
|
+
if model_cls is not None:
|
|
321
|
+
mapper = inspect(model_cls)
|
|
322
|
+
all_cols = [col.key for col in mapper.columns]
|
|
323
|
+
out_fields = list(desired_fields) if desired_fields else all_cols
|
|
324
|
+
elif desired_fields:
|
|
325
|
+
out_fields = list(desired_fields)
|
|
326
|
+
else:
|
|
327
|
+
out_fields = [k for k in dir(first) if not k.startswith("_")]
|
|
328
|
+
|
|
329
|
+
tabular_rows = [
|
|
330
|
+
[_serialize_val(getattr(item, f, None)) for f in out_fields]
|
|
331
|
+
for item in items
|
|
332
|
+
]
|
|
333
|
+
return out_fields, tabular_rows
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyferro
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.1
|
|
4
4
|
Summary: High-performance reactive fullstack Python framework on Rust RSGI (Granian) with symmetric WSRPC (JSON-RPC 2.0) and Tabular Data Compression.
|
|
5
5
|
Author-email: Alex Titoff <a.tit.off@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -252,3 +252,13 @@ example.com {
|
|
|
252
252
|
| `POST /upload` | Потоковая закачка файлов во временную транзакцию (`O(1)` RAM) |
|
|
253
253
|
| `GET /files/<folder>/<name>` | Защищенная раздача файлов (поддержка `X-Accel-Redirect` и файловых JWT) |
|
|
254
254
|
| `GET /pyferro_admin` | Встроенная панель управления RBAC и CRUD (маршрут `/admin` свободен для вашего приложения) |
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## 6. Табличная пагинация и сжатие трафика ($tabular)
|
|
259
|
+
|
|
260
|
+
PyFerro 2.0 включает высокопроизводительную подсистему пагинации для SQLAlchemy и фронтенда:
|
|
261
|
+
|
|
262
|
+
- **Бэкенд:** `paginate_tabular(db, Model, **params)` — выполняет поиск, фильтры, сортировку, умный `count(*)` и пакует ответ в сжатый протокол `$tabular` за **1 строчку кода**.
|
|
263
|
+
- **Фронтенд:** `createPaginator("Model.read")` + компонент `<Pagination />` — полностью берут на себя состояние, номера страниц `[1, '...', 4, 5, 6, '...', 25]`, стрелки «Назад/Вперед» и живой поиск.
|
|
264
|
+
- **Подробное руководство:** см. [PAGINATION.md](PAGINATION.md).
|
|
@@ -17,6 +17,7 @@ crud/spec.py
|
|
|
17
17
|
db/__init__.py
|
|
18
18
|
db/base.py
|
|
19
19
|
db/engine.py
|
|
20
|
+
db/pagination.py
|
|
20
21
|
files/__init__.py
|
|
21
22
|
files/models.py
|
|
22
23
|
files/rpc.py
|
|
@@ -34,4 +35,5 @@ server/app.py
|
|
|
34
35
|
server/auth_rpc.py
|
|
35
36
|
server/test_client.py
|
|
36
37
|
server/tokens.py
|
|
37
|
-
tests/test_e2e_lifecycle.py
|
|
38
|
+
tests/test_e2e_lifecycle.py
|
|
39
|
+
tests/test_pagination.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pyferro"
|
|
7
|
-
version = "2.0.
|
|
7
|
+
version = "2.0.1"
|
|
8
8
|
description = "High-performance reactive fullstack Python framework on Rust RSGI (Granian) with symmetric WSRPC (JSON-RPC 2.0) and Tabular Data Compression."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Тесты для системы пагинации PyFerro 2.0 (paginate_tabular, encode_cursor, decode_cursor).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from enum import Enum
|
|
7
|
+
import uuid
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from sqlalchemy import DateTime, Integer, String, select
|
|
11
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
12
|
+
|
|
13
|
+
from pyferro2.core.tabular import unpack_tabular
|
|
14
|
+
from pyferro2.db.base import Base
|
|
15
|
+
from pyferro2.db.engine import Database
|
|
16
|
+
from pyferro2.db.pagination import decode_cursor, encode_cursor, paginate_tabular
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BookGenre(str, Enum):
|
|
20
|
+
FICTION = "fiction"
|
|
21
|
+
SCIENCE = "science"
|
|
22
|
+
FANTASY = "fantasy"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DemoBook(Base):
|
|
26
|
+
__tablename__ = "demo_books"
|
|
27
|
+
|
|
28
|
+
title: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
29
|
+
author: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
30
|
+
genre: Mapped[str] = mapped_column(String(50), default=BookGenre.FICTION.value)
|
|
31
|
+
price: Mapped[int] = mapped_column(Integer, default=100)
|
|
32
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
33
|
+
DateTime(timezone=True),
|
|
34
|
+
default=lambda: datetime.now(timezone.utc),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
async def db_session():
|
|
40
|
+
db = Database("sqlite+aiosqlite:///:memory:")
|
|
41
|
+
async with db.engine.begin() as conn:
|
|
42
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
43
|
+
|
|
44
|
+
async with db.session() as session:
|
|
45
|
+
# Наполняем тестовыми данными (5 книг)
|
|
46
|
+
books = [
|
|
47
|
+
DemoBook(title="Book Alpha", author="Author 1", genre=BookGenre.FICTION.value, price=100),
|
|
48
|
+
DemoBook(title="Book Beta", author="Author 2", genre=BookGenre.SCIENCE.value, price=200),
|
|
49
|
+
DemoBook(title="Book Gamma", author="Author 1", genre=BookGenre.FICTION.value, price=150),
|
|
50
|
+
DemoBook(title="Book Delta", author="Author 3", genre=BookGenre.FANTASY.value, price=300),
|
|
51
|
+
DemoBook(title="Book Epsilon", author="Author 2", genre=BookGenre.SCIENCE.value, price=250),
|
|
52
|
+
]
|
|
53
|
+
session.add_all(books)
|
|
54
|
+
await session.commit()
|
|
55
|
+
yield session
|
|
56
|
+
|
|
57
|
+
await db.engine.dispose()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.asyncio
|
|
61
|
+
async def test_offset_pagination_first_page(db_session):
|
|
62
|
+
res = await paginate_tabular(db_session, DemoBook, page=1, limit=2)
|
|
63
|
+
assert res["$tabular"] is True
|
|
64
|
+
assert res["total"] == 5
|
|
65
|
+
assert res["pages"] == 3
|
|
66
|
+
assert res["page"] == 1
|
|
67
|
+
assert res["limit"] == 2
|
|
68
|
+
assert res["has_next"] is True
|
|
69
|
+
assert res["has_prev"] is False
|
|
70
|
+
assert len(res["rows"]) == 2
|
|
71
|
+
assert "title" in res["fields"]
|
|
72
|
+
assert res["next_cursor"] is not None
|
|
73
|
+
|
|
74
|
+
unpacked = unpack_tabular(res)
|
|
75
|
+
assert len(unpacked["items"]) == 2
|
|
76
|
+
# По умолчанию сортировка id.desc()
|
|
77
|
+
assert unpacked["items"][0]["title"] == "Book Epsilon"
|
|
78
|
+
assert unpacked["items"][1]["title"] == "Book Delta"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.asyncio
|
|
82
|
+
async def test_offset_pagination_second_page(db_session):
|
|
83
|
+
res = await paginate_tabular(db_session, DemoBook, page=2, limit=2)
|
|
84
|
+
assert res["page"] == 2
|
|
85
|
+
assert res["has_next"] is True
|
|
86
|
+
assert res["has_prev"] is True
|
|
87
|
+
assert len(res["rows"]) == 2
|
|
88
|
+
|
|
89
|
+
unpacked = unpack_tabular(res)
|
|
90
|
+
assert unpacked["items"][0]["title"] == "Book Gamma"
|
|
91
|
+
assert unpacked["items"][1]["title"] == "Book Beta"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.asyncio
|
|
95
|
+
async def test_offset_pagination_last_page(db_session):
|
|
96
|
+
res = await paginate_tabular(db_session, DemoBook, page=3, limit=2)
|
|
97
|
+
assert res["page"] == 3
|
|
98
|
+
assert res["has_next"] is False
|
|
99
|
+
assert res["has_prev"] is True
|
|
100
|
+
assert len(res["rows"]) == 1
|
|
101
|
+
|
|
102
|
+
unpacked = unpack_tabular(res)
|
|
103
|
+
assert unpacked["items"][0]["title"] == "Book Alpha"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@pytest.mark.asyncio
|
|
107
|
+
async def test_cursor_pagination(db_session):
|
|
108
|
+
# Страница 1 (Offset с получением next_cursor)
|
|
109
|
+
p1 = await paginate_tabular(db_session, DemoBook, page=1, limit=2)
|
|
110
|
+
cursor = p1["next_cursor"]
|
|
111
|
+
assert cursor is not None
|
|
112
|
+
|
|
113
|
+
# Страница 2 по курсору
|
|
114
|
+
p2 = await paginate_tabular(db_session, DemoBook, cursor=cursor, limit=2)
|
|
115
|
+
assert p2["$tabular"] is True
|
|
116
|
+
assert p2["total"] is None # В режиме курсора count(*) не вызывается!
|
|
117
|
+
assert p2["has_next"] is True
|
|
118
|
+
assert p2["has_prev"] is True
|
|
119
|
+
assert len(p2["rows"]) == 2
|
|
120
|
+
|
|
121
|
+
unpacked2 = unpack_tabular(p2)
|
|
122
|
+
assert unpacked2["items"][0]["title"] == "Book Gamma"
|
|
123
|
+
assert unpacked2["items"][1]["title"] == "Book Beta"
|
|
124
|
+
|
|
125
|
+
# Страница 3 по следующему курсору
|
|
126
|
+
cursor2 = p2["next_cursor"]
|
|
127
|
+
p3 = await paginate_tabular(db_session, DemoBook, cursor=cursor2, limit=2)
|
|
128
|
+
assert p3["has_next"] is False
|
|
129
|
+
assert len(p3["rows"]) == 1
|
|
130
|
+
unpacked3 = unpack_tabular(p3)
|
|
131
|
+
assert unpacked3["items"][0]["title"] == "Book Alpha"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@pytest.mark.asyncio
|
|
135
|
+
async def test_search_and_filters(db_session):
|
|
136
|
+
# Поиск по подстроке
|
|
137
|
+
res_search = await paginate_tabular(db_session, DemoBook, search="Gamma")
|
|
138
|
+
assert res_search["total"] == 1
|
|
139
|
+
unpacked = unpack_tabular(res_search)
|
|
140
|
+
assert unpacked["items"][0]["title"] == "Book Gamma"
|
|
141
|
+
|
|
142
|
+
# Фильтрация по жанру
|
|
143
|
+
res_filter = await paginate_tabular(
|
|
144
|
+
db_session, DemoBook, filters={"genre": BookGenre.SCIENCE.value}
|
|
145
|
+
)
|
|
146
|
+
assert res_filter["total"] == 2
|
|
147
|
+
unpacked_filter = unpack_tabular(res_filter)
|
|
148
|
+
genres = {item["genre"] for item in unpacked_filter["items"]}
|
|
149
|
+
assert genres == {BookGenre.SCIENCE.value}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@pytest.mark.asyncio
|
|
153
|
+
async def test_custom_select_and_column_projection(db_session):
|
|
154
|
+
# Передаем готовый Select с проекцией колонок
|
|
155
|
+
stmt = select(DemoBook.id, DemoBook.title).where(DemoBook.price > 150)
|
|
156
|
+
res = await paginate_tabular(db_session, stmt, limit=10)
|
|
157
|
+
|
|
158
|
+
assert res["$tabular"] is True
|
|
159
|
+
assert res["fields"] == ["id", "title"]
|
|
160
|
+
assert res["total"] == 3
|
|
161
|
+
assert len(res["rows"]) == 3
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@pytest.mark.asyncio
|
|
165
|
+
async def test_count_false_mode(db_session):
|
|
166
|
+
# Режим без COUNT (для бесконечного скролла)
|
|
167
|
+
res = await paginate_tabular(db_session, DemoBook, page=1, limit=2, count=False)
|
|
168
|
+
assert res["total"] is None
|
|
169
|
+
assert res["pages"] is None
|
|
170
|
+
assert res["has_next"] is True
|
|
171
|
+
assert len(res["rows"]) == 2
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@pytest.mark.asyncio
|
|
175
|
+
def test_cursor_encode_decode():
|
|
176
|
+
token = encode_cursor(42, col="id", direction="desc")
|
|
177
|
+
decoded = decode_cursor(token)
|
|
178
|
+
assert decoded["v"] == 42
|
|
179
|
+
assert decoded["c"] == "id"
|
|
180
|
+
assert decoded["d"] == "desc"
|
|
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
|
|
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
|