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.
- fastapi_sqlalchemy_querykit-0.1.0.dist-info/METADATA +122 -0
- fastapi_sqlalchemy_querykit-0.1.0.dist-info/RECORD +17 -0
- fastapi_sqlalchemy_querykit-0.1.0.dist-info/WHEEL +4 -0
- fastapi_sqlalchemy_querykit-0.1.0.dist-info/licenses/LICENSE +22 -0
- fastapi_sqlalchemy_querykit-0.1.0.dist-info/licenses/NOTICE +13 -0
- querybuilder/__init__.py +35 -0
- querybuilder/allowlist.py +88 -0
- querybuilder/builder.py +307 -0
- querybuilder/columns.py +64 -0
- querybuilder/dates.py +57 -0
- querybuilder/errors.py +43 -0
- querybuilder/mixin.py +30 -0
- querybuilder/operators.py +294 -0
- querybuilder/params.py +39 -0
- querybuilder/py.typed +0 -0
- querybuilder/resolver.py +100 -0
- querybuilder/validation.py +58 -0
|
@@ -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,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.
|
querybuilder/__init__.py
ADDED
|
@@ -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)
|
querybuilder/builder.py
ADDED
|
@@ -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
|
querybuilder/columns.py
ADDED
|
@@ -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
|
querybuilder/resolver.py
ADDED
|
@@ -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.")
|