diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 15e98d89c6..8c25882033 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -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" diff --git a/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts index 17ceba9d96..8dbb6b9fb2 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts @@ -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) => { + 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 { 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", diff --git a/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts b/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts new file mode 100644 index 0000000000..b1adaa113b --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts @@ -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) => { + 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 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 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 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]); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts new file mode 100644 index 0000000000..4510b96608 --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts @@ -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) => { + 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(); + 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((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((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((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(); + }); +}); diff --git a/apps/desktop/src/composables/useDataGridActions.ts b/apps/desktop/src/composables/useDataGridActions.ts index 8645dbb7ed..960bd1036c 100644 --- a/apps/desktop/src/composables/useDataGridActions.ts +++ b/apps/desktop/src/composables/useDataGridActions.ts @@ -49,6 +49,10 @@ export function useDataGridActions(activeTab: ComputedRef) 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) 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) 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) 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) } 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) { diff --git a/apps/desktop/src/composables/useNavigationTargets.ts b/apps/desktop/src/composables/useNavigationTargets.ts index 285726b443..223c8a6eaa 100644 --- a/apps/desktop/src/composables/useNavigationTargets.ts +++ b/apps/desktop/src/composables/useNavigationTargets.ts @@ -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, }); diff --git a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts index 4e684b397d..a8b36d29f5 100644 --- a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts +++ b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts @@ -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 }); } diff --git a/apps/desktop/src/lib/tabs/dataTabNavigationGeneration.ts b/apps/desktop/src/lib/tabs/dataTabNavigationGeneration.ts new file mode 100644 index 0000000000..cf39de87d0 --- /dev/null +++ b/apps/desktop/src/lib/tabs/dataTabNavigationGeneration.ts @@ -0,0 +1,22 @@ +// data tab 的导航代次:每个 tab 只允许最新一次导航流程落地元数据/SQL/结果。 +// 目标身份(canApplyDataTabMetadata)无法区分同表不同 whereInput 或同表连点 +// 两次的请求代次,所有可复用 data tab 的导航入口(openTableTarget、侧边栏 +// openData)都必须经由这里作废旧代次,否则旧请求晚返回会覆盖新导航。 +const activeNavigationByTab = new Map(); + +/** 开始一次导航流程:作废该 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); +} diff --git a/apps/desktop/src/stores/__tests__/queryStore.tableMetaRestore.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.tableMetaRestore.spec.ts new file mode 100644 index 0000000000..90e92c9773 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/queryStore.tableMetaRestore.spec.ts @@ -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(); + 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(); + 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); + }); +}); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index f44d368815..da8dc13562 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -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; diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index ed110cd082..5ed7d752a5 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -851,6 +851,11 @@ export interface QueryTab { primaryKeys: string[]; }; tableMetaUpdatedAt?: number; + /** 冷缓存打开表数据时元数据仍在途:行标识未知,编辑/保存必须等待其落地 */ + tableMetaPending?: boolean; + /** 取消请求单调计数:isCancelling 是瞬态的(取消失败/查询先完成会被清), + * 需要跨越 executeTabSql 生命周期判断"执行期间用户是否请求过停止"时比对它 */ + cancelRequestCount?: number; tableInfoTab?: TableInfoTab; queryAnalysis?: { catalog?: string;