Intermédiaire

Pagination & Filtrage

Un jeu de données peut contenir des centaines de milliers de lignes — impossible de tout envoyer en une seule réponse.

Pourquoi paginer ?

Imaginez une table ventes contenant 500 000 lignes. Sans pagination :

  • Le serveur charge toutes les lignes en mémoire
  • La réponse JSON pèse plusieurs dizaines de Mo
  • Le client (dashboard, navigateur) doit parser tout ce JSON
  • Le réseau est saturé, l'interface est bloquée

Règle absolue

Ne jamais retourner une collection entière sans pagination. Même si votre table ne contient que 200 lignes aujourd'hui, elle en contiendra peut-être 200 000 demain. Paginez dès le départ.

Pagination par offset

La méthode la plus courante. Le client spécifie :

  • offset : le nombre d'éléments à sauter (à partir de 0)
  • limit : le nombre d'éléments à retourner
GET /api/v1/ventes?offset=0&limit=20     # Page 1 (éléments 1 à 20)
GET /api/v1/ventes?offset=20&limit=20    # Page 2 (éléments 21 à 40)
GET /api/v1/ventes?offset=40&limit=20    # Page 3 (éléments 41 à 60)

Implémentation Flask + SQLite

python
@ventes_bp.route('', methods=['GET'])
def lister_ventes():
    # Récupérer les paramètres avec valeurs par défaut
    limit = request.args.get('limit', 20, type=int)
    offset = request.args.get('offset', 0, type=int)

    # Sécuriser les valeurs
    limit = max(1, min(limit, 100))   # Entre 1 et 100
    offset = max(0, offset)            # Minimum 0

    conn = sqlite3.connect('data.db')
    conn.row_factory = sqlite3.Row

    # Requête paginée
    ventes = conn.execute(
        "SELECT * FROM ventes ORDER BY date DESC LIMIT ? OFFSET ?",
        (limit, offset)
    ).fetchall()

    # Compter le total (sans pagination)
    total = conn.execute("SELECT COUNT(*) FROM ventes").fetchone()[0]

    conn.close()

    return jsonify({
        "data": [dict(row) for row in ventes],
        "total": total,
        "limit": limit,
        "offset": offset,
    })

L'enveloppe de réponse

La réponse paginée inclut les métadonnées de pagination dans une enveloppe (envelope) :

json
{
  "data": [
    {"id": 1, "region": "IDF", "montant": 1500.0, "date": "2024-03-15"},
    {"id": 2, "region": "IDF", "montant": 2300.0, "date": "2024-03-14"}
  ],
  "total": 15420,
  "limit": 20,
  "offset": 0
}

Pourquoi le total ?

Le champ total permet au client de calculer le nombre de pages : nb_pages = ceil(total / limit). Un dashboard peut ainsi afficher "Page 1 sur 771" et proposer une navigation.

Limites de la pagination par offset

La pagination par offset a un défaut : plus l'offset est grand, plus la requête SQL est lente. OFFSET 400000 oblige la base à parcourir 400 000 lignes avant de retourner les résultats.

Pour des jeux de données très volumineux (millions de lignes), on préfère la pagination par curseur.

Pagination par curseur (concept)

Au lieu d'un offset numérique, on utilise la dernière valeur vue comme point de départ :

GET /api/v1/ventes?after=2024-03-15T10:30:00&limit=20

Le serveur retourne les 20 ventes dont la date est après la valeur donnée. Pas besoin de parcourir toutes les lignes précédentes.

python
# Concept — pagination par curseur
@ventes_bp.route('', methods=['GET'])
def lister_ventes_cursor():
    after = request.args.get('after')  # Dernier ID ou date vu
    limit = request.args.get('limit', 20, type=int)

    if after:
        ventes = conn.execute(
            "SELECT * FROM ventes WHERE date > ? ORDER BY date ASC LIMIT ?",
            (after, limit)
        ).fetchall()
    else:
        ventes = conn.execute(
            "SELECT * FROM ventes ORDER BY date ASC LIMIT ?",
            (limit,)
        ).fetchall()

    return jsonify({
        "data": [dict(row) for row in ventes],
        "next_cursor": ventes[-1]['date'] if ventes else None,
    })

Offset vs Curseur

  • Offset : simple, permet d'accéder à n'importe quelle page, mais lent sur de grands offsets
  • Curseur : performant même sur de grands volumes, mais pas d'accès direct à la page N

Pour ce cours, on utilisera principalement la pagination par offset qui couvre la majorité des cas.

Filtrage

Les filtres sont passés en query parameters :

GET /api/v1/ventes?region=IDF
GET /api/v1/ventes?region=IDF&annee=2024
GET /api/v1/ventes?region=IDF&annee=2024&categorie=electronique

Implémentation avec construction dynamique de requête

