fastapi-viewsets 1.3.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.3.0 → fastapi_viewsets-1.5.0}/PKG-INFO +72 -57
  2. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/README.md +62 -53
  3. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/__init__.py +30 -2
  4. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/async_base.py +30 -2
  5. {fastapi_viewsets-1.3.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.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/base.py +21 -1
  8. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/peewee_adapter.py +47 -1
  9. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/sqlalchemy_adapter.py +71 -2
  10. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/tortoise_adapter.py +56 -1
  11. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/utils.py +59 -0
  12. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets.egg-info/PKG-INFO +72 -57
  13. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets.egg-info/SOURCES.txt +3 -0
  14. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets.egg-info/requires.txt +7 -2
  15. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/pyproject.toml +8 -5
  16. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_adapter_methods_coverage.py +20 -33
  17. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_exception_handling.py +13 -25
  18. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_missing_coverage.py +2 -1
  19. fastapi_viewsets-1.5.0/tests/test_search_ordering.py +441 -0
  20. fastapi_viewsets-1.5.0/tests/test_tortoise_lifecycle.py +100 -0
  21. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/LICENSE +0 -0
  22. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/_compat.py +0 -0
  23. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/_register.py +0 -0
  24. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/constants.py +0 -0
  25. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/db_conf.py +0 -0
  26. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/__init__.py +0 -0
  27. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/factory.py +0 -0
  28. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/serializer_utils.py +0 -0
  29. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets.egg-info/dependency_links.txt +0 -0
  30. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets.egg-info/top_level.txt +0 -0
  31. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/setup.cfg +0 -0
  32. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/setup.py +0 -0
  33. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_async_base_viewset.py +0 -0
  34. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_async_driver_missing.py +0 -0
  35. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_async_utils.py +0 -0
  36. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_backward_compatibility.py +0 -0
  37. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_base_viewset.py +0 -0
  38. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_coverage_gaps.py +0 -0
  39. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_db_conf.py +0 -0
  40. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_db_conf_extended.py +0 -0
  41. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_edge_cases.py +0 -0
  42. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_error_handling.py +0 -0
  43. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_integration.py +0 -0
  44. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_orm_adapters.py +0 -0
  45. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_orm_adapters_extended.py +0 -0
  46. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_select_prefetch_related.py +0 -0
  47. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_utils.py +0 -0
  48. {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_viewsets_with_adapters.py +0 -0
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi_viewsets
3
- Version: 1.3.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
7
7
  Project-URL: Homepage, https://github.com/svalench/fastapi_viewsets
8
+ Project-URL: Documentation, https://svalench.github.io/fastapi_viewsets/
8
9
  Project-URL: Issues, https://github.com/svalench/fastapi_viewsets/issues
9
- Project-URL: Changelog, https://github.com/svalench/fastapi_viewsets/blob/main/RELEASE_NOTES.md
10
+ Project-URL: Changelog, https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_NOTES.md
10
11
  Keywords: fastapi,viewsets,crud,sqlalchemy,tortoise,peewee,pydantic
11
12
  Classifier: Programming Language :: Python :: 3
12
13
  Classifier: Programming Language :: Python :: 3.9
@@ -14,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.10
14
15
  Classifier: Programming Language :: Python :: 3.11
15
16
  Classifier: Programming Language :: Python :: 3.12
16
17
  Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
17
19
  Classifier: License :: OSI Approved :: MIT License
18
20
  Classifier: Operating System :: OS Independent
19
21
  Classifier: Framework :: FastAPI
@@ -30,10 +32,10 @@ Requires-Dist: python-dotenv>=0.19.0
30
32
  Provides-Extra: sqlalchemy
31
33
  Requires-Dist: SQLAlchemy>=1.4.36; extra == "sqlalchemy"
32
34
  Provides-Extra: tortoise
33
- Requires-Dist: tortoise-orm>=0.20.0; extra == "tortoise"
35
+ Requires-Dist: tortoise-orm<1.0,>=0.20.0; extra == "tortoise"
34
36
  Requires-Dist: asyncpg>=0.28.0; extra == "tortoise"
35
37
  Provides-Extra: peewee
36
- Requires-Dist: peewee>=3.17.0; extra == "peewee"
38
+ Requires-Dist: peewee<4,>=3.17.0; extra == "peewee"
37
39
  Provides-Extra: test
38
40
  Requires-Dist: pytest>=7.0.0; extra == "test"
39
41
  Requires-Dist: pytest-asyncio>=0.21.0; extra == "test"
@@ -45,6 +47,10 @@ Provides-Extra: lint
45
47
  Requires-Dist: ruff>=0.5; extra == "lint"
46
48
  Requires-Dist: black>=24; extra == "lint"
47
49
  Requires-Dist: mypy>=1.8; extra == "lint"
50
+ Provides-Extra: docs
51
+ Requires-Dist: mkdocs>=1.6; extra == "docs"
52
+ Requires-Dist: mkdocs-material>=9.5; extra == "docs"
53
+ Requires-Dist: pymdown-extensions>=10.7; extra == "docs"
48
54
  Dynamic: license-file
49
55
 
50
56
  # fastapi-viewsets
@@ -53,12 +59,15 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
53
59
 
54
60
  [![PyPI version](https://badge.fury.io/py/fastapi-viewsets.svg)](https://pypi.org/project/fastapi-viewsets/)
55
61
  [![Python versions](https://img.shields.io/pypi/pyversions/fastapi-viewsets.svg)](https://pypi.org/project/fastapi-viewsets/)
56
- [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://github.com/svalench/fastapi_viewsets/blob/main/LICENSE)
57
- [![CI](https://github.com/svalench/fastapi_viewsets/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/svalench/fastapi_viewsets/actions/workflows/test.yml)
62
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://github.com/svalench/fastapi_viewsets/blob/master/LICENSE)
63
+ [![CI](https://github.com/svalench/fastapi_viewsets/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/svalench/fastapi_viewsets/actions/workflows/test.yml)
58
64
  [![codecov](https://codecov.io/gh/svalench/fastapi_viewsets/graph/badge.svg)](https://codecov.io/gh/svalench/fastapi_viewsets)
59
65
  [![Downloads/month](https://static.pepy.tech/badge/fastapi-viewsets/month)](https://pepy.tech/project/fastapi-viewsets)
60
66
  [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
61
67
  [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/svalench/fastapi_viewsets/pulls)
68
+ [![Docs](https://img.shields.io/badge/docs-svalench.github.io-blue.svg)](https://svalench.github.io/fastapi_viewsets/)
69
+
70
+ **Documentation:** [https://svalench.github.io/fastapi_viewsets/](https://svalench.github.io/fastapi_viewsets/)
62
71
 
63
72
  ## Why fastapi-viewsets
64
73
 
@@ -67,7 +76,7 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
67
76
  - **ORM-agnostic core** — pluggable adapters for SQLAlchemy (sync/async), Tortoise ORM, and Peewee (`ORM_TYPE` / optional extras).
68
77
  - **Typed, Pydantic-first responses** with OpenAPI tags and schemas generated from your `response_model`.
69
78
  - **Declarative eager loading** (`select_related` / `prefetch_related`) via an inner `RelatedConfig` class on Pydantic schemas — eliminates N+1 without touching the viewset.
70
- - **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.
71
80
 
72
81
  ## Feature matrix
73
82
 
@@ -77,8 +86,8 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
77
86
  | `limit` / `offset` on LIST | Supported | Supported | Supported | Supported |
78
87
  | OAuth2 on selected methods (`register`) | Supported | Supported | Supported | Supported |
79
88
  | Declarative eager loading (`select_related` / `prefetch_related`) | Supported | Supported | Supported (`prefetch_related`) | Supported (`select_related`) |
80
- | `search` query on LIST (server-side) | **Roadmap** | **Roadmap** | **Roadmap** | **Roadmap** |
81
- | 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 |
82
91
 
83
92
  ## Installation
84
93
 
@@ -108,7 +117,7 @@ you need, and the URL shape for **PostgreSQL**, **MySQL**, and
108
117
  | --- | --- | --- | --- |
109
118
  | **SQLAlchemy (sync)** | `psycopg[binary]` or `psycopg2-binary` | `pymysql` or `mysqlclient` | `pyodbc` + ODBC Driver 17/18 |
110
119
  | **SQLAlchemy (async)** | `asyncpg` | `aiomysql` or `asyncmy` | `aioodbc` + ODBC Driver 17/18 |
111
- | **Tortoise ORM** | `asyncpg` (built-in) | `aiomysql` (built-in) | Not supported by Tortoise |
120
+ | **Tortoise ORM** | `asyncpg` (included in `[tortoise]` extra) | `aiomysql` (install separately) | Not supported by Tortoise |
112
121
  | **Peewee** | `psycopg2-binary` | `pymysql` or `mysqlclient` | Not supported by this adapter |
113
122
 
114
123
  > The `SQLAlchemyAdapter` auto-converts a sync URL to its async
@@ -191,8 +200,9 @@ TORTOISE_APP_LABEL=models
191
200
  ```
192
201
 
193
202
  ```python
203
+ from contextlib import asynccontextmanager
204
+
194
205
  from fastapi import FastAPI
195
- from tortoise import Tortoise
196
206
 
197
207
  from fastapi_viewsets import AsyncBaseViewset
198
208
  from fastapi_viewsets.orm.factory import ORMFactory
@@ -201,24 +211,19 @@ app = FastAPI()
201
211
  adapter = ORMFactory.get_default_adapter() # built from the env vars above
202
212
 
203
213
 
204
- @app.on_event("startup")
205
- async def _init_tortoise() -> None:
206
- """Open the Tortoise connection pool and create schema if needed.
214
+ @asynccontextmanager
215
+ async def lifespan(app: FastAPI):
216
+ """Open the Tortoise connection pool at startup.
207
217
 
208
- The adapter also initializes Tortoise lazily on first DB call;
209
- doing it here gives you control over schema creation.
218
+ The adapter also initialises Tortoise lazily on the first DB call;
219
+ calling ``initialize()`` here gives you control over schema creation
220
+ and avoids a cold-start penalty on the first request.
210
221
  """
211
- await Tortoise.init(
212
- db_url=adapter.database_url,
213
- modules={adapter.app_label: adapter.models},
214
- )
215
- await Tortoise.generate_schemas(safe=True)
222
+ await adapter.initialize(generate_schemas=True)
223
+ yield
224
+ await adapter.close()
216
225
 
217
-
218
- @app.on_event("shutdown")
219
- async def _close_tortoise() -> None:
220
- """Close the Tortoise connection pool."""
221
- await Tortoise.close_connections()
226
+ app = FastAPI(lifespan=lifespan)
222
227
 
223
228
 
224
229
  # Define your Tortoise models in app/models.py and pass them to AsyncBaseViewset.
@@ -236,6 +241,10 @@ async def _close_tortoise() -> None:
236
241
  # app.include_router(items)
237
242
  ```
238
243
 
244
+ > **Note:** `generate_schemas=True` is convenient for development. In
245
+ > production, use [Aerich](https://github.com/tortoise/aerich) or another
246
+ > migration tool instead of auto-generating schemas at startup.
247
+ >
239
248
  > **MSSQL is not supported by Tortoise ORM.** Use SQLAlchemy with
240
249
  > `aioodbc` for SQL Server.
241
250
 
@@ -314,6 +323,7 @@ Save as `main.py` in an empty folder and run `python main.py` or `uvicorn main:a
314
323
  from fastapi import FastAPI
315
324
  from pydantic import BaseModel, ConfigDict
316
325
  from sqlalchemy import Column, Integer, String
326
+ from typing import Optional
317
327
 
318
328
  from fastapi_viewsets import BaseViewset
319
329
  from fastapi_viewsets.db_conf import Base, engine, get_session
@@ -333,7 +343,7 @@ class ItemSchema(BaseModel):
333
343
  """Pydantic model for request and response bodies."""
334
344
 
335
345
  model_config = ConfigDict(from_attributes=True)
336
- id: int | None = None
346
+ id: Optional[int] = None
337
347
  name: str
338
348
 
339
349
 
@@ -423,9 +433,12 @@ package auto-converts `sqlite://` to `sqlite+aiosqlite://`,
423
433
  `postgresql://` to `postgresql+asyncpg://`, etc.
424
434
 
425
435
  ```python
436
+ from contextlib import asynccontextmanager
437
+
426
438
  from fastapi import FastAPI
427
439
  from pydantic import BaseModel, ConfigDict
428
440
  from sqlalchemy import Column, Integer, String
441
+ from typing import Optional
429
442
 
430
443
  from fastapi_viewsets import AsyncBaseViewset
431
444
  from fastapi_viewsets.db_conf import (
@@ -434,7 +447,16 @@ from fastapi_viewsets.db_conf import (
434
447
  get_async_session,
435
448
  )
436
449
 
437
- app = FastAPI()
450
+
451
+ @asynccontextmanager
452
+ async def lifespan(app: FastAPI):
453
+ """Create tables once on startup using the async engine."""
454
+ async with async_engine.begin() as conn:
455
+ await conn.run_sync(Base.metadata.create_all)
456
+ yield
457
+
458
+
459
+ app = FastAPI(lifespan=lifespan)
438
460
 
439
461
 
440
462
  class Item(Base):
@@ -449,17 +471,10 @@ class ItemSchema(BaseModel):
449
471
  """Pydantic v2 schema reused as request and response model."""
450
472
 
451
473
  model_config = ConfigDict(from_attributes=True)
452
- id: int | None = None
474
+ id: Optional[int] = None
453
475
  name: str
454
476
 
455
477
 
456
- @app.on_event("startup")
457
- async def _create_tables() -> None:
458
- """Create tables once on startup using the async engine."""
459
- async with async_engine.begin() as conn:
460
- await conn.run_sync(Base.metadata.create_all)
461
-
462
-
463
478
  items = AsyncBaseViewset(
464
479
  endpoint="/items",
465
480
  model=Item,
@@ -624,6 +639,7 @@ from fastapi import FastAPI
624
639
  from fastapi.security import OAuth2PasswordBearer
625
640
  from pydantic import BaseModel, ConfigDict
626
641
  from sqlalchemy import Column, Integer, String
642
+ from typing import Optional
627
643
  from fastapi_viewsets import BaseViewset
628
644
  from fastapi_viewsets.db_conf import Base, engine, get_session
629
645
 
@@ -642,7 +658,7 @@ class ItemSchema(BaseModel):
642
658
  """Pydantic schema for Item payloads and responses."""
643
659
 
644
660
  model_config = ConfigDict(from_attributes=True)
645
- id: int | None = None
661
+ id: Optional[int] = None
646
662
  name: str
647
663
 
648
664
 
@@ -664,26 +680,25 @@ def pagination_hint() -> str:
664
680
  return "limit and offset are parsed by `BaseViewset.list`"
665
681
  ```
666
682
 
667
- **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:
668
684
 
669
685
  ```python
670
- from fastapi_viewsets import BaseViewset
671
-
672
- def filtering_hint() -> str:
673
- """Explain that `search` is reserved; override `list` for real filters today."""
674
- return "search parameter is not yet applied in adapters"
675
- ```
676
-
677
- **Ordering** — there is no shared `order_by` helper yet; override `list()` with an ordered query or wait for the Roadmap.
686
+ from pydantic import BaseModel
678
687
 
679
- ```python
680
- from fastapi_viewsets import BaseViewset
688
+ class ItemSchema(BaseModel):
689
+ id: int
690
+ name: str
691
+ status: str
681
692
 
682
- def ordering_hint() -> str:
683
- """Note the absence of a built-in ordering helper on LIST endpoints."""
684
- 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"]
685
698
  ```
686
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
+
687
702
  ## Permissions and custom routes
688
703
 
689
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}`:
@@ -739,17 +754,17 @@ Details: [RELEASE_NOTES.md](RELEASE_NOTES.md), [RELEASE_1.2.0.md](RELEASE_1.2.0.
739
754
 
740
755
  | Item | Target | Status |
741
756
  | --- | --- | --- |
742
- | Dedicated `AsyncModelViewSet` ergonomics on top of SQLAlchemy 2.x async sessions | v1.2 | Planned |
743
- | First-class Tortoise ORM viewset examples and docs (`TortoiseModelViewSet` naming TBD) | v1.2 | Planned |
744
- | Async pagination helpers and transaction boundaries across adapters | v1.3 | Planned |
745
- | Nested Pydantic models with automatic eager-loading | v1.3 | **Done** |
746
- | Wire `search` on LIST to real database queries | v1.2 | 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).
747
762
 
748
763
  ## Comparison with alternatives
749
764
 
750
765
  | Approach | Developer experience | ORM support | Permissions | Filtering |
751
766
  | --- | --- | --- | --- | --- |
752
- | 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` |
753
768
  | fastapi-crudrouter | CRUD-focused generators, less ViewSet-shaped | Primarily SQLAlchemy | Custom middleware/deps | Often extended manually |
754
769
  | Hand-rolled FastAPI | Full control, most boilerplate | Any ORM you integrate | Fully custom | Fully custom |
755
770
 
@@ -4,12 +4,15 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
4
4
 
5
5
  [![PyPI version](https://badge.fury.io/py/fastapi-viewsets.svg)](https://pypi.org/project/fastapi-viewsets/)
6
6
  [![Python versions](https://img.shields.io/pypi/pyversions/fastapi-viewsets.svg)](https://pypi.org/project/fastapi-viewsets/)
7
- [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://github.com/svalench/fastapi_viewsets/blob/main/LICENSE)
8
- [![CI](https://github.com/svalench/fastapi_viewsets/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/svalench/fastapi_viewsets/actions/workflows/test.yml)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://github.com/svalench/fastapi_viewsets/blob/master/LICENSE)
8
+ [![CI](https://github.com/svalench/fastapi_viewsets/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/svalench/fastapi_viewsets/actions/workflows/test.yml)
9
9
  [![codecov](https://codecov.io/gh/svalench/fastapi_viewsets/graph/badge.svg)](https://codecov.io/gh/svalench/fastapi_viewsets)
10
10
  [![Downloads/month](https://static.pepy.tech/badge/fastapi-viewsets/month)](https://pepy.tech/project/fastapi-viewsets)
11
11
  [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
12
12
  [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/svalench/fastapi_viewsets/pulls)
13
+ [![Docs](https://img.shields.io/badge/docs-svalench.github.io-blue.svg)](https://svalench.github.io/fastapi_viewsets/)
14
+
15
+ **Documentation:** [https://svalench.github.io/fastapi_viewsets/](https://svalench.github.io/fastapi_viewsets/)
13
16
 
14
17
  ## Why fastapi-viewsets
15
18
 
@@ -18,7 +21,7 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
18
21
  - **ORM-agnostic core** — pluggable adapters for SQLAlchemy (sync/async), Tortoise ORM, and Peewee (`ORM_TYPE` / optional extras).
19
22
  - **Typed, Pydantic-first responses** with OpenAPI tags and schemas generated from your `response_model`.
20
23
  - **Declarative eager loading** (`select_related` / `prefetch_related`) via an inner `RelatedConfig` class on Pydantic schemas — eliminates N+1 without touching the viewset.
21
- - **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.
22
25
 
23
26
  ## Feature matrix
24
27
 
@@ -28,8 +31,8 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
28
31
  | `limit` / `offset` on LIST | Supported | Supported | Supported | Supported |
29
32
  | OAuth2 on selected methods (`register`) | Supported | Supported | Supported | Supported |
30
33
  | Declarative eager loading (`select_related` / `prefetch_related`) | Supported | Supported | Supported (`prefetch_related`) | Supported (`select_related`) |
31
- | `search` query on LIST (server-side) | **Roadmap** | **Roadmap** | **Roadmap** | **Roadmap** |
32
- | 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 |
33
36
 
34
37
  ## Installation
35
38
 
@@ -59,7 +62,7 @@ you need, and the URL shape for **PostgreSQL**, **MySQL**, and
59
62
  | --- | --- | --- | --- |
60
63
  | **SQLAlchemy (sync)** | `psycopg[binary]` or `psycopg2-binary` | `pymysql` or `mysqlclient` | `pyodbc` + ODBC Driver 17/18 |
61
64
  | **SQLAlchemy (async)** | `asyncpg` | `aiomysql` or `asyncmy` | `aioodbc` + ODBC Driver 17/18 |
62
- | **Tortoise ORM** | `asyncpg` (built-in) | `aiomysql` (built-in) | Not supported by Tortoise |
65
+ | **Tortoise ORM** | `asyncpg` (included in `[tortoise]` extra) | `aiomysql` (install separately) | Not supported by Tortoise |
63
66
  | **Peewee** | `psycopg2-binary` | `pymysql` or `mysqlclient` | Not supported by this adapter |
64
67
 
65
68
  > The `SQLAlchemyAdapter` auto-converts a sync URL to its async
@@ -142,8 +145,9 @@ TORTOISE_APP_LABEL=models
142
145
  ```
143
146
 
144
147
  ```python
148
+ from contextlib import asynccontextmanager
149
+
145
150
  from fastapi import FastAPI
146
- from tortoise import Tortoise
147
151
 
148
152
  from fastapi_viewsets import AsyncBaseViewset
149
153
  from fastapi_viewsets.orm.factory import ORMFactory
@@ -152,24 +156,19 @@ app = FastAPI()
152
156
  adapter = ORMFactory.get_default_adapter() # built from the env vars above
153
157
 
154
158
 
155
- @app.on_event("startup")
156
- async def _init_tortoise() -> None:
157
- """Open the Tortoise connection pool and create schema if needed.
159
+ @asynccontextmanager
160
+ async def lifespan(app: FastAPI):
161
+ """Open the Tortoise connection pool at startup.
158
162
 
159
- The adapter also initializes Tortoise lazily on first DB call;
160
- doing it here gives you control over schema creation.
163
+ The adapter also initialises Tortoise lazily on the first DB call;
164
+ calling ``initialize()`` here gives you control over schema creation
165
+ and avoids a cold-start penalty on the first request.
161
166
  """
162
- await Tortoise.init(
163
- db_url=adapter.database_url,
164
- modules={adapter.app_label: adapter.models},
165
- )
166
- await Tortoise.generate_schemas(safe=True)
167
+ await adapter.initialize(generate_schemas=True)
168
+ yield
169
+ await adapter.close()
167
170
 
168
-
169
- @app.on_event("shutdown")
170
- async def _close_tortoise() -> None:
171
- """Close the Tortoise connection pool."""
172
- await Tortoise.close_connections()
171
+ app = FastAPI(lifespan=lifespan)
173
172
 
174
173
 
175
174
  # Define your Tortoise models in app/models.py and pass them to AsyncBaseViewset.
@@ -187,6 +186,10 @@ async def _close_tortoise() -> None:
187
186
  # app.include_router(items)
188
187
  ```
189
188
 
189
+ > **Note:** `generate_schemas=True` is convenient for development. In
190
+ > production, use [Aerich](https://github.com/tortoise/aerich) or another
191
+ > migration tool instead of auto-generating schemas at startup.
192
+ >
190
193
  > **MSSQL is not supported by Tortoise ORM.** Use SQLAlchemy with
191
194
  > `aioodbc` for SQL Server.
192
195
 
@@ -265,6 +268,7 @@ Save as `main.py` in an empty folder and run `python main.py` or `uvicorn main:a
265
268
  from fastapi import FastAPI
266
269
  from pydantic import BaseModel, ConfigDict
267
270
  from sqlalchemy import Column, Integer, String
271
+ from typing import Optional
268
272
 
269
273
  from fastapi_viewsets import BaseViewset
270
274
  from fastapi_viewsets.db_conf import Base, engine, get_session
@@ -284,7 +288,7 @@ class ItemSchema(BaseModel):
284
288
  """Pydantic model for request and response bodies."""
285
289
 
286
290
  model_config = ConfigDict(from_attributes=True)
287
- id: int | None = None
291
+ id: Optional[int] = None
288
292
  name: str
289
293
 
290
294
 
@@ -374,9 +378,12 @@ package auto-converts `sqlite://` to `sqlite+aiosqlite://`,
374
378
  `postgresql://` to `postgresql+asyncpg://`, etc.
375
379
 
376
380
  ```python
381
+ from contextlib import asynccontextmanager
382
+
377
383
  from fastapi import FastAPI
378
384
  from pydantic import BaseModel, ConfigDict
379
385
  from sqlalchemy import Column, Integer, String
386
+ from typing import Optional
380
387
 
381
388
  from fastapi_viewsets import AsyncBaseViewset
382
389
  from fastapi_viewsets.db_conf import (
@@ -385,7 +392,16 @@ from fastapi_viewsets.db_conf import (
385
392
  get_async_session,
386
393
  )
387
394
 
388
- app = FastAPI()
395
+
396
+ @asynccontextmanager
397
+ async def lifespan(app: FastAPI):
398
+ """Create tables once on startup using the async engine."""
399
+ async with async_engine.begin() as conn:
400
+ await conn.run_sync(Base.metadata.create_all)
401
+ yield
402
+
403
+
404
+ app = FastAPI(lifespan=lifespan)
389
405
 
390
406
 
391
407
  class Item(Base):
@@ -400,17 +416,10 @@ class ItemSchema(BaseModel):
400
416
  """Pydantic v2 schema reused as request and response model."""
401
417
 
402
418
  model_config = ConfigDict(from_attributes=True)
403
- id: int | None = None
419
+ id: Optional[int] = None
404
420
  name: str
405
421
 
406
422
 
407
- @app.on_event("startup")
408
- async def _create_tables() -> None:
409
- """Create tables once on startup using the async engine."""
410
- async with async_engine.begin() as conn:
411
- await conn.run_sync(Base.metadata.create_all)
412
-
413
-
414
423
  items = AsyncBaseViewset(
415
424
  endpoint="/items",
416
425
  model=Item,
@@ -575,6 +584,7 @@ from fastapi import FastAPI
575
584
  from fastapi.security import OAuth2PasswordBearer
576
585
  from pydantic import BaseModel, ConfigDict
577
586
  from sqlalchemy import Column, Integer, String
587
+ from typing import Optional
578
588
  from fastapi_viewsets import BaseViewset
579
589
  from fastapi_viewsets.db_conf import Base, engine, get_session
580
590
 
@@ -593,7 +603,7 @@ class ItemSchema(BaseModel):
593
603
  """Pydantic schema for Item payloads and responses."""
594
604
 
595
605
  model_config = ConfigDict(from_attributes=True)
596
- id: int | None = None
606
+ id: Optional[int] = None
597
607
  name: str
598
608
 
599
609
 
@@ -615,26 +625,25 @@ def pagination_hint() -> str:
615
625
  return "limit and offset are parsed by `BaseViewset.list`"
616
626
  ```
617
627
 
618
- **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:
619
629
 
620
630
  ```python
621
- from fastapi_viewsets import BaseViewset
622
-
623
- def filtering_hint() -> str:
624
- """Explain that `search` is reserved; override `list` for real filters today."""
625
- return "search parameter is not yet applied in adapters"
626
- ```
627
-
628
- **Ordering** — there is no shared `order_by` helper yet; override `list()` with an ordered query or wait for the Roadmap.
631
+ from pydantic import BaseModel
629
632
 
630
- ```python
631
- from fastapi_viewsets import BaseViewset
633
+ class ItemSchema(BaseModel):
634
+ id: int
635
+ name: str
636
+ status: str
632
637
 
633
- def ordering_hint() -> str:
634
- """Note the absence of a built-in ordering helper on LIST endpoints."""
635
- 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"]
636
643
  ```
637
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
+
638
647
  ## Permissions and custom routes
639
648
 
640
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}`:
@@ -690,17 +699,17 @@ Details: [RELEASE_NOTES.md](RELEASE_NOTES.md), [RELEASE_1.2.0.md](RELEASE_1.2.0.
690
699
 
691
700
  | Item | Target | Status |
692
701
  | --- | --- | --- |
693
- | Dedicated `AsyncModelViewSet` ergonomics on top of SQLAlchemy 2.x async sessions | v1.2 | Planned |
694
- | First-class Tortoise ORM viewset examples and docs (`TortoiseModelViewSet` naming TBD) | v1.2 | Planned |
695
- | Async pagination helpers and transaction boundaries across adapters | v1.3 | Planned |
696
- | Nested Pydantic models with automatic eager-loading | v1.3 | **Done** |
697
- | Wire `search` on LIST to real database queries | v1.2 | 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).
698
707
 
699
708
  ## Comparison with alternatives
700
709
 
701
710
  | Approach | Developer experience | ORM support | Permissions | Filtering |
702
711
  | --- | --- | --- | --- | --- |
703
- | 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` |
704
713
  | fastapi-crudrouter | CRUD-focused generators, less ViewSet-shaped | Primarily SQLAlchemy | Custom middleware/deps | Often extended manually |
705
714
  | Hand-rolled FastAPI | Full control, most boilerplate | Any ORM you integrate | Fully custom | Fully custom |
706
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(