Many-to-Many
Define many-to-many relationships connecting multiple records on both sides
A many-to-many relationship is what two s.toMany slots spell. Neither
factory name says “many-to-many”: the pair does, and the junction table follows
from it.
Basic Example
import { s } from "viborm";
const post = s.model({
id: s.string().id().ulid(),
title: s.string(),
tags: s.toMany(() => tag),
});
const tag = s.model({
id: s.string().id().ulid(),
name: s.string().unique(),
posts: s.toMany(() => post),
});
When these models are registered under the schema keys post and tag, VibORM
automatically creates a junction table post_tag with postId and tagId
columns.
Characteristics
| Aspect | Value |
|---|---|
| Returns | Array on both sides |
| Junction table | Auto-created or explicit |
| Can be empty | Yes (empty array) |
| FK location | Junction table |
Configuration
The foreign keys live in a junction table, configured with .through(),
.source() and .target(). Supplying all three pins the ordinary junction’s
table and both side tokens independently of generated names. See the relation method
reference and referential
actions:
s.toMany(() => tag)
.name("labels") // Pairing label, matched exactly on both endpoints
.through("post_tags") // Junction table name
.source("post_id") // THIS endpoint's column, or compound-key prefix
.target("tag_id") // The other endpoint's column, or compound-key prefix
.onDelete("cascade") // Referential action (applies to both FKs)
.onUpdate("cascade") // Referential action (applies to both FKs)
One junction, one owner. These five methods describe one physical table, so
exactly one endpoint declares them; the other endpoint declares none and reads
the mirrored view. Configuring both sides is refused with R011. .source()
always means the side that spells it, so moving the configuration to the other
endpoint swaps .source() and .target() and leaves the table unchanged.
Junction actions accept cascade, restrict and noAction — but not
setNull: every junction column carries the membership itself, so nulling one
would erase the row’s meaning rather than clear a reference.
.source() and .target() remain scalar configuration methods even when an
endpoint uses a compound primary key. A one-field key uses the supplied string as
its exact junction column. A compound key expands it as a positional prefix in
the primary key’s declared field order:
const post = s.model({
tenantId: s.string(),
localId: s.string(),
tags: s.toMany(() => tag)
.through("post_tags")
.source("post")
.target("tagId"),
}).id(["tenantId", "localId"]);
// Junction columns: post_1, post_2, tagId
// post_1 -> post.tenantId; post_2 -> post.localId
The suffix is positional by design. Renaming or mapping a primary-key field does not turn its field name into junction storage; the declared primary-key order remains the source of truth.
Auto-Generated Junction Table
Without explicit configuration, VibORM generates:
// For: post.tags = s.toMany(() => tag) paired with tag.posts = s.toMany(() => post)
// Junction table: post_tag
// Columns: postId, tagId
The generated table and non-self side tokens are derived from the models’
schema object keys, lowercased, with the table keys sorted alphabetically:
post + tag → post_tag. JavaScript variable names and .map() table names
do not participate. Renaming a schema key therefore renames generated junction
storage; use the complete .through().source().target() configuration when the
physical names must stay fixed.
When the pair carries a .name(), the generated table name gains it as a
suffix, so several relationships between the same two models never collide.
Explicit Junction Table
Put every override on one endpoint. The other side is left bare:
const post = s.model({
id: s.string().id().ulid(),
title: s.string(),
tags: s.toMany(() => tag)
.through("post_tags")
.source("post_id")
.target("tag_id"),
});
const tag = s.model({
id: s.string().id().ulid(),
name: s.string().unique(),
posts: s.toMany(() => post), // mirrors the configuration above
});
tag.posts reads the same table with the sides swapped — tag_id is its source
and post_id its target — without repeating a single fact.
Querying Many-to-Many
// Include tags when fetching post
const post = await client.post.findUnique({
where: { id: "post_123" },
include: { tags: true },
});
// post.tags: Tag[]
// Connect and disconnect tags
await client.post.update({
where: { id: "post_123" },
data: {
tags: {
connect: [{ id: "tag_1" }],
disconnect: [{ id: "tag_2" }],
}
}
});
See To-Many Relation Filters for some/every/none and Nested Writes for connect/disconnect/set/connectOrCreate.
Common Patterns
Explicit Junction Model
When you need additional data on the relation, model the junction table yourself with two singular relations:
// Junction model with extra fields
const enrollment = s.model({
id: s.string().id().ulid(),
studentId: s.string(),
courseId: s.string(),
enrolledAt: s.dateTime().now(),
grade: s.string().nullable(),
student: s.toOne(() => student)
.fields("studentId")
.references("id"),
course: s.toOne(() => course)
.fields("courseId")
.references("id"),
})
.map("enrollments")
.unique(["studentId", "courseId"]);
const student = s.model({
id: s.string().id().ulid(),
name: s.string(),
enrollments: s.toMany(() => enrollment),
});
const course = s.model({
id: s.string().id().ulid(),
title: s.string(),
enrollments: s.toMany(() => enrollment),
});
Self-Referential Many-to-Many
Two collection slots on the same model pair with each other, and their side tokens default to the field names — no configuration required:
const user = s.model({
id: s.string().id().ulid(),
name: s.string(),
following: s.toMany(() => user), // junction column: followingId
followers: s.toMany(() => user), // junction column: followersId
});
Name the columns yourself by putting every override on one of the two slots:
const user = s.model({
id: s.string().id().ulid(),
name: s.string(),
following: s.toMany(() => user)
.through("user_follows")
.source("follower_id")
.target("following_id"),
followers: s.toMany(() => user),
});