Merge pull request #139 from rarnu/feat/edit-on-resultset

add: 增加对于查询得到的结果集直接进行数据编辑的功能
This commit is contained in:
skyler 2026-05-07 17:44:48 +08:00 committed by GitHub
commit cf25b7d6d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 641 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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()))?))
}

View File

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

View File

@ -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')"

View File

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

View File

@ -218,6 +218,10 @@ export default {
rowsPerPageShort: " 行/页",
columnDetails: "列详情",
queryError: "查询出错",
refresh: "刷新",
commit: "提交",
rollback: "回滚",
transactionActive: "编辑中",
},
welcome: {
title: "数据库工作台",

View File

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

View File

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

154
src/lib/sqlAnalysis.ts Normal file
View File

@ -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()));
}

View File

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

View File

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

View File

@ -172,4 +172,10 @@ export interface QueryTab {
columns: ColumnInfo[];
primaryKeys: string[];
};
queryAnalysis?: {
schema?: string;
tableName: string;
selectStar: boolean;
columns: string[];
};
}