fastapi-sqlalchemy-querykit 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

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: fastapi-sqlalchemy-querykit
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Deny-by-default declarative filtering, sorting, and searching for FastAPI + SQLAlchemy 2.0.
5
5
  Project-URL: Homepage, https://github.com/Rooler-ai/fastapi-sqlalchemy-querybuilder
6
6
  Project-URL: Repository, https://github.com/Rooler-ai/fastapi-sqlalchemy-querybuilder
@@ -24,6 +24,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
24
24
  Classifier: Typing :: Typed
25
25
  Requires-Python: >=3.10
26
26
  Requires-Dist: fastapi>=0.115
27
+ Requires-Dist: pydantic>=2
27
28
  Requires-Dist: sqlalchemy>=2.0
28
29
  Provides-Extra: dev
29
30
  Requires-Dist: aiosqlite>=0.20; extra == 'dev'
@@ -105,6 +106,7 @@ stmt = build_query(User, params, allowed_filters={"status", "username"})
105
106
  - [Operator reference](docs/operators.md) — wire format, every operator, the per-type matrix
106
107
  - [Guide](docs/guide.md) — `Queryable`, relationships, search, sort, errors, limits, startup validation
107
108
  - [FastAPI integration](docs/integration.md)
109
+ - [Pagination & query-config](docs/pagination.md) — the `Page` envelope, `list_and_paginate`, `build_query_config`
108
110
 
109
111
  Build the docs site locally: `pip install -e ".[docs]" && mkdocs serve`.
110
112
 
@@ -1,17 +1,20 @@
1
- querybuilder/__init__.py,sha256=NIxtKiOIeas6dpnjurihR0lCL2n5xV0J7uLdVqzWq7w,825
1
+ querybuilder/__init__.py,sha256=1kJ8B1g7GGOr3lb7xuFQYoPP_hP3l6LFZfam6RIP-Go,1336
2
2
  querybuilder/allowlist.py,sha256=OeJRFt2u8oJg6gUO0mz_4mFEVRNunyWlhZzyW1cl-rY,3514
3
3
  querybuilder/builder.py,sha256=BCSyRsiI-t44BD9sSx7Simp0qcvBleUjq1995rKF4Q4,11538
4
4
  querybuilder/columns.py,sha256=IybmfUho1d-a5CQ7A530a6WyDZ5sjeGwBih6W14tuAc,1986
5
+ querybuilder/config.py,sha256=S7hfraek8D_xsPqOcSGMduc-ZzTroOZfl_Y5WO1w00s,4675
5
6
  querybuilder/dates.py,sha256=CtG1mxAC6NNofe2FVj40Fx3kB1IfbEXSE1BnsRymi08,1994
6
7
  querybuilder/errors.py,sha256=o7SfFhEZMZAktr1nSuHkhNajKBcHBjirHpV5y5Sd3pY,1504
7
8
  querybuilder/mixin.py,sha256=nvVkAVKubt8pDDbE5xcmIFm64FxL04A1y-IY0Ik4kCA,1185
8
9
  querybuilder/operators.py,sha256=0NN9oxZjUvJX02NasTKmSocFC8WoULNnXvmtxw5hwPA,9957
10
+ querybuilder/pagination.py,sha256=pZf0_bgGYOw5ZT4jIc8LD8rwCPP7K-E2ogJp1ATTqIg,4757
9
11
  querybuilder/params.py,sha256=lyFvyFSEA0QU2R8w4mqVITN0Eeda6YYap1aDPm3f3aY,1362
10
12
  querybuilder/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
13
  querybuilder/resolver.py,sha256=5RjArTbfrn5bheXmnfHX8GUUqRY08VVsb6-hQdhk_bQ,4009
14
+ querybuilder/runner.py,sha256=4cMxQEmpWrsvgDYC0Ky71pl-hlU8CpAIATLX-00TScs,1893
12
15
  querybuilder/validation.py,sha256=kHudpTbCJvsBVHkQV3J2zJVM10_tubNUPgvVETpnXJU,2178
