Du kennst SQL, schreibst JOINs im Schlaf und weißt, wofür eine Zwischentabelle gut ist? Dann wirst du dich in PocketBase schnell zurechtfinden. Nur sieht vieles auf den ersten Blick anders aus: Es gibt kein JOIN-Statement in der API, keine Fremdschlüssel-Spalten mit _id-Suffix und meistens auch keine Zwischentabellen. Stattdessen arbeitest du mit Relationsfeldern, dem Parameter expand und sogenannten Back-Relations.
In diesem Beitrag übersetze ich dein SQL-Wissen Schritt für Schritt in die PocketBase-Welt. Als roten Faden nehme ich einen kleinen Blog mit Nutzern, Beiträgen, Tags und Kommentaren.
Das Grundprinzip: Relationsfeld statt Fremdschlüssel
Unter der Haube nutzt PocketBase SQLite. Eine Collection ist im Kern eine Tabelle, ein Record eine Zeile. Eine Relation legst du an, indem du einer Collection ein Feld vom Typ Relation hinzufügst und die Ziel-Collection auswählst. In diesem Feld speichert PocketBase die ID des verknüpften Records, also genau das, was in SQL dein Fremdschlüssel wäre.
Der wichtigste Unterschied: Ein Relationsfeld kann entweder Single sein (genau eine ID, gespeichert als String) oder Multiple (mehrere IDs, gespeichert als JSON-Array). Damit deckst du 1:n und n:m ab, ohne zusätzliche Tabellen anzulegen.
In der folgenden Übersicht habe ich dir zusammengestellt, welches SQL-Konzept welchem PocketBase-Konzept entspricht:
Mein Beispiel: ein kleiner Blog
Für alle folgenden Beispiele verwende ich vier Collections:
- users: die eingebaute Auth-Collection mit
nameundemail - posts: Beiträge mit
title, einem Autor (author, Single-Relation aufusers) und Tags (tags, Multiple-Relation auftags) - tags: Schlagwörter mit
name - comments: Kommentare mit
text, dem zugehörigen Beitrag (post) und dem Verfasser (author)
1:n: Der klassische Fremdschlüssel
Ein Beitrag hat genau einen Autor, ein Autor kann viele Beiträge schreiben. In SQL würdest du das so modellieren:
|
1 2 3 4 5 |
CREATE TABLE posts ( id TEXT PRIMARY KEY, title TEXT NOT NULL, author_id TEXT REFERENCES users(id) ); |
In PocketBase legst du in der Collection posts ein Feld author vom Typ Relation an, wählst users als Ziel-Collection und stellst es auf Single. Ein Record sieht dann so aus:
|
1 2 3 4 5 |
{ "id": "p1x9k2m4n6q8r0s", "title": "PocketBase für SQL-Profis", "author": "u7a3b5c7d9e1f2g" } |
Im Feld author steht nur die ID. Das ist exakt dein author_id, nur ohne Suffix.
Der JOIN heißt hier expand
In SQL holst du dir die Autordaten per JOIN:
|
1 2 3 |
SELECT p.*, u.name FROM posts p JOIN users u ON u.id = p.author_id; |
In PocketBase hängst du stattdessen den Parameter expand an die Anfrage:
|
1 |
GET /api/collections/posts/records?expand=author |
Mit dem JavaScript-SDK sieht das so aus:
|
1 2 3 |
const result = await pb.collection('posts').getList(1, 20, { expand: 'author', }); |
Die Antwort enthält den Autor dann vollständig unter dem Schlüssel expand:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
{ "id": "p1x9k2m4n6q8r0s", "title": "PocketBase für SQL-Profis", "author": "u7a3b5c7d9e1f2g", "expand": { "author": { "id": "u7a3b5c7d9e1f2g", "name": "Anna", "email": "anna@example.com" } } } |
Anders als bei einem SQL-JOIN werden die Daten nicht in eine flache Ergebniszeile gemischt, sondern bleiben verschachtelt. Ich finde das für die Arbeit im Frontend meist sogar angenehmer.
Filtern über die Relation
Du willst nur die Beiträge von Anna? In SQL wäre das ein JOIN mit WHERE. In PocketBase greifst du im Filter einfach per Punkt-Notation auf Felder der verknüpften Collection zu:
|
1 2 3 4 |
const result = await pb.collection('posts').getList(1, 20, { filter: 'author.name = "Anna"', expand: 'author', }); |
PocketBase baut den nötigen JOIN intern selbst. Das funktioniert übrigens auch beim Sortieren, zum Beispiel mit sort: 'author.name'.
Die Rückrichtung: Back-Relations
Jetzt wird es aus meiner Sicht richtig spannend. Kommentare verweisen über das Feld post auf einen Beitrag. Was aber, wenn du einen Beitrag mitsamt seinen Kommentaren laden willst? Der Beitrag selbst hat ja kein Feld, das auf die Kommentare zeigt.
In SQL drehst du einfach den JOIN um:
|
1 2 3 4 |
SELECT p.*, c.text FROM posts p LEFT JOIN comments c ON c.post_id = p.id WHERE p.id = 'p1x9k2m4n6q8r0s'; |
PocketBase löst das mit der Syntax collection_via_feld. Gemeint ist: „alle Records aus comments, deren Feld post auf diesen Record zeigt“.
|
1 2 3 |
const post = await pb.collection('posts').getOne('p1x9k2m4n6q8r0s', { expand: 'comments_via_post', }); |
In post.expand.comments_via_post findest du anschließend ein Array mit allen Kommentaren. Auch im Filter kannst du Back-Relations nutzen. So findest du alle Beiträge, in denen irgendein Kommentar das Wort „SQLite“ enthält:
|
1 |
filter: 'comments_via_post.text ?~ "SQLite"' |
Das Fragezeichen vor dem Operator bedeutet „mindestens einer“. Es entspricht also einem EXISTS bzw. ANY in SQL. Ohne Fragezeichen müssten alle verknüpften Kommentare die Bedingung erfüllen.
Mein Tipp: Beim Expand einer Back-Relation liefert PocketBase nicht beliebig viele Records mit. Wenn ein Beitrag tausende Kommentare haben kann, rate ich dir, die Kommentare direkt und paginiert abzufragen, etwa mit filter: 'post = "p1x9k2m4n6q8r0s"' auf der Collection comments.
n:m: Multiple-Relation statt Zwischentabelle
Ein Beitrag hat mehrere Tags, ein Tag gehört zu vielen Beiträgen. In SQL baust du dafür eine Zwischentabelle:
|
1 2 3 4 5 |
CREATE TABLE post_tags ( post_id TEXT REFERENCES posts(id), tag_id TEXT REFERENCES tags(id), PRIMARY KEY (post_id, tag_id) ); |
In PocketBase brauchst du das in den meisten Fällen nicht. Du legst in posts ein Relationsfeld tags an und stellst es auf Multiple (also eine maximale Auswahl größer als 1). PocketBase speichert dann ein Array von IDs direkt im Record:
Abfragen funktionieren genauso wie bei Single-Relationen. expand: 'tags' liefert dir ein Array mit allen Tag-Records. Für den Filter verwendest du wieder den „mindestens einer“-Operator:
|
1 2 |
// Alle Beiträge mit dem Tag "sqlite" filter: 'tags.name ?= "sqlite"' |
Beim Schreiben musst du nicht das komplette Array neu senden. Mit den Modifikatoren + und - fügst du einzelne IDs hinzu oder entfernst sie, ähnlich einem INSERT bzw. DELETE auf der Zwischentabelle:
|
1 2 3 4 |
await pb.collection('posts').update('p1x9k2m4n6q8r0s', { 'tags+': 't3', // Tag hinzufügen 'tags-': 't1', // Tag entfernen }); |
Wann du doch eine Zwischen-Collection brauchst
Sobald die Verknüpfung selbst Daten trägt, reicht ein Array von IDs nicht mehr. Typisches Beispiel: Ein Nutzer ist Mitglied in mehreren Teams und hat in jedem Team eine eigene Rolle. Dann legst du wie in SQL eine eigene Collection memberships an, mit den Feldern user (Relation auf users), team (Relation auf teams) und role (Text oder Select). Mit einem Unique-Index auf user und team verhinderst du doppelte Einträge.
1:1: Single-Relation mit Unique-Index
Für eine echte 1:1-Beziehung, etwa ein Profil pro Nutzer, nimmst du eine Single-Relation und machst sie eindeutig. In den Einstellungen der Collection kannst du dafür einen Index anlegen, und zwar in ganz normaler SQL-Syntax:
|
1 |
CREATE UNIQUE INDEX idx_profiles_user ON profiles (user); |
Damit kann jeder Nutzer höchstens ein Profil haben, genau wie mit einer UNIQUE-Constraint auf dem Fremdschlüssel.
Verschachteltes Expand: mehrere JOINs auf einmal
Was in SQL eine Kette von JOINs ist, erledigst du in PocketBase mit Punkt-Notation im expand. Mehrere Relationen trennst du mit Kommas. Ein Kommentar mit Beitrag, dessen Autor und dessen Tags:
|
1 2 3 |
const comments = await pb.collection('comments').getList(1, 50, { expand: 'author,post.author,post.tags', }); |
PocketBase erlaubt dabei mehrere Ebenen Verschachtelung. Wenn du nicht alle Felder brauchst, schränkst du die Antwort mit dem Parameter fields ein, ähnlich wie du in SQL statt SELECT * gezielt Spalten auswählst:
|
1 |
fields: 'id,text,expand.post.title,expand.author.name' |
Löschen: Cascade Delete und referentielle Integrität
In SQL legst du mit ON DELETE CASCADE fest, dass abhängige Zeilen mitgelöscht werden. PocketBase bietet dafür an jedem Relationsfeld die Option Cascade delete.
- Cascade delete aktiv: Löschst du einen Beitrag, verschwinden auch alle Kommentare, deren Feld
postauf ihn zeigt. Für das Feldcomments.postin meinem Beispiel halte ich das für sinnvoll. - Cascade delete inaktiv: PocketBase entfernt beim Löschen die Referenz aus dem Relationsfeld, entspricht also grob
ON DELETE SET NULL. Ist das Feld jedoch ein Pflichtfeld, verweigert PocketBase das Löschen, ähnlich wieON DELETE RESTRICT.
Bei posts.author würde ich Cascade delete dagegen nicht aktivieren: Ein gelöschter Nutzer würde sonst all seine Beiträge mitnehmen. Ich empfehle dir deshalb, pro Feld zu überlegen, welches Verhalten fachlich richtig ist.
Relationen in API Rules nutzen
Den größten Vorteil von Relationsfeldern sehe ich bei den Zugriffsregeln. Weil du per Punkt-Notation durch Relationen navigieren kannst, lassen sich Berechtigungen sehr ausdrucksstark formulieren. Beispiel: Nur der Autor eines Beitrags darf dessen Kommentare löschen:
|
1 |
post.author = @request.auth.id |
In SQL würdest du dafür eine Row-Level-Security-Policy mit Subquery schreiben. In PocketBase ist es eine Zeile.
Typische Stolperfallen für SQL-Profis
- Expand respektiert Zugriffsregeln: Darf der aktuelle Nutzer die verknüpfte Collection nicht lesen, bleibt
expandeinfach leer. Es gibt keinen Fehler. Wenn ein Expand „nicht funktioniert“, prüfe zuerst die View Rule der Ziel-Collection. - Keine freien JOINs über die API: Du kannst nur entlang definierter Relationsfelder expandieren. Für Bedingungen über nicht verknüpfte Collections gibt es in Filtern und Rules die Syntax
@collection.name.feld. - Kein GROUP BY in der Records-API: Für Aggregationen wie Anzahl der Kommentare pro Beitrag nutzt du eine View Collection. Die definierst du mit einem ganz normalen SQL-
SELECT, inklusiveJOIN,COUNTundGROUP BY. Sie ist allerdings nur lesbar. - Single und Multiple sind unterschiedliche Datentypen: Single liefert einen String, Multiple ein Array. Stellst du ein bestehendes Feld später um, muss dein Frontend damit umgehen können.
Fazit
Wenn du SQL kannst, kennst du die Konzepte hinter PocketBase-Relationen bereits. Der Fremdschlüssel heißt hier Relationsfeld, der JOIN heißt expand, und der umgekehrte JOIN heißt Back-Relation mit _via_. Neu ist vor allem, dass n:m-Beziehungen meist ohne Zwischentabelle auskommen und dass Filter und API Rules ganz selbstverständlich durch Relationen navigieren. Mein Rat: Lege dir den Beispiel-Blog selbst an und probiere die Abfragen im Admin-Dashboard und mit dem SDK aus.
Wenn du tiefer einsteigen willst, empfehle ich dir die offizielle Dokumentation unter Working with relations sowie in der Records API.
Häufige Fragen zu Relationen in PocketBase
Ein Relationsfeld speichert die ID des Ziel-Records und PocketBase prüft beim Speichern, ob diese ID existiert. Das Verhalten beim Löschen steuerst du über die Option Cascade delete. Funktional entspricht das einem Fremdschlüssel mit definierter Löschregel. Über den Parameter Das ist eine Back-Relation. Sie liefert alle Records der Collection Meist nicht. Ein Relationsfeld mit Mehrfachauswahl speichert mehrere IDs direkt im Record. Nur wenn die Verknüpfung eigene Daten wie eine Rolle oder ein Datum braucht, legst du eine eigene Zwischen-Collection an. In den meisten Fällen fehlt die Leseberechtigung auf der Ziel-Collection. Expand berücksichtigt die View Rule der verknüpften Collection. Prüfe außerdem, ob der Feldname im Nicht direkt über die Records-API. Dafür gibt es View Collections, die du mit einer SQL-SELECT-Abfrage definierst. Das Ergebnis kannst du anschließend wie eine normale Collection lesen und filtern.
Gibt es in PocketBase echte Fremdschlüssel wie in SQL?
Wie mache ich einen JOIN in PocketBase?
expand, zum Beispiel ?expand=author. Die verknüpften Records landen verschachtelt im Schlüssel expand der Antwort. Mehrere Relationen trennst du mit Kommas, tiefere Ebenen erreichst du per Punkt-Notation.Was bedeutet comments_via_post?
comments, deren Feld post auf den aktuellen Record zeigt. Du kannst sie in expand, in Filtern und in API Rules verwenden.Brauche ich für n:m-Beziehungen eine Zwischentabelle?
Warum ist mein expand leer?
expand-Parameter exakt stimmt.Kann ich in PocketBase Aggregationen wie COUNT oder GROUP BY nutzen?
