fix(navigation): create independent table target tabs
This commit is contained in:
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 的错位
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user