fastapi-viewsets 1.2.0__tar.gz → 1.3.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.3.0/PKG-INFO +776 -0
- fastapi_viewsets-1.3.0/README.md +727 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/fastapi_viewsets/__init__.py +2 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/fastapi_viewsets/async_base.py +2 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/fastapi_viewsets/async_utils.py +37 -4
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/fastapi_viewsets/orm/base.py +12 -4
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/fastapi_viewsets/orm/peewee_adapter.py +16 -4
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/fastapi_viewsets/orm/sqlalchemy_adapter.py +53 -13
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/fastapi_viewsets/orm/tortoise_adapter.py +16 -3
- fastapi_viewsets-1.3.0/fastapi_viewsets/serializer_utils.py +46 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/fastapi_viewsets/utils.py +37 -4
- fastapi_viewsets-1.3.0/fastapi_viewsets.egg-info/PKG-INFO +776 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/fastapi_viewsets.egg-info/SOURCES.txt +3 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/pyproject.toml +5 -3
- fastapi_viewsets-1.3.0/tests/test_async_driver_missing.py +57 -0
- fastapi_viewsets-1.3.0/tests/test_select_prefetch_related.py +315 -0
- fastapi_viewsets-1.2.0/PKG-INFO +0 -291
- fastapi_viewsets-1.2.0/README.md +0 -242
- fastapi_viewsets-1.2.0/fastapi_viewsets.egg-info/PKG-INFO +0 -291
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/LICENSE +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/fastapi_viewsets/_compat.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/fastapi_viewsets/_register.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/fastapi_viewsets/constants.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/fastapi_viewsets/db_conf.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/fastapi_viewsets/orm/__init__.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/fastapi_viewsets/orm/factory.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/fastapi_viewsets.egg-info/dependency_links.txt +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/fastapi_viewsets.egg-info/requires.txt +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/fastapi_viewsets.egg-info/top_level.txt +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/setup.cfg +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/setup.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/tests/test_adapter_methods_coverage.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/tests/test_async_base_viewset.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/tests/test_async_utils.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/tests/test_backward_compatibility.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/tests/test_base_viewset.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/tests/test_coverage_gaps.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/tests/test_db_conf.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/tests/test_db_conf_extended.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/tests/test_edge_cases.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/tests/test_error_handling.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/tests/test_exception_handling.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/tests/test_integration.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/tests/test_missing_coverage.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/tests/test_orm_adapters.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/tests/test_orm_adapters_extended.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/tests/test_utils.py +0 -0
- {fastapi_viewsets-1.2.0 → fastapi_viewsets-1.3.0}/tests/test_viewsets_with_adapters.py +0 -0
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi_viewsets
|
|
3
|
+
Version: 1.3.0
|
|
4
|
+
Summary: DRF-style viewsets for FastAPI with SQLAlchemy/Tortoise/Peewee adapters and Pydantic v2 support.
|
|
5
|
+
Author: Alexander Valenchits
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/svalench/fastapi_viewsets
|
|
8
|
+
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
|
+
Keywords: fastapi,viewsets,crud,sqlalchemy,tortoise,peewee,pydantic
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Framework :: FastAPI
|
|
20
|
+
Classifier: Framework :: Pydantic :: 2
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: fastapi>=0.110.0
|
|
26
|
+
Requires-Dist: uvicorn>=0.17.6
|
|
27
|
+
Requires-Dist: SQLAlchemy>=1.4.36
|
|
28
|
+
Requires-Dist: pydantic<3,>=2.5
|
|
29
|
+
Requires-Dist: python-dotenv>=0.19.0
|
|
30
|
+
Provides-Extra: sqlalchemy
|
|
31
|
+
Requires-Dist: SQLAlchemy>=1.4.36; extra == "sqlalchemy"
|
|
32
|
+
Provides-Extra: tortoise
|
|
33
|
+
Requires-Dist: tortoise-orm>=0.20.0; extra == "tortoise"
|
|
34
|
+
Requires-Dist: asyncpg>=0.28.0; extra == "tortoise"
|
|
35
|
+
Provides-Extra: peewee
|
|
36
|
+
Requires-Dist: peewee>=3.17.0; extra == "peewee"
|
|
37
|
+
Provides-Extra: test
|
|
38
|
+
Requires-Dist: pytest>=7.0.0; extra == "test"
|
|
39
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "test"
|
|
40
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "test"
|
|
41
|
+
Requires-Dist: httpx>=0.24.0; extra == "test"
|
|
42
|
+
Requires-Dist: faker>=18.0.0; extra == "test"
|
|
43
|
+
Requires-Dist: aiosqlite>=0.19.0; extra == "test"
|
|
44
|
+
Provides-Extra: lint
|
|
45
|
+
Requires-Dist: ruff>=0.5; extra == "lint"
|
|
46
|
+
Requires-Dist: black>=24; extra == "lint"
|
|
47
|
+
Requires-Dist: mypy>=1.8; extra == "lint"
|
|
48
|
+
Dynamic: license-file
|
|
49
|
+
|
|
50
|
+
# fastapi-viewsets
|
|
51
|
+
|
|
52
|
+
Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoints from SQLAlchemy, Tortoise ORM, or Peewee models in minutes.
|
|
53
|
+
|
|
54
|
+
[](https://pypi.org/project/fastapi-viewsets/)
|
|
55
|
+
[](https://pypi.org/project/fastapi-viewsets/)
|
|
56
|
+
[](https://github.com/svalench/fastapi_viewsets/blob/main/LICENSE)
|
|
57
|
+
[](https://github.com/svalench/fastapi_viewsets/actions/workflows/test.yml)
|
|
58
|
+
[](https://codecov.io/gh/svalench/fastapi_viewsets)
|
|
59
|
+
[](https://pepy.tech/project/fastapi-viewsets)
|
|
60
|
+
[](https://github.com/psf/black)
|
|
61
|
+
[](https://github.com/svalench/fastapi_viewsets/pulls)
|
|
62
|
+
|
|
63
|
+
## Why fastapi-viewsets
|
|
64
|
+
|
|
65
|
+
- **DRF-style ergonomics** on top of FastAPI routers and dependency injection.
|
|
66
|
+
- **Less boilerplate** — register LIST, GET, POST, PUT, PATCH, and DELETE from one class.
|
|
67
|
+
- **ORM-agnostic core** — pluggable adapters for SQLAlchemy (sync/async), Tortoise ORM, and Peewee (`ORM_TYPE` / optional extras).
|
|
68
|
+
- **Typed, Pydantic-first responses** with OpenAPI tags and schemas generated from your `response_model`.
|
|
69
|
+
- **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).
|
|
71
|
+
|
|
72
|
+
## Feature matrix
|
|
73
|
+
|
|
74
|
+
| Feature | SQLAlchemy (sync) | SQLAlchemy (async) | Tortoise ORM | Peewee |
|
|
75
|
+
| --- | --- | --- | --- | --- |
|
|
76
|
+
| `BaseViewset` / `AsyncBaseViewset` CRUD | Supported | Supported (`AsyncBaseViewset`) | Supported via adapter + async session | Supported via adapter |
|
|
77
|
+
| `limit` / `offset` on LIST | Supported | Supported | Supported | Supported |
|
|
78
|
+
| OAuth2 on selected methods (`register`) | Supported | Supported | Supported | Supported |
|
|
79
|
+
| 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** |
|
|
82
|
+
|
|
83
|
+
## Installation
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pip install fastapi-viewsets
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Optional extras (see `setup.py`):
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pip install "fastapi-viewsets[sqlalchemy]"
|
|
93
|
+
pip install "fastapi-viewsets[tortoise]"
|
|
94
|
+
pip install "fastapi-viewsets[peewee]"
|
|
95
|
+
pip install "fastapi-viewsets[test]" # pytest, httpx, coverage, etc.
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
For async SQLAlchemy you still need a driver such as `aiosqlite`, `asyncpg`, or `aiomysql` alongside your database URL.
|
|
99
|
+
|
|
100
|
+
## Database connection examples
|
|
101
|
+
|
|
102
|
+
`fastapi-viewsets` doesn't bundle DB drivers — you pick them per stack.
|
|
103
|
+
The table below maps each ORM to the install command, the driver(s)
|
|
104
|
+
you need, and the URL shape for **PostgreSQL**, **MySQL**, and
|
|
105
|
+
**Microsoft SQL Server**.
|
|
106
|
+
|
|
107
|
+
| ORM | PostgreSQL | MySQL | MSSQL |
|
|
108
|
+
| --- | --- | --- | --- |
|
|
109
|
+
| **SQLAlchemy (sync)** | `psycopg[binary]` or `psycopg2-binary` | `pymysql` or `mysqlclient` | `pyodbc` + ODBC Driver 17/18 |
|
|
110
|
+
| **SQLAlchemy (async)** | `asyncpg` | `aiomysql` or `asyncmy` | `aioodbc` + ODBC Driver 17/18 |
|
|
111
|
+
| **Tortoise ORM** | `asyncpg` (built-in) | `aiomysql` (built-in) | Not supported by Tortoise |
|
|
112
|
+
| **Peewee** | `psycopg2-binary` | `pymysql` or `mysqlclient` | Not supported by this adapter |
|
|
113
|
+
|
|
114
|
+
> The `SQLAlchemyAdapter` auto-converts a sync URL to its async
|
|
115
|
+
> counterpart (`postgresql://` → `postgresql+asyncpg://`,
|
|
116
|
+
> `mysql://` → `mysql+aiomysql://`, `sqlite:///` →
|
|
117
|
+
> `sqlite+aiosqlite:///`). For MSSQL you have to set the async URL
|
|
118
|
+
> explicitly via `SQLALCHEMY_ASYNC_DATABASE_URL`. If the matching async
|
|
119
|
+
> driver is not installed, `SQLAlchemyAdapter` falls back to sync-only
|
|
120
|
+
> mode and `get_async_session()` raises a helpful `RuntimeError`
|
|
121
|
+
> (since v1.2.1).
|
|
122
|
+
|
|
123
|
+
The library reads database configuration from environment variables
|
|
124
|
+
(loaded via `python-dotenv` from `.env`). Pick the ORM with `ORM_TYPE`,
|
|
125
|
+
then set the URL with `<ORM>_DATABASE_URL` (or the generic
|
|
126
|
+
`DATABASE_URL`).
|
|
127
|
+
|
|
128
|
+
### SQLAlchemy (sync and async)
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
# PostgreSQL
|
|
132
|
+
pip install "fastapi-viewsets[sqlalchemy]" "psycopg[binary]" asyncpg
|
|
133
|
+
|
|
134
|
+
# MySQL
|
|
135
|
+
pip install "fastapi-viewsets[sqlalchemy]" pymysql aiomysql
|
|
136
|
+
|
|
137
|
+
# MSSQL (needs Microsoft ODBC Driver 17 or 18 on the host)
|
|
138
|
+
pip install "fastapi-viewsets[sqlalchemy]" pyodbc aioodbc
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Example `.env` (one block at a time):
|
|
142
|
+
|
|
143
|
+
```dotenv
|
|
144
|
+
# --- PostgreSQL ---
|
|
145
|
+
ORM_TYPE=sqlalchemy
|
|
146
|
+
SQLALCHEMY_DATABASE_URL=postgresql+psycopg://user:pass@db.example.com:5432/app
|
|
147
|
+
# Optional explicit async URL; otherwise auto-derived to postgresql+asyncpg://
|
|
148
|
+
SQLALCHEMY_ASYNC_DATABASE_URL=postgresql+asyncpg://user:pass@db.example.com:5432/app
|
|
149
|
+
|
|
150
|
+
# --- MySQL ---
|
|
151
|
+
ORM_TYPE=sqlalchemy
|
|
152
|
+
SQLALCHEMY_DATABASE_URL=mysql+pymysql://user:pass@db.example.com:3306/app?charset=utf8mb4
|
|
153
|
+
SQLALCHEMY_ASYNC_DATABASE_URL=mysql+aiomysql://user:pass@db.example.com:3306/app?charset=utf8mb4
|
|
154
|
+
|
|
155
|
+
# --- MSSQL ---
|
|
156
|
+
ORM_TYPE=sqlalchemy
|
|
157
|
+
# URL-encode the ODBC driver name ("+" instead of spaces).
|
|
158
|
+
SQLALCHEMY_DATABASE_URL=mssql+pyodbc://user:pass@db.example.com:1433/app?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=yes&TrustServerCertificate=no
|
|
159
|
+
SQLALCHEMY_ASYNC_DATABASE_URL=mssql+aioodbc://user:pass@db.example.com:1433/app?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=yes&TrustServerCertificate=no
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Use `BaseViewset` for sync code or `AsyncBaseViewset` for async code
|
|
163
|
+
(see the [Async quickstart](#async-quickstart-sqlalchemy-2x--pydantic-v2)
|
|
164
|
+
below).
|
|
165
|
+
|
|
166
|
+
### Tortoise ORM
|
|
167
|
+
|
|
168
|
+
Tortoise is async-only. The adapter takes a database URL plus a list
|
|
169
|
+
of model modules to register on startup.
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
# PostgreSQL
|
|
173
|
+
pip install "fastapi-viewsets[tortoise]" # pulls in asyncpg
|
|
174
|
+
|
|
175
|
+
# MySQL
|
|
176
|
+
pip install "fastapi-viewsets[tortoise]" aiomysql
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
```dotenv
|
|
180
|
+
# --- PostgreSQL ---
|
|
181
|
+
ORM_TYPE=tortoise
|
|
182
|
+
TORTOISE_DATABASE_URL=postgres://user:pass@db.example.com:5432/app
|
|
183
|
+
TORTOISE_MODELS=["app.models"]
|
|
184
|
+
TORTOISE_APP_LABEL=models
|
|
185
|
+
|
|
186
|
+
# --- MySQL ---
|
|
187
|
+
ORM_TYPE=tortoise
|
|
188
|
+
TORTOISE_DATABASE_URL=mysql://user:pass@db.example.com:3306/app
|
|
189
|
+
TORTOISE_MODELS=["app.models"]
|
|
190
|
+
TORTOISE_APP_LABEL=models
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from fastapi import FastAPI
|
|
195
|
+
from tortoise import Tortoise
|
|
196
|
+
|
|
197
|
+
from fastapi_viewsets import AsyncBaseViewset
|
|
198
|
+
from fastapi_viewsets.orm.factory import ORMFactory
|
|
199
|
+
|
|
200
|
+
app = FastAPI()
|
|
201
|
+
adapter = ORMFactory.get_default_adapter() # built from the env vars above
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@app.on_event("startup")
|
|
205
|
+
async def _init_tortoise() -> None:
|
|
206
|
+
"""Open the Tortoise connection pool and create schema if needed.
|
|
207
|
+
|
|
208
|
+
The adapter also initializes Tortoise lazily on first DB call;
|
|
209
|
+
doing it here gives you control over schema creation.
|
|
210
|
+
"""
|
|
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)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@app.on_event("shutdown")
|
|
219
|
+
async def _close_tortoise() -> None:
|
|
220
|
+
"""Close the Tortoise connection pool."""
|
|
221
|
+
await Tortoise.close_connections()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# Define your Tortoise models in app/models.py and pass them to AsyncBaseViewset.
|
|
225
|
+
# from app.models import Item
|
|
226
|
+
# from app.schemas import ItemSchema
|
|
227
|
+
# items = AsyncBaseViewset(
|
|
228
|
+
# endpoint="/items",
|
|
229
|
+
# model=Item,
|
|
230
|
+
# response_model=ItemSchema,
|
|
231
|
+
# db_session=adapter.get_async_session,
|
|
232
|
+
# orm_adapter=adapter,
|
|
233
|
+
# tags=["items"],
|
|
234
|
+
# )
|
|
235
|
+
# items.register(methods=["LIST", "GET", "POST", "PATCH", "DELETE"])
|
|
236
|
+
# app.include_router(items)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
> **MSSQL is not supported by Tortoise ORM.** Use SQLAlchemy with
|
|
240
|
+
> `aioodbc` for SQL Server.
|
|
241
|
+
|
|
242
|
+
### Peewee
|
|
243
|
+
|
|
244
|
+
Peewee is sync-only. The adapter parses the URL and instantiates the
|
|
245
|
+
right `Database` class.
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
# PostgreSQL
|
|
249
|
+
pip install "fastapi-viewsets[peewee]" psycopg2-binary
|
|
250
|
+
|
|
251
|
+
# MySQL
|
|
252
|
+
pip install "fastapi-viewsets[peewee]" pymysql
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
```dotenv
|
|
256
|
+
# --- PostgreSQL ---
|
|
257
|
+
ORM_TYPE=peewee
|
|
258
|
+
PEEWEE_DATABASE_URL=postgresql://user:pass@db.example.com:5432/app
|
|
259
|
+
|
|
260
|
+
# --- MySQL ---
|
|
261
|
+
ORM_TYPE=peewee
|
|
262
|
+
PEEWEE_DATABASE_URL=mysql://user:pass@db.example.com:3306/app
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
from fastapi import FastAPI
|
|
267
|
+
|
|
268
|
+
from fastapi_viewsets import BaseViewset
|
|
269
|
+
from fastapi_viewsets.orm.factory import ORMFactory
|
|
270
|
+
|
|
271
|
+
app = FastAPI()
|
|
272
|
+
adapter = ORMFactory.get_default_adapter() # built from the env vars above
|
|
273
|
+
|
|
274
|
+
# from app.models import Item # peewee.Model subclass
|
|
275
|
+
# from app.schemas import ItemSchema # Pydantic v2 schema
|
|
276
|
+
# items = BaseViewset(
|
|
277
|
+
# endpoint="/items",
|
|
278
|
+
# model=Item,
|
|
279
|
+
# response_model=ItemSchema,
|
|
280
|
+
# db_session=adapter.get_session,
|
|
281
|
+
# orm_adapter=adapter,
|
|
282
|
+
# tags=["items"],
|
|
283
|
+
# )
|
|
284
|
+
# items.register(methods=["LIST", "GET", "POST", "PATCH", "DELETE"])
|
|
285
|
+
# app.include_router(items)
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
> **MSSQL is not supported by this Peewee adapter** (the URL parser
|
|
289
|
+
> only handles `sqlite:///`, `postgresql://`, `postgres://`,
|
|
290
|
+
> `mysql://`). For SQL Server, use SQLAlchemy.
|
|
291
|
+
|
|
292
|
+
### Building the adapter from code (no env vars)
|
|
293
|
+
|
|
294
|
+
When you don't want to rely on environment variables, instantiate the
|
|
295
|
+
adapter directly and pass it to the viewset via `orm_adapter=`:
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
from fastapi_viewsets.orm.factory import ORMFactory
|
|
299
|
+
|
|
300
|
+
adapter = ORMFactory.create_adapter(
|
|
301
|
+
"sqlalchemy",
|
|
302
|
+
{
|
|
303
|
+
"database_url": "postgresql+psycopg://user:pass@db.example.com:5432/app",
|
|
304
|
+
"async_database_url": "postgresql+asyncpg://user:pass@db.example.com:5432/app",
|
|
305
|
+
},
|
|
306
|
+
)
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
## Quickstart (SQLAlchemy, sync)
|
|
310
|
+
|
|
311
|
+
Save as `main.py` in an empty folder and run `python main.py` or `uvicorn main:app --reload`.
|
|
312
|
+
|
|
313
|
+
```python
|
|
314
|
+
from fastapi import FastAPI
|
|
315
|
+
from pydantic import BaseModel, ConfigDict
|
|
316
|
+
from sqlalchemy import Column, Integer, String
|
|
317
|
+
|
|
318
|
+
from fastapi_viewsets import BaseViewset
|
|
319
|
+
from fastapi_viewsets.db_conf import Base, engine, get_session
|
|
320
|
+
|
|
321
|
+
app = FastAPI()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class Item(Base):
|
|
325
|
+
"""Example SQLAlchemy model."""
|
|
326
|
+
|
|
327
|
+
__tablename__ = "items"
|
|
328
|
+
id = Column(Integer, primary_key=True)
|
|
329
|
+
name = Column(String(255), nullable=False)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class ItemSchema(BaseModel):
|
|
333
|
+
"""Pydantic model for request and response bodies."""
|
|
334
|
+
|
|
335
|
+
model_config = ConfigDict(from_attributes=True)
|
|
336
|
+
id: int | None = None
|
|
337
|
+
name: str
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
Base.metadata.create_all(bind=engine)
|
|
341
|
+
items = BaseViewset(endpoint="/items", model=Item, response_model=ItemSchema, db_session=get_session, tags=["items"])
|
|
342
|
+
items.register(methods=["LIST", "GET", "POST", "PATCH", "DELETE"])
|
|
343
|
+
app.include_router(items)
|
|
344
|
+
|
|
345
|
+
if __name__ == "__main__":
|
|
346
|
+
import uvicorn
|
|
347
|
+
|
|
348
|
+
uvicorn.run(app, host="127.0.0.1", port=8000)
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
`GET /items` returns `200` with a JSON list (possibly empty). Use `POST /items` with `{"name": "apple"}` to create rows.
|
|
352
|
+
|
|
353
|
+
## Eager loading (`select_related` / `prefetch_related`)
|
|
354
|
+
|
|
355
|
+
If your Pydantic schema includes nested models (e.g. `author: UserSchema`), SQLAlchemy will normally emit extra queries for every row (the classic **N+1** problem). You can fix this declaratively by adding an inner `RelatedConfig` class to the schema:
|
|
356
|
+
|
|
357
|
+
```python
|
|
358
|
+
from pydantic import BaseModel, ConfigDict
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class AuthorSchema(BaseModel):
|
|
362
|
+
model_config = ConfigDict(from_attributes=True)
|
|
363
|
+
id: int
|
|
364
|
+
name: str
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class PostSchema(BaseModel):
|
|
368
|
+
model_config = ConfigDict(from_attributes=True)
|
|
369
|
+
id: int
|
|
370
|
+
title: str
|
|
371
|
+
author: AuthorSchema # nested model → signals the need for a join
|
|
372
|
+
|
|
373
|
+
class RelatedConfig:
|
|
374
|
+
select_related = ["author"] # FK / many-to-one → one JOIN query
|
|
375
|
+
prefetch_related = ["tags"] # collections / M2M → separate SELECT IN
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
When `PostSchema` is passed as `response_model` to a viewset, `LIST` and `GET` automatically apply the correct eager-loading strategy:
|
|
379
|
+
|
|
380
|
+
```python
|
|
381
|
+
posts = AsyncBaseViewset(
|
|
382
|
+
endpoint="/posts",
|
|
383
|
+
model=Post,
|
|
384
|
+
response_model=PostSchema,
|
|
385
|
+
db_session=get_async_session,
|
|
386
|
+
)
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
- `select_related` — `joinedload` in SQLAlchemy (single query, FK side).
|
|
390
|
+
- `prefetch_related` — `selectinload` in SQLAlchemy (two queries, collection side, no Cartesian product).
|
|
391
|
+
- Tortoise ORM uses its native `prefetch_related()` for both lists.
|
|
392
|
+
- Peewee uses `.join()` for `select_related`.
|
|
393
|
+
|
|
394
|
+
You can also override the config per-call when using the low-level utilities directly:
|
|
395
|
+
|
|
396
|
+
```python
|
|
397
|
+
from fastapi_viewsets.async_utils import get_list_queryset
|
|
398
|
+
|
|
399
|
+
posts = await get_list_queryset(
|
|
400
|
+
Post,
|
|
401
|
+
db_session=get_async_session,
|
|
402
|
+
response_model=PostSchema,
|
|
403
|
+
select_related=["author"],
|
|
404
|
+
prefetch_related=["tags", "comments"],
|
|
405
|
+
)
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
> **Backward compatibility:** all new parameters default to `None`. Existing code and tests continue to work unchanged.
|
|
409
|
+
|
|
410
|
+
## Async quickstart (SQLAlchemy 2.x + Pydantic v2)
|
|
411
|
+
|
|
412
|
+
`AsyncBaseViewset` mirrors `BaseViewset` but every CRUD handler is
|
|
413
|
+
`async`, backed by an async SQLAlchemy `AsyncSession`. Install an async
|
|
414
|
+
driver alongside the package:
|
|
415
|
+
|
|
416
|
+
```bash
|
|
417
|
+
pip install "fastapi-viewsets[sqlalchemy]" aiosqlite
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Point `SQLALCHEMY_DATABASE_URL` (or `SQLALCHEMY_ASYNC_DATABASE_URL`) at
|
|
421
|
+
an async-capable URL and use the lazy helpers from `db_conf`. The
|
|
422
|
+
package auto-converts `sqlite://` to `sqlite+aiosqlite://`,
|
|
423
|
+
`postgresql://` to `postgresql+asyncpg://`, etc.
|
|
424
|
+
|
|
425
|
+
```python
|
|
426
|
+
from fastapi import FastAPI
|
|
427
|
+
from pydantic import BaseModel, ConfigDict
|
|
428
|
+
from sqlalchemy import Column, Integer, String
|
|
429
|
+
|
|
430
|
+
from fastapi_viewsets import AsyncBaseViewset
|
|
431
|
+
from fastapi_viewsets.db_conf import (
|
|
432
|
+
Base,
|
|
433
|
+
async_engine,
|
|
434
|
+
get_async_session,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
app = FastAPI()
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class Item(Base):
|
|
441
|
+
"""Async-friendly SQLAlchemy model."""
|
|
442
|
+
|
|
443
|
+
__tablename__ = "items_async"
|
|
444
|
+
id = Column(Integer, primary_key=True)
|
|
445
|
+
name = Column(String(255), nullable=False)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class ItemSchema(BaseModel):
|
|
449
|
+
"""Pydantic v2 schema reused as request and response model."""
|
|
450
|
+
|
|
451
|
+
model_config = ConfigDict(from_attributes=True)
|
|
452
|
+
id: int | None = None
|
|
453
|
+
name: str
|
|
454
|
+
|
|
455
|
+
|
|
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
|
+
items = AsyncBaseViewset(
|
|
464
|
+
endpoint="/items",
|
|
465
|
+
model=Item,
|
|
466
|
+
response_model=ItemSchema,
|
|
467
|
+
db_session=get_async_session,
|
|
468
|
+
tags=["items"],
|
|
469
|
+
)
|
|
470
|
+
items.register(methods=["LIST", "GET", "POST", "PATCH", "DELETE"])
|
|
471
|
+
app.include_router(items)
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
Notes:
|
|
475
|
+
|
|
476
|
+
- Pydantic v2 is required (`pydantic>=2.5`). Use
|
|
477
|
+
`model_config = ConfigDict(from_attributes=True)` instead of the v1
|
|
478
|
+
`class Config: orm_mode = True`.
|
|
479
|
+
- `PATCH` uses `model_dump(exclude_unset=True)` internally, so unset
|
|
480
|
+
fields are no longer overwritten with defaults.
|
|
481
|
+
- If the async driver (`aiosqlite` / `asyncpg` / `aiomysql`) is not
|
|
482
|
+
installed, sync usage still works — only `get_async_session()` raises
|
|
483
|
+
a helpful `RuntimeError`.
|
|
484
|
+
|
|
485
|
+
## Overriding `list` and `create_element` (custom LIST and POST)
|
|
486
|
+
|
|
487
|
+
Every CRUD handler is a regular method, so subclassing the viewset is
|
|
488
|
+
the canonical way to add filtering, ordering, validation, conflict
|
|
489
|
+
handling, and so on. The example below subclasses `AsyncBaseViewset`
|
|
490
|
+
and overrides both `list` (case-insensitive search + simple ordering)
|
|
491
|
+
and `create_element` (input normalization + map `IntegrityError` to
|
|
492
|
+
409).
|
|
493
|
+
|
|
494
|
+
```python
|
|
495
|
+
from typing import List, Optional
|
|
496
|
+
|
|
497
|
+
from fastapi import Body, HTTPException, status
|
|
498
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
499
|
+
from sqlalchemy import Column, DateTime, Integer, String, func, select
|
|
500
|
+
from sqlalchemy.exc import IntegrityError
|
|
501
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
502
|
+
|
|
503
|
+
from fastapi_viewsets import AsyncBaseViewset
|
|
504
|
+
from fastapi_viewsets.db_conf import Base, get_async_session
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
class Item(Base):
|
|
508
|
+
"""Item model with timestamps and a unique name."""
|
|
509
|
+
|
|
510
|
+
__tablename__ = "items_custom"
|
|
511
|
+
id = Column(Integer, primary_key=True)
|
|
512
|
+
name = Column(String(255), nullable=False, unique=True, index=True)
|
|
513
|
+
description = Column(String(1024), nullable=True)
|
|
514
|
+
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
class ItemSchema(BaseModel):
|
|
518
|
+
"""Single Pydantic v2 schema reused as request and response model.
|
|
519
|
+
|
|
520
|
+
Server-controlled fields (``id``, ``created_at``) are optional so
|
|
521
|
+
the same schema can be used for POST/PATCH bodies and responses
|
|
522
|
+
— ``register()`` patches the body annotation to ``response_model``.
|
|
523
|
+
"""
|
|
524
|
+
|
|
525
|
+
model_config = ConfigDict(from_attributes=True, str_strip_whitespace=True)
|
|
526
|
+
id: Optional[int] = None
|
|
527
|
+
name: str = Field(..., min_length=1, max_length=255)
|
|
528
|
+
description: Optional[str] = Field(default=None, max_length=1024)
|
|
529
|
+
created_at: Optional[object] = None # datetime in real code
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
class ItemsViewSet(AsyncBaseViewset):
|
|
533
|
+
"""Custom async viewset that overrides LIST and POST."""
|
|
534
|
+
|
|
535
|
+
async def list( # type: ignore[override]
|
|
536
|
+
self,
|
|
537
|
+
limit: int = 20,
|
|
538
|
+
offset: int = 0,
|
|
539
|
+
search: Optional[str] = None,
|
|
540
|
+
order_by: str = "-created_at",
|
|
541
|
+
token: Optional[str] = None,
|
|
542
|
+
) -> List[ItemSchema]:
|
|
543
|
+
"""Custom LIST: case-insensitive search + whitelist ordering.
|
|
544
|
+
|
|
545
|
+
Query: ``GET /items?search=foo&order_by=-name&limit=10``.
|
|
546
|
+
"""
|
|
547
|
+
session: AsyncSession = self.db_session()
|
|
548
|
+
try:
|
|
549
|
+
stmt = select(self.model)
|
|
550
|
+
if search:
|
|
551
|
+
stmt = stmt.where(self.model.name.ilike(f"%{search}%"))
|
|
552
|
+
|
|
553
|
+
# "-name" → desc, "name" → asc; whitelist allowed columns.
|
|
554
|
+
field, desc = (order_by[1:], True) if order_by.startswith("-") else (order_by, False)
|
|
555
|
+
column = {"name": self.model.name, "created_at": self.model.created_at}.get(field)
|
|
556
|
+
if column is None:
|
|
557
|
+
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unsupported order_by")
|
|
558
|
+
stmt = stmt.order_by(column.desc() if desc else column.asc())
|
|
559
|
+
stmt = stmt.offset(offset).limit(limit)
|
|
560
|
+
|
|
561
|
+
rows = (await session.execute(stmt)).scalars().all()
|
|
562
|
+
return [ItemSchema.model_validate(row) for row in rows]
|
|
563
|
+
finally:
|
|
564
|
+
await session.close()
|
|
565
|
+
|
|
566
|
+
async def create_element( # type: ignore[override]
|
|
567
|
+
self,
|
|
568
|
+
item: ItemSchema = Body(...),
|
|
569
|
+
token: Optional[str] = None,
|
|
570
|
+
) -> ItemSchema:
|
|
571
|
+
"""Custom POST: normalize, persist, map IntegrityError to 409."""
|
|
572
|
+
# Pydantic v2 dump; ``str_strip_whitespace`` already trimmed strings.
|
|
573
|
+
payload = item.model_dump(exclude_unset=True, exclude={"id", "created_at"})
|
|
574
|
+
|
|
575
|
+
session: AsyncSession = self.db_session()
|
|
576
|
+
try:
|
|
577
|
+
obj = self.model(**payload)
|
|
578
|
+
session.add(obj)
|
|
579
|
+
try:
|
|
580
|
+
await session.commit()
|
|
581
|
+
except IntegrityError as exc:
|
|
582
|
+
await session.rollback()
|
|
583
|
+
raise HTTPException(
|
|
584
|
+
status.HTTP_409_CONFLICT,
|
|
585
|
+
f"Item '{payload.get('name')}' already exists",
|
|
586
|
+
) from exc
|
|
587
|
+
await session.refresh(obj)
|
|
588
|
+
return ItemSchema.model_validate(obj)
|
|
589
|
+
finally:
|
|
590
|
+
await session.close()
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
items = ItemsViewSet(
|
|
594
|
+
endpoint="/items",
|
|
595
|
+
model=Item,
|
|
596
|
+
response_model=ItemSchema,
|
|
597
|
+
db_session=get_async_session,
|
|
598
|
+
tags=["items"],
|
|
599
|
+
)
|
|
600
|
+
items.register(methods=["LIST", "GET", "POST", "PATCH", "DELETE"])
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
Key points when overriding:
|
|
604
|
+
|
|
605
|
+
- **Keep the method names and the `item` body parameter.** `register()`
|
|
606
|
+
introspects `list`, `get_element`, `create_element`,
|
|
607
|
+
`update_element`, `delete_element`. It also rewrites the
|
|
608
|
+
``item.__annotation__`` to `response_model` so the OpenAPI body
|
|
609
|
+
schema stays consistent — use the same schema for request and
|
|
610
|
+
response, or pre-validate inside the handler.
|
|
611
|
+
- **Adding new query parameters is fine** (`search`, `order_by`,
|
|
612
|
+
filters, etc.); FastAPI picks them up automatically.
|
|
613
|
+
- **Manage your own session lifecycle** in overrides (`try/finally` +
|
|
614
|
+
`await session.close()`) or use a FastAPI dependency with `yield`.
|
|
615
|
+
- For sync apps, the same pattern applies to `BaseViewset` — just drop
|
|
616
|
+
the `async`/`await` and use `Session` instead of `AsyncSession`.
|
|
617
|
+
|
|
618
|
+
## Authentication example
|
|
619
|
+
|
|
620
|
+
`register()` accepts `OAuth2PasswordBearer` plus a list of logical operations (`POST`, `PUT`, …) that require a bearer token.
|
|
621
|
+
|
|
622
|
+
```python
|
|
623
|
+
from fastapi import FastAPI
|
|
624
|
+
from fastapi.security import OAuth2PasswordBearer
|
|
625
|
+
from pydantic import BaseModel, ConfigDict
|
|
626
|
+
from sqlalchemy import Column, Integer, String
|
|
627
|
+
from fastapi_viewsets import BaseViewset
|
|
628
|
+
from fastapi_viewsets.db_conf import Base, engine, get_session
|
|
629
|
+
|
|
630
|
+
app = FastAPI()
|
|
631
|
+
oauth2 = OAuth2PasswordBearer(tokenUrl="/token")
|
|
632
|
+
|
|
633
|
+
class Item(Base):
|
|
634
|
+
"""SQLAlchemy model for OAuth2-protected writes."""
|
|
635
|
+
|
|
636
|
+
__tablename__ = "items_oauth"
|
|
637
|
+
id = Column(Integer, primary_key=True)
|
|
638
|
+
name = Column(String(255), nullable=False)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
class ItemSchema(BaseModel):
|
|
642
|
+
"""Pydantic schema for Item payloads and responses."""
|
|
643
|
+
|
|
644
|
+
model_config = ConfigDict(from_attributes=True)
|
|
645
|
+
id: int | None = None
|
|
646
|
+
name: str
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
Base.metadata.create_all(bind=engine)
|
|
650
|
+
router = BaseViewset(endpoint="/items", model=Item, response_model=ItemSchema, db_session=get_session, tags=["items"])
|
|
651
|
+
router.register(methods=["LIST", "GET", "POST", "PATCH", "DELETE"], oauth_protect=oauth2, protected_methods=["POST", "PATCH", "DELETE"])
|
|
652
|
+
app.include_router(router)
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
## Pagination, filtering, ordering
|
|
656
|
+
|
|
657
|
+
**Pagination** — `BaseViewset.list` maps `limit` and `offset` to query parameters on the LIST route.
|
|
658
|
+
|
|
659
|
+
```python
|
|
660
|
+
from fastapi_viewsets import BaseViewset
|
|
661
|
+
|
|
662
|
+
def pagination_hint() -> str:
|
|
663
|
+
"""Document LIST pagination after `register()` (e.g. GET /items?limit=10&offset=20)."""
|
|
664
|
+
return "limit and offset are parsed by `BaseViewset.list`"
|
|
665
|
+
```
|
|
666
|
+
|
|
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.
|
|
668
|
+
|
|
669
|
+
```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.
|
|
678
|
+
|
|
679
|
+
```python
|
|
680
|
+
from fastapi_viewsets import BaseViewset
|
|
681
|
+
|
|
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"
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
## Permissions and custom routes
|
|
688
|
+
|
|
689
|
+
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
|
+
|
|
691
|
+
```python
|
|
692
|
+
from fastapi_viewsets import BaseViewset
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
class ItemsWithStats(BaseViewset):
|
|
696
|
+
"""Adds a custom read-only route alongside generated CRUD."""
|
|
697
|
+
|
|
698
|
+
def __init__(self, *args, **kwargs):
|
|
699
|
+
"""Register static paths before CRUD routes."""
|
|
700
|
+
super().__init__(*args, **kwargs)
|
|
701
|
+
self.add_api_route(
|
|
702
|
+
f"{self.endpoint}/stats",
|
|
703
|
+
self.collection_stats,
|
|
704
|
+
methods=["GET"],
|
|
705
|
+
tags=self.tags or [],
|
|
706
|
+
name="items_stats",
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
def collection_stats(self) -> dict[str, str]:
|
|
710
|
+
"""Return a minimal summary for monitoring or health checks."""
|
|
711
|
+
return {"resource": self.endpoint.strip("/")}
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
# Instantiate with model, response_model, and db_session (see quickstart), then call register().
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
## What is new
|
|
718
|
+
|
|
719
|
+
### v1.3.0
|
|
720
|
+
|
|
721
|
+
- **Declarative eager loading** via `RelatedConfig` inside Pydantic schemas.
|
|
722
|
+
Add `select_related = [...]` and/or `prefetch_related = [...]` to a schema's inner `RelatedConfig` class, and `BaseViewset` / `AsyncBaseViewset` automatically applies `joinedload` / `selectinload` (SQLAlchemy) or `prefetch_related` (Tortoise) on `LIST` and `GET` endpoints. This eliminates N+1 queries without duplicating configuration between schemas and viewsets.
|
|
723
|
+
- All adapter methods (`get_list_queryset`, `get_element_by_id`, and their async counterparts) accept optional `select_related` and `prefetch_related` arguments for explicit overrides.
|
|
724
|
+
- Full backward compatibility: new parameters default to `None`; existing code works unchanged.
|
|
725
|
+
|
|
726
|
+
### v1.2.0
|
|
727
|
+
|
|
728
|
+
- Pydantic v2 first: CRUD handlers use `model_dump(exclude_unset=...)`, fixing PATCH semantics that previously overwrote unset fields with defaults.
|
|
729
|
+
- Lazy `db_conf`: importing the package no longer creates SQLAlchemy engines unless they are needed, and works without async drivers installed.
|
|
730
|
+
- Single source of truth for sync→async URL conversion and the default adapter singleton.
|
|
731
|
+
- Internal `register()` deduplicated between sync and async viewsets via a shared mixin.
|
|
732
|
+
- PEP 621 `pyproject.toml`, `python_requires>=3.9`, FastAPI `>=0.110`, ruff/black/mypy preconfigured.
|
|
733
|
+
|
|
734
|
+
Previous release: [v1.1.0](RELEASE_1.1.0.md) introduced multi-ORM support via adapters (SQLAlchemy default, optional Tortoise and Peewee), `ORMFactory` and environment-driven `ORM_TYPE` configuration.
|
|
735
|
+
|
|
736
|
+
Details: [RELEASE_NOTES.md](RELEASE_NOTES.md), [RELEASE_1.2.0.md](RELEASE_1.2.0.md), [RELEASE_1.1.0.md](RELEASE_1.1.0.md).
|
|
737
|
+
|
|
738
|
+
## Roadmap (planned)
|
|
739
|
+
|
|
740
|
+
| Item | Target | Status |
|
|
741
|
+
| --- | --- | --- |
|
|
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 |
|
|
747
|
+
|
|
748
|
+
## Comparison with alternatives
|
|
749
|
+
|
|
750
|
+
| Approach | Developer experience | ORM support | Permissions | Filtering |
|
|
751
|
+
| --- | --- | --- | --- | --- |
|
|
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 |
|
|
753
|
+
| fastapi-crudrouter | CRUD-focused generators, less ViewSet-shaped | Primarily SQLAlchemy | Custom middleware/deps | Often extended manually |
|
|
754
|
+
| Hand-rolled FastAPI | Full control, most boilerplate | Any ORM you integrate | Fully custom | Fully custom |
|
|
755
|
+
|
|
756
|
+
## Testing
|
|
757
|
+
|
|
758
|
+
From the repository root (see `pytest.ini`):
|
|
759
|
+
|
|
760
|
+
```bash
|
|
761
|
+
pytest
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
Coverage is enforced with `--cov-fail-under=70` (HTML and XML reports are emitted for local inspection).
|
|
765
|
+
|
|
766
|
+
## Contributing
|
|
767
|
+
|
|
768
|
+
See [open issues](https://github.com/svalench/fastapi_viewsets/issues) to propose changes; pull requests are welcome.
|
|
769
|
+
|
|
770
|
+
## License
|
|
771
|
+
|
|
772
|
+
Distributed under the MIT License. See [LICENSE](LICENSE).
|
|
773
|
+
|
|
774
|
+
## Author
|
|
775
|
+
|
|
776
|
+
Built by [Alexander Valenchits](https://github.com/svalench) — Tech Lead @ AluSoft, Minsk.
|