Nested writes
Create, update, and upsert a parent and its children — to any depth — in one request with writable nested serializers.
Writable nested objects
Create a parent row and its children in a single request. Declare the nested relationship on the resource:
RestPlugin::default() .resource(ResourceConfig::for_::<Order>().nested("items", "order_item"))Now a POST whose body carries the nested array creates everything:
POST /api/order/Content-Type: application/json { "customer": "Ada", "items": [ { "product": "Widget", "qty": 2 }, { "product": "Gadget", "qty": 1 } ]}The order is inserted first; then each item is inserted with its foreign key to the order set automatically, so you don't repeat the parent id in each child. The response echoes the created children back:
{ "id": 7, "customer": "Ada", "items": [ { "id": 1, "order": 7, "product": "Widget", "qty": 2 }, { "id": 2, "order": 7, "product": "Gadget", "qty": 1 } ]}How the FK is found
.nested("items", "order_item") says "the items array holds order_item rows." The framework discovers which column on order_item points back at order (the column whose foreign key targets the parent's table) and fills it. If the child has no FK to the parent, or more than one (ambiguous), the request fails with a clear 400.
All-or-nothing on failure
If any child fails validation (a missing required field, a bad FK, a constraint clash), the whole write is rolled back in a single database transaction - the parent and all children either commit together or not at all, so you never get a half-created parent:
POST /api/order/ { "customer": "Grace", "items": [ {"product":"ok","qty":1}, {"qty":5} ] }→ 400 (the second item has no product) → no order is createdThis is a real database transaction: create_nested opens one umbral::db::Transaction, inserts the parent + every child on it (DynQuerySet::insert_json_in_tx), and commit()s only after they all succeed. Any failure - a bad child, a constraint clash, or even a process crash between inserts - leaves zero rows, because the transaction is never committed and the database rolls it back. (This replaced an earlier compensating delete-on-failure handler; see planning/orm_fixes.md #2.)
Nested updates
The same .nested(...) declaration also powers PATCH/PUT. A nested array on an update upserts its children in one transaction alongside the parent's own columns:
PATCH /api/order/7Content-Type: application/json { "customer": "Ada Lovelace", "items": [ { "id": 1, "qty": 99 }, { "product": "Sprocket", "qty": 4 } ]}Each item is matched by whether it carries the child's primary key:
- With a pk (
{ "id": 1, ... }) → that child is updated in place. The update is scoped to this parent via the foreign key, so one parent's payload can never touch another parent's child (a pk that belongs to a different parent returns404and the whole PATCH rolls back). - Without a pk (
{ "product": "Sprocket", ... }) → a new child is created, its FK set to the parent automatically (just like nested create).
No implicit deletes. Children you don't mention in the array are left untouched — a forgotten item never silently deletes a row. In the example above, order 7's other existing items survive; only item 1 is updated and one new item is added. Full "replace the whole set" (delete-the-missing) semantics are intentionally not the default; if you need to remove a child, delete it through its own resource.
Like nested create, the entire update — parent columns plus every child upsert — runs on one umbral::db::Transaction and commits only if all writes succeed. Any failure (a bad child, a cross-parent pk, a constraint clash) rolls everything back, including the parent's column changes.
Going deeper — multi-level nesting
Nesting recurses. Declare .nested(...) on each level and a single request writes the whole tree — order → items → components:
RestPlugin::default() .resource(ResourceConfig::for_::<Order>().nested("items", "order_item")) .resource(ResourceConfig::for_::<OrderItem>().nested("components", "component"))POST /api/order/{ "customer": "Ada", "items": [ { "product": "Widget", "qty": 2, "components": [ { "name": "Screw", "grams": 5 } ] } ]}The order, its item, and the item's components are all inserted on one transaction, each FK auto-wired to its parent. PATCH upserts the same way at every level (update-by-pk, create-without-pk, ownership-scoped so a grandchild pk from another parent is a 404).
The rule is simple: a level is written only if its parent's table also declared .nested(...). One declaration per level, no magic — and no silent drops. (If a table has no .nested() for an array key you send, that key is rejected rather than quietly ignored.) Depth is bounded at 16 to stop a cyclic declaration from recursing forever.
Declaring .nested() on a mid-level table (like OrderItem above) currently also exposes it as its own /api/order_item/ resource. If you don't want that, gate it with permissions for now; a declare-nesting-without-exposing option is on the roadmap.
Calling the writer without REST
The tree-write above is not REST-only. The same one-document → one-object-graph writer lives at the ORM layer (umbral::orm::nested), so a CLI seeder or an AI-agent object-graph seeder can hand ONE nested JSON document to ONE call — no RestPlugin, no HTTP request — and get the parent, its declared reverse-FK children (FKs auto-filled), and any M2M ids carried in the document written on one transaction, rolled back whole if any row fails.
use umbral::orm::nested::{NestedSpec, write_nested_tree}; // The (json_field → child_table) map REST builds from `.nested(...)`,// here built by hand for the seeder.let mut spec = NestedSpec::new();spec.insert("nw_author".into(), vec![("posts".into(), "nw_post".into())]); let mut body = /* { "name": "Ada", "posts": [ { "title": "…", "tags": [1, 2] } ] } */; let mut tx = umbral::db::begin().await?;let author = write_nested_tree(&spec, &author_meta, &mut body, &mut tx).await?;tx.commit().await?; // nothing is durable until hereThe direct caller runs with no security gating (it is trusted). The REST endpoints wrap the same writer with a gate that keeps their per-child security — hidden-field stripping, each child's own create permission, object-scope — fully intact. Pass a NestedWriteGate to write_nested_tree_gated to supply your own.
The update half is symmetric. update_nested_tree(&spec, &meta, "id", &id, &mut body, &mut tx) reconciles the same one-document tree against an existing parent: a child item carrying its primary key is UPDATED in place (scoped to its parent via the FK, so one parent's payload can never touch another's child — a cross-parent id is a NestedError::NotFound), an item without a pk is CREATED with its FK set, and a child absent from the payload is left untouched (upsert, no implicit deletes). Same one transaction, same whole-tree rollback, same optional NestedWriteGate (via update_nested_tree_gated) — so a CLI/agent reconciler gets the exact behavior REST's PATCH does, without an HTTP request.
use umbral::orm::nested::{NestedSpec, update_nested_tree}; let mut body = /* { "name": "Ada Lovelace", "posts": [ { "id": 7, "title": "…" }, { "title": "new" } ] } */; let mut tx = umbral::db::begin().await?;let author = update_nested_tree(&spec, &author_meta, "id", "3", &mut body, &mut tx).await?;tx.commit().await?; // the whole reconciliation is durable only hereScope
Arbitrary depth, one .nested(...) declaration per level. Declare multiple sibling arrays on the same table by calling .nested(...) more than once. The flat create/update paths are unchanged and carry zero overhead when no nested resources are declared.
See also
- Exposure: which models become resources.
- Authentication & permissions: gate the create action.
- Read-side expand: the
GETcounterpart — embed a reverse-FK relation or expand an M2M field via?expand=, instead of writing one throughPOST.