From 57416bfcea19fb8bbe792fcf460c5dc0af4af2b3 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 17 Jul 2026 22:54:31 +0800 Subject: [PATCH] fix(quick-open): search unloaded database objects --- .../__tests__/useQuickOpen.spec.ts | 217 +++++++++++++++++- apps/desktop/src/composables/useQuickOpen.ts | 149 +++++++++++- 2 files changed, 362 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/composables/__tests__/useQuickOpen.spec.ts b/apps/desktop/src/composables/__tests__/useQuickOpen.spec.ts index a88994476..66adb2b13 100644 --- a/apps/desktop/src/composables/__tests__/useQuickOpen.spec.ts +++ b/apps/desktop/src/composables/__tests__/useQuickOpen.spec.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi } from "vitest"; +import { nextTick } from "vue"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useQuickOpen } from "@/composables/useQuickOpen"; import { useConnectionStore } from "@/stores/connectionStore"; @@ -6,6 +7,22 @@ vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: vi.fn(), })); +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +async function flushAsyncWork(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await nextTick(); +} + describe("useQuickOpen", () => { describe("fuzzyMatch function", () => { it("should return exact substring match with score 1", () => { @@ -578,4 +595,202 @@ describe("useQuickOpen", () => { expect(funcItem?.label).toBe("ComputeAge"); }); }); + + describe("remote metadata search", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + function remoteSearchStore(overrides: Record = {}) { + return { + connections: [{ id: "conn1", name: "MySQL", db_type: "mysql" }], + connectedIds: new Set(["conn1"]), + treeNodes: [ + { + id: "conn1:app", + connectionId: "conn1", + type: "database", + database: "app", + label: "app", + }, + ], + listCompletionTables: vi.fn().mockResolvedValue([]), + ...overrides, + }; + } + + async function runDebouncedSearch(): Promise { + await vi.advanceTimersByTimeAsync(200); + await flushAsyncWork(); + } + + it("finds unloaded tables through server metadata", async () => { + const mockStore = remoteSearchStore({ + listCompletionTables: vi.fn().mockResolvedValue([{ name: "orders", type: "table" }]), + }); + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { filteredItems, setQuery } = useQuickOpen(); + setQuery("ord"); + await runDebouncedSearch(); + + expect(mockStore.listCompletionTables).toHaveBeenCalledWith("conn1", "app", "ord", 25, undefined, true); + expect(filteredItems.value).toEqual(expect.arrayContaining([expect.objectContaining({ label: "orders", type: "table", database: "app" })])); + }); + + it("deduplicates loaded and remote table results", async () => { + const mockStore = remoteSearchStore({ + treeNodes: [ + { + id: "conn1:app", + connectionId: "conn1", + type: "database", + database: "app", + label: "app", + children: [ + { + id: "conn1:app:users", + connectionId: "conn1", + type: "table", + database: "app", + label: "users", + }, + ], + }, + ], + listCompletionTables: vi.fn().mockResolvedValue([{ name: "users", type: "table" }]), + }); + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { filteredItems, setQuery } = useQuickOpen(); + setQuery("users"); + await runDebouncedSearch(); + + expect(filteredItems.value.filter((item) => item.label === "users")).toHaveLength(1); + }); + + it("ignores stale remote responses", async () => { + const alpha = deferred>(); + const beta = deferred>(); + const mockStore = remoteSearchStore({ + listCompletionTables: vi.fn((_connectionId, _database, query) => (query === "alpha" ? alpha.promise : beta.promise)), + }); + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { filteredItems, setQuery } = useQuickOpen(); + setQuery("alpha"); + await runDebouncedSearch(); + setQuery("beta"); + await runDebouncedSearch(); + + beta.resolve([{ name: "beta_table", type: "table" }]); + await flushAsyncWork(); + expect(filteredItems.value.map((item) => item.label)).toContain("beta_table"); + + alpha.resolve([{ name: "alpha_table", type: "table" }]); + await flushAsyncWork(); + expect(filteredItems.value.map((item) => item.label)).toContain("beta_table"); + expect(filteredItems.value.map((item) => item.label)).not.toContain("alpha_table"); + }); + + it("does not request metadata for empty or one-character queries", async () => { + const mockStore = remoteSearchStore(); + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { setQuery } = useQuickOpen(); + setQuery(""); + setQuery("a"); + await runDebouncedSearch(); + + expect(mockStore.listCompletionTables).not.toHaveBeenCalled(); + }); + + it("does not request metadata from disconnected contexts", async () => { + const mockStore = remoteSearchStore({ connectedIds: new Set() }); + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { setQuery } = useQuickOpen(); + setQuery("users"); + await runDebouncedSearch(); + + expect(mockStore.listCompletionTables).not.toHaveBeenCalled(); + }); + + it("keeps local results when remote metadata search fails", async () => { + const mockStore = remoteSearchStore({ + treeNodes: [ + { + id: "conn1:app", + connectionId: "conn1", + type: "database", + database: "app", + label: "app", + children: [ + { + id: "conn1:app:users", + connectionId: "conn1", + type: "table", + database: "app", + label: "users", + }, + ], + }, + ], + listCompletionTables: vi.fn().mockRejectedValue(new Error("metadata unavailable")), + }); + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { filteredItems, setQuery } = useQuickOpen(); + setQuery("users"); + await runDebouncedSearch(); + + expect(filteredItems.value.map((item) => item.label)).toContain("users"); + }); + + it("caps requests, concurrency, and merged remote results", async () => { + const pending: Array>>> = []; + let callIndex = 0; + const listCompletionTables = vi.fn(() => { + const request = deferred>(); + pending.push(request); + return request.promise; + }); + const mockStore = remoteSearchStore({ + treeNodes: Array.from({ length: 12 }, (_, index) => ({ + id: `conn1:db${index}`, + connectionId: "conn1", + type: "database", + database: `db${index}`, + label: `db${index}`, + })), + listCompletionTables, + }); + vi.mocked(useConnectionStore).mockReturnValue(mockStore as any); + + const { filteredItems, setQuery } = useQuickOpen(); + setQuery("table"); + await vi.advanceTimersByTimeAsync(200); + await flushAsyncWork(); + expect(listCompletionTables).toHaveBeenCalledTimes(2); + + for (let wave = 0; wave < 4; wave++) { + const active = pending.slice(wave * 2, wave * 2 + 2); + for (const request of active) { + const requestIndex = callIndex++; + request.resolve(Array.from({ length: 30 }, (_, index) => ({ name: `table_${requestIndex}_${index}`, type: "table" }))); + } + await flushAsyncWork(); + expect(listCompletionTables.mock.calls.length).toBeLessThanOrEqual(Math.min((wave + 2) * 2, 8)); + } + + await flushAsyncWork(); + expect(listCompletionTables).toHaveBeenCalledTimes(8); + expect(filteredItems.value).toHaveLength(100); + }); + }); }); diff --git a/apps/desktop/src/composables/useQuickOpen.ts b/apps/desktop/src/composables/useQuickOpen.ts index 3e7ac0c89..3c1b17645 100644 --- a/apps/desktop/src/composables/useQuickOpen.ts +++ b/apps/desktop/src/composables/useQuickOpen.ts @@ -1,7 +1,18 @@ -import { computed, ref } from "vue"; +import { computed, ref, watch } from "vue"; import type { ConnectionConfig } from "@/types/database"; +import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion"; import { useConnectionStore } from "@/stores/connectionStore"; +const REMOTE_SEARCH_DEBOUNCE_MS = 180; +const REMOTE_SEARCH_MIN_QUERY_LENGTH = 2; +const REMOTE_SEARCH_MAX_REQUESTS = 8; +const REMOTE_SEARCH_CONCURRENCY = 2; +const REMOTE_SEARCH_RESULTS_PER_REQUEST = 25; +const REMOTE_SEARCH_MAX_RESULTS = 100; +const QUICK_OPEN_MAX_RESULTS = 200; + +const REMOTE_SEARCH_UNSUPPORTED_TYPES = new Set(["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "weaviate", "chromadb", "neo4j", "influxdb", "etcd", "zookeeper", "mq", "nacos"]); + export interface QuickOpenItem { id: string; type: "connection" | "database" | "schema" | "table" | "view" | "materialized_view" | "procedure" | "function" | "sequence" | "package" | "package-body"; @@ -66,6 +77,11 @@ export function useQuickOpen() { const connectionStore = useConnectionStore(); const searchQuery = ref(""); const selectedIndex = ref(0); + const remoteItems = ref([]); + let remoteSearchGeneration = 0; + let remoteSearchTimer: ReturnType | undefined; + let activeRemoteRequests = 0; + const remoteRequestWaiters: Array<() => void> = []; const allItems = computed((): QuickOpenItem[] => { const items: QuickOpenItem[] = []; @@ -286,6 +302,129 @@ export function useQuickOpen() { return undefined; } + function quickOpenItemKey(item: QuickOpenItem): string { + if (item.type === "table" || item.type === "view" || item.type === "materialized_view") { + return `${item.connectionId}:${item.database ?? ""}:${item.schema ?? ""}:${item.tableName ?? item.objectName ?? item.label}`.toLowerCase(); + } + return item.id.toLowerCase(); + } + + function remoteTableItem(table: SqlCompletionTable, conn: ConnectionConfig, database: string): QuickOpenItem { + const type = table.type ?? "table"; + const prefix = type === "materialized_view" ? "mview" : type; + return { + id: `${prefix}-${conn.id}-${database}-${table.schema || ""}-${table.name}`, + type, + label: table.name, + description: `${conn.name} / ${database}${table.schema ? " / " + table.schema : ""}`, + connectionId: conn.id, + database, + schema: table.schema, + ...(type === "table" ? { tableName: table.name } : { objectName: table.name }), + connectionName: conn.name, + searchText: `${conn.name} ${database} ${table.schema || ""} ${table.name}`, + }; + } + + function collectConnectionDatabases(nodes: any[], connectionId: string, databases: Set): void { + for (const node of nodes) { + if (node.connectionId === connectionId && node.type === "database" && node.database) { + databases.add(node.database); + } + if (node.children) collectConnectionDatabases(node.children, connectionId, databases); + } + } + + function remoteSearchContexts(): Array<{ conn: ConnectionConfig; database: string }> { + if (typeof connectionStore.listCompletionTables !== "function") return []; + const connectedIds = connectionStore.connectedIds; + if (!(connectedIds instanceof Set)) return []; + + const databasesByConnection: Array<{ conn: ConnectionConfig; databases: string[] }> = []; + for (const conn of connectionStore.connections) { + if (!connectedIds.has(conn.id) || REMOTE_SEARCH_UNSUPPORTED_TYPES.has(conn.db_type)) continue; + const databases = new Set(); + collectConnectionDatabases(connectionStore.treeNodes, conn.id, databases); + if (conn.database?.trim()) databases.add(conn.database.trim()); + for (const database of conn.visible_databases ?? []) { + if (database.trim()) databases.add(database.trim()); + } + for (const database of conn.attached_databases ?? []) { + if (database.name.trim()) databases.add(database.name.trim()); + } + if (databases.size > 0) databasesByConnection.push({ conn, databases: [...databases] }); + } + + const contexts: Array<{ conn: ConnectionConfig; database: string }> = []; + for (let databaseIndex = 0; contexts.length < REMOTE_SEARCH_MAX_REQUESTS; databaseIndex++) { + let added = false; + for (const { conn, databases } of databasesByConnection) { + const database = databases[databaseIndex]; + if (!database) continue; + contexts.push({ conn, database }); + added = true; + if (contexts.length >= REMOTE_SEARCH_MAX_REQUESTS) break; + } + if (!added) break; + } + return contexts; + } + + async function acquireRemoteRequestSlot(): Promise { + if (activeRemoteRequests < REMOTE_SEARCH_CONCURRENCY) { + activeRemoteRequests++; + return; + } + await new Promise((resolve) => remoteRequestWaiters.push(resolve)); + } + + function releaseRemoteRequestSlot(): void { + const next = remoteRequestWaiters.shift(); + if (next) next(); + else activeRemoteRequests--; + } + + async function runRemoteSearch(query: string, generation: number, contexts: Array<{ conn: ConnectionConfig; database: string }>): Promise { + const groups = await Promise.all( + contexts.map(async ({ conn, database }) => { + await acquireRemoteRequestSlot(); + try { + // A newer query may supersede queued work before it reaches the metadata API. + if (generation !== remoteSearchGeneration) return []; + const tables = await connectionStore.listCompletionTables(conn.id, database, query, REMOTE_SEARCH_RESULTS_PER_REQUEST, undefined, true); + return tables.slice(0, REMOTE_SEARCH_RESULTS_PER_REQUEST).map((table) => remoteTableItem(table, conn, database)); + } catch { + return []; + } finally { + releaseRemoteRequestSlot(); + } + }), + ); + + if (generation !== remoteSearchGeneration) return; + remoteItems.value = groups.flat().slice(0, REMOTE_SEARCH_MAX_RESULTS); + } + + watch( + searchQuery, + (query) => { + const generation = ++remoteSearchGeneration; + if (remoteSearchTimer) clearTimeout(remoteSearchTimer); + remoteItems.value = []; + + const normalizedQuery = query.trim(); + if (normalizedQuery.length < REMOTE_SEARCH_MIN_QUERY_LENGTH) return; + const contexts = remoteSearchContexts(); + if (contexts.length === 0) return; + + remoteSearchTimer = setTimeout(() => { + remoteSearchTimer = undefined; + void runRemoteSearch(normalizedQuery, generation, contexts); + }, REMOTE_SEARCH_DEBOUNCE_MS); + }, + { flush: "sync" }, + ); + const filteredItems = computed((): MatchedItem[] => { if (!searchQuery.value.trim()) { return allItems.value.map((item) => ({ @@ -297,7 +436,11 @@ export function useQuickOpen() { const matched: MatchedItem[] = []; - for (const item of allItems.value) { + const seen = new Set(); + for (const item of [...allItems.value, ...remoteItems.value]) { + const key = quickOpenItemKey(item); + if (seen.has(key)) continue; + seen.add(key); const result = fuzzyMatch(searchQuery.value, item.searchText); if (result) { matched.push({ @@ -330,7 +473,7 @@ export function useQuickOpen() { return typeOrder[a.type] - typeOrder[b.type]; }); - return matched; + return matched.slice(0, QUICK_OPEN_MAX_RESULTS); }); const selectedItem = computed((): MatchedItem | null => {