diff --git a/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts index ea0f70abf..4195781b8 100644 --- a/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ ensureConnected: vi.fn(), executeTabSql: vi.fn(), loadTableMetadata: vi.fn(), + buildTableSelectSql: vi.fn(), setErrorResult: vi.fn(), cancelTabExecution: vi.fn(), })); @@ -81,7 +82,7 @@ vi.mock("@/stores/settingsStore", () => ({ vi.mock("@/lib/database/jdbcDialect", () => ({ effectiveDatabaseTypeForConnection: () => mocks.databaseType, connectionObjectTreeNodeSchema: (_config: unknown, _database: string, schema?: string) => schema, - connectionObjectTreeQuerySchema: (_config: unknown, _database: string, schema?: string) => schema ?? "", + connectionObjectTreeQuerySchema: (_config: unknown, database: string, schema?: string) => schema ?? database, })); vi.mock("@/lib/metadata/tableMetadataCache", async (importOriginal) => { @@ -97,7 +98,7 @@ vi.mock("@/lib/common/utils", () => ({ uuid: () => "open-data-id" })); vi.mock("@/lib/backend/debugLog", () => ({ appendDebugLog: vi.fn(), isDebugLoggingEnabled: () => false })); // dataTabOpenPolicy 使用真实实现,覆盖设置开关对应的复用范围 vi.mock("@/lib/sidebar/treeNodeContext", () => ({ hasTreeNodeDatabaseContext: () => true })); -vi.mock("@/lib/table/tableSelectSql", () => ({ buildTableSelectSql: async () => "SELECT * FROM users" })); +vi.mock("@/lib/table/tableSelectSql", () => ({ buildTableSelectSql: mocks.buildTableSelectSql })); vi.mock("@/lib/table/tableEditing", () => ({ usesSyntheticRowIdKey: () => false })); vi.mock("@/lib/table/tableOpenPageLimit", () => ({ tableOpenPageLimit: () => 100 })); vi.mock("@/lib/tabs/dataTabActivation", () => ({ canActivateExistingDataTableTab: () => false })); @@ -112,6 +113,14 @@ const tableNode: TreeNode = { tableType: "TABLE", }; +const mysqlTableNode: TreeNode = { + ...tableNode, + id: "table-zcyy-write-off-record", + label: "zcyy_write_off_record", + database: "yf_db", + schema: undefined, +}; + describe("useSidebarDataOpenRuntime", () => { beforeEach(() => { vi.clearAllMocks(); @@ -121,6 +130,7 @@ describe("useSidebarDataOpenRuntime", () => { mocks.cachedMetadata = undefined; mocks.reuseDataTab = false; mocks.ensureConnected.mockResolvedValue(undefined); + mocks.buildTableSelectSql.mockResolvedValue("SELECT * FROM users"); mocks.executeTabSql.mockImplementation(async () => { mocks.callOrder.push("query"); }); @@ -188,6 +198,55 @@ describe("useSidebarDataOpenRuntime", () => { }); }); + it("keeps MySQL data-tab identity unqualified after metadata loads", async () => { + mocks.databaseType = "mysql"; + mocks.loadTableMetadata.mockImplementation(async (request: { database: string; schema?: string; tableName: string; tableType?: string }) => ({ + metadata: { + schema: request.schema, + tableName: request.tableName, + tableType: request.tableType, + database: request.database, + columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + indexes: [], + primaryKeys: ["id"], + cachedAt: Date.now(), + }, + cacheStatus: "miss", + ageMs: 0, + })); + + await useSidebarDataOpenRuntime().openData(mysqlTableNode); + + await vi.waitFor(() => expect(mocks.tabs[0]?.tableMeta?.primaryKeys).toEqual(["id"])); + expect(mocks.loadTableMetadata).toHaveBeenCalledWith(expect.objectContaining({ database: "yf_db", schema: "yf_db" })); + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ database: "yf_db", schema: undefined, tableName: "zcyy_write_off_record" })); + expect(mocks.tabs[0]?.tableMeta).toMatchObject({ database: "yf_db", schema: undefined, tableName: "zcyy_write_off_record" }); + }); + + it("keeps cached MySQL table metadata unqualified", async () => { + mocks.databaseType = "mysql"; + mocks.cachedMetadata = { + metadata: { + schema: "yf_db", + tableName: "zcyy_write_off_record", + tableType: "TABLE", + database: "yf_db", + columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + indexes: [], + primaryKeys: ["id"], + cachedAt: Date.now(), + }, + cacheStatus: "hit", + ageMs: 0, + }; + + await useSidebarDataOpenRuntime().openData(mysqlTableNode); + + expect(mocks.loadTableMetadata).not.toHaveBeenCalled(); + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ database: "yf_db", schema: undefined, tableName: "zcyy_write_off_record" })); + expect(mocks.tabs[0]?.tableMeta).toMatchObject({ database: "yf_db", schema: undefined, tableName: "zcyy_write_off_record" }); + }); + it("keeps row identity pending while delayed metadata is in flight and the query finishes first", async () => { // 元数据延迟:查询先返回,元数据仍挂起 let releaseMetadata: () => void = () => {}; diff --git a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts index cebde0a41..8c2aaad15 100644 --- a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts +++ b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts @@ -108,7 +108,7 @@ export function useSidebarDataOpenRuntime() { }); return; } - const nextTableMeta = tableMetadataToDataTabMeta(loadedMetadata.metadata, tableSchema); + const nextTableMeta = tableMetadataToDataTabMeta(loadedMetadata.metadata, { schema: tableSchema }); queryStore.setTableMeta(targetTabId, nextTableMeta); openDataLog("info", "metadata:done", { traceId, @@ -223,7 +223,7 @@ export function useSidebarDataOpenRuntime() { // 空列的共享缓存条目不算暖缓存:columns=[] 无法区分"表确实无列"与 // 占位/异常态,按暖缓存跳过 pending 与刷新会让整行 WHERE 保存路径 // 在行标识未知时重新可用(#3727 审查意见) - const cachedTableMeta = sharedCachedTableMeta?.metadata.columns.length ? tableMetadataToDataTabMeta(sharedCachedTableMeta.metadata, tableSchema) : tabCachedTableMeta; + const cachedTableMeta = sharedCachedTableMeta?.metadata.columns.length ? tableMetadataToDataTabMeta(sharedCachedTableMeta.metadata, { schema: tableSchema }) : tabCachedTableMeta; const cachedTableMetaAgeMs = sharedCachedTableMeta?.metadata.columns.length ? sharedCachedTableMeta.ageMs : existingTableMetaAgeMs; const cachedTableMetaSource = sharedCachedTableMeta?.metadata.columns.length ? "shared" : tabCachedTableMeta ? "tab" : undefined; queryStore.setTableMeta( diff --git a/apps/desktop/src/lib/metadata/tableMetadataCache.ts b/apps/desktop/src/lib/metadata/tableMetadataCache.ts index df4cbdce1..c052d4518 100644 --- a/apps/desktop/src/lib/metadata/tableMetadataCache.ts +++ b/apps/desktop/src/lib/metadata/tableMetadataCache.ts @@ -103,9 +103,9 @@ export function getCachedTableMetadata(request: Pick { +export function tableMetadataToDataTabMeta(metadata: TableMetadata, overrides?: { schema?: string }): NonNullable { return { - schema, + schema: overrides ? overrides.schema : metadata.schema, tableName: metadata.tableName, tableType: metadata.tableType, catalog: metadata.catalog, diff --git a/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts index ecfbe5f8a..407b4854e 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts @@ -166,6 +166,33 @@ describe("queryStore table data refresh", () => { expect(store.tabs.find((tab) => tab.id === secondTabId)?.result).toBeUndefined(); }); + it("keeps a MySQL table refresh unqualified in the selected database context", async () => { + mocks.getConnectionConfig.mockReturnValue({ + id: "mysql-1", + name: "MySQL Proxy", + db_type: "mysql", + database: "yf_db", + query_timeout_secs: 30, + }); + mocks.buildTableSelectSql.mockResolvedValue("SELECT * FROM `zcyy_write_off_record` LIMIT 100;"); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("mysql-1", "yf_db", "zcyy_write_off_record", "data"); + store.setTableMeta(tabId, { + database: "yf_db", + schema: undefined, + tableName: "zcyy_write_off_record", + tableType: "TABLE", + columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + primaryKeys: ["id"], + }); + + await expect(store.refreshDataTab(tabId)).resolves.toBe(true); + + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ databaseType: "mysql", database: "yf_db", schema: undefined, tableName: "zcyy_write_off_record" })); + expect(store.tabs.find((tab) => tab.id === tabId)?.sql).toBe("SELECT * FROM `zcyy_write_off_record` LIMIT 100;"); + }); + it("clears a structured sort when refreshed table metadata no longer contains its column", async () => { const { useQueryStore } = await import("@/stores/queryStore"); const store = useQueryStore();