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.
Files changed (43) hide show
  1. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/CHANGELOG.md +20 -0
  2. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/PKG-INFO +3 -1
  3. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/README.md +1 -0
  4. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/docs/index.md +2 -2
  5. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/docs/integration.md +11 -6
  6. fastapi_sqlalchemy_querykit-0.2.0/docs/pagination.md +142 -0
  7. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/mkdocs.yml +1 -0
  8. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/pyproject.toml +1 -0
  9. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/__init__.py +25 -1
  10. fastapi_sqlalchemy_querykit-0.2.0/querybuilder/config.py +129 -0
  11. fastapi_sqlalchemy_querykit-0.2.0/querybuilder/pagination.py +135 -0
  12. fastapi_sqlalchemy_querykit-0.2.0/querybuilder/runner.py +48 -0
  13. fastapi_sqlalchemy_querykit-0.2.0/tests/test_config.py +64 -0
  14. fastapi_sqlalchemy_querykit-0.2.0/tests/test_pagination.py +42 -0
  15. fastapi_sqlalchemy_querykit-0.2.0/tests/test_runner.py +89 -0
  16. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/.github/workflows/release.yml +0 -0
  17. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/.gitignore +0 -0
  18. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/LICENSE +0 -0
  19. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/NOTICE +0 -0
  20. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/docs/guide.md +0 -0
  21. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/docs/operators.md +0 -0
  22. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/allowlist.py +0 -0
  23. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/builder.py +0 -0
  24. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/columns.py +0 -0
  25. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/dates.py +0 -0
  26. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/errors.py +0 -0
  27. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/mixin.py +0 -0
  28. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/operators.py +0 -0
  29. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/params.py +0 -0
  30. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/py.typed +0 -0
  31. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/resolver.py +0 -0
  32. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/querybuilder/validation.py +0 -0
  33. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/models.py +0 -0
  34. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_allowlist.py +0 -0
  35. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_execution.py +0 -0
  36. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_filters.py +0 -0
  37. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_operators.py +0 -0
  38. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_params.py +0 -0
  39. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_resolver.py +0 -0
  40. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_search.py +0 -0
  41. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_sort.py +0 -0
  42. {fastapi_sqlalchemy_querykit-0.1.0 → fastapi_sqlalchemy_querykit-0.2.0}/tests/test_startup.py +0 -0
  43. {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.1.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` + `sqlalchemy` only. Ships
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
- `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`:
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 fastapi_pagination.ext.sqlalchemy import paginate
46
+ from querybuilder import list_and_paginate
48
47
 
49
- stmt = build_query(User, params).where(User.is_deleted.is_(False))
50
- page = await paginate(db, stmt)
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)).
@@ -35,3 +35,4 @@ nav:
35
35
  - Operator reference: operators.md
36
36
  - Guide: guide.md
37
37
  - FastAPI integration: integration.md
38
+ - Pagination & query-config: pagination.md
@@ -31,6 +31,7 @@ classifiers = [
31
31
  dependencies = [
32
32
  "fastapi>=0.115",
33
33
  "sqlalchemy>=2.0",
34
+ "pydantic>=2",
34
35
  ]
35
36
 
36
37
  [project.optional-dependencies]
@@ -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.1.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