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.

Before (`v2`)Now (`v3`)

    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.

Before (`v2`)Now (`v3`)

    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).

Before (`v2`)Now (`v3`)

    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:

Before (`v2`)Now (`v3`)

    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.