belongs to associations
Overview
Belongs to associations are to be used when the model in question is related to another model, and contains a foreign key to that model within its table. An example of a belongs to would be in the case of a User and Post model, where a User can write many Posts, and each Post would contain a field (called a foreign key) which points back to the id field of a User who owns it. In this context, we would say that the Post belongs to the User:
export default class Post extends ApplicationModel {
...
@deco.BelongsTo('User')
public user: User
public userId: DreamColumn<Post, 'userId'>
}
Options
optional
BelongsTo associations are required by default (and should have a corresponding not-null declaration on the foreign key column). To make an association optional, pass the optional: true option (and ensure that the column allows null values):
export default class Post extends ApplicationModel {
...
@deco.BelongsTo('User', { optional: true })
public user: User | null
public userId: DreamColumn<Post, 'userId'>
}
A required BelongsTo is a two-way non-nullable contract
optional is the single source of truth for whether a BelongsTo can be null. A non-optional BelongsTo (the default) is typed non-null and serializes to a non-nullable OpenAPI field — "required" means "always present" — and Dream defends that contract at both ends:
- You may not conditionally load it. A trailing constraint on a non-optional
BelongsToinpreload/load/leftJoinPreload/leftJoinLoadis a compile error, since the constraint could filter the parent out and null a field the spec declares non-nullable. (innerJoin/leftJoinare exempt — they don't hydrate a value.) - It registers a
requiredBelongsTovalidation for you. Saving without the parent fails Dream validation before Postgres sees it, and.errorsis keyed by the association name ({ user: ['requiredBelongsTo'] }), not the foreign key column. Never declare@deco.Validates('requiredBelongsTo')yourself. - Accessing it when null throws
MissingRequiredBelongsToAssociation. When an internal mechanism nulls a required parent — a default scope (e.g. soft delete) filtered it out, anand/onbaked into the association excluded it, or the parent row was hard-deleted leaving a dangling FK — the getter throws this error instead of returning a null the types say is impossible.
The runtime error is a modeling bug, so the fix is a model change, not a looser spec: add dependent: 'destroy' to the inverse HasOne/HasMany so the parent can't be orphaned, or mark the BelongsTo optional: true if absence is legitimate.
Foreign key (on option)
By default, the foreign key is derived from the association name. E.g., in the example below, the default foreign key would be userId. This can be overridden by including an explicit on: '<camelized-column-name-on-this-table>' option:
export default class Post extends ApplicationModel {
...
@deco.BelongsTo('User', { on: 'myUserId' })
public user: User
public myUserId: number
}
Primary key override
By default, the column that the foreign key points to is returned by the primaryKey getter
(and defaults to id). For associations that point to something other than the primary key,
this can be overridden by passing the primaryKeyOverride: '<column-name-on-target-table>' option.
export default class Post extends ApplicationModel {
...
@deco.BelongsTo('User', { primaryKeyOverride: 'uuid' })
public user: User
public userId: number
}
on and primaryKeyOverride are independent knobs that can be combined to associate on any column pair, such as a natural key rather than the id-based FK. on names the column that holds the reference (here, on this model); primaryKeyOverride names the column it matches against on the other side, instead of the default id. The join becomes <fk-holder>.[on] = <other-side>.[primaryKeyOverride]:
// Associate Post and User by a public `uuid` instead of the numeric `user_id` → `id`.
@deco.BelongsTo('User', { on: 'userUuid', primaryKeyOverride: 'uuid' })
public user: User
public userUuid: string
// Resolves to: posts.user_uuid = users.uuid
This is distinct from selfAnd (see the HasOne/HasMany guide): on/primaryKeyOverride change which columns the join uses, while selfAnd keeps the default FK join and adds a second condition on top of it.
Polymorphic
A polymorphic BelongsTo points to one of several models. Pass an array of model names, polymorphic: true, and on for the id column; the type-discriminator column (<on-prefix>Type) is inferred:
export default class LocalizedText extends ApplicationModel {
...
@deco.BelongsTo(['Host', 'Place', 'Room'], { polymorphic: true, on: 'localizableId' })
public localizable: Host | Place | Room
public localizableType: DreamColumn<LocalizedText, 'localizableType'>
public localizableId: DreamColumn<LocalizedText, 'localizableId'>
}
See the full polymorphism guide for a worked example, migration, and caveats.
withoutDefaultScopes
To skip a model's default scopes (such as dream:SoftDelete) when loading a BelongsTo, pass the scope name(s):
export default class Booking extends ApplicationModel {
...
@deco.BelongsTo('Place', { withoutDefaultScopes: ['dream:SoftDelete'] })
public place: Place
public placeId: DreamColumn<Booking, 'placeId'>
}
Anchor polymorphism to a stable model
When a record participates polymorphically in more than one direction, don't stack the polymorphic ownership directly onto a model that also has to stay generic elsewhere — the association shape becomes ambiguous and call sites get hard to read. Introduce a stable join model that owns the participant polymorphism and acts as the fixed boundary; the other polymorphic axis hangs off that boundary instead of off the same generic model.
ConversationParticipant # stable table — the polymorphic boundary
├── participant: polymorphic Guest | Host
└── conversationThreads
ConversationThread
├── conversationParticipantId # plain BelongsTo to the stable model
└── context: polymorphic Booking | Place | ...
ConversationParticipant is the constant table: its participant association resolves to the domain record (Guest or Host), and the second polymorphic axis (context) lives on ConversationThread rather than being layered onto the participant. The call site stays readable — conversationParticipant.participant and conversationThread.context — and each model has exactly one polymorphic association to reason about.