# Relations (/docs/schema/relations)

---
title: Relations
description: One field declares the link between two collections. The column, the query filters, the nested writes and the admin control all follow from it.
kind: guide
package: questpie
---

An appointment has to know which barber it is for. This page starts from that
one link and ends with the barber's row loaded next to the appointment.

## Declaring the link

`f.relation()` takes the name of the collection you are pointing at. Nothing
else is required.

```ts title="src/questpie/server/collections/appointments.ts"
import { collection } from "#questpie/factories";

export const appointments = collection("appointments").fields(({ f }) => ({
	customer: f.relation("user").label("Customer").required(),
	barber: f.relation("barbers").label("Barber").required(),
	service: f.relation("services").label("Service").required(),
	scheduledAt: f.datetime().required(),
}));
```

Each of those three is a `varchar(36)` column named after the field, holding the
id of a row in the other collection. Without `.required()` the column is
nullable. `@questpie/admin` renders the field as a picker that searches the
target collection by its title.

The target name is checked against the collection registry codegen writes, so
`f.relation("barbre")` does not compile. Before the first `questpie generate`
that registry is empty and the argument falls back to plain `string`, which is
why a fresh checkout still builds.

## The id, and the row behind it

A relation reads back as the id it stores. Pass `with` to get the row instead.

```ts
const appt = await client.collections.appointments.findOne({
	where: { id },
	with: { barber: true },
});

// appt.scheduledAt → Date
// appt.customer    → "0d9a…"   still the id, it was not in `with`
// appt.barber      → { id: "3a0e…", name: "Sam", email: "sam@shop.test", … }
```

`with` replaces the key rather than adding one. The default read stays a single
query against a single table, and each key you name adds one more, so you pay
only where you ask. Each key takes `true` or an options object scoped to the
target collection.

```ts
with: { barber: { columns: { id: true, name: true } } }
```

**[Loading related rows](/docs/schema/relations/loading)** covers the rest of
that options object, and what each relation kind costs.

## Five kinds of link

The plain call above is a **belongsTo**. Three chained methods turn it into a
to-many kind, and each declares a whole new field rather than adding to this
one, so chain it first and refine afterwards. The fifth kind comes from the
argument instead: pass a map of collections and you get a **morphTo**.

| Kind       | Reach for it when                        | Written as                                           |
| ---------- | ---------------------------------------- | ---------------------------------------------------- |
| belongsTo  | this row points at one other row         | `f.relation("barbers")`                              |
| hasMany    | the other table holds the key            | `.hasMany({ foreignKey, relationName })`             |
| manyToMany | a junction collection joins the two      | `.manyToMany({ through, sourceField, targetField })` |
| multiple   | a read-only list of ids sits on this row | `.multiple()`                                        |
| morphTo    | one field points at several collections  | `f.relation({ posts: "posts", pages: "pages" })`     |

**[One row, many rows](/docs/schema/relations/to-many)** builds the middle three.
**[One field, several targets](/docs/schema/relations/polymorphic)** builds the
last.

## Filtering by a relation

A belongsTo field is filterable four ways, all typed against the target
collection.

```ts
// 1. The id, bare.
where: { barber: barberId }

// 2. The id, with an operator: eq, ne, not, in, notIn, isNull, isNotNull.
where: { barber: { in: [barberId, otherId] } }

// 3. Fields of the row it points at.
where: { barber: { is: { isActive: { eq: true } } } }

// 4. The same, without the `is` wrapper.
where: { barber: { isActive: { eq: true } } }
```

Forms 3 and 4 compile to an `EXISTS` subquery against the target table, and
`isNot` negates it. To-many fields take `some`, `none` and `every` instead.

## Linking rows on write

`create` and `update` take either the id or a nested mutation under the same
key.

```ts
await client.collections.appointments.create({
	customer: customerId,
	barber: { connect: { id: barberId } },
	service: serviceId,
	scheduledAt: new Date(),
});
```

`connect` links an existing row, `create` inserts one and links it, and
`connectOrCreate` looks first and inserts only on a miss. Give a belongsTo key
one of the three. **[Writing relations](/docs/schema/relations/writing)** adds
the to-many forms, including `set`.

Whichever form you use, the id is checked before the parent row is written. A
write that names a row that is not there is a `400 Bad Request`, and the row it
does name is locked until the transaction ends.

## What a delete does

`.onDelete(action)` takes `"cascade"`, `"set null"`, `"restrict"` or
`"no action"`, and it belongs on the side that declares the to-many relation.
QUESTPIE runs the action itself, on the delete path, so your hooks fire for
every cascaded row. No database foreign key is involved.

```ts
// On `barbers`: deleting a barber deletes that barber's appointments.
appointments: f
	.relation("appointments")
	.hasMany({ foreignKey: "barber", onDelete: "cascade", relationName: "barber" }),
```

A `hasMany` honours all four. `cascade` deletes the children one by one,
`set null` clears their key, and `restrict` returns `409 Conflict` while any
child is left. A `manyToMany` honours only `cascade`, which deletes the junction
rows. On a belongsTo the setting is stored and never read, and so is
`.onUpdate()` on every kind.

<Callout type="warn" title="`relationName` names a field, not a pair">
	A `hasMany` loads, filters and cascades through the reverse belongsTo, found
	by looking its `relationName` up among the target's field names. Point it at
	that field, not at a label you invent, or the list comes back empty and its
	filters drop out.
</Callout>

## Related

- **[Fields](/docs/schema/fields)** for the modifiers every field shares, and
  for `f.upload()`, the relation to a file that renders as an upload control.
- **[Collections](/docs/schema/collections)** for the `find` and `findOne`
  surface these options belong to.
- **[Access control](/docs/schema/access-control)** for who may read the rows a
  relation pulls in.
