QoreDB LogoQoreDB
Retour au blog
ArchitectureJournal technique

La pagination dans QoreDB : OFFSET/LIMIT et ses limites réelles

Une table de production ne tient pas dans une fenêtre. Passé quelques dizaines de milliers de lignes, la charger d'un bloc sature la mémoire du client, l'interface se fige, et le réseau transporte des données que personne ne regardera. La réponse classique tient en un mot : la…

Raphaël – Creator of QoreDBRaphaël – Creator of QoreDB
4 min de lecture
La pagination dans QoreDB : OFFSET/LIMIT et ses limites réelles

Une table de production ne tient pas dans une fenêtre. Passé quelques dizaines de milliers de lignes, la charger d'un bloc sature la mémoire du client, l'interface se fige, et le réseau transporte des données que personne ne regardera. La réponse classique tient en un mot : la pagination. QoreDB ne fait pas exception, mais sa contrainte particulière est qu'il pilote une douzaine de moteurs — PostgreSQL, MySQL, SQL Server, MongoDB… — qui n'expriment pas la pagination de la même façon. Le parti pris est d'imposer un modèle commun au cœur, puis de le traduire dans le dialecte natif de chaque moteur.

Un modèle commun : TableQueryOptions et PaginatedQueryResult

Toute navigation dans une table passe par une seule structure de requête, définie dans qore-core/src/types.rs. TableQueryOptions porte le numéro de page, la taille de page, la colonne de tri, les filtres de colonne et un terme de recherche plein-texte. En sortie, PaginatedQueryResult ne renvoie pas seulement les lignes de la page courante : il fournit aussi le total de lignes correspondant à la requête, la page en cours, la taille de page utilisée et le nombre total de pages, calculé par total_rows.div_ceil(page_size).

types.rs
impl TableQueryOptions {
    /// Effective page number
    pub fn effective_page(&self) -> u32 {
        self.page.unwrap_or(0)
    }

    /// Effective page size
    pub fn effective_page_size(&self) -> u32 {
        self.page_size.unwrap_or(50).clamp(1, 10000)
    }

    /// SQL OFFSET for pagination
    pub fn offset(&self) -> u64 {
        let page = self.effective_page();
        let zero_indexed_page = if page > 0 { page - 1 } else { 0 };
        zero_indexed_page as u64 * self.effective_page_size() as u64
    }
}

Deux valeurs méritent l'attention. La taille de page par défaut est de 50 lignes, et elle est bornée par clamp(1, 10000) : impossible de demander zéro ligne, impossible d'en réclamer un million en un seul appel. Le grid côté React reprend la même valeur — pageSize: 50 dans DataGrid.tsx — et le panneau InstantApi rejette explicitement toute taille hors de l'intervalle [1, 10000]. Cette borne haute n'est pas cosmétique : elle garantit qu'une seule page ne pourra jamais faire exploser la mémoire, quel que soit le moteur derrière.

Un dialecte de pagination par moteur

Le cœur raisonne en page / page_size / offset, mais chaque driver traduit ces trois nombres dans la syntaxe de son moteur. Pour PostgreSQL et MySQL, c'est le couple LIMIT … OFFSET … classique, assemblé dans pg_compat.rs :

pg_compat.rs
let data_sql = format!(
    "SELECT * FROM {}{}{} LIMIT {} OFFSET {}",
    table_ref, where_sql, order_sql, page_size, offset
);

SQL Server n'a pas de LIMIT. Son driver (sqlserver.rs) utilise la syntaxe standard OFFSET … ROWS FETCH NEXT … ROWS ONLY, disponible depuis SQL Server 2012. Cette forme impose une clause ORDER BY : quand l'utilisateur n'a choisi aucun tri, QoreDB injecte un ORDER BY (SELECT NULL) pour rester syntaxiquement valide sans imposer d'ordre arbitraire.

sqlserver.rs
let data_sql = format!(
    "SELECT * FROM {}{}{} OFFSET {} ROWS FETCH NEXT {} ROWS ONLY",
    table_ref, where_sql, order_sql, offset, page_size
);

