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
15 changed files with 641 additions and 2 deletions
+108 -2
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>
+15
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')"
+4
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",
+4
View File
@@ -218,6 +218,10 @@ export default {
rowsPerPageShort: " 行/页",
columnDetails: "列详情",
queryError: "查询出错",
refresh: "刷新",
commit: "提交",
rollback: "回滚",
transactionActive: "编辑中",
},
welcome: {
title: "数据库工作台",
+1
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
+9
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
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()));
}
+9
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,
+70
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);
+6
View File
@@ -172,4 +172,10 @@ export interface QueryTab {
columns: ColumnInfo[];
primaryKeys: string[];
};
queryAnalysis?: {
schema?: string;
tableName: string;
selectStar: boolean;
columns: string[];
};
}