Skip to content
Closed
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@ public final class CompletionAssistantResponse {
private final List<CompletionAssistantCandidate> candidates;
private final boolean incomplete;
private final boolean fallback_used;
private final Boolean routine_search_supported;

public CompletionAssistantResponse(List<CompletionAssistantCandidate> candidates, boolean incomplete, boolean fallbackUsed) {
this(candidates, incomplete, fallbackUsed, null);
}

public CompletionAssistantResponse(List<CompletionAssistantCandidate> candidates, boolean incomplete, boolean fallbackUsed, Boolean routineSearchSupported) {
this.candidates = candidates;
this.incomplete = incomplete;
this.fallback_used = fallbackUsed;
this.routine_search_supported = routineSearchSupported;
}

public List<CompletionAssistantCandidate> getCandidates() { return candidates; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<CompletionAssistantObjectKind> kinds = request.getObject_kinds();
List<Object> 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<CompletionAssistantCandidate> 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));
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -485,6 +486,78 @@ void scopedCompletionTableSearchFiltersOwnerCaseInsensitively() {
Assertions.assertEquals(List.of("ORD%", "STAGING", "ORD%", "STAGING", "DWD", "ORD", 21), query.args);
}

@Test
void completionAssistantSearchReturnsBoundedStandaloneRoutines() {
List<String> 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<String> 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<String> sql = new ArrayList<>();
List<String> 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<String> sql = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
// @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";
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 }));
Expand Down Expand Up @@ -48,7 +48,7 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}, configureMetad
lookupLocalCompletionSchemas: vi.fn((): string[] => []),
lookupLocalCompletionForeignKeys: vi.fn(() => []),
listCompletionTables: vi.fn(async (): Promise<SqlCompletionTable[]> => []),
listCompletionObjects: vi.fn(async () => []),
listCompletionObjects: vi.fn(async (): Promise<SqlCompletionObject[]> => []),
listCompletionColumns: vi.fn(async (): Promise<SqlCompletionColumn[]> => []),
listCompletionSchemas: vi.fn(async (): Promise<string[]> => []),
listCompletionDatabases: vi.fn(async (): Promise<string[]> => []),
Expand Down Expand Up @@ -104,6 +104,69 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}, 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());

Expand Down
Loading
Loading