mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-22 15:30:41 +00:00
fix(sidebar): preserve primary keys on first table open
This commit is contained in:
@@ -1477,7 +1477,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
||||
:initial-order-by-input="activeTab.orderByInput"
|
||||
:sql="activeTab.sql"
|
||||
:loading="activeTab.isExecuting"
|
||||
:editable="isTableDataEditable(activeEffectiveDatabaseType, activeTableMeta?.primaryKeys ?? [], activeTableMeta?.tableType)"
|
||||
:editable="!activeTab.tableMetaPending && isTableDataEditable(activeEffectiveDatabaseType, activeTableMeta?.primaryKeys ?? [], activeTableMeta?.tableType)"
|
||||
context="table-data"
|
||||
:initial-where-input="activeTab.whereInput"
|
||||
:database-type="activeEffectiveDatabaseType"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed } from "vue";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDataGridActions } from "@/composables/useDataGridActions";
|
||||
import { clearTableMetadataCache } from "@/lib/metadata/tableMetadataCache";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -10,6 +11,11 @@ const mocks = vi.hoisted(() => ({
|
||||
getConfig: vi.fn(),
|
||||
setExecuting: vi.fn(),
|
||||
updateSql: vi.fn(),
|
||||
getColumns: vi.fn(),
|
||||
listIndexes: vi.fn(),
|
||||
ensureConnected: vi.fn(),
|
||||
tabs: [] as QueryTab[],
|
||||
setTableMeta: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
@@ -18,6 +24,8 @@ vi.mock("vue-i18n", () => ({
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
buildSortedQuerySql: mocks.buildSortedQuerySql,
|
||||
getColumns: mocks.getColumns,
|
||||
listIndexes: mocks.listIndexes,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/table/tableSelectSql", () => ({
|
||||
@@ -28,6 +36,7 @@ vi.mock("@/lib/table/tableSelectSql", () => ({
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
getConfig: mocks.getConfig,
|
||||
ensureConnected: mocks.ensureConnected,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -36,6 +45,16 @@ vi.mock("@/stores/queryStore", () => ({
|
||||
executeTabSql: mocks.executeTabSql,
|
||||
setExecuting: mocks.setExecuting,
|
||||
updateSql: mocks.updateSql,
|
||||
tabs: mocks.tabs,
|
||||
setTableMeta: mocks.setTableMeta.mockImplementation((id: string, meta: NonNullable<QueryTab["tableMeta"]>) => {
|
||||
const tab = mocks.tabs.find((item) => item.id === id);
|
||||
if (tab) {
|
||||
tab.tableMeta = meta;
|
||||
tab.tableMetaUpdatedAt = Date.now();
|
||||
// 与真实 store 一致:仅真实元数据(columns 非空)落地才结束行标识等待
|
||||
if (meta.columns.length > 0) tab.tableMetaPending = false;
|
||||
}
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -70,10 +89,15 @@ function tableDataTab(patch: Partial<QueryTab> = {}): QueryTab {
|
||||
|
||||
describe("useDataGridActions", () => {
|
||||
beforeEach(() => {
|
||||
clearTableMetadataCache();
|
||||
vi.clearAllMocks();
|
||||
mocks.tabs.length = 0;
|
||||
mocks.getConfig.mockReturnValue({ id: "postgres-1", db_type: "postgres" });
|
||||
mocks.buildTableSelectSql.mockResolvedValue("SELECT * FROM public.users LIMIT 100 OFFSET 0");
|
||||
mocks.buildSortedQuerySql.mockResolvedValue({ ok: true, sql: "SELECT sorted" });
|
||||
mocks.ensureConnected.mockResolvedValue(undefined);
|
||||
mocks.getColumns.mockResolvedValue([{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }]);
|
||||
mocks.listIndexes.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("uses the table-data default when toolbar reload has no saved pagination", async () => {
|
||||
@@ -137,6 +161,103 @@ describe("useDataGridActions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("marks row identity pending and refreshes metadata when real columns are missing, despite fallback result columns", async () => {
|
||||
// 恢复的占位身份:真实 tableMeta.columns 为空,但存在(失败)结果列。
|
||||
// tableMetaForDataTab 会用结果列合成 columns,不得据此跳过刷新
|
||||
const tab = tableDataTab({
|
||||
tableMeta: { schema: "public", tableName: "users", tableType: "TABLE", columns: [], primaryKeys: [] },
|
||||
tableMetaUpdatedAt: Date.now(),
|
||||
result: { columns: ["Error"], rows: [["boom"]], affected_rows: 0, execution_time_ms: 1 },
|
||||
});
|
||||
mocks.tabs.push(tab);
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
|
||||
// 合成的 ["Error"] 结果列不得进入 SQL 投影:真实列缺失时省略 columns(SELECT *)
|
||||
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: undefined }));
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.getColumns).toHaveBeenCalled();
|
||||
expect(mocks.setTableMeta).toHaveBeenCalledWith("tab-1", expect.objectContaining({ primaryKeys: ["id"] }));
|
||||
expect(tab.tableMetaPending).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses an in-flight metadata refresh when a later reload can apply the result", async () => {
|
||||
const tab = tableDataTab({
|
||||
tableMeta: { schema: "public", tableName: "users", tableType: "TABLE", columns: [], primaryKeys: [] },
|
||||
tableMetaUpdatedAt: Date.now(),
|
||||
});
|
||||
mocks.tabs.push(tab);
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
// 第一轮:stale-tab 早退(tabs 里找不到匹配项时 refreshDataTabTableMeta 直接返回)
|
||||
mocks.tabs.length = 0;
|
||||
await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
await vi.waitFor(() => expect(mocks.getColumns).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.setTableMeta).not.toHaveBeenCalled();
|
||||
expect(tab.tableMetaPending).toBe(true);
|
||||
|
||||
// 第二轮:真实 columns 仍为空,新的消费者加入同一在途请求;共享缓存应
|
||||
// 去重后端调用,但本轮仍要在目标恢复后落地结果
|
||||
mocks.tabs.push(tab);
|
||||
await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.getColumns).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.setTableMeta).toHaveBeenCalledWith("tab-1", expect.objectContaining({ primaryKeys: ["id"] }));
|
||||
});
|
||||
});
|
||||
|
||||
it("defers the Dameng metadata refresh until after the reload query", async () => {
|
||||
mocks.getConfig.mockReturnValue({ id: "dameng-1", db_type: "dameng" });
|
||||
const callOrder: string[] = [];
|
||||
const tab = tableDataTab({
|
||||
connectionId: "dameng-1",
|
||||
tableMeta: { schema: "public", tableName: "users", tableType: "TABLE", columns: [], primaryKeys: [] },
|
||||
tableMetaUpdatedAt: Date.now(),
|
||||
});
|
||||
mocks.executeTabSql.mockImplementationOnce(async () => {
|
||||
callOrder.push("query");
|
||||
// 查询执行期间:元数据尚未启动,行标识等待保持
|
||||
expect(tab.tableMetaPending).toBe(true);
|
||||
});
|
||||
mocks.getColumns.mockImplementationOnce(async () => {
|
||||
callOrder.push("metadata");
|
||||
return [{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }];
|
||||
});
|
||||
mocks.tabs.push(tab);
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
|
||||
// Dameng 元数据必须排在数据查询之后(串行约束,同 useSidebarDataOpenRuntime);
|
||||
// 真实元数据落地后结束行标识等待
|
||||
await vi.waitFor(() => {
|
||||
expect(callOrder).toEqual(["query", "metadata"]);
|
||||
expect(tab.tableMetaPending).toBe(false);
|
||||
expect(tab.tableMeta?.primaryKeys).toEqual(["id"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("starts the deferred Dameng metadata refresh even when the reload query rejects", async () => {
|
||||
mocks.getConfig.mockReturnValue({ id: "dameng-1", db_type: "dameng" });
|
||||
mocks.executeTabSql.mockRejectedValueOnce(new Error("query failed"));
|
||||
const tab = tableDataTab({
|
||||
connectionId: "dameng-1",
|
||||
tableMeta: { schema: "public", tableName: "users", tableType: "TABLE", columns: [], primaryKeys: [] },
|
||||
tableMetaUpdatedAt: Date.now(),
|
||||
});
|
||||
mocks.tabs.push(tab);
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
// 查询 reject 仍会重新抛出,但元数据刷新必须已启动,标签页可恢复
|
||||
await expect(actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh")).rejects.toThrow("query failed");
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.getColumns).toHaveBeenCalled();
|
||||
expect(tab.tableMetaPending).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes hidden primary keys and remaps the selected column for database sorting", async () => {
|
||||
const tab = {
|
||||
id: "tab-1",
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useNavigationTargets } from "@/composables/useNavigationTargets";
|
||||
import { clearTableMetadataCache } from "@/lib/metadata/tableMetadataCache";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
tabs: [] as QueryTab[],
|
||||
reuseDataTab: true,
|
||||
activeTabId: "",
|
||||
databaseType: "postgres" as string,
|
||||
ensureConnected: vi.fn(),
|
||||
executeTabSql: vi.fn(),
|
||||
getColumns: vi.fn(),
|
||||
listIndexes: vi.fn(),
|
||||
setTableMeta: vi.fn(),
|
||||
updateSql: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
getColumns: mocks.getColumns,
|
||||
listIndexes: mocks.listIndexes,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
activeConnectionId: "",
|
||||
getConfig: () => ({ id: "connection-1", db_type: mocks.databaseType }),
|
||||
ensureConnected: mocks.ensureConnected,
|
||||
connectionIdentifierQuote: () => undefined,
|
||||
refreshObjectListTreeNode: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/queryStore", () => ({
|
||||
useQueryStore: () => ({
|
||||
tabs: mocks.tabs,
|
||||
activeTabId: mocks.activeTabId,
|
||||
createTab: (connectionId: string, database: string, title: string, mode: QueryTab["mode"], schema?: string) => {
|
||||
const tab = {
|
||||
id: `tab-${mocks.tabs.length + 1}`,
|
||||
connectionId,
|
||||
database,
|
||||
title,
|
||||
mode,
|
||||
schema,
|
||||
sql: "",
|
||||
isDirty: false,
|
||||
isExecuting: false,
|
||||
isCancelling: false,
|
||||
isExplaining: false,
|
||||
} as QueryTab;
|
||||
mocks.tabs.push(tab);
|
||||
return tab.id;
|
||||
},
|
||||
switchTab: vi.fn(),
|
||||
setExecuting: vi.fn(),
|
||||
setExecutingWithId: (id: string, executionId: string) => {
|
||||
const tab = mocks.tabs.find((item) => item.id === id);
|
||||
if (tab) {
|
||||
tab.isExecuting = true;
|
||||
tab.executionId = executionId;
|
||||
}
|
||||
},
|
||||
setTableMeta: mocks.setTableMeta.mockImplementation((id: string, meta: NonNullable<QueryTab["tableMeta"]>) => {
|
||||
const tab = mocks.tabs.find((item) => item.id === id);
|
||||
if (tab) {
|
||||
tab.tableMeta = meta;
|
||||
tab.tableMetaUpdatedAt = Date.now();
|
||||
// 与真实 store 一致:仅真实元数据(columns 非空)落地才结束行标识等待
|
||||
if (meta.columns.length > 0) tab.tableMetaPending = false;
|
||||
}
|
||||
}),
|
||||
updateSql: mocks.updateSql,
|
||||
executeTabSql: mocks.executeTabSql,
|
||||
setErrorResult: vi.fn(),
|
||||
invalidateTableStructure: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/settingsStore", () => ({
|
||||
useSettingsStore: () => ({ editorSettings: { reuseDataTab: mocks.reuseDataTab } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/table/tableSelectSql", () => ({
|
||||
buildTableSelectSql: async ({ tableName }: { tableName: string }) => `SELECT * FROM ${tableName}`,
|
||||
}));
|
||||
|
||||
const dialogs = {
|
||||
showFieldLineageDialog: { value: false },
|
||||
showDatabaseSearchDialog: { value: false },
|
||||
showDiagramDialog: { value: false },
|
||||
};
|
||||
|
||||
function column(name: string) {
|
||||
return { name, data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null };
|
||||
}
|
||||
|
||||
describe("useNavigationTargets openTableTarget", () => {
|
||||
beforeEach(() => {
|
||||
clearTableMetadataCache();
|
||||
vi.clearAllMocks();
|
||||
mocks.tabs.length = 0;
|
||||
mocks.reuseDataTab = true;
|
||||
mocks.databaseType = "postgres";
|
||||
mocks.ensureConnected.mockResolvedValue(undefined);
|
||||
mocks.executeTabSql.mockResolvedValue(undefined);
|
||||
mocks.getColumns.mockResolvedValue([column("id")]);
|
||||
mocks.listIndexes.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("marks row identity pending until real metadata lands", async () => {
|
||||
let releaseColumns: (columns: unknown[]) => void = () => {};
|
||||
mocks.getColumns.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
releaseColumns = resolve;
|
||||
}),
|
||||
);
|
||||
let pendingDuringQuery: boolean | undefined;
|
||||
mocks.executeTabSql.mockImplementationOnce(async () => {
|
||||
pendingDuringQuery = mocks.tabs[0]?.tableMetaPending;
|
||||
});
|
||||
|
||||
const open = useNavigationTargets(dialogs).openLineageTarget({ connectionId: "connection-1", database: "app", schema: "public", tableName: "users" });
|
||||
releaseColumns([column("id")]);
|
||||
await open;
|
||||
|
||||
// 数据查询执行期间元数据未落地:行标识等待必须已挂起
|
||||
expect(pendingDuringQuery).toBe(true);
|
||||
expect(mocks.tabs[0]?.tableMeta?.primaryKeys).toEqual(["id"]);
|
||||
expect(mocks.tabs[0]?.tableMetaPending).toBe(false);
|
||||
});
|
||||
|
||||
it("reuses cached metadata and force-refreshes it once per catalog after a structure save", async () => {
|
||||
const navigation = useNavigationTargets(dialogs);
|
||||
const target = { connectionId: "connection-1", database: "app", schema: "public", catalog: "catalog-1", tableName: "users" };
|
||||
|
||||
await navigation.openLineageTarget(target);
|
||||
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(), {
|
||||
connectionId: target.connectionId,
|
||||
database: target.database,
|
||||
schema: target.schema,
|
||||
tableName: target.tableName,
|
||||
});
|
||||
|
||||
expect(mocks.getColumns).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.tabs.map((tab) => tab.tableMeta?.primaryKeys)).toEqual([["fresh_id"], ["fresh_id"]]);
|
||||
});
|
||||
|
||||
it("keeps row identity pending when shared metadata loading fails", async () => {
|
||||
mocks.getColumns.mockRejectedValueOnce(new Error("metadata unavailable"));
|
||||
|
||||
await useNavigationTargets(dialogs).openLineageTarget({ connectionId: "connection-1", database: "app", schema: "public", tableName: "users" });
|
||||
|
||||
expect(mocks.tabs[0]?.tableMeta?.columns).toEqual([]);
|
||||
expect(mocks.tabs[0]?.tableMetaPending).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 才返回
|
||||
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 openB = navigation.openLineageTarget({ 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);
|
||||
|
||||
// 旧 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);
|
||||
});
|
||||
|
||||
it("stops landing when another entry point takes over the tab, even for the same table", async () => {
|
||||
const { beginDataTabNavigation } = await import("@/lib/tabs/dataTabNavigationGeneration");
|
||||
// A(openTableTarget)的 getColumns 挂起
|
||||
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: "users" });
|
||||
await vi.waitFor(() => expect(columnGates.has("users")).toBe(true));
|
||||
mocks.setTableMeta.mockClear();
|
||||
|
||||
// 侧边栏 openData 接管同一 tab(同表——目标身份校验无法区分):登记新代次即作废旧代次
|
||||
beginDataTabNavigation(mocks.tabs[0]!.id);
|
||||
|
||||
// 旧 A 晚返回:目标身份仍匹配,但代次已作废,不得落地元数据
|
||||
columnGates.get("users")?.([column("id")]);
|
||||
await openA;
|
||||
expect(mocks.setTableMeta).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips the tdengine requery when a cancel was requested during the first execute", async () => {
|
||||
// tdengine 无条件走元数据后的第二次查询。模拟:首次 executeTabSql 期间
|
||||
// 用户点击停止,但取消返回 false(查询先完成),isCancelling 被清、
|
||||
// 结果正常——重查仍然必须被跳过(不能替用户重跑他停掉的查询)
|
||||
mocks.databaseType = "tdengine";
|
||||
mocks.executeTabSql.mockImplementationOnce(async (id: string) => {
|
||||
const tab = mocks.tabs.find((item) => item.id === id);
|
||||
if (tab) {
|
||||
// 用户在执行期间请求停止:计数单调递增;随后取消失败、状态被清
|
||||
tab.cancelRequestCount = (tab.cancelRequestCount ?? 0) + 1;
|
||||
tab.isCancelling = false;
|
||||
tab.result = { columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 };
|
||||
}
|
||||
});
|
||||
|
||||
await useNavigationTargets(dialogs).openLineageTarget({ connectionId: "connection-1", database: "app", schema: "public", tableName: "users" });
|
||||
|
||||
// 只有首次执行;tdengine 重查被取消请求拦下,元数据仍正常落地
|
||||
expect(mocks.executeTabSql).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.tabs[0]?.tableMeta?.columns.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("runs the tdengine requery normally when no cancel was requested", async () => {
|
||||
mocks.databaseType = "tdengine";
|
||||
|
||||
await useNavigationTargets(dialogs).openLineageTarget({ connectionId: "connection-1", database: "app", schema: "public", tableName: "users" });
|
||||
|
||||
// 无取消请求:元数据落地后按既有行为执行第二次查询
|
||||
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,310 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSidebarDataOpenRuntime } from "@/composables/useSidebarDataOpenRuntime";
|
||||
import type { QueryTab, TreeNode } from "@/types/database";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
databaseType: "oceanbase" as string,
|
||||
callOrder: [] as string[],
|
||||
tabs: [] as QueryTab[],
|
||||
cachedMetadata: undefined as unknown,
|
||||
ensureConnected: vi.fn(),
|
||||
executeTabSql: vi.fn(),
|
||||
loadTableMetadata: 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,
|
||||
createTab: (connectionId: string, database: string, title: string, mode: QueryTab["mode"], schema?: string) => {
|
||||
const tab = {
|
||||
id: "tab-1",
|
||||
connectionId,
|
||||
database,
|
||||
title,
|
||||
mode,
|
||||
schema,
|
||||
sql: "",
|
||||
isDirty: false,
|
||||
isExecuting: false,
|
||||
isCancelling: false,
|
||||
isExplaining: false,
|
||||
} as QueryTab;
|
||||
mocks.tabs.push(tab);
|
||||
return tab.id;
|
||||
},
|
||||
switchTab: vi.fn(),
|
||||
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: { reuseDataTab: false, 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 ?? "",
|
||||
}));
|
||||
|
||||
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 使用真实实现:beforeEach 清空 tabs 时无候选可复用,
|
||||
// 取消窗口测试则依赖真实 findExistingDataTabCandidate 选中同表 tab
|
||||
vi.mock("@/lib/sidebar/treeNodeContext", () => ({ hasTreeNodeDatabaseContext: () => true }));
|
||||
vi.mock("@/lib/table/tableSelectSql", () => ({ buildTableSelectSql: async () => "SELECT * FROM users" }));
|
||||
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",
|
||||
};
|
||||
|
||||
describe("useSidebarDataOpenRuntime", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.databaseType = "oceanbase";
|
||||
mocks.callOrder.length = 0;
|
||||
mocks.tabs.length = 0;
|
||||
mocks.cachedMetadata = undefined;
|
||||
mocks.ensureConnected.mockResolvedValue(undefined);
|
||||
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("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 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("aborts when a newer navigation takes over the tab during the cancel wait", async () => {
|
||||
const { beginDataTabNavigation } = await import("@/lib/tabs/dataTabNavigationGeneration");
|
||||
// 已存在同表 data tab 且有在途执行:真实 findExistingDataTabCandidate 会
|
||||
// 选中它(same-table 复用分支),openData 需先等待取消
|
||||
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: [], primaryKeys: [] },
|
||||
} as QueryTab);
|
||||
let releaseCancel: () => void = () => {};
|
||||
mocks.cancelTabExecution.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
releaseCancel = resolve;
|
||||
}),
|
||||
);
|
||||
const open = useSidebarDataOpenRuntime().openData(tableNode);
|
||||
await vi.waitFor(() => expect(mocks.cancelTabExecution).toHaveBeenCalledWith("existing-tab"));
|
||||
// 复用既有 tab,而非新建
|
||||
expect(mocks.tabs).toHaveLength(1);
|
||||
|
||||
// 取消等待期间,更晚的导航(如 openTableTarget)接管该 tab
|
||||
beginDataTabNavigation("existing-tab");
|
||||
const takeoverMeta = { schema: "public", tableName: "other_table", tableType: "TABLE", columns: [], primaryKeys: [] };
|
||||
mocks.tabs[0]!.tableMeta = takeoverMeta;
|
||||
|
||||
releaseCancel();
|
||||
await open;
|
||||
|
||||
// 旧 openData 必须让位:不得覆盖新导航写入的身份,不得启动查询
|
||||
expect(mocks.tabs[0]?.tableMeta).toBe(takeoverMeta);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,10 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
const tableMeta = tableMetaForDataTab(tab);
|
||||
const primaryKeys = tab.tableMeta ? tab.tableMeta.primaryKeys : (tableMeta?.primaryKeys ?? []);
|
||||
const useRowId = usesSyntheticRowIdKey(effectiveDbType, primaryKeys, tableMeta?.tableType);
|
||||
// 列投影只信任真实元数据列:tableMetaForDataTab 的 fallback 列来自查询
|
||||
// 结果(可能是失败结果的 ["Error"]),进入 SQL 会生成非法投影;
|
||||
// 真实列缺失时省略 columns 让 builder 生成 SELECT *
|
||||
const realColumns = tab.tableMeta?.columns.length ? tab.tableMeta.columns : undefined;
|
||||
return buildTableSelectSql({
|
||||
databaseType: effectiveDbType,
|
||||
identifierQuote: connectionStore.connectionIdentifierQuote?.(tab.connectionId),
|
||||
@@ -57,7 +61,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
tableName: tableMeta?.tableName ?? "",
|
||||
tableType: tableMeta?.tableType,
|
||||
catalog: tableMeta?.catalog,
|
||||
columns: tableMeta?.columns.map((column) => column.name),
|
||||
columns: realColumns?.map((column) => column.name),
|
||||
primaryKeys,
|
||||
includeRowId: useRowId,
|
||||
limit: options.limit ?? tab.resultPageLimit ?? tableOpenPageLimit(),
|
||||
@@ -142,11 +146,17 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
queryStore.setExecuting(tab.id, true);
|
||||
const tableMeta = tableMetaForDataTab(tab);
|
||||
const metadataAgeMs = tab.tableMetaUpdatedAt ? Date.now() - tab.tableMetaUpdatedAt : Number.POSITIVE_INFINITY;
|
||||
const shouldRefreshMetadata = !tab.tableMeta || !tableMeta?.columns.length || metadataAgeMs > DATA_TAB_METADATA_TTL_MS;
|
||||
if (shouldRefreshMetadata) {
|
||||
console.info("[DBX][reloadData:metadata:background:start]", { traceId, elapsed: elapsed(), reason: tableMeta?.columns.length ? "stale" : "missing", metadataAgeMs });
|
||||
// 判断元数据是否真实存在必须用原始 tab.tableMeta:tableMetaForDataTab 会在
|
||||
// 真实列缺失时用查询结果列合成 columns(包括失败结果的 ["Error"] 列),
|
||||
// 不能据此跳过刷新,否则恢复/失败后的重试会被 TTL 卡住
|
||||
const hasRealTableMetaColumns = !!tab.tableMeta?.columns.length;
|
||||
const shouldRefreshMetadata = !hasRealTableMetaColumns || metadataAgeMs > DATA_TAB_METADATA_TTL_MS;
|
||||
// Dameng 元数据必须与数据查询串行(同 useSidebarDataOpenRuntime),
|
||||
// 延后到查询完成后再启动
|
||||
const deferMetadataRefresh = effectiveDatabaseTypeForConnection(connectionStore.getConfig(tab.connectionId)) === "dameng";
|
||||
const startMetadataRefresh = () => {
|
||||
console.info("[DBX][reloadData:metadata:background:start]", { traceId, elapsed: elapsed(), reason: hasRealTableMetaColumns ? "stale" : "missing", metadataAgeMs });
|
||||
void refreshDataTabTableMeta(tab, { traceId, elapsed })
|
||||
.then(() => {
|
||||
console.info("[DBX][reloadData:metadata:background:done]", { traceId, elapsed: elapsed() });
|
||||
@@ -155,8 +165,15 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
console.warn("[DBX][reloadData:metadata:background:error]", { traceId, elapsed: elapsed(), error: e });
|
||||
toast(e?.message || String(e), 5000);
|
||||
});
|
||||
};
|
||||
if (shouldRefreshMetadata) {
|
||||
// 元数据缺失(如重启恢复的标签页只持久化了占位身份)时行标识未知:
|
||||
// 挂起等待,防止数据查询先返回后编辑/保存以空 primaryKeys 短暂可用,
|
||||
// 走整行 WHERE 保存路径(#3727)。真实元数据经 setTableMeta 落地后解除
|
||||
if (!hasRealTableMetaColumns) tab.tableMetaPending = true;
|
||||
if (!deferMetadataRefresh) startMetadataRefresh();
|
||||
} else {
|
||||
console.info("[DBX][reloadData:metadata:skip]", { traceId, elapsed: elapsed(), columnCount: tableMeta.columns.length, metadataAgeMs });
|
||||
console.info("[DBX][reloadData:metadata:skip]", { traceId, elapsed: elapsed(), columnCount: tab.tableMeta!.columns.length, metadataAgeMs });
|
||||
}
|
||||
try {
|
||||
console.info("[DBX][reloadData:build-sql:start]", { traceId, elapsed: elapsed() });
|
||||
@@ -172,8 +189,10 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
} catch (e) {
|
||||
console.error("[DBX][reloadData:error]", { traceId, elapsed: elapsed(), error: e });
|
||||
queryStore.setExecuting(tab.id, false);
|
||||
if (shouldRefreshMetadata && deferMetadataRefresh) startMetadataRefresh();
|
||||
throw e;
|
||||
}
|
||||
if (shouldRefreshMetadata && deferMetadataRefresh) startMetadataRefresh();
|
||||
return;
|
||||
}
|
||||
if (intent === "refresh" && tab.mode === "query" && (tab.results?.length ?? 0) > 1) {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { effectiveDatabaseTypeForConnection, metadataSchemaForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { invalidateTableMetadataCache, loadTableMetadata } from "@/lib/metadata/tableMetadataCache";
|
||||
import { canApplyDataTabMetadata } from "@/lib/sidebar/dataTabOpenPolicy";
|
||||
import { isNoSnapshotErrorResult } from "@/lib/query/queryResultError";
|
||||
import { buildTableSelectSql } from "@/lib/table/tableSelectSql";
|
||||
import { editableRowIdentifierColumns, usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
|
||||
import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { beginDataTabNavigation, endDataTabNavigation, isCurrentDataTabNavigation } from "@/lib/tabs/dataTabNavigationGeneration";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
@@ -63,10 +66,50 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
|
||||
columns: [],
|
||||
primaryKeys: [],
|
||||
});
|
||||
queryStore.setExecuting(tabId, true);
|
||||
// 占位身份的行标识未知:真实元数据落地(下方 getColumns 成功后的 setTableMeta)
|
||||
// 前编辑/保存保持禁用,防止数据查询先返回时整行 WHERE 保存路径短暂可用(#3727)
|
||||
if (targetTab) targetTab.tableMetaPending = true;
|
||||
// 立即作废旧执行代次:旧导航在途的 executeTabSql 结果按 executionId 比对
|
||||
// 落地,仅置 isExecuting 不会替换 executionId,旧结果仍可能写入本 tab。
|
||||
// preparationId 同时参与 isCurrentTarget:用户在准备期点击停止会清掉
|
||||
// executionId,后续异步返回后不得再启动查询(同 refreshPreparationId 模式)
|
||||
const preparationId = uuid();
|
||||
queryStore.setExecutingWithId(tabId, preparationId);
|
||||
// reuseDataTab 下并发导航会复用同一 tab:每次异步返回后必须校验(1)本次
|
||||
// 导航仍是该 tab 的最新代次(区分同表不同 whereInput/连点两次,且侧边栏
|
||||
// openData 接管同一 tab 时会作废本代次),(2)tab 未被其他流程改指别的
|
||||
// 目标,(3)准备期 executionId 未被停止/接管清除。否则旧请求晚返回会用
|
||||
// A 的元数据覆盖新目标 B 的占位并解除 pending,造成结果属于 B、写入目标
|
||||
// 却是 A 的错位
|
||||
const navigationToken = beginDataTabNavigation(tabId);
|
||||
const isCurrentTarget = () =>
|
||||
isCurrentDataTabNavigation(tabId, navigationToken) &&
|
||||
canApplyDataTabMetadata(
|
||||
queryStore.tabs.find((tab) => tab.id === tabId),
|
||||
{
|
||||
connectionId: target.connectionId,
|
||||
database: target.database,
|
||||
schema: target.schema,
|
||||
catalog: target.catalog,
|
||||
tableName: target.tableName,
|
||||
},
|
||||
);
|
||||
// 仅首次 executeTabSql 前有效:executeTabSql 会替换 executionId、完成后清空,
|
||||
// 之后的落地点(元数据/fallback)回到 isCurrentTarget 判定。
|
||||
// isCancelling 必须同查:停止请求先同步置 isCancelling,等 cancelQuery 返回
|
||||
// 才清 executionId——这个窗口内准备流程不得启动查询
|
||||
const isPreparationCurrent = () => {
|
||||
const currentTab = queryStore.tabs.find((tab) => tab.id === tabId);
|
||||
return isCurrentTarget() && currentTab?.executionId === preparationId && !currentTab.isCancelling;
|
||||
};
|
||||
// 区分外层 catch 的判定:准备期(首次 executeTabSql 前)reject 用
|
||||
// isPreparationCurrent——停止/接管后的迟到错误不得写入;首次执行之后
|
||||
// preparationId 正常失效,回到 isCurrentTarget,真实错误仍要展示
|
||||
let firstExecuteStarted = false;
|
||||
|
||||
try {
|
||||
await connectionStore.ensureConnected(target.connectionId);
|
||||
if (!isPreparationCurrent()) return;
|
||||
if (!config) throw new Error("Connection config not found");
|
||||
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
|
||||
const identifierQuote = connectionStore.connectionIdentifierQuote?.(target.connectionId);
|
||||
@@ -88,6 +131,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
|
||||
whereInput: target.whereInput,
|
||||
limit: pageLimit,
|
||||
});
|
||||
if (!isPreparationCurrent()) return;
|
||||
queryStore.updateSql(tabId, sql);
|
||||
queryStore.setTableMeta(tabId, {
|
||||
catalog: target.catalog,
|
||||
@@ -98,6 +142,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
|
||||
columns,
|
||||
primaryKeys,
|
||||
});
|
||||
firstExecuteStarted = true;
|
||||
await queryStore.executeTabSql(tabId, sql, { pagination: { limit: pageLimit, offset: 0 } });
|
||||
return;
|
||||
}
|
||||
@@ -112,6 +157,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
|
||||
whereInput: target.whereInput,
|
||||
limit: pageLimit,
|
||||
});
|
||||
if (!isPreparationCurrent()) return;
|
||||
queryStore.updateSql(tabId, sql);
|
||||
queryStore.setTableMeta(tabId, {
|
||||
schema: target.schema,
|
||||
@@ -122,7 +168,19 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
|
||||
columns: [],
|
||||
primaryKeys: [],
|
||||
});
|
||||
firstExecuteStarted = true;
|
||||
// 取消计数快照:isCancelling 是瞬态的(取消失败/查询先完成会被清掉),
|
||||
// 比对计数才能跨越 executeTabSql 生命周期识别"执行期间用户请求过停止"
|
||||
const cancelCountBeforeExecute = queryStore.tabs.find((tab) => tab.id === tabId)?.cancelRequestCount ?? 0;
|
||||
await queryStore.executeTabSql(tabId, sql, { pagination: { limit: pageLimit, offset: 0 } });
|
||||
if (!isCurrentTarget()) return;
|
||||
// 首次查询被停止/失败(executeTabSql 以 Error 结果表达,不抛出)时,
|
||||
// 后续不得自动启动第二次查询(synthetic row-id/TDengine 重查)——用户
|
||||
// 停止了查询,流程不能替他再跑一次;元数据落地不受影响
|
||||
const tabAfterFirstExecute = queryStore.tabs.find((tab) => tab.id === tabId);
|
||||
const firstResult = tabAfterFirstExecute?.result;
|
||||
const cancelRequestedDuringExecute = (tabAfterFirstExecute?.cancelRequestCount ?? 0) > cancelCountBeforeExecute;
|
||||
const firstQueryFailed = cancelRequestedDuringExecute || tabAfterFirstExecute?.isCancelling === true || (firstResult?.columns.length === 1 && firstResult.columns[0] === "Error");
|
||||
// executeTabSql surfaces query failures as an "Error" result instead of throwing.
|
||||
// A snapshot-less lake table fails the data preview above but its metadata still
|
||||
// reads fine — retry with LIMIT 0 so the user sees the table structure (columns +
|
||||
@@ -142,8 +200,10 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
|
||||
whereInput: target.whereInput,
|
||||
limit: 0,
|
||||
});
|
||||
if (!isCurrentTarget()) return;
|
||||
queryStore.updateSql(tabId, emptySql);
|
||||
await queryStore.executeTabSql(tabId, emptySql, { pagination: { limit: pageLimit, offset: 0 } });
|
||||
if (!isCurrentTarget()) return;
|
||||
}
|
||||
try {
|
||||
// 复用共享表元数据缓存(30s TTL + in-flight 去重)
|
||||
@@ -159,6 +219,9 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
|
||||
});
|
||||
const columns = metadata.columns;
|
||||
const primaryKeys = metadata.primaryKeys;
|
||||
// 异步窗口内 tab 可能已被复用为其他目标:旧请求的元数据不得落地、
|
||||
// 不得解除新目标的 pending
|
||||
if (!isCurrentTarget()) return;
|
||||
const useRowId = usesSyntheticRowIdKey(effectiveDbType, primaryKeys, targetTableType);
|
||||
queryStore.setTableMeta(tabId, {
|
||||
schema: target.schema,
|
||||
@@ -169,7 +232,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
|
||||
columns,
|
||||
primaryKeys,
|
||||
});
|
||||
if (!fellBackToLimitZero && (useRowId || config.db_type === "tdengine")) {
|
||||
if (!fellBackToLimitZero && !firstQueryFailed && (useRowId || config.db_type === "tdengine")) {
|
||||
const newSql = await buildTableSelectSql({
|
||||
databaseType: effectiveDbType,
|
||||
identifierQuote,
|
||||
@@ -184,6 +247,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
|
||||
includeRowId: true,
|
||||
limit: pageLimit,
|
||||
});
|
||||
if (!isCurrentTarget()) return;
|
||||
queryStore.updateSql(tabId, newSql);
|
||||
await queryStore.executeTabSql(tabId, newSql, { pagination: { limit: pageLimit, offset: 0 } });
|
||||
}
|
||||
@@ -191,7 +255,10 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
|
||||
console.error("[DBX] ERROR fetching table metadata:", reason);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (firstExecuteStarted ? !isCurrentTarget() : !isPreparationCurrent()) return;
|
||||
queryStore.setErrorResult(tabId, e);
|
||||
} finally {
|
||||
endDataTabNavigation(tabId, navigationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,9 +304,13 @@ export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value:
|
||||
for (const tab of matchingDataTabs) {
|
||||
try {
|
||||
const connection = connectionStore.getConfig(tab.connectionId);
|
||||
const metadataSchema = metadataSchemaForConnection(connection, tab.database, tab.tableMeta?.schema);
|
||||
// 捕获不可变目标身份:await 期间 tab 可能被导航复用改指其他表,
|
||||
// 共享缓存结果落地前仍必须复核,不能解除新目标的 pending
|
||||
const capturedMeta = tab.tableMeta!;
|
||||
const capturedTarget = { connectionId: tab.connectionId, database: tab.database, schema: capturedMeta.schema, catalog: capturedMeta.catalog, tableName: capturedMeta.tableName };
|
||||
const metadataSchema = metadataSchemaForConnection(connection, tab.database, capturedMeta.schema);
|
||||
// 分组含 tableType:主键计算依赖它,不同 tableType 不能共享加载结果
|
||||
const catalogKey = `${tab.tableMeta?.catalog ?? ""}\u0000${tab.tableMeta?.tableType ?? ""}`;
|
||||
const catalogKey = `${capturedMeta.catalog ?? ""}\u0000${capturedMeta.tableType ?? ""}`;
|
||||
let metadata = loadedByCatalog.get(catalogKey);
|
||||
if (!metadata) {
|
||||
metadata = (
|
||||
@@ -247,18 +318,20 @@ export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value:
|
||||
connectionId: tab.connectionId,
|
||||
database: tab.database,
|
||||
schema: metadataSchema,
|
||||
tableName: tab.tableMeta!.tableName,
|
||||
tableType: tab.tableMeta!.tableType,
|
||||
tableName: capturedMeta.tableName,
|
||||
tableType: capturedMeta.tableType,
|
||||
databaseType: effectiveDatabaseTypeForConnection(connection) ?? connection?.db_type ?? "",
|
||||
driverProfile: connection?.driver_profile || connection?.db_type,
|
||||
catalog: tab.tableMeta?.catalog,
|
||||
catalog: capturedMeta.catalog,
|
||||
force: true,
|
||||
})
|
||||
).metadata;
|
||||
loadedByCatalog.set(catalogKey, metadata);
|
||||
}
|
||||
const currentTab = queryStore.tabs.find((item) => item.id === tab.id);
|
||||
if (!canApplyDataTabMetadata(currentTab, capturedTarget)) continue;
|
||||
queryStore.setTableMeta(tab.id, {
|
||||
...tab.tableMeta!,
|
||||
...capturedMeta,
|
||||
columns: metadata.columns,
|
||||
primaryKeys: metadata.primaryKeys,
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { buildTableSelectSql } from "@/lib/table/tableSelectSql";
|
||||
import { usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
|
||||
import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit";
|
||||
import { canActivateExistingDataTableTab } from "@/lib/tabs/dataTabActivation";
|
||||
import { beginDataTabNavigation, endDataTabNavigation, isCurrentDataTabNavigation } from "@/lib/tabs/dataTabNavigationGeneration";
|
||||
|
||||
const DATA_TAB_METADATA_TTL_MS = TABLE_METADATA_CACHE_TTL_MS;
|
||||
|
||||
@@ -113,6 +114,8 @@ export function useSidebarDataOpenRuntime() {
|
||||
metadataMs: Math.round(performance.now() - metadataStartedAt),
|
||||
});
|
||||
} catch (error) {
|
||||
// 元数据加载失败:行标识仍然未知,保持只读(tableMetaPending 不清除),
|
||||
// 不回退到整行 WHERE 写入;刷新或重开表会重试加载并恢复
|
||||
openDataLog("warn", "metadata:error", { traceId, tabId: targetTabId, elapsed: elapsed(), error });
|
||||
}
|
||||
};
|
||||
@@ -145,6 +148,9 @@ export function useSidebarDataOpenRuntime() {
|
||||
queryStore.switchTab(existingSameTableTab.id);
|
||||
logPhase("existing-tab-activated", { table: node.label });
|
||||
if (dataTabMetadataNeedsRefresh(existingSameTableTab, DATA_TAB_METADATA_TTL_MS)) {
|
||||
// 真实列缺失时行标识未知:启动刷新的同时必须挂起编辑门控(所有
|
||||
// "真实列缺失且启动刷新"的入口统一置 pending)
|
||||
if (!existingSameTableTab.tableMeta?.columns.length) existingSameTableTab.tableMetaPending = true;
|
||||
void refreshTableMetaInBackground(existingSameTableTab.id, true);
|
||||
logPhase("metadata-started", { tabId: existingSameTableTab.id, reason: "existing-tab-stale" });
|
||||
}
|
||||
@@ -161,13 +167,25 @@ export function useSidebarDataOpenRuntime() {
|
||||
})();
|
||||
openDataLog("info", "tab-created", { traceId, tabId, elapsed: elapsed() });
|
||||
logPhase("tab-created", { tabId });
|
||||
// 接管该 tab:登记本次导航代次,作废在途的 openTableTarget/openData 旧代次
|
||||
// ——旧导航即使目标身份相同(同表不同 whereInput)也不得再落地
|
||||
const navigationToken = beginDataTabNavigation(tabId);
|
||||
|
||||
// Cancel any previous execution on this tab before starting a new one
|
||||
const existingTab = queryStore.tabs.find((t) => t.id === tabId);
|
||||
if (existingTab?.isExecuting && existingTab.executionId) {
|
||||
await queryStore.cancelTabExecution(tabId);
|
||||
logPhase("previous-execution-cancelled", { tabId });
|
||||
// 取消等待期间可能有更晚的导航(openTableTarget/openData)接管本 tab:
|
||||
// 本代次已作废,不得再写占位元数据/executionId 覆盖新导航
|
||||
if (!isCurrentDataTabNavigation(tabId, navigationToken)) {
|
||||
logPhase("superseded-during-cancel", { tabId });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 取消窗口之后由 executionId 比对(isActive)保护,代次使命完成即注销;
|
||||
// 已作废的旧 token 不因删除而复活(比对按 token 同一性)
|
||||
endDataTabNavigation(tabId, navigationToken);
|
||||
|
||||
const openDataId = uuid();
|
||||
// Clear previous result so DataGrid doesn't show its internal loading overlay (without stop button)
|
||||
@@ -194,9 +212,12 @@ export function useSidebarDataOpenRuntime() {
|
||||
existingTableMeta?.tableName === node.label && (existingTableMeta.catalog || "") === (node.catalog || "") && existingTableMeta.schema === tableSchema && existingTableMeta.tableType === tableType && existingTableMeta.columns.length > 0 && existingTableMetaAgeMs < DATA_TAB_METADATA_TTL_MS
|
||||
? existingTableMeta
|
||||
: undefined;
|
||||
const cachedTableMeta = sharedCachedTableMeta ? tableMetadataToDataTabMeta(sharedCachedTableMeta.metadata, tableSchema) : tabCachedTableMeta;
|
||||
const cachedTableMetaAgeMs = sharedCachedTableMeta?.ageMs ?? existingTableMetaAgeMs;
|
||||
const cachedTableMetaSource = sharedCachedTableMeta ? "shared" : tabCachedTableMeta ? "tab" : undefined;
|
||||
// 空列的共享缓存条目不算暖缓存:columns=[] 无法区分"表确实无列"与
|
||||
// 占位/异常态,按暖缓存跳过 pending 与刷新会让整行 WHERE 保存路径
|
||||
// 在行标识未知时重新可用(#3727 审查意见)
|
||||
const cachedTableMeta = sharedCachedTableMeta?.metadata.columns.length ? tableMetadataToDataTabMeta(sharedCachedTableMeta.metadata, tableSchema) : tabCachedTableMeta;
|
||||
const cachedTableMetaAgeMs = sharedCachedTableMeta?.metadata.columns.length ? sharedCachedTableMeta.ageMs : existingTableMetaAgeMs;
|
||||
const cachedTableMetaSource = sharedCachedTableMeta?.metadata.columns.length ? "shared" : tabCachedTableMeta ? "tab" : undefined;
|
||||
queryStore.setTableMeta(
|
||||
tabId,
|
||||
cachedTableMeta ?? {
|
||||
@@ -209,6 +230,13 @@ export function useSidebarDataOpenRuntime() {
|
||||
primaryKeys: [],
|
||||
},
|
||||
);
|
||||
if (!cachedTableMeta) {
|
||||
// 冷缓存:占位元数据的 primaryKeys 为空但真实主键未知。挂起行标识
|
||||
// 等待,元数据落地(setTableMeta)或加载失败前编辑/保存保持禁用,
|
||||
// 防止数据查询先返回时整行 WHERE 保存路径短暂可用(#3727)
|
||||
const pendingTab = queryStore.tabs.find((item) => item.id === tabId);
|
||||
if (pendingTab) pendingTab.tableMetaPending = true;
|
||||
}
|
||||
queryStore.setExecutingWithId(tabId, openDataId);
|
||||
request?.registerCancel(async () => {
|
||||
const current = queryStore.tabs.find((item) => item.id === tabId);
|
||||
@@ -240,6 +268,8 @@ export function useSidebarDataOpenRuntime() {
|
||||
|
||||
const limit = tableOpenPageLimit();
|
||||
const shouldRefreshTableMeta = !cachedTableMeta;
|
||||
// Dameng metadata calls must remain serialized behind the table query.
|
||||
const deferTableMetaRefresh = effectiveDbType === "dameng";
|
||||
if (cachedTableMeta) {
|
||||
openDataLog("info", "metadata:cache-hit", {
|
||||
traceId,
|
||||
@@ -250,8 +280,11 @@ export function useSidebarDataOpenRuntime() {
|
||||
ageMs: Math.round(cachedTableMetaAgeMs),
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
} else {
|
||||
} else if (deferTableMetaRefresh) {
|
||||
logPhase("metadata-deferred", { tabId });
|
||||
} else {
|
||||
void refreshTableMetaInBackground(tabId);
|
||||
logPhase("metadata-started", { tabId });
|
||||
}
|
||||
|
||||
// Check if superseded by a newer openData call
|
||||
@@ -276,6 +309,12 @@ export function useSidebarDataOpenRuntime() {
|
||||
limit,
|
||||
includeRowId,
|
||||
});
|
||||
// SQL 构建是异步后端调用:期间更晚的导航(openData/openTableTarget)
|
||||
// 接管会替换 executionId,旧流程不得再覆盖 SQL/启动查询
|
||||
if (!isActive()) {
|
||||
logPhase("superseded-after-build-sql", { tabId });
|
||||
return;
|
||||
}
|
||||
openDataLog("info", "sql-built", {
|
||||
traceId,
|
||||
primaryKeyCount: primaryKeys.length,
|
||||
@@ -295,7 +334,7 @@ export function useSidebarDataOpenRuntime() {
|
||||
});
|
||||
openDataLog("info", "execute:done", { traceId, tabId, elapsed: elapsed() });
|
||||
logPhase("execute-tab-sql", { tabId });
|
||||
if (shouldRefreshTableMeta && canApplyTableMetadata(tabId)) {
|
||||
if (shouldRefreshTableMeta && deferTableMetaRefresh && canApplyTableMetadata(tabId)) {
|
||||
void refreshTableMetaInBackground(tabId);
|
||||
logPhase("metadata-started", { tabId });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// data tab 的导航代次:每个 tab 只允许最新一次导航流程落地元数据/SQL/结果。
|
||||
// 目标身份(canApplyDataTabMetadata)无法区分同表不同 whereInput 或同表连点
|
||||
// 两次的请求代次,所有可复用 data tab 的导航入口(openTableTarget、侧边栏
|
||||
// openData)都必须经由这里作废旧代次,否则旧请求晚返回会覆盖新导航。
|
||||
const activeNavigationByTab = new Map<string, symbol>();
|
||||
|
||||
/** 开始一次导航流程:作废该 tab 的旧代次并返回本次 token */
|
||||
export function beginDataTabNavigation(tabId: string): symbol {
|
||||
const token = Symbol("data-tab-navigation");
|
||||
activeNavigationByTab.set(tabId, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** 本次导航是否仍是该 tab 的最新代次 */
|
||||
export function isCurrentDataTabNavigation(tabId: string, token: symbol): boolean {
|
||||
return activeNavigationByTab.get(tabId) === token;
|
||||
}
|
||||
|
||||
/** 导航流程结束:仍持有最新代次时清掉登记,Map 只在在途导航期间持有条目 */
|
||||
export function endDataTabNavigation(tabId: string, token: symbol): void {
|
||||
if (activeNavigationByTab.get(tabId) === token) activeNavigationByTab.delete(tabId);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ColumnInfo, QueryResult } from "@/types/database";
|
||||
|
||||
function sampleResult(): QueryResult {
|
||||
return { columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 };
|
||||
}
|
||||
|
||||
function column(name: string): ColumnInfo {
|
||||
return { name, data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null };
|
||||
}
|
||||
|
||||
describe("data tab snapshot restore vs tableMetaPending", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("does not roll back real metadata to a placeholder snapshot and re-pends placeholder restores", async () => {
|
||||
vi.doMock("@/lib/tabs/tabResultCache", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/tabs/tabResultCache")>();
|
||||
return {
|
||||
...actual,
|
||||
// 延迟元数据期间落盘的快照:空列占位身份
|
||||
readTabResultSnapshot: vi.fn(async () => ({ result: sampleResult(), tableMeta: { tableName: "users", schema: "public", columns: [], primaryKeys: [] } }) as any),
|
||||
};
|
||||
});
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
|
||||
const tabId = store.createTab("pg-1", "app", "users", "data", "public");
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
// 真实元数据已在快照落盘后到达并清除 pending
|
||||
tab.tableMeta = { tableName: "users", schema: "public", columns: [column("id")], primaryKeys: ["id"] };
|
||||
tab.tableMetaPending = false;
|
||||
tab.resultEvicted = true;
|
||||
tab.resultCacheKey = "cache-key";
|
||||
|
||||
await store.reloadEvictedTab(tabId);
|
||||
|
||||
// 快照确已恢复(防止"恢复根本没发生"导致的误通过)
|
||||
expect(tab.result).toBeDefined();
|
||||
expect(tab.resultEvicted).toBeUndefined();
|
||||
// 快照恢复不得用占位身份回滚真实元数据(否则 pending=false + 空主键 = 编辑门控失效)
|
||||
expect(tab.tableMeta?.columns.length).toBe(1);
|
||||
expect(tab.tableMeta?.primaryKeys).toEqual(["id"]);
|
||||
expect(tab.tableMetaPending).toBe(false);
|
||||
});
|
||||
|
||||
it("re-pends the edit gate when a restore leaves only placeholder metadata", async () => {
|
||||
vi.doMock("@/lib/tabs/tabResultCache", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/tabs/tabResultCache")>();
|
||||
return {
|
||||
...actual,
|
||||
readTabResultSnapshot: vi.fn(async () => ({ result: sampleResult(), tableMeta: { tableName: "users", schema: "public", columns: [], primaryKeys: [] } }) as any),
|
||||
};
|
||||
});
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
|
||||
const tabId = store.createTab("pg-1", "app", "users", "data", "public");
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
// 当前也只有占位身份(真实元数据从未落地)
|
||||
tab.tableMeta = { tableName: "users", schema: "public", columns: [], primaryKeys: [] };
|
||||
tab.tableMetaPending = false;
|
||||
tab.resultEvicted = true;
|
||||
tab.resultCacheKey = "cache-key";
|
||||
|
||||
await store.reloadEvictedTab(tabId);
|
||||
|
||||
expect(tab.result).toBeDefined();
|
||||
// 恢复后行标识仍未知:编辑门控必须重新挂起
|
||||
expect(tab.tableMetaPending).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -2240,6 +2240,11 @@ export const useQueryStore = defineStore("query", () => {
|
||||
if (tab) {
|
||||
tab.tableMeta = meta;
|
||||
tab.tableMetaUpdatedAt = Date.now();
|
||||
// 只有真实元数据(columns 非空)落地才结束行标识等待;多处调用方会先写
|
||||
// columns/primaryKeys 为空的占位身份(如 useNavigationTargets),不得
|
||||
// 借此提前解除编辑门控。失败/中止路径不清除——标签页保持只读是安全
|
||||
// 兜底,刷新或重开表会重新加载元数据恢复
|
||||
if (meta.columns.length > 0) tab.tableMetaPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3827,6 +3832,9 @@ export const useQueryStore = defineStore("query", () => {
|
||||
const executionId = tab.executionId;
|
||||
if (!executionId) return false;
|
||||
tab.isCancelling = true;
|
||||
// 单调递增、不随取消结果回退:导航流程据此判断"执行期间用户请求过停止"
|
||||
// (isCancelling 在取消失败或查询先完成时会被清掉,无法承担这个语义)
|
||||
tab.cancelRequestCount = (tab.cancelRequestCount ?? 0) + 1;
|
||||
const cancellationStartedAt = performance.now();
|
||||
try {
|
||||
const canceled = await withCancelQueryTimeout(api.cancelQuery(executionId));
|
||||
@@ -4010,7 +4018,17 @@ export const useQueryStore = defineStore("query", () => {
|
||||
tab.querySourceColumns = snapshot.querySourceColumns;
|
||||
tab.queryEditabilityReason = snapshot.queryEditabilityReason;
|
||||
tab.mongoEditTarget = snapshot.mongoEditTarget;
|
||||
tab.tableMeta = snapshot.tableMeta;
|
||||
// Data tab 快照可能是延迟元数据期间落盘的空列占位身份:不得用它回滚
|
||||
// 之后已落地的真实元数据(否则 pending 已清除而行标识又变未知,编辑
|
||||
// 门控失效);若恢复后确实没有真实列,重新挂起编辑门控
|
||||
if (tab.tableMeta?.columns.length && !snapshot.tableMeta?.columns.length) {
|
||||
// 保留当前真实元数据,忽略快照中的占位身份
|
||||
} else {
|
||||
tab.tableMeta = snapshot.tableMeta;
|
||||
}
|
||||
if (tab.mode === "data" && !tab.tableMeta?.columns.length) {
|
||||
tab.tableMetaPending = true;
|
||||
}
|
||||
tab.resultPageSql = snapshot.resultPageSql;
|
||||
tab.resultPageLimit = snapshot.resultPageLimit;
|
||||
tab.resultPageOffset = snapshot.resultPageOffset;
|
||||
|
||||
@@ -851,6 +851,11 @@ export interface QueryTab {
|
||||
primaryKeys: string[];
|
||||
};
|
||||
tableMetaUpdatedAt?: number;
|
||||
/** 冷缓存打开表数据时元数据仍在途:行标识未知,编辑/保存必须等待其落地 */
|
||||
tableMetaPending?: boolean;
|
||||
/** 取消请求单调计数:isCancelling 是瞬态的(取消失败/查询先完成会被清),
|
||||
* 需要跨越 executeTabSql 生命周期判断"执行期间用户是否请求过停止"时比对它 */
|
||||
cancelRequestCount?: number;
|
||||
tableInfoTab?: TableInfoTab;
|
||||
queryAnalysis?: {
|
||||
catalog?: string;
|
||||
|
||||
Reference in New Issue
Block a user