13
- fastapi_sqlalchemy_querykit-0.1.0.dist-info/METADATA,sha256=HKxZpLeeQXCZVzRkYLSZhhAK11Y_I4oc5_jnH0hDvYc,4870
14
- fastapi_sqlalchemy_querykit-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
15
- fastapi_sqlalchemy_querykit-0.1.0.dist-info/licenses/LICENSE,sha256=cqZedEuNrvR7QYfocPvfNbKnnfYb14dvpFES61HGq7I,1166
16
- fastapi_sqlalchemy_querykit-0.1.0.dist-info/licenses/NOTICE,sha256=Wi8KVt1WphKO-KtYXuWEfxrsYRPAimbgAvI7_E9LP_E,394
17
- fastapi_sqlalchemy_querykit-0.1.0.dist-info/RECORD,,
16
+ fastapi_sqlalchemy_querykit-0.2.0.dist-info/METADATA,sha256=MCg5r3PuYRaAaRuUAd04vUAfdXCi0wvaWY9cAsWihFg,5014
17
+ fastapi_sqlalchemy_querykit-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
18
+ fastapi_sqlalchemy_querykit-0.2.0.dist-info/licenses/LICENSE,sha256=cqZedEuNrvR7QYfocPvfNbKnnfYb14dvpFES61HGq7I,1166
19
+ fastapi_sqlalchemy_querykit-0.2.0.dist-info/licenses/NOTICE,sha256=Wi8KVt1WphKO-KtYXuWEfxrsYRPAimbgAvI7_E9LP_E,394
20
+ fastapi_sqlalchemy_querykit-0.2.0.dist-info/RECORD,,
querybuilder/__init__.py CHANGED
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  from .allowlist import validate_queryable, validate_queryables
11
11
  from .builder import build_query
