fastapi-viewsets 1.4.0__tar.gz → 1.5.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_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/PKG-INFO +23 -23
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/README.md +22 -22
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/__init__.py +30 -2
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/async_base.py +30 -2
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/async_utils.py +54 -0
- fastapi_viewsets-1.5.0/fastapi_viewsets/filtering.py +197 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/base.py +21 -1
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/peewee_adapter.py +47 -1
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/sqlalchemy_adapter.py +71 -2
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/tortoise_adapter.py +34 -1
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/utils.py +59 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets.egg-info/PKG-INFO +23 -23
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets.egg-info/SOURCES.txt +2 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/pyproject.toml +1 -1
- fastapi_viewsets-1.5.0/tests/test_search_ordering.py +441 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/LICENSE +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/_compat.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/_register.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/constants.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/db_conf.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/__init__.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/factory.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/serializer_utils.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets.egg-info/dependency_links.txt +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets.egg-info/requires.txt +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets.egg-info/top_level.txt +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/setup.cfg +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/setup.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_adapter_methods_coverage.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_async_base_viewset.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_async_driver_missing.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_async_utils.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_backward_compatibility.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_base_viewset.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_coverage_gaps.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_db_conf.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_db_conf_extended.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_edge_cases.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_error_handling.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_exception_handling.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_integration.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_missing_coverage.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_orm_adapters.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_orm_adapters_extended.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_select_prefetch_related.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_tortoise_lifecycle.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_utils.py +0 -0
- {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_viewsets_with_adapters.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi_viewsets
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: DRF-style viewsets for FastAPI with SQLAlchemy/Tortoise/Peewee adapters and Pydantic v2 support.
|
|
5
5
|
Author: Alexander Valenchits
|
|
6
6
|
License: MIT
|
|
@@ -76,7 +76,7 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
|
|
|
76
76
|
- **ORM-agnostic core** — pluggable adapters for SQLAlchemy (sync/async), Tortoise ORM, and Peewee (`ORM_TYPE` / optional extras).
|
|
77
77
|
- **Typed, Pydantic-first responses** with OpenAPI tags and schemas generated from your `response_model`.
|
|
78
78
|
- **Declarative eager loading** (`select_related` / `prefetch_related`) via an inner `RelatedConfig` class on Pydantic schemas — eliminates N+1 without touching the viewset.
|
|
79
|
-
- **Built-in list pagination** (`limit` / `offset`),
|
|
79
|
+
- **Built-in list pagination** (`limit` / `offset`), **server-side search**, **declarative ordering** and **advanced filters** (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `contains`, `in`) driven by an inner `ListConfig` class on Pydantic schemas, plus optional OAuth2 on selected operations.
|
|
80
80
|
|
|
81
81
|
## Feature matrix
|
|
82
82
|
|
|
@@ -86,8 +86,8 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
|
|
|
86
86
|
| `limit` / `offset` on LIST | Supported | Supported | Supported | Supported |
|
|
87
87
|
| OAuth2 on selected methods (`register`) | Supported | Supported | Supported | Supported |
|
|
88
88
|
| Declarative eager loading (`select_related` / `prefetch_related`) | Supported | Supported | Supported (`prefetch_related`) | Supported (`select_related`) |
|
|
89
|
-
| `search` query on LIST (server-side) |
|
|
90
|
-
| Declarative ordering / advanced filters |
|
|
89
|
+
| `search` query on LIST (server-side) | Supported | Supported | Supported | Supported |
|
|
90
|
+
| Declarative ordering / advanced filters | Supported | Supported | Supported | Supported |
|
|
91
91
|
|
|
92
92
|
## Installation
|
|
93
93
|
|
|
@@ -680,26 +680,25 @@ def pagination_hint() -> str:
|
|
|
680
680
|
return "limit and offset are parsed by `BaseViewset.list`"
|
|
681
681
|
```
|
|
682
682
|
|
|
683
|
-
**
|
|
683
|
+
**Search, ordering and filters** — declare a `ListConfig` inner class on the response schema and the LIST endpoint gains `?search=`, `?ordering=` and whitelisted `?<field>` / `?<field>__<op>` query parameters, applied server-side by every ORM adapter:
|
|
684
684
|
|
|
685
685
|
```python
|
|
686
|
-
from
|
|
687
|
-
|
|
688
|
-
def filtering_hint() -> str:
|
|
689
|
-
"""Explain that `search` is reserved; override `list` for real filters today."""
|
|
690
|
-
return "search parameter is not yet applied in adapters"
|
|
691
|
-
```
|
|
686
|
+
from pydantic import BaseModel
|
|
692
687
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
688
|
+
class ItemSchema(BaseModel):
|
|
689
|
+
id: int
|
|
690
|
+
name: str
|
|
691
|
+
status: str
|
|
697
692
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
693
|
+
class ListConfig:
|
|
694
|
+
search_fields = ["name"]
|
|
695
|
+
ordering_fields = ["name", "id"]
|
|
696
|
+
ordering = ["-id"]
|
|
697
|
+
filters = ["status"]
|
|
701
698
|
```
|
|
702
699
|
|
|
700
|
+
`GET /items?search=foo&ordering=name&status=active` then works out of the box. Operators: `ne`, `gt`, `gte`, `lt`, `lte`, `contains`, `in` (e.g. `?status__in=active,pending`). Fields outside the whitelist are ignored; ordering by an unknown field returns `400`. Without a `ListConfig`, LIST behaves exactly as before.
|
|
701
|
+
|
|
703
702
|
## Permissions and custom routes
|
|
704
703
|
|
|
705
704
|
There is no `get_queryset` hook; scope queries by subclassing `BaseViewset` and overriding `list()`, `get_element()`, or related handlers. The class subclasses `APIRouter`, so attach extra endpoints with `add_api_route` **before** `register()` if paths must win over `/{id}`:
|
|
@@ -755,16 +754,17 @@ Details: [RELEASE_NOTES.md](RELEASE_NOTES.md), [RELEASE_1.2.0.md](RELEASE_1.2.0.
|
|
|
755
754
|
|
|
756
755
|
| Item | Target | Status |
|
|
757
756
|
| --- | --- | --- |
|
|
758
|
-
|
|
|
759
|
-
|
|
|
760
|
-
|
|
|
761
|
-
|
|
757
|
+
| Transaction helpers (`begin` / `atomic`) across adapters | future | Planned |
|
|
758
|
+
| Range filters on dates (`date__range`) and null checks (`field__isnull`) | future | Planned |
|
|
759
|
+
| Cross-relation search (searching through `select_related` fields) | future | Planned |
|
|
760
|
+
|
|
761
|
+
Released: server-side `search` (v1.5.0), declarative ordering and advanced filters (v1.5.0).
|
|
762
762
|
|
|
763
763
|
## Comparison with alternatives
|
|
764
764
|
|
|
765
765
|
| Approach | Developer experience | ORM support | Permissions | Filtering |
|
|
766
766
|
| --- | --- | --- | --- | --- |
|
|
767
|
-
| fastapi-viewsets | One `BaseViewset` registers CRUD routes | SQLAlchemy sync/async, Tortoise, Peewee via adapters | OAuth2 per logical method via `register` | `limit`/`offset
|
|
767
|
+
| fastapi-viewsets | One `BaseViewset` registers CRUD routes | SQLAlchemy sync/async, Tortoise, Peewee via adapters | OAuth2 per logical method via `register` | `limit`/`offset`, `search`, `ordering` and operator filters via `ListConfig` |
|
|
768
768
|
| fastapi-crudrouter | CRUD-focused generators, less ViewSet-shaped | Primarily SQLAlchemy | Custom middleware/deps | Often extended manually |
|
|
769
769
|
| Hand-rolled FastAPI | Full control, most boilerplate | Any ORM you integrate | Fully custom | Fully custom |
|
|
770
770
|
|
|
@@ -21,7 +21,7 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
|
|
|
21
21
|
- **ORM-agnostic core** — pluggable adapters for SQLAlchemy (sync/async), Tortoise ORM, and Peewee (`ORM_TYPE` / optional extras).
|
|
22
22
|
- **Typed, Pydantic-first responses** with OpenAPI tags and schemas generated from your `response_model`.
|
|
23
23
|
- **Declarative eager loading** (`select_related` / `prefetch_related`) via an inner `RelatedConfig` class on Pydantic schemas — eliminates N+1 without touching the viewset.
|
|
24
|
-
- **Built-in list pagination** (`limit` / `offset`),
|
|
24
|
+
- **Built-in list pagination** (`limit` / `offset`), **server-side search**, **declarative ordering** and **advanced filters** (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `contains`, `in`) driven by an inner `ListConfig` class on Pydantic schemas, plus optional OAuth2 on selected operations.
|
|
25
25
|
|
|
26
26
|
## Feature matrix
|
|
27
27
|
|
|
@@ -31,8 +31,8 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
|
|
|
31
31
|
| `limit` / `offset` on LIST | Supported | Supported | Supported | Supported |
|
|
32
32
|
| OAuth2 on selected methods (`register`) | Supported | Supported | Supported | Supported |
|
|
33
33
|
| Declarative eager loading (`select_related` / `prefetch_related`) | Supported | Supported | Supported (`prefetch_related`) | Supported (`select_related`) |
|
|
34
|
-
| `search` query on LIST (server-side) |
|
|
35
|
-
| Declarative ordering / advanced filters |
|
|
34
|
+
| `search` query on LIST (server-side) | Supported | Supported | Supported | Supported |
|
|
35
|
+
| Declarative ordering / advanced filters | Supported | Supported | Supported | Supported |
|
|
36
36
|
|
|
37
37
|
## Installation
|
|
38
38
|
|
|
@@ -625,26 +625,25 @@ def pagination_hint() -> str:
|
|
|
625
625
|
return "limit and offset are parsed by `BaseViewset.list`"
|
|
626
626
|
```
|
|
627
627
|
|
|
628
|
-
**
|
|
628
|
+
**Search, ordering and filters** — declare a `ListConfig` inner class on the response schema and the LIST endpoint gains `?search=`, `?ordering=` and whitelisted `?<field>` / `?<field>__<op>` query parameters, applied server-side by every ORM adapter:
|
|
629
629
|
|
|
630
630
|
```python
|
|
631
|
-
from
|
|
632
|
-
|
|
633
|
-
def filtering_hint() -> str:
|
|
634
|
-
"""Explain that `search` is reserved; override `list` for real filters today."""
|
|
635
|
-
return "search parameter is not yet applied in adapters"
|
|
636
|
-
```
|
|
631
|
+
from pydantic import BaseModel
|
|
637
632
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
633
|
+
class ItemSchema(BaseModel):
|
|
634
|
+
id: int
|
|
635
|
+
name: str
|
|
636
|
+
status: str
|
|
642
637
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
638
|
+
class ListConfig:
|
|
639
|
+
search_fields = ["name"]
|
|
640
|
+
ordering_fields = ["name", "id"]
|
|
641
|
+
ordering = ["-id"]
|
|
642
|
+
filters = ["status"]
|
|
646
643
|
```
|
|
647
644
|
|
|
645
|
+
`GET /items?search=foo&ordering=name&status=active` then works out of the box. Operators: `ne`, `gt`, `gte`, `lt`, `lte`, `contains`, `in` (e.g. `?status__in=active,pending`). Fields outside the whitelist are ignored; ordering by an unknown field returns `400`. Without a `ListConfig`, LIST behaves exactly as before.
|
|
646
|
+
|
|
648
647
|
## Permissions and custom routes
|
|
649
648
|
|
|
650
649
|
There is no `get_queryset` hook; scope queries by subclassing `BaseViewset` and overriding `list()`, `get_element()`, or related handlers. The class subclasses `APIRouter`, so attach extra endpoints with `add_api_route` **before** `register()` if paths must win over `/{id}`:
|
|
@@ -700,16 +699,17 @@ Details: [RELEASE_NOTES.md](RELEASE_NOTES.md), [RELEASE_1.2.0.md](RELEASE_1.2.0.
|
|
|
700
699
|
|
|
701
700
|
| Item | Target | Status |
|
|
702
701
|
| --- | --- | --- |
|
|
703
|
-
|
|
|
704
|
-
|
|
|
705
|
-
|
|
|
706
|
-
|
|
702
|
+
| Transaction helpers (`begin` / `atomic`) across adapters | future | Planned |
|
|
703
|
+
| Range filters on dates (`date__range`) and null checks (`field__isnull`) | future | Planned |
|
|
704
|
+
| Cross-relation search (searching through `select_related` fields) | future | Planned |
|
|
705
|
+
|
|
706
|
+
Released: server-side `search` (v1.5.0), declarative ordering and advanced filters (v1.5.0).
|
|
707
707
|
|
|
708
708
|
## Comparison with alternatives
|
|
709
709
|
|
|
710
710
|
| Approach | Developer experience | ORM support | Permissions | Filtering |
|
|
711
711
|
| --- | --- | --- | --- | --- |
|
|
712
|
-
| fastapi-viewsets | One `BaseViewset` registers CRUD routes | SQLAlchemy sync/async, Tortoise, Peewee via adapters | OAuth2 per logical method via `register` | `limit`/`offset
|
|
712
|
+
| fastapi-viewsets | One `BaseViewset` registers CRUD routes | SQLAlchemy sync/async, Tortoise, Peewee via adapters | OAuth2 per logical method via `register` | `limit`/`offset`, `search`, `ordering` and operator filters via `ListConfig` |
|
|
713
713
|
| fastapi-crudrouter | CRUD-focused generators, less ViewSet-shaped | Primarily SQLAlchemy | Custom middleware/deps | Often extended manually |
|
|
714
714
|
| Hand-rolled FastAPI | Full control, most boilerplate | Any ORM you integrate | Fully custom | Fully custom |
|
|
715
715
|
|
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, Union
|
|
11
11
|
|
|
12
|
-
from fastapi import APIRouter, Body, Depends
|
|
12
|
+
from fastapi import APIRouter, Body, Depends, Request
|
|
13
13
|
from pydantic import BaseModel
|
|
14
14
|
from sqlalchemy.orm import Session
|
|
15
15
|
|
|
@@ -94,9 +94,30 @@ class BaseViewset(_RegisterMixin, APIRouter):
|
|
|
94
94
|
limit: Optional[int] = 10,
|
|
95
95
|
offset: Optional[int] = 0,
|
|
96
96
|
search: Optional[str] = None,
|
|
97
|
+
ordering: Optional[str] = None,
|
|
98
|
+
request: Request = None,
|
|
97
99
|
token: str = Depends(_noop_dependency),
|
|
98
100
|
) -> List[ResponseModelType]:
|
|
99
|
-
"""List items with
|
|
101
|
+
"""List items with pagination, search, ordering and filters.
|
|
102
|
+
|
|
103
|
+
Query parameters (behaviour is driven by the response schema's
|
|
104
|
+
``ListConfig`` — see :mod:`fastapi_viewsets.filtering`):
|
|
105
|
+
|
|
106
|
+
* ``limit`` / ``offset`` — pagination.
|
|
107
|
+
* ``search`` — case-insensitive substring match across
|
|
108
|
+
``ListConfig.search_fields``.
|
|
109
|
+
* ``ordering`` — comma-separated fields, ``-`` prefix for
|
|
110
|
+
descending, validated against ``ListConfig.ordering_fields``.
|
|
111
|
+
* ``<field>`` / ``<field>__<op>`` — exact and comparison filters
|
|
112
|
+
on ``ListConfig.filters`` fields.
|
|
113
|
+
"""
|
|
114
|
+
from fastapi_viewsets.filtering import (
|
|
115
|
+
get_list_config,
|
|
116
|
+
parse_filters,
|
|
117
|
+
parse_ordering_param,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
config = get_list_config(self.response_model)
|
|
100
121
|
return get_list_queryset(
|
|
101
122
|
self.model,
|
|
102
123
|
db_session=self.db_session,
|
|
@@ -104,6 +125,13 @@ class BaseViewset(_RegisterMixin, APIRouter):
|
|
|
104
125
|
offset=offset,
|
|
105
126
|
orm_adapter=self.orm_adapter,
|
|
106
127
|
response_model=self.response_model,
|
|
128
|
+
search=search,
|
|
129
|
+
ordering=parse_ordering_param(
|
|
130
|
+
ordering, config.ordering_fields, config.ordering
|
|
131
|
+
),
|
|
132
|
+
filters=parse_filters(request.query_params, config.filters)
|
|
133
|
+
if request is not None
|
|
134
|
+
else None,
|
|
107
135
|
)
|
|
108
136
|
|
|
109
137
|
def get_element(
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, Union
|
|
6
6
|
|
|
7
|
-
from fastapi import APIRouter, Body, Depends
|
|
7
|
+
from fastapi import APIRouter, Body, Depends, Request
|
|
8
8
|
from pydantic import BaseModel
|
|
9
9
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
10
|
|
|
@@ -85,9 +85,30 @@ class AsyncBaseViewset(_RegisterMixin, APIRouter):
|
|
|
85
85
|
limit: Optional[int] = 10,
|
|
86
86
|
offset: Optional[int] = 0,
|
|
87
87
|
search: Optional[str] = None,
|
|
88
|
+
ordering: Optional[str] = None,
|
|
89
|
+
request: Request = None,
|
|
88
90
|
token: str = Depends(_noop_dependency),
|
|
89
91
|
) -> List[ResponseModelType]:
|
|
90
|
-
"""List items with
|
|
92
|
+
"""List items with pagination, search, ordering and filters (async).
|
|
93
|
+
|
|
94
|
+
Query parameters (behaviour is driven by the response schema's
|
|
95
|
+
``ListConfig`` — see :mod:`fastapi_viewsets.filtering`):
|
|
96
|
+
|
|
97
|
+
* ``limit`` / ``offset`` — pagination.
|
|
98
|
+
* ``search`` — case-insensitive substring match across
|
|
99
|
+
``ListConfig.search_fields``.
|
|
100
|
+
* ``ordering`` — comma-separated fields, ``-`` prefix for
|
|
101
|
+
descending, validated against ``ListConfig.ordering_fields``.
|
|
102
|
+
* ``<field>`` / ``<field>__<op>`` — exact and comparison filters
|
|
103
|
+
on ``ListConfig.filters`` fields.
|
|
104
|
+
"""
|
|
105
|
+
from fastapi_viewsets.filtering import (
|
|
106
|
+
get_list_config,
|
|
107
|
+
parse_filters,
|
|
108
|
+
parse_ordering_param,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
config = get_list_config(self.response_model)
|
|
91
112
|
return await get_list_queryset(
|
|
92
113
|
self.model,
|
|
93
114
|
db_session=self.db_session,
|
|
@@ -95,6 +116,13 @@ class AsyncBaseViewset(_RegisterMixin, APIRouter):
|
|
|
95
116
|
offset=offset,
|
|
96
117
|
orm_adapter=self.orm_adapter,
|
|
97
118
|
response_model=self.response_model,
|
|
119
|
+
search=search,
|
|
120
|
+
ordering=parse_ordering_param(
|
|
121
|
+
ordering, config.ordering_fields, config.ordering
|
|
122
|
+
),
|
|
123
|
+
filters=parse_filters(request.query_params, config.filters)
|
|
124
|
+
if request is not None
|
|
125
|
+
else None,
|
|
98
126
|
)
|
|
99
127
|
|
|
100
128
|
async def get_element(
|
|
@@ -7,11 +7,49 @@ from pydantic import BaseModel
|
|
|
7
7
|
from fastapi_viewsets.orm.base import BaseORMAdapter
|
|
8
8
|
from fastapi_viewsets.db_conf import get_orm_adapter
|
|
9
9
|
from fastapi_viewsets.serializer_utils import get_select_related, get_prefetch_related
|
|
10
|
+
from fastapi_viewsets.filtering import get_list_config
|
|
10
11
|
|
|
11
12
|
# Type variable for ORM models
|
|
12
13
|
ModelType = TypeVar('ModelType')
|
|
13
14
|
|
|
14
15
|
|
|
16
|
+
def _resolve_list_options(
|
|
17
|
+
response_model: Optional[Type[BaseModel]],
|
|
18
|
+
search: Optional[str],
|
|
19
|
+
search_fields: Optional[List[str]],
|
|
20
|
+
ordering: Optional[List[str]],
|
|
21
|
+
):
|
|
22
|
+
"""Fill in declarative ``ListConfig`` defaults for search/ordering."""
|
|
23
|
+
if search_fields is None and search:
|
|
24
|
+
search_fields = get_list_config(response_model).search_fields
|
|
25
|
+
if ordering is None:
|
|
26
|
+
ordering = get_list_config(response_model).ordering or None
|
|
27
|
+
return search_fields, ordering
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _list_extra_kwargs(
|
|
31
|
+
search: Optional[str],
|
|
32
|
+
search_fields: Optional[List[str]],
|
|
33
|
+
ordering: Optional[List[str]],
|
|
34
|
+
filters: Optional[Dict[str, Any]],
|
|
35
|
+
) -> Dict[str, Any]:
|
|
36
|
+
"""Build adapter kwargs for search/ordering/filters.
|
|
37
|
+
|
|
38
|
+
Only non-empty values are included so custom ORM adapters that do
|
|
39
|
+
not (yet) accept the new keyword arguments keep working.
|
|
40
|
+
"""
|
|
41
|
+
extra: Dict[str, Any] = {}
|
|
42
|
+
if search:
|
|
43
|
+
extra["search"] = search
|
|
44
|
+
if search_fields:
|
|
45
|
+
extra["search_fields"] = search_fields
|
|
46
|
+
if ordering:
|
|
47
|
+
extra["ordering"] = ordering
|
|
48
|
+
if filters:
|
|
49
|
+
extra["filters"] = filters
|
|
50
|
+
return extra
|
|
51
|
+
|
|
52
|
+
|
|
15
53
|
async def get_list_queryset(
|
|
16
54
|
model: Type[ModelType],
|
|
17
55
|
db_session: Callable[[], Any],
|
|
@@ -21,6 +59,10 @@ async def get_list_queryset(
|
|
|
21
59
|
response_model: Optional[Type[BaseModel]] = None,
|
|
22
60
|
select_related: Optional[List[str]] = None,
|
|
23
61
|
prefetch_related: Optional[List[str]] = None,
|
|
62
|
+
search: Optional[str] = None,
|
|
63
|
+
search_fields: Optional[List[str]] = None,
|
|
64
|
+
ordering: Optional[List[str]] = None,
|
|
65
|
+
filters: Optional[Dict[str, Any]] = None,
|
|
24
66
|
) -> List[ModelType]:
|
|
25
67
|
"""Get list of elements from database with pagination support (async).
|
|
26
68
|
|
|
@@ -31,8 +73,16 @@ async def get_list_queryset(
|
|
|
31
73
|
offset: Number of items to skip
|
|
32
74
|
orm_adapter: ORM adapter instance (optional, uses default if not provided)
|
|
33
75
|
response_model: Pydantic schema that may define ``RelatedConfig``
|
|
76
|
+
and ``ListConfig``
|
|
34
77
|
select_related: Explicit list of FK relations to join (overrides schema config)
|
|
35
78
|
prefetch_related: Explicit list of collection relations to prefetch (overrides schema config)
|
|
79
|
+
search: Search term for case-insensitive substring matching
|
|
80
|
+
search_fields: Fields to search across (defaults to
|
|
81
|
+
``ListConfig.search_fields`` from the response schema)
|
|
82
|
+
ordering: Ordering tokens like ``["-name"]`` (defaults to the
|
|
83
|
+
declarative ``ListConfig.ordering`` from the response schema)
|
|
84
|
+
filters: Parsed filter constraints, mapping field name to
|
|
85
|
+
``(operator, value)`` (see :mod:`fastapi_viewsets.filtering`)
|
|
36
86
|
|
|
37
87
|
Returns:
|
|
38
88
|
List of model instances
|
|
@@ -44,11 +94,15 @@ async def get_list_queryset(
|
|
|
44
94
|
select_related = get_select_related(response_model)
|
|
45
95
|
if prefetch_related is None and response_model is not None:
|
|
46
96
|
prefetch_related = get_prefetch_related(response_model)
|
|
97
|
+
search_fields, ordering = _resolve_list_options(
|
|
98
|
+
response_model, search, search_fields, ordering
|
|
99
|
+
)
|
|
47
100
|
|
|
48
101
|
return await orm_adapter.get_list_queryset_async(
|
|
49
102
|
model, db_session, limit, offset,
|
|
50
103
|
select_related=select_related or None,
|
|
51
104
|
prefetch_related=prefetch_related or None,
|
|
105
|
+
**_list_extra_kwargs(search, search_fields, ordering, filters),
|
|
52
106
|
)
|
|
53
107
|
|
|
54
108
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Server-side search, ordering and filtering for LIST endpoints.
|
|
2
|
+
|
|
3
|
+
Configuration is declarative: a Pydantic response schema may declare an
|
|
4
|
+
inner ``ListConfig`` class::
|
|
5
|
+
|
|
6
|
+
class UserSchema(BaseModel):
|
|
7
|
+
class RelatedConfig:
|
|
8
|
+
select_related = ["company"]
|
|
9
|
+
|
|
10
|
+
class ListConfig:
|
|
11
|
+
search_fields = ["name", "email"]
|
|
12
|
+
ordering_fields = ["name", "created_at"]
|
|
13
|
+
ordering = ["-created_at"]
|
|
14
|
+
filters = ["status", "age", "company_id"]
|
|
15
|
+
|
|
16
|
+
The viewset ``list`` handler then automatically supports:
|
|
17
|
+
|
|
18
|
+
* ``?search=term`` — case-insensitive substring match, OR-ed across
|
|
19
|
+
``search_fields``.
|
|
20
|
+
* ``?ordering=-name,created_at`` — ordering validated against
|
|
21
|
+
``ordering_fields``; a leading ``-`` means descending. When the query
|
|
22
|
+
parameter is omitted (or ordering is not enabled), the declarative
|
|
23
|
+
default ``ordering`` applies.
|
|
24
|
+
* ``?<field>=value`` and ``?<field>__<op>=value`` — exact-match and
|
|
25
|
+
comparison filtering, restricted to the fields listed in ``filters``.
|
|
26
|
+
Supported operators: ``ne``, ``gt``, ``gte``, ``lt``, ``lte``,
|
|
27
|
+
``contains`` (case-insensitive substring) and ``in``
|
|
28
|
+
(comma-separated values).
|
|
29
|
+
|
|
30
|
+
All configuration is optional; without a ``ListConfig`` the LIST
|
|
31
|
+
endpoint behaves exactly as before (backward compatible).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from dataclasses import field as dc_field
|
|
38
|
+
from typing import Any, Dict, List, Mapping, Optional, Tuple, Type
|
|
39
|
+
|
|
40
|
+
from fastapi import HTTPException
|
|
41
|
+
from pydantic import BaseModel
|
|
42
|
+
from starlette import status
|
|
43
|
+
|
|
44
|
+
# Operators accepted after a field name in query params: ?age__gte=18
|
|
45
|
+
FILTER_OPS = ("ne", "gt", "gte", "lt", "lte", "contains", "in")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ListConfig:
|
|
50
|
+
"""Declarative LIST configuration read from a schema's ``ListConfig``."""
|
|
51
|
+
|
|
52
|
+
search_fields: List[str] = dc_field(default_factory=list)
|
|
53
|
+
ordering_fields: List[str] = dc_field(default_factory=list)
|
|
54
|
+
ordering: List[str] = dc_field(default_factory=list)
|
|
55
|
+
filters: List[str] = dc_field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_list_config(response_model: Optional[Type[BaseModel]]) -> ListConfig:
|
|
59
|
+
"""Read ``ListConfig`` from a Pydantic schema (if present).
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
response_model: Pydantic BaseModel subclass that may define an
|
|
63
|
+
inner ``ListConfig`` class.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
A :class:`ListConfig` with whatever attributes were declared;
|
|
67
|
+
an all-empty config when the schema declares nothing.
|
|
68
|
+
"""
|
|
69
|
+
if response_model is None:
|
|
70
|
+
return ListConfig()
|
|
71
|
+
cfg = getattr(response_model, "ListConfig", None)
|
|
72
|
+
if cfg is None:
|
|
73
|
+
return ListConfig()
|
|
74
|
+
return ListConfig(
|
|
75
|
+
search_fields=list(getattr(cfg, "search_fields", []) or []),
|
|
76
|
+
ordering_fields=list(getattr(cfg, "ordering_fields", []) or []),
|
|
77
|
+
ordering=list(getattr(cfg, "ordering", []) or []),
|
|
78
|
+
filters=list(getattr(cfg, "filters", []) or []),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def coerce_value(raw: str) -> Any:
|
|
83
|
+
"""Coerce a raw query-param string to int/float/bool when possible.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
raw: Raw string value from a query parameter.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
``int``, ``float`` or ``bool`` when the string parses as one,
|
|
90
|
+
otherwise the original string.
|
|
91
|
+
"""
|
|
92
|
+
if raw is None:
|
|
93
|
+
return None
|
|
94
|
+
text = str(raw).strip()
|
|
95
|
+
lowered = text.lower()
|
|
96
|
+
if lowered in ("true", "false"):
|
|
97
|
+
return lowered == "true"
|
|
98
|
+
try:
|
|
99
|
+
return int(text)
|
|
100
|
+
except ValueError:
|
|
101
|
+
pass
|
|
102
|
+
try:
|
|
103
|
+
return float(text)
|
|
104
|
+
except ValueError:
|
|
105
|
+
pass
|
|
106
|
+
return text
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def parse_ordering_param(
|
|
110
|
+
ordering: Optional[str],
|
|
111
|
+
ordering_fields: Optional[List[str]] = None,
|
|
112
|
+
default: Optional[List[str]] = None,
|
|
113
|
+
) -> Optional[List[str]]:
|
|
114
|
+
"""Parse the ``ordering`` query parameter into ordering tokens.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
ordering: Raw ``?ordering=`` value, e.g. ``"-name,created_at"``.
|
|
118
|
+
ordering_fields: Whitelist of fields that may be used. When
|
|
119
|
+
empty/None the whitelist is not enforced.
|
|
120
|
+
default: Declarative default ordering used when ``ordering`` is
|
|
121
|
+
empty or None.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
List of tokens such as ``["-name", "created_at"]``, or None when
|
|
125
|
+
neither the parameter nor a default is provided.
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
HTTPException: 400 when the parameter references a field outside
|
|
129
|
+
``ordering_fields``.
|
|
130
|
+
"""
|
|
131
|
+
if ordering is None or not ordering.strip():
|
|
132
|
+
return list(default) if default else None
|
|
133
|
+
|
|
134
|
+
allowed = set(ordering_fields or [])
|
|
135
|
+
if not allowed:
|
|
136
|
+
# Ordering is not enabled for this schema; ignore the parameter
|
|
137
|
+
# so arbitrary field names never reach the ORM.
|
|
138
|
+
return list(default) if default else None
|
|
139
|
+
|
|
140
|
+
tokens = [token.strip() for token in ordering.split(",") if token.strip()]
|
|
141
|
+
for token in tokens:
|
|
142
|
+
field_name = token.lstrip("-").strip()
|
|
143
|
+
if allowed and field_name not in allowed:
|
|
144
|
+
raise HTTPException(
|
|
145
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
146
|
+
detail=(
|
|
147
|
+
f"Invalid ordering field: '{field_name}'. "
|
|
148
|
+
f"Allowed fields: {sorted(allowed)}"
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
return tokens or (list(default) if default else None)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def parse_filters(
|
|
155
|
+
query_params: Mapping[str, Any],
|
|
156
|
+
allowed_fields: Optional[List[str]] = None,
|
|
157
|
+
) -> Dict[str, Tuple[str, Any]]:
|
|
158
|
+
"""Extract filter constraints from request query parameters.
|
|
159
|
+
|
|
160
|
+
Only fields listed in ``allowed_fields`` are extracted; everything
|
|
161
|
+
else (including ``limit``, ``offset``, ``search`` and ``ordering``)
|
|
162
|
+
is ignored.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
query_params: Query parameters mapping (e.g.
|
|
166
|
+
``request.query_params``).
|
|
167
|
+
allowed_fields: Whitelist of filterable field names.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Mapping ``field -> (operator, value)`` where operator is one of
|
|
171
|
+
``"eq"``, ``"ne"``, ``"gt"``, ``"gte"``, ``"lt"``, ``"lte"``,
|
|
172
|
+
``"contains"``, ``"in"``. For ``"in"`` the value is a list.
|
|
173
|
+
"""
|
|
174
|
+
allowed = set(allowed_fields or [])
|
|
175
|
+
if not allowed:
|
|
176
|
+
return {}
|
|
177
|
+
|
|
178
|
+
filters: Dict[str, Tuple[str, Any]] = {}
|
|
179
|
+
for key, raw in query_params.items():
|
|
180
|
+
if key in ("limit", "offset", "search", "ordering", "token"):
|
|
181
|
+
continue
|
|
182
|
+
if key not in allowed:
|
|
183
|
+
# Maybe an operator suffix: field__op
|
|
184
|
+
if "__" in key:
|
|
185
|
+
base, op = key.rsplit("__", 1)
|
|
186
|
+
if base in allowed and op in FILTER_OPS:
|
|
187
|
+
value: Any = (
|
|
188
|
+
[coerce_value(part) for part in str(raw).split(",")]
|
|
189
|
+
if op == "in"
|
|
190
|
+
else coerce_value(raw)
|
|
191
|
+
)
|
|
192
|
+
filters[base] = (op, value)
|
|
193
|
+
continue
|
|
194
|
+
if raw is None or str(raw) == "":
|
|
195
|
+
continue
|
|
196
|
+
filters[key] = ("eq", coerce_value(raw))
|
|
197
|
+
return filters
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Base abstract class for ORM adapters."""
|
|
2
2
|
|
|
3
3
|
from abc import ABC, abstractmethod
|
|
4
|
-
from typing import Optional, Dict, Any, Union, Type, TypeVar, List, Callable
|
|
4
|
+
from typing import Optional, Dict, Any, Tuple, Union, Type, TypeVar, List, Callable
|
|
5
5
|
|
|
6
6
|
# Type variable for ORM models
|
|
7
7
|
ModelType = TypeVar('ModelType')
|
|
@@ -54,6 +54,10 @@ class BaseORMAdapter(ABC):
|
|
|
54
54
|
offset: Optional[int] = None,
|
|
55
55
|
select_related: Optional[List[str]] = None,
|
|
56
56
|
prefetch_related: Optional[List[str]] = None,
|
|
57
|
+
search: Optional[str] = None,
|
|
58
|
+
search_fields: Optional[List[str]] = None,
|
|
59
|
+
ordering: Optional[List[str]] = None,
|
|
60
|
+
filters: Optional[Dict[str, Tuple[str, Any]]] = None,
|
|
57
61
|
) -> List[ModelType]:
|
|
58
62
|
"""Get list of elements from database with pagination support (synchronous).
|
|
59
63
|
|
|
@@ -62,6 +66,12 @@ class BaseORMAdapter(ABC):
|
|
|
62
66
|
db_session: Database session factory function
|
|
63
67
|
limit: Maximum number of items to return
|
|
64
68
|
offset: Number of items to skip
|
|
69
|
+
select_related: FK relations to join
|
|
70
|
+
prefetch_related: Collection relations to prefetch
|
|
71
|
+
search: Search term (case-insensitive substring match)
|
|
72
|
+
search_fields: Fields to search across
|
|
73
|
+
ordering: Ordering tokens, e.g. ["-name", "created_at"]
|
|
74
|
+
filters: Mapping field -> (operator, value) for filtering
|
|
65
75
|
|
|
66
76
|
Returns:
|
|
67
77
|
List of model instances
|
|
@@ -77,6 +87,10 @@ class BaseORMAdapter(ABC):
|
|
|
77
87
|
offset: Optional[int] = None,
|
|
78
88
|
select_related: Optional[List[str]] = None,
|
|
79
89
|
prefetch_related: Optional[List[str]] = None,
|
|
90
|
+
search: Optional[str] = None,
|
|
91
|
+
search_fields: Optional[List[str]] = None,
|
|
92
|
+
ordering: Optional[List[str]] = None,
|
|
93
|
+
filters: Optional[Dict[str, Tuple[str, Any]]] = None,
|
|
80
94
|
) -> List[ModelType]:
|
|
81
95
|
"""Get list of elements from database with pagination support (asynchronous).
|
|
82
96
|
|
|
@@ -85,6 +99,12 @@ class BaseORMAdapter(ABC):
|
|
|
85
99
|
db_session: Async database session factory function
|
|
86
100
|
limit: Maximum number of items to return
|
|
87
101
|
offset: Number of items to skip
|
|
102
|
+
select_related: FK relations to join
|
|
103
|
+
prefetch_related: Collection relations to prefetch
|
|
104
|
+
search: Search term (case-insensitive substring match)
|
|
105
|
+
search_fields: Fields to search across
|
|
106
|
+
ordering: Ordering tokens, e.g. ["-name", "created_at"]
|
|
107
|
+
filters: Mapping field -> (operator, value) for filtering
|
|
88
108
|
|
|
89
109
|
Returns:
|
|
90
110
|
List of model instances
|