Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,14 @@ jobs:

- name: Run tests
run: |
pytest tests/ -v --cov=fastapi_viewsets --cov-report=term-missing --cov-fail-under=70
pytest tests/ -v --cov=fastapi_viewsets --cov-report=xml --cov-report=term-missing --cov-fail-under=70

- name: Upload coverage to codecov
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
flags: extras
fail_ci_if_error: false

package:
name: Build and test installed wheel
Expand Down
67 changes: 57 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,21 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
pip install fastapi-viewsets
```

SQLAlchemy 2.0 or newer is installed automatically. For a local SQLite app,
start with the [sync quickstart](#quickstart-sqlalchemy-sync); no separate database driver is needed.

Optional extras (see `pyproject.toml`):
The base install is ORM-agnostic: it pulls in only FastAPI, Pydantic and
python-dotenv. Pick your ORM via an extra (see `pyproject.toml`):

```bash
pip install "fastapi-viewsets[sqlalchemy]"
pip install "fastapi-viewsets[sqlalchemy]" # SQLAlchemy 2.x, incl. asyncio support (greenlet)
pip install "fastapi-viewsets[tortoise]"
pip install "fastapi-viewsets[peewee]"
pip install "fastapi-viewsets[test]" # pytest, httpx, coverage, etc.
```

For a local SQLite app, start with the
[sync quickstart](#quickstart-sqlalchemy-sync); no separate database driver is needed.
To execute the quickstart's `python main.py` (or serve any app) you also
need an ASGI server, e.g. `pip install uvicorn`.

For async SQLAlchemy you still need a driver such as `aiosqlite`, `asyncpg`, or `aiomysql` alongside your database URL.

## Database connection examples
Expand Down Expand Up @@ -443,19 +446,35 @@ Notes:
`model_config = ConfigDict(from_attributes=True)` instead of the v1
`class Config: orm_mode = True`.
- `PATCH` uses `model_dump(exclude_unset=True)` internally, so unset
fields are no longer overwritten with defaults.
fields are no longer overwritten with defaults. Since v1.5.2 the PATCH
body is validated against an auto-generated all-optional variant of
your `response_model`, so partial bodies pass validation even when the
schema has required fields, and an explicit JSON `null` clears a
nullable column.
- `PUT` replaces exactly the fields present in the payload; model
columns that are not part of the `response_model` are left untouched.
- `POST` never sends an explicit `NULL` primary key to the database —
`id: Optional[int] = None` in the schema is safe on every adapter.
- Integrity violations (e.g. duplicate unique values) return
`409 Conflict` with a sanitized message; raw SQL and driver internals
are never exposed in the response body.
- If the async driver (`aiosqlite` / `asyncpg` / `aiomysql`) is not
installed, sync usage still works — only `get_async_session()` raises
a helpful `RuntimeError`.
a helpful `RuntimeError`. The `sqlalchemy` extra installs
`SQLAlchemy[asyncio]`, which already includes `greenlet` for async
sessions.

## Overriding `list` and `create_element` (custom LIST and POST)

Every CRUD handler is a regular method, so subclassing the viewset is
the canonical way to add filtering, ordering, validation, conflict
handling, and so on. The example below subclasses `AsyncBaseViewset`
and overrides both `list` (case-insensitive search + simple ordering)
and `create_element` (input normalization + map `IntegrityError` to
409).
and `create_element` (input normalization + custom conflict message).

> Since v1.5.2 the adapters themselves map `IntegrityError` to
> `409 Conflict`; override `create_element` only when you need a custom
> error payload or extra normalization.

```python
from typing import List, Optional
Expand Down Expand Up @@ -621,7 +640,7 @@ app.include_router(router)

## Pagination, filtering, ordering

**Pagination** — `BaseViewset.list` maps `limit` and `offset` to query parameters on the LIST route.
**Pagination** — `BaseViewset.list` maps `limit` and `offset` to query parameters on the LIST route. Defaults are `limit=10`, `offset=0`; negative values are rejected with `422`, and `limit` is capped at `10000`.

```python
from fastapi_viewsets import BaseViewset
Expand Down Expand Up @@ -682,6 +701,34 @@ class ItemsWithStats(BaseViewset):

## What is new

### v1.5.2

Bugfix release for the CRUD write paths (see
[RELEASE_1.5.2.md](https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_1.5.2.md)):

- **Multi-viewset apps fixed**: `register()` no longer leaks one viewset's
body schema into other viewsets — every `POST`/`PUT`/`PATCH` endpoint
now validates against its own `response_model`.
- **PATCH is truly partial**: the PATCH body is validated against an
auto-generated all-optional variant of the response schema, and an
explicit JSON `null` clears a nullable column.
- **PUT no longer nulls columns** that are absent from the Pydantic
schema.
- **Integrity violations return `409 Conflict`** with a sanitized message
instead of `400` with raw SQL/driver internals.
- **Create never passes an explicit `NULL` primary key** — fixes Tortoise
`POST` and PostgreSQL inserts with `id: Optional[int] = None` schemas.
- **Pagination validated**: negative `limit`/`offset` rejected with `422`.
- **Filters advertised in OpenAPI**: whitelisted `ListConfig.filters`
fields and their `__op` variants show up in Swagger UI.
- **Leaner dependencies**: the base install no longer requires SQLAlchemy
or uvicorn; the `sqlalchemy` extra installs `SQLAlchemy[asyncio]`
(includes `greenlet`).

Earlier releases: v1.5.0 (server-side `search`, declarative ordering and
filters — [RELEASE_1.5.0.md](https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_1.5.0.md)),
v1.4.0 ([RELEASE_1.4.0.md](https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_1.4.0.md)).

### v1.3.0

- **Declarative eager loading** via `RelatedConfig` inside Pydantic schemas.
Expand Down
59 changes: 59 additions & 0 deletions RELEASE_1.5.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Release v1.5.2

Bugfix release for the CRUD write paths, route registration, and
packaging. All fixes are covered by regression tests
(`tests/test_write_path_regressions.py`).

## 🐞 Fixes

### Route registration

- **Body-schema leak between viewsets (critical).** `register()` used to
mutate `__annotations__` of the shared class-level CRUD functions, so
in any process with two or more viewsets every `POST`/`PUT`/`PATCH`
endpoint validated against the *last registered* viewset's schema.
Handlers are now cloned per instance before the body annotation is
patched.
- **PATCH is truly partial.** The PATCH body is validated against an
auto-generated all-optional variant of the `response_model`
(`<Schema>Patch`). Previously a partial PATCH body failed validation
with `422` whenever the schema had required fields.
- **PATCH honors explicit `null`.** Explicitly sent JSON `null` now
clears a nullable column instead of being silently dropped.
- **Pagination validated.** Negative `limit`/`offset` are rejected with
`422`; `limit` is capped at 10000. Defaults (`limit=10`, `offset=0`)
are documented.
- **Filters in OpenAPI.** Whitelisted `ListConfig.filters` fields and
their `__op` variants (`__gte`, `__in`, …) are advertised in the
generated OpenAPI schema and Swagger UI.

### ORM adapters (SQLAlchemy sync/async, Tortoise, Peewee)

- **PUT no longer nulls columns absent from the schema.** Only fields
explicitly present in the payload are written; the primary key is
never overwritten. Previously `PUT` set every non-schema column to
`NULL` (data loss).
- **Create strips an explicit `NULL` primary key.** Schemas with
`id: Optional[int] = None` no longer break `POST` on Tortoise
(`id is non nullable field, but null was passed`) and no longer send
`NULL` for the PK on backends like PostgreSQL.
- **Integrity violations return `409 Conflict`** with a sanitized
message (`"Integrity error: a database constraint was violated"`).
Raw SQL, parameters and driver internals are no longer exposed in
response bodies; generic adapter errors likewise use static messages.

### Packaging

- Base install is now truly ORM-agnostic: `SQLAlchemy` and `uvicorn`
were removed from core dependencies. The package imports cleanly
without SQLAlchemy installed.
- `fastapi-viewsets[sqlalchemy]` now installs `SQLAlchemy[asyncio]`,
which includes `greenlet` — async sessions work out of the box.

## ⚠️ Behavior changes to be aware of

- Integrity violations: `400` → `409` (message no longer contains raw SQL).
- PATCH with explicit `null` writes `NULL` (previously ignored).
- PUT no longer clears fields/columns missing from the payload.
- `pip install fastapi-viewsets` no longer pulls SQLAlchemy/uvicorn —
use `fastapi-viewsets[sqlalchemy]` and install `uvicorn` to run apps.
10 changes: 10 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Release Notes

## Version 1.5.2

Bugfix release for the CRUD write paths: multi-viewset body-schema leak,
true partial PATCH (incl. explicit `null`), PUT no longer nulls columns
outside the schema, `409 Conflict` without SQL leaks on integrity
violations, validated pagination, filters in OpenAPI, and leaner core
dependencies.

Details: [RELEASE_1.5.2.md](RELEASE_1.5.2.md).

## Version 1.5.1

Maintenance release fixing dependency metadata, source-distribution contents,
Expand Down
7 changes: 4 additions & 3 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
| Python | >= 3.9 |
| FastAPI | >= 0.110 |
| Pydantic | >= 2.5, < 3 |
| SQLAlchemy | >= 2.0.0 |
| python-dotenv | >= 0.19 |
| SQLAlchemy | >= 2.0.0 (only for the `sqlalchemy` extra / default `ORM_TYPE`) |

## Install from PyPI

Expand All @@ -18,8 +18,9 @@ pip install fastapi-viewsets

## Optional extras

SQLAlchemy is a core dependency (installed automatically). The extras below
add ORM-specific packages when you need Tortoise or Peewee:
The base install is ORM-agnostic (FastAPI + Pydantic + python-dotenv only).
Add the extra for your ORM — and an ASGI server such as `uvicorn` to run
your app:

=== "SQLAlchemy"

Expand Down
4 changes: 2 additions & 2 deletions docs/pagination-filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ GET /items?limit=10&offset=20

| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `limit` | `Optional[int]` | `10` | Maximum number of items to return |
| `offset` | `Optional[int]` | `0` | Number of items to skip |
| `limit` | `int` | `10` | Maximum number of items to return (must be `0..10000`, negative values are rejected with `422`) |
| `offset` | `int` | `0` | Number of items to skip (negative values are rejected with `422`) |

No additional configuration needed — pagination is built into the default `list()` handler.

Expand Down
16 changes: 11 additions & 5 deletions fastapi_viewsets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@

from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, Union

from fastapi import APIRouter, Body, Depends, Request
from fastapi import APIRouter, Body, Depends, Query, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session

from fastapi_viewsets._compat import model_to_dict
from fastapi_viewsets._register import _RegisterMixin
Expand Down Expand Up @@ -82,7 +81,7 @@ def __init__(
self.endpoint: Optional[str] = endpoint
self.response_model: Optional[Type[ResponseModelType]] = response_model
self.model: Optional[Type[ModelType]] = model
self.db_session: Optional[Callable[[], Session]] = db_session
self.db_session: Optional[Callable[[], Any]] = db_session
self.orm_adapter: Optional[BaseORMAdapter] = orm_adapter or get_orm_adapter()

# ------------------------------------------------------------------
Expand All @@ -91,8 +90,8 @@ def __init__(

def list(
self,
limit: Optional[int] = 10,
offset: Optional[int] = 0,
limit: int = Query(10, ge=0, le=10000),
offset: int = Query(0, ge=0),
search: Optional[str] = None,
ordering: Optional[str] = None,
request: Request = None,
Expand All @@ -117,6 +116,13 @@ def list(
parse_ordering_param,
)

# FastAPI resolves ``Query`` defaults for HTTP calls; direct
# programmatic calls receive the unresolved ``Query`` object.
if not isinstance(limit, int):
limit = 10
if not isinstance(offset, int):
offset = 0

config = get_list_config(self.response_model)
return get_list_queryset(
self.model,
Expand Down
Loading
Loading