Nested changesets ​
A parent changeset can carry changes to its associated records, so a post and its comments are validated together and saved in one transaction.
// From untrusted input
cast(post, params, ["title"]).castAssoc("comments", { with: Comment.changeset });
// From trusted data
change(post).putAssoc("tags", [tagA, tagB]);
change(comment).putAssoc("post", post);For nested objects stored in a JSON column, see Embeds. The examples on this page use these schemas:
class Comment extends Schema("comments", {
id: t.integer().primaryKey().dbGenerated(),
postId: t.integer(),
body: t.string(),
}) {
static post = belongsTo(() => Post, { foreignKey: "postId" });
static changeset(comment: Comment | typeof Comment, params: unknown) {
return cast(comment, params, ["body"]).validateRequired(["body"]);
}
}
class Post extends Schema("posts", {
id: t.integer().primaryKey().dbGenerated(),
authorId: t.integer(),
title: t.string(),
}) {
// Comments left out of a submitted form are deleted.
static comments = hasMany(() => Comment, { foreignKey: "postId", onReplace: "delete" });
// Removing a tag deletes its join row; the tag itself stays.
static tags = manyToMany(() => Tag, {
joinThrough: "posts_tags",
joinKeys: { owner: "post_id", target: "tag_id" },
onReplace: "delete",
});
static changeset(post: Post | typeof Post, params: unknown) {
return cast(post, params, ["title"]).validateRequired(["title"]);
}
}castAssoc ​
castAssoc(name, { with?, required? }) reads the association's data from the params given to cast and builds a child changeset for each item.
withis the child's changeset function, called aswith(target, item, index):targetis the existing child for an update, the target class for a new record, andindexis the item's position (0 for a to-one value). It defaults to the target schema's staticchangesetmethod; without one,withis required (a compile error where the types know the target).required: trueaddscan't be blankwhen the result is empty (no items, ornull).- Params are an object or
nullfor a to-one association, and an array of objects for a to-many association. Anything else addsis invalidto the association. - A key missing from the params changes nothing (with
required: true, it iscan't be blankwhen the parent has no children). - The association must be preloaded on a loaded parent, as whole rows (a preload with a
selectdoes not count). OtherwisecastAssocthrowsNotLoadedError. A new parent is treated as having no children. castAssocneeds a changeset made bycast; for values you trust, useputAssoc.
Post.changeset(post, params).castAssoc("comments", {
with: (comment, item, index) =>
Comment.changeset(comment, item).validateLength("body", { max: 500 - index }),
required: true,
});How items are matched ​
Each incoming item is compared with the existing children by primary key, the item's key first cast with the key field's type ("3" matches 3):
| Incoming item | Existing child | Result |
|---|---|---|
| has the key of a child | yes | with(existingChild, item), an update |
| has no key, or a key no child has | — | with(ChildClass, item), an insert |
| has the same key as an earlier item | — | an insert in its place, and is invalid on the association |
| — | a child no item names | decided by onReplace |
The new children keep the order of the params; the changes for children left out follow them. When every item matches its child in order and changes nothing, the association has no change.
For a to-one association, an item whose key matches updates the existing row; otherwise the existing row is replaced according to onReplace and a new one is inserted. null removes the existing row according to onReplace.
onReplace ​
onReplace is set on the association and decides what happens to an existing child that the new data leaves out (or, for a to-one association, replaces).
| Value | Effect | Kinds |
|---|---|---|
"error" | Throw a QueryError, before anything is sent. The default: nothing is removed unless you said how. | all |
"invalid" | Add is invalid to the association; the parent cannot be saved. | all |
"setNull" | Update the child, setting its foreign key to null (the key must be nullable). For belongsTo, the old parent is left as it is. | hasOne, hasMany, belongsTo |
"update" | To-one only: reuse the existing row and update it, whatever key the item has. It cannot remove the row (null throws). | hasOne, belongsTo |
"delete" | Delete the child. For manyToMany, delete the join row only: the target row stays. | all |
"deleteIfExists" | Delete the child, and do not fail if it is already gone. | hasOne, hasMany, belongsTo |
// body: { title: "…", comments: [{ id: 3, body: "edited" }, { body: "new" }] }
const post = await repo.oneOrThrow(from(Post, "p").where({ id: postId }).preload("comments"));
const result = await repo.update(Post.changeset(post, body).castAssoc("comments"));
if (!result.success) return result.error.errorMessages(); // { comments: [{}, { body: ["can't be blank"] }] }
// Comment 3 is updated, a new one inserted, and every other comment of the post deleted.putAssoc ​
putAssoc(name, value) sets an association from data you trust: a list for hasMany and manyToMany, one value or null for the others. Each value is one of:
- a loaded row: kept as it is (for
hasManyandhasOne, its foreign key is set to the parent's key when the parent is saved; formanyToMany, it is linked); - a changeset: inserted if it is for a new record, updated otherwise;
- a plain object: a new record, or an update when it carries the primary key of an existing child. It may hold values for the child's own associations and embeds, which are put on the child in turn.
Children that are left out follow onReplace. Putting the rows that are already there changes nothing. Giving the same row twice throws a QueryError.
const tags = await repo.all(from(Tag, "t").where(({ t }) => t.name.in(["news", "howto"])));
const loaded = await repo.preload(Post, post, "tags");
await repo.updateOrThrow(change(loaded).putAssoc("tags", [...tags, { name: "fresh" }]));buildAssoc(Schema, parent, name, values) returns a changeset for a new child of a hasOne or hasMany association, its foreign key already set. The schema class comes first, as for assoc and isLoaded: a row's type does not lead back to its class.
const cs = buildAssoc(User, user, "posts", { title: "Hello" }); // Changeset<typeof Post, true>, authorId = user.id
await repo.insert(cs);Errors ​
Child errors stay on the child changesets. The parent is invalid when any child is.
const cs = Post.changeset(post, params).castAssoc("comments");
cs.changes.comments; // readonly Changeset<typeof Comment, boolean>[] | undefined
cs.errorMessages(); // { comments: [{}, { body: ["can't be blank"] }] }When the association also has errors of its own (is invalid from onReplace, say), the tree keeps both: { comments: { $errors: ["is invalid"], $items: [{}, { body: [...] }] } } for a list, and $errors beside the child's own keys for a single child.
An issue from a schema validator whose path leads into an association or embed is placed on the matching child (by position for a list, counting the children that stay), so one nested schema can validate the whole payload. The validator sees each changed association as the values of its children.
const PostInput = z.object({
title: z.string().min(3),
comments: z.array(z.object({ body: z.string().max(500) })),
});
const cs = cast(post, params, ["title"]).castAssoc("comments").validate(PostInput);
// An issue at ["comments", 1, "body"] lands on the second comment's changeset.Saving ​
repo.insert and repo.update save the whole tree:
- Everything runs in one transaction: its own on the pool, or a savepoint inside a transaction (so a failure undoes only this write and the outer transaction carries on). The parent's
prepareChangesfunctions run inside it, and get the transaction's repo. - Order:
belongsTotargets first (their keys go into the parent), then the parent, thenhasOneandhasManychildren (which receive the parent's key): rows leaving the association first, then updates, then inserts. ThenmanyToManytargets, and their join rows. A replacedbelongsTotarget withonReplace: "delete"is deleted after the parent stops pointing at it. - Each child goes through the normal insert, update or delete, so its own nested changes, prepare functions, constraints and optimistic locking apply.
- If any child fails, everything is rolled back and the result is the parent changeset, with the failing child in its place carrying its errors and
action. An error that is thrown (an undeclared constraint, a stale row) is thrown after the rollback. - On success, the returned parent has each written association loaded with the saved children (rows that left it removed). Its type does not show them: narrow it with
isLoaded.
const result = await repo.insert(Post.changeset(Post, input).castAssoc("comments"));
if (result.success && isLoaded(Post, result.data, "comments")) {
result.data.comments; // Comment[], with their ids and postId
}timeoutMs is one budget for every statement of the call. An upsert (onConflict) cannot be combined with changes of associations.
Good to know
- Nothing is written until you call the repo; changesets never touch the database.
- Deleting a parent does not touch its children: that is the database's
ON DELETE. - Children are never removed silently: leaving one out throws unless the association's
onReplacesays what to do. - Has-through associations are read-only: change the associations they go through.
Recipes ​
- A form with rows that can be added, edited and removed:
castAssocwithonReplace: "delete"on the association. Rows left out of the submitted array are deleted. - A tag picker:
putAssoc("tags", tags)with rows you looked up or created first, inside a batch; the association needsonReplace: "delete"for tags to be removed.
const save = createBatch()
.run("tags", async (tx) => {
const found = await tx.all(from(Tag, "t").where(({ t }) => t.name.in(picked)));
const missing = picked.filter((name) => !found.some((tag) => tag.name === name));
const made =
missing.length === 0
? []
: (
await tx.insertAll(
Tag,
missing.map((name) => ({ name })),
{ returning: true },
)
).rows;
return success([...found, ...(made ?? [])]);
})
.run("post", async (tx) =>
success(await tx.oneOrThrow(from(Post, "p").where({ id: postId }).preload("tags"))),
)
.update("saved", ({ post, tags }) => change(post).putAssoc("tags", tags));
const result = await repo.transaction(save);See also ​
- Associations:
onReplaceand the other association options. - Embeds
- The tutorial's moderation form.

