Skip to content

Cascade Operations

When you delete or update a document, related documents elsewhere in the database often need to react. Craft Easy supports this declaratively through CascadeConfig on models: you say what should happen, and the framework figures out when and how.

Two independent concerns are handled by the cascade system:

  • Cascade delete — what happens to documents that reference a deleted item (deny, null, or delete).
  • Cascade update — how to propagate field changes from one document to denormalized copies in others.

Both mechanisms are opt-in per model and discovered automatically at startup. No manual wiring is needed.

Declaring cascade rules

Add a CascadeConfig nested class to any Beanie model:

from beanie import PydanticObjectId
from craft_easy.core.models import TenantScopedDocument

class Department(TenantScopedDocument):
    name: str
    organization_id: PydanticObjectId
    organization_name: str | None = None  # Denormalized from Organization.name

    class Settings:
        name = "departments"

    class CascadeConfig:
        # If the organization is deleted, deny the delete (block it).
        delete = {"organization_id": "deny"}

        # If Organization.name changes, update organization_name here.
        update = {"organization_name": "organization_id.name"}

At startup, the cascade registry walks every registered model, reads its CascadeConfig, and indexes the rules. During a request, the auto-hooks registered by every Resource fire the appropriate cascade logic.

Cascade delete

A rule in delete maps a reference field on this model to a strategy for what should happen when the referenced document is deleted. Three strategies are supported:

"deny" — block the delete

class Order(TenantScopedDocument):
    customer_id: PydanticObjectId
    ...
    class CascadeConfig:
        delete = {"customer_id": "deny"}

If a caller tries to delete a customer that has orders, the delete fails with:

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{
  "error": {
    "code": "CASCADE_DENY",
    "message": "Cannot delete customers/507f...: referenced by 7 orders",
    "details": {
      "resource": "customers",
      "item_id": "507f...",
      "referenced_by": "orders",
      "count": 7
    }
  }
}

The check runs in the BEFORE_DELETE auto-hook, before any writes happen. A single deny subscriber with at least one matching row is enough to block the delete.

"null" — nullify the reference

class Product(TenantScopedDocument):
    category_id: PydanticObjectId | None = None
    ...
    class CascadeConfig:
        delete = {"category_id": "null"}

When the referenced category is deleted, every product that pointed at it has its category_id set to None. For array-valued references like data_access.access_group_id, the matching entries are pulled from the array.

"delete" — recursively delete

class LineItem(TenantScopedDocument):
    order_id: PydanticObjectId
    ...
    class CascadeConfig:
        delete = {"order_id": "delete"}

When an order is deleted, each of its line items is deleted too. The cascade runs recursively — if a line item has its own downstream cascade rules, they fire in turn. A depth limit (currently 10) prevents runaway loops from mis-configured rules.

How it fits together

All three strategies can coexist on the same target. The cascade engine:

  1. BEFORE_DELETE: walks every deny rule pointing at the collection. The first subscriber with a non-zero count raises CascadeDenyError.
  2. The document is deleted (soft or hard, depending on soft_delete).
  3. AFTER_DELETE: runs every null and delete rule. null updates run as bulk updates; delete rules fetch the referencing documents and recursively delete them.

Cascade update

update rules keep denormalized copies in sync. Each entry maps a local field to a source path of the form reference_field.source_field:

class CascadeConfig:
    update = {
        "organization_name": "organization_id.name",
        "organization_region": "organization_id.region",
    }

Two things happen:

1. Populate on insert and update

When you create or update a Department and supply an organization_id, the BEFORE_CREATE/BEFORE_UPDATE auto-hook:

  1. Looks up the Organization by that id.
  2. Copies its name into the department's organization_name.
  3. Copies its region into the department's organization_region.

Callers never need to send the denormalized fields themselves — they come for free from the reference.

2. Propagate on source change

When an Organization is updated and its name or region changes, the AFTER_UPDATE auto-hook walks the cascade registry and runs a bulk update on every Department that subscribed to that field:

// Equivalent MongoDB bulk update
db.departments.updateMany(
  {organization_id: ObjectId("...")},
  {$set: {organization_name: "New Name"}}
);

Chained updates follow: if each updated department itself has subscribers (e.g. Employee denormalizes department.organization_name), the propagation continues. The chain depth is limited to 10 to prevent loops.

Array-valued references

Nested array references work the same way, using the dot syntax:

class UserRole(TenantScopedDocument):
    roles: list[RoleAssignment]  # each has an access_group_id

    class CascadeConfig:
        update = {"roles.access_group_name": "roles.access_group_id.name"}
        delete = {"roles.access_group_id": "null"}

For nested updates, the MongoDB positional operator $ targets the matching array element; for nested deletes, $pull removes the entry.

Reference resolution

Cascade config refers to fields by name, not by model. The registry resolves a field like organization_id to a collection by convention:

  1. Strip the _id suffix → organization.
  2. Try plural forms in order: organizations, organizationes, organization. For fields ending in y, also try y → ies (e.g. company_idcompanies).
  3. Handle prefixed self-references: parent_unit_idunits, entry_organization_idorganizations.

If no matching model is registered, the cascade rule is silently skipped. This means cascade declarations are safe even when optional modules are disabled — nothing references a collection that does not exist.

Disabling cascade

Cascade is master-switched by the CASCADE_ENABLED setting (default true). Set it to false to fall back to plain deletes and updates without cascade effects — handy in migration scripts and some test scenarios.

Example: the full picture

from beanie import PydanticObjectId
from craft_easy.core.models import TenantScopedDocument

class Customer(TenantScopedDocument):
    name: str
    email: str
    class Settings:
        name = "customers"

class Order(TenantScopedDocument):
    customer_id: PydanticObjectId
    customer_name: str | None = None     # Denormalized
    total: float
    class Settings:
        name = "orders"
    class CascadeConfig:
        # Never delete a customer that has orders
        delete = {"customer_id": "deny"}
        # Keep customer_name fresh
        update = {"customer_name": "customer_id.name"}

class Invoice(TenantScopedDocument):
    order_id: PydanticObjectId
    class Settings:
        name = "invoices"
    class CascadeConfig:
        # Deleting the order deletes its invoice rows too
        delete = {"order_id": "delete"}

Deleting an order whose customer is referenced by other orders is allowed (the deny rule points at customers, not orders). Deleting an order with invoices triggers invoice deletion. Deleting a customer with any orders is blocked.

Source

  • craft_easy/core/cascade/registry.pyCascadeRegistry, field-to-collection resolution, subscriber discovery.
  • craft_easy/core/cascade/delete.pyCascadeDeleteService with deny/null/delete strategies.
  • craft_easy/core/cascade/update.pyCascadeUpdateService, populate_readonly_fields, chain propagation.
  • craft_easy/core/crud/auto_hooks.py — auto-hooks that wire cascade into every Resource.