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.