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.
Files changed (48) hide show
  1. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/PKG-INFO +23 -23
  2. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/README.md +22 -22
  3. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/__init__.py +30 -2
  4. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/async_base.py +30 -2
  5. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/async_utils.py +54 -0
  6. fastapi_viewsets-1.5.0/fastapi_viewsets/filtering.py +197 -0
  7. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/base.py +21 -1
  8. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/peewee_adapter.py +47 -1
  9. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/sqlalchemy_adapter.py +71 -2
  10. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/tortoise_adapter.py +34 -1
  11. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/utils.py +59 -0
  12. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets.egg-info/PKG-INFO +23 -23
  13. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets.egg-info/SOURCES.txt +2 -0
  14. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/pyproject.toml +1 -1
  15. fastapi_viewsets-1.5.0/tests/test_search_ordering.py +441 -0
  16. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/LICENSE +0 -0
  17. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/_compat.py +0 -0
  18. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/_register.py +0 -0
  19. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/constants.py +0 -0
  20. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/db_conf.py +0 -0
  21. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/__init__.py +0 -0
  22. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/factory.py +0 -0
  23. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/serializer_utils.py +0 -0
  24. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets.egg-info/dependency_links.txt +0 -0
  25. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets.egg-info/requires.txt +0 -0
  26. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets.egg-info/top_level.txt +0 -0
  27. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/setup.cfg +0 -0
  28. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/setup.py +0 -0
  29. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_adapter_methods_coverage.py +0 -0
  30. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_async_base_viewset.py +0 -0
  31. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_async_driver_missing.py +0 -0
  32. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_async_utils.py +0 -0
  33. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_backward_compatibility.py +0 -0
  34. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_base_viewset.py +0 -0
  35. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_coverage_gaps.py +0 -0
  36. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_db_conf.py +0 -0
  37. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_db_conf_extended.py +0 -0
  38. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_edge_cases.py +0 -0
  39. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_error_handling.py +0 -0
  40. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_exception_handling.py +0 -0
  41. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_integration.py +0 -0
  42. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_missing_coverage.py +0 -0
  43. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_orm_adapters.py +0 -0
  44. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_orm_adapters_extended.py +0 -0
  45. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_select_prefetch_related.py +0 -0
  46. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_tortoise_lifecycle.py +0 -0
  47. {fastapi_viewsets-1.4.0 → fastapi_viewsets-1.5.0}/tests/test_utils.py +0 -0
  48. {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.4.0
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`), optional OAuth2 on selected operations, and room to grow for search and richer filters (see Roadmap).
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) | **Roadmap** | **Roadmap** | **Roadmap** | **Roadmap** |
90
- | Declarative ordering / advanced filters | **Roadmap** | **Roadmap** | **Roadmap** | **Roadmap** |
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
- **Filtering** — `list` accepts `search`, but ORM adapters ignore it today; server-side search is on the Roadmap. Subclass `BaseViewset` and override `list()` with your own query until then.
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 fastapi_viewsets import BaseViewset
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
- **Ordering** — there is no shared `order_by` helper yet; override `list()` with an ordered query or wait for the Roadmap.
694
-
695
- ```python
696
- from fastapi_viewsets import BaseViewset
688
+ class ItemSchema(BaseModel):
689
+ id: int
690
+ name: str
691
+ status: str
697
692
 
698
- def ordering_hint() -> str:
699
- """Note the absence of a built-in ordering helper on LIST endpoints."""
700
- return "override list or wait for roadmap ordering helpers"
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
- | Wire `search` on LIST to real database queries | v1.4 | Planned |
759
- | Transaction helpers (`begin` / `atomic`) across adapters | v1.4 | Planned |
760
- | Declarative ordering (`order_by`) on LIST endpoints | v1.4 | Planned |
761
- | Advanced filters (`__gt`, `__lt`, `__in`) via query params | v1.5 | Planned |
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` today; `search` and advanced filters on Roadmap |
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`), optional OAuth2 on selected operations, and room to grow for search and richer filters (see Roadmap).
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) | **Roadmap** | **Roadmap** | **Roadmap** | **Roadmap** |
35
- | Declarative ordering / advanced filters | **Roadmap** | **Roadmap** | **Roadmap** | **Roadmap** |
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
- **Filtering** — `list` accepts `search`, but ORM adapters ignore it today; server-side search is on the Roadmap. Subclass `BaseViewset` and override `list()` with your own query until then.
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 fastapi_viewsets import BaseViewset
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
- **Ordering** — there is no shared `order_by` helper yet; override `list()` with an ordered query or wait for the Roadmap.
639
-
640
- ```python
641
- from fastapi_viewsets import BaseViewset
633
+ class ItemSchema(BaseModel):
634
+ id: int
635
+ name: str
636
+ status: str
642
637
 
643
- def ordering_hint() -> str:
644
- """Note the absence of a built-in ordering helper on LIST endpoints."""
645
- return "override list or wait for roadmap ordering helpers"
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
- | Wire `search` on LIST to real database queries | v1.4 | Planned |
704
- | Transaction helpers (`begin` / `atomic`) across adapters | v1.4 | Planned |
705
- | Declarative ordering (`order_by`) on LIST endpoints | v1.4 | Planned |
706
- | Advanced filters (`__gt`, `__lt`, `__in`) via query params | v1.5 | Planned |
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` today; `search` and advanced filters on Roadmap |
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 ``limit``/``offset`` pagination."""
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 ``limit``/``offset`` pagination (async)."""
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