backend / graphql
Pagination cursor-based
Explication
Ce que vous allez apprendre
- Comprendre pourquoi la pagination par offset (limite/décalage) devient fragile en production
- Lire et écrire la structure standard de la convention Relay (
edges,node,cursor,pageInfo) - Comprendre ce qu'encode réellement un curseur et pourquoi il doit être opaque pour le client
- Implémenter
hasNextPagesans requêteCOUNTsupplémentaire côté base de données - Enchaîner des pages successives en réutilisant le curseur de fin de la page précédente
Dans quel contexte ?
Un fil d'actualité affiche 20 produits à la fois, avec un bouton "charger plus". Pendant que l'utilisateur consulte la première page, un autre utilisateur ajoute ou supprime des produits dans le catalogue. Avec une pagination classique par numéro de page, ces insertions décalent tous les résultats suivants, provoquant des doublons ou des produits sautés d'une page à l'autre. C'est exactement le problème que la pagination par curseur (cursor-based) résout.
D'abord, pourquoi l'offset pose problème
Une pagination par offset (LIMIT 10 OFFSET 20 en SQL) dit "donne-moi les éléments 20 à 30". Si un élément est inséré avant la position 20 pendant que l'utilisateur navigue, tout se décale d'un cran : un élément peut apparaître deux fois, un autre disparaître complètement de la navigation. Ce n'est pas un bug rare, c'est un défaut structurel de l'approche par position numérique.
Une fois ce problème identifié, l'idée du curseur devient logique
Plutôt que de dire "donne-moi la page numéro 3", le client dit "donne-moi ce qui vient après CET élément précis". Le curseur encode une position stable, typiquement l'identifiant de l'élément ou un critère de tri composite, jamais un simple numéro de page qui peut se décaler.
Ensuite, il faut adopter une structure de réponse standardisée
La convention Relay, devenue le standard de facto en GraphQL, structure une page de résultats en trois parties : edges (chaque élément accompagné de son curseur), node (l'objet réel à l'intérieur de chaque edge) et pageInfo (des métadonnées sur la navigation : y a-t-il une page suivante, quel est le dernier curseur).
| Champ | Rôle |
|---|---|
edges[].node | L'objet métier lui-même (ex : un Produit) |
edges[].cursor | Position opaque de cet élément précis dans le tri |
pageInfo.hasNextPage | Indique s'il reste des éléments après cette page |
pageInfo.endCursor | Le curseur à réutiliser comme argument apres pour la page suivante |
Il reste un problème pratique : comment savoir s'il y a une page suivante sans requête supplémentaire ?
L'astuce consiste à demander systématiquement un élément de plus que ce qui sera réellement affiché : si le client demande 10 éléments, le serveur en charge 11 en base. S'il obtient effectivement 11 résultats, cela prouve qu'il en reste au moins un après la page affichée ; le serveur tronque alors la liste à 10 avant de la renvoyer.
Prérequis
Cette leçon suppose que tu es à l'aise avec les arguments et les listes typées (leçons précédentes) : un Connection type comme ProduitConnection est un type de sortie classique, construit à partir de ce que tu connais déjà.
Piège fréquent
Ne jamais laisser le client interpréter ou construire lui-même un curseur : il doit rester une chaîne opaque (souvent encodée en base64) que le client se contente de renvoyer telle quelle. Exposer la structure interne du curseur (par exemple un simple entier auto-incrémenté en clair) lie le client à l'implémentation serveur et complique toute évolution future du tri.
Maintenant que la lecture de grandes collections est maîtrisée, la prochaine leçon change de sujet : comment GraphQL rapporte-t-il les erreurs au client, sachant qu'il répond toujours avec un code HTTP 200, même en cas de problème ?
Commandes & code
Pagination cursor-based
# La pagination par offset (limite/décalage) est fragile : instable si des lignes sont insérées
# La convention Relay (cursor-based) est le standard GraphQL de facto
type ProduitConnection {
edges: [ProduitEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type ProduitEdge {
node: Produit!
cursor: String! # curseur opaque encodant la position de cet élément
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
produits(premier: Int, apres: String, dernier: Int, avant: String): ProduitConnection!
}# Requête : "les 10 premiers produits après ce curseur"
query {
produits(premier: 10, apres: "Y3Vyc29yOjEw") {
edges {
cursor
node {
nom
prix
}
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
# Page suivante : réutiliser endCursor de la réponse précédente comme "apres"
query {
produits(premier: 10, apres: "Y3Vyc29yOjIw") {
edges { cursor node { nom prix } }
pageInfo { hasNextPage endCursor }
}
}# Implémentation serveur : encoder/décoder un curseur opaque (base64 de l'id ou d'un tri stable)
import base64
def encoder_curseur(id_produit: int) -> str:
return base64.b64encode(f"produit:{id_produit}".encode()).decode()
def decoder_curseur(curseur: str) -> int:
valeur = base64.b64decode(curseur.encode()).decode()
return int(valeur.split(":")[1])
def resoudre_produits(premier: int, apres: str | None):
requete = "SELECT * FROM produits ORDER BY id"
params = []
if apres:
id_apres = decoder_curseur(apres)
requete += " WHERE id > %s"
params.append(id_apres)
requete += " ORDER BY id LIMIT %s"
params.append(premier + 1) # +1 pour savoir s'il y a une page suivante
lignes = db.execute(requete, params)
a_page_suivante = len(lignes) > premier
lignes = lignes[:premier]
edges = [{"node": ligne, "cursor": encoder_curseur(ligne["id"])} for ligne in lignes]
return {
"edges": edges,
"pageInfo": {
"hasNextPage": a_page_suivante,
"endCursor": edges[-1]["cursor"] if edges else None,
},
}Résumé
- Le curseur encode une position stable (souvent l'id ou un tri composite), pas juste un numéro de page.
- Charger
limite + 1lignes permet de déterminerhasNextPagesans requête COUNT supplémentaire. - La convention Relay (
edges,node,cursor,pageInfo) est reconnue nativement par Apollo Client / Relay. - Contrairement à l'offset, le cursor-based reste stable même si des lignes sont insérées/supprimées entre deux pages.
Exercices pratiques
Mission : réparer une pagination catalogue instable en pleine vente flash
Objectif : Diagnostiquer précisément le bug d'une pagination par offset sous insertions concurrentes, puis la remplacer par une Connection Relay.
Contexte
Pendant une vente flash, le catalogue reçoit de nouveaux produits en continu pendant que des clients feuillettent les pages avec LIMIT 10 OFFSET 20, LIMIT 10 OFFSET 30, etc. Le support reçoit des signalements de produits vus deux fois ou jamais vus du tout en naviguant page par page. Ton rôle : expliquer le bug, puis migrer ce endpoint vers la pagination par curseur.