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