Source code for loom.core.repository.abc.cursor
"""Backend-neutral cursor tokens for keyset pagination.
A token is the base64url form of a msgspec-encoded record
``{"b": backend, "k": [sort key values...], "id": tie-breaker}``. Every
backend issues and consumes the same shape, so a token from another backend
or in a legacy format is rejected at decode time with
:class:`~loom.core.repository.abc.errors.UnsupportedQuery`.
"""
from __future__ import annotations
import base64
from collections.abc import Sequence
from datetime import date, datetime
from decimal import Decimal
from enum import Enum
from uuid import UUID
import msgspec
from loom.core.model.struct import LoomFrozenStruct
from loom.core.repository.abc.errors import UnsupportedQuery
[docs]
class Cursor(LoomFrozenStruct, frozen=True):
"""Decoded cursor position.
Args:
backend: Name of the backend that issued the token.
keys: Sort key values of the last row on the previous page, in sort order.
tie_breaker: Primary key of that row.
"""
backend: str
keys: tuple[object, ...]
tie_breaker: object
class _DateTimeKey(msgspec.Struct, tag="datetime"):
v: datetime
class _DateKey(msgspec.Struct, tag="date"):
v: date
class _UuidKey(msgspec.Struct, tag="uuid"):
v: UUID
class _DecimalKey(msgspec.Struct, tag="decimal"):
v: Decimal
_Key = int | float | str | bool | None | _DateTimeKey | _DateKey | _UuidKey | _DecimalKey
class _Token(msgspec.Struct):
b: str
k: list[_Key]
id: _Key
def _wrap(value: object) -> _Key:
if isinstance(value, Enum):
value = value.value
if value is None or isinstance(value, int | float | str):
return value
if isinstance(value, datetime):
return _DateTimeKey(value)
if isinstance(value, date):
return _DateKey(value)
if isinstance(value, UUID):
return _UuidKey(value)
if isinstance(value, Decimal):
return _DecimalKey(value)
raise TypeError(f"Unsupported cursor key type: {type(value).__name__}")
def _unwrap(key: _Key) -> object:
if isinstance(key, _DateTimeKey | _DateKey | _UuidKey | _DecimalKey):
return key.v
return key
[docs]
def encode_cursor(backend: str, keys: Sequence[object], tie_breaker: object) -> str:
"""Encode a cursor position into an opaque URL-safe token.
Args:
backend: Name of the issuing backend.
keys: Sort key values of the last row on the page, in sort order.
tie_breaker: Primary key of that row.
Returns:
base64url token that :func:`decode_cursor` accepts for ``backend``.
Raises:
TypeError: If a key has a type the token format cannot carry.
"""
token = _Token(b=backend, k=[_wrap(key) for key in keys], id=_wrap(tie_breaker))
return base64.urlsafe_b64encode(msgspec.json.encode(token)).decode()
[docs]
def decode_cursor(token: str, backend: str, model: str, *, key_count: int | None = None) -> Cursor:
"""Decode a token issued by :func:`encode_cursor` for ``backend``.
Args:
token: Opaque token supplied by the client.
backend: Name of the backend decoding the token.
model: Qualified model name, used in the error.
key_count: Number of sort keys the query orders by; when given, a
token carrying a different number is rejected.
Returns:
The decoded :class:`Cursor`.
Raises:
UnsupportedQuery: If the token is undecodable, in a legacy format,
was issued by another backend or does not match the sort.
"""
try:
record = msgspec.json.decode(base64.urlsafe_b64decode(token.encode()), type=_Token)
except (ValueError, msgspec.DecodeError) as exc:
raise UnsupportedQuery(backend, model, "cursor token is not valid") from exc
if record.b != backend:
raise UnsupportedQuery(backend, model, "cursor token was issued by another backend")
if key_count is not None and len(record.k) != key_count:
raise UnsupportedQuery(backend, model, "cursor token does not match the sort")
return Cursor(
backend=record.b,
keys=tuple(_unwrap(key) for key in record.k),
tie_breaker=_unwrap(record.id),
)