From 0c5a456aed2d4493c9aedf0d6a3ea230a124cb8c Mon Sep 17 00:00:00 2001 From: Mo-Moore <33098900+Mo-Moore@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:34:58 +0800 Subject: [PATCH] fix(oceanbase-oracle): preserve quoted identifier case --- .../oceanbaseoracle/OceanBaseOracleAgent.java | 61 ++++++---- .../OceanBaseOracleAgentTest.java | 109 ++++++++++++++++-- .../lib/__tests__/sql/sqlNavigation.spec.ts | 7 ++ 3 files changed, 145 insertions(+), 32 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 7b53ef05a6..757125af71 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 @@ -351,11 +351,12 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { public ObjectSource getObjectSource(String schema, String name, String objectType) { return unchecked(() -> { String owner = normalizeSchema(schema); + String objectName = normalizeObjectName(name); String normalizedType = normalizeObjectSourceType(objectType); String source; SQLException metadataError = null; try { - source = queryDbmsMetadataSource(owner, name, normalizedType); + source = queryDbmsMetadataSource(owner, objectName, normalizedType); } catch (SQLException e) { metadataError = e; source = null; @@ -365,7 +366,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { try { // OceanBase versions and tenant privileges differ in DBMS_METADATA coverage. // Oracle-compatible dictionary views keep schema compare and source editing usable. - source = queryDictionarySource(owner, name, normalizedType); + source = queryDictionarySource(owner, objectName, normalizedType); } catch (SQLException fallbackError) { if (metadataError != null) { metadataError.addSuppressed(fallbackError); @@ -377,7 +378,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { if (metadataError != null && (source == null || source.trim().isEmpty())) { throw metadataError; } - return new ObjectSource(name, normalizedType, owner, source == null ? "" : source); + return new ObjectSource(objectName, normalizedType, owner, source == null ? "" : source); }); } @@ -484,6 +485,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { public List getColumns(String schema, String table) { return unchecked(() -> { String owner = normalizeSchema(schema); + String tableName = normalizeObjectName(table); String sql = """ SELECT c.COLUMN_NAME, c.DATA_TYPE, c.NULLABLE, c.DATA_PRECISION, c.DATA_SCALE, c.DATA_LENGTH, c.CHAR_LENGTH, c.DATA_DEFAULT, cc.COMMENTS, @@ -505,9 +507,9 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { List result = new ArrayList<>(); try (var stmt = requireConnection().prepareStatement(sql)) { stmt.setString(1, owner); - stmt.setString(2, table); + stmt.setString(2, tableName); stmt.setString(3, owner); - stmt.setString(4, table); + stmt.setString(4, tableName); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { // Oracle-compatible DATA_DEFAULT is LONG-like; read it before other metadata fields. @@ -541,15 +543,16 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { public String getTableDdl(String schema, String table) { return unchecked(() -> { String owner = normalizeSchema(schema); - String ddl = queryDbmsMetadataSource(owner, table, "TABLE"); + String tableName = normalizeObjectName(table); + String ddl = queryDbmsMetadataSource(owner, tableName, "TABLE"); if (ddl == null || ddl.isBlank()) { - throw new SQLException("DBMS_METADATA.GET_DDL returned empty DDL for " + owner + "." + table); + throw new SQLException("DBMS_METADATA.GET_DDL returned empty DDL for " + owner + "." + tableName); } // OceanBase 4.2.5 omits the owner from CREATE TABLE, even for another schema. var header = Pattern.compile("(?i)^(\\s*CREATE\\s+(?:GLOBAL\\s+TEMPORARY\\s+)?TABLE\\s+)" - + Pattern.quote(quoteIdentifier(table)) + "(?=\\s*\\()").matcher(ddl); + + Pattern.quote(quoteIdentifier(tableName)) + "(?=\\s*\\()").matcher(ddl); if (header.find()) { - ddl = header.replaceFirst(Matcher.quoteReplacement(header.group(1) + quoteIdentifier(owner) + "." + quoteIdentifier(table))); + ddl = header.replaceFirst(Matcher.quoteReplacement(header.group(1) + quoteIdentifier(owner) + "." + quoteIdentifier(tableName))); } ddl = ddl.strip(); if (!ddl.endsWith(";")) ddl += ";"; @@ -571,7 +574,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { List indexNames = new ArrayList<>(); try (var stmt = requireConnection().prepareStatement(indexSql)) { stmt.setString(1, owner); - stmt.setString(2, table); + stmt.setString(2, tableName); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) indexNames.add(rs.getString(1)); } @@ -581,15 +584,15 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { if (indexDdl == null || indexDdl.isBlank()) throw new SQLException("Empty index DDL: " + index); ddl = DdlBuilder.appendTrailingSql(ddl, indexDdl); } - String tableRef = quoteIdentifier(owner) + "." + quoteIdentifier(table); - String comment = getTableComment(owner, table); + String tableRef = quoteIdentifier(owner) + "." + quoteIdentifier(tableName); + String comment = getTableComment(owner, tableName); if (comment != null && !comment.isBlank()) { ddl = DdlBuilder.appendTrailingSql(ddl, "COMMENT ON TABLE " + tableRef + " IS '" + comment.replace("'", "''") + "';"); } String commentSql = "SELECT COLUMN_NAME, COMMENTS FROM ALL_COL_COMMENTS WHERE OWNER = ? AND TABLE_NAME = ? AND COMMENTS IS NOT NULL ORDER BY COLUMN_NAME"; try (var stmt = requireConnection().prepareStatement(commentSql)) { stmt.setString(1, owner); - stmt.setString(2, table); + stmt.setString(2, tableName); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { String text = rs.getString("COMMENTS"); @@ -599,7 +602,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { } } try { - ddl = DdlBuilder.appendTrailingSql(ddl, queryObjectGrantSql(owner, table)); + ddl = DdlBuilder.appendTrailingSql(ddl, queryObjectGrantSql(owner, tableName)); } catch (RuntimeException | SQLException ignored) { // Privilege metadata remains optional for users without access to grant views. } @@ -624,13 +627,14 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { private List queryPartitions(String schema, String table, boolean subpartition) { return unchecked(() -> { String owner = normalizeSchema(schema); + String tableName = normalizeObjectName(table); String prefix = subpartition ? "SUBPARTITION" : "PARTITION"; String keysView = subpartition ? "ALL_SUBPART_KEY_COLUMNS" : "ALL_PART_KEY_COLUMNS"; List keys = new ArrayList<>(); try (var stmt = requireConnection().prepareStatement("SELECT COLUMN_NAME FROM " + keysView + " WHERE OWNER = ? AND NAME = ? AND OBJECT_TYPE = 'TABLE' ORDER BY COLUMN_POSITION")) { stmt.setString(1, owner); - stmt.setString(2, table); + stmt.setString(2, tableName); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) keys.add(quoteIdentifier(rs.getString(1))); } @@ -643,7 +647,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { List result = new ArrayList<>(); try (var stmt = requireConnection().prepareStatement(sql)) { stmt.setString(1, owner); - stmt.setString(2, table); + stmt.setString(2, tableName); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { String value = rs.getString("HIGH_VALUE"); @@ -777,10 +781,11 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { public String getTableComment(String schema, String table) { return unchecked(() -> { String owner = normalizeSchema(schema); + String tableName = normalizeObjectName(table); String sql = "SELECT COMMENTS FROM ALL_TAB_COMMENTS WHERE OWNER = ? AND TABLE_NAME = ? AND TABLE_TYPE = 'TABLE'"; try (var stmt = requireConnection().prepareStatement(sql)) { stmt.setString(1, owner); - stmt.setString(2, table); + stmt.setString(2, tableName); try (var rs = stmt.executeQuery()) { if (rs.next()) { String comment = rs.getString("COMMENTS"); @@ -796,6 +801,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { public List listIndexes(String schema, String table) { return unchecked(() -> { String owner = normalizeSchema(schema); + String tableName = normalizeObjectName(table); String sql = """ SELECT i.INDEX_NAME, ic.COLUMN_NAME, ic.COLUMN_POSITION, i.UNIQUENESS, c.CONSTRAINT_TYPE, i.INDEX_TYPE @@ -820,7 +826,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { Map typeByIndex = new LinkedHashMap<>(); try (var stmt = requireConnection().prepareStatement(sql)) { stmt.setString(1, owner); - stmt.setString(2, table); + stmt.setString(2, tableName); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { String indexName = rs.getString("INDEX_NAME"); @@ -854,6 +860,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { public List listForeignKeys(String schema, String table) { return unchecked(() -> { String owner = normalizeSchema(schema); + String tableName = normalizeObjectName(table); String sql = """ SELECT c.CONSTRAINT_NAME, cc.COLUMN_NAME, rc.TABLE_NAME, rcc.COLUMN_NAME FROM ALL_CONSTRAINTS c @@ -870,7 +877,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { List result = new ArrayList<>(); try (var stmt = requireConnection().prepareStatement(sql)) { stmt.setString(1, owner); - stmt.setString(2, table); + stmt.setString(2, tableName); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { result.add(new ForeignKeyInfo( @@ -890,6 +897,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { public List listTriggers(String schema, String table) { return unchecked(() -> { String owner = normalizeSchema(schema); + String tableName = normalizeObjectName(table); String sql = """ SELECT TRIGGER_NAME, TRIGGERING_EVENT, TRIGGER_TYPE FROM ALL_TRIGGERS @@ -900,7 +908,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { List result = new ArrayList<>(); try (var stmt = requireConnection().prepareStatement(sql)) { stmt.setString(1, owner); - stmt.setString(2, table); + stmt.setString(2, tableName); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { result.add(new TriggerInfo(rs.getString(1), rs.getString(2), rs.getString(3))); @@ -913,6 +921,9 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { @Override public String setSchemaSQL(String schema) { + if (schema == null || schema.isBlank()) { + return ""; + } return "ALTER SESSION SET CURRENT_SCHEMA = " + JdbcIdentifiers.INSTANCE.doubleQuote(schema); } @@ -958,9 +969,15 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { return result; } + private static String normalizeObjectName(String name) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Object name must not be blank"); + } + return name; + } + private String normalizeSchema(String schema) throws SQLException { - String value = schema == null ? "" : schema.trim(); - return value.isEmpty() ? currentSchema() : value; + return schema == null || schema.isBlank() ? currentSchema() : schema; } private String currentSchema() throws SQLException { 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 2515043958..ba71767cd3 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 @@ -345,15 +345,91 @@ class OceanBaseOracleAgentTest { ) )); - ObjectSource source = agent.getObjectSource("app", "ACTIVE_USERS", "VIEW"); + ObjectSource source = agent.getObjectSource("MixedOwner", "MixedView", "VIEW"); Assertions.assertEquals("VIEW", source.getObject_type()); - Assertions.assertEquals("app", source.getSchema()); + Assertions.assertEquals("MixedOwner", source.getSchema()); Assertions.assertTrue(source.getSource().startsWith("CREATE OR REPLACE VIEW"), source.getSource()); - Assertions.assertEquals(List.of("VIEW", "ACTIVE_USERS", "app"), params); + Assertions.assertEquals(List.of("VIEW", "MixedView", "MixedOwner"), params); Assertions.assertTrue(sql.get(0).contains("DBMS_METADATA.GET_DDL"), sql.get(0)); } + @Test + void setSchemaSQLPreservesSelectedSchemaName() { + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + Assertions.assertEquals("ALTER SESSION SET CURRENT_SCHEMA = \"MixedOwner\"", agent.setSchemaSQL("MixedOwner")); + Assertions.assertEquals("", agent.setSchemaSQL("")); + Assertions.assertEquals("", agent.setSchemaSQL(null)); + } + + @Test + void getColumnsPreservesMetadataIdentifierSpelling() { + List sql = new ArrayList<>(); + List params = new ArrayList<>(); + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection( + sql, + params, + columnResultSet(new Object[][]{{"MIXED_ID", "NUMBER", "N", 19, 0, 22, null, null, null, 1}}), + columnResultSet(new Object[][]{{"UPPER_ID", "NUMBER", "N", 19, 0, 22, null, null, null, 1}}) + )); + + List mixedColumns = agent.getColumns("MixedOwner", "Mixed"); + List upperColumns = agent.getColumns("MixedOwner", "MIXED"); + + Assertions.assertEquals(List.of("MIXED_ID"), mixedColumns.stream().map(ColumnInfo::getName).toList()); + Assertions.assertEquals(List.of("UPPER_ID"), upperColumns.stream().map(ColumnInfo::getName).toList()); + Assertions.assertEquals( + List.of("MixedOwner", "Mixed", "MixedOwner", "Mixed", "MixedOwner", "MIXED", "MixedOwner", "MIXED"), + params + ); + Assertions.assertTrue(sql.get(0).contains("FROM ALL_TAB_COLUMNS"), sql.get(0)); + } + + @Test + void getObjectSourcePreservesMetadataIdentifierSpelling() { + List sql = new ArrayList<>(); + List params = new ArrayList<>(); + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, objectSourceConnection( + sql, + params, + resultSet( + new String[]{"DDL"}, + new Object[][]{{"CREATE OR REPLACE VIEW \"APP\".\"ACTIVE_USERS\" AS SELECT ID FROM USERS"}} + ) + )); + + ObjectSource source = agent.getObjectSource("MixedOwner", "MixedView", "VIEW"); + + Assertions.assertEquals("MixedView", source.getName()); + Assertions.assertEquals("MixedOwner", source.getSchema()); + Assertions.assertEquals(List.of("VIEW", "MixedView", "MixedOwner"), params); + } + + @Test + void getTableDdlPreservesMetadataIdentifierSpelling() { + List params = new ArrayList<>(); + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(), params, + resultSet( + new String[]{"DDL"}, + new Object[][]{{"CREATE TABLE \"Mixed\" (\"ID\" NUMBER)"}} + ), + resultSet(new String[]{"INDEX_NAME"}, new Object[][]{}), + resultSet(new String[]{"COMMENTS"}, new Object[][]{{null}}), + resultSet(new String[]{"COLUMN_NAME", "COMMENTS"}, new Object[][]{}), + resultSet(new String[]{"GRANTEE", "PRIVILEGE", "GRANTABLE"}, new Object[][]{}) + )); + + String ddl = agent.getTableDdl("MixedOwner", "Mixed"); + + Assertions.assertTrue(ddl.contains("CREATE TABLE"), ddl); + Assertions.assertEquals("TABLE", params.get(0)); + Assertions.assertEquals("Mixed", params.get(1)); + Assertions.assertEquals("MixedOwner", params.get(2)); + } + @Test void fallsBackToAllViewsWhenDbmsMetadataIsUnavailable() { List sql = new ArrayList<>(); @@ -365,10 +441,10 @@ class OceanBaseOracleAgentTest { resultSet(new String[]{"TEXT"}, new Object[][]{{"SELECT ID FROM USERS"}}) )); - ObjectSource source = agent.getObjectSource("APP", "ACTIVE_USERS", "VIEW"); + ObjectSource source = agent.getObjectSource("MixedOwner", "MixedView", "VIEW"); Assertions.assertEquals("SELECT ID FROM USERS", source.getSource()); - Assertions.assertEquals(List.of("VIEW", "ACTIVE_USERS", "APP", "APP", "ACTIVE_USERS"), params); + Assertions.assertEquals(List.of("VIEW", "MixedView", "MixedOwner", "MixedOwner", "MixedView"), params); Assertions.assertTrue(sql.get(1).contains("ALL_VIEWS"), sql.get(1)); } @@ -392,11 +468,14 @@ class OceanBaseOracleAgentTest { ) )); - ObjectSource source = agent.getObjectSource("APP", "ACCOUNT_API", object[0]); + ObjectSource source = agent.getObjectSource("MixedOwner", "MixedRoutine", object[0]); Assertions.assertEquals(object[0], source.getObject_type()); Assertions.assertTrue(source.getSource().startsWith("CREATE OR REPLACE " + object[1]), source.getSource()); - Assertions.assertEquals(object[1], params.get(params.size() - 1)); + Assertions.assertEquals( + List.of(object[0], "MixedRoutine", "MixedOwner", "MixedOwner", "MixedRoutine", object[1]), + params + ); Assertions.assertTrue(sql.get(1).contains("ALL_SOURCE"), sql.get(1)); Assertions.assertTrue(sql.get(1).contains("ORDER BY LINE"), sql.get(1)); } @@ -446,7 +525,7 @@ class OceanBaseOracleAgentTest { TestSupport.setPrivateConnection(agent, currentSchemaColumnConnection( sql, params, - resultSet(new String[]{"CURRENT_SCHEMA"}, new Object[][]{{"APP"}}), + resultSet(new String[]{"CURRENT_SCHEMA"}, new Object[][]{{"MixedOwner"}}), columnResultSet(new Object[][]{ {"PARAM_VALUE", "VARCHAR2", "Y", null, null, 100, 100, null, null, 0} }) @@ -455,7 +534,7 @@ class OceanBaseOracleAgentTest { List columns = agent.getColumns("", "TBPARAM"); Assertions.assertEquals(List.of("PARAM_VALUE"), columns.stream().map(ColumnInfo::getName).toList()); - Assertions.assertEquals(List.of("APP", "TBPARAM", "APP", "TBPARAM"), params); + Assertions.assertEquals(List.of("MixedOwner", "TBPARAM", "MixedOwner", "TBPARAM"), params); Assertions.assertTrue(sql.get(0).contains("SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA')"), sql.get(0)); Assertions.assertTrue(sql.get(1).contains("FROM ALL_TAB_COLUMNS"), sql.get(1)); } @@ -714,6 +793,10 @@ class OceanBaseOracleAgentTest { } private static Connection preparedConnection(List sql, ResultSet... resultSets) { + return preparedConnection(sql, null, resultSets); + } + + private static Connection preparedConnection(List sql, List params, ResultSet... resultSets) { int[] resultSetIndex = {0}; PreparedStatement statement = proxy(PreparedStatement.class, (method, args) -> { if ("executeQuery".equals(method.getName())) { @@ -721,7 +804,13 @@ class OceanBaseOracleAgentTest { resultSetIndex[0] += 1; return resultSets[current]; } - if ("setString".equals(method.getName()) || "setInt".equals(method.getName()) || "close".equals(method.getName())) { + if ("setString".equals(method.getName())) { + if (params != null) { + params.add(String.valueOf(args[1])); + } + return null; + } + if ("setInt".equals(method.getName()) || "close".equals(method.getName())) { return null; } return defaultValue(method.getReturnType()); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlNavigation.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlNavigation.spec.ts index c7a8fd6235..82e04e3ff6 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlNavigation.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlNavigation.spec.ts @@ -311,6 +311,13 @@ describe("call-site navigation helpers", () => { }); }); + it("folds unquoted Oracle schema and table navigation targets", () => { + expect(normalizeOracleNavigationTarget({ name: "emp", schema: "hr" })).toEqual({ + name: "EMP", + schema: "HR", + }); + }); + it("preserves quoted package.member mixed-case identities", () => { const sql = 'BEGIN\n "Pkg"."Member"();\nEND;'; const identity = resolveSqlObjectNavigationIdentity(sql, sql.indexOf("Member"));