One-to-One
Define relations in which each side can reference at most one record
A one-to-one relationship is what two s.toOne slots spell. Neither factory
name says “one-to-one”: the pair does.
Basic Example
import { s } from "viborm";
// User has one profile
const user = s.model({
id: s.string().id().ulid(),
email: s.string().unique(),
profile: s.toOne(() => profile),
});
// Profile belongs to one user
const profile = s.model({
id: s.string().id().ulid(),
bio: s.string(),
userId: s.string(), // FK field
user: s.toOne(() => user)
.fields("userId")
.references("id"),
});
Which Side Owns the FK?
Exactly one side completes .fields(...).references(...); that side stores the
foreign key. Because its partner is also a toOne, the referenced tuple is
derived unique — VibORM emits the unique constraint for you.
| Side | Has FK | Configuration |
|---|---|---|
| Owner (Profile) | Yes | .fields("userId").references("id") |
| Non-owner (User) | No | none |
Declaring userId .unique() yourself is no longer needed to make the relation
one-to-one. Declare it only when callers must address that column through
whereUnique — that is a model-key decision, not a relation rule.
Neither endpoint may be left out: a toOne whose target model declares nothing
back is refused with R002. Two owners are CM003; no owner is FK004.
Configuration
One-to-one relations use the shared singular methods — see the relation method reference for details and referential actions:
s.toOne(() => user)
.name("owner") // Pairing label, matched exactly on both endpoints
.fields("userId") // FK field(s) on this model (owning side only)
.references("id") // Referenced field(s) on the target
.onDelete("cascade") // Referential action on delete
.onUpdate("cascade") // Referential action on update
Complete Example
const user = s.model({
id: s.string().id().ulid(),
email: s.string().unique(),
// Non-owning side - no FK, so zero related rows is valid
profile: s.toOne(() => profile),
});
const profile = s.model({
id: s.string().id().ulid(),
bio: s.string().nullable(),
avatar: s.string().nullable(),
// FK field - the pair derives its unique constraint
userId: s.string(),
// Owning side - has FK configuration
user: s.toOne(() => user)
.fields("userId")
.references("id")
.onDelete("cascade"),
});
Querying One-to-One
// Include profile when fetching user
const user = await client.user.findUnique({
where: { id: "user_123" },
include: { profile: true },
});
// user.profile: Profile | null
// Filter users by profile data
const users = await client.user.findMany({
where: {
profile: {
is: { bio: { contains: "developer" } }
}
}
});
See To-One Relation Filters for all operators and Nested Writes for creating related records.
Ownership and absence
// Non-owning: the related row may not exist, so this view is always nullable
profile: s.toOne(() => profile)
// Owning and required: this model stores a non-null foreign key
user: s.toOne(() => user)
.fields("userId")
.references("id")
There is no .optional() on a model-target relation, and none is needed.
A toOne without .fields(...) is the non-owning side. It has no local foreign
key that can require another row to exist, so its public slot is derived
zero-or-one. Nothing to declare, nothing to keep in sync.
An owning toOne follows its own scalars: required while its foreign-key fields
are required, empty-able as soon as one of them is .nullable(). A compound
foreign key mixing nullable and required members is nullable too — clearing its
nullable members empties the membership while the required context members stay.
For nested updates through the non-owning side, delete is available because
removing the child leaves the slot empty. disconnect is available only when the
child’s owning foreign key is nullable: disconnect preserves the child and must
therefore be able to clear its membership. Database setNull is stricter still —
it needs every foreign-key member to be nullable.