From 814fda7433d6f28183ee9a07baef182b5dc27756 Mon Sep 17 00:00:00 2001 From: rarnu Date: Thu, 7 May 2026 15:09:59 +0800 Subject: [PATCH] =?UTF-8?q?add:=20=E5=A2=9E=E5=8A=A0=E5=AF=B9=E4=BA=8E?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=BE=97=E5=88=B0=E7=9A=84=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E9=9B=86=E7=9B=B4=E6=8E=A5=E8=BF=9B=E8=A1=8C=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E7=9A=84=E5=8A=9F=E8=83=BD=E3=80=82=E5=BD=93?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E6=98=AF=E5=8D=95=E8=A1=A8=E4=B8=94=E5=8C=85?= =?UTF-8?q?=E5=90=AB=E4=B8=BB=E9=94=AE(=E6=9C=89=E8=81=94=E5=90=88?= =?UTF-8?q?=E4=B8=BB=E9=94=AE=E6=97=B6=E5=8C=85=E5=90=AB=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E4=B8=BB=E9=94=AE)=E6=97=B6=EF=BC=8C=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E5=9C=A8=E7=BB=93=E6=9E=9C=E9=9B=86=E5=86=85=E5=8F=8C=E5=87=BB?= =?UTF-8?q?=E5=8D=95=E5=85=83=E6=A0=BC=E4=BF=AE=E6=94=B9=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E6=94=B9=E7=BB=93=E6=9E=9C=E4=BB=A5=E4=BA=8B?= =?UTF-8?q?=E5=8A=A1=E5=BD=A2=E5=BC=8F=E6=8F=90=E4=BA=A4=E6=88=96=E5=9B=9E?= =?UTF-8?q?=E6=BB=9A=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/dbx-core/src/query.rs | 117 +++++++++++++++++++ src-tauri/src/commands/query.rs | 18 +++ src-tauri/src/lib.rs | 1 + src-web/src/main.rs | 1 + src-web/src/routes/query.rs | 17 +++ src/components/grid/DataGrid.vue | 112 +++++++++++++++++- src/components/layout/ContentArea.vue | 15 +++ src/i18n/locales/en.ts | 4 + src/i18n/locales/zh-CN.ts | 4 + src/lib/api.ts | 1 + src/lib/http.ts | 9 ++ src/lib/sqlAnalysis.ts | 154 ++++++++++++++++++++++++ src/lib/tauri.ts | 9 ++ src/stores/queryStore.ts | 161 ++++++++++++++++++++++++++ src/types/database.ts | 6 + 15 files changed, 627 insertions(+), 2 deletions(-) create mode 100644 src/lib/sqlAnalysis.ts diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index ece96748b..568301f55 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -361,6 +361,123 @@ pub async fn execute_statements( }) } +/// Execute multiple SQL statements within a single transaction (BEGIN ... COMMIT). +/// If any statement fails, ROLLBACK is issued and an error is returned. +/// For databases that don't support explicit transactions (Redis, MongoDB), this +/// falls back to executing statements sequentially. +pub async fn execute_statements_in_transaction( + state: &AppState, + connection_id: &str, + database: &str, + statements: &[String], + schema: Option<&str>, +) -> Result { + let pool_key = if database.is_empty() { + connection_id.to_string() + } else { + state.get_or_create_pool(connection_id, Some(database)).await? + }; + + // Check if this connection supports transactions + let supports_tx = { + let connections = state.connections.lock().await; + matches!( + connections.get(&pool_key), + Some(PoolKind::Mysql(_, _)) + | Some(PoolKind::Postgres(_)) + | Some(PoolKind::Sqlite(_)) + | Some(PoolKind::ClickHouse(_)) + | Some(PoolKind::SqlServer(_)) + | Some(PoolKind::Dameng(_)) + | Some(PoolKind::Gaussdb(_)) + ) + }; + + let start = std::time::Instant::now(); + + if !supports_tx { + // Fallback: execute statements sequentially without transaction + let mut total_affected: u64 = 0; + for (i, sql) in statements.iter().enumerate() { + match do_execute(state, &pool_key, sql, schema, None).await { + Ok(result) => { + total_affected += result.affected_rows; + } + Err(e) => { + log::warn!("Statement {} failed (no transaction support): {}", i + 1, e); + return Err(format!( + "Statement {} failed: {}. No transaction support for this database type.", + i + 1, + e + )); + } + } + } + return Ok(db::QueryResult { + columns: vec![], + rows: vec![], + affected_rows: total_affected, + execution_time_ms: start.elapsed().as_millis(), + truncated: false, + }); + } + + // Execute within transaction + match do_execute(state, &pool_key, "BEGIN", schema, None).await { + Ok(_) => {} + Err(e) => { + log::warn!("BEGIN failed: {}, proceeding without transaction", e); + // Fallback without transaction + let mut total_affected: u64 = 0; + for sql in statements { + match do_execute(state, &pool_key, sql, schema, None).await { + Ok(result) => total_affected += result.affected_rows, + Err(e2) => return Err(e2), + } + } + return Ok(db::QueryResult { + columns: vec![], + rows: vec![], + affected_rows: total_affected, + execution_time_ms: start.elapsed().as_millis(), + truncated: false, + }); + } + } + + let mut total_affected: u64 = 0; + for (i, sql) in statements.iter().enumerate() { + match do_execute(state, &pool_key, sql, schema, None).await { + Ok(result) => { + total_affected += result.affected_rows; + } + Err(e) => { + // Rollback on failure + if let Err(rb_err) = do_execute(state, &pool_key, "ROLLBACK", schema, None).await { + log::error!("ROLLBACK failed after statement {} error: {}", i + 1, rb_err); + } + return Err(format!("Statement {} failed: {}", i + 1, e)); + } + } + } + + // Commit + match do_execute(state, &pool_key, "COMMIT", schema, None).await { + Ok(_) => {} + Err(e) => { + return Err(format!("COMMIT failed: {}", e)); + } + } + + Ok(db::QueryResult { + columns: vec![], + rows: vec![], + affected_rows: total_affected, + execution_time_ms: start.elapsed().as_millis(), + truncated: false, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index eacdae498..387d6a587 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -74,3 +74,21 @@ pub async fn execute_script( ) .await } + +#[tauri::command] +pub async fn execute_in_transaction( + state: State<'_, Arc>, + connection_id: String, + database: String, + statements: Vec, + schema: Option, +) -> Result { + dbx_core::query::execute_statements_in_transaction( + &state, + &connection_id, + &database, + &statements, + schema.as_deref(), + ) + .await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5a8fad1d3..87d5b5a64 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -77,6 +77,7 @@ pub fn run() { commands::query::cancel_query, commands::query::execute_batch, commands::query::execute_script, + commands::query::execute_in_transaction, commands::sql_file::preview_sql_file, commands::sql_file::execute_sql_file, commands::sql_file::cancel_sql_file_execution, diff --git a/src-web/src/main.rs b/src-web/src/main.rs index 4c22e466d..94ccbcb78 100644 --- a/src-web/src/main.rs +++ b/src-web/src/main.rs @@ -94,6 +94,7 @@ async fn main() { .route("/query/execute-multi", post(routes::query::execute_multi)) .route("/query/execute-batch", post(routes::query::execute_batch)) .route("/query/execute-script", post(routes::query::execute_script)) + .route("/query/execute-in-transaction", post(routes::query::execute_in_transaction)) .route("/query/cancel", post(routes::query::cancel_query)) // Redis .route("/redis/list-databases", post(routes::redis::list_databases)) diff --git a/src-web/src/routes/query.rs b/src-web/src/routes/query.rs index efd240262..125868800 100644 --- a/src-web/src/routes/query.rs +++ b/src-web/src/routes/query.rs @@ -122,3 +122,20 @@ pub async fn execute_script( Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) } + +pub async fn execute_in_transaction( + State(state): State>, + Json(req): Json, +) -> Result, AppError> { + let result = dbx_core::query::execute_statements_in_transaction( + &state.app, + &req.connection_id, + &req.database, + &req.statements, + req.schema.as_deref(), + ) + .await + .map_err(AppError)?; + + Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) +} diff --git a/src/components/grid/DataGrid.vue b/src/components/grid/DataGrid.vue index 903af8738..cf88a82d3 100644 --- a/src/components/grid/DataGrid.vue +++ b/src/components/grid/DataGrid.vue @@ -28,6 +28,9 @@ import { Info, Rows3, TriangleAlert, + RefreshCcw, + RotateCcw, + Table2, } from "lucide-vue-next"; import { Button } from "@/components/ui/button"; import { @@ -461,6 +464,37 @@ const deletedRowCount = computed(() => deletedRows.value.size); const pendingChangeCount = computed(() => dirtyRowCount.value + newRowCount.value + deletedRowCount.value); const hasPendingChanges = computed(() => pendingChangeCount.value > 0); +// --- Transaction state --- +const transactionActive = ref(false); +const isSaving = ref(false); + +function enterTransaction() { + transactionActive.value = true; +} + +function exitTransaction() { + transactionActive.value = false; +} + +function shouldUseTransaction(): boolean { + return props.editable && !!props.connectionId && !!props.database && !!props.tableMeta; +} + +async function onToolbarRefresh() { + if (transactionActive.value) { + discardChanges(); + } + emit("reload"); +} + +async function onToolbarCommit() { + await saveChanges(); +} + +function onToolbarRollback() { + discardChanges(); +} + const sortedRows = computed(() => { let rows = props.result.rows.map((row, sourceIndex) => ({ row, sourceIndex })); if (clientSearchText.value) { @@ -760,6 +794,10 @@ function commitEdit() { if (newVal !== oldVal) { if (!dirtyRows.value.has(item.sourceIndex)) dirtyRows.value.set(item.sourceIndex, new Map()); dirtyRows.value.get(item.sourceIndex)!.set(col, newVal); + // Enter transaction mode on first edit + if (shouldUseTransaction() && !transactionActive.value) { + enterTransaction(); + } } else { const rowChanges = dirtyRows.value.get(item.sourceIndex); rowChanges?.delete(col); @@ -790,6 +828,9 @@ function onEditKeydown(e: KeyboardEvent) { function addRow() { rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value); newRows.value.push(props.result.columns.map(() => null)); + if (shouldUseTransaction() && !transactionActive.value) { + enterTransaction(); + } const rowId = -newRows.value.length; nextTick(() => { const el = getScrollerElement(); @@ -808,6 +849,9 @@ function applyDeleteRow(rowId: number) { deletedRows.value.add(item.sourceIndex); } if (editingCell.value?.rowId === rowId) editingCell.value = null; + if (shouldUseTransaction() && !transactionActive.value) { + enterTransaction(); + } } const showDeleteRowConfirm = ref(false); @@ -858,12 +902,22 @@ async function saveChanges() { const stmts = generateSaveStatements(); if (stmts.length === 0) return; saveError.value = ""; + isSaving.value = true; - if (props.connectionId && props.database) { + if (shouldUseTransaction() && props.connectionId && props.database) { + try { + await api.executeInTransaction(props.connectionId, props.database, stmts, props.tableMeta?.schema); + } catch (e: any) { + saveError.value = String(e.message || e); + isSaving.value = false; + return; + } + } else if (props.connectionId && props.database) { try { await api.executeBatch(props.connectionId, props.database, stmts); } catch (e: any) { saveError.value = String(e.message || e); + isSaving.value = false; return; } } else if (props.onExecuteSql) { @@ -873,12 +927,15 @@ async function saveChanges() { } } catch (e: any) { saveError.value = String(e.message || e); + isSaving.value = false; return; } } dirtyRows.value.clear(); newRows.value = []; deletedRows.value.clear(); + exitTransaction(); + isSaving.value = false; emit("reload"); } @@ -887,6 +944,7 @@ function discardChanges() { newRows.value = []; deletedRows.value.clear(); editingCell.value = null; + exitTransaction(); } // --- Cell selection and detail --- @@ -1009,6 +1067,7 @@ watch( detailCell.value = null; showTranspose.value = false; transposeRowIndex.value = null; + exitTransaction(); }, ); @@ -1211,6 +1270,55 @@ function escapeAndHighlightKeywords(s: string): string {
+ +
+ + + {{ tableMeta.tableName }} + + + + {{ t("grid.transactionActive") }} + + + + + +
@@ -1673,7 +1781,7 @@ function escapeAndHighlightKeywords(s: string): string { {{ result.execution_time_ms }}ms {{ selectionSummary }} -