From 99066319cd56c90207bad414e87c4db61ce4cb8c Mon Sep 17 00:00:00 2001 From: GIWTO <40267153+GIWTO@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:39:55 +0800 Subject: [PATCH] fix(dameng): fall back when DBMS_METADATA is unavailable --- .../com/dbx/agent/dameng/DamengAgent.java | 448 +++++++++++++++++- .../agent/dameng/DamengAgentMetadataTest.java | 413 ++++++++++++++++ 2 files changed, 850 insertions(+), 11 deletions(-) diff --git a/agents/drivers/dameng/src/main/java/com/dbx/agent/dameng/DamengAgent.java b/agents/drivers/dameng/src/main/java/com/dbx/agent/dameng/DamengAgent.java index d06e8008b0..40b020d671 100644 --- a/agents/drivers/dameng/src/main/java/com/dbx/agent/dameng/DamengAgent.java +++ b/agents/drivers/dameng/src/main/java/com/dbx/agent/dameng/DamengAgent.java @@ -49,8 +49,11 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; public final class DamengAgent extends AbstractJdbcAgent { + private static final Logger LOGGER = Logger.getLogger("com.dbx.agent.dameng"); private static final String AGENT_VERSION = "9999.06.04.1-fix-default"; private static final int DBMS_OUTPUT_ENABLE_TIMEOUT_SECS = 5; private static final int DBMS_OUTPUT_ENABLE_NETWORK_TIMEOUT_MILLIS = 5_000; @@ -505,6 +508,92 @@ public final class DamengAgent extends AbstractJdbcAgent { return false; } + /** + * 判断错误是否属于「元数据不可用」:DBMS_METADATA 系统包未安装/无权限、 + * 或系统字典视图缺失/无权限。典型 DM 错误码:[‑3325] 包/对象解析失败、 + * [‑2207] 无法解析的成员访问表达式(如 SF_DBMS_METADATA_RETURN_DDL)。 + * 此类错误应触发源码/DDL 降级,而不是让双击对象查看源码直接报错。 + */ + private static boolean isDamengMetadataUnavailableError(Throwable error) { + for (Throwable current = error; current != null; current = current.getCause()) { + if (!(current instanceof SQLException sqlError)) { + continue; + } + for (SQLException candidate = sqlError; candidate != null; candidate = candidate.getNextException()) { + int errorCode = candidate.getErrorCode(); + if (errorCode == -3325 || errorCode == -2207) { + return true; + } + String message = candidate.getMessage(); + if (message == null) { + continue; + } + String normalized = message.toLowerCase(Locale.ROOT); + boolean metadataObject = normalized.contains("all_objects") + || normalized.contains("sysobjects") + || normalized.contains("all_dependencies") + || normalized.contains("all_tab_comments") + || normalized.contains("dbms_metadata") + || normalized.contains("get_ddl") + || normalized.contains("return_ddl") + || normalized.contains("all_source") + || normalized.contains("all_views") + || normalized.contains("all_triggers") + || normalized.contains("all_sequences") + || normalized.contains("systexts"); + boolean permissionDenied = normalized.contains("权限") + || normalized.contains("privilege") + || normalized.contains("permission") + || normalized.contains("access denied") + || normalized.contains("not authorized"); + boolean missingObject = normalized.contains("解析失败") + || normalized.contains("无法解析") + || normalized.contains("不存在") + || normalized.contains("not exist") + || normalized.contains("does not exist") + || normalized.contains("not found") + || normalized.contains("未发现"); + if (metadataObject && (permissionDenied || missingObject)) { + return true; + } + } + } + return false; + } + + /** 连接类错误(断连/超时等)不属于元数据不可用,必须继续上抛。 */ + private static boolean isDamengConnectionError(Throwable error) { + for (Throwable current = error; current != null; current = current.getCause()) { + if (current instanceof SQLRecoverableException + || current instanceof SQLNonTransientConnectionException + || current instanceof SQLTransientConnectionException) { + return true; + } + if (!(current instanceof SQLException sqlError)) { + continue; + } + for (SQLException candidate = sqlError; candidate != null; candidate = candidate.getNextException()) { + String message = candidate.getMessage(); + if (message == null) { + continue; + } + String normalized = message.toLowerCase(Locale.ROOT); + if (normalized.contains("connection reset") + || normalized.contains("connection lost") + || normalized.contains("connection closed") + || normalized.contains("关闭的连接") + || normalized.contains("网络通信异常") + || normalized.contains("通信异常") + || normalized.contains("socket") + || normalized.contains("timed out") + || normalized.contains("超时")) { + return true; + } + } + } + return false; + } + private List executeConstrainedTables(MetadataQuery query, MetadataListConstraints constraints) { return unchecked(() -> { List result = new ArrayList<>(); @@ -958,18 +1047,352 @@ public final class DamengAgent extends AbstractJdbcAgent { public ObjectSource getObjectSource(String schema, String name, String objectType) { return unchecked(() -> { String dbmsType = damengDdlObjectType(objectType); - String source; - String sql = "SELECT /*+ PARALLEL(1) */ DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL"; - try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { - stmt.setString(1, dbmsType); - stmt.setString(2, name); - stmt.setString(3, schema); - try (ResultSet rs = stmt.executeQuery()) { - source = rs.next() ? coalesce(readTextColumn(rs, 1)) : ""; + RuntimeException dbmsError; + try { + String source = readDbmsMetadataObjectSource(dbmsType, name, schema); + return new ObjectSource(name, objectType, schema, source); + } catch (Exception error) { + // 检查型异常(SQLException 等)由 unchecked 透传后在此处统一处理。 + RuntimeException runtimeError = error instanceof RuntimeException runtime ? runtime : new RuntimeException(error); + if (!isDamengMetadataUnavailableError(runtimeError)) { + throw runtimeError; + } + dbmsError = runtimeError; + } + try { + return buildCatalogObjectSource(schema, name, objectType, dbmsError); + } catch (RuntimeException fallbackError) { + fallbackError.addSuppressed(dbmsError); + throw fallbackError; + } + }); + } + + private String readDbmsMetadataObjectSource(String dbmsType, String name, String schema) throws Exception { + String sql = "SELECT /*+ PARALLEL(1) */ DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL"; + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, dbmsType); + stmt.setString(2, name); + stmt.setString(3, schema); + try (ResultSet rs = stmt.executeQuery()) { + return rs.next() ? coalesce(readTextColumn(rs, 1)) : ""; + } + } + } + + /** + * 在 DBMS_METADATA 系统包缺失/无权限时(典型错误:[‑3325] 包/对象解析失败、 + * [‑2207] 无法解析的成员访问表达式,如 SF_DBMS_METADATA_RETURN_DDL), + * 从系统字典视图重建对象源码: + * + * 所有字典来源都不可用时返回带原因说明的占位源码(不可编辑),避免双击对象查看源码直接报错; + * 连接类错误(断连/超时等)不属于元数据不可用,继续上抛由上层处理会话。 + */ + private ObjectSource buildCatalogObjectSource( + String schema, + String name, + String objectType, + RuntimeException dbmsError + ) throws Exception { + String type = normalizeObjectSourceType(objectType); + switch (type) { + case "VIEW", "MATERIALIZED_VIEW" -> { + ObjectSource fromView = catalogTextSource( + schema, + name, + objectType, + "SELECT TEXT FROM ALL_VIEWS WHERE OWNER = ? AND VIEW_NAME = ?", + false + ); + if (fromView != null) { + return fromView; } } - return new ObjectSource(name, objectType, schema, source); - }); + case "TRIGGER" -> { + ObjectSource fromTrigger = catalogTextSource( + schema, + name, + objectType, + "SELECT TRIGGER_BODY FROM ALL_TRIGGERS WHERE OWNER = ? AND TRIGGER_NAME = ?", + false + ); + if (fromTrigger != null) { + return fromTrigger; + } + } + case "SEQUENCE" -> { + ObjectSource fromSequence = catalogSequenceSource(schema, name, objectType); + if (fromSequence != null) { + return fromSequence; + } + } + case "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY" -> { + ObjectSource fromAllSource = catalogRoutineSource(schema, name, objectType); + if (fromAllSource != null) { + return fromAllSource; + } + ObjectSource fromSystemText = catalogRoutineSystemText(schema, name, objectType); + if (fromSystemText != null) { + return fromSystemText; + } + } + default -> throw new IllegalArgumentException("Unsupported object type: " + objectType); + } + return unavailableObjectSource(schema, name, objectType, dbmsError); + } + + /** + * 字典视图来源(ALL_VIEWS.TEXT / ALL_TRIGGERS.TRIGGER_BODY)通常只包含对象正文、 + * 不含完整的 CREATE/ALTER 语句头,不能作为可执行 DDL 保存,故标记为不可在线编辑。 + */ + private static final String CATALOG_BODY_HINT = + "-- 以下内容来自系统字典视图,仅为对象正文,可能不包含完整语句头,不可在线编辑。\n"; + + /** 读取单行单列文本(视图/触发器源码),空结果返回空串。 */ + private String scalarText(String sql, String schema, String name) throws Exception { + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, name); + try (ResultSet rs = stmt.executeQuery()) { + return rs.next() ? coalesce(readTextColumn(rs, 1)) : ""; + } + } + } + + private ObjectSource catalogTextSource(String schema, String name, String objectType, String sql, boolean editable) { + try { + String text = scalarText(sql, schema, name); + if (!notBlank(text)) { + return null; + } + if (!editable) { + text = CATALOG_BODY_HINT + text; + } + return new ObjectSource(name, objectType, schema, text, editable); + } catch (RuntimeException error) { + return metadataTierError(error); + } catch (Exception error) { + return metadataTierError(new RuntimeException(error)); + } + } + + private ObjectSource catalogSequenceSource(String schema, String name, String objectType) { + try { + String sql = """ + SELECT MIN_VALUE, MAX_VALUE, INCREMENT_BY, CYCLE_FLAG, ORDER_FLAG, CACHE_SIZE + FROM ALL_SEQUENCES + WHERE SEQUENCE_OWNER = ? AND SEQUENCE_NAME = ? + """.stripIndent().trim(); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, name); + try (ResultSet rs = stmt.executeQuery()) { + if (!rs.next()) { + return null; + } + StringBuilder ddl = new StringBuilder("CREATE SEQUENCE ").append(qualifiedName(schema, name)); + String minValue = rs.getString("MIN_VALUE"); + String maxValue = rs.getString("MAX_VALUE"); + String increment = rs.getString("INCREMENT_BY"); + if (increment != null && !"1".equals(increment.trim())) { + ddl.append(" INCREMENT BY ").append(increment.trim()); + } + if (minValue != null && !"1".equals(minValue.trim())) { + ddl.append(" MINVALUE ").append(minValue.trim()); + } + if (maxValue != null) { + ddl.append(" MAXVALUE ").append(maxValue.trim()); + } + String cache = rs.getString("CACHE_SIZE"); + if (cache != null && !"0".equals(cache.trim())) { + ddl.append(" CACHE ").append(cache.trim()); + } else { + ddl.append(" NOCACHE"); + } + ddl.append("Y".equalsIgnoreCase(rs.getString("CYCLE_FLAG")) ? " CYCLE" : " NOCYCLE"); + ddl.append("Y".equalsIgnoreCase(rs.getString("ORDER_FLAG")) ? " ORDER" : " NOORDER"); + ddl.append(";"); + // 序列 DDL 由元数据重建,不提供在线编辑。 + return new ObjectSource(name, objectType, schema, ddl.toString(), false); + } + } + } catch (RuntimeException error) { + return metadataTierError(error); + } catch (Exception error) { + return metadataTierError(new RuntimeException(error)); + } + } + + private ObjectSource catalogRoutineSource(String schema, String name, String objectType) { + try { + String type = normalizeObjectSourceType(objectType); + List typeCandidates = switch (type) { + // 不同 DM 版本对函数/过程的 TYPE 取值不一致(有的统一为 PROCEDURE),逐一尝试。 + case "FUNCTION" -> List.of("FUNCTION", "PROCEDURE"); + case "PROCEDURE" -> List.of("PROCEDURE", "FUNCTION"); + case "PACKAGE" -> List.of("PACKAGE"); + case "PACKAGE_BODY" -> List.of("PACKAGE BODY", "PACKAGE_BODY"); + case "TYPE" -> List.of("TYPE"); + case "TYPE_BODY" -> List.of("TYPE BODY", "TYPE_BODY"); + default -> List.of(); + }; + for (String candidate : typeCandidates) { + String source = readAllSourceLines(schema, name, candidate); + if (notBlank(source)) { + return catalogRoutineObjectSource(schema, name, objectType, source); + } + } + // 兜底:忽略 TYPE 过滤按名称读取全部源码行(同一 schema 内对象名唯一)。 + String anyTypeSource = readAllSourceLines(schema, name, null); + if (notBlank(anyTypeSource)) { + return catalogRoutineObjectSource(schema, name, objectType, anyTypeSource); + } + return null; + } catch (RuntimeException error) { + return metadataTierError(error); + } catch (Exception error) { + return metadataTierError(new RuntimeException(error)); + } + } + + private String readAllSourceLines(String schema, String name, String sourceType) throws Exception { + String sql = sourceType == null + ? "SELECT TEXT FROM ALL_SOURCE WHERE OWNER = ? AND NAME = ? ORDER BY LINE" + : "SELECT TEXT FROM ALL_SOURCE WHERE OWNER = ? AND NAME = ? AND TYPE = ? ORDER BY LINE"; + StringBuilder source = new StringBuilder(); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, name); + if (sourceType != null) { + stmt.setString(3, sourceType); + } + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String line = coalesce(readTextColumn(rs, 1)); + source.append(line); + if (!line.endsWith("\n")) { + source.append("\n"); + } + } + } + } + return source.toString(); + } + + private static ObjectSource catalogRoutineObjectSource( + String schema, + String name, + String objectType, + String source + ) { + String normalizedSource = source.stripLeading(); + String upperSource = normalizedSource.toUpperCase(Locale.ROOT); + boolean executable = upperSource.startsWith("CREATE ") || upperSource.startsWith("ALTER "); + if (!executable) { + String declarationType = normalizeObjectSourceType(objectType).replace('_', ' '); + if (startsWithRoutineDeclaration(normalizedSource, declarationType)) { + normalizedSource = "CREATE OR REPLACE " + normalizedSource; + executable = true; + } + } + if (!executable) { + normalizedSource = CATALOG_BODY_HINT + normalizedSource; + } + return new ObjectSource(name, objectType, schema, normalizedSource, executable); + } + + private static boolean startsWithRoutineDeclaration(String source, String declarationType) { + if (!source.regionMatches(true, 0, declarationType, 0, declarationType.length())) { + return false; + } + return source.length() == declarationType.length() + || Character.isWhitespace(source.charAt(declarationType.length())); + } + + private ObjectSource catalogRoutineSystemText(String schema, String name, String objectType) { + try { + // DM 将过程/函数/包/类型的定义文本按行存放在 SYS.SYSTEXTS, + // SYS.SYSOBJECTS 的 SCHID 关联所属 schema;适用于 ALL_SOURCE 不可用的实例。 + String sql = """ + SELECT t.TEXT + FROM SYS.SYSTEXTS t + JOIN SYS.SYSOBJECTS o ON o.ID = t.ID + JOIN SYS.SYSOBJECTS s ON s.ID = o.SCHID AND s.TYPE$ = 'SCH' AND s.NAME = ? + WHERE o.NAME = ? + ORDER BY t.LINE + """.stripIndent().trim(); + StringBuilder source = new StringBuilder(); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, name); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String line = coalesce(readTextColumn(rs, 1)); + source.append(line); + if (!line.endsWith("\n")) { + source.append("\n"); + } + } + } + } + return notBlank(source.toString()) + ? catalogRoutineObjectSource(schema, name, objectType, source.toString()) + : null; + } catch (RuntimeException error) { + return metadataTierError(error); + } catch (Exception error) { + return metadataTierError(new RuntimeException(error)); + } + } + + /** + * 元数据降级分层中的单层错误处理:连接类错误继续上抛(会话处理需要真实错误), + * 元数据不可用/未知错误返回 {@code null} 让调用方尝试下一层。 + */ + private static ObjectSource metadataTierError(RuntimeException error) { + if (isDamengConnectionError(error)) { + throw error; + } + LOGGER.log( + Level.FINE, + "Dameng metadata catalog tier unavailable for source fallback: " + firstLine(error.getMessage()), + error + ); + return null; + } + + /** 所有降级来源都不可用时返回带原因说明的占位源码,避免双击对象查看源码直接报错。 */ + private ObjectSource unavailableObjectSource( + String schema, + String name, + String objectType, + RuntimeException dbmsError + ) { + String reason = dbmsError == null ? "未知" : firstLine(dbmsError.getMessage()); + String source = "-- 无法获取 " + qualifiedName(schema, name) + "(" + objectType + ")的完整定义。\n" + + "-- 当前连接缺少 DBMS_METADATA 系统包(未安装或无执行权限),系统字典中也没有可用源码。\n" + + "-- 如需完整 DDL,请由 DBA 执行 SP_CREATE_SYSTEM_PACKAGES(1) 安装系统包后重试。\n" + + "-- 底层错误:" + reason; + return new ObjectSource(name, objectType, schema, source, false); + } + + private static String firstLine(String message) { + if (message == null || message.isEmpty()) { + return "未知"; + } + int newline = message.indexOf('\n'); + return newline < 0 ? message.trim() : message.substring(0, newline).trim(); + } + + private static String normalizeObjectSourceType(String objectType) { + String value = objectType == null ? "" : objectType.trim().toUpperCase(Locale.ROOT); + return value.replace(' ', '_'); } static String damengDdlObjectType(String objectType) { @@ -983,6 +1406,9 @@ public final class DamengAgent extends AbstractJdbcAgent { case "PACKAGE_BODY", "PACKAGE BODY" -> "PKG_BODY"; // DM DBMS_METADATA accepts TRIGGER directly and returns executable CREATE OR REPLACE DDL. case "TRIGGER" -> "TRIGGER"; + // 用户自定义类型:与 Oracle 兼容的 DBMS_METADATA 对象类型名。 + case "TYPE" -> "TYPE"; + case "TYPE_BODY", "TYPE BODY" -> "TYPE BODY"; default -> throw new IllegalArgumentException("Unsupported object type: " + objectType); }; } @@ -1010,7 +1436,7 @@ public final class DamengAgent extends AbstractJdbcAgent { throw new IllegalArgumentException("Table not found: " + schema + "." + table); }); } catch (RuntimeException error) { - if (!isDamengMetadataPermissionError(error)) { + if (!isDamengMetadataUnavailableError(error)) { throw error; } try { diff --git a/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentMetadataTest.java b/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentMetadataTest.java index 1cf3949112..c842f02068 100644 --- a/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentMetadataTest.java +++ b/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentMetadataTest.java @@ -508,6 +508,341 @@ class DamengAgentMetadataTest { Assertions.assertTrue(source.getSource().contains("CREATE VIEW"), source.getSource()); } + @Test + void fallsBackToAllSourceFunctionDdlWhenDbmsMetadataPackageIsMissing() { + DamengAgent agent = new DamengAgent(); + List sqls = new ArrayList<>(); + TestSupport.setPrivateConnection( + agent, + catalogFallbackConnection( + missingDbmsMetadataPackageError(), + sqls, + List.of( + Arrays.asList("CREATE OR REPLACE FUNCTION \"APP\".\"CALC_SCORE\"(V_ID INT) RETURN INT AS"), + Arrays.asList("BEGIN"), + Arrays.asList(" RETURN V_ID * 2;"), + Arrays.asList("END;") + ), + null, + List.of(), + null + ) + ); + + ObjectSource source = agent.getObjectSource("APP", "CALC_SCORE", "FUNCTION"); + + Assertions.assertTrue(source.getSource().contains("CREATE OR REPLACE FUNCTION"), source.getSource()); + Assertions.assertTrue(source.getSource().contains("RETURN V_ID * 2;"), source.getSource()); + Assertions.assertTrue(source.isEditable()); + Assertions.assertEquals(1, sqls.stream().filter(sql -> sql.contains("DBMS_METADATA.GET_DDL")).count()); + Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls)); + Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("SYS.SYSTEXTS")), String.join("\n", sqls)); + } + + @Test + void fallsBackToAllSourceTypeDdlWhenDbmsMetadataPackageIsMissing() { + DamengAgent agent = new DamengAgent(); + List sqls = new ArrayList<>(); + TestSupport.setPrivateConnection( + agent, + catalogFallbackConnection( + missingDbmsMetadataPackageError(), + sqls, + List.of( + Arrays.asList("CREATE OR REPLACE TYPE \"APP\".\"T_ADDR\" AS OBJECT("), + Arrays.asList(" CITY VARCHAR2(64)"), + Arrays.asList(")") + ), + null, + List.of(), + null + ) + ); + + ObjectSource source = agent.getObjectSource("APP", "T_ADDR", "TYPE"); + + Assertions.assertTrue(source.getSource().contains("CREATE OR REPLACE TYPE"), source.getSource()); + Assertions.assertTrue(source.isEditable()); + Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls)); + } + + @Test + void fallsBackToAllSourcePackageBodyDdlWhenDbmsMetadataPackageIsMissing() { + DamengAgent agent = new DamengAgent(); + List sqls = new ArrayList<>(); + TestSupport.setPrivateConnection( + agent, + catalogFallbackConnection( + missingDbmsMetadataPackageError(), + sqls, + List.of( + Arrays.asList("CREATE OR REPLACE PACKAGE BODY \"APP\".\"PKG_DEMO\" AS"), + Arrays.asList(" PROCEDURE P IS BEGIN NULL; END;"), + Arrays.asList("END PKG_DEMO;") + ), + null, + List.of(), + null + ) + ); + + ObjectSource source = agent.getObjectSource("APP", "PKG_DEMO", "PACKAGE_BODY"); + + Assertions.assertTrue(source.getSource().contains("CREATE OR REPLACE PACKAGE BODY"), source.getSource()); + Assertions.assertTrue(source.isEditable()); + Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls)); + } + + @Test + void wrapsBareAllSourcePackageBodyAsExecutableDdl() { + DamengAgent agent = new DamengAgent(); + List sqls = new ArrayList<>(); + TestSupport.setPrivateConnection( + agent, + catalogFallbackConnection( + missingDbmsMetadataPackageError(), + sqls, + List.of( + Arrays.asList("PACKAGE BODY \"APP\".\"PKG_DEMO\" AS"), + Arrays.asList(" PROCEDURE P IS BEGIN NULL; END;"), + Arrays.asList("END PKG_DEMO;") + ), + null, + List.of(), + null + ) + ); + + ObjectSource source = agent.getObjectSource("APP", "PKG_DEMO", "PACKAGE_BODY"); + + Assertions.assertTrue(source.getSource().startsWith("CREATE OR REPLACE PACKAGE BODY"), source.getSource()); + Assertions.assertTrue(source.isEditable()); + } + + @Test + void fallsBackToViewTextWhenDbmsMetadataPackageIsMissing() { + DamengAgent agent = new DamengAgent(); + List sqls = new ArrayList<>(); + TestSupport.setPrivateConnection( + agent, + catalogFallbackConnection(missingDbmsMetadataPackageError(), sqls, List.of(), null, List.of(), null) + ); + + ObjectSource source = agent.getObjectSource("APP", "V_ACTIVE_USERS", "VIEW"); + + Assertions.assertTrue(source.getSource().contains("SELECT 1 AS ID FROM DUAL"), source.getSource()); + // 字典视图正文不包含完整 CREATE VIEW 头,标记为不可编辑。 + Assertions.assertFalse(source.isEditable()); + Assertions.assertTrue(source.getSource().startsWith("--"), source.getSource()); + Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_VIEWS")), String.join("\n", sqls)); + } + + @Test + void fallsBackToTriggerBodyWhenDbmsMetadataPackageIsMissing() { + DamengAgent agent = new DamengAgent(); + List sqls = new ArrayList<>(); + TestSupport.setPrivateConnection( + agent, + catalogFallbackConnection(missingDbmsMetadataPackageError(), sqls, List.of(), null, List.of(), null) + ); + + ObjectSource source = agent.getObjectSource("APP", "TRG_USERS", "TRIGGER"); + + Assertions.assertTrue(source.getSource().contains("CREATE TRIGGER"), source.getSource()); + // 触发器正文可能不含完整创建语句头,标记为不可编辑。 + Assertions.assertFalse(source.isEditable()); + Assertions.assertTrue(source.getSource().startsWith("--"), source.getSource()); + Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_TRIGGERS")), String.join("\n", sqls)); + } + + @Test + void reconstructsSequenceDdlWhenDbmsMetadataPackageIsMissing() { + DamengAgent agent = new DamengAgent(); + List sqls = new ArrayList<>(); + TestSupport.setPrivateConnection( + agent, + catalogFallbackConnection(missingDbmsMetadataPackageError(), sqls, List.of(), null, List.of(), null) + ); + + ObjectSource source = agent.getObjectSource("APP", "SEQ_ORDERS", "SEQUENCE"); + + Assertions.assertTrue( + source.getSource().contains("CREATE SEQUENCE \"APP\".\"SEQ_ORDERS\""), + source.getSource() + ); + Assertions.assertTrue(source.getSource().contains("INCREMENT BY 5"), source.getSource()); + Assertions.assertTrue(source.getSource().contains("MAXVALUE 9999999999"), source.getSource()); + Assertions.assertTrue(source.getSource().contains("CACHE 20"), source.getSource()); + Assertions.assertTrue(source.getSource().contains("NOCYCLE"), source.getSource()); + Assertions.assertFalse(source.isEditable()); + Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_SEQUENCES")), String.join("\n", sqls)); + } + + @Test + void fallsBackToSysTextsWhenAllSourceIsUnavailable() { + DamengAgent agent = new DamengAgent(); + List sqls = new ArrayList<>(); + TestSupport.setPrivateConnection( + agent, + catalogFallbackConnection( + missingDbmsMetadataPackageError(), + sqls, + List.of(), + missingCatalogViewError("ALL_SOURCE"), + List.of( + Arrays.asList("CREATE OR REPLACE FUNCTION \"APP\".\"CALC_SCORE\"(V_ID INT) RETURN INT AS"), + Arrays.asList("BEGIN"), + Arrays.asList(" RETURN V_ID * 2;"), + Arrays.asList("END;") + ), + null + ) + ); + + ObjectSource source = agent.getObjectSource("APP", "CALC_SCORE", "FUNCTION"); + + Assertions.assertTrue(source.getSource().contains("CREATE OR REPLACE FUNCTION"), source.getSource()); + Assertions.assertTrue(source.isEditable()); + Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls)); + Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("SYS.SYSTEXTS") && sql.contains("SYS.SYSOBJECTS")), String.join("\n", sqls)); + } + + @Test + void wrapsBareSysTextsTypeAsExecutableDdl() { + DamengAgent agent = new DamengAgent(); + List sqls = new ArrayList<>(); + TestSupport.setPrivateConnection( + agent, + catalogFallbackConnection( + missingDbmsMetadataPackageError(), + sqls, + List.of(), + missingCatalogViewError("ALL_SOURCE"), + List.of( + Arrays.asList("TYPE \"APP\".\"T_ADDR\" AS OBJECT("), + Arrays.asList(" CITY VARCHAR2(64)"), + Arrays.asList(")") + ), + null + ) + ); + + ObjectSource source = agent.getObjectSource("APP", "T_ADDR", "TYPE"); + + Assertions.assertTrue(source.getSource().startsWith("CREATE OR REPLACE TYPE"), source.getSource()); + Assertions.assertTrue(source.isEditable()); + } + + @Test + void keepsUnrecognizedRoutineCatalogTextReadOnly() { + DamengAgent agent = new DamengAgent(); + List sqls = new ArrayList<>(); + TestSupport.setPrivateConnection( + agent, + catalogFallbackConnection( + missingDbmsMetadataPackageError(), + sqls, + List.of(Arrays.asList("BEGIN NULL; END;")), + null, + List.of(), + null + ) + ); + + ObjectSource source = agent.getObjectSource("APP", "PKG_DEMO", "PACKAGE"); + + Assertions.assertTrue(source.getSource().startsWith("--"), source.getSource()); + Assertions.assertFalse(source.isEditable()); + } + + @Test + void returnsUnavailablePlaceholderWhenAllCatalogSourcesFail() { + DamengAgent agent = new DamengAgent(); + List sqls = new ArrayList<>(); + TestSupport.setPrivateConnection( + agent, + catalogFallbackConnection( + missingDbmsMetadataPackageError(), + sqls, + List.of(), + missingCatalogViewError("ALL_SOURCE"), + List.of(), + missingCatalogViewError("SYS.SYSTEXTS") + ) + ); + + ObjectSource source = agent.getObjectSource("APP", "CALC_SCORE", "FUNCTION"); + + Assertions.assertFalse(source.isEditable()); + Assertions.assertTrue(source.getSource().startsWith("--"), source.getSource()); + Assertions.assertTrue(source.getSource().contains("SP_CREATE_SYSTEM_PACKAGES"), source.getSource()); + Assertions.assertTrue(source.getSource().contains("DBMS_METADATA"), source.getSource()); + Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls)); + Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("SYS.SYSTEXTS")), String.join("\n", sqls)); + } + + @Test + void propagatesConnectionResetWithoutCatalogFallback() { + DamengAgent agent = new DamengAgent(); + List sqls = new ArrayList<>(); + TestSupport.setPrivateConnection( + agent, + catalogFallbackConnection( + new SQLException("DBMS_METADATA.GET_DDL connection reset"), + sqls, + List.of(), + null, + List.of(), + null + ) + ); + + RuntimeException error = Assertions.assertThrows( + RuntimeException.class, + () -> agent.getObjectSource("APP", "CALC_SCORE", "FUNCTION") + ); + + Assertions.assertEquals("DBMS_METADATA.GET_DDL connection reset", error.getCause().getMessage()); + Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls)); + } + + @Test + void doesNotFallBackForUnrelatedDbmsMetadataErrors() { + DamengAgent agent = new DamengAgent(); + List sqls = new ArrayList<>(); + SQLException unrelated = new SQLException("DBMS_METADATA.GET_DDL 内部执行错误", "99999", -9001); + TestSupport.setPrivateConnection( + agent, + catalogFallbackConnection(unrelated, sqls, List.of(), null, List.of(), null) + ); + + Assertions.assertThrows( + RuntimeException.class, + () -> agent.getObjectSource("APP", "CALC_SCORE", "FUNCTION") + ); + + Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls)); + } + + @Test + void tableDdlFallsBackWhenDbmsMetadataPackageIsMissing() { + DamengAgent agent = new DamengAgent(); + List sqls = new ArrayList<>(); + TestSupport.setPrivateConnection( + agent, + metadataConnectionWithDbmsMetadataError( + sqls, + "[-2207]:无法解析的成员访问表达式[SF_DBMS_METADATA_RETURN_DDL]\n[-3325]:包/对象[DBMS_METADATA]解析失败" + ) + ); + + String ddl = agent.getTableDdl("APP", "USERS"); + + Assertions.assertTrue(ddl.contains("CREATE TABLE \"APP\".\"USERS\""), ddl); + Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("ALL_TAB_COLUMNS")), String.join("\n", sqls)); + Assertions.assertEquals(1, sqls.stream().filter(sql -> sql.contains("DBMS_METADATA.GET_DDL")).count()); + } + @Test void triggerMetadataDoesNotRequireOracleTriggerTypeColumn() { DamengAgent agent = new DamengAgent(); @@ -1016,6 +1351,78 @@ class DamengAgentMetadataTest { }); } + /** 模拟 DM 实例未安装 DBMS_METADATA 系统包时的典型错误([‑2207]/[‑3325])。 */ + private static SQLException missingDbmsMetadataPackageError() { + return new SQLException( + "[-2207]:无法解析的成员访问表达式[SF_DBMS_METADATA_RETURN_DDL]\n" + + "[-3325]:包/对象[DBMS_METADATA]解析失败", + "22000", + -3325 + ); + } + + /** 模拟系统字典视图(ALL_SOURCE / SYS.SYSTEXTS 等)不存在的错误。 */ + private static SQLException missingCatalogViewError(String view) { + return new SQLException("表或视图不存在: " + view, "42000", -2106); + } + + /** + * DBMS_METADATA 失败时走系统字典降级的连接:ALL_VIEWS/ALL_TRIGGERS/ALL_SEQUENCES + * 固定返回一行样例数据;ALL_SOURCE / SYS.SYSTEXTS 的行与错误可通过参数定制。 + */ + private static Connection catalogFallbackConnection( + SQLException dbmsMetadataError, + List sqls, + List> allSourceRows, + SQLException allSourceError, + List> systemTextRows, + SQLException systemTextError + ) { + return proxy(Connection.class, (method, args) -> { + String name = method.getName(); + if ("prepareStatement".equals(name)) { + String sql = (String) args[0]; + if (sqls != null) { + sqls.add(sql); + } + if (sql.contains("DBMS_METADATA.GET_DDL")) { + if (dbmsMetadataError != null) { + return failingMetadataStatement(dbmsMetadataError); + } + return metadataStatement(List.of(List.of("CREATE OR REPLACE PROCEDURE placeholder"))); + } + if (sql.contains("FROM ALL_SOURCE")) { + if (allSourceError != null) { + return failingMetadataStatement(allSourceError); + } + return metadataStatement(allSourceRows); + } + if (sql.contains("SYS.SYSTEXTS")) { + if (systemTextError != null) { + return failingMetadataStatement(systemTextError); + } + return metadataStatement(systemTextRows); + } + if (sql.contains("FROM ALL_VIEWS")) { + return metadataStatement(List.of(List.of("SELECT 1 AS ID FROM DUAL"))); + } + if (sql.contains("FROM ALL_TRIGGERS")) { + return metadataStatement(List.of(List.of("CREATE TRIGGER \"APP\".\"TRG_USERS\" BEFORE INSERT ON \"APP\".\"USERS\" BEGIN NULL; END;"))); + } + if (sql.contains("FROM ALL_SEQUENCES")) { + return metadataStatement(List.of(List.of("1", "9999999999", "5", "N", "N", "20"))); + } + } + if ("close".equals(name)) { + return null; + } + if ("isClosed".equals(name)) { + return false; + } + return defaultValue(method.getReturnType()); + }); + } + private static Connection restrictedCrossOwnerMetadataConnection(List sqls) { return proxy(Connection.class, (method, args) -> { String name = method.getName(); @@ -1274,6 +1681,12 @@ class DamengAgentMetadataTest { case "CHAR_USED" -> string(rows, index[0], 7); case "COMMENTS", "COMMENT$", "REMARKS" -> string(rows, index[0], rows.get(index[0]).size() - 1); case "COLNAME" -> string(rows, index[0], 0); + case "MIN_VALUE" -> string(rows, index[0], 0); + case "MAX_VALUE" -> string(rows, index[0], 1); + case "INCREMENT_BY" -> string(rows, index[0], 2); + case "CYCLE_FLAG" -> string(rows, index[0], 3); + case "ORDER_FLAG" -> string(rows, index[0], 4); + case "CACHE_SIZE" -> string(rows, index[0], 5); default -> null; }; }