@@ -90,6 +90,214 @@ if __name__ == "__main__":
9090
9191` GET /items ` returns ` 200 ` with a JSON list (possibly empty). Use ` POST /items ` with ` {"name": "apple"} ` to create rows.
9292
93+ ## Async quickstart (SQLAlchemy 2.x + Pydantic v2)
94+
95+ ` AsyncBaseViewset ` mirrors ` BaseViewset ` but every CRUD handler is
96+ ` async ` , backed by an async SQLAlchemy ` AsyncSession ` . Install an async
97+ driver alongside the package:
98+
99+ ``` bash
100+ pip install " fastapi-viewsets[sqlalchemy]" aiosqlite
101+ ```
102+
103+ Point ` SQLALCHEMY_DATABASE_URL ` (or ` SQLALCHEMY_ASYNC_DATABASE_URL ` ) at
104+ an async-capable URL and use the lazy helpers from ` db_conf ` . The
105+ package auto-converts ` sqlite:// ` to ` sqlite+aiosqlite:// ` ,
106+ ` postgresql:// ` to ` postgresql+asyncpg:// ` , etc.
107+
108+ ``` python
109+ from fastapi import FastAPI
110+ from pydantic import BaseModel, ConfigDict
111+ from sqlalchemy import Column, Integer, String
112+
113+ from fastapi_viewsets import AsyncBaseViewset
114+ from fastapi_viewsets.db_conf import (
115+ Base,
116+ async_engine,
117+ get_async_session,
118+ )
119+
120+ app = FastAPI()
121+
122+
123+ class Item (Base ):
124+ """ Async-friendly SQLAlchemy model."""
125+
126+ __tablename__ = " items_async"
127+ id = Column(Integer, primary_key = True )
128+ name = Column(String(255 ), nullable = False )
129+
130+
131+ class ItemSchema (BaseModel ):
132+ """ Pydantic v2 schema reused as request and response model."""
133+
134+ model_config = ConfigDict(from_attributes = True )
135+ id : int | None = None
136+ name: str
137+
138+
139+ @app.on_event (" startup" )
140+ async def _create_tables () -> None :
141+ """ Create tables once on startup using the async engine."""
142+ async with async_engine.begin() as conn:
143+ await conn.run_sync(Base.metadata.create_all)
144+
145+
146+ items = AsyncBaseViewset(
147+ endpoint = " /items" ,
148+ model = Item,
149+ response_model = ItemSchema,
150+ db_session = get_async_session,
151+ tags = [" items" ],
152+ )
153+ items.register(methods = [" LIST" , " GET" , " POST" , " PATCH" , " DELETE" ])
154+ app.include_router(items)
155+ ```
156+
157+ Notes:
158+
159+ - Pydantic v2 is required (` pydantic>=2.5 ` ). Use
160+ ` model_config = ConfigDict(from_attributes=True) ` instead of the v1
161+ ` class Config: orm_mode = True ` .
162+ - ` PATCH ` uses ` model_dump(exclude_unset=True) ` internally, so unset
163+ fields are no longer overwritten with defaults.
164+ - If the async driver (` aiosqlite ` / ` asyncpg ` / ` aiomysql ` ) is not
165+ installed, sync usage still works — only ` get_async_session() ` raises
166+ a helpful ` RuntimeError ` .
167+
168+ ## Overriding ` list ` and ` create_element ` (custom LIST and POST)
169+
170+ Every CRUD handler is a regular method, so subclassing the viewset is
171+ the canonical way to add filtering, ordering, validation, conflict
172+ handling, and so on. The example below subclasses ` AsyncBaseViewset `
173+ and overrides both ` list ` (case-insensitive search + simple ordering)
174+ and ` create_element ` (input normalization + map ` IntegrityError ` to
175+ 409).
176+
177+ ``` python
178+ from typing import List, Optional
179+
180+ from fastapi import Body, HTTPException, status
181+ from pydantic import BaseModel, ConfigDict, Field
182+ from sqlalchemy import Column, DateTime, Integer, String, func, select
183+ from sqlalchemy.exc import IntegrityError
184+ from sqlalchemy.ext.asyncio import AsyncSession
185+
186+ from fastapi_viewsets import AsyncBaseViewset
187+ from fastapi_viewsets.db_conf import Base, get_async_session
188+
189+
190+ class Item (Base ):
191+ """ Item model with timestamps and a unique name."""
192+
193+ __tablename__ = " items_custom"
194+ id = Column(Integer, primary_key = True )
195+ name = Column(String(255 ), nullable = False , unique = True , index = True )
196+ description = Column(String(1024 ), nullable = True )
197+ created_at = Column(DateTime(timezone = True ), server_default = func.now(), nullable = False )
198+
199+
200+ class ItemSchema (BaseModel ):
201+ """ Single Pydantic v2 schema reused as request and response model.
202+
203+ Server-controlled fields (``id``, ``created_at``) are optional so
204+ the same schema can be used for POST/PATCH bodies and responses
205+ — ``register()`` patches the body annotation to ``response_model``.
206+ """
207+
208+ model_config = ConfigDict(from_attributes = True , str_strip_whitespace = True )
209+ id : Optional[int ] = None
210+ name: str = Field(... , min_length = 1 , max_length = 255 )
211+ description: Optional[str ] = Field(default = None , max_length = 1024 )
212+ created_at: Optional[object ] = None # datetime in real code
213+
214+
215+ class ItemsViewSet (AsyncBaseViewset ):
216+ """ Custom async viewset that overrides LIST and POST."""
217+
218+ async def list ( # type: ignore [ override ]
219+ self ,
220+ limit : int = 20 ,
221+ offset : int = 0 ,
222+ search : Optional[str ] = None ,
223+ order_by : str = " -created_at" ,
224+ token : Optional[str ] = None ,
225+ ) -> List[ItemSchema]:
226+ """ Custom LIST: case-insensitive search + whitelist ordering.
227+
228+ Query: ``GET /items?search=foo&order_by=-name&limit=10``.
229+ """
230+ session: AsyncSession = self .db_session()
231+ try :
232+ stmt = select(self .model)
233+ if search:
234+ stmt = stmt.where(self .model.name.ilike(f " % { search} % " ))
235+
236+ # "-name" → desc, "name" → asc; whitelist allowed columns.
237+ field, desc = (order_by[1 :], True ) if order_by.startswith(" -" ) else (order_by, False )
238+ column = {" name" : self .model.name, " created_at" : self .model.created_at}.get(field)
239+ if column is None :
240+ raise HTTPException(status.HTTP_400_BAD_REQUEST , " Unsupported order_by" )
241+ stmt = stmt.order_by(column.desc() if desc else column.asc())
242+ stmt = stmt.offset(offset).limit(limit)
243+
244+ rows = (await session.execute(stmt)).scalars().all()
245+ return [ItemSchema.model_validate(row) for row in rows]
246+ finally :
247+ await session.close()
248+
249+ async def create_element ( # type: ignore [ override ]
250+ self ,
251+ item : ItemSchema = Body(... ),
252+ token : Optional[str ] = None ,
253+ ) -> ItemSchema:
254+ """ Custom POST: normalize, persist, map IntegrityError to 409."""
255+ # Pydantic v2 dump; ``str_strip_whitespace`` already trimmed strings.
256+ payload = item.model_dump(exclude_unset = True , exclude = {" id" , " created_at" })
257+
258+ session: AsyncSession = self .db_session()
259+ try :
260+ obj = self .model(** payload)
261+ session.add(obj)
262+ try :
263+ await session.commit()
264+ except IntegrityError as exc:
265+ await session.rollback()
266+ raise HTTPException(
267+ status.HTTP_409_CONFLICT ,
268+ f " Item ' { payload.get(' name' )} ' already exists " ,
269+ ) from exc
270+ await session.refresh(obj)
271+ return ItemSchema.model_validate(obj)
272+ finally :
273+ await session.close()
274+
275+
276+ items = ItemsViewSet(
277+ endpoint = " /items" ,
278+ model = Item,
279+ response_model = ItemSchema,
280+ db_session = get_async_session,
281+ tags = [" items" ],
282+ )
283+ items.register(methods = [" LIST" , " GET" , " POST" , " PATCH" , " DELETE" ])
284+ ```
285+
286+ Key points when overriding:
287+
288+ - ** Keep the method names and the ` item ` body parameter.** ` register() `
289+ introspects ` list ` , ` get_element ` , ` create_element ` ,
290+ ` update_element ` , ` delete_element ` . It also rewrites the
291+ `` item.__annotation__ `` to ` response_model ` so the OpenAPI body
292+ schema stays consistent — use the same schema for request and
293+ response, or pre-validate inside the handler.
294+ - ** Adding new query parameters is fine** (` search ` , ` order_by ` ,
295+ filters, etc.); FastAPI picks them up automatically.
296+ - ** Manage your own session lifecycle** in overrides (` try/finally ` +
297+ ` await session.close() ` ) or use a FastAPI dependency with ` yield ` .
298+ - For sync apps, the same pattern applies to ` BaseViewset ` — just drop
299+ the ` async ` /` await ` and use ` Session ` instead of ` AsyncSession ` .
300+
93301## Authentication example
94302
95303` register() ` accepts ` OAuth2PasswordBearer ` plus a list of logical operations (` POST ` , ` PUT ` , …) that require a bearer token.
0 commit comments