12
+ from .config import FieldConfig, QueryConfig, build_query_config
12
13
  from .errors import (
13
14
  FilterError,
14
15
  QueryBuilderError,
@@ -17,7 +18,16 @@ from .errors import (
17
18
  SortError,
18
19
  )
19
20
  from .mixin import Queryable
21
+ from .pagination import (
22
+ Page,
23
+ PageMeta,
24
+ PageParams,
25
+ build_page_meta,
26
+ page_params,
27
+ paginate,
28
+ )
20
29
  from .params import QueryParams
30
+ from .runner import ScopeFn, list_and_paginate
21
31
 
22
32
  __all__ = [
23
33
  "build_query",
@@ -30,6 +40,20 @@ __all__ = [
30
40
  "SortError",
31
41
  "SearchError",
32
42
  "QueryConfigurationError",
43
+ # pagination
44
+ "Page",
45
+ "PageMeta",
46
+ "PageParams",
47
+ "page_params",
48
+ "paginate",
49
+ "build_page_meta",
50
+ # query-config introspection
51
+ "QueryConfig",
52
+ "FieldConfig",
53
+ "build_query_config",
54
+ # turnkey runner
55
+ "list_and_paginate",
56
+ "ScopeFn",
33
57
  ]
34
58
 
35
- __version__ = "0.1.0"
59
+ __version__ = "0.2.0"
querybuilder/config.py ADDED
@@ -0,0 +1,129 @@
1
+ """Introspection of a model's ``Queryable`` policy into a frontend-facing config.
2
+
3
+ Powers per-entity ``GET /<plural>/query-config`` endpoints: for each declared
4
+ filterable field it returns the field type, the operators valid for that type, and
5
+ (for enums) the allowed values — so a filter-builder UI can render itself.
6
+
7
+ Everything is derived from the library's OWN rules — ``resolver.describe_path`` to
8
+ resolve dotted paths to a leaf column, and ``validation.allowed_operators_for`` for
9
+ the type→operator gate — so the config can never drift from what ``build_query``
10
+ actually accepts.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from pydantic import BaseModel, Field
18
+
19
+ from sqlalchemy import Uuid as _SAUuid
20
+
21
+ from .columns import (
22
+ is_boolean_column,
23
+ is_datetime_like_column,
24
+ is_enum_column,
25
+ is_integer_column,
26
+ is_numeric_column,
27
+ is_string_column,
28
+ )
29
+ from .resolver import describe_path
30
+ from .validation import allowed_operators_for
31
+
32
+ try: # postgresql-specific UUID type
33
+ from sqlalchemy.dialects.postgresql import UUID as _PGUuid
34
+ except Exception: # pragma: no cover - dialect import guard
35
+ _PGUuid = ()
36
+
37
+ # Canonical display order for operators (a superset; only the applicable ones are
38
+ # emitted per field). Keeps the UI stable instead of alphabetical noise.
39
+ _OP_ORDER = [
40
+ "$eq", "$ne",
41
+ "$gt", "$gte", "$lt", "$lte",
42
+ "$in", "$nin",
43
+ "$contains", "$ncontains", "$startswith", "$endswith",
44
+ "$isnull", "$isnotnull",
45
+ ]
46
+
47
+
48
+ class FieldConfig(BaseModel):
49
+ """How one filterable field may be queried."""
50
+
51
+ type: str = Field(..., description="Logical field type (string/enum/integer/number/boolean/datetime)")
52
+ operators: List[str] = Field(..., description="Operators valid for this field's type")
53
+ values: Optional[List[str]] = Field(None, description="Allowed values for enum fields")
54
+
55
+
56
+ class QueryConfig(BaseModel):
57
+ """The filter/sort/search capabilities a list endpoint exposes for a model."""
58
+
59
+ filterable: Dict[str, FieldConfig] = Field(default_factory=dict)
60
+ sortable: List[str] = Field(default_factory=list)
61
+ searchable: List[str] = Field(default_factory=list)
62
+
63
+
64
+ def _is_uuid_column(column: Any) -> bool:
65
+ col_type = getattr(column, "type", None)
66
+ if col_type is None:
67
+ return False
68
+ if isinstance(col_type, _SAUuid): # generic SQLAlchemy 2.0 Uuid
69
+ return True
70
+ if _PGUuid and isinstance(col_type, _PGUuid): # postgresql UUID
71
+ return True
72
+ return col_type.__class__.__name__.lower() == "uuid"
73
+
74
+
75
+ def _type_label(column: Any) -> str:
76
+ # Enum first: an enum column is also string-like, but "enum" is the useful label.
77
+ if is_enum_column(column):
78
+ return "enum"
79
+ if is_boolean_column(column):
80
+ return "boolean"
81
+ if is_integer_column(column):
82
+ return "integer"
83
+ if is_numeric_column(column):
84
+ return "number"
85
+ if is_datetime_like_column(column):
86
+ return "datetime"
87
+ if _is_uuid_column(column):
88
+ # Equality/set comparison only (no substring/ordering) — see allowed_operators_for.
89
+ return "uuid"
90
+ if is_string_column(column):
91
+ return "string"
92
+ return "string"
93
+
94
+
95
+ def _enum_values(column: Any) -> Optional[List[str]]:
96
+ if not is_enum_column(column):
97
+ return None
98
+ col_type = getattr(column, "type", None)
99
+ enum_class = getattr(col_type, "enum_class", None)
100
+ if enum_class is not None:
101
+ # Filtering matches by the enum member's VALUE (per docs/operators.md).
102
+ return [str(m.value) for m in enum_class]
103
+ # Raw Enum("a", "b") with no Python class.
104
+ return [str(v) for v in (getattr(col_type, "enums", None) or [])]
105
+
106
+
107
+ def _ordered_ops(ops) -> List[str]:
108
+ present = set(ops)
109
+ ordered = [o for o in _OP_ORDER if o in present]
110
+ # Surface any operator not in the canonical list (forward-compat) at the end.
111
+ ordered.extend(sorted(present - set(ordered)))
112
+ return ordered
113
+
114
+
115
+ def build_query_config(model: Any) -> QueryConfig:
116
+ """Build the :class:`QueryConfig` for a ``Queryable`` model from its allowlists."""
117
+ filterable: Dict[str, FieldConfig] = {}
118
+ for path in sorted(getattr(model, "__filterable__", frozenset())):
119
+ leaf, _has_to_many = describe_path(model, path.split("."))
120
+ filterable[path] = FieldConfig(
121
+ type=_type_label(leaf),
122
+ operators=_ordered_ops(allowed_operators_for(leaf)),
123
+ values=_enum_values(leaf),
124
+ )
125
+ return QueryConfig(
126
+ filterable=filterable,
127
+ sortable=sorted(getattr(model, "__sortable__", frozenset())),
128
+ searchable=sorted(getattr(model, "__searchable__", frozenset())),
129
+ )
@@ -0,0 +1,135 @@
1
+ """Offset pagination: request params, response envelope, and a turnkey runner.
2
+
3
+ Standard list shape::
4
+
5
+ { "items": [...], "meta": { total, page, limit, total_pages, has_next, has_prev } }
6
+
7
+ Two modes: a turnkey :func:`paginate` (and :func:`querybuilder.runner.list_and_paginate`)
8
+ that runs the count + page query for you, or :func:`build_page_meta` for endpoints
9
+ that keep their own query logic but still want the standard envelope.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ from typing import Any, Generic, List, Sequence, Tuple, TypeVar
16
+
17
+ from fastapi import Query
18
+ from pydantic import BaseModel, Field
19
+ from sqlalchemy import func, select
20
+ from sqlalchemy.ext.asyncio import AsyncSession
21
+ from sqlalchemy.sql import Select
22
+
23
+ T = TypeVar("T")
24
+
25
+ DEFAULT_LIMIT = 25
26
+ MAX_LIMIT = 100
27
+
28
+
29
+ class PageMeta(BaseModel):
30
+ """Pagination metadata for a single page of results."""
31
+
32
+ total: int = Field(..., description="Total rows matching the query (all pages)")
33
+ page: int = Field(..., description="Current 1-based page number")
34
+ limit: int = Field(..., description="Page size used for this query")
35
+ total_pages: int = Field(..., description="Total number of pages")
36
+ has_next: bool = Field(..., description="Whether a next page exists")
37
+ has_prev: bool = Field(..., description="Whether a previous page exists")
38
+
39
+
40
+ class Page(BaseModel, Generic[T]):
41
+ """Generic paginated response envelope: ``{items, meta}``."""
42
+
43
+ items: List[T] = Field(default_factory=list)
44
+ meta: PageMeta
45
+
46
+
47
+ def build_page_meta(total: int, page: int, limit: int) -> PageMeta:
48
+ """Compute :class:`PageMeta` from raw counts (Mode 2: bring-your-own query).
49
+
50
+ Use when an endpoint keeps its own count/fetch logic but should still emit the
51
+ standard envelope.
52
+ """
53
+ limit = max(1, limit)
54
+ total_pages = math.ceil(total / limit) if total else 0
55
+ return PageMeta(
56
+ total=total,
57
+ page=page,
58
+ limit=limit,
59
+ total_pages=total_pages,
60
+ has_next=page < total_pages,
61
+ has_prev=page > 1,
62
+ )
63
+
64
+
65
+ class PageParams:
66
+ """FastAPI dependency exposing ``page`` + ``limit`` query params (offset paging).
67
+
68
+ Use directly — ``p: PageParams = Depends()`` — or build a variant with a custom
69
+ default/max page size via :func:`page_params`.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ page: int = Query(1, ge=1, description="1-based page number"),
75
+ limit: int = Query(
76
+ DEFAULT_LIMIT, ge=1, le=MAX_LIMIT, description="Items per page"
77
+ ),
78
+ ):
79
+ self.page = page
80
+ self.limit = limit
81
+
82
+ @property
83
+ def offset(self) -> int:
84
+ return (self.page - 1) * self.limit
85
+
86
+
87
+ def page_params(default: int = DEFAULT_LIMIT, max_: int = MAX_LIMIT):
88
+ """Factory for a :class:`PageParams` dependency with a custom default/max size.
89
+
90
+ Mirrors Spatie's configurable ``default_size`` / ``max_results``::
91
+
92
+ p: PageParams = Depends(page_params(default=50, max_=200))
93
+ """
94
+
95
+ def _dep(
96
+ page: int = Query(1, ge=1, description="1-based page number"),
97
+ limit: int = Query(default, ge=1, le=max_, description="Items per page"),
98
+ ) -> PageParams:
99
+ return PageParams(page=page, limit=limit)
100
+
101
+ return _dep
102
+
103
+
104
+ async def paginate(
105
+ db: AsyncSession, stmt: Select, params: PageParams
106
+ ) -> Tuple[Sequence[Any], PageMeta]:
107
+ """Mode 1 (turnkey): run ``COUNT`` + ``LIMIT/OFFSET`` over ``stmt``.
108
+
109
+ Returns ``(rows, meta)``; the endpoint wraps ``rows`` in ``Page[Read]``.
110
+
111
+ SCOPE FOOTGUN: the count is built as
112
+ ``select(count()).select_from(stmt.order_by(None).subquery())``, whose outer
113
+ statement has no ORM mapper. Any scope you rely on *outside* ``stmt`` — e.g. a
114
+ session-level scoping ORM event — will NOT fire on this count path even if it
115
+ fires on the fetch. So tenant / soft-delete / visibility predicates MUST live
116
+ INSIDE ``stmt`` (via the ``scope`` callable in
117
+ :func:`querybuilder.runner.list_and_paginate`) to land in the count subquery
118
+ too — otherwise ``total`` is computed unscoped and leaks across tenants.
119
+
120
+ Eager-loading rule: to-many relations must use ``selectinload`` (a separate
121
+ query, safe under ``LIMIT``); ``joinedload`` of a collection truncates rows under
122
+ ``LIMIT``. ``.unique()`` defensively dedups joined to-one rows.
123
+ """
124
+ count_stmt = select(func.count()).select_from(stmt.order_by(None).subquery())
125
+ total = int((await db.execute(count_stmt)).scalar_one() or 0)
126
+ if total == 0:
127
+ return [], build_page_meta(0, params.page, params.limit)
128
+
129
+ rows = (
130
+ (await db.execute(stmt.limit(params.limit).offset(params.offset)))
131
+ .unique()
132
+ .scalars()
133
+ .all()
134
+ )
135
+ return rows, build_page_meta(total, params.page, params.limit)
querybuilder/runner.py ADDED
@@ -0,0 +1,48 @@
1
+ """Turnkey list path: ``build_query`` → caller ``scope`` → eager ``options`` → paginate.
2
+
3
+ A thin convenience over :func:`querybuilder.build_query` and
4
+ :func:`querybuilder.pagination.paginate` for the common list-endpoint shape. The
5
+ caller supplies the mandatory base scope (tenant / soft-delete / visibility) as a
6
+ ``Select -> Select`` callable; the builder never adds it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Callable, Iterable, Optional, Sequence, Tuple
12
+
13
+ from sqlalchemy.ext.asyncio import AsyncSession
14
+ from sqlalchemy.sql import Select
15
+
16
+ from .builder import build_query
17
+ from .pagination import PageMeta, PageParams, paginate
18
+ from .params import QueryParams
19
+
20
+ # A scope callable receives the built Select and returns it with the caller's
21
+ # mandatory predicates applied (tenant / company, soft-delete, visibility). For
22
+ # directly-scoped models this is a ``.where(...)``; for indirectly-scoped models it
23
+ # is a ``.join(...).where(...)`` chain. It is REQUIRED for correct tenant isolation
24
+ # — the builder never adds it, and it must be applied here (inside ``stmt``) so it
25
+ # also constrains the pagination count. See :func:`querybuilder.pagination.paginate`.
26
+ ScopeFn = Callable[[Select], Select]
27
+
28
+
29
+ async def list_and_paginate(
30
+ db: AsyncSession,
31
+ model: Any,
32
+ q: QueryParams,
33
+ p: PageParams,
34
+ *,
35
+ scope: Optional[ScopeFn] = None,
36
+ options: Optional[Iterable[Any]] = None,
37
+ ) -> Tuple[Sequence[Any], PageMeta]:
38
+ """Turnkey list path: ``build_query`` → caller ``scope`` → eager ``options`` → paginate.
39
+
40
+ Returns ``(rows, meta)``. ``options`` should use ``selectinload`` for to-many
41
+ relations (see :func:`querybuilder.pagination.paginate`).
42
+ """
43
+ stmt = build_query(model, q)
44
+ if scope is not None:
45
+ stmt = scope(stmt)
46
+ if options:
47
+ stmt = stmt.options(*options)
48
+ return await paginate(db, stmt, p)