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, ordelete). - 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:
BEFORE_DELETE: walks everydenyrule pointing at the collection. The first subscriber with a non-zero count raisesCascadeDenyError.- The document is deleted (soft or hard, depending on
soft_delete). AFTER_DELETE: runs everynullanddeleterule.nullupdates run as bulk updates;deleterules 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:
- Looks up the
Organizationby that id. - Copies its
nameinto the department'sorganization_name. - Copies its
regioninto the department'sorganization_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:
- Strip the
_idsuffix →organization. - Try plural forms in order:
organizations,organizationes,organization. For fields ending iny, also tryy → ies(e.g.company_id→companies). - Handle prefixed self-references:
parent_unit_id→units,entry_organization_id→organizations.
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.py—CascadeRegistry, field-to-collection resolution, subscriber discovery.craft_easy/core/cascade/delete.py—CascadeDeleteServicewithdeny/null/deletestrategies.craft_easy/core/cascade/update.py—CascadeUpdateService,populate_readonly_fields, chain propagation.craft_easy/core/crud/auto_hooks.py— auto-hooks that wire cascade into every Resource.