FastSQLA 0.8.8__tar.gz → 0.9.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.
- {fastsqla-0.8.8/src/FastSQLA.egg-info → fastsqla-0.9.0}/PKG-INFO +40 -2
- {fastsqla-0.8.8 → fastsqla-0.9.0}/README.md +39 -1
- {fastsqla-0.8.8 → fastsqla-0.9.0}/pyproject.toml +1 -1
- {fastsqla-0.8.8 → fastsqla-0.9.0/src/FastSQLA.egg-info}/PKG-INFO +40 -2
- {fastsqla-0.8.8 → fastsqla-0.9.0}/src/FastSQLA.egg-info/SOURCES.txt +2 -1
- fastsqla-0.9.0/src/fastsqla/cursor.py +306 -0
- {fastsqla-0.8.8 → fastsqla-0.9.0}/LICENSE +0 -0
- {fastsqla-0.8.8 → fastsqla-0.9.0}/setup.cfg +0 -0
- {fastsqla-0.8.8 → fastsqla-0.9.0}/src/FastSQLA.egg-info/dependency_links.txt +0 -0
- {fastsqla-0.8.8 → fastsqla-0.9.0}/src/FastSQLA.egg-info/requires.txt +0 -0
- {fastsqla-0.8.8 → fastsqla-0.9.0}/src/FastSQLA.egg-info/top_level.txt +0 -0
- {fastsqla-0.8.8 → fastsqla-0.9.0}/src/fastsqla/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: FastSQLA
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: SQLAlchemy extension for FastAPI that supports asynchronous sessions and includes built-in pagination.
|
|
5
5
|
Author-email: Hadrien David <h@driendavid.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -110,7 +110,7 @@ following [`SQLAlchemy`'s best practices](https://docs.sqlalchemy.org/en/20/orm/
|
|
|
110
110
|
...
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
-
* Built-in pagination:
|
|
113
|
+
* Built-in pagination with offset/limit:
|
|
114
114
|
|
|
115
115
|
```python
|
|
116
116
|
...
|
|
@@ -151,6 +151,44 @@ following [`SQLAlchemy`'s best practices](https://docs.sqlalchemy.org/en/20/orm/
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
```
|
|
154
|
+
* Forward cursor pagination for "Load more" lists:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from fastsqla.cursor import Page, Paginate
|
|
158
|
+
|
|
159
|
+
@app.get("/heros")
|
|
160
|
+
async def get_heros(paginate: Paginate[Hero]) -> Page[HeroModel]:
|
|
161
|
+
return await paginate(select(Hero).order_by(Hero.id))
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
<center>
|
|
165
|
+
|
|
166
|
+
👇 `/heros?limit=2&cursor=eyJ2IjoxLCJvcmRlciI6W1siaGVybyIsImlkIixmYWxzZSwiaW50Il1dLCJ2YWx1ZXMiOlsyXX0` 👇
|
|
167
|
+
|
|
168
|
+
</center>
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"data": [
|
|
173
|
+
{
|
|
174
|
+
"name": "Superman",
|
|
175
|
+
"secret_identity": "Clark Kent",
|
|
176
|
+
"id": 3
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
"name": "Batman",
|
|
180
|
+
"secret_identity": "Bruce Wayne",
|
|
181
|
+
"id": 4
|
|
182
|
+
}
|
|
183
|
+
],
|
|
184
|
+
"meta": {
|
|
185
|
+
"next_cursor": "eyJ2IjoxLCJvcmRlciI6W1siaGVybyIsImlkIixmYWxzZSwiaW50Il1dLCJ2YWx1ZXMiOls0XX0"
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Pass `meta.next_cursor` as `cursor` in the next query string. A `null` cursor means the
|
|
191
|
+
end of the results.
|
|
154
192
|
|
|
155
193
|
* Pagination customization:
|
|
156
194
|
|
|
@@ -63,7 +63,7 @@ following [`SQLAlchemy`'s best practices](https://docs.sqlalchemy.org/en/20/orm/
|
|
|
63
63
|
...
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
* Built-in pagination:
|
|
66
|
+
* Built-in pagination with offset/limit:
|
|
67
67
|
|
|
68
68
|
```python
|
|
69
69
|
...
|
|
@@ -104,6 +104,44 @@ following [`SQLAlchemy`'s best practices](https://docs.sqlalchemy.org/en/20/orm/
|
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
```
|
|
107
|
+
* Forward cursor pagination for "Load more" lists:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from fastsqla.cursor import Page, Paginate
|
|
111
|
+
|
|
112
|
+
@app.get("/heros")
|
|
113
|
+
async def get_heros(paginate: Paginate[Hero]) -> Page[HeroModel]:
|
|
114
|
+
return await paginate(select(Hero).order_by(Hero.id))
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
<center>
|
|
118
|
+
|
|
119
|
+
👇 `/heros?limit=2&cursor=eyJ2IjoxLCJvcmRlciI6W1siaGVybyIsImlkIixmYWxzZSwiaW50Il1dLCJ2YWx1ZXMiOlsyXX0` 👇
|
|
120
|
+
|
|
121
|
+
</center>
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"data": [
|
|
126
|
+
{
|
|
127
|
+
"name": "Superman",
|
|
128
|
+
"secret_identity": "Clark Kent",
|
|
129
|
+
"id": 3
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"name": "Batman",
|
|
133
|
+
"secret_identity": "Bruce Wayne",
|
|
134
|
+
"id": 4
|
|
135
|
+
}
|
|
136
|
+
],
|
|
137
|
+
"meta": {
|
|
138
|
+
"next_cursor": "eyJ2IjoxLCJvcmRlciI6W1siaGVybyIsImlkIixmYWxzZSwiaW50Il1dLCJ2YWx1ZXMiOls0XX0"
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Pass `meta.next_cursor` as `cursor` in the next query string. A `null` cursor means the
|
|
144
|
+
end of the results.
|
|
107
145
|
|
|
108
146
|
* Pagination customization:
|
|
109
147
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "FastSQLA"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.9.0"
|
|
8
8
|
description = "SQLAlchemy extension for FastAPI that supports asynchronous sessions and includes built-in pagination."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: FastSQLA
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: SQLAlchemy extension for FastAPI that supports asynchronous sessions and includes built-in pagination.
|
|
5
5
|
Author-email: Hadrien David <h@driendavid.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -110,7 +110,7 @@ following [`SQLAlchemy`'s best practices](https://docs.sqlalchemy.org/en/20/orm/
|
|
|
110
110
|
...
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
-
* Built-in pagination:
|
|
113
|
+
* Built-in pagination with offset/limit:
|
|
114
114
|
|
|
115
115
|
```python
|
|
116
116
|
...
|
|
@@ -151,6 +151,44 @@ following [`SQLAlchemy`'s best practices](https://docs.sqlalchemy.org/en/20/orm/
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
```
|
|
154
|
+
* Forward cursor pagination for "Load more" lists:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from fastsqla.cursor import Page, Paginate
|
|
158
|
+
|
|
159
|
+
@app.get("/heros")
|
|
160
|
+
async def get_heros(paginate: Paginate[Hero]) -> Page[HeroModel]:
|
|
161
|
+
return await paginate(select(Hero).order_by(Hero.id))
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
<center>
|
|
165
|
+
|
|
166
|
+
👇 `/heros?limit=2&cursor=eyJ2IjoxLCJvcmRlciI6W1siaGVybyIsImlkIixmYWxzZSwiaW50Il1dLCJ2YWx1ZXMiOlsyXX0` 👇
|
|
167
|
+
|
|
168
|
+
</center>
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"data": [
|
|
173
|
+
{
|
|
174
|
+
"name": "Superman",
|
|
175
|
+
"secret_identity": "Clark Kent",
|
|
176
|
+
"id": 3
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
"name": "Batman",
|
|
180
|
+
"secret_identity": "Bruce Wayne",
|
|
181
|
+
"id": 4
|
|
182
|
+
}
|
|
183
|
+
],
|
|
184
|
+
"meta": {
|
|
185
|
+
"next_cursor": "eyJ2IjoxLCJvcmRlciI6W1siaGVybyIsImlkIixmYWxzZSwiaW50Il1dLCJ2YWx1ZXMiOls0XX0"
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Pass `meta.next_cursor` as `cursor` in the next query string. A `null` cursor means the
|
|
191
|
+
end of the results.
|
|
154
192
|
|
|
155
193
|
* Pagination customization:
|
|
156
194
|
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import date, datetime
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Annotated, Any
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
import sqlalchemy as sa
|
|
11
|
+
from fastapi import HTTPException, Query
|
|
12
|
+
from fastapi.exceptions import RequestValidationError
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
|
|
14
|
+
from sqlalchemy import Select
|
|
15
|
+
from sqlalchemy.dialects import mysql
|
|
16
|
+
from sqlalchemy.sql import operators, visitors
|
|
17
|
+
from sqlalchemy.sql.elements import Label, UnaryExpression
|
|
18
|
+
from sqlalchemy.sql.selectable import Join
|
|
19
|
+
|
|
20
|
+
import fastsqla
|
|
21
|
+
|
|
22
|
+
__all__ = ["Meta", "Page", "Paginate", "PaginateType", "new_pagination"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Meta(BaseModel):
|
|
26
|
+
next_cursor: str | None = Field(description="Next cursor, or null at the end.")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Page[T](fastsqla.Collection[T]):
|
|
30
|
+
"""Forward page with a continuation cursor or null at the end."""
|
|
31
|
+
|
|
32
|
+
meta: Meta
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
type PaginateType[T] = Callable[[Select], Awaitable[Page[T]]]
|
|
36
|
+
type _Parameters = tuple[str | None, int | None]
|
|
37
|
+
type _Value = int | str | UUID | datetime | date | Decimal
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class _OrderTerm:
|
|
42
|
+
column: sa.Column
|
|
43
|
+
descending: bool
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
type _Order = list[_OrderTerm]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _Payload(BaseModel):
|
|
50
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
51
|
+
|
|
52
|
+
v: int = Field(ge=1, le=1)
|
|
53
|
+
order: list[tuple[str, str, bool, str]]
|
|
54
|
+
values: list[int | str]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _sources(stmt: Select) -> list[sa.FromClause]:
|
|
58
|
+
if not isinstance(stmt, Select) or any(
|
|
59
|
+
getattr(stmt, name) is not None
|
|
60
|
+
for name in ("_limit_clause", "_offset_clause", "_fetch_clause")
|
|
61
|
+
):
|
|
62
|
+
raise ValueError("Cursor pagination requires an unlimited Select")
|
|
63
|
+
if stmt._distinct or stmt._group_by_clauses or stmt._having_criteria:
|
|
64
|
+
raise ValueError("Cursor pagination does not support DISTINCT or aggregation")
|
|
65
|
+
if any(
|
|
66
|
+
not isinstance(col.element if isinstance(col, Label) else col, sa.Column)
|
|
67
|
+
for col in stmt.selected_columns
|
|
68
|
+
):
|
|
69
|
+
raise ValueError("Cursor pagination requires entity or column selections")
|
|
70
|
+
sources = stmt.get_final_froms()
|
|
71
|
+
if any(
|
|
72
|
+
isinstance(node, Join) and (node.isouter or node.full)
|
|
73
|
+
for source in sources
|
|
74
|
+
for node in visitors.iterate(source)
|
|
75
|
+
):
|
|
76
|
+
raise ValueError("Cursor pagination does not support outer joins")
|
|
77
|
+
return sources
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _order(stmt: Select) -> _Order:
|
|
81
|
+
sources = _sources(stmt)
|
|
82
|
+
order = []
|
|
83
|
+
for expression in stmt._order_by_clauses:
|
|
84
|
+
descending = False
|
|
85
|
+
if isinstance(expression, UnaryExpression) and expression.modifier in (
|
|
86
|
+
operators.asc_op,
|
|
87
|
+
operators.desc_op,
|
|
88
|
+
):
|
|
89
|
+
descending = expression.modifier is operators.desc_op
|
|
90
|
+
expression = expression.element
|
|
91
|
+
if (
|
|
92
|
+
not isinstance(expression, sa.Column)
|
|
93
|
+
or not isinstance(expression.table, sa.Table)
|
|
94
|
+
or expression.nullable
|
|
95
|
+
or not any(source.is_derived_from(expression.table) for source in sources)
|
|
96
|
+
):
|
|
97
|
+
raise ValueError("Cursor ordering requires non-null columns from the query")
|
|
98
|
+
if not isinstance(
|
|
99
|
+
expression.type,
|
|
100
|
+
(sa.Integer, sa.String, sa.Uuid, sa.DateTime, sa.Date, sa.Numeric),
|
|
101
|
+
) or expression.type.python_type not in (int, str, UUID, datetime, date, Decimal):
|
|
102
|
+
raise ValueError("Unsupported cursor column type")
|
|
103
|
+
order.append(_OrderTerm(expression, descending))
|
|
104
|
+
if not order:
|
|
105
|
+
raise ValueError("Cursor pagination requires an explicit unique ordering")
|
|
106
|
+
return order
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _schema(order: _Order) -> list[tuple[str, str, bool, str]]:
|
|
110
|
+
return [
|
|
111
|
+
(
|
|
112
|
+
term.column.table.fullname,
|
|
113
|
+
term.column.name,
|
|
114
|
+
term.descending,
|
|
115
|
+
term.column.type.python_type.__name__,
|
|
116
|
+
)
|
|
117
|
+
for term in order
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _encode(order: _Order, values: tuple[_Value, ...]) -> str:
|
|
122
|
+
key_types = tuple(term.column.type.python_type for term in order)
|
|
123
|
+
adapter = TypeAdapter(tuple[key_types])
|
|
124
|
+
encoded = adapter.dump_python(adapter.validate_python(values, strict=True), mode="json")
|
|
125
|
+
payload = _Payload(v=1, order=_schema(order), values=encoded)
|
|
126
|
+
return base64.urlsafe_b64encode(payload.model_dump_json().encode()).decode().rstrip("=")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _decode_payload(cursor: str) -> _Payload:
|
|
130
|
+
padded = cursor + "=" * (-len(cursor) % 4)
|
|
131
|
+
try:
|
|
132
|
+
decoded = base64.b64decode(padded, altchars=b"-_", validate=True)
|
|
133
|
+
return _Payload.model_validate_json(decoded)
|
|
134
|
+
except ValueError as error:
|
|
135
|
+
raise HTTPException(status_code=422, detail="Invalid cursor") from error
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _integer_bounds(column_type: sa.Integer, dialect: str) -> tuple[int, int]:
|
|
139
|
+
if dialect == "sqlite":
|
|
140
|
+
return -(2**63), 2**63
|
|
141
|
+
widths = (
|
|
142
|
+
(mysql.TINYINT, 8),
|
|
143
|
+
(mysql.MEDIUMINT, 24),
|
|
144
|
+
(sa.SmallInteger, 16),
|
|
145
|
+
(sa.BigInteger, 64),
|
|
146
|
+
)
|
|
147
|
+
bits = next((width for kind, width in widths if isinstance(column_type, kind)), 32)
|
|
148
|
+
if getattr(column_type, "unsigned", False):
|
|
149
|
+
return 0, 2**bits
|
|
150
|
+
return -(2 ** (bits - 1)), 2 ** (bits - 1)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _validate_value(column: sa.Column, value: _Value, dialect: str):
|
|
154
|
+
if isinstance(value, int):
|
|
155
|
+
lower, upper = _integer_bounds(column.type, dialect)
|
|
156
|
+
if not lower <= value < upper:
|
|
157
|
+
raise ValueError("Integer key out of range")
|
|
158
|
+
if isinstance(value, Decimal) and (
|
|
159
|
+
value.as_tuple().exponent < -16383
|
|
160
|
+
or value.adjusted() >= (column.type.precision or 131072) - (column.type.scale or 0)
|
|
161
|
+
):
|
|
162
|
+
raise ValueError("Decimal key out of range")
|
|
163
|
+
if dialect != "postgresql":
|
|
164
|
+
return
|
|
165
|
+
if isinstance(value, str) and "\0" in value:
|
|
166
|
+
raise ValueError("Text key contains NUL")
|
|
167
|
+
if isinstance(value, datetime):
|
|
168
|
+
aware = value.utcoffset() is not None
|
|
169
|
+
if aware != column.type.timezone:
|
|
170
|
+
raise ValueError("Datetime key timezone does not match the column")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _decode(cursor: str, order: _Order, dialect: str) -> list[_Value]:
|
|
174
|
+
payload = _decode_payload(cursor)
|
|
175
|
+
if payload.order != _schema(order) or len(payload.values) != len(order):
|
|
176
|
+
raise HTTPException(status_code=422, detail="Invalid cursor")
|
|
177
|
+
values = []
|
|
178
|
+
for term, value in zip(order, payload.values, strict=True):
|
|
179
|
+
value_type = term.column.type.python_type
|
|
180
|
+
encoded_type = int if value_type is int else str
|
|
181
|
+
if type(value) is not encoded_type:
|
|
182
|
+
raise HTTPException(status_code=422, detail="Invalid cursor")
|
|
183
|
+
try:
|
|
184
|
+
parsed = TypeAdapter(value_type).validate_json(json.dumps(value), strict=True)
|
|
185
|
+
except ValidationError as error:
|
|
186
|
+
raise HTTPException(status_code=422, detail="Invalid cursor") from error
|
|
187
|
+
try:
|
|
188
|
+
_validate_value(term.column, parsed, dialect)
|
|
189
|
+
except ValueError as error:
|
|
190
|
+
raise HTTPException(status_code=422, detail="Invalid cursor") from error
|
|
191
|
+
values.append(parsed)
|
|
192
|
+
return values
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _condition(
|
|
196
|
+
order: _Order, values: list[_Value], dialect: str
|
|
197
|
+
) -> sa.ColumnElement[bool]:
|
|
198
|
+
first = order[0]
|
|
199
|
+
same_direction = all(term.descending == first.descending for term in order)
|
|
200
|
+
if same_direction and dialect in ("postgresql", "sqlite", "mysql"):
|
|
201
|
+
keys = sa.tuple_(*(term.column for term in order))
|
|
202
|
+
return keys < tuple(values) if first.descending else keys > tuple(values)
|
|
203
|
+
terms, prefix = [], []
|
|
204
|
+
for term, value in zip(order, values, strict=True):
|
|
205
|
+
column = term.column
|
|
206
|
+
comparison = column < value if term.descending else column > value
|
|
207
|
+
terms.append(sa.and_(*prefix, comparison))
|
|
208
|
+
prefix.append(column == value)
|
|
209
|
+
bound = first.column <= values[0] if first.descending else first.column >= values[0]
|
|
210
|
+
return sa.and_(bound, sa.or_(*terms))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def new_pagination[T](
|
|
214
|
+
default_page_size: int = 10,
|
|
215
|
+
max_page_size: int = 100,
|
|
216
|
+
*,
|
|
217
|
+
parameters_dependency: Callable[..., _Parameters | Awaitable[_Parameters]] | None = None,
|
|
218
|
+
row_mapper: Callable[[sa.Row], T] = lambda row: row[0],
|
|
219
|
+
) -> Any:
|
|
220
|
+
"""Create a generic pagination dependency: `Paginate = new_pagination(...)`.
|
|
221
|
+
|
|
222
|
+
Annotate the endpoint argument as `Paginate[T]`. Pagination fields come from
|
|
223
|
+
query parameters unless a custom extraction dependency is supplied.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
default_page_size: Default limit when the client omits it.
|
|
227
|
+
max_page_size: Maximum accepted limit.
|
|
228
|
+
parameters_dependency: Sync or async FastAPI dependency returning
|
|
229
|
+
`(cursor, limit)`. A `None` limit uses `default_page_size`.
|
|
230
|
+
row_mapper: Maps each original result row to exactly one response item.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Generic annotated dependency with a one-row-to-one-item mapper.
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
ValueError: Page-size bounds or the supplied Select are unsupported.
|
|
237
|
+
"""
|
|
238
|
+
if (
|
|
239
|
+
type(default_page_size) is not int
|
|
240
|
+
or type(max_page_size) is not int
|
|
241
|
+
or not 1 <= default_page_size <= max_page_size
|
|
242
|
+
):
|
|
243
|
+
raise ValueError("Require 1 <= default_page_size <= max_page_size")
|
|
244
|
+
|
|
245
|
+
class Parameters(BaseModel):
|
|
246
|
+
model_config = ConfigDict(strict=True)
|
|
247
|
+
|
|
248
|
+
cursor: str | None = Field(None, min_length=1)
|
|
249
|
+
limit: int = Field(default_page_size, ge=1, le=max_page_size)
|
|
250
|
+
|
|
251
|
+
async def query_parameters(
|
|
252
|
+
cursor: str | None = Query(None, min_length=1),
|
|
253
|
+
limit: int = Query(default_page_size, ge=1, le=max_page_size),
|
|
254
|
+
) -> _Parameters:
|
|
255
|
+
return cursor, limit
|
|
256
|
+
|
|
257
|
+
if parameters_dependency is None:
|
|
258
|
+
parameters_dependency = query_parameters
|
|
259
|
+
|
|
260
|
+
async def dependency(
|
|
261
|
+
session: fastsqla.Session,
|
|
262
|
+
parameters: Annotated[_Parameters, fastsqla.Depends(parameters_dependency)],
|
|
263
|
+
) -> PaginateType[T]:
|
|
264
|
+
cursor, limit = parameters
|
|
265
|
+
try:
|
|
266
|
+
validated = Parameters(
|
|
267
|
+
cursor=cursor, limit=default_page_size if limit is None else limit
|
|
268
|
+
)
|
|
269
|
+
except ValidationError as error:
|
|
270
|
+
raise RequestValidationError(
|
|
271
|
+
error.errors(include_url=False, include_input=False)
|
|
272
|
+
) from error
|
|
273
|
+
cursor, limit = validated.cursor, validated.limit
|
|
274
|
+
|
|
275
|
+
async def paginate(stmt: Select) -> Page[T]:
|
|
276
|
+
order = _order(stmt)
|
|
277
|
+
dialect = session.get_bind(clause=stmt).dialect.name
|
|
278
|
+
if dialect == "sqlite" and any(
|
|
279
|
+
term.column.type.python_type is Decimal for term in order
|
|
280
|
+
):
|
|
281
|
+
raise ValueError("SQLite decimal ordering cannot preserve cursor precision")
|
|
282
|
+
columns = [term.column for term in order]
|
|
283
|
+
if cursor is not None:
|
|
284
|
+
values = _decode(cursor, order, dialect)
|
|
285
|
+
stmt = stmt.where(_condition(order, values, dialect))
|
|
286
|
+
stmt = stmt.add_columns(*(col.label(None) for col in columns)).limit(limit + 1)
|
|
287
|
+
result = await session.execute(stmt)
|
|
288
|
+
width = len(result.keys()) - len(columns)
|
|
289
|
+
frozen = result.freeze()
|
|
290
|
+
rows = frozen().all()
|
|
291
|
+
next_cursor = None
|
|
292
|
+
if len(rows) > limit:
|
|
293
|
+
boundary = rows[limit - 1]
|
|
294
|
+
cursor_values = boundary[-len(columns) :]
|
|
295
|
+
next_cursor = _encode(order, cursor_values)
|
|
296
|
+
original = frozen().columns(*range(width)).all()
|
|
297
|
+
data = [row_mapper(row) for row in original[:limit]]
|
|
298
|
+
return Page(data=data, meta=Meta(next_cursor=next_cursor))
|
|
299
|
+
|
|
300
|
+
return paginate
|
|
301
|
+
|
|
302
|
+
return Annotated[PaginateType[T], fastsqla.Depends(dependency)]
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
Paginate = new_pagination()
|
|
306
|
+
"""Inject a forward paginator accepting cursor and limit query parameters."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|