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

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.manyToOne(() => user)
    .fields("authorId")
    .references("id"),
});

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

Characteristics

Aspect Value
Returns Single object or null
FK location On this model
Can be null Yes, with .optional()
Required by default Yes

Configuration

Many-to-one is the FK-owning side — .fields() and .references() are required. See the relation method reference and referential actions:

s.manyToOne(() => user)
  .fields("authorId")   // FK field(s) on this model - required
  .references("id")     // Referenced field(s) on the target - required
  .optional()           // Relation can be null
  .onDelete("cascade")  // Referential action on delete
  .onUpdate("cascade")  // Referential action on update
  .name("writer")       // Custom relation name

Composite foreign keys pass multiple fields:

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

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.manyToOne(() => 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.

When using .optional(), the FK field itself must also be .nullable():

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

Complete Example

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

  // Required author
  authorId: s.string(),
  author: s.manyToOne(() => user)
    .fields("authorId")
    .references("id")
    .onDelete("cascade"),

  // Optional category
  categoryId: s.string().nullable(),
  category: s.manyToOne(() => category)
    .fields("categoryId")
    .references("id")
    .optional()
    .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 many-to-one relation with a one-to-many inverse:

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

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

Was this page helpful?