# 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 ``` --- ## 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.