execute sql files with progress

This commit is contained in:
SuLe 2026-05-02 04:47:43 +08:00
parent 33705b9cbb
commit 15fa5b0d2c
3 changed files with 516 additions and 1 deletions

View File

@ -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;

View File

@ -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<RwLock<HashMap<String, CancellationToken>>> =
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<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SqlFileSummary {
status: SqlFileStatus,
success_count: usize,
failure_count: usize,
failed_statement_index: Option<usize>,
}
#[derive(Default)]
struct SqlStatementSplitter {
buffer: String,
@ -109,6 +179,391 @@ impl SqlStatementSplitter {
}
}
#[tauri::command]
pub async fn preview_sql_file(file_path: String) -> Result<SqlFilePreview, String> {
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<AppState>>,
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<bool, String> {
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<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 {
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<AppState>>,
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<bool, String> {
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<String>,
) {
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::<Vec<_>>().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<String>,
continue_on_error: bool,
token: CancellationToken,
cancel_after_successes: Option<usize>,
) -> 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<Vec<String>, String> {
let mut splitter = SqlStatementSplitter::default();
@ -177,3 +632,60 @@ mod tests {
assert_eq!(splitter.finish(), Vec::<String>::new());
}
}
#[cfg(test)]
mod execution_tests {
use super::*;
use tokio_util::sync::CancellationToken;
async fn run_fake_script(
statements: Vec<String>,
continue_on_error: bool,
cancel_after_successes: Option<usize>,
) -> 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);
}
}

View File

@ -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,