add: 增加对于查询得到的结果集直接进行数据编辑的功能。当查询是单表且包含主键(有联合主键时包含全部主键)时,可以在结果集内双击单元格修改数据,修改结果以事务形式提交或回滚。

This commit is contained in:
rarnu 2026-05-07 15:09:59 +08:00
parent ecbd94c7be
commit 814fda7433
15 changed files with 627 additions and 2 deletions

View File

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

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,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 {
<ContextMenu>
<ContextMenuTrigger as-child>
<div v-if="hasData" class="flex-1 flex flex-col overflow-hidden">
<!-- Transaction toolbar -->
<div
v-if="shouldUseTransaction()"
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 +1781,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

@ -215,6 +215,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

@ -214,6 +214,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,7 +229,163 @@ 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) {
console.log("[DBX] === analyzeQueryEditability CALLED ===", { tabId: tab.id });
// Only analyze for query mode tabs with a valid result set
if (tab.mode !== "query") {
console.log("[DBX] SKIP: mode is not query, mode =", tab.mode);
return;
}
if (!tab.result || !tab.result.columns.length) {
console.log("[DBX] SKIP: no result or no columns", {
hasResult: !!tab.result,
colCount: tab.result?.columns.length,
});
tab.queryAnalysis = undefined;
tab.tableMeta = undefined;
return;
}
console.log("[DBX] analyzeQueryEditability:", {
sql: sql.substring(0, 100),
mode: tab.mode,
columns: tab.result.columns,
connectionId: tab.connectionId,
database: tab.database,
schema: tab.schema,
});
const analysis = analyzeEditableQuery(sql);
console.log("[DBX] analyzeEditableQuery result:", analysis);
if (!analysis) {
console.log("[DBX] SKIP: analyzeEditableQuery returned null");
tab.queryAnalysis = undefined;
tab.tableMeta = undefined;
return;
}
// Resolve schema: use explicit schema from analysis/tab, else fall back to db-type defaults
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 = "";
}
console.log("[DBX] Resolved schema:", schema, "dbType:", dbType);
if (analysis.selectStar && tab.connectionId && tab.database) {
try {
console.log("[DBX] Fetching columns:", {
connectionId: tab.connectionId,
database: tab.database,
schema,
tableName: analysis.tableName,
});
const columns = await api.getColumns(tab.connectionId, tab.database, schema, analysis.tableName);
console.log(
"[DBX] Columns fetched:",
columns.length,
"columns, PKs:",
columns.filter((c) => c.is_primary_key).map((c) => c.name),
);
const primaryKeys = columns.filter((c) => c.is_primary_key).map((c) => c.name);
if (primaryKeys.length === 0) {
console.log("[DBX] SKIP: no primary keys found for table", analysis.tableName);
tab.queryAnalysis = undefined;
tab.tableMeta = undefined;
return;
}
tab.tableMeta = {
schema: schema || undefined,
tableName: analysis.tableName,
columns,
primaryKeys,
};
// Verify all PK columns are present in result
if (!allPrimaryKeysPresent(primaryKeys, tab.result.columns)) {
console.log(
"[DBX] SKIP: not all PKs in result columns. PKs:",
primaryKeys,
"Result cols:",
tab.result.columns,
);
tab.queryAnalysis = undefined;
tab.tableMeta = undefined;
return;
}
console.log("[DBX] SET queryAnalysis + tableMeta successfully");
tab.queryAnalysis = analysis;
} catch (err) {
console.error("[DBX] ERROR fetching columns:", err);
tab.queryAnalysis = undefined;
tab.tableMeta = undefined;
}
} else {
// Non-SELECT *: need to fetch table metadata to get PK info
console.log("[DBX] Non-SELECT* path, fetching table metadata");
if (!tab.connectionId || !tab.database) {
console.log("[DBX] SKIP: no connectionId or database");
tab.queryAnalysis = undefined;
tab.tableMeta = undefined;
return;
}
try {
const columns = await api.getColumns(tab.connectionId, tab.database, schema, analysis.tableName);
console.log(
"[DBX] Columns fetched:",
columns.length,
"columns, PKs:",
columns.filter((c) => c.is_primary_key).map((c) => c.name),
);
const primaryKeys = columns.filter((c) => c.is_primary_key).map((c) => c.name);
if (primaryKeys.length === 0) {
console.log("[DBX] SKIP: no primary keys found for table", analysis.tableName);
tab.queryAnalysis = undefined;
tab.tableMeta = undefined;
return;
}
// Check all PKs are in the result columns
if (!allPrimaryKeysPresent(primaryKeys, tab.result.columns)) {
console.log(
"[DBX] SKIP: not all PKs in result columns. PKs:",
primaryKeys,
"Result cols:",
tab.result.columns,
);
tab.queryAnalysis = undefined;
tab.tableMeta = undefined;
return;
}
console.log("[DBX] SET queryAnalysis + tableMeta successfully (non-SELECT* path)");
tab.tableMeta = {
schema: schema || undefined,
tableName: analysis.tableName,
columns,
primaryKeys,
};
tab.queryAnalysis = analysis;
} catch (err) {
console.error("[DBX] ERROR fetching columns (non-SELECT* path):", err);
tab.queryAnalysis = undefined;
tab.tableMeta = undefined;
}
}
}
async function executeTabSql(id: string, sql: string) {
console.log("[DBX] === executeTabSql CALLED ===", { tabId: id, sql: sql.substring(0, 80) });
const tab = tabs.value.find((t) => t.id === id);
if (!tab || !sql.trim()) return;
@ -249,6 +407,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 +416,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[];
};
}