From 2aeafa136c99bd63266c43d6d1601a4a85a545ff Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sun, 2 Aug 2026 19:46:44 +0800 Subject: [PATCH] fix(editor): refresh diagnostics after structure saves --- apps/desktop/src/App.vue | 1 + .../__tests__/useNavigationTargets.spec.ts | 4 ++ .../useNavigationTargets.store.spec.ts | 1 + .../src/composables/useNavigationTargets.ts | 3 +- .../connectionStore.completion.spec.ts | 72 +++++++++++++++++++ apps/desktop/src/stores/connectionStore.ts | 41 +++++++++++ apps/desktop/src/stores/queryStore.ts | 4 ++ packages/app-tests/queryStore.test.ts | 14 ++++ 8 files changed, 139 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index fa14d1a7c..3cb7815e6 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -2399,6 +2399,7 @@ onUnmounted(() => { connectionId: activeTab.connectionId, database: activeTab.database, schema: activeTab.schema, + catalog: activeTab.catalog, tableName: activeTab.structureTableName || '', }, commentChanged, diff --git a/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts b/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts index c3cea8eae..8d615bcda 100644 --- a/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts +++ b/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({ ensureConnected: vi.fn(), executeTabSql: vi.fn(), getColumns: vi.fn(), + invalidateCompletionTableCache: vi.fn(), listIndexes: vi.fn(), setTableMeta: vi.fn(), updateSql: vi.fn(), @@ -27,6 +28,7 @@ vi.mock("@/stores/connectionStore", () => ({ ensureConnected: mocks.ensureConnected, connectionIdentifierQuote: () => undefined, refreshObjectListTreeNode: vi.fn(), + invalidateCompletionTableCache: mocks.invalidateCompletionTableCache, }), })); @@ -142,9 +144,11 @@ describe("useNavigationTargets openTableTarget", () => { connectionId: target.connectionId, database: target.database, schema: target.schema, + catalog: target.catalog, tableName: target.tableName, }); + expect(mocks.invalidateCompletionTableCache).toHaveBeenCalledWith("connection-1", "app", "users", "public", "catalog-1"); expect(mocks.getColumns).toHaveBeenCalledTimes(2); expect(mocks.tabs.map((tab) => tab.tableMeta?.primaryKeys)).toEqual([["fresh_id"], ["fresh_id"]]); }); diff --git a/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts b/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts index 22e6b754d..d06ede977 100644 --- a/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts +++ b/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ ensureConnected: vi.fn(), connectionIdentifierQuote: vi.fn(() => undefined), refreshObjectListTreeNode: vi.fn(), + invalidateCompletionTableCache: vi.fn(), }, settingsStore: { editorSettings: { diff --git a/apps/desktop/src/composables/useNavigationTargets.ts b/apps/desktop/src/composables/useNavigationTargets.ts index 292c45032..339ab3205 100644 --- a/apps/desktop/src/composables/useNavigationTargets.ts +++ b/apps/desktop/src/composables/useNavigationTargets.ts @@ -297,7 +297,7 @@ export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value: await openTableTarget(target); } - async function onStructureEditorSaved(reloadData: () => Promise, toast: (msg: string, duration?: number) => void, context: { connectionId: string; database: string; schema?: string; tableName: string }, commentChanged?: boolean) { + async function onStructureEditorSaved(reloadData: () => Promise, toast: (msg: string, duration?: number) => void, context: { connectionId: string; database: string; schema?: string; catalog?: string; tableName: string }, commentChanged?: boolean) { if (!context.tableName) { try { await connectionStore.refreshObjectListTreeNode(context.connectionId, context.database, context.schema || undefined); @@ -309,6 +309,7 @@ export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value: await connectionStore.refreshObjectListTreeNode(context.connectionId, context.database, context.schema || undefined); } catch {} } + connectionStore.invalidateCompletionTableCache(context.connectionId, context.database, context.tableName, context.schema, context.catalog); queryStore.invalidateTableStructure(context.connectionId, context.database, context.schema, context.tableName); // 结构已变更:无论是否有打开的 data tab 都必须作废共享元数据缓存,否则 // 其它 loadTableMetadata 消费者最长 30 秒拿到旧列。不带 schema/catalog diff --git a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts index dbe0b05fa..1392f8a3c 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts @@ -391,6 +391,78 @@ describe("connectionStore completion assistant", () => { expect(quoted).toEqual([expect.objectContaining({ name: "MixedColumn", table: "MixedTable", schema: "MixedSchema" })]); }); + it("invalidates only the changed table completion metadata", async () => { + const getColumns = vi.fn(async (_connectionId: string, _database: string, _schema: string, table: string) => [ + { + name: `${table}_column_${getColumns.mock.calls.length}`, + data_type: "integer", + is_nullable: false, + column_default: null, + is_primary_key: false, + extra: null, + }, + ]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + completionAssistantSearch: vi.fn().mockRejectedValue(new Error("assistant unavailable")), + getColumns, + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [sqlServerConnection()]; + store.connectedIds.add("sqlserver-1"); + + await store.listCompletionColumns("sqlserver-1", "app", "users", "dbo"); + await store.listCompletionColumns("sqlserver-1", "app", "orders", "dbo"); + await store.listCompletionColumns("sqlserver-1", "app", "users", "dbo"); + await store.listCompletionColumns("sqlserver-1", "app", "orders", "dbo"); + expect(getColumns.mock.calls.map((call) => call[3])).toEqual(["users", "orders"]); + + expect(store.invalidateCompletionTableCache("sqlserver-1", "app", "users", "dbo")).toBeGreaterThan(0); + + await store.listCompletionColumns("sqlserver-1", "app", "users", "dbo"); + await store.listCompletionColumns("sqlserver-1", "app", "orders", "dbo"); + expect(getColumns.mock.calls.map((call) => call[3])).toEqual(["users", "orders", "users"]); + }); + + it("keeps the same table cached in other catalogs", async () => { + const getColumns = vi.fn(async (_connectionId: string, _database: string, _schema: string, table: string, catalog?: string) => [ + { + name: `${catalog}_${table}`, + data_type: "integer", + is_nullable: false, + column_default: null, + is_primary_key: false, + extra: null, + }, + ]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + completionAssistantSearch: vi.fn(), + getColumns, + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [dorisConnection()]; + store.connectedIds.add("doris-1"); + + await store.listCompletionColumns("doris-1", "sales", "users", undefined, undefined, "internal"); + await store.listCompletionColumns("doris-1", "sales", "users", undefined, undefined, "hive"); + expect(getColumns.mock.calls.map((call) => call[4])).toEqual(["internal", "hive"]); + + expect(store.invalidateCompletionTableCache("doris-1", "sales", "users", undefined, "internal")).toBeGreaterThan(0); + + await store.listCompletionColumns("doris-1", "sales", "users", undefined, undefined, "internal"); + await store.listCompletionColumns("doris-1", "sales", "users", undefined, undefined, "hive"); + expect(getColumns.mock.calls.map((call) => call[4])).toEqual(["internal", "hive", "internal"]); + }); + it("keeps quoted and unquoted Oracle objects separate in the local column index", async () => { const completionAssistantSearch = vi.fn(async (request: { parent_name?: string | null }) => ({ candidates: [ diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index cde022c80..70f099ad8 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -5293,6 +5293,46 @@ export const useConnectionStore = defineStore("connection", () => { return `${completionScopeKey(connectionId, database, schema)}:${table.toLowerCase()}:fkeys`; } + function completionTableCacheKeyMatches(key: string, connectionId: string, database: string, tableName: string, schema?: string, catalog?: string): boolean { + const normalizedKey = key.toLowerCase(); + const prefix = `${connectionId}:${database}:`.toLowerCase(); + if (!normalizedKey.startsWith(prefix)) return false; + const tableToken = `:${tableName.toLowerCase()}`; + const tableOffset = normalizedKey.lastIndexOf(tableToken); + if (tableOffset < prefix.length) return false; + const trailing = normalizedKey.slice(tableOffset + tableToken.length); + if (trailing && !trailing.startsWith(":")) return false; + const normalizedSchema = schema?.trim().toLowerCase(); + const normalizedCatalog = catalog?.trim().toLowerCase(); + const scope = normalizedKey.slice(prefix.length, tableOffset); + if (normalizedCatalog) { + const catalogScope = `${normalizedCatalog}:${normalizedSchema ?? ""}`; + return scope === catalogScope || (!!normalizedSchema && scope === normalizedSchema); + } + if (!normalizedSchema) return true; + return scope === normalizedSchema || scope.endsWith(`:${normalizedSchema}`); + } + + function invalidateCompletionTableCache(connectionId: string, database: string, tableName: string, schema?: string, catalog?: string): number { + const matches = (key: string) => completionTableCacheKeyMatches(key, connectionId, database, tableName, schema, catalog); + let removed = 0; + for (const cache of [completionColumnsCache.value, completionForeignKeysCache.value]) { + for (const key of Object.keys(cache)) { + if (!matches(key)) continue; + delete cache[key]; + removed++; + } + } + for (const cache of [completionColumnIndex, completionForeignKeyIndex, completionInFlight]) { + for (const key of cache.keys()) { + if (!matches(key)) continue; + cache.delete(key); + removed++; + } + } + return removed; + } + function touchCompletionIndex(index: Map, key: string, value: T, max = COMPLETION_CACHE_MAX) { index.set(key, { ...value, touched: Date.now() }); if (index.size <= max) return; @@ -6854,6 +6894,7 @@ export const useConnectionStore = defineStore("connection", () => { listMongoCompletionCollections, listMongoCompletionFields, invalidateCompletionCache, + invalidateCompletionTableCache, invalidateMetadataCache, exportConnectionsToFile, readImportFile, diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 23142c8f5..43dde67d4 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -668,6 +668,10 @@ export const useQueryStore = defineStore("query", () => { ...tableStructureRefreshVersions.value, [key]: (tableStructureRefreshVersions.value[key] ?? 0) + 1, }; + for (const tab of tabs.value) { + if (tab.mode !== "query" || tab.connectionId !== connectionId || tab.database !== database) continue; + tab.completionContextVersion = (tab.completionContextVersion ?? 0) + 1; + } } function tableStructureRefreshVersion(connectionId: string, database: string, schema: string | undefined, tableName: string): number { diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index d3a9596b8..6a8b796fa 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -6940,6 +6940,20 @@ test("table structure refresh versions are scoped by table target", () => { assert.equal(store.tableStructureRefreshVersion("conn-1", "db", "public", "orders"), 0); }); +test("table structure invalidation refreshes matching query completion contexts", () => { + setActivePinia(createPinia()); + const store = useQueryStore(); + const matchingTabId = store.createTab("conn-1", "db", "Query A", "query", "public"); + const sameDatabaseTabId = store.createTab("conn-1", "db", "Query B", "query", "audit"); + const otherDatabaseTabId = store.createTab("conn-1", "analytics", "Query C", "query", "public"); + + store.invalidateTableStructure("conn-1", "db", "public", "users"); + + assert.equal(store.tabs.find((tab) => tab.id === matchingTabId)?.completionContextVersion, 1); + assert.equal(store.tabs.find((tab) => tab.id === sameDatabaseTabId)?.completionContextVersion, 1); + assert.equal(store.tabs.find((tab) => tab.id === otherDatabaseTabId)?.completionContextVersion, undefined); +}); + test("duplicating a table structure tab clones its unsaved draft", () => { setActivePinia(createPinia()); const store = useQueryStore();