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
44 changes: 22 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
- **ORM-agnostic core** — pluggable adapters for SQLAlchemy (sync/async), Tortoise ORM, and Peewee (`ORM_TYPE` / optional extras).
- **Typed, Pydantic-first responses** with OpenAPI tags and schemas generated from your `response_model`.
- **Declarative eager loading** (`select_related` / `prefetch_related`) via an inner `RelatedConfig` class on Pydantic schemas — eliminates N+1 without touching the viewset.
- **Built-in list pagination** (`limit` / `offset`), optional OAuth2 on selected operations, and room to grow for search and richer filters (see Roadmap).
- **Built-in list pagination** (`limit` / `offset`), **server-side search**, **declarative ordering** and **advanced filters** (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `contains`, `in`) driven by an inner `ListConfig` class on Pydantic schemas, plus optional OAuth2 on selected operations.

## Feature matrix

Expand All @@ -31,8 +31,8 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
| `limit` / `offset` on LIST | Supported | Supported | Supported | Supported |
| OAuth2 on selected methods (`register`) | Supported | Supported | Supported | Supported |
| Declarative eager loading (`select_related` / `prefetch_related`) | Supported | Supported | Supported (`prefetch_related`) | Supported (`select_related`) |
| `search` query on LIST (server-side) | **Roadmap** | **Roadmap** | **Roadmap** | **Roadmap** |
| Declarative ordering / advanced filters | **Roadmap** | **Roadmap** | **Roadmap** | **Roadmap** |
| `search` query on LIST (server-side) | Supported | Supported | Supported | Supported |
| Declarative ordering / advanced filters | Supported | Supported | Supported | Supported |

## Installation

Expand Down Expand Up @@ -625,26 +625,25 @@ def pagination_hint() -> str:
return "limit and offset are parsed by `BaseViewset.list`"
```

**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.
**Search, ordering and filters** — declare a `ListConfig` inner class on the response schema and the LIST endpoint gains `?search=`, `?ordering=` and whitelisted `?<field>` / `?<field>__<op>` query parameters, applied server-side by every ORM adapter:

```python
from fastapi_viewsets import BaseViewset

def filtering_hint() -> str:
"""Explain that `search` is reserved; override `list` for real filters today."""
return "search parameter is not yet applied in adapters"
```
from pydantic import BaseModel

**Ordering** — there is no shared `order_by` helper yet; override `list()` with an ordered query or wait for the Roadmap.

```python
from fastapi_viewsets import BaseViewset
class ItemSchema(BaseModel):
id: int
name: str
status: str

def ordering_hint() -> str:
"""Note the absence of a built-in ordering helper on LIST endpoints."""
return "override list or wait for roadmap ordering helpers"
class ListConfig:
search_fields = ["name"]
ordering_fields = ["name", "id"]
ordering = ["-id"]
filters = ["status"]
```

`GET /items?search=foo&ordering=name&status=active` then works out of the box. Operators: `ne`, `gt`, `gte`, `lt`, `lte`, `contains`, `in` (e.g. `?status__in=active,pending`). Fields outside the whitelist are ignored; ordering by an unknown field returns `400`. Without a `ListConfig`, LIST behaves exactly as before.

## Permissions and custom routes

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}`:
Expand Down Expand Up @@ -700,16 +699,17 @@ Details: [RELEASE_NOTES.md](RELEASE_NOTES.md), [RELEASE_1.2.0.md](RELEASE_1.2.0.

| Item | Target | Status |
| --- | --- | --- |
| Wire `search` on LIST to real database queries | v1.4 | Planned |
| Transaction helpers (`begin` / `atomic`) across adapters | v1.4 | Planned |
| Declarative ordering (`order_by`) on LIST endpoints | v1.4 | Planned |
| Advanced filters (`__gt`, `__lt`, `__in`) via query params | v1.5 | Planned |
| Transaction helpers (`begin` / `atomic`) across adapters | future | Planned |
| Range filters on dates (`date__range`) and null checks (`field__isnull`) | future | Planned |
| Cross-relation search (searching through `select_related` fields) | future | Planned |

Released: server-side `search` (v1.5.0), declarative ordering and advanced filters (v1.5.0).

## Comparison with alternatives

| Approach | Developer experience | ORM support | Permissions | Filtering |
| --- | --- | --- | --- | --- |
| 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 |
| fastapi-viewsets | One `BaseViewset` registers CRUD routes | SQLAlchemy sync/async, Tortoise, Peewee via adapters | OAuth2 per logical method via `register` | `limit`/`offset`, `search`, `ordering` and operator filters via `ListConfig` |
| fastapi-crudrouter | CRUD-focused generators, less ViewSet-shaped | Primarily SQLAlchemy | Custom middleware/deps | Often extended manually |
| Hand-rolled FastAPI | Full control, most boilerplate | Any ORM you integrate | Fully custom | Fully custom |

Expand Down
67 changes: 67 additions & 0 deletions RELEASE_1.5.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Release v1.5.0

## ✨ What's New

The two last "Roadmap" rows of the feature matrix are done: **server-side
search** and **declarative ordering with advanced filters** are now
supported across all four adapters.

## 🔎 Search, Ordering & Filters via `ListConfig`

Declare an inner `ListConfig` class on your Pydantic response schema and
the LIST endpoint gains query-parameter-driven search, ordering and
filtering — no viewset code changes needed:

```python
class ItemSchema(BaseModel):
id: int
name: str
status: str

class ListConfig:
search_fields = ["name"] # ?search=foo
ordering_fields = ["name", "id"] # ?ordering=-name,id
ordering = ["-id"] # default ordering
filters = ["status"] # ?status=active, ?status__in=a,b
```

- **`?search=term`** — case-insensitive substring match, OR-ed across
`search_fields`.
- **`?ordering=-name,id`** — validated against `ordering_fields` (unknown
field → `400`); the declarative `ordering` applies when the parameter
is omitted.
- **Filters** — exact match plus operators `ne`, `gt`, `gte`, `lt`, `lte`,
`contains`, `in`; values coerced to int/float/bool; only whitelisted
fields ever reach the ORM.

Supported by **SQLAlchemy (sync & async), Tortoise ORM and Peewee**
adapters, on both `BaseViewset` and `AsyncBaseViewset`. The utility layer
(`get_list_queryset` in `utils` / `async_utils`) accepts the same
`search`, `search_fields`, `ordering`, `filters` arguments for
programmatic use.

## 🛡 Safety & Compatibility

- Field whitelists are enforced: unknown filter fields are ignored and
unknown ordering fields return `400` — arbitrary column names never
reach the ORM.
- Fully backward compatible: schemas without a `ListConfig` behave
exactly as before (search/ordering parameters ignored).
- Custom adapters keep working — new arguments are only forwarded when
actually used.

## 📚 Documentation

- `docs/pagination-filtering.md` rewritten around `ListConfig`.
- Feature matrix and roadmap updated in README and docs.

## 🧪 Testing

- New test suite `tests/test_search_ordering.py` (31 tests) covering
config parsing, sync/async viewsets end-to-end, and Tortoise/Peewee
adapter behaviour.
- 294 tests pass, coverage 88.5%.

---

**Full Changelog**: https://github.com/svalench/fastapi_viewsets/compare/v1.4.0...v1.5.0
17 changes: 17 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Release Notes

## Version 1.5.0

### ✨ Search, Ordering & Filters

- **Server-side `?search=` on LIST** — case-insensitive substring match
across schema-declared `ListConfig.search_fields`.
- **Declarative ordering** — `?ordering=` validated against
`ListConfig.ordering_fields`, with declarative defaults from
`ListConfig.ordering`.
- **Advanced filters** — exact match plus `ne` / `gt` / `gte` / `lt` /
`lte` / `contains` / `in` operators, restricted to
`ListConfig.filters` whitelist.
- Works across SQLAlchemy (sync & async), Tortoise ORM and Peewee;
fully backward compatible (schemas without `ListConfig` unchanged).

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

## Version 1.4.0

### 🐍 Python 3.14
Expand Down
13 changes: 7 additions & 6 deletions docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

| Approach | Developer experience | ORM support | Permissions | Filtering |
| --- | --- | --- | --- | --- |
| **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 |
| **fastapi-viewsets** | One `BaseViewset` registers CRUD routes | SQLAlchemy sync/async, Tortoise, Peewee via adapters | OAuth2 per logical method via `register` | `limit`/`offset`, `search`, `ordering` and operator filters via `ListConfig` |
| fastapi-crudrouter | CRUD-focused generators, less ViewSet-shaped | SQLAlchemy, Tortoise, Ormar, Gino, databases | Custom middleware/deps | Often extended manually |
| Hand-rolled FastAPI | Full control, most boilerplate | Any ORM you integrate | Fully custom | Fully custom |

Expand All @@ -24,7 +24,7 @@

**Consider alternatives if you:**

- Need complex filtering, ordering, or search out of the box (these are on the [roadmap](#roadmap) but not yet built)
- Need filtering beyond whitelisted exact/comparison operators (date ranges, null checks are on the [roadmap](#roadmap))
- Use an ORM without an adapter (e.g. SQLModel, Beanie) — though you can write a custom `BaseORMAdapter`
- Need a full permissions framework with role-based access control

Expand Down Expand Up @@ -60,7 +60,8 @@ Each adapter implements the same `BaseORMAdapter` interface, so viewset code sta

| Item | Target | Status |
| --- | --- | --- |
| Wire `search` on LIST to real database queries | v1.4 | Planned |
| Transaction helpers (`begin` / `atomic`) across adapters | v1.4 | Planned |
| Declarative ordering (`order_by`) on LIST endpoints | v1.4 | Planned |
| Advanced filters (`__gt`, `__lt`, `__in`) via query params | v1.5 | Planned |
| Server-side `search` on LIST | v1.5.0 | Released |
| Declarative ordering on LIST | v1.5.0 | Released |
| Advanced filters (`__gt`, `__lt`, `__in`, ...) via query params | v1.5.0 | Released |
| Transaction helpers (`begin` / `atomic`) across adapters | future | Planned |
| Date-range filters (`__range`) and null checks (`__isnull`) | future | Planned |
4 changes: 2 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ That's it — you now have `GET /items`, `GET /items/{id}`, `POST /items`, `PATC
| `limit` / `offset` on LIST | ✅ | ✅ | ✅ | ✅ |
| OAuth2 on selected methods | ✅ | ✅ | ✅ | ✅ |
| Declarative eager loading | ✅ | ✅ | ✅ (`prefetch_related`) | ✅ (`select_related`) |
| `search` query on LIST | Roadmap | Roadmap | Roadmap | Roadmap |
| Declarative ordering / advanced filters | Roadmap | Roadmap | Roadmap | Roadmap |
| `search` query on LIST | | | | |
| Declarative ordering / advanced filters | | | | |

---

Expand Down
125 changes: 82 additions & 43 deletions docs/pagination-filtering.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Pagination, Filtering & Ordering
# Pagination, Search, Ordering & Filtering

## Pagination

Expand All @@ -12,27 +12,90 @@ GET /items?limit=10&offset=20
| --- | --- | --- | --- |
| `limit` | `Optional[int]` | `10` | Maximum number of items to return |
| `offset` | `Optional[int]` | `0` | Number of items to skip |
| `search` | `Optional[str]` | `None` | Search query (reserved — see below) |

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

## Search, ordering and filters via `ListConfig`

```text
# Default: GET /items → 10 items, offset 0
GET /items
Since v1.5.0 the LIST endpoint supports server-side search, declarative
ordering and whitelisted filters. Declare an inner `ListConfig` class on
your Pydantic response schema:

# Custom page: GET /items?limit=50&offset=100 → items 101-150
GET /items?limit=50&offset=100
```python
from pydantic import BaseModel

class ItemSchema(BaseModel):
id: int
name: str
status: str
price: float

class ListConfig:
search_fields = ["name"] # ?search=foo → case-insensitive substring
ordering_fields = ["name", "price", "id"] # ?ordering= allowed fields
ordering = ["-id"] # default ordering (newest first)
filters = ["status", "price"] # ?<field>=... allowed fields
```

No additional configuration needed — pagination is built into the default `list()` handler.
That is all — the default `list()` handler picks the configuration up
automatically.

### Search

`GET /items?search=foo` performs a case-insensitive substring match,
OR-ed across every field in `search_fields`.

### Ordering

`GET /items?ordering=-price,name` orders by `price` descending, then
`name` ascending. A leading `-` means descending.

* The field must be listed in `ordering_fields`, otherwise the endpoint
returns `400 Bad Request`.
* When the `ordering` parameter is omitted, the declarative default
(`ListConfig.ordering`) applies.
* When `ordering_fields` is not declared, ordering via the query
parameter is disabled (the parameter is ignored).

## Filtering
### Filters

!!! warning "`search` is reserved but not yet wired"
Any field listed in `filters` can be filtered with an exact-match query
parameter, and supports operator suffixes:

The `search` parameter is accepted by `list()` but ORM adapters currently ignore it. Server-side search is on the [Roadmap](#roadmap) for v1.4.
| Example | Meaning |
| --- | --- |
| `?status=active` | `status == "active"` |
| `?status__ne=active` | `status != "active"` |
| `?price__gt=100` | `price > 100` |
| `?price__gte=100` | `price >= 100` |
| `?price__lt=100` | `price < 100` |
| `?price__lte=100` | `price <= 100` |
| `?name__contains=pro` | case-insensitive substring |
| `?status__in=active,pending` | `status IN (...)` |

Until then, subclass `BaseViewset` or `AsyncBaseViewset` and override `list()` with your own filtering logic:
* Fields not listed in `filters` are ignored — arbitrary fields never
reach the ORM.
* Values are automatically coerced to `int` / `float` / `bool` when
they parse.
* Everything composes: `?search=pro&status=active&ordering=-price&limit=5`.

All built-in adapters (SQLAlchemy sync/async, Tortoise ORM, Peewee)
implement search, ordering and filters. The utility layer
(`fastapi_viewsets.utils.get_list_queryset` /
`fastapi_viewsets.async_utils.get_list_queryset`) also accepts
`search`, `search_fields`, `ordering` and `filters` keyword arguments
for programmatic use.

### Backward compatibility

Without a `ListConfig` on the response schema, the LIST endpoint
behaves exactly as before: `?search=` is ignored (no fields to search),
`?ordering=` is ignored, and unknown query parameters are dropped.

## Custom filtering beyond the whitelist

For anything the declarative config does not cover, subclass
`BaseViewset` or `AsyncBaseViewset` and override `list()`:

```python
from typing import List, Optional
Expand All @@ -51,7 +114,7 @@ class ItemsWithSearch(AsyncBaseViewset):
search: Optional[str] = None,
token: Optional[str] = Depends(lambda: None),
) -> list:
"""Custom LIST with case-insensitive search."""
"""Custom LIST with search beyond the whitelist."""
session = self.db_session()
try:
stmt = select(self.model)
Expand All @@ -66,39 +129,15 @@ class ItemsWithSearch(AsyncBaseViewset):

See [Overriding Handlers](overrides.md) for the full example with search + ordering + conflict handling.

## Ordering

There is no built-in `order_by` helper yet. Override `list()` with an ordered query:

```python
from sqlalchemy import select


class OrderedItems(AsyncBaseViewset):
async def list(
self,
limit: int = 10,
offset: int = 0,
token: Optional[str] = Depends(lambda: None),
):
session = self.db_session()
try:
stmt = select(self.model).order_by(self.model.created_at.desc())
stmt = stmt.offset(offset).limit(limit)
rows = (await session.execute(stmt)).scalars().all()
return [self.response_model.model_validate(row) for row in rows]
finally:
await session.close()
```

## Roadmap

| Item | Target | Status |
| --- | --- | --- |
| Wire `search` on LIST to real database queries | v1.4 | Planned |
| Declarative ordering (`order_by`) on LIST endpoints | v1.4 | Planned |
| Advanced filters (`__gt`, `__lt`, `__in`) via query params | v1.5 | Planned |
| Transaction helpers (`begin` / `atomic`) across adapters | v1.4 | Planned |
| Server-side `search` on LIST | v1.5.0 | Released |
| Declarative ordering on LIST | v1.5.0 | Released |
| Advanced filters (`__gt`, `__lt`, `__in`, ...) via query params | v1.5.0 | Released |
| Date-range filters (`__range`) and null checks (`__isnull`) | future | Planned |
| Transaction helpers (`begin` / `atomic`) across adapters | future | Planned |

## Next steps

Expand Down
Loading
Loading