diff --git a/agents/common/src/main/java/com/dbx/agent/CompletionAssistantResponse.java b/agents/common/src/main/java/com/dbx/agent/CompletionAssistantResponse.java index c20c5407744..5d721190bf3 100644 --- a/agents/common/src/main/java/com/dbx/agent/CompletionAssistantResponse.java +++ b/agents/common/src/main/java/com/dbx/agent/CompletionAssistantResponse.java @@ -6,11 +6,17 @@ public final class CompletionAssistantResponse { private final List candidates; private final boolean incomplete; private final boolean fallback_used; + private final Boolean routine_search_supported; public CompletionAssistantResponse(List candidates, boolean incomplete, boolean fallbackUsed) { + this(candidates, incomplete, fallbackUsed, null); + } + + public CompletionAssistantResponse(List candidates, boolean incomplete, boolean fallbackUsed, Boolean routineSearchSupported) { this.candidates = candidates; this.incomplete = incomplete; this.fallback_used = fallbackUsed; + this.routine_search_supported = routineSearchSupported; } public List getCandidates() { return candidates; } diff --git a/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java b/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java index afec2e06689..8f3fd384a9a 100644 --- a/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java +++ b/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java @@ -36,6 +36,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; import java.sql.Types; import java.util.ArrayDeque; import java.util.ArrayList; @@ -350,9 +351,63 @@ public CompletionAssistantResponse completionAssistantSearch(CompletionAssistant if (hasTableLikeCompletionKind(request.getObject_kinds())) { return unchecked(() -> completionAssistantTables(request)); } + if (request.getObject_kinds().stream().anyMatch(kind -> kind == CompletionAssistantObjectKind.ROUTINE + || kind == CompletionAssistantObjectKind.PROCEDURE || kind == CompletionAssistantObjectKind.FUNCTION)) { + return unchecked(() -> completionAssistantRoutines(request)); + } return super.completionAssistantSearch(request); } + private CompletionAssistantResponse completionAssistantRoutines(CompletionAssistantRequest request) throws SQLException { + if (firstNonBlank(request.getParent_name()) != null) { + throw new SQLFeatureNotSupportedException("Package routine completion is not supported"); + } + int limit = boundedCompletionLimit(request.getMax_results()); + String preferredSchema = preferredCompletionSchema(request); + List kinds = request.getObject_kinds(); + List args = new ArrayList<>(); + String types = kinds.contains(CompletionAssistantObjectKind.ROUTINE) + || (kinds.contains(CompletionAssistantObjectKind.PROCEDURE) && kinds.contains(CompletionAssistantObjectKind.FUNCTION)) + ? "'PROCEDURE', 'FUNCTION'" + : kinds.contains(CompletionAssistantObjectKind.PROCEDURE) ? "'PROCEDURE'" : "'FUNCTION'"; + if (kinds.contains(CompletionAssistantObjectKind.SEQUENCE)) types += ", 'SEQUENCE'"; + String pattern = completionLikePattern(request.getMask(), request.getMatch_mode()); + args.add(request.getCase_sensitive() ? pattern : pattern.toUpperCase(Locale.ROOT)); + String ownerFilter = ""; + if (!request.getGlobal_search()) { + ownerFilter = " AND OWNER = ?"; + args.add(firstNonBlank(request.getParent_schema(), request.getSchema(), preferredSchema)); + } + args.add(preferredSchema); + args.add(limit + 1); + String sql = """ + SELECT OWNER, OBJECT_NAME, OBJECT_TYPE FROM ( + SELECT OWNER, OBJECT_NAME, OBJECT_TYPE FROM ALL_OBJECTS + WHERE OBJECT_TYPE IN (%s) AND %s%s + ORDER BY CASE WHEN OWNER = ? THEN 0 ELSE 1 END, OBJECT_NAME, OWNER, OBJECT_TYPE + ) WHERE ROWNUM <= ? + """.formatted(types, completionNamePredicate("OBJECT_NAME", request.getCase_sensitive()), ownerFilter); + List candidates = new ArrayList<>(); + boolean incomplete = false; + try (PreparedStatement stmt = requireConnection().prepareStatement(sql)) { + bindCompletionArgs(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + if (candidates.size() == limit) { + incomplete = true; + break; + } + String objectType = rs.getString(3); + candidates.add(new CompletionAssistantCandidate(rs.getString(2), + "PROCEDURE".equals(objectType) ? CompletionAssistantCandidateKind.PROCEDURE + : "SEQUENCE".equals(objectType) ? CompletionAssistantCandidateKind.SEQUENCE : CompletionAssistantCandidateKind.FUNCTION, + blankToNull(request.getDatabase()), rs.getString(1), null, null, null, objectType)); + } + } + } + return new CompletionAssistantResponse(candidates, incomplete, false, true); + } + private CompletionAssistantResponse completionAssistantTables(CompletionAssistantRequest request) throws SQLException { int limit = boundedCompletionLimit(request.getMax_results()); int scanLimit = Math.min(1000, Math.max(limit * 3, limit + 1)); diff --git a/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java b/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java index 285d2594c42..8fd43135ef4 100644 --- a/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java +++ b/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java @@ -1,6 +1,7 @@ package com.dbx.agent.oceanbaseoracle; import com.dbx.agent.ColumnInfo; +import com.dbx.agent.CompletionAssistantCandidateKind; import com.dbx.agent.CompletionAssistantMatchMode; import com.dbx.agent.CompletionAssistantObjectKind; import com.dbx.agent.CompletionAssistantRequest; @@ -485,6 +486,78 @@ void scopedCompletionTableSearchFiltersOwnerCaseInsensitively() { Assertions.assertEquals(List.of("ORD%", "STAGING", "ORD%", "STAGING", "DWD", "ORD", 21), query.args); } + @Test + void completionAssistantSearchReturnsBoundedStandaloneRoutines() { + List sql = new ArrayList<>(); + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet( + new String[]{"OWNER", "OBJECT_NAME", "OBJECT_TYPE"}, + new Object[][]{{"APP", "CALC", "PROCEDURE"}, {"APP", "CALCULATE", "FUNCTION"}, {"OTHER", "CALCULATE", "FUNCTION"}} + ))); + CompletionAssistantRequest request = completionRequest("APP", null, "CAL", true); + setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE)); + setField(request, "max_results", 2); + + CompletionAssistantResponse response = agent.completionAssistantSearch(request); + + Assertions.assertEquals(List.of("CALC", "CALCULATE"), response.getCandidates().stream().map(candidate -> candidate.getName()).toList()); + Assertions.assertEquals(List.of("procedure", "function"), response.getCandidates().stream().map(candidate -> candidate.getKind().name().toLowerCase(Locale.ROOT)).toList()); + Assertions.assertEquals("APP", response.getCandidates().get(0).getSchema()); + Assertions.assertNull(response.getCandidates().get(0).getParent_name()); + Assertions.assertTrue(response.getIncomplete()); + Assertions.assertFalse(response.getFallback_used()); + Assertions.assertTrue(sql.get(0).contains("ROWNUM <= ?")); + Assertions.assertTrue(new com.google.gson.Gson().toJsonTree(response).getAsJsonObject().has("routine_search_supported")); + } + + @Test + void mixedRoutineSearchPreservesRequestedSequences() { + for (CompletionAssistantObjectKind routineKind : List.of(CompletionAssistantObjectKind.ROUTINE, CompletionAssistantObjectKind.FUNCTION)) { + List sql = new ArrayList<>(); + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet( + new String[]{"OWNER", "OBJECT_NAME", "OBJECT_TYPE"}, + new Object[][]{{"APP", "ORDER_SEQ", "SEQUENCE"}} + ))); + CompletionAssistantRequest request = completionRequest("APP", null, "ORDER", false); + setField(request, "object_kinds", List.of(routineKind, CompletionAssistantObjectKind.SEQUENCE)); + CompletionAssistantResponse response = agent.completionAssistantSearch(request); + Assertions.assertTrue(sql.get(0).contains("'SEQUENCE'")); + Assertions.assertEquals(CompletionAssistantCandidateKind.SEQUENCE, response.getCandidates().get(0).getKind()); + } + } + + @Test + void routineSearchPreservesQuotedOwnersAndEscapesLiteralMasks() { + List sql = new ArrayList<>(); + List params = new ArrayList<>(); + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, params, resultSet( + new String[]{"OWNER", "OBJECT_NAME", "OBJECT_TYPE"}, + new Object[][]{{"Mixed.Owner", "中文_%", "PROCEDURE"}} + ))); + CompletionAssistantRequest request = completionRequest("APP", "Mixed.Owner", "中文_%", false); + setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.PROCEDURE)); + setField(request, "case_sensitive", true); + setField(request, "match_mode", CompletionAssistantMatchMode.CONTAINS); + setField(request, "max_results", 1); + CompletionAssistantResponse response = agent.completionAssistantSearch(request); + Assertions.assertEquals(List.of("%中文\\_\\%%", "Mixed.Owner", "APP"), params); + Assertions.assertTrue(sql.get(0).contains("OBJECT_TYPE IN ('PROCEDURE')")); + Assertions.assertTrue(sql.get(0).contains("OWNER = ?")); + Assertions.assertFalse(sql.get(0).contains("UPPER(OBJECT_NAME)")); + Assertions.assertEquals("中文_%", response.getCandidates().get(0).getName()); + Assertions.assertFalse(response.getIncomplete(), "Exactly the requested count is complete"); + } + + @Test + void routineSearchDoesNotReturnStandaloneCandidatesForAPackageRequest() { + CompletionAssistantRequest request = completionRequest("APP", null, "P", false); + setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.ROUTINE)); + setField(request, "parent_name", "PKG"); + Assertions.assertThrows(RuntimeException.class, () -> new OceanBaseOracleAgent().completionAssistantSearch(request)); + } + @Test void completionAssistantSearchReturnsGlobalTableCandidates() { List sql = new ArrayList<>(); diff --git a/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletion.spec.ts b/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletion.spec.ts index bc5b8d08f39..72bc764e92c 100644 --- a/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletion.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletion.spec.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom -import { CompletionContext, insertCompletionText, snippetCompletion } from "@codemirror/autocomplete"; -import { EditorState } from "@codemirror/state"; +import { acceptCompletion, autocompletion, CompletionContext, currentCompletions, insertCompletionText, snippetCompletion, startCompletion } from "@codemirror/autocomplete"; +import { EditorState, StateEffect } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { computed, reactive, shallowRef } from "vue"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -9,7 +9,7 @@ import { useQueryEditorCompletion } from "../useQueryEditorCompletion"; import { useQueryEditorCompletionMetadata } from "../useQueryEditorCompletionMetadata"; import type { QueryEditorProps } from "../queryEditorTypes"; import type { RedisCommandDocumentation } from "@/lib/redis/redisCommandDocs"; -import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sql/sqlCompletion"; +import type { SqlCompletionColumn, SqlCompletionObject, SqlCompletionTable } from "@/lib/sql/sqlCompletion"; import { analyzeSqlCompletion, type SqlCompletionAnalysisResult } from "@/lib/sql/sqlCompletionAnalysis"; vi.mock("@/stores/connectionStore", () => ({ COMPLETION_METADATA_CONCURRENCY: 4 })); @@ -48,7 +48,7 @@ function createHarness(overrides: Partial = {}, configureMetad lookupLocalCompletionSchemas: vi.fn((): string[] => []), lookupLocalCompletionForeignKeys: vi.fn(() => []), listCompletionTables: vi.fn(async (): Promise => []), - listCompletionObjects: vi.fn(async () => []), + listCompletionObjects: vi.fn(async (): Promise => []), listCompletionColumns: vi.fn(async (): Promise => []), listCompletionSchemas: vi.fn(async (): Promise => []), listCompletionDatabases: vi.fn(async (): Promise => []), @@ -104,6 +104,69 @@ function createHarness(overrides: Partial = {}, configureMetad return { props, currentView, view, store, settings, runtime, completion, provide, startCompletion }; } +describe.each([false, true])("OceanBase standalone routine completion (semantic=%s)", (semanticCompletionEnabled) => { + beforeEach(() => vi.useRealTimers()); + + it("loads a quoted owner exactly and inserts a case-sensitive routine identifier", async () => { + const sql = 'CALL "Mixed.Owner".'; + const { provide, store, currentView } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", database: "OB", schema: "APP", modelValue: sql }, undefined, semanticCompletionEnabled); + store.listCompletionObjects.mockResolvedValue([{ name: "Do.Work", schema: "Mixed.Owner", type: "procedure" }]); + const result = await provide(); + expect(store.listCompletionObjects).toHaveBeenCalledWith("connection", "OB", "", 100, "Mixed.Owner", undefined, false, "APP", expect.any(Array), false); + const option = result!.options.find((candidate) => candidate.label === "Do.Work")!; + expect(option).toBeDefined(); + if (typeof option.apply === "function") option.apply(currentView, option, result!.from, currentView.state.doc.length); + else currentView.dispatch(insertCompletionText(currentView.state, option.apply ?? option.label, result!.from, currentView.state.doc.length)); + expect(currentView.state.doc.toString()).toBe('CALL "Mixed.Owner"."Do.Work"()'); + }); + + it.each(['CALL "Wild%_', 'CALL "APP"."Wild%_'])("shows and accepts a quoted wildcard routine after %s", async (sql) => { + const { provide, store, currentView } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", database: "OB", schema: "APP", modelValue: sql }, undefined, semanticCompletionEnabled); + store.listCompletionObjects.mockResolvedValue([{ name: "Wild%_Proc", schema: "APP", type: "procedure" }]); + currentView.dispatch({ effects: StateEffect.appendConfig.of(autocompletion({ override: [() => provide()] })) }); + startCompletion(currentView); + await vi.waitFor(() => expect(currentCompletions(currentView.state).some((option) => (option.displayLabel ?? option.label) === "Wild%_Proc")).toBe(true)); + expect(store.listCompletionObjects).toHaveBeenCalledWith("connection", "OB", "Wild%_", 100, "APP", undefined, expect.any(Boolean), "APP", expect.any(Array), true); + expect(document.querySelector('[role="listbox"]')).not.toBeNull(); + await vi.waitFor(() => expect(acceptCompletion(currentView)).toBe(true)); + expect(currentView.state.doc.toString()).toBe(sql.startsWith('CALL "APP".') ? 'CALL "APP"."Wild%_Proc"()' : 'CALL "Wild%_Proc"()'); + }); + + it("drops a routine response when the request is invalidated", async () => { + const { provide, store, completion } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", database: "OB", schema: "APP", modelValue: "CALL P" }, undefined, semanticCompletionEnabled); + let resolve!: (objects: SqlCompletionObject[]) => void; + store.listCompletionObjects.mockReturnValue( + new Promise((done) => { + resolve = done; + }), + ); + const pending = provide(); + await vi.waitFor(() => expect(store.listCompletionObjects).toHaveBeenCalled()); + completion.invalidateRequests(); + resolve([{ name: "P_OLD", schema: "APP", type: "procedure" }]); + const result = await pending; + expect(result?.options.some((option) => option.label === "P_OLD") ?? false).toBe(false); + }); + + it.each(["schema", "database", "catalog", "connectionId"] as const)("drops a held routine response after %s changes", async (scopeKey) => { + const { provide, props, store } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", database: "OB", schema: "B", modelValue: "SELECT 函数" }, undefined, semanticCompletionEnabled); + let release!: (objects: SqlCompletionObject[]) => void; + store.listCompletionObjects.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + const pending = provide(); + await vi.waitFor(() => expect(store.listCompletionObjects).toHaveBeenCalled()); + props[scopeKey] = "A"; + release([{ name: "函数.Mixed", schema: "A", type: "function" }]); + expect(await pending).toBeNull(); + if (scopeKey === "catalog") props.catalog = undefined; + store.listCompletionObjects.mockResolvedValue([{ name: "函数.Mixed", schema: "A", type: "function" }]); + expect((await provide())?.options.some((option) => (option.displayLabel ?? option.label) === "函数.Mixed")).toBe(true); + }); +}); + describe.each([false, true])("Snowflake namespace completion (semantic=%s)", (semanticCompletionEnabled) => { beforeEach(() => vi.useRealTimers()); diff --git a/apps/desktop/src/components/editor/useQueryEditorCompletion.ts b/apps/desktop/src/components/editor/useQueryEditorCompletion.ts index 45ac875763a..ea47d50cdd6 100644 --- a/apps/desktop/src/components/editor/useQueryEditorCompletion.ts +++ b/apps/desktop/src/components/editor/useQueryEditorCompletion.ts @@ -1,5 +1,5 @@ import { type RoutineCompletionTarget } from "./useQueryEditorCompletionMetadata"; -import { computed } from "vue"; +import { computed, watch } from "vue"; import type { CompletionContext } from "@codemirror/autocomplete"; import type { EditorView as EditorViewType } from "@codemirror/view"; import { type QueryCompletionItem, type BatchColumnSelectionActionItem } from "./useQueryEditorBatchSelection"; @@ -225,6 +225,15 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) let completionEpoch = 0; + watch( + [() => props.connectionId, () => props.database, () => props.catalog, () => props.schema], + () => { + completionEpoch++; + cancelEditorSqlCompletionAnalysis(); + }, + { flush: "sync" }, + ); + let tableCompletionRefreshActive = false; let latestTableCompletionRefresh: (() => Promise) | null = null; @@ -959,9 +968,9 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) } try { const result = await performAsyncCompletionWithResult(epoch, completionContext, fullDoc, position, completionScope); - resolve(result ?? localResult); + resolve(epoch === completionEpoch ? (result ?? localResult) : null); } catch { - resolve(localResult); + resolve(epoch === completionEpoch ? localResult : null); } }, COMPLETION_DEBOUNCE_DELAY_MS); }); @@ -1269,8 +1278,10 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) } if (!localOnlyMetadata && shouldLoadCompletionObjects(completionContext)) { const completionObjectScope = routineCompletionScopeForContext(completionContext, scope); + const refreshEpoch = completionEpoch; void listCompletionObjectsForContext(completionContext, scope) .then((objects) => { + if (refreshEpoch !== completionEpoch) return; const cachedObjects = completionObjectsForScope(completionObjectScope); const merged = mergeCompletionObjects(cachedObjects, objects); const changed = completionObjectsDiffer(cachedObjects, merged); @@ -1373,12 +1384,18 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) } function routineCompletionTargetForContext(completionContext: ReturnType, scope: CompletionMetadataScope) { - return resolveSqlCompletionRoutineLookupTarget({ + const target = resolveSqlCompletionRoutineLookupTarget({ currentDatabase: scope.database, currentSchema: scope.schema, supportsDatabaseSchemaQualifier: supportsDatabaseSchemaQualifierCompletion(), completionContext, }); + if (props.databaseType === "oceanbase-oracle" && completionContext.qualifier) { + const parts = completionContext.qualifierParts ?? completionContext.qualifier.split("."); + const index = parts.length - 1; + target.schema = completionContext.qualifierQuoted?.[index] ? parts[index]!.replaceAll('""', '"') : parts[index]!.toUpperCase(); + } + return target; } function routineCompletionScopeForContext(completionContext: ReturnType, scope: CompletionMetadataScope): CompletionMetadataScope { @@ -1389,7 +1406,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) function lookupLocalCompletionObjectsForContext(completionContext: ReturnType, scope: CompletionMetadataScope): SqlCompletionObject[] { if (!props.connectionId || props.database == null) return []; - if (usesPackageAwareRoutineCompletion()) { + if (usesPackageAwareRoutineCompletion() || (props.databaseType === "oceanbase-oracle" && !completionContext.qualifier)) { return connectionStore.lookupLocalCompletionObjects(props.connectionId, scope.database, completionContext.prefix, MAX_COMPLETION_TABLES); } const target = routineCompletionTargetForContext(completionContext, scope); @@ -1399,6 +1416,11 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) async function listCompletionObjectsForContext(completionContext: ReturnType, scope: CompletionMetadataScope): Promise { if (!props.connectionId || props.database == null) return []; const objectKinds = completionObjectKindsForContext(completionContext); + if (props.databaseType === "oceanbase-oracle") { + const parts = (completionContext.qualifierParts ?? completionContext.qualifier?.split(".") ?? []).map((part, index) => (completionContext.qualifierQuoted?.[index] ? part.replaceAll('""', '"') : part.toUpperCase())); + if (parts.length > 1) return []; + return connectionStore.listCompletionObjects(props.connectionId, scope.database, completionContext.prefix, MAX_COMPLETION_TABLES, parts[0] ?? scope.schema, undefined, parts.length === 0, scope.schema, objectKinds, !!completionContext.prefixQuoted); + } if (!usesPackageAwareRoutineCompletion()) { const target = routineCompletionTargetForContext(completionContext, scope); return connectionStore.listCompletionObjects(props.connectionId, target.database, target.mask, MAX_COMPLETION_TABLES, target.schema, undefined, false, scope.schema, objectKinds); diff --git a/apps/desktop/src/components/editor/useQueryEditorCompletionMetadata.ts b/apps/desktop/src/components/editor/useQueryEditorCompletionMetadata.ts index d1a4920b13d..909bab41e79 100644 --- a/apps/desktop/src/components/editor/useQueryEditorCompletionMetadata.ts +++ b/apps/desktop/src/components/editor/useQueryEditorCompletionMetadata.ts @@ -571,7 +571,8 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM } function completionObjectScopeKey(scope: CompletionMetadataScope): string { - return `${scope.database}:${scope.schema ?? ""}`.toLowerCase(); + const key = JSON.stringify([scope.database, scope.schema]); + return props.databaseType === "oceanbase-oracle" ? key : key.toLowerCase(); } function completionObjectsForScope(scope: CompletionMetadataScope): SqlCompletionObject[] { @@ -579,7 +580,8 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM } function completionObjectIdentityKey(object: SqlCompletionObject): string { - return `${object.type}:${object.schema ?? ""}:${object.name}:${object.parentName ?? ""}:${object.signature?.trim() ?? ""}`.toLowerCase(); + const key = JSON.stringify([object.type, object.schema, object.name, object.parentName, object.signature?.trim()]); + return props.databaseType === "oceanbase-oracle" ? key : key.toLowerCase(); } function completionObjectsDiffer(existing: SqlCompletionObject[], incoming: SqlCompletionObject[]): boolean { diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts index d2402bd5868..795e9bee6f1 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts @@ -14,6 +14,13 @@ import { sqlCompletionContextFromSemantic } from "@/lib/sql/semantic/completion" import { buildSqlSemanticModel } from "@/lib/sql/semantic/model"; import { originForSqlCompletionProvider, originForTypedSqlCompletionStart, shouldAllowSqlCompletionTrigger, type SqlCompletionTriggerFacts } from "@/lib/sql/sqlCompletionTriggerPolicy"; +describe("OceanBase quoted routine lookup context", () => { + it.each(['CALL "Mixed.Owner"."Do', 'CALL "Mixed.Owner"."Do"'])("preserves quoted prefixes in %s", (sql) => { + const context = getSqlCompletionContext(sql, sql.length, { databaseType: "oceanbase-oracle" }); + expect(context).toMatchObject({ prefix: "Do", prefixQuoted: true, qualifierParts: ["Mixed.Owner"], qualifierQuoted: [true] }); + }); +}); + describe("SQL completion replacement", () => { const columnItem = { label: "price", type: "column" as const, apply: "price", boost: 0 }; diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 7982e63e8fe..dfce15d90a2 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -1555,11 +1555,13 @@ export type SqlCompletionContextKind = "table" | "schema" | "catalog" | "routine export interface SqlCompletionContext { prefix: string; + prefixQuoted?: boolean; replacementRange?: { start: number; end: number }; preferredValueKeywords?: string[]; localVariables?: string[]; qualifier?: string; qualifierParts?: string[]; + qualifierQuoted?: boolean[]; suggestTables: boolean; suggestColumns: boolean; suggestKeywords: boolean; @@ -1627,9 +1629,10 @@ function isStandaloneSelectWildcard(sql: string, cursor: number, selectListColum return next.text === "," || next.text === ";" || (next.kind === "word" && SELECT_WILDCARD_FOLLOWING_CLAUSES.has(next.normalized)); } -export function prepareSqlCompletionReplacement(sql: string, cursor: number, context: Pick, items: SqlCompletionItem[]): { from: number; items: SqlCompletionItem[] } { +export function prepareSqlCompletionReplacement(sql: string, cursor: number, context: Pick, items: SqlCompletionItem[]): { from: number; items: SqlCompletionItem[] } { const range = context.replacementRange; - const from = range && range.start >= 0 && range.start <= cursor && range.end === cursor ? range.start : cursor - context.prefix.length; + let from = range && range.start >= 0 && range.start <= cursor && range.end === cursor ? range.start : cursor - context.prefix.length; + if (!range && context.prefixQuoted && SQL_COMPLETION_CLOSING_QUOTES[sql[from - 1] ?? ""] && items.some((item) => item.type === "function")) from--; const closingQuote = from < cursor ? SQL_COMPLETION_CLOSING_QUOTES[sql[from] ?? ""] : undefined; const replaceClosingQuote = sql[cursor] === closingQuote ? closingQuote : undefined; const replaceSelectWildcard = from === cursor && context.selectListWildcardAfterCursor === true; @@ -1639,6 +1642,8 @@ export function prepareSqlCompletionReplacement(sql: string, cursor: number, con items: items.map((item) => { let prepared = item; const apply = item.apply ?? item.label; + // The replacement includes the opening quote, so CodeMirror must match it too. + if (closingQuote && item.type === "function") prepared = { ...prepared, filterText: `${sql[from]}${item.label.replaceAll(closingQuote, closingQuote + closingQuote)}` }; if (closingQuote && item.type === "column" && !(apply.startsWith(sql[from] ?? "") && apply.endsWith(closingQuote)) && (context.qualifier || !apply.includes("."))) { const escaped = apply.replaceAll(closingQuote, closingQuote + closingQuote); prepared = { ...prepared, apply: `${sql[from]}${escaped}${closingQuote}` }; @@ -2713,6 +2718,8 @@ export function getSqlCompletionContext(sql: string, cursor: number, options: Sq localVariables, qualifier: insertInfo ? undefined : qualifier, qualifierParts: insertInfo ? undefined : qualifierParts, + qualifierQuoted: insertInfo ? undefined : trailingIdentifier?.qualifierQuoted, + prefixQuoted: trailingIdentifier?.prefixQuoted, suggestTables: insertInfo || dataTypeContext ? false : afterTableTrigger, suggestColumns, suggestKeywords: !exclusiveTableSuggestions && !exclusiveColumnSuggestions && !insertInfo && !inCallRoutineContext, @@ -2988,7 +2995,14 @@ function detectCompletionContextKind(options: { return "keyword"; } -function parseTrailingIdentifierContext(input: string, databaseType?: DatabaseType): { start: number; prefix: string; qualifier?: string; qualifierParts?: string[] } | null { +function parseTrailingIdentifierContext(input: string, databaseType?: DatabaseType): { start: number; prefix: string; prefixQuoted?: boolean; qualifier?: string; qualifierParts?: string[]; qualifierQuoted?: boolean[] } | null { + if (databaseType === "oceanbase-oracle" && input.includes('"')) { + const tokens = tokenizeSqlSemantic(input, "oracle"); + const tail = tokens[tokens.length - 1]; + if (tail?.kind === "quoted_identifier" && tail.quote === '"' && tail.closed === false) { + return parseTrailingIdentifierContext(`${input}"`, databaseType); + } + } if (/\s$/.test(input)) return null; let i = input.length - 1; while (i >= 0 && /\s/.test(input[i] ?? "")) i--; @@ -3022,19 +3036,23 @@ function parseTrailingIdentifierContext(input: string, databaseType?: DatabaseTy if (parts.length >= 2 || endsWithDot) { const qualifierParts = (endsWithDot ? parts : parts.slice(0, -1)).map((part) => completionLookupIdentifier(part, databaseType)); - const prefixPart = endsWithDot ? "" : unquoteIdentifier(parts[parts.length - 1] ?? ""); + const rawPrefix = endsWithDot ? "" : unquoteIdentifier(parts[parts.length - 1] ?? ""); + const prefixPart = databaseType === "oceanbase-oracle" ? rawPrefix.replaceAll('""', '"') : rawPrefix; const qualifierValue = qualifierParts.join("."); return { start, prefix: prefixPart, qualifier: qualifierValue || undefined, qualifierParts: qualifierParts.length > 0 ? qualifierParts : undefined, + qualifierQuoted: (endsWithDot ? parts : parts.slice(0, -1)).map((part) => part.startsWith('"')), + prefixQuoted: !endsWithDot && parts[parts.length - 1]?.startsWith('"'), }; } return { start, - prefix: unquoteIdentifier(parts[0] ?? ""), + prefix: databaseType === "oceanbase-oracle" ? unquoteIdentifier(parts[0] ?? "").replaceAll('""', '"') : unquoteIdentifier(parts[0] ?? ""), + prefixQuoted: parts[0]?.startsWith('"'), }; } @@ -4498,6 +4516,8 @@ function buildSchemaItems(prefix: string, schemas: string[], dialect?: SqlComple } function buildObjectItems(context: SqlCompletionContext, objects: SqlCompletionObject[], dialect?: SqlCompletionApplyDialect, databaseType?: DatabaseType, currentSchema?: string, includeParams = true): SqlCompletionItem[] { + // A quote preference must not change an Oracle routine's exact identity. + if (isOracleCompletionDatabase(databaseType)) dialect = "oracle"; if (completionQualifierIsReferencedTable(context)) return []; const onlyProcedures = context.contextKind === "exec"; const onlyFunctions = context.suggestColumns && context.referencedTables.length > 0 && !context.qualifier; @@ -4506,16 +4526,16 @@ function buildObjectItems(context: SqlCompletionContext, objects: SqlCompletionO return objects .filter((object) => { if (object.type === "sequence") { - return allowOracleSequences && objectMatchesCompletionContext(object, context); + return allowOracleSequences && objectMatchesCompletionContext(object, context, databaseType); } - return (!onlyProcedures || object.type === "procedure") && (!onlyFunctions || (object.type === "function" && object.name.toLowerCase().startsWith(context.prefix.toLowerCase()))) && objectMatchesCompletionContext(object, context); + return (!onlyProcedures || object.type === "procedure") && (!onlyFunctions || (object.type === "function" && object.name.toLowerCase().startsWith(context.prefix.toLowerCase()))) && objectMatchesCompletionContext(object, context, databaseType); }) .map((object) => { - const qualifiedByContext = objectIsQualifiedByContext(object, context); - const objectInCurrentSchema = !!currentSchema && !!object.schema && normalizeIdentifierPart(object.schema) === normalizeIdentifierPart(currentSchema); + const qualifiedByContext = objectIsQualifiedByContext(object, context, databaseType); + const objectInCurrentSchema = !!currentSchema && !!object.schema && (databaseType === "oceanbase-oracle" ? object.schema === currentSchema : normalizeIdentifierPart(object.schema) === normalizeIdentifierPart(currentSchema)); const suppliedApplyName = object.applyName ? quoteCompletionRoutineName(object.applyName, dialect) : undefined; const applyName = - qualifiedByContext || (context.qualifier && object.schema?.toLowerCase() === context.qualifier.toLowerCase()) + qualifiedByContext || (context.qualifier && (databaseType === "oceanbase-oracle" ? object.schema === oceanBaseRoutineQualifierParts(context)[0] : object.schema?.toLowerCase() === context.qualifier.toLowerCase())) ? quoteCompletionRoutineIdentifier(object.name, dialect) : (suppliedApplyName ?? (object.schema && !objectInCurrentSchema ? `${quoteCompletionRoutineIdentifier(object.schema, dialect)}.${quoteCompletionRoutineIdentifier(object.name, dialect)}` : quoteCompletionRoutineIdentifier(object.name, dialect))); const locationDetail = object.type === "trigger" && object.parentName ? `trigger on ${object.parentName}` : object.parentName ? `${object.type} in ${object.parentName}` : object.schema ? `${object.type} in ${object.schema}` : object.type; @@ -4656,8 +4676,16 @@ function buildOracleSequencePseudoColumnItems(prefix: string, keywordCase?: SqlK }); } -function objectIsQualifiedByContext(object: SqlCompletionObject, context: SqlCompletionContext): boolean { +function oceanBaseRoutineQualifierParts(context: SqlCompletionContext): string[] { + return (context.qualifierParts ?? context.qualifier?.split(".") ?? []).map((part, index) => (context.qualifierQuoted?.[index] ? part.replaceAll('""', '"') : part.toUpperCase())); +} + +function objectIsQualifiedByContext(object: SqlCompletionObject, context: SqlCompletionContext, databaseType?: DatabaseType): boolean { if (!context.qualifier || !object.parentName) return false; + if (databaseType === "oceanbase-oracle") { + const parts = oceanBaseRoutineQualifierParts(context); + return object.parentName === parts[parts.length - 1] && (parts.length === 1 || (object.parentSchema ?? object.schema) === parts[parts.length - 2]); + } const qualifier = context.qualifier.toLowerCase(); const qualifierParts = qualifier.split(".").filter(Boolean); const qualifierSchema = qualifierParts.length > 1 ? qualifierParts[qualifierParts.length - 2] : undefined; @@ -4665,8 +4693,14 @@ function objectIsQualifiedByContext(object: SqlCompletionObject, context: SqlCom return object.parentName.toLowerCase() === qualifier || (!!qualifierPackage && object.parentName.toLowerCase() === qualifierPackage && (!qualifierSchema || !object.parentSchema || object.parentSchema.toLowerCase() === qualifierSchema)); } -function objectMatchesCompletionContext(object: SqlCompletionObject, context: SqlCompletionContext): boolean { +function objectMatchesCompletionContext(object: SqlCompletionObject, context: SqlCompletionContext, databaseType?: DatabaseType): boolean { if (context.oracleTableFunctionContext && object.type !== "function") return false; + if (databaseType === "oceanbase-oracle") { + const prefixMatches = context.prefixQuoted ? object.name.startsWith(context.prefix) : matchesPrefix(object.name, context.prefix); + if (!context.qualifier) return prefixMatches; + const parts = oceanBaseRoutineQualifierParts(context); + return prefixMatches && (object.parentName ? objectIsQualifiedByContext(object, context, databaseType) : parts.length === 1 && object.schema === parts[0]); + } if (object.type === "sequence") { if (context.qualifier) { const qualifier = context.qualifier.toLowerCase(); diff --git a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts index 54b250d304a..caa3d70cf55 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts @@ -1600,6 +1600,75 @@ describe("connectionStore completion assistant", () => { expect(columns).toEqual([expect.objectContaining({ name: "EXPLICIT_ID", schema: "REPORTING" })]); }); + it("filters OceanBase routines on the server and preserves case-distinct names", async () => { + const completionAssistantSearch = vi.fn().mockResolvedValue({ + candidates: [ + { name: "CALC", kind: "procedure", schema: "APP", data_type: "PROCEDURE" }, + { name: "Calc", kind: "function", schema: "APP", data_type: "FUNCTION" }, + ], + incomplete: false, + fallback_used: false, + }); + const listCompletionObjects = vi.fn(); + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ checkConnectionHealth: vi.fn(), completionAssistantSearch, listCompletionObjects })); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [oceanBaseOracleConnection()]; + store.connectedIds.add("oceanbase-oracle-1"); + const objects = await store.listCompletionObjects("oceanbase-oracle-1", "OBORCL", "CA", 20, "APP"); + expect(objects.map((object) => object.name)).toEqual(["CALC", "Calc"]); + expect(completionAssistantSearch).toHaveBeenCalledWith(expect.objectContaining({ mask: "CA", parent_schema: "APP", max_results: 20, object_kinds: ["routine"] })); + expect(listCompletionObjects).not.toHaveBeenCalled(); + expect((await store.listCompletionObjects("oceanbase-oracle-1", "OBORCL", "Ca", 20, "APP", undefined, false, "APP", ["function"], true)).map((object) => object.name)).toEqual(["Calc"]); + }); + + it.each([["routine", "sequence"], ["function", "sequence"], ["sequence"]] as const)("preserves requested OceanBase sequence candidates for %j", async (...objectKinds) => { + const completionAssistantSearch = vi.fn().mockResolvedValue({ candidates: [{ name: "ORDER_SEQ", kind: "sequence", schema: "APP" }], incomplete: false, fallback_used: false }); + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ checkConnectionHealth: vi.fn(), completionAssistantSearch })); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [oceanBaseOracleConnection()]; + store.connectedIds.add("oceanbase-oracle-1"); + expect(await store.listCompletionObjects("oceanbase-oracle-1", "OBORCL", "ORDER", 20, "APP", undefined, false, "APP", [...objectKinds])).toEqual([expect.objectContaining({ name: "ORDER_SEQ", type: "sequence", schema: "APP" })]); + }); + + it("retains OceanBase legacy-agent fallback without treating an empty search as unsupported", async () => { + const completionAssistantSearch = vi.fn().mockRejectedValueOnce(new Error("OceanBase agent does not support filtered routine completion")).mockResolvedValue({ candidates: [], incomplete: false, fallback_used: false }); + const listCompletionObjects = vi.fn().mockResolvedValue([{ name: "P", object_type: "PROCEDURE", schema: "APP" }]); + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ checkConnectionHealth: vi.fn(), completionAssistantSearch, listCompletionObjects })); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [oceanBaseOracleConnection()]; + store.connectedIds.add("oceanbase-oracle-1"); + expect((await store.listCompletionObjects("oceanbase-oracle-1", "OBORCL", "P", 20, "APP")).map((object) => object.name)).toEqual(["P"]); + expect(await store.listCompletionObjects("oceanbase-oracle-1", "OBORCL", "ZZ", 20, "APP")).toEqual([]); + expect(listCompletionObjects).toHaveBeenCalledTimes(1); + }); + + it("does not restore invalidated OceanBase routine results or their local index", async () => { + const stale = deferred<{ candidates: Array<{ name: string; kind: string; schema: string }>; incomplete: boolean; fallback_used: boolean }>(); + const completionAssistantSearch = vi + .fn() + .mockReturnValueOnce(stale.promise) + .mockResolvedValue({ candidates: [{ name: "P_NEW", kind: "procedure", schema: "APP" }], incomplete: false, fallback_used: false }); + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ checkConnectionHealth: vi.fn(), completionAssistantSearch })); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [oceanBaseOracleConnection()]; + store.connectedIds.add("oceanbase-oracle-1"); + const pending = store.listCompletionObjects("oceanbase-oracle-1", "OBORCL", "P", 20, "APP"); + await vi.waitFor(() => expect(completionAssistantSearch).toHaveBeenCalledTimes(1)); + store.invalidateCompletionTableCache("oceanbase-oracle-1", "OBORCL", "P_OLD", "APP"); + await store.listCompletionObjects("oceanbase-oracle-1", "OBORCL", "P", 20, "APP"); + stale.resolve({ candidates: [{ name: "P_OLD", kind: "procedure", schema: "APP" }], incomplete: false, fallback_used: false }); + expect((await pending).map((object) => object.name)).toEqual(["P_NEW"]); + expect(store.lookupLocalCompletionObjects("oceanbase-oracle-1", "OBORCL", "P", 20, "APP").map((object) => object.name)).toEqual(["P_NEW"]); + }); + it("maps Oracle package members without scanning every schema", async () => { const completionAssistantSearch = vi.fn().mockResolvedValue({ candidates: [{ name: "CALCULATE_BONUS", kind: "function", schema: "HR", parent_schema: "HR", parent_name: "PAYROLL", data_type: "FUNCTION" }], diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 9cc682afbaf..09d69d94c25 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -8216,7 +8216,7 @@ export const useConnectionStore = defineStore("connection", () => { } const ORACLE_SYSTEM_COMPLETION_SCHEMAS = new Set(["SYS", "SYSTEM", "SYSMAN", "DBSNMP", "OUTLN", "XDB", "MDSYS", "CTXSYS", "WMSYS"]); - const FILTERED_ROUTINE_COMPLETION_DATABASES = new Set(["mysql", "postgres", "sqlserver", "oracle", "opengauss"]); + const FILTERED_ROUTINE_COMPLETION_DATABASES = new Set(["mysql", "postgres", "sqlserver", "oracle", "oceanbase-oracle", "opengauss"]); function completionPreferredSchema(connectionId: string, preferredSchema?: string): string | undefined { return preferredSchema?.trim() || getConfig(connectionId)?.username?.trim() || undefined; @@ -8367,30 +8367,35 @@ export const useConnectionStore = defineStore("connection", () => { objectKinds: CompletionAssistantObjectKind[], caseSensitive: boolean, matchMode: CompletionAssistantMatchMode = "prefix", + requestRevision = completionCacheRevision(connectionId, database), ): Promise { const databaseType = getConfig(connectionId)?.db_type; const oracleAssistant = isOracleCompletionDatabase(databaseType); const requestedSchema = schema?.trim() || currentSchema?.trim() || undefined; const sequenceOnly = objectKinds.length === 1 && objectKinds[0] === "sequence"; const preferredSchema = oracleAssistant ? completionPreferredSchema(connectionId, currentSchema) : requestedSchema || (!sequenceOnly && databaseType === "postgres" ? "public" : databaseType === "mysql" ? database : undefined); - const response = await completionAssistantSearch({ - connection_id: connectionId, - database, - schema: oracleAssistant ? (preferredSchema ?? null) : (requestedSchema ?? null), - object_kinds: objectKinds, - mask: filter.trim(), - case_sensitive: caseSensitive, - max_results: limit ?? 200, - global_search: globalSearch, - parent_schema: globalSearch || sequenceOnly ? null : (schema ?? null), - parent_name: parentName ?? null, - match_mode: matchMode, - }); + const response = await completionAssistantSearch( + { + connection_id: connectionId, + database, + schema: oracleAssistant ? (preferredSchema ?? null) : (requestedSchema ?? null), + object_kinds: objectKinds, + mask: filter.trim(), + case_sensitive: caseSensitive, + max_results: limit ?? 200, + global_search: globalSearch, + parent_schema: globalSearch || sequenceOnly ? null : (schema ?? null), + parent_name: parentName ?? null, + match_mode: matchMode, + }, + requestRevision, + ); + if (databaseType === "oceanbase-oracle" && response.fallback_used) throw new Error("OceanBase agent does not support filtered routine completion"); const objects = completionAssistantObjects(response.candidates, preferredSchema, oracleAssistant).map((object) => ({ ...object, - applyName: databaseType === "sqlserver" && object.schema ? `${object.schema}.${object.name}` : object.applyName, + applyName: databaseType === "oceanbase-oracle" ? undefined : databaseType === "sqlserver" && object.schema ? `${object.schema}.${object.name}` : object.applyName, })); - indexCompletionObjects(connectionId, database, schema, objects); + if (requestRevision === completionCacheRevision(connectionId, database)) indexCompletionObjects(connectionId, database, schema, objects); return objects; } @@ -8555,6 +8560,7 @@ export const useConnectionStore = defineStore("connection", () => { const scopes = schema ? (preferred ? [preferred] : []) : allScopes; const ranked = scopes .flatMap((entry) => entry?.objects ?? []) + .filter((object) => getConfig(connectionId)?.db_type !== "oceanbase-oracle" || !schema || object.schema === schema) .map((object) => ({ object, score: objectMatchScore(object, filter, schema) })) .filter((entry) => entry.score >= 0) .sort((a, b) => b.score - a.score || a.object.name.localeCompare(b.object.name)); @@ -9142,43 +9148,60 @@ export const useConnectionStore = defineStore("connection", () => { ? `${connectionId}:${database}:${schema ?? ""}:${parentName ?? ""}:${cacheFilter}:${limit ?? ""}:${globalSearch ? "global" : "scoped"}:${currentSchema ?? ""}:${[...objectKinds].sort().join(",")}:${caseSensitive ? "case-sensitive" : "case-insensitive"}` : `${connectionId}:${database}:${schema ?? ""}`; if (!completionObjectsCache.value[cacheKey]) { + const requestRevision = completionCacheRevision(connectionId, database); await withCompletionInFlight( - `${cacheKey}:objects`, + `${cacheKey}:objects:${requestRevision}`, async () => { await ensureConnected(connectionId); + let loadedObjects: SqlCompletionObject[]; if (filteredRoutineAssistant) { try { - let assistantObjects = 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, "prefix", requestRevision); if (shouldWidenCompletionMatch(filter, assistantObjects.length, limit)) { try { - const widenedObjects = await listCompletionAssistantObjects(connectionId, database, filter, limit, schema, parentName, globalSearch, currentSchema, objectKinds, caseSensitive, "contains"); + const widenedObjects = await listCompletionAssistantObjects(connectionId, database, filter, limit, schema, parentName, globalSearch, currentSchema, objectKinds, caseSensitive, "contains", requestRevision); assistantObjects = [...assistantObjects, ...widenedObjects]; } catch { // Keep the prefix matches when the widened lookup is unavailable. } } - completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(assistantObjects); - } catch { + loadedObjects = dedupeCompletionObjects(assistantObjects); + } catch (error) { + if (requestRevision !== completionCacheRevision(connectionId, database)) return; + if (databaseType === "oceanbase-oracle" && parentName) throw error; + if (databaseType === "oceanbase-oracle" && !/does not support filtered routine completion|unknown method|method not found|not supported/i.test(String(error))) throw error; if (objectKinds.length === 1 && objectKinds[0] === "sequence") { - completionObjectsCache.value[cacheKey] = []; + loadedObjects = []; } else { const objects = isSchemaAwareDatabase(connectionId) ? await listSchemaAwareCompletionObjects(connectionId, database, schema) : await api.listCompletionObjects(connectionId, database, connectionDatabaseMetadataSchema(getConfig(connectionId), database, schema)); - completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(objects.map(toSqlCompletionObject).filter((object): object is SqlCompletionObject => object != null)); + loadedObjects = dedupeCompletionObjects(objects.map(toSqlCompletionObject).filter((object): object is SqlCompletionObject => object != null)); } } } else { const objects = isSchemaAwareDatabase(connectionId) ? await listSchemaAwareCompletionObjects(connectionId, database, schema) : await api.listCompletionObjects(connectionId, database, connectionDatabaseMetadataSchema(getConfig(connectionId), database, schema)); - completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(objects.map(toSqlCompletionObject).filter((object): object is SqlCompletionObject => object != null)); + loadedObjects = dedupeCompletionObjects(objects.map(toSqlCompletionObject).filter((object): object is SqlCompletionObject => object != null)); } - indexCompletionObjects(connectionId, database, schema, completionObjectsCache.value[cacheKey]); + if (requestRevision !== completionCacheRevision(connectionId, database)) return; + completionObjectsCache.value[cacheKey] = loadedObjects; + indexCompletionObjects(connectionId, database, schema, loadedObjects); evictOldestCacheEntries(completionObjectsCache.value, COMPLETION_CACHE_MAX); }, { scope: completionLimiterScope(connectionId, database), kind: "objects" }, ); } - const objects = completionObjectsCache.value[cacheKey]; - const filtered = normalizedFilter ? objects.filter((object) => fuzzyCompletionObjectMatch(object, normalizedFilter)) : objects; + const objects = completionObjectsCache.value[cacheKey] ?? []; + const filtered = objects.filter((object) => { + if (databaseType === "oceanbase-oracle") { + if (object.type === "sequence") { + if (!objectKinds.includes("sequence")) return false; + } else if (object.type !== "procedure" && object.type !== "function") return false; + else if (!objectKinds.includes("routine") && !objectKinds.includes(object.type)) return false; + if (schema && !globalSearch && object.schema !== schema) return false; + if (caseSensitive) return object.name.includes(filter.trim()); + } + return !normalizedFilter || fuzzyCompletionObjectMatch(object, normalizedFilter); + }); return typeof limit === "number" ? filtered.slice(0, limit) : filtered; } @@ -9243,7 +9266,7 @@ export const useConnectionStore = defineStore("connection", () => { const seen = new Set(); const deduped: SqlCompletionObject[] = []; for (const object of objects) { - const key = `${object.type}:${object.schema ?? ""}:${object.name}:${object.parentName ?? ""}:${object.signature?.trim() ?? ""}`.toLowerCase(); + const key = JSON.stringify([object.type, object.schema, object.name, object.parentName, object.signature?.trim()]); if (seen.has(key)) continue; seen.add(key); deduped.push(object); diff --git a/crates/dbx-core/src/schema/mod.rs b/crates/dbx-core/src/schema/mod.rs index 476b19893ac..a2dfc18b6a4 100644 --- a/crates/dbx-core/src/schema/mod.rs +++ b/crates/dbx-core/src/schema/mod.rs @@ -5477,6 +5477,22 @@ for line in sys.stdin: assert_eq!(columns[1].character_maximum_length, Some(64)); } + #[test] + fn routine_completion_wire_distinguishes_legacy_agents_from_empty_searches() { + let legacy: super::AgentCompletionAssistantResponse = serde_json::from_value(serde_json::json!({ + "candidates": [], "incomplete": false, "fallback_used": false + })) + .unwrap(); + assert!(!legacy.routine_search_supported); + let current: super::AgentCompletionAssistantResponse = serde_json::from_value(serde_json::json!({ + "candidates": [], "incomplete": false, "fallback_used": false, "routine_search_supported": true + })) + .unwrap(); + assert!(current.routine_search_supported); + assert!(current.response.candidates.is_empty()); + assert!(!current.response.incomplete); + } + #[test] fn detects_unsupported_agent_completion_assistant_errors() { assert!(super::is_agent_completion_assistant_unsupported( @@ -6663,6 +6679,14 @@ async fn close_ephemeral_agent_metadata_session( } } +#[derive(serde::Deserialize)] +struct AgentCompletionAssistantResponse { + #[serde(flatten)] + response: db::CompletionAssistantResponse, + #[serde(default)] + routine_search_supported: bool, +} + pub async fn completion_assistant_search_core( state: &AppState, request: db::CompletionAssistantRequest, @@ -6750,13 +6774,25 @@ pub async fn completion_assistant_search_core( let db_config = connection_config(state, &request.connection_id).await; let mut client = client.lock().await; match client - .completion_assistant_search::( + .completion_assistant_search::( &request, agent_metadata_timeout(db_config.as_ref()), ) .await { - Ok(mut response) => { + Ok(agent_response) => { + if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::OceanbaseOracle) + && request.object_kinds.iter().any(db::CompletionAssistantObjectKind::is_routine_like) + && !agent_response.routine_search_supported + { + // Older JDBC agents return a successful empty list for routine requests. + // Let the frontend retain its existing schema-list fallback only there. + return Err( + "OceanBase agent does not support filtered routine completion; update the agent" + .to_string(), + ); + } + let mut response = agent_response.response; response.fallback_used = false; return Ok(response); } diff --git a/crates/dbx-web/src/routes/transfer.rs b/crates/dbx-web/src/routes/transfer.rs index b70ed93e9ba..2a228ce05ce 100644 --- a/crates/dbx-web/src/routes/transfer.rs +++ b/crates/dbx-web/src/routes/transfer.rs @@ -972,6 +972,13 @@ mod tests { async fn demo_mode_transfer_does_not_write_task_history() { let (mut state, dir) = test_web_state().await; Arc::get_mut(&mut state).unwrap().demo_mode = true; + let src = sqlite_config("src", &dir.join("src.db").to_string_lossy()); + let dst = sqlite_config("dst", &dir.join("dst.db").to_string_lossy()); + std::fs::write(dir.join("src.db"), b"").unwrap(); + std::fs::write(dir.join("dst.db"), b"").unwrap(); + std::fs::write(dir.join("main.db"), b"").unwrap(); + state.app.configs.write().await.insert("src".to_string(), src); + state.app.configs.write().await.insert("dst".to_string(), dst); let req = transfer_request("src", "dst", &dir); let transfer_id = req.transfer_id.clone(); let _ = start_transfer(State(state.clone()), Json(StartTransferRequest { request: req })).await.unwrap();