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').execute()
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, additional where statements can be passed in, and many associations can be chained together:

let user = await User.firstOrFail()
user = await user.load('posts', { 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', { 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 derives the foreign key from the instance's primary key 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 through an STI child records the base type ('Room', not 'Bathroom'), because the association is declared on the base — a hand-written type string gets this wrong with nothing at the call site to warn 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.

Pass ids when you don't hold an instance: an array of ids for a bulk filter, a cross-type polymorphic batch load that falls back to explicit type/id groups, 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 where clauses can be applied during update, like so:

await user.updateAssociation('userSettings', { active: true }, { where: { userId: [1, 2, 3] } })

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 an additional where clause while destroying associations, like so:

await user.destroyAssociation('posts', { where: { 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

Additionally, one is free to set up cascade deletion, either at the db level (which is preferred), or else by leveraging dependent: 'cascade' at the association level (though usually, this would only be done for polymorphic relationships, since cascade deleting at the DB level will not work in those cases.)