From a73bc4df699219465ec4e25d9125aefd0616cd82 Mon Sep 17 00:00:00 2001 From: Mo-Moore <33098900+Mo-Moore@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:16:18 +0800 Subject: [PATCH] fix(oceanbase-oracle): complete tables across schemas --- .github/scripts/ci-plan.mjs | 31 +- .github/scripts/ci-plan.test.mjs | 15 + .../oceanbaseoracle/OceanBaseOracleAgent.java | 332 ++++++++++++++++++ .../OceanBaseOracleAgentTest.java | 89 +++++ .../editor/useQueryEditorCompletion.ts | 11 +- .../src/lib/sql/oracleCompletionSession.ts | 7 +- apps/desktop/src/lib/sql/sqlCompletion.ts | 15 +- .../connectionStore.completion.spec.ts | 53 +++ apps/desktop/src/stores/connectionStore.ts | 6 +- packages/app-tests/sqlCompletion.test.ts | 37 ++ 10 files changed, 574 insertions(+), 22 deletions(-) diff --git a/.github/scripts/ci-plan.mjs b/.github/scripts/ci-plan.mjs index b97cfad679..14f5b3fa4b 100644 --- a/.github/scripts/ci-plan.mjs +++ b/.github/scripts/ci-plan.mjs @@ -1,4 +1,4 @@ -import { appendFileSync } from "node:fs"; +import { appendFileSync, existsSync, readdirSync } from "node:fs"; import { execFileSync } from "node:child_process"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -12,7 +12,21 @@ export function changedPaths(base, root) { }).split("\0").filter(Boolean); } -export function planCi({ files, metadata, root, eventName = "pull_request", rustChanged = false, agentsChanged = false }) { +export function knownJavaDrivers(root, nativeDrivers) { + try { + const driversRoot = path.join(root, "agents/drivers"); + return readdirSync(driversRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() + && existsSync(path.join(driversRoot, entry.name, "build.gradle")) + && !nativeDrivers.includes(entry.name)) + .map((entry) => entry.name) + .sort(); + } catch { + return []; + } +} + +export function planCi({ files, metadata, root, eventName = "pull_request", rustChanged = false, agentsChanged = false, javaDrivers }) { const unknownDiff = files === null; files ??= []; const packages = metadata.packages.filter((pkg) => metadata.workspace_members.includes(pkg.id)); @@ -49,12 +63,17 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust .filter(([, names]) => names.some((name) => affected.has(name))) .map(([group]) => ({ group })); const nativeDrivers = [...goAgents.map((agent) => agent.driver), ...rustAgents]; + const jdbcDrivers = javaDrivers ?? knownJavaDrivers(root, nativeDrivers); const nativeChanges = new Set(nativeDrivers.filter((driver) => files.some((file) => file.startsWith(`agents/drivers/${driver}/`)))); + const javaDriverChanges = jdbcDrivers.some((driver) => files.some((file) => file.startsWith(`agents/drivers/${driver}/`))); + // Driver-scoped JDBC/Go/Rust trees are not "shared Agent inputs". Unknown + // agents/drivers// paths still fail open to full coverage below. const sharedAgents = ciChanged || sharedRust || files.some((file) => file.startsWith("agents/") - && !nativeDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`))) + && !nativeDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`)) + && !jdbcDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`))) || files.some((file) => file.startsWith(".github/scripts/bump-agent-versions.") || file === ".github/workflows/agents-release.yml" || file === "crates/dbx-drivers/assets/agent-protocol-v2.json"); - const allAgents = sharedAgents || (agentsChanged && nativeChanges.size === 0); + const allAgents = sharedAgents || (agentsChanged && nativeChanges.size === 0 && !javaDriverChanges); if (allAgents) { for (const driver of nativeDrivers) nativeChanges.add(driver); } @@ -62,8 +81,8 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust const goMatrix = goAgents.filter((agent) => nativeChanges.has(agent.driver)); const nativeRustMatrix = rustAgents.filter((driver) => nativeChanges.has(driver)).map((driver) => ({ driver })); const liveMatrix = integrationCases.filter((entry) => nativeChanges.has(entry.driver)); - const java = allAgents; - const agents = agentsChanged || sharedAgents || nativeChanges.size > 0; + const java = allAgents || javaDriverChanges; + const agents = agentsChanged || sharedAgents || nativeChanges.size > 0 || javaDriverChanges; return { rust, rust_full: full, rust_matrix: { include: rustMatrix }, rust_groups_known: !unknownMember, affected_packages: [...affected].sort(), diff --git a/.github/scripts/ci-plan.test.mjs b/.github/scripts/ci-plan.test.mjs index 60edf33ab1..2feb2b15ee 100644 --- a/.github/scripts/ci-plan.test.mjs +++ b/.github/scripts/ci-plan.test.mjs @@ -106,6 +106,21 @@ for (const driver of ["duckdb", "tdengine", ...goAgents.map((entry) => entry.dri }); } +test("known JDBC driver changes only select Java agent tests", () => { + const result = plan(["agents/drivers/oceanbase-oracle/src/main/java/Agent.java"], { + agentsChanged: true, + javaDrivers: ["oceanbase-oracle", "dameng"], + }); + assert.equal(result.rust, false); + assert.equal(result.agents, true); + assert.equal(result.agent_java, true); + assert.equal(result.agent_go.include.length, 0); + assert.equal(result.agent_rust.include.length, 0); + assert.equal(result.agent_integration.include.length, 0); + assert.equal(result.duckdb_changed, false); + assert.equal(result.fast, true); +}); + test("shared Agent inputs and unknown native modules never silently lose coverage", () => { for (const file of ["agents/common/src/main/java/Protocol.java", "agents/scripts/validate_agents.py", "agents/build.gradle", "agents/drivers/new-driver/main.go", "crates/dbx-drivers/assets/agent-protocol-v2.json", ".github/workflows/agents-release.yml"]) { 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 055fa56556..bbe7dbbce5 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 @@ -2,6 +2,12 @@ package com.dbx.agent.oceanbaseoracle; import com.dbx.agent.ColumnInfo; import com.dbx.agent.AgentProtocol; +import com.dbx.agent.CompletionAssistantCandidate; +import com.dbx.agent.CompletionAssistantCandidateKind; +import com.dbx.agent.CompletionAssistantMatchMode; +import com.dbx.agent.CompletionAssistantObjectKind; +import com.dbx.agent.CompletionAssistantRequest; +import com.dbx.agent.CompletionAssistantResponse; import com.dbx.agent.ConfiguredJdbcAgent; import com.dbx.agent.ConnectParams; import com.dbx.agent.DatabaseInfo; @@ -34,8 +40,10 @@ import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; import java.util.Deque; +import java.util.HashSet; import java.util.IdentityHashMap; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -388,6 +396,330 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { }); } + @Override + public CompletionAssistantResponse completionAssistantSearch(CompletionAssistantRequest request) { + if (hasTableLikeCompletionKind(request.getObject_kinds())) { + return unchecked(() -> completionAssistantTables(request)); + } + return super.completionAssistantSearch(request); + } + + private CompletionAssistantResponse completionAssistantTables(CompletionAssistantRequest request) throws SQLException { + int limit = boundedCompletionLimit(request.getMax_results()); + int scanLimit = Math.min(1000, Math.max(limit * 3, limit + 1)); + String preferredSchema = preferredCompletionSchema(request); + CompletionTablesQuery query = buildCompletionTablesQuery(request, preferredSchema, scanLimit + 1); + List rows = new ArrayList<>(); + try (PreparedStatement stmt = requireConnection().prepareStatement(query.sql)) { + bindCompletionArgs(stmt, query.args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + rows.add(new CompletionTableRow( + rs.getString(1), + rs.getString(2), + rs.getString(3), + rs.getString(4), + rs.getString(5) + )); + } + } + } + Set validTargets = validCompletionSynonymTargets( + rows, + completionTableObjectTypes(request.getObject_kinds()) + ); + List candidates = new ArrayList<>(); + for (CompletionTableRow row : rows) { + if (row.name == null || row.name.isBlank()) { + continue; + } + if ("SYNONYM".equalsIgnoreCase(row.objectType) + && (row.targetOwner == null || row.targetName == null + || !validTargets.contains(new CompletionSynonymTarget(row.targetOwner, row.targetName)))) { + continue; + } + CompletionAssistantCandidateKind kind = "VIEW".equalsIgnoreCase(row.objectType) + ? CompletionAssistantCandidateKind.VIEW + : CompletionAssistantCandidateKind.TABLE; + candidates.add(new CompletionAssistantCandidate( + row.name, + kind, + blankToNull(request.getDatabase()), + row.owner, + null, + null, + null, + row.objectType + )); + } + boolean incomplete = candidates.size() > limit; + if (incomplete) { + candidates = new ArrayList<>(candidates.subList(0, limit)); + } + return new CompletionAssistantResponse(candidates, incomplete, false); + } + + static CompletionTablesQuery buildCompletionTablesQuery( + CompletionAssistantRequest request, + String preferredSchema, + int limit + ) { + List objectTypes = completionTableObjectTypes(request.getObject_kinds()); + boolean caseSensitive = request.getCase_sensitive(); + String pattern = completionLikePattern(request.getMask(), request.getMatch_mode()); + // OceanBase Oracle matches metadata the same way as listTables: fold the + // bind value in Java and compare UPPER(column) LIKE ?. UPPER(?) on binds + // is unreliable for fuzzy/lowercase masks in this driver. + if (!caseSensitive) { + pattern = pattern.toUpperCase(Locale.ROOT); + } + List args = new ArrayList<>(); + String namePredicate = completionNamePredicate("o.OBJECT_NAME", caseSensitive); + String synonymNamePredicate = completionNamePredicate("s.SYNONYM_NAME", caseSensitive); + args.add(pattern); + + String ownerPredicate = ""; + String synonymOwnerPredicate = ""; + String owner = ""; + if (!request.getGlobal_search()) { + owner = firstNonBlank(request.getParent_schema(), request.getSchema(), preferredSchema); + if (owner == null) { + owner = ""; + } + owner = owner.toUpperCase(Locale.ROOT); + args.add(owner); + ownerPredicate = " AND UPPER(o.OWNER) = ?"; + } + + args.add(pattern); + if (!owner.isEmpty()) { + args.add(owner); + synonymOwnerPredicate = " AND UPPER(s.OWNER) = ?"; + } + + String preferred = preferredSchema == null ? "" : preferredSchema.trim(); + String preferredFolded = caseSensitive ? preferred : preferred.toUpperCase(Locale.ROOT); + String exactMask = request.getMask() == null ? "" : request.getMask().trim(); + String exactFolded = caseSensitive ? exactMask : exactMask.toUpperCase(Locale.ROOT); + args.add(preferredFolded); + args.add(exactFolded); + args.add(limit); + + String typeList = String.join(", ", objectTypes); + String baseSql = """ + SELECT o.OWNER, + o.OBJECT_NAME, + o.OBJECT_TYPE, + CAST(NULL AS VARCHAR2(128)) AS TARGET_OWNER, + CAST(NULL AS VARCHAR2(128)) AS TARGET_NAME + FROM ALL_OBJECTS o + WHERE o.OBJECT_TYPE IN (%s) + AND %s%s + UNION ALL + SELECT s.OWNER, + s.SYNONYM_NAME AS OBJECT_NAME, + 'SYNONYM' AS OBJECT_TYPE, + s.TABLE_OWNER AS TARGET_OWNER, + s.TABLE_NAME AS TARGET_NAME + FROM ALL_SYNONYMS s + WHERE s.DB_LINK IS NULL + AND %s%s + """.formatted(typeList, namePredicate, ownerPredicate, synonymNamePredicate, synonymOwnerPredicate) + .stripIndent() + .trim(); + + String preferredOwnerPredicate = caseSensitive ? "OWNER = ?" : "UPPER(OWNER) = ?"; + String exactNamePredicate = caseSensitive + ? "OBJECT_NAME = ?" + : "UPPER(OBJECT_NAME) = ?"; + String orderedSql = """ + SELECT OWNER, OBJECT_NAME, OBJECT_TYPE, TARGET_OWNER, TARGET_NAME + FROM ( + %s + ) + ORDER BY CASE + WHEN %s THEN 0 + WHEN OWNER = 'PUBLIC' THEN 1 + WHEN OWNER IN ('SYS','SYSTEM','SYSMAN','DBSNMP','OUTLN','XDB','MDSYS','CTXSYS','WMSYS') THEN 3 + ELSE 2 + END, + CASE WHEN %s THEN 0 ELSE 1 END, + CASE OBJECT_TYPE WHEN 'TABLE' THEN 0 WHEN 'VIEW' THEN 1 WHEN 'SYNONYM' THEN 3 ELSE 4 END, + OBJECT_NAME, + OWNER + """.formatted(baseSql, preferredOwnerPredicate, exactNamePredicate).stripIndent().trim(); + + String sql = """ + SELECT OWNER, OBJECT_NAME, OBJECT_TYPE, TARGET_OWNER, TARGET_NAME + FROM ( + %s + ) + WHERE ROWNUM <= ? + """.formatted(orderedSql).stripIndent().trim(); + + return new CompletionTablesQuery(sql, args); + } + + private static List completionTableObjectTypes(List kinds) { + List objectTypes = new ArrayList<>(); + boolean any = kinds == null || kinds.isEmpty(); + if (any || kinds.contains(CompletionAssistantObjectKind.TABLE)) { + objectTypes.add("'TABLE'"); + } + if (any || kinds.contains(CompletionAssistantObjectKind.VIEW)) { + objectTypes.add("'VIEW'"); + } + if (objectTypes.isEmpty()) { + objectTypes.add("'TABLE'"); + objectTypes.add("'VIEW'"); + } + return objectTypes; + } + + private static boolean hasTableLikeCompletionKind(List kinds) { + if (kinds == null || kinds.isEmpty()) { + return true; + } + for (CompletionAssistantObjectKind kind : kinds) { + if (kind == CompletionAssistantObjectKind.TABLE || kind == CompletionAssistantObjectKind.VIEW) { + return true; + } + } + return false; + } + + private String preferredCompletionSchema(CompletionAssistantRequest request) throws SQLException { + String preferred = firstNonBlank(request.getSchema()); + if (preferred != null && !preferred.isBlank()) { + return preferred; + } + String current = currentSchema(); + return current == null ? "" : current; + } + + private static String completionLikePattern(String mask, CompletionAssistantMatchMode matchMode) { + String escaped = escapeLikePattern(mask == null ? "" : mask.trim()); + if (matchMode == CompletionAssistantMatchMode.CONTAINS) { + return "%" + escaped + "%"; + } + return escaped + "%"; + } + + private static String escapeLikePattern(String mask) { + return mask.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); + } + + private static String completionNamePredicate(String column, boolean caseSensitive) { + if (caseSensitive) { + return column + " LIKE ? ESCAPE '\\'"; + } + return "UPPER(" + column + ") LIKE ? ESCAPE '\\'"; + } + + private static int boundedCompletionLimit(Integer requested) { + if (requested == null || requested <= 0) { + return 100; + } + return Math.min(requested, 1000); + } + + private static String firstNonBlank(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + return null; + } + + private static String blankToNull(String value) { + return value == null || value.isBlank() ? null : value; + } + + private static void bindCompletionArgs(PreparedStatement stmt, List args) throws SQLException { + for (int i = 0; i < args.size(); i++) { + Object arg = args.get(i); + if (arg instanceof Integer integer) { + stmt.setInt(i + 1, integer); + } else { + stmt.setString(i + 1, arg == null ? null : String.valueOf(arg)); + } + } + } + + private Set validCompletionSynonymTargets( + List rows, + List objectTypes + ) throws SQLException { + Set targets = new LinkedHashSet<>(); + for (CompletionTableRow row : rows) { + if ("SYNONYM".equalsIgnoreCase(row.objectType) && row.targetOwner != null && row.targetName != null) { + targets.add(new CompletionSynonymTarget(row.targetOwner, row.targetName)); + } + } + Set valid = new HashSet<>(); + if (targets.isEmpty()) { + return valid; + } + String typeList = String.join(", ", objectTypes); + List ordered = new ArrayList<>(targets); + for (int start = 0; start < ordered.size(); start += 100) { + List batch = ordered.subList(start, Math.min(start + 100, ordered.size())); + List args = new ArrayList<>(); + List predicates = new ArrayList<>(); + for (CompletionSynonymTarget target : batch) { + args.add(target.owner()); + args.add(target.name()); + predicates.add("(o.OWNER = ? AND o.OBJECT_NAME = ?)"); + } + String sql = "SELECT DISTINCT o.OWNER, o.OBJECT_NAME" + + " FROM ALL_OBJECTS o" + + " WHERE o.OBJECT_TYPE IN (" + typeList + ")" + + " AND (" + String.join(" OR ", predicates) + ")"; + try (PreparedStatement stmt = requireConnection().prepareStatement(sql)) { + bindCompletionArgs(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + valid.add(new CompletionSynonymTarget(rs.getString(1), rs.getString(2))); + } + } + } + } + return valid; + } + + record CompletionSynonymTarget(String owner, String name) { + } + + static final class CompletionTableRow { + final String owner; + final String name; + final String objectType; + final String targetOwner; + final String targetName; + + CompletionTableRow(String owner, String name, String objectType, String targetOwner, String targetName) { + this.owner = owner; + this.name = name; + this.objectType = objectType; + this.targetOwner = targetOwner; + this.targetName = targetName; + } + } + + static final class CompletionTablesQuery { + final String sql; + final List args; + + CompletionTablesQuery(String sql, List args) { + this.sql = sql; + this.args = List.copyOf(args); + } + } + private static MetadataSql oceanBaseMetadataSql( String baseSql, String selectList, 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 69c5abc163..1e07fdfd76 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 @@ -2,6 +2,10 @@ package com.dbx.agent.oceanbaseoracle; import com.dbx.agent.AgentProtocol; import com.dbx.agent.ColumnInfo; +import com.dbx.agent.CompletionAssistantMatchMode; +import com.dbx.agent.CompletionAssistantObjectKind; +import com.dbx.agent.CompletionAssistantRequest; +import com.dbx.agent.CompletionAssistantResponse; import com.dbx.agent.ConnectParams; import com.dbx.agent.ExecuteQueryOptions; import com.dbx.agent.MetadataListConstraints; @@ -460,6 +464,64 @@ class OceanBaseOracleAgentTest { Assertions.assertTrue(sql.get(0).contains("ROWNUM <= ?"), sql.get(0)); } + @Test + void globalCompletionTableSearchOmitsOwnerFilter() { + CompletionAssistantRequest request = completionRequest("DWD", null, "STAG", true); + OceanBaseOracleAgent.CompletionTablesQuery query = OceanBaseOracleAgent.buildCompletionTablesQuery(request, "DWD", 21); + + Assertions.assertTrue(query.sql.contains("FROM ALL_OBJECTS"), query.sql); + Assertions.assertTrue(query.sql.contains("FROM ALL_SYNONYMS"), query.sql); + Assertions.assertTrue(query.sql.contains("UPPER(o.OBJECT_NAME) LIKE ?"), query.sql); + Assertions.assertFalse(query.sql.contains("UPPER(o.OWNER) = ?"), query.sql); + Assertions.assertFalse(query.sql.contains("UPPER(s.OWNER) = ?"), query.sql); + Assertions.assertTrue(query.sql.contains("ROWNUM <= ?"), query.sql); + Assertions.assertEquals(List.of("STAG%", "STAG%", "DWD", "STAG", 21), query.args); + } + + @Test + void completionTableSearchFoldsLowercaseMasksForFuzzyMatch() { + CompletionAssistantRequest request = completionRequest("dwd", null, "ord", true); + setField(request, "match_mode", CompletionAssistantMatchMode.CONTAINS); + OceanBaseOracleAgent.CompletionTablesQuery query = OceanBaseOracleAgent.buildCompletionTablesQuery(request, "dwd", 21); + + Assertions.assertTrue(query.sql.contains("UPPER(o.OBJECT_NAME) LIKE ?"), query.sql); + Assertions.assertTrue(query.sql.contains("UPPER(OWNER) = ?"), query.sql); + Assertions.assertTrue(query.sql.contains("UPPER(OBJECT_NAME) = ?"), query.sql); + Assertions.assertFalse(query.sql.contains("LIKE UPPER(?)"), query.sql); + Assertions.assertEquals(List.of("%ORD%", "%ORD%", "DWD", "ORD", 21), query.args); + } + + @Test + void scopedCompletionTableSearchFiltersOwnerCaseInsensitively() { + CompletionAssistantRequest request = completionRequest("dwd", "staging", "ord", false); + OceanBaseOracleAgent.CompletionTablesQuery query = OceanBaseOracleAgent.buildCompletionTablesQuery(request, "dwd", 21); + + Assertions.assertTrue(query.sql.contains("UPPER(o.OWNER) = ?"), query.sql); + Assertions.assertTrue(query.sql.contains("UPPER(s.OWNER) = ?"), query.sql); + Assertions.assertEquals(List.of("ORD%", "STAGING", "ORD%", "STAGING", "DWD", "ORD", 21), query.args); + } + + @Test + void completionAssistantSearchReturnsGlobalTableCandidates() { + List sql = new ArrayList<>(); + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet( + new String[]{"OWNER", "OBJECT_NAME", "OBJECT_TYPE", "TARGET_OWNER", "TARGET_NAME"}, + new Object[][]{ + {"DWD", "ORDERS", "TABLE", null, null}, + {"STAGING", "ORDERS", "TABLE", null, null} + } + ))); + + CompletionAssistantResponse response = agent.completionAssistantSearch(completionRequest("DWD", null, "ORD", true)); + + Assertions.assertEquals(2, response.getCandidates().size()); + Assertions.assertEquals("ORDERS", response.getCandidates().get(0).getName()); + Assertions.assertEquals("DWD", response.getCandidates().get(0).getSchema()); + Assertions.assertEquals("STAGING", response.getCandidates().get(1).getSchema()); + Assertions.assertFalse(sql.get(0).contains("UPPER(o.OWNER) = ?"), sql.get(0)); + } + @Test void readsViewDdlWithDbmsMetadataForSchemaCompare() { List sql = new ArrayList<>(); @@ -1467,6 +1529,33 @@ class OceanBaseOracleAgentTest { return null; } + private static CompletionAssistantRequest completionRequest( + String schema, + String parentSchema, + String mask, + boolean globalSearch + ) { + CompletionAssistantRequest request = new CompletionAssistantRequest(); + setField(request, "database", "OBORCL"); + setField(request, "schema", schema); + setField(request, "parent_schema", parentSchema); + setField(request, "mask", mask); + setField(request, "global_search", globalSearch); + setField(request, "max_results", 20); + setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.TABLE, CompletionAssistantObjectKind.VIEW)); + return request; + } + + private static void setField(Object target, String name, Object value) { + try { + var field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + } + private static T proxy(Class type, MethodHandler handler) { InvocationHandler invocationHandler = new InvocationHandler() { @Override diff --git a/apps/desktop/src/components/editor/useQueryEditorCompletion.ts b/apps/desktop/src/components/editor/useQueryEditorCompletion.ts index fc9ec5f82e..695046c1f6 100644 --- a/apps/desktop/src/components/editor/useQueryEditorCompletion.ts +++ b/apps/desktop/src/components/editor/useQueryEditorCompletion.ts @@ -42,6 +42,7 @@ import { completionLabelPresentation } from "@/lib/editor/sqlCompletionPresentat import { supportsDatabaseNameCompletion } from "@/lib/database/databaseFeatureSupport"; import { sqlSnippetDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { oracleDatabaseLinkCompletionContext, oracleDatabaseLinkCompletionItems } from "@/lib/sql/oracleDatabaseLinkCompletion"; +import { isOracleCompletionDatabase } from "@/lib/sql/oracleCompletionSession"; import { buildRedisCompletionItemsFromContext, getRedisCompletionContext, getRedisCompletionResultValidFor, shouldAutoOpenRedisCompletion, takesKeyArgument } from "@/lib/redis/redisCompletion"; import type { SqlCompletionColumn, SqlCompletionContext, SqlCompletionForeignKey, SqlCompletionItem, SqlCompletionObject } from "@/lib/sql/sqlCompletion"; import type { CompletionAssistantObjectKind, SqlServerCompletionContext } from "@/types/database"; @@ -995,7 +996,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) completionContext, knownDatabases: databaseNames, }); - const globalOracleTableSearch = props.databaseType === "oracle" && completionContext.suggestTables && !completionContext.qualifier; + const globalOracleTableSearch = isOracleCompletionDatabase(props.databaseType) && completionContext.suggestTables && !completionContext.qualifier; const tables = schemaLookupDatabase ? [] : shouldLoadTables @@ -1157,7 +1158,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) knownDatabases: databaseNames, }); if (!localOnlyMetadata && !schemaLookupDatabase && (completionContext.suggestTables || (!!completionContext.qualifier && !isReferencedTableQualifier(completionContext)))) { - const globalOracleTableSearch = props.databaseType === "oracle" && completionContext.suggestTables && !completionContext.qualifier; + const globalOracleTableSearch = isOracleCompletionDatabase(props.databaseType) && completionContext.suggestTables && !completionContext.qualifier; const refreshEpoch = completionEpoch; queueTableCompletionRefresh(async () => { if (refreshEpoch !== completionEpoch) return; @@ -1270,7 +1271,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) if (parts.length === 0) { // Oracle resolves unqualified routines across all schemas (owner semantics). // openGauss keeps the PG search_path scope for the unqualified case. - if (props.databaseType === "oracle") return [{ schema: props.schema, globalSearch: true }]; + if (isOracleCompletionDatabase(props.databaseType)) return [{ schema: props.schema, globalSearch: true }]; return [{ schema: props.schema }]; } if (parts.length === 1) { @@ -1366,7 +1367,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) completionContext, knownDatabases: databaseNames, }); - const globalOracleTableSearch = props.databaseType === "oracle" && completionContext.suggestTables && !completionContext.qualifier; + const globalOracleTableSearch = isOracleCompletionDatabase(props.databaseType) && completionContext.suggestTables && !completionContext.qualifier; let tables = schemaLookupDatabase ? [] : shouldLoadTables @@ -1385,7 +1386,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) let completionObjects = shouldLoadObjects ? (localOnlyMetadata ? lookupLocalCompletionObjectsForContext(completionContext, scope) : await listCompletionObjectsForContext(completionContext, scope)) : scopedCachedCompletionObjects; if (epoch !== completionEpoch) return null; - if (!props.catalog && props.databaseType !== "oracle" && !localOnlyMetadata && completionContext.qualifier && completionObjects.length === 0) { + if (!props.catalog && !isOracleCompletionDatabase(props.databaseType) && !localOnlyMetadata && completionContext.qualifier && completionObjects.length === 0) { const target = routineCompletionTargetForContext(completionContext, scope); const schemaObjects = await connectionStore.listCompletionObjects(props.connectionId!, target.database, target.mask, MAX_COMPLETION_TABLES, target.schema, undefined, false, scope.schema); if (schemaObjects.length > 0) { diff --git a/apps/desktop/src/lib/sql/oracleCompletionSession.ts b/apps/desktop/src/lib/sql/oracleCompletionSession.ts index c4cd84284a..0817a2149f 100644 --- a/apps/desktop/src/lib/sql/oracleCompletionSession.ts +++ b/apps/desktop/src/lib/sql/oracleCompletionSession.ts @@ -1,7 +1,12 @@ import type { DatabaseType } from "@/types/database"; +/** Native Oracle and OceanBase Oracle share multi-schema completion semantics. */ +export function isOracleCompletionDatabase(databaseType?: DatabaseType): boolean { + return databaseType === "oracle" || databaseType === "oceanbase-oracle"; +} + export function usesOracleCurrentSchemaCompletion(databaseType?: DatabaseType, schema?: string | null): boolean { - return (databaseType === "oracle" || databaseType === "oceanbase-oracle") && !schema; + return isOracleCompletionDatabase(databaseType) && !schema; } export function usesOracleSessionCompletionColumns(options: { databaseType?: DatabaseType; selectedSchema?: string; referenceSchema?: string | null; clientSessionId?: string }): boolean { diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index f70cc9fa41..06816e01e9 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -20,6 +20,7 @@ import { quoteTableIdentifier, quoteTableIdentifierIfNeeded } from "@/lib/table/ import { driverProfileCompletionObjects, driverProfileCompletionTableMetadata, driverProfileCompletionTables, driverProfileRoutineSignatures } from "@/lib/database/driverProfileExtensions"; import { DORIS_FUNCTION_DOCS, DORIS_FUNCTION_SIGNATURES } from "@/lib/sql/doris/functions"; import { rejectsAliasReferenceInHaving } from "@/lib/database/databaseFeatureSupport"; +import { isOracleCompletionDatabase } from "@/lib/sql/oracleCompletionSession"; export { DEFAULT_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates"; @@ -3907,10 +3908,10 @@ function resolveTableSchemaQualification( currentSchema: string | undefined, schemasByTableName: Map>, ): { ambiguousTableName: boolean; schemaQualification: boolean; defaultApplyName: string } { - const oracleSchemaQualification = databaseType === "oracle" && table.schema && table.schema.toUpperCase() !== "PUBLIC" && (!currentSchema || normalizeIdentifierPart(table.schema) !== normalizeIdentifierPart(currentSchema)); + const oracleSchemaQualification = isOracleCompletionDatabase(databaseType) && table.schema && table.schema.toUpperCase() !== "PUBLIC" && (!currentSchema || normalizeIdentifierPart(table.schema) !== normalizeIdentifierPart(currentSchema)); // A bare table name is ambiguous when metadata contains the same name in multiple schemas. // Keep Oracle's current-schema behavior, but qualify the generic/PostgreSQL/SQL Server paths. - const ambiguousTableName = databaseType !== "oracle" && (schemasByTableName.get(normalizeIdentifierPart(table.name))?.size ?? 0) > 1; + const ambiguousTableName = !isOracleCompletionDatabase(databaseType) && (schemasByTableName.get(normalizeIdentifierPart(table.name))?.size ?? 0) > 1; const schemaQualification = !!table.schema && (oracleSchemaQualification || ambiguousTableName); const defaultApplyName = schemaQualification ? `${quoteCompletionApplyIdentifier(table.schema!, dialect)}.${quoteCompletionApplyIdentifier(table.name, dialect)}` : quoteCompletionApplyIdentifier(table.name, dialect); return { ambiguousTableName, schemaQualification, defaultApplyName }; @@ -3948,7 +3949,7 @@ function buildTableItems( detail, apply: formatTableAliasApply(applyName, alias), boost: computeBoost(table.name, prefix) + 1000 + (table.boost ?? 0), - dedupeKey: table.applyName || ambiguousTableName || (databaseType === "oracle" && table.schema) ? applyName : undefined, + dedupeKey: table.applyName || ambiguousTableName || (isOracleCompletionDatabase(databaseType) && table.schema) ? applyName : undefined, }; }) .sort(compareCompletionItems) @@ -4004,7 +4005,7 @@ function buildForeignKeyRelatedTableItems( boost: computeBoost(table.name, context.prefix) + 3600, // Mirror buildTableItems' dedupeKey so an FK candidate and the regular // candidate for the same schema-qualified table collapse to one entry. - dedupeKey: ambiguousTableName || (databaseType === "oracle" && table.schema) ? applyName : undefined, + dedupeKey: ambiguousTableName || (isOracleCompletionDatabase(databaseType) && table.schema) ? applyName : undefined, }; }) .sort(compareCompletionItems); @@ -4044,7 +4045,7 @@ function buildObjectItems(context: SqlCompletionContext, objects: SqlCompletionO if (completionQualifierIsReferencedTable(context)) return []; const onlyProcedures = context.contextKind === "exec"; const onlyFunctions = context.suggestColumns && context.referencedTables.length > 0 && !context.qualifier; - const prioritizeOracleFunctions = databaseType === "oracle" && context.statementKind === "select"; + 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)) .map((object) => { @@ -4060,7 +4061,7 @@ function buildObjectItems(context: SqlCompletionContext, objects: SqlCompletionO const detail = [locationDetail, signature ? `(${signature})` : undefined, object.dataType ? `[${object.dataType}]` : undefined].filter(Boolean).join(" "); const schemaBoost = onlyFunctions ? Math.min(object.boost ?? 0, 1000) : (object.boost ?? 0); const typeBoost = routineTypeBoost(object.type, prioritizeOracleFunctions && !onlyFunctions); - const baseDedupeKey = object.applyName || (databaseType === "oracle" && object.schema) ? applyName : undefined; + const baseDedupeKey = object.applyName || (isOracleCompletionDatabase(databaseType) && object.schema) ? applyName : undefined; return { label: object.name, type: "function" as const, @@ -5470,7 +5471,7 @@ function activeSqlKeywords(databaseType?: DatabaseType): string[] { } function isOracleLikeDatabase(databaseType?: DatabaseType): boolean { - return databaseType === "oracle" || databaseType === "oceanbase-oracle"; + return isOracleCompletionDatabase(databaseType); } // CQL has no table alias syntax, so Cassandra must never receive `table alias` diff --git a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts index df837104c0..b5d4dd59c0 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts @@ -678,6 +678,59 @@ describe("connectionStore completion assistant", () => { ]); }); + it("maps global OceanBase Oracle tables with safe qualification and schema priority", async () => { + const completionAssistantSearch = vi.fn().mockResolvedValue({ + candidates: [ + { name: "ORDERS", kind: "table", schema: "DWD", data_type: "TABLE" }, + { name: "ORDERS", kind: "table", schema: "STAGING", data_type: "TABLE" }, + ], + incomplete: false, + fallback_used: false, + }); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + completionAssistantSearch, + listTables: vi.fn().mockResolvedValue([]), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [oceanBaseOracleConnection()]; + store.connectedIds.add("oceanbase-oracle-1"); + + const tables = await store.listCompletionTables("oceanbase-oracle-1", "OBORCL", "ORD", 20, "DWD", true, "DWD"); + + expect(completionAssistantSearch).toHaveBeenCalledWith(expect.objectContaining({ schema: "DWD", parent_schema: null, global_search: true, mask: "ORD" })); + expect(tables).toEqual([expect.objectContaining({ name: "ORDERS", schema: "DWD", applyName: "ORDERS", boost: 2400 }), expect.objectContaining({ name: "ORDERS", schema: "STAGING", applyName: "STAGING.ORDERS", boost: 0 })]); + }); + + it("scopes OceanBase Oracle table completion when a schema qualifier is present", async () => { + const completionAssistantSearch = vi.fn(async (request: { schema?: string | null; parent_schema?: string | null }) => ({ + candidates: request.parent_schema?.toLowerCase() === "staging" ? [{ name: "ORDERS", kind: "table", schema: "STAGING", data_type: "TABLE" }] : [], + incomplete: false, + fallback_used: false, + })); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + completionAssistantSearch, + listTables: vi.fn().mockResolvedValue([]), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [oceanBaseOracleConnection()]; + store.connectedIds.add("oceanbase-oracle-1"); + + const tables = await store.listCompletionTables("oceanbase-oracle-1", "OBORCL", "", 20, "staging", false, "DWD"); + + expect(completionAssistantSearch).toHaveBeenCalledWith(expect.objectContaining({ schema: "staging", parent_schema: "staging", global_search: false, mask: "" })); + expect(tables).toEqual([expect.objectContaining({ name: "ORDERS", schema: "STAGING", applyName: "ORDERS", boost: 2400 })]); + }); + it("lets Oracle resolve CURRENT_SCHEMA for unqualified column completion", async () => { const completionAssistantSearch = vi.fn().mockResolvedValue({ candidates: [], diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 8bd98baf4b..98eba5fce8 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -104,7 +104,7 @@ import { type ConnectionExportProtection, } from "@/lib/connection/connectionConfigTransfer"; import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionObject, SqlCompletionTable } from "@/lib/sql/sqlCompletion"; -import { usesOracleCurrentSchemaCompletion } from "@/lib/sql/oracleCompletionSession"; +import { usesOracleCurrentSchemaCompletion, isOracleCompletionDatabase } from "@/lib/sql/oracleCompletionSession"; import { mergeSqlObjectNavigationType, sqlObjectNavigationTypeFromTableType } from "@/lib/sql/sqlNavigation"; import * as api from "@/lib/backend/api"; import { oracleDatabaseLinksFromResult, oracleDatabaseLinksSql, supportsOracleDatabaseLinks } from "@/lib/database/oracleDatabaseLinks"; @@ -7997,7 +7997,7 @@ export const useConnectionStore = defineStore("connection", () => { requestRevision = completionCacheRevision(connectionId, database), matchMode: CompletionAssistantMatchMode = "prefix", ): Promise { - const oracleAssistant = getConfig(connectionId)?.db_type === "oracle"; + const oracleAssistant = isOracleCompletionDatabase(getConfig(connectionId)?.db_type); const preferredSchema = oracleAssistant ? completionPreferredSchema(connectionId, globalSearch ? currentSchema : (schema ?? currentSchema)) : schema?.trim() || undefined; const objectKinds: CompletionAssistantObjectKind[] = ["table", "view"]; const response = await completionAssistantSearch( @@ -8033,7 +8033,7 @@ export const useConnectionStore = defineStore("connection", () => { matchMode: CompletionAssistantMatchMode = "prefix", ): Promise { const databaseType = getConfig(connectionId)?.db_type; - const oracleAssistant = databaseType === "oracle"; + 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); diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index 3490ae594b..3b2a27ff44 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -2821,6 +2821,43 @@ test("prioritizes current Oracle schema tables and safely qualifies other schema ); }); +test("prioritizes current OceanBase Oracle schema tables and safely qualifies other schemas", () => { + const sql = "select * from orders"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [ + { name: "ORDERS", schema: "STAGING", type: "table", applyName: "STAGING.ORDERS", boost: 0 }, + { name: "ORDERS", schema: "DWD", type: "table", applyName: "ORDERS", boost: 2400 }, + ], + columnsByTable, + databaseType: "oceanbase-oracle", + currentSchema: "DWD", + }); + const matches = items.filter((item) => item.label === "ORDERS"); + + assert.deepEqual( + matches.map((item) => item.apply), + ["ORDERS", "STAGING.ORDERS"], + ); +}); + +test("qualifies OceanBase Oracle tables outside the current schema without applyName", () => { + const sql = "select * from orders"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [ + { name: "ORDERS", schema: "STAGING", type: "table", boost: 0 }, + { name: "ORDERS", schema: "DWD", type: "table", boost: 2400 }, + ], + columnsByTable, + databaseType: "oceanbase-oracle", + currentSchema: "DWD", + }); + + assert.deepEqual( + items.filter((item) => item.label === "ORDERS").map((item) => item.apply), + ["ORDERS", "STAGING.ORDERS"], + ); +}); + test("does not duplicate an Oracle schema qualifier when applying a scoped table", () => { const sql = "select * from COMM.DEPT_D"; const items = buildSqlCompletionItems(sql, sql.length, {