Zum Inhalt springen

Beziehungen

Beziehungen definieren, wie Sammlungen auf Datenbankebene miteinander verbunden sind. Sie ermöglichen Rebase, Folgendes zu tun:

  • Beziehungsauswahlfelder in Entitätsformularen rendern
  • Verwandte Entitäten beim Anzeigen von Vorschauen auflösen
  • Fremdschlüsselbeschränkungen im Drizzle-Schema generieren
  • Kaskadierendes Löschen/Aktualisieren unterstützen

Beziehungen können entweder inline innerhalb der Eigenschaft oder explizit im relations-Array einer Sammlung definiert werden:

Deklarieren Sie die Verknüpfung an der Eigenschaft, verschachtelt unter relation. Wählen Sie die kind, und der Typ bietet genau die Felder an, die diese Art benötigt.

import { defineCollection } from "@rebasepro/cms-types";
const postsCollection = defineCollection({
slug: "posts",
name: "Posts",
table: "posts",
properties: {
title: { type: "string", name: "Title" },
content: { type: "string", name: "Content", admin: { multiline: true } },
author: {
type: "relation",
name: "Author",
relation: {
kind: "belongsTo",
target: () => usersCollection
}
}
}
});

Für eine Verknüpfung ohne eigene Eigenschaft — nichts, wonach sie im Formular oder in einer Tabellenspalte benannt werden könnte — deklarieren Sie sie in relations:

import { defineCollection } from "@rebasepro/cms-types";
const usersCollection = defineCollection({
slug: "users",
name: "Users",
table: "users",
properties: {
name: { type: "string", name: "Name" }
},
relations: [
{
kind: "hasMany",
relationName: "posts",
target: () => postsCollection
}
]
});

Eine Beziehung ist eine von fünf Arten. Die Art entscheidet, wo der Schlüssel liegt, ob eine Zeile oder viele zurückkommen und was ein Schreibvorgang durch sie hindurch anfassen darf.

Art Der Schlüssel liegt Liefert Hinweise
belongsTo auf dieser Tabelle eine localKey, Standard <relationName>_id
hasOne auf der Tabelle des Ziels eine foreignKeyOnTarget, Standard der Singular dieser Collection plus _id (users → user_id)
hasMany auf der Tabelle des Ziels viele Kinder gehören allein zu diesem Elternteil
manyToMany in einer Verbindungstabelle viele Zeilen werden geteilt; Ihnen gehört die Verknüpfung
via ein expliziter joinPath beides schreibgeschützt; cardinality geben Sie selbst an

Jedes Feld außer kind und target ist optional — der Rest wird abgeleitet.

belongsTo — der Schlüssel liegt auf dieser Tabelle

Abschnitt betitelt „belongsTo — der Schlüssel liegt auf dieser Tabelle“
author: {
type: "relation",
name: "Author",
relation: { kind: "belongsTo", target: () => usersCollection }
}
// → posts.author_id

Seit 0.25 Die Spalte wird für Sie indiziert: Postgres indiziert den Schlüssel, auf den ein Fremdschlüssel zeigt, nie die Spalte, die ihn hält, sodass ohne Index jedes hasMany-Include und jede verschachtelte Auflistung von der anderen Seite diese Tabelle scannt. index: false behält die Constraint und lässt den Index weg — siehe Fremdschlüssel werden für Sie indiziert, wann sich das lohnt und wann Rebase ihn von selbst auslässt, weil ein anderer Index die Spalte bereits bedient.

hasMany / hasOne — der Schlüssel liegt auf ihrer

Abschnitt betitelt „hasMany / hasOne — der Schlüssel liegt auf ihrer“
relations: [
{ kind: "hasMany", relationName: "posts", target: () => postsCollection }
]
// → reads posts.user_id

hasOne ist dieselbe Verknüpfung mit höchstens einer Zeile auf der Gegenseite.

Standardmäßig hält der Fremdschlüssel des Ziels die id der Quellzeile. Werden die beiden Seiten über etwas anderes verbunden — eine externe Identitäts-Id, eine SKU, ein Mandanten-Slug —, benennen Sie diese Spalte mit sourceKey:

relations: [
{
kind: "hasMany",
relationName: "applications",
target: () => applicationsCollection,
sourceKey: "auth_user_id", // column on THIS table
foreignKeyOnTarget: "auth_user_id" // column on the TARGET's table
}
]
// → reads applications.auth_user_id = talents.auth_user_id

sourceKey ist das Spiegelbild von localKey bei belongsTo: jenes benennt die Spalte, aus der diese Seite liest, dieses die Spalte, auf die die andere Seite zeigt. Ohne es ist eine Verknüpfung wie die obige als hasMany überhaupt nicht ausdrückbar und muss auf via zurückfallen, das schreibgeschützt ist.

Die Spalte muss eindeutig sein. Eine Verknüpfung, die mehr als eine Quellzeile adressiert, kann nicht sagen, zu welcher eine verwandte Zeile gehört, und Postgres akzeptiert einen Fremdschlüssel auf eine nicht-eindeutige Spalte ebenso wenig. Rebase prüft das zur Lesezeit und verweigert, statt eine auszuwählen.

Ein Elternteil, dessen sourceKey NULL ist, erreicht keine Zeilen, und das Schreiben durch die Beziehung ist ein Fehler — es gibt nichts, worauf die verwandten Zeilen zeigen könnten.

tags: {
type: "relation",
name: "Tags",
relation: { kind: "manyToMany", target: () => tagsCollection }
}
// → junction `posts_tags` (both table names, sorted), columns post_id / tag_id

Beide Seiten deklarieren ihre eigene, und jede schreibt through aus ihrer eigenen Sicht — sourceColumn benennt immer diese Sammlung:

// on posts
{ kind: "manyToMany", relationName: "tags", target: () => tagsCollection,
through: { table: "posts_tags", sourceColumn: "post_id", targetColumn: "tag_id" } }
// on tags
{ kind: "manyToMany", relationName: "posts", target: () => postsCollection,
through: { table: "posts_tags", sourceColumn: "tag_id", targetColumn: "post_id" } }

Für Verknüpfungen, die die vier obigen Formen nicht ausdrücken können: Multi-Hop-Pfade, zusammengesetzte Schlüssel oder ein Join, dessen Bedingung kein einfacher Fremdschlüssel ist. Schreibgeschützt — Rebase leitet nicht ab, wie durch eine beliebige Kette hindurch geschrieben werden soll.

{
kind: "via",
relationName: "permissions",
target: () => permissionsCollection,
cardinality: "many",
joinPath: [
{ table: "user_roles", on: { from: "id", to: "user_id" } },
{ table: "role_permissions", on: { from: "role_id", to: "role_id" } },
{ table: "permissions", on: { from: "permission_id", to: "id" } }
]
}

Um ein Beziehungsfeld in einem Formular zu rendern, fügen Sie eine Eigenschaft mit type: "relation" hinzu:

properties: {
author: {
type: "relation",
name: "Author",
relation: { kind: "belongsTo", target: () => usersCollection },
// Which control draws it is presentation, so it goes in the admin block:
// "select" (dropdown) or "dialog" (full picker).
admin: { widget: "select" }
}
}

Beim Rendern einer Vorschau (z. B. in einer Tabellenzelle oder einem Referenz-Chip) übernimmt Rebase die Hydration automatisch.

Die Kardinalität entscheidet über die Oberfläche, und es wird nur eine verwendet:

  • belongsTo / hasOne — eine Zeile, die Eigenschaft ist also ein Fremdschlüssel, den der Autor bearbeitet. Sie wird als der obige Picker gerendert.
  • hasMany / manyToMany — viele Zeilen, die Entitätsansicht listet sie also in einem eigenen Tab auf. Die Eigenschaft wird nicht im Formular gerendert: Die Kinder einer Sammlung sind eine Liste, kein Wert, den der Datensatz hält, und sie aus einem Dropdown auszuwählen ist nichts, was das Formular sinnvoll anbieten kann.

Eine Viele-Beziehung dennoch als Eigenschaft zu deklarieren lohnt sich: Sie ist es, die den Tab benennt, und sie gibt der Beziehung eine Spalte in der Sammlungstabelle, die der Listen-Abruf hydriert, sodass die Kindzeilen als Chips in der Zeile erscheinen. Nur das Formularfeld entfällt.

