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.
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.
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.
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 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.
Related
- 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
findandfindOnesurface these options belong to. - Access control for who may read the rows a relation pulls in.