fastapi-sqlalchemy-querykit 0.1.0__tar.gz → 0.2.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 → fastapi_sqlalchemy_querykit-0.2.0}/CHANGELOG.md +20 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/PKG-INFO +3 -1
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/README.md +1 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/docs/index.md +2 -2
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/docs/integration.md +11 -6
- fastapi_sqlalchemy_querykit-0.2.0/docs/pagination.md +142 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/mkdocs.yml +1 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/pyproject.toml +1 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/__init__.py +25 -1
- fastapi_sqlalchemy_querykit-0.2.0/querybuilder/config.py +129 -0
- fastapi_sqlalchemy_querykit-0.2.0/querybuilder/pagination.py +135 -0
- fastapi_sqlalchemy_querykit-0.2.0/querybuilder/runner.py +48 -0
- fastapi_sqlalchemy_querykit-0.2.0/tests/test_config.py +64 -0
- fastapi_sqlalchemy_querykit-0.2.0/tests/test_pagination.py +42 -0
- fastapi_sqlalchemy_querykit-0.2.0/tests/test_runner.py +89 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/.github/workflows/release.yml +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/.gitignore +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/LICENSE +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/NOTICE +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/docs/guide.md +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/docs/operators.md +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/allowlist.py +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/builder.py +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/columns.py +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/dates.py +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/errors.py +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/mixin.py +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/operators.py +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/params.py +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/py.typed +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/resolver.py +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/validation.py +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/models.py +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_allowlist.py +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_execution.py +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_filters.py +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_operators.py +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_params.py +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_resolver.py +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_search.py +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_sort.py +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_startup.py +0 -0
- {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_validation.py +0 -0
|
@@ -4,6 +4,26 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/), and the project aims to follow
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [0.2.0] - 2026-06-13
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Offset pagination** (`querybuilder.pagination`): the `Page[T]` / `PageMeta`
|
|
11
|
+
response envelope, the `PageParams` dependency (plus a `page_params(default=, max_=)`
|
|
12
|
+
factory), a turnkey `paginate(db, stmt, params)` runner, and `build_page_meta`
|
|
13
|
+
for bring-your-own-query endpoints.
|
|
14
|
+
- **Query-config introspection** (`querybuilder.config`): `build_query_config(model)`
|
|
15
|
+
returns a `QueryConfig` / `FieldConfig` description of a `Queryable` model's
|
|
16
|
+
filterable fields (logical type, valid operators, enum values), derived from the
|
|
17
|
+
same `describe_path` / `allowed_operators_for` rules `build_query` enforces — so a
|
|
18
|
+
filter-builder UI can never drift from what the API actually accepts.
|
|
19
|
+
- **Turnkey list runner** (`querybuilder.runner`): `list_and_paginate(...)`
|
|
20
|
+
(`build_query` → caller `scope` → eager `options` → `paginate`) and the `ScopeFn`
|
|
21
|
+
type alias for the caller-owned base scope.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- `pydantic>=2` is now an explicit runtime dependency (already required transitively
|
|
25
|
+
by `fastapi`); the pagination and query-config models use it.
|
|
26
|
+
|
|
7
27
|
## [0.1.0] - 2026-06-02
|
|
8
28
|
|
|
9
29
|
First public release. A deny-by-default query builder for FastAPI + SQLAlchemy 2.0,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-sqlalchemy-querykit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Deny-by-default declarative filtering, sorting, and searching for FastAPI + SQLAlchemy 2.0.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Rooler-ai/fastapi-sqlalchemy-querybuilder
|
|
6
6
|
Project-URL: Repository, https://github.com/Rooler-ai/fastapi-sqlalchemy-querybuilder
|
|
@@ -24,6 +24,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
24
24
|
Classifier: Typing :: Typed
|
|
25
25
|
Requires-Python: >=3.10
|
|
26
26
|
Requires-Dist: fastapi>=0.115
|
|
27
|
+
Requires-Dist: pydantic>=2
|
|
27
28
|
Requires-Dist: sqlalchemy>=2.0
|
|
28
29
|
Provides-Extra: dev
|
|
29
30
|
Requires-Dist: aiosqlite>=0.20; extra == 'dev'
|
|
@@ -105,6 +106,7 @@ stmt = build_query(User, params, allowed_filters={"status", "username"})
|
|
|
105
106
|
- [Operator reference](docs/operators.md) — wire format, every operator, the per-type matrix
|
|
106
107
|
- [Guide](docs/guide.md) — `Queryable`, relationships, search, sort, errors, limits, startup validation
|
|
107
108
|
- [FastAPI integration](docs/integration.md)
|
|
109
|
+
- [Pagination & query-config](docs/pagination.md) — the `Page` envelope, `list_and_paginate`, `build_query_config`
|
|
108
110
|
|
|
109
111
|
Build the docs site locally: `pip install -e ".[docs]" && mkdocs serve`.
|
|
110
112
|
|
|
@@ -71,6 +71,7 @@ stmt = build_query(User, params, allowed_filters={"status", "username"})
|
|
|
71
71
|
- [Operator reference](docs/operators.md) — wire format, every operator, the per-type matrix
|
|
72
72
|
- [Guide](docs/guide.md) — `Queryable`, relationships, search, sort, errors, limits, startup validation
|
|
73
73
|
- [FastAPI integration](docs/integration.md)
|
|
74
|
+
- [Pagination & query-config](docs/pagination.md) — the `Page` envelope, `list_and_paginate`, `build_query_config`
|
|
74
75
|
|
|
75
76
|
Build the docs site locally: `pip install -e ".[docs]" && mkdocs serve`.
|
|
76
77
|
|
|
@@ -29,8 +29,8 @@ pip install fastapi-sqlalchemy-querykit
|
|
|
29
29
|
an integer → 400). Enums match by value and are Postgres-safe.
|
|
30
30
|
- **Caller-owned scope.** The builder is purely additive — it never adds or
|
|
31
31
|
inspects your tenant / soft-delete / visibility `WHERE`.
|
|
32
|
-
- **Tiny surface.** Runtime dependencies: `fastapi`
|
|
33
|
-
`py.typed`.
|
|
32
|
+
- **Tiny surface.** Runtime dependencies: `fastapi`, `sqlalchemy`, and `pydantic`
|
|
33
|
+
(which `fastapi` already requires). Ships `py.typed`.
|
|
34
34
|
|
|
35
35
|
## 60-second example
|
|
36
36
|
|
|
@@ -39,17 +39,22 @@ GET /users?filters={"status":{"$eq":"active"}}&sort=created_at:desc&search=jdoe
|
|
|
39
39
|
|
|
40
40
|
## Pagination
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
library such as `fastapi-pagination`:
|
|
42
|
+
The library ships an offset-pagination envelope and a turnkey runner — see
|
|
43
|
+
[Pagination & query-config](pagination.md):
|
|
45
44
|
|
|
46
45
|
```python
|
|
47
|
-
from
|
|
46
|
+
from querybuilder import list_and_paginate
|
|
48
47
|
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
rows, meta = await list_and_paginate(
|
|
49
|
+
db, User, params, page_params,
|
|
50
|
+
scope=lambda stmt: stmt.where(User.is_deleted.is_(False)),
|
|
51
|
+
)
|
|
51
52
|
```
|
|
52
53
|
|
|
54
|
+
Because `build_query` returns a plain `Select`, it also composes with whatever
|
|
55
|
+
pagination you already use — `.limit()/.offset()`, a `COUNT(*)` over the same
|
|
56
|
+
statement, or a library such as `fastapi-pagination`.
|
|
57
|
+
|
|
53
58
|
Relationship filters/search are correlated `EXISTS`, so the count is correct
|
|
54
59
|
(no fan-out duplicates).
|
|
55
60
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Pagination & query-config
|
|
2
|
+
|
|
3
|
+
Beyond building the `Select`, the library ships the two things every list endpoint
|
|
4
|
+
also needs: a standard **offset-pagination** envelope, and a **query-config**
|
|
5
|
+
introspection so a frontend filter-builder can render itself.
|
|
6
|
+
|
|
7
|
+
## The response envelope
|
|
8
|
+
|
|
9
|
+
Every list endpoint returns the same shape:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"items": [ ... ],
|
|
14
|
+
"meta": { "total": 137, "page": 2, "limit": 25, "total_pages": 6, "has_next": true, "has_prev": true }
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`Page[T]` and `PageMeta` are the Pydantic models for it:
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from querybuilder import Page, PageMeta
|
|
22
|
+
|
|
23
|
+
@router.get("/users", response_model=Page[UserRead])
|
|
24
|
+
async def list_users(...):
|
|
25
|
+
rows, meta = await ... # see below
|
|
26
|
+
return Page[UserRead](items=[UserRead.model_validate(r) for r in rows], meta=meta)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Request params
|
|
30
|
+
|
|
31
|
+
`PageParams` is a FastAPI dependency exposing `page` (1-based) and `limit`:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from fastapi import Depends
|
|
35
|
+
from querybuilder import PageParams
|
|
36
|
+
|
|
37
|
+
@router.get("/users")
|
|
38
|
+
async def list_users(p: PageParams = Depends(), ...):
|
|
39
|
+
...
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Defaults are `page=1`, `limit=25`, capped at `100`. For a different default/cap,
|
|
43
|
+
build a variant with the `page_params` factory:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from querybuilder import page_params
|
|
47
|
+
|
|
48
|
+
p: PageParams = Depends(page_params(default=50, max_=200))
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Two ways to paginate
|
|
52
|
+
|
|
53
|
+
### Turnkey — `list_and_paginate`
|
|
54
|
+
|
|
55
|
+
The common case: build the query, apply your base scope, eager-load, and paginate
|
|
56
|
+
in one call.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from sqlalchemy.orm import selectinload
|
|
60
|
+
from querybuilder import QueryParams, PageParams, list_and_paginate
|
|
61
|
+
|
|
62
|
+
# data/query layer
|
|
63
|
+
async def list_users(db, company_id: int, q: QueryParams, p: PageParams):
|
|
64
|
+
def scope(stmt): # caller-owned base scope
|
|
65
|
+
return stmt.where(User.company_id == company_id, User.is_deleted.is_(False))
|
|
66
|
+
|
|
67
|
+
return await list_and_paginate(
|
|
68
|
+
db, User, q, p,
|
|
69
|
+
scope=scope,
|
|
70
|
+
options=[selectinload(User.posts)], # to-many → selectinload
|
|
71
|
+
)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`list_and_paginate` runs `build_query` → your `scope` → eager `options` →
|
|
75
|
+
`paginate`, and returns `(rows, meta)`.
|
|
76
|
+
|
|
77
|
+
`scope` is a `ScopeFn` — a `Select -> Select` callable that applies your mandatory
|
|
78
|
+
predicates. The builder is purely additive and **never** injects tenant / soft-delete
|
|
79
|
+
/ visibility scope; that is always the caller's `scope`. For directly-scoped models
|
|
80
|
+
it is a `.where(...)`; for indirectly-scoped models it can be a `.join(...).where(...)`
|
|
81
|
+
chain.
|
|
82
|
+
|
|
83
|
+
!!! warning "The scope must live inside the statement"
|
|
84
|
+
`paginate` counts with `select(func.count()).select_from(stmt.order_by(None).subquery())`.
|
|
85
|
+
The outer count statement has **no ORM mapper**, so any scope you rely on *outside*
|
|
86
|
+
the statement — e.g. a session-level scoping ORM event — will not fire on the count
|
|
87
|
+
path even if it fires on the fetch. Apply tenant/visibility predicates **inside**
|
|
88
|
+
`stmt` (which `scope` does) or the `total` is computed unscoped and leaks across
|
|
89
|
+
tenants.
|
|
90
|
+
|
|
91
|
+
!!! note "Eager loading"
|
|
92
|
+
Use `selectinload` for to-many relations — it runs a separate query and is safe
|
|
93
|
+
under `LIMIT`. A `joinedload` of a collection truncates rows under `LIMIT`.
|
|
94
|
+
|
|
95
|
+
### Bring-your-own query — `build_page_meta`
|
|
96
|
+
|
|
97
|
+
When an endpoint keeps its own count/fetch logic (aggregates, raw SQL, a union)
|
|
98
|
+
but should still emit the standard envelope:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from querybuilder import build_page_meta
|
|
102
|
+
|
|
103
|
+
total = await db.scalar(my_count_query)
|
|
104
|
+
rows = (await db.execute(my_page_query)).scalars().all()
|
|
105
|
+
meta = build_page_meta(total=total, page=p.page, limit=p.limit)
|
|
106
|
+
return Page[UserRead](items=[...], meta=meta)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
You can also call `paginate(db, stmt, p)` directly if you have a `Select` but don't
|
|
110
|
+
need the `scope`/`options` plumbing of `list_and_paginate`.
|
|
111
|
+
|
|
112
|
+
## Describing the query surface — `build_query_config`
|
|
113
|
+
|
|
114
|
+
`build_query_config(model)` introspects a `Queryable` model's allowlists into a
|
|
115
|
+
frontend-facing config: for each filterable field, its logical type, the operators
|
|
116
|
+
valid for that type, and (for enums) the allowed values. It is derived from the same
|
|
117
|
+
`describe_path` and `allowed_operators_for` rules `build_query` enforces, so the
|
|
118
|
+
config can never drift from what the API actually accepts.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from querybuilder import build_query_config, QueryConfig
|
|
122
|
+
|
|
123
|
+
@router.get("/users/query-config", response_model=QueryConfig)
|
|
124
|
+
async def users_query_config():
|
|
125
|
+
return build_query_config(User)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"filterable": {
|
|
131
|
+
"status": { "type": "enum", "operators": ["$eq","$ne","$in","$nin","$contains", ...], "values": ["active","inactive"] },
|
|
132
|
+
"age": { "type": "integer", "operators": ["$eq","$ne","$gt","$gte","$lt","$lte","$in","$nin","$isnull","$isnotnull"], "values": null },
|
|
133
|
+
"organization.name":{ "type": "string", "operators": ["$eq","$ne","$in","$nin","$contains", ...], "values": null }
|
|
134
|
+
},
|
|
135
|
+
"sortable": ["created_at", "username"],
|
|
136
|
+
"searchable": ["username", "organization.name"]
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Enum `values` are the member **values** (what you filter by), not the stored member
|
|
141
|
+
names — consistent with how enum filtering works (see the
|
|
142
|
+
[operator reference](operators.md)).
|
{fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/__init__.py
RENAMED
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
from .allowlist import validate_queryable, validate_queryables
|
|
11
11
|
from .builder import build_query
|
|
12
|
+
from .config import FieldConfig, QueryConfig, build_query_config
|
|
12
13
|
from .errors import (
|
|
13
14
|
FilterError,
|
|
14
15
|
QueryBuilderError,
|
|
@@ -17,7 +18,16 @@ from .errors import (
|
|
|
17
18
|
SortError,
|
|
18
19
|
)
|
|
19
20
|
from .mixin import Queryable
|
|
21
|
+
from .pagination import (
|
|
22
|
+
Page,
|
|
23
|
+
PageMeta,
|
|
24
|
+
PageParams,
|
|
25
|
+
build_page_meta,
|
|
26
|
+
page_params,
|
|
27
|
+
paginate,
|
|
28
|
+
)
|
|
20
29
|
from .params import QueryParams
|
|
30
|
+
from .runner import ScopeFn, list_and_paginate
|
|
21
31
|
|
|
22
32
|
__all__ = [
|
|
23
33
|
"build_query",
|
|
@@ -30,6 +40,20 @@ __all__ = [
|
|
|
30
40
|
"SortError",
|
|
31
41
|
"SearchError",
|
|
32
42
|
"QueryConfigurationError",
|
|
43
|
+
# pagination
|
|
44
|
+
"Page",
|
|
45
|
+
"PageMeta",
|
|
46
|
+
"PageParams",
|
|
47
|
+
"page_params",
|
|
48
|
+
"paginate",
|
|
49
|
+
"build_page_meta",
|
|
50
|
+
# query-config introspection
|
|
51
|
+
"QueryConfig",
|
|
52
|
+
"FieldConfig",
|
|
53
|
+
"build_query_config",
|
|
54
|
+
# turnkey runner
|
|
55
|
+
"list_and_paginate",
|
|
56
|
+
"ScopeFn",
|
|
33
57
|
]
|
|
34
58
|
|
|
35
|
-
__version__ = "0.
|
|
59
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Introspection of a model's ``Queryable`` policy into a frontend-facing config.
|
|
2
|
+
|
|
3
|
+
Powers per-entity ``GET /<plural>/query-config`` endpoints: for each declared
|
|
4
|
+
filterable field it returns the field type, the operators valid for that type, and
|
|
5
|
+
(for enums) the allowed values — so a filter-builder UI can render itself.
|
|
6
|
+
|
|
7
|
+
Everything is derived from the library's OWN rules — ``resolver.describe_path`` to
|
|
8
|
+
resolve dotted paths to a leaf column, and ``validation.allowed_operators_for`` for
|
|
9
|
+
the type→operator gate — so the config can never drift from what ``build_query``
|
|
10
|
+
actually accepts.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
from sqlalchemy import Uuid as _SAUuid
|
|
20
|
+
|
|
21
|
+
from .columns import (
|
|
22
|
+
is_boolean_column,
|
|
23
|
+
is_datetime_like_column,
|
|
24
|
+
is_enum_column,
|
|
25
|
+
is_integer_column,
|
|
26
|
+
is_numeric_column,
|
|
27
|
+
is_string_column,
|
|
28
|
+
)
|
|
29
|
+
from .resolver import describe_path
|
|
30
|
+
from .validation import allowed_operators_for
|
|
31
|
+
|
|
32
|
+
try: # postgresql-specific UUID type
|
|
33
|
+
from sqlalchemy.dialects.postgresql import UUID as _PGUuid
|
|
34
|
+
except Exception: # pragma: no cover - dialect import guard
|
|
35
|
+
_PGUuid = ()
|
|
36
|
+
|
|
37
|
+
# Canonical display order for operators (a superset; only the applicable ones are
|
|
38
|
+
# emitted per field). Keeps the UI stable instead of alphabetical noise.
|
|
39
|
+
_OP_ORDER = [
|
|
40
|
+
"$eq", "$ne",
|
|
41
|
+
"$gt", "$gte", "$lt", "$lte",
|
|
42
|
+
"$in", "$nin",
|
|
43
|
+
"$contains", "$ncontains", "$startswith", "$endswith",
|
|
44
|
+
"$isnull", "$isnotnull",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class FieldConfig(BaseModel):
|
|
49
|
+
"""How one filterable field may be queried."""
|
|
50
|
+
|
|
51
|
+
type: str = Field(..., description="Logical field type (string/enum/integer/number/boolean/datetime)")
|
|
52
|
+
operators: List[str] = Field(..., description="Operators valid for this field's type")
|
|
53
|
+
values: Optional[List[str]] = Field(None, description="Allowed values for enum fields")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class QueryConfig(BaseModel):
|
|
57
|
+
"""The filter/sort/search capabilities a list endpoint exposes for a model."""
|
|
58
|
+
|
|
59
|
+
filterable: Dict[str, FieldConfig] = Field(default_factory=dict)
|
|
60
|
+
sortable: List[str] = Field(default_factory=list)
|
|
61
|
+
searchable: List[str] = Field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_uuid_column(column: Any) -> bool:
|
|
65
|
+
col_type = getattr(column, "type", None)
|
|
66
|
+
if col_type is None:
|
|
67
|
+
return False
|
|
68
|
+
if isinstance(col_type, _SAUuid): # generic SQLAlchemy 2.0 Uuid
|
|
69
|
+
return True
|
|
70
|
+
if _PGUuid and isinstance(col_type, _PGUuid): # postgresql UUID
|
|
71
|
+
return True
|
|
72
|
+
return col_type.__class__.__name__.lower() == "uuid"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _type_label(column: Any) -> str:
|
|
76
|
+
# Enum first: an enum column is also string-like, but "enum" is the useful label.
|
|
77
|
+
if is_enum_column(column):
|
|
78
|
+
return "enum"
|
|
79
|
+
if is_boolean_column(column):
|
|
80
|
+
return "boolean"
|
|
81
|
+
if is_integer_column(column):
|
|
82
|
+
return "integer"
|
|
83
|
+
if is_numeric_column(column):
|
|
84
|
+
return "number"
|
|
85
|
+
if is_datetime_like_column(column):
|
|
86
|
+
return "datetime"
|
|
87
|
+
if _is_uuid_column(column):
|
|
88
|
+
# Equality/set comparison only (no substring/ordering) — see allowed_operators_for.
|
|
89
|
+
return "uuid"
|
|
90
|
+
if is_string_column(column):
|
|
91
|
+
return "string"
|
|
92
|
+
return "string"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _enum_values(column: Any) -> Optional[List[str]]:
|
|
96
|
+
if not is_enum_column(column):
|
|
97
|
+
return None
|
|
98
|
+
col_type = getattr(column, "type", None)
|
|
99
|
+
enum_class = getattr(col_type, "enum_class", None)
|
|
100
|
+
if enum_class is not None:
|
|
101
|
+
# Filtering matches by the enum member's VALUE (per docs/operators.md).
|
|
102
|
+
return [str(m.value) for m in enum_class]
|
|
103
|
+
# Raw Enum("a", "b") with no Python class.
|
|
104
|
+
return [str(v) for v in (getattr(col_type, "enums", None) or [])]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _ordered_ops(ops) -> List[str]:
|
|
108
|
+
present = set(ops)
|
|
109
|
+
ordered = [o for o in _OP_ORDER if o in present]
|
|
110
|
+
# Surface any operator not in the canonical list (forward-compat) at the end.
|
|
111
|
+
ordered.extend(sorted(present - set(ordered)))
|
|
112
|
+
return ordered
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def build_query_config(model: Any) -> QueryConfig:
|
|
116
|
+
"""Build the :class:`QueryConfig` for a ``Queryable`` model from its allowlists."""
|
|
117
|
+
filterable: Dict[str, FieldConfig] = {}
|
|
118
|
+
for path in sorted(getattr(model, "__filterable__", frozenset())):
|
|
119
|
+
leaf, _has_to_many = describe_path(model, path.split("."))
|
|
120
|
+
filterable[path] = FieldConfig(
|
|
121
|
+
type=_type_label(leaf),
|
|
122
|
+
operators=_ordered_ops(allowed_operators_for(leaf)),
|
|
123
|
+
values=_enum_values(leaf),
|
|
124
|
+
)
|
|
125
|
+
return QueryConfig(
|
|
126
|
+
filterable=filterable,
|
|
127
|
+
sortable=sorted(getattr(model, "__sortable__", frozenset())),
|
|
128
|
+
searchable=sorted(getattr(model, "__searchable__", frozenset())),
|
|
129
|
+
)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Offset pagination: request params, response envelope, and a turnkey runner.
|
|
2
|
+
|
|
3
|
+
Standard list shape::
|
|
4
|
+
|
|
5
|
+
{ "items": [...], "meta": { total, page, limit, total_pages, has_next, has_prev } }
|
|
6
|
+
|
|
7
|
+
Two modes: a turnkey :func:`paginate` (and :func:`querybuilder.runner.list_and_paginate`)
|
|
8
|
+
that runs the count + page query for you, or :func:`build_page_meta` for endpoints
|
|
9
|
+
that keep their own query logic but still want the standard envelope.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
from typing import Any, Generic, List, Sequence, Tuple, TypeVar
|
|
16
|
+
|
|
17
|
+
from fastapi import Query
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
from sqlalchemy import func, select
|
|
20
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
21
|
+
from sqlalchemy.sql import Select
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
|
|
25
|
+
DEFAULT_LIMIT = 25
|
|
26
|
+
MAX_LIMIT = 100
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PageMeta(BaseModel):
|
|
30
|
+
"""Pagination metadata for a single page of results."""
|
|
31
|
+
|
|
32
|
+
total: int = Field(..., description="Total rows matching the query (all pages)")
|
|
33
|
+
page: int = Field(..., description="Current 1-based page number")
|
|
34
|
+
limit: int = Field(..., description="Page size used for this query")
|
|
35
|
+
total_pages: int = Field(..., description="Total number of pages")
|
|
36
|
+
has_next: bool = Field(..., description="Whether a next page exists")
|
|
37
|
+
has_prev: bool = Field(..., description="Whether a previous page exists")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Page(BaseModel, Generic[T]):
|
|
41
|
+
"""Generic paginated response envelope: ``{items, meta}``."""
|
|
42
|
+
|
|
43
|
+
items: List[T] = Field(default_factory=list)
|
|
44
|
+
meta: PageMeta
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_page_meta(total: int, page: int, limit: int) -> PageMeta:
|
|
48
|
+
"""Compute :class:`PageMeta` from raw counts (Mode 2: bring-your-own query).
|
|
49
|
+
|
|
50
|
+
Use when an endpoint keeps its own count/fetch logic but should still emit the
|
|
51
|
+
standard envelope.
|
|
52
|
+
"""
|
|
53
|
+
limit = max(1, limit)
|
|
54
|
+
total_pages = math.ceil(total / limit) if total else 0
|
|
55
|
+
return PageMeta(
|
|
56
|
+
total=total,
|
|
57
|
+
page=page,
|
|
58
|
+
limit=limit,
|
|
59
|
+
total_pages=total_pages,
|
|
60
|
+
has_next=page < total_pages,
|
|
61
|
+
has_prev=page > 1,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PageParams:
|
|
66
|
+
"""FastAPI dependency exposing ``page`` + ``limit`` query params (offset paging).
|
|
67
|
+
|
|
68
|
+
Use directly — ``p: PageParams = Depends()`` — or build a variant with a custom
|
|
69
|
+
default/max page size via :func:`page_params`.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
page: int = Query(1, ge=1, description="1-based page number"),
|
|
75
|
+
limit: int = Query(
|
|
76
|
+
DEFAULT_LIMIT, ge=1, le=MAX_LIMIT, description="Items per page"
|
|
77
|
+
),
|
|
78
|
+
):
|
|
79
|
+
self.page = page
|
|
80
|
+
self.limit = limit
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def offset(self) -> int:
|
|
84
|
+
return (self.page - 1) * self.limit
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def page_params(default: int = DEFAULT_LIMIT, max_: int = MAX_LIMIT):
|
|
88
|
+
"""Factory for a :class:`PageParams` dependency with a custom default/max size.
|
|
89
|
+
|
|
90
|
+
Mirrors Spatie's configurable ``default_size`` / ``max_results``::
|
|
91
|
+
|
|
92
|
+
p: PageParams = Depends(page_params(default=50, max_=200))
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def _dep(
|
|
96
|
+
page: int = Query(1, ge=1, description="1-based page number"),
|
|
97
|
+
limit: int = Query(default, ge=1, le=max_, description="Items per page"),
|
|
98
|
+
) -> PageParams:
|
|
99
|
+
return PageParams(page=page, limit=limit)
|
|
100
|
+
|
|
101
|
+
return _dep
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def paginate(
|
|
105
|
+
db: AsyncSession, stmt: Select, params: PageParams
|
|
106
|
+
) -> Tuple[Sequence[Any], PageMeta]:
|
|
107
|
+
"""Mode 1 (turnkey): run ``COUNT`` + ``LIMIT/OFFSET`` over ``stmt``.
|
|
108
|
+
|
|
109
|
+
Returns ``(rows, meta)``; the endpoint wraps ``rows`` in ``Page[Read]``.
|
|
110
|
+
|
|
111
|
+
SCOPE FOOTGUN: the count is built as
|
|
112
|
+
``select(count()).select_from(stmt.order_by(None).subquery())``, whose outer
|
|
113
|
+
statement has no ORM mapper. Any scope you rely on *outside* ``stmt`` — e.g. a
|
|
114
|
+
session-level scoping ORM event — will NOT fire on this count path even if it
|
|
115
|
+
fires on the fetch. So tenant / soft-delete / visibility predicates MUST live
|
|
116
|
+
INSIDE ``stmt`` (via the ``scope`` callable in
|
|
117
|
+
:func:`querybuilder.runner.list_and_paginate`) to land in the count subquery
|
|
118
|
+
too — otherwise ``total`` is computed unscoped and leaks across tenants.
|
|
119
|
+
|
|
120
|
+
Eager-loading rule: to-many relations must use ``selectinload`` (a separate
|
|
121
|
+
query, safe under ``LIMIT``); ``joinedload`` of a collection truncates rows under
|
|
122
|
+
``LIMIT``. ``.unique()`` defensively dedups joined to-one rows.
|
|
123
|
+
"""
|
|
124
|
+
count_stmt = select(func.count()).select_from(stmt.order_by(None).subquery())
|
|
125
|
+
total = int((await db.execute(count_stmt)).scalar_one() or 0)
|
|
126
|
+
if total == 0:
|
|
127
|
+
return [], build_page_meta(0, params.page, params.limit)
|
|
128
|
+
|
|
129
|
+
rows = (
|
|
130
|
+
(await db.execute(stmt.limit(params.limit).offset(params.offset)))
|
|
131
|
+
.unique()
|
|
132
|
+
.scalars()
|
|
133
|
+
.all()
|
|
134
|
+
)
|
|
135
|
+
return rows, build_page_meta(total, params.page, params.limit)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Turnkey list path: ``build_query`` → caller ``scope`` → eager ``options`` → paginate.
|
|
2
|
+
|
|
3
|
+
A thin convenience over :func:`querybuilder.build_query` and
|
|
4
|
+
:func:`querybuilder.pagination.paginate` for the common list-endpoint shape. The
|
|
5
|
+
caller supplies the mandatory base scope (tenant / soft-delete / visibility) as a
|
|
6
|
+
``Select -> Select`` callable; the builder never adds it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Callable, Iterable, Optional, Sequence, Tuple
|
|
12
|
+
|
|
13
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
14
|
+
from sqlalchemy.sql import Select
|
|
15
|
+
|
|
16
|
+
from .builder import build_query
|
|
17
|
+
from .pagination import PageMeta, PageParams, paginate
|
|
18
|
+
from .params import QueryParams
|
|
19
|
+
|
|
20
|
+
# A scope callable receives the built Select and returns it with the caller's
|
|
21
|
+
# mandatory predicates applied (tenant / company, soft-delete, visibility). For
|
|
22
|
+
# directly-scoped models this is a ``.where(...)``; for indirectly-scoped models it
|
|
23
|
+
# is a ``.join(...).where(...)`` chain. It is REQUIRED for correct tenant isolation
|
|
24
|
+
# — the builder never adds it, and it must be applied here (inside ``stmt``) so it
|
|
25
|
+
# also constrains the pagination count. See :func:`querybuilder.pagination.paginate`.
|
|
26
|
+
ScopeFn = Callable[[Select], Select]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def list_and_paginate(
|
|
30
|
+
db: AsyncSession,
|
|
31
|
+
model: Any,
|
|
32
|
+
q: QueryParams,
|
|
33
|
+
p: PageParams,
|
|
34
|
+
*,
|
|
35
|
+
scope: Optional[ScopeFn] = None,
|
|
36
|
+
options: Optional[Iterable[Any]] = None,
|
|
37
|
+
) -> Tuple[Sequence[Any], PageMeta]:
|
|
38
|
+
"""Turnkey list path: ``build_query`` → caller ``scope`` → eager ``options`` → paginate.
|
|
39
|
+
|
|
40
|
+
Returns ``(rows, meta)``. ``options`` should use ``selectinload`` for to-many
|
|
41
|
+
relations (see :func:`querybuilder.pagination.paginate`).
|
|
42
|
+
"""
|
|
43
|
+
stmt = build_query(model, q)
|
|
44
|
+
if scope is not None:
|
|
45
|
+
stmt = scope(stmt)
|
|
46
|
+
if options:
|
|
47
|
+
stmt = stmt.options(*options)
|
|
48
|
+
return await paginate(db, stmt, p)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""build_query_config introspection over a model's Queryable allowlists."""
|
|
2
|
+
|
|
3
|
+
from querybuilder import build_query_config
|
|
4
|
+
|
|
5
|
+
from models import User
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_filterable_keys_match_declaration():
|
|
9
|
+
cfg = build_query_config(User)
|
|
10
|
+
assert set(cfg.filterable) == set(User.__filterable__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_sortable_and_searchable_are_sorted_declared_sets():
|
|
14
|
+
cfg = build_query_config(User)
|
|
15
|
+
assert cfg.sortable == sorted(User.__sortable__)
|
|
16
|
+
assert cfg.searchable == sorted(User.__searchable__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_enum_field_reports_values_by_value_not_name():
|
|
20
|
+
cfg = build_query_config(User)
|
|
21
|
+
stage = cfg.filterable["stage"]
|
|
22
|
+
assert stage.type == "enum"
|
|
23
|
+
# Stage.ACCOUNT_SETUP.value == "accountsetup" (name != value) — surfaced by VALUE.
|
|
24
|
+
assert stage.values == ["accountsetup", "profilesetup"]
|
|
25
|
+
assert "$contains" in stage.operators # enums get substring ops
|
|
26
|
+
assert "$gt" not in stage.operators # but not ordering
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_integer_field_gets_ordering_not_substring():
|
|
30
|
+
cfg = build_query_config(User)
|
|
31
|
+
age = cfg.filterable["age"]
|
|
32
|
+
assert age.type == "integer"
|
|
33
|
+
assert age.values is None
|
|
34
|
+
assert "$gt" in age.operators
|
|
35
|
+
assert "$contains" not in age.operators
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_boolean_field_equality_and_null_only():
|
|
39
|
+
cfg = build_query_config(User)
|
|
40
|
+
v = cfg.filterable["is_verified"]
|
|
41
|
+
assert v.type == "boolean"
|
|
42
|
+
assert set(v.operators) == {"$eq", "$ne", "$isnull", "$isnotnull"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_datetime_field_gets_ordering():
|
|
46
|
+
cfg = build_query_config(User)
|
|
47
|
+
c = cfg.filterable["created_at"]
|
|
48
|
+
assert c.type == "datetime"
|
|
49
|
+
assert "$gte" in c.operators
|
|
50
|
+
assert "$contains" not in c.operators
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_string_field_and_relationship_path_resolve_to_leaf_type():
|
|
54
|
+
cfg = build_query_config(User)
|
|
55
|
+
assert cfg.filterable["username"].type == "string"
|
|
56
|
+
assert "$contains" in cfg.filterable["username"].operators
|
|
57
|
+
# A dotted relationship path resolves to its leaf column's type.
|
|
58
|
+
assert cfg.filterable["organization.name"].type == "string"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_operators_follow_canonical_order_not_alphabetical():
|
|
62
|
+
cfg = build_query_config(User)
|
|
63
|
+
ops = cfg.filterable["username"].operators
|
|
64
|
+
assert ops.index("$eq") < ops.index("$ne") < ops.index("$contains")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Pagination metadata math — ``build_page_meta`` (pure, no DB)."""
|
|
2
|
+
|
|
3
|
+
from querybuilder import build_page_meta
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_first_page_has_next_not_prev():
|
|
7
|
+
m = build_page_meta(total=100, page=1, limit=25)
|
|
8
|
+
assert m.total == 100
|
|
9
|
+
assert m.total_pages == 4
|
|
10
|
+
assert m.has_next is True
|
|
11
|
+
assert m.has_prev is False
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_middle_page_has_both_neighbors():
|
|
15
|
+
m = build_page_meta(total=100, page=2, limit=25)
|
|
16
|
+
assert m.has_next is True
|
|
17
|
+
assert m.has_prev is True
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_last_page_has_prev_not_next():
|
|
21
|
+
m = build_page_meta(total=100, page=4, limit=25)
|
|
22
|
+
assert m.has_next is False
|
|
23
|
+
assert m.has_prev is True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_partial_last_page_rounds_up():
|
|
27
|
+
m = build_page_meta(total=101, page=1, limit=25)
|
|
28
|
+
assert m.total_pages == 5
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_empty_result_has_zero_pages_and_no_neighbors():
|
|
32
|
+
m = build_page_meta(total=0, page=1, limit=25)
|
|
33
|
+
assert m.total_pages == 0
|
|
34
|
+
assert m.has_next is False
|
|
35
|
+
assert m.has_prev is False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_limit_is_clamped_to_at_least_one():
|
|
39
|
+
# limit=0 would divide-by-zero without the max(1, limit) clamp.
|
|
40
|
+
m = build_page_meta(total=10, page=1, limit=0)
|
|
41
|
+
assert m.limit == 1
|
|
42
|
+
assert m.total_pages == 10
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""End-to-end pagination over async SQLite (``paginate`` + ``list_and_paginate``).
|
|
2
|
+
|
|
3
|
+
Uses ``asyncio.run`` rather than pytest-asyncio; ``StaticPool`` keeps ``create_all``
|
|
4
|
+
and the queries on a single in-memory connection (otherwise each checkout is a
|
|
5
|
+
fresh, empty database).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
|
11
|
+
from sqlalchemy.pool import StaticPool
|
|
12
|
+
|
|
13
|
+
from querybuilder import build_query, list_and_paginate, paginate
|
|
14
|
+
from querybuilder.pagination import PageParams
|
|
15
|
+
|
|
16
|
+
from models import Address, Base, Organization, Region, Status, User, make_params
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _run(coro):
|
|
20
|
+
return asyncio.run(coro)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def _fresh_session() -> AsyncSession:
|
|
24
|
+
engine = create_async_engine(
|
|
25
|
+
"sqlite+aiosqlite://",
|
|
26
|
+
poolclass=StaticPool,
|
|
27
|
+
connect_args={"check_same_thread": False},
|
|
28
|
+
)
|
|
29
|
+
async with engine.begin() as conn:
|
|
30
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
31
|
+
return AsyncSession(engine)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def _seed(session: AsyncSession) -> None:
|
|
35
|
+
org = Organization(name="acme", region=Region(name="west"))
|
|
36
|
+
addr = Address(city="A")
|
|
37
|
+
session.add_all([org, addr])
|
|
38
|
+
await session.flush()
|
|
39
|
+
for i in range(5):
|
|
40
|
+
session.add(
|
|
41
|
+
User(
|
|
42
|
+
username=f"u{i}",
|
|
43
|
+
status=Status.ACTIVE if i % 2 == 0 else Status.INACTIVE,
|
|
44
|
+
home_address=addr,
|
|
45
|
+
work_address=addr,
|
|
46
|
+
organization=org,
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
await session.commit()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def _paginate_scenario():
|
|
53
|
+
session = await _fresh_session()
|
|
54
|
+
async with session:
|
|
55
|
+
await _seed(session)
|
|
56
|
+
stmt = build_query(User, make_params(sort="username:asc"))
|
|
57
|
+
rows, meta = await paginate(session, stmt, PageParams(page=1, limit=2))
|
|
58
|
+
return [r.username for r in rows], meta
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _scoped_scenario():
|
|
62
|
+
session = await _fresh_session()
|
|
63
|
+
async with session:
|
|
64
|
+
await _seed(session)
|
|
65
|
+
|
|
66
|
+
def scope(stmt):
|
|
67
|
+
return stmt.where(User.status == Status.ACTIVE)
|
|
68
|
+
|
|
69
|
+
rows, meta = await list_and_paginate(
|
|
70
|
+
session, User, make_params(), PageParams(page=1, limit=10), scope=scope
|
|
71
|
+
)
|
|
72
|
+
return meta.total, len(rows)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_paginate_returns_first_page_and_full_total():
|
|
76
|
+
usernames, meta = _run(_paginate_scenario())
|
|
77
|
+
assert usernames == ["u0", "u1"] # order_by stripped from count, kept on fetch
|
|
78
|
+
assert meta.total == 5
|
|
79
|
+
assert meta.total_pages == 3
|
|
80
|
+
assert meta.has_next is True
|
|
81
|
+
assert meta.has_prev is False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_scope_constrains_the_pagination_count():
|
|
85
|
+
total, returned = _run(_scoped_scenario())
|
|
86
|
+
# 3 ACTIVE users (i = 0, 2, 4). A correct count means the scope landed inside
|
|
87
|
+
# stmt and reached the count subquery — not just the fetch.
|
|
88
|
+
assert total == 3
|
|
89
|
+
assert returned == 3
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/allowlist.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/builder.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/columns.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/dates.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/errors.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/mixin.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/operators.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/params.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/py.typed
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/resolver.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/validation.py
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_allowlist.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_execution.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_filters.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_operators.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_params.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_resolver.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_search.py
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_startup.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_validation.py
RENAMED
|
File without changes
|