# 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`)" ``` User.status -> status (User.role, Role.name) -> name (User.role, Role.permission, Permission.name) -> name ``` === "Now (`v3`)" ``` 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`)" ```python 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, ) ``` === "Now (`v3`)" ```python @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: ```python # 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`](../reference/models.md#fastapi_toolsets.models.listens_for) decorator and dispatched by [`EventSession`](../reference/models.md#fastapi_toolsets.models.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`](../reference/models.md#fastapi_toolsets.models.ModelEvent) types (replaces `on_create` / `on_delete` / `on_update` methods). === "Before (`v2`)" ```python 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) ``` === "Now (`v3`)" ```python 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`)" ```python from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine engine = create_async_engine("postgresql+asyncpg://...") SessionLocal = async_sessionmaker(engine, expire_on_commit=False) ``` === "Now (`v3`)" ```python 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.