Un développeur colle dans l'éditeur un script de trois instructions séparées par des points-virgules : un CREATE TABLE, un INSERT, puis un SELECT. Il lance l'exécution. Que doit faire le client ? Envoyer le bloc entier au moteur d'un seul tenant, ou le découper et exécuter chaque instruction l'une après l'autre ? QoreDB choisit le découpage, et ce choix engage toute une chaîne : un parseur SQL conscient du dialecte, une exécution séquentielle et un affichage à onglets. Voici comment cela fonctionne, et où se trouvent les limites assumées.
Le découpage se décide au niveau de la commande
Tout part de la commande execute_query côté backend (src-tauri/src/commands/query.rs). Pour un driver SQL, elle appelle split_sql_statements sur la requête. Si le découpage renvoie plus d'une instruction, le lot est transmis à la couche service ; sinon, la requête reste un None et suit le chemin d'exécution simple. Le point-virgule n'active donc rien à lui seul : c'est le nombre d'instructions reconnues qui décide.
let sql_statements = if is_sql_driver {
match sql_safety::split_sql_statements(driver.driver_id(), &query) {
Ok(statements) if statements.len() > 1 => Some(statements),
_ => None,
}
} else if matches!(
driver.driver_id().to_ascii_lowercase().as_str(),
"elasticsearch" | "opensearch"
) {
// Console multi-requêtes : plusieurs blocs `METHOD /path`
// exécutés séquentiellement, chacun produisant son onglet.
let blocks = qore_drivers::drivers::search_compat::split_requests(&query);
(blocks.len() > 1).then_some(blocks)
} else {
None
};Deux détails méritent d'être notés. D'abord, Elasticsearch et OpenSearch réutilisent exactement le même chemin : leur console découpe des blocs METHOD /path via split_requests, chacun produisant son propre résultat. Ensuite, un script multi-instructions désactive le streaming : la variable should_stream exige que sql_statements soit vide. On ne peut pas streamer des lignes tout en enchaînant plusieurs requêtes.
Découper proprement : sqlparser et le dialecte du moteur
La fonction split_sql_statements vit dans src-tauri/crates/qore-sql/src/safety.rs. Elle ne coupe pas naïvement sur le point-virgule : elle passe le script à sqlparser avec le bon dialecte, puis re-sérialise chaque instruction reconnue avec statement.to_string(). Un point-virgule à l'intérieur d'une chaîne littérale ou d'un commentaire ne coupe donc rien, parce que ce n'est pas le texte qui est scanné mais l'arbre syntaxique.
fn split_sql_statements_uncached(driver_id: &str, trimmed: &str) -> Result<Vec<String>, String> {
if driver_id.eq_ignore_ascii_case("clickhouse") {
// sqlparser ne sait pas round-tripper la syntaxe CH,
// on coupe sur `;` de haut niveau hors chaînes.
return Ok(split_ch_statements(trimmed));
}
let dialect = dialect_for_driver(driver_id);
let statements = Parser::parse_sql(&*dialect, trimmed).map_err(|err| err.to_string())?;
let mut rendered = Vec::with_capacity(statements.len());
for statement in statements {
let statement_sql = statement.to_string();
if !statement_sql.trim().is_empty() {
rendered.push(statement_sql);
}
}
Ok(rendered)
}Le dialecte est choisi par dialect_for_driver : PostgreSQL et CockroachDB reçoivent le PostgreSqlDialect, MySQL le MySqlDialect, DuckDB et MotherDuck le DuckDbDialect, SQL Server le MsSqlDialect, et tout le reste retombe sur le GenericDialect. Le résultat est mémorisé dans un cache LRU borné (128 entrées), indexé sur la paire (driver, SQL trimé) : un même script relancé avec F5 ne repaie pas le coût de parsing.
ClickHouse : le découpage fait à la main
ClickHouse est l'exception. Sa syntaxe est trop spécifique pour que sqlparser la round-trippe de façon fiable, donc QoreDB bascule sur split_ch_statements, un scanner caractère par caractère. Il coupe sur les ; de haut niveau tout en respectant les chaînes '…' et "…", les commentaires -- … et /* … */, et l'échappement \'. Plus léger et plus sûr que forcer un parseur sur du SQL dialectal.
';' if !in_single && !in_double => {
let s = buf.trim().to_string();
if !s.is_empty() {
out.push(s);
}
buf.clear();
i += 1;
continue;
}Exécution séquentielle, sans transaction implicite
Une fois le lot transmis, la couche service (qore-service/src/query.rs) boucle sur les instructions et exécute chacune via driver.execute_in_namespace, dans l'ordre. Elle n'ouvre aucune transaction autour de la boucle : chaque instruction est envoyée telle quelle, et une instruction qui a réussi reste appliquée même si une suivante échoue.
if let Some(statements) = sql_statements {
let mut results = Vec::with_capacity(statements.len());
for (idx, statement) in statements.iter().enumerate() {
match driver
.execute_in_namespace(session, namespace.clone(), statement, query_id)
.await
{
Ok(result) => results.push(result),
Err(e) => {
return Err(EngineError::execution_error(format!(
"Statement {} failed after {} succeeded: {}",
idx + 1,
results.len(),
e
)));
}
}
}
// ...
}Le message d'erreur est explicite : Statement 3 failed after 2 succeeded. Il dit exactement combien d'instructions ont été appliquées avant l'échec, ce qui est essentiel quand rien n'est annulé. Le timeout, lui, s'applique à l'ensemble du script : c'est la boucle entière qui est encadrée par la limite de durée, pas chaque instruction isolément.
Un onglet de résultat par requête
Côté résultats, la première instruction devient le résultat principal et les autres sont renvoyées dans extra_results. Le front (QueryPanel.tsx) en fait un onglet par jeu de résultats. Pour étiqueter chaque onglet avec l'instruction correspondante, il re-découpe localement sur ; — mais uniquement si ce découpage naïf tombe sur le même nombre de jeux que le backend. Sinon il retombe sur un libellé générique. Un compromis honnête : jamais de fausse étiquette.
// Une requête multi-instructions renvoie un jeu de résultats par instruction.
const extraResults = isFederated
? []
: ((response as { extra_results?: QueryResult[] }).extra_results ?? []);
// Libellés par onglet au mieux : seulement si le découpage local
// correspond au nombre de jeux renvoyés par le backend.
const statementParts = queryToRun
.split(';')
.map(part => part.trim())
.filter(Boolean);
const labels =
statementParts.length === extraResults.length + 1 ? statementParts : null;Les migrations ont leur propre découpeur
Le découpeur du panneau de requêtes re-rend le SQL, ce qui convient à des requêtes ad hoc. Pour les migrations, ce serait inacceptable : un script de migration doit atteindre la base exactement tel qu'il a été écrit, commentaires, casse et mise en forme compris. QoreDB garde donc un second découpeur, migration_split.rs, qui renvoie des tranches empruntées du texte d'origine (&input[span]) au lieu de re-sérialiser. Là où sqlparser tolérerait, ce découpeur refuse ce qu'il ne peut pas couper sans risque.
pub enum SplitErrorCode {
UnterminatedString,
UnterminatedComment,
UnterminatedDollarQuote,
UnterminatedBracket,
/// MySQL `DELIMITER`.
UnsupportedDelimiter,
/// T-SQL `GO <n>` repeat count.
UnsupportedGoCount,
/// Corps procédural dont les `;` internes ne peuvent pas
/// être distingués des séparateurs.
UnsupportedProceduralBlock,
}Deux découpeurs pour deux exigences, c'est le fil conducteur : l'un privilégie une classification de sécurité fiable et un affichage à onglets pour l'exploration interactive ; l'autre privilégie la fidélité au texte pour les migrations. Dans les deux cas, QoreDB refuse le raccourci du split(';') global, qui casserait sur le premier point-virgule caché dans une chaîne. Exécuter un script complet paraît trivial vu de l'éditeur ; la garantie qu'il se comporte comme attendu tient précisément aux détails qui ne se voient pas.
Restez informé des nouveautés
Rejoignez notre newsletter pour recevoir les mises à jour majeures, les nouveaux drivers et nos coulisses techniques.

