QUESTPIE
SchemaRelations

Loading related rows

The with clause decides which relations come back and what shape they arrive in. The options it accepts follow the relation's cardinality.

View markdown

A read gives you the row you asked for and nothing hanging off it. with is where you ask for more, one relation at a time.

Naming what to load

Each key in with takes true or an options object.

const barber = await client.collections.barbers.findOne({
	where: { id },
	with: {
		appointments: { orderBy: { scheduledAt: "asc" }, limit: 10 },
		services: { where: { isActive: { eq: true } } },
		avatar: true,
	},
});

A to-one relation arrives as the row, replacing the id that was under that key. A to-many relation arrives as an array. A relation you leave out costs nothing: a to-one keeps its id, and a hasMany or manyToMany is not on the row at all, because it has no column here.

What each cardinality accepts

The types offer only the options that mean something for the shape you asked for. List options on a single row are not offered at all.

Optionto-oneto-manyWhat it does
columnsyesyesPartial select on the related rows.
whereyesyesFilter, typed against the target.
withyesyesGo one relation deeper.
limit, offsetnoyesCap and page them.
orderBynoyesSort the related rows. hasMany only.
_countnoyesA count in place of the rows. hasMany only.
_aggregatenoyes_sum, _avg, _min, _max too. hasMany only.

The last three are offered on a manyToMany and ignored there. It rebuilds each parent's array from the junction rows after the fact, so what comes back is the rows themselves, in junction order.

The types follow a nested with three levels deep, which is the cap that keeps inference finite. The client and the server use the same number.

Counting instead of fetching

const barber = await client.collections.barbers.findOne({
	where: { id },
	with: { appointments: { _count: true } },
});
// barber.appointments → { _count: 12 }

_aggregate returns the same shape with more in it. On a relation whose rows carry a numeric price, { _aggregate: { _sum: { price: true } } } gives back { _sum: { price: 4500 } }, and a field the target does not have is dropped without an error. Both forms are computed for a hasMany only, in one grouped query. A manyToMany returns its rows, so scope it with where and measure the array.

What it costs

Relations are resolved after the main query, in a batch. Each relation you name costs one extra read for the whole page of results rather than one per row, and a manyToMany costs two, one for the junction and one for the rows. Nesting adds a read per level, still not per row.

`limit` inside `with` caps the batch

The related rows for every parent on the page are fetched together, so a limit applies to that whole set rather than to each parent. On findOne there is only one parent and the two are the same.

Access rules still apply

Loading a relation runs a real read against the target collection, so that collection's own access rules decide what comes back. A relation is not a back door into a table the caller may not read. f.upload() is the exception: file relations are read through the parent row's decision, because the parent's rule already authorized that content.

On this page