python
def find_all(self, region=None, annee=None, categorie=None,
             limit=20, offset=0, sort_by='date', order='desc'):
    """Récupère les ventes avec filtres dynamiques."""
    conn = self._get_connection()

    # Construction dynamique de la requête
    query = "SELECT * FROM ventes WHERE 1=1"
    count_query = "SELECT COUNT(*) as total FROM ventes WHERE 1=1"
    params = []

    if region:
        query += " AND region = ?"
        count_query += " AND region = ?"
        params.append(region)
    if annee:
        query += " AND strftime('%Y', date) = ?"
        count_query += " AND strftime('%Y', date) = ?"
        params.append(str(annee))
    if categorie:
        query += " AND categorie = ?"
        count_query += " AND categorie = ?"
        params.append(categorie)

    # Compter avant pagination
    total = conn.execute(count_query, params).fetchone()['total']

    # Tri
    colonnes_autorisees = ['date', 'montant', 'region', 'categorie']
    if sort_by in colonnes_autorisees:
        direction = 'DESC' if order == 'desc' else 'ASC'
        query += f" ORDER BY {sort_by} {direction}"

    # Pagination
    query += " LIMIT ? OFFSET ?"
    params.extend([limit, offset])

    rows = conn.execute(query, params).fetchall()
    conn.close()

    return [dict(row) for row in rows], total

Sécurité SQL

Notez l'utilisation de ? pour les valeurs (paramètres liés, protection contre l'injection SQL) et la liste blanche pour les noms de colonnes dans ORDER BY. Ne jamais injecter directement une valeur utilisateur dans une requête SQL.

Le pattern WHERE 1=1

WHERE 1=1 permet d'ajouter tous les filtres avec AND sans gérer le premier cas :

python
# Sans WHERE 1=1 — il faut gérer le premier filtre différemment
query = "SELECT * FROM ventes"
if region:
    query += " WHERE region = ?"   # WHERE pour le premier
if annee:
    query += " AND annee = ?"      # AND pour les suivants — mais si region est None ?

# Avec WHERE 1=1 — tous les filtres commencent par AND
query = "SELECT * FROM ventes WHERE 1=1"
if region:
    query += " AND region = ?"
if annee:
    query += " AND annee = ?"

Tri (sorting)

Deux paramètres : sort_by (la colonne) et order (la direction).

GET /api/v1/ventes?sort_by=montant&order=desc    # Plus gros montants d'abord
GET /api/v1/ventes?sort_by=date&order=asc         # Plus anciennes d'abord
python
@ventes_bp.route('', methods=['GET'])
def lister_ventes():
    sort_by = request.args.get('sort_by', 'date')
    order = request.args.get('order', 'desc')

    # Validation stricte des colonnes autorisées
    colonnes_autorisees = {'date', 'montant', 'region', 'categorie'}
    if sort_by not in colonnes_autorisees:
        return jsonify({"error": f"Tri invalide. Colonnes autorisées : {', '.join(colonnes_autorisees)}"}), 400

    if order not in ('asc', 'desc'):
        return jsonify({"error": "Order doit être 'asc' ou 'desc'"}), 400

    # ... suite du traitement

Exemple complet : pagination + filtrage + tri

Un endpoint qui combine les trois :

python
@ventes_bp.route('', methods=['GET'])
def lister_ventes():
    # Paramètres de pagination
    limit = request.args.get('limit', 20, type=int)
    offset = request.args.get('offset', 0, type=int)
    limit = max(1, min(limit, 100))
    offset = max(0, offset)

    # Paramètres de filtrage
    region = request.args.get('region')
    annee = request.args.get('annee', type=int)
    categorie = request.args.get('categorie')

    # Paramètres de tri
    sort_by = request.args.get('sort_by', 'date')
    order = request.args.get('order', 'desc')

    # Validation du tri
    colonnes_autorisees = {'date', 'montant', 'region'}
    if sort_by not in colonnes_autorisees:
        sort_by = 'date'
    if order not in ('asc', 'desc'):
        order = 'desc'

    # Appel au repository
    ventes, total = repository.find_all(
        region=region,
        annee=annee,
        categorie=categorie,
        limit=limit,
        offset=offset,
        sort_by=sort_by,
        order=order,
    )

    return jsonify({
        "data": ventes,
        "total": total,
        "limit": limit,
        "offset": offset,
        "filters": {
            "region": region,
            "annee": annee,
            "categorie": categorie,
        },
        "sort": {
            "by": sort_by,
            "order": order,
        }
    })
bash
curl "http://localhost:5000/api/v1/ventes?region=IDF&annee=2024&sort_by=montant&order=desc&limit=10&offset=0"
json
{
  "data": [
    {"id": 42, "region": "IDF", "montant": 8500.0, "date": "2024-11-03", "categorie": "electronique"},
    {"id": 17, "region": "IDF", "montant": 7200.0, "date": "2024-09-21", "categorie": "mobilier"}
  ],
  "total": 3420,
  "limit": 10,
  "offset": 0,
  "filters": {"region": "IDF", "annee": 2024, "categorie": null},
  "sort": {"by": "montant", "order": "desc"}
}

Dans une réponse paginée, à quoi sert le champ 'total' ?

Pourquoi utiliser une liste blanche pour les colonnes de tri ?

À retenir

Points clés

  • Toujours paginer les endpoints qui retournent des collections
  • L'enveloppe de réponse inclut data, total, limit et offset
  • La pagination par offset est simple mais peut être lente sur de grands offsets ; la pagination par curseur est plus performante
  • Les filtres sont passés en query parameters et ajoutés dynamiquement à la requête SQL
  • Le pattern WHERE 1=1 simplifie la construction dynamique de requêtes
  • Toujours valider les paramètres de tri avec une liste blanche de colonnes autorisées
  • Toujours utiliser les paramètres liés (?) pour les valeurs — jamais d'interpolation directe