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).
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 :
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.
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 :
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.
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 :
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))
}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.
Restez informé des nouveautés
Rejoignez notre newsletter pour recevoir les mises à jour majeures, les nouveaux drivers et nos coulisses techniques.

