FastSQLA 0.8.7__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: FastSQLA
3
- Version: 0.8.7
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.8.7"
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.8.7
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
 
@@ -1,9 +1,10 @@
1
1
  LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
- src/fastsqla.py
5
4
  src/FastSQLA.egg-info/PKG-INFO
6
5
  src/FastSQLA.egg-info/SOURCES.txt
7
6
  src/FastSQLA.egg-info/dependency_links.txt
8
7
  src/FastSQLA.egg-info/requires.txt
9
- src/FastSQLA.egg-info/top_level.txt
8
+ src/FastSQLA.egg-info/top_level.txt
9
+ src/fastsqla/__init__.py
10
+ src/fastsqla/cursor.py
@@ -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