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.
Files changed (41) hide show
  1. {pyferro-2.0.0 → pyferro-2.0.1}/PKG-INFO +11 -1
  2. {pyferro-2.0.0 → pyferro-2.0.1}/README.md +10 -0
  3. {pyferro-2.0.0 → pyferro-2.0.1}/core/tabular.py +17 -2
  4. {pyferro-2.0.0 → pyferro-2.0.1}/db/__init__.py +4 -0
  5. pyferro-2.0.1/db/pagination.py +333 -0
  6. {pyferro-2.0.0 → pyferro-2.0.1}/pyferro.egg-info/PKG-INFO +11 -1
  7. {pyferro-2.0.0 → pyferro-2.0.1}/pyferro.egg-info/SOURCES.txt +3 -1
  8. {pyferro-2.0.0 → pyferro-2.0.1}/pyproject.toml +1 -1
  9. pyferro-2.0.1/tests/test_pagination.py +180 -0
  10. {pyferro-2.0.0 → pyferro-2.0.1}/LICENSE +0 -0
  11. {pyferro-2.0.0 → pyferro-2.0.1}/auth/__init__.py +0 -0
  12. {pyferro-2.0.0 → pyferro-2.0.1}/auth/models.py +0 -0
  13. {pyferro-2.0.0 → pyferro-2.0.1}/auth/passwords.py +0 -0
  14. {pyferro-2.0.0 → pyferro-2.0.1}/auth/seeder.py +0 -0
  15. {pyferro-2.0.0 → pyferro-2.0.1}/core/__init__.py +0 -0
  16. {pyferro-2.0.0 → pyferro-2.0.1}/core/constants.py +0 -0
  17. {pyferro-2.0.0 → pyferro-2.0.1}/core/session.py +0 -0
  18. {pyferro-2.0.0 → pyferro-2.0.1}/core/session_data.py +0 -0
  19. {pyferro-2.0.0 → pyferro-2.0.1}/crud/__init__.py +0 -0
  20. {pyferro-2.0.0 → pyferro-2.0.1}/crud/engine.py +0 -0
  21. {pyferro-2.0.0 → pyferro-2.0.1}/crud/rpc.py +0 -0
  22. {pyferro-2.0.0 → pyferro-2.0.1}/crud/spec.py +0 -0
  23. {pyferro-2.0.0 → pyferro-2.0.1}/db/base.py +0 -0
  24. {pyferro-2.0.0 → pyferro-2.0.1}/db/engine.py +0 -0
  25. {pyferro-2.0.0 → pyferro-2.0.1}/files/__init__.py +0 -0
  26. {pyferro-2.0.0 → pyferro-2.0.1}/files/models.py +0 -0
  27. {pyferro-2.0.0 → pyferro-2.0.1}/files/rpc.py +0 -0
  28. {pyferro-2.0.0 → pyferro-2.0.1}/files/storage.py +0 -0
  29. {pyferro-2.0.0 → pyferro-2.0.1}/pyferro.egg-info/dependency_links.txt +0 -0
  30. {pyferro-2.0.0 → pyferro-2.0.1}/pyferro.egg-info/requires.txt +0 -0
  31. {pyferro-2.0.0 → pyferro-2.0.1}/pyferro.egg-info/top_level.txt +0 -0
  32. {pyferro-2.0.0 → pyferro-2.0.1}/rpc/__init__.py +0 -0
  33. {pyferro-2.0.0 → pyferro-2.0.1}/rpc/errors.py +0 -0
  34. {pyferro-2.0.0 → pyferro-2.0.1}/rpc/router.py +0 -0
  35. {pyferro-2.0.0 → pyferro-2.0.1}/server/__init__.py +0 -0
  36. {pyferro-2.0.0 → pyferro-2.0.1}/server/app.py +0 -0
  37. {pyferro-2.0.0 → pyferro-2.0.1}/server/auth_rpc.py +0 -0
  38. {pyferro-2.0.0 → pyferro-2.0.1}/server/test_client.py +0 -0
  39. {pyferro-2.0.0 → pyferro-2.0.1}/server/tokens.py +0 -0
  40. {pyferro-2.0.0 → pyferro-2.0.1}/setup.cfg +0 -0
  41. {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.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[dict[str, Any] | tuple[Any, ...] | list[Any]],
15
+ items: Sequence[Any],
16
16
  fields: Sequence[str] | None = None,
17
17
  ) -> dict[str, Any] | Any:
18
18
  """
19
- Упаковывает список словарей или кортежей в табличный пакет `{"$tabular": True, "fields": [...], "rows": [...]}`.
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.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.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