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