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.
Files changed (36) hide show
  1. fastapi_sqlalchemy_querykit-0.1.0/.github/workflows/release.yml +49 -0
  2. fastapi_sqlalchemy_querykit-0.1.0/.gitignore +27 -0
  3. fastapi_sqlalchemy_querykit-0.1.0/CHANGELOG.md +39 -0
  4. fastapi_sqlalchemy_querykit-0.1.0/LICENSE +22 -0
  5. fastapi_sqlalchemy_querykit-0.1.0/NOTICE +13 -0
  6. fastapi_sqlalchemy_querykit-0.1.0/PKG-INFO +122 -0
  7. fastapi_sqlalchemy_querykit-0.1.0/README.md +88 -0
  8. fastapi_sqlalchemy_querykit-0.1.0/docs/guide.md +132 -0
  9. fastapi_sqlalchemy_querykit-0.1.0/docs/index.md +68 -0
  10. fastapi_sqlalchemy_querykit-0.1.0/docs/integration.md +82 -0
  11. fastapi_sqlalchemy_querykit-0.1.0/docs/operators.md +114 -0
  12. fastapi_sqlalchemy_querykit-0.1.0/mkdocs.yml +37 -0
  13. fastapi_sqlalchemy_querykit-0.1.0/pyproject.toml +62 -0
  14. fastapi_sqlalchemy_querykit-0.1.0/querybuilder/__init__.py +35 -0
  15. fastapi_sqlalchemy_querykit-0.1.0/querybuilder/allowlist.py +88 -0
  16. fastapi_sqlalchemy_querykit-0.1.0/querybuilder/builder.py +307 -0
  17. fastapi_sqlalchemy_querykit-0.1.0/querybuilder/columns.py +64 -0
  18. fastapi_sqlalchemy_querykit-0.1.0/querybuilder/dates.py +57 -0
  19. fastapi_sqlalchemy_querykit-0.1.0/querybuilder/errors.py +43 -0
  20. fastapi_sqlalchemy_querykit-0.1.0/querybuilder/mixin.py +30 -0
  21. fastapi_sqlalchemy_querykit-0.1.0/querybuilder/operators.py +294 -0
  22. fastapi_sqlalchemy_querykit-0.1.0/querybuilder/params.py +39 -0
  23. fastapi_sqlalchemy_querykit-0.1.0/querybuilder/py.typed +0 -0
  24. fastapi_sqlalchemy_querykit-0.1.0/querybuilder/resolver.py +100 -0
  25. fastapi_sqlalchemy_querykit-0.1.0/querybuilder/validation.py +58 -0
  26. fastapi_sqlalchemy_querykit-0.1.0/tests/models.py +121 -0
  27. fastapi_sqlalchemy_querykit-0.1.0/tests/test_allowlist.py +68 -0
  28. fastapi_sqlalchemy_querykit-0.1.0/tests/test_execution.py +213 -0
  29. fastapi_sqlalchemy_querykit-0.1.0/tests/test_filters.py +170 -0
  30. fastapi_sqlalchemy_querykit-0.1.0/tests/test_operators.py +100 -0
  31. fastapi_sqlalchemy_querykit-0.1.0/tests/test_params.py +17 -0
  32. fastapi_sqlalchemy_querykit-0.1.0/tests/test_resolver.py +43 -0
  33. fastapi_sqlalchemy_querykit-0.1.0/tests/test_search.py +74 -0
  34. fastapi_sqlalchemy_querykit-0.1.0/tests/test_sort.py +66 -0
  35. fastapi_sqlalchemy_querykit-0.1.0/tests/test_startup.py +69 -0
  36. 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.