{"id":51444586,"url":"https://github.com/svalench/fastapi_viewsets","last_synced_at":"2026-07-24T08:00:32.650Z","repository":{"id":57677901,"uuid":"490360372","full_name":"svalench/fastapi_viewsets","owner":"svalench","description":"Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoints from SQLAlchemy, Tortoise ORM, or Peewee models in minutes.","archived":false,"fork":false,"pushed_at":"2026-04-20T15:55:38.000Z","size":317,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"master","last_synced_at":"2026-04-20T17:42:29.838Z","etag":null,"topics":["fastapi","orm"],"latest_commit_sha":null,"homepage":"","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/svalench.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2022-05-09T16:24:38.000Z","updated_at":"2026-04-20T15:56:21.000Z","dependencies_parsed_at":"2022-08-31T06:51:00.498Z","dependency_job_id":null,"html_url":"https://github.com/svalench/fastapi_viewsets","commit_stats":null,"previous_names":[],"tags_count":3,"template":false,"template_full_name":null,"purl":"pkg:github/svalench/fastapi_viewsets","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/svalench%2Ffastapi_viewsets","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/svalench%2Ffastapi_viewsets/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/svalench%2Ffastapi_viewsets/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/svalench%2Ffastapi_viewsets/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/svalench","download_url":"https://codeload.github.com/svalench/fastapi_viewsets/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/svalench%2Ffastapi_viewsets/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35832970,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-07-20T02:08:10.276Z","status":"online","status_checked_at":"2026-07-24T02:00:07.870Z","response_time":62,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["fastapi","orm"],"created_at":"2026-07-05T15:00:32.803Z","updated_at":"2026-07-24T08:00:32.637Z","avatar_url":"https://github.com/svalench.png","language":"Python","funding_links":[],"categories":["Third-Party Extensions"],"sub_categories":["Utils"],"readme":"# fastapi-viewsets\n\nDjango REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoints from SQLAlchemy, Tortoise ORM, or Peewee models in minutes.\n\n[![PyPI version](https://badge.fury.io/py/fastapi-viewsets.svg)](https://pypi.org/project/fastapi-viewsets/)\n[![Python versions](https://img.shields.io/pypi/pyversions/fastapi-viewsets.svg)](https://pypi.org/project/fastapi-viewsets/)\n[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://github.com/svalench/fastapi_viewsets/blob/main/LICENSE)\n[![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)\n[![codecov](https://codecov.io/gh/svalench/fastapi_viewsets/graph/badge.svg)](https://codecov.io/gh/svalench/fastapi_viewsets)\n[![Downloads/month](https://static.pepy.tech/badge/fastapi-viewsets/month)](https://pepy.tech/project/fastapi-viewsets)\n[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)\n[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/svalench/fastapi_viewsets/pulls)\n\n## Why fastapi-viewsets\n\n- **DRF-style ergonomics** on top of FastAPI routers and dependency injection.\n- **Less boilerplate** — register LIST, GET, POST, PUT, PATCH, and DELETE from one class.\n- **ORM-agnostic core** — pluggable adapters for SQLAlchemy (sync/async), Tortoise ORM, and Peewee (`ORM_TYPE` / optional extras).\n- **Typed, Pydantic-first responses** with OpenAPI tags and schemas generated from your `response_model`.\n- **Declarative eager loading** (`select_related` / `prefetch_related`) via an inner `RelatedConfig` class on Pydantic schemas — eliminates N+1 without touching the viewset.\n- **Built-in list pagination** (`limit` / `offset`), optional OAuth2 on selected operations, and room to grow for search and richer filters (see Roadmap).\n\n## Feature matrix\n\n| Feature | SQLAlchemy (sync) | SQLAlchemy (async) | Tortoise ORM | Peewee |\n| --- | --- | --- | --- | --- |\n| `BaseViewset` / `AsyncBaseViewset` CRUD | Supported | Supported (`AsyncBaseViewset`) | Supported via adapter + async session | Supported via adapter |\n| `limit` / `offset` on LIST | Supported | Supported | Supported | Supported |\n| OAuth2 on selected methods (`register`) | Supported | Supported | Supported | Supported |\n| Declarative eager loading (`select_related` / `prefetch_related`) | Supported | Supported | Supported (`prefetch_related`) | Supported (`select_related`) |\n| `search` query on LIST (server-side) | **Roadmap** | **Roadmap** | **Roadmap** | **Roadmap** |\n| Declarative ordering / advanced filters | **Roadmap** | **Roadmap** | **Roadmap** | **Roadmap** |\n\n## Installation\n\n```bash\npip install fastapi-viewsets\n```\n\nOptional extras (see `setup.py`):\n\n```bash\npip install \"fastapi-viewsets[sqlalchemy]\"\npip install \"fastapi-viewsets[tortoise]\"\npip install \"fastapi-viewsets[peewee]\"\npip install \"fastapi-viewsets[test]\"   # pytest, httpx, coverage, etc.\n```\n\nFor async SQLAlchemy you still need a driver such as `aiosqlite`, `asyncpg`, or `aiomysql` alongside your database URL.\n\n## Database connection examples\n\n`fastapi-viewsets` doesn't bundle DB drivers — you pick them per stack.\nThe table below maps each ORM to the install command, the driver(s)\nyou need, and the URL shape for **PostgreSQL**, **MySQL**, and\n**Microsoft SQL Server**.\n\n| ORM | PostgreSQL | MySQL | MSSQL |\n| --- | --- | --- | --- |\n| **SQLAlchemy (sync)** | `psycopg[binary]` or `psycopg2-binary` | `pymysql` or `mysqlclient` | `pyodbc` + ODBC Driver 17/18 |\n| **SQLAlchemy (async)** | `asyncpg` | `aiomysql` or `asyncmy` | `aioodbc` + ODBC Driver 17/18 |\n| **Tortoise ORM** | `asyncpg` (built-in) | `aiomysql` (built-in) | Not supported by Tortoise |\n| **Peewee** | `psycopg2-binary` | `pymysql` or `mysqlclient` | Not supported by this adapter |\n\n\u003e The `SQLAlchemyAdapter` auto-converts a sync URL to its async\n\u003e counterpart (`postgresql://` → `postgresql+asyncpg://`,\n\u003e `mysql://` → `mysql+aiomysql://`, `sqlite:///` →\n\u003e `sqlite+aiosqlite:///`). For MSSQL you have to set the async URL\n\u003e explicitly via `SQLALCHEMY_ASYNC_DATABASE_URL`. If the matching async\n\u003e driver is not installed, `SQLAlchemyAdapter` falls back to sync-only\n\u003e mode and `get_async_session()` raises a helpful `RuntimeError`\n\u003e (since v1.2.1).\n\nThe library reads database configuration from environment variables\n(loaded via `python-dotenv` from `.env`). Pick the ORM with `ORM_TYPE`,\nthen set the URL with `\u003cORM\u003e_DATABASE_URL` (or the generic\n`DATABASE_URL`).\n\n### SQLAlchemy (sync and async)\n\n```bash\n# PostgreSQL\npip install \"fastapi-viewsets[sqlalchemy]\" \"psycopg[binary]\" asyncpg\n\n# MySQL\npip install \"fastapi-viewsets[sqlalchemy]\" pymysql aiomysql\n\n# MSSQL (needs Microsoft ODBC Driver 17 or 18 on the host)\npip install \"fastapi-viewsets[sqlalchemy]\" pyodbc aioodbc\n```\n\nExample `.env` (one block at a time):\n\n```dotenv\n# --- PostgreSQL ---\nORM_TYPE=sqlalchemy\nSQLALCHEMY_DATABASE_URL=postgresql+psycopg://user:pass@db.example.com:5432/app\n# Optional explicit async URL; otherwise auto-derived to postgresql+asyncpg://\nSQLALCHEMY_ASYNC_DATABASE_URL=postgresql+asyncpg://user:pass@db.example.com:5432/app\n\n# --- MySQL ---\nORM_TYPE=sqlalchemy\nSQLALCHEMY_DATABASE_URL=mysql+pymysql://user:pass@db.example.com:3306/app?charset=utf8mb4\nSQLALCHEMY_ASYNC_DATABASE_URL=mysql+aiomysql://user:pass@db.example.com:3306/app?charset=utf8mb4\n\n# --- MSSQL ---\nORM_TYPE=sqlalchemy\n# URL-encode the ODBC driver name (\"+\" instead of spaces).\nSQLALCHEMY_DATABASE_URL=mssql+pyodbc://user:pass@db.example.com:1433/app?driver=ODBC+Driver+18+for+SQL+Server\u0026Encrypt=yes\u0026TrustServerCertificate=no\nSQLALCHEMY_ASYNC_DATABASE_URL=mssql+aioodbc://user:pass@db.example.com:1433/app?driver=ODBC+Driver+18+for+SQL+Server\u0026Encrypt=yes\u0026TrustServerCertificate=no\n```\n\nUse `BaseViewset` for sync code or `AsyncBaseViewset` for async code\n(see the [Async quickstart](#async-quickstart-sqlalchemy-2x--pydantic-v2)\nbelow).\n\n### Tortoise ORM\n\nTortoise is async-only. The adapter takes a database URL plus a list\nof model modules to register on startup.\n\n```bash\n# PostgreSQL\npip install \"fastapi-viewsets[tortoise]\"          # pulls in asyncpg\n\n# MySQL\npip install \"fastapi-viewsets[tortoise]\" aiomysql\n```\n\n```dotenv\n# --- PostgreSQL ---\nORM_TYPE=tortoise\nTORTOISE_DATABASE_URL=postgres://user:pass@db.example.com:5432/app\nTORTOISE_MODELS=[\"app.models\"]\nTORTOISE_APP_LABEL=models\n\n# --- MySQL ---\nORM_TYPE=tortoise\nTORTOISE_DATABASE_URL=mysql://user:pass@db.example.com:3306/app\nTORTOISE_MODELS=[\"app.models\"]\nTORTOISE_APP_LABEL=models\n```\n\n```python\nfrom fastapi import FastAPI\nfrom tortoise import Tortoise\n\nfrom fastapi_viewsets import AsyncBaseViewset\nfrom fastapi_viewsets.orm.factory import ORMFactory\n\napp = FastAPI()\nadapter = ORMFactory.get_default_adapter()  # built from the env vars above\n\n\n@app.on_event(\"startup\")\nasync def _init_tortoise() -\u003e None:\n    \"\"\"Open the Tortoise connection pool and create schema if needed.\n\n    The adapter also initializes Tortoise lazily on first DB call;\n    doing it here gives you control over schema creation.\n    \"\"\"\n    await Tortoise.init(\n        db_url=adapter.database_url,\n        modules={adapter.app_label: adapter.models},\n    )\n    await Tortoise.generate_schemas(safe=True)\n\n\n@app.on_event(\"shutdown\")\nasync def _close_tortoise() -\u003e None:\n    \"\"\"Close the Tortoise connection pool.\"\"\"\n    await Tortoise.close_connections()\n\n\n# Define your Tortoise models in app/models.py and pass them to AsyncBaseViewset.\n# from app.models import Item\n# from app.schemas import ItemSchema\n# items = AsyncBaseViewset(\n#     endpoint=\"/items\",\n#     model=Item,\n#     response_model=ItemSchema,\n#     db_session=adapter.get_async_session,\n#     orm_adapter=adapter,\n#     tags=[\"items\"],\n# )\n# items.register(methods=[\"LIST\", \"GET\", \"POST\", \"PATCH\", \"DELETE\"])\n# app.include_router(items)\n```\n\n\u003e **MSSQL is not supported by Tortoise ORM.** Use SQLAlchemy with\n\u003e `aioodbc` for SQL Server.\n\n### Peewee\n\nPeewee is sync-only. The adapter parses the URL and instantiates the\nright `Database` class.\n\n```bash\n# PostgreSQL\npip install \"fastapi-viewsets[peewee]\" psycopg2-binary\n\n# MySQL\npip install \"fastapi-viewsets[peewee]\" pymysql\n```\n\n```dotenv\n# --- PostgreSQL ---\nORM_TYPE=peewee\nPEEWEE_DATABASE_URL=postgresql://user:pass@db.example.com:5432/app\n\n# --- MySQL ---\nORM_TYPE=peewee\nPEEWEE_DATABASE_URL=mysql://user:pass@db.example.com:3306/app\n```\n\n```python\nfrom fastapi import FastAPI\n\nfrom fastapi_viewsets import BaseViewset\nfrom fastapi_viewsets.orm.factory import ORMFactory\n\napp = FastAPI()\nadapter = ORMFactory.get_default_adapter()  # built from the env vars above\n\n# from app.models import Item            # peewee.Model subclass\n# from app.schemas import ItemSchema     # Pydantic v2 schema\n# items = BaseViewset(\n#     endpoint=\"/items\",\n#     model=Item,\n#     response_model=ItemSchema,\n#     db_session=adapter.get_session,\n#     orm_adapter=adapter,\n#     tags=[\"items\"],\n# )\n# items.register(methods=[\"LIST\", \"GET\", \"POST\", \"PATCH\", \"DELETE\"])\n# app.include_router(items)\n```\n\n\u003e **MSSQL is not supported by this Peewee adapter** (the URL parser\n\u003e only handles `sqlite:///`, `postgresql://`, `postgres://`,\n\u003e `mysql://`). For SQL Server, use SQLAlchemy.\n\n### Building the adapter from code (no env vars)\n\nWhen you don't want to rely on environment variables, instantiate the\nadapter directly and pass it to the viewset via `orm_adapter=`:\n\n```python\nfrom fastapi_viewsets.orm.factory import ORMFactory\n\nadapter = ORMFactory.create_adapter(\n    \"sqlalchemy\",\n    {\n        \"database_url\": \"postgresql+psycopg://user:pass@db.example.com:5432/app\",\n        \"async_database_url\": \"postgresql+asyncpg://user:pass@db.example.com:5432/app\",\n    },\n)\n```\n\n## Quickstart (SQLAlchemy, sync)\n\nSave as `main.py` in an empty folder and run `python main.py` or `uvicorn main:app --reload`.\n\n```python\nfrom fastapi import FastAPI\nfrom pydantic import BaseModel, ConfigDict\nfrom sqlalchemy import Column, Integer, String\n\nfrom fastapi_viewsets import BaseViewset\nfrom fastapi_viewsets.db_conf import Base, engine, get_session\n\napp = FastAPI()\n\n\nclass Item(Base):\n    \"\"\"Example SQLAlchemy model.\"\"\"\n\n    __tablename__ = \"items\"\n    id = Column(Integer, primary_key=True)\n    name = Column(String(255), nullable=False)\n\n\nclass ItemSchema(BaseModel):\n    \"\"\"Pydantic model for request and response bodies.\"\"\"\n\n    model_config = ConfigDict(from_attributes=True)\n    id: int | None = None\n    name: str\n\n\nBase.metadata.create_all(bind=engine)\nitems = BaseViewset(endpoint=\"/items\", model=Item, response_model=ItemSchema, db_session=get_session, tags=[\"items\"])\nitems.register(methods=[\"LIST\", \"GET\", \"POST\", \"PATCH\", \"DELETE\"])\napp.include_router(items)\n\nif __name__ == \"__main__\":\n    import uvicorn\n\n    uvicorn.run(app, host=\"127.0.0.1\", port=8000)\n```\n\n`GET /items` returns `200` with a JSON list (possibly empty). Use `POST /items` with `{\"name\": \"apple\"}` to create rows.\n\n## Eager loading (`select_related` / `prefetch_related`)\n\nIf 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:\n\n```python\nfrom pydantic import BaseModel, ConfigDict\n\n\nclass AuthorSchema(BaseModel):\n    model_config = ConfigDict(from_attributes=True)\n    id: int\n    name: str\n\n\nclass PostSchema(BaseModel):\n    model_config = ConfigDict(from_attributes=True)\n    id: int\n    title: str\n    author: AuthorSchema          # nested model → signals the need for a join\n\n    class RelatedConfig:\n        select_related = [\"author\"]   # FK / many-to-one  → one JOIN query\n        prefetch_related = [\"tags\"]   # collections / M2M → separate SELECT IN\n```\n\nWhen `PostSchema` is passed as `response_model` to a viewset, `LIST` and `GET` automatically apply the correct eager-loading strategy:\n\n```python\nposts = AsyncBaseViewset(\n    endpoint=\"/posts\",\n    model=Post,\n    response_model=PostSchema,\n    db_session=get_async_session,\n)\n```\n\n- `select_related` — `joinedload` in SQLAlchemy (single query, FK side).  \n- `prefetch_related` — `selectinload` in SQLAlchemy (two queries, collection side, no Cartesian product).  \n- Tortoise ORM uses its native `prefetch_related()` for both lists.  \n- Peewee uses `.join()` for `select_related`.\n\nYou can also override the config per-call when using the low-level utilities directly:\n\n```python\nfrom fastapi_viewsets.async_utils import get_list_queryset\n\nposts = await get_list_queryset(\n    Post,\n    db_session=get_async_session,\n    response_model=PostSchema,\n    select_related=[\"author\"],\n    prefetch_related=[\"tags\", \"comments\"],\n)\n```\n\n\u003e **Backward compatibility:** all new parameters default to `None`. Existing code and tests continue to work unchanged.\n\n## Async quickstart (SQLAlchemy 2.x + Pydantic v2)\n\n`AsyncBaseViewset` mirrors `BaseViewset` but every CRUD handler is\n`async`, backed by an async SQLAlchemy `AsyncSession`. Install an async\ndriver alongside the package:\n\n```bash\npip install \"fastapi-viewsets[sqlalchemy]\" aiosqlite\n```\n\nPoint `SQLALCHEMY_DATABASE_URL` (or `SQLALCHEMY_ASYNC_DATABASE_URL`) at\nan async-capable URL and use the lazy helpers from `db_conf`. The\npackage auto-converts `sqlite://` to `sqlite+aiosqlite://`,\n`postgresql://` to `postgresql+asyncpg://`, etc.\n\n```python\nfrom fastapi import FastAPI\nfrom pydantic import BaseModel, ConfigDict\nfrom sqlalchemy import Column, Integer, String\n\nfrom fastapi_viewsets import AsyncBaseViewset\nfrom fastapi_viewsets.db_conf import (\n    Base,\n    async_engine,\n    get_async_session,\n)\n\napp = FastAPI()\n\n\nclass Item(Base):\n    \"\"\"Async-friendly SQLAlchemy model.\"\"\"\n\n    __tablename__ = \"items_async\"\n    id = Column(Integer, primary_key=True)\n    name = Column(String(255), nullable=False)\n\n\nclass ItemSchema(BaseModel):\n    \"\"\"Pydantic v2 schema reused as request and response model.\"\"\"\n\n    model_config = ConfigDict(from_attributes=True)\n    id: int | None = None\n    name: str\n\n\n@app.on_event(\"startup\")\nasync def _create_tables() -\u003e None:\n    \"\"\"Create tables once on startup using the async engine.\"\"\"\n    async with async_engine.begin() as conn:\n        await conn.run_sync(Base.metadata.create_all)\n\n\nitems = AsyncBaseViewset(\n    endpoint=\"/items\",\n    model=Item,\n    response_model=ItemSchema,\n    db_session=get_async_session,\n    tags=[\"items\"],\n)\nitems.register(methods=[\"LIST\", \"GET\", \"POST\", \"PATCH\", \"DELETE\"])\napp.include_router(items)\n```\n\nNotes:\n\n- Pydantic v2 is required (`pydantic\u003e=2.5`). Use\n  `model_config = ConfigDict(from_attributes=True)` instead of the v1\n  `class Config: orm_mode = True`.\n- `PATCH` uses `model_dump(exclude_unset=True)` internally, so unset\n  fields are no longer overwritten with defaults.\n- If the async driver (`aiosqlite` / `asyncpg` / `aiomysql`) is not\n  installed, sync usage still works — only `get_async_session()` raises\n  a helpful `RuntimeError`.\n\n## Overriding `list` and `create_element` (custom LIST and POST)\n\nEvery CRUD handler is a regular method, so subclassing the viewset is\nthe canonical way to add filtering, ordering, validation, conflict\nhandling, and so on. The example below subclasses `AsyncBaseViewset`\nand overrides both `list` (case-insensitive search + simple ordering)\nand `create_element` (input normalization + map `IntegrityError` to\n409).\n\n```python\nfrom typing import List, Optional\n\nfrom fastapi import Body, HTTPException, status\nfrom pydantic import BaseModel, ConfigDict, Field\nfrom sqlalchemy import Column, DateTime, Integer, String, func, select\nfrom sqlalchemy.exc import IntegrityError\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom fastapi_viewsets import AsyncBaseViewset\nfrom fastapi_viewsets.db_conf import Base, get_async_session\n\n\nclass Item(Base):\n    \"\"\"Item model with timestamps and a unique name.\"\"\"\n\n    __tablename__ = \"items_custom\"\n    id = Column(Integer, primary_key=True)\n    name = Column(String(255), nullable=False, unique=True, index=True)\n    description = Column(String(1024), nullable=True)\n    created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)\n\n\nclass ItemSchema(BaseModel):\n    \"\"\"Single Pydantic v2 schema reused as request and response model.\n\n    Server-controlled fields (``id``, ``created_at``) are optional so\n    the same schema can be used for POST/PATCH bodies and responses\n    — ``register()`` patches the body annotation to ``response_model``.\n    \"\"\"\n\n    model_config = ConfigDict(from_attributes=True, str_strip_whitespace=True)\n    id: Optional[int] = None\n    name: str = Field(..., min_length=1, max_length=255)\n    description: Optional[str] = Field(default=None, max_length=1024)\n    created_at: Optional[object] = None  # datetime in real code\n\n\nclass ItemsViewSet(AsyncBaseViewset):\n    \"\"\"Custom async viewset that overrides LIST and POST.\"\"\"\n\n    async def list(  # type: ignore[override]\n        self,\n        limit: int = 20,\n        offset: int = 0,\n        search: Optional[str] = None,\n        order_by: str = \"-created_at\",\n        token: Optional[str] = None,\n    ) -\u003e List[ItemSchema]:\n        \"\"\"Custom LIST: case-insensitive search + whitelist ordering.\n\n        Query: ``GET /items?search=foo\u0026order_by=-name\u0026limit=10``.\n        \"\"\"\n        session: AsyncSession = self.db_session()\n        try:\n            stmt = select(self.model)\n            if search:\n                stmt = stmt.where(self.model.name.ilike(f\"%{search}%\"))\n\n            # \"-name\" → desc, \"name\" → asc; whitelist allowed columns.\n            field, desc = (order_by[1:], True) if order_by.startswith(\"-\") else (order_by, False)\n            column = {\"name\": self.model.name, \"created_at\": self.model.created_at}.get(field)\n            if column is None:\n                raise HTTPException(status.HTTP_400_BAD_REQUEST, \"Unsupported order_by\")\n            stmt = stmt.order_by(column.desc() if desc else column.asc())\n            stmt = stmt.offset(offset).limit(limit)\n\n            rows = (await session.execute(stmt)).scalars().all()\n            return [ItemSchema.model_validate(row) for row in rows]\n        finally:\n            await session.close()\n\n    async def create_element(  # type: ignore[override]\n        self,\n        item: ItemSchema = Body(...),\n        token: Optional[str] = None,\n    ) -\u003e ItemSchema:\n        \"\"\"Custom POST: normalize, persist, map IntegrityError to 409.\"\"\"\n        # Pydantic v2 dump; ``str_strip_whitespace`` already trimmed strings.\n        payload = item.model_dump(exclude_unset=True, exclude={\"id\", \"created_at\"})\n\n        session: AsyncSession = self.db_session()\n        try:\n            obj = self.model(**payload)\n            session.add(obj)\n            try:\n                await session.commit()\n            except IntegrityError as exc:\n                await session.rollback()\n                raise HTTPException(\n                    status.HTTP_409_CONFLICT,\n                    f\"Item '{payload.get('name')}' already exists\",\n                ) from exc\n            await session.refresh(obj)\n            return ItemSchema.model_validate(obj)\n        finally:\n            await session.close()\n\n\nitems = ItemsViewSet(\n    endpoint=\"/items\",\n    model=Item,\n    response_model=ItemSchema,\n    db_session=get_async_session,\n    tags=[\"items\"],\n)\nitems.register(methods=[\"LIST\", \"GET\", \"POST\", \"PATCH\", \"DELETE\"])\n```\n\nKey points when overriding:\n\n- **Keep the method names and the `item` body parameter.** `register()`\n  introspects `list`, `get_element`, `create_element`,\n  `update_element`, `delete_element`. It also rewrites the\n  ``item.__annotation__`` to `response_model` so the OpenAPI body\n  schema stays consistent — use the same schema for request and\n  response, or pre-validate inside the handler.\n- **Adding new query parameters is fine** (`search`, `order_by`,\n  filters, etc.); FastAPI picks them up automatically.\n- **Manage your own session lifecycle** in overrides (`try/finally` +\n  `await session.close()`) or use a FastAPI dependency with `yield`.\n- For sync apps, the same pattern applies to `BaseViewset` — just drop\n  the `async`/`await` and use `Session` instead of `AsyncSession`.\n\n## Authentication example\n\n`register()` accepts `OAuth2PasswordBearer` plus a list of logical operations (`POST`, `PUT`, …) that require a bearer token.\n\n```python\nfrom fastapi import FastAPI\nfrom fastapi.security import OAuth2PasswordBearer\nfrom pydantic import BaseModel, ConfigDict\nfrom sqlalchemy import Column, Integer, String\nfrom fastapi_viewsets import BaseViewset\nfrom fastapi_viewsets.db_conf import Base, engine, get_session\n\napp = FastAPI()\noauth2 = OAuth2PasswordBearer(tokenUrl=\"/token\")\n\nclass Item(Base):\n    \"\"\"SQLAlchemy model for OAuth2-protected writes.\"\"\"\n\n    __tablename__ = \"items_oauth\"\n    id = Column(Integer, primary_key=True)\n    name = Column(String(255), nullable=False)\n\n\nclass ItemSchema(BaseModel):\n    \"\"\"Pydantic schema for Item payloads and responses.\"\"\"\n\n    model_config = ConfigDict(from_attributes=True)\n    id: int | None = None\n    name: str\n\n\nBase.metadata.create_all(bind=engine)\nrouter = BaseViewset(endpoint=\"/items\", model=Item, response_model=ItemSchema, db_session=get_session, tags=[\"items\"])\nrouter.register(methods=[\"LIST\", \"GET\", \"POST\", \"PATCH\", \"DELETE\"], oauth_protect=oauth2, protected_methods=[\"POST\", \"PATCH\", \"DELETE\"])\napp.include_router(router)\n```\n\n## Pagination, filtering, ordering\n\n**Pagination** — `BaseViewset.list` maps `limit` and `offset` to query parameters on the LIST route.\n\n```python\nfrom fastapi_viewsets import BaseViewset\n\ndef pagination_hint() -\u003e str:\n    \"\"\"Document LIST pagination after `register()` (e.g. GET /items?limit=10\u0026offset=20).\"\"\"\n    return \"limit and offset are parsed by `BaseViewset.list`\"\n```\n\n**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.\n\n```python\nfrom fastapi_viewsets import BaseViewset\n\ndef filtering_hint() -\u003e str:\n    \"\"\"Explain that `search` is reserved; override `list` for real filters today.\"\"\"\n    return \"search parameter is not yet applied in adapters\"\n```\n\n**Ordering** — there is no shared `order_by` helper yet; override `list()` with an ordered query or wait for the Roadmap.\n\n```python\nfrom fastapi_viewsets import BaseViewset\n\ndef ordering_hint() -\u003e str:\n    \"\"\"Note the absence of a built-in ordering helper on LIST endpoints.\"\"\"\n    return \"override list or wait for roadmap ordering helpers\"\n```\n\n## Permissions and custom routes\n\nThere 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}`:\n\n```python\nfrom fastapi_viewsets import BaseViewset\n\n\nclass ItemsWithStats(BaseViewset):\n    \"\"\"Adds a custom read-only route alongside generated CRUD.\"\"\"\n\n    def __init__(self, *args, **kwargs):\n        \"\"\"Register static paths before CRUD routes.\"\"\"\n        super().__init__(*args, **kwargs)\n        self.add_api_route(\n            f\"{self.endpoint}/stats\",\n            self.collection_stats,\n            methods=[\"GET\"],\n            tags=self.tags or [],\n            name=\"items_stats\",\n        )\n\n    def collection_stats(self) -\u003e dict[str, str]:\n        \"\"\"Return a minimal summary for monitoring or health checks.\"\"\"\n        return {\"resource\": self.endpoint.strip(\"/\")}\n\n\n# Instantiate with model, response_model, and db_session (see quickstart), then call register().\n```\n\n## What is new\n\n### v1.3.0\n\n- **Declarative eager loading** via `RelatedConfig` inside Pydantic schemas.  \n  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.\n- 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.\n- Full backward compatibility: new parameters default to `None`; existing code works unchanged.\n\n### v1.2.0\n\n- Pydantic v2 first: CRUD handlers use `model_dump(exclude_unset=...)`, fixing PATCH semantics that previously overwrote unset fields with defaults.\n- Lazy `db_conf`: importing the package no longer creates SQLAlchemy engines unless they are needed, and works without async drivers installed.\n- Single source of truth for sync→async URL conversion and the default adapter singleton.\n- Internal `register()` deduplicated between sync and async viewsets via a shared mixin.\n- PEP 621 `pyproject.toml`, `python_requires\u003e=3.9`, FastAPI `\u003e=0.110`, ruff/black/mypy preconfigured.\n\nPrevious 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.\n\nDetails: [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).\n\n## Roadmap (planned)\n\n| Item | Target | Status |\n| --- | --- | --- |\n| Wire `search` on LIST to real database queries | v1.4 | Planned |\n| Transaction helpers (`begin` / `atomic`) across adapters | v1.4 | Planned |\n| Declarative ordering (`order_by`) on LIST endpoints | v1.4 | Planned |\n| Advanced filters (`__gt`, `__lt`, `__in`) via query params | v1.5 | Planned |\n\n## Comparison with alternatives\n\n| Approach | Developer experience | ORM support | Permissions | Filtering |\n| --- | --- | --- | --- | --- |\n| 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 |\n| fastapi-crudrouter | CRUD-focused generators, less ViewSet-shaped | Primarily SQLAlchemy | Custom middleware/deps | Often extended manually |\n| Hand-rolled FastAPI | Full control, most boilerplate | Any ORM you integrate | Fully custom | Fully custom |\n\n## Testing\n\nFrom the repository root (see `pytest.ini`):\n\n```bash\npytest\n```\n\nCoverage is enforced with `--cov-fail-under=70` (HTML and XML reports are emitted for local inspection).\n\n## Contributing\n\nSee [open issues](https://github.com/svalench/fastapi_viewsets/issues) to propose changes; pull requests are welcome.\n\n## License\n\nDistributed under the MIT License. See [LICENSE](LICENSE).\n\n## Author\n\nBuilt by [Alexander Valenchits](https://github.com/svalench) — Tech Lead @ AluSoft, Minsk.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsvalench%2Ffastapi_viewsets","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsvalench%2Ffastapi_viewsets","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsvalench%2Ffastapi_viewsets/lists"}