QoreDB LogoQoreDB
Back to blog
ProduitTechnical journal

Multi-statement execution in QoreDB: running a whole SQL script

A developer pastes a three-statement script into the editor, separated by semicolons: a CREATE TABLE, an INSERT, then a SELECT. They hit run. What should the client do? Send the whole block to the engine in one go, or split it and execute each statement one after the other?…

Raphaël – Creator of QoreDBRaphaël – Creator of QoreDB
4 min read
Multi-statement execution in QoreDB: running a whole SQL script

A developer pastes a three-statement script into the editor, separated by semicolons: a CREATE TABLE, an INSERT, then a SELECT. They hit run. What should the client do? Send the whole block to the engine in one go, or split it and execute each statement one after the other? QoreDB opts for splitting, and that choice pulls in a whole chain: a dialect-aware SQL parser, sequential execution and a tabbed result view. Here is how it works, and where the deliberate limits are.

The split is decided at the command layer

It all starts in the execute_query command on the backend (src-tauri/src/commands/query.rs). For a SQL driver, it calls split_sql_statements on the query. If the split returns more than one statement, the batch is handed to the service layer; otherwise the query stays a None and follows the single-statement path. The semicolon alone triggers nothing: what decides is the number of statements the parser recognizes.

commands/query.rs
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"
) {
    // Multi-request console: several `METHOD /path` blocks run
    // sequentially, each producing its own result tab.
    let blocks = qore_drivers::drivers::search_compat::split_requests(&query);
    (blocks.len() > 1).then_some(blocks)
} else {
    None
};

Two details are worth noting. First, Elasticsearch and OpenSearch reuse the exact same path: their console splits METHOD /path blocks via split_requests, each producing its own result. Second, a multi-statement script disables streaming: the should_stream flag requires sql_statements to be empty. You can't stream rows while chaining several queries.

Splitting cleanly: sqlparser and the engine's dialect

The split_sql_statements function lives in src-tauri/crates/qore-sql/src/safety.rs. It does not naively cut on the semicolon: it feeds the script to sqlparser with the right dialect, then re-serializes each recognized statement with statement.to_string(). A semicolon inside a string literal or a comment cuts nothing, because it is the syntax tree that gets scanned, not the raw text.

qore-sql/src/safety.rs
fn split_sql_statements_uncached(driver_id: &str, trimmed: &str) -> Result<Vec<String>, String> {
    if driver_id.eq_ignore_ascii_case("clickhouse") {
        // sqlparser cannot reliably round-trip CH syntax, so split
        // on top-level `;` outside string literals.
        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)
}

The dialect is picked by dialect_for_driver: PostgreSQL and CockroachDB get the PostgreSqlDialect, MySQL the MySqlDialect, DuckDB and MotherDuck the DuckDbDialect, SQL Server the MsSqlDialect, and everything else falls back to the GenericDialect. The result is memoized in a bounded LRU cache (128 entries), keyed on the (driver, trimmed SQL) pair: the same script re-run with F5 doesn't pay the parsing cost again.

A direct consequence of re-rendering: the SQL actually sent to the engine is the one sqlparser re-serialized, not your raw text. Behavior stays identical, but formatting (whitespace, the casing of some keywords) may differ. For scripts that must reach the database character-for-character, QoreDB uses a different splitter, described below.

ClickHouse: splitting by hand

ClickHouse is the exception. Its syntax is too specific for sqlparser to round-trip reliably, so QoreDB switches to split_ch_statements, a character-by-character scanner. It cuts on top-level ; while respecting '…' and "…" strings, -- … and /* … */ comments, and the \' escape. Lighter and safer than forcing a parser onto dialect-heavy SQL.

qore-sql/src/safety.rs
';' if !in_single && !in_double => {
    let s = buf.trim().to_string();
    if !s.is_empty() {
        out.push(s);
    }
    buf.clear();
    i += 1;
    continue;
}

Sequential execution, no implicit transaction

Once the batch is handed down, the service layer (qore-service/src/query.rs) loops over the statements and runs each one via driver.execute_in_namespace, in order. It opens no transaction around the loop: each statement is sent as-is, and a statement that succeeded stays applied even if a later one fails.

qore-service/src/query.rs
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
                )));
            }
        }
    }
    // ...
}

The error message is explicit: Statement 3 failed after 2 succeeded. It states exactly how many statements were applied before the failure, which matters when nothing is rolled back. The timeout, meanwhile, applies to the whole script: it is the entire loop that sits under the duration limit, not each statement in isolation.

A multi-statement script is not atomic by default. If you need all-or-nothing, wrap your statements yourself in BEGIN and COMMIT: QoreDB exposes dedicated transaction management, with a statement counter in the toolbar. Multi-statement execution instead mirrors the behavior of a command-line client, where each statement is autonomous.

One result tab per query

On the results side, the first statement becomes the primary result and the others are returned in extra_results. The frontend (QueryPanel.tsx) turns each result set into a tab. To label each tab with its statement, it re-splits locally on ; — but only if that naive split lands on the same number of sets as the backend. Otherwise it falls back to a generic label. An honest tradeoff: never a wrong label.

QueryPanel.tsx
// A multi-statement query returns one result set per statement.
const extraResults = isFederated
  ? []
  : ((response as { extra_results?: QueryResult[] }).extra_results ?? []);

// Best-effort per-tab labels: only when the local split matches
// the number of result sets returned by the backend.
const statementParts = queryToRun
  .split(';')
  .map(part => part.trim())
  .filter(Boolean);
const labels =
  statementParts.length === extraResults.length + 1 ? statementParts : null;

Migrations get their own splitter

The query panel's splitter re-renders SQL, which is fine for ad-hoc queries. For migrations it would be unacceptable: a migration script must reach the database exactly as authored — comments, casing and formatting included. So QoreDB keeps a second splitter, migration_split.rs, which returns borrowed slices of the original text (&input[span]) instead of re-serializing. Where sqlparser would tolerate, this splitter rejects what it cannot cut without risk.

qore-sql/src/migration_split.rs
pub enum SplitErrorCode {
    UnterminatedString,
    UnterminatedComment,
    UnterminatedDollarQuote,
    UnterminatedBracket,
    /// MySQL `DELIMITER`.
    UnsupportedDelimiter,
    /// T-SQL `GO <n>` repeat count.
    UnsupportedGoCount,
    /// Procedural body whose inner `;` cannot be told
    /// apart from separators.
    UnsupportedProceduralBlock,
}

Two splitters for two requirements is the throughline: one favors reliable safety classification and a tabbed view for interactive exploration; the other favors fidelity to the text for migrations. In both cases, QoreDB refuses the shortcut of a global split(';'), which would break on the first semicolon hidden inside a string. Running a full script looks trivial from the editor; the guarantee that it behaves as expected rests precisely on the details you never see.

Newsletter

Stay updated on new releases

Subscribe to get product releases, new drivers notifications, and technical tutorials.

🎁 Bonus: Get our free SQL Performance Cheat Sheet — 9 pages, PostgreSQL / MySQL / SQLite (PDF)!
Share
Multi-statement execution in QoreDB: running a whole SQL script - Blog - QoreDB