Files
dbx/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts
T

485 lines
18 KiB
TypeScript
Vendored
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useSidebarDataOpenRuntime } from "@/composables/useSidebarDataOpenRuntime";
import type { DataTabReuseMode } from "@/lib/tabs/dataTabReuseMode";
import type { QueryTab, TreeNode } from "@/types/database";
const mocks = vi.hoisted(() => ({
databaseType: "oceanbase" as string,
callOrder: [] as string[],
tabs: [] as QueryTab[],
activeTabId: null as string | null,
cachedMetadata: undefined as unknown,
dataTabReuseMode: "same-table" as DataTabReuseMode,
ensureConnected: vi.fn(),
executeTabSql: vi.fn(),
loadTableMetadata: vi.fn(),
buildTableSelectSql: vi.fn(),
setErrorResult: vi.fn(),
cancelTabExecution: vi.fn(),
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
getConfig: () => ({ id: "connection-1", db_type: mocks.databaseType }),
ensureConnected: mocks.ensureConnected,
connectionIdentifierQuote: () => undefined,
}),
}));
vi.mock("@/stores/queryStore", () => ({
useQueryStore: () => ({
tabs: mocks.tabs,
get activeTabId() {
return mocks.activeTabId;
},
createTab: (connectionId: string, database: string, title: string, mode: QueryTab["mode"], schema?: string, _initialSql?: string, catalog?: string, options: { forceNew?: boolean } = {}) => {
if (!options.forceNew) {
const existing = mocks.tabs.find((tab) => tab.connectionId === connectionId && tab.database === database && tab.title === title && tab.mode === mode && (tab.schema || "") === (schema || "") && (tab.catalog || "") === (catalog || ""));
if (existing) {
mocks.activeTabId = existing.id;
return existing.id;
}
}
const tab = {
id: `tab-${mocks.tabs.length + 1}`,
connectionId,
database,
title,
mode,
schema,
catalog,
sql: "",
isDirty: false,
isExecuting: false,
isCancelling: false,
isExplaining: false,
} as QueryTab;
mocks.tabs.push(tab);
mocks.activeTabId = tab.id;
return tab.id;
},
switchTab: vi.fn((id: string) => {
mocks.activeTabId = id;
}),
cancelTabExecution: mocks.cancelTabExecution,
setExecutingWithId: (id: string, executionId: string) => {
const tab = mocks.tabs.find((item) => item.id === id);
if (tab) {
tab.isExecuting = true;
tab.executionId = executionId;
}
},
setTableMeta: (id: string, tableMeta: NonNullable<QueryTab["tableMeta"]>) => {
const tab = mocks.tabs.find((item) => item.id === id);
if (tab) {
tab.tableMeta = tableMeta;
// 与真实 store 一致:仅真实元数据(columns 非空)落地才结束行标识等待
if (tableMeta.columns.length > 0) tab.tableMetaPending = false;
}
},
updateSql: (id: string, sql: string) => {
const tab = mocks.tabs.find((item) => item.id === id);
if (tab) tab.sql = sql;
},
executeTabSql: mocks.executeTabSql,
setErrorResult: mocks.setErrorResult,
}),
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({ editorSettings: { dataTabReuseMode: mocks.dataTabReuseMode, pageSize: 100 } }),
}));
vi.mock("@/lib/database/jdbcDialect", () => ({
effectiveDatabaseTypeForConnection: () => mocks.databaseType,
connectionObjectTreeNodeSchema: (_config: unknown, _database: string, schema?: string) => schema,
connectionObjectTreeQuerySchema: (_config: unknown, database: string, schema?: string) => schema ?? database,
}));
vi.mock("@/lib/metadata/tableMetadataCache", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/metadata/tableMetadataCache")>();
return {
...actual,
getCachedTableMetadata: () => mocks.cachedMetadata,
loadTableMetadata: mocks.loadTableMetadata,
};
});
vi.mock("@/lib/common/utils", () => ({ uuid: () => "open-data-id" }));
vi.mock("@/lib/backend/debugLog", () => ({ appendDebugLog: vi.fn(), isDebugLoggingEnabled: () => false }));
// dataTabOpenPolicy 使用真实实现,覆盖设置开关对应的复用范围
vi.mock("@/lib/sidebar/treeNodeContext", () => ({ hasTreeNodeDatabaseContext: () => true }));
vi.mock("@/lib/table/tableSelectSql", () => ({ buildTableSelectSql: mocks.buildTableSelectSql }));
vi.mock("@/lib/table/tableEditing", () => ({ usesSyntheticRowIdKey: () => false }));
vi.mock("@/lib/table/tableOpenPageLimit", () => ({ tableOpenPageLimit: () => 100 }));
vi.mock("@/lib/tabs/dataTabActivation", () => ({ canActivateExistingDataTableTab: () => false }));
const tableNode: TreeNode = {
id: "table-users",
label: "users",
type: "table",
connectionId: "connection-1",
database: "app",
schema: "public",
tableType: "TABLE",
};
const mysqlTableNode: TreeNode = {
...tableNode,
id: "table-zcyy-write-off-record",
label: "zcyy_write_off_record",
database: "yf_db",
schema: undefined,
};
describe("useSidebarDataOpenRuntime", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.databaseType = "oceanbase";
mocks.callOrder.length = 0;
mocks.tabs.length = 0;
mocks.activeTabId = null;
mocks.cachedMetadata = undefined;
mocks.dataTabReuseMode = "same-table";
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.buildTableSelectSql.mockResolvedValue("SELECT * FROM users");
mocks.executeTabSql.mockImplementation(async () => {
mocks.callOrder.push("query");
});
mocks.loadTableMetadata.mockImplementation(async () => {
mocks.callOrder.push("metadata");
return {
metadata: {
schema: "public",
tableName: "users",
tableType: "TABLE",
database: "app",
columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
indexes: [],
primaryKeys: ["id"],
cachedAt: Date.now(),
},
cacheStatus: "miss",
ageMs: 0,
};
});
});
it("creates a new sidebar tab for the same table in always-new mode", async () => {
mocks.dataTabReuseMode = "always-new";
await useSidebarDataOpenRuntime().openData(tableNode);
await useSidebarDataOpenRuntime().openData(tableNode);
expect(mocks.tabs).toHaveLength(2);
});
it("reuses a sidebar tab for the same table in same-table mode", async () => {
await useSidebarDataOpenRuntime().openData(tableNode);
await useSidebarDataOpenRuntime().openData(tableNode);
expect(mocks.tabs).toHaveLength(1);
});
it("keeps different sidebar tables independent in same-table mode", async () => {
const ordersNode = { ...tableNode, id: "table-orders", label: "orders" };
await useSidebarDataOpenRuntime().openData(tableNode);
await useSidebarDataOpenRuntime().openData(ordersNode);
expect(mocks.tabs).toHaveLength(2);
expect(mocks.tabs.map((tab) => tab.title)).toEqual(["users", "orders"]);
});
it("reuses the active safe data tab for a different table in active-tab mode", async () => {
mocks.dataTabReuseMode = "active-tab";
const ordersNode = { ...tableNode, id: "table-orders", label: "orders" };
mocks.loadTableMetadata.mockImplementation(async (request: { database: string; schema?: string; tableName: string; tableType?: string }) => ({
metadata: {
schema: request.schema,
tableName: request.tableName,
tableType: request.tableType ?? "TABLE",
database: request.database,
columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
indexes: [],
primaryKeys: ["id"],
cachedAt: Date.now(),
},
cacheStatus: "miss",
ageMs: 0,
}));
await useSidebarDataOpenRuntime().openData(tableNode);
mocks.tabs[0]!.isExecuting = false;
mocks.tabs[0]!.executionId = undefined;
mocks.tabs[0]!.resultLocalSortOriginalLargeValueCells = [{ row_index: 0, column_index: 1, original_bytes: 1_000_000 }];
await useSidebarDataOpenRuntime().openData(ordersNode);
expect(mocks.tabs).toHaveLength(1);
expect(mocks.tabs[0]?.title).toBe("orders");
expect(mocks.tabs[0]?.tableMeta?.tableName).toBe("orders");
expect(mocks.tabs[0]?.resultLocalSortOriginalLargeValueCells).toBeUndefined();
});
it.each([
["pinned", { pinned: true }],
["executing", { isExecuting: true, executionId: "running" }],
["pending edits", { pendingDataChangeCount: 1 }],
["pending editor draft", { hasPendingDataEditorDraft: true }],
])("opens a new tab instead of replacing an active %s data tab", async (_label, patch) => {
mocks.dataTabReuseMode = "active-tab";
const activeTab = {
id: "active-users",
connectionId: "connection-1",
database: "app",
title: "users",
mode: "data",
schema: "public",
sql: "SELECT * FROM users",
isExecuting: false,
isCancelling: false,
isExplaining: false,
tableMeta: { schema: "public", tableName: "users", tableType: "TABLE", columns: [], primaryKeys: [] },
...patch,
} as QueryTab;
mocks.tabs.push(activeTab);
mocks.activeTabId = activeTab.id;
await useSidebarDataOpenRuntime().openData({ ...tableNode, id: "table-orders", label: "orders" });
expect(mocks.tabs).toHaveLength(2);
expect(mocks.tabs[0]?.title).toBe("users");
expect(mocks.tabs[1]?.title).toBe("orders");
expect(mocks.cancelTabExecution).not.toHaveBeenCalledWith(activeTab.id);
});
it("creates a new HBase tab for the same table in always-new mode", async () => {
mocks.databaseType = "hbase";
mocks.dataTabReuseMode = "always-new";
await useSidebarDataOpenRuntime().openData(tableNode);
await useSidebarDataOpenRuntime().openData(tableNode);
expect(mocks.tabs).toHaveLength(2);
});
it("keeps different HBase tables independent in same-table mode", async () => {
mocks.databaseType = "hbase";
await useSidebarDataOpenRuntime().openData(tableNode);
await useSidebarDataOpenRuntime().openData({ ...tableNode, id: "table-orders", label: "orders" });
expect(mocks.tabs).toHaveLength(2);
});
it("starts cold-cache OceanBase metadata before the table query", async () => {
await useSidebarDataOpenRuntime().openData(tableNode);
await vi.waitFor(() => {
expect(mocks.callOrder).toEqual(["metadata", "query"]);
expect(mocks.tabs[0]?.tableMeta?.primaryKeys).toEqual(["id"]);
});
});
it("keeps Dameng metadata deferred until after the table query", async () => {
mocks.databaseType = "dameng";
await useSidebarDataOpenRuntime().openData(tableNode);
await vi.waitFor(() => {
expect(mocks.callOrder).toEqual(["query", "metadata"]);
expect(mocks.tabs[0]?.tableMeta?.primaryKeys).toEqual(["id"]);
});
});
it("keeps MySQL data-tab identity unqualified after metadata loads", async () => {
mocks.databaseType = "mysql";
mocks.loadTableMetadata.mockImplementation(async (request: { database: string; schema?: string; tableName: string; tableType?: string }) => ({
metadata: {
schema: request.schema,
tableName: request.tableName,
tableType: request.tableType,
database: request.database,
columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
indexes: [],
primaryKeys: ["id"],
cachedAt: Date.now(),
},
cacheStatus: "miss",
ageMs: 0,
}));
await useSidebarDataOpenRuntime().openData(mysqlTableNode);
await vi.waitFor(() => expect(mocks.tabs[0]?.tableMeta?.primaryKeys).toEqual(["id"]));
expect(mocks.loadTableMetadata).toHaveBeenCalledWith(expect.objectContaining({ database: "yf_db", schema: "yf_db" }));
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ database: "yf_db", schema: undefined, tableName: "zcyy_write_off_record" }));
expect(mocks.tabs[0]?.tableMeta).toMatchObject({ database: "yf_db", schema: undefined, tableName: "zcyy_write_off_record" });
});
it("keeps cached MySQL table metadata unqualified", async () => {
mocks.databaseType = "mysql";
mocks.cachedMetadata = {
metadata: {
schema: "yf_db",
tableName: "zcyy_write_off_record",
tableType: "TABLE",
database: "yf_db",
columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
indexes: [],
primaryKeys: ["id"],
cachedAt: Date.now(),
},
cacheStatus: "hit",
ageMs: 0,
};
await useSidebarDataOpenRuntime().openData(mysqlTableNode);
expect(mocks.loadTableMetadata).not.toHaveBeenCalled();
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ database: "yf_db", schema: undefined, tableName: "zcyy_write_off_record" }));
expect(mocks.tabs[0]?.tableMeta).toMatchObject({ database: "yf_db", schema: undefined, tableName: "zcyy_write_off_record" });
});
it("keeps row identity pending while delayed metadata is in flight and the query finishes first", async () => {
// 元数据延迟:查询先返回,元数据仍挂起
let releaseMetadata: () => void = () => {};
const gate = new Promise<void>((resolve) => {
releaseMetadata = resolve;
});
mocks.loadTableMetadata.mockImplementation(async () => {
mocks.callOrder.push("metadata-start");
await gate;
mocks.callOrder.push("metadata-done");
return {
metadata: {
schema: "public",
tableName: "users",
tableType: "TABLE",
database: "app",
columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
indexes: [],
primaryKeys: ["id"],
cachedAt: Date.now(),
},
cacheStatus: "miss",
ageMs: 0,
};
});
await useSidebarDataOpenRuntime().openData(tableNode);
// 查询已完成、元数据尚未返回:行标识必须仍处于等待状态,
// primaryKeys 为空不得被当作"确认无主键"(#3727 整行 WHERE 保存路径)
expect(mocks.callOrder).toEqual(["metadata-start", "query"]);
expect(mocks.tabs[0]?.tableMeta?.primaryKeys).toEqual([]);
expect(mocks.tabs[0]?.tableMetaPending).toBe(true);
releaseMetadata();
await vi.waitFor(() => {
expect(mocks.tabs[0]?.tableMeta?.primaryKeys).toEqual(["id"]);
expect(mocks.tabs[0]?.tableMetaPending).toBe(false);
});
});
it("keeps the tab read-only when the metadata load fails", async () => {
mocks.loadTableMetadata.mockRejectedValue(new Error("metadata failed"));
await useSidebarDataOpenRuntime().openData(tableNode);
await vi.waitFor(() => {
expect(mocks.loadTableMetadata).toHaveBeenCalled();
});
// 行标识仍然未知:保持只读兜底,不回退到整行 WHERE 写入;
// 刷新或重开表会重试元数据加载并恢复可编辑
expect(mocks.tabs[0]?.tableMetaPending).toBe(true);
expect(mocks.tabs[0]?.tableMeta?.primaryKeys).toEqual([]);
});
it("keeps the superseded tab read-only when metadata never starts", async () => {
// openData 在 ensureConnected 阶段被新请求取代:元数据请求不会启动
let releaseConnect: () => void = () => {};
mocks.ensureConnected.mockImplementation(
() =>
new Promise<void>((resolve) => {
releaseConnect = resolve;
}),
);
let current = true;
const request = {
isCurrent: () => current,
signal: new AbortController().signal,
registerCancel: () => {},
};
const open = useSidebarDataOpenRuntime().openData(tableNode, request);
await vi.waitFor(() => {
expect(mocks.ensureConnected).toHaveBeenCalled();
});
current = false;
releaseConnect();
await open;
// 行标识从未确认:保持只读兜底;重开该表或刷新会重新加载元数据恢复
expect(mocks.loadTableMetadata).not.toHaveBeenCalled();
expect(mocks.executeTabSql).not.toHaveBeenCalled();
expect(mocks.tabs[0]?.tableMetaPending).toBe(true);
});
it("activates an already-running same-table tab without cancelling it", async () => {
mocks.tabs.push({
id: "existing-tab",
connectionId: "connection-1",
database: "app",
title: "users",
mode: "data",
schema: "public",
sql: "",
isDirty: false,
isExecuting: true,
executionId: "stale-execution",
isCancelling: false,
isExplaining: false,
tableMeta: {
schema: "public",
tableName: "users",
tableType: "TABLE",
columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
primaryKeys: ["id"],
},
tableMetaUpdatedAt: Date.now(),
} as QueryTab);
mocks.activeTabId = null;
await useSidebarDataOpenRuntime().openData(tableNode);
expect(mocks.tabs).toHaveLength(1);
expect(mocks.activeTabId).toBe("existing-tab");
expect(mocks.cancelTabExecution).not.toHaveBeenCalled();
expect(mocks.executeTabSql).not.toHaveBeenCalled();
expect(mocks.loadTableMetadata).not.toHaveBeenCalled();
});
it("does not mark row identity pending on a warm metadata cache", async () => {
mocks.cachedMetadata = {
metadata: {
schema: "public",
tableName: "users",
tableType: "TABLE",
database: "app",
columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
indexes: [],
primaryKeys: ["id"],
cachedAt: Date.now(),
},
cacheStatus: "hit",
ageMs: 0,
};
await useSidebarDataOpenRuntime().openData(tableNode);
expect(mocks.tabs[0]?.tableMeta?.primaryKeys).toEqual(["id"]);
expect(mocks.tabs[0]?.tableMetaPending).toBeFalsy();
expect(mocks.loadTableMetadata).not.toHaveBeenCalled();
});
});