fix(navigation): create independent table target tabs

This commit is contained in:
zipg authored and GitHub committed 2026-07-27 20:02:39 +08:00
1 parent 6b0f20ad5c
commit c4b21cf666
4 files changed
+209 -62

No files matched your search

@@ -5,7 +5,6 @@ import type { QueryTab } from "@/types/database";
const mocks = vi.hoisted(() => ({
tabs: [] as QueryTab[],
reuseDataTab: true,
activeTabId: "",
databaseType: "postgres" as string,
ensureConnected: vi.fn(),
@@ -78,7 +77,7 @@ vi.mock("@/stores/queryStore", () => ({
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({ editorSettings: { reuseDataTab: mocks.reuseDataTab } }),
useSettingsStore: () => ({ editorSettings: { reuseDataTab: true } }),
}));
vi.mock("@/lib/table/tableSelectSql", () => ({
@@ -100,7 +99,6 @@ describe("useNavigationTargets openTableTarget", () => {
clearTableMetadataCache();
vi.clearAllMocks();
mocks.tabs.length = 0;
mocks.reuseDataTab = true;
mocks.databaseType = "postgres";
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.executeTabSql.mockResolvedValue(undefined);
@@ -138,8 +136,6 @@ describe("useNavigationTargets openTableTarget", () => {
await navigation.openLineageTarget(target);
expect(mocks.getColumns).toHaveBeenCalledTimes(1);
const firstTab = mocks.tabs[0]!;
mocks.tabs.push({ ...firstTab, id: "tab-2", tableMeta: { ...firstTab.tableMeta! } });
mocks.getColumns.mockResolvedValueOnce([column("fresh_id")]);
await navigation.onStructureEditorSaved(vi.fn().mockResolvedValue(undefined), vi.fn(), {
@@ -172,8 +168,7 @@ describe("useNavigationTargets openTableTarget", () => {
expect(mocks.getColumns.mock.calls.some(([connectionId, database, schema, tableName]) => connectionId === "connection-1" && database === "analytics" && schema === "analytics" && tableName === "events")).toBe(true);
});
it("does not let a stale navigation land metadata over a newer target on a reused tab", async () => {
// A 的 getColumns 挂起;B 复用同一 tab 后 A 才返回
it("opens different targets in separate tabs even when sidebar data-tab reuse is enabled", async () => {
const columnGates = new Map<string, (columns: unknown[]) => void>();
mocks.getColumns.mockImplementation(
(_connectionId: string, _database: string, _schema: string, tableName: string) =>
@@ -182,25 +177,19 @@ describe("useNavigationTargets openTableTarget", () => {
}),
);
const navigation = useNavigationTargets(dialogs);
const openA = navigation.openLineageTarget({ connectionId: "connection-1", database: "app", schema: "public", tableName: "table_a" });
const openA = navigation.openTableTarget({ connectionId: "connection-1", database: "app", schema: "public", tableName: "table_a" });
await vi.waitFor(() => expect(columnGates.has("table_a")).toBe(true));
const openB = navigation.openLineageTarget({ connectionId: "connection-1", database: "app", schema: "public", tableName: "table_b" });
const openB = navigation.openTableTarget({ connectionId: "connection-1", database: "app", schema: "public", tableName: "table_b" });
await vi.waitFor(() => expect(columnGates.has("table_b")).toBe(true));
expect(mocks.tabs).toHaveLength(1);
expect(mocks.tabs).toHaveLength(2);
expect(mocks.tabs.map((tab) => tab.tableMeta?.tableName)).toEqual(["table_a", "table_b"]);
// 旧 A 晚返回:不得覆盖 B 的占位身份,不得解除 B 的行标识等待
columnGates.get("table_a")?.([column("a_id")]);
await openA;
expect(mocks.tabs[0]?.tableMeta?.tableName).toBe("table_b");
expect(mocks.tabs[0]?.tableMetaPending).toBe(true);
// B 返回后正常落地
columnGates.get("table_b")?.([column("b_id")]);
await openB;
expect(mocks.tabs[0]?.tableMeta?.tableName).toBe("table_b");
expect(mocks.tabs[0]?.tableMeta?.primaryKeys).toEqual(["b_id"]);
expect(mocks.tabs[0]?.tableMetaPending).toBe(false);
await Promise.all([openA, openB]);
expect(mocks.tabs.map((tab) => tab.tableMeta?.primaryKeys)).toEqual([["a_id"], ["b_id"]]);
expect(mocks.tabs.every((tab) => tab.tableMetaPending === false)).toBe(true);
});
it("stops landing when another entry point takes over the tab, even for the same table", async () => {
@@ -257,28 +246,4 @@ describe("useNavigationTargets openTableTarget", () => {
// 无取消请求:元数据落地后按既有行为执行第二次查询
expect(mocks.executeTabSql).toHaveBeenCalledTimes(2);
});
it("invalidates the previous execution id when a new navigation reuses the tab", async () => {
const columnGates = new Map<string, (columns: unknown[]) => void>();
mocks.getColumns.mockImplementation(
(_connectionId: string, _database: string, _schema: string, tableName: string) =>
new Promise((resolve) => {
columnGates.set(tableName, resolve);
}),
);
const navigation = useNavigationTargets(dialogs);
const openA = navigation.openLineageTarget({ connectionId: "connection-1", database: "app", schema: "public", tableName: "table_a" });
await vi.waitFor(() => expect(columnGates.has("table_a")).toBe(true));
const executionIdForA = mocks.tabs[0]?.executionId;
const openB = navigation.openLineageTarget({ connectionId: "connection-1", database: "app", schema: "public", tableName: "table_b" });
// B 开始即作废 A 的执行代次,A 在途查询结果无法再按旧 executionId 落地
expect(mocks.tabs[0]?.executionId).toBeDefined();
expect(mocks.tabs[0]?.executionId).not.toBe(executionIdForA);
columnGates.get("table_a")?.([column("a_id")]);
await vi.waitFor(() => expect(columnGates.has("table_b")).toBe(true));
columnGates.get("table_b")?.([column("b_id")]);
await Promise.all([openA, openB]);
});
});
@@ -0,0 +1,195 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { QueryResult } from "@/types/database";
const mocks = vi.hoisted(() => ({
connectionStore: {
activeConnectionId: "",
getConfig: vi.fn((connectionId: string) => ({ id: connectionId, db_type: "postgres" })),
ensureConnected: vi.fn(),
connectionIdentifierQuote: vi.fn(() => undefined),
refreshObjectListTreeNode: vi.fn(),
},
settingsStore: {
editorSettings: {
autoCalculateTotalRows: false,
continueOnErrorOnBatch: false,
openTabsRestoreMode: "all",
pageSize: 100,
reuseDataTab: true,
tableOpenPageSize: 100,
},
},
buildTableSelectSql: vi.fn(),
loadOpenTabsState: vi.fn(),
loadTableMetadata: vi.fn(),
saveOpenTabsState: vi.fn(),
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => mocks.connectionStore,
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => mocks.settingsStore,
}));
vi.mock("@/lib/backend/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/backend/api")>();
return {
...actual,
loadOpenTabsState: mocks.loadOpenTabsState,
saveOpenTabsState: mocks.saveOpenTabsState,
};
});
vi.mock("@/lib/metadata/tableMetadataCache", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/metadata/tableMetadataCache")>();
return {
...actual,
loadTableMetadata: mocks.loadTableMetadata,
};
});
vi.mock("@/lib/table/tableSelectSql", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/table/tableSelectSql")>();
return {
...actual,
buildTableSelectSql: mocks.buildTableSelectSql,
};
});
const dialogs = {
showFieldLineageDialog: { value: false },
showDatabaseSearchDialog: { value: false },
showDiagramDialog: { value: false },
};
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)),
});
}
async function setupNavigation() {
setActivePinia(createPinia());
const { useQueryStore } = await import("@/stores/queryStore");
const queryStore = useQueryStore();
vi.spyOn(queryStore, "executeTabSql").mockImplementation(async (tabId: string, sql: string) => {
const tab = queryStore.tabs.find((item) => item.id === tabId);
if (!tab) return;
const result: QueryResult = { columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 };
tab.result = result;
tab.isExecuting = false;
tab.executionId = undefined;
queryStore.updateSql(tabId, sql);
});
const { useNavigationTargets } = await import("@/composables/useNavigationTargets");
return { navigation: useNavigationTargets(dialogs), queryStore };
}
describe("useNavigationTargets with the real query store", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
vi.unstubAllGlobals();
installLocalStorage();
mocks.connectionStore.activeConnectionId = "";
mocks.ensureConnected?.mockResolvedValue?.(undefined);
mocks.connectionStore.ensureConnected.mockResolvedValue(undefined);
mocks.loadOpenTabsState.mockResolvedValue(null);
mocks.saveOpenTabsState.mockResolvedValue(undefined);
mocks.buildTableSelectSql.mockImplementation(async ({ tableName, whereInput }: { tableName: string; whereInput?: string }) => `SELECT * FROM ${tableName}${whereInput ? ` WHERE ${whereInput}` : ""}`);
mocks.loadTableMetadata.mockImplementation(async (request: { database: string; schema?: string; tableName: string; tableType?: string; catalog?: string }) => ({
metadata: {
database: request.database,
schema: request.schema,
catalog: request.catalog,
tableName: request.tableName,
tableType: request.tableType ?? "TABLE",
columns: [{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
indexes: [],
primaryKeys: ["id"],
cachedAt: Date.now(),
},
cacheStatus: "miss",
ageMs: 0,
}));
});
it("opens same-table search results with different predicates in separate tabs", async () => {
const { navigation, queryStore } = await setupNavigation();
const target = { connectionId: "connection-1", database: "app", schema: "public", tableName: "users" };
await navigation.openDatabaseSearchTarget({ ...target, whereInput: '"id" = 1' });
await navigation.openDatabaseSearchTarget({ ...target, whereInput: '"id" = 2' });
expect(queryStore.tabs).toHaveLength(2);
expect(queryStore.tabs.map((tab) => tab.sql)).toEqual(['SELECT * FROM users WHERE "id" = 1', 'SELECT * FROM users WHERE "id" = 2']);
});
it("creates a new target tab even when the same table was restored", async () => {
mocks.loadOpenTabsState.mockResolvedValue({
tabs: [
{
id: "restored-users",
title: "public.users",
connectionId: "connection-1",
database: "app",
schema: "public",
mode: "data",
sql: "SELECT * FROM users WHERE restored = true",
tableMeta: { schema: "public", tableName: "users", tableType: "TABLE", columns: [], primaryKeys: [] },
},
],
activeTabId: "restored-users",
});
const { navigation, queryStore } = await setupNavigation();
await queryStore.initOpenTabs();
expect(queryStore.createTab("connection-1", "app", "public.users", "data", "public")).toBe("restored-users");
await navigation.openLineageTarget({ connectionId: "connection-1", database: "app", schema: "public", tableName: "users" });
expect(queryStore.tabs).toHaveLength(2);
expect(queryStore.tabs[0]?.id).toBe("restored-users");
expect(queryStore.tabs[0]?.sql).toBe("SELECT * FROM users WHERE restored = true");
expect(queryStore.tabs[1]?.id).not.toBe("restored-users");
});
it("keeps concurrent same-table opens independent", async () => {
const connectionGates: Array<() => void> = [];
mocks.connectionStore.ensureConnected.mockImplementation(
() =>
new Promise<void>((resolve) => {
connectionGates.push(resolve);
}),
);
const { navigation, queryStore } = await setupNavigation();
const target = { connectionId: "connection-1", database: "app", schema: "public", tableName: "users" };
const first = navigation.openTableTarget({ ...target, whereInput: '"id" = 1' });
const second = navigation.openTableTarget({ ...target, whereInput: '"id" = 2' });
await vi.waitFor(() => expect(queryStore.tabs).toHaveLength(2));
connectionGates.splice(0).forEach((release) => release());
await Promise.all([first, second]);
expect(new Set(queryStore.tabs.map((tab) => tab.id))).toHaveLength(2);
expect(queryStore.tabs.map((tab) => tab.sql)).toEqual(['SELECT * FROM users WHERE "id" = 1', 'SELECT * FROM users WHERE "id" = 2']);
});
it("preserves default identity reuse and cross-scope isolation", async () => {
const { queryStore } = await setupNavigation();
const base = queryStore.createTab("connection-1", "app", "public.users", "data", "public");
expect(queryStore.createTab("connection-1", "app", "public.users", "data", "public")).toBe(base);
expect(queryStore.createTab("connection-2", "app", "public.users", "data", "public")).not.toBe(base);
expect(queryStore.createTab("connection-1", "analytics", "public.users", "data", "public")).not.toBe(base);
expect(queryStore.createTab("connection-1", "app", "archive.users", "data", "archive")).not.toBe(base);
expect(queryStore.createTab("connection-1", "app", "public.orders", "data", "public")).not.toBe(base);
expect(queryStore.createTab("connection-1", "app", "public.users", "data", "public", undefined, undefined, { forceNew: true })).not.toBe(base);
});
});
@@ -40,19 +40,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
queryStore.updateSql(tabId, target.tableName);
return;
}
const tabId = (() => {
if (settingsStore.editorSettings.reuseDataTab) {
const existing = queryStore.tabs.find((tab) => tab.mode === "data" && tab.connectionId === target.connectionId && tab.database === target.database && (tab.tableMeta?.catalog || "") === (target.catalog || ""));
if (existing) {
existing.title = tabTitle;
existing.schema = tableSchema;
existing.tableInfoTab = options.tableInfoTab;
queryStore.switchTab(existing.id);
return existing.id;
}
}
return queryStore.createTab(target.connectionId, target.database, tabTitle, "data", tableSchema);
})();
const tabId = queryStore.createTab(target.connectionId, target.database, tabTitle, "data", tableSchema, undefined, undefined, { forceNew: true });
const targetTab = queryStore.tabs.find((tab) => tab.id === tabId);
if (targetTab) targetTab.tableInfoTab = options.tableInfoTab;
// Stamp the new table identity synchronously so SQL rebuilds (refresh,
@@ -76,9 +64,8 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
// executionId,后续异步返回后不得再启动查询(同 refreshPreparationId 模式)
const preparationId = uuid();
queryStore.setExecutingWithId(tabId, preparationId);
// reuseDataTab 下并发导航会复用同一 tab:每次异步返回后必须校验(1)本次
// 导航仍是该 tab 的最新代次(区分同表不同 whereInput/连点两次,且侧边栏
// openData 接管同一 tab 时会作废本代次),(2)tab 未被其他流程改指别的
// 侧边栏 openData 可能接管同一 tab:每次异步返回后必须校验(1)本次
// 导航仍是该 tab 的最新代次,(2)tab 未被其他流程改指别的
// 目标,(3)准备期 executionId 未被停止/接管清除。否则旧请求晚返回会用
// A 的元数据覆盖新目标 B 的占位并解除 pending,造成结果属于 B、写入目标
// 却是 A 的错位
+2 -2
View File
@@ -1131,8 +1131,8 @@ export const useQueryStore = defineStore("query", () => {
return tabs.value.find((tab) => tab.connectionId === connectionId && tab.database === database && tab.title === title && tab.mode === mode && (tab.schema || "") === (schema || ""));
}
function createTab(connectionId: string, database: string, title?: string, mode: QueryTab["mode"] = "query", schema?: string, initialSql?: string, catalog?: string) {
if (title) {
function createTab(connectionId: string, database: string, title?: string, mode: QueryTab["mode"] = "query", schema?: string, initialSql?: string, catalog?: string, options: { forceNew?: boolean } = {}) {
if (title && !options.forceNew) {
const existing = findTabByIdentity(connectionId, database, title, mode, schema);
if (existing) {
switchTab(existing.id);