Skip to main content

usage

Dream provides many useful tools for accessing and manipulating associations. Leveraging these powerful tools will enable you to easily bring forward complex, nested relationships throughout your app without ever manually writing a single line of SQL yourself.

Loading​

With associations already created in the database, there are several ways to access them. The most common way to do this would be to leverage the preload method, which will enable you to load an association onto your record as you are loading the record itself, like so:

const user = await User.preload('posts').firstOrFail()
user.posts
// [Post{}, Post{}, ...]

Additionally, you can retroactively load associations onto an instance using the load method:

let user = await User.firstOrFail()
user = await user.load('posts').execute()
user.posts
// [Post{}, Post{}, ...]

When loading and preloading, association conditions can be passed under and (or andNot/andAny), and many associations can be chained together:

let user = await User.firstOrFail()
user = await user.load('posts', { and: { body: null } }).load('pets').execute()
user.posts
// [Post{ body: null }, Post{ body: null }, ...]

user.pets
// [Cat{}, Dog{}]

associationQuery​

In addition to loading associations onto an existing instance, you can also construct a new query targeting your association using associationQuery:

const posts = await user.associationQuery('posts').all()
// [Post{}, Post{}, ...]

Using associationQuery, you can add additional statements to your query before loading, like so:

const posts = await user
.associationQuery('posts')
.where({ body: null })
.innerJoin('comments', { and: { body: null } })
.all()

// [Post{ body: null }, Post{ body: null }, ...]

association and associationOrFail​

Dream provides convenient methods for accessing single associations (HasOne and BelongsTo) that handle both loaded and unloaded states efficiently.

association​

The association method returns the associated record if already loaded, or queries for it if not loaded. It accepts required and passthrough options for dynamic loading.

N+1 Query Problem

Using association in loops or when processing multiple records can lead to the N+1 query problem. Each call to association on an unloaded association will trigger a separate database query. To avoid this, use preload or load to eager load associations before accessing them.

// Simple usage - returns loaded association or queries for it
const userSettings = await user.association('userSettings')
// UserSettings | null

// With required conditions (must be provided if association uses DreamConst.required)
const favoritePet = await user.association('favoritePet', {
required: { species: 'cat' },
})

// With passthrough data for serializers
const profile = await user.association('profile', {
passthrough: { locale: 'en-US' },
})

associationOrFail​

Similar to association, but throws a RecordNotFound exception if the association is not found.

N+1 Query Problem

Using associationOrFail in loops or when processing multiple records can lead to the N+1 query problem. Each call to associationOrFail on an unloaded association will trigger a separate database query. To avoid this, use preload or load to eager load associations before accessing them.

// Will throw if userSettings doesn't exist
const userSettings = await user.associationOrFail('userSettings')
// UserSettings (guaranteed to exist)

// With required conditions
const favoritePet = await user.associationOrFail('favoritePet', {
required: { species: 'dog' },
})
tip

Use required and passthrough instead of and conditions with these methods. The and parameter would be misleading for already-loaded associations since it wouldn't apply retroactively. The required and passthrough parameters are necessary for the association loading to work correctly.

creating​

Dream provides some helpful tools for saving associations. One of the simplest and most common ways of saving BelongsTo associations is to attach them directly to their parent while saving it, like so:

const user = await User.firstOrFail()
const post = await Post.create({ user })

This approach will automatically connect the userId field on the new Post model to the primary key field of the User model. This approach will also work with STI or polymorphic BelongsTo associations, since Dream is smart enough to autoset type fields in both cases.

Note: This approach will not work for HasOne or HasMany associations. Only BelongsTo associations are assignable through create/update params — the write path is the model holding the foreign key, so a HasOne/HasMany (including through) is neither in the param type nor accepted at runtime.

Passing associations: use the instance, not the foreign key​

When you hold a model instance and are creating or updating a record with an association, pass the instance — never the foreign-key id (or id + type for a polymorphic association):

await Booking.create({ place, guest }) // not { placeId: place.id, guestId: guest.id }
await review.update({ booking }) // not { bookingId: booking.id }

This works the same regardless of the association's shape — polymorphic or not, and whether the model you pass is a plain model or an STI child. Dream reads the foreign-key value off the instance and, for a polymorphic association, the type discriminator from the instance's referenceTypeString (which resolves an STI child such as a Bathroom to its base, 'Room'). One form covers every case, so the call site never has to know which case it's in.

Why this matters:

  1. Polymorphic filters/writes are wrong without it. Hand-assigning localizableId: host.id never sets the paired type discriminator. With integer/serial primary keys, a Host and a Place can share id 1, so an id-only reference is ambiguous between types; passing the instance sets the type discriminator for you.
  2. STI type strings resolve correctly. Creating or filtering through an STI child records/matches the base type ('Room', not 'Bathroom'). A hand-written localizableType: 'Bathroom' matches nothing — and nothing at the call site warns you.
  3. Holding the instance means you loaded it. The authorized way to obtain a record is to load it — usually through an association chain from the current user that proves they may touch it. Passing the instance keeps that step in the code path; hand-assigning a raw id from a request param skips it.
  4. Factories create an orphan from an id. Generated factories guard on the association (host: attrs.host ?? (await createHost())) with ...attrs spread after, so createHostPlace({ hostId: host.id }) creates a second Host and then overwrites hostId with the one passed — a stray row and an extra insert per call, silently.

Filtering accepts an array of instances too — Booking.where({ place: places }), and likewise whereNot/whereAny and the and/andNot/andAny of a join. A non-polymorphic key expands to a foreign-key IN; a polymorphic key groups the instances by type, so LocalizedText.where({ localizable: [...places, ...rooms] }) matches each id only with its own type. create, update, and findOrCreateBy still take a single instance.

Pass ids only when you don't hold the instances: an array of ids for a bulk filter (Booking.where({ placeId: placeIds }), where placeIds came from a prior query), and background-job arguments, which must serialize to a queue and therefore pass ids that the job re-hydrates on the other end.

Additionally, Dream provides the createAssociation method for creating associations, which can be used like so:

await user.createAssociation('posts', { body: 'hello world' })

updating​

Updating associations can happen in a multitude of ways. If the instance has already been loaded and attached to another instance, it can be saved just the same as it's parent, using the standard tools provided to all dream instances, like so:

const user = await User.preload('userSettings').firstOrFail()
await user.userSettings.update({ active: true })

Additionally, you can leverage updateAssociation to update associations which haven't been loaded yet:

await user.updateAssociation('userSettings', { active: true })

NOTE: in the case of HasMany associations, calling updateAssociation will apply to all discovered associations.

Additional conditions can be applied during update under and (or andNot/andAny), like so:

await user.updateAssociation('posts', { body: 'goodbye world' }, { and: { body: 'hello world' } })

destroying​

Dream also provides helpful tools for destroying associated records. The most commonly used tool is destroyAssociation, a method which will destroy all associated records, and return the number of records deleted:

await user.destroyAssociation('posts')
// 3

You can pass additional conditions under and (or andNot/andAny) while destroying associations, like so:

await user.destroyAssociation('posts', { and: { body: null } })
// 3

If you would like to bypass cascading on dependent: "destroy" child associations, you can pass cascade: false like so:

await user.destroyAssociation('posts', { cascade: false })
// 3

To remove child records whenever their parent is destroyed, declare dependent: 'destroy' on the parent's association and keep the foreign key on its default restrict behavior. Dream then destroys each child through its own lifecycle, so its hooks run and a @SoftDelete() child is soft-deleted rather than removed. A database-level ON DELETE CASCADE bypasses all of that, and never fires at all when the parent is soft-deleted, since the parent row stays in the table.