fix(table): refresh data tabs after clearing tables
This commit is contained in:
parent
085d7661af
commit
3747f08baa
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
export async function runBatchTableTruncate<T>(targets: readonly T[], execute: (target: T) => Promise<boolean | void>, refreshSucceeded: (targets: readonly T[]) => Promise<void>): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, string>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string | undefined>;
|
||||
catalog?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function tabClientSessionId(tab: Pick<QueryTab, "id">, 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<number> {
|
||||
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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue