fastapi-sqlalchemy-querykit 0.1.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.
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastapi-sqlalchemy-querykit
3
+ Version: 0.1.0
4
+ Summary: Deny-by-default declarative filtering, sorting, and searching for FastAPI + SQLAlchemy 2.0.
5
+ Project-URL: Homepage, https://github.com/Rooler-ai/fastapi-sqlalchemy-querybuilder
6
+ Project-URL: Repository, https://github.com/Rooler-ai/fastapi-sqlalchemy-querybuilder
7
+ Project-URL: Documentation, https://rooler-ai.github.io/fastapi-sqlalchemy-querybuilder/
8
+ Project-URL: Changelog, https://github.com/Rooler-ai/fastapi-sqlalchemy-querybuilder/blob/main/CHANGELOG.md
9
+ Author-email: Yaqupboyev Yoqubboy <yoqubboy.yaqupboyev@rooler.ai>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ License-File: NOTICE
13
+ Keywords: api,fastapi,filter,filtering,pagination,query,querybuilder,rest,search,sort,sqlalchemy
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Framework :: FastAPI
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Database
23
+ Classifier: Topic :: Internet :: WWW/HTTP
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: fastapi>=0.115
27
+ Requires-Dist: sqlalchemy>=2.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: aiosqlite>=0.20; extra == 'dev'
30
+ Requires-Dist: pytest>=8; extra == 'dev'
31
+ Provides-Extra: docs
32
+ Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # fastapi-sqlalchemy-querykit
36
+
37
+ Deny-by-default declarative filtering, sorting, and searching for **FastAPI +
38
+ SQLAlchemy 2.0** (async-safe). A field is filterable, sortable, or searchable only
39
+ if the model explicitly declares it — the opposite of the open-by-default library
40
+ this is derived from.
41
+
42
+ ```bash
43
+ pip install fastapi-sqlalchemy-querykit
44
+ ```
45
+
46
+ > Distribution: `fastapi-sqlalchemy-querykit` · import package: `querybuilder`.
47
+ > Full docs in [`docs/`](docs/index.md).
48
+
49
+ ## Why
50
+
51
+ - **Deny by default.** Undeclared fields return a generic 400, never a silent query.
52
+ - **Declare policy on the model.** A `Queryable` mixin lists the allowed fields once;
53
+ endpoints may narrow but never widen.
54
+ - **Correct relationships.** Filters and search across relationships compile to
55
+ correlated `EXISTS` (`.has()` / `.any()`) — no row fan-out, no `DISTINCT`, to-many
56
+ supported; two paths to the same model never collide. Sorting uses path-keyed joins.
57
+ - **Type-aware & enum-safe.** Operators are validated against the column type; enums
58
+ match by value (Postgres-safe).
59
+ - **Caller-owned base scope.** The builder is purely additive — it never adds or
60
+ inspects your own `WHERE` (tenant, soft-delete, visibility).
61
+
62
+ ## Quick example
63
+
64
+ ```python
65
+ from querybuilder import Queryable, QueryParams, build_query
66
+
67
+ class User(Base, Queryable):
68
+ __filterable__ = frozenset({"status", "username", "age", "organization.name"})
69
+ __sortable__ = frozenset({"created_at", "username"})
70
+ __searchable__ = frozenset({"username", "organization.name"})
71
+
72
+ # inside your data/query layer
73
+ async def list_users(db, params: QueryParams):
74
+ stmt = build_query(User, params) # filters + search + sort
75
+ stmt = stmt.where(User.is_deleted.is_(False)) # caller-owned base scope
76
+ return (await db.execute(stmt)).scalars().all()
77
+ ```
78
+
79
+ ```text
80
+ GET /users?filters={"status":{"$eq":"active"},"organization.name":{"$contains":"acme"}}&sort=created_at:desc&search=jdoe
81
+ ```
82
+
83
+ The filter travels as one JSON object, `{field: {operator: value}}`, with `$and` /
84
+ `$or` for grouping and a dot for relationship traversal (`organization.name`).
85
+
86
+ ## Operators
87
+
88
+ `$eq` `$ne` `$gt` `$gte` `$lt` `$lte` `$in` `$nin` · `$contains` `$ncontains`
89
+ `$startswith` `$endswith` · `$isnull` `$isnotnull` · logical `$and` `$or`.
90
+
91
+ String comparisons are case-insensitive by default (`case_sensitive=true` to opt
92
+ out). Enum filters resolve the operand to the matching member, so you filter by the
93
+ enum value regardless of how SQLAlchemy stores it.
94
+
95
+ ## Endpoint-level narrowing
96
+
97
+ The model declares the maximum; an endpoint may expose a subset (never a superset):
98
+
99
+ ```python
100
+ stmt = build_query(User, params, allowed_filters={"status", "username"})
101
+ ```
102
+
103
+ ## Documentation
104
+
105
+ - [Operator reference](docs/operators.md) — wire format, every operator, the per-type matrix
106
+ - [Guide](docs/guide.md) — `Queryable`, relationships, search, sort, errors, limits, startup validation
107
+ - [FastAPI integration](docs/integration.md)
108
+
109
+ Build the docs site locally: `pip install -e ".[docs]" && mkdocs serve`.
110
+
111
+ ## Development
112
+
113
+ ```bash
114
+ python -m venv .venv && source .venv/bin/activate
115
+ pip install -e ".[dev]"
116
+ pytest
117
+ ```
118
+
119
+ ## License
120
+
121
+ MIT. Derivative work of [fastapi-querybuilder](https://github.com/bhadri01/fastapi-querybuilder)
122
+ by bhadri01 — see `LICENSE` and `NOTICE`.
@@ -0,0 +1,17 @@
1
+ querybuilder/__init__.py,sha256=NIxtKiOIeas6dpnjurihR0lCL2n5xV0J7uLdVqzWq7w,825
2
+ querybuilder/allowlist.py,sha256=OeJRFt2u8oJg6gUO0mz_4mFEVRNunyWlhZzyW1cl-rY,3514
3
+ querybuilder/builder.py,sha256=BCSyRsiI-t44BD9sSx7Simp0qcvBleUjq1995rKF4Q4,11538
4
+ querybuilder/columns.py,sha256=IybmfUho1d-a5CQ7A530a6WyDZ5sjeGwBih6W14tuAc,1986
5
+ querybuilder/dates.py,sha256=CtG1mxAC6NNofe2FVj40Fx3kB1IfbEXSE1BnsRymi08,1994
6
+ querybuilder/errors.py,sha256=o7SfFhEZMZAktr1nSuHkhNajKBcHBjirHpV5y5Sd3pY,1504
7
+ querybuilder/mixin.py,sha256=nvVkAVKubt8pDDbE5xcmIFm64FxL04A1y-IY0Ik4kCA,1185
8
+ querybuilder/operators.py,sha256=0NN9oxZjUvJX02NasTKmSocFC8WoULNnXvmtxw5hwPA,9957
9
+ querybuilder/params.py,sha256=lyFvyFSEA0QU2R8w4mqVITN0Eeda6YYap1aDPm3f3aY,1362
10
+ querybuilder/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ querybuilder/resolver.py,sha256=5RjArTbfrn5bheXmnfHX8GUUqRY08VVsb6-hQdhk_bQ,4009
12
+ 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 bhadri01 (original work: fastapi-querybuilder)
4
+ Copyright (c) 2026 querybuilder contributors (derivative work)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,13 @@
1
+ fastapi-sqlalchemy-querykit
2
+
3
+ This product is a derivative work that adapts engine concepts and the JSON filter
4
+ wire format from:
5
+
6
+ fastapi-querybuilder by bhadri01
7
+ https://github.com/bhadri01/fastapi-querybuilder
8
+ Licensed under the MIT License.
9
+
10
+ Portions Copyright (c) 2025 bhadri01.
11
+
12
+ The full MIT license text (covering both the original and this derivative work)
13
+ is in the LICENSE file.
@@ -0,0 +1,35 @@
1
+ """querybuilder — deny-by-default declarative filtering, sorting, and
2
+ searching for FastAPI + SQLAlchemy 2.0 (async-safe).
3
+
4
+ Derivative work adapting the engine concepts and JSON wire format of
5
+ fastapi-querybuilder (bhadri01, MIT). See NOTICE.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .allowlist import validate_queryable, validate_queryables
11
+ from .builder import build_query
12
+ from .errors import (
13
+ FilterError,
14
+ QueryBuilderError,
15
+ QueryConfigurationError,
16
+ SearchError,
17
+ SortError,
18
+ )
19
+ from .mixin import Queryable
20
+ from .params import QueryParams
21
+
22
+ __all__ = [
23
+ "build_query",
24
+ "Queryable",
25
+ "QueryParams",
26
+ "validate_queryable",
27
+ "validate_queryables",
28
+ "QueryBuilderError",
29
+ "FilterError",
30
+ "SortError",
31
+ "SearchError",
32
+ "QueryConfigurationError",
33
+ ]
34
+
35
+ __version__ = "0.1.0"
@@ -0,0 +1,88 @@
1
+ """Deny-by-default allowlist enforcement and endpoint-level narrowing.
2
+
3
+ The model declares the maximum via the Queryable frozensets. An endpoint may pass
4
+ a subset through ``build_query``'s ``allowed_*`` arguments to expose less, but a
5
+ subset containing a field the model never declared is a configuration error.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Iterable, Optional, Type
11
+
12
+
13
+ def effective_allowlist(model, attr: str, override: Optional[Iterable[str]]) -> frozenset:
14
+ """Resolve the effective allowlist for one dimension.
15
+
16
+ Returns the model's declared set, or — when an endpoint override is given —
17
+ the override, after verifying it does not widen beyond the declaration.
18
+ Raises :class:`QueryConfigurationError` (a programmer error) on widening.
19
+ """
20
+ from .errors import QueryConfigurationError
21
+
22
+ declared = frozenset(getattr(model, attr, frozenset()))
23
+ if override is None:
24
+ return declared
25
+ override_set = frozenset(override)
26
+ widened = override_set - declared
27
+ if widened:
28
+ raise QueryConfigurationError(
29
+ f"Endpoint {attr} allowlist widens beyond the model declaration: "
30
+ f"{sorted(widened)}"
31
+ )
32
+ return override_set
33
+
34
+
35
+ def ensure_allowed(
36
+ field_path: str,
37
+ allowed: frozenset,
38
+ error_cls: Type[Exception],
39
+ message: str,
40
+ ) -> None:
41
+ """Raise ``error_cls(message)`` if ``field_path`` is not allowlisted."""
42
+ if field_path not in allowed:
43
+ raise error_cls(message)
44
+
45
+
46
+ def validate_queryable(model) -> None:
47
+ """Validate a model's Queryable allowlists at startup (spec §8).
48
+
49
+ Every declared path must resolve to a real leaf column — each segment a real
50
+ relationship, the leaf a real column — so typos like ``"organizaton.name"``
51
+ fail at deploy time rather than per request. ``__sortable__`` additionally may
52
+ not cross a to-many relationship (you can't ``ORDER BY`` a to-many); to-many
53
+ paths are fine in ``__filterable__`` / ``__searchable__`` (they compile to
54
+ EXISTS). Raises :class:`QueryConfigurationError` naming the offending path.
55
+
56
+ Call once at startup, e.g. ``validate_queryables(User, Order, ...)``. Run it
57
+ *after* all models are imported (i.e. in app startup, not at a module top
58
+ level): it reads relationship metadata, which requires SQLAlchemy mappers to
59
+ be configured — calling it too early surfaces SQLAlchemy's own error, not a
60
+ :class:`QueryConfigurationError`.
61
+ """
62
+ from .errors import FilterError, QueryConfigurationError
63
+ from .resolver import describe_path
64
+
65
+ name = getattr(model, "__name__", repr(model))
66
+ for attr, allow_to_many in (
67
+ ("__filterable__", True),
68
+ ("__searchable__", True),
69
+ ("__sortable__", False),
70
+ ):
71
+ for path in getattr(model, attr, frozenset()):
72
+ try:
73
+ _, has_to_many = describe_path(model, path.split("."))
74
+ except FilterError:
75
+ raise QueryConfigurationError(
76
+ f"{name}.{attr}: path {path!r} does not resolve to a column."
77
+ )
78
+ if has_to_many and not allow_to_many:
79
+ raise QueryConfigurationError(
80
+ f"{name}.{attr}: path {path!r} crosses a to-many relationship, "
81
+ f"which cannot be sorted."
82
+ )
83
+
84
+
85
+ def validate_queryables(*models) -> None:
86
+ """Validate several models' allowlists — call once at app startup."""
87
+ for model in models:
88
+ validate_queryable(model)
@@ -0,0 +1,307 @@
1
+ """The build pipeline: ``build_query`` turns request params into a SQLAlchemy
2
+ ``Select``, additively.
3
+
4
+ Pipeline order (spec §10):
5
+ 1. Start from ``select(model)``. The caller owns the mandatory base scope
6
+ (e.g. tenant / soft-delete / visibility filters); the builder never adds nor
7
+ inspects it — it ANDs cleanly onto whatever the caller has.
8
+ 2. Filters — deny-by-default allowlist, then a predicate per field.
9
+ 3. Search — OR a single term across the declared searchable set.
10
+ 4. Sort — validated against the sortable set.
11
+
12
+ Relationships in a WHERE position (filters and search) compile to correlated
13
+ EXISTS (``.has()`` for to-one, ``.any()`` for to-many), so they never join or
14
+ fan-out the root. Only sort joins — relationships in ORDER BY use the path-keyed
15
+ LEFT-JOIN resolver, because a column must be projected to be ordered.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from functools import lru_cache
22
+ from typing import Any, Dict, List, Optional, Tuple
23
+
24
+ from sqlalchemy import String, asc, cast, desc, func, or_, select
25
+ from sqlalchemy.sql import Select, and_
26
+
27
+ from .allowlist import effective_allowlist, ensure_allowed
28
+ from .columns import (
29
+ is_boolean_column,
30
+ is_datetime_like_column,
31
+ is_enum_column,
32
+ is_integer_column,
33
+ is_string_column,
34
+ )
35
+ from .errors import FilterError, SortError
36
+ from .operators import (
37
+ LOGICAL_OPERATORS,
38
+ NULL_OPERATORS,
39
+ enum_value_members,
40
+ get_comparison_operators,
41
+ )
42
+ from .resolver import JoinCache, describe_path, resolve_path
43
+ from .validation import validate_operator
44
+
45
+ #: Default bounds on the filter payload (spec §19), overridable per call.
46
+ DEFAULT_MAX_FILTER_BYTES = 8192
47
+ DEFAULT_MAX_FILTER_DEPTH = 10
48
+
49
+
50
+ def build_query(
51
+ model,
52
+ params,
53
+ *,
54
+ allowed_filters: Optional[Any] = None,
55
+ allowed_sorts: Optional[Any] = None,
56
+ allowed_search: Optional[Any] = None,
57
+ max_filter_bytes: int = DEFAULT_MAX_FILTER_BYTES,
58
+ max_filter_depth: int = DEFAULT_MAX_FILTER_DEPTH,
59
+ ) -> Select:
60
+ """Build a ``Select`` for ``model`` from ``params``.
61
+
62
+ ``params`` is any object exposing ``filters`` / ``sort`` / ``search`` and an
63
+ optional ``case_sensitive`` attribute (see :class:`QueryParams`).
64
+
65
+ The ``allowed_*`` arguments optionally narrow the model's declared allowlists
66
+ for this endpoint; each must be a subset of the model declaration.
67
+
68
+ ``max_filter_bytes`` / ``max_filter_depth`` bound the filter payload size and
69
+ its ``$and``/``$or`` nesting depth (spec §19).
70
+ """
71
+ query: Select = select(model)
72
+
73
+ filterable = effective_allowlist(model, "__filterable__", allowed_filters)
74
+ sortable = effective_allowlist(model, "__sortable__", allowed_sorts)
75
+ searchable = effective_allowlist(model, "__searchable__", allowed_search)
76
+
77
+ filters = getattr(params, "filters", None)
78
+ if filters:
79
+ if len(filters.encode("utf-8")) > max_filter_bytes:
80
+ raise FilterError("Filter payload too large.")
81
+ parsed = _parse_filter_json(filters)
82
+ expr = _parse_filters(
83
+ model, parsed, filterable,
84
+ case_sensitive=getattr(params, "case_sensitive", False),
85
+ max_depth=max_filter_depth,
86
+ )
87
+ if expr is not None:
88
+ query = query.where(expr)
89
+
90
+ search = getattr(params, "search", None)
91
+ if search:
92
+ expr = _build_search(model, search, searchable)
93
+ if expr is not None:
94
+ query = query.where(expr)
95
+
96
+ sort = getattr(params, "sort", None)
97
+ if sort:
98
+ query = _apply_sort(
99
+ model, sort, query, sortable,
100
+ case_sensitive=getattr(params, "case_sensitive", False),
101
+ )
102
+
103
+ return query
104
+
105
+
106
+ @lru_cache(maxsize=1024)
107
+ def _parse_filter_json(raw: str) -> Dict:
108
+ """Parse the filter JSON string (cached). Generic 400 on any malformation."""
109
+ try:
110
+ parsed = json.loads(raw)
111
+ except (ValueError, TypeError):
112
+ raise FilterError("Invalid filter format.")
113
+ if not isinstance(parsed, dict):
114
+ raise FilterError("Invalid filter format.")
115
+ return parsed
116
+
117
+
118
+ def _parse_filters(
119
+ model,
120
+ filters: Any,
121
+ filterable: frozenset,
122
+ *,
123
+ case_sensitive: bool,
124
+ depth: int = 0,
125
+ max_depth: int = DEFAULT_MAX_FILTER_DEPTH,
126
+ ) -> Optional[Any]:
127
+ """Recursively build the filter predicate tree.
128
+
129
+ Root-column fields compile to a direct predicate; relationship paths compile
130
+ to correlated EXISTS, so nothing is joined onto the outer query here. ``depth``
131
+ counts ``$and``/``$or`` nesting and is bounded by ``max_depth``.
132
+ """
133
+ if not isinstance(filters, dict):
134
+ raise FilterError("Invalid filter format.")
135
+
136
+ operators = get_comparison_operators(case_sensitive=case_sensitive)
137
+ expressions = []
138
+
139
+ for key, value in filters.items():
140
+ if key in LOGICAL_OPERATORS:
141
+ if depth + 1 > max_depth:
142
+ raise FilterError("Filter nesting too deep.")
143
+ if not isinstance(value, list):
144
+ raise FilterError("Invalid filter format.")
145
+ sub_expressions = [
146
+ sub
147
+ for sub in (
148
+ _parse_filters(
149
+ model, sub_filter, filterable,
150
+ case_sensitive=case_sensitive,
151
+ depth=depth + 1, max_depth=max_depth,
152
+ )
153
+ for sub_filter in value
154
+ )
155
+ if sub is not None
156
+ ]
157
+ if sub_expressions:
158
+ expressions.append(LOGICAL_OPERATORS[key](*sub_expressions))
159
+
160
+ elif isinstance(value, dict):
161
+ ensure_allowed(key, filterable, FilterError, "Unknown or disallowed filter field.")
162
+ parts = key.split(".")
163
+ for operator, operand in value.items():
164
+ expressions.append(
165
+ _field_predicate(model, parts, operator, operand, operators)
166
+ )
167
+ else:
168
+ raise FilterError("Invalid filter format.")
169
+
170
+ return and_(*expressions) if expressions else None
171
+
172
+
173
+ def _field_predicate(model, parts: List[str], operator: str, operand: Any, operators) -> Any:
174
+ """One field predicate: a direct comparison for a root column, or a correlated
175
+ EXISTS chain for a relationship path. Type validation runs on the leaf column."""
176
+ if operator not in operators:
177
+ raise FilterError("Invalid or unsupported operator.")
178
+
179
+ def make_leaf(column):
180
+ validate_operator(column, operator) # type-aware (spec §9), on the leaf
181
+ if operator in NULL_OPERATORS:
182
+ return operators[operator](column)
183
+ return operators[operator](column, operand)
184
+
185
+ leaf, _ = describe_path(model, parts)
186
+ predicate = make_leaf(leaf)
187
+ return predicate if len(parts) == 1 else _wrap_relationship(model, parts, predicate)
188
+
189
+
190
+ def _wrap_relationship(model, parts: List[str], inner: Any) -> Any:
191
+ """Wrap a prebuilt leaf predicate in the ``.has()`` (to-one) / ``.any()``
192
+ (to-many) chain for ``parts[:-1]``, yielding a correlated EXISTS. Auto-selected
193
+ by ``uselist`` — the caller never chooses join vs EXISTS. Shared by filters and
194
+ search; the path is assumed already validated by ``describe_path``."""
195
+ name, rest = parts[0], parts[1:]
196
+ rel_attr = getattr(model, name)
197
+ prop = rel_attr.property
198
+ wrapped = inner if len(rest) == 1 else _wrap_relationship(prop.mapper.class_, rest, inner)
199
+ return rel_attr.any(wrapped) if prop.uselist else rel_attr.has(wrapped)
200
+
201
+
202
+ def _build_search(model, term: str, searchable: frozenset) -> Optional[Any]:
203
+ """OR a single search term across the declared searchable paths.
204
+
205
+ Relationship paths compile to correlated EXISTS (to-many included); root
206
+ columns compile directly. The client supplies only the term, never the field
207
+ list, so there is no per-field allowlist check here.
208
+ """
209
+ expressions = []
210
+ for path in sorted(searchable):
211
+ parts = path.split(".")
212
+ leaf, _ = describe_path(model, parts)
213
+ expr = _search_leaf(leaf, term)
214
+ if expr is None:
215
+ continue
216
+ expressions.append(expr if len(parts) == 1 else _wrap_relationship(model, parts, expr))
217
+ return or_(*expressions) if expressions else None
218
+
219
+
220
+ def _search_leaf(column: Any, term: str):
221
+ """Per-column-type search expression, or None when the term doesn't apply.
222
+
223
+ Enum search matches by VALUE (resolving members whose value contains the term),
224
+ consistent with ``$contains`` filtering — not the stored member name.
225
+ """
226
+ if is_enum_column(column):
227
+ members = enum_value_members(column, term)
228
+ if members is None: # raw value enum (no Python class) -> text match
229
+ return cast(column, String).ilike(f"%{term}%")
230
+ return column.in_(members)
231
+ if is_string_column(column):
232
+ return column.ilike(f"%{term}%")
233
+ if is_integer_column(column):
234
+ return (column == int(term)) if term.isdigit() else None
235
+ if is_boolean_column(column):
236
+ low = term.casefold()
237
+ if low in ("true", "false"):
238
+ return column == (low == "true")
239
+ return None
240
+ return None
241
+
242
+
243
+ def _apply_sort(
244
+ model,
245
+ sort_value: str,
246
+ query: Select,
247
+ sortable: frozenset,
248
+ *,
249
+ case_sensitive: bool,
250
+ ) -> Select:
251
+ joins: JoinCache = {} # path-keyed; reused across multi-clause sorts
252
+ order_expressions = []
253
+ for path_parts, direction in _parse_sort_clauses(sort_value):
254
+ field_path = ".".join(path_parts)
255
+ ensure_allowed(field_path, sortable, SortError, "Unknown or disallowed sort field.")
256
+ column, query = resolve_path(model, path_parts, query, joins)
257
+ expr = _sort_expr(column, case_sensitive=case_sensitive)
258
+ order_expressions.append(asc(expr) if direction == "asc" else desc(expr))
259
+ if order_expressions:
260
+ query = query.order_by(*order_expressions)
261
+ return query
262
+
263
+
264
+ def _parse_sort_clauses(sort_value: str) -> List[Tuple[List[str], str]]:
265
+ """Parse ``"name:asc,organization.name:desc"`` (dot or double-underscore notation)."""
266
+ if not sort_value or not sort_value.strip():
267
+ return []
268
+
269
+ parsed: List[Tuple[List[str], str]] = []
270
+ for clause in (c.strip() for c in sort_value.split(",")):
271
+ if not clause:
272
+ continue
273
+ if ":" in clause:
274
+ raw_field, _, raw_dir = clause.partition(":")
275
+ raw_field = raw_field.strip()
276
+ raw_dir = raw_dir.strip().lower() or "asc"
277
+ else:
278
+ raw_field, raw_dir = clause, "asc"
279
+
280
+ if not raw_field:
281
+ raise SortError("Invalid sort field.")
282
+ if raw_dir not in ("asc", "desc"):
283
+ raise SortError("Invalid sort direction.")
284
+
285
+ path_parts = [p.strip() for p in raw_field.replace("__", ".").split(".")]
286
+ if any(not p for p in path_parts):
287
+ raise SortError("Invalid sort field.")
288
+ parsed.append((path_parts, raw_dir))
289
+
290
+ return parsed
291
+
292
+
293
+ def _sort_expr(column: Any, *, case_sensitive: bool):
294
+ """Sort expression. Real date/datetime columns sort directly; strings sort
295
+ case-insensitively by default; enums are cast to text before ``lower()``.
296
+
297
+ The fork's fragile "string column named ``*_at`` is a timestamp" heuristic is
298
+ intentionally dropped (spec §16): fix the column type instead.
299
+ """
300
+ if is_datetime_like_column(column):
301
+ return column
302
+ if not case_sensitive:
303
+ if is_enum_column(column):
304
+ return func.lower(cast(column, String))
305
+ if is_string_column(column):
306
+ return func.lower(column)
307
+ return column
@@ -0,0 +1,64 @@
1
+ """Column type predicates, centralized so operators, resolver, and builder agree
2
+ on what counts as a string / enum / numeric / boolean / datetime column.
3
+
4
+ These are deliberately the single source of truth: the fork duplicated this logic
5
+ across modules and they drifted.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from decimal import Decimal
11
+ from typing import Any
12
+
13
+ from sqlalchemy import Date, DateTime, Enum, String
14
+
15
+
16
+ def _python_type(column: Any):
17
+ """Best-effort Python type for a column, or None if undefined.
18
+
19
+ ``column.type.python_type`` raises ``NotImplementedError`` for some types, so
20
+ this is guarded.
21
+ """
22
+ col_type = getattr(column, "type", None)
23
+ try:
24
+ return col_type.python_type
25
+ except (NotImplementedError, AttributeError):
26
+ return None
27
+
28
+
29
+ def is_enum_column(column: Any) -> bool:
30
+ return isinstance(getattr(column, "type", None), Enum)
31
+
32
+
33
+ def is_string_column(column: Any) -> bool:
34
+ """True for string columns.
35
+
36
+ NOTE: SQLAlchemy's ``Enum`` subclasses ``String``, so this also returns True
37
+ for enum columns. Callers that must treat enums differently should check
38
+ :func:`is_enum_column` first.
39
+ """
40
+ return isinstance(getattr(column, "type", None), String)
41
+
42
+
43
+ def is_string_like_column(column: Any) -> bool:
44
+ return is_string_column(column) or is_enum_column(column)
45
+
46
+
47
+ def is_datetime_like_column(column: Any) -> bool:
48
+ return isinstance(getattr(column, "type", None), (Date, DateTime))
49
+
50
+
51
+ def is_integer_column(column: Any) -> bool:
52
+ # Identity check keeps Boolean (python_type is bool) out, even though bool is
53
+ # a subclass of int.
54
+ return _python_type(column) is int
55
+
56
+
57
+ def is_boolean_column(column: Any) -> bool:
58
+ return _python_type(column) is bool
59
+
60
+
61
+ def is_numeric_column(column: Any) -> bool:
62
+ # bool is excluded: its python_type is bool, which is not in this set
63
+ # (membership compares by ==, and bool != int as type objects).
64
+ return _python_type(column) in (int, float, Decimal)
querybuilder/dates.py ADDED
@@ -0,0 +1,57 @@
1
+ """Date parsing and date-only range expansion (ported from the fork's utils).
2
+
3
+ A date-only string (``"2024-01-15"``) compared against a DateTime column expands
4
+ to a full-day range, so ``$eq`` on a date matches the whole day rather than only
5
+ midnight. Full datetimes are used as-is.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime, timedelta
11
+ from functools import lru_cache
12
+ from typing import Any, Tuple
13
+
14
+ from sqlalchemy import DateTime
15
+ from sqlalchemy.sql import and_, or_
16
+
17
+ from .errors import FilterError
18
+
19
+ _FORMATS = ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%SZ")
20
+
21
+
22
+ @lru_cache(maxsize=512)
23
+ def parse_datetime(value: str) -> datetime:
24
+ for fmt in _FORMATS:
25
+ try:
26
+ return datetime.strptime(value, fmt)
27
+ except ValueError:
28
+ continue
29
+ raise FilterError("Invalid date value.")
30
+
31
+
32
+ def adjust_date_range(column: Any, value: Any, operator: str) -> Tuple[Any, bool]:
33
+ """Return ``(value_or_expression, is_range)``.
34
+
35
+ For a date-only string on a DateTime column, returns a full-day range
36
+ expression (``is_range=True``) for ``$eq``/``$ne`` or a shifted boundary for
37
+ the ordering operators. Otherwise returns the parsed/raw value unchanged.
38
+ """
39
+ if not isinstance(getattr(column, "type", None), DateTime) or not isinstance(value, str):
40
+ return value, False
41
+
42
+ dt = parse_datetime(value)
43
+ is_date_only = ("T" not in value) and (" " not in value)
44
+ if is_date_only:
45
+ if operator == "$eq":
46
+ return and_(column >= dt, column < dt + timedelta(days=1)), True
47
+ if operator == "$ne":
48
+ return or_(column < dt, column >= dt + timedelta(days=1)), True
49
+ if operator == "$gt":
50
+ return dt + timedelta(days=1), False
51
+ if operator == "$gte":
52
+ return dt, False
53
+ if operator == "$lt":
54
+ return dt, False
55
+ if operator == "$lte":
56
+ return dt + timedelta(days=1), False
57
+ return dt, False
querybuilder/errors.py ADDED
@@ -0,0 +1,43 @@
1
+ """Exception types for querybuilder.
2
+
3
+ Client-input failures subclass :class:`fastapi.HTTPException` so FastAPI renders
4
+ them as HTTP 400 with a generic, schema-free message — never a model class name
5
+ or a column list (see spec §19). Programmer mistakes (e.g. an endpoint widening
6
+ past the model allowlist, or a malformed allowlist entry) raise
7
+ :class:`QueryConfigurationError`, which is *not* an HTTPException because it is a
8
+ bug to fix at deploy time, not a client error to report.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from fastapi import HTTPException
14
+
15
+
16
+ class QueryBuilderError(HTTPException):
17
+ """Base for client-facing query errors. Always HTTP 400, always generic."""
18
+
19
+ def __init__(self, detail: str = "Invalid query.") -> None:
20
+ super().__init__(status_code=400, detail=detail)
21
+
22
+
23
+ class FilterError(QueryBuilderError):
24
+ def __init__(self, detail: str = "Invalid filter.") -> None:
25
+ super().__init__(detail=detail)
26
+
27
+
28
+ class SortError(QueryBuilderError):
29
+ def __init__(self, detail: str = "Invalid sort.") -> None:
30
+ super().__init__(detail=detail)
31
+
32
+
33
+ class SearchError(QueryBuilderError):
34
+ def __init__(self, detail: str = "Invalid search.") -> None:
35
+ super().__init__(detail=detail)
36
+
37
+
38
+ class QueryConfigurationError(Exception):
39
+ """Raised for developer misconfiguration (not a client error).
40
+
41
+ Surfaces as an unhandled 500 in production, which is correct: it means the
42
+ code declared an allowlist or path that the library cannot honor.
43
+ """
querybuilder/mixin.py ADDED
@@ -0,0 +1,30 @@
1
+ """The Queryable mixin: declarative, deny-by-default policy declared on the model.
2
+
3
+ This is the Pythonic equivalent of a Spatie/Eloquent trait — the model declares
4
+ the maximum set of fields that may ever be filtered, sorted, or searched. An
5
+ endpoint may narrow these further (see ``build_query``'s ``allowed_*`` arguments)
6
+ but can never widen beyond them.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ class Queryable:
13
+ """Declarative allowlist for query building. Deny-by-default.
14
+
15
+ A field path is filterable / sortable / searchable **only** if it appears in
16
+ the matching frozenset. Relationship paths use dotted notation, e.g.
17
+ ``"organization.name"``. The three sets are independent: a field may be
18
+ sortable without being filterable.
19
+
20
+ Example::
21
+
22
+ class User(Base, Queryable):
23
+ __filterable__ = frozenset({"status", "username", "organization.name"})
24
+ __sortable__ = frozenset({"created_at", "username"})
25
+ __searchable__ = frozenset({"username", "organization.name"})
26
+ """
27
+
28
+ __filterable__: frozenset[str] = frozenset()
29
+ __sortable__: frozenset[str] = frozenset()
30
+ __searchable__: frozenset[str] = frozenset()
@@ -0,0 +1,294 @@
1
+ """Operator dispatch table.
2
+
3
+ Differences from the fork (per spec §6):
4
+ - ``$isempty``/``$isnotempty`` renamed to ``$isnull``/``$isnotnull``.
5
+ - Added ``$nin`` (not-in); dropped the ``$isanyof`` alias.
6
+ - Removed the ``$eq: "" -> IS NULL`` transform — empty string and NULL are
7
+ different; callers use ``$isnull`` explicitly.
8
+
9
+ Enum handling (refines spec §18): SQLAlchemy persists the enum member *name* by
10
+ default, so the fork's "cast to text and compare to the operand" silently returned
11
+ zero rows whenever ``name.lower() != value``. Instead, for enum columns that carry
12
+ a Python enum class we resolve the operand to the matching member(s) and compare
13
+ natively (``col.in_(members)``) — correct regardless of whether the database stores
14
+ names or values. Equality matches the operand against member value *or* name;
15
+ substring operators match the member value. Raw ``Enum("a", "b")`` columns with no
16
+ Python class fall back to the text comparison (there the stored text *is* the value).
17
+
18
+ Per-type operator *validation* (rejecting e.g. ``$contains`` on an integer) lives
19
+ in ``validation.py``; this module only defines the operators themselves.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any, Callable, Dict, List
25
+
26
+ from sqlalchemy import String, and_, cast, func, or_
27
+ from sqlalchemy.sql import operators as sa_operators
28
+
29
+ from .columns import is_enum_column, is_string_like_column
30
+ from .dates import adjust_date_range
31
+ from .errors import FilterError
32
+
33
+ LOGICAL_OPERATORS: Dict[str, Callable] = {"$and": and_, "$or": or_}
34
+
35
+ #: Operators that take only a column (the JSON operand is ignored).
36
+ NULL_OPERATORS = frozenset({"$isnull", "$isnotnull"})
37
+
38
+
39
+ # --- enum resolution --------------------------------------------------------
40
+
41
+ def _enum_class(column):
42
+ """The Python enum class behind an Enum column, or None (raw value enums)."""
43
+ return getattr(getattr(column, "type", None), "enum_class", None)
44
+
45
+
46
+ def _member_value(member) -> str:
47
+ value = member.value
48
+ return value.casefold() if isinstance(value, str) else str(value).casefold()
49
+
50
+
51
+ def _enum_members(enum_class, value: Any, match: str) -> List[Any]:
52
+ """Members of ``enum_class`` matching ``value`` under ``match``.
53
+
54
+ ``exact`` matches the operand against member value *or* name; the substring
55
+ matches (``contains``/``startswith``/``endswith``) match the member value.
56
+ An operand that is already a member is taken as-is.
57
+ """
58
+ if isinstance(value, enum_class):
59
+ return [value]
60
+ target = (value if isinstance(value, str) else str(value)).casefold()
61
+ out = []
62
+ for member in enum_class:
63
+ mv = _member_value(member)
64
+ if match == "exact":
65
+ ok = target == mv or target == member.name.casefold()
66
+ elif match == "contains":
67
+ ok = target in mv
68
+ elif match == "startswith":
69
+ ok = mv.startswith(target)
70
+ elif match == "endswith":
71
+ ok = mv.endswith(target)
72
+ else: # pragma: no cover - guarded by callers
73
+ ok = False
74
+ if ok:
75
+ out.append(member)
76
+ return out
77
+
78
+
79
+ def _enum_match(enum_class, values: List[Any], match: str) -> List[Any]:
80
+ """Resolve a list of operands to a de-duplicated member list."""
81
+ seen = set()
82
+ result = []
83
+ for value in values:
84
+ for member in _enum_members(enum_class, value, match):
85
+ if member not in seen:
86
+ seen.add(member)
87
+ result.append(member)
88
+ return result
89
+
90
+
91
+ def enum_value_members(column, term: str):
92
+ """Members of an enum column whose ``.value`` contains ``term`` (case-insensitive),
93
+ or ``None`` if the column's Enum has no Python class (a raw value enum). Lets
94
+ value-based search reuse the same member resolution as ``$contains`` filtering."""
95
+ enum_class = _enum_class(column)
96
+ if enum_class is None:
97
+ return None
98
+ return _enum_match(enum_class, [term], "contains")
99
+
100
+
101
+ # --- string helpers ---------------------------------------------------------
102
+
103
+ def _casefold_string_like(value: Any):
104
+ """Casefold plain strings and enum-member ``.value`` strings; else None."""
105
+ if isinstance(value, str):
106
+ return value.casefold()
107
+ enum_value = getattr(value, "value", None)
108
+ if isinstance(enum_value, str):
109
+ return enum_value.casefold()
110
+ return None
111
+
112
+
113
+ def _ci_expr(column):
114
+ """Case-insensitive comparison target (enum-safe, for raw value enums)."""
115
+ if is_enum_column(column):
116
+ return func.lower(cast(column, String))
117
+ return func.lower(column)
118
+
119
+
120
+ def _ilike_target(column):
121
+ """ILIKE target (enums cast to text so Postgres doesn't choke)."""
122
+ if is_enum_column(column):
123
+ return cast(column, String)
124
+ return column
125
+
126
+
127
+ def _require_list(value: Any) -> None:
128
+ if not isinstance(value, (list, tuple)):
129
+ raise FilterError("This operator expects a list value.")
130
+
131
+
132
+ def _normalized_list(column, value):
133
+ return [
134
+ (_casefold_string_like(v) if _casefold_string_like(v) is not None else v)
135
+ for v in value
136
+ ]
137
+
138
+
139
+ # --- comparison -------------------------------------------------------------
140
+
141
+ def _eq(column, value):
142
+ enum_class = _enum_class(column)
143
+ if enum_class is not None:
144
+ return column.in_(_enum_match(enum_class, [value], "exact"))
145
+ adjusted, is_range = adjust_date_range(column, value, "$eq")
146
+ return adjusted if is_range else column == adjusted
147
+
148
+
149
+ def _eq_ci(column, value):
150
+ enum_class = _enum_class(column)
151
+ if enum_class is not None:
152
+ return column.in_(_enum_match(enum_class, [value], "exact"))
153
+ norm = _casefold_string_like(value)
154
+ if norm is not None and is_string_like_column(column):
155
+ return _ci_expr(column) == norm
156
+ adjusted, is_range = adjust_date_range(column, value, "$eq")
157
+ return adjusted if is_range else column == adjusted
158
+
159
+
160
+ def _ne(column, value):
161
+ enum_class = _enum_class(column)
162
+ if enum_class is not None:
163
+ return column.not_in(_enum_match(enum_class, [value], "exact"))
164
+ adjusted, is_range = adjust_date_range(column, value, "$ne")
165
+ return adjusted if is_range else column != adjusted
166
+
167
+
168
+ def _ne_ci(column, value):
169
+ enum_class = _enum_class(column)
170
+ if enum_class is not None:
171
+ return column.not_in(_enum_match(enum_class, [value], "exact"))
172
+ norm = _casefold_string_like(value)
173
+ if norm is not None and is_string_like_column(column):
174
+ return _ci_expr(column) != norm
175
+ adjusted, is_range = adjust_date_range(column, value, "$ne")
176
+ return adjusted if is_range else column != adjusted
177
+
178
+
179
+ def _gt(column, value):
180
+ return sa_operators.gt(column, adjust_date_range(column, value, "$gt")[0])
181
+
182
+
183
+ def _gte(column, value):
184
+ return sa_operators.ge(column, adjust_date_range(column, value, "$gte")[0])
185
+
186
+
187
+ def _lt(column, value):
188
+ return sa_operators.lt(column, adjust_date_range(column, value, "$lt")[0])
189
+
190
+
191
+ def _lte(column, value):
192
+ return sa_operators.le(column, adjust_date_range(column, value, "$lte")[0])
193
+
194
+
195
+ def _in(column, value):
196
+ _require_list(value)
197
+ enum_class = _enum_class(column)
198
+ if enum_class is not None:
199
+ return column.in_(_enum_match(enum_class, list(value), "exact"))
200
+ return column.in_(value)
201
+
202
+
203
+ def _in_ci(column, value):
204
+ _require_list(value)
205
+ enum_class = _enum_class(column)
206
+ if enum_class is not None:
207
+ return column.in_(_enum_match(enum_class, list(value), "exact"))
208
+ if is_string_like_column(column):
209
+ return _ci_expr(column).in_(_normalized_list(column, value))
210
+ return column.in_(value)
211
+
212
+
213
+ def _nin(column, value):
214
+ _require_list(value)
215
+ enum_class = _enum_class(column)
216
+ if enum_class is not None:
217
+ return column.not_in(_enum_match(enum_class, list(value), "exact"))
218
+ return column.not_in(value)
219
+
220
+
221
+ def _nin_ci(column, value):
222
+ _require_list(value)
223
+ enum_class = _enum_class(column)
224
+ if enum_class is not None:
225
+ return column.not_in(_enum_match(enum_class, list(value), "exact"))
226
+ if is_string_like_column(column):
227
+ return _ci_expr(column).not_in(_normalized_list(column, value))
228
+ return column.not_in(value)
229
+
230
+
231
+ # --- string -----------------------------------------------------------------
232
+
233
+ def _contains(column, value):
234
+ enum_class = _enum_class(column)
235
+ if enum_class is not None:
236
+ return column.in_(_enum_match(enum_class, [value], "contains"))
237
+ return _ilike_target(column).ilike(f"%{value}%")
238
+
239
+
240
+ def _ncontains(column, value):
241
+ enum_class = _enum_class(column)
242
+ if enum_class is not None:
243
+ return column.not_in(_enum_match(enum_class, [value], "contains"))
244
+ return ~_ilike_target(column).ilike(f"%{value}%")
245
+
246
+
247
+ def _startswith(column, value):
248
+ enum_class = _enum_class(column)
249
+ if enum_class is not None:
250
+ return column.in_(_enum_match(enum_class, [value], "startswith"))
251
+ return _ilike_target(column).ilike(f"{value}%")
252
+
253
+
254
+ def _endswith(column, value):
255
+ enum_class = _enum_class(column)
256
+ if enum_class is not None:
257
+ return column.in_(_enum_match(enum_class, [value], "endswith"))
258
+ return _ilike_target(column).ilike(f"%{value}")
259
+
260
+
261
+ # --- null -------------------------------------------------------------------
262
+
263
+ def _isnull(column):
264
+ return column.is_(None)
265
+
266
+
267
+ def _isnotnull(column):
268
+ return column.is_not(None)
269
+
270
+
271
+ def get_comparison_operators(case_sensitive: bool = False) -> Dict[str, Callable]:
272
+ eq = _eq if case_sensitive else _eq_ci
273
+ ne = _ne if case_sensitive else _ne_ci
274
+ in_ = _in if case_sensitive else _in_ci
275
+ nin = _nin if case_sensitive else _nin_ci
276
+ return {
277
+ "$eq": eq,
278
+ "$ne": ne,
279
+ "$gt": _gt,
280
+ "$gte": _gte,
281
+ "$lt": _lt,
282
+ "$lte": _lte,
283
+ "$in": in_,
284
+ "$nin": nin,
285
+ "$contains": _contains,
286
+ "$ncontains": _ncontains,
287
+ "$startswith": _startswith,
288
+ "$endswith": _endswith,
289
+ "$isnull": _isnull,
290
+ "$isnotnull": _isnotnull,
291
+ }
292
+
293
+
294
+ COMPARISON_OPERATORS = get_comparison_operators(case_sensitive=False)
querybuilder/params.py ADDED
@@ -0,0 +1,39 @@
1
+ """FastAPI-injectable query parameters.
2
+
3
+ Routers depend on :class:`QueryParams` and pass it through to ``build_query`` in
4
+ your data/query layer (keep the builder out of route handlers).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional
10
+
11
+ from fastapi import Query
12
+
13
+
14
+ class QueryParams:
15
+ def __init__(
16
+ self,
17
+ filters: Optional[str] = Query(
18
+ None,
19
+ description='JSON filter object, e.g. {"status":{"$eq":"active"},"organization.name":{"$contains":"acme"}}.',
20
+ ),
21
+ sort: Optional[str] = Query(
22
+ None,
23
+ description="Comma-separated sort clauses, e.g. created_at:desc,username:asc.",
24
+ ),
25
+ search: Optional[str] = Query(
26
+ None,
27
+ description="Search term applied across the model's declared searchable fields.",
28
+ ),
29
+ case_sensitive: bool = Query(
30
+ False,
31
+ description="When true, string filters and sorting are case-sensitive (default: case-insensitive).",
32
+ ),
33
+ ):
34
+ self.filters = filters
35
+ self.sort = sort
36
+ self.search = search
37
+ # Guard direct (non-FastAPI) instantiation, where an unset bool default
38
+ # would arrive as a Query() FieldInfo rather than a real bool.
39
+ self.case_sensitive = case_sensitive if isinstance(case_sensitive, bool) else False
querybuilder/py.typed ADDED
File without changes
@@ -0,0 +1,100 @@
1
+ """Path-aware relationship resolver, used by **sort** (relationships in ORDER BY).
2
+
3
+ The join cache is keyed by ``(relationship_path_tuple, target_model)`` rather than
4
+ by target model class alone, so two distinct sort paths to the same model (e.g.
5
+ ``home_address.city`` and ``work_address.city``, both to ``Address``) get two
6
+ distinct aliases instead of colliding into one.
7
+
8
+ To-one relationships use a path-aliased LEFT OUTER JOIN (so sorting never drops
9
+ rows). To-many relationships are rejected — you can't ``ORDER BY`` a to-many
10
+ column. Filters and search compile relationships to correlated EXISTS instead
11
+ (see ``builder``); ``describe_path`` below is the shared no-join path walker they
12
+ use to find the leaf column.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any, Dict, List, Tuple
18
+
19
+ from sqlalchemy.orm import RelationshipProperty, aliased
20
+ from sqlalchemy.sql import Select
21
+
22
+ from .errors import FilterError, QueryConfigurationError
23
+
24
+ #: (relationship path tuple, target model) -> alias
25
+ JoinCache = Dict[Tuple[Tuple[str, ...], type], Any]
26
+
27
+
28
+ def resolve_path(
29
+ model: Any,
30
+ path_parts: List[str],
31
+ query: Select,
32
+ joins: JoinCache,
33
+ ) -> Tuple[Any, Select]:
34
+ """Resolve a field path to ``(column, query)``, adding path-keyed joins.
35
+
36
+ ``path_parts`` is the already-split path, e.g. ``["organization", "name"]``. The
37
+ final segment must be a column; every earlier segment must be a to-one
38
+ relationship.
39
+ """
40
+ current = model
41
+ rel_path: List[str] = []
42
+
43
+ for index, attr in enumerate(path_parts):
44
+ candidate = getattr(current, attr, None)
45
+ prop = getattr(candidate, "property", None)
46
+
47
+ if isinstance(prop, RelationshipProperty):
48
+ if prop.uselist:
49
+ # You can't ORDER BY a to-many column, and a plain join would
50
+ # multiply root rows. (Filters/search handle to-many via EXISTS.)
51
+ raise QueryConfigurationError(
52
+ "To-many relationship paths cannot be sorted."
53
+ )
54
+ related = prop.mapper.class_
55
+ rel_path.append(attr)
56
+ key = (tuple(rel_path), related)
57
+ if key not in joins:
58
+ alias = aliased(related)
59
+ joins[key] = alias
60
+ query = query.outerjoin(candidate.of_type(alias))
61
+ current = joins[key]
62
+ continue
63
+
64
+ # Not a relationship: must be the terminal column.
65
+ if index != len(path_parts) - 1:
66
+ raise FilterError("Unknown or disallowed field.")
67
+ if candidate is None:
68
+ raise FilterError("Unknown or disallowed field.")
69
+ return candidate, query
70
+
71
+ # Path ended on a relationship rather than a column.
72
+ raise FilterError("Unknown or disallowed field.")
73
+
74
+
75
+ def describe_path(model: Any, path_parts: List[str]) -> Tuple[Any, bool]:
76
+ """Walk a dotted path WITHOUT joining; return ``(leaf_column, has_to_many)``.
77
+
78
+ ``has_to_many`` is True if any traversed relationship is to-many. Used by the
79
+ filter/search EXISTS builders (to find the leaf column) and by
80
+ ``validate_queryable``. Raises :class:`FilterError` if a segment is missing, an
81
+ intermediate segment is not a relationship, or the path does not end on a column.
82
+ """
83
+ current = model
84
+ has_to_many = False
85
+ for index, attr in enumerate(path_parts):
86
+ candidate = getattr(current, attr, None)
87
+ prop = getattr(candidate, "property", None)
88
+ is_last = index == len(path_parts) - 1
89
+
90
+ if isinstance(prop, RelationshipProperty):
91
+ if is_last: # path ends on a relationship, not a column
92
+ raise FilterError("Unknown or disallowed field.")
93
+ has_to_many = has_to_many or bool(prop.uselist)
94
+ current = prop.mapper.class_
95
+ else:
96
+ if not is_last or candidate is None:
97
+ raise FilterError("Unknown or disallowed field.")
98
+ return candidate, has_to_many
99
+
100
+ raise FilterError("Unknown or disallowed field.")
@@ -0,0 +1,58 @@
1
+ """Type-aware operator validation (spec §9).
2
+
3
+ After a field passes the allowlist and resolves to a column, its operator is
4
+ checked against the column's type. This is the second safety layer: even an
5
+ allowlisted column cannot be probed with an operator that makes no sense for its
6
+ type (e.g. ``$contains`` on an integer, or ordering on an enum), which closes the
7
+ blind-enumeration surface.
8
+
9
+ ``$isnull`` / ``$isnotnull`` are allowed on every type — a column reached through a
10
+ LEFT JOIN is effectively nullable in the result (the row may have no related
11
+ record), so "nullable only" would wrongly reject valid queries.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ from .columns import (
19
+ is_boolean_column,
20
+ is_datetime_like_column,
21
+ is_enum_column,
22
+ is_numeric_column,
23
+ is_string_column,
24
+ )
25
+ from .errors import FilterError
26
+
27
+ _EQUALITY = frozenset({"$eq", "$ne", "$in", "$nin"})
28
+ _NULL = frozenset({"$isnull", "$isnotnull"})
29
+ _SUBSTRING = frozenset({"$contains", "$ncontains", "$startswith", "$endswith"})
30
+ _ORDERING = frozenset({"$gt", "$gte", "$lt", "$lte"})
31
+
32
+ _STRING_OPS = _EQUALITY | _SUBSTRING | _NULL
33
+ _NUMERIC_OPS = _EQUALITY | _ORDERING | _NULL
34
+ _DATETIME_OPS = _EQUALITY | _ORDERING | _NULL
35
+ _BOOLEAN_OPS = frozenset({"$eq", "$ne"}) | _NULL
36
+ #: Conservative default for column types we don't specifically recognize.
37
+ _OTHER_OPS = _EQUALITY | _NULL
38
+
39
+
40
+ def allowed_operators_for(column: Any) -> frozenset:
41
+ """The operators permitted for a column, by its SQLAlchemy type."""
42
+ # Enum subclasses String, so enum and string share the same set; checking
43
+ # either first is fine.
44
+ if is_enum_column(column) or is_string_column(column):
45
+ return _STRING_OPS
46
+ if is_boolean_column(column):
47
+ return _BOOLEAN_OPS
48
+ if is_numeric_column(column):
49
+ return _NUMERIC_OPS
50
+ if is_datetime_like_column(column):
51
+ return _DATETIME_OPS
52
+ return _OTHER_OPS
53
+
54
+
55
+ def validate_operator(column: Any, operator: str) -> None:
56
+ """Raise a generic 400 if ``operator`` is not valid for ``column``'s type."""
57
+ if operator not in allowed_operators_for(column):
58
+ raise FilterError("Operator not allowed for this field.")