Zum Hauptinhalt springen

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​

ArgumentTypBedeutung
firstIntDie ersten N Datensätze (Vorwärtspaginierung).
afterStringAb nach diesem Cursor beginnen. Verwenden Sie pageInfo.endCursor der vorigen Seite.
lastIntDie letzten N Datensätze (Rückwärtspaginierung).
beforeStringVor diesem Cursor enden. Verwenden Sie pageInfo.startCursor.
offsetIntN Datensätze vom Anfang überspringen. In Kombination mit first für direkten Seitenzugriff.

Seitengrößen​

Standard, Wurzel-Connection20
Standard, verschachtelte (To-many-)Connection10
Maximum, überall100

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​

KombinationGrund
first und lastVorwärts oder rückwärts paginieren, nicht beides.
offset mit afteroffset ist eine absolute Position, after eine relative.
offset mit beforeGleicher Grund.
offset mit lastVerwenden Sie offset + first oder last + before.
ein negatives first, last oder offset

Jede Kombination liefert graphql/invalid-pagination und nennt die widersprüchlichen Argumente:

invalid-pagination.graphql
{ 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:

pagination-first-page.graphql
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" } }
]
}
}
}
pagination-second-page.graphql
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:

pagination-last-page.graphql
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:

pagination-page-six.graphql
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:

open-request-count.graphql
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:

  • _gt auf dem Zeitstempel überspringt alle Datensätze, die an der Seitengrenze denselben Wert haben. Teilen mehr Datensätze ein updated_at, als auf eine Seite passen, werden die übrigen nie zurückgegeben — und der Durchlauf endet scheinbar erfolgreich.
  • _gte liest 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:

exhaustive-page.graphql
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:

  1. Erste Seite mit order_by: [{ updated_at: asc }, { id: asc }] und ohne Cursor-Filter abrufen.
  2. updated_at und id des letzten zurückgegebenen Knotens übernehmen.
  3. Beide in die obige Abfrage einsetzen.
  4. 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
hasNextPageOb 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.
hasPreviousPageOb dieser Seite Datensätze vorausgehen. Immer true, wenn after angegeben wurde.
startCursor / endCursorCursor der ersten und der letzten Edge, oder null bei leerer Seite.