Paginierung
Jedes Wurzelfeld und jedes To-many-Relationsfeld gibt eine Connection nach der Spezifikation
Relay Cursor Connections zurück. Es gibt keine einfache Liste
und kein limit-Argument: Sie selektieren immer über edges { node { … } } und bestimmen die Seitengröße
mit first oder last.
Die Beispiele verwenden die Objekte equipment und maintenance_request aus dem Überblick.
Aufbau einer Connection
type equipmentConnection {
edges: [equipmentEdge] # die angeforderte Seite
pageInfo: PageInfo!
totalCount: Long! # alle passenden Datensätze, unabhängig vom Seitenfenster
}
type equipmentEdge {
node: equipment # der Datensatz
cursor: String! # die Position dieses Datensatzes, opak
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String # null bei leerer Seite
endCursor: String # null bei leerer Seite
}
totalCount zählt alles, was auf Ihr where passt, nicht den Inhalt der Seite — auch bei einer
verschachtelten Connection, wo es die Größe der gesamten Relation angibt und nicht die der angeforderten
Vorschau.
Argumente
| Argument | Typ | Bedeutung |
|---|---|---|
first | Int | Die ersten N Datensätze (Vorwärtspaginierung). |
after | String | Ab nach diesem Cursor beginnen. Verwenden Sie pageInfo.endCursor der vorigen Seite. |
last | Int | Die letzten N Datensätze (Rückwärtspaginierung). |
before | String | Vor diesem Cursor enden. Verwenden Sie pageInfo.startCursor. |
offset | Int | N Datensätze vom Anfang überspringen. In Kombination mit first für direkten Seitenzugriff. |
Seitengrößen
| Standard, Wurzel-Connection | 20 |
| Standard, verschachtelte (To-many-)Connection | 10 |
| Maximum, überall | 100 |
Der verschachtelte Standardwert ist absichtlich kleiner: Eine verschachtelte Connection wird einmal pro
übergeordnetem Datensatz aufgelöst, ihre Seitengröße multipliziert sich also, während eine
Wurzel-Seitengröße nur einmal wirkt. Eine Wurzelabfrage mit first: 20 und einer verschachtelten
Connection ohne Größenangabe sind bereits 20 × 10 Datensätze.
Ein first oder last über 100 wird stillschweigend auf 100 reduziert, nicht abgelehnt. Werten Sie
edges.length — oder pageInfo.hasNextPage — aus, statt anzunehmen, dass Sie das Angeforderte erhalten
haben.
Abgelehnte Kombinationen
| Kombination | Grund |
|---|---|
first und last | Vorwärts oder rückwärts paginieren, nicht beides. |
offset mit after | offset ist eine absolute Position, after eine relative. |
offset mit before | Gleicher Grund. |
offset mit last | Verwenden Sie offset + first oder last + before. |
ein negatives first, last oder offset |
Jede Kombination liefert graphql/invalid-pagination und nennt die widersprüchlichen Argumente:
{ equipment(first: 20, offset: 100, after: "Y3Vyc29yOjA=") { edges { node { name } } } }
{
"data": null,
"errors": [
{
"message": "Pass either 'offset' or 'after', not both: 'offset' addresses an absolute position and 'after' a relative one.",
"path": ["equipment"],
"extensions": {
"code": "https://problems.tourfold.com/graphql/invalid-pagination",
"title": "Invalid pagination arguments",
"data": { "offset": 100, "after": "Y3Vyc29yOjA=" }
}
}
]
}
where und order_by lassen sich mit jedem Paginierungsmodus frei kombinieren.
Vorwärts paginieren
Fordern Sie die erste Seite an und übergeben Sie deren endCursor als after:
query FirstPage {
equipment(first: 2, order_by: [{ name: asc }]) {
totalCount
pageInfo { hasNextPage endCursor }
edges { cursor node { id name } }
}
}
{
"data": {
"equipment": {
"totalCount": 3,
"pageInfo": { "hasNextPage": true, "endCursor": "Y3Vyc29yOjE=" },
"edges": [
{ "cursor": "Y3Vyc29yOjA=", "node": { "id": "6f3d2a10-0000-4000-8000-000000000101", "name": "Basement Chiller CH-02" } },
{ "cursor": "Y3Vyc29yOjE=", "node": { "id": "6f3d2a10-0000-4000-8000-000000000102", "name": "Passenger Lift LIFT-01" } }
]
}
}
}
query SecondPage {
equipment(first: 2, after: "Y3Vyc29yOjE=", order_by: [{ name: asc }]) {
pageInfo { hasNextPage endCursor }
edges { cursor node { id name } }
}
}
Fahren Sie fort, solange pageInfo.hasNextPage wahr ist. Weil jede Abfrage eine
totale Ordnung erhält, passen
aufeinanderfolgende Seiten zusammen, ohne dass Sie ein Tiebreaker-Feld angeben.
Rückwärts paginieren
last mit before läuft in Richtung Anfang. last allein liefert die letzte Seite:
query LastPage {
equipment(last: 2, order_by: [{ name: asc }]) {
pageInfo { hasPreviousPage startCursor }
edges { cursor node { name } }
}
}
Übergeben Sie dann startCursor als before, um einen weiteren Schritt zurückzugehen.
Zu einer Seite springen
Für eine „Gehe zu Seite N“-Steuerung verwenden Sie offset mit first:
query PageSix {
equipment(first: 20, offset: 100, order_by: [{ name: asc }]) {
totalCount
pageInfo { hasNextPage hasPreviousPage }
edges { node { id name } }
}
}
offset = (Seitennummer − 1) × Seitengröße
Zählen ohne Laden
Wird totalCount ohne edges selektiert, werden überhaupt keine Datensätze geladen — der Server führt
nur die Zählung aus:
query OpenRequestCount {
maintenance_request(where: { resolved: { _eq: false } }) {
totalCount
}
}
Nutzen Sie das für Zähler und Übersichten. Ergänzen Sie edges in derselben Abfrage, greift wieder die
Standard-Seitengröße — halten Sie reine Zählabfragen also frei von edges.
:::warning Cursor sind Positionen, keine Lesezeichen Ein Cursor kodiert die Position eines Datensatzes in der sortierten Ergebnismenge, nicht einen Verweis auf den Datensatz selbst. Er ist opak — konstruieren, parsen oder speichern Sie ihn nie auf Dauer — und hat eine Konsequenz, die Sie einplanen müssen: Er gilt nur für die Ergebnismenge, die ihn erzeugt hat.
Werden während des Paginierens Datensätze eingefügt oder gelöscht, verschieben sich die Positionen unter
Ihnen. Ein Datensatz kann auf zwei aufeinanderfolgenden Seiten auftauchen oder ganz übersprungen werden. Der
endCursor von gestern setzt gegen die heutigen Daten nicht dort fort, wo Sie aufgehört haben; er landet
bei dem, was jetzt an dieser Position steht.
Cursor eignen sich daher nicht für vollständige Durchläufe — Synchronisation, Export, „alle Datensätze holen“. Verwenden Sie dafür Keyset-Paginierung: Sie adressiert Daten statt Positionen.
Für Personen, die durch eine Tabelle blättern, sind Cursor weiterhin das richtige Mittel — eine verschobene Zeile ist dort kein Korrektheitsproblem. :::
Vollständige Durchläufe
Um jeden Datensatz zu lesen — Synchronisation, Export, Abgleich — paginieren Sie über eine stabile Sortierung plus Filter und führen den letzten gesehenen Datensatz als Startpunkt der nächsten Anfrage mit.
Die Sortierung muss als Filter ausdrückbar und eindeutig sein. Die zweite Bedingung ist die, über die man stolpert:
:::danger Ein einzelner Zeitstempel-Cursor verliert Datensätze
Nur nach updated_at zu sortieren und ausschließlich diesen Wert mitzuführen funktioniert nicht — und
scheitert stillschweigend:
_gtauf dem Zeitstempel überspringt alle Datensätze, die an der Seitengrenze denselben Wert haben. Teilen mehr Datensätze einupdated_at, als auf eine Seite passen, werden die übrigen nie zurückgegeben — und der Durchlauf endet scheinbar erfolgreich._gteliest stattdessen dieselbe Seite endlos erneut und terminiert nie.
Das ist kein Randfall: Massenimportierte oder migrierte Datensätze teilen regelmäßig einen Zeitstempel bis zur Mikrosekunde, und die maximale Seitengröße ist 100. :::
Die Lösung ist ein zusammengesetzter Cursor: updated_at für den Fortschritt plus id zur Auflösung
von Gleichständen. id funktioniert, weil die totale Ordnung
jeder Abfrage mit id aufsteigend endet — es ist die einzige Spalte, die zwei ansonsten identische Zeilen
garantiert unterscheidet.
Sortieren Sie nach beiden und fragen Sie „späterer Zeitstempel oder gleicher Zeitstempel und spätere id“ ab:
query ExhaustivePage($lastUpdatedAt: DateTime!, $lastId: ID!) {
equipment(
where: {
_or: [
{ updated_at: { _gt: $lastUpdatedAt } }
{ _and: [{ updated_at: { _eq: $lastUpdatedAt } }, { id: { _gt: $lastId } }] }
]
}
order_by: [{ updated_at: asc }, { id: asc }]
first: 100
) {
edges {
node {
id
name
updated_at
}
}
}
}
Der Ablauf:
- Erste Seite mit
order_by: [{ updated_at: asc }, { id: asc }]und ohne Cursor-Filter abrufen. updated_atundiddes letzten zurückgegebenen Knotens übernehmen.- Beide in die obige Abfrage einsetzen.
- Wiederholen, bis eine Seite leer zurückkommt.
Führen Sie beide Werte mit. Nur den Zeitstempel mitzuführen führt genau das oben beschriebene Problem wieder ein.
Um von vorn zu beginnen, starten Sie mit $lastId = 00000000-0000-0000-0000-000000000000; dieser Wert
sortiert vor jeder erzeugten id.
:::note Kein Snapshot
Das Verfahren ist robust: Es überspringt und wiederholt keinen Datensatz aufgrund gleichzeitiger Einfüge-
oder Löschvorgänge — genau das können Offset-Cursor nicht zusichern. Ein Snapshot zu einem Zeitpunkt ist es
nicht: Ein Datensatz, der während des Durchlaufs geändert wird, wandert auf ein späteres updated_at und
kann daher mit neuem Inhalt erneut auftreten, und ein zwischenzeitlich gelöschter Datensatz erscheint
einfach nicht. Für transaktionale Konsistenz über einen gesamten Export bietet die API derzeit keine
Änderungssequenz und keinen High-Water-Mark, auf dem sich das aufbauen ließe.
:::
Semantik von pageInfo
| Feld | |
|---|---|
hasNextPage | Ob nach dieser Seite weitere Datensätze folgen. Aussagekräftig bei Vorwärtspaginierung mit first oder wenn before gesetzt ist. Bei einer Abfrage nur mit last ist der Wert false, denn das ist bereits die letzte Seite. |
hasPreviousPage | Ob dieser Seite Datensätze vorausgehen. Immer true, wenn after angegeben wurde. |
startCursor / endCursor | Cursor der ersten und der letzten Edge, oder null bei leerer Seite. |