fix(sidebar): preserve primary keys on first table open

This commit is contained in:
vrustx
2026-07-19 15:38:50 +08:00
committed by GitHub
parent 7b14ef3b48
commit 5289dc819d
11 changed files with 978 additions and 21 deletions
@@ -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);
});
});
+19 -1
View File
@@ -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;
+5
View File
@@ -851,6 +851,11 @@ export interface QueryTab {
primaryKeys: string[];
};
tableMetaUpdatedAt?: number;
/** 冷缓存打开表数据时元数据仍在途:行标识未知,编辑/保存必须等待其落地 */
tableMetaPending?: boolean;
/** 取消请求单调计数:isCancelling 是瞬态的(取消失败/查询先完成会被清),
* 需要跨越 executeTabSql 生命周期判断"执行期间用户是否请求过停止"时比对它 */
cancelRequestCount?: number;
tableInfoTab?: TableInfoTab;
queryAnalysis?: {
catalog?: string;