From 85d7d83477543bab78b0e94d9d98a9fbf2495a64 Mon Sep 17 00:00:00 2001 From: efeire <15682810+efeire@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:10:44 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(oceanbase):=20=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E8=BF=87=E7=A8=8B=E5=87=BD=E6=95=B0=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E8=A1=A5=E5=85=A8=20(#11423)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/CompletionAssistantResponse.java | 6 ++ .../oceanbaseoracle/OceanBaseOracleAgent.java | 53 ++++++++++++++++++ .../OceanBaseOracleAgentTest.java | 55 +++++++++++++++++++ .../useQueryEditorCompletion.spec.ts | 33 ++++++++++- .../editor/useQueryEditorCompletion.ts | 17 +++++- .../useQueryEditorCompletionMetadata.ts | 6 +- .../sql/sqlCompletion.context.spec.ts | 7 +++ apps/desktop/src/lib/sql/sqlCompletion.ts | 46 +++++++++++++--- .../connectionStore.completion.spec.ts | 54 ++++++++++++++++++ apps/desktop/src/stores/connectionStore.ts | 50 +++++++++++------ crates/dbx-core/src/schema/mod.rs | 35 +++++++++++- 11 files changed, 329 insertions(+), 33 deletions(-) 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 43596973df8..1f5334b4c81 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 @@ -35,6 +35,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; @@ -347,9 +348,61 @@ 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'"; + 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 : 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 ffc78b2f807..0da16f9c049 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 @@ -485,6 +485,61 @@ 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 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..3fc93fcaa41 100644 --- a/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletion.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletion.spec.ts @@ -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,35 @@ 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("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); + }); +}); + 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 24cd2c4c1e5..ff9eff725fd 100644 --- a/apps/desktop/src/components/editor/useQueryEditorCompletion.ts +++ b/apps/desktop/src/components/editor/useQueryEditorCompletion.ts @@ -1266,8 +1266,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); @@ -1370,12 +1372,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 { @@ -1386,7 +1394,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); @@ -1396,6 +1404,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 3e0bb0ff1af..95bfbc44d8b 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 9d502b7bf8d..b7d82fef2bb 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -1525,11 +1525,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; @@ -2654,6 +2656,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, @@ -2928,7 +2932,13 @@ 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 tail = tokenizeSqlSemantic(input, "oracle").at(-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--; @@ -2962,19 +2972,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('"'), }; } @@ -4390,13 +4404,13 @@ function buildObjectItems(context: SqlCompletionContext, objects: SqlCompletionO const onlyFunctions = context.suggestColumns && context.referencedTables.length > 0 && !context.qualifier; const prioritizeOracleFunctions = isOracleCompletionDatabase(databaseType) && context.statementKind === "select"; return objects - .filter((object) => object.type !== "sequence" && (!onlyProcedures || object.type === "procedure") && (!onlyFunctions || (object.type === "function" && object.name.toLowerCase().startsWith(context.prefix.toLowerCase()))) && objectMatchesCompletionContext(object, context)) + .filter((object) => object.type !== "sequence" && (!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; @@ -4487,8 +4501,16 @@ function completionQualifierIsReferencedTable(context: SqlCompletionContext): bo return context.referencedTables.some((table) => referencedTableMatchesColumnQualifier(table, qualifier, qualifierLower, qualifiedTarget)); } -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; @@ -4496,8 +4518,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 (context.qualifier) { const qualifier = context.qualifier.toLowerCase(); const qualifierParts = qualifier.split(".").filter(Boolean); diff --git a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts index 71c95d60b48..00b07990369 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts @@ -1600,6 +1600,60 @@ 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("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 715e9b03f8b..5ac09bf3209 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -8215,7 +8215,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; @@ -8366,6 +8366,7 @@ 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); @@ -8384,12 +8385,13 @@ export const useConnectionStore = defineStore("connection", () => { 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; } @@ -8554,6 +8556,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)); @@ -9141,43 +9144,58 @@ 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 !== "procedure" && object.type !== "function") return false; + 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; } @@ -9242,7 +9260,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 78d3431e014..aae741c9a9c 100644 --- a/crates/dbx-core/src/schema/mod.rs +++ b/crates/dbx-core/src/schema/mod.rs @@ -5477,6 +5477,20 @@ 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( @@ -6582,6 +6596,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, @@ -6669,13 +6691,22 @@ 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); } From 6e1d1ee77ce613dd173d64ccc591af1e14e1d415 Mon Sep 17 00:00:00 2001 From: efeire <15682810+efeire@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:37:18 +0800 Subject: [PATCH 2/6] =?UTF-8?q?test(web):=20=E4=B8=BA=E6=BC=94=E7=A4=BA?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E8=BF=81=E7=A7=BB=E6=B5=8B=E8=AF=95=E6=8F=90?= =?UTF-8?q?=E4=BE=9B=E8=BF=9E=E6=8E=A5=E5=A4=B9=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/dbx-web/src/routes/transfer.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/dbx-web/src/routes/transfer.rs b/crates/dbx-web/src/routes/transfer.rs index 16dd46ce041..f7eb06eb545 100644 --- a/crates/dbx-web/src/routes/transfer.rs +++ b/crates/dbx-web/src/routes/transfer.rs @@ -962,6 +962,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(); From c7e0d01dbacb8507bd1b01d451a3f7e3f88c091b Mon Sep 17 00:00:00 2001 From: efeire <15682810+efeire@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:27:10 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(oceanbase):=20=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E5=B9=B6=E6=AD=A3=E7=A1=AE=E9=80=89=E5=85=A5=E5=B8=A6=E5=BC=95?= =?UTF-8?q?=E5=8F=B7=E7=9A=84=E4=BE=8B=E7=A8=8B=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/useQueryEditorCompletion.spec.ts | 16 ++++++++++++++-- apps/desktop/src/lib/sql/sqlCompletion.ts | 7 +++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletion.spec.ts b/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletion.spec.ts index 48a0cc3e0da..78b844bedc1 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"; @@ -120,6 +120,18 @@ describe.each([false, true])("OceanBase standalone routine completion (semantic= 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; diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index a561097bbd0..81b304c7e0b 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -1605,9 +1605,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; @@ -1617,6 +1618,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}` }; From 47acd37a3c6ce3aadf0e0cb941a3187109e9ebed Mon Sep 17 00:00:00 2001 From: efeire <15682810+efeire@users.noreply.github.com> Date: Sat, 10 Oct 2026 06:04:42 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(editor):=20=E5=88=87=E6=8D=A2=E8=A1=A5?= =?UTF-8?q?=E5=85=A8=E8=8C=83=E5=9B=B4=E5=90=8E=E4=B8=A2=E5=BC=83=E8=BF=9F?= =?UTF-8?q?=E5=88=B0=E5=93=8D=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/useQueryEditorCompletion.spec.ts | 18 ++++++++++++++++++ .../editor/useQueryEditorCompletion.ts | 15 ++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletion.spec.ts b/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletion.spec.ts index 78b844bedc1..72bc764e92c 100644 --- a/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletion.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletion.spec.ts @@ -147,6 +147,24 @@ describe.each([false, true])("OceanBase standalone routine completion (semantic= 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) => { diff --git a/apps/desktop/src/components/editor/useQueryEditorCompletion.ts b/apps/desktop/src/components/editor/useQueryEditorCompletion.ts index 6a6a74ac1a7..d0a98e0e63c 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); }); From d06c1920db307d13060afb9f0945311a0568c67c Mon Sep 17 00:00:00 2001 From: efeire <15682810+efeire@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:02:08 +0800 Subject: [PATCH 5/6] fix(oceanbase): preserve sequence candidates in routine completion --- .../oceanbaseoracle/OceanBaseOracleAgent.java | 4 +++- .../OceanBaseOracleAgentTest.java | 18 ++++++++++++++++++ .../connectionStore.completion.spec.ts | 11 +++++++++++ apps/desktop/src/stores/connectionStore.ts | 6 ++++-- 4 files changed, 36 insertions(+), 3 deletions(-) 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 02b006aa7b2..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 @@ -370,6 +370,7 @@ private CompletionAssistantResponse completionAssistantRoutines(CompletionAssist || (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 = ""; @@ -398,7 +399,8 @@ WHERE OBJECT_TYPE IN (%s) AND %s%s } String objectType = rs.getString(3); candidates.add(new CompletionAssistantCandidate(rs.getString(2), - "PROCEDURE".equals(objectType) ? CompletionAssistantCandidateKind.PROCEDURE : CompletionAssistantCandidateKind.FUNCTION, + "PROCEDURE".equals(objectType) ? CompletionAssistantCandidateKind.PROCEDURE + : "SEQUENCE".equals(objectType) ? CompletionAssistantCandidateKind.SEQUENCE : CompletionAssistantCandidateKind.FUNCTION, blankToNull(request.getDatabase()), rs.getString(1), null, null, null, objectType)); } } 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 d208bdf05d7..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; @@ -509,6 +510,23 @@ void completionAssistantSearchReturnsBoundedStandaloneRoutines() { 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<>(); diff --git a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts index e29a2549bf0..caa3d70cf55 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts @@ -1623,6 +1623,17 @@ describe("connectionStore completion assistant", () => { 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" }]); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index d303e488242..09d69d94c25 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -9193,8 +9193,10 @@ export const useConnectionStore = defineStore("connection", () => { const objects = completionObjectsCache.value[cacheKey] ?? []; const filtered = objects.filter((object) => { if (databaseType === "oceanbase-oracle") { - if (object.type !== "procedure" && object.type !== "function") return false; - if (!objectKinds.includes("routine") && !objectKinds.includes(object.type)) return false; + 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()); } From a7895b075b45e3a58a3c2413fc0e7f0070e81fe6 Mon Sep 17 00:00:00 2001 From: efeire <15682810+efeire@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:18:01 +0800 Subject: [PATCH 6/6] ci: retrigger cancelled checks for PR #11535