MongoDB, enfin, n'est pas relationnel du tout. Le driver traduit la même TableQueryOptions en un curseur find paramétré par skip et limit, via un FindOptions::builder().skip(Some(offset)) et .limit(Some(page_size as i64)). Trois moteurs, trois syntaxes, une seule intention. C'est exactement le contrat du trait DataEngine : le reste de l'application ne connaît que query_table et récupère toujours un PaginatedQueryResult, sans savoir si le moteur a écrit OFFSET, FETCH NEXT ou .skip().

Deux coûts à nommer : COUNT(*) et l'OFFSET profond

Afficher « page 3 sur 128 » suppose de connaître le total. QoreDB le recalcule à chaque chargement de page : avant la requête de données, chaque driver exécute un COUNT(*) portant exactement les mêmes filtres que la page. Côté PostgreSQL :

pg_compat.rs
let count_sql = format!(
    "SELECT COUNT(*)::bigint AS cnt FROM {}{}",
    table_ref, where_sql
);

Le même schéma se retrouve partout : count_documents(filter) sur MongoDB, SELECT COUNT(*) sur SQL Server. Le total est toujours exact et cohérent avec les filtres actifs, mais il a un prix. Sur une table volumineuse sans index utile au filtre, compter les lignes peut coûter plus cher que lire la page elle-même — et ce coût est payé à chaque navigation. Le second coût est intrinsèque à l'OFFSET : pour renvoyer la page qui commence à l'offset N, le moteur doit produire puis jeter les N premières lignes. Sauter à la page 5 000 avec 50 lignes par page, c'est demander au moteur de traverser 250 000 lignes avant d'en renvoyer 50. Le coût de l'OFFSET croît linéairement avec la profondeur.

QoreDB ne cache pas ces coûts derrière une illusion de fluidité : le total est vrai, la page est bornée, et la pagination par offset reste rapide sur les premières pages — celles que l'on consulte réellement lorsqu'on explore une table.

Le repli par défaut du trait, et ses pièges

Le trait DataEngine fournit une implémentation par défaut de query_table, pour qu'un nouveau driver compile avant même d'avoir écrit sa pagination. Ce repli appelle simplement preview_table et déduit le total du nombre de lignes ramenées :

traits.rs
async fn query_table(
    &self,
    session: SessionId,
    namespace: &Namespace,
    table: &str,
    options: TableQueryOptions,
) -> EngineResult<PaginatedQueryResult> {
    let page = options.effective_page();
    let page_size = options.effective_page_size();
    let result = self
        .preview_table(session, namespace, table, page_size)
        .await?;
    let total = result.rows.len() as u64;
    Ok(PaginatedQueryResult::new(result, total, page, page_size))
}
Ce repli ne pagine pas vraiment : il ignore l'offset et déclare comme « total » le nombre de lignes de la seule page courante. C'est un filet de compilation, pas un comportement de production. Les moteurs réellement supportés — PostgreSQL, MySQL, SQL Server, MongoDB… — redéfinissent tous query_table pour appliquer un vrai OFFSET/LIMIT et un vrai COUNT.

Un compromis assumé

La pagination par OFFSET / LIMIT n'est pas la plus rapide en profondeur, et QoreDB ne prétend pas le contraire. Elle a l'avantage d'être universelle, exacte sur le total, et traduisible dans chaque moteur sans réécrire l'interface. Pour le cas où la pagination ne suffit pas — parcourir ou exporter une table entière sans sauter de page en page — QoreDB s'appuie sur un autre mécanisme, le streaming par curseur à mémoire bornée, exposé séparément par le trait via execute_stream. Deux outils, deux usages : la pagination pour naviguer, le streaming pour tout lire. Le fil conducteur reste le même — dire la vérité du moteur plutôt que masquer ses coûts.

Newsletter

Restez informé des nouveautés

Rejoignez notre newsletter pour recevoir les mises à jour majeures, les nouveaux drivers et nos coulisses techniques.

🎁 Bonus : Recevez notre fiche mémo d'optimisation SQL — 9 pages, PostgreSQL / MySQL / SQLite (PDF) !
Partager
La pagination dans QoreDB : OFFSET/LIMIT et ses limites réelles - Blog - QoreDB