fastapi-sqlalchemy-querykit 0.1.0__tar.gz
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/.github/workflows/release.yml +49 -0
- fastapi_sqlalchemy_querykit-0.1.0/.gitignore +27 -0
- fastapi_sqlalchemy_querykit-0.1.0/CHANGELOG.md +39 -0
- fastapi_sqlalchemy_querykit-0.1.0/LICENSE +22 -0
- fastapi_sqlalchemy_querykit-0.1.0/NOTICE +13 -0
- fastapi_sqlalchemy_querykit-0.1.0/PKG-INFO +122 -0
- fastapi_sqlalchemy_querykit-0.1.0/README.md +88 -0
- fastapi_sqlalchemy_querykit-0.1.0/docs/guide.md +132 -0
- fastapi_sqlalchemy_querykit-0.1.0/docs/index.md +68 -0
- fastapi_sqlalchemy_querykit-0.1.0/docs/integration.md +82 -0
- fastapi_sqlalchemy_querykit-0.1.0/docs/operators.md +114 -0
- fastapi_sqlalchemy_querykit-0.1.0/mkdocs.yml +37 -0
- fastapi_sqlalchemy_querykit-0.1.0/pyproject.toml +62 -0
- fastapi_sqlalchemy_querykit-0.1.0/querybuilder/__init__.py +35 -0
- fastapi_sqlalchemy_querykit-0.1.0/querybuilder/allowlist.py +88 -0
- fastapi_sqlalchemy_querykit-0.1.0/querybuilder/builder.py +307 -0
- fastapi_sqlalchemy_querykit-0.1.0/querybuilder/columns.py +64 -0
- fastapi_sqlalchemy_querykit-0.1.0/querybuilder/dates.py +57 -0
- fastapi_sqlalchemy_querykit-0.1.0/querybuilder/errors.py +43 -0
- fastapi_sqlalchemy_querykit-0.1.0/querybuilder/mixin.py +30 -0
- fastapi_sqlalchemy_querykit-0.1.0/querybuilder/operators.py +294 -0
- fastapi_sqlalchemy_querykit-0.1.0/querybuilder/params.py +39 -0
- fastapi_sqlalchemy_querykit-0.1.0/querybuilder/py.typed +0 -0
- fastapi_sqlalchemy_querykit-0.1.0/querybuilder/resolver.py +100 -0
- fastapi_sqlalchemy_querykit-0.1.0/querybuilder/validation.py +58 -0
- fastapi_sqlalchemy_querykit-0.1.0/tests/models.py +121 -0
- fastapi_sqlalchemy_querykit-0.1.0/tests/test_allowlist.py +68 -0
- fastapi_sqlalchemy_querykit-0.1.0/tests/test_execution.py +213 -0
- fastapi_sqlalchemy_querykit-0.1.0/tests/test_filters.py +170 -0
- fastapi_sqlalchemy_querykit-0.1.0/tests/test_operators.py +100 -0
- fastapi_sqlalchemy_querykit-0.1.0/tests/test_params.py +17 -0
- fastapi_sqlalchemy_querykit-0.1.0/tests/test_resolver.py +43 -0
- fastapi_sqlalchemy_querykit-0.1.0/tests/test_search.py +74 -0
- fastapi_sqlalchemy_querykit-0.1.0/tests/test_sort.py +66 -0
- fastapi_sqlalchemy_querykit-0.1.0/tests/test_startup.py +69 -0
- fastapi_sqlalchemy_querykit-0.1.0/tests/test_validation.py +122 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Builds on every version tag and publishes to PyPI via Trusted Publishing.
|
|
4
|
+
#
|
|
5
|
+
# One-time setup before the first release:
|
|
6
|
+
# 1. Push this repo to GitHub.
|
|
7
|
+
# 2. On PyPI, create a "pending publisher" (Account -> Publishing) for project
|
|
8
|
+
# `fastapi-sqlalchemy-querykit` (the PyPI project), repo = your GitHub repo name:
|
|
9
|
+
# owner = <your GitHub org/user>, repo = <repo name>,
|
|
10
|
+
# workflow = release.yml, environment = pypi
|
|
11
|
+
# 3. Tag a release: git tag v0.1.0 && git push origin v0.1.0
|
|
12
|
+
# No API token is stored — OIDC handles auth.
|
|
13
|
+
|
|
14
|
+
on:
|
|
15
|
+
push:
|
|
16
|
+
tags:
|
|
17
|
+
- "v*"
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
build:
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
- uses: actions/setup-python@v5
|
|
25
|
+
with:
|
|
26
|
+
python-version: "3.12"
|
|
27
|
+
- name: Build sdist + wheel
|
|
28
|
+
run: |
|
|
29
|
+
python -m pip install --upgrade build twine
|
|
30
|
+
python -m build
|
|
31
|
+
python -m twine check dist/*
|
|
32
|
+
- uses: actions/upload-artifact@v4
|
|
33
|
+
with:
|
|
34
|
+
name: dist
|
|
35
|
+
path: dist/
|
|
36
|
+
|
|
37
|
+
publish:
|
|
38
|
+
needs: build
|
|
39
|
+
runs-on: ubuntu-latest
|
|
40
|
+
environment: pypi
|
|
41
|
+
permissions:
|
|
42
|
+
id-token: write # required for PyPI Trusted Publishing (OIDC)
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/download-artifact@v4
|
|
45
|
+
with:
|
|
46
|
+
name: dist
|
|
47
|
+
path: dist/
|
|
48
|
+
- name: Publish to PyPI
|
|
49
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
|
|
9
|
+
# Virtualenvs
|
|
10
|
+
.venv/
|
|
11
|
+
venv/
|
|
12
|
+
env/
|
|
13
|
+
|
|
14
|
+
# Docs site (mkdocs output)
|
|
15
|
+
site/
|
|
16
|
+
|
|
17
|
+
# Test / coverage
|
|
18
|
+
.pytest_cache/
|
|
19
|
+
.coverage
|
|
20
|
+
htmlcov/
|
|
21
|
+
.mypy_cache/
|
|
22
|
+
.ruff_cache/
|
|
23
|
+
|
|
24
|
+
# Editors / OS
|
|
25
|
+
.vscode/
|
|
26
|
+
.idea/
|
|
27
|
+
.DS_Store
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/), and the project aims to follow
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-06-02
|
|
8
|
+
|
|
9
|
+
First public release. A deny-by-default query builder for FastAPI + SQLAlchemy 2.0,
|
|
10
|
+
derived from [fastapi-querybuilder](https://github.com/bhadri01/fastapi-querybuilder)
|
|
11
|
+
(bhadri01, MIT) and hardened throughout.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- `Queryable` mixin with deny-by-default `__filterable__` / `__sortable__` /
|
|
15
|
+
`__searchable__` allowlists, and endpoint-level narrowing via `build_query`'s
|
|
16
|
+
`allowed_*` arguments.
|
|
17
|
+
- Filter operators: `$eq` `$ne` `$gt` `$gte` `$lt` `$lte` `$in` `$nin`,
|
|
18
|
+
`$contains` `$ncontains` `$startswith` `$endswith`, `$isnull` `$isnotnull`, and
|
|
19
|
+
logical `$and` / `$or`.
|
|
20
|
+
- Type-aware operator validation (per-column-type matrix) and enum matching by
|
|
21
|
+
value (Postgres-safe), case-insensitive string comparison by default with a
|
|
22
|
+
`case_sensitive` escape hatch, and date-only range expansion.
|
|
23
|
+
- Correlated `EXISTS` for relationship filters and search (`.has()` to-one,
|
|
24
|
+
`.any()` to-many, auto-selected by `uselist`) — no fan-out, no `DISTINCT`,
|
|
25
|
+
to-many supported; path-keyed LEFT JOIN for relationship sorting.
|
|
26
|
+
- Search across the declared searchable set (root + relationship paths); sort with
|
|
27
|
+
case-insensitive / enum-aware / datetime-aware ordering.
|
|
28
|
+
- `validate_queryable` / `validate_queryables` for startup allowlist validation.
|
|
29
|
+
- Filter payload size and `$and`/`$or` depth limits (`max_filter_bytes`,
|
|
30
|
+
`max_filter_depth`).
|
|
31
|
+
- Generic, schema-free `400` errors; `py.typed`. Runtime dependencies limited to
|
|
32
|
+
`fastapi` and `sqlalchemy`.
|
|
33
|
+
|
|
34
|
+
### Notably different from the fork
|
|
35
|
+
- Deny-by-default instead of open-by-default.
|
|
36
|
+
- Path-keyed relationship resolution (two paths to the same model no longer
|
|
37
|
+
collide); correlated `EXISTS` for to-many instead of fan-out joins.
|
|
38
|
+
- Caller-owned base scope (no hardcoded soft-delete); generic non-leaking errors.
|
|
39
|
+
- Removed the `$eq: "" → IS NULL` transform, `$isanyof`, and `$isempty`/`$isnotempty`.
|
|
@@ -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,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,88 @@
|
|
|
1
|
+
# fastapi-sqlalchemy-querykit
|
|
2
|
+
|
|
3
|
+
Deny-by-default declarative filtering, sorting, and searching for **FastAPI +
|
|
4
|
+
SQLAlchemy 2.0** (async-safe). A field is filterable, sortable, or searchable only
|
|
5
|
+
if the model explicitly declares it — the opposite of the open-by-default library
|
|
6
|
+
this is derived from.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install fastapi-sqlalchemy-querykit
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
> Distribution: `fastapi-sqlalchemy-querykit` · import package: `querybuilder`.
|
|
13
|
+
> Full docs in [`docs/`](docs/index.md).
|
|
14
|
+
|
|
15
|
+
## Why
|
|
16
|
+
|
|
17
|
+
- **Deny by default.** Undeclared fields return a generic 400, never a silent query.
|
|
18
|
+
- **Declare policy on the model.** A `Queryable` mixin lists the allowed fields once;
|
|
19
|
+
endpoints may narrow but never widen.
|
|
20
|
+
- **Correct relationships.** Filters and search across relationships compile to
|
|
21
|
+
correlated `EXISTS` (`.has()` / `.any()`) — no row fan-out, no `DISTINCT`, to-many
|
|
22
|
+
supported; two paths to the same model never collide. Sorting uses path-keyed joins.
|
|
23
|
+
- **Type-aware & enum-safe.** Operators are validated against the column type; enums
|
|
24
|
+
match by value (Postgres-safe).
|
|
25
|
+
- **Caller-owned base scope.** The builder is purely additive — it never adds or
|
|
26
|
+
inspects your own `WHERE` (tenant, soft-delete, visibility).
|
|
27
|
+
|
|
28
|
+
## Quick example
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from querybuilder import Queryable, QueryParams, build_query
|
|
32
|
+
|
|
33
|
+
class User(Base, Queryable):
|
|
34
|
+
__filterable__ = frozenset({"status", "username", "age", "organization.name"})
|
|
35
|
+
__sortable__ = frozenset({"created_at", "username"})
|
|
36
|
+
__searchable__ = frozenset({"username", "organization.name"})
|
|
37
|
+
|
|
38
|
+
# inside your data/query layer
|
|
39
|
+
async def list_users(db, params: QueryParams):
|
|
40
|
+
stmt = build_query(User, params) # filters + search + sort
|
|
41
|
+
stmt = stmt.where(User.is_deleted.is_(False)) # caller-owned base scope
|
|
42
|
+
return (await db.execute(stmt)).scalars().all()
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
GET /users?filters={"status":{"$eq":"active"},"organization.name":{"$contains":"acme"}}&sort=created_at:desc&search=jdoe
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The filter travels as one JSON object, `{field: {operator: value}}`, with `$and` /
|
|
50
|
+
`$or` for grouping and a dot for relationship traversal (`organization.name`).
|
|
51
|
+
|
|
52
|
+
## Operators
|
|
53
|
+
|
|
54
|
+
`$eq` `$ne` `$gt` `$gte` `$lt` `$lte` `$in` `$nin` · `$contains` `$ncontains`
|
|
55
|
+
`$startswith` `$endswith` · `$isnull` `$isnotnull` · logical `$and` `$or`.
|
|
56
|
+
|
|
57
|
+
String comparisons are case-insensitive by default (`case_sensitive=true` to opt
|
|
58
|
+
out). Enum filters resolve the operand to the matching member, so you filter by the
|
|
59
|
+
enum value regardless of how SQLAlchemy stores it.
|
|
60
|
+
|
|
61
|
+
## Endpoint-level narrowing
|
|
62
|
+
|
|
63
|
+
The model declares the maximum; an endpoint may expose a subset (never a superset):
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
stmt = build_query(User, params, allowed_filters={"status", "username"})
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Documentation
|
|
70
|
+
|
|
71
|
+
- [Operator reference](docs/operators.md) — wire format, every operator, the per-type matrix
|
|
72
|
+
- [Guide](docs/guide.md) — `Queryable`, relationships, search, sort, errors, limits, startup validation
|
|
73
|
+
- [FastAPI integration](docs/integration.md)
|
|
74
|
+
|
|
75
|
+
Build the docs site locally: `pip install -e ".[docs]" && mkdocs serve`.
|
|
76
|
+
|
|
77
|
+
## Development
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
python -m venv .venv && source .venv/bin/activate
|
|
81
|
+
pip install -e ".[dev]"
|
|
82
|
+
pytest
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT. Derivative work of [fastapi-querybuilder](https://github.com/bhadri01/fastapi-querybuilder)
|
|
88
|
+
by bhadri01 — see `LICENSE` and `NOTICE`.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Guide
|
|
2
|
+
|
|
3
|
+
## The `Queryable` mixin (deny-by-default)
|
|
4
|
+
|
|
5
|
+
A model declares what may be filtered, sorted, and searched — and nothing else is
|
|
6
|
+
allowed. Add the mixin and the three frozensets:
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from querybuilder import Queryable
|
|
10
|
+
|
|
11
|
+
class User(Base, Queryable):
|
|
12
|
+
__filterable__ = frozenset({
|
|
13
|
+
"username", "status", "age", "created_at",
|
|
14
|
+
"organization.name", "organization.region.name", # to-one chains
|
|
15
|
+
"posts.title", # to-many (EXISTS)
|
|
16
|
+
})
|
|
17
|
+
__sortable__ = frozenset({"username", "status", "created_at", "organization.name"})
|
|
18
|
+
__searchable__ = frozenset({"username", "organization.name", "posts.title"})
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- A field path absent from the relevant set produces a **400**.
|
|
22
|
+
- The three sets are independent: a field can be sortable without being filterable.
|
|
23
|
+
- Relationship paths use dotted notation; every segment must be a real
|
|
24
|
+
relationship and the leaf must be a real column.
|
|
25
|
+
|
|
26
|
+
## Endpoint-level narrowing
|
|
27
|
+
|
|
28
|
+
The model declares the maximum; an endpoint may expose a **subset** (never a
|
|
29
|
+
superset):
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
stmt = build_query(User, params, allowed_filters={"status", "username"})
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Passing a field the model never declared is a configuration error
|
|
36
|
+
(`QueryConfigurationError`), not a client 400.
|
|
37
|
+
|
|
38
|
+
## Startup validation
|
|
39
|
+
|
|
40
|
+
Catch typos and misconfigured allowlists at deploy time rather than per request:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from querybuilder import validate_queryables
|
|
44
|
+
|
|
45
|
+
validate_queryables(User, Order, Invoice) # in app startup, after models import
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
It checks every declared path resolves to a real leaf column, and that
|
|
49
|
+
`__sortable__` contains no to-many path (you can't `ORDER BY` a to-many). Run it
|
|
50
|
+
**after** all models are imported (mappers must be configured).
|
|
51
|
+
|
|
52
|
+
## Relationships (EXISTS)
|
|
53
|
+
|
|
54
|
+
Relationship paths in **filters and search** compile to a correlated `EXISTS`,
|
|
55
|
+
chosen automatically by relationship direction:
|
|
56
|
+
|
|
57
|
+
- to-one → `.has(...)`
|
|
58
|
+
- to-many → `.any(...)`
|
|
59
|
+
- chains nest, e.g. `posts.category.name` → `posts.any(category.has(name == ...))`
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
{"posts.title": {"$contains": "hello"}}
|
|
63
|
+
-> EXISTS (SELECT 1 FROM posts WHERE posts.author_id = users.id
|
|
64
|
+
AND lower(posts.title) LIKE lower('%hello%'))
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
This never multiplies root rows (no fan-out, no `DISTINCT`), and two paths to the
|
|
68
|
+
same model — e.g. `home_address.city` and `work_address.city` — are independent
|
|
69
|
+
EXISTS that cannot collide.
|
|
70
|
+
|
|
71
|
+
**Semantics.** A predicate on `rel.col` matches rows that *have a related row
|
|
72
|
+
satisfying it*. A row with **no related row matches no relationship predicate** —
|
|
73
|
+
including negatives: `{"organization.name": {"$ne": "acme"}}` means "has an org
|
|
74
|
+
whose name ≠ acme", and `{"organization.region.name": {"$isnull": true}}` means
|
|
75
|
+
"has a region whose name is null" (not "has no region"). A relationship-level
|
|
76
|
+
"has / has-no related row" filter is out of scope for now.
|
|
77
|
+
|
|
78
|
+
## Sorting
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
sort=created_at:desc,organization.name:asc # dot notation
|
|
82
|
+
sort=organization__name:asc # double-underscore also accepted
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- Validated against `__sortable__`; unknown field or bad direction → 400.
|
|
86
|
+
- String sorting is case-insensitive by default (`case_sensitive=true` to opt out);
|
|
87
|
+
enums are cast to text before `lower(...)`; real date/datetime columns sort
|
|
88
|
+
directly.
|
|
89
|
+
- Relationship sort paths use a **path-keyed LEFT JOIN** (sorting must never drop
|
|
90
|
+
rows, and the column must be projected to be ordered). To-many sort paths are
|
|
91
|
+
rejected.
|
|
92
|
+
|
|
93
|
+
## Searching
|
|
94
|
+
|
|
95
|
+
A single `search` term is OR-combined across the declared `__searchable__` set:
|
|
96
|
+
|
|
97
|
+
- string → `ILIKE '%term%'`; enum → matched **by value** (same as `$contains`);
|
|
98
|
+
integer → exact match when the term is a number; boolean → `true` / `false`.
|
|
99
|
+
- Relationship search paths use the same `EXISTS` as filters, so **to-many search**
|
|
100
|
+
works without fan-out.
|
|
101
|
+
- Only declared paths are searched — the client supplies the term, never the field
|
|
102
|
+
list.
|
|
103
|
+
|
|
104
|
+
## Errors
|
|
105
|
+
|
|
106
|
+
All client-input failures return **HTTP 400** with a generic, schema-free message
|
|
107
|
+
— never a model class name or column list. Examples: `Unknown or disallowed
|
|
108
|
+
filter field.`, `Operator not allowed for this field.`, `Invalid filter format.`
|
|
109
|
+
Developer misconfiguration (e.g. widening an endpoint past the model) raises
|
|
110
|
+
`QueryConfigurationError`, which is a bug to fix, not a client error.
|
|
111
|
+
|
|
112
|
+
## Payload limits
|
|
113
|
+
|
|
114
|
+
`build_query` bounds the filter payload (spec-driven, overridable):
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
build_query(User, params, max_filter_bytes=8192, max_filter_depth=10)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
- `max_filter_bytes` — reject oversized filter JSON → `Filter payload too large.`
|
|
121
|
+
- `max_filter_depth` — reject deep `$and`/`$or` nesting → `Filter nesting too deep.`
|
|
122
|
+
|
|
123
|
+
## Caller-owned base scope
|
|
124
|
+
|
|
125
|
+
`build_query` returns `select(model)` plus the filter/search/sort predicates. It
|
|
126
|
+
**never** adds or inspects your mandatory scope — tenant, soft-delete, visibility.
|
|
127
|
+
AND your own `WHERE` onto the result:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
stmt = build_query(User, params)
|
|
131
|
+
stmt = stmt.where(User.org_id == ctx.org_id, User.is_deleted.is_(False))
|
|
132
|
+
```
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# fastapi-sqlalchemy-querykit
|
|
2
|
+
|
|
3
|
+
Deny-by-default declarative **filtering, sorting, and searching** for **FastAPI +
|
|
4
|
+
SQLAlchemy 2.0** (async-safe).
|
|
5
|
+
|
|
6
|
+
A list endpoint accepts a structured request — a JSON filter object, a sort
|
|
7
|
+
string, a search term — and the library turns it into a safe SQLAlchemy `Select`.
|
|
8
|
+
A field is queryable **only** if the model explicitly declares it; everything else
|
|
9
|
+
is a generic `400`.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install fastapi-sqlalchemy-querykit
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
!!! note
|
|
16
|
+
The distribution is **`fastapi-sqlalchemy-querykit`**; the import package is
|
|
17
|
+
**`querybuilder`** (`from querybuilder import build_query`).
|
|
18
|
+
|
|
19
|
+
## Why
|
|
20
|
+
|
|
21
|
+
- **Deny by default.** Undeclared fields return a generic 400 — never a silent or
|
|
22
|
+
accidental query. The opposite of the open-by-default libraries this derives from.
|
|
23
|
+
- **Policy on the model.** A `Queryable` mixin lists the allowed fields once;
|
|
24
|
+
endpoints may narrow but never widen.
|
|
25
|
+
- **Correct relationships.** Filters and search across relationships compile to
|
|
26
|
+
correlated `EXISTS` (`.has()` / `.any()`) — no row fan-out, no `DISTINCT`,
|
|
27
|
+
to-many supported. Two paths to the same model never collide.
|
|
28
|
+
- **Type-aware.** Operators are validated against the column type (`$contains` on
|
|
29
|
+
an integer → 400). Enums match by value and are Postgres-safe.
|
|
30
|
+
- **Caller-owned scope.** The builder is purely additive — it never adds or
|
|
31
|
+
inspects your tenant / soft-delete / visibility `WHERE`.
|
|
32
|
+
- **Tiny surface.** Runtime dependencies: `fastapi` + `sqlalchemy` only. Ships
|
|
33
|
+
`py.typed`.
|
|
34
|
+
|
|
35
|
+
## 60-second example
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from querybuilder import Queryable, QueryParams, build_query
|
|
39
|
+
|
|
40
|
+
class User(Base, Queryable):
|
|
41
|
+
__filterable__ = frozenset({"status", "username", "age", "organization.name"})
|
|
42
|
+
__sortable__ = frozenset({"created_at", "username"})
|
|
43
|
+
__searchable__ = frozenset({"username", "organization.name"})
|
|
44
|
+
|
|
45
|
+
# in your data/query layer
|
|
46
|
+
async def list_users(db, params: QueryParams):
|
|
47
|
+
stmt = build_query(User, params) # filters + search + sort
|
|
48
|
+
stmt = stmt.where(User.is_deleted.is_(False)) # your base scope, untouched
|
|
49
|
+
return (await db.execute(stmt)).scalars().all()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
GET /users?filters={"status":{"$eq":"active"},"organization.name":{"$contains":"acme"}}
|
|
54
|
+
&sort=created_at:desc&search=jdoe
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Where to next
|
|
58
|
+
|
|
59
|
+
- **[Operator reference](operators.md)** — the JSON wire format and every operator.
|
|
60
|
+
- **[Guide](guide.md)** — the `Queryable` mixin, relationships, search, sort,
|
|
61
|
+
errors, limits, and startup validation.
|
|
62
|
+
- **[FastAPI integration](integration.md)** — wiring `QueryParams` into endpoints.
|
|
63
|
+
|
|
64
|
+
## Credits
|
|
65
|
+
|
|
66
|
+
A derivative work of
|
|
67
|
+
[fastapi-querybuilder](https://github.com/bhadri01/fastapi-querybuilder) by
|
|
68
|
+
bhadri01 (MIT). MIT licensed — see `LICENSE` and `NOTICE`.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# FastAPI integration
|
|
2
|
+
|
|
3
|
+
## `QueryParams`
|
|
4
|
+
|
|
5
|
+
`QueryParams` is a FastAPI-injectable dependency exposing the four query-string
|
|
6
|
+
parameters: `filters`, `sort`, `search`, `case_sensitive`. Depend on it in the
|
|
7
|
+
router and pass it through to `build_query` — keep the builder out of the route
|
|
8
|
+
handler and inside your data/query layer.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from fastapi import APIRouter, Depends
|
|
12
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
13
|
+
from querybuilder import QueryParams, build_query
|
|
14
|
+
|
|
15
|
+
router = APIRouter()
|
|
16
|
+
|
|
17
|
+
# queries/users.py — the data layer owns the builder and the base scope
|
|
18
|
+
async def list_users(db: AsyncSession, org_id: int, params: QueryParams):
|
|
19
|
+
stmt = build_query(User, params)
|
|
20
|
+
stmt = stmt.where(User.org_id == org_id, User.is_deleted.is_(False)) # base scope
|
|
21
|
+
result = await db.execute(stmt)
|
|
22
|
+
return result.scalars().all()
|
|
23
|
+
|
|
24
|
+
# routers/users.py — the router just forwards params
|
|
25
|
+
@router.get("/users")
|
|
26
|
+
async def get_users(
|
|
27
|
+
params: QueryParams = Depends(),
|
|
28
|
+
db: AsyncSession = Depends(get_db),
|
|
29
|
+
ctx: CompanyContext = Depends(get_context),
|
|
30
|
+
):
|
|
31
|
+
return await list_users(db, ctx.org_id, params)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The query string the client sends:
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
GET /users?filters={"status":{"$eq":"active"}}&sort=created_at:desc&search=jdoe
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Pagination
|
|
41
|
+
|
|
42
|
+
`build_query` returns a plain `Select`, so it composes with whatever pagination
|
|
43
|
+
you already use — `.limit()/.offset()`, a `COUNT(*)` over the same statement, or a
|
|
44
|
+
library such as `fastapi-pagination`:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from fastapi_pagination.ext.sqlalchemy import paginate
|
|
48
|
+
|
|
49
|
+
stmt = build_query(User, params).where(User.is_deleted.is_(False))
|
|
50
|
+
page = await paginate(db, stmt)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Relationship filters/search are correlated `EXISTS`, so the count is correct
|
|
54
|
+
(no fan-out duplicates).
|
|
55
|
+
|
|
56
|
+
## Validate at startup
|
|
57
|
+
|
|
58
|
+
Register a startup check so a bad allowlist entry fails fast:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from contextlib import asynccontextmanager
|
|
62
|
+
from querybuilder import validate_queryables
|
|
63
|
+
|
|
64
|
+
@asynccontextmanager
|
|
65
|
+
async def lifespan(app):
|
|
66
|
+
validate_queryables(User, Order, Invoice) # raises QueryConfigurationError on a bad path
|
|
67
|
+
yield
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## OpenAPI note
|
|
71
|
+
|
|
72
|
+
The filter parameter is an opaque JSON string in Swagger (`filters: string`) — a
|
|
73
|
+
deliberate tradeoff: the filter-builder UI constructs the payload, and the
|
|
74
|
+
operator grammar is documented in the [operator reference](operators.md) rather
|
|
75
|
+
than via generated parameter docs.
|
|
76
|
+
|
|
77
|
+
## Notes
|
|
78
|
+
|
|
79
|
+
- `company_id` / tenant scope comes from your request context, never from the
|
|
80
|
+
filter payload — the builder never reads it.
|
|
81
|
+
- The builder is async-safe: it only constructs a `Select`; you execute it on your
|
|
82
|
+
`AsyncSession` as usual.
|