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.
- {fastapi_sqlalchemy_querykit-0.1.0.dist-info → fastapi_sqlalchemy_querykit-0.2.0.dist-info}/METADATA +3 -1
- {fastapi_sqlalchemy_querykit-0.1.0.dist-info → fastapi_sqlalchemy_querykit-0.2.0.dist-info}/RECORD +9 -6
- querybuilder/__init__.py +25 -1
- querybuilder/config.py +129 -0
- querybuilder/pagination.py +135 -0
- querybuilder/runner.py +48 -0
- {fastapi_sqlalchemy_querykit-0.1.0.dist-info → fastapi_sqlalchemy_querykit-0.2.0.dist-info}/WHEEL +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0.dist-info → fastapi_sqlalchemy_querykit-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0.dist-info → fastapi_sqlalchemy_querykit-0.2.0.dist-info}/licenses/NOTICE +0 -0
{fastapi_sqlalchemy_querykit-0.1.0.dist-info → fastapi_sqlalchemy_querykit-0.2.0.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-sqlalchemy-querykit
|
|
3
|
-
Version: 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
|
|
{fastapi_sqlalchemy_querykit-0.1.0.dist-info → fastapi_sqlalchemy_querykit-0.2.0.dist-info}/RECORD
RENAMED
|
@@ -1,17 +1,20 @@
|
|
|
1
|
-
querybuilder/__init__.py,sha256=
|
|
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.
|
|
14
|
-
fastapi_sqlalchemy_querykit-0.
|
|
15
|
-
fastapi_sqlalchemy_querykit-0.
|
|
16
|
-
fastapi_sqlalchemy_querykit-0.
|
|
17
|
-
fastapi_sqlalchemy_querykit-0.
|
|
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.
|
|
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)
|
{fastapi_sqlalchemy_querykit-0.1.0.dist-info → fastapi_sqlalchemy_querykit-0.2.0.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|