fix(completion): add contains fallback for first fuzzy input

This commit is contained in:
zipg
2026-09-18 22:47:40 +08:00
committed by GitHub
parent ab2ef4a66a
commit ef086865dd
5 changed files with 106 additions and 12 deletions
@@ -374,7 +374,8 @@ describe("connectionStore completion assistant", () => {
const [first, second] = await Promise.all([store.listCompletionTables("pg-1", "app", "acc", 20, "public"), store.listCompletionTables("pg-1", "app", "acc", 20, "public")]);
expect(completionAssistantSearch).toHaveBeenCalledTimes(1);
// Both callers share one in-flight prefix lookup plus its widened substring lookup.
expect(completionAssistantSearch.mock.calls.map(([request]) => (request as { match_mode?: string }).match_mode)).toEqual(["prefix", "contains"]);
expect(first).toEqual(second);
expect(first[0]).toMatchObject({ name: "accounts", schema: "public", type: "table" });
});
@@ -426,11 +427,62 @@ describe("connectionStore completion assistant", () => {
const tables = await store.listCompletionTables("pg-1", "app", "accounts", 200, "reporting", false, "reporting", undefined, { verifySchemaMetadata: true });
expect(completionAssistantSearch).toHaveBeenCalledOnce();
expect(completionAssistantSearch.mock.calls.map(([request]) => (request as { match_mode?: string }).match_mode)).toEqual(["prefix", "contains"]);
expect(listTables).toHaveBeenCalledWith("pg-1", "app", "reporting", "accounts", 200);
expect(tables).toEqual([{ name: "accounts", schema: "reporting", type: "table", detail: "→ Customer accounts" }]);
});
it("widens a sparse prefix search with substring matches", async () => {
const completionAssistantSearch = vi.fn(async (request: { match_mode?: string }) =>
request.match_mode === "contains" ? { candidates: [{ name: "YY_SFXXMK", kind: "view", schema: "public" }], incomplete: false, fallback_used: false } : { candidates: [{ name: "SFXXMK", kind: "table", schema: "public" }], incomplete: false, fallback_used: false },
);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
completionAssistantSearch,
listSchemas: vi.fn().mockResolvedValue(["public"]),
listTables: vi.fn().mockResolvedValue([]),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
store.connections = [postgresConnection()];
store.connectedIds.add("pg-1");
const tables = await store.listCompletionTables("pg-1", "app", "sfxxm", 200, "public");
expect(completionAssistantSearch.mock.calls.map(([request]) => (request as { match_mode?: string }).match_mode)).toEqual(["prefix", "contains"]);
expect(tables.map((table) => table.name)).toEqual(["SFXXMK", "YY_SFXXMK"]);
});
it("skips the substring lookup when the prefix search fills the result budget or the filter is short", async () => {
const completionAssistantSearch = vi.fn().mockResolvedValue({
candidates: [{ name: "accounts", kind: "table", schema: "public" }],
incomplete: false,
fallback_used: false,
});
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
completionAssistantSearch,
listSchemas: vi.fn().mockResolvedValue(["public"]),
listTables: vi.fn().mockResolvedValue([]),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
store.connections = [postgresConnection()];
store.connectedIds.add("pg-1");
await store.listCompletionTables("pg-1", "app", "acc", 1, "public");
expect(completionAssistantSearch).toHaveBeenCalledTimes(1);
await store.listCompletionTables("pg-1", "app", "ac", 200, "public");
expect(completionAssistantSearch).toHaveBeenCalledTimes(2);
});
it("keeps schema-qualified local table completion scoped to the selected schema", async () => {
const completionAssistantSearch = vi.fn().mockRejectedValue(new Error("assistant unavailable"));
const listTables = vi.fn(async (_connectionId: string, _database: string, schema: string, filter: string) => {
@@ -1386,8 +1438,11 @@ describe("connectionStore completion assistant", () => {
await store.listCompletionObjects("pg-1", "app", "Order", 20, undefined, undefined, false, undefined, ["sequence"]);
await store.listCompletionObjects("pg-1", "app", "order", 20, "App", undefined, false, undefined, ["sequence"], true);
expect(completionAssistantSearch).toHaveBeenNthCalledWith(
1,
// Each lookup issues a prefix search plus a widened substring search; the prefix
// requests carry the scoping and case sensitivity under test here.
const prefixRequests = completionAssistantSearch.mock.calls.map(([request]) => request as { match_mode?: string }).filter((request) => request.match_mode === "prefix");
expect(prefixRequests).toHaveLength(3);
expect(prefixRequests[0]).toEqual(
expect.objectContaining({
object_kinds: ["sequence"],
mask: "Order",
@@ -1396,8 +1451,7 @@ describe("connectionStore completion assistant", () => {
parent_schema: null,
}),
);
expect(completionAssistantSearch).toHaveBeenNthCalledWith(
2,
expect(prefixRequests[1]).toEqual(
expect.objectContaining({
object_kinds: ["sequence"],
mask: "Order",
@@ -1406,8 +1460,7 @@ describe("connectionStore completion assistant", () => {
parent_schema: null,
}),
);
expect(completionAssistantSearch).toHaveBeenNthCalledWith(
3,
expect(prefixRequests[2]).toEqual(
expect.objectContaining({
object_kinds: ["sequence"],
mask: "order",
+45 -4
View File
@@ -6,6 +6,7 @@ import { DEFAULT_QUERY_TIMEOUT_SECS } from "@/lib/connection/timeoutLimits";
import type {
ColumnInfo,
CompletionAssistantCandidate,
CompletionAssistantMatchMode,
CompletionAssistantObjectKind,
CompletionAssistantRequest,
ConnectionConfig,
@@ -7625,7 +7626,29 @@ export const useConnectionStore = defineStore("connection", () => {
}));
}
async function listCompletionAssistantTables(connectionId: string, database: string, filter: string, limit?: number, schema?: string, globalSearch = false, currentSchema?: string, requestRevision = completionCacheRevision(connectionId, database)): Promise<SqlCompletionTable[]> {
/**
* The completion assistant matches names by prefix only, while the warm local
* index also matches substrings. Without widening, the very first fuzzy lookup
* of a connection (empty local index) returns far fewer candidates than the
* same lookup once the index is warm. Widening to a substring search keeps the
* two paths consistent whenever the prefix search left room in the result list.
*/
function shouldWidenCompletionMatch(filter: string, resultCount: number, limit?: number): boolean {
if (filter.trim().length < 3) return false;
return limit === undefined || resultCount < limit;
}
async function listCompletionAssistantTables(
connectionId: string,
database: string,
filter: string,
limit?: number,
schema?: string,
globalSearch = false,
currentSchema?: string,
requestRevision = completionCacheRevision(connectionId, database),
matchMode: CompletionAssistantMatchMode = "prefix",
): Promise<SqlCompletionTable[]> {
const oracleAssistant = getConfig(connectionId)?.db_type === "oracle";
const preferredSchema = oracleAssistant ? completionPreferredSchema(connectionId, globalSearch ? currentSchema : (schema ?? currentSchema)) : schema?.trim() || undefined;
const objectKinds: CompletionAssistantObjectKind[] = ["table", "view"];
@@ -7639,7 +7662,7 @@ export const useConnectionStore = defineStore("connection", () => {
max_results: limit ?? 200,
global_search: globalSearch,
parent_schema: globalSearch ? null : (schema ?? null),
match_mode: "prefix",
match_mode: matchMode,
},
requestRevision,
);
@@ -7659,6 +7682,7 @@ export const useConnectionStore = defineStore("connection", () => {
currentSchema: string | undefined,
objectKinds: CompletionAssistantObjectKind[],
caseSensitive: boolean,
matchMode: CompletionAssistantMatchMode = "prefix",
): Promise<SqlCompletionObject[]> {
const databaseType = getConfig(connectionId)?.db_type;
const oracleAssistant = databaseType === "oracle";
@@ -7676,7 +7700,7 @@ export const useConnectionStore = defineStore("connection", () => {
global_search: globalSearch,
parent_schema: globalSearch || sequenceOnly ? null : (schema ?? null),
parent_name: parentName ?? null,
match_mode: "prefix",
match_mode: matchMode,
});
const objects = completionAssistantObjects(response.candidates, preferredSchema, oracleAssistant).map((object) => ({
...object,
@@ -8176,6 +8200,14 @@ export const useConnectionStore = defineStore("connection", () => {
try {
results = await listCompletionAssistantTables(connectionId, database, trimmedFilter, limit, schema, globalSearch, currentSchema, requestRevision);
assistantCompleted = true;
if (shouldWidenCompletionMatch(trimmedFilter, results.length, limit)) {
try {
const widenedTables = await listCompletionAssistantTables(connectionId, database, trimmedFilter, limit, schema, globalSearch, currentSchema, requestRevision, "contains");
results = dedupeCompletionTables([...results, ...widenedTables]);
} catch {
// Keep the prefix matches when the widened lookup is unavailable.
}
}
} catch {
if (schema) {
const tables = await listCompletionTableMetadata(connectionId, database, schema, trimmedFilter, limit, catalog);
@@ -8309,7 +8341,16 @@ export const useConnectionStore = defineStore("connection", () => {
await ensureConnected(connectionId);
if (filteredRoutineAssistant) {
try {
completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(await listCompletionAssistantObjects(connectionId, database, filter, limit, schema, parentName, globalSearch, currentSchema, objectKinds, caseSensitive));
let assistantObjects = await listCompletionAssistantObjects(connectionId, database, filter, limit, schema, parentName, globalSearch, currentSchema, objectKinds, caseSensitive);
if (shouldWidenCompletionMatch(filter, assistantObjects.length, limit)) {
try {
const widenedObjects = await listCompletionAssistantObjects(connectionId, database, filter, limit, schema, parentName, globalSearch, currentSchema, objectKinds, caseSensitive, "contains");
assistantObjects = [...assistantObjects, ...widenedObjects];
} catch {
// Keep the prefix matches when the widened lookup is unavailable.
}
}
completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(assistantObjects);
} catch {
if (objectKinds.length === 1 && objectKinds[0] === "sequence") {
completionObjectsCache.value[cacheKey] = [];
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB