fix(quick-open): search unloaded database objects
This commit is contained in:
parent
990be3c329
commit
57416bfcea
|
|
@ -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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function flushAsyncWork(): Promise<void> {
|
||||
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<string, unknown> = {}) {
|
||||
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<void> {
|
||||
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<Array<{ name: string; type: "table" }>>();
|
||||
const beta = deferred<Array<{ name: string; type: "table" }>>();
|
||||
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<string>() });
|
||||
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<ReturnType<typeof deferred<Array<{ name: string; type: "table" }>>>> = [];
|
||||
let callIndex = 0;
|
||||
const listCompletionTables = vi.fn(() => {
|
||||
const request = deferred<Array<{ name: string; type: "table" }>>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ConnectionConfig["db_type"]>(["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<QuickOpenItem[]>([]);
|
||||
let remoteSearchGeneration = 0;
|
||||
let remoteSearchTimer: ReturnType<typeof setTimeout> | 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<string>): 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<string>();
|
||||
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<void> {
|
||||
if (activeRemoteRequests < REMOTE_SEARCH_CONCURRENCY) {
|
||||
activeRemoteRequests++;
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((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<void> {
|
||||
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<string>();
|
||||
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 => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue