QUESTPIE
Schema

Relations

One field declares the link between two collections. The column, the query filters, the nested writes and the admin control all follow from it.

View markdown

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.

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

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.

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.

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

Loading related rows covers the rest of that options object, and what each relation kind costs.

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.

KindReach for it whenWritten as
belongsTothis row points at one other rowf.relation("barbers")
hasManythe other table holds the key.hasMany({ foreignKey, relationName })
manyToManya junction collection joins the two.manyToMany({ through, sourceField, targetField })
multiplea read-only list of ids sits on this row.multiple()
morphToone field points at several collectionsf.relation({ posts: "posts", pages: "pages" })

One row, many rows builds the middle three. One field, several targets builds the last.

Filtering by a relation

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

// 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.

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 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.

// 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.

`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.

  • Fields for the modifiers every field shares, and for f.upload(), the relation to a file that renders as an upload control.
  • Collections for the find and findOne surface these options belong to.
  • Access control for who may read the rows a relation pulls in.

On this page