Nested create with Drizzle relations
Drizzle nested create in NestJS. Write a row and its related rows in one transaction, return writes with relations, and attach or sync links.
Drizzle nested create in NestJS works through writableRelations: list a relation there, put the related rows in the create body under the relation's name, and the package writes the parent and its children in one transaction. Every write method also takes options.relations and returns its rows with those relations loaded, and attach, detach and sync change many-to-many links with set-based statements.
Define the relations first
Every example here uses relations declared in forFeature config. If you have not set those up, start with Relations. Nested create needs RETURNING, so it runs on PostgreSQL only.
How do I get a created row back with its relations?
Pass relations (and counts, see Aggregates) as you would on find:
const post = await posts.create(
{ title: 'Hello', author_id: 2, status: 'draft' },
{ relations: ['author', 'tags'] },
);
post.author.name; // 'Bob'
post.tags; // []That is one INSERT plus one query per relation. A mass write loads the relations of every row it wrote in one query per relation too, so massCreate of 10 posts with relations: ['author'] is an INSERT and one SELECT, not ten.
const created = await posts.massCreate(dtos, { relations: ['author'], counts: ['tags'] });These methods load relations onto what they return:
| Method | Loads relations and counts |
|---|---|
create, massCreate | yes |
update, massUpdate | yes |
upsert, massUpsert | yes |
restore, massRestore | yes |
attach, detach, sync | yes, plus the relation they changed |
The relation paths are checked against loadableRelations before anything is written, so a bad path is a 400 and leaves the table untouched.
How do I create a row with its children in one call?
Allow the relation, then send the children in the body.
Declare the relation and allow writing it
List the relation in writableRelations next to its definition:
DrizzleCrudModule.forFeature([{
service: AuthorsService,
table: authors,
config: {
relations: {
posts: { table: posts, type: 'hasMany', foreignKey: 'author_id' },
},
writableRelations: ['posts'],
},
}]);Put the children in the body under the relation's name
const author = await authors.create(
{
name: 'Dee',
email: 'dee@x.io',
posts: [
{ title: 'First', status: 'draft' },
{ title: 'Second', status: 'draft' },
],
},
{ relations: ['posts'] },
);The author and both posts are written in one transaction: one INSERT for the author and one multi-row INSERT for the posts, with author_id set for you.
Handle a failing child
If a child fails, the parent is rolled back too, and the call answers with the child's error: a bad enum value is a 400, a duplicate key a 409. If you passed your own transaction, the nested writes join it instead of opening a new one.
What shapes does a nested body take?
The body value depends on the relation type:
| Relation | Body value | What happens |
|---|---|---|
| has-many | an array of objects | One multi-row INSERT, with the foreign key set to the new row's key. |
| belongs-to | an object | The related row is inserted first and its key goes on the new row (localKey). |
| many-to-many | an array of ids, objects, or both | Objects are inserted into the related table in one statement, then every id is linked through the join table. |
A few rules apply to every shape:
- Nested writes run on
createonly, and go one level deep. - Only the related table's own columns are written, never its soft delete column. Other keys in a child object are dropped.
- The timestamps and the tenant scope are set on a child row when the related table has those columns.
- A relation key set to
nullis skipped. - A wrong shape (an object for has-many, an array for belongs-to) is a 400.
- On MySQL, which has no
RETURNING, a nested create is a 400.
What if writableRelations is not set?
Then nested writes are off. A relation key in the body is left alone and Drizzle ignores it, which is how 3.4 behaved. With the list set, a relation key that is not in it is a 400, the same way writableColumns treats a column. A trusted server-side call can pass skipAllowlists: true to write any declared relation.
How do I attach or detach many-to-many rows?
Call attach, detach or sync with the row's id, the relation name and the related ids:
await posts.attach(2, 'tags', [1, 2]); // link tags 1 and 2
await posts.detach(2, 'tags', [1]); // unlink tag 1
await posts.detach(2, 'tags'); // unlink every tag
const post = await posts.sync(2, 'tags', [2]); // links are exactly [2] now
post.tags; // [{ id: 2, name: 'tech' }]| Method | Signature | What it does |
|---|---|---|
attach | attach(id, relation, relatedIds, options?) | Adds links. Ids already linked are skipped. |
detach | detach(id, relation, relatedIds?, options?) | Removes the given links, or every link when relatedIds is left out. |
sync | sync(id, relation, relatedIds, options?) | Adds the missing links and removes the rest, so the links match relatedIds exactly. [] removes every link. |
Each method returns Promise<T>: the row with that relation loaded, plus anything in options.relations. Each runs in one transaction and is set based whatever the number of ids: one SELECT checks the targets exist, one finds the links already there, and one INSERT adds the rest (sync adds one DELETE). Duplicates in the list are ignored. The related rows themselves are never changed or deleted.
With global hooks on, they raise attach, detach and sync events.
What errors can attach, detach and sync return?
| Cause | Result |
|---|---|
| The row does not exist, or is outside the caller's scope | 404 |
| A target id that does not exist or is soft-deleted | 400 BulkOperationException with one 404 entry per missing id |
| A target of another tenant, when the related table has the scope column | Counted as missing: a 404 entry |
| The relation is not a many-to-many | 400 |
writableRelations is set and does not list the relation | 400 |
relatedIds is not an array of plain ids | 400 |
When writableRelations is unset, attach, detach and sync work on any many-to-many relation.
Common questions
Yes. List the relation in writableRelations and send the children under the relation's name in the create body. The parent and children are written in one transaction.
No. Nested writes run on create only. To change many-to-many links on an existing row, use attach, detach or sync.
No. It needs RETURNING to know the new keys, so on MySQL a nested create is a 400.
No. sync only adds and removes rows in the join table. The related rows themselves are never changed or deleted.
Next steps
Relations
Declare belongs-to, has-many and many-to-many relations and load them on reads.
Aggregates, relation counts and countBy
Relation counts with counts and grouped totals with countBy.
NestJS Drizzle filter and column allowlists
writableRelations, writableColumns and the other per-entity lists.
Bulk insert and update with Drizzle
Bulk insert, update and delete with Drizzle in NestJS. Each mass method is one statement in a transaction, and a failure lists every failed item.
Drizzle optimistic locking with versions
Drizzle optimistic locking with a version column in NestJS. Set versionColumn, send the version you read, and a stale write is a 409 conflict.