Merge pull request #139 from rarnu/feat/edit-on-resultset
add: 增加对于查询得到的结果集直接进行数据编辑的功能
This commit is contained in:
commit
cf25b7d6d4
|
|
@ -368,6 +368,230 @@ pub async fn execute_statements(
|
|||
})
|
||||
}
|
||||
|
||||
/// Execute multiple SQL statements within a single transaction.
|
||||
/// For sqlx-based pools (Postgres/MySQL/SQLite), uses the Transaction API to
|
||||
/// guarantee all statements run on the same physical connection.
|
||||
/// For custom drivers (ClickHouse/SqlServer/Dameng/Gaussdb), uses explicit
|
||||
/// BEGIN/COMMIT/ROLLBACK on the already-single-connection client.
|
||||
/// For databases that don't support explicit transactions (Redis, MongoDB, Oracle),
|
||||
/// executes statements sequentially without transaction.
|
||||
/// If BEGIN fails, returns an error — no silent fallback to auto-commit.
|
||||
pub async fn execute_statements_in_transaction(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
statements: &[String],
|
||||
schema: Option<&str>,
|
||||
) -> Result<db::QueryResult, String> {
|
||||
let pool_key = if database.is_empty() {
|
||||
connection_id.to_string()
|
||||
} else {
|
||||
state.get_or_create_pool(connection_id, Some(database)).await?
|
||||
};
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// Clone the pool handle within the lock, then drop it before any async work.
|
||||
let path = {
|
||||
let conns = state.connections.lock().await;
|
||||
conns.get(&pool_key).map(|p| match p {
|
||||
PoolKind::Postgres(pg) => TxPath::Pg(pg.clone()),
|
||||
PoolKind::Mysql(mp, bare) => TxPath::Mysql(mp.clone(), *bare),
|
||||
PoolKind::Sqlite(sq) => TxPath::Sqlite(sq.clone()),
|
||||
PoolKind::ClickHouse(_) | PoolKind::SqlServer(_) | PoolKind::Dameng(_) | PoolKind::Gaussdb(_) => {
|
||||
TxPath::Explicit
|
||||
}
|
||||
PoolKind::DuckDb(_)
|
||||
| PoolKind::Redis(_)
|
||||
| PoolKind::MongoDb(_)
|
||||
| PoolKind::Oracle(_)
|
||||
| PoolKind::Elasticsearch(_) => TxPath::None,
|
||||
})
|
||||
};
|
||||
|
||||
match path {
|
||||
Some(TxPath::Pg(pool)) => exec_tx_pg_inner(pool, statements, schema, start).await,
|
||||
Some(TxPath::Mysql(pool, _bare)) => exec_tx_mysql_inner(pool, statements, start).await,
|
||||
Some(TxPath::Sqlite(pool)) => exec_tx_sqlite_inner(pool, statements, start).await,
|
||||
Some(TxPath::Explicit) => exec_tx_explicit_inner(state, &pool_key, statements, schema, start).await,
|
||||
Some(TxPath::None) => exec_tx_none_inner(state, &pool_key, statements, schema, start).await,
|
||||
None => Err("Connection not found for transaction".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Owned pool variants for safe dispatch across async boundaries.
|
||||
enum TxPath {
|
||||
Pg(sqlx::postgres::PgPool),
|
||||
Mysql(sqlx::mysql::MySqlPool, bool),
|
||||
Sqlite(sqlx::sqlite::SqlitePool),
|
||||
Explicit,
|
||||
None,
|
||||
}
|
||||
|
||||
// Each of these acquires a dedicated connection and runs all statements within
|
||||
// BEGIN ... COMMIT/ROLLBACK, guaranteeing a single physical connection.
|
||||
// This avoids sqlx::Transaction<T> which has Send/lifetime incompatibility with Tauri macro.
|
||||
|
||||
async fn exec_tx_pg_inner(
|
||||
pool: sqlx::postgres::PgPool,
|
||||
statements: &[String],
|
||||
schema: Option<&str>,
|
||||
start: std::time::Instant,
|
||||
) -> Result<db::QueryResult, String> {
|
||||
let mut conn = pool.acquire().await.map_err(|e| format!("Failed to acquire connection: {}", e))?;
|
||||
// Set schema first
|
||||
if let Some(s) = schema {
|
||||
let sp = format!("SET search_path TO \"{}\", public", s);
|
||||
sqlx::query(&sp).execute(&mut *conn).await.map_err(|e| format!("SET search_path failed: {}", e))?;
|
||||
}
|
||||
sqlx::query("BEGIN").execute(&mut *conn).await.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
||||
let mut total_affected: u64 = 0;
|
||||
for (i, sql) in statements.iter().enumerate() {
|
||||
match sqlx::query(sql).execute(&mut *conn).await {
|
||||
Ok(r) => total_affected += r.rows_affected(),
|
||||
Err(e) => {
|
||||
let _ = sqlx::query("ROLLBACK").execute(&mut *conn).await;
|
||||
return Err(format!("Statement {} failed: {}", i + 1, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
sqlx::query("COMMIT").execute(&mut *conn).await.map_err(|e| format!("COMMIT failed: {}", e))?;
|
||||
Ok(db::QueryResult {
|
||||
columns: vec![],
|
||||
rows: vec![],
|
||||
affected_rows: total_affected,
|
||||
execution_time_ms: start.elapsed().as_millis(),
|
||||
truncated: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn exec_tx_mysql_inner(
|
||||
pool: sqlx::mysql::MySqlPool,
|
||||
statements: &[String],
|
||||
start: std::time::Instant,
|
||||
) -> Result<db::QueryResult, String> {
|
||||
let mut conn = pool.acquire().await.map_err(|e| format!("Failed to acquire connection: {}", e))?;
|
||||
sqlx::query("START TRANSACTION")
|
||||
.execute(&mut *conn)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
||||
let mut total_affected: u64 = 0;
|
||||
for (i, sql) in statements.iter().enumerate() {
|
||||
match sqlx::query(sql).execute(&mut *conn).await {
|
||||
Ok(r) => total_affected += r.rows_affected(),
|
||||
Err(e) => {
|
||||
let _ = sqlx::query("ROLLBACK").execute(&mut *conn).await;
|
||||
return Err(format!("Statement {} failed: {}", i + 1, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
sqlx::query("COMMIT").execute(&mut *conn).await.map_err(|e| format!("COMMIT failed: {}", e))?;
|
||||
Ok(db::QueryResult {
|
||||
columns: vec![],
|
||||
rows: vec![],
|
||||
affected_rows: total_affected,
|
||||
execution_time_ms: start.elapsed().as_millis(),
|
||||
truncated: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn exec_tx_sqlite_inner(
|
||||
pool: sqlx::sqlite::SqlitePool,
|
||||
statements: &[String],
|
||||
start: std::time::Instant,
|
||||
) -> Result<db::QueryResult, String> {
|
||||
let mut conn = pool.acquire().await.map_err(|e| format!("Failed to acquire connection: {}", e))?;
|
||||
sqlx::query("BEGIN").execute(&mut *conn).await.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
||||
let mut total_affected: u64 = 0;
|
||||
for (i, sql) in statements.iter().enumerate() {
|
||||
match sqlx::query(sql).execute(&mut *conn).await {
|
||||
Ok(r) => total_affected += r.rows_affected(),
|
||||
Err(e) => {
|
||||
let _ = sqlx::query("ROLLBACK").execute(&mut *conn).await;
|
||||
return Err(format!("Statement {} failed: {}", i + 1, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
sqlx::query("COMMIT").execute(&mut *conn).await.map_err(|e| format!("COMMIT failed: {}", e))?;
|
||||
Ok(db::QueryResult {
|
||||
columns: vec![],
|
||||
rows: vec![],
|
||||
affected_rows: total_affected,
|
||||
execution_time_ms: start.elapsed().as_millis(),
|
||||
truncated: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn exec_tx_explicit_inner(
|
||||
state: &AppState,
|
||||
pool_key: &str,
|
||||
statements: &[String],
|
||||
schema: Option<&str>,
|
||||
start: std::time::Instant,
|
||||
) -> Result<db::QueryResult, String> {
|
||||
do_execute(state, pool_key, "BEGIN", schema, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
||||
|
||||
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) => {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
do_execute(state, pool_key, "COMMIT", schema, None).await.map_err(|e| format!("COMMIT failed: {}", e))?;
|
||||
|
||||
Ok(db::QueryResult {
|
||||
columns: vec![],
|
||||
rows: vec![],
|
||||
affected_rows: total_affected,
|
||||
execution_time_ms: start.elapsed().as_millis(),
|
||||
truncated: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn exec_tx_none_inner(
|
||||
state: &AppState,
|
||||
pool_key: &str,
|
||||
statements: &[String],
|
||||
schema: Option<&str>,
|
||||
start: std::time::Instant,
|
||||
) -> Result<db::QueryResult, String> {
|
||||
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
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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::*;
|
||||
|
|
|
|||
|
|
@ -74,3 +74,21 @@ pub async fn execute_script(
|
|||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn execute_in_transaction(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
statements: Vec<String>,
|
||||
schema: Option<String>,
|
||||
) -> Result<db::QueryResult, String> {
|
||||
dbx_core::query::execute_statements_in_transaction(
|
||||
&state,
|
||||
&connection_id,
|
||||
&database,
|
||||
&statements,
|
||||
schema.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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<Arc<WebState>>,
|
||||
Json(req): Json<ExecuteBatchRequest>,
|
||||
) -> Result<Json<serde_json::Value>, 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()))?))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,35 @@ 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;
|
||||
}
|
||||
|
||||
const useTransaction = computed(() => 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 +792,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 (useTransaction.value && !transactionActive.value) {
|
||||
enterTransaction();
|
||||
}
|
||||
} else {
|
||||
const rowChanges = dirtyRows.value.get(item.sourceIndex);
|
||||
rowChanges?.delete(col);
|
||||
|
|
@ -790,6 +826,9 @@ function onEditKeydown(e: KeyboardEvent) {
|
|||
function addRow() {
|
||||
rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value);
|
||||
newRows.value.push(props.result.columns.map(() => null));
|
||||
if (useTransaction.value && !transactionActive.value) {
|
||||
enterTransaction();
|
||||
}
|
||||
const rowId = -newRows.value.length;
|
||||
nextTick(() => {
|
||||
const el = getScrollerElement();
|
||||
|
|
@ -808,6 +847,9 @@ function applyDeleteRow(rowId: number) {
|
|||
deletedRows.value.add(item.sourceIndex);
|
||||
}
|
||||
if (editingCell.value?.rowId === rowId) editingCell.value = null;
|
||||
if (useTransaction.value && !transactionActive.value) {
|
||||
enterTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
const showDeleteRowConfirm = ref(false);
|
||||
|
|
@ -858,12 +900,22 @@ async function saveChanges() {
|
|||
const stmts = generateSaveStatements();
|
||||
if (stmts.length === 0) return;
|
||||
saveError.value = "";
|
||||
isSaving.value = true;
|
||||
|
||||
if (props.connectionId && props.database) {
|
||||
if (useTransaction.value && 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 +925,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 +942,7 @@ function discardChanges() {
|
|||
newRows.value = [];
|
||||
deletedRows.value.clear();
|
||||
editingCell.value = null;
|
||||
exitTransaction();
|
||||
}
|
||||
|
||||
// --- Cell selection and detail ---
|
||||
|
|
@ -1009,6 +1065,7 @@ watch(
|
|||
detailCell.value = null;
|
||||
showTranspose.value = false;
|
||||
transposeRowIndex.value = null;
|
||||
exitTransaction();
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -1211,6 +1268,55 @@ function escapeAndHighlightKeywords(s: string): string {
|
|||
<ContextMenu>
|
||||
<ContextMenuTrigger as-child>
|
||||
<div v-if="hasData" class="flex-1 flex flex-col overflow-hidden">
|
||||
<!-- Transaction toolbar -->
|
||||
<div
|
||||
v-if="useTransaction"
|
||||
class="flex items-center gap-1.5 px-2 py-1 border-b shrink-0"
|
||||
:class="transactionActive ? 'bg-emerald-500/5 border-emerald-500/20' : 'bg-muted/20'"
|
||||
>
|
||||
<span
|
||||
v-if="tableMeta"
|
||||
class="inline-flex items-center gap-1 text-xs font-medium"
|
||||
:class="transactionActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground'"
|
||||
>
|
||||
<Table2 class="w-3 h-3" />
|
||||
{{ tableMeta.tableName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="transactionActive"
|
||||
class="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
{{ t("grid.transactionActive") }}
|
||||
</span>
|
||||
<span class="flex-1" />
|
||||
<Button variant="ghost" size="sm" class="h-5 text-xs px-1.5" :disabled="isSaving" @click="onToolbarRefresh">
|
||||
<Loader2 v-if="loading" class="w-3 h-3 mr-1 animate-spin" />
|
||||
<RefreshCcw v-else class="w-3 h-3 mr-1" />
|
||||
{{ t("grid.refresh") }}
|
||||
</Button>
|
||||
<Button
|
||||
:variant="transactionActive ? 'default' : 'secondary'"
|
||||
size="sm"
|
||||
class="h-5 text-xs px-1.5"
|
||||
:disabled="!transactionActive || isSaving"
|
||||
@click="onToolbarCommit"
|
||||
>
|
||||
<Loader2 v-if="isSaving" class="w-3 h-3 mr-1 animate-spin" />
|
||||
<Save v-else class="w-3 h-3 mr-1" />
|
||||
{{ t("grid.commit") }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-5 text-xs px-1.5"
|
||||
:disabled="!transactionActive"
|
||||
@click="onToolbarRollback"
|
||||
>
|
||||
<RotateCcw class="w-3 h-3 mr-1" />
|
||||
{{ t("grid.rollback") }}
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Search bar -->
|
||||
<div class="flex items-center gap-1 px-2 py-1 border-b shrink-0 bg-muted/20 relative">
|
||||
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
|
|
@ -1673,7 +1779,7 @@ function escapeAndHighlightKeywords(s: string): string {
|
|||
<span>{{ result.execution_time_ms }}ms</span>
|
||||
<span v-if="hasCellSelection" class="text-foreground">{{ selectionSummary }}</span>
|
||||
|
||||
<template v-if="editable && tableMeta">
|
||||
<template v-if="editable && tableMeta && !transactionActive">
|
||||
<span v-if="hasPendingChanges" class="ml-2 text-foreground">
|
||||
{{ t("grid.pendingChanges", { count: pendingChangeCount }) }}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -267,6 +267,21 @@ function onHandleCloseColumnPanel() {
|
|||
:result="activeTab.result"
|
||||
:sql="activeTab.lastExecutedSql || activeTab.sql"
|
||||
:loading="activeTab.isExecuting"
|
||||
:editable="!!activeTab.queryAnalysis"
|
||||
:database-type="activeConnection?.db_type"
|
||||
:connection-id="activeTab.connectionId"
|
||||
:database="activeTab.database"
|
||||
:table-meta="activeTab.tableMeta"
|
||||
:on-execute-sql="async (sql: string) => emit('executeSql', sql)"
|
||||
@reload="emit('reload')"
|
||||
@paginate="
|
||||
(offset: number, limit: number, whereInput?: string, orderBy?: string) =>
|
||||
emit('paginate', offset, limit, whereInput, orderBy)
|
||||
"
|
||||
@sort="
|
||||
(column: string, direction: 'asc' | 'desc' | null, whereInput?: string) =>
|
||||
emit('sort', column, direction, whereInput)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-if="activeTab.result?.columns.includes('Error')"
|
||||
|
|
|
|||
|
|
@ -219,6 +219,10 @@ export default {
|
|||
rowsPerPageShort: " rows",
|
||||
columnDetails: "Column Details",
|
||||
queryError: "Query Error",
|
||||
refresh: "Refresh",
|
||||
commit: "Commit",
|
||||
rollback: "Rollback",
|
||||
transactionActive: "Editing",
|
||||
},
|
||||
welcome: {
|
||||
title: "Database Workspace",
|
||||
|
|
|
|||
|
|
@ -218,6 +218,10 @@ export default {
|
|||
rowsPerPageShort: " 行/页",
|
||||
columnDetails: "列详情",
|
||||
queryError: "查询出错",
|
||||
refresh: "刷新",
|
||||
commit: "提交",
|
||||
rollback: "回滚",
|
||||
transactionActive: "编辑中",
|
||||
},
|
||||
welcome: {
|
||||
title: "数据库工作台",
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export const executeQuery = forward("executeQuery");
|
|||
export const executeMulti = forward("executeMulti");
|
||||
export const executeBatch = forward("executeBatch");
|
||||
export const executeScript = forward("executeScript");
|
||||
export const executeInTransaction = forward("executeInTransaction");
|
||||
export const cancelQuery = forward("cancelQuery");
|
||||
|
||||
// AI
|
||||
|
|
|
|||
|
|
@ -191,6 +191,15 @@ export async function executeScript(
|
|||
return post("/api/query/execute-script", { connectionId, database, sql, schema });
|
||||
}
|
||||
|
||||
export async function executeInTransaction(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
statements: string[],
|
||||
schema?: string,
|
||||
): Promise<QueryResult> {
|
||||
return post("/api/query/execute-in-transaction", { connectionId, database, statements, schema });
|
||||
}
|
||||
|
||||
export async function cancelQuery(executionId: string): Promise<boolean> {
|
||||
return post("/api/query/cancel", { executionId });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
// Binary column types that should not be edited inline
|
||||
export const BINARY_TYPES = new Set([
|
||||
"blob",
|
||||
"clob",
|
||||
"bytea",
|
||||
"varbinary",
|
||||
"binary",
|
||||
"image",
|
||||
"longblob",
|
||||
"mediumblob",
|
||||
"tinyblob",
|
||||
"blob sub_type 2004",
|
||||
"blob sub_type 2005",
|
||||
]);
|
||||
|
||||
export function isBinaryType(dataType: string): boolean {
|
||||
const lower = dataType.toLowerCase();
|
||||
return BINARY_TYPES.has(lower);
|
||||
}
|
||||
|
||||
export interface EditableQueryInfo {
|
||||
schema: string | undefined;
|
||||
tableName: string;
|
||||
selectStar: boolean;
|
||||
columns: string[]; // empty array if SELECT *
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a SELECT statement to determine if it's editable.
|
||||
* Only simple single-table SELECT queries are considered editable:
|
||||
* - No JOIN, GROUP BY, HAVING, UNION, subqueries, CTEs, DISTINCT, aggregations
|
||||
* - Must have a single FROM clause with one table
|
||||
* - WHERE, ORDER BY, LIMIT are allowed
|
||||
*/
|
||||
export function analyzeEditableQuery(sql: string): EditableQueryInfo | null {
|
||||
const trimmed = sql.trim();
|
||||
|
||||
// Strip trailing semicolons and whitespace
|
||||
const cleaned = trimmed.replace(/;+\s*$/, "").trim();
|
||||
|
||||
// Must start with SELECT (case-insensitive)
|
||||
if (!/^SELECT\b/i.test(cleaned)) return null;
|
||||
|
||||
// Reject CTEs (WITH clause before SELECT)
|
||||
if (/^\s*WITH\b/i.test(trimmed)) return null;
|
||||
|
||||
// Remove inline comments and block comments
|
||||
const normalized = cleaned.replace(/--.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
|
||||
// Reject UNION, INTERSECT, EXCEPT
|
||||
if (/\b(UNION|INTERSECT|EXCEPT)\b/i.test(normalized)) return null;
|
||||
|
||||
// --- Extract SELECT body (columns) ---
|
||||
// Match everything between SELECT and the first SQL clause keyword (FROM, WHERE, ORDER, etc.)
|
||||
const selectMatch = normalized.match(
|
||||
/^SELECT\s+(.+?)(?:\bFROM\b|\bWHERE\b|\bORDER\b|\bLIMIT\b|\bGROUP\b|\bHAVING\b|$)/is,
|
||||
);
|
||||
if (!selectMatch) return null;
|
||||
const selectBody = selectMatch[1].trim();
|
||||
|
||||
// Reject DISTINCT
|
||||
if (/^\s*DISTINCT\b/i.test(selectBody)) return null;
|
||||
|
||||
// Check if SELECT *
|
||||
const selectStar = /^\s*\*\s*$/.test(selectBody);
|
||||
const columns: string[] = [];
|
||||
if (!selectStar) {
|
||||
columns.push(...parseSelectColumns(selectBody));
|
||||
}
|
||||
|
||||
// --- Extract FROM body ---
|
||||
const fromMatch = normalized.match(/\bFROM\s+(.+?)(?:\bWHERE\b|\bORDER\b|\bLIMIT\b|\bGROUP\b|\bHAVING\b|$)/is);
|
||||
if (!fromMatch) return null;
|
||||
const fromBody = fromMatch[1].trim();
|
||||
|
||||
// Reject JOIN
|
||||
if (/\bJOIN\b/i.test(fromBody)) return null;
|
||||
|
||||
// Reject GROUP BY, HAVING (already partially covered but double-check)
|
||||
if (/\b(GROUP\s+BY|HAVING)\b/i.test(fromBody)) return null;
|
||||
|
||||
// Reject subqueries: if FROM body contains SELECT keyword
|
||||
if (/\bSELECT\b/i.test(fromBody)) return null;
|
||||
|
||||
// Also reject if there's a SELECT anywhere after FROM (subquery in WHERE etc.)
|
||||
const fromIdx = normalized.toUpperCase().indexOf("FROM");
|
||||
if (fromIdx >= 0) {
|
||||
const afterFirstFrom = normalized.slice(fromIdx);
|
||||
// Count SELECT occurrences after FROM — should be 0 (the original SELECT is before FROM)
|
||||
const selectCount = (afterFirstFrom.match(/\bSELECT\b/g) || []).length;
|
||||
if (selectCount > 0) return null;
|
||||
}
|
||||
|
||||
// --- Extract table name from FROM clause ---
|
||||
// Strip all quoting characters (backticks, double quotes, square brackets)
|
||||
const stripped = fromBody.replace(/[`"[\]]/g, "").trim();
|
||||
|
||||
// Match: table OR schema.table
|
||||
const tableMatch = stripped.match(/^(\w+)(?:\.(\w+))?/);
|
||||
if (!tableMatch) return null;
|
||||
|
||||
const schema = tableMatch[2] ? tableMatch[1] : undefined;
|
||||
const tableName = tableMatch[2] || tableMatch[1];
|
||||
|
||||
return {
|
||||
schema,
|
||||
tableName,
|
||||
selectStar,
|
||||
columns,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSelectColumns(body: string): string[] {
|
||||
const cols: string[] = [];
|
||||
let depth = 0;
|
||||
let current = "";
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if (ch === "(") depth++;
|
||||
else if (ch === ")") depth--;
|
||||
else if (ch === "," && depth === 0) {
|
||||
cols.push(extractColumnName(current.trim()));
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
if (current.trim()) {
|
||||
cols.push(extractColumnName(current.trim()));
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
function extractColumnName(col: string): string {
|
||||
// Handle AS alias
|
||||
const asMatch = col.match(/\bAS\s+(\w+)/i);
|
||||
if (asMatch) return asMatch[1];
|
||||
|
||||
// Strip quoting characters
|
||||
const stripped = col.replace(/[`"'[\]]/g, "");
|
||||
|
||||
// Take last identifier (handles table.column -> column)
|
||||
const parts = stripped.split(".");
|
||||
return parts[parts.length - 1] || stripped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all primary key columns are present in the result set columns.
|
||||
* Comparison is case-insensitive.
|
||||
*/
|
||||
export function allPrimaryKeysPresent(primaryKeys: string[], resultColumns: string[]): boolean {
|
||||
const colSet = new Set(resultColumns.map((c) => c.toLowerCase()));
|
||||
return primaryKeys.every((pk) => colSet.has(pk.toLowerCase()));
|
||||
}
|
||||
|
|
@ -176,6 +176,15 @@ export async function executeScript(
|
|||
return invoke("execute_script", { connectionId, database, sql, schema });
|
||||
}
|
||||
|
||||
export async function executeInTransaction(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
statements: string[],
|
||||
schema?: string,
|
||||
): Promise<QueryResult> {
|
||||
return invoke("execute_in_transaction", { connectionId, database, statements, schema });
|
||||
}
|
||||
|
||||
export async function listIndexes(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import { orderPinnedFirst } from "@/lib/pinnedItems";
|
|||
import { canCancelQueryExecution } from "@/lib/queryExecutionState";
|
||||
import { closeAllTabsState, closeOtherTabsState } from "@/lib/tabCloseActions";
|
||||
import { buildExplainSql, parseExplainResult } from "@/lib/explainPlan";
|
||||
import { analyzeEditableQuery, allPrimaryKeysPresent } from "@/lib/sqlAnalysis";
|
||||
import * as api from "@/lib/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
|
||||
interface SavedTab {
|
||||
|
|
@ -227,6 +229,71 @@ export const useQueryStore = defineStore("query", () => {
|
|||
await executeTabSql(activeTabId.value, sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze if the query result is editable (single-table SELECT with primary keys).
|
||||
* If editable, fetches table metadata and sets queryAnalysis + tableMeta on the tab.
|
||||
*/
|
||||
async function analyzeQueryEditability(tab: QueryTab, sql: string) {
|
||||
if (tab.mode !== "query") return;
|
||||
if (!tab.result || !tab.result.columns.length) {
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.tableMeta = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const analysis = analyzeEditableQuery(sql);
|
||||
if (!analysis) {
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.tableMeta = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tab.connectionId || !tab.database) {
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.tableMeta = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve schema per database type
|
||||
const connStore = useConnectionStore();
|
||||
const conn = connStore.getConfig(tab.connectionId);
|
||||
const dbType = conn?.db_type || "";
|
||||
let schema = analysis.schema || tab.schema;
|
||||
if (!schema) {
|
||||
if (dbType === "postgres") schema = "public";
|
||||
else schema = "";
|
||||
}
|
||||
|
||||
try {
|
||||
const columns = await api.getColumns(tab.connectionId, tab.database, schema, analysis.tableName);
|
||||
const primaryKeys = columns.filter((c) => c.is_primary_key).map((c) => c.name);
|
||||
|
||||
if (primaryKeys.length === 0) {
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.tableMeta = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allPrimaryKeysPresent(primaryKeys, tab.result.columns)) {
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.tableMeta = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
tab.tableMeta = {
|
||||
schema: schema || undefined,
|
||||
tableName: analysis.tableName,
|
||||
columns,
|
||||
primaryKeys,
|
||||
};
|
||||
tab.queryAnalysis = analysis;
|
||||
} catch (err) {
|
||||
console.error("[DBX] ERROR fetching columns for editable query:", err);
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.tableMeta = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function executeTabSql(id: string, sql: string) {
|
||||
const tab = tabs.value.find((t) => t.id === id);
|
||||
if (!tab || !sql.trim()) return;
|
||||
|
|
@ -249,6 +316,8 @@ export const useQueryStore = defineStore("query", () => {
|
|||
current.activeResultIndex = undefined;
|
||||
current.result = results[0];
|
||||
}
|
||||
// Analyze editability after successful execution
|
||||
await analyzeQueryEditability(current, sql);
|
||||
}
|
||||
} catch (e: any) {
|
||||
const current = tabs.value.find((t) => t.id === id);
|
||||
|
|
@ -256,6 +325,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
current.result = toErrorResult(e);
|
||||
current.results = undefined;
|
||||
current.activeResultIndex = undefined;
|
||||
current.queryAnalysis = undefined;
|
||||
}
|
||||
} finally {
|
||||
const current = tabs.value.find((t) => t.id === id);
|
||||
|
|
|
|||
|
|
@ -172,4 +172,10 @@ export interface QueryTab {
|
|||
columns: ColumnInfo[];
|
||||
primaryKeys: string[];
|
||||
};
|
||||
queryAnalysis?: {
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
selectStar: boolean;
|
||||
columns: string[];
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue