diff --git a/agents/common/src/main/java/com/dbx/agent/StandardJdbcMetadata.java b/agents/common/src/main/java/com/dbx/agent/StandardJdbcMetadata.java index 9a6eada6f..bbf788761 100644 --- a/agents/common/src/main/java/com/dbx/agent/StandardJdbcMetadata.java +++ b/agents/common/src/main/java/com/dbx/agent/StandardJdbcMetadata.java @@ -123,12 +123,12 @@ public final class StandardJdbcMetadata { listObjects(listTables(conn, profile, configuredDatabase, schema, tableConstraints), schema) ); DatabaseMetaData meta = conn.getMetaData(); - appendRoutines(result, meta, null, blankToNull(schema), schema); + appendRoutines(result, meta, null, schema, schema); if (!containsRoutine(result) && profile.getCatalogFallbackEnabled() && configuredDatabase != null && !configuredDatabase.trim().isEmpty()) { - appendRoutines(result, meta, configuredDatabase, blankToNull(schema), schema); + appendRoutines(result, meta, configuredDatabase, schema, schema); } result.sort(Comparator.comparing(ObjectInfo::getName)); return normalized.filterObjects(result); @@ -332,7 +332,9 @@ public final class StandardJdbcMetadata { return; } // JDBC has no portable metadata limit/offset. Use table types for safe pushdown and filter/page locally. - try (ResultSet rs = meta.getTables(catalog, blankToNull(schema), "%", tableTypes)) { + // schema 是 DatabaseMetaData.getTables() 的 schemaPattern,_ 和 % 具有通配符语义; + // HANA 存在 _SYS_RT 等含下划线的 schema,需按 getSearchStringEscape() 转义,避免混入其他 schema 的对象。 + try (ResultSet rs = meta.getTables(catalog, escapeSchemaPattern(meta, schema), "%", tableTypes)) { while (rs.next()) { result.add(new TableInfo( rs.getString("TABLE_NAME"), @@ -618,16 +620,18 @@ public final class StandardJdbcMetadata { List result, DatabaseMetaData meta, String catalog, - String schemaPattern, - String schema + String schema, + String schemaLabel ) { + // schema 作为 getProcedures/getFunctions 的 schemaPattern,同样需要转义 _ 和 % 通配符。 + String schemaPattern = escapeSchemaPattern(meta, schema); Set procedureNames = new LinkedHashSet<>(); try (ResultSet rs = meta.getProcedures(catalog, schemaPattern, "%")) { while (rs.next()) { String name = rs.getString("PROCEDURE_NAME"); if (name != null && !name.trim().isEmpty()) { procedureNames.add(name); - result.add(new ObjectInfo(name, "PROCEDURE", schema, rs.getString("REMARKS"))); + result.add(new ObjectInfo(name, "PROCEDURE", schemaLabel, rs.getString("REMARKS"))); } } } catch (Exception | AbstractMethodError ignored) { @@ -637,7 +641,7 @@ public final class StandardJdbcMetadata { while (rs.next()) { String name = rs.getString("FUNCTION_NAME"); if (name != null && !name.trim().isEmpty() && !procedureNames.contains(name)) { - result.add(new ObjectInfo(name, "FUNCTION", schema, rs.getString("REMARKS"))); + result.add(new ObjectInfo(name, "FUNCTION", schemaLabel, rs.getString("REMARKS"))); } } } catch (Exception | AbstractMethodError ignored) { @@ -676,6 +680,38 @@ public final class StandardJdbcMetadata { return value == null || value.trim().isEmpty() ? null : value; } + /** + * 将 schema 名转义为可安全用作 {@link DatabaseMetaData#getTables(String, String, String, String[])} + * 等 schemaPattern 参数的形式。 + * + *

JDBC 的 schemaPattern 把 {@code _}(匹配任意单字符)和 {@code %}(匹配任意字符序列)当作通配符。 + * 当 schema 名本身含有这些字符时(例如 HANA 的 {@code _SYS_RT}、{@code _SYS_BIC},或被引号引用的含 + * {@code %} 的 schema),直接作为 schemaPattern 传入会误匹配到其他 schema 的对象。参考 DBeaver 的 + * {@code JDBCUtils.escapeWildCards()},按 {@link DatabaseMetaData#getSearchStringEscape()} 对通配符做转义。 + * + *

当 schema 为空白(将映射为 {@code null},表示不做 schema 过滤)或驱动未提供转义字符时,原样返回。 + */ + private static String escapeSchemaPattern(DatabaseMetaData meta, String schema) { + String normalized = blankToNull(schema); + if (normalized == null) { + return null; + } + String escape; + try { + escape = meta.getSearchStringEscape(); + } catch (Exception ignored) { + escape = null; + } + if (escape == null || escape.isEmpty()) { + return normalized; + } + // 先转义 escape 自身,再转义 % 和 _,顺序与 PreparedStatement.setEscapeProcessing / DBeaver 一致。 + String escapedEscape = normalized.replace(escape, escape + escape); + String escapedPercent = escapedEscape.replace("%", escape + "%"); + String escapedUnderscore = escapedPercent.replace("_", escape + "_"); + return escapedUnderscore; + } + private static void addNonBlank(Set values, String value) { if (value != null && !value.trim().isEmpty()) { values.add(value); diff --git a/agents/common/src/test/java/com/dbx/agent/StandardJdbcMetadataTest.java b/agents/common/src/test/java/com/dbx/agent/StandardJdbcMetadataTest.java index 1ae5e8d13..f3fdb090c 100644 --- a/agents/common/src/test/java/com/dbx/agent/StandardJdbcMetadataTest.java +++ b/agents/common/src/test/java/com/dbx/agent/StandardJdbcMetadataTest.java @@ -218,6 +218,101 @@ class StandardJdbcMetadataTest { assertEquals("TABLE", tables.get(2).getTable_type()); } + @Test + void listTablesEscapesUnderscoreInHanaSysSchema() { + // HANA 的 _SYS_RT 等 schema 名含下划线,JDBC schemaPattern 把 _ 当作通配符, + // 若不转义会误匹配 _xSYSxRT 等其他 schema。HANA 驱动 getSearchStringEscape() 返回 "\\"。 + AtomicReference capturedArgs = new AtomicReference<>(); + Connection conn = schemaEscapeConnection("\\", rows( + row("TABLE_NAME", "RT_OBJECTS", "TABLE_TYPE", "TABLE", "REMARKS", null) + ), capturedArgs); + + List tables = StandardJdbcMetadata.INSTANCE.listTables(conn, profile, "", "_SYS_RT"); + + assertEquals(1, tables.size()); + assertEquals("RT_OBJECTS", tables.get(0).getName()); + // schema 第二个参数应转义为 _\_S\_Y\_S\_R\_T(HANA 转义符为反斜杠) + assertEquals("\\_SYS\\_RT", capturedArgs.get()[1]); + } + + @Test + void listTablesEscapesPercentInQuotedSchema() { + // 被引号引用、含 % 的 schema(如 "SALES%2024"),% 是通配符,必须转义。 + AtomicReference capturedArgs = new AtomicReference<>(); + Connection conn = schemaEscapeConnection("\\", rows( + row("TABLE_NAME", "ORDERS_2024", "TABLE_TYPE", "TABLE", "REMARKS", null) + ), capturedArgs); + + List tables = StandardJdbcMetadata.INSTANCE.listTables(conn, profile, "", "SALES%2024"); + + assertEquals(1, tables.size()); + assertEquals("ORDERS_2024", tables.get(0).getName()); + assertEquals("SALES\\%2024", capturedArgs.get()[1]); + } + + @Test + void listTablesLeavesPlainSchemaUntouched() { + // 普通 schema(无 _ 和 %)不应被转义,且仍作为 schemaPattern 传入。 + AtomicReference capturedArgs = new AtomicReference<>(); + Connection conn = schemaEscapeConnection("\\", rows( + row("TABLE_NAME", "ORDERS", "TABLE_TYPE", "TABLE", "REMARKS", null) + ), capturedArgs); + + List tables = StandardJdbcMetadata.INSTANCE.listTables(conn, profile, "", "SALES"); + + assertEquals(1, tables.size()); + assertEquals("ORDERS", tables.get(0).getName()); + assertEquals("SALES", capturedArgs.get()[1]); + } + + @Test + void listTablesFallsBackWhenSearchEscapeUnavailable() { + // 驱动不支持 getSearchStringEscape() 时,schema 原样返回(不做转义),不抛异常。 + AtomicReference capturedArgs = new AtomicReference<>(); + Connection conn = schemaEscapeConnection(null, rows( + row("TABLE_NAME", "ORDERS", "TABLE_TYPE", "TABLE", "REMARKS", null) + ), capturedArgs); + + List tables = StandardJdbcMetadata.INSTANCE.listTables(conn, profile, "", "SALES"); + + assertEquals(1, tables.size()); + assertEquals("SALES", capturedArgs.get()[1]); + } + + private static Connection schemaEscapeConnection(String searchEscape, ResultSet tables, AtomicReference capturedArgs) { + DatabaseMetaData meta = proxy(DatabaseMetaData.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("getTables".equals(name)) { + if (capturedArgs != null) { + capturedArgs.set(args); + } + return tables; + } + if ("getTableTypes".equals(name)) { + return rows(row("TABLE_TYPE", "TABLE")); + } + if ("getSearchStringEscape".equals(name)) { + if (searchEscape == null) { + throw new UnsupportedOperationException("escape unavailable"); + } + return searchEscape; + } + return defaultValue(method.getReturnType()); + } + }); + return proxy(Connection.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + if ("getMetaData".equals(method.getName())) { + return meta; + } + return defaultValue(method.getReturnType()); + } + }); + } + @Test void mapsColumnsWithPrimaryKeysAndLengths() { Connection conn = connection( diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index df29947fa..4f415243f 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -1297,7 +1297,7 @@ export const useConnectionStore = defineStore("connection", () => { searchFilter?: string; force?: boolean; }): Promise<{ children: TreeNode[]; objectCount: number; hasMore: boolean; nextOffset: number; loadMoreParent?: TableTreeLoadMoreParent }> { - if (!options.node.connectionId || !options.node.database) { + if (!options.node.connectionId || options.node.database == null) { return { children: [], objectCount: 0, hasMore: false, nextOffset: options.offset }; } const searchFilter = (options.searchFilter ?? sidebarSearchQuery.value) || undefined; @@ -1351,7 +1351,7 @@ export const useConnectionStore = defineStore("connection", () => { searchFilter?: string; force?: boolean; }): Promise<{ children: TreeNode[]; objectCount: number; hasMore: boolean; nextOffset: number }> { - if (!options.node.connectionId || !options.node.database) { + if (!options.node.connectionId || options.node.database == null) { return { children: [], objectCount: 0, hasMore: false, nextOffset: options.offset }; } const searchFilter = options.searchFilter || undefined;