better-drizzle

Relation writes

Attach, detach, and replace related rows from a single create, update, or upsert with connect, disconnect, and set.

Use relation commands when a single-row create, update, or upsert should also attach, detach, or replace related rows. You describe the link and better-drizzle resolves the foreign keys and junction rows inside one transaction.

The examples use author (a to-one relation on posts) and labels (a many-to-many relation from posts to tags through a postTags junction).

const post = await client.posts.create({
	data: {
		title: 'Relations without the glue',
		author: { connect: { email: 'alice@example.com' } },
		labels: { connect: [{ slug: 'drizzle' }, { slug: 'typescript' }] },
	},
	include: { author: true, labels: true },
});

No authorId in sight, no junction rows to insert by hand, and post comes back typed with both relations attached.

Attach existing rows with connect

connect links rows that already exist. On a to-one relation it takes a single selector; on a to-many relation it takes one selector or an array.

// to-one: resolve the author and write the foreign key
await client.posts.create({
	data: {
		title: 'Hello',
		author: { connect: { email: 'alice@example.com' } },
	},
});

// to-many: attach three existing posts to a user
await client.users.update({
	where: { id: 1 },
	data: {
		posts: { connect: [{ id: 2 }, { id: 3 }, { id: 9 }] },
	},
});

For a many-to-many relation declared with .through() in defineRelations, connect inserts the junction rows for you, as in the labels example at the top: you never name the junction table.

Detach rows with disconnect

disconnect breaks the link and leaves both rows in place. The shape differs by cardinality, because a to-one relation has nothing to select:

// to-one: `true`, not a selector
await client.posts.update({
	where: { id: 10 },
	data: {
		author: { disconnect: true },
	},
});

// to-many: the rows to detach
await client.users.update({
	where: { id: 1 },
	data: {
		posts: { disconnect: [{ id: 2 }, { id: 3 }] },
	},
});

A to-many disconnect selector must match an existing row, but a row that is linked to a different parent is skipped rather than rejected.

Replace a relation with set

set makes the relation exactly what you pass, connecting what is missing and disconnecting what is no longer listed. It is the right command for "these are the tags now" semantics:

// the post ends up with exactly these two tags
await client.posts.update({
	where: { id: 10 },
	data: {
		labels: { set: [{ slug: 'drizzle' }, { slug: 'orm' }] },
	},
});

// an empty array clears the relation
await client.posts.update({
	where: { id: 10 },
	data: { labels: { set: [] } },
});

// to-one: repoint it, or null it out
await client.posts.update({
	where: { id: 10 },
	data: { author: { set: { email: 'bob@example.com' } } },
});

await client.posts.update({
	where: { id: 10 },
	data: { author: { set: null } },
});

Reaching for set where connect would do means re-resolving rows that were already linked. Prefer connect / disconnect for incremental edits and keep set for replace-everything flows.

upsert follows its branch

upsert applies the rules of whichever branch runs. The create branch accepts connect only; the update branch accepts all three commands:

await client.posts.upsert({
	where: { slug: 'hello-world' },
	create: {
		slug: 'hello-world',
		title: 'Hello world',
		author: { connect: { email: 'alice@example.com' } },
	},
	update: {
		title: 'Hello world (revised)',
		labels: { set: [{ slug: 'drizzle' }] },
	},
});

Edge cases

Reference

Which commands a relation accepts depends on its cardinality and on whether you are creating or updating.

CommandTo-one on createTo-one on updateTo-many on createTo-many on update
connectYes - one selectorYes - one selectorYes - one or many selectorsYes - one or many selectors
disconnect-true-Yes - one or many selectors
set-one selector or null-an array of selectors

set is exclusive

set replaces the entire relation, so it cannot be combined with connect or disconnect for that same relation. The type system enforces this - mixing them is a compile error, not a runtime surprise.

On this page