From 07648b1e01cd8eadfa8226c6caf38f6d6e057095 Mon Sep 17 00:00:00 2001 From: SuLe Date: Sat, 2 May 2026 03:54:57 +0800 Subject: [PATCH 01/22] spec sql file execution --- .../2026-05-02-sql-file-execution-design.md | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-02-sql-file-execution-design.md diff --git a/docs/superpowers/specs/2026-05-02-sql-file-execution-design.md b/docs/superpowers/specs/2026-05-02-sql-file-execution-design.md new file mode 100644 index 000000000..df4897e41 --- /dev/null +++ b/docs/superpowers/specs/2026-05-02-sql-file-execution-design.md @@ -0,0 +1,134 @@ +# SQL File Execution Design + +## Goal + +Add a first-class workflow for executing `.sql` files against a selected connection and database. The feature targets common migration, initialization, and dump-restore tasks where users need to run a script file, see progress, stop on errors by default, and cancel long-running execution. + +## Entry Points + +- Add a toolbar action labeled "Execute SQL File". +- Add a context-menu action labeled "Execute SQL File" on connection and database tree nodes. +- The context-menu entry opens the same dialog as the toolbar action, with the current connection and database preselected when available. + +## Dialog Behavior + +The dialog lets the user choose: + +- Connection. +- Database, when the selected connection supports databases. +- SQL file path. +- Failure policy. + +The default failure policy is to stop on the first failed statement. A secondary option allows continuing after statement failures. + +Before execution, the dialog shows the file name, size, and a small preview from the beginning of the file. The preview is informational only; execution uses the file path directly so large files do not need to be loaded into frontend memory. + +During execution, the dialog shows: + +- Current statement index. +- Successful statement count. +- Failed statement count. +- Elapsed time. +- Current statement summary. +- Last error, if any. + +The dialog provides a Cancel button while execution is active. + +## Backend Architecture + +Add a new SQL file execution command family: + +- `execute_sql_file(connection_id, database, file_path, execution_id, continue_on_error)`. +- `cancel_sql_file_execution(execution_id)`. + +The backend reads the file incrementally from disk and splits it into statements as it reads. It executes statements one at a time using the existing query execution path so database-specific connection handling, reconnect behavior, timeout behavior, and result handling remain consistent with the query editor. + +Execution sends progress events through Tauri: + +- `started`: file execution has begun. +- `statementDone`: one statement completed successfully. +- `statementFailed`: one statement failed. +- `cancelled`: execution stopped because the user cancelled. +- `done`: execution completed. + +Each event includes the `execution_id` so the frontend can ignore events from stale runs. + +## SQL Splitting + +The first implementation supports standard semicolon-delimited SQL files. The splitter must ignore semicolons inside: + +- Single-quoted strings. +- Double-quoted strings. +- Backtick-quoted identifiers. +- Line comments. +- Block comments. + +The splitter also emits a final trailing statement when the file does not end with a semicolon. + +MySQL `DELIMITER` syntax for stored procedures is intentionally out of scope for this first PR. If a file depends on custom delimiters, execution may fail at the relevant statement and report the failing index. A later PR can add delimiter directives without blocking the common migration and initialization cases. + +## Failure And Cancellation + +Default behavior is stop-on-first-error. When a statement fails: + +- Emit `statementFailed`. +- Stop immediately unless `continue_on_error` is true. +- Report the failing statement index and the count of statements that may already have committed. + +When `continue_on_error` is true, execution continues after failures and emits a final summary including successful and failed counts. + +Cancellation uses a cancellation token keyed by `execution_id`. Cancelling stops before the next statement when possible and also passes the token into the active statement execution path so supported drivers can stop promptly. The UI should show cancellation as a stopped state rather than a generic SQL error. + +## Frontend Architecture + +Add a dedicated SQL file execution dialog component. The component owns: + +- File selection via Tauri dialog. +- Connection and database selection. +- Preview metadata. +- Execution progress state. +- Cancel action. + +Add lightweight API wrappers in `src/lib/tauri.ts` for the two backend commands and the progress event payload type. + +Do not store SQL file execution as a normal query tab. A file execution can contain many statements and progress events, so a modal task workflow is clearer than overloading the query result grid. + +## Testing + +Rust tests: + +- Split multiple statements by semicolon. +- Ignore semicolons in single-quoted strings. +- Ignore semicolons in double-quoted strings. +- Ignore semicolons in backtick identifiers. +- Ignore semicolons in line comments and block comments. +- Emit trailing statement without a final semicolon. +- Stop on first failure when `continue_on_error` is false. +- Continue after failure when `continue_on_error` is true. +- Stop execution after cancellation. + +Frontend verification: + +- Typecheck and production build. +- Manual smoke test with a small SQL file showing successful progress. +- Manual smoke test with a failing second statement showing stop-on-error and the failing index. + +## Scope Boundaries + +In scope: + +- Local `.sql` file selection. +- Streaming backend execution. +- Progress events. +- Stop-on-error default. +- Continue-on-error option. +- Cancellation. +- Toolbar and tree context-menu entry points. + +Out of scope for this PR: + +- MySQL custom `DELIMITER` directives. +- Transaction wrapping of the whole file. +- Remote file URLs. +- Saving import history. +- Editing the SQL file inside dbx before execution. From 313ce124acdec04309fd04416498c2e9b305bf7d Mon Sep 17 00:00:00 2001 From: SuLe Date: Sat, 2 May 2026 04:05:04 +0800 Subject: [PATCH 02/22] plan sql file execution --- .../plans/2026-05-02-sql-file-execution.md | 1322 +++++++++++++++++ 1 file changed, 1322 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-02-sql-file-execution.md diff --git a/docs/superpowers/plans/2026-05-02-sql-file-execution.md b/docs/superpowers/plans/2026-05-02-sql-file-execution.md new file mode 100644 index 000000000..f4bcaa2f7 --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-sql-file-execution.md @@ -0,0 +1,1322 @@ +# SQL File Execution Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a safe SQL file execution/import workflow with toolbar and tree context-menu entry points, backend streaming execution, progress events, stop-on-error defaults, and cancellation. + +**Architecture:** Add a focused Rust command module for SQL file preview, splitting, execution, progress events, and cancellation. Reuse the existing query execution path by extracting a small shared helper from `commands/query.rs`, then add a Vue dialog component that owns file selection and progress UI. + +**Tech Stack:** Tauri 2 commands/events, Rust async file IO, existing `tokio_util::sync::CancellationToken`, Vue 3 + Pinia + shadcn-vue/reka UI, existing i18n files, existing Tauri dialog/fs patterns. + +--- + +## File Structure + +- Create `src-tauri/src/commands/sql_file.rs` + - SQL splitter. + - File preview command. + - SQL file execution request/progress types. + - File execution command and cancel command. + - Rust unit tests for splitting and execution control flow. +- Modify `src-tauri/src/commands/query.rs` + - Extract a reusable `execute_sql_statement` helper that accepts an optional cancellation token. + - Keep existing `execute_query` behavior unchanged. +- Modify `src-tauri/src/commands/mod.rs` + - Register `sql_file` module. +- Modify `src-tauri/src/lib.rs` + - Register `preview_sql_file`, `execute_sql_file`, and `cancel_sql_file_execution`. +- Modify `src/lib/tauri.ts` + - Add request/progress/preview types. + - Add wrappers and event subscription helper. +- Modify `src/stores/connectionStore.ts` + - Add `sqlFileSource` prefill state mirroring transfer/schema diff patterns. +- Create `src/components/sql-file/SqlFileExecutionDialog.vue` + - File picker, connection/database selectors, preview metadata, progress UI, cancel. +- Modify `src/App.vue` + - Toolbar entry. + - Dialog state, prefill watcher, and component mount. +- Modify `src/components/sidebar/TreeItem.vue` + - Context-menu entry on connection/database/schema nodes. +- Modify `src/i18n/locales/en.ts` and `src/i18n/locales/zh-CN.ts` + - Add `sqlFile` strings and context-menu labels. + +--- + +### Task 1: Backend SQL Splitter + +**Files:** +- Create: `src-tauri/src/commands/sql_file.rs` +- Modify: `src-tauri/src/commands/mod.rs` + +- [ ] **Step 1: Add failing splitter tests** + +Add `mod sql_file;` to `src-tauri/src/commands/mod.rs`. + +Create `src-tauri/src/commands/sql_file.rs` with tests first: + +```rust +#[cfg(test)] +mod tests { + use super::split_sql_script; + + #[test] + fn splits_semicolon_delimited_statements() { + assert_eq!( + split_sql_script("CREATE TABLE a(id int); INSERT INTO a VALUES (1);").unwrap(), + vec!["CREATE TABLE a(id int)", "INSERT INTO a VALUES (1)"] + ); + } + + #[test] + fn ignores_semicolons_inside_quotes_and_comments() { + let sql = "\ + INSERT INTO logs VALUES ('a;b', \"c;d\", `weird;name`);\n\ + -- comment ; ignored\n\ + /* block ; ignored */\n\ + SELECT 1;"; + assert_eq!( + split_sql_script(sql).unwrap(), + vec![ + "INSERT INTO logs VALUES ('a;b', \"c;d\", `weird;name`)", + "-- comment ; ignored\n/* block ; ignored */\nSELECT 1", + ] + ); + } + + #[test] + fn emits_trailing_statement_without_semicolon() { + assert_eq!( + split_sql_script("CREATE TABLE a(id int);\nINSERT INTO a VALUES (1)").unwrap(), + vec!["CREATE TABLE a(id int)", "INSERT INTO a VALUES (1)"] + ); + } +} +``` + +- [ ] **Step 2: Run splitter tests and verify RED** + +Run: + +```bash +cd src-tauri +cargo test sql_file::tests::splits_semicolon_delimited_statements --lib +``` + +Expected: fail because `split_sql_script` does not exist. + +- [ ] **Step 3: Implement minimal splitter** + +In `src-tauri/src/commands/sql_file.rs`, add: + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Default)] +struct SqlStatementSplitter { + buffer: String, + in_single_quote: bool, + in_double_quote: bool, + in_backtick: bool, + in_line_comment: bool, + in_block_comment: bool, + previous: Option, +} + +impl SqlStatementSplitter { + fn push_chunk(&mut self, chunk: &str) -> Vec { + let mut statements = Vec::new(); + let mut chars = chunk.chars().peekable(); + + while let Some(ch) = chars.next() { + let next = chars.peek().copied(); + + if self.in_line_comment { + self.buffer.push(ch); + if ch == '\n' { + self.in_line_comment = false; + } + self.previous = Some(ch); + continue; + } + + if self.in_block_comment { + self.buffer.push(ch); + if self.previous == Some('*') && ch == '/' { + self.in_block_comment = false; + } + self.previous = Some(ch); + continue; + } + + if !self.in_single_quote && !self.in_double_quote && !self.in_backtick { + if ch == '-' && next == Some('-') { + self.in_line_comment = true; + self.buffer.push(ch); + self.previous = Some(ch); + continue; + } + if ch == '/' && next == Some('*') { + self.in_block_comment = true; + self.buffer.push(ch); + self.previous = Some(ch); + continue; + } + } + + match ch { + '\'' if !self.in_double_quote && !self.in_backtick && self.previous != Some('\\') => { + self.in_single_quote = !self.in_single_quote; + self.buffer.push(ch); + } + '"' if !self.in_single_quote && !self.in_backtick && self.previous != Some('\\') => { + self.in_double_quote = !self.in_double_quote; + self.buffer.push(ch); + } + '`' if !self.in_single_quote && !self.in_double_quote => { + self.in_backtick = !self.in_backtick; + self.buffer.push(ch); + } + ';' if !self.in_single_quote && !self.in_double_quote && !self.in_backtick => { + self.push_current_statement(&mut statements); + } + _ => self.buffer.push(ch), + } + + self.previous = Some(ch); + } + + statements + } + + fn finish(mut self) -> Vec { + let mut statements = Vec::new(); + self.push_current_statement(&mut statements); + statements + } + + fn push_current_statement(&mut self, statements: &mut Vec) { + let statement = self.buffer.trim(); + if !statement.is_empty() { + statements.push(statement.to_string()); + } + self.buffer.clear(); + self.previous = None; + } +} + +#[cfg(test)] +fn split_sql_script(sql: &str) -> Result, String> { + let mut splitter = SqlStatementSplitter::default(); + let mut statements = splitter.push_chunk(sql); + statements.extend(splitter.finish()); + Ok(statements) +} +``` + +- [ ] **Step 4: Run splitter tests and verify GREEN** + +Run: + +```bash +cd src-tauri +cargo test sql_file::tests --lib +``` + +Expected: all splitter tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/src/commands/mod.rs src-tauri/src/commands/sql_file.rs +git commit -m "add sql file statement splitter" +``` + +--- + +### Task 2: Shared Query Execution Helper + +**Files:** +- Modify: `src-tauri/src/commands/query.rs` +- Test: `src-tauri/src/commands/query.rs` + +- [ ] **Step 1: Add helper-level cancellation regression test** + +Add this test to `src-tauri/src/commands/query.rs` tests module: + +```rust +#[tokio::test] +async fn wait_for_query_without_token_still_times_out() { + let result = wait_for_query(None, async { + tokio::time::sleep(Duration::from_secs(31)).await; + Ok(db::QueryResult { + columns: vec![], + rows: vec![], + affected_rows: 0, + execution_time_ms: 0, + truncated: false, + }) + }) + .await; + + assert_eq!(result.unwrap_err(), timeout_error()); +} +``` + +- [ ] **Step 2: Run query tests and verify they pass before refactor** + +Run: + +```bash +cd src-tauri +cargo test commands::query::tests --lib +``` + +Expected: query tests pass before refactor. + +- [ ] **Step 3: Extract reusable helper** + +In `src-tauri/src/commands/query.rs`, add a public-in-commands helper below `do_execute`: + +```rust +pub(super) async fn execute_sql_statement( + state: &AppState, + connection_id: &str, + database: &str, + sql: &str, + cancel_token: Option, +) -> Result { + let pool_key = if database.is_empty() { + connection_id.to_string() + } else { + state.get_or_create_pool(connection_id, Some(database)).await? + }; + + if is_canceled(&cancel_token) { + return Err(canceled_error()); + } + + let result = do_execute(state, &pool_key, sql, cancel_token.clone()).await; + + match &result { + Err(e) if is_connection_error(e) && !is_canceled(&cancel_token) => { + let db_opt = if database.is_empty() { None } else { Some(database) }; + let new_key = state.reconnect_pool(connection_id, db_opt).await?; + do_execute(state, &new_key, sql, cancel_token).await + } + _ => result, + } +} +``` + +Then simplify `execute_query` to call it: + +```rust +let result = execute_sql_statement( + &state, + &connection_id, + &database, + &sql, + cancel_token, +) +.await; + +result +``` + +- [ ] **Step 4: Run query tests** + +Run: + +```bash +cd src-tauri +cargo test commands::query::tests --lib +``` + +Expected: all query tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/src/commands/query.rs +git commit -m "share query execution helper" +``` + +--- + +### Task 3: Backend SQL File Preview, Execution, Progress, And Cancel + +**Files:** +- Modify: `src-tauri/src/commands/sql_file.rs` +- Modify: `src-tauri/src/lib.rs` + +- [ ] **Step 1: Add backend control-flow tests** + +Add test-only fake execution helpers in `src-tauri/src/commands/sql_file.rs`: + +```rust +#[cfg(test)] +mod execution_tests { + use super::*; + use tokio_util::sync::CancellationToken; + + async fn run_fake_script( + statements: Vec, + continue_on_error: bool, + cancel_after_successes: Option, + ) -> SqlFileSummary { + let token = CancellationToken::new(); + run_statements_for_test(statements, continue_on_error, token, cancel_after_successes).await + } + + #[tokio::test] + async fn stops_on_first_failure_by_default() { + let summary = run_fake_script( + vec!["ok 1".into(), "fail 2".into(), "ok 3".into()], + false, + None, + ).await; + + assert_eq!(summary.success_count, 1); + assert_eq!(summary.failure_count, 1); + assert_eq!(summary.status, SqlFileStatus::Error); + assert_eq!(summary.failed_statement_index, Some(2)); + } + + #[tokio::test] + async fn continues_after_failure_when_enabled() { + let summary = run_fake_script( + vec!["ok 1".into(), "fail 2".into(), "ok 3".into()], + true, + None, + ).await; + + assert_eq!(summary.success_count, 2); + assert_eq!(summary.failure_count, 1); + assert_eq!(summary.status, SqlFileStatus::Done); + } + + #[tokio::test] + async fn cancellation_stops_before_next_statement() { + let summary = run_fake_script( + vec!["ok 1".into(), "ok 2".into(), "ok 3".into()], + true, + Some(1), + ).await; + + assert_eq!(summary.success_count, 1); + assert_eq!(summary.status, SqlFileStatus::Cancelled); + } +} +``` + +- [ ] **Step 2: Run backend control-flow tests and verify RED** + +Run: + +```bash +cd src-tauri +cargo test sql_file::execution_tests --lib +``` + +Expected: fail because `SqlFileSummary`, `SqlFileStatus`, and `run_statements_for_test` do not exist. + +- [ ] **Step 3: Add command types and execution implementation** + +Add these types and command functions to `src-tauri/src/commands/sql_file.rs`: + +```rust +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Instant; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, State}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; + +use crate::commands::connection::AppState; +use crate::commands::query::execute_sql_statement; + +static SQL_FILE_EXECUTIONS: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| RwLock::new(HashMap::new())); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SqlFileRequest { + pub execution_id: String, + pub connection_id: String, + pub database: String, + pub file_path: String, + pub continue_on_error: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SqlFilePreview { + pub file_name: String, + pub file_path: String, + pub size_bytes: u64, + pub preview: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SqlFileStatus { + Started, + Running, + StatementDone, + StatementFailed, + Done, + Error, + Cancelled, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SqlFileProgress { + pub execution_id: String, + pub status: SqlFileStatus, + pub statement_index: usize, + pub success_count: usize, + pub failure_count: usize, + pub affected_rows: u64, + pub elapsed_ms: u128, + pub statement_summary: String, + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SqlFileSummary { + status: SqlFileStatus, + success_count: usize, + failure_count: usize, + failed_statement_index: Option, +} +``` + +Add preview command: + +```rust +#[tauri::command] +pub async fn preview_sql_file(file_path: String) -> Result { + let path = PathBuf::from(&file_path); + let metadata = tokio::fs::metadata(&path).await.map_err(|e| e.to_string())?; + let mut file = tokio::fs::File::open(&path).await.map_err(|e| e.to_string())?; + let mut buffer = vec![0; 4096]; + let bytes_read = tokio::io::AsyncReadExt::read(&mut file, &mut buffer) + .await + .map_err(|e| e.to_string())?; + buffer.truncate(bytes_read); + let preview = String::from_utf8_lossy(&buffer).to_string(); + + Ok(SqlFilePreview { + file_name: path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("script.sql") + .to_string(), + file_path, + size_bytes: metadata.len(), + preview, + }) +} +``` + +Add execution command: + +```rust +#[tauri::command] +pub async fn execute_sql_file( + app: AppHandle, + state: State<'_, Arc>, + request: SqlFileRequest, +) -> Result<(), String> { + let token = CancellationToken::new(); + SQL_FILE_EXECUTIONS + .write() + .await + .insert(request.execution_id.clone(), token.clone()); + + let started_at = Instant::now(); + emit_progress(&app, &request.execution_id, SqlFileStatus::Started, 0, 0, 0, 0, started_at, "", None); + + let result = execute_sql_file_inner(&app, &state, &request, token, started_at).await; + SQL_FILE_EXECUTIONS.write().await.remove(&request.execution_id); + result +} + +#[tauri::command] +pub async fn cancel_sql_file_execution(execution_id: String) -> Result { + let executions = SQL_FILE_EXECUTIONS.read().await; + if let Some(token) = executions.get(&execution_id) { + token.cancel(); + Ok(true) + } else { + Ok(false) + } +} +``` + +Add the execution helpers: + +```rust +async fn execute_sql_file_inner( + app: &AppHandle, + state: &AppState, + request: &SqlFileRequest, + token: CancellationToken, + started_at: Instant, +) -> Result<(), String> { + let file = tokio::fs::File::open(&request.file_path).await.map_err(|e| e.to_string())?; + let mut reader = BufReader::new(file); + let mut splitter = SqlStatementSplitter::default(); + let mut line = String::new(); + let mut statement_index = 0; + let mut success_count = 0; + let mut failure_count = 0; + let mut affected_rows = 0; + + loop { + line.clear(); + let read = reader.read_line(&mut line).await.map_err(|e| e.to_string())?; + if read == 0 { + break; + } + for statement in splitter.push_chunk(&line) { + statement_index += 1; + let outcome = execute_statement_with_progress( + app, + state, + request, + &token, + statement_index, + &statement, + &mut success_count, + &mut failure_count, + &mut affected_rows, + started_at, + ) + .await; + if outcome? { + return Ok(()); + } + } + } + + for statement in splitter.finish() { + statement_index += 1; + let outcome = execute_statement_with_progress( + app, + state, + request, + &token, + statement_index, + &statement, + &mut success_count, + &mut failure_count, + &mut affected_rows, + started_at, + ) + .await; + if outcome? { + return Ok(()); + } + } + + emit_progress(app, &request.execution_id, SqlFileStatus::Done, statement_index, success_count, failure_count, affected_rows, started_at, "", None); + Ok(()) +} + +async fn execute_statement_with_progress( + app: &AppHandle, + state: &AppState, + request: &SqlFileRequest, + token: &CancellationToken, + statement_index: usize, + statement: &str, + success_count: &mut usize, + failure_count: &mut usize, + affected_rows: &mut u64, + started_at: Instant, +) -> Result { + if token.is_cancelled() { + emit_progress(app, &request.execution_id, SqlFileStatus::Cancelled, statement_index, *success_count, *failure_count, *affected_rows, started_at, "", None); + return Ok(true); + } + + emit_progress(app, &request.execution_id, SqlFileStatus::Running, statement_index, *success_count, *failure_count, *affected_rows, started_at, statement, None); + + match execute_sql_statement( + state, + &request.connection_id, + &request.database, + statement, + Some(token.clone()), + ).await { + Ok(result) => { + *success_count += 1; + *affected_rows += result.affected_rows; + emit_progress(app, &request.execution_id, SqlFileStatus::StatementDone, statement_index, *success_count, *failure_count, *affected_rows, started_at, statement, None); + Ok(false) + } + Err(err) => { + *failure_count += 1; + let status = if token.is_cancelled() { SqlFileStatus::Cancelled } else { SqlFileStatus::StatementFailed }; + emit_progress(app, &request.execution_id, status, statement_index, *success_count, *failure_count, *affected_rows, started_at, statement, Some(err.clone())); + if token.is_cancelled() { + return Ok(true); + } + if request.continue_on_error { + Ok(false) + } else { + emit_progress(app, &request.execution_id, SqlFileStatus::Error, statement_index, *success_count, *failure_count, *affected_rows, started_at, statement, Some(format!("Statement {statement_index} failed: {err}. Previous {} statement(s) may have been committed.", statement_index.saturating_sub(1)))); + Ok(true) + } + } + } +} + +fn emit_progress( + app: &AppHandle, + execution_id: &str, + status: SqlFileStatus, + statement_index: usize, + success_count: usize, + failure_count: usize, + affected_rows: u64, + started_at: Instant, + statement: &str, + error: Option, +) { + let _ = app.emit("sql-file-progress", SqlFileProgress { + execution_id: execution_id.to_string(), + status, + statement_index, + success_count, + failure_count, + affected_rows, + elapsed_ms: started_at.elapsed().as_millis(), + statement_summary: statement_summary(statement), + error, + }); +} + +fn statement_summary(statement: &str) -> String { + let collapsed = statement.split_whitespace().collect::>().join(" "); + if collapsed.chars().count() <= 120 { + collapsed + } else { + let mut summary = collapsed.chars().take(117).collect::(); + summary.push_str("..."); + summary + } +} +``` + +Use this outcome convention throughout Task 3: `Ok(true)` means stop because cancelled or stop-on-error already emitted a terminal event. + +Add the test-only fake runner required by the tests: + +```rust +#[cfg(test)] +async fn run_statements_for_test( + statements: Vec, + continue_on_error: bool, + token: CancellationToken, + cancel_after_successes: Option, +) -> SqlFileSummary { + let mut success_count = 0; + let mut failure_count = 0; + let mut failed_statement_index = None; + + for (idx, statement) in statements.iter().enumerate() { + if token.is_cancelled() { + return SqlFileSummary { + status: SqlFileStatus::Cancelled, + success_count, + failure_count, + failed_statement_index, + }; + } + + if statement.starts_with("fail") { + failure_count += 1; + failed_statement_index = Some(idx + 1); + if !continue_on_error { + return SqlFileSummary { + status: SqlFileStatus::Error, + success_count, + failure_count, + failed_statement_index, + }; + } + } else { + success_count += 1; + if cancel_after_successes == Some(success_count) { + token.cancel(); + } + } + } + + SqlFileSummary { + status: if token.is_cancelled() { SqlFileStatus::Cancelled } else { SqlFileStatus::Done }, + success_count, + failure_count, + failed_statement_index, + } +} +``` + +- [ ] **Step 4: Register commands** + +In `src-tauri/src/lib.rs`, add to `generate_handler!`: + +```rust +commands::sql_file::preview_sql_file, +commands::sql_file::execute_sql_file, +commands::sql_file::cancel_sql_file_execution, +``` + +- [ ] **Step 5: Run backend tests** + +Run: + +```bash +cd src-tauri +cargo test sql_file --lib +cargo test commands::query::tests --lib +``` + +Expected: tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src-tauri/src/commands/sql_file.rs src-tauri/src/lib.rs +git commit -m "execute sql files with progress" +``` + +--- + +### Task 4: Tauri API Wrappers And Store Prefill + +**Files:** +- Modify: `src/lib/tauri.ts` +- Modify: `src/stores/connectionStore.ts` + +- [ ] **Step 1: Add TypeScript API types** + +In `src/lib/tauri.ts`, add: + +```ts +export type SqlFileStatus = + | "started" + | "running" + | "statementDone" + | "statementFailed" + | "done" + | "error" + | "cancelled"; + +export interface SqlFileRequest { + executionId: string; + connectionId: string; + database: string; + filePath: string; + continueOnError: boolean; +} + +export interface SqlFilePreview { + fileName: string; + filePath: string; + sizeBytes: number; + preview: string; +} + +export interface SqlFileProgress { + executionId: string; + status: SqlFileStatus; + statementIndex: number; + successCount: number; + failureCount: number; + affectedRows: number; + elapsedMs: number; + statementSummary: string; + error?: string | null; +} +``` + +- [ ] **Step 2: Add wrappers** + +In `src/lib/tauri.ts`, add: + +```ts +export async function previewSqlFile(filePath: string): Promise { + return invoke("preview_sql_file", { filePath }); +} + +export async function executeSqlFile(request: SqlFileRequest): Promise { + return invoke("execute_sql_file", { request }); +} + +export async function cancelSqlFileExecution(executionId: string): Promise { + return invoke("cancel_sql_file_execution", { executionId }); +} + +export async function listenSqlFileProgress( + handler: (progress: SqlFileProgress) => void, +): Promise { + return listen("sql-file-progress", (event) => handler(event.payload)); +} +``` + +- [ ] **Step 3: Add connection store prefill** + +In `src/stores/connectionStore.ts`, add beside transfer/schema diff refs: + +```ts +const sqlFileSource = ref<{ connectionId: string; database: string } | null>(null); +``` + +Return it from the store: + +```ts +sqlFileSource, +``` + +- [ ] **Step 4: Run frontend typecheck** + +Run: + +```bash +pnpm build +``` + +Expected: build passes or fails only because the dialog is not created yet. If it fails because the new wrappers are unused, continue; TypeScript should not fail on unused exports. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/tauri.ts src/stores/connectionStore.ts +git commit -m "add sql file frontend api" +``` + +--- + +### Task 5: SQL File Execution Dialog + +**Files:** +- Create: `src/components/sql-file/SqlFileExecutionDialog.vue` + +- [ ] **Step 1: Create dialog component** + +Create `src/components/sql-file/SqlFileExecutionDialog.vue`: + +```vue + + + +``` + +- [ ] **Step 2: Run frontend build** + +Run: + +```bash +pnpm build +``` + +Expected: build passes. If TypeScript reports that `Checkbox` requires `boolean | "indeterminate"`, change the binding to `:checked="continueOnError" @update:checked="(value) => { continueOnError = value === true }"` and rerun `pnpm build`. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/sql-file/SqlFileExecutionDialog.vue +git commit -m "add sql file execution dialog" +``` + +--- + +### Task 6: Toolbar And Tree Entry Points + +**Files:** +- Modify: `src/App.vue` +- Modify: `src/components/sidebar/TreeItem.vue` +- Modify: `src/i18n/locales/en.ts` +- Modify: `src/i18n/locales/zh-CN.ts` + +- [ ] **Step 1: Add App state and dialog mount** + +In `src/App.vue`, import the dialog and icon: + +```ts +import SqlFileExecutionDialog from "@/components/sql-file/SqlFileExecutionDialog.vue"; +``` + +Add state beside transfer/schema diff state: + +```ts +const showSqlFileDialog = ref(false); +const sqlFilePrefillConnectionId = ref(""); +const sqlFilePrefillDatabase = ref(""); +``` + +Add watcher: + +```ts +watch(() => connectionStore.sqlFileSource, (v) => { + if (v) { + sqlFilePrefillConnectionId.value = v.connectionId; + sqlFilePrefillDatabase.value = v.database; + showSqlFileDialog.value = true; + connectionStore.sqlFileSource = null; + } +}); +``` + +Add toolbar button after Data Transfer: + +```vue + +``` + +Mount dialog near existing dialogs: + +```vue + +``` + +- [ ] **Step 2: Add tree context action** + +In `src/components/sidebar/TreeItem.vue`, add: + +```ts +function openSqlFileExecution() { + if (props.node.connectionId) { + connectionStore.sqlFileSource = { + connectionId: props.node.connectionId, + database: props.node.database ?? "", + }; + } +} +``` + +For connection menu, add after New Query: + +```vue + + {{ t('sqlFile.title') }} + +``` + +For database/schema menu, add after New Query: + +```vue + + {{ t('sqlFile.title') }} + +``` + +- [ ] **Step 3: Add i18n strings** + +In `src/i18n/locales/en.ts`, add: + +```ts +sqlFile: { + title: "Execute SQL File", + file: "SQL File", + chooseFile: "Choose File", + execute: "Execute File", + cancel: "Cancel", + continueOnError: "Continue after failed statements", + progress: "Statement {current} · {success} succeeded · {failed} failed", +}, +``` + +In `src/i18n/locales/zh-CN.ts`, add: + +```ts +sqlFile: { + title: "执行 SQL 文件", + file: "SQL 文件", + chooseFile: "选择文件", + execute: "执行文件", + cancel: "取消", + continueOnError: "失败后继续执行", + progress: "第 {current} 条 · 成功 {success} · 失败 {failed}", +}, +``` + +- [ ] **Step 4: Run frontend build** + +Run: + +```bash +pnpm build +``` + +Expected: build passes. + +- [ ] **Step 5: Commit** + +```bash +git add src/App.vue src/components/sidebar/TreeItem.vue src/i18n/locales/en.ts src/i18n/locales/zh-CN.ts +git commit -m "wire sql file execution entry points" +``` + +--- + +### Task 7: Final Verification + +**Files:** +- All changed files. + +- [ ] **Step 1: Run Rust tests** + +Run: + +```bash +cd src-tauri +cargo test --lib +``` + +Expected: all tests pass. + +- [ ] **Step 2: Run frontend build** + +Run: + +```bash +pnpm build +``` + +Expected: build passes. Existing chunk-size warnings are acceptable. + +- [ ] **Step 3: Run diff hygiene** + +Run: + +```bash +git diff --check +git status -sb +``` + +Expected: no whitespace errors. Status should show only intended tracked changes plus any pre-existing untracked docs that are not part of this PR. + +- [ ] **Step 4: Manual smoke test** + +Create a small file outside the repo: + +```sql +CREATE TABLE IF NOT EXISTS codex_sql_file_test (id INTEGER); +INSERT INTO codex_sql_file_test VALUES (1); +SELECT * FROM codex_sql_file_test; +``` + +Open DBX with `pnpm tauri dev`, choose a local SQLite or DuckDB connection, execute the file, and verify progress reaches done with at least two successful statements. + +- [ ] **Step 5: Manual failure smoke test** + +Create a small file outside the repo: + +```sql +CREATE TABLE IF NOT EXISTS codex_sql_file_test_failure (id INTEGER); +BROKEN STATEMENT; +INSERT INTO codex_sql_file_test_failure VALUES (1); +``` + +Run with default stop-on-error. Verify the dialog stops at statement 2 and shows the error without executing statement 3. + +- [ ] **Step 6: Final commit after verification fixes** + +If verification uncovers a concrete issue, stage the exact files changed by that fix: + +```bash +git add src-tauri/src/commands/sql_file.rs src/App.vue src/components/sql-file/SqlFileExecutionDialog.vue src/lib/tauri.ts +git commit -m "polish sql file execution" +``` + +If those exact files were not all touched by the fix, remove the untouched paths from the `git add` command before running it. If no verification fixes were needed, leave the task commits as the implementation history. From 893c17ef9ffbd54d4764cb963c7f13007271898f Mon Sep 17 00:00:00 2001 From: SuLe Date: Sat, 2 May 2026 04:24:52 +0800 Subject: [PATCH 03/22] add sql file statement splitter --- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/sql_file.rs | 143 +++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 src-tauri/src/commands/sql_file.rs diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f0bc01bf5..eaad47767 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -6,5 +6,6 @@ pub mod query; pub mod query_cancel; pub mod redis_cmd; pub mod schema; +mod sql_file; pub mod transfer; pub mod update; diff --git a/src-tauri/src/commands/sql_file.rs b/src-tauri/src/commands/sql_file.rs new file mode 100644 index 000000000..9a7b043ba --- /dev/null +++ b/src-tauri/src/commands/sql_file.rs @@ -0,0 +1,143 @@ +#[derive(Default)] +struct SqlStatementSplitter { + buffer: String, + in_single_quote: bool, + in_double_quote: bool, + in_backtick: bool, + in_line_comment: bool, + in_block_comment: bool, + previous: Option, +} + +impl SqlStatementSplitter { + fn push_chunk(&mut self, chunk: &str) -> Vec { + let mut statements = Vec::new(); + let mut chars = chunk.chars().peekable(); + + while let Some(ch) = chars.next() { + let next = chars.peek().copied(); + + if self.in_line_comment { + self.buffer.push(ch); + if ch == '\n' { + self.in_line_comment = false; + } + self.previous = Some(ch); + continue; + } + + if self.in_block_comment { + self.buffer.push(ch); + if self.previous == Some('*') && ch == '/' { + self.in_block_comment = false; + } + self.previous = Some(ch); + continue; + } + + if !self.in_single_quote && !self.in_double_quote && !self.in_backtick { + if ch == '-' && next == Some('-') { + self.in_line_comment = true; + self.buffer.push(ch); + self.previous = Some(ch); + continue; + } + if ch == '/' && next == Some('*') { + self.in_block_comment = true; + self.buffer.push(ch); + self.previous = Some(ch); + continue; + } + } + + match ch { + '\'' if !self.in_double_quote + && !self.in_backtick + && self.previous != Some('\\') => + { + self.in_single_quote = !self.in_single_quote; + self.buffer.push(ch); + } + '"' if !self.in_single_quote + && !self.in_backtick + && self.previous != Some('\\') => + { + self.in_double_quote = !self.in_double_quote; + self.buffer.push(ch); + } + '`' if !self.in_single_quote && !self.in_double_quote => { + self.in_backtick = !self.in_backtick; + self.buffer.push(ch); + } + ';' if !self.in_single_quote && !self.in_double_quote && !self.in_backtick => { + self.push_current_statement(&mut statements); + } + _ => self.buffer.push(ch), + } + + self.previous = Some(ch); + } + + statements + } + + fn finish(mut self) -> Vec { + let mut statements = Vec::new(); + self.push_current_statement(&mut statements); + statements + } + + fn push_current_statement(&mut self, statements: &mut Vec) { + let statement = self.buffer.trim(); + if !statement.is_empty() { + statements.push(statement.to_string()); + } + self.buffer.clear(); + self.previous = None; + } +} + +#[cfg(test)] +fn split_sql_script(sql: &str) -> Result, String> { + let mut splitter = SqlStatementSplitter::default(); + let mut statements = splitter.push_chunk(sql); + statements.extend(splitter.finish()); + Ok(statements) +} + +#[cfg(test)] +mod tests { + use super::split_sql_script; + + #[test] + fn splits_semicolon_delimited_statements() { + assert_eq!( + split_sql_script("CREATE TABLE a(id int); INSERT INTO a VALUES (1);").unwrap(), + vec!["CREATE TABLE a(id int)", "INSERT INTO a VALUES (1)"] + ); + } + + #[test] + fn ignores_semicolons_inside_quotes_and_comments() { + let sql = "\ + INSERT INTO logs VALUES ('a;b', \"c;d\", `weird;name`);\n\ + -- comment ; ignored\n\ + /* block ; ignored */\n\ + SELECT 1;"; + assert_eq!( + split_sql_script(sql).unwrap(), + vec![ + "INSERT INTO logs VALUES ('a;b', \"c;d\", `weird;name`)", + "-- comment ; ignored\n/* block ; ignored */\nSELECT 1", + ] + ); + } + + #[test] + fn emits_trailing_statement_without_semicolon() { + assert_eq!( + split_sql_script("CREATE TABLE a(id int);\nINSERT INTO a VALUES (1)").unwrap(), + vec!["CREATE TABLE a(id int)", "INSERT INTO a VALUES (1)"] + ); + } +} From 733fc69e13c3a39ae2786610bcae19a515bb8e0b Mon Sep 17 00:00:00 2001 From: SuLe Date: Sat, 2 May 2026 04:26:03 +0800 Subject: [PATCH 04/22] fix connection test initializer --- src-tauri/src/models/connection.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/src/models/connection.rs b/src-tauri/src/models/connection.rs index eace8b326..e59a8ca0e 100644 --- a/src-tauri/src/models/connection.rs +++ b/src-tauri/src/models/connection.rs @@ -249,6 +249,7 @@ mod tests { ssh_user: String::new(), ssh_password: String::new(), ssh_key_path: String::new(), + ssh_expose_lan: false, ssl: false, connection_string: None, } From f862a27858b5c22c70ee6deac69009bd2bce65e5 Mon Sep 17 00:00:00 2001 From: SuLe Date: Sat, 2 May 2026 04:29:10 +0800 Subject: [PATCH 05/22] fix streamed comment splitting --- src-tauri/src/commands/sql_file.rs | 38 +++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/sql_file.rs b/src-tauri/src/commands/sql_file.rs index 9a7b043ba..12f5e7939 100644 --- a/src-tauri/src/commands/sql_file.rs +++ b/src-tauri/src/commands/sql_file.rs @@ -36,6 +36,18 @@ impl SqlStatementSplitter { } if !self.in_single_quote && !self.in_double_quote && !self.in_backtick { + if self.previous == Some('-') && ch == '-' { + self.in_line_comment = true; + self.buffer.push(ch); + self.previous = Some(ch); + continue; + } + if self.previous == Some('/') && ch == '*' { + self.in_block_comment = true; + self.buffer.push(ch); + self.previous = Some(ch); + continue; + } if ch == '-' && next == Some('-') { self.in_line_comment = true; self.buffer.push(ch); @@ -107,7 +119,7 @@ fn split_sql_script(sql: &str) -> Result, String> { #[cfg(test)] mod tests { - use super::split_sql_script; + use super::{split_sql_script, SqlStatementSplitter}; #[test] fn splits_semicolon_delimited_statements() { @@ -140,4 +152,28 @@ mod tests { vec!["CREATE TABLE a(id int)", "INSERT INTO a VALUES (1)"] ); } + + #[test] + fn line_comment_openers_can_span_chunks() { + let mut splitter = SqlStatementSplitter::default(); + + assert_eq!(splitter.push_chunk("SELECT 1; -"), vec!["SELECT 1"]); + assert_eq!( + splitter.push_chunk("- comment ; ignored\nSELECT 2;"), + vec!["-- comment ; ignored\nSELECT 2"] + ); + assert_eq!(splitter.finish(), Vec::::new()); + } + + #[test] + fn block_comment_openers_can_span_chunks() { + let mut splitter = SqlStatementSplitter::default(); + + assert_eq!(splitter.push_chunk("SELECT 1; /"), vec!["SELECT 1"]); + assert_eq!( + splitter.push_chunk("* comment ; ignored */\nSELECT 2;"), + vec!["/* comment ; ignored */\nSELECT 2"] + ); + assert_eq!(splitter.finish(), Vec::::new()); + } } From ed475f1d1bd8946d6b110b4629b83c0a5e2451ef Mon Sep 17 00:00:00 2001 From: SuLe Date: Sat, 2 May 2026 04:36:44 +0800 Subject: [PATCH 06/22] share query execution helper --- src-tauri/src/commands/query.rs | 74 ++++++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index 596c6bfd8..7ac0f9936 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -194,6 +194,35 @@ async fn do_execute( } } +pub(super) async fn execute_sql_statement( + state: &AppState, + connection_id: &str, + database: &str, + sql: &str, + cancel_token: Option, +) -> Result { + let pool_key = if database.is_empty() { + connection_id.to_string() + } else { + state.get_or_create_pool(connection_id, Some(database)).await? + }; + + if is_canceled(&cancel_token) { + return Err(canceled_error()); + } + + let result = do_execute(state, &pool_key, sql, cancel_token.clone()).await; + + match &result { + Err(e) if is_connection_error(e) && !is_canceled(&cancel_token) => { + let db_opt = if database.is_empty() { None } else { Some(database) }; + let new_key = state.reconnect_pool(connection_id, db_opt).await?; + do_execute(state, &new_key, sql, cancel_token).await + } + _ => result, + } +} + #[tauri::command] pub async fn execute_query( state: State<'_, Arc>, @@ -208,26 +237,16 @@ pub async fn execute_query( .map(|id| state.running_queries.register(id.clone())); let cancel_token = registered_query.as_ref().map(|query| query.token()); - let pool_key = if database.is_empty() { - connection_id.clone() - } else { - state.get_or_create_pool(&connection_id, Some(&database)).await? - }; + let result = execute_sql_statement( + &state, + &connection_id, + &database, + &sql, + cancel_token, + ) + .await; - if is_canceled(&cancel_token) { - return Err(canceled_error()); - } - - let result = do_execute(&state, &pool_key, &sql, cancel_token.clone()).await; - - match &result { - Err(e) if is_connection_error(e) && !is_canceled(&cancel_token) => { - let db_opt = if database.is_empty() { None } else { Some(database.as_str()) }; - let new_key = state.reconnect_pool(&connection_id, db_opt).await?; - do_execute(&state, &new_key, &sql, cancel_token).await - } - _ => result, - } + result } #[tauri::command] @@ -304,4 +323,21 @@ mod tests { assert_eq!(result.unwrap_err(), QUERY_CANCELED); } + + #[tokio::test] + async fn wait_for_query_without_token_still_times_out() { + let result = wait_for_query(None, async { + tokio::time::sleep(Duration::from_secs(31)).await; + Ok(db::QueryResult { + columns: vec![], + rows: vec![], + affected_rows: 0, + execution_time_ms: 0, + truncated: false, + }) + }) + .await; + + assert_eq!(result.unwrap_err(), timeout_error()); + } } From 33705b9cbbb14164dc61b58591a49a20d8e904fd Mon Sep 17 00:00:00 2001 From: SuLe Date: Sat, 2 May 2026 04:41:09 +0800 Subject: [PATCH 07/22] speed up query timeout test --- src-tauri/src/commands/query.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index 7ac0f9936..c406dce54 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -90,6 +90,17 @@ async fn wait_for_query( cancel_token: Option, future: F, ) -> Result +where + F: Future>, +{ + wait_for_query_with_timeout(cancel_token, QUERY_TIMEOUT, future).await +} + +async fn wait_for_query_with_timeout( + cancel_token: Option, + timeout_duration: Duration, + future: F, +) -> Result where F: Future>, { @@ -97,10 +108,10 @@ where tokio::select! { biased; _ = token.cancelled() => Err(canceled_error()), - result = timeout(QUERY_TIMEOUT, future) => result.map_err(|_| timeout_error())?, + result = timeout(timeout_duration, future) => result.map_err(|_| timeout_error())?, } } else { - timeout(QUERY_TIMEOUT, future) + timeout(timeout_duration, future) .await .map_err(|_| timeout_error())? } @@ -326,8 +337,8 @@ mod tests { #[tokio::test] async fn wait_for_query_without_token_still_times_out() { - let result = wait_for_query(None, async { - tokio::time::sleep(Duration::from_secs(31)).await; + let result = wait_for_query_with_timeout(None, Duration::from_millis(10), async { + tokio::time::sleep(Duration::from_secs(1)).await; Ok(db::QueryResult { columns: vec![], rows: vec![], From 15fa5b0d2c27bd379eb19c8557838b1d5a19e370 Mon Sep 17 00:00:00 2001 From: SuLe Date: Sat, 2 May 2026 04:47:43 +0800 Subject: [PATCH 08/22] execute sql files with progress --- src-tauri/src/commands/mod.rs | 2 +- src-tauri/src/commands/sql_file.rs | 512 +++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 3 + 3 files changed, 516 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index eaad47767..d9c07e582 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -6,6 +6,6 @@ pub mod query; pub mod query_cancel; pub mod redis_cmd; pub mod schema; -mod sql_file; +pub mod sql_file; pub mod transfer; pub mod update; diff --git a/src-tauri/src/commands/sql_file.rs b/src-tauri/src/commands/sql_file.rs index 12f5e7939..ca9a5f6f0 100644 --- a/src-tauri/src/commands/sql_file.rs +++ b/src-tauri/src/commands/sql_file.rs @@ -1,3 +1,73 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Instant; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, State}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; + +use crate::commands::connection::AppState; +use crate::commands::query::execute_sql_statement; + +static SQL_FILE_EXECUTIONS: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| RwLock::new(HashMap::new())); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SqlFileRequest { + pub execution_id: String, + pub connection_id: String, + pub database: String, + pub file_path: String, + pub continue_on_error: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SqlFilePreview { + pub file_name: String, + pub file_path: String, + pub size_bytes: u64, + pub preview: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SqlFileStatus { + Started, + Running, + StatementDone, + StatementFailed, + Done, + Error, + Cancelled, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SqlFileProgress { + pub execution_id: String, + pub status: SqlFileStatus, + pub statement_index: usize, + pub success_count: usize, + pub failure_count: usize, + pub affected_rows: u64, + pub elapsed_ms: u128, + pub statement_summary: String, + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SqlFileSummary { + status: SqlFileStatus, + success_count: usize, + failure_count: usize, + failed_statement_index: Option, +} + #[derive(Default)] struct SqlStatementSplitter { buffer: String, @@ -109,6 +179,391 @@ impl SqlStatementSplitter { } } +#[tauri::command] +pub async fn preview_sql_file(file_path: String) -> Result { + let path = PathBuf::from(&file_path); + let metadata = tokio::fs::metadata(&path) + .await + .map_err(|e| e.to_string())?; + let mut file = tokio::fs::File::open(&path) + .await + .map_err(|e| e.to_string())?; + let mut buffer = vec![0; 4096]; + let bytes_read = tokio::io::AsyncReadExt::read(&mut file, &mut buffer) + .await + .map_err(|e| e.to_string())?; + buffer.truncate(bytes_read); + let preview = String::from_utf8_lossy(&buffer).to_string(); + + Ok(SqlFilePreview { + file_name: path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("script.sql") + .to_string(), + file_path, + size_bytes: metadata.len(), + preview, + }) +} + +#[tauri::command] +pub async fn execute_sql_file( + app: AppHandle, + state: State<'_, Arc>, + request: SqlFileRequest, +) -> Result<(), String> { + let token = CancellationToken::new(); + SQL_FILE_EXECUTIONS + .write() + .await + .insert(request.execution_id.clone(), token.clone()); + + let started_at = Instant::now(); + emit_progress( + &app, + &request.execution_id, + SqlFileStatus::Started, + 0, + 0, + 0, + 0, + started_at, + "", + None, + ); + + let result = execute_sql_file_inner(&app, &state, &request, token, started_at).await; + SQL_FILE_EXECUTIONS + .write() + .await + .remove(&request.execution_id); + result +} + +#[tauri::command] +pub async fn cancel_sql_file_execution(execution_id: String) -> Result { + let executions = SQL_FILE_EXECUTIONS.read().await; + if let Some(token) = executions.get(&execution_id) { + token.cancel(); + Ok(true) + } else { + Ok(false) + } +} + +async fn execute_sql_file_inner( + app: &AppHandle, + state: &State<'_, Arc>, + request: &SqlFileRequest, + token: CancellationToken, + started_at: Instant, +) -> Result<(), String> { + let file = tokio::fs::File::open(&request.file_path) + .await + .map_err(|e| e.to_string())?; + let mut reader = BufReader::new(file); + let mut splitter = SqlStatementSplitter::default(); + let mut line = String::new(); + let mut statement_index = 0; + let mut success_count = 0; + let mut failure_count = 0; + let mut affected_rows = 0; + + loop { + if token.is_cancelled() { + emit_progress( + app, + &request.execution_id, + SqlFileStatus::Cancelled, + statement_index, + success_count, + failure_count, + affected_rows, + started_at, + "", + None, + ); + return Ok(()); + } + + line.clear(); + let bytes_read = reader + .read_line(&mut line) + .await + .map_err(|e| e.to_string())?; + if bytes_read == 0 { + break; + } + + for statement in splitter.push_chunk(&line) { + statement_index += 1; + if execute_statement_with_progress( + app, + state, + request, + &token, + started_at, + statement_index, + &statement, + &mut success_count, + &mut failure_count, + &mut affected_rows, + ) + .await? + { + return Ok(()); + } + } + } + + for statement in splitter.finish() { + statement_index += 1; + if execute_statement_with_progress( + app, + state, + request, + &token, + started_at, + statement_index, + &statement, + &mut success_count, + &mut failure_count, + &mut affected_rows, + ) + .await? + { + return Ok(()); + } + } + + emit_progress( + app, + &request.execution_id, + SqlFileStatus::Done, + statement_index, + success_count, + failure_count, + affected_rows, + started_at, + "", + None, + ); + Ok(()) +} + +async fn execute_statement_with_progress( + app: &AppHandle, + state: &State<'_, Arc>, + request: &SqlFileRequest, + token: &CancellationToken, + started_at: Instant, + statement_index: usize, + statement: &str, + success_count: &mut usize, + failure_count: &mut usize, + affected_rows: &mut u64, +) -> Result { + let summary = statement_summary(statement); + + if token.is_cancelled() { + emit_progress( + app, + &request.execution_id, + SqlFileStatus::Cancelled, + statement_index, + *success_count, + *failure_count, + *affected_rows, + started_at, + &summary, + None, + ); + return Ok(true); + } + + emit_progress( + app, + &request.execution_id, + SqlFileStatus::Running, + statement_index, + *success_count, + *failure_count, + *affected_rows, + started_at, + &summary, + None, + ); + + match execute_sql_statement( + state.inner().as_ref(), + &request.connection_id, + &request.database, + statement, + Some(token.clone()), + ) + .await + { + Ok(result) => { + *success_count += 1; + *affected_rows += result.affected_rows; + emit_progress( + app, + &request.execution_id, + SqlFileStatus::StatementDone, + statement_index, + *success_count, + *failure_count, + *affected_rows, + started_at, + &summary, + None, + ); + Ok(false) + } + Err(error) => { + *failure_count += 1; + emit_progress( + app, + &request.execution_id, + SqlFileStatus::StatementFailed, + statement_index, + *success_count, + *failure_count, + *affected_rows, + started_at, + &summary, + Some(error.clone()), + ); + + if token.is_cancelled() { + emit_progress( + app, + &request.execution_id, + SqlFileStatus::Cancelled, + statement_index, + *success_count, + *failure_count, + *affected_rows, + started_at, + &summary, + Some(error), + ); + return Ok(true); + } + + if request.continue_on_error { + Ok(false) + } else { + emit_progress( + app, + &request.execution_id, + SqlFileStatus::Error, + statement_index, + *success_count, + *failure_count, + *affected_rows, + started_at, + &summary, + Some(error), + ); + Ok(true) + } + } + } +} + +fn emit_progress( + app: &AppHandle, + execution_id: &str, + status: SqlFileStatus, + statement_index: usize, + success_count: usize, + failure_count: usize, + affected_rows: u64, + started_at: Instant, + statement_summary: &str, + error: Option, +) { + let _ = app.emit( + "sql-file-progress", + SqlFileProgress { + execution_id: execution_id.to_string(), + status, + statement_index, + success_count, + failure_count, + affected_rows, + elapsed_ms: started_at.elapsed().as_millis(), + statement_summary: statement_summary.to_string(), + error, + }, + ); +} + +fn statement_summary(statement: &str) -> String { + const MAX_LEN: usize = 120; + + let collapsed = statement.split_whitespace().collect::>().join(" "); + if collapsed.chars().count() <= MAX_LEN { + return collapsed; + } + + collapsed.chars().take(MAX_LEN).collect() +} + +#[cfg(test)] +async fn run_statements_for_test( + statements: Vec, + continue_on_error: bool, + token: CancellationToken, + cancel_after_successes: Option, +) -> SqlFileSummary { + let mut success_count = 0; + let mut failure_count = 0; + let mut failed_statement_index = None; + + for (idx, statement) in statements.iter().enumerate() { + if token.is_cancelled() { + return SqlFileSummary { + status: SqlFileStatus::Cancelled, + success_count, + failure_count, + failed_statement_index, + }; + } + + if statement.starts_with("fail") { + failure_count += 1; + failed_statement_index = Some(idx + 1); + if !continue_on_error { + return SqlFileSummary { + status: SqlFileStatus::Error, + success_count, + failure_count, + failed_statement_index, + }; + } + } else { + success_count += 1; + if cancel_after_successes == Some(success_count) { + token.cancel(); + } + } + } + + SqlFileSummary { + status: if token.is_cancelled() { + SqlFileStatus::Cancelled + } else { + SqlFileStatus::Done + }, + success_count, + failure_count, + failed_statement_index, + } +} + #[cfg(test)] fn split_sql_script(sql: &str) -> Result, String> { let mut splitter = SqlStatementSplitter::default(); @@ -177,3 +632,60 @@ mod tests { assert_eq!(splitter.finish(), Vec::::new()); } } + +#[cfg(test)] +mod execution_tests { + use super::*; + use tokio_util::sync::CancellationToken; + + async fn run_fake_script( + statements: Vec, + continue_on_error: bool, + cancel_after_successes: Option, + ) -> SqlFileSummary { + let token = CancellationToken::new(); + run_statements_for_test(statements, continue_on_error, token, cancel_after_successes).await + } + + #[tokio::test] + async fn stops_on_first_failure_by_default() { + let summary = run_fake_script( + vec!["ok 1".into(), "fail 2".into(), "ok 3".into()], + false, + None, + ) + .await; + + assert_eq!(summary.success_count, 1); + assert_eq!(summary.failure_count, 1); + assert_eq!(summary.status, SqlFileStatus::Error); + assert_eq!(summary.failed_statement_index, Some(2)); + } + + #[tokio::test] + async fn continues_after_failure_when_enabled() { + let summary = run_fake_script( + vec!["ok 1".into(), "fail 2".into(), "ok 3".into()], + true, + None, + ) + .await; + + assert_eq!(summary.success_count, 2); + assert_eq!(summary.failure_count, 1); + assert_eq!(summary.status, SqlFileStatus::Done); + } + + #[tokio::test] + async fn cancellation_stops_before_next_statement() { + let summary = run_fake_script( + vec!["ok 1".into(), "ok 2".into(), "ok 3".into()], + true, + Some(1), + ) + .await; + + assert_eq!(summary.success_count, 1); + assert_eq!(summary.status, SqlFileStatus::Cancelled); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4eefa675a..9455f546e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -61,6 +61,9 @@ pub fn run() { commands::query::execute_query, commands::query::cancel_query, commands::query::execute_batch, + commands::sql_file::preview_sql_file, + commands::sql_file::execute_sql_file, + commands::sql_file::cancel_sql_file_execution, commands::redis_cmd::redis_list_databases, commands::redis_cmd::redis_scan_keys, commands::redis_cmd::redis_get_value, From 7a3b35637920e26235994644d16d1dfe7aa30030 Mon Sep 17 00:00:00 2001 From: SuLe Date: Sat, 2 May 2026 04:51:10 +0800 Subject: [PATCH 09/22] emit sql file io errors --- src-tauri/src/commands/sql_file.rs | 150 ++++++++++++++++++++++++++--- 1 file changed, 135 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/commands/sql_file.rs b/src-tauri/src/commands/sql_file.rs index ca9a5f6f0..da93f87cb 100644 --- a/src-tauri/src/commands/sql_file.rs +++ b/src-tauri/src/commands/sql_file.rs @@ -259,17 +259,32 @@ async fn execute_sql_file_inner( token: CancellationToken, started_at: Instant, ) -> Result<(), String> { - let file = tokio::fs::File::open(&request.file_path) - .await - .map_err(|e| e.to_string())?; - let mut reader = BufReader::new(file); - let mut splitter = SqlStatementSplitter::default(); - let mut line = String::new(); let mut statement_index = 0; let mut success_count = 0; let mut failure_count = 0; let mut affected_rows = 0; + let file = match tokio::fs::File::open(&request.file_path).await { + Ok(file) => file, + Err(error) => { + let error = error.to_string(); + emit_file_io_error_progress( + app, + &request.execution_id, + statement_index, + success_count, + failure_count, + affected_rows, + started_at, + error.clone(), + ); + return Err(error); + } + }; + let mut reader = BufReader::new(file); + let mut splitter = SqlStatementSplitter::default(); + let mut line = String::new(); + loop { if token.is_cancelled() { emit_progress( @@ -288,10 +303,23 @@ async fn execute_sql_file_inner( } line.clear(); - let bytes_read = reader - .read_line(&mut line) - .await - .map_err(|e| e.to_string())?; + let bytes_read = match reader.read_line(&mut line).await { + Ok(bytes_read) => bytes_read, + Err(error) => { + let error = error.to_string(); + emit_file_io_error_progress( + app, + &request.execution_id, + statement_index, + success_count, + failure_count, + affected_rows, + started_at, + error.clone(), + ); + return Err(error); + } + }; if bytes_read == 0 { break; } @@ -487,20 +515,90 @@ fn emit_progress( ) { let _ = app.emit( "sql-file-progress", - SqlFileProgress { - execution_id: execution_id.to_string(), + sql_file_progress( + execution_id, status, statement_index, success_count, failure_count, affected_rows, - elapsed_ms: started_at.elapsed().as_millis(), - statement_summary: statement_summary.to_string(), + started_at, + statement_summary, error, - }, + ), ); } +fn emit_file_io_error_progress( + app: &AppHandle, + execution_id: &str, + statement_index: usize, + success_count: usize, + failure_count: usize, + affected_rows: u64, + started_at: Instant, + error: String, +) { + let _ = app.emit( + "sql-file-progress", + file_io_error_progress( + execution_id, + statement_index, + success_count, + failure_count, + affected_rows, + started_at, + error, + ), + ); +} + +fn file_io_error_progress( + execution_id: &str, + statement_index: usize, + success_count: usize, + failure_count: usize, + affected_rows: u64, + started_at: Instant, + error: String, +) -> SqlFileProgress { + sql_file_progress( + execution_id, + SqlFileStatus::Error, + statement_index, + success_count, + failure_count, + affected_rows, + started_at, + "", + Some(error), + ) +} + +fn sql_file_progress( + execution_id: &str, + status: SqlFileStatus, + statement_index: usize, + success_count: usize, + failure_count: usize, + affected_rows: u64, + started_at: Instant, + statement_summary: &str, + error: Option, +) -> SqlFileProgress { + SqlFileProgress { + execution_id: execution_id.to_string(), + status, + statement_index, + success_count, + failure_count, + affected_rows, + elapsed_ms: started_at.elapsed().as_millis(), + statement_summary: statement_summary.to_string(), + error, + } +} + fn statement_summary(statement: &str) -> String { const MAX_LEN: usize = 120; @@ -688,4 +786,26 @@ mod execution_tests { assert_eq!(summary.success_count, 1); assert_eq!(summary.status, SqlFileStatus::Cancelled); } + + #[test] + fn file_io_errors_build_terminal_error_progress() { + let progress = file_io_error_progress( + "exec-1", + 4, + 2, + 1, + 17, + Instant::now(), + "read failed".to_string(), + ); + + assert_eq!(progress.execution_id, "exec-1"); + assert_eq!(progress.status, SqlFileStatus::Error); + assert_eq!(progress.statement_index, 4); + assert_eq!(progress.success_count, 2); + assert_eq!(progress.failure_count, 1); + assert_eq!(progress.affected_rows, 17); + assert_eq!(progress.statement_summary, ""); + assert_eq!(progress.error, Some("read failed".to_string())); + } } From 41066476be83c78163a757b9d9252dd385945be5 Mon Sep 17 00:00:00 2001 From: SuLe Date: Sat, 2 May 2026 04:57:41 +0800 Subject: [PATCH 10/22] harden sql file execution --- src-tauri/src/commands/sql_file.rs | 257 +++++++++++++++++++++++------ 1 file changed, 211 insertions(+), 46 deletions(-) diff --git a/src-tauri/src/commands/sql_file.rs b/src-tauri/src/commands/sql_file.rs index da93f87cb..aa7196f86 100644 --- a/src-tauri/src/commands/sql_file.rs +++ b/src-tauri/src/commands/sql_file.rs @@ -15,6 +15,13 @@ use crate::commands::query::execute_sql_statement; static SQL_FILE_EXECUTIONS: std::sync::LazyLock>> = std::sync::LazyLock::new(|| RwLock::new(HashMap::new())); +#[derive(Debug)] +struct StatementErrorDecision { + progress: Vec, + failure_count: usize, + result: Result, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SqlFileRequest { @@ -214,10 +221,10 @@ pub async fn execute_sql_file( request: SqlFileRequest, ) -> Result<(), String> { let token = CancellationToken::new(); - SQL_FILE_EXECUTIONS - .write() - .await - .insert(request.execution_id.clone(), token.clone()); + { + let mut executions = SQL_FILE_EXECUTIONS.write().await; + register_sql_file_execution(&mut executions, request.execution_id.clone(), token.clone())?; + } let started_at = Instant::now(); emit_progress( @@ -234,10 +241,10 @@ pub async fn execute_sql_file( ); let result = execute_sql_file_inner(&app, &state, &request, token, started_at).await; - SQL_FILE_EXECUTIONS - .write() - .await - .remove(&request.execution_id); + { + let mut executions = SQL_FILE_EXECUTIONS.write().await; + remove_sql_file_execution(&mut executions, &request.execution_id); + } result } @@ -450,57 +457,120 @@ async fn execute_statement_with_progress( Ok(false) } Err(error) => { - *failure_count += 1; - emit_progress( - app, + let decision = statement_error_decision( &request.execution_id, - SqlFileStatus::StatementFailed, + token, + request.continue_on_error, + started_at, statement_index, *success_count, *failure_count, *affected_rows, - started_at, &summary, - Some(error.clone()), + error, ); - if token.is_cancelled() { - emit_progress( - app, - &request.execution_id, - SqlFileStatus::Cancelled, - statement_index, - *success_count, - *failure_count, - *affected_rows, - started_at, - &summary, - Some(error), - ); - return Ok(true); - } - - if request.continue_on_error { - Ok(false) - } else { - emit_progress( - app, - &request.execution_id, - SqlFileStatus::Error, - statement_index, - *success_count, - *failure_count, - *affected_rows, - started_at, - &summary, - Some(error), - ); - Ok(true) + *failure_count = decision.failure_count; + for progress in decision.progress { + let _ = app.emit("sql-file-progress", progress); } + decision.result } } } +fn register_sql_file_execution( + executions: &mut HashMap, + execution_id: String, + token: CancellationToken, +) -> Result<(), String> { + if executions.contains_key(&execution_id) { + return Err(format!( + "SQL file execution '{execution_id}' already exists" + )); + } + + executions.insert(execution_id, token); + Ok(()) +} + +fn remove_sql_file_execution( + executions: &mut HashMap, + execution_id: &str, +) { + executions.remove(execution_id); +} + +fn statement_error_decision( + execution_id: &str, + token: &CancellationToken, + continue_on_error: bool, + started_at: Instant, + statement_index: usize, + success_count: usize, + failure_count: usize, + affected_rows: u64, + summary: &str, + error: String, +) -> StatementErrorDecision { + if token.is_cancelled() { + return StatementErrorDecision { + progress: vec![sql_file_progress( + execution_id, + SqlFileStatus::Cancelled, + statement_index, + success_count, + failure_count, + affected_rows, + started_at, + summary, + Some(error), + )], + failure_count, + result: Ok(true), + }; + } + + let failure_count = failure_count + 1; + let statement_failed = sql_file_progress( + execution_id, + SqlFileStatus::StatementFailed, + statement_index, + success_count, + failure_count, + affected_rows, + started_at, + summary, + Some(error.clone()), + ); + + if continue_on_error { + return StatementErrorDecision { + progress: vec![statement_failed], + failure_count, + result: Ok(false), + }; + } + + let terminal_error = sql_file_progress( + execution_id, + SqlFileStatus::Error, + statement_index, + success_count, + failure_count, + affected_rows, + started_at, + summary, + Some(error.clone()), + ); + + StatementErrorDecision { + progress: vec![statement_failed, terminal_error], + failure_count, + result: Err(error), + } +} + fn emit_progress( app: &AppHandle, execution_id: &str, @@ -808,4 +878,99 @@ mod execution_tests { assert_eq!(progress.statement_summary, ""); assert_eq!(progress.error, Some("read failed".to_string())); } + + #[test] + fn duplicate_execution_id_is_rejected_without_replacing_token() { + let mut executions = HashMap::new(); + let original = CancellationToken::new(); + let replacement = CancellationToken::new(); + executions.insert("dup".to_string(), original.clone()); + + let result = + register_sql_file_execution(&mut executions, "dup".to_string(), replacement.clone()); + + assert_eq!( + result.unwrap_err(), + "SQL file execution 'dup' already exists" + ); + assert_eq!(executions.len(), 1); + + executions.get("dup").unwrap().cancel(); + assert!(original.is_cancelled()); + assert!(!replacement.is_cancelled()); + } + + #[test] + fn stop_on_error_returns_err_with_terminal_error_progress() { + let decision = statement_error_decision( + "exec-1", + &CancellationToken::new(), + false, + Instant::now(), + 3, + 1, + 0, + 5, + "bad statement", + "syntax error".to_string(), + ); + + assert_eq!(decision.failure_count, 1); + assert_eq!(decision.result, Err("syntax error".to_string())); + assert_eq!(decision.progress.len(), 2); + assert_eq!(decision.progress[0].status, SqlFileStatus::StatementFailed); + assert_eq!(decision.progress[1].status, SqlFileStatus::Error); + assert_eq!(decision.progress[1].error, Some("syntax error".to_string())); + } + + #[test] + fn cancelled_in_flight_error_does_not_increment_failure_count() { + let token = CancellationToken::new(); + token.cancel(); + + let decision = statement_error_decision( + "exec-1", + &token, + false, + Instant::now(), + 2, + 1, + 4, + 9, + "slow statement", + "Query canceled".to_string(), + ); + + assert_eq!(decision.failure_count, 4); + assert_eq!(decision.result, Ok(true)); + assert_eq!(decision.progress.len(), 1); + assert_eq!(decision.progress[0].status, SqlFileStatus::Cancelled); + assert_eq!(decision.progress[0].failure_count, 4); + } + + #[test] + fn progress_payload_serializes_camel_case_status() { + let progress = sql_file_progress( + "exec-1", + SqlFileStatus::StatementDone, + 1, + 1, + 0, + 3, + Instant::now(), + "select 1", + None, + ); + + let value = serde_json::to_value(progress).unwrap(); + + assert_eq!(value["executionId"], "exec-1"); + assert_eq!(value["statementIndex"], 1); + assert_eq!(value["successCount"], 1); + assert_eq!(value["failureCount"], 0); + assert_eq!(value["affectedRows"], 3); + assert_eq!(value["statementSummary"], "select 1"); + assert_eq!(value["status"], "statementDone"); + assert!(value.get("execution_id").is_none()); + } } From 69cd5831a19c8894a43c0667fd5e43b359bbd8f6 Mon Sep 17 00:00:00 2001 From: SuLe Date: Sat, 2 May 2026 05:06:14 +0800 Subject: [PATCH 11/22] skip comment-only sql file statements --- src-tauri/src/commands/sql_file.rs | 60 +++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/sql_file.rs b/src-tauri/src/commands/sql_file.rs index aa7196f86..02106f53f 100644 --- a/src-tauri/src/commands/sql_file.rs +++ b/src-tauri/src/commands/sql_file.rs @@ -178,7 +178,7 @@ impl SqlStatementSplitter { fn push_current_statement(&mut self, statements: &mut Vec) { let statement = self.buffer.trim(); - if !statement.is_empty() { + if has_executable_sql(statement) { statements.push(statement.to_string()); } self.buffer.clear(); @@ -186,6 +186,53 @@ impl SqlStatementSplitter { } } +fn has_executable_sql(statement: &str) -> bool { + let mut chars = statement.chars().peekable(); + let mut in_line_comment = false; + let mut in_block_comment = false; + let mut previous = None; + + while let Some(ch) = chars.next() { + let next = chars.peek().copied(); + + if in_line_comment { + if ch == '\n' { + in_line_comment = false; + } + previous = Some(ch); + continue; + } + + if in_block_comment { + if previous == Some('*') && ch == '/' { + in_block_comment = false; + } + previous = Some(ch); + continue; + } + + if ch == '-' && next == Some('-') { + in_line_comment = true; + previous = Some(ch); + continue; + } + + if ch == '/' && next == Some('*') { + in_block_comment = true; + previous = Some(ch); + continue; + } + + if !ch.is_whitespace() { + return true; + } + + previous = Some(ch); + } + + false +} + #[tauri::command] pub async fn preview_sql_file(file_path: String) -> Result { let path = PathBuf::from(&file_path); @@ -524,7 +571,7 @@ fn statement_error_decision( affected_rows, started_at, summary, - Some(error), + None, )], failure_count, result: Ok(true), @@ -799,6 +846,14 @@ mod tests { ); assert_eq!(splitter.finish(), Vec::::new()); } + + #[test] + fn skips_comment_only_tail_after_statement() { + assert_eq!( + split_sql_script("CREATE TABLE a(id int); -- done\n/* no more sql */").unwrap(), + vec!["CREATE TABLE a(id int)"] + ); + } } #[cfg(test)] @@ -946,6 +1001,7 @@ mod execution_tests { assert_eq!(decision.progress.len(), 1); assert_eq!(decision.progress[0].status, SqlFileStatus::Cancelled); assert_eq!(decision.progress[0].failure_count, 4); + assert_eq!(decision.progress[0].error, None); } #[test] From 6407e13ef374e654de92d0e5a9ac7257e997c3a1 Mon Sep 17 00:00:00 2001 From: SuLe Date: Sat, 2 May 2026 05:10:01 +0800 Subject: [PATCH 12/22] add sql file frontend api --- src/lib/tauri.ts | 55 +++++++++++++++++++++++++++++++++++ src/stores/connectionStore.ts | 2 ++ 2 files changed, 57 insertions(+) diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index e37d7fe4d..171e5ef0b 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -303,6 +303,61 @@ export async function deleteHistoryEntry(id: string): Promise { return invoke("delete_history_entry", { id }); } +// --- SQL File Execution --- +export type SqlFileStatus = + | "started" + | "running" + | "statementDone" + | "statementFailed" + | "done" + | "error" + | "cancelled"; + +export interface SqlFileRequest { + executionId: string; + connectionId: string; + database: string; + filePath: string; + continueOnError: boolean; +} + +export interface SqlFilePreview { + fileName: string; + filePath: string; + sizeBytes: number; + preview: string; +} + +export interface SqlFileProgress { + executionId: string; + status: SqlFileStatus; + statementIndex: number; + successCount: number; + failureCount: number; + affectedRows: number; + elapsedMs: number; + statementSummary: string; + error?: string | null; +} + +export async function previewSqlFile(filePath: string): Promise { + return invoke("preview_sql_file", { filePath }); +} + +export async function executeSqlFile(request: SqlFileRequest): Promise { + return invoke("execute_sql_file", { request }); +} + +export async function cancelSqlFileExecution(executionId: string): Promise { + return invoke("cancel_sql_file_execution", { executionId }); +} + +export async function listenSqlFileProgress( + handler: (progress: SqlFileProgress) => void, +): Promise { + return listen("sql-file-progress", (event) => handler(event.payload)); +} + // --- Data Transfer --- export interface TransferRequest { transferId: string; diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts index 9764d5e56..addc38357 100644 --- a/src/stores/connectionStore.ts +++ b/src/stores/connectionStore.ts @@ -18,6 +18,7 @@ export const useConnectionStore = defineStore("connection", () => { const completionColumnsCache = ref>({}); const transferSource = ref<{ connectionId: string; database: string } | null>(null); const schemaDiffSource = ref<{ connectionId: string; database: string } | null>(null); + const sqlFileSource = ref<{ connectionId: string; database: string } | null>(null); function startEditing(id: string) { editingConnectionId.value = id; @@ -647,5 +648,6 @@ export const useConnectionStore = defineStore("connection", () => { importConnectionsFromFile, transferSource, schemaDiffSource, + sqlFileSource, }; }); From 08c26203e40d79c3c7667e93795664605340e1c1 Mon Sep 17 00:00:00 2001 From: SuLe Date: Sat, 2 May 2026 05:15:41 +0800 Subject: [PATCH 13/22] add sql file execution dialog --- .../sql-file/SqlFileExecutionDialog.vue | 421 ++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 src/components/sql-file/SqlFileExecutionDialog.vue diff --git a/src/components/sql-file/SqlFileExecutionDialog.vue b/src/components/sql-file/SqlFileExecutionDialog.vue new file mode 100644 index 000000000..f169720fe --- /dev/null +++ b/src/components/sql-file/SqlFileExecutionDialog.vue @@ -0,0 +1,421 @@ + + + From 2cb76f7b7841521378d076299b309f2a43387023 Mon Sep 17 00:00:00 2001 From: SuLe Date: Sat, 2 May 2026 05:19:10 +0800 Subject: [PATCH 14/22] fix sql file dialog database selection --- .../sql-file/SqlFileExecutionDialog.vue | 98 ++++++++++++++++--- 1 file changed, 86 insertions(+), 12 deletions(-) diff --git a/src/components/sql-file/SqlFileExecutionDialog.vue b/src/components/sql-file/SqlFileExecutionDialog.vue index f169720fe..ddef42cf9 100644 --- a/src/components/sql-file/SqlFileExecutionDialog.vue +++ b/src/components/sql-file/SqlFileExecutionDialog.vue @@ -18,6 +18,7 @@ import { cancelSqlFileExecution, executeSqlFile, listenSqlFileProgress, + listDatabases, previewSqlFile, type SqlFilePreview, type SqlFileProgress, @@ -42,6 +43,8 @@ const selectingFile = ref(false); const loadingPreview = ref(false); const connectionId = ref(""); const database = ref(""); +const databaseOptions = ref([]); +const loadingDatabases = ref(false); const continueOnError = ref(false); const running = ref(false); @@ -62,7 +65,7 @@ const selectedConnection = computed(() => ); const canStart = computed(() => - Boolean(preview.value && connectionId.value && database.value.trim() && !running.value && !loadingPreview.value), + Boolean(preview.value && selectedConnection.value && database.value.trim() && !running.value && !loadingPreview.value && !loadingDatabases.value), ); const statusTone = computed(() => { @@ -122,6 +125,23 @@ function isTerminalStatus(status: SqlFileStatus | "idle") { return status === "done" || status === "error" || status === "cancelled"; } +function resolveInitialConnectionId() { + if (props.prefillConnectionId && sqlConnections.value.some((c) => c.id === props.prefillConnectionId)) { + return props.prefillConnectionId; + } + return sqlConnections.value[0]?.id ?? ""; +} + +function chooseDatabase(names: string[], id: string) { + const configDatabase = store.getConfig(id)?.database ?? ""; + if (names.length > 0) { + if (props.prefillDatabase && names.includes(props.prefillDatabase)) return props.prefillDatabase; + if (configDatabase && names.includes(configDatabase)) return configDatabase; + return names.length === 1 ? names[0] : ""; + } + return props.prefillDatabase ?? configDatabase; +} + function resetExecution() { running.value = false; cancelling.value = false; @@ -136,12 +156,44 @@ function resetState() { preview.value = null; selectingFile.value = false; loadingPreview.value = false; - connectionId.value = props.prefillConnectionId ?? ""; - database.value = props.prefillDatabase ?? selectedConnection.value?.database ?? ""; + connectionId.value = resolveInitialConnectionId(); + database.value = ""; + databaseOptions.value = []; + loadingDatabases.value = false; continueOnError.value = false; resetExecution(); } +let databaseLoadToken = 0; + +async function loadDatabasesForConnection(id: string) { + const token = databaseLoadToken + 1; + databaseLoadToken = token; + databaseOptions.value = []; + + if (!sqlConnections.value.some((c) => c.id === id)) { + database.value = ""; + return; + } + + loadingDatabases.value = true; + try { + await store.ensureConnected(id); + const names = (await listDatabases(id)).map((db) => db.name); + if (token !== databaseLoadToken) return; + databaseOptions.value = names; + database.value = chooseDatabase(names, id); + } catch { + if (token !== databaseLoadToken) return; + databaseOptions.value = []; + database.value = chooseDatabase([], id); + } finally { + if (token === databaseLoadToken) { + loadingDatabases.value = false; + } + } +} + async function loadPreview(path: string) { loadingPreview.value = true; preview.value = null; @@ -238,12 +290,20 @@ function handleOpenChange(nextOpen: boolean) { } watch(connectionId, (id) => { - const config = store.getConfig(id); - database.value = props.prefillDatabase ?? config?.database ?? ""; + loadDatabasesForConnection(id); +}); + +watch(sqlConnections, () => { + if (!open.value || running.value || selectedConnection.value) return; + connectionId.value = resolveInitialConnectionId(); }); watch(open, (value) => { - if (value) resetState(); + if (!value) return; + resetState(); + if (connectionId.value) { + loadDatabasesForConnection(connectionId.value); + } }); @@ -318,12 +378,26 @@ watch(open, (value) => {
- + +
+ + +
From 6796357d584f2c827068e61926b61073dcab7428 Mon Sep 17 00:00:00 2001 From: SuLe Date: Sat, 2 May 2026 05:24:07 +0800 Subject: [PATCH 15/22] harden sql file dialog cancellation --- .../sql-file/SqlFileExecutionDialog.vue | 53 +++++++++++++------ 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/src/components/sql-file/SqlFileExecutionDialog.vue b/src/components/sql-file/SqlFileExecutionDialog.vue index ddef42cf9..4d5d13abf 100644 --- a/src/components/sql-file/SqlFileExecutionDialog.vue +++ b/src/components/sql-file/SqlFileExecutionDialog.vue @@ -49,6 +49,8 @@ const continueOnError = ref(false); const running = ref(false); const cancelling = ref(false); +const cancelRequested = ref(false); +const executionStarted = ref(false); const executionId = ref(""); const progress = ref(null); const terminalStatus = ref("idle"); @@ -145,6 +147,8 @@ function chooseDatabase(names: string[], id: string) { function resetExecution() { running.value = false; cancelling.value = false; + cancelRequested.value = false; + executionStarted.value = false; executionId.value = ""; progress.value = null; terminalStatus.value = "idle"; @@ -233,6 +237,8 @@ async function startExecution() { executionId.value = id; running.value = true; cancelling.value = false; + cancelRequested.value = false; + executionStarted.value = false; terminalStatus.value = "running"; terminalError.value = ""; progress.value = null; @@ -240,6 +246,10 @@ async function startExecution() { let unlisten: (() => void) | undefined; try { await store.ensureConnected(connectionId.value); + if (cancelRequested.value) { + terminalStatus.value = "cancelled"; + return; + } unlisten = await listenSqlFileProgress((next) => { if (next.executionId !== id) return; @@ -252,6 +262,12 @@ async function startExecution() { } }); + if (cancelRequested.value) { + terminalStatus.value = "cancelled"; + return; + } + + executionStarted.value = true; await executeSqlFile({ executionId: id, connectionId: connectionId.value, @@ -260,22 +276,27 @@ async function startExecution() { continueOnError: continueOnError.value, }); if (!isTerminalStatus(terminalStatus.value)) { - terminalStatus.value = "done"; + terminalStatus.value = cancelRequested.value ? "cancelled" : "done"; } } catch (e: any) { - terminalStatus.value = cancelling.value ? "cancelled" : "error"; + terminalStatus.value = cancelRequested.value ? "cancelled" : "error"; terminalError.value = e?.message || String(e); - toast(terminalError.value, 5000); + if (!cancelRequested.value) { + toast(terminalError.value, 5000); + } } finally { unlisten?.(); running.value = false; cancelling.value = false; + executionStarted.value = false; } } async function cancelExecution() { if (!executionId.value || !running.value || cancelling.value) return; + cancelRequested.value = true; cancelling.value = true; + if (!executionStarted.value) return; try { await cancelSqlFileExecution(executionId.value); } catch (e: any) { @@ -440,22 +461,22 @@ watch(open, (value) => { /> -
-
-
{{ t('sqlFile.statement') }}
-
{{ progress?.statementIndex ?? 0 }}
+
+
+
{{ t('sqlFile.statement') }}
+
{{ progress?.statementIndex ?? 0 }}
-
-
{{ t('sqlFile.succeeded') }}
-
{{ progress?.successCount ?? 0 }}
+
+
{{ t('sqlFile.succeeded') }}
+
{{ progress?.successCount ?? 0 }}
-
-
{{ t('sqlFile.failed') }}
-
{{ progress?.failureCount ?? 0 }}
+
+
{{ t('sqlFile.failed') }}
+
{{ progress?.failureCount ?? 0 }}
-
-
{{ t('sqlFile.affectedRows') }}
-
{{ (progress?.affectedRows ?? 0).toLocaleString() }}
+
+
{{ t('sqlFile.affectedRows') }}
+
{{ (progress?.affectedRows ?? 0).toLocaleString() }}
From 13724a245d815b6a9c56fc687a46ce4076438dca Mon Sep 17 00:00:00 2001 From: SuLe Date: Sat, 2 May 2026 05:26:27 +0800 Subject: [PATCH 16/22] fix failed sql file cancel state --- src/components/sql-file/SqlFileExecutionDialog.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/sql-file/SqlFileExecutionDialog.vue b/src/components/sql-file/SqlFileExecutionDialog.vue index 4d5d13abf..82e5815d1 100644 --- a/src/components/sql-file/SqlFileExecutionDialog.vue +++ b/src/components/sql-file/SqlFileExecutionDialog.vue @@ -300,6 +300,7 @@ async function cancelExecution() { try { await cancelSqlFileExecution(executionId.value); } catch (e: any) { + cancelRequested.value = false; cancelling.value = false; toast(e?.message || String(e), 5000); } From c33afc8cef89f13f67f8ddbe5177717f2da25d0b Mon Sep 17 00:00:00 2001 From: SuLe Date: Sat, 2 May 2026 05:28:28 +0800 Subject: [PATCH 17/22] wire sql file execution entry points --- src/App.vue | 25 +++++++++++++++++++++- src/components/sidebar/TreeItem.vue | 15 ++++++++++++++ src/i18n/locales/en.ts | 32 +++++++++++++++++++++++++++++ src/i18n/locales/zh-CN.ts | 32 +++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/App.vue b/src/App.vue index 67c18346a..d34ff79bc 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,7 +1,7 @@ - - -``` - -- [ ] **Step 2: Run frontend build** - -Run: - -```bash -pnpm build -``` - -Expected: build passes. If TypeScript reports that `Checkbox` requires `boolean | "indeterminate"`, change the binding to `:checked="continueOnError" @update:checked="(value) => { continueOnError = value === true }"` and rerun `pnpm build`. - -- [ ] **Step 3: Commit** - -```bash -git add src/components/sql-file/SqlFileExecutionDialog.vue -git commit -m "add sql file execution dialog" -``` - ---- - -### Task 6: Toolbar And Tree Entry Points - -**Files:** -- Modify: `src/App.vue` -- Modify: `src/components/sidebar/TreeItem.vue` -- Modify: `src/i18n/locales/en.ts` -- Modify: `src/i18n/locales/zh-CN.ts` - -- [ ] **Step 1: Add App state and dialog mount** - -In `src/App.vue`, import the dialog and icon: - -```ts -import SqlFileExecutionDialog from "@/components/sql-file/SqlFileExecutionDialog.vue"; -``` - -Add state beside transfer/schema diff state: - -```ts -const showSqlFileDialog = ref(false); -const sqlFilePrefillConnectionId = ref(""); -const sqlFilePrefillDatabase = ref(""); -``` - -Add watcher: - -```ts -watch(() => connectionStore.sqlFileSource, (v) => { - if (v) { - sqlFilePrefillConnectionId.value = v.connectionId; - sqlFilePrefillDatabase.value = v.database; - showSqlFileDialog.value = true; - connectionStore.sqlFileSource = null; - } -}); -``` - -Add toolbar button after Data Transfer: - -```vue - -``` - -Mount dialog near existing dialogs: - -```vue - -``` - -- [ ] **Step 2: Add tree context action** - -In `src/components/sidebar/TreeItem.vue`, add: - -```ts -function openSqlFileExecution() { - if (props.node.connectionId) { - connectionStore.sqlFileSource = { - connectionId: props.node.connectionId, - database: props.node.database ?? "", - }; - } -} -``` - -For connection menu, add after New Query: - -```vue - - {{ t('sqlFile.title') }} - -``` - -For database/schema menu, add after New Query: - -```vue - - {{ t('sqlFile.title') }} - -``` - -- [ ] **Step 3: Add i18n strings** - -In `src/i18n/locales/en.ts`, add: - -```ts -sqlFile: { - title: "Execute SQL File", - file: "SQL File", - chooseFile: "Choose File", - execute: "Execute File", - cancel: "Cancel", - continueOnError: "Continue after failed statements", - progress: "Statement {current} · {success} succeeded · {failed} failed", -}, -``` - -In `src/i18n/locales/zh-CN.ts`, add: - -```ts -sqlFile: { - title: "执行 SQL 文件", - file: "SQL 文件", - chooseFile: "选择文件", - execute: "执行文件", - cancel: "取消", - continueOnError: "失败后继续执行", - progress: "第 {current} 条 · 成功 {success} · 失败 {failed}", -}, -``` - -- [ ] **Step 4: Run frontend build** - -Run: - -```bash -pnpm build -``` - -Expected: build passes. - -- [ ] **Step 5: Commit** - -```bash -git add src/App.vue src/components/sidebar/TreeItem.vue src/i18n/locales/en.ts src/i18n/locales/zh-CN.ts -git commit -m "wire sql file execution entry points" -``` - ---- - -### Task 7: Final Verification - -**Files:** -- All changed files. - -- [ ] **Step 1: Run Rust tests** - -Run: - -```bash -cd src-tauri -cargo test --lib -``` - -Expected: all tests pass. - -- [ ] **Step 2: Run frontend build** - -Run: - -```bash -pnpm build -``` - -Expected: build passes. Existing chunk-size warnings are acceptable. - -- [ ] **Step 3: Run diff hygiene** - -Run: - -```bash -git diff --check -git status -sb -``` - -Expected: no whitespace errors. Status should show only intended tracked changes plus any pre-existing untracked docs that are not part of this PR. - -- [ ] **Step 4: Manual smoke test** - -Create a small file outside the repo: - -```sql -CREATE TABLE IF NOT EXISTS codex_sql_file_test (id INTEGER); -INSERT INTO codex_sql_file_test VALUES (1); -SELECT * FROM codex_sql_file_test; -``` - -Open DBX with `pnpm tauri dev`, choose a local SQLite or DuckDB connection, execute the file, and verify progress reaches done with at least two successful statements. - -- [ ] **Step 5: Manual failure smoke test** - -Create a small file outside the repo: - -```sql -CREATE TABLE IF NOT EXISTS codex_sql_file_test_failure (id INTEGER); -BROKEN STATEMENT; -INSERT INTO codex_sql_file_test_failure VALUES (1); -``` - -Run with default stop-on-error. Verify the dialog stops at statement 2 and shows the error without executing statement 3. - -- [ ] **Step 6: Final commit after verification fixes** - -If verification uncovers a concrete issue, stage the exact files changed by that fix: - -```bash -git add src-tauri/src/commands/sql_file.rs src/App.vue src/components/sql-file/SqlFileExecutionDialog.vue src/lib/tauri.ts -git commit -m "polish sql file execution" -``` - -If those exact files were not all touched by the fix, remove the untouched paths from the `git add` command before running it. If no verification fixes were needed, leave the task commits as the implementation history. diff --git a/docs/superpowers/specs/2026-05-02-sql-file-execution-design.md b/docs/superpowers/specs/2026-05-02-sql-file-execution-design.md deleted file mode 100644 index df4897e41..000000000 --- a/docs/superpowers/specs/2026-05-02-sql-file-execution-design.md +++ /dev/null @@ -1,134 +0,0 @@ -# SQL File Execution Design - -## Goal - -Add a first-class workflow for executing `.sql` files against a selected connection and database. The feature targets common migration, initialization, and dump-restore tasks where users need to run a script file, see progress, stop on errors by default, and cancel long-running execution. - -## Entry Points - -- Add a toolbar action labeled "Execute SQL File". -- Add a context-menu action labeled "Execute SQL File" on connection and database tree nodes. -- The context-menu entry opens the same dialog as the toolbar action, with the current connection and database preselected when available. - -## Dialog Behavior - -The dialog lets the user choose: - -- Connection. -- Database, when the selected connection supports databases. -- SQL file path. -- Failure policy. - -The default failure policy is to stop on the first failed statement. A secondary option allows continuing after statement failures. - -Before execution, the dialog shows the file name, size, and a small preview from the beginning of the file. The preview is informational only; execution uses the file path directly so large files do not need to be loaded into frontend memory. - -During execution, the dialog shows: - -- Current statement index. -- Successful statement count. -- Failed statement count. -- Elapsed time. -- Current statement summary. -- Last error, if any. - -The dialog provides a Cancel button while execution is active. - -## Backend Architecture - -Add a new SQL file execution command family: - -- `execute_sql_file(connection_id, database, file_path, execution_id, continue_on_error)`. -- `cancel_sql_file_execution(execution_id)`. - -The backend reads the file incrementally from disk and splits it into statements as it reads. It executes statements one at a time using the existing query execution path so database-specific connection handling, reconnect behavior, timeout behavior, and result handling remain consistent with the query editor. - -Execution sends progress events through Tauri: - -- `started`: file execution has begun. -- `statementDone`: one statement completed successfully. -- `statementFailed`: one statement failed. -- `cancelled`: execution stopped because the user cancelled. -- `done`: execution completed. - -Each event includes the `execution_id` so the frontend can ignore events from stale runs. - -## SQL Splitting - -The first implementation supports standard semicolon-delimited SQL files. The splitter must ignore semicolons inside: - -- Single-quoted strings. -- Double-quoted strings. -- Backtick-quoted identifiers. -- Line comments. -- Block comments. - -The splitter also emits a final trailing statement when the file does not end with a semicolon. - -MySQL `DELIMITER` syntax for stored procedures is intentionally out of scope for this first PR. If a file depends on custom delimiters, execution may fail at the relevant statement and report the failing index. A later PR can add delimiter directives without blocking the common migration and initialization cases. - -## Failure And Cancellation - -Default behavior is stop-on-first-error. When a statement fails: - -- Emit `statementFailed`. -- Stop immediately unless `continue_on_error` is true. -- Report the failing statement index and the count of statements that may already have committed. - -When `continue_on_error` is true, execution continues after failures and emits a final summary including successful and failed counts. - -Cancellation uses a cancellation token keyed by `execution_id`. Cancelling stops before the next statement when possible and also passes the token into the active statement execution path so supported drivers can stop promptly. The UI should show cancellation as a stopped state rather than a generic SQL error. - -## Frontend Architecture - -Add a dedicated SQL file execution dialog component. The component owns: - -- File selection via Tauri dialog. -- Connection and database selection. -- Preview metadata. -- Execution progress state. -- Cancel action. - -Add lightweight API wrappers in `src/lib/tauri.ts` for the two backend commands and the progress event payload type. - -Do not store SQL file execution as a normal query tab. A file execution can contain many statements and progress events, so a modal task workflow is clearer than overloading the query result grid. - -## Testing - -Rust tests: - -- Split multiple statements by semicolon. -- Ignore semicolons in single-quoted strings. -- Ignore semicolons in double-quoted strings. -- Ignore semicolons in backtick identifiers. -- Ignore semicolons in line comments and block comments. -- Emit trailing statement without a final semicolon. -- Stop on first failure when `continue_on_error` is false. -- Continue after failure when `continue_on_error` is true. -- Stop execution after cancellation. - -Frontend verification: - -- Typecheck and production build. -- Manual smoke test with a small SQL file showing successful progress. -- Manual smoke test with a failing second statement showing stop-on-error and the failing index. - -## Scope Boundaries - -In scope: - -- Local `.sql` file selection. -- Streaming backend execution. -- Progress events. -- Stop-on-error default. -- Continue-on-error option. -- Cancellation. -- Toolbar and tree context-menu entry points. - -Out of scope for this PR: - -- MySQL custom `DELIMITER` directives. -- Transaction wrapping of the whole file. -- Remote file URLs. -- Saving import history. -- Editing the SQL file inside dbx before execution.