From 3747f08baaa7dc71a68b4dc5e66ba2aab5f61fef Mon Sep 17 00:00:00 2001 From: zipg Date: Sat, 11 Jul 2026 17:48:51 +0800 Subject: [PATCH] fix(table): refresh data tabs after clearing tables --- .../src/components/objects/ObjectBrowser.vue | 37 +++++- .../src/components/sidebar/TreeItem.vue | 52 +++++++- .../table/batchTableTruncate.spec.ts | 33 +++++ .../src/lib/table/batchTableTruncate.ts | 11 ++ .../queryStore.tableDataRefresh.spec.ts | 124 ++++++++++++++++++ apps/desktop/src/stores/queryStore.ts | 65 ++++++++- 6 files changed, 309 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/table/batchTableTruncate.spec.ts create mode 100644 apps/desktop/src/lib/table/batchTableTruncate.ts create mode 100644 apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 00def092c..83167454a 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -96,6 +96,7 @@ import { } from "@/lib/table/objectBrowserRows"; import { resolveRowClickAction, shouldDeferSingleClick, type ObjectBrowserRowAction } from "@/lib/table/objectBrowserRowAction"; import { createSidePanelRequestGuard } from "@/lib/table/sidePanelRequestGuard"; +import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate"; type ObjectFilter = "all" | "tables" | "views" | "materializedViews" | "procedures" | "functions" | "sequences" | "packages"; type ObjectBrowserColumnKey = "select" | "name" | "type" | "estimatedRows" | "totalBytes" | "created_at" | "updated_at" | "comment"; @@ -1440,15 +1441,41 @@ function requestBatchTruncateTables() { showBatchTruncateConfirm.value = true; } +function tableDataRefreshTargetForRow(row: ObjectBrowserRow) { + return { + connectionId: props.connection.id, + database: props.database, + schema: row.schema || selectedSchema.value, + schemaCandidates: [row.schema, selectedSchema.value], + catalog: props.catalog, + name: row.name, + }; +} + +async function refreshMutatedTableDataTabsForRows(rows: readonly ObjectBrowserRow[]) { + for (const row of rows) { + const target = tableDataRefreshTargetForRow(row); + try { + await queryStore.refreshDataTabsForTable(target); + } catch (error) { + console.warn("[DBX][table-data-refresh-after-mutation:error]", { target, error }); + } + } +} + async function confirmBatchTruncateTables() { const targets = [...selectedTableRows.value]; if (targets.length === 0) return; try { const useCascade = canBatchTruncateCascade.value && batchTruncateCascade.value; - for (const row of targets) { - const sql = await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: useCascade })); - await api.executeQuery(props.connection.id, props.database, sql); - } + await runBatchTableTruncate( + targets, + async (row) => { + const sql = await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: useCascade })); + await api.executeQuery(props.connection.id, props.database, sql); + }, + refreshMutatedTableDataTabsForRows, + ); toast(t("objects.batchTruncateSuccess", { count: targets.length })); clearTableSelection(); showBatchTruncateConfirm.value = false; @@ -1833,6 +1860,7 @@ async function confirmTruncateTable() { const sql = truncatePreviewSql.value || (await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: canTruncateTargetCascade.value && truncateTableCascade.value }))); await api.executeQuery(props.connection.id, props.database, sql); toast(t("contextMenu.truncateTableSuccess", { name: row.name })); + await refreshMutatedTableDataTabsForRows([row]); } catch (e: any) { toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); } @@ -1857,6 +1885,7 @@ async function confirmEmptyTable() { const sql = emptyPreviewSql.value || (await buildEmptyTableSql(tableAdminSqlOptions(row))); await api.executeQuery(props.connection.id, props.database, sql); toast(t("contextMenu.emptyTableSuccess", { name: row.name })); + await refreshMutatedTableDataTabsForRows([row]); } catch (e: any) { toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); } diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 4d6debe2a..b016800b3 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -147,6 +147,7 @@ import { supportsDatabaseUserAdmin } from "@/lib/database/databaseUserAdmin"; import { canCloseSidebarDatabaseConnection, isSidebarDatabaseOpened } from "@/lib/sidebar/sidebarDatabaseOpenState"; import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext"; import { batchTableEmptyFeedback, runBatchTableEmpty } from "@/lib/sidebar/batchTableEmpty"; +import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue"; import InstallExtensionDialog from "@/components/objects/InstallExtensionDialog.vue"; @@ -2061,6 +2062,36 @@ function closeDroppedTableObjectTabsForNode(node: TreeNode) { }); } +function tableDataRefreshTargetForNode(node: TreeNode) { + if (!node.connectionId || !node.database) return null; + const config = connectionStore.getConfig(node.connectionId); + const dataTabSchema = connectionObjectTreeNodeSchema(config, node.database, node.schema); + return { + connectionId: node.connectionId, + database: node.database, + schema: dataTabSchema, + schemaCandidates: [node.schema, dataTabSchema], + catalog: node.catalog, + name: node.label, + }; +} + +async function refreshMutatedTableDataTabsForNode(node: TreeNode) { + const target = tableDataRefreshTargetForNode(node); + if (!target) return; + try { + await queryStore.refreshDataTabsForTable(target); + } catch (error) { + console.warn("[DBX][table-data-refresh-after-mutation:error]", { target, error }); + } +} + +async function refreshMutatedTableDataTabsForNodes(nodes: readonly TreeNode[]) { + for (const target of nodes) { + await refreshMutatedTableDataTabsForNode(target); + } +} + function selectedBatchDropTargets(): TreeNode[] { const selected = selectedTreeNodesInVisibleOrder(); if (selected.length <= 1 || !selected.some((node) => node.id === props.node.id)) return []; @@ -2469,13 +2500,17 @@ async function confirmBatchTruncate() { if (!targets.length) return; try { const useCascade = canBatchTruncateCascade.value && batchTruncateCascade.value; - for (const target of targets) { - if (!target.connectionId || !target.database) continue; - await connectionStore.ensureConnected(target.connectionId); - const sql = await truncateSqlForTreeNode(target, { cascade: useCascade }); - if (!sql) continue; - await api.executeQuery(target.connectionId, target.database, sql, target.schema); - } + await runBatchTableTruncate( + targets, + async (target) => { + if (!target.connectionId || !target.database) return false; + await connectionStore.ensureConnected(target.connectionId); + const sql = await truncateSqlForTreeNode(target, { cascade: useCascade }); + if (!sql) return false; + await api.executeQuery(target.connectionId, target.database, sql, target.schema); + }, + refreshMutatedTableDataTabsForNodes, + ); toast(t("contextMenu.batchTruncateSuccess", { count: targets.length }), 3000); showBatchTruncateConfirm.value = false; } catch (e: any) { @@ -2507,6 +2542,7 @@ async function confirmBatchEmpty() { } else { toast(t("contextMenu.batchEmptyPartialFail", { success: result.succeeded.length, failed: result.failed.length }), 5000); } + await refreshMutatedTableDataTabsForNodes(result.succeeded); batchEmptyTargets.value = []; showBatchEmptyConfirm.value = false; } @@ -2706,6 +2742,7 @@ async function confirmEmptyTable() { await api.executeQuery(node.connectionId, node.database, sql, node.schema); const messageKey = currentDatabaseType() === "clickhouse" ? "contextMenu.emptyTableSubmitted" : "contextMenu.emptyTableSuccess"; toast(t(messageKey, { name: node.label }), 3000); + await refreshMutatedTableDataTabsForNode(node); } catch (e: any) { toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); } @@ -2725,6 +2762,7 @@ async function confirmTruncateTable() { const sql = truncateTablePreviewSql.value || (await buildTruncateTableSql(truncateTableSqlOptions())); await api.executeQuery(node.connectionId, node.database, sql, node.schema); toast(t("contextMenu.truncateTableSuccess", { name: node.label }), 3000); + await refreshMutatedTableDataTabsForNode(node); } catch (e: any) { toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); } diff --git a/apps/desktop/src/lib/__tests__/table/batchTableTruncate.spec.ts b/apps/desktop/src/lib/__tests__/table/batchTableTruncate.spec.ts new file mode 100644 index 000000000..49256d630 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/table/batchTableTruncate.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from "vitest"; +import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate"; + +describe("batch table truncate", () => { + it("refreshes completed targets before propagating a later failure", async () => { + const execute = vi.fn(async (table: string) => { + if (table === "locked") throw new Error("permission denied"); + }); + const refreshSucceeded = vi.fn(async () => undefined); + + await expect(runBatchTableTruncate(["orders", "locked", "customers"], execute, refreshSucceeded)).rejects.toThrow("permission denied"); + + expect(execute).toHaveBeenCalledTimes(2); + expect(refreshSucceeded).toHaveBeenCalledOnce(); + expect(refreshSucceeded).toHaveBeenCalledWith(["orders"]); + }); + + it("does not refresh when the first target fails", async () => { + const refreshSucceeded = vi.fn(async () => undefined); + + await expect( + runBatchTableTruncate( + ["locked"], + async () => { + throw new Error("permission denied"); + }, + refreshSucceeded, + ), + ).rejects.toThrow("permission denied"); + + expect(refreshSucceeded).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/lib/table/batchTableTruncate.ts b/apps/desktop/src/lib/table/batchTableTruncate.ts new file mode 100644 index 000000000..fae28664e --- /dev/null +++ b/apps/desktop/src/lib/table/batchTableTruncate.ts @@ -0,0 +1,11 @@ +export async function runBatchTableTruncate(targets: readonly T[], execute: (target: T) => Promise, refreshSucceeded: (targets: readonly T[]) => Promise): Promise { + const succeeded: T[] = []; + try { + for (const target of targets) { + if ((await execute(target)) !== false) succeeded.push(target); + } + } finally { + // A later failure must not leave tabs for already truncated tables showing stale rows. + if (succeeded.length > 0) await refreshSucceeded(succeeded); + } +} diff --git a/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts new file mode 100644 index 000000000..4e7c07dfb --- /dev/null +++ b/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts @@ -0,0 +1,124 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + buildTableSelectSql: vi.fn(), + closeClientConnectionSession: vi.fn(), + closeQuerySession: vi.fn(), + executeMulti: vi.fn(), + getConnectionConfig: vi.fn(), + saveOpenTabsState: vi.fn(), +})); + +vi.mock("@/lib/backend/api", () => ({ + buildTableSelectSql: mocks.buildTableSelectSql, + closeClientConnectionSession: mocks.closeClientConnectionSession, + closeQuerySession: mocks.closeQuerySession, + executeMulti: mocks.executeMulti, + saveOpenTabsState: mocks.saveOpenTabsState, +})); + +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ + ensureConnected: vi.fn().mockResolvedValue(undefined), + getConfig: mocks.getConnectionConfig, + recordConnectionLostError: vi.fn(), + }), +})); + +vi.mock("@/stores/settingsStore", () => ({ + useSettingsStore: () => ({ + editorSettings: { pageSize: 100 }, + }), +})); + +function installLocalStorage() { + const data = new Map(); + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => data.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => data.set(key, value)), + removeItem: vi.fn((key: string) => data.delete(key)), + }); +} + +describe("queryStore table data refresh", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + installLocalStorage(); + setActivePinia(createPinia()); + mocks.getConnectionConfig.mockReturnValue({ + id: "pg-1", + name: "Postgres", + db_type: "postgres", + database: "app", + query_timeout_secs: 30, + }); + mocks.buildTableSelectSql.mockResolvedValue("SELECT id, status FROM public.users WHERE status = 'ACTIVE' ORDER BY created_at DESC LIMIT 25 OFFSET 50"); + mocks.executeMulti.mockResolvedValue([ + { + columns: ["id", "status"], + rows: [], + affected_rows: 0, + execution_time_ms: 1, + }, + ]); + }); + + it("refreshes only matching data tabs after a table mutation", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + + const publicTabId = store.createTab("pg-1", "app", "users", "data", "public"); + store.setTableMeta(publicTabId, { + schema: "public", + tableName: "users", + tableType: "TABLE", + columns: [ + { name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "status", data_type: "text", is_nullable: true, column_default: null, is_primary_key: false, extra: null }, + ], + primaryKeys: ["id"], + }); + const publicTab = store.tabs.find((tab) => tab.id === publicTabId)!; + publicTab.whereInput = "status = 'ACTIVE'"; + publicTab.orderByInput = "created_at DESC"; + publicTab.resultPageLimit = 25; + publicTab.resultPageOffset = 50; + + const archiveTabId = store.createTab("pg-1", "app", "users", "data", "archive"); + store.setTableMeta(archiveTabId, { + schema: "archive", + tableName: "users", + tableType: "TABLE", + columns: [{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + primaryKeys: ["id"], + }); + + const refreshed = await store.refreshDataTabsForTable({ + connectionId: "pg-1", + database: "app", + schema: "public", + name: "users", + }); + + expect(refreshed).toBe(1); + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith({ + databaseType: "postgres", + schema: "public", + tableName: "users", + tableType: "TABLE", + catalog: undefined, + columns: ["id", "status"], + primaryKeys: ["id"], + includeRowId: false, + whereInput: "status = 'ACTIVE'", + orderBy: "created_at DESC", + limit: 25, + offset: 50, + }); + expect(mocks.executeMulti).toHaveBeenCalledTimes(1); + expect(store.tabs.find((tab) => tab.id === publicTabId)?.result?.rows).toEqual([]); + expect(store.tabs.find((tab) => tab.id === archiveTabId)?.result).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index b4eaef8f4..ef4cb0b69 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -28,13 +28,13 @@ import { redisCommandResultToQueryResult } from "@/lib/redis/redisQueryResult"; import { nextRedisCommandDb } from "@/lib/redis/redisCommandSession"; import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable"; import { usesAgentCursorForQuery } from "@/lib/database/databaseDriverManifest"; -import { canUseKeylessRowPredicate } from "@/lib/table/tableEditing"; +import { canUseKeylessRowPredicate, usesSyntheticRowIdKey } from "@/lib/table/tableEditing"; import { TABLE_DATA_EXPORT_PAGE_SIZE } from "@/lib/table/tableDataExport"; import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta"; import { dataTabExecutionDatabase } from "@/lib/table/dataTabExecutionDatabase"; import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit"; import { loadTableMetadata } from "@/lib/metadata/tableMetadataCache"; -import { quoteTableIdentifier } from "@/lib/table/tableSelectSql"; +import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/table/tableSelectSql"; import { connectionQueryExecutionSchema, effectiveDatabaseTypeForConnection, metadataSchemaForConnection } from "@/lib/database/jdbcDialect"; import { frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout"; import { queryResultSourceLabel } from "@/lib/sql/queryResultSource"; @@ -83,6 +83,15 @@ interface DroppedTableObjectTarget { objectType?: DroppedTableObjectType; } +interface TableDataRefreshTarget { + connectionId: string; + database: string; + schema?: string; + schemaCandidates?: Array; + catalog?: string; + name: string; +} + function tabClientSessionId(tab: Pick, suffix?: (typeof BACKGROUND_CLIENT_SESSION_SUFFIXES)[number]): string { return suffix ? `${tab.id}:${suffix}` : tab.id; } @@ -1528,12 +1537,63 @@ export const useQueryStore = defineStore("query", () => { return false; } + function tabMatchesTableDataRefreshTarget(tab: QueryTab, target: TableDataRefreshTarget): boolean { + if (tab.mode !== "data" || tab.connectionId !== target.connectionId || tab.database !== target.database) return false; + const tableMeta = tableMetaForDataTab(tab); + if (!tableMeta || tableMeta.tableName !== target.name) return false; + if ((tableMeta.catalog || "") !== (target.catalog || "")) return false; + const targetSchemas = droppedTableObjectSchemaCandidates(target); + return targetSchemas.has(normalizeOptionalSchema(tableMeta.schema ?? tab.schema)); + } + function closeDroppedTableObjectTabs(target: DroppedTableObjectTarget) { // A dropped table-like object makes existing data/structure tabs stale; close // them immediately instead of letting the next refresh fail against a missing object. closeTabsWhere((tab) => tabMatchesDroppedTableObject(tab, target)); } + async function refreshDataTabsForTable(target: TableDataRefreshTarget): Promise { + const matchingTabs = tabs.value.filter((tab) => tabMatchesTableDataRefreshTarget(tab, target)); + if (matchingTabs.length === 0) return 0; + + const settingsStore = useSettingsStore(); + let refreshed = 0; + + for (const tab of matchingTabs) { + const tableMeta = tableMetaForDataTab(tab); + if (!tableMeta?.tableName) continue; + const conn = useConnectionStore().getConfig(tab.connectionId); + const effectiveDbType = effectiveDatabaseTypeForConnection(conn); + const primaryKeys = tab.tableMeta ? tab.tableMeta.primaryKeys : tableMeta.primaryKeys; + const sortOrder = tab.resultSortColumn && tab.resultSortDirection ? `${quoteTableIdentifier(effectiveDbType, tab.resultSortColumn)} ${tab.resultSortDirection.toUpperCase()}` : undefined; + const orderBy = tab.orderByInput?.trim() || sortOrder; + const limit = tab.resultPageLimit ?? settingsStore.editorSettings.pageSize ?? tableOpenPageLimit(); + const offset = tab.resultPageOffset ?? 0; + const sql = await buildTableSelectSql({ + databaseType: effectiveDbType, + schema: tableMeta.schema, + tableName: tableMeta.tableName, + tableType: tableMeta.tableType, + catalog: tableMeta.catalog, + columns: tableMeta.columns.map((column) => column.name), + primaryKeys, + includeRowId: usesSyntheticRowIdKey(effectiveDbType, primaryKeys, tableMeta.tableType), + whereInput: tab.whereInput, + orderBy, + limit, + offset, + }); + updateSql(tab.id, sql); + await executeTabSql(tab.id, sql, { + pagination: { limit, offset }, + preserveResultDuringExecution: true, + }); + refreshed += 1; + } + + return refreshed; + } + function releaseTabsWhere(predicate: (tab: QueryTab) => boolean) { closeTabsWhere((tab) => predicate(tab) && tab.mode !== "query"); tabs.value @@ -3501,6 +3561,7 @@ export const useQueryStore = defineStore("query", () => { closeConnectionTabs, closeDatabaseTabs, closeDroppedTableObjectTabs, + refreshDataTabsForTable, releaseConnectionTabs, releaseDatabaseTabs, isDatabaseOpen,