In der Tabelle bekommt eine Beziehung mit eigener Eigenschaft eine Spalte: ihre eigene. Jeder Tab hat außerdem eine Spalte mit einer Sprung-zum-Tab-Taste, aber bei einer per Eigenschaft deklarierten Beziehung wiederholte diese Taste dieselbe Überschrift neben einer Spalte, die die Kinder bereits zeigt — sie entfällt daher. Blenden Sie die Spalte der Beziehung aus (admin: { hideFromCollection: true }), und die Taste kommt zurück, sodass die Beziehung nie ganz aus der Tabelle fällt.

Das Ausblenden der Spalte verhindert das Laden nicht. Der Listen-Read der Collection bettet jede Beziehung ein, die eine Eigenschaft deklariert, ausgeblendet oder nicht; Code, der Listenzeilen liest – additionalFields, Entity-Actions, Collection-Actions über tableController.data, Plugin-Slots, browserCallbacks.afterRead –, findet die verknüpften Zeilen daher dort. Eine Beziehung, die nur im relations-Array der Collection ohne Eigenschaft deklariert ist, wird in der Liste nicht eingebettet; ihre Zeilen laden, wenn ihr Tab geöffnet wird.

Wenn Sie den Inline-Picker trotzdem wollen, verlangen Sie ihn:

properties: {
tags: {
type: "relation",
name: "Tags",
relation: { kind: "manyToMany", target: () => tagsCollection },
admin: { renderInForm: true } // off by default; the tab is the default treatment
}
}

Für Beziehungen, die mehrere Tabellen durchqueren, verwenden Sie kind: "via" mit einem joinPath. Diese sind schreibgeschützt: Rebase leitet nicht ab, wie durch eine beliebige Kette hindurch geschrieben werden soll.

// Users → Permissions through Roles
relations: [
{
kind: "via",
relationName: "permissions",
target: () => permissionsCollection,
cardinality: "many",
joinPath: [
{
table: "user_roles",
on: { from: "id", to: "user_id" }
},
{
table: "roles",
on: { from: "role_id", to: "id" }
},
{
table: "role_permissions",
on: { from: "id", to: "role_id" }
},
{
table: "permissions",
on: { from: "permission_id", to: "id" }
}
]
}
]
joinPath: [
{
table: "customers",
on: {
from: ["company_code", "region_id"], // Multiple columns
to: ["code", "region_id"]
}
}
]

Steuern Sie, was passiert, wenn verwandte Entitäten aktualisiert oder gelöscht werden:

relations: [
{
kind: "belongsTo",
relationName: "author",
target: () => usersCollection,
localKey: "author_id",
onDelete: "cascade", // Delete posts when user is deleted
onUpdate: "cascade" // Update FK when user ID changes
}
]
Aktion Verhalten
"cascade" Die Änderung auf verwandte Zeilen übertragen
"restrict" Den Vorgang verhindern, wenn verwandte Zeilen existieren
"no action" Gleich wie restrict (Aufschub bis zur Beschränkungsprüfung)
"set null" Die FK-Spalte auf NULL setzen
"set default" Die FK-Spalte auf ihren Standardwert setzen

Der Standardwert für ein erforderliches belongsTo hat sich geändert. In 0.17.3 ist er ON DELETE CASCADE — das Löschen eines Elternteils löscht seine Kinder — und ab 0.18 ist er RESTRICT: das Löschen schlägt fehl und nennt den Constraint. Alles Übrige in diesem Abschnitt ist unverändert, und db push plant die Constraint-Umschreibung beim Upgrade.

onDelete ist optional, daher benennen die meisten Beziehungen nie eines. Der Standardwert hängt davon ab, ob die Beziehung erforderlich ist:

Beziehung Standard-onDelete
belongsTo, optional "set null" — der Zeiger wird geleert
belongsTo, validation: { required: true } "restrict" — das Löschen des Elternteils schlägt fehl
manyToMany (Verknüpfungszeilen) "cascade" — die Verknüpfung geht, die Zielzeile bleibt

Eine erforderliche Beziehung ist keine Kaskade. required sagt, dass ein Kind ohne Elternteil nicht existieren kann; es sagt nicht, dass das Löschen des Elternteils das Kind zerstören soll. Das sind verschiedene Aussagen, und nur eine davon entfernt Zeilen, die Sie nicht benannt haben. Deshalb lässt der Standardwert das Löschen fehlschlagen und nennt die Beschränkung, und "cascade" ist etwas, worum Sie ausdrücklich bitten:

{
kind: "belongsTo",
relationName: "order",
target: () => ordersCollection,
// Eine Bestellposition ist ohne ihre Bestellung sinnlos — sagen Sie es.
onDelete: "cascade"
}

onUpdate hat keinen Standardwert: ohne Angabe wendet Postgres NO ACTION an. Setzen Sie "cascade", wenn der Schlüssel des Ziels etwas ist, das eine Person bearbeiten kann — ein Slug, eine SKU — damit die Zeiger ihm folgen.

Beim Abfragen von Daten über das typisierte SDK von Rebase sind Beziehungen standardmäßig nicht enthalten. Verwenden Sie die Methode include(), um verwandte Entitäten zusammen mit den primären Daten anzufordern.

const { data } = await client.data.articles
.include("author", "categories")
.find();
const { data } = await client.data.articles
.include("*")
.find();
const { data } = await client.data.articles.find({
include: ["author", "categories"]
});

Wenn enthalten, enthält die Antwort sowohl den skalaren Fremdschlüssel als auch das hydrierte Beziehungsobjekt:

const { data } = await client.data
.collection<{ id: string; authorId: string; author?: { name: string } }>("articles")
.include("author")
.find();
// The SDK returns flat rows — there is no `.values` wrapper. (`Entity`, with
// `id`/`path`/`values`, is an admin-UI view model, not what the client hands back.)
for (const article of data) {
// Scalar FK — always present
article.authorId; // "uuid-1234"
// Hydrated relation — only present when included
article.author?.name; // "Jane Doe"
}

Die an include() übergebenen Beziehungsnamen müssen mit dem relationName übereinstimmen, der im relations-Array der Sammlung definiert ist.

Für die vollständige Referenz zum Query Builder (Filtern, Sortieren, Paginierung, Echtzeit) siehe die Dokumentation des typisierten SDK.

Jede Zu-viele-Beziehung — hasMany, manyToMany oder ein Zu-viele-via — wird im Admin-Panel zu einem Tab unter einem Datensatz, der die Zeilen auflistet, die dieser Datensatz erreicht.

Eine Kindliste wird als parent/parentId/relationName adressiert:

/c/authors/a-1/posts the posts of author a-1
/c/posts/p-1/tags the tags of post p-1

Das letzte Segment ist der Beziehungsname, nicht der Slug der Zielsammlung. Beide sind oft gleich, weil eine unbenannte Beziehung den Slug ihres Ziels übernimmt — eine Inline-Beziehungseigenschaft übernimmt jedoch den Eigenschaftsschlüssel:

properties: {
featuredTags: {
type: "relation",
relation: { kind: "manyToMany", target: () => tagsCollection }
}
}
// tab and path segment: featuredTags (not "tags")

Das ist auch der Grund, warum zwei Beziehungen zur selben Sammlung funktionieren: Jede hat ihren eigenen Namen, also ihren eigenen Tab und ihren eigenen Pfad.

Was ein Tab Ihnen erlaubt, hängt davon ab, wie die Beziehung gespeichert ist, denn die beiden Fälle bedeuten Unterschiedliches:

Eins-zu-viele (foreignKeyOnTarget) Viele-zu-viele (through)
Das Kind gehört zu allein diesem Elternteil jedem Elternteil, das es verknüpft
Anlegen legt die Zeile unter diesem Elternteil an legt die Zeile an und verknüpft sie
Vorhandenes hinzufügen — verknüpft eine vorhandene Zeile
Entfernen löscht die Zeile löst die Verknüpfung; die Zeile bleibt unberührt

Das Admin-Panel rendert das entsprechend: Ein Viele-zu-viele-Tab bietet Vorhandenes hinzufügen und Aus diesem Datensatz entfernen an, und nie ein Löschen, das die Zeile anderen Elternteilen wegnähme.

Kindlisten sind gewöhnliche Sammlungsabfragen, eingeschränkt auf einen Elternteil, und akzeptieren daher alles, was eine Wurzelliste akzeptiert — Filter, orderBy, limit, offset, include —, und meta.total zählt die gefilterten Zeilen. Filtern Sie entweder pro Feld (?field=op.value) oder mit einem ganzen Objekt ?where={"field":["op","value"]}; beides erreicht dieselbe Abfrage:

GET /api/data/authors/a-1/posts?status=eq.published&orderBy=title&limit=20
GET /api/data/authors/a-1/posts?where={"status":["==","published"]}&orderBy=title
GET /api/data/authors/a-1/posts/p-1
POST /api/data/authors/a-1/posts create under this parent
PATCH /api/data/authors/a-1/posts/p-1 update; will not reparent
DELETE /api/data/authors/a-1/posts/p-1 delete (one-to-many) / unlink (many-to-many)

Das Elternsegment wird durchgesetzt, es ist keine Dekoration. Eine Zeile zu adressieren, die nicht unter diesem Elternteil liegt, ergibt 404, und PATCH verschiebt eine Zeile nie von einem Elternteil zu einem anderen — setzen Sie den Fremdschlüssel ausdrücklich, wenn Sie genau das wollen.

Bei einer Viele-zu-viele-Beziehung ist PATCH parent/id/child/childId Mengenzugehörigkeit: Es verknüpft die Zeile, falls sie noch nicht verknüpft ist, und ist idempotent. So hängen Sie eine bereits vorhandene Zeile an.

  • Zu-eins-Beziehungen — sie sind ein Feld am Datensatz, keine Liste. Das Schreiben über einen Zu-eins-Pfad wird abgelehnt: Der Fremdschlüssel liegt auf der Tabelle des Elternteils.
  • Beziehungen, die innerhalb einer map deklariert sind — sie sind ein Feld dieser Map.

Relation ist eine geschlossene Union — ein Mitglied pro Art, das jeweils nur die Felder trägt, die diese Art hat. Es gibt keine Feldkombination, die zwei verschiedene Verknüpfungen beschreibt, und kein Feld, das Sie setzen können und das die Art nicht verwendet.

type Relation =
| BelongsToRelation
| HasOneRelation
| HasManyRelation
| ManyToManyRelation
| ViaRelation;
interface RelationBase {
relationName?: string; // defaults to the property key, then the target's slug
target: () => CollectionConfig;
onUpdate?: OnAction;
onDelete?: OnAction;
overrides?: Partial<CollectionConfig>; // applied when rendered as a tab
}
// `required` is not here. It is `validation: { required: true }` on the
// property that declares the relation, the same key every other field uses.
interface BelongsToRelation extends RelationBase {
kind: "belongsTo";
localKey?: string; // column on THIS table
index?: boolean; // btree on localKey; defaults to true
}
interface HasOneRelation extends RelationBase {
kind: "hasOne";
foreignKeyOnTarget?: string; // column on the TARGET's table
sourceKey?: string; // column on THIS table; defaults to the primary key
}
interface HasManyRelation extends RelationBase {
kind: "hasMany";
foreignKeyOnTarget?: string; // column on the TARGET's table
sourceKey?: string; // column on THIS table; defaults to the primary key
}
interface ManyToManyRelation extends RelationBase {
kind: "manyToMany";
through?: { table?: string; sourceColumn?: string; targetColumn?: string };
}
interface ViaRelation extends RelationBase {
kind: "via";
cardinality: "one" | "many"; // a join chain cannot imply it
joinPath: JoinStep[];
}

Was Sie oben schreiben, ist die Autoren-Form. Intern arbeitet Rebase mit ResolvedRelation: dieselbe Verknüpfung mit allen eingesetzten Standardwerten und nichts Optionalem, dazu cardinality, targetSlug und zwei Flags — writable (nur bei via false) und shared (true, wenn die Zielzeilen auch anderen Elternteilen gehören, ein Entfernen also die Verknüpfung löst, statt zu löschen).

sourceKey ist die eine Ausnahme von „nichts Optionales“: Sein Standardwert ist der Primärschlüssel der Quelle, und um den aufzulösen, bräuchte es das Schema des Treibers, das die Auflösung nicht hat. undefined bedeutet dort „der Primärschlüssel“ und sonst nichts.

Sie schreiben nie eine ResolvedRelation. An einer Beziehungseigenschaft gehört relation Ihnen, und resolvedRelation ist die ausgefüllte, während der Normalisierung gestempelte Fassung.