Skip to content
VibORM
Esc
navigateopen⌘Jpreview
On this page

Many-to-One

Define many-to-one relationships connecting multiple records to a single related record

A many-to-one relationship is what an s.toOne slot paired with an s.toMany slot spells, read from the singular side. The same pair read from the collection side is one-to-many.

Basic Example

import { s } from "viborm";

const post = s.model({
  id: s.string().id().ulid(),
  title: s.string(),
  authorId: s.string(),  // FK field
  author: s.toOne(() => user)
    .fields("authorId")
    .references("id"),
});

const user = s.model({
  id: s.string().id().ulid(),
  email: s.string().unique(),
  posts: s.toMany(() => post),  // Inverse side
});

Characteristics

Aspect Value
Returns Single object or null
FK location On this model
Can be null When a foreign-key field is .nullable()
Required by default Yes

Configuration

The singular side of this pair owns the foreign key, so .fields() and .references() are required here. See the relation method reference and referential actions:

s.toOne(() => user)
  .name("writer")       // Pairing label, matched exactly on both endpoints
  .fields("authorId")   // FK field(s) on this model - required
  .references("id")     // Referenced field(s) on the target - required
  .onDelete("cascade")  // Referential action on delete
  .onUpdate("cascade")  // Referential action on update

Composite foreign keys pass multiple fields:

s.toOne(() => organization)
  .fields("orgId", "teamId")
  .references("id", "teamId")

The collection side needs no configuration — but it must exist. A toOne whose target model declares nothing back is refused with R002, and an edge whose endpoints both decline .fields(...) is refused with FK004.

The foreign-key index

You do not declare it. push and migrate add an index over the .fields() columns on every database — PostgreSQL, MySQL and SQLite all leave a foreign key unindexed otherwise, and every include, relation filter and nested write reads this side of the relation through those columns.

What is worth declaring is a wider index, when a query needs more than the foreign key. The usual case is an include that orders and limits:

const post = s.model({
  id: s.string().id().ulid(),
  title: s.string(),
  createdAt: s.dateTime().now(),
  authorId: s.string(),
  author: s.toOne(() => user).fields("authorId").references("id"),
}).index(["authorId", "createdAt"]);

An index whose leading columns are the foreign key replaces the automatic one rather than adding a second index over the same column — so put the foreign key first. See composite indexes for ordered includes.

One index does not count: a partial index (.index([...], { where })) holds only the rows its predicate keeps, so it cannot serve the rows the predicate excludes. The automatic foreign-key index is still added alongside it.

Optionality

There is no .optional(). The foreign-key scalar is the optionality: mark it .nullable() and the relation becomes empty-able, offers disconnect, and accepts setNull actions.

authorId: s.string().nullable(),
author: s.toOne(() => user)
  .fields("authorId")
  .references("id")

Complete Example

const post = s.model({
  id: s.string().id().ulid(),
  title: s.string(),
  content: s.string(),
  published: s.boolean().default(false),

  // Required author - the FK scalar is required
  authorId: s.string(),
  author: s.toOne(() => user)
    .fields("authorId")
    .references("id")
    .onDelete("cascade"),

  // Optional category - the FK scalar is nullable
  categoryId: s.string().nullable(),
  category: s.toOne(() => category)
    .fields("categoryId")
    .references("id")
    .onDelete("setNull"),
});

Querying Many-to-One

// Include author when fetching post
const post = await client.post.findUnique({
  where: { id: "post_123" },
  include: { author: true },
});
// post.author: User

// Connect to an existing user on create (or set authorId directly)
const created = await client.post.create({
  data: {
    title: "My Post",
    author: {
      connect: { id: "user_123" }
    }
  }
});

See To-One Relation Filters for filter operators and Nested Writes for create/connect/connectOrCreate.

Self-Referential

The parent/child pattern — an entity pointing at another row of the same model — is a singular slot paired with a collection slot on the same model:

// Employees and their manager
const employee = s.model({
  id: s.string().id().ulid(),
  name: s.string(),
  managerId: s.string().nullable(),
  manager: s.toOne(() => employee)
    .fields("managerId")
    .references("id"),
  reports: s.toMany(() => employee),
});

// Threaded comments
const comment = s.model({
  id: s.string().id().ulid(),
  content: s.string(),
  parentId: s.string().nullable(),
  parent: s.toOne(() => comment)
    .fields("parentId")
    .references("id"),
  replies: s.toMany(() => comment),
});

Both halves must be declared: a self relation with no partner slot is refused. When a model carries two self relationships, give each pair a matching .name() so the endpoints know which one they belong to.

Was this page helpful?