Skip to content

Migrating to v3.0

This page covers every breaking change introduced in v3.0 and the steps required to update your code.


CRUD

Facet keys now always use the full relationship chain

In v2, relationship facet fields used only the terminal column key (e.g. "name" for Role.name) and only prepended the relationship name when two facet fields shared the same column key. In v3, facet keys always include the full relationship chain joined by __, regardless of collisions.

User.status -> status
(User.role, Role.name) -> name
(User.role, Role.permission, Permission.name) -> name
User.status -> status
(User.role, Role.name) -> role__name
(User.role, Role.permission, Permission.name) -> role__permission__name

*_params dependencies consolidated into per-paginate methods

The six individual dependency methods (offset_params, cursor_params, paginate_params, filter_params, search_params, order_params) have been removed and replaced by three consolidated methods that bundle pagination, search, filter, and order into a single Depends() call.

Removed Replacement
offset_params() + filter_params() + search_params() + order_params() offset_paginate_params()
cursor_params() + filter_params() + search_params() + order_params() cursor_paginate_params()
paginate_params() + filter_params() + search_params() + order_params() paginate_params()

Each new method accepts search, filter, and order boolean toggles (all True by default) to disable features you don't need.

from fastapi_toolsets.crud import OrderByClause

@router.get("/offset")
async def list_articles_offset(
    session: SessionDep,
    params: Annotated[dict, Depends(ArticleCrud.offset_params(default_page_size=20))],
    filter_by: Annotated[dict, Depends(ArticleCrud.filter_params())],
    order_by: Annotated[OrderByClause | None, Depends(ArticleCrud.order_params(default_field=Article.created_at))],
    search: str | None = None,
) -> OffsetPaginatedResponse[ArticleRead]:
    return await ArticleCrud.offset_paginate(
        session=session,
        **params,
        search=search,
        filter_by=filter_by or None,
        order_by=order_by,
        schema=ArticleRead,
    )
@router.get("/offset")
async def list_articles_offset(
    session: SessionDep,
    params: Annotated[
        dict,
        Depends(
            ArticleCrud.offset_paginate_params(
                default_page_size=20,
                default_order_field=Article.created_at,
            )
        ),
    ],
) -> OffsetPaginatedResponse[ArticleRead]:
    return await ArticleCrud.offset_paginate(session=session, **params, schema=ArticleRead)

The same pattern applies to cursor_paginate_params() and paginate_params(). To disable a feature, pass the toggle:

# No search or ordering, only pagination + filtering
ArticleCrud.offset_paginate_params(search=False, order=False)

Models

The lifecycle event system has been rewritten. Callbacks are now registered with a module-level listens_for decorator and dispatched by EventSession, replacing the mixin-based approach from v2.

WatchedFieldsMixin and @watch removed

Importing WatchedFieldsMixin or watch will raise ImportError.

Model method callbacks (on_create, on_delete, on_update) and the @watch decorator are replaced by:

  1. __watched_fields__ — a plain class attribute to restrict which field changes trigger UPDATE events (replaces @watch).
  2. @listens_for — a module-level decorator to register callbacks for one or more ModelEvent types (replaces on_create / on_delete / on_update methods).
from fastapi_toolsets.models import WatchedFieldsMixin, watch

@watch("status")
class Order(Base, UUIDMixin, WatchedFieldsMixin):
    __tablename__ = "orders"

    status: Mapped[str]

    async def on_create(self):
        await notify_new_order(self.id)

    async def on_update(self, changes):
        if "status" in changes:
            await notify_status_change(self.id, changes["status"])

    async def on_delete(self):
        await notify_order_cancelled(self.id)
from fastapi_toolsets.models import ModelEvent, UUIDMixin, listens_for

class Order(Base, UUIDMixin):
    __tablename__ = "orders"
    __watched_fields__ = ("status",)

    status: Mapped[str]

@listens_for(Order, [ModelEvent.CREATE])
async def on_order_created(order: Order, event_type: ModelEvent, changes: None):
    await notify_new_order(order.id)

@listens_for(Order, [ModelEvent.UPDATE])
async def on_order_updated(order: Order, event_type: ModelEvent, changes: dict):
    if "status" in changes:
        await notify_status_change(order.id, changes["status"])

@listens_for(Order, [ModelEvent.DELETE])
async def on_order_deleted(order: Order, event_type: ModelEvent, changes: None):
    await notify_order_cancelled(order.id)

EventSession now required

Without EventSession, lifecycle callbacks will silently stop firing.

Callbacks are now dispatched inside EventSession.commit() rather than via background tasks. Pass it as the session class when creating your session factory:

from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

engine = create_async_engine("postgresql+asyncpg://...")
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from fastapi_toolsets.models import EventSession

engine = create_async_engine("postgresql+asyncpg://...")
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=EventSession)

Note

If you use create_db_session from fastapi_toolsets.pytest, the session already uses EventSession — no changes needed in tests.