Skip to content
VibORM
Esc
navigateopen⌘Jpreview
On this page

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 + tagpost_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),
});

Was this page helpful?