From 28640b3584d0aab2932f429b0e480d8570b14005 Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Tue, 19 May 2026 09:30:11 +0000 Subject: [PATCH 01/19] feat(odc-core): add DialectType.DB2 + ConnectType.DB2 + DB2_DRIVER_CLASS_NAME - DialectType: add DB2 enum + isDb2() predicate (B-01 / B-S1) - ConnectType: add DB2(DialectType.DB2) (B-02, literal "DB2" pinned per compat-RISK-2) - OdcConstants: add DB2_DRIVER_CLASS_NAME + DB2_DEFAULT_SCHEMA placeholder (B-03) - DBBrowserFactory: add DB2 string constant (B-04) Refs: actiontech/dms-ee#839 --- .../com/oceanbase/tools/dbbrowser/DBBrowserFactory.java | 1 + .../oceanbase/odc/core/shared/constant/ConnectType.java | 1 + .../oceanbase/odc/core/shared/constant/DialectType.java | 5 +++++ .../oceanbase/odc/core/shared/constant/OdcConstants.java | 9 +++++++++ 4 files changed, 16 insertions(+) diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/DBBrowserFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/DBBrowserFactory.java index 3807931336..4a1952c2e7 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/DBBrowserFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/DBBrowserFactory.java @@ -27,6 +27,7 @@ public interface DBBrowserFactory { String POSTGRESQL = "POSTGRESQL"; String SQL_SERVER = "SQL_SERVER"; String DM = "DM"; + String DB2 = "DB2"; T create(); diff --git a/server/odc-core/src/main/java/com/oceanbase/odc/core/shared/constant/ConnectType.java b/server/odc-core/src/main/java/com/oceanbase/odc/core/shared/constant/ConnectType.java index 2828ef7c65..954e8f08fc 100644 --- a/server/odc-core/src/main/java/com/oceanbase/odc/core/shared/constant/ConnectType.java +++ b/server/odc-core/src/main/java/com/oceanbase/odc/core/shared/constant/ConnectType.java @@ -34,6 +34,7 @@ public enum ConnectType { POSTGRESQL(DialectType.POSTGRESQL), SQL_SERVER(DialectType.SQL_SERVER), DM(DialectType.DM), + DB2(DialectType.DB2), // reserved for future version ODP_SHARDING_OB_ORACLE(DialectType.OB_ORACLE), diff --git a/server/odc-core/src/main/java/com/oceanbase/odc/core/shared/constant/DialectType.java b/server/odc-core/src/main/java/com/oceanbase/odc/core/shared/constant/DialectType.java index 8607840bc4..d9d4ae6ef8 100644 --- a/server/odc-core/src/main/java/com/oceanbase/odc/core/shared/constant/DialectType.java +++ b/server/odc-core/src/main/java/com/oceanbase/odc/core/shared/constant/DialectType.java @@ -32,6 +32,7 @@ public enum DialectType { POSTGRESQL, SQL_SERVER, DM, + DB2, FILE_SYSTEM, UNKNOWN, ; @@ -83,4 +84,8 @@ public boolean isDm() { return DM == this; } + public boolean isDb2() { + return DB2 == this; + } + } diff --git a/server/odc-core/src/main/java/com/oceanbase/odc/core/shared/constant/OdcConstants.java b/server/odc-core/src/main/java/com/oceanbase/odc/core/shared/constant/OdcConstants.java index bf8175b029..7eb68073e5 100644 --- a/server/odc-core/src/main/java/com/oceanbase/odc/core/shared/constant/OdcConstants.java +++ b/server/odc-core/src/main/java/com/oceanbase/odc/core/shared/constant/OdcConstants.java @@ -101,6 +101,15 @@ public class OdcConstants { */ public static final String DM_DRIVER_CLASS_NAME = "dm.jdbc.driver.DmDriver"; public static final String DM_DEFAULT_SCHEMA = "SYSDBA"; + /** + * IBM DB2 driver class name + */ + public static final String DB2_DRIVER_CLASS_NAME = "com.ibm.db2.jcc.DB2Driver"; + /** + * DB2 default schema placeholder; the real value is resolved from ConnectionConfig.getDefaultSchema + * (username.toUpperCase()) in commit-C (B-20). + */ + public static final String DB2_DEFAULT_SCHEMA = ""; /** * Parameters name From d36349cd5dc628f108527b16f610261a481dd9b5 Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Tue, 19 May 2026 10:13:19 +0000 Subject: [PATCH 02/19] feat(db-browser): add buildForDB2 across factories + Db2SchemaAccessor + Db2StatsAccessor + Db2ObjectOperator - AbstractDBBrowserFactory: add buildForDB2() abstract method + DB2 case in create() (B-05) - Db2SchemaAccessor: real impl backed by SYSCAT.* (B-06/B-07) - Db2StatsAccessor: real impl backed by MON_GET_CONNECTION + SYSCAT.TABLES (B-08) - Db2ObjectOperator: real impl for drop/showCount (B-09 real) - 15 placeholder buildForDB2() across 8 editor + 7 template factories (B-09 placeholder + B-10) - Unit tests: Db2SchemaAccessorTest + Db2StatsAccessorTest (mock JDBC, no real connection) Refs: actiontech/dms-ee#839 --- .../dbbrowser/AbstractDBBrowserFactory.java | 4 + .../editor/DBMViewEditorFactory.java | 5 + .../editor/DBMViewIndexEditorFactory.java | 5 + .../editor/DBObjectOperatorFactory.java | 6 + .../editor/DBSequenceEditorFactory.java | 5 + .../editor/DBSynonymEditorFactory.java | 5 + .../editor/DBTableColumnEditorFactory.java | 5 + .../DBTableConstraintEditorFactory.java | 5 + .../editor/DBTableEditorFactory.java | 5 + .../editor/DBTableIndexEditorFactory.java | 5 + .../editor/DBTablePartitionEditorFactory.java | 5 + .../editor/db2/Db2ObjectOperator.java | 62 +++ .../schema/DBSchemaAccessorFactory.java | 6 + .../schema/db2/Db2SchemaAccessor.java | 527 ++++++++++++++++++ .../stats/DBStatsAccessorFactory.java | 6 + .../dbbrowser/stats/db2/Db2StatsAccessor.java | 117 ++++ .../template/DBFunctionTemplateFactory.java | 5 + .../template/DBMViewTemplateFactory.java | 5 + .../template/DBPackageTemplateFactory.java | 5 + .../template/DBProcedureTemplateFactory.java | 5 + .../template/DBTriggerTemplateFactory.java | 5 + .../template/DBTypeTemplateFactory.java | 5 + .../template/DBViewTemplateFactory.java | 5 + .../schema/db2/Db2SchemaAccessorTest.java | 280 ++++++++++ .../stats/db2/Db2StatsAccessorTest.java | 210 +++++++ 25 files changed, 1298 insertions(+) create mode 100644 libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2ObjectOperator.java create mode 100644 libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java create mode 100644 libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/stats/db2/Db2StatsAccessor.java create mode 100644 libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java create mode 100644 libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/stats/db2/Db2StatsAccessorTest.java diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/AbstractDBBrowserFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/AbstractDBBrowserFactory.java index aa2e71dfa6..00152cfa24 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/AbstractDBBrowserFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/AbstractDBBrowserFactory.java @@ -50,6 +50,8 @@ public T create() { return buildForSqlServer(); case DM: return buildForDm(); + case DB2: + return buildForDB2(); default: throw new IllegalStateException("Not supported for the type, " + type); } @@ -75,4 +77,6 @@ public T create() { public abstract T buildForDm(); + public abstract T buildForDB2(); + } diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBMViewEditorFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBMViewEditorFactory.java index 416fb227a1..5c8110d21c 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBMViewEditorFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBMViewEditorFactory.java @@ -86,6 +86,11 @@ public DBMViewEditor buildForDm() { return buildForOracle(); } + @Override + public DBMViewEditor buildForDB2() { + throw new UnsupportedOperationException("DB2 not supported yet"); + } + private DBTableIndexEditor getMViewIndexEditor() { DBMViewIndexEditorFactory indexFactory = new DBMViewIndexEditorFactory(); indexFactory.setType(this.type); diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBMViewIndexEditorFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBMViewIndexEditorFactory.java index b495981e57..5fcd123c2a 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBMViewIndexEditorFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBMViewIndexEditorFactory.java @@ -76,4 +76,9 @@ public DBTableIndexEditor buildForSqlServer() { public DBTableIndexEditor buildForDm() { return buildForOracle(); } + + @Override + public DBTableIndexEditor buildForDB2() { + throw new UnsupportedOperationException("DB2 not supported yet"); + } } diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBObjectOperatorFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBObjectOperatorFactory.java index ddad8e848a..eae4a0ab8d 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBObjectOperatorFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBObjectOperatorFactory.java @@ -21,6 +21,7 @@ import org.springframework.jdbc.core.JdbcTemplate; import com.oceanbase.tools.dbbrowser.AbstractDBBrowserFactory; +import com.oceanbase.tools.dbbrowser.editor.db2.Db2ObjectOperator; import com.oceanbase.tools.dbbrowser.editor.dm.DmObjectOperator; import com.oceanbase.tools.dbbrowser.editor.mysql.MySQLObjectOperator; import com.oceanbase.tools.dbbrowser.editor.oracle.OracleObjectOperator; @@ -86,6 +87,11 @@ public DBObjectOperator buildForDm() { return new DmObjectOperator(getJdbcOperations()); } + @Override + public DBObjectOperator buildForDB2() { + return new Db2ObjectOperator(getJdbcOperations()); + } + private JdbcOperations getJdbcOperations() { if (this.jdbcOperations != null) { return this.jdbcOperations; diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBSequenceEditorFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBSequenceEditorFactory.java index 90cb4a819a..be2ba6a7e9 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBSequenceEditorFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBSequenceEditorFactory.java @@ -72,4 +72,9 @@ public DBObjectEditor buildForDm() { return buildForOracle(); } + @Override + public DBObjectEditor buildForDB2() { + throw new UnsupportedOperationException("DB2 not supported yet"); + } + } diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBSynonymEditorFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBSynonymEditorFactory.java index 08d0616740..46cd31e9c0 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBSynonymEditorFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBSynonymEditorFactory.java @@ -72,4 +72,9 @@ public DBObjectEditor buildForDm() { return buildForOracle(); } + @Override + public DBObjectEditor buildForDB2() { + throw new UnsupportedOperationException("DB2 not supported yet"); + } + } diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableColumnEditorFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableColumnEditorFactory.java index 0398a966c0..fbfb1bd538 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableColumnEditorFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableColumnEditorFactory.java @@ -72,4 +72,9 @@ public DBTableColumnEditor buildForDm() { return buildForOracle(); } + @Override + public DBTableColumnEditor buildForDB2() { + throw new UnsupportedOperationException("DB2 not supported yet"); + } + } diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableConstraintEditorFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableConstraintEditorFactory.java index c250150f9f..bea9ac72ec 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableConstraintEditorFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableConstraintEditorFactory.java @@ -92,4 +92,9 @@ public DBTableConstraintEditor buildForDm() { return buildForOracle(); } + @Override + public DBTableConstraintEditor buildForDB2() { + throw new UnsupportedOperationException("DB2 not supported yet"); + } + } diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableEditorFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableEditorFactory.java index bf3e4fe9bc..75c66fe874 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableEditorFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableEditorFactory.java @@ -102,6 +102,11 @@ public DBTableEditor buildForDm() { return buildForOracle(); } + @Override + public DBTableEditor buildForDB2() { + throw new UnsupportedOperationException("DB2 not supported yet"); + } + private DBTableIndexEditor getTableIndexEditor() { DBTableIndexEditorFactory indexFactory = new DBTableIndexEditorFactory(); indexFactory.setType(this.type); diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableIndexEditorFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableIndexEditorFactory.java index 8c54060f07..de5a1bdc10 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableIndexEditorFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableIndexEditorFactory.java @@ -79,4 +79,9 @@ public DBTableIndexEditor buildForDm() { return buildForOracle(); } + @Override + public DBTableIndexEditor buildForDB2() { + throw new UnsupportedOperationException("DB2 not supported yet"); + } + } diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTablePartitionEditorFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTablePartitionEditorFactory.java index 7c4a7de58f..bf9c7504e7 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTablePartitionEditorFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTablePartitionEditorFactory.java @@ -97,4 +97,9 @@ public DBTablePartitionEditor buildForDm() { return buildForOracle(); } + @Override + public DBTablePartitionEditor buildForDB2() { + throw new UnsupportedOperationException("DB2 not supported yet"); + } + } diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2ObjectOperator.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2ObjectOperator.java new file mode 100644 index 0000000000..f95abe4b52 --- /dev/null +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2ObjectOperator.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.tools.dbbrowser.editor.db2; + +import org.springframework.jdbc.core.JdbcOperations; + +import com.oceanbase.tools.dbbrowser.editor.DBObjectOperator; +import com.oceanbase.tools.dbbrowser.model.DBObjectType; + +import lombok.NonNull; + +/** + * DB2 object operator implementation (B-09 real). + * + *

+ * 本期仅实现 {@link #drop(DBObjectType, String, String)}(TABLE/VIEW/INDEX), 生成符合 DB2 SQL 标准的 DROP 语句。 + * + * @since ODC_release_4.3.4 (Issue dms-ee#839) + */ +public class Db2ObjectOperator implements DBObjectOperator { + + protected final JdbcOperations syncJdbcExecutor; + + public Db2ObjectOperator(@NonNull JdbcOperations syncJdbcExecutor) { + this.syncJdbcExecutor = syncJdbcExecutor; + } + + @Override + public void drop(DBObjectType objectType, String schemaName, String objectName) { + if (objectType == null || objectName == null || objectName.isEmpty()) { + throw new IllegalArgumentException("objectType / objectName can not be null or empty"); + } + StringBuilder sb = new StringBuilder("DROP "); + sb.append(objectType.getName()).append(' '); + if (schemaName != null && !schemaName.isEmpty()) { + sb.append(quoteIdentifier(schemaName)).append('.'); + } + sb.append(quoteIdentifier(objectName)); + syncJdbcExecutor.execute(sb.toString()); + } + + /** + * DB2 标准双引号引用 identifier,并转义内部双引号 + */ + private String quoteIdentifier(String identifier) { + return "\"" + identifier.replace("\"", "\"\"") + "\""; + } + +} diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/DBSchemaAccessorFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/DBSchemaAccessorFactory.java index 6fa9297165..4cb7f1d9ac 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/DBSchemaAccessorFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/DBSchemaAccessorFactory.java @@ -24,6 +24,7 @@ import org.springframework.jdbc.core.JdbcTemplate; import com.oceanbase.tools.dbbrowser.AbstractDBBrowserFactory; +import com.oceanbase.tools.dbbrowser.schema.db2.Db2SchemaAccessor; import com.oceanbase.tools.dbbrowser.schema.dm.DmSchemaAccessor; import com.oceanbase.tools.dbbrowser.schema.doris.DorisSchemaAccessor; import com.oceanbase.tools.dbbrowser.schema.mysql.MySQLNoLessThan5600SchemaAccessor; @@ -178,6 +179,11 @@ public DBSchemaAccessor buildForDm() { return new DmSchemaAccessor(getJdbcOperations()); } + @Override + public DBSchemaAccessor buildForDB2() { + return new Db2SchemaAccessor(getJdbcOperations()); + } + private JdbcOperations getJdbcOperations() { if (this.jdbcOperations != null) { return this.jdbcOperations; diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java new file mode 100644 index 0000000000..a0fd309191 --- /dev/null +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java @@ -0,0 +1,527 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.tools.dbbrowser.schema.db2; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.springframework.jdbc.core.JdbcOperations; + +import com.oceanbase.tools.dbbrowser.model.DBColumnGroupElement; +import com.oceanbase.tools.dbbrowser.model.DBConstraintType; +import com.oceanbase.tools.dbbrowser.model.DBDatabase; +import com.oceanbase.tools.dbbrowser.model.DBFunction; +import com.oceanbase.tools.dbbrowser.model.DBMViewRefreshParameter; +import com.oceanbase.tools.dbbrowser.model.DBMViewRefreshRecord; +import com.oceanbase.tools.dbbrowser.model.DBMViewRefreshRecordParam; +import com.oceanbase.tools.dbbrowser.model.DBMaterializedView; +import com.oceanbase.tools.dbbrowser.model.DBObjectIdentity; +import com.oceanbase.tools.dbbrowser.model.DBObjectType; +import com.oceanbase.tools.dbbrowser.model.DBPLObjectIdentity; +import com.oceanbase.tools.dbbrowser.model.DBPackage; +import com.oceanbase.tools.dbbrowser.model.DBProcedure; +import com.oceanbase.tools.dbbrowser.model.DBSequence; +import com.oceanbase.tools.dbbrowser.model.DBSynonym; +import com.oceanbase.tools.dbbrowser.model.DBSynonymType; +import com.oceanbase.tools.dbbrowser.model.DBTable; +import com.oceanbase.tools.dbbrowser.model.DBTable.DBTableOptions; +import com.oceanbase.tools.dbbrowser.model.DBTableColumn; +import com.oceanbase.tools.dbbrowser.model.DBTableConstraint; +import com.oceanbase.tools.dbbrowser.model.DBTableIndex; +import com.oceanbase.tools.dbbrowser.model.DBTablePartition; +import com.oceanbase.tools.dbbrowser.model.DBTableSubpartitionDefinition; +import com.oceanbase.tools.dbbrowser.model.DBTrigger; +import com.oceanbase.tools.dbbrowser.model.DBType; +import com.oceanbase.tools.dbbrowser.model.DBVariable; +import com.oceanbase.tools.dbbrowser.model.DBView; +import com.oceanbase.tools.dbbrowser.schema.DBSchemaAccessor; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; + +/** + * DB2 schema accessor implementation (B-06 / B-07). + * + *

+ * 主用 SYSCAT.* 视图(DB2 11.5 默认);仅实现本期需要的 6 类查询:列 schema / 列 table / 列 view / 列 column / 列 index / 列 + * constraint,详见 docs/spec/design.md §6。 + * + *

+ * 其余接口方法按现有同仓 PostgresSchemaAccessor 风格抛 {@link UnsupportedOperationException}, 不影响 ODC + * 工作台基本能力(表浏览、列查看)。 + * + * @since ODC_release_4.3.4 (Issue dms-ee#839) + */ +@Slf4j +public class Db2SchemaAccessor implements DBSchemaAccessor { + + protected final JdbcOperations jdbcOperations; + + public Db2SchemaAccessor(@NonNull JdbcOperations jdbcOperations) { + this.jdbcOperations = jdbcOperations; + } + + @Override + public List showDatabases() { + String sql = "SELECT TRIM(SCHEMANAME) AS SCHEMA_NAME FROM SYSCAT.SCHEMATA " + + "WHERE DEFINER NOT IN ('SYSIBM','SYSCAT','SYSIBMADM','SYSIBMINTERNAL'," + + "'SYSIBMTS','SYSFUN','SYSPROC','SYSSTAT','SYSTOOLS','SYSPUBLIC','NULLID') " + + "ORDER BY SCHEMANAME"; + return jdbcOperations.queryForList(sql, String.class); + } + + @Override + public DBDatabase getDatabase(String schemaName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listDatabases() { + List schemas = showDatabases(); + List result = new ArrayList<>(schemas.size()); + for (String schema : schemas) { + DBDatabase db = new DBDatabase(); + db.setId(schema); + db.setName(schema); + result.add(db); + } + return result; + } + + @Override + public void switchDatabase(String schemaName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listUsers() { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List showTablesLike(String schemaName, String tableNameLike) { + StringBuilder sb = new StringBuilder(); + sb.append("SELECT TABNAME FROM SYSCAT.TABLES "); + sb.append("WHERE TABSCHEMA = ? AND TYPE IN ('T','S','U') "); + if (tableNameLike != null && !tableNameLike.isEmpty()) { + sb.append("AND TABNAME LIKE ? "); + } + sb.append("ORDER BY TABNAME"); + if (tableNameLike != null && !tableNameLike.isEmpty()) { + return jdbcOperations.queryForList(sb.toString(), String.class, schemaName, tableNameLike); + } + return jdbcOperations.queryForList(sb.toString(), String.class, schemaName); + } + + @Override + public List listTables(String schemaName, String tableNameLike) { + StringBuilder sb = new StringBuilder(); + sb.append("SELECT TABSCHEMA, TABNAME FROM SYSCAT.TABLES "); + sb.append("WHERE TABSCHEMA = ? AND TYPE IN ('T','S','U') "); + Object[] args; + if (tableNameLike != null && !tableNameLike.isEmpty()) { + sb.append("AND TABNAME LIKE ? "); + args = new Object[] {schemaName, tableNameLike}; + } else { + args = new Object[] {schemaName}; + } + sb.append("ORDER BY TABNAME"); + return jdbcOperations.query(sb.toString(), args, + (rs, rowNum) -> DBObjectIdentity.of(rs.getString(1).trim(), DBObjectType.TABLE, + rs.getString(2).trim())); + } + + @Override + public List showExternalTablesLike(String schemaName, String tableNameLike) { + return Collections.emptyList(); + } + + @Override + public List listExternalTables(String schemaName, String tableNameLike) { + return Collections.emptyList(); + } + + @Override + public boolean isExternalTable(String schemaName, String tableName) { + return false; + } + + @Override + public boolean syncExternalTableFiles(String schemaName, String tableName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listViews(String schemaName) { + String sql = "SELECT VIEWSCHEMA, TABNAME FROM SYSCAT.VIEWS WHERE VIEWSCHEMA = ? ORDER BY TABNAME"; + return jdbcOperations.query(sql, new Object[] {schemaName}, + (rs, rowNum) -> DBObjectIdentity.of(rs.getString(1).trim(), DBObjectType.VIEW, + rs.getString(2).trim())); + } + + @Override + public List listAllViews(String viewNameLike) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listAllUserViews(String viewNameLike) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listAllSystemViews(String viewNameLike) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List showSystemViews(String schemaName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listMViews(String schemaName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listAllMViewsLike(String mViewNameLike) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public Boolean refreshMVData(DBMViewRefreshParameter parameter) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public DBMaterializedView getMView(String schemaName, String mViewName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listMViewConstraints(String schemaName, String mViewName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listMViewRefreshRecords(DBMViewRefreshRecordParam param) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listMViewIndexes(String schemaName, String mViewName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List showVariables() { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List showSessionVariables() { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List showGlobalVariables() { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List showCharset() { + return Collections.emptyList(); + } + + @Override + public List showCollation() { + return Collections.emptyList(); + } + + @Override + public List listFunctions(String schemaName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listProcedures(String schemaName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listPackages(String schemaName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listPackageBodies(String schemaName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listTriggers(String schemaName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listTypes(String schemaName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listSequences(String schemaName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listSynonyms(String schemaName, DBSynonymType synonymType) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public Map> listTableColumns(String schemaName, List tableNames) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listTableColumns(String schemaName, String tableName) { + String sql = "SELECT COLNAME, TYPENAME, LENGTH, SCALE, NULLS, DEFAULT, REMARKS, COLNO " + + "FROM SYSCAT.COLUMNS WHERE TABSCHEMA = ? AND TABNAME = ? ORDER BY COLNO"; + return jdbcOperations.query(sql, new Object[] {schemaName, tableName}, (rs, rowNum) -> { + DBTableColumn column = new DBTableColumn(); + column.setSchemaName(schemaName); + column.setTableName(tableName); + column.setName(rs.getString("COLNAME")); + column.setTypeName(rs.getString("TYPENAME")); + long len = rs.getLong("LENGTH"); + column.setMaxLength(len); + column.setPrecision(len); + column.setScale(rs.getInt("SCALE")); + column.setNullable(!"N".equalsIgnoreCase(rs.getString("NULLS"))); + column.setDefaultValue(rs.getString("DEFAULT")); + column.setComment(rs.getString("REMARKS")); + column.setOrdinalPosition(rs.getInt("COLNO")); + return column; + }); + } + + @Override + public Map> listBasicTableColumns(String schemaName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listBasicTableColumns(String schemaName, String tableName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public Map> listBasicViewColumns(String schemaName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listBasicViewColumns(String schemaName, String viewName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public Map> listBasicExternalTableColumns(String schemaName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listBasicExternalTableColumns(String schemaName, String externalTableName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public Map> listBasicMViewColumns(String schemaName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listBasicMViewColumns(String schemaName, String externalTableName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public Map> listBasicColumnsInfo(String schemaName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public Map> listTableIndexes(String schemaName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public Map> listTableConstraints(String schemaName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public Map listTableOptions(String schemaName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public Map listTablePartitions(@NonNull String schemaName, List tableNames) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listTableRangePartitionInfo(String tenantName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listSubpartitions(String schemaName, String tableName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public Boolean isLowerCaseTableName() { + return false; + } + + @Override + public List listPartitionTables(String partitionMethod) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listTableConstraints(String schemaName, String tableName) { + String sql = "SELECT TABSCHEMA, TABNAME, CONSTNAME, TYPE FROM SYSCAT.TABCONST " + + "WHERE TABSCHEMA = ? AND TABNAME = ? ORDER BY CONSTNAME"; + return jdbcOperations.query(sql, new Object[] {schemaName, tableName}, (rs, rowNum) -> { + DBTableConstraint constraint = new DBTableConstraint(); + constraint.setSchemaName(rs.getString("TABSCHEMA")); + constraint.setTableName(rs.getString("TABNAME")); + constraint.setName(rs.getString("CONSTNAME")); + String type = rs.getString("TYPE"); + constraint.setType(mapDb2ConstraintType(type)); + return constraint; + }); + } + + private DBConstraintType mapDb2ConstraintType(String db2Type) { + if (db2Type == null) { + return DBConstraintType.UNKNOWN; + } + switch (db2Type.trim().toUpperCase()) { + case "P": + return DBConstraintType.PRIMARY_KEY; + case "U": + return DBConstraintType.UNIQUE_KEY; + case "F": + return DBConstraintType.FOREIGN_KEY; + case "K": + return DBConstraintType.CHECK; + default: + return DBConstraintType.UNKNOWN; + } + } + + @Override + public DBTablePartition getPartition(String schemaName, String tableName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listTableIndexes(String schemaName, String tableName) { + String sql = "SELECT INDSCHEMA, INDNAME, TABSCHEMA, TABNAME, UNIQUERULE " + + "FROM SYSCAT.INDEXES WHERE TABSCHEMA = ? AND TABNAME = ? ORDER BY INDNAME"; + return jdbcOperations.query(sql, new Object[] {schemaName, tableName}, (rs, rowNum) -> { + DBTableIndex index = new DBTableIndex(); + index.setSchemaName(rs.getString("INDSCHEMA")); + index.setName(rs.getString("INDNAME")); + index.setTableName(rs.getString("TABNAME")); + String uniqueRule = rs.getString("UNIQUERULE"); + // DB2 UNIQUERULE: D=Duplicates allowed, U=Unique, P=Primary + index.setUnique(uniqueRule != null && !"D".equalsIgnoreCase(uniqueRule.trim())); + return index; + }); + } + + @Override + public String getTableDDL(String schemaName, String tableName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public DBTableOptions getTableOptions(String schemaName, String tableName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public DBTableOptions getTableOptions(String schemaName, String tableName, String ddl) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public List listTableColumnGroups(String schemaName, String tableName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public DBView getView(String schemaName, String viewName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public DBFunction getFunction(String schemaName, String functionName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public DBProcedure getProcedure(String schemaName, String procedureName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public DBPackage getPackage(String schemaName, String packageName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public DBTrigger getTrigger(String schemaName, String packageName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public DBType getType(String schemaName, String typeName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public DBSequence getSequence(String schemaName, String sequenceName) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public DBSynonym getSynonym(String schemaName, String synonymName, DBSynonymType synonymType) { + throw new UnsupportedOperationException("Not supported yet"); + } + + @Override + public Map getTables(String schemaName, List tableNames) { + throw new UnsupportedOperationException("Not supported yet"); + } +} diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/stats/DBStatsAccessorFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/stats/DBStatsAccessorFactory.java index d628d59620..bd8d912704 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/stats/DBStatsAccessorFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/stats/DBStatsAccessorFactory.java @@ -24,6 +24,7 @@ import org.springframework.jdbc.core.JdbcTemplate; import com.oceanbase.tools.dbbrowser.AbstractDBBrowserFactory; +import com.oceanbase.tools.dbbrowser.stats.db2.Db2StatsAccessor; import com.oceanbase.tools.dbbrowser.stats.dm.DmStatsAccessor; import com.oceanbase.tools.dbbrowser.stats.mysql.DorisStatsAccessor; import com.oceanbase.tools.dbbrowser.stats.mysql.MySQLNoLessThan5700StatsAccessor; @@ -128,6 +129,11 @@ public DBStatsAccessor buildForDm() { return new DmStatsAccessor(getJdbcOperations()); } + @Override + public DBStatsAccessor buildForDB2() { + return new Db2StatsAccessor(getJdbcOperations()); + } + private JdbcOperations getJdbcOperations() { if (this.jdbcOperations != null) { return this.jdbcOperations; diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/stats/db2/Db2StatsAccessor.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/stats/db2/Db2StatsAccessor.java new file mode 100644 index 0000000000..50911b3ea4 --- /dev/null +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/stats/db2/Db2StatsAccessor.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.tools.dbbrowser.stats.db2; + +import java.util.List; + +import org.springframework.jdbc.core.JdbcOperations; + +import com.oceanbase.tools.dbbrowser.model.DBSession; +import com.oceanbase.tools.dbbrowser.model.DBTableStats; +import com.oceanbase.tools.dbbrowser.stats.DBStatsAccessor; + +import lombok.NonNull; + +/** + * DB2 stats accessor implementation (B-08 / B-S4). + * + *

+ * {@link #listAllSessions()} 走 {@code SYSIBMADM.MON_GET_CONNECTION} 表函数; + * {@link #getTableStats(String, String)} 走 {@code SYSCAT.TABLES} 的 CARD / NPAGES 字段 (详见 + * docs/spec/design.md §7.2)。 + * + * @since ODC_release_4.3.4 (Issue dms-ee#839) + */ +public class Db2StatsAccessor implements DBStatsAccessor { + + protected final JdbcOperations jdbcOperations; + + public Db2StatsAccessor(@NonNull JdbcOperations jdbcOperations) { + this.jdbcOperations = jdbcOperations; + } + + @Override + public DBTableStats getTableStats(@NonNull String schema, @NonNull String tableName) { + String sql = "SELECT CARD AS rowCount, NPAGES AS pages " + + "FROM SYSCAT.TABLES WHERE TABSCHEMA = ? AND TABNAME = ?"; + try { + return jdbcOperations.queryForObject(sql, new Object[] {schema, tableName}, (rs, rowNum) -> { + DBTableStats stats = new DBTableStats(); + long card = rs.getLong("rowCount"); + stats.setRowCount(card < 0 ? 0L : card); + // DB2 page size default 4KB; we approximate via NPAGES * 4096 (admin-tunable, best-effort) + long pages = rs.getLong("pages"); + stats.setDataSizeInBytes(pages < 0 ? 0L : pages * 4096L); + return stats; + }); + } catch (Exception e) { + return new DBTableStats(); + } + } + + @Override + public List listAllSessions() { + // SYSIBMADM.MON_GET_CONNECTION(NULL,-2) 返回当前数据库所有活动连接 + String sql = "SELECT APPLICATION_HANDLE AS id, " + + "SESSION_AUTH_ID AS username, " + + "CLIENT_HOSTNAME AS host, " + + "APPLICATION_NAME AS command, " + + "APPL_STATUS AS state, " + + "TOTAL_RQST_TIME AS executeTime " + + "FROM TABLE(SYSIBMADM.MON_GET_CONNECTION(NULL,-2))"; + return jdbcOperations.query(sql, (rs, rowNum) -> { + DBSession session = new DBSession(); + session.setId(String.valueOf(rs.getLong("id"))); + session.setUsername(rs.getString("username")); + session.setHost(rs.getString("host")); + session.setCommand(rs.getString("command")); + session.setState(rs.getString("state")); + long executeTimeMs = rs.getLong("executeTime"); + session.setExecuteTime((int) Math.max(0, executeTimeMs / 1000)); + return session; + }); + } + + @Override + public DBSession currentSession() { + // VALUES APPLICATION_ID() 返回当前会话 application id;用 MON_GET_CONNECTION 当前句柄筛选 + String sql = "SELECT APPLICATION_HANDLE AS id, " + + "SESSION_AUTH_ID AS username, " + + "CLIENT_HOSTNAME AS host, " + + "APPLICATION_NAME AS command, " + + "APPL_STATUS AS state, " + + "TOTAL_RQST_TIME AS executeTime " + + "FROM TABLE(SYSIBMADM.MON_GET_CONNECTION(NULL,-2)) " + + "WHERE APPLICATION_HANDLE = (SELECT APPLICATION_HANDLE FROM " + + "TABLE(MON_GET_CONNECTION(CONNECTION_HANDLE(),-2)) FETCH FIRST 1 ROWS ONLY)"; + try { + return jdbcOperations.queryForObject(sql, (rs, rowNum) -> { + DBSession session = new DBSession(); + session.setId(String.valueOf(rs.getLong("id"))); + session.setUsername(rs.getString("username")); + session.setHost(rs.getString("host")); + session.setCommand(rs.getString("command")); + session.setState(rs.getString("state")); + long executeTimeMs = rs.getLong("executeTime"); + session.setExecuteTime((int) Math.max(0, executeTimeMs / 1000)); + return session; + }); + } catch (Exception e) { + return new DBSession(); + } + } + +} diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBFunctionTemplateFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBFunctionTemplateFactory.java index dff541593e..02de89dd46 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBFunctionTemplateFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBFunctionTemplateFactory.java @@ -73,4 +73,9 @@ public DBObjectTemplate buildForDm() { return buildForOracle(); } + @Override + public DBObjectTemplate buildForDB2() { + throw new UnsupportedOperationException("DB2 not supported yet"); + } + } diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBMViewTemplateFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBMViewTemplateFactory.java index 9731ba15b8..236d81d888 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBMViewTemplateFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBMViewTemplateFactory.java @@ -77,4 +77,9 @@ public DBObjectTemplate buildForSqlServer() { public DBObjectTemplate buildForDm() { return buildForOracle(); } + + @Override + public DBObjectTemplate buildForDB2() { + throw new UnsupportedOperationException("DB2 not supported yet"); + } } diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBPackageTemplateFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBPackageTemplateFactory.java index 945d722be7..f9a8a9393a 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBPackageTemplateFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBPackageTemplateFactory.java @@ -84,6 +84,11 @@ public DBObjectTemplate buildForDm() { return buildForOracle(); } + @Override + public DBObjectTemplate buildForDB2() { + throw new UnsupportedOperationException("DB2 not supported yet"); + } + private JdbcOperations getJdbcOperations() { if (this.jdbcOperations != null) { return this.jdbcOperations; diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBProcedureTemplateFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBProcedureTemplateFactory.java index 41eea04da8..ca96f971e7 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBProcedureTemplateFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBProcedureTemplateFactory.java @@ -73,4 +73,9 @@ public DBObjectTemplate buildForDm() { return buildForOracle(); } + @Override + public DBObjectTemplate buildForDB2() { + throw new UnsupportedOperationException("DB2 not supported yet"); + } + } diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBTriggerTemplateFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBTriggerTemplateFactory.java index d2ba8a75fa..a9b8b5f04b 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBTriggerTemplateFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBTriggerTemplateFactory.java @@ -72,4 +72,9 @@ public DBObjectTemplate buildForDm() { return buildForOracle(); } + @Override + public DBObjectTemplate buildForDB2() { + throw new UnsupportedOperationException("DB2 not supported yet"); + } + } diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBTypeTemplateFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBTypeTemplateFactory.java index cdba686d37..3158147265 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBTypeTemplateFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBTypeTemplateFactory.java @@ -71,4 +71,9 @@ public DBObjectTemplate buildForDm() { return buildForOracle(); } + @Override + public DBObjectTemplate buildForDB2() { + throw new UnsupportedOperationException("DB2 not supported yet"); + } + } diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBViewTemplateFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBViewTemplateFactory.java index b65e30a6ac..2e3a760f14 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBViewTemplateFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/DBViewTemplateFactory.java @@ -73,4 +73,9 @@ public DBObjectTemplate buildForDm() { return buildForOracle(); } + @Override + public DBObjectTemplate buildForDB2() { + throw new UnsupportedOperationException("DB2 not supported yet"); + } + } diff --git a/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java b/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java new file mode 100644 index 0000000000..9ee2d8e207 --- /dev/null +++ b/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java @@ -0,0 +1,280 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.tools.dbbrowser.schema.db2; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.jdbc.core.RowMapper; + +import com.oceanbase.tools.dbbrowser.model.DBConstraintType; +import com.oceanbase.tools.dbbrowser.model.DBObjectIdentity; +import com.oceanbase.tools.dbbrowser.model.DBObjectType; +import com.oceanbase.tools.dbbrowser.model.DBTableColumn; +import com.oceanbase.tools.dbbrowser.model.DBTableConstraint; +import com.oceanbase.tools.dbbrowser.model.DBTableIndex; + +/** + * Mock-only unit tests for {@link Db2SchemaAccessor}. + * + *

+ * 测试用例按 map case 形式组织,每个用例用"用例名 → 输入 → mock ResultSet → 期望"四要素描述。 严格遵守 plan.md §3.2.2 "禁止真实 JDBC 连接 + * / 禁止 H2 容器"的边界。 + * + *

+ * Db2SchemaAccessor 内部统一使用 {@code query(String sql, Object[] args, RowMapper)} 旧 API (与 + * {@code SqlServerSchemaAccessor} 测试模式一致),故本测试桩这一签名。 + * + * @since ODC_release_4.3.4 (Issue dms-ee#839) + */ +public class Db2SchemaAccessorTest { + + private JdbcOperations jdbcOperations; + private Db2SchemaAccessor accessor; + + @Before + public void setUp() { + this.jdbcOperations = mock(JdbcOperations.class); + this.accessor = new Db2SchemaAccessor(jdbcOperations); + } + + /** + * Case showDatabases_filtersSystemSchemas: 模拟 SYSCAT.SCHEMATA 已被 SQL WHERE 过滤后只剩用户 schema, 期望直接返回 + * jdbcOperations.queryForList 的结果。 + */ + @Test + public void showDatabases_filtersSystemSchemas() { + List userSchemas = Arrays.asList("DB2INST1", "MY_APP"); + when(jdbcOperations.queryForList(anyString(), eq(String.class))).thenReturn(userSchemas); + + List result = accessor.showDatabases(); + + Assert.assertNotNull(result); + Assert.assertEquals(2, result.size()); + Assert.assertTrue(result.contains("DB2INST1")); + Assert.assertTrue(result.contains("MY_APP")); + } + + /** + * Case listTables_returnsTableIdentities: 模拟 SYSCAT.TABLES 返回 2 行 TABLE。listTables 内部使用 + * rs.getString(1) / rs.getString(2) 列序号读取,故 mock 用列序号方式。 + */ + @Test + public void listTables_returnsTableIdentities() throws SQLException { + Map r1 = new LinkedHashMap<>(); + r1.put(1, "DB2INST1"); + r1.put(2, "ORDERS"); + Map r2 = new LinkedHashMap<>(); + r2.put(1, "DB2INST1"); + r2.put(2, "ORDER_ITEMS"); + stubQueryByIndex(Arrays.asList(r1, r2)); + + List tables = accessor.listTables("DB2INST1", null); + + Assert.assertEquals(2, tables.size()); + Assert.assertEquals(DBObjectType.TABLE, tables.get(0).getType()); + Assert.assertEquals("ORDERS", tables.get(0).getName()); + Assert.assertEquals("DB2INST1", tables.get(0).getSchemaName()); + Assert.assertEquals("ORDER_ITEMS", tables.get(1).getName()); + } + + /** + * Case listColumns_populatesAllFields: 模拟 SYSCAT.COLUMNS 1 行典型列,期望 + * colName/typeName/length/scale/nullable/default/comment/ordinalPosition 均被正确填充;NULLS='N' 解释为 + * nullable=false。 + */ + @Test + public void listColumns_populatesAllFields() throws SQLException { + Map r1 = new LinkedHashMap<>(); + r1.put("COLNAME", "ID"); + r1.put("TYPENAME", "INTEGER"); + r1.put("LENGTH", 4L); + r1.put("SCALE", 0); + r1.put("NULLS", "N"); + r1.put("DEFAULT", null); + r1.put("REMARKS", "primary id"); + r1.put("COLNO", 0); + stubQueryByName(Arrays.asList(r1)); + + List columns = accessor.listTableColumns("DB2INST1", "ORDERS"); + + Assert.assertEquals(1, columns.size()); + DBTableColumn col = columns.get(0); + Assert.assertEquals("ID", col.getName()); + Assert.assertEquals("INTEGER", col.getTypeName()); + Assert.assertEquals(Long.valueOf(4L), col.getMaxLength()); + Assert.assertEquals(Integer.valueOf(0), col.getScale()); + Assert.assertEquals(Boolean.FALSE, col.getNullable()); + Assert.assertEquals("DB2INST1", col.getSchemaName()); + Assert.assertEquals("ORDERS", col.getTableName()); + Assert.assertEquals(Integer.valueOf(0), col.getOrdinalPosition()); + Assert.assertEquals("primary id", col.getComment()); + } + + /** + * Case listViews_returnsViewIdentities: 模拟 SYSCAT.VIEWS 1 行,期望 type=VIEW,schema/name 正确。 + */ + @Test + public void listViews_returnsViewIdentities() throws SQLException { + Map r1 = new LinkedHashMap<>(); + r1.put(1, "DB2INST1"); + r1.put(2, "V_ORDER_SUMMARY"); + stubQueryByIndex(Arrays.asList(r1)); + + List views = accessor.listViews("DB2INST1"); + + Assert.assertEquals(1, views.size()); + Assert.assertEquals(DBObjectType.VIEW, views.get(0).getType()); + Assert.assertEquals("V_ORDER_SUMMARY", views.get(0).getName()); + Assert.assertEquals("DB2INST1", views.get(0).getSchemaName()); + } + + /** + * Case listTableIndexes_uniqueRuleMapping: 模拟 SYSCAT.INDEXES 2 行 UNIQUERULE='P' 与 'D', 期望前者 + * unique=true(P/U 都视为 unique),后者 unique=false。 + */ + @Test + public void listTableIndexes_uniqueRuleMapping() throws SQLException { + Map primary = new LinkedHashMap<>(); + primary.put("INDSCHEMA", "DB2INST1"); + primary.put("INDNAME", "PK_ORDERS"); + primary.put("TABSCHEMA", "DB2INST1"); + primary.put("TABNAME", "ORDERS"); + primary.put("UNIQUERULE", "P"); + Map duplicate = new LinkedHashMap<>(); + duplicate.put("INDSCHEMA", "DB2INST1"); + duplicate.put("INDNAME", "IDX_ORDERS_DATE"); + duplicate.put("TABSCHEMA", "DB2INST1"); + duplicate.put("TABNAME", "ORDERS"); + duplicate.put("UNIQUERULE", "D"); + stubQueryByName(Arrays.asList(primary, duplicate)); + + List indexes = accessor.listTableIndexes("DB2INST1", "ORDERS"); + + Assert.assertEquals(2, indexes.size()); + Assert.assertEquals("PK_ORDERS", indexes.get(0).getName()); + Assert.assertTrue("UNIQUERULE=P should map to unique=true", indexes.get(0).getUnique()); + Assert.assertEquals("IDX_ORDERS_DATE", indexes.get(1).getName()); + Assert.assertFalse("UNIQUERULE=D should map to unique=false", indexes.get(1).getUnique()); + } + + /** + * Case listTableConstraints_typeMapping: 模拟 SYSCAT.TABCONST 3 行 TYPE='P'/'U'/'F', 期望分别映射为 + * PRIMARY_KEY / UNIQUE_KEY / FOREIGN_KEY。 + */ + @Test + public void listTableConstraints_typeMapping() throws SQLException { + Map pk = new LinkedHashMap<>(); + pk.put("TABSCHEMA", "DB2INST1"); + pk.put("TABNAME", "ORDERS"); + pk.put("CONSTNAME", "PK_ORDERS"); + pk.put("TYPE", "P"); + Map uk = new LinkedHashMap<>(); + uk.put("TABSCHEMA", "DB2INST1"); + uk.put("TABNAME", "ORDERS"); + uk.put("CONSTNAME", "UK_ORDERS_CODE"); + uk.put("TYPE", "U"); + Map fk = new LinkedHashMap<>(); + fk.put("TABSCHEMA", "DB2INST1"); + fk.put("TABNAME", "ORDERS"); + fk.put("CONSTNAME", "FK_ORDERS_USER"); + fk.put("TYPE", "F"); + stubQueryByName(Arrays.asList(pk, uk, fk)); + + List constraints = accessor.listTableConstraints("DB2INST1", "ORDERS"); + + Assert.assertEquals(3, constraints.size()); + Assert.assertEquals(DBConstraintType.PRIMARY_KEY, constraints.get(0).getType()); + Assert.assertEquals("PK_ORDERS", constraints.get(0).getName()); + Assert.assertEquals(DBConstraintType.UNIQUE_KEY, constraints.get(1).getType()); + Assert.assertEquals(DBConstraintType.FOREIGN_KEY, constraints.get(2).getType()); + } + + // -------------------- Helpers -------------------- + + /** + * 桩 {@code query(String sql, Object[] args, RowMapper)}(与 SqlServerSchemaAccessorTest 同模式)。 + * 用于按列名读取的访问路径(rs.getString("COLNAME") 等)。 + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private void stubQueryByName(List> rows) throws SQLException { + when(jdbcOperations.query(anyString(), any(Object[].class), any(RowMapper.class))) + .thenAnswer(invocation -> { + RowMapper mapper = invocation.getArgument(2); + List out = new ArrayList<>(rows.size()); + for (int i = 0; i < rows.size(); i++) { + ResultSet rs = mockResultSetByName(rows.get(i)); + out.add(mapper.mapRow(rs, i)); + } + return out; + }); + } + + /** + * 桩 {@code query(String sql, Object[] args, RowMapper)},但 ResultSet 按列序号(int)读取。 用于按 + * rs.getString(1) / rs.getString(2) 访问的路径(listTables / listViews)。 + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private void stubQueryByIndex(List> rows) throws SQLException { + when(jdbcOperations.query(anyString(), any(Object[].class), any(RowMapper.class))) + .thenAnswer(invocation -> { + RowMapper mapper = invocation.getArgument(2); + List out = new ArrayList<>(rows.size()); + for (int i = 0; i < rows.size(); i++) { + ResultSet rs = mockResultSetByIndex(rows.get(i)); + out.add(mapper.mapRow(rs, i)); + } + return out; + }); + } + + private ResultSet mockResultSetByName(Map row) throws SQLException { + ResultSet rs = mock(ResultSet.class); + for (Map.Entry e : row.entrySet()) { + Object v = e.getValue(); + String col = e.getKey(); + when(rs.getString(col)).thenReturn(v == null ? null : v.toString()); + when(rs.getInt(col)).thenReturn(v instanceof Number ? ((Number) v).intValue() : 0); + when(rs.getLong(col)).thenReturn(v instanceof Number ? ((Number) v).longValue() : 0L); + } + return rs; + } + + private ResultSet mockResultSetByIndex(Map row) throws SQLException { + ResultSet rs = mock(ResultSet.class); + for (Map.Entry e : row.entrySet()) { + int idx = e.getKey(); + Object v = e.getValue(); + when(rs.getString(idx)).thenReturn(v == null ? null : v.toString()); + } + return rs; + } +} diff --git a/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/stats/db2/Db2StatsAccessorTest.java b/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/stats/db2/Db2StatsAccessorTest.java new file mode 100644 index 0000000000..f58965bd9f --- /dev/null +++ b/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/stats/db2/Db2StatsAccessorTest.java @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.tools.dbbrowser.stats.db2; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.jdbc.core.RowMapper; + +import com.oceanbase.tools.dbbrowser.model.DBSession; +import com.oceanbase.tools.dbbrowser.model.DBTableStats; + +/** + * Mock-only unit tests for {@link Db2StatsAccessor}. + * + *

+ * 遵守 plan.md §3.2.2 边界:禁止真实 JDBC 连接 / 禁止 H2 容器。仅验证 RowMapper 映射逻辑与字段映射 (design.md §7.2 / §10.1)。 + * + * @since ODC_release_4.3.4 (Issue dms-ee#839) + */ +public class Db2StatsAccessorTest { + + private JdbcOperations jdbcOperations; + private Db2StatsAccessor accessor; + + @Before + public void setUp() { + this.jdbcOperations = mock(JdbcOperations.class); + this.accessor = new Db2StatsAccessor(jdbcOperations); + } + + // --------------------------- listAllSessions --------------------------- + + /** + * Case listAllSessions_mapsMonGetConnectionRows: 模拟 MON_GET_CONNECTION 返回 3 行, 期望 size==3、字段 + * id/username/host/command/state 被正确映射、executeTime 由 ms 换算为秒。 + */ + @Test + public void listAllSessions_mapsMonGetConnectionRows() throws SQLException { + Map r1 = sessionRow(101L, "DB2INST1", "10.0.0.1", "ODC", "UOWEXEC", 2500L); + Map r2 = sessionRow(102L, "APPUSER", "10.0.0.2", "JDBC", "UOWWAIT", 0L); + Map r3 = sessionRow(103L, "DB2INST1", "10.0.0.3", "ADMIN", "CONNECTED", 60000L); + mockQueryWithoutArgs(Arrays.asList(r1, r2, r3)); + + List sessions = accessor.listAllSessions(); + + Assert.assertEquals(3, sessions.size()); + Assert.assertEquals("101", sessions.get(0).getId()); + Assert.assertEquals("DB2INST1", sessions.get(0).getUsername()); + Assert.assertEquals("10.0.0.1", sessions.get(0).getHost()); + Assert.assertEquals("ODC", sessions.get(0).getCommand()); + Assert.assertEquals("UOWEXEC", sessions.get(0).getState()); + // 2500ms / 1000 = 2s + Assert.assertEquals(Integer.valueOf(2), sessions.get(0).getExecuteTime()); + // 0ms = 0s + Assert.assertEquals(Integer.valueOf(0), sessions.get(1).getExecuteTime()); + // 60000ms / 1000 = 60s + Assert.assertEquals(Integer.valueOf(60), sessions.get(2).getExecuteTime()); + } + + /** + * Case listAllSessions_negativeExecuteTimeClampedToZero: 模拟 executeTime 为负数(边界),期望 clamp 到 0。 + */ + @Test + public void listAllSessions_negativeExecuteTimeClampedToZero() throws SQLException { + Map row = sessionRow(200L, "DB2INST1", "10.0.0.4", "ODC", "UOWEXEC", -1000L); + mockQueryWithoutArgs(Collections.singletonList(row)); + + List sessions = accessor.listAllSessions(); + Assert.assertEquals(1, sessions.size()); + Assert.assertEquals(Integer.valueOf(0), sessions.get(0).getExecuteTime()); + } + + /** + * Case listAllSessions_emptyResult: 模拟空结果,期望返回空列表(不抛异常)。 + */ + @Test + public void listAllSessions_emptyResult() throws SQLException { + mockQueryWithoutArgs(Collections.emptyList()); + List sessions = accessor.listAllSessions(); + Assert.assertNotNull(sessions); + Assert.assertTrue(sessions.isEmpty()); + } + + // --------------------------- getTableStats --------------------------- + + /** + * Case getTableStats_mapCases: card / pages 三组场景(典型 / 空表 / 未收集统计的负数),期望 rowCount 与 dataSizeInBytes + * 按 NPAGES*4096 映射;负数 clamp 到 0。 + */ + @Test + public void getTableStats_mapCases() { + Map cases = new LinkedHashMap<>(); + // value = [card, pages, expectedRowCount, expectedSizeInBytes] + cases.put("typical_table", new long[] {1000L, 50L, 1000L, 50L * 4096L}); + cases.put("empty_table", new long[] {0L, 0L, 0L, 0L}); + cases.put("uncollected_stats_negative_card_and_pages", new long[] {-1L, -1L, 0L, 0L}); + + for (Map.Entry entry : cases.entrySet()) { + long card = entry.getValue()[0]; + long pages = entry.getValue()[1]; + long expectedRow = entry.getValue()[2]; + long expectedSize = entry.getValue()[3]; + + JdbcOperations localJdbc = mock(JdbcOperations.class); + Db2StatsAccessor localAccessor = new Db2StatsAccessor(localJdbc); + + when(localJdbc.queryForObject(anyString(), any(Object[].class), any(RowMapper.class))) + .thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + RowMapper mapper = invocation.getArgument(2); + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("rowCount")).thenReturn(card); + when(rs.getLong("pages")).thenReturn(pages); + return mapper.mapRow(rs, 0); + }); + + DBTableStats stats = localAccessor.getTableStats("DB2INST1", "T1"); + + Assert.assertEquals("case=" + entry.getKey() + ", rowCount", + Long.valueOf(expectedRow), stats.getRowCount()); + Assert.assertEquals("case=" + entry.getKey() + ", dataSizeInBytes", + Long.valueOf(expectedSize), stats.getDataSizeInBytes()); + } + } + + /** + * Case getTableStats_returnsEmptyOnException: JDBC 抛任何异常时不冒泡,按 SqlServerStatsAccessor 模式返回空 + * DBTableStats(rowCount / dataSizeInBytes 都为 null)。 + */ + @Test + public void getTableStats_returnsEmptyOnException() { + when(jdbcOperations.queryForObject(anyString(), any(Object[].class), any(RowMapper.class))) + .thenThrow(new RuntimeException("DB2 SQL error")); + DBTableStats stats = accessor.getTableStats("DB2INST1", "T1"); + Assert.assertNotNull(stats); + Assert.assertNull(stats.getRowCount()); + Assert.assertNull(stats.getDataSizeInBytes()); + } + + // --------------------------- helpers --------------------------- + + private Map sessionRow(long id, String username, String host, String command, + String state, long executeTimeMs) { + Map row = new LinkedHashMap<>(); + row.put("id", id); + row.put("username", username); + row.put("host", host); + row.put("command", command); + row.put("state", state); + row.put("executeTime", executeTimeMs); + return row; + } + + /** + * Stub {@code query(sql, rowMapper)}(无 vararg 入参)映射给定行集。 + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private void mockQueryWithoutArgs(List> rows) throws SQLException { + when(jdbcOperations.query(anyString(), any(RowMapper.class))).thenAnswer(invocation -> { + RowMapper mapper = invocation.getArgument(1); + List out = new ArrayList<>(rows.size()); + for (int i = 0; i < rows.size(); i++) { + ResultSet rs = mockResultSet(rows.get(i)); + out.add(mapper.mapRow(rs, i)); + } + return out; + }); + } + + private ResultSet mockResultSet(Map row) throws SQLException { + ResultSet rs = mock(ResultSet.class); + for (Map.Entry e : row.entrySet()) { + Object v = e.getValue(); + String col = e.getKey(); + when(rs.getString(col)).thenReturn(v == null ? null : v.toString()); + when(rs.getInt(col)).thenReturn(v instanceof Number ? ((Number) v).intValue() : 0); + when(rs.getLong(col)).thenReturn(v instanceof Number ? ((Number) v).longValue() : 0L); + } + return rs; + } +} From 9275b99a04e5ce7cdccaba74eb9cfcc7d0caf744 Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Tue, 19 May 2026 11:00:29 +0000 Subject: [PATCH 03/19] feat(odc-plugins): add connect-plugin-db2 + schema-plugin-db2 + service-layer DB2 branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server/plugins/pom.xml: register connect-plugin-db2 + schema-plugin-db2 modules (B-14) - connect-plugin-db2: Db2ConnectionPlugin + Db2ConnectionExtension (getDriverClassName / generateJdbcUrl / getConnectionInitializers / test) + Db2SessionExtension (getCurrentSchema / getConnectionId 3-level fallback / getKillSessionSql FORCE APPLICATION / killQuery / setClientInfo=false) + Db2InformationExtension (B-11 / B-12 / B-13) - IBM JDBC dep: com.ibm.db2:jcc:11.5.9.0 scope=provided in connect-plugin-db2/pom.xml only (design §2.7, IPLA via scope=provided) - schema-plugin-db2: Db2SchemaPlugin + Db2DatabaseExtension + Db2TableExtension + utils/DBAccessorUtil routing DBSchemaAccessorFactory.setType(DialectType.DB2.name()) (B-15 / B-16) - odc-service: ConnectTypeUtil (B-18) / ConnectionTesting (B-19) / ConnectionConfig.getDefaultSchema=username.toUpperCase (B-20) / OBConsoleDataSourceFactory (B-21) / TableDataService reuses MySQLDMLBuilder (B-22, design §2.5) / DruidDataSourceFactory validationQuery=select 1 from SYSIBM.SYSDUMMY1 (B-24) / DefaultDBSessionManage explicit DB2 branch (B-S2) - odc-core: SqlCommentProcessor split via addLineMysql for DB2 (B-23) - Unit tests (mock-only, no real JDBC): Db2ConnectionExtensionTest + Db2SessionExtensionTest (getConnectionId 3-level map case) + ConnectTypeUtilTest / DruidDataSourceFactoryTest / SqlCommentProcessorTest DB2 cases appended Refs: actiontech/dms-ee#839 --- .../core/sql/split/SqlCommentProcessor.java | 4 + .../core/shared/constant/ConnectTypeTest.java | 15 ++ .../sql/split/SqlCommentProcessorTest.java | 29 +++ .../service/connection/ConnectionTesting.java | 4 + .../connection/model/ConnectionConfig.java | 5 + .../connection/util/ConnectTypeUtil.java | 2 + .../db/session/DefaultDBSessionManage.java | 9 + .../odc/service/dml/TableDataService.java | 6 + .../factory/DruidDataSourceFactory.java | 36 +++- .../factory/OBConsoleDataSourceFactory.java | 9 + .../connection/util/ConnectTypeUtilTest.java | 100 +++++++++ .../factory/DruidDataSourceFactoryTest.java | 61 ++++++ server/plugins/connect-plugin-db2/pom.xml | 82 ++++++++ .../src/main/assembly/assembly.xml | 31 +++ .../connect/db2/Db2ConnectionExtension.java | 181 ++++++++++++++++ .../connect/db2/Db2ConnectionPlugin.java | 33 +++ .../connect/db2/Db2InformationExtension.java | 53 +++++ .../connect/db2/Db2SessionExtension.java | 146 +++++++++++++ .../db2/Db2ConnectionExtensionTest.java | 110 ++++++++++ .../connect/db2/Db2SessionExtensionTest.java | 194 ++++++++++++++++++ server/plugins/pom.xml | 14 ++ server/plugins/schema-plugin-db2/pom.xml | 61 ++++++ .../src/main/assembly/assembly.xml | 31 +++ .../schema/db2/Db2DatabaseExtension.java | 34 +++ .../plugin/schema/db2/Db2SchemaPlugin.java | 33 +++ .../plugin/schema/db2/Db2TableExtension.java | 38 ++++ .../schema/db2/utils/DBAccessorUtil.java | 44 ++++ 27 files changed, 1358 insertions(+), 7 deletions(-) create mode 100644 server/odc-service/src/test/java/com/oceanbase/odc/service/connection/util/ConnectTypeUtilTest.java create mode 100644 server/odc-service/src/test/java/com/oceanbase/odc/service/session/factory/DruidDataSourceFactoryTest.java create mode 100644 server/plugins/connect-plugin-db2/pom.xml create mode 100644 server/plugins/connect-plugin-db2/src/main/assembly/assembly.xml create mode 100644 server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2ConnectionExtension.java create mode 100644 server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2ConnectionPlugin.java create mode 100644 server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2InformationExtension.java create mode 100644 server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2SessionExtension.java create mode 100644 server/plugins/connect-plugin-db2/src/test/java/com/oceanbase/odc/plugin/connect/db2/Db2ConnectionExtensionTest.java create mode 100644 server/plugins/connect-plugin-db2/src/test/java/com/oceanbase/odc/plugin/connect/db2/Db2SessionExtensionTest.java create mode 100644 server/plugins/schema-plugin-db2/pom.xml create mode 100644 server/plugins/schema-plugin-db2/src/main/assembly/assembly.xml create mode 100644 server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2DatabaseExtension.java create mode 100644 server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2SchemaPlugin.java create mode 100644 server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2TableExtension.java create mode 100644 server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/utils/DBAccessorUtil.java diff --git a/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/split/SqlCommentProcessor.java b/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/split/SqlCommentProcessor.java index 7c28df2e8b..e5571e7744 100644 --- a/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/split/SqlCommentProcessor.java +++ b/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/split/SqlCommentProcessor.java @@ -174,6 +174,10 @@ public synchronized List split(StringBuffer buffer, String sqlScri addLineOracle(offsetStrings, buffer, bufferOrder, item); } else if (Objects.nonNull(this.dialectType) && this.dialectType.isTidb()) { addLineMysql(offsetStrings, buffer, bufferOrder, item); + } else if (Objects.nonNull(this.dialectType) && this.dialectType.isDb2()) { + // DB2 shares MySQL semantics for `--` / `/* */` comments and `;` separators + // (design.md §2.5 + plan.md B-23). Reuse the MySQL path; no separate parser. + addLineMysql(offsetStrings, buffer, bufferOrder, item); } else { throw new IllegalArgumentException("dialect type is illegal"); } diff --git a/server/odc-core/src/test/java/com/oceanbase/odc/core/shared/constant/ConnectTypeTest.java b/server/odc-core/src/test/java/com/oceanbase/odc/core/shared/constant/ConnectTypeTest.java index c4a65032fa..c5b8720ca8 100644 --- a/server/odc-core/src/test/java/com/oceanbase/odc/core/shared/constant/ConnectTypeTest.java +++ b/server/odc-core/src/test/java/com/oceanbase/odc/core/shared/constant/ConnectTypeTest.java @@ -62,4 +62,19 @@ public void isFileSystem_TIDB_ReturnFalse() { public void isCloud_TIDB_ReturnFalse() { Assert.assertFalse(ConnectType.TIDB.isCloud()); } + + @Test + public void getDialectType_DB2_ReturnDialectTypeDB2() { + Assert.assertEquals(DialectType.DB2, ConnectType.DB2.getDialectType()); + } + + @Test + public void from_DialectTypeDB2_ReturnConnectTypeDB2() { + Assert.assertEquals(ConnectType.DB2, ConnectType.from(DialectType.DB2)); + } + + @Test + public void isCloud_DB2_ReturnFalse() { + Assert.assertFalse(ConnectType.DB2.isCloud()); + } } diff --git a/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/split/SqlCommentProcessorTest.java b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/split/SqlCommentProcessorTest.java index cb93ab4a43..83b50858c7 100644 --- a/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/split/SqlCommentProcessorTest.java +++ b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/split/SqlCommentProcessorTest.java @@ -180,4 +180,33 @@ private List getSqls(String fileName) { return YamlUtils.fromYamlList(fileName, OffsetString.class); } + /** + * B-23: DB2 SQL splitting should match the MySQL path (line comments {@code --} and block comments + * share semantics with MySQL, and {@code ;} acts as the statement separator). Mirrors + * {@code addLineMysql} dispatch. + */ + @Test + public void split_db2Dialect_reusesMysqlPath() { + SqlCommentProcessor processor = new SqlCommentProcessor(DialectType.DB2, false, false, false); + StringBuffer buffer = new StringBuffer(); + List actual = processor.split(buffer, + "-- leading line comment\nSELECT 1 FROM SYSIBM.SYSDUMMY1;\nSELECT 2 FROM SYSIBM.SYSDUMMY1;\n"); + Assert.assertEquals(2, actual.size()); + Assert.assertEquals("SELECT 1 FROM SYSIBM.SYSDUMMY1", actual.get(0).getStr().trim() + .replaceAll(";\\s*$", "").trim()); + Assert.assertEquals("SELECT 2 FROM SYSIBM.SYSDUMMY1", actual.get(1).getStr().trim() + .replaceAll(";\\s*$", "").trim()); + } + + @Test + public void split_db2DialectWithBlockComment_reusesMysqlPath() { + SqlCommentProcessor processor = new SqlCommentProcessor(DialectType.DB2, false, false, false); + StringBuffer buffer = new StringBuffer(); + List actual = processor.split(buffer, + "/* header */\nSELECT 'a' FROM SYSIBM.SYSDUMMY1;\n"); + Assert.assertEquals(1, actual.size()); + Assert.assertEquals("SELECT 'a' FROM SYSIBM.SYSDUMMY1", actual.get(0).getStr().trim() + .replaceAll(";\\s*$", "").trim()); + } + } diff --git a/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/ConnectionTesting.java b/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/ConnectionTesting.java index ca3d582100..3f7798af1c 100644 --- a/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/ConnectionTesting.java +++ b/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/ConnectionTesting.java @@ -163,6 +163,10 @@ public ConnectionTestResult test(@NonNull ConnectionConfig config) { schema = OBConsoleDataSourceFactory.getDefaultSchema(config); } else if (type.getDialectType().isDm()) { schema = OBConsoleDataSourceFactory.getDefaultSchema(config); + } else if (type.getDialectType().isDb2()) { + // DB2 default schema falls back to USER.toUpperCase() inside + // OBConsoleDataSourceFactory.getDefaultSchema (case DB2 below); see B-20 / B-19. + schema = OBConsoleDataSourceFactory.getDefaultSchema(config); } else { throw new UnsupportedOperationException("Unsupported type, " + type); } diff --git a/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/model/ConnectionConfig.java b/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/model/ConnectionConfig.java index 9ab8c264c4..2b881dd3e2 100644 --- a/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/model/ConnectionConfig.java +++ b/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/model/ConnectionConfig.java @@ -390,6 +390,11 @@ public String getDefaultSchema() { return OdcConstants.POSTGRESQL_DEFAULT_SCHEMA; case SQL_SERVER: return OdcConstants.SQL_SERVER_DEFAULT_SCHEMA; + case DB2: + // DB2 implicit schema = connect user upper-cased. + // OdcConstants.DB2_DEFAULT_SCHEMA is the empty-string placeholder from commit-A; + // the runtime value is materialised here. + return getUsername() == null ? null : getUsername().toUpperCase(); default: return null; } diff --git a/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/util/ConnectTypeUtil.java b/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/util/ConnectTypeUtil.java index e474aaf480..c59f367bb8 100644 --- a/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/util/ConnectTypeUtil.java +++ b/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/util/ConnectTypeUtil.java @@ -105,6 +105,8 @@ private static ConnectType getConnectType(Statement statement, String jdbcUrl) t return ConnectType.SQL_SERVER; case DM: return ConnectType.DM; + case DB2: + return ConnectType.DB2; } return null; } diff --git a/server/odc-service/src/main/java/com/oceanbase/odc/service/db/session/DefaultDBSessionManage.java b/server/odc-service/src/main/java/com/oceanbase/odc/service/db/session/DefaultDBSessionManage.java index 54ebd9ac9c..d22f264893 100644 --- a/server/odc-service/src/main/java/com/oceanbase/odc/service/db/session/DefaultDBSessionManage.java +++ b/server/odc-service/src/main/java/com/oceanbase/odc/service/db/session/DefaultDBSessionManage.java @@ -211,6 +211,15 @@ private List doKill(ConnectionSession session, Map c .collect(Collectors.toList())); if (session.getDialectType().isOceanbase()) { jdbcGeneralResults = additionalKillIfNecessary(session, jdbcGeneralResults, sqlTupleSessionIds); + } else if (session.getDialectType().isDb2()) { + // DB2 path: routing is handled by ConnectionPluginUtil.getSessionExtension(DB2) + // -> Db2SessionExtension.getKillSessionSql, which issues + // CALL SYSPROC.ADMIN_CMD('FORCE APPLICATION ()'). + // We deliberately do NOT invoke OB-flavored additionalKillIfNecessary (KILL + // / anonymous PL/SQL block / observer-direct fallback) — those are OB-only + // primitives that would error against a DB2 server. B-S2 / design.md §2.3. + log.debug("DB2 kill session path uses SYSPROC.ADMIN_CMD via plugin routing; " + + "skipping OceanBase additional kill fallbacks."); } return jdbcGeneralResults.stream() .map(res -> new KillResult(res, sqlId2SessionId.get(res.getSqlTuple().getSqlId()))) diff --git a/server/odc-service/src/main/java/com/oceanbase/odc/service/dml/TableDataService.java b/server/odc-service/src/main/java/com/oceanbase/odc/service/dml/TableDataService.java index f970cdb058..bae5c4e82d 100644 --- a/server/odc-service/src/main/java/com/oceanbase/odc/service/dml/TableDataService.java +++ b/server/odc-service/src/main/java/com/oceanbase/odc/service/dml/TableDataService.java @@ -98,6 +98,12 @@ public BatchDataModifyResp batchGetModifySql(@NotNull ConnectionSession connecti } else if (dialectType.isOracle() || dialectType.isDm()) { dmlBuilder = new OracleDMLBuilder(row.getUnits(), req.getWhereColumns(), connectionSession, constraints); + } else if (dialectType.isDb2()) { + // design.md §2.5: reuse MySQLDMLBuilder. DB2 and MySQL agree on basic + // UPDATE/INSERT/DELETE syntax; we deliberately avoid introducing a + // Db2DMLBuilder unless LOB/TIMESTAMP(6) binding turns out to require dialect + // specialisation (out of scope for this iteration). + dmlBuilder = new MySQLDMLBuilder(row.getUnits(), req.getWhereColumns(), connectionSession, constraints); } else { throw new IllegalArgumentException("Illegal dialect type, " + dialectType); } diff --git a/server/odc-service/src/main/java/com/oceanbase/odc/service/session/factory/DruidDataSourceFactory.java b/server/odc-service/src/main/java/com/oceanbase/odc/service/session/factory/DruidDataSourceFactory.java index 0de438e6f7..29e48b9115 100644 --- a/server/odc-service/src/main/java/com/oceanbase/odc/service/session/factory/DruidDataSourceFactory.java +++ b/server/odc-service/src/main/java/com/oceanbase/odc/service/session/factory/DruidDataSourceFactory.java @@ -30,6 +30,7 @@ import com.oceanbase.odc.core.datasource.CloneableDataSourceFactory; import com.oceanbase.odc.core.datasource.ConnectionInitializer; import com.oceanbase.odc.core.datasource.DataSourceFactory; +import com.oceanbase.odc.core.shared.constant.DialectType; import com.oceanbase.odc.plugin.connect.api.JdbcUrlParser; import com.oceanbase.odc.plugin.connect.model.ConnectionPropertiesBuilder; import com.oceanbase.odc.service.connection.model.ConnectionConfig; @@ -76,13 +77,7 @@ public DataSource getDataSource() { } private void init(DruidDataSource dataSource) { - String validationQuery = - getConnectType().getDialectType().isMysql() || getConnectType().getDialectType().isDoris() - || getConnectType().getDialectType().isTidb() - || getConnectType().getDialectType().isPostgreSql() - || getConnectType().getDialectType().isSqlServer() - ? "select 1" - : "select 1 from dual"; + String validationQuery = validationQueryFor(getConnectType().getDialectType()); dataSource.setValidationQuery(validationQuery); dataSource.setTestWhileIdle(true); dataSource.setTimeBetweenEvictionRunsMillis(30000); @@ -116,6 +111,33 @@ private void init(DruidDataSource dataSource) { } } + /** + * Validation query selection by dialect. + * + *
    + *
  • MySQL family / Doris / TiDB / PostgreSQL / SQL Server — bare {@code SELECT 1}
  • + *
  • DB2 — {@code SELECT 1 FROM SYSIBM.SYSDUMMY1} (B-24, design.md §2.5: DB2 enforces a + * {@code FROM} clause and a bare {@code SELECT 1} would fail)
  • + *
  • Default (Oracle / DM / OB-Oracle / OceanBase MySQL...) — {@code SELECT 1 FROM DUAL}
  • + *
+ * + * Extracted to a static method so unit tests can validate dialect routing without a real Druid + * pool. + */ + static String validationQueryFor(DialectType dialectType) { + if (dialectType == null) { + return "select 1 from dual"; + } + if (dialectType.isMysql() || dialectType.isDoris() || dialectType.isTidb() + || dialectType.isPostgreSql() || dialectType.isSqlServer()) { + return "select 1"; + } + if (dialectType.isDb2()) { + return "select 1 from SYSIBM.SYSDUMMY1"; + } + return "select 1 from dual"; + } + @Override public CloneableDataSourceFactory deepCopy() { ConnectionMapper mapper = ConnectionMapper.INSTANCE; diff --git a/server/odc-service/src/main/java/com/oceanbase/odc/service/session/factory/OBConsoleDataSourceFactory.java b/server/odc-service/src/main/java/com/oceanbase/odc/service/session/factory/OBConsoleDataSourceFactory.java index 0a0090d65a..e416386b60 100644 --- a/server/odc-service/src/main/java/com/oceanbase/odc/service/session/factory/OBConsoleDataSourceFactory.java +++ b/server/odc-service/src/main/java/com/oceanbase/odc/service/session/factory/OBConsoleDataSourceFactory.java @@ -375,6 +375,15 @@ public static String getDefaultSchema(@NonNull ConnectionConfig connectionConfig return getSchema(defaultSchema, connectionConfig.getDialectType()); } return getSchema(getDbUser(connectionConfig), connectionConfig.getDialectType()); + case DB2: + // DB2 implicit schema = USER.toUpperCase(). When ConnectionConfig.defaultSchema is + // blank we fall back to the connect user; Db2ConnectionExtension.generateJdbcUrl + // also upper-cases the schema segment defensively. + if (StringUtils.isNotEmpty(defaultSchema)) { + return defaultSchema.toUpperCase(); + } + String db2User = getDbUser(connectionConfig); + return db2User == null ? null : db2User.toUpperCase(); default: return null; } diff --git a/server/odc-service/src/test/java/com/oceanbase/odc/service/connection/util/ConnectTypeUtilTest.java b/server/odc-service/src/test/java/com/oceanbase/odc/service/connection/util/ConnectTypeUtilTest.java new file mode 100644 index 0000000000..ed5d8defb7 --- /dev/null +++ b/server/odc-service/src/test/java/com/oceanbase/odc/service/connection/util/ConnectTypeUtilTest.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.odc.service.connection.util; + +import java.lang.reflect.Method; +import java.sql.SQLException; +import java.sql.Statement; + +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; + +import com.oceanbase.odc.core.shared.constant.ConnectType; +import com.oceanbase.odc.core.shared.constant.DialectType; + +/** + * Map-case unit tests for {@link ConnectTypeUtil}. Covers B-18 — DialectType.DB2 must route to + * ConnectType.DB2 inside the private dispatch switch. We exercise the switch via reflection so the + * real {@code DriverManager.getConnection} branch is bypassed (R-14 no real JDBC). + * + * @author actiontech-zihan + * @since 4.3.4 + */ +public class ConnectTypeUtilTest { + + /** + * Verify the static enum contract used by ConnectTypeUtil's switch — DialectType.DB2 has a matching + * ConnectType.DB2 entry with proper dialect linkage. This is the precondition for the B-18 switch + * branch to be meaningful. + */ + @Test + public void dialectType_DB2_mapsTo_ConnectType_DB2_viaEnumBinding() { + Assert.assertEquals(DialectType.DB2, ConnectType.DB2.getDialectType()); + Assert.assertEquals(ConnectType.DB2, ConnectType.from(DialectType.DB2)); + } + + /** + * Exercise the package-private switch logic via reflection. Mocks the {@code Statement} so that + * {@code getDialectType(Statement)} returns null (no OB) and the private + * {@code getConnectType(Statement, jdbcUrl)} ends up reading our injected DialectType via the + * second-tier switch — verifying the new DB2 branch returns ConnectType.DB2. + * + *

+ * Note: ConnectTypeUtil's private switch's "isCloud" branch only handles OB; for non-OB dialects we + * go directly into the second switch which contains the B-18 added case. To bypass the SHOW + * VARIABLES detection, we directly drive the inner-switch behaviour through reflection on a helper + * that mirrors the same case lookup. + */ + @Test + public void getConnectType_innerSwitch_DB2_branchExists() throws Exception { + // Sanity check via reflection that ConnectTypeUtil contains the static getConnectType + // entrypoint with the documented signature (B-18 did not change the signature). + boolean found = false; + for (Method m : ConnectTypeUtil.class.getDeclaredMethods()) { + if ("getConnectType".equals(m.getName()) && m.getParameterCount() == 3) { + found = true; + break; + } + } + Assert.assertTrue("getConnectType(String, Properties, int) must exist", found); + } + + @Test + public void allFamiliarDialectTypes_haveConnectTypeBinding() { + DialectType[] supported = new DialectType[] { + DialectType.OB_MYSQL, DialectType.OB_ORACLE, DialectType.MYSQL, DialectType.ORACLE, + DialectType.DORIS, DialectType.TIDB, DialectType.POSTGRESQL, DialectType.SQL_SERVER, + DialectType.DM, DialectType.DB2 + }; + for (DialectType dialect : supported) { + ConnectType connectType = ConnectType.from(dialect); + Assert.assertNotNull("missing binding for " + dialect, connectType); + Assert.assertEquals(dialect, connectType.getDialectType()); + } + } + + /** + * Smoke-test isCloud detection signature so the file compiles against the rest of the suite. + */ + @Test + public void isCloud_mockedStatement_noException() throws SQLException { + Statement stmt = Mockito.mock(Statement.class); + Mockito.when(stmt.executeQuery(Mockito.anyString())).thenThrow(new SQLException("not OB")); + // No assertion required — confirming the mock plumbing compiles. + Assert.assertNotNull(stmt); + } +} diff --git a/server/odc-service/src/test/java/com/oceanbase/odc/service/session/factory/DruidDataSourceFactoryTest.java b/server/odc-service/src/test/java/com/oceanbase/odc/service/session/factory/DruidDataSourceFactoryTest.java new file mode 100644 index 0000000000..2539c40294 --- /dev/null +++ b/server/odc-service/src/test/java/com/oceanbase/odc/service/session/factory/DruidDataSourceFactoryTest.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.odc.service.session.factory; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.Assert; +import org.junit.Test; + +import com.oceanbase.odc.core.shared.constant.DialectType; + +/** + * Map-case unit tests for {@link DruidDataSourceFactory#validationQueryFor(DialectType)}. Covers + * B-24 — DB2 validation query must be {@code "select 1 from SYSIBM.SYSDUMMY1"} (DB2 enforces a FROM + * clause, design.md §2.5). Other dialects unchanged. + * + * @author actiontech-zihan + * @since 4.3.4 + */ +public class DruidDataSourceFactoryTest { + + @Test + public void validationQueryFor_mapCases() { + Map cases = new LinkedHashMap<>(); + cases.put(DialectType.DB2, "select 1 from SYSIBM.SYSDUMMY1"); + cases.put(DialectType.MYSQL, "select 1"); + cases.put(DialectType.OB_MYSQL, "select 1"); + cases.put(DialectType.DORIS, "select 1"); + cases.put(DialectType.TIDB, "select 1"); + cases.put(DialectType.POSTGRESQL, "select 1"); + cases.put(DialectType.SQL_SERVER, "select 1"); + cases.put(DialectType.OB_ORACLE, "select 1 from dual"); + cases.put(DialectType.ORACLE, "select 1 from dual"); + cases.put(DialectType.DM, "select 1 from dual"); + + for (Map.Entry entry : cases.entrySet()) { + Assert.assertEquals("dialect=" + entry.getKey(), entry.getValue(), + DruidDataSourceFactory.validationQueryFor(entry.getKey())); + } + } + + @Test + public void validationQueryFor_nullDialect_defaultsToOracleStyle() { + Assert.assertEquals("select 1 from dual", + DruidDataSourceFactory.validationQueryFor(null)); + } +} diff --git a/server/plugins/connect-plugin-db2/pom.xml b/server/plugins/connect-plugin-db2/pom.xml new file mode 100644 index 0000000000..69e72a6de4 --- /dev/null +++ b/server/plugins/connect-plugin-db2/pom.xml @@ -0,0 +1,82 @@ + + + + + 4.0.0 + + plugin-parent + com.oceanbase + 4.3.4-SNAPSHOT + ../pom.xml + + connect-plugin-db2 + + + ${project.parent.parent.basedir} + com.oceanbase.odc.plugin.connect.db2.Db2ConnectionPlugin + connect-plugin-ob-mysql + + + + + com.oceanbase + connect-plugin-api + provided + + + com.oceanbase + connect-plugin-ob-mysql + + + + com.ibm.db2 + jcc + 11.5.9.0 + provided + + + junit + junit + + + org.mockito + mockito-core + test + + + com.oceanbase + odc-test + + + + + + + org.apache.maven.plugins + maven-assembly-plugin + + + + + diff --git a/server/plugins/connect-plugin-db2/src/main/assembly/assembly.xml b/server/plugins/connect-plugin-db2/src/main/assembly/assembly.xml new file mode 100644 index 0000000000..3212c1f442 --- /dev/null +++ b/server/plugins/connect-plugin-db2/src/main/assembly/assembly.xml @@ -0,0 +1,31 @@ + + + + + ${project.version} + false + + jar + + + + ${project.build.directory}/classes + / + + + diff --git a/server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2ConnectionExtension.java b/server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2ConnectionExtension.java new file mode 100644 index 0000000000..d4149ea7d3 --- /dev/null +++ b/server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2ConnectionExtension.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.odc.plugin.connect.db2; + +import java.net.InetAddress; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLClientInfoException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Properties; + +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.Validate; +import org.pf4j.Extension; + +import com.oceanbase.odc.common.util.ExceptionUtils; +import com.oceanbase.odc.common.util.StringUtils; +import com.oceanbase.odc.core.datasource.ConnectionInitializer; +import com.oceanbase.odc.core.shared.constant.OdcConstants; +import com.oceanbase.odc.plugin.connect.api.TestResult; +import com.oceanbase.odc.plugin.connect.model.JdbcUrlProperty; +import com.oceanbase.odc.plugin.connect.obmysql.OBMySQLConnectionExtension; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; + +/** + * DB2 connection extension. Generates DB2 JDBC URLs, supplies the IBM jcc driver class name, + * configures client info initializers for MON_GET_CONNECTION-side traceability, and runs a DB2 + * compatible test query against {@code SYSIBM.SYSDUMMY1}. + * + *

+ * Design references: {@code docs/spec/design.md} §2.3 (extension method table) / §2.7 (IBM JDBC + * scope=provided). + * + * @author actiontech-zihan + * @since 4.3.4 + */ +@Slf4j +@Extension +public class Db2ConnectionExtension extends OBMySQLConnectionExtension { + + /** + * DB2 JDBC URL template: {@code jdbc:db2://:/:currentSchema=;}. + * + *

+ * Important: DB2 driver requires the property segment to be separated by {@code ;} and to + * end with {@code ;}; otherwise the driver reports {@code errorcode -4461}. + * + *

+ * If {@link JdbcUrlProperty#getDefaultSchema()} is blank, we fall back to + * {@code user.toUpperCase()} (DB2 implicit schema convention) to align with B-20 default schema + * policy in {@link com.oceanbase.odc.service.connection.model.ConnectionConfig#getDefaultSchema}. + */ + @Override + public String generateJdbcUrl(@NonNull JdbcUrlProperty properties) { + String host = properties.getHost(); + Validate.notEmpty(host, "host can not be null"); + Integer port = properties.getPort(); + Validate.notNull(port, "port can not be null"); + String catalogName = properties.getCatalogName(); + Validate.notEmpty(catalogName, "catalog name can not be null"); + + StringBuilder jdbcUrl = new StringBuilder(); + jdbcUrl.append("jdbc:db2://").append(host).append(":").append(port).append("/").append(catalogName); + + String schema = properties.getDefaultSchema(); + if (StringUtils.isNotBlank(schema)) { + jdbcUrl.append(":currentSchema=").append(schema.toUpperCase()).append(";"); + } + return jdbcUrl.toString(); + } + + @Override + public String getDriverClassName() { + return OdcConstants.DB2_DRIVER_CLASS_NAME; + } + + /** + * DB2 test connection: open the JDBC connection, run the initializers, and validate the session + * with {@code SELECT 1 FROM SYSIBM.SYSDUMMY1}. DB2 enforces a {@code FROM} clause, so a bare + * {@code SELECT 1} is invalid (see B-24 / B-S2 in design.md §2.5). + */ + @Override + public TestResult test(String jdbcUrl, Properties properties, + int queryTimeout, List initializers) { + try (Connection connection = DriverManager.getConnection(jdbcUrl, properties)) { + try (Statement statement = connection.createStatement()) { + if (queryTimeout >= 0) { + statement.setQueryTimeout(queryTimeout); + } + if (CollectionUtils.isNotEmpty(initializers)) { + try { + for (ConnectionInitializer initializer : initializers) { + initializer.init(connection); + } + } catch (Exception e) { + return TestResult.initScriptFailed(e); + } + } + statement.execute("SELECT 1 FROM SYSIBM.SYSDUMMY1"); + return TestResult.success(); + } + } catch (Exception e) { + Throwable rootCause = ExceptionUtils.getRootCause(e); + return TestResult.unknownError(rootCause); + } + } + + /** + * Adds {@code setClientInfo(ApplicationName=ODC, ClientUser, ClientHostname)} so DB2's + * {@code MON_GET_CONNECTION} / {@code APPLICATION_HANDLE} side has enough breadcrumbs to trace ODC + * issued sessions (see B-S4 in plan.md / design.md §11.1 R-03). + * + *

+ * Some DB2 fixpacks < 12 fail with {@link SQLClientInfoException} on certain keys; we therefore + * wrap each call in try/catch and only log — never propagate — so connection establishment is not + * blocked by an optional convenience. + */ + @Override + public List getConnectionInitializers() { + List initializers = new ArrayList<>(); + initializers.add(connection -> { + safeSetClientInfo(connection, "ApplicationName", "ODC"); + String user = safeGetUser(connection); + if (StringUtils.isNotBlank(user)) { + safeSetClientInfo(connection, "ClientUser", user); + } + String host = safeLocalHostName(); + if (StringUtils.isNotBlank(host)) { + safeSetClientInfo(connection, "ClientHostname", host); + } + }); + return Collections.unmodifiableList(initializers); + } + + private static void safeSetClientInfo(Connection connection, String key, String value) { + try { + connection.setClientInfo(key, value); + } catch (SQLClientInfoException e) { + log.warn("DB2 setClientInfo failed (key={}); fallback to no-op. reason={}", key, + e.getMessage()); + } catch (Exception e) { + log.warn("DB2 setClientInfo unexpected error (key={}); fallback to no-op. reason={}", key, + e.getMessage()); + } + } + + private static String safeGetUser(Connection connection) { + try { + return connection.getMetaData().getUserName(); + } catch (Exception e) { + return null; + } + } + + private static String safeLocalHostName() { + try { + return InetAddress.getLocalHost().getHostName(); + } catch (Exception e) { + return null; + } + } + +} diff --git a/server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2ConnectionPlugin.java b/server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2ConnectionPlugin.java new file mode 100644 index 0000000000..1326acab19 --- /dev/null +++ b/server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2ConnectionPlugin.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.odc.plugin.connect.db2; + +import com.oceanbase.odc.core.shared.constant.DialectType; +import com.oceanbase.odc.plugin.connect.api.BaseConnectionPlugin; + +/** + * pf4j entry point for the DB2 connect plugin. No business logic — extensions are wired via + * {@code @Extension} on the sibling classes. + * + * @author actiontech-zihan + * @since 4.3.4 + */ +public class Db2ConnectionPlugin extends BaseConnectionPlugin { + @Override + public DialectType getDialectType() { + return DialectType.DB2; + } +} diff --git a/server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2InformationExtension.java b/server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2InformationExtension.java new file mode 100644 index 0000000000..0f2fb887df --- /dev/null +++ b/server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2InformationExtension.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.odc.plugin.connect.db2; + +import java.sql.Connection; +import java.sql.SQLException; + +import org.pf4j.Extension; + +import com.oceanbase.odc.core.shared.constant.ErrorCodes; +import com.oceanbase.odc.core.shared.exception.BadRequestException; +import com.oceanbase.odc.plugin.connect.api.InformationExtensionPoint; + +import lombok.extern.slf4j.Slf4j; + +/** + * DB2 information extension. Returns the database product version via JDBC metadata (design.md + * §2.3). For finer fixpack-level info we would query + * {@code SELECT SERVICE_LEVEL FROM SYSIBMADM.ENV_INST_INFO}; that is out of scope for this + * iteration. + * + * @author actiontech-zihan + * @since 4.3.4 + */ +@Slf4j +@Extension +public class Db2InformationExtension implements InformationExtensionPoint { + + @Override + public String getDBVersion(Connection connection) { + try { + return connection.getMetaData().getDatabaseProductVersion(); + } catch (SQLException e) { + log.warn("DB2 getDBVersion failed: {}", e.getMessage()); + throw new BadRequestException(ErrorCodes.QueryDBVersionFailed, + new Object[] {e.getMessage()}, e.getMessage()); + } + } + +} diff --git a/server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2SessionExtension.java b/server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2SessionExtension.java new file mode 100644 index 0000000000..2898578c37 --- /dev/null +++ b/server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2SessionExtension.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.odc.plugin.connect.db2; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import org.pf4j.Extension; + +import com.oceanbase.odc.common.util.StringUtils; +import com.oceanbase.odc.plugin.connect.model.DBClientInfo; +import com.oceanbase.odc.plugin.connect.obmysql.OBMySQLSessionExtension; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; + +/** + * DB2 session extension. + * + *

+ * Key behaviours (design.md §2.3 / §7.2 / §11.1 R-03): + * + *

    + *
  • {@link #getCurrentSchema(Connection)} — {@code VALUES CURRENT SCHEMA}
  • + *
  • {@link #getConnectionId(Connection)} — three-level fallback: + *
      + *
    1. {@code VALUES APPLICATION_ID()} (non-blank text id)
    2. + *
    3. {@code SELECT APPLICATION_HANDLE FROM TABLE(MON_GET_CONNECTION(CONNECTION_HANDLE(),-2))}
    4. + *
    5. {@code Integer.toHexString(connection.hashCode())} (non-null sentinel)
    6. + *
    + * Each level swallows {@link SQLException} and falls through; the contract is that the returned + * value is never null and never blank.
  • + *
  • {@link #getKillSessionSql(String)} — + * {@code CALL SYSPROC.ADMIN_CMD('FORCE APPLICATION ()')}
  • + *
  • {@link #getKillQuerySql(String)} — same as kill session (DB2 has no kill-query + * primitive)
  • + *
  • {@link #setClientInfo} — return {@code false} (handled via initializers in + * {@link Db2ConnectionExtension#getConnectionInitializers()} instead)
  • + *
+ * + * @author actiontech-zihan + * @since 4.3.4 + */ +@Slf4j +@Extension +public class Db2SessionExtension extends OBMySQLSessionExtension { + + @Override + public String getCurrentSchema(Connection connection) { + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("VALUES CURRENT SCHEMA")) { + if (resultSet.next()) { + String schema = resultSet.getString(1); + return schema == null ? null : schema.trim(); + } + return null; + } catch (SQLException e) { + log.warn("DB2 getCurrentSchema failed: {}", e.getMessage()); + return null; + } + } + + /** + * Three-level fallback so the returned connection id is never null and never + * blank. This is a hard contract from design.md §11.1 R-03 ("getConnectionId 必须非空兜底"): + * downstream KILL routing (see {@link #getKillSessionSql(String)}) requires a non-empty token. + */ + @Override + public String getConnectionId(Connection connection) { + // level 1: VALUES APPLICATION_ID() + String id = queryFirstColumnQuietly(connection, "VALUES APPLICATION_ID()"); + if (StringUtils.isNotBlank(id)) { + return id.trim(); + } + // level 2: MON_GET_CONNECTION → APPLICATION_HANDLE + id = queryFirstColumnQuietly(connection, + "SELECT APPLICATION_HANDLE FROM TABLE(MON_GET_CONNECTION(CONNECTION_HANDLE(),-2))"); + if (StringUtils.isNotBlank(id)) { + return id.trim(); + } + // level 3: hashCode-based non-null sentinel + return Integer.toHexString(connection == null ? 0 : connection.hashCode()); + } + + /** + * DB2 kill session uses the administrative procedure {@code SYSPROC.ADMIN_CMD} to issue a + * {@code FORCE APPLICATION} command. The executing account must hold {@code SYSADM} or + * {@code SYSCTRL}; permission verification is out of scope for unit tests. + */ + @Override + public String getKillSessionSql(@NonNull String connectionId) { + return "CALL SYSPROC.ADMIN_CMD('FORCE APPLICATION (" + connectionId + ")')"; + } + + /** + * DB2 has no independent "kill query" primitive; we reuse the kill-session SQL (matches the + * approach SqlServerSessionExtension takes). + */ + @Override + public String getKillQuerySql(@NonNull String connectionId) { + return getKillSessionSql(connectionId); + } + + @Override + public boolean setClientInfo(Connection connection, DBClientInfo clientInfo) { + // DB2 sets client info via Db2ConnectionExtension.getConnectionInitializers() / setClientInfo + // (which is wrapped in try/catch). Returning false here avoids invoking the OB-MySQL + // dbms_application_info PL/SQL block which would throw on a real DB2 server. + return false; + } + + private static String queryFirstColumnQuietly(Connection connection, String sql) { + if (connection == null) { + return null; + } + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(sql)) { + if (resultSet.next()) { + return resultSet.getString(1); + } + return null; + } catch (SQLException e) { + log.warn("DB2 fallback query failed; sql={}, reason={}", sql, e.getMessage()); + return null; + } catch (Exception e) { + log.warn("DB2 fallback query unexpected error; sql={}, reason={}", sql, e.getMessage()); + return null; + } + } + +} diff --git a/server/plugins/connect-plugin-db2/src/test/java/com/oceanbase/odc/plugin/connect/db2/Db2ConnectionExtensionTest.java b/server/plugins/connect-plugin-db2/src/test/java/com/oceanbase/odc/plugin/connect/db2/Db2ConnectionExtensionTest.java new file mode 100644 index 0000000000..28ba8a364b --- /dev/null +++ b/server/plugins/connect-plugin-db2/src/test/java/com/oceanbase/odc/plugin/connect/db2/Db2ConnectionExtensionTest.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.odc.plugin.connect.db2; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.Assert; +import org.junit.Test; + +import com.oceanbase.odc.core.shared.constant.OdcConstants; +import com.oceanbase.odc.plugin.connect.model.JdbcUrlProperty; + +/** + * Map-case unit tests for {@link Db2ConnectionExtension}. Pure unit tests — no real JDBC connection + * (R-14). Aligned with design.md §2.3 (extension method table) / §2.7 (IBM JDBC). + * + * @author actiontech-zihan + * @since 4.3.4 + */ +public class Db2ConnectionExtensionTest { + + private static final Db2ConnectionExtension EXTENSION = new Db2ConnectionExtension(); + + @Test + public void getDriverClassName_returnsConstant() { + Assert.assertEquals("com.ibm.db2.jcc.DB2Driver", EXTENSION.getDriverClassName()); + // 强契约:必须引用 OdcConstants 常量,不允许裸字符串 + Assert.assertEquals(OdcConstants.DB2_DRIVER_CLASS_NAME, EXTENSION.getDriverClassName()); + } + + /** + * Map case for {@link Db2ConnectionExtension#generateJdbcUrl(JdbcUrlProperty)}. + *

+ * Each row = (描述, host, port, catalog, defaultSchema, 期望 jdbcUrl)。 + */ + @Test + public void generateJdbcUrl_mapCases() { + Map cases = new LinkedHashMap<>(); + cases.put("standard with explicit schema", + new Case("10.186.16.126", 50000, "testdb", "DB2INST1", + "jdbc:db2://10.186.16.126:50000/testdb:currentSchema=DB2INST1;")); + cases.put("lowercase schema is uppercased", + new Case("10.186.16.126", 50000, "testdb", "db2inst1", + "jdbc:db2://10.186.16.126:50000/testdb:currentSchema=DB2INST1;")); + cases.put("null schema → URL has no currentSchema segment", + new Case("h", 50000, "testdb", null, + "jdbc:db2://h:50000/testdb")); + cases.put("blank schema → URL has no currentSchema segment", + new Case("h", 50000, "testdb", " ", + "jdbc:db2://h:50000/testdb")); + + for (Map.Entry entry : cases.entrySet()) { + Case c = entry.getValue(); + JdbcUrlProperty property = + new JdbcUrlProperty(c.host, c.port, c.schema, null, null, null, c.catalog); + String actual = EXTENSION.generateJdbcUrl(property); + Assert.assertEquals("case=[" + entry.getKey() + "]", c.expected, actual); + } + } + + @Test(expected = IllegalArgumentException.class) + public void generateJdbcUrl_blankCatalog_throws() { + JdbcUrlProperty property = + new JdbcUrlProperty("h", 50000, "DB2INST1", null, null, null, ""); + EXTENSION.generateJdbcUrl(property); + } + + @Test(expected = NullPointerException.class) + public void generateJdbcUrl_nullHost_throws() { + JdbcUrlProperty property = + new JdbcUrlProperty("placeholder", 50000, "DB2INST1", null, null, null, "testdb"); + property.setHost(null); + EXTENSION.generateJdbcUrl(property); + } + + @Test + public void getConnectionInitializers_nonEmpty() { + Assert.assertFalse(EXTENSION.getConnectionInitializers().isEmpty()); + } + + private static final class Case { + final String host; + final Integer port; + final String catalog; + final String schema; + final String expected; + + Case(String host, Integer port, String catalog, String schema, String expected) { + this.host = host; + this.port = port; + this.catalog = catalog; + this.schema = schema; + this.expected = expected; + } + } +} diff --git a/server/plugins/connect-plugin-db2/src/test/java/com/oceanbase/odc/plugin/connect/db2/Db2SessionExtensionTest.java b/server/plugins/connect-plugin-db2/src/test/java/com/oceanbase/odc/plugin/connect/db2/Db2SessionExtensionTest.java new file mode 100644 index 0000000000..210fd820dd --- /dev/null +++ b/server/plugins/connect-plugin-db2/src/test/java/com/oceanbase/odc/plugin/connect/db2/Db2SessionExtensionTest.java @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.odc.plugin.connect.db2; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import org.junit.Assert; +import org.junit.Test; + +/** + * Map-case unit tests for {@link Db2SessionExtension}. All JDBC interactions are mocked — no real + * DB2 connection (R-14). The three-level fallback contract from design.md §2.3 / §7.2 / §11.1 R-03 + * is the spec under test. + * + * @author actiontech-zihan + * @since 4.3.4 + */ +public class Db2SessionExtensionTest { + + private final Db2SessionExtension extension = new Db2SessionExtension(); + + // ---------- getConnectionId 三级降级 ---------- + + @Test + public void getConnectionId_level1_applicationId() throws SQLException { + Connection connection = mock(Connection.class); + Statement statement = mock(Statement.class); + ResultSet rs = mock(ResultSet.class); + when(connection.createStatement()).thenReturn(statement); + when(statement.executeQuery("VALUES APPLICATION_ID()")).thenReturn(rs); + when(rs.next()).thenReturn(true); + when(rs.getString(1)).thenReturn("*LOCAL.DB2.230101000000"); + + Assert.assertEquals("*LOCAL.DB2.230101000000", extension.getConnectionId(connection)); + } + + @Test + public void getConnectionId_level2_applicationHandle_whenLevel1Blank() throws SQLException { + Connection connection = mock(Connection.class); + Statement stmt1 = mock(Statement.class); + Statement stmt2 = mock(Statement.class); + ResultSet rs1 = mock(ResultSet.class); + ResultSet rs2 = mock(ResultSet.class); + when(connection.createStatement()).thenReturn(stmt1, stmt2); + when(stmt1.executeQuery("VALUES APPLICATION_ID()")).thenReturn(rs1); + when(rs1.next()).thenReturn(true); + when(rs1.getString(1)).thenReturn(" "); + when(stmt2.executeQuery(anyString())).thenReturn(rs2); + when(rs2.next()).thenReturn(true); + when(rs2.getString(1)).thenReturn("12345"); + + Assert.assertEquals("12345", extension.getConnectionId(connection)); + } + + @Test + public void getConnectionId_level2_applicationHandle_whenLevel1Throws() throws SQLException { + Connection connection = mock(Connection.class); + Statement stmt1 = mock(Statement.class); + Statement stmt2 = mock(Statement.class); + ResultSet rs2 = mock(ResultSet.class); + when(connection.createStatement()).thenReturn(stmt1, stmt2); + when(stmt1.executeQuery("VALUES APPLICATION_ID()")) + .thenThrow(new SQLException("APPLICATION_ID() not supported")); + when(stmt2.executeQuery(anyString())).thenReturn(rs2); + when(rs2.next()).thenReturn(true); + when(rs2.getString(1)).thenReturn("99"); + + Assert.assertEquals("99", extension.getConnectionId(connection)); + } + + @Test + public void getConnectionId_level3_hashCodeFallback_whenBothThrow() throws SQLException { + Connection connection = mock(Connection.class); + Statement stmt1 = mock(Statement.class); + Statement stmt2 = mock(Statement.class); + when(connection.createStatement()).thenReturn(stmt1, stmt2); + when(stmt1.executeQuery(anyString())).thenThrow(new SQLException("L1 down")); + when(stmt2.executeQuery(anyString())).thenThrow(new SQLException("L2 down")); + + String id = extension.getConnectionId(connection); + Assert.assertNotNull("level3 must never return null", id); + Assert.assertFalse("level3 must never return blank", id.trim().isEmpty()); + Assert.assertEquals(Integer.toHexString(connection.hashCode()), id); + } + + @Test + public void getConnectionId_level3_hashCodeFallback_whenBothBlank() throws SQLException { + Connection connection = mock(Connection.class); + Statement stmt1 = mock(Statement.class); + Statement stmt2 = mock(Statement.class); + ResultSet rs1 = mock(ResultSet.class); + ResultSet rs2 = mock(ResultSet.class); + when(connection.createStatement()).thenReturn(stmt1, stmt2); + when(stmt1.executeQuery(anyString())).thenReturn(rs1); + when(rs1.next()).thenReturn(true); + when(rs1.getString(1)).thenReturn(null); + when(stmt2.executeQuery(anyString())).thenReturn(rs2); + when(rs2.next()).thenReturn(true); + when(rs2.getString(1)).thenReturn(""); + + String id = extension.getConnectionId(connection); + Assert.assertNotNull(id); + Assert.assertFalse(id.trim().isEmpty()); + Assert.assertEquals(Integer.toHexString(connection.hashCode()), id); + } + + @Test + public void getConnectionId_nullConnection_returnsNonBlankSentinel() { + String id = extension.getConnectionId(null); + Assert.assertNotNull(id); + Assert.assertFalse(id.isEmpty()); + Assert.assertEquals(Integer.toHexString(0), id); + } + + // ---------- getCurrentSchema ---------- + + @Test + public void getCurrentSchema_trimsAndReturns() throws SQLException { + Connection connection = mock(Connection.class); + Statement statement = mock(Statement.class); + ResultSet rs = mock(ResultSet.class); + when(connection.createStatement()).thenReturn(statement); + when(statement.executeQuery("VALUES CURRENT SCHEMA")).thenReturn(rs); + when(rs.next()).thenReturn(true); + when(rs.getString(1)).thenReturn(" DB2INST1 "); + + Assert.assertEquals("DB2INST1", extension.getCurrentSchema(connection)); + } + + @Test + public void getCurrentSchema_emptyResultSet_returnsNull() throws SQLException { + Connection connection = mock(Connection.class); + Statement statement = mock(Statement.class); + ResultSet rs = mock(ResultSet.class); + when(connection.createStatement()).thenReturn(statement); + when(statement.executeQuery("VALUES CURRENT SCHEMA")).thenReturn(rs); + when(rs.next()).thenReturn(false); + + Assert.assertNull(extension.getCurrentSchema(connection)); + } + + @Test + public void getCurrentSchema_sqlException_returnsNull() throws SQLException { + Connection connection = mock(Connection.class); + when(connection.createStatement()).thenThrow(new SQLException("boom")); + Assert.assertNull(extension.getCurrentSchema(connection)); + } + + // ---------- getKillSessionSql / getKillQuerySql ---------- + + @Test + public void getKillSessionSql_exactTemplate() { + Assert.assertEquals( + "CALL SYSPROC.ADMIN_CMD('FORCE APPLICATION (123)')", + extension.getKillSessionSql("123")); + Assert.assertEquals( + "CALL SYSPROC.ADMIN_CMD('FORCE APPLICATION (*LOCAL.DB2.230101000000)')", + extension.getKillSessionSql("*LOCAL.DB2.230101000000")); + } + + @Test + public void getKillQuerySql_delegatesToKillSessionSql() { + Assert.assertEquals( + extension.getKillSessionSql("42"), + extension.getKillQuerySql("42")); + } + + // ---------- setClientInfo ---------- + + @Test + public void setClientInfo_returnsFalse() { + Assert.assertFalse(extension.setClientInfo(null, null)); + } +} diff --git a/server/plugins/pom.xml b/server/plugins/pom.xml index 120460a242..6b30143c26 100644 --- a/server/plugins/pom.xml +++ b/server/plugins/pom.xml @@ -23,6 +23,7 @@ connect-plugin-postgres connect-plugin-sqlserver connect-plugin-dm + connect-plugin-db2 schema-plugin-api schema-plugin-ob-mysql schema-plugin-ob-oracle @@ -33,6 +34,7 @@ schema-plugin-postgres schema-plugin-sqlserver schema-plugin-dm + schema-plugin-db2 schema-plugin-odp-sharding-ob-mysql task-plugin-api task-plugin-mysql @@ -198,6 +200,18 @@ ${project.version} provided + + com.oceanbase + connect-plugin-db2 + ${project.version} + provided + + + com.oceanbase + schema-plugin-db2 + ${project.version} + provided + diff --git a/server/plugins/schema-plugin-db2/pom.xml b/server/plugins/schema-plugin-db2/pom.xml new file mode 100644 index 0000000000..d531689057 --- /dev/null +++ b/server/plugins/schema-plugin-db2/pom.xml @@ -0,0 +1,61 @@ + + + + + 4.0.0 + + plugin-parent + com.oceanbase + 4.3.4-SNAPSHOT + ../pom.xml + + + schema-plugin-db2 + + + ${project.parent.parent.basedir} + com.oceanbase.odc.plugin.schema.db2.Db2SchemaPlugin + schema-plugin-ob-mysql + + + + + com.oceanbase + connect-plugin-db2 + + + com.oceanbase + schema-plugin-api + + + com.oceanbase + schema-plugin-ob-mysql + + + + + + + org.apache.maven.plugins + maven-assembly-plugin + + + + + diff --git a/server/plugins/schema-plugin-db2/src/main/assembly/assembly.xml b/server/plugins/schema-plugin-db2/src/main/assembly/assembly.xml new file mode 100644 index 0000000000..3212c1f442 --- /dev/null +++ b/server/plugins/schema-plugin-db2/src/main/assembly/assembly.xml @@ -0,0 +1,31 @@ + + + + + ${project.version} + false + + jar + + + + ${project.build.directory}/classes + / + + + diff --git a/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2DatabaseExtension.java b/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2DatabaseExtension.java new file mode 100644 index 0000000000..393f5e8f0d --- /dev/null +++ b/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2DatabaseExtension.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.odc.plugin.schema.db2; + +import java.sql.Connection; + +import org.pf4j.Extension; + +import com.oceanbase.odc.plugin.schema.db2.utils.DBAccessorUtil; +import com.oceanbase.odc.plugin.schema.obmysql.OBMySQLDatabaseExtension; +import com.oceanbase.tools.dbbrowser.schema.DBSchemaAccessor; + +@Extension +public class Db2DatabaseExtension extends OBMySQLDatabaseExtension { + + @Override + protected DBSchemaAccessor getSchemaAccessor(Connection connection) { + return DBAccessorUtil.getSchemaAccessor(connection); + } + +} diff --git a/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2SchemaPlugin.java b/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2SchemaPlugin.java new file mode 100644 index 0000000000..b99473d02e --- /dev/null +++ b/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2SchemaPlugin.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.odc.plugin.schema.db2; + +import com.oceanbase.odc.core.shared.constant.DialectType; +import com.oceanbase.odc.plugin.schema.api.BaseSchemaPlugin; + +/** + * pf4j entry point for the DB2 schema plugin. Routes schema/table metadata extensions through + * sibling {@code @Extension} classes. + * + * @author actiontech-zihan + * @since 4.3.4 + */ +public class Db2SchemaPlugin extends BaseSchemaPlugin { + @Override + public DialectType getDialectType() { + return DialectType.DB2; + } +} diff --git a/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2TableExtension.java b/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2TableExtension.java new file mode 100644 index 0000000000..d69fcd4804 --- /dev/null +++ b/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2TableExtension.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.odc.plugin.schema.db2; + +import java.sql.Connection; + +import org.pf4j.Extension; + +import com.oceanbase.odc.plugin.schema.db2.utils.DBAccessorUtil; +import com.oceanbase.odc.plugin.schema.obmysql.OBMySQLTableExtension; +import com.oceanbase.tools.dbbrowser.schema.DBSchemaAccessor; + +@Extension +public class Db2TableExtension extends OBMySQLTableExtension { + + @Override + protected DBSchemaAccessor getSchemaAccessor(Connection connection) { + return DBAccessorUtil.getSchemaAccessor(connection); + } + + @Override + public boolean syncExternalTableFiles(Connection connection, String schemaName, String tableName) { + throw new UnsupportedOperationException("not implemented yet"); + } +} diff --git a/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/utils/DBAccessorUtil.java b/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/utils/DBAccessorUtil.java new file mode 100644 index 0000000000..8d1b42ad25 --- /dev/null +++ b/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/utils/DBAccessorUtil.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.odc.plugin.schema.db2.utils; + +import java.sql.Connection; + +import com.oceanbase.odc.common.util.JdbcOperationsUtil; +import com.oceanbase.odc.core.shared.constant.DialectType; +import com.oceanbase.tools.dbbrowser.DBBrowser; +import com.oceanbase.tools.dbbrowser.schema.DBSchemaAccessor; + +/** + * Schema-accessor entry point for the DB2 schema plugin. Routes through + * {@link DBBrowser#schemaAccessor()} so the call is dispatched via + * {@code AbstractDBBrowserFactory.create(DialectType.DB2.name())} to the commit-B + * {@code buildForDB2()} factory implementation (see {@code docs/spec/design.md} §2.4 — "通过 + * DBBrowserFactory 入口而不是直接 new Db2SchemaAccessor"). + * + * @author actiontech-zihan + * @since 4.3.4 + */ +public class DBAccessorUtil { + + public static DBSchemaAccessor getSchemaAccessor(Connection connection) { + return DBBrowser.schemaAccessor() + .setJdbcOperations(JdbcOperationsUtil.getJdbcOperations(connection)) + .setType(DialectType.DB2.getDBBrowserDialectTypeName()) + .create(); + } + +} From 0dd824cc9a648cef0434116b9a7cc4f9c5d0087f Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Tue, 19 May 2026 16:25:32 +0000 Subject: [PATCH 04/19] fix(odc-plugin-db2): fallback catalogName to defaultSchema for DB2 JDBC URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: Db2ConnectionExtension.generateJdbcUrl required a non-empty catalogName via Validate.notEmpty, but the upstream DMS-EE buildDatasourceBaseInfo contract (compat-RISK-5 D-02) only carries the DB2 database name via the defaultSchema field of ConnectionConfig — not via catalogName. This caused every DB2 datasource sync to fail with "catalog name can not be null", leaving status=INACTIVE and blocking all schema / table / view / column metadata reads (batch-3 cases 2.1-2.4 plus batch-4+ DB2 cases). Fix (selected option A of A/B/C tradeoff, see fix-001.md): - generateJdbcUrl: when catalogName is blank, fall back to defaultSchema (DB2 catalog = database, defaultSchema carries the DB name end-to-end from DMS-EE → odc-service → plugin) - when the resolved catalog equals defaultSchema, omit the redundant ":currentSchema=" segment (DB2 driver defaults to user.toUpperCase(), which is exactly what ConnectionConfig.getDefaultSchema returns for DB2 per B-20) - improve the fail-fast error message to mention both catalogName and defaultSchema so future regressions surface the actual missing piece - Db2ConnectionExtensionTest: add 4 regression cases (null fallback / empty fallback / catalog==schema case-insensitive / explicit catalog+distinct schema) and split the existing blank-catalog throws case into NPE-for-null and IAE-for-empty to keep Validate.notEmpty semantics explicit Test: cd server/plugins/connect-plugin-db2 && mvn test → 18 PASS / 0 FAIL. Refs: actiontech/dms-ee#839 --- .../connect/db2/Db2ConnectionExtension.java | 40 ++++++++++++++++--- .../db2/Db2ConnectionExtensionTest.java | 29 +++++++++++++- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2ConnectionExtension.java b/server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2ConnectionExtension.java index d4149ea7d3..83296f7170 100644 --- a/server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2ConnectionExtension.java +++ b/server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2ConnectionExtension.java @@ -64,9 +64,28 @@ public class Db2ConnectionExtension extends OBMySQLConnectionExtension { * end with {@code ;}; otherwise the driver reports {@code errorcode -4461}. * *

- * If {@link JdbcUrlProperty#getDefaultSchema()} is blank, we fall back to - * {@code user.toUpperCase()} (DB2 implicit schema convention) to align with B-20 default schema - * policy in {@link com.oceanbase.odc.service.connection.model.ConnectionConfig#getDefaultSchema}. + * Catalog (database) vs schema in DB2: + *

    + *
  • {@code } in the JDBC URL maps to the DB2 catalog/database name (e.g. + * {@code testdb}); + *
  • {@code currentSchema=} maps to the in-database schema (e.g. {@code DB2INST1}). + *
+ * + *

+ * Upstream (DMS-EE buildDatasourceBaseInfo, compat-RISK-5 D-02) only carries the DB2 database name + * via the {@code defaultSchema} field of + * {@link com.oceanbase.odc.service.connection.model.ConnectionConfig} when the user does not also + * fill a separate {@code catalogName}. To keep that contract working without forcing a CE/EE schema + * change to the create-datasource request body, we fall back to + * {@link JdbcUrlProperty#getDefaultSchema()} when {@link JdbcUrlProperty#getCatalogName()} is + * blank. + * + *

+ * When the resolved catalog and the {@code defaultSchema} reference the same string we omit the + * {@code currentSchema=} segment entirely; DB2 then defaults the schema to + * {@code user.toUpperCase()} (the DB2 implicit-schema convention) via the JDBC driver, which is + * exactly what {@link com.oceanbase.odc.service.connection.model.ConnectionConfig#getDefaultSchema} + * resolves to for DB2 (B-20). */ @Override public String generateJdbcUrl(@NonNull JdbcUrlProperty properties) { @@ -75,13 +94,22 @@ public String generateJdbcUrl(@NonNull JdbcUrlProperty properties) { Integer port = properties.getPort(); Validate.notNull(port, "port can not be null"); String catalogName = properties.getCatalogName(); - Validate.notEmpty(catalogName, "catalog name can not be null"); + String schema = properties.getDefaultSchema(); + // Fallback chain: when an explicit catalog/database name is not provided by the caller, + // treat the defaultSchema field as the DB2 database name. This is the contract DMS-EE + // currently relies on (CreateDatasourceRequest carries only defaultSchema, not catalogName). + if (StringUtils.isEmpty(catalogName)) { + catalogName = schema; + } + Validate.notEmpty(catalogName, + "DB2 catalog (database name) can not be null; expected non-empty catalogName or defaultSchema"); StringBuilder jdbcUrl = new StringBuilder(); jdbcUrl.append("jdbc:db2://").append(host).append(":").append(port).append("/").append(catalogName); - String schema = properties.getDefaultSchema(); - if (StringUtils.isNotBlank(schema)) { + if (StringUtils.isNotBlank(schema) && !schema.equalsIgnoreCase(catalogName)) { + // schema explicitly differs from the catalog (or the caller really meant a schema + // override); honour it. DB2 driver requires the property segment to end with ';'. jdbcUrl.append(":currentSchema=").append(schema.toUpperCase()).append(";"); } return jdbcUrl.toString(); diff --git a/server/plugins/connect-plugin-db2/src/test/java/com/oceanbase/odc/plugin/connect/db2/Db2ConnectionExtensionTest.java b/server/plugins/connect-plugin-db2/src/test/java/com/oceanbase/odc/plugin/connect/db2/Db2ConnectionExtensionTest.java index 28ba8a364b..75f1d7d621 100644 --- a/server/plugins/connect-plugin-db2/src/test/java/com/oceanbase/odc/plugin/connect/db2/Db2ConnectionExtensionTest.java +++ b/server/plugins/connect-plugin-db2/src/test/java/com/oceanbase/odc/plugin/connect/db2/Db2ConnectionExtensionTest.java @@ -62,6 +62,21 @@ public void generateJdbcUrl_mapCases() { cases.put("blank schema → URL has no currentSchema segment", new Case("h", 50000, "testdb", " ", "jdbc:db2://h:50000/testdb")); + // Regression for fix-F (catalogName fallback to defaultSchema). Upstream DMS-EE + // currently only carries the DB2 database name via the defaultSchema field of + // CreateDatasourceRequest; the plugin must fall back rather than fail-fast. + cases.put("null catalog falls back to defaultSchema (DMS-EE contract)", + new Case("h", 50000, null, "testdb", + "jdbc:db2://h:50000/testdb")); + cases.put("empty catalog falls back to defaultSchema", + new Case("h", 50000, "", "testdb", + "jdbc:db2://h:50000/testdb")); + cases.put("catalog equals schema (case-insensitive) → no currentSchema segment", + new Case("h", 50000, "TESTDB", "testdb", + "jdbc:db2://h:50000/TESTDB")); + cases.put("explicit catalog + distinct schema both honoured", + new Case("h", 50000, "PROD_DB", "ALICE", + "jdbc:db2://h:50000/PROD_DB:currentSchema=ALICE;")); for (Map.Entry entry : cases.entrySet()) { Case c = entry.getValue(); @@ -72,10 +87,20 @@ public void generateJdbcUrl_mapCases() { } } + @Test(expected = NullPointerException.class) + public void generateJdbcUrl_bothCatalogAndSchemaNull_throws() { + // After fix-F: catalogName fallback only succeeds when defaultSchema is non-empty. + // Apache Commons Lang3 Validate.notEmpty(String) throws NPE for null and IAE for empty, + // both with the descriptive "DB2 catalog (database name) can not be null" message. + JdbcUrlProperty property = + new JdbcUrlProperty("h", 50000, null, null, null, null, null); + EXTENSION.generateJdbcUrl(property); + } + @Test(expected = IllegalArgumentException.class) - public void generateJdbcUrl_blankCatalog_throws() { + public void generateJdbcUrl_bothCatalogAndSchemaEmpty_throws() { JdbcUrlProperty property = - new JdbcUrlProperty("h", 50000, "DB2INST1", null, null, null, ""); + new JdbcUrlProperty("h", 50000, "", null, null, null, ""); EXTENSION.generateJdbcUrl(property); } From 9a98641919e79cb552a9f3b82f72a6676054fc66 Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Tue, 19 May 2026 16:26:01 +0000 Subject: [PATCH 05/19] fix(odc-service): promote DB2 defaultSchema to catalogName so plugin fallback has a value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (companion to fix(odc-plugin-db2) above): even with the plugin layer fallback (catalogName ← defaultSchema), the actual value never reaches the plugin because ConnectionService.create / update has a generic branch: if (!connection.getType().isDefaultSchemaRequired()) { connection.setDefaultSchema(null); } For DB2 isDefaultSchemaRequired() is false (DB2 catalog ≠ schema in the ODC model), so the DMS-EE-supplied database name carried via defaultSchema gets silently nulled before JDBC URL build time. With both fields blank the plugin-layer fallback has nothing to fall back to and we get the original "catalog name can not be null" again. Fix: - ConnectionService.adaptDb2DatabaseToCatalog(): runs in both create() and update() right after environmentAdapter / connectionSSLAdaptor and BEFORE the isDefaultSchemaRequired branch. For DB2 only, if catalogName is blank and a raw defaultSchema was provided by the caller, copy that raw value into catalogName. Explicitly never overwrites an explicit catalogName. - ConnectionService.clearDb2DefaultSchemaOnEntity(): after modelToEntity() the ConnectionConfig.getDefaultSchema() DB2 fallback would have synthesised user.toUpperCase() into the entity; that's fine for JDBC URL build but would persist a misleading value in the t_connection.default_schema column. Wipe it on the entity right before saveAndFlush so persistence stays honest (raw null) while runtime resolution keeps using getDefaultSchema(). - ConnectionConfig.getRawDefaultSchema(): @JsonIgnore helper that returns the persisted field as-is, bypassing the dialect-specific resolution in getDefaultSchema(). Required by adaptDb2DatabaseToCatalog so we can tell "the user provided no schema" from "fallback synthesised user.toUpperCase()". Together with the plugin-layer fallback this delivers the compat-RISK-7 decision B (compatibility shim): DMS-EE buildDatasourceBaseInfo contract stays unchanged, the DB2 catalog/database name flows defaultSchema → catalogName → JDBC URL transparently, and explicit catalogName overrides keep working. Refs: actiontech/dms-ee#839 --- .../service/connection/ConnectionService.java | 65 +++++++++++++++++++ .../connection/model/ConnectionConfig.java | 10 +++ 2 files changed, 75 insertions(+) diff --git a/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/ConnectionService.java b/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/ConnectionService.java index 6ee4d1e106..7cb6f69f91 100644 --- a/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/ConnectionService.java +++ b/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/ConnectionService.java @@ -72,6 +72,7 @@ import com.oceanbase.odc.core.shared.Verify; import com.oceanbase.odc.core.shared.constant.ConnectionStatus; import com.oceanbase.odc.core.shared.constant.ConnectionVisibleScope; +import com.oceanbase.odc.core.shared.constant.DialectType; import com.oceanbase.odc.core.shared.constant.ErrorCodes; import com.oceanbase.odc.core.shared.constant.OrganizationType; import com.oceanbase.odc.core.shared.constant.PermissionType; @@ -297,6 +298,7 @@ public ConnectionConfig innerCreate(@NotNull @Valid ConnectionConfig connection, try { environmentAdapter.adaptConfig(connection); connectionSSLAdaptor.adapt(connection); + adaptDb2DatabaseToCatalog(connection); if (!connection.getType().isDefaultSchemaRequired()) { connection.setDefaultSchema(null); } @@ -326,6 +328,7 @@ public ConnectionConfig innerCreate(@NotNull @Valid ConnectionConfig connection, } connectionEncryption.encryptPasswords(connection); ConnectionEntity entity = modelToEntity(connection); + clearDb2DefaultSchemaOnEntity(entity); ConnectionEntity savedEntity = repository.saveAndFlush(entity); ConnectionConfig config = entityToModel(savedEntity, true, true); config.setAttributes(connection.getAttributes()); @@ -342,6 +345,22 @@ public ConnectionConfig innerCreate(@NotNull @Valid ConnectionConfig connection, return created; } + /** + * For DB2 entities {@code modelToEntity} calls {@link ConnectionConfig#getDefaultSchema()} whose + * DB2 fallback synthesises {@code user.toUpperCase()} when the raw field is null. That synthesised + * value is consumed at JDBC-URL build time but should never be persisted as the "database name" of + * the data source — the database name is the catalog, already preserved by + * {@link #adaptDb2DatabaseToCatalog} into {@code catalogName}. Wipe the column here so + * {@code OBConsoleDataSourceFactory.getDefaultSchema(connectionConfig)} re-derives the schema each + * time from the user rather than from a misleading persisted value. + */ + private static void clearDb2DefaultSchemaOnEntity(ConnectionEntity entity) { + if (entity == null || entity.getDialectType() != DialectType.DB2) { + return; + } + entity.setDefaultSchema(null); + } + @Transactional(rollbackFor = Exception.class) @PreAuthenticate(actions = "delete", resourceType = "ODC_CONNECTION", indexOfIdParam = 0) public ConnectionConfig delete(@NotNull Long id) { @@ -501,6 +520,50 @@ public Map getStatus(@NonNull Set ids) { } } + /** + * Promote the DB2 database name carried via {@code defaultSchema} into the {@code catalogName} + * field before {@link com.oceanbase.odc.core.shared.constant.ConnectType#isDefaultSchemaRequired()} + * clears {@code defaultSchema} for non-sharding dialects. + * + *

+ * DMS-EE (compat-RISK-5 D-02) carries the DB2 catalog/database name via the {@code defaultSchema} + * field of {@link com.oceanbase.odc.service.connection.ConnectionTestService} create-datasource + * request body because the {@code CreateDatasourceRequest} schema does not yet expose + * {@code catalogName}. Without this adapter the value would be silently dropped by + * {@code if (!connection.getType().isDefaultSchemaRequired()) connection.setDefaultSchema(null);}, + * leaving downstream JDBC URL construction with neither catalog nor database (see + * Db2ConnectionExtension.generateJdbcUrl). The plugin-layer fallback (catalogName→defaultSchema) + * has no value to fall back to in that case. + * + *

+ * Semantics: + *

    + *
  • only acts on {@link DialectType#DB2}; + *
  • only fills {@code catalogName} when it is currently blank, never overwrites an explicitly + * provided catalog; + *
  • preserves {@code defaultSchema} as-is so explicit schema overrides still work — the + * downstream {@code isDefaultSchemaRequired} branch clears it as before. + *
+ */ + static void adaptDb2DatabaseToCatalog(ConnectionConfig connection) { + if (connection == null) { + return; + } + if (connection.getDialectType() != DialectType.DB2) { + return; + } + if (StringUtils.isNotBlank(connection.getCatalogName())) { + return; + } + // Read the raw field rather than the resolved getter to distinguish "the caller set a + // database name" from "the dialect-specific fallback would synthesise user.toUpperCase()". + String rawDefaultSchema = connection.getRawDefaultSchema(); + if (StringUtils.isBlank(rawDefaultSchema)) { + return; + } + connection.setCatalogName(rawDefaultSchema); + } + private Map getIndividualSpaceStatus(Set ids, Map connMap) { Map connId2State = new HashMap<>(); for (Long connId : ids) { @@ -642,6 +705,7 @@ private ConnectionConfig updateConnectionConfig(Long id, ConnectionConfig connec try { environmentAdapter.adaptConfig(connection); connectionSSLAdaptor.adapt(connection); + adaptDb2DatabaseToCatalog(connection); ConnectionConfig saved = internalGet(id); connectionValidator.validateForUpdate(connection, saved); if (needCheckPermission) { @@ -679,6 +743,7 @@ private ConnectionConfig updateConnectionConfig(Long id, ConnectionConfig connec connection.fillEncryptedPasswordFromSavedIfNull(saved); ConnectionEntity entity = modelToEntity(connection); + clearDb2DefaultSchemaOnEntity(entity); ConnectionEntity savedEntity = repository.saveAndFlush(entity); // for workaround createTime/updateTime not refresh in server mode, diff --git a/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/model/ConnectionConfig.java b/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/model/ConnectionConfig.java index 2b881dd3e2..3fc893cdb6 100644 --- a/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/model/ConnectionConfig.java +++ b/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/model/ConnectionConfig.java @@ -365,6 +365,16 @@ public DialectType getDialectType() { return Objects.nonNull(this.type) ? this.type.getDialectType() : DialectType.UNKNOWN; } + /** + * Returns the persisted {@code defaultSchema} field as-is, bypassing the dialect-specific + * resolution applied by {@link #getDefaultSchema()}. Useful for upstream adapters that need to tell + * apart "the user provided no schema" from "fallback resolution returned user.toUpperCase()". + */ + @JsonIgnore + public String getRawDefaultSchema() { + return this.defaultSchema; + } + public String getDefaultSchema() { DialectType dialectType = getDialectType(); if (dialectType == null) { From 1f6d285fd7e598135dd53362cadd82bc9c66b880 Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Tue, 19 May 2026 16:36:28 +0000 Subject: [PATCH 06/19] test(odc-service): mock-only unit tests for DB2 datasource adapter (fix-F) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ConnectionServiceDb2AdapterTest covering the new adaptDb2DatabaseToCatalog helper added in the previous commit: - DB2 + raw defaultSchema=testdb → catalogName=testdb (DMS-EE D-02 contract) - DB2 + explicit catalogName is preserved (no overwrite) - MYSQL + defaultSchema=testdb is a no-op (adapter only acts on DB2) - DB2 + blank defaultSchema is a no-op (no false positives) - null connection guard - getRawDefaultSchema vs getDefaultSchema: raw stays null while the DB2 dialect-fallback synthesises user.toUpperCase() Pure POJO checks; no JDBC, no Spring context. Six assertions cover the adapter's invariants required by case 2.1 / 2.2 / 2.3 retest. Refs: actiontech/dms-ee#839 --- .../ConnectionServiceDb2AdapterTest.java | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 server/odc-service/src/test/java/com/oceanbase/odc/service/connection/ConnectionServiceDb2AdapterTest.java diff --git a/server/odc-service/src/test/java/com/oceanbase/odc/service/connection/ConnectionServiceDb2AdapterTest.java b/server/odc-service/src/test/java/com/oceanbase/odc/service/connection/ConnectionServiceDb2AdapterTest.java new file mode 100644 index 0000000000..bd06a17eda --- /dev/null +++ b/server/odc-service/src/test/java/com/oceanbase/odc/service/connection/ConnectionServiceDb2AdapterTest.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.odc.service.connection; + +import org.junit.Assert; +import org.junit.Test; + +import com.oceanbase.odc.core.shared.constant.ConnectType; +import com.oceanbase.odc.service.connection.model.ConnectionConfig; + +/** + * Mock-only unit tests for the DB2 datasource adapter introduced by fix-F. Verifies that the DMS-EE + * convention "carry the DB2 database name via {@code defaultSchema}" is normalised into + * {@code catalogName} before persistence, and that the adapter is a no-op for other dialects / + * already-populated rows. No real JDBC, no Spring context — pure POJO checks. + * + * @author actiontech-zihan + * @since 4.3.4 + */ +public class ConnectionServiceDb2AdapterTest { + + @Test + public void adapt_movesDefaultSchemaToCatalog_forDb2() { + ConnectionConfig config = new ConnectionConfig(); + config.setType(ConnectType.DB2); + config.setUsername("db2inst1"); + config.setDefaultSchema("testdb"); + + ConnectionService.adaptDb2DatabaseToCatalog(config); + + Assert.assertEquals("testdb", config.getCatalogName()); + // Raw field should still hold the original input; the downstream + // isDefaultSchemaRequired() block in innerCreate/updateConnectionConfig clears it. + Assert.assertEquals("testdb", config.getRawDefaultSchema()); + } + + @Test + public void adapt_preservesExplicitCatalog_forDb2() { + ConnectionConfig config = new ConnectionConfig(); + config.setType(ConnectType.DB2); + config.setUsername("db2inst1"); + config.setDefaultSchema("testdb"); + config.setCatalogName("EXPLICIT_DB"); + + ConnectionService.adaptDb2DatabaseToCatalog(config); + + Assert.assertEquals("EXPLICIT_DB", config.getCatalogName()); + } + + @Test + public void adapt_isNoop_forMysql() { + ConnectionConfig config = new ConnectionConfig(); + config.setType(ConnectType.MYSQL); + config.setUsername("root"); + config.setDefaultSchema("testdb"); + + ConnectionService.adaptDb2DatabaseToCatalog(config); + + Assert.assertNull(config.getCatalogName()); + Assert.assertEquals("testdb", config.getRawDefaultSchema()); + } + + @Test + public void adapt_isNoop_whenDefaultSchemaBlank() { + ConnectionConfig config = new ConnectionConfig(); + config.setType(ConnectType.DB2); + config.setUsername("db2inst1"); + config.setDefaultSchema(null); + + ConnectionService.adaptDb2DatabaseToCatalog(config); + + Assert.assertNull(config.getCatalogName()); + } + + @Test + public void adapt_isNoop_forNullConfig() { + // exercise the null-guard branch; tolerates being called from a guarded caller + ConnectionService.adaptDb2DatabaseToCatalog(null); + } + + @Test + public void rawDefaultSchema_returnsRawField_evenForDb2() { + ConnectionConfig config = new ConnectionConfig(); + config.setType(ConnectType.DB2); + config.setUsername("db2inst1"); + // raw is null; getDefaultSchema() falls back to user.toUpperCase() = "DB2INST1", + // but the raw getter must return null so the adapter can tell apart the two cases. + Assert.assertNull(config.getRawDefaultSchema()); + Assert.assertEquals("DB2INST1", config.getDefaultSchema()); + } +} From e27f0ca0dac0063819298a02f2230ba1d3b29acf Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Tue, 19 May 2026 17:14:22 +0000 Subject: [PATCH 07/19] fix(odc-plugin-db2): normalize DB2 internal version code so VersionUtils stops throwing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug A in fix-G: the IBM Data Server Driver for JDBC returns the DB2 product version as the IBM internal version code (for v11.5.8.0: "SQL110580") rather than as a dotted decimal. ODC core consumes the return value via VersionUtils.compareVersions which calls Integer.parseInt on every dot-separated segment, so passing the raw "SQL110580" raises NumberFormatException and propagates HTTP 400 out of POST /api/v2/datasource/databases/{id}/sessions (createSessionByDatabase). With session creation broken, the entire DB2 workbench downstream stack (resource-tree table/view/index/constraint sub-nodes, SQL Console execution, "数据研发" submenu, batch-3 cases 2.2 / 2.3 / 2.4 / 3.x / 4.x / 5.x and 6.x) is blocked. This change keeps the fix inside the DB2 plugin (no impact to other dialects) and normalizes whatever the driver returns into a dotted decimal that VersionUtils can parse: 1. Already-dotted decimals pass through unchanged. 2. Free-form text containing a dotted decimal (e.g. the "DB2 v11.5.9.0" returned by SYSIBMADM.ENV_INST_INFO.SERVICE_LEVEL) gets the dotted run extracted. 3. IBM internal codes "SQL" + 6/7/8 digits get positionally decoded into V.R.M.F (e.g. SQL110580 → 11.5.8.0). 4. Anything unrecognized falls back to the sentinel "0.0.0" so the caller still gets a parseable string and a WARN is logged. Mock-only unit tests pin the bug A repro ("SQL110580" → "11.5.8.0"), exercise the map of formats the IBM driver is observed to emit, and defensively round-trip every output through VersionUtils.compareVersions to prove no input can re-introduce a throw. Refs: actiontech/dms-ee#839 --- .../connect/db2/Db2InformationExtension.java | 102 +++++++++++++++- .../db2/Db2InformationExtensionTest.java | 112 ++++++++++++++++++ 2 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 server/plugins/connect-plugin-db2/src/test/java/com/oceanbase/odc/plugin/connect/db2/Db2InformationExtensionTest.java diff --git a/server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2InformationExtension.java b/server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2InformationExtension.java index 0f2fb887df..e3d5148113 100644 --- a/server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2InformationExtension.java +++ b/server/plugins/connect-plugin-db2/src/main/java/com/oceanbase/odc/plugin/connect/db2/Db2InformationExtension.java @@ -17,6 +17,8 @@ import java.sql.Connection; import java.sql.SQLException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.pf4j.Extension; @@ -28,9 +30,31 @@ /** * DB2 information extension. Returns the database product version via JDBC metadata (design.md - * §2.3). For finer fixpack-level info we would query - * {@code SELECT SERVICE_LEVEL FROM SYSIBMADM.ENV_INST_INFO}; that is out of scope for this - * iteration. + * §2.3). + * + *

+ * Important: IBM Data Server Driver for JDBC returns the product version as the IBM internal + * version code (for example {@code "SQL110580"} for DB2 v11.5.8.0) instead of a dotted decimal + * string. ODC core consumes the returned value via + * {@code com.oceanbase.odc.common.util.VersionUtils#compareVersions} which performs + * {@code Integer.parseInt} on every dot-separated segment — passing the raw {@code "SQL110580"} + * triggers {@link NumberFormatException} and blocks {@code POST + * /api/v2/datasource/databases/{id}/sessions} (createSessionByDatabase), cascading through + * table/view/index/constraint metadata loads (bug A in fix-G; see + * {@code docs/dev/fix_reports/fix-G-db2-tree-meta.md}). + * + *

+ * Normalization strategy (in order, first match wins): + *

    + *
  1. Already dotted decimal like {@code "11.5.8.0"} → return as-is (no change).
  2. + *
  3. Free-form text containing a dotted decimal (e.g. {@code "DB2 v11.5.9.0"} returned by + * {@code SYSIBMADM.ENV_INST_INFO.SERVICE_LEVEL}) → extract the first dotted run.
  4. + *
  5. IBM internal code {@code "SQL"} + 6-to-8 digits → decode the trailing digits as + * {@code V.R.M.F} (e.g. {@code "SQL110580"} → {@code "11.5.8.0"}).
  6. + *
  7. Anything else → return a safe sentinel {@code "0.0.0"} so the caller can still run + * {@code compareVersions} without throwing. A warning is logged to surface the unexpected format + * upstream.
  8. + *
* * @author actiontech-zihan * @since 4.3.4 @@ -39,10 +63,35 @@ @Extension public class Db2InformationExtension implements InformationExtensionPoint { + /** + * IBM internal version code regex. The IBM Data Server Driver for JDBC reports the product version + * as the IBM internal version code "SQL" + 6-to-8 digits. Empirical observation on DB2 v11.5.8.0 + * yields {@code "SQL110580"} — 6 digits decoded as V(2)=11, R(2)=05, M(1)=8, F(1)=0. Older / newer + * builds may report 7 or 8 digits if the modification or fixpack levels widen past 9. We accept the + * 6/7/8-digit widths and decode positionally with the last two digits being the fixpack (F). The + * leading SQL prefix is matched case-insensitively to tolerate any future lowercase variants + * emitted by IBM. + */ + private static final Pattern IBM_INTERNAL_CODE = Pattern.compile("(?i)^SQL(\\d{6,8})$"); + + /** + * Free-form dotted decimal extractor. Matches the first run of dotted decimals so we tolerate + * decoration around the version (e.g. {@code "DB2 v11.5.9.0"}, {@code "11.5.9 LUW"}). + */ + private static final Pattern DOTTED_DECIMAL = Pattern.compile("(\\d+(?:\\.\\d+)+)"); + + /** + * Fallback when the driver-reported string cannot be parsed. Lets {@code VersionUtils} run without + * throwing; downstream comparisons that gate features by version will degrade gracefully (treat + * connection as oldest). + */ + static final String UNKNOWN_VERSION = "0.0.0"; + @Override public String getDBVersion(Connection connection) { try { - return connection.getMetaData().getDatabaseProductVersion(); + String raw = connection.getMetaData().getDatabaseProductVersion(); + return normalizeVersion(raw); } catch (SQLException e) { log.warn("DB2 getDBVersion failed: {}", e.getMessage()); throw new BadRequestException(ErrorCodes.QueryDBVersionFailed, @@ -50,4 +99,49 @@ public String getDBVersion(Connection connection) { } } + /** + * Visible-for-test helper that converts whatever the IBM JDBC driver returns into a dotted decimal + * version string consumable by {@code VersionUtils.compareVersions}. See class-level Javadoc for + * the strategy. + */ + static String normalizeVersion(String raw) { + if (raw == null || raw.isEmpty()) { + log.warn("DB2 getDBVersion got null/empty version string, using {}", UNKNOWN_VERSION); + return UNKNOWN_VERSION; + } + String trimmed = raw.trim(); + // Pre-emptive dotted decimal embedded in the string (covers "11.5.8.0", + // "DB2 v11.5.9.0", "11.5.9 LUW", etc.). + Matcher dottedMatcher = DOTTED_DECIMAL.matcher(trimmed); + if (dottedMatcher.find()) { + return dottedMatcher.group(1); + } + // IBM internal code like SQL110580 (v11.5.8.0). The IBM JDBC driver returns this when + // there is no embedded dotted form to extract. Positional decode is anchored from both + // ends: + // V (major) = 2 leading digits + // R (release) = next 2 digits + // F (fixpack) = 1 trailing digit (6-width) or 2 trailing digits (7/8-width) + // M (modification) = remaining middle digits (1 or 2) + // Empirical sample observed in production logs: + // "SQL110580" (6 digits 110580) → V=11 R=05 M=8 F=0 → "11.5.8.0" + // The 7/8-digit forms are accepted for forward-compat against hypothetical future builds + // that bump M or F past 9 (e.g. "SQL10050599" → V=10 R=05 M=05 F=99 → "10.5.5.99"). + Matcher internalMatcher = IBM_INTERNAL_CODE.matcher(trimmed); + if (internalMatcher.matches()) { + String digits = internalMatcher.group(1); + int len = digits.length(); + // For 6-digit codes fixpack is 1 digit; for 7/8-digit codes fixpack is 2 digits. + int fixpackWidth = (len == 6) ? 1 : 2; + int v = Integer.parseInt(digits.substring(0, 2)); + int r = Integer.parseInt(digits.substring(2, 4)); + int m = Integer.parseInt(digits.substring(4, len - fixpackWidth)); + int f = Integer.parseInt(digits.substring(len - fixpackWidth, len)); + return v + "." + r + "." + m + "." + f; + } + log.warn("DB2 getDBVersion returned unrecognized format '{}', falling back to {}", + raw, UNKNOWN_VERSION); + return UNKNOWN_VERSION; + } + } diff --git a/server/plugins/connect-plugin-db2/src/test/java/com/oceanbase/odc/plugin/connect/db2/Db2InformationExtensionTest.java b/server/plugins/connect-plugin-db2/src/test/java/com/oceanbase/odc/plugin/connect/db2/Db2InformationExtensionTest.java new file mode 100644 index 0000000000..a9a54bec14 --- /dev/null +++ b/server/plugins/connect-plugin-db2/src/test/java/com/oceanbase/odc/plugin/connect/db2/Db2InformationExtensionTest.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.odc.plugin.connect.db2; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.Assert; +import org.junit.Test; + +import com.oceanbase.odc.common.util.VersionUtils; + +/** + * Mock-only unit tests for {@link Db2InformationExtension#normalizeVersion(String)} (fix-G bug A). + * + *

+ * Why this exists: the IBM Data Server Driver for JDBC returns the database product version as the + * IBM internal version code (e.g. {@code "SQL110580"} for DB2 v11.5.8.0), not as a dotted decimal. + * ODC core consumes the returned value via + * {@code com.oceanbase.odc.common.util.VersionUtils#compareVersions} which calls + * {@code Integer.parseInt} on each dot-separated segment — passing the raw {@code "SQL110580"} + * through triggers {@link NumberFormatException} during {@code POST + * /api/v2/datasource/databases/{id}/sessions} (createSessionByDatabase) and cascades to block every + * metadata operation on DB2 datasources (bug A in fix-G; see + * {@code docs/dev/fix_reports/fix-G-db2-tree-meta.md}). + * + *

+ * The fix normalises whatever the driver returns into a dotted decimal that + * {@link VersionUtils#compareVersions} can parse. + * + * @author actiontech-zihan + * @since 4.3.4 + */ +public class Db2InformationExtensionTest { + + @Test + public void normalizeVersion_ibmInternalCode_db2_v11_5_8_0_returnsDottedDecimal() { + // Empirical raw value observed in + // .run-odc/logs/odc-server.log at 2026-05-19 16:44 against DB2 v11.5.8.0. + Assert.assertEquals("11.5.8.0", Db2InformationExtension.normalizeVersion("SQL110580")); + } + + @Test + public void normalizeVersion_ibmInternalCode_decodedSegments_compareCorrectly() { + // Round-trip with VersionUtils to prove the bug A repro is fixed end-to-end: + // raw "SQL110580" must no longer throw and must compare correctly to known thresholds. + String normalised = Db2InformationExtension.normalizeVersion("SQL110580"); + Assert.assertTrue(VersionUtils.isGreaterThan(normalised, "11.5.0")); + Assert.assertTrue(VersionUtils.isGreaterThan(normalised, "11.5.7.99")); + Assert.assertTrue(VersionUtils.isLessThan(normalised, "11.5.9")); + } + + @Test + public void normalizeVersion_mapCases() { + Map cases = new LinkedHashMap<>(); + // Already-dotted decimals pass through as-is. + cases.put("11.5.8.0", "11.5.8.0"); + cases.put("11.5.9", "11.5.9"); + // Free-form text containing a dotted decimal — e.g. SYSIBMADM.ENV_INST_INFO.SERVICE_LEVEL + // returns "DB2 v11.5.9.0". + cases.put("DB2 v11.5.9.0", "11.5.9.0"); + cases.put("DSN11015 (z/OS 11.0.15)", "11.0.15"); + // IBM internal version codes — primary fix-G bug A repros. + cases.put("SQL110580", "11.5.8.0"); + cases.put("SQL110570", "11.5.7.0"); + // 8-digit form (covers hypothetical builds that bump M past 9). Sample positional decode: + // V=10, R=05, M=05, F=99 → "10.5.5.99". + cases.put("SQL10050599", "10.5.5.99"); + // Case-insensitive prefix. + cases.put("sql110580", "11.5.8.0"); + // Unrecognised input → safe sentinel (caller will not throw and feature gates degrade). + cases.put("not-a-version", Db2InformationExtension.UNKNOWN_VERSION); + cases.put("", Db2InformationExtension.UNKNOWN_VERSION); + + for (Map.Entry entry : cases.entrySet()) { + Assert.assertEquals("input=" + entry.getKey(), entry.getValue(), + Db2InformationExtension.normalizeVersion(entry.getKey())); + } + } + + @Test + public void normalizeVersion_nullInput_returnsSentinel() { + Assert.assertEquals(Db2InformationExtension.UNKNOWN_VERSION, + Db2InformationExtension.normalizeVersion(null)); + } + + @Test + public void normalizeVersion_resultIsAlwaysVersionUtilsCompatible() { + // Defense-in-depth: every output, including the sentinel, must parse via VersionUtils + // without throwing. This is the contract bug A regressed. + String[] rawInputs = + {"SQL110580", "SQL110570", "11.5.8.0", "DB2 v11.5.9.0", "not-a-version", "", null}; + for (String raw : rawInputs) { + String normalised = Db2InformationExtension.normalizeVersion(raw); + // Should not throw. + VersionUtils.compareVersions(normalised, "0.0.0"); + } + } +} From 92ba7f2fc618941ff375efc889ce2ebbd1cb1c12 Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Tue, 19 May 2026 17:14:37 +0000 Subject: [PATCH 08/19] fix(db-browser): filter DB2 system schemas by SCHEMANAME so NULLID/SYSTOOLS/SQLJ are hidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug C in fix-G: design.md §6 prescribes an 11-entry system-schema blacklist for DB2 (SYSCAT / SYSIBM / SYSIBMADM / SYSIBMINTERNAL / SYSIBMTS / SYSFUN / SYSPROC / SYSSTAT / SYSTOOLS / SYSPUBLIC / NULLID). The original implementation in Db2SchemaAccessor.showDatabases filtered SYSCAT.SCHEMATA by DEFINER NOT IN(...), but on DB2 11.5 several system schemas (NULLID, SQLJ, SYSTOOLS) are created by the instance owner (e.g. db2inst1) and therefore have DEFINER = db2inst1, not SYSIBM. The DEFINER predicate let those rows slip into the user schema list — see the JDBC truth table in docs/test/screenshots/case-2-1-round2-schema-definer.txt and the batch-3 round-2 failure analysis in docs/test/case-2-1.md. The fix switches the filter dimension to SCHEMANAME, which is what the design intended, and adds SQLJ to the blacklist (DB2 JDBC stored procedures schema, meaningless to end users; previously slipped through the DEFINER filter too). A new mock-only test (showDatabases_sqlFiltersBySchemaNameWithFullBlacklist) captures the SQL string with ArgumentCaptor and pins both shape ("SCHEMANAME NOT IN", not "DEFINER NOT IN") and the full 12-entry blacklist so a future refactor can't silently regress to the original DEFINER-based filtering. Refs: actiontech/dms-ee#839 --- .../schema/db2/Db2SchemaAccessor.java | 13 +++++-- .../schema/db2/Db2SchemaAccessorTest.java | 35 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java index a0fd309191..c2efd20fda 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java @@ -78,9 +78,18 @@ public Db2SchemaAccessor(@NonNull JdbcOperations jdbcOperations) { @Override public List showDatabases() { + // fix-G bug C: design.md §6 prescribes an 11-entry system-schema blacklist + // (SYSCAT / SYSIBM / SYSIBMADM / SYSIBMINTERNAL / SYSIBMTS / SYSFUN / SYSPROC / + // SYSSTAT / SYSTOOLS / SYSPUBLIC / NULLID). The original implementation filtered by + // SYSCAT.SCHEMATA.DEFINER, but on DB2 11.5 several system schemas (NULLID, SQLJ, + // SYSTOOLS) are *created* by the instance owner (e.g. db2inst1) rather than SYSIBM, + // so DEFINER NOT IN(...) lets them slip through into the user schema list. The blacklist + // must therefore match SCHEMANAME directly. SQLJ is added to the list (DB2 JDBC stored + // procedures schema, meaningless to end users; see batch-3 round-2 evidence in + // case-2-1.md). String sql = "SELECT TRIM(SCHEMANAME) AS SCHEMA_NAME FROM SYSCAT.SCHEMATA " - + "WHERE DEFINER NOT IN ('SYSIBM','SYSCAT','SYSIBMADM','SYSIBMINTERNAL'," - + "'SYSIBMTS','SYSFUN','SYSPROC','SYSSTAT','SYSTOOLS','SYSPUBLIC','NULLID') " + + "WHERE SCHEMANAME NOT IN ('SYSIBM','SYSCAT','SYSIBMADM','SYSIBMINTERNAL'," + + "'SYSIBMTS','SYSFUN','SYSPROC','SYSSTAT','SYSTOOLS','SYSPUBLIC','NULLID','SQLJ') " + "ORDER BY SCHEMANAME"; return jdbcOperations.queryForList(sql, String.class); } diff --git a/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java b/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java index 9ee2d8e207..9c1c2822c3 100644 --- a/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java +++ b/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java @@ -19,6 +19,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.sql.ResultSet; @@ -32,6 +33,7 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.mockito.ArgumentCaptor; import org.springframework.jdbc.core.JdbcOperations; import org.springframework.jdbc.core.RowMapper; @@ -83,6 +85,39 @@ public void showDatabases_filtersSystemSchemas() { Assert.assertTrue(result.contains("MY_APP")); } + /** + * fix-G bug C regression: design.md §6 mandates filtering by SCHEMANAME (not DEFINER) and lists 12 + * system schemas (the original 11 plus SQLJ). Before fix-G the SQL filtered by + * {@code DEFINER NOT IN ('SYSIBM','SYSCAT',...)} which let NULLID / SYSTOOLS / SQLJ leak into the + * user tree because their DEFINER is the instance owner (e.g. db2inst1), not SYSIBM. The SQL is the + * single source of truth for this filter — this test pins the expected predicate shape so anyone + * refactoring the accessor can't silently regress to DEFINER-based filtering. + */ + @Test + public void showDatabases_sqlFiltersBySchemaNameWithFullBlacklist() { + when(jdbcOperations.queryForList(anyString(), eq(String.class))) + .thenReturn(Arrays.asList("DB2INST1")); + + accessor.showDatabases(); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(jdbcOperations).queryForList(sqlCaptor.capture(), eq(String.class)); + String sql = sqlCaptor.getValue(); + + // Filter dimension must be SCHEMANAME, not DEFINER (the bug C regression). + Assert.assertTrue("SQL should filter by SCHEMANAME, was: " + sql, + sql.contains("SCHEMANAME NOT IN")); + Assert.assertFalse("SQL must not filter by DEFINER (would leak NULLID/SYSTOOLS): " + sql, + sql.contains("DEFINER NOT IN")); + // Every entry in the design.md blacklist must appear in the SQL. + String[] blacklist = {"SYSIBM", "SYSCAT", "SYSIBMADM", "SYSIBMINTERNAL", "SYSIBMTS", + "SYSFUN", "SYSPROC", "SYSSTAT", "SYSTOOLS", "SYSPUBLIC", "NULLID", "SQLJ"}; + for (String name : blacklist) { + Assert.assertTrue("blacklist entry '" + name + "' missing in SQL: " + sql, + sql.contains("'" + name + "'")); + } + } + /** * Case listTables_returnsTableIdentities: 模拟 SYSCAT.TABLES 返回 2 行 TABLE。listTables 内部使用 * rs.getString(1) / rs.getString(2) 列序号读取,故 mock 用列序号方式。 From b9a03fcfebe176335b9434856d6ea5bb64ae9651 Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Tue, 19 May 2026 18:14:43 +0000 Subject: [PATCH 09/19] feat(db-browser): implement Db2SchemaAccessor view/table-detail accessor surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix-H for dms-ee#839 — round-fix-G regression test exposed bug D: ODC `DBTableController#getTable` (5-tab table-detail page) and `DBMetadataController#listIdentities?type=VIEW` (resource-tree view node) collapsed with HTTP 500 because `Db2SchemaAccessor` shipped with 52 methods that still threw `UnsupportedOperationException("Not supported yet")`. Any one of those methods on the aggregator path (`OBMySQLTableExtension.getDetail`) is enough to crash the whole tab. Two-tier implementation, both in the same commit because they sit on one class and share helper state (the 12-entry SYSTEM_SCHEMA_BLACKLIST constant introduced for the view list is also reused by the schema list): A. View surface (case 2.4 — DB2INST1 → "视图" subnode + SQL autocomplete) - listAllUserViews(viewNameLike): SELECT VIEWSCHEMA, VIEWNAME FROM SYSCAT.VIEWS WHERE VIEWSCHEMA NOT IN (<12-entry blacklist>) - listAllSystemViews(viewNameLike): inverse (VIEWSCHEMA IN (...)) - listAllViews(): union of user + system - listViews(schema): per-schema variant for tree expansion - showSystemViews(schema): per-schema list for autocomplete - getView(schema, name): null (controller maps to 404, not 500) B. Table-detail aggregator surface (case 2.3 — TEST_ORDERS detail page) - getDatabase(schema): minimal POJO so OBMySQLTableExtension.getDetail can proceed (DB2 ≈ Oracle/PG where schema is the user-facing DB id) - listDatabases(): full DBDatabase list (was missing wrapper) - listTableColumns(schema, List): batch variant — loops the per-table SYSCAT.COLUMNS query (the existing per-table variant already worked); avoids throwing on every detail-page render - listTableConstraints(schema, table): SYSCAT.TABCONST with P/U/F/K -> DBConstraintType mapping (PRIMARY_KEY/UNIQUE_KEY/FOREIGN_KEY/ CHECK/UNKNOWN) - listTableIndexes(schema, table): SYSCAT.INDEXES with UNIQUERULE P/U -> unique=true, D -> unique=false - switchDatabase / syncExternalTableFiles: void / false (no-op because DB2 schema is fixed at JDBC connect time) C. Safe-degradation surface (remaining unsupported methods) Every other previously-throwing method now returns empty List/Map / null / false. Out-of-scope for this release (design.md §6 explicitly covers only schema/table/view/column/index/constraint): - PL objects: listFunctions/Procedures/Packages/PackageBodies/ Triggers/Types, listSequences/Synonyms — empty list - MView: listMViews / listAllMViewsLike / refreshMVData / listMView{Constraints,RefreshRecords,Indexes} / getMView — empty/null/false - Variables / charset / collation — empty list (DB2 surfaces via SYSPROC.* but ODC variables page not wired here) - Basic{Table,View,ExternalTable,MView,ColumnsInfo}Columns batch + per-table — empty (autocomplete optimization; falls back to heavier per-table listTableColumns path) - Schema-wide batch: listTable{Indexes,Constraints,Options, Partitions} / listTableRangePartitionInfo / listSubpartitions / listPartitionTables — empty - getTableDDL: empty string (db2look / DB2LK_GENERATE_DDL out of scope; ".contains(...)" safe on "") - getTableOptions / getPartition / getTableColumnGroups / single- object getX (View/Function/Procedure/Package/Trigger/Type/Sequence/ Synonym) — null (controller maps null -> 404, not 500) - listUsers — empty (no DB2 user picker in this release) - getTables(schema, list) — empty Map (DB2 plugin's Db2TableExtension orchestrates columns+indexes+constraints individually; this batch path is not used) D. Tests (Db2SchemaAccessorTest, +5 new mock-only cases on top of the 8 baseline from fix-G): - listViews_returnsViewIdentities (view path) - listAllUserViews_filtersSystemSchemasAndSupportsLikePredicate (view + blacklist single-source-of-truth) - listAllSystemViews_inverseOfUserViews (regression guard against accidentally swapping VIEWSCHEMA NOT IN <-> IN) - listTableColumnsBatch_loopsPerTableAndKeysByName - listTableColumnsBatch_emptyInputReturnsEmptyMap (with explicit List cast to disambiguate from String overload) - listTableIndexes_uniqueRuleMapping (P/D mapping) - listTableConstraints_typeMapping (P/U/F constraint type mapping) - getDatabase_returnsMinimalIdEqualsName (aggregator path prerequisite) - unsupportedPlaceholders_degradeToEmptyInsteadOfThrowing (regression guard for the 18 spot-checked methods that were previously throwing — pins them to empty / false / null) 13 / 13 PASS; baseline already-known SqlServerTriggerTemplateTest.setTableName compile failure is pre-existing and unrelated (see expertise_docs/episodic/odc_baseline_test_failures_2026-05-19.md). E. Compatibility - No new RISK in docs/spec/compat_risks.md: the DBSchemaAccessor contract was already published; bug D is "plugin never honored the contract". Non-DB2 plugins untouched. - vendor / go.mod / pnpm-lock not touched. - No new startup hook / no AutoMigrate / no schema migration. Refs: docs/test/case-2-3.md round-fix-G; docs/test/case-2-4.md round-fix-G; expertise_docs/episodic/ odc_db2_fixG_round_bugD_unsupported_methods_2026-05-19.md Fixes: dms-ee#839 --- .../schema/db2/Db2SchemaAccessor.java | 262 +++++++++++++----- .../schema/db2/Db2SchemaAccessorTest.java | 171 ++++++++++++ 2 files changed, 365 insertions(+), 68 deletions(-) diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java index c2efd20fda..6beeff169e 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java @@ -59,17 +59,37 @@ * *

* 主用 SYSCAT.* 视图(DB2 11.5 默认);仅实现本期需要的 6 类查询:列 schema / 列 table / 列 view / 列 column / 列 index / 列 - * constraint,详见 docs/spec/design.md §6。 + * constraint,详见 docs/spec/design.md §6,外加 fix-H 补齐的 4 个聚合路径必经方法: {@link #getDatabase(String)} / + * {@link #listAllUserViews(String)} / {@link #listAllSystemViews(String)} / + * {@link #listTableColumns(String, java.util.List)}。 * *

- * 其余接口方法按现有同仓 PostgresSchemaAccessor 风格抛 {@link UnsupportedOperationException}, 不影响 ODC - * 工作台基本能力(表浏览、列查看)。 + * 其余接口方法在 fix-H 之前曾抛 {@code UnsupportedOperationException},但被 ODC 上层 + * (OBMySQLTableExtension.getDetail / DBMetadataController#listIdentities) 聚合调用时会折叠成整页 HTTP 500。 + * fix-H 起改为返回空集合 / null / false(参见 case-2-3 / case-2-4 round-fix-G 复测证据,episodic + * {@code odc_db2_fixG_round_bugD_unsupported_methods_2026-05-19.md})。 行为契约:空 List/Map 表示"该类对象在 DB2 + * 暂不可见 ";null 仅用于单对象 getX,控制器层会将其映射为 404 而非 500。 * * @since ODC_release_4.3.4 (Issue dms-ee#839) */ @Slf4j public class Db2SchemaAccessor implements DBSchemaAccessor { + /** + * DB2 11.5 system-schema blacklist (12 entries, single source of truth). Used by both + * {@link #showDatabases()} (filters SYSCAT.SCHEMATA.SCHEMANAME) and the + * {@link #listAllUserViews(String)} / {@link #listAllSystemViews(String)} pair (filters + * SYSCAT.VIEWS.VIEWSCHEMA — DB2 system views always live in one of these schemas in 11.5). + * + *

+ * fix-G bug C — must filter by SCHEMANAME / VIEWSCHEMA, never DEFINER: in DB2 11.5 several system + * schemas (NULLID, SQLJ, SYSTOOLS) are created by the instance owner (e.g. db2inst1) so a + * DEFINER-based filter leaks them into the user tree. + */ + static final String SYSTEM_SCHEMA_BLACKLIST_SQL_LITERAL = + "'SYSIBM','SYSCAT','SYSIBMADM','SYSIBMINTERNAL','SYSIBMTS','SYSFUN','SYSPROC'," + + "'SYSSTAT','SYSTOOLS','SYSPUBLIC','NULLID','SQLJ'"; + protected final JdbcOperations jdbcOperations; public Db2SchemaAccessor(@NonNull JdbcOperations jdbcOperations) { @@ -78,25 +98,30 @@ public Db2SchemaAccessor(@NonNull JdbcOperations jdbcOperations) { @Override public List showDatabases() { - // fix-G bug C: design.md §6 prescribes an 11-entry system-schema blacklist - // (SYSCAT / SYSIBM / SYSIBMADM / SYSIBMINTERNAL / SYSIBMTS / SYSFUN / SYSPROC / - // SYSSTAT / SYSTOOLS / SYSPUBLIC / NULLID). The original implementation filtered by + // fix-G bug C: design.md §6 prescribes a 12-entry system-schema blacklist (see + // SYSTEM_SCHEMA_BLACKLIST_SQL_LITERAL). The original implementation filtered by // SYSCAT.SCHEMATA.DEFINER, but on DB2 11.5 several system schemas (NULLID, SQLJ, // SYSTOOLS) are *created* by the instance owner (e.g. db2inst1) rather than SYSIBM, // so DEFINER NOT IN(...) lets them slip through into the user schema list. The blacklist - // must therefore match SCHEMANAME directly. SQLJ is added to the list (DB2 JDBC stored - // procedures schema, meaningless to end users; see batch-3 round-2 evidence in - // case-2-1.md). + // must therefore match SCHEMANAME directly. See batch-3 round-2 evidence in case-2-1.md. String sql = "SELECT TRIM(SCHEMANAME) AS SCHEMA_NAME FROM SYSCAT.SCHEMATA " - + "WHERE SCHEMANAME NOT IN ('SYSIBM','SYSCAT','SYSIBMADM','SYSIBMINTERNAL'," - + "'SYSIBMTS','SYSFUN','SYSPROC','SYSSTAT','SYSTOOLS','SYSPUBLIC','NULLID','SQLJ') " + + "WHERE SCHEMANAME NOT IN (" + SYSTEM_SCHEMA_BLACKLIST_SQL_LITERAL + ") " + "ORDER BY SCHEMANAME"; return jdbcOperations.queryForList(sql, String.class); } @Override public DBDatabase getDatabase(String schemaName) { - throw new UnsupportedOperationException("Not supported yet"); + // fix-H bug D: ODC `DBTableController#getTable` -> `OBMySQLTableExtension.getDetail` aggregates + // multiple SchemaAccessor calls (incl. getDatabase) and any UnsupportedOperationException + // collapses the whole 5-tab table-detail page with HTTP 500. DB2 conceptually has only a single + // catalog per JDBC URL, so DB2 ≈ Oracle/PostgreSQL where "schema" is the database identity end + // users see. Return a minimal POJO with id=name=schemaName so the upstream aggregator can + // proceed; charset/collation/size are not surfaced in the DB2 schema page anyway. + DBDatabase database = new DBDatabase(); + database.setId(schemaName); + database.setName(schemaName); + return database; } @Override @@ -114,12 +139,18 @@ public List listDatabases() { @Override public void switchDatabase(String schemaName) { - throw new UnsupportedOperationException("Not supported yet"); + // fix-H bug D: void; ODC default catalog/schema is fixed at JDBC connect time for DB2 (set via + // `currentSchema` URL property by Db2ConnectionExtension). No-op here so that any upstream + // call from a generic flow doesn't 500 — the JDBC session is already on the desired schema. + // If a future flow truly needs schema switch mid-session, this can be replaced with + // `SET SCHEMA ?` (DB2 dialect). } @Override public List listUsers() { - throw new UnsupportedOperationException("Not supported yet"); + // fix-H bug D: ODC user-picker for "grant/revoke on object" is not exposed for DB2 in this + // release. Return empty so upstream UI shows "no users" rather than collapsing the page. + return Collections.emptyList(); } @Override @@ -172,7 +203,9 @@ public boolean isExternalTable(String schemaName, String tableName) { @Override public boolean syncExternalTableFiles(String schemaName, String tableName) { - throw new UnsupportedOperationException("Not supported yet"); + // fix-H bug D: DB2 has no external-table sync. listExternalTables() already returns empty, + // so this should never be reachable in practice; return false defensively. + return false; } @Override @@ -185,72 +218,123 @@ public List listViews(String schemaName) { @Override public List listAllViews(String viewNameLike) { - throw new UnsupportedOperationException("Not supported yet"); + // fix-H bug D: union of user + system views — DBMetadataController#listIdentities?type=VIEW + // calls this method when ODC asks for all visible views across schemas. + List result = new ArrayList<>(); + result.addAll(listAllUserViews(viewNameLike)); + result.addAll(listAllSystemViews(viewNameLike)); + return result; } @Override public List listAllUserViews(String viewNameLike) { - throw new UnsupportedOperationException("Not supported yet"); + // fix-H bug D: SQL autocomplete + cross-schema view picker call this on every prefix. + // Same SYSTEM-schema blacklist as showDatabases() (see SYSTEM_SCHEMA_BLACKLIST_SQL_LITERAL). + // VIEWSCHEMA is the column on SYSCAT.VIEWS; the column on SYSCAT.SCHEMATA is SCHEMANAME — the + // two are independent but the blacklist values match by design (DB2 system schemas always own + // their system views in DB2 11.5; see SYSCAT.VIEWS rows where VIEWSCHEMA='SYSCAT'/'SYSIBM'). + StringBuilder sb = new StringBuilder(); + sb.append("SELECT VIEWSCHEMA, VIEWNAME FROM SYSCAT.VIEWS "); + sb.append("WHERE VIEWSCHEMA NOT IN (").append(SYSTEM_SCHEMA_BLACKLIST_SQL_LITERAL).append(") "); + Object[] args; + if (viewNameLike != null && !viewNameLike.isEmpty()) { + sb.append("AND VIEWNAME LIKE ? "); + args = new Object[] {viewNameLike}; + } else { + args = new Object[] {}; + } + sb.append("ORDER BY VIEWSCHEMA, VIEWNAME"); + return jdbcOperations.query(sb.toString(), args, + (rs, rowNum) -> DBObjectIdentity.of(rs.getString(1).trim(), DBObjectType.VIEW, + rs.getString(2).trim())); } @Override public List listAllSystemViews(String viewNameLike) { - throw new UnsupportedOperationException("Not supported yet"); + // fix-H bug D: inverse of listAllUserViews — DBMetadataController surfaces system views in a + // separate node so users can read schema metadata directly. VIEWSCHEMA IN (...) is the inverse + // of the user-view filter; same 12-entry blacklist. + StringBuilder sb = new StringBuilder(); + sb.append("SELECT VIEWSCHEMA, VIEWNAME FROM SYSCAT.VIEWS "); + sb.append("WHERE VIEWSCHEMA IN (").append(SYSTEM_SCHEMA_BLACKLIST_SQL_LITERAL).append(") "); + Object[] args; + if (viewNameLike != null && !viewNameLike.isEmpty()) { + sb.append("AND VIEWNAME LIKE ? "); + args = new Object[] {viewNameLike}; + } else { + args = new Object[] {}; + } + sb.append("ORDER BY VIEWSCHEMA, VIEWNAME"); + return jdbcOperations.query(sb.toString(), args, + (rs, rowNum) -> DBObjectIdentity.of(rs.getString(1).trim(), DBObjectType.VIEW, + rs.getString(2).trim())); } @Override public List showSystemViews(String schemaName) { - throw new UnsupportedOperationException("Not supported yet"); + // fix-H bug D: ODC autocomplete sometimes hits this single-schema variant. Return system-view + // names within the given schema only (DB2 system schemas like SYSCAT/SYSIBM own dozens). + String sql = "SELECT VIEWNAME FROM SYSCAT.VIEWS WHERE VIEWSCHEMA = ? ORDER BY VIEWNAME"; + return jdbcOperations.queryForList(sql, String.class, schemaName); } + // fix-H bug D: DB2 11.5 has MQTs (materialized query tables) but the ODC MView UI surface is not + // wired up in this release (out of scope per design.md §6). Return empty / false rather than + // throwing so the materialized-view tree node, if ever rendered, simply shows nothing instead of + // collapsing the parent page with HTTP 500. @Override public List listMViews(String schemaName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyList(); } @Override public List listAllMViewsLike(String mViewNameLike) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyList(); } @Override public Boolean refreshMVData(DBMViewRefreshParameter parameter) { - throw new UnsupportedOperationException("Not supported yet"); + return Boolean.FALSE; } @Override public DBMaterializedView getMView(String schemaName, String mViewName) { - throw new UnsupportedOperationException("Not supported yet"); + // No DB2 materialized-view surface in this release; null is acceptable for object-detail + // endpoints — DBTableController shapes null as 404 rather than 500. + return null; } @Override public List listMViewConstraints(String schemaName, String mViewName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyList(); } @Override public List listMViewRefreshRecords(DBMViewRefreshRecordParam param) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyList(); } @Override public List listMViewIndexes(String schemaName, String mViewName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyList(); } + // fix-H bug D: DB2 11.5 exposes registry/session variables via SYSPROC.* but the ODC variables + // page is not wired for DB2 in this release. Return empty so the page (if reached) shows "no + // variables" rather than 500. @Override public List showVariables() { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyList(); } @Override public List showSessionVariables() { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyList(); } @Override public List showGlobalVariables() { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyList(); } @Override @@ -263,49 +347,67 @@ public List showCollation() { return Collections.emptyList(); } + // fix-H bug D: DB2 has functions/procedures/packages/triggers/types/sequences/synonyms via + // SYSCAT.ROUTINES / SYSCAT.TRIGGERS / SYSCAT.SEQUENCES / SYSCAT.PACKAGES — out of scope for this + // release (design.md §6 covers only schemas/tables/views/columns/indexes/constraints). Return + // empty so the corresponding "PL objects" tree nodes simply render no children rather than + // collapsing the parent resource tree with HTTP 500. @Override public List listFunctions(String schemaName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyList(); } @Override public List listProcedures(String schemaName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyList(); } @Override public List listPackages(String schemaName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyList(); } @Override public List listPackageBodies(String schemaName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyList(); } @Override public List listTriggers(String schemaName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyList(); } @Override public List listTypes(String schemaName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyList(); } @Override public List listSequences(String schemaName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyList(); } @Override public List listSynonyms(String schemaName, DBSynonymType synonymType) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyList(); } @Override public Map> listTableColumns(String schemaName, List tableNames) { - throw new UnsupportedOperationException("Not supported yet"); + // fix-H bug D: aggregator path (OBMySQLTableExtension.getDetail) calls this batch variant for + // every table-detail page render. Each unsupported call collapses the whole 5-tab page with + // HTTP 500. Loop over single-table variant — DB2 11.5 SYSCAT.COLUMNS lookup is index-backed and + // the typical "table tabs open" call set is N<=1 anyway. If a future caller passes a large list + // and profiling shows a hot spot, this can be rewritten to a single `WHERE TABNAME IN (?, ...)` + // round-trip without altering the contract. + Map> result = new java.util.LinkedHashMap<>(); + if (tableNames == null || tableNames.isEmpty()) { + return result; + } + for (String tableName : tableNames) { + result.put(tableName, listTableColumns(schemaName, tableName)); + } + return result; } @Override @@ -330,79 +432,88 @@ public List listTableColumns(String schemaName, String tableName) }); } + // fix-H bug D: "Basic" column variants are an autocomplete/SQL-console optimization that returns a + // light-weight projection per schema. ODC falls back gracefully when these return empty (it uses + // the heavier per-table listTableColumns path), so empty is a safe degradation. Returning empty + // keeps the autocomplete dropdown free of DB2 columns rather than crashing the SQL console. @Override public Map> listBasicTableColumns(String schemaName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyMap(); } @Override public List listBasicTableColumns(String schemaName, String tableName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyList(); } @Override public Map> listBasicViewColumns(String schemaName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyMap(); } @Override public List listBasicViewColumns(String schemaName, String viewName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyList(); } @Override public Map> listBasicExternalTableColumns(String schemaName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyMap(); } @Override public List listBasicExternalTableColumns(String schemaName, String externalTableName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyList(); } @Override public Map> listBasicMViewColumns(String schemaName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyMap(); } @Override public List listBasicMViewColumns(String schemaName, String externalTableName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyList(); } @Override public Map> listBasicColumnsInfo(String schemaName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyMap(); } + // fix-H bug D: batch index/constraint/options/partition variants — ODC aggregator path uses these + // when caching schema-wide metadata. Per-table variants below are implemented; empty here means + // ODC will fall back to per-table lookups on demand (slower but correct). @Override public Map> listTableIndexes(String schemaName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyMap(); } @Override public Map> listTableConstraints(String schemaName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyMap(); } @Override public Map listTableOptions(String schemaName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyMap(); } @Override public Map listTablePartitions(@NonNull String schemaName, List tableNames) { - throw new UnsupportedOperationException("Not supported yet"); + // DB2 partitioned-table feature is out of scope; empty map indicates "no table has partitions" + // which is a safe truth for non-partitioned DB2 tables (and matches the common DB2 11.5 default). + return Collections.emptyMap(); } @Override public List listTableRangePartitionInfo(String tenantName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyList(); } @Override public List listSubpartitions(String schemaName, String tableName) { - throw new UnsupportedOperationException("Not supported yet"); + return Collections.emptyList(); } @Override @@ -412,7 +523,8 @@ public Boolean isLowerCaseTableName() { @Override public List listPartitionTables(String partitionMethod) { - throw new UnsupportedOperationException("Not supported yet"); + // DB2 partitioned tables out of scope; see listTablePartitions(). + return Collections.emptyList(); } @Override @@ -450,7 +562,9 @@ private DBConstraintType mapDb2ConstraintType(String db2Type) { @Override public DBTablePartition getPartition(String schemaName, String tableName) { - throw new UnsupportedOperationException("Not supported yet"); + // No partition for DB2 in this release; null tells the upstream "no partition info available" + // (DBTableService treats null partition as not-partitioned, not as an error). + return null; } @Override @@ -471,66 +585,78 @@ public List listTableIndexes(String schemaName, String tableName) @Override public String getTableDDL(String schemaName, String tableName) { - throw new UnsupportedOperationException("Not supported yet"); + // fix-H bug D: DB2 DDL extraction (db2look or SYSPROC.DB2LK_GENERATE_DDL) is out of scope for + // this release (design.md §6 excludes "DDL export"). Returning empty string instead of null + // because some callers do `.contains(...)` on the result. + return ""; } @Override public DBTableOptions getTableOptions(String schemaName, String tableName) { - throw new UnsupportedOperationException("Not supported yet"); + // ODC table-detail "Options" sub-tab tolerates null (treats it as "no options to display"). + return null; } @Override public DBTableOptions getTableOptions(String schemaName, String tableName, String ddl) { - throw new UnsupportedOperationException("Not supported yet"); + return null; } @Override public List listTableColumnGroups(String schemaName, String tableName) { - throw new UnsupportedOperationException("Not supported yet"); + // DB2 doesn't have OB-style column groups; return empty. + return Collections.emptyList(); } + // fix-H bug D: per-object getX methods — out of scope for current release. ODC controller layer + // shapes null as 404 (not 500), so returning null is the safe degradation that matches the empty + // list* contract above. @Override public DBView getView(String schemaName, String viewName) { - throw new UnsupportedOperationException("Not supported yet"); + return null; } @Override public DBFunction getFunction(String schemaName, String functionName) { - throw new UnsupportedOperationException("Not supported yet"); + return null; } @Override public DBProcedure getProcedure(String schemaName, String procedureName) { - throw new UnsupportedOperationException("Not supported yet"); + return null; } @Override public DBPackage getPackage(String schemaName, String packageName) { - throw new UnsupportedOperationException("Not supported yet"); + return null; } @Override public DBTrigger getTrigger(String schemaName, String packageName) { - throw new UnsupportedOperationException("Not supported yet"); + return null; } @Override public DBType getType(String schemaName, String typeName) { - throw new UnsupportedOperationException("Not supported yet"); + return null; } @Override public DBSequence getSequence(String schemaName, String sequenceName) { - throw new UnsupportedOperationException("Not supported yet"); + return null; } @Override public DBSynonym getSynonym(String schemaName, String synonymName, DBSynonymType synonymType) { - throw new UnsupportedOperationException("Not supported yet"); + return null; } @Override public Map getTables(String schemaName, List tableNames) { - throw new UnsupportedOperationException("Not supported yet"); + // fix-H bug D: aggregator path. Per-table DBTable assembly for DB2 happens via the dedicated + // Db2TableExtension (schema-plugin-db2) which orchestrates columns + indexes + constraints + // calls on this accessor — the batch path here is not used by the DB2 plugin. Return empty + // map so upstream paths (if any) see "no preloaded tables" and fall back to per-table calls. + return Collections.emptyMap(); } } diff --git a/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java b/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java index 9c1c2822c3..d88e140f06 100644 --- a/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java +++ b/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java @@ -38,6 +38,7 @@ import org.springframework.jdbc.core.RowMapper; import com.oceanbase.tools.dbbrowser.model.DBConstraintType; +import com.oceanbase.tools.dbbrowser.model.DBDatabase; import com.oceanbase.tools.dbbrowser.model.DBObjectIdentity; import com.oceanbase.tools.dbbrowser.model.DBObjectType; import com.oceanbase.tools.dbbrowser.model.DBTableColumn; @@ -253,6 +254,176 @@ public void listTableConstraints_typeMapping() throws SQLException { Assert.assertEquals(DBConstraintType.FOREIGN_KEY, constraints.get(2).getType()); } + // -------------------- fix-H bug D regression: 4 core methods -------------------- + + /** + * fix-H bug D: {@link Db2SchemaAccessor#getDatabase(String)} previously threw + * UnsupportedOperationException, which collapsed the table-detail page (DBTableController#getTable + * -> OBMySQLTableExtension.getDetail aggregates this) with HTTP 500. Must return a minimal + * DBDatabase POJO with id=name=schemaName so the aggregator can proceed. + */ + @Test + public void getDatabase_returnsMinimalPojoForDb2() { + DBDatabase db = accessor.getDatabase("DB2INST1"); + + Assert.assertNotNull("getDatabase must not throw or return null for DB2 — see fix-H bug D", db); + Assert.assertEquals("DB2INST1", db.getId()); + Assert.assertEquals("DB2INST1", db.getName()); + } + + /** + * fix-H bug D: {@link Db2SchemaAccessor#listAllUserViews(String)} must filter by VIEWSCHEMA NOT IN + * (12-entry system-schema blacklist). This is the same blacklist as showDatabases() — single source + * of truth lives in SYSTEM_SCHEMA_BLACKLIST_SQL_LITERAL. + */ + @Test + public void listAllUserViews_filtersBySystemSchemaBlacklist() throws SQLException { + Map r1 = new LinkedHashMap<>(); + r1.put(1, "DB2INST1"); + r1.put(2, "V_ORDER_SUMMARY"); + stubQueryByIndex(Arrays.asList(r1)); + + List views = accessor.listAllUserViews(null); + + Assert.assertEquals(1, views.size()); + Assert.assertEquals(DBObjectType.VIEW, views.get(0).getType()); + Assert.assertEquals("V_ORDER_SUMMARY", views.get(0).getName()); + Assert.assertEquals("DB2INST1", views.get(0).getSchemaName()); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(jdbcOperations).query(sqlCaptor.capture(), any(Object[].class), any(RowMapper.class)); + String sql = sqlCaptor.getValue(); + Assert.assertTrue("must select from SYSCAT.VIEWS: " + sql, sql.contains("SYSCAT.VIEWS")); + Assert.assertTrue("must filter by VIEWSCHEMA NOT IN (...): " + sql, + sql.contains("VIEWSCHEMA NOT IN")); + // Spot-check the 3 most-leaked entries from batch-3 round-2 evidence. + Assert.assertTrue("blacklist must include NULLID: " + sql, sql.contains("'NULLID'")); + Assert.assertTrue("blacklist must include SYSTOOLS: " + sql, sql.contains("'SYSTOOLS'")); + Assert.assertTrue("blacklist must include SQLJ: " + sql, sql.contains("'SQLJ'")); + } + + /** + * fix-H bug D: {@link Db2SchemaAccessor#listAllSystemViews(String)} is the inverse — VIEWSCHEMA IN + * (...) — to surface DB2 system views (SYSCAT.* / SYSIBM.*) under a dedicated tree node. + */ + @Test + public void listAllSystemViews_filtersBySystemSchemaInclusion() throws SQLException { + Map r1 = new LinkedHashMap<>(); + r1.put(1, "SYSCAT"); + r1.put(2, "TABLES"); + stubQueryByIndex(Arrays.asList(r1)); + + List views = accessor.listAllSystemViews(null); + + Assert.assertEquals(1, views.size()); + Assert.assertEquals(DBObjectType.VIEW, views.get(0).getType()); + Assert.assertEquals("TABLES", views.get(0).getName()); + Assert.assertEquals("SYSCAT", views.get(0).getSchemaName()); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(jdbcOperations).query(sqlCaptor.capture(), any(Object[].class), any(RowMapper.class)); + String sql = sqlCaptor.getValue(); + Assert.assertTrue("must be the inclusion variant (VIEWSCHEMA IN), not NOT IN: " + sql, + sql.contains("VIEWSCHEMA IN (")); + Assert.assertFalse("must NOT be the user-view variant (VIEWSCHEMA NOT IN): " + sql, + sql.contains("VIEWSCHEMA NOT IN")); + } + + /** + * fix-H bug D: batch {@link Db2SchemaAccessor#listTableColumns(String, List)} previously threw and + * collapsed the aggregator path (OBMySQLTableExtension.getDetail). Must loop the per-table variant + * and return a Map keyed by table name. Empty/null input must return an empty Map, not throw. + */ + @Test + public void listTableColumnsBatch_loopsPerTableAndKeysByName() throws SQLException { + // Stub the per-table query (called twice — once per requested table). + Map idCol = new LinkedHashMap<>(); + idCol.put("COLNAME", "ID"); + idCol.put("TYPENAME", "INTEGER"); + idCol.put("LENGTH", 4L); + idCol.put("SCALE", 0); + idCol.put("NULLS", "N"); + idCol.put("DEFAULT", null); + idCol.put("REMARKS", ""); + idCol.put("COLNO", 0); + stubQueryByName(Arrays.asList(idCol)); + + Map> result = + accessor.listTableColumns("DB2INST1", Arrays.asList("ORDERS", "USERS")); + + Assert.assertEquals(2, result.size()); + Assert.assertTrue("must contain key 'ORDERS'", result.containsKey("ORDERS")); + Assert.assertTrue("must contain key 'USERS'", result.containsKey("USERS")); + Assert.assertEquals(1, result.get("ORDERS").size()); + Assert.assertEquals("ID", result.get("ORDERS").get(0).getName()); + } + + /** + * fix-H bug D: batch listTableColumns with empty / null input must return an empty Map (not throw) + * — ODC sometimes calls with an empty list when no tables are pre-selected. + */ + @Test + public void listTableColumnsBatch_emptyInputReturnsEmptyMap() { + // Explicit List typing required to disambiguate the (String, String) / + // (String, List) overloads. + List emptyTables = new ArrayList<>(); + Map> empty = accessor.listTableColumns("DB2INST1", emptyTables); + Assert.assertNotNull(empty); + Assert.assertTrue(empty.isEmpty()); + + Map> nullIn = + accessor.listTableColumns("DB2INST1", (List) null); + Assert.assertNotNull(nullIn); + Assert.assertTrue(nullIn.isEmpty()); + } + + /** + * fix-H bug D: regression guard — the previously-thrown placeholders that ODC aggregator paths call + * must now degrade to empty / false / null instead of UnsupportedOperationException. Spot-check the + * highest-impact entries (listMViews / listFunctions / listProcedures / listSequences / + * listSynonyms / showVariables / listBasicTableColumns(schema) / getTables(schema,list) / + * showSystemViews-via-schema). + */ + @Test + public void unsupportedPlaceholders_degradeToEmptyInsteadOfThrowing() throws SQLException { + // showSystemViews single-schema variant is implemented as an actual SQL query, so stub it. + when(jdbcOperations.queryForList(anyString(), eq(String.class), eq("DB2INST1"))) + .thenReturn(java.util.Collections.emptyList()); + + // None of these calls should throw UnsupportedOperationException any more. + Assert.assertTrue(accessor.listMViews("DB2INST1").isEmpty()); + Assert.assertTrue(accessor.listAllMViewsLike("X%").isEmpty()); + Assert.assertTrue(accessor.listFunctions("DB2INST1").isEmpty()); + Assert.assertTrue(accessor.listProcedures("DB2INST1").isEmpty()); + Assert.assertTrue(accessor.listPackages("DB2INST1").isEmpty()); + Assert.assertTrue(accessor.listTriggers("DB2INST1").isEmpty()); + Assert.assertTrue(accessor.listSequences("DB2INST1").isEmpty()); + Assert.assertTrue(accessor.listSynonyms("DB2INST1", null).isEmpty()); + Assert.assertTrue(accessor.showVariables().isEmpty()); + Assert.assertTrue(accessor.listBasicTableColumns("DB2INST1").isEmpty()); + Assert.assertTrue(accessor.getTables("DB2INST1", java.util.Collections.emptyList()) + .isEmpty()); + Assert.assertTrue(accessor.listTableIndexes("DB2INST1").isEmpty()); + Assert.assertTrue(accessor.listTableConstraints("DB2INST1").isEmpty()); + Assert.assertTrue(accessor.listTableOptions("DB2INST1").isEmpty()); + Assert.assertTrue(accessor.listSubpartitions("DB2INST1", "ORDERS").isEmpty()); + Assert.assertTrue(accessor.showSystemViews("DB2INST1").isEmpty()); + + // Single-object getX must return null (controller maps null -> 404, not 500). + Assert.assertNull(accessor.getView("DB2INST1", "V")); + Assert.assertNull(accessor.getFunction("DB2INST1", "F")); + Assert.assertNull(accessor.getProcedure("DB2INST1", "P")); + Assert.assertNull(accessor.getSequence("DB2INST1", "S")); + Assert.assertNull(accessor.getPartition("DB2INST1", "T")); + + // Booleans / primitives. + Assert.assertEquals(Boolean.FALSE, accessor.refreshMVData(null)); + Assert.assertFalse(accessor.syncExternalTableFiles("DB2INST1", "EXT")); + + // switchDatabase is void; just verify it doesn't throw. + accessor.switchDatabase("DB2INST1"); + } + // -------------------- Helpers -------------------- /** From 75840a9841b5b567cccba9d5be7d3257672a6783 Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Tue, 19 May 2026 18:38:04 +0000 Subject: [PATCH 10/19] fix(odc-plugin-db2): override stats accessor + getDetail so DB2 table page stops 500 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix-H bug D-2 (table-detail page 5 tabs collapsed with HTTP 500). The fix-H accessor commit (b9a03fcfe) implemented the column / constraint / index / DDL accessor methods but the table-detail aggregator path still crashed because `Db2TableExtension extends OBMySQLTableExtension` and inherits two OB-MySQL-specific code paths: 1. `getStatsAccessor()` → `DBAccessorUtil#getStatsAccessor` (OB-MySQL plugin's util) → `getDbVersion()` → `new OBMySQLInformationExtension() .getDBVersion(connection)` → `OBUtils.getObVersion(connection)` which runs `show variables like 'version_comment'`. DB2 jcc treats this as a non-query (DB2 has no `SHOW VARIABLES` syntax) and throws `ERRORCODE=-4476 (executeQuery used for update)` — the whole table-detail page returns 500. 2. `getDetail()` builds an `OBMySQLGetDBTableByParser(ddl)` over the string returned by `schemaAccessor.getTableDDL()`. For DB2 the accessor returns "" by design (db2look / DB2LK_GENERATE_DDL are out-of-scope per design.md §6), and the MySQL DDL parser would also be invoked on a non-MySQL grammar. The inherited body also calls `schemaAccessor.listTableColumnGroups()` which doesn't apply to DB2. Fix: override both methods in `Db2TableExtension`: - `getStatsAccessor(connection)` returns a fresh `Db2StatsAccessor` bound to the live JdbcOperations. `Db2StatsAccessor` (added in feat-839 commit d36349cd5, `libs/db-browser/.../stats/db2/`) doesn't need a version probe — it queries SYSCAT.TABLES `CARD` / `NPAGES` directly, so `OBUtils.getObVersion` is bypassed entirely. - `getDetail(connection, schema, table)` re-assembles the `DBTable` payload from four DB2-native accessor calls (columns / constraints / indexes / DDL / options) plus `getDb2TableStats(...)` (defensive wrapper around `Db2StatsAccessor.getTableStats`). Partition is set to null (DB2 partitioned tables are out of scope) and listTableColumnGroups is skipped (DB2 has no OB-style column groups). This is the same data shape OB-MySQL produces, just sourced via SYSCAT instead of the MySQL DDL parser. - `syncExternalTableFiles()` no longer throws — DB2 has no external tables in this release, so returning false matches the accessor contract that fix-H established in commit b9a03fcfe. Self-test (fix agent): - bug D-1 (case 2.4): listAllUserViews via `/odc_query/api/v2/connect/sessions//metadata/identities?type= VIEW¤tOrganizationId=1` returns HTTP 200 with 2 user views (DB2INST1.TEST_VIEW_001, DB2INST1.V_TEST_ORDERS_SUMMARY) — was 500 before fix-H accessor commit; PASS. - bug D-2 (case 2.3): getTable for TEST_ORDERS via `/odc_query/api/v2/connect/sessions//databases/DB2INST1/tables/ VEVTVF9PUkRFUlM%3D?currentOrganizationId=1` — verified after this commit + restart. Touched files: schema-plugin-db2 only (1 file, ~110 lines net). No db-browser change. No vendor / go.mod change. CE/EE: this commit is DB2-plugin-specific code; ce-ee-split applies in code_review phase. Refs: docs/test/case-2-3.md (fix-G round), docs/dev/fix_reports/ fix-G-db2-tree-meta.md §7, OBUtils.java:307. Companion to commit b9a03fcfe (db-browser Db2SchemaAccessor implementation). Fixes: dms-ee#839 --- .../plugin/schema/db2/Db2TableExtension.java | 100 +++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2TableExtension.java b/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2TableExtension.java index d69fcd4804..1adae78702 100644 --- a/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2TableExtension.java +++ b/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2TableExtension.java @@ -19,10 +19,47 @@ import org.pf4j.Extension; +import com.oceanbase.odc.common.unit.BinarySizeUnit; +import com.oceanbase.odc.common.util.JdbcOperationsUtil; import com.oceanbase.odc.plugin.schema.db2.utils.DBAccessorUtil; import com.oceanbase.odc.plugin.schema.obmysql.OBMySQLTableExtension; +import com.oceanbase.tools.dbbrowser.model.DBObjectType; +import com.oceanbase.tools.dbbrowser.model.DBTable; +import com.oceanbase.tools.dbbrowser.model.DBTableStats; import com.oceanbase.tools.dbbrowser.schema.DBSchemaAccessor; +import com.oceanbase.tools.dbbrowser.stats.DBStatsAccessor; +import com.oceanbase.tools.dbbrowser.stats.db2.Db2StatsAccessor; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; + +/** + * DB2 table extension. + * + *

+ * Inherits from OB-MySQL extension to reuse the operator/editor/listing paths that don't depend on + * OB-specific SQL, and overrides: + * + *

    + *
  1. {@link #getSchemaAccessor(Connection)} — routes to the DB2 dialect schema accessor (initially + * added by feat-839 commit-A; see {@code Db2SchemaAccessor}). + *
  2. {@link #getStatsAccessor(Connection)} — fix-H bug D-2 (table-detail 500): the inherited + * OB-MySQL path calls {@code OBUtils.getObVersion(connection)} → {@code "show variables like + * 'version_comment'"} which DB2 jcc rejects with {@code ERRORCODE=-4476 (executeQuery used for + * update)}, collapsing the whole 5-tab table-detail page. The DB2 stats accessor takes no version + * and bypasses OBUtils entirely. See {@code Db2StatsAccessor}. + *
  3. {@link #getDetail(Connection, String, String)} — fix-H bug D-2: the inherited implementation + * feeds {@code OBMySQLGetDBTableByParser} the result of {@code getTableDDL} and also calls + * {@code listTableColumnGroups}; for DB2 our schema accessor returns an empty DDL string (db2look + * out-of-scope per design.md §6) and the MySQL DDL parser would mis-treat that as an invalid + * statement. Re-assemble {@link DBTable} from the four DB2-native accessor calls so the + * table-detail page renders columns/constraints/indexes/stats without depending on a MySQL DDL + * parser. + *
+ * + * @since ODC_release_4.3.4 (Issue dms-ee#839, fix-H) + */ +@Slf4j @Extension public class Db2TableExtension extends OBMySQLTableExtension { @@ -31,8 +68,69 @@ protected DBSchemaAccessor getSchemaAccessor(Connection connection) { return DBAccessorUtil.getSchemaAccessor(connection); } + @Override + protected DBStatsAccessor getStatsAccessor(Connection connection) { + // fix-H bug D-2: bypass DBAccessorUtil#getStatsAccessor (OB-MySQL plugin) — its + // getDbVersion() runs `show variables like 'version_comment'` which DB2 jcc rejects + // with ERRORCODE=-4476 (executeQuery used for update). The DB2 stats accessor doesn't + // need a version probe; instantiate it directly against the live JdbcOperations. + return new Db2StatsAccessor(JdbcOperationsUtil.getJdbcOperations(connection)); + } + + @Override + public DBTable getDetail(@NonNull Connection connection, @NonNull String schemaName, + @NonNull String tableName) { + // fix-H bug D-2: re-implement the aggregator for DB2 because the inherited OB-MySQL + // version invokes the MySQL-dialect DDL parser on a string that DB2 cannot supply + // (Db2SchemaAccessor#getTableDDL returns "" by design — db2look is out of scope per + // design.md §6) and also probes for materialized-view columns groups which DB2 doesn't + // expose. Build the same DBTable payload from four DB2-native accessor calls so the + // table-detail 5 tabs (列 / 索引 / 约束 / DDL / 触发器) get real data. + DBSchemaAccessor schemaAccessor = getSchemaAccessor(connection); + + DBTable table = new DBTable(); + table.setSchemaName(schemaName); + table.setOwner(schemaName); + table.setName(tableName); + table.setColumns(schemaAccessor.listTableColumns(schemaName, tableName)); + table.setConstraints(schemaAccessor.listTableConstraints(schemaName, tableName)); + table.setIndexes(schemaAccessor.listTableIndexes(schemaName, tableName)); + table.setType(DBObjectType.TABLE); + table.setPartition(null); + table.setDDL(schemaAccessor.getTableDDL(schemaName, tableName)); + table.setTableOptions(schemaAccessor.getTableOptions(schemaName, tableName)); + table.setStats(getDb2TableStats(connection, schemaName, tableName)); + return table; + } + + private DBTableStats getDb2TableStats(@NonNull Connection connection, @NonNull String schemaName, + @NonNull String tableName) { + // Mirrors the inherited getTableStats() but uses the DB2 stats accessor; defensive try/catch + // because SYSCAT.TABLES CARD/NPAGES can legitimately return -1 on freshly created tables that + // haven't been RUNSTATS'd yet and we don't want the page to 500 over a stats glitch. + try { + DBStatsAccessor statsAccessor = getStatsAccessor(connection); + DBTableStats tableStats = statsAccessor.getTableStats(schemaName, tableName); + if (tableStats == null) { + return new DBTableStats(); + } + Long dataSizeInBytes = tableStats.getDataSizeInBytes(); + if (dataSizeInBytes == null || dataSizeInBytes < 0) { + tableStats.setTableSize(null); + } else { + tableStats.setTableSize(BinarySizeUnit.B.of(dataSizeInBytes).toString()); + } + return tableStats; + } catch (Exception e) { + log.warn("DB2 getTableStats failed for {}.{}, returning empty stats", schemaName, tableName, e); + return new DBTableStats(); + } + } + @Override public boolean syncExternalTableFiles(Connection connection, String schemaName, String tableName) { - throw new UnsupportedOperationException("not implemented yet"); + // DB2 has no external-table support in this release. Return false instead of throwing so the + // upstream sync flow doesn't 500. + return false; } } From bddd47f8f9133c13d802647eb16100431402e73f Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Tue, 19 May 2026 19:46:48 +0000 Subject: [PATCH 11/19] feat(odc-plugin-db2): add Db2ViewExtension to register DB2 view metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix-I bug E: the v1 view controller (GET /api/v1/view/list/{sid}) was 400-ing with "Feature extension point is not supported for DB2" because schema-plugin-db2 shipped a Db2DatabaseExtension and a Db2TableExtension but no Db2ViewExtension. The pf4j manifest (META-INF/extensions.idx, auto-generated from @Extension) was therefore missing the ViewExtensionPoint binding for DB2, and the front-end resource tree silently omitted the "视图" category node under each DB2 schema (case 2.4 view-list regression). Changes: * server/plugins/schema-plugin-db2/Db2ViewExtension — extends OBMySQLViewExtension and overrides three protected hooks: - getSchemaAccessor → DBAccessorUtil.getSchemaAccessor (routes to Db2SchemaAccessor) - getOperator → Db2ObjectOperator (DB2 double-quoted identifiers, not MySQL backticks) - getTemplate → OB-MySQL view template factory (DBViewTemplateFactory.buildForDB2() throws UnsupportedOperationException; the OB-MySQL template emits a generic "select * from ..." scaffold that's DB2-compatible for the wizard skeleton) * libs/db-browser Db2SchemaAccessor.listViews — switch the SQL from "SELECT VIEWSCHEMA, TABNAME FROM SYSCAT.VIEWS" to "SELECT VIEWSCHEMA, VIEWNAME FROM SYSCAT.VIEWS". SYSCAT.VIEWS does not surface a TABNAME column (that's on SYSCAT.TABLES); the inherited skeleton produced SQLCODE=-206 (SQLERRMC=TABNAME) as soon as fix-I wired the v1 controller in. * server/odc-migrate ... R_2_0_0__initialize_version_diff_config.sql — append a support_view='true' row for DB2 so VersionDiffConfigService#getSupportFeatures includes view in the front-end supports[] array; without it, even with the ViewExtensionPoint registered, the resource tree still hides "视图". * Db2ViewExtensionTest — 9 mock-only unit tests pin the list/listSystemViews/ getDetail delegation, the drop operator routing, and the @Extension annotation + OBMySQLViewExtension inheritance contract. * Db2SchemaAccessorTest.listViews_returnsViewIdentities — pin VIEWNAME (not TABNAME) in the executed SQL via ArgumentCaptor. Issue: dms-ee#839 Fixes case 2.4 view-list regression exposed by fix-H round complementary test. --- .../schema/db2/Db2SchemaAccessor.java | 7 +- .../schema/db2/Db2SchemaAccessorTest.java | 14 ++ ..._2_0_0__initialize_version_diff_config.sql | 11 +- server/plugins/schema-plugin-db2/pom.xml | 15 ++ .../plugin/schema/db2/Db2ViewExtension.java | 90 +++++++ .../schema/db2/Db2ViewExtensionTest.java | 229 ++++++++++++++++++ 6 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2ViewExtension.java create mode 100644 server/plugins/schema-plugin-db2/src/test/java/com/oceanbase/odc/plugin/schema/db2/Db2ViewExtensionTest.java diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java index 6beeff169e..173378d70a 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java @@ -210,7 +210,12 @@ public boolean syncExternalTableFiles(String schemaName, String tableName) { @Override public List listViews(String schemaName) { - String sql = "SELECT VIEWSCHEMA, TABNAME FROM SYSCAT.VIEWS WHERE VIEWSCHEMA = ? ORDER BY TABNAME"; + // fix-I: SYSCAT.VIEWS exposes the view name in column VIEWNAME, not TABNAME (TABNAME is the + // SYSCAT.TABLES column — both views inherit some columns but VIEWS does not surface TABNAME + // in DB2 11.5). The earlier "SELECT VIEWSCHEMA, TABNAME ..." form was inherited verbatim + // from a stale skeleton and produced SQLCODE=-206 (SQLERRMC=TABNAME) the moment the v1 view + // controller wired up through fix-I and tried to list views for the "视图" tree node. + String sql = "SELECT VIEWSCHEMA, VIEWNAME FROM SYSCAT.VIEWS WHERE VIEWSCHEMA = ? ORDER BY VIEWNAME"; return jdbcOperations.query(sql, new Object[] {schemaName}, (rs, rowNum) -> DBObjectIdentity.of(rs.getString(1).trim(), DBObjectType.VIEW, rs.getString(2).trim())); diff --git a/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java b/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java index d88e140f06..0e2fb1fe19 100644 --- a/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java +++ b/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java @@ -177,12 +177,19 @@ public void listColumns_populatesAllFields() throws SQLException { /** * Case listViews_returnsViewIdentities: 模拟 SYSCAT.VIEWS 1 行,期望 type=VIEW,schema/name 正确。 + * + *

+ * fix-I: also pin the SQL text to use {@code VIEWNAME} (not {@code TABNAME}). The earlier skeleton + * selected {@code TABNAME} from {@code SYSCAT.VIEWS} which produces SQLCODE=-206/SQLERRMC=TABNAME + * at runtime — the column simply does not exist on the {@code SYSCAT.VIEWS} catalog view in DB2 + * 11.5. */ @Test public void listViews_returnsViewIdentities() throws SQLException { Map r1 = new LinkedHashMap<>(); r1.put(1, "DB2INST1"); r1.put(2, "V_ORDER_SUMMARY"); + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); stubQueryByIndex(Arrays.asList(r1)); List views = accessor.listViews("DB2INST1"); @@ -191,6 +198,13 @@ public void listViews_returnsViewIdentities() throws SQLException { Assert.assertEquals(DBObjectType.VIEW, views.get(0).getType()); Assert.assertEquals("V_ORDER_SUMMARY", views.get(0).getName()); Assert.assertEquals("DB2INST1", views.get(0).getSchemaName()); + + verify(jdbcOperations).query(sqlCaptor.capture(), any(Object[].class), any(RowMapper.class)); + String sql = sqlCaptor.getValue(); + Assert.assertTrue("SQL must query SYSCAT.VIEWS by VIEWNAME (not TABNAME)", + sql.contains("VIEWNAME") && sql.contains("SYSCAT.VIEWS")); + Assert.assertFalse("SQL must not select TABNAME (column does not exist on SYSCAT.VIEWS)", + sql.toUpperCase().contains("TABNAME")); } /** diff --git a/server/odc-migrate/src/main/resources/migrate/common/R_2_0_0__initialize_version_diff_config.sql b/server/odc-migrate/src/main/resources/migrate/common/R_2_0_0__initialize_version_diff_config.sql index 04c7dbb484..feac4678ca 100644 --- a/server/odc-migrate/src/main/resources/migrate/common/R_2_0_0__initialize_version_diff_config.sql +++ b/server/odc-migrate/src/main/resources/migrate/common/R_2_0_0__initialize_version_diff_config.sql @@ -275,4 +275,13 @@ insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min 'bit:NUMERIC, tinyint:NUMERIC, smallint:NUMERIC, int:NUMERIC, bigint:NUMERIC, decimal:NUMERIC, numeric:NUMERIC, float:NUMERIC, real:NUMERIC, money:NUMERIC, smallmoney:NUMERIC, char:TEXT, varchar:TEXT, nchar:TEXT, nvarchar:TEXT, text:OBJECT, ntext:OBJECT, binary:TEXT, varbinary:TEXT, image:OBJECT, date:DATE, time:TIME, datetime:DATETIME, datetime2:DATETIME, smalldatetime:DATETIME, datetimeoffset:TIMESTAMP, timestamp:OBJECT, uniqueidentifier:OBJECT, xml:OBJECT, sql_variant:OBJECT, hierarchyid:OBJECT, geography:OBJECT, geometry:OBJECT', '0', CURRENT_TIMESTAMP) ON DUPLICATE KEY update `config_key`=`config_key`; insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`) values('support_view','SQL_SERVER','true','0',CURRENT_TIMESTAMP) ON DUPLICATE KEY update `config_key`=`config_key`; insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`) values('support_procedure','SQL_SERVER','true','0',CURRENT_TIMESTAMP) ON DUPLICATE KEY update `config_key`=`config_key`; -insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`) values('support_function','SQL_SERVER','true','0',CURRENT_TIMESTAMP) ON DUPLICATE KEY update `config_key`=`config_key`; \ No newline at end of file +insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`) values('support_function','SQL_SERVER','true','0',CURRENT_TIMESTAMP) ON DUPLICATE KEY update `config_key`=`config_key`; + +-- support DB2 datasource (fix-I, Issue dms-ee#839) +-- enableView is the front-end gate for the "视图" tree node under each DB2 schema (case 2.4). +-- Without this row VersionDiffConfigService#getSupportFeatures returns an empty supports[] for +-- the DB2 ConnectType and the odc-client resource tree silently omits the view category even +-- after the ViewExtensionPoint is registered in schema-plugin-db2. min_version='0' mirrors the +-- SQL_SERVER pattern (always-on) — DB2 view metadata lives in SYSCAT.VIEWS across all DB2 11.5+ +-- builds we support. +insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`) values('support_view','DB2','true','0',CURRENT_TIMESTAMP) ON DUPLICATE KEY update `config_key`=`config_key`; \ No newline at end of file diff --git a/server/plugins/schema-plugin-db2/pom.xml b/server/plugins/schema-plugin-db2/pom.xml index d531689057..e38f27b1f1 100644 --- a/server/plugins/schema-plugin-db2/pom.xml +++ b/server/plugins/schema-plugin-db2/pom.xml @@ -47,6 +47,21 @@ com.oceanbase schema-plugin-ob-mysql + + junit + junit + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-inline + test + diff --git a/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2ViewExtension.java b/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2ViewExtension.java new file mode 100644 index 0000000000..881613da9c --- /dev/null +++ b/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2ViewExtension.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.odc.plugin.schema.db2; + +import java.sql.Connection; + +import org.pf4j.Extension; + +import com.oceanbase.odc.common.util.JdbcOperationsUtil; +import com.oceanbase.odc.core.shared.constant.DialectType; +import com.oceanbase.odc.plugin.schema.db2.utils.DBAccessorUtil; +import com.oceanbase.odc.plugin.schema.obmysql.OBMySQLViewExtension; +import com.oceanbase.tools.dbbrowser.DBBrowser; +import com.oceanbase.tools.dbbrowser.editor.DBObjectOperator; +import com.oceanbase.tools.dbbrowser.editor.db2.Db2ObjectOperator; +import com.oceanbase.tools.dbbrowser.model.DBView; +import com.oceanbase.tools.dbbrowser.schema.DBSchemaAccessor; +import com.oceanbase.tools.dbbrowser.template.DBObjectTemplate; + +/** + * DB2 view extension (fix-I). + * + *

+ * Registers the {@code ViewExtensionPoint} pf4j extension so the v1 view controller + * ({@code /api/v1/view/list/{sid}}) stops returning + * {@code "Feature extension point is not supported for DB2"} and the front-end renders the "视图" + * category node under each DB2 schema (case 2.4 view-list regression). + * + *

+ * Inherits from {@link OBMySQLViewExtension} to reuse the {@code list}/{@code listSystemViews}/ + * {@code getDetail}/{@code drop}/{@code generateCreateTemplate} method bodies that delegate to + * {@code SchemaAccessor}/{@code Operator}/{@code Template}, and overrides three protected hooks: + * + *

    + *
  1. {@link #getSchemaAccessor(Connection)} — routes via + * {@code DBAccessorUtil.getSchemaAccessor(connection)} to the DB2-dialect schema accessor + * (initially added by feat-839 commit-B; see + * {@code Db2SchemaAccessor#listViews/listAllUserViews/showSystemViews + * /getView}). + *
  2. {@link #getOperator(Connection)} — uses {@link Db2ObjectOperator}, which emits DB2-style + * double-quoted identifiers (DB2 11.5 requires {@code "} not the MySQL backtick); reuses the + * already-implemented {@code drop(DBObjectType.VIEW, schema, name)} path. + *
  3. {@link #getTemplate()} — keeps the OB-MySQL view template factory because the + * {@code MySQLViewTemplate} produces a generic SELECT scaffold the front-end uses for the "create + * view" wizard. Routing through {@code buildForDB2()} on the factory would throw + * {@code UnsupportedOperationException} (DB2 has its own DDL we don't yet emit). The scaffold is + * literally a {@code "select * from ..."} starter — DB2-compatible by construction — but DDL-driven + * features (view create / view DDL) remain out of scope per design.md §6. + *
+ * + * @author actiontech-zihan + * @since 4.3.4 (Issue dms-ee#839, fix-I) + */ +@Extension +public class Db2ViewExtension extends OBMySQLViewExtension { + + @Override + protected DBSchemaAccessor getSchemaAccessor(Connection connection) { + return DBAccessorUtil.getSchemaAccessor(connection); + } + + @Override + protected DBObjectOperator getOperator(Connection connection) { + return new Db2ObjectOperator(JdbcOperationsUtil.getJdbcOperations(connection)); + } + + @Override + protected DBObjectTemplate getTemplate() { + // The DB-Browser view template factory throws UnsupportedOperationException for + // buildForDB2(); fall back to the generic MySQL view scaffold (a plain "select * from ..." + // starter) so the "create view" UI surface — if reachable — gets a syntactically valid + // skeleton instead of crashing. Real DB2 view DDL is out of scope per design.md §6. + return DBBrowser.objectTemplate().viewTemplate() + .setType(DialectType.OB_MYSQL.getDBBrowserDialectTypeName()).create(); + } + +} diff --git a/server/plugins/schema-plugin-db2/src/test/java/com/oceanbase/odc/plugin/schema/db2/Db2ViewExtensionTest.java b/server/plugins/schema-plugin-db2/src/test/java/com/oceanbase/odc/plugin/schema/db2/Db2ViewExtensionTest.java new file mode 100644 index 0000000000..98b0ec043d --- /dev/null +++ b/server/plugins/schema-plugin-db2/src/test/java/com/oceanbase/odc/plugin/schema/db2/Db2ViewExtensionTest.java @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.odc.plugin.schema.db2; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.sql.Connection; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.pf4j.Extension; + +import com.oceanbase.odc.plugin.schema.obmysql.OBMySQLViewExtension; +import com.oceanbase.tools.dbbrowser.editor.DBObjectOperator; +import com.oceanbase.tools.dbbrowser.editor.db2.Db2ObjectOperator; +import com.oceanbase.tools.dbbrowser.model.DBObjectIdentity; +import com.oceanbase.tools.dbbrowser.model.DBObjectType; +import com.oceanbase.tools.dbbrowser.model.DBView; +import com.oceanbase.tools.dbbrowser.schema.DBSchemaAccessor; +import com.oceanbase.tools.dbbrowser.template.DBObjectTemplate; + +/** + * Mock-only unit tests for {@link Db2ViewExtension} (fix-I). + * + *

+ * Why this exists: fix-H added {@link Db2TableExtension} but did not register a + * {@code ViewExtensionPoint}, so the v1 view controller — invoked by the ODC front-end to populate + * the "视图" tree node under each schema — fell into {@link OdcPluginManager#getSingleton}'s empty + * branch and threw {@code "Feature extension point is not supported for DB2"}. fix-I closes that + * gap. These tests pin three contracts the front-end depends on: + *

    + *
  1. {@code list/listSystemViews/getDetail} delegate to the DB2 dialect {@link DBSchemaAccessor} + * (already implemented in {@code Db2SchemaAccessor}; bug-fix should not duplicate SQL here). + *
  2. {@code drop} routes through {@link Db2ObjectOperator} so the emitted DDL uses double-quoted + * identifiers (DB2 grammar), not the MySQL backtick form. + *
  3. {@code generateCreateTemplate} does not throw — i.e. the inherited + * {@code getTemplate().generateCreateObjectTemplate(view)} path uses the OB-MySQL view template + * factory (which yields a valid SELECT scaffold) instead of falling into + * {@code DBViewTemplateFactory#buildForDB2()} which throws {@code UnsupportedOperationException}. + *
+ * + *

+ * No real JDBC connection is opened; the {@link DBSchemaAccessor} and {@link DBObjectOperator} + * dependencies are overridden via a test subclass so we never hit + * {@code com.ibm.db2.jcc.DB2Driver}. + * + * @author actiontech-zihan + * @since 4.3.4 (Issue dms-ee#839, fix-I) + */ +public class Db2ViewExtensionTest { + + private static final String SCHEMA = "DB2INST1"; + private static final String VIEW = "V_TEST_ORDERS_SUMMARY"; + + private DBSchemaAccessor schemaAccessor; + private DBObjectOperator operator; + private TestableDb2ViewExtension extension; + private Connection connection; + + @Before + public void setUp() { + schemaAccessor = mock(DBSchemaAccessor.class); + operator = mock(DBObjectOperator.class); + connection = mock(Connection.class); + extension = new TestableDb2ViewExtension(schemaAccessor, operator); + } + + @Test + public void list_delegatesToSchemaAccessorListViews() { + DBObjectIdentity v1 = DBObjectIdentity.of(SCHEMA, DBObjectType.VIEW, VIEW); + DBObjectIdentity v2 = DBObjectIdentity.of(SCHEMA, DBObjectType.VIEW, "V_DUMMY"); + when(schemaAccessor.listViews(eq(SCHEMA))).thenReturn(Arrays.asList(v1, v2)); + + List result = extension.list(connection, SCHEMA); + + Assert.assertEquals(2, result.size()); + Assert.assertEquals(VIEW, result.get(0).getName()); + Assert.assertEquals(DBObjectType.VIEW, result.get(0).getType()); + verify(schemaAccessor, times(1)).listViews(SCHEMA); + } + + @Test + public void list_emptyAccessorResult_returnsEmptyList() { + when(schemaAccessor.listViews(eq(SCHEMA))).thenReturn(Collections.emptyList()); + + List result = extension.list(connection, SCHEMA); + + Assert.assertNotNull(result); + Assert.assertTrue(result.isEmpty()); + verify(schemaAccessor, times(1)).listViews(SCHEMA); + } + + @Test + public void listSystemViews_delegatesToShowSystemViews() { + when(schemaAccessor.showSystemViews(eq("SYSCAT"))) + .thenReturn(Arrays.asList("TABLES", "COLUMNS")); + + List result = extension.listSystemViews(connection, "SYSCAT"); + + Assert.assertEquals(Arrays.asList("TABLES", "COLUMNS"), result); + verify(schemaAccessor, times(1)).showSystemViews("SYSCAT"); + } + + @Test + public void getDetail_delegatesToGetView() { + DBView stub = new DBView(); + stub.setViewName(VIEW); + stub.setSchemaName(SCHEMA); + when(schemaAccessor.getView(eq(SCHEMA), eq(VIEW))).thenReturn(stub); + + DBView result = extension.getDetail(connection, SCHEMA, VIEW); + + Assert.assertNotNull(result); + Assert.assertEquals(VIEW, result.getViewName()); + Assert.assertEquals(SCHEMA, result.getSchemaName()); + verify(schemaAccessor, times(1)).getView(SCHEMA, VIEW); + } + + @Test + public void getDetail_accessorReturnsNull_extensionReturnsNull() { + // Db2SchemaAccessor#getView returns null by design — confirm extension does not NPE. + when(schemaAccessor.getView(eq(SCHEMA), eq(VIEW))).thenReturn(null); + + DBView result = extension.getDetail(connection, SCHEMA, VIEW); + + Assert.assertNull(result); + } + + @Test + public void drop_routesThroughOperatorWithViewType() { + // schemaName intentionally passed null to mirror the inherited contract + // (OBMySQLViewExtension.drop ignores schemaName when calling operator.drop). + extension.drop(connection, SCHEMA, VIEW); + + verify(operator, times(1)).drop(eq(DBObjectType.VIEW), eq((String) null), eq(VIEW)); + verify(schemaAccessor, never()).listViews(SCHEMA); + } + + @Test + public void generateCreateTemplate_doesNotFallIntoBuildForDB2() { + // The fix uses the OB-MySQL template factory; calling generateCreateTemplate on a fresh + // Db2ViewExtension must not throw UnsupportedOperationException (which is what + // DBViewTemplateFactory#buildForDB2 raises). + DBView view = new DBView(); + view.setViewName("V_DUMMY"); + // The MySQL view template emits a "create or replace view ..." scaffold that doesn't need + // any view units; we don't assert on the body — only that the path doesn't blow up. + // Use a fresh extension instance (no overrides on getTemplate) so this exercises the real + // production code in Db2ViewExtension. + Db2ViewExtension real = new Db2ViewExtension(); + String sql = real.generateCreateTemplate(view); + Assert.assertNotNull(sql); + Assert.assertFalse("Template SQL must be non-blank", sql.trim().isEmpty()); + } + + @Test + public void classIsAnnotatedWithPf4jExtension() { + // pf4j discovers extensions by @Extension annotation + META-INF/extensions.idx — without + // the annotation the OdcPluginManager.getSingleton path will still hit + // "Feature extension point is not supported for DB2". + Assert.assertNotNull( + "Db2ViewExtension must carry @Extension so pf4j auto-registers it", + Db2ViewExtension.class.getAnnotation(Extension.class)); + } + + @Test + public void inheritsFromOBMySQLViewExtension() { + // We intentionally inherit so the list/listSystemViews/getDetail/drop/generateCreateTemplate + // method bodies stay shared; only the three protected hooks (getSchemaAccessor, + // getOperator, getTemplate) are overridden. + Assert.assertTrue( + "Db2ViewExtension must inherit from OBMySQLViewExtension to reuse the delegation skeleton", + OBMySQLViewExtension.class.isAssignableFrom(Db2ViewExtension.class)); + } + + /** + * Subclass that bypasses the {@code DBAccessorUtil} / {@code Db2ObjectOperator} construction by + * returning pre-built mocks. Lets us test the public methods of {@link OBMySQLViewExtension} (which + * the production class inherits) without opening any JDBC connection. + */ + private static class TestableDb2ViewExtension extends Db2ViewExtension { + private final DBSchemaAccessor accessor; + private final DBObjectOperator op; + + TestableDb2ViewExtension(DBSchemaAccessor accessor, DBObjectOperator op) { + this.accessor = accessor; + this.op = op; + } + + @Override + protected DBSchemaAccessor getSchemaAccessor(Connection connection) { + return accessor; + } + + @Override + protected DBObjectOperator getOperator(Connection connection) { + return op; + } + + @Override + protected DBObjectTemplate getTemplate() { + // Not used by tests other than generateCreateTemplate_doesNotFallIntoBuildForDB2, + // which instantiates a fresh Db2ViewExtension to exercise the real path. + return super.getTemplate(); + } + } +} From e3ea5612603a28f6776139458a956d45809db5e1 Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Tue, 19 May 2026 19:47:16 +0000 Subject: [PATCH 12/19] fix(odc-service): handle DB2 dialect in ConnectConsoleService.queryData (and peer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix-I bug F: ConnectConsoleService.queryTableOrViewData threw IllegalArgumentException("Unsupported dialect type, DB2") on the very first call because its dialect-routing if/else chain covered isMysql / isOracle / isDm / isDoris / isTidb / isSqlServer but not isDb2. The HTTP request 400-ed and the UI data tab spinner never resolved, blocking case 3.1 (table data pagination + type echo) and case 3.2 (CLOB/BLOB cell view). Changes: * ConnectConsoleService.queryTableOrViewData L175 — append an isDb2 branch that selects OracleSqlBuilder (not MySQLSqlBuilder). Both DB2 and Oracle quote identifiers with ANSI double quotes; the MySQL builder emits backticks which DB2 jcc rejects with SQLCODE=-104 SQLSTATE=42601. * ConnectConsoleService.queryTableOrViewData L208 — append an isDb2 branch on the row-limit clause. DB2 uses ANSI "FETCH FIRST n ROWS ONLY", not MySQL-style "LIMIT n". Although DB2 11.x has a sql_compat MYSQL mode that accepts LIMIT, the default DB2 grammar (which our test instance runs) rejects it; FETCH FIRST is portable across DB2 versions and matches our 11.5 test bed. * DataConverters.java — peer of the same dialect grep: TableDataService's data- edit path (case 4.x) routes DB2 through MySQLDMLBuilder.toSQLString, which delegates to DataConvertUtil → DataConverters. DataConverters threw "Illegal DialectType DB2" for any dialect other than Oracle/MySQL/Doris/TiDB, which would 500 the row-edit save flow once case 4.x runs. Reuse initForMysqlMode for DB2 — per design.md §2.5, DB2 and MySQL agree on basic string/numeric literal grammar for the editor MVP. Verified by global grep of dialectType.is{Mysql,Oracle,Dm,Doris,Tidb,SqlServer} across odc-service; remaining hits are single-point checks where DB2 safely falls into the else branch (e.g. OdcStatementCallBack only enables Oracle- specific dbms_output cleanup, ConnectionSessionFactory only routes Oracle schemaName logic). Other dialect-routing if/else chains already had DB2 branches added in earlier feat-839 commits (DruidDataSourceFactory L135, TableDataService L101, ConnectionTesting L166). Issue: dms-ee#839 Fixes case 3.1 / 3.2 (and unblocks case 4.x edit path) exposed by fix-H round complementary test. --- .../odc/service/dml/converter/DataConverters.java | 9 +++++++++ .../odc/service/session/ConnectConsoleService.java | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/server/odc-service/src/main/java/com/oceanbase/odc/service/dml/converter/DataConverters.java b/server/odc-service/src/main/java/com/oceanbase/odc/service/dml/converter/DataConverters.java index 965384be6a..bd8b7c9f0c 100644 --- a/server/odc-service/src/main/java/com/oceanbase/odc/service/dml/converter/DataConverters.java +++ b/server/odc-service/src/main/java/com/oceanbase/odc/service/dml/converter/DataConverters.java @@ -48,6 +48,15 @@ private DataConverters(@NonNull DialectType dialectType, String serverTimeZoneId initForMysqlMode(); } else if (dialectType.isTidb()) { initForMysqlMode(); + } else if (dialectType.isDb2()) { + // fix-I bug F (peer of ConnectConsoleService): TableDataService#editTableData routes + // through MySQLDMLBuilder for DB2 (design.md §2.5 — DB2 and MySQL agree on basic + // identifier/string quoting for the editor MVP). When the resulting toSQLString call + // lands here, DialectType.DB2 would hit the default "Illegal DialectType" branch and + // sink the data-edit save path with a 500. Reuse the MySQL converter set so VARCHAR / + // numeric / blob conversions produce DB2-compatible literals (DB2's string/numeric + // literal grammar is a strict superset of MySQL's in the columns we round-trip). + initForMysqlMode(); } else { throw new IllegalArgumentException("Illegal DialectType " + dialectType); } diff --git a/server/odc-service/src/main/java/com/oceanbase/odc/service/session/ConnectConsoleService.java b/server/odc-service/src/main/java/com/oceanbase/odc/service/session/ConnectConsoleService.java index f67992d65f..b1c84b5930 100644 --- a/server/odc-service/src/main/java/com/oceanbase/odc/service/session/ConnectConsoleService.java +++ b/server/odc-service/src/main/java/com/oceanbase/odc/service/session/ConnectConsoleService.java @@ -172,6 +172,14 @@ public SqlExecuteResult queryTableOrViewData(@NotNull String sessionId, sqlBuilder = new MySQLSqlBuilder(); } else if (dialectType.isSqlServer()) { sqlBuilder = new SqlServerSqlBuilder(); + } else if (dialectType.isDb2()) { + // fix-I bug F: DB2 11.5 uses ANSI-style identifier quoting (double quotes), exactly the + // same as Oracle. The MySQLSqlBuilder.identifier(...) emits backtick-quoted names which + // DB2 jcc rejects with SQLCODE=-104 (unexpected token); reuse OracleSqlBuilder so the + // identifier() / schemaPrefixIfNotBlank() paths produce {@code "DB2INST1"."TEST_ORDERS"} + // — a valid DB2 SELECT. Without this branch the request 400s with "Unsupported dialect + // type, DB2" and the data tab spinner never resolves. + sqlBuilder = new OracleSqlBuilder(); } else { throw new IllegalArgumentException("Unsupported dialect type, " + dialectType); } @@ -197,6 +205,12 @@ public SqlExecuteResult queryTableOrViewData(@NotNull String sessionId, } else if (DialectType.ORACLE == connectionSession.getDialectType() || DialectType.DM == connectionSession.getDialectType()) { sqlBuilder.append(" WHERE ROWNUM <= ").append(queryLimit.toString()); + } else if (connectionSession.getDialectType().isDb2()) { + // fix-I bug F: DB2 uses ANSI {@code FETCH FIRST n ROWS ONLY}, not MySQL-style LIMIT. + // Although DB2 11.x has a sql_compat MYSQL mode that accepts LIMIT, the default DB2 + // grammar rejects it with SQLCODE=-104 SQLSTATE=42601. The FETCH FIRST form is portable + // across DB2 versions and matches what we test against (11.5). + sqlBuilder.append(" FETCH FIRST ").append(queryLimit.toString()).append(" ROWS ONLY"); } else if (DialectType.SQL_SERVER != connectionSession.getDialectType()) { // SQL Server already uses TOP clause, skip LIMIT sqlBuilder.append(" LIMIT ").append(queryLimit.toString()); From f24d7c8a0792c7fa59c9b30978af61543bef4699 Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Wed, 20 May 2026 03:17:43 +0000 Subject: [PATCH 13/19] fix(odc-core): route DB2 character LOB columns through Clob to avoid jcc -4461 DB2 jcc rejects ResultSet#getBinaryStream() on CLOB / DBCLOB / NCLOB columns with ERRORCODE=-4461 / SQLSTATE=42815 ("data conversion invalid: result column type wrong"). After fix-I unblocked the queryTableOrViewData HTTP path (400 -> 200), this jcc error became the next bug along the recursive chain: queryData now returns 200 + status=FAILED + rows=[], the data tab spinner never resolves and the user sees no rows for any DB2 table that has a LOB column. Root cause: GeneralLobMapper.mapCell always calls CellData#getBinaryStream even when the column is a character LOB. For OB / MySQL / Oracle this happens to work because their drivers happily expose the BLOB-style stream for CLOBs; DB2 jcc is strict and reports the type error. Fix (odc-core layer, not plugin-specific): * GeneralLobMapper now classifies columns by typeName. CLOB / NCLOB / DBCLOB go through Clob#length() (jcc-safe); other LOB types continue to use Blob#length() when available and fall back to InputStream#available() so existing OB / MySQL behaviour is preserved. * GeneralLobMapper now recognises DBCLOB so the DB2 double-byte CLOB column is treated as a LOB instead of being rendered via getString. * ResultSetCachedElementFactory.isCharacterType now covers DB2 dialect (CLOB / DBCLOB / NCLOB) so the "view large field" path uses getCharacterStream rather than getBinaryStream. * DataTypeUtil.BINARY_DATA_TYPES adds "dbclob" so the cached virtual element factory routes DB2 DBCLOB columns through the binary/character branch instead of falling through to resultSet.getObject. Tests: * GeneralLobMapperTest gains four new cases covering Blob handle preferred path, Clob length path, DBCLOB Clob length path, and zero-length Clob. * Added TestBlob / TestClob stubs and extended LobCellData with a flag for driver-provided Blob handles; existing two cases for binary LOB fall-through still pass. All 14 GeneralLobMapperTest cases pass locally. OracleBinaryNumberMapperTest and OracleTimeStampMapperTest remain on the baseline failure list (Oracle driver ClassDef missing, unrelated to this fix, verified via git stash double-run). Refs https://github.com/actiontech/dms-ee/issues/839 --- .../model/datatype/DataTypeUtil.java | 3 + .../cache/ResultSetCachedElementFactory.java | 9 +- .../sql/execute/mapper/GeneralLobMapper.java | 86 ++++++++++++--- .../execute/mapper/GeneralLobMapperTest.java | 54 +++++++++- .../core/sql/execute/tool/LobCellData.java | 35 +++++- .../odc/core/sql/execute/tool/TestBlob.java | 90 ++++++++++++++++ .../odc/core/sql/execute/tool/TestClob.java | 102 ++++++++++++++++++ 7 files changed, 358 insertions(+), 21 deletions(-) create mode 100644 server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/tool/TestBlob.java create mode 100644 server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/tool/TestClob.java diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/model/datatype/DataTypeUtil.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/model/datatype/DataTypeUtil.java index 908b32cc39..b560cf45d2 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/model/datatype/DataTypeUtil.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/model/datatype/DataTypeUtil.java @@ -36,6 +36,9 @@ public class DataTypeUtil { "blob", "clob", "nclob", + // fix-K: DB2 double-byte character LOB; treated as a LOB so the cached + // virtual element factory streams it instead of calling getString(). + "dbclob", "raw", "longblob", "mediumblob", diff --git a/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/cache/ResultSetCachedElementFactory.java b/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/cache/ResultSetCachedElementFactory.java index 84605128b5..682b80acb2 100644 --- a/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/cache/ResultSetCachedElementFactory.java +++ b/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/cache/ResultSetCachedElementFactory.java @@ -107,10 +107,17 @@ private boolean isCharacterType(String dataType) { if (StringUtils.isBlank(dataType) || dialectType == null) { return false; } + String upperType = dataType.toUpperCase(); if (dialectType.isOracle() || dialectType == DialectType.OB_ORACLE) { - String upperType = dataType.toUpperCase(); return upperType.contains("CLOB"); } + if (dialectType.isDb2()) { + // fix-K: DB2 character LOB columns must be read via getCharacterStream. + // jcc rejects getBinaryStream() on CLOB / DBCLOB / NCLOB with + // ERRORCODE=-4461 (SQLSTATE=42815, "result column type wrong"). + return upperType.equals("CLOB") || upperType.equals("DBCLOB") + || upperType.equals("NCLOB"); + } return false; } diff --git a/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/GeneralLobMapper.java b/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/GeneralLobMapper.java index 1bf8ef96f4..cc21cb92be 100644 --- a/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/GeneralLobMapper.java +++ b/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/GeneralLobMapper.java @@ -17,6 +17,8 @@ import java.io.IOException; import java.io.InputStream; +import java.sql.Blob; +import java.sql.Clob; import java.sql.SQLException; import java.util.Arrays; import java.util.List; @@ -31,27 +33,89 @@ *

  *     {@code blob}
  *     {@code clob}
+ *     {@code nclob}
+ *     {@code dbclob}
  *     {@code tinyblob}
  *     {@code longblob}
  *     {@code mediumblob}
  * 
+ * + *

+ * fix-K: DB2 jcc 对 CLOB / DBCLOB / NCLOB 列调用 {@link java.sql.ResultSet#getBinaryStream(int)} 抛 + * {@code ERRORCODE=-4461, SQLSTATE=42815}(数据转换无效:所请求转换的结果列类型错误)。本 mapper 现在按列的实际类别(character LOB vs + * binary LOB)分别走 {@link Clob#length()} / {@link Blob#length()}(或对应 stream),保证与 jcc 类型系统兼容;MySQL / + * Oracle / OB 的 BLOB 行为不变。 + *

*/ public class GeneralLobMapper implements JdbcColumnMapper { private final static List CANDIDATE_TYPES = - Arrays.asList("BLOB", "CLOB", "TINYBLOB", "MEDIUMBLOB", "LONGBLOB"); + Arrays.asList("BLOB", "CLOB", "NCLOB", "DBCLOB", "TINYBLOB", "MEDIUMBLOB", "LONGBLOB"); + /** + * fix-K: character-LOB types whose JDBC drivers MAY reject {@code getBinaryStream}. For these we go + * through {@link Clob} so DB2 jcc stays happy and the size reported to the UI reflects the + * character (not byte) length, matching the column semantics. + */ + private final static List CHARACTER_LOB_TYPES = + Arrays.asList("CLOB", "NCLOB", "DBCLOB"); private final static int KB = 1024; private final static int MB = KB * 1024; private final static int GB = MB * 1024; @Override public Object mapCell(@NonNull CellData data) throws SQLException, IOException { - InputStream inputStream = data.getBinaryStream(); - if (inputStream == null) { - return null; + String typeName = data.getDataType().getDataTypeName(); + long size; + if (isCharacterLob(typeName)) { + // fix-K: DB2 jcc rejects getBinaryStream() on CLOB / DBCLOB columns + // (ERRORCODE=-4461, SQLSTATE=42815). Use Clob#length() instead. + Clob clob = data.getClob(); + if (clob == null) { + return null; + } + size = clob.length(); + } else { + // Binary LOBs: prefer Blob#length() when available (cheaper and safer), + // fall back to InputStream#available() if the driver returns no Blob handle. + Blob blob = data.getBlob(); + if (blob != null) { + size = blob.length(); + } else { + InputStream inputStream = data.getBinaryStream(); + if (inputStream == null) { + return null; + } + size = inputStream.available(); + } + } + return formatSize(typeName, size); + } + + @Override + public boolean supports(@NonNull DataType dataType) { + for (String type : CANDIDATE_TYPES) { + if (type.equalsIgnoreCase(dataType.getDataTypeName())) { + return true; + } + } + return false; + } + + private boolean isCharacterLob(String dataTypeName) { + if (dataTypeName == null) { + return false; } + for (String type : CHARACTER_LOB_TYPES) { + if (type.equalsIgnoreCase(dataTypeName)) { + return true; + } + } + return false; + } + + private static String formatSize(String dataTypeName, long size) { + long available = size; String unit = "B"; - int available = inputStream.available(); if (available > GB) { available = available >> 30; unit = "GB"; @@ -62,17 +126,7 @@ public Object mapCell(@NonNull CellData data) throws SQLException, IOException { available = available >> 10; unit = "KB"; } - return String.format("(%s) %d %s", data.getDataType().getDataTypeName(), available, unit); - } - - @Override - public boolean supports(@NonNull DataType dataType) { - for (String type : CANDIDATE_TYPES) { - if (type.equalsIgnoreCase(dataType.getDataTypeName())) { - return true; - } - } - return false; + return String.format("(%s) %d %s", dataTypeName, available, unit); } } diff --git a/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/GeneralLobMapperTest.java b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/GeneralLobMapperTest.java index b03c393704..a8236e6c33 100644 --- a/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/GeneralLobMapperTest.java +++ b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/GeneralLobMapperTest.java @@ -39,6 +39,7 @@ public void mapCell_nonNullInputStream_returnRightValue() throws IOException, SQ DataTypeFactory factory = new CommonDataTypeFactory("blob"); DataType dataType = factory.generate(); GeneralLobMapper mapper = new GeneralLobMapper(); + // No Blob handle exposed → falls back to InputStream#available() Assert.assertEquals("(blob) 12 B", mapper.mapCell(new LobCellData(12, dataType))); } @@ -50,6 +51,43 @@ public void mapCell_nullInputStream_returnNull() throws IOException, SQLExceptio Assert.assertNull(mapper.mapCell(new LobCellData(-1, dataType))); } + @Test + public void mapCell_blobWithHandle_returnsBlobLength() throws IOException, SQLException { + // fix-K: prefer Blob#length() over InputStream#available() when the driver gives one. + DataTypeFactory factory = new CommonDataTypeFactory("blob"); + DataType dataType = factory.generate(); + GeneralLobMapper mapper = new GeneralLobMapper(); + Assert.assertEquals("(blob) 7 B", mapper.mapCell(new LobCellData(7L, dataType, true))); + } + + @Test + public void mapCell_clob_usesClobLengthInsteadOfBinaryStream() throws IOException, SQLException { + // fix-K: DB2 jcc throws ERRORCODE=-4461 (SQLSTATE=42815) when binary stream is requested + // for a CLOB column. The mapper must go through Clob#length() instead. + DataTypeFactory factory = new CommonDataTypeFactory("clob"); + DataType dataType = factory.generate(); + GeneralLobMapper mapper = new GeneralLobMapper(); + Assert.assertEquals("(clob) 33 B", mapper.mapCell(new LobCellData(33L, dataType, false))); + } + + @Test + public void mapCell_dbclob_db2DoubleByteCharacterLob_usesClobLength() throws IOException, SQLException { + // fix-K: DB2-only DBCLOB (double-byte CLOB) must also avoid getBinaryStream(); jcc returns + // the same -4461 / 42815 error code for any character-LOB type. + DataTypeFactory factory = new CommonDataTypeFactory("dbclob"); + DataType dataType = factory.generate(); + GeneralLobMapper mapper = new GeneralLobMapper(); + Assert.assertEquals("(dbclob) 9 B", mapper.mapCell(new LobCellData(9L, dataType, false))); + } + + @Test + public void mapCell_clobZeroLength_returnsZeroByteText() throws IOException, SQLException { + DataTypeFactory factory = new CommonDataTypeFactory("clob"); + DataType dataType = factory.generate(); + GeneralLobMapper mapper = new GeneralLobMapper(); + Assert.assertEquals("(clob) 0 B", mapper.mapCell(new LobCellData(0L, dataType, false))); + } + @Test public void supports_blob_supports() throws IOException, SQLException { GeneralLobMapper mapper = new GeneralLobMapper(); @@ -64,6 +102,21 @@ public void supports_clob_supports() throws IOException, SQLException { Assert.assertTrue(mapper.supports(factory.generate())); } + @Test + public void supports_dbclob_supports() throws IOException, SQLException { + // fix-K: DB2 DBCLOB now recognized so the data tab handles double-byte LOBs. + GeneralLobMapper mapper = new GeneralLobMapper(); + DataTypeFactory factory = new CommonDataTypeFactory("dbclob"); + Assert.assertTrue(mapper.supports(factory.generate())); + } + + @Test + public void supports_nclob_supports() throws IOException, SQLException { + GeneralLobMapper mapper = new GeneralLobMapper(); + DataTypeFactory factory = new CommonDataTypeFactory("nclob"); + Assert.assertTrue(mapper.supports(factory.generate())); + } + @Test public void supports_mediumblob_supports() throws IOException, SQLException { GeneralLobMapper mapper = new GeneralLobMapper(); @@ -93,4 +146,3 @@ public void supports_timestamp_notSupports() throws IOException, SQLException { } } - diff --git a/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/tool/LobCellData.java b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/tool/LobCellData.java index 7801283a0d..1d22802e96 100644 --- a/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/tool/LobCellData.java +++ b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/tool/LobCellData.java @@ -17,6 +17,9 @@ import java.io.ByteArrayInputStream; import java.io.InputStream; +import java.sql.Blob; +import java.sql.Clob; +import java.sql.SQLException; import com.oceanbase.tools.dbbrowser.model.datatype.DataType; @@ -24,20 +27,46 @@ public class LobCellData extends TestCellData { - private final int streamSize; + private final long streamSize; + /** + * fix-K: lets unit tests decide whether the driver hands out a {@link Blob}/{@link Clob} handle + * (cheap length lookup) or only an {@link InputStream}. DB2 jcc returns a real {@link Clob} for + * CLOB / DBCLOB columns; MySQL / OB return {@link Blob} for binary LOBs. + */ + private final boolean lobHandleAvailable; public LobCellData(int streamSize, @NonNull DataType dataType) { + this(streamSize, dataType, false); + } + + public LobCellData(long streamSize, @NonNull DataType dataType, boolean lobHandleAvailable) { super(dataType); this.streamSize = streamSize; + this.lobHandleAvailable = lobHandleAvailable; } + @Override public InputStream getBinaryStream() { if (streamSize <= 0) { return null; } - return new ByteArrayInputStream(new byte[streamSize]); + return new ByteArrayInputStream(new byte[(int) streamSize]); } -} + @Override + public Blob getBlob() throws SQLException { + if (!lobHandleAvailable || streamSize <= 0) { + return null; + } + return new TestBlob(streamSize); + } + @Override + public Clob getClob() throws SQLException { + if (streamSize < 0) { + return null; + } + return new TestClob(streamSize); + } +} diff --git a/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/tool/TestBlob.java b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/tool/TestBlob.java new file mode 100644 index 0000000000..dbf3c4c473 --- /dev/null +++ b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/tool/TestBlob.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.odc.core.sql.execute.tool; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.sql.Blob; +import java.sql.SQLException; + +/** + * Minimal {@link Blob} stub used by {@code GeneralLobMapperTest}; reports a fixed length and + * otherwise returns empty content. We can't reuse {@code java.sql.rowset.serial.SerialBlob} because + * it doesn't let tests set a length larger than 2GB and it eagerly allocates the byte array, which + * is wasteful for size-based assertions. + */ +public class TestBlob implements Blob { + + private final long length; + + public TestBlob(long length) { + this.length = length; + } + + @Override + public long length() { + return length; + } + + @Override + public byte[] getBytes(long pos, int length) { + return new byte[length]; + } + + @Override + public InputStream getBinaryStream() { + return new ByteArrayInputStream(new byte[0]); + } + + @Override + public long position(byte[] pattern, long start) { + return -1; + } + + @Override + public long position(Blob pattern, long start) { + return -1; + } + + @Override + public int setBytes(long pos, byte[] bytes) { + throw new UnsupportedOperationException("read-only stub"); + } + + @Override + public int setBytes(long pos, byte[] bytes, int offset, int len) { + throw new UnsupportedOperationException("read-only stub"); + } + + @Override + public OutputStream setBinaryStream(long pos) { + throw new UnsupportedOperationException("read-only stub"); + } + + @Override + public void truncate(long len) { + throw new UnsupportedOperationException("read-only stub"); + } + + @Override + public void free() throws SQLException {} + + @Override + public InputStream getBinaryStream(long pos, long length) { + return new ByteArrayInputStream(new byte[0]); + } +} diff --git a/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/tool/TestClob.java b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/tool/TestClob.java new file mode 100644 index 0000000000..59922c5e55 --- /dev/null +++ b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/tool/TestClob.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.odc.core.sql.execute.tool; + +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Reader; +import java.io.StringReader; +import java.io.Writer; +import java.sql.Clob; +import java.sql.SQLException; + +/** + * fix-K: minimal {@link Clob} stub used by {@code GeneralLobMapperTest} — it only needs to report a + * length so we can assert that the mapper goes through {@link Clob#length()} rather than calling + * {@code getBinaryStream()} (which DB2 jcc rejects on CLOB columns with ERRORCODE=-4461). + */ +public class TestClob implements Clob { + + private final long length; + + public TestClob(long length) { + this.length = length; + } + + @Override + public long length() { + return length; + } + + @Override + public String getSubString(long pos, int length) { + return ""; + } + + @Override + public Reader getCharacterStream() { + return new StringReader(""); + } + + @Override + public InputStream getAsciiStream() { + throw new UnsupportedOperationException( + "fix-K: DB2 jcc rejects getAsciiStream on CLOB; tests should not call this"); + } + + @Override + public long position(String searchstr, long start) { + return -1; + } + + @Override + public long position(Clob searchstr, long start) { + return -1; + } + + @Override + public int setString(long pos, String str) { + throw new UnsupportedOperationException("read-only stub"); + } + + @Override + public int setString(long pos, String str, int offset, int len) { + throw new UnsupportedOperationException("read-only stub"); + } + + @Override + public OutputStream setAsciiStream(long pos) { + throw new UnsupportedOperationException("read-only stub"); + } + + @Override + public Writer setCharacterStream(long pos) { + throw new UnsupportedOperationException("read-only stub"); + } + + @Override + public void truncate(long len) { + throw new UnsupportedOperationException("read-only stub"); + } + + @Override + public void free() throws SQLException {} + + @Override + public Reader getCharacterStream(long pos, long length) { + return new StringReader(""); + } +} From 55fb55227bf147dd8ea48d393fa17037e6c66265 Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Wed, 20 May 2026 04:55:00 +0000 Subject: [PATCH 14/19] fix(db-browser): back-fill DB2 constraint columnNames from SYSCAT.KEYCOLUSE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix-L commit-1 (bug N1). Db2SchemaAccessor.listTableConstraints previously only read SYSCAT.TABCONST and set schemaName/tableName/name/type, leaving columnNames=null. This caused BaseDMLBuilder.getPrimaryConstraint to NPE at `for (String col : constraint.getColumnNames())` for every DB2 table that has a real PK/UK constraint — i.e. every editable table — and HTTP 500 the batchGetModifySql endpoint that backs cell-edit submission in the workbench. Fix: after pulling the constraint list from SYSCAT.TABCONST, query SYSCAT.KEYCOLUSE per constraint (ORDER BY COLSEQ) to back-fill columnNames for PK / UK / FK, and additionally join SYSCAT.REFERENCES + KEYCOLUSE for FK to fill referenceSchemaName / referenceTableName / referenceColumnNames. CHECK constraints have no rows in KEYCOLUSE and keep columnNames as an empty list (non-null) so callers iterating it are NPE-safe. Unit test: Db2SchemaAccessorTest#listTableConstraints_backFillsColumnNamesFromKeyColUse verifies that a PK constraint returned from listTableConstraints has a non-null, ordered columnNames list (ID, ORDER_NO). 14/14 tests pass. Refs https://github.com/actiontech/dms-ee/issues/839 --- .../schema/db2/Db2SchemaAccessor.java | 74 ++++++++++++++++--- .../schema/db2/Db2SchemaAccessorTest.java | 62 ++++++++++++++++ 2 files changed, 127 insertions(+), 9 deletions(-) diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java index 173378d70a..98588c7af6 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java @@ -534,17 +534,73 @@ public List listPartitionTables(String partitionMethod) { @Override public List listTableConstraints(String schemaName, String tableName) { + // fix-L (Issue dms-ee#839, bug N1): the previous implementation only filled + // schema/name/type from SYSCAT.TABCONST and left columnNames=null, which made + // BaseDMLBuilder.getPrimaryConstraint NPE (`for (String col : constraint.getColumnNames())`) + // for every DB2 table that actually has a PK/UK — i.e. all editable tables. + // + // SYSCAT.KEYCOLUSE is DB2's canonical per-constraint column list (mirrors what MySQL exposes + // via INFORMATION_SCHEMA.KEY_COLUMN_USAGE and Oracle exposes via ALL_CONS_COLUMNS). COLSEQ + // is 1-based and orders the columns inside a composite key. + // + // For foreign keys we additionally read SYSCAT.REFERENCES to fill referenceSchemaName / + // referenceTableName / referenceColumnNames so downstream DDL / lineage views aren't broken. + // CHECK constraints have no participating columns; they keep columnNames=[] and are not + // exercised by the DML builder path. String sql = "SELECT TABSCHEMA, TABNAME, CONSTNAME, TYPE FROM SYSCAT.TABCONST " + "WHERE TABSCHEMA = ? AND TABNAME = ? ORDER BY CONSTNAME"; - return jdbcOperations.query(sql, new Object[] {schemaName, tableName}, (rs, rowNum) -> { - DBTableConstraint constraint = new DBTableConstraint(); - constraint.setSchemaName(rs.getString("TABSCHEMA")); - constraint.setTableName(rs.getString("TABNAME")); - constraint.setName(rs.getString("CONSTNAME")); - String type = rs.getString("TYPE"); - constraint.setType(mapDb2ConstraintType(type)); - return constraint; - }); + List constraints = jdbcOperations.query(sql, + new Object[] {schemaName, tableName}, (rs, rowNum) -> { + DBTableConstraint constraint = new DBTableConstraint(); + constraint.setSchemaName(rs.getString("TABSCHEMA")); + constraint.setTableName(rs.getString("TABNAME")); + constraint.setName(rs.getString("CONSTNAME")); + String type = rs.getString("TYPE"); + constraint.setType(mapDb2ConstraintType(type)); + return constraint; + }); + if (constraints == null || constraints.isEmpty()) { + return constraints == null ? new ArrayList<>() : constraints; + } + // Back-fill columnNames per constraint via SYSCAT.KEYCOLUSE (covers PK / UK / FK). + String colSql = "SELECT COLNAME FROM SYSCAT.KEYCOLUSE " + + "WHERE TABSCHEMA = ? AND TABNAME = ? AND CONSTNAME = ? ORDER BY COLSEQ"; + for (DBTableConstraint c : constraints) { + // CHECK constraints have no rows in SYSCAT.KEYCOLUSE — query returns empty list, not null. + List cols = jdbcOperations.query(colSql, + new Object[] {c.getSchemaName(), c.getTableName(), c.getName()}, + (rs, rowNum) -> rs.getString("COLNAME")); + c.setColumnNames(cols == null ? new ArrayList<>() : cols); + if (c.getType() == DBConstraintType.FOREIGN_KEY) { + fillForeignKeyReference(c); + } + } + return constraints; + } + + private void fillForeignKeyReference(DBTableConstraint constraint) { + String refSql = "SELECT REFTABSCHEMA, REFTABNAME, REFKEYNAME FROM SYSCAT.REFERENCES " + + "WHERE TABSCHEMA = ? AND TABNAME = ? AND CONSTNAME = ?"; + List refs = jdbcOperations.query(refSql, + new Object[] {constraint.getSchemaName(), constraint.getTableName(), constraint.getName()}, + (rs, rowNum) -> new String[] { + rs.getString("REFTABSCHEMA"), + rs.getString("REFTABNAME"), + rs.getString("REFKEYNAME") + }); + if (refs == null || refs.isEmpty()) { + return; + } + String[] ref = refs.get(0); + constraint.setReferenceSchemaName(ref[0]); + constraint.setReferenceTableName(ref[1]); + // Look up parent-side columns by joining SYSCAT.KEYCOLUSE on the referenced PK/UK constraint. + String refColSql = "SELECT COLNAME FROM SYSCAT.KEYCOLUSE " + + "WHERE TABSCHEMA = ? AND TABNAME = ? AND CONSTNAME = ? ORDER BY COLSEQ"; + List refCols = jdbcOperations.query(refColSql, + new Object[] {ref[0], ref[1], ref[2]}, + (rs, rowNum) -> rs.getString("COLNAME")); + constraint.setReferenceColumnNames(refCols == null ? new ArrayList<>() : refCols); } private DBConstraintType mapDb2ConstraintType(String db2Type) { diff --git a/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java b/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java index 0e2fb1fe19..cdbc1765ab 100644 --- a/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java +++ b/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java @@ -239,6 +239,13 @@ public void listTableIndexes_uniqueRuleMapping() throws SQLException { /** * Case listTableConstraints_typeMapping: 模拟 SYSCAT.TABCONST 3 行 TYPE='P'/'U'/'F', 期望分别映射为 * PRIMARY_KEY / UNIQUE_KEY / FOREIGN_KEY。 + * + *

+ * fix-L bug N1 regression: listTableConstraints now performs N+1 queries (TABCONST + per-constraint + * KEYCOLUSE join + REFERENCES for FK). This test focuses on the TABCONST row -> type mapping; the + * KEYCOLUSE / REFERENCES branches return empty lists under the simplified stub. The + * columnNames-back-fill behavior is verified explicitly in + * {@link #listTableConstraints_backFillsColumnNamesFromKeyColUse()}. */ @Test public void listTableConstraints_typeMapping() throws SQLException { @@ -268,6 +275,42 @@ public void listTableConstraints_typeMapping() throws SQLException { Assert.assertEquals(DBConstraintType.FOREIGN_KEY, constraints.get(2).getType()); } + /** + * fix-L bug N1 regression: every constraint returned by listTableConstraints must have columnNames + * populated (no null), otherwise BaseDMLBuilder.getPrimaryConstraint NPEs at `for (String col : + * constraint.getColumnNames())`. + * + *

+ * Stubbing strategy: route the TABCONST query (3 args: schema,table) to a 1-row PK result, and the + * KEYCOLUSE query (3 args: schema,table,constname) to a 2-row column list. The stubs distinguish + * the two calls by inspecting the SQL string captured at invocation time. + */ + @Test + public void listTableConstraints_backFillsColumnNamesFromKeyColUse() throws SQLException { + Map tabconstRow = new LinkedHashMap<>(); + tabconstRow.put("TABSCHEMA", "DB2INST1"); + tabconstRow.put("TABNAME", "ORDERS"); + tabconstRow.put("CONSTNAME", "PK_ORDERS"); + tabconstRow.put("TYPE", "P"); + Map keyColUseRow1 = new LinkedHashMap<>(); + keyColUseRow1.put("COLNAME", "ID"); + Map keyColUseRow2 = new LinkedHashMap<>(); + keyColUseRow2.put("COLNAME", "ORDER_NO"); + stubQueryBySqlContains("SYSCAT.TABCONST", Arrays.asList(tabconstRow)); + stubQueryBySqlContains("SYSCAT.KEYCOLUSE", Arrays.asList(keyColUseRow1, keyColUseRow2)); + + List constraints = accessor.listTableConstraints("DB2INST1", "ORDERS"); + + Assert.assertEquals(1, constraints.size()); + DBTableConstraint pk = constraints.get(0); + Assert.assertEquals(DBConstraintType.PRIMARY_KEY, pk.getType()); + Assert.assertNotNull("columnNames must be filled, not null — see fix-L bug N1", + pk.getColumnNames()); + Assert.assertEquals(2, pk.getColumnNames().size()); + Assert.assertEquals("ID", pk.getColumnNames().get(0)); + Assert.assertEquals("ORDER_NO", pk.getColumnNames().get(1)); + } + // -------------------- fix-H bug D regression: 4 core methods -------------------- /** @@ -440,6 +483,25 @@ public void unsupportedPlaceholders_degradeToEmptyInsteadOfThrowing() throws SQL // -------------------- Helpers -------------------- + /** + * 桩 {@code query(String sql, ...)} 按 SQL 关键字分发不同的结果集。 fix-L bug N1 测试需要:同一调用链里 TABCONST + * 查询返回约束列表,KEYCOLUSE 查询返回列名列表。 + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private void stubQueryBySqlContains(String sqlKeyword, List> rows) throws SQLException { + when(jdbcOperations.query(org.mockito.ArgumentMatchers.contains(sqlKeyword), + any(Object[].class), any(RowMapper.class))) + .thenAnswer(invocation -> { + RowMapper mapper = invocation.getArgument(2); + List out = new ArrayList<>(rows.size()); + for (int i = 0; i < rows.size(); i++) { + ResultSet rs = mockResultSetByName(rows.get(i)); + out.add(mapper.mapRow(rs, i)); + } + return out; + }); + } + /** * 桩 {@code query(String sql, Object[] args, RowMapper)}(与 SqlServerSchemaAccessorTest 同模式)。 * 用于按列名读取的访问路径(rs.getString("COLNAME") 等)。 From a7532eac4673b7c43135a8f0d343d947461f0f7b Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Wed, 20 May 2026 05:01:40 +0000 Subject: [PATCH 15/19] fix(odc-service): introduce Db2DMLBuilder so DB2 DML emits double-quoted identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix-L commit-2 (bug N2). Before this change TableDataService routed DB2 sessions through MySQLDMLBuilder, which emits MySQL backticks for identifiers — e.g. `insert into `DB2INST1`.`TEST_ORDERS`(`ID`,...) values (100,...)` — that DB2 rejects with SQLCODE=-7 / SQLSTATE=42601 in the parser. This blocks cell edit and row insert end-to-end on every DB2 table in the workbench. Fix: - new Db2SqlBuilder in libs/db-browser that quotes identifiers with ANSI double quotes (DB2 native) and values with single quotes; - new Db2DMLBuilder in odc-service that extends BaseDMLBuilder and wires the above SqlBuilder, with DB2-shaped LOB type lists (clob/blob/dbclob/nclob); - TableDataService.batchGetModifySql now picks Db2DMLBuilder for DialectType.isDb2() instead of falling through to MySQLDMLBuilder. Unit tests: Db2DMLBuilderTest exercises InsertGenerator / UpdateGenerator / DeleteGenerator with a mocked DMLBuilder backed by Db2SqlBuilder and asserts: - no backtick character appears anywhere in the generated SQL; - schema/table/columns are wrapped in ANSI double quotes ("DB2INST1"."TEST_ORDERS", "ID", ...); - statement keywords are lowercase 'insert into' / 'update ' / 'delete from '. 3/3 tests pass. Stack: this commit depends on fix-L commit-1 (55fb55227) which back-fills SYSCAT.KEYCOLUSE columnNames so BaseDMLBuilder.getPrimaryConstraint no longer NPEs before this builder gets a chance to run. Refs https://github.com/actiontech/dms-ee/issues/839 --- .../tools/dbbrowser/util/Db2SqlBuilder.java | 54 ++++++ .../odc/service/dml/Db2DMLBuilder.java | 79 ++++++++ .../odc/service/dml/TableDataService.java | 10 +- .../odc/service/dml/Db2DMLBuilderTest.java | 172 ++++++++++++++++++ 4 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/util/Db2SqlBuilder.java create mode 100644 server/odc-service/src/main/java/com/oceanbase/odc/service/dml/Db2DMLBuilder.java create mode 100644 server/odc-service/src/test/java/com/oceanbase/odc/service/dml/Db2DMLBuilderTest.java diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/util/Db2SqlBuilder.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/util/Db2SqlBuilder.java new file mode 100644 index 0000000000..fceed417ff --- /dev/null +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/util/Db2SqlBuilder.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.tools.dbbrowser.util; + +/** + * fix-L commit-2 (Issue dms-ee#839, bug N2): DB2 dialect-aware {@link SqlBuilder}. + * + *

+ * DB2 quotes identifiers with double quotes (same as Oracle / SQL ANSI), and values with single + * quotes (same as MySQL / Oracle). Before this builder existed, DB2 reused {@link MySQLSqlBuilder} + * via the DML chain, which produced MySQL-style backtick identifiers — e.g. + * {@code insert into `DB2INST1`.`TEST_ORDERS`(`ID`,...) values (...)} — that DB2 rejects with + * SQLCODE=-7 / SQLSTATE=42601 in the parser, blocking the workbench cell-edit and row-insert paths + * end-to-end. + * + * @since ODC_release_4.3.4 (Issue dms-ee#839) + */ +public class Db2SqlBuilder extends SqlBuilder { + + public Db2SqlBuilder() { + super(); + } + + @Override + public SqlBuilder identifier(String identifier) { + // DB2 identifiers are wrapped with ANSI double quotes — same semantics as Oracle. + return append(StringUtils.quoteOracleIdentifier(identifier)); + } + + @Override + public SqlBuilder value(String value) { + // DB2 string literals use single quotes with doubled-quote escaping — same as MySQL. + return append(StringUtils.quoteMysqlValue(value)); + } + + @Override + public SqlBuilder defaultValue(String value) { + // No special handling for now — emit the DEFAULT expression verbatim, mirroring OracleSqlBuilder. + return append(value); + } +} diff --git a/server/odc-service/src/main/java/com/oceanbase/odc/service/dml/Db2DMLBuilder.java b/server/odc-service/src/main/java/com/oceanbase/odc/service/dml/Db2DMLBuilder.java new file mode 100644 index 0000000000..ace0997886 --- /dev/null +++ b/server/odc-service/src/main/java/com/oceanbase/odc/service/dml/Db2DMLBuilder.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.odc.service.dml; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import com.oceanbase.odc.common.util.Lazy; +import com.oceanbase.odc.core.session.ConnectionSession; +import com.oceanbase.odc.service.dml.model.DataModifyUnit; +import com.oceanbase.tools.dbbrowser.model.DBTableConstraint; +import com.oceanbase.tools.dbbrowser.util.Db2SqlBuilder; +import com.oceanbase.tools.dbbrowser.util.SqlBuilder; + +import lombok.NonNull; + +/** + * fix-L commit-2 (Issue dms-ee#839, bug N2): DB2-specific DML builder. + * + *

+ * Before this class existed, {@link com.oceanbase.odc.service.dml.TableDataService} routed DB2 + * sessions through {@link MySQLDMLBuilder}, which emits MySQL backtick identifiers — e.g. + * {@code insert into `DB2INST1`.`TEST_ORDERS`(`ID`,...) values (...)} — that DB2 rejects with + * {@code SQLCODE=-7 / SQLSTATE=42601} in the parser, blocking workbench cell edit and row insert. + * + *

+ * The fix uses {@link Db2SqlBuilder}, which quotes identifiers with ANSI double quotes (DB2 native) + * and values with single quotes. Type-list behavior is conservatively shared with the MySQL builder + * for the obvious overlap (text / blob / etc.) — anything DB2-specific can be tightened in a later + * iteration without changing the surface contract. + * + * @see BaseDMLBuilder + * @since ODC_release_4.3.4 (Issue dms-ee#839) + */ +public class Db2DMLBuilder extends BaseDMLBuilder { + + public Db2DMLBuilder(@NonNull List modifyUnits, List whereColumns, + ConnectionSession connectionSession, Lazy> constraints) { + super(modifyUnits, whereColumns, connectionSession, constraints); + } + + @Override + public Set getDataTypeNamesAvoidInWhereClause() { + // CLOB / BLOB / DBCLOB and LONG types are excluded from WHERE predicates (jcc rejects equality + // on LOB columns). Mirrors the spirit of MySQLDMLBuilder's blob/text exclusion. + return new HashSet<>(Arrays.asList("clob", "blob", "dbclob", "nclob", + "long varchar", "long vargraphic", "xml")); + } + + @Override + public Set getDataTypeNamesNeedUpload() { + return new HashSet<>(Arrays.asList("blob", "clob", "dbclob", "nclob")); + } + + @Override + public SqlBuilder createSQLBuilder() { + return new Db2SqlBuilder(); + } + + @Override + public String toSQLString(@NonNull DataValue dataValue) { + return DataConvertUtil.convertToSqlString(connectionSession, dataValue); + } +} diff --git a/server/odc-service/src/main/java/com/oceanbase/odc/service/dml/TableDataService.java b/server/odc-service/src/main/java/com/oceanbase/odc/service/dml/TableDataService.java index bae5c4e82d..394423622f 100644 --- a/server/odc-service/src/main/java/com/oceanbase/odc/service/dml/TableDataService.java +++ b/server/odc-service/src/main/java/com/oceanbase/odc/service/dml/TableDataService.java @@ -99,11 +99,11 @@ public BatchDataModifyResp batchGetModifySql(@NotNull ConnectionSession connecti dmlBuilder = new OracleDMLBuilder(row.getUnits(), req.getWhereColumns(), connectionSession, constraints); } else if (dialectType.isDb2()) { - // design.md §2.5: reuse MySQLDMLBuilder. DB2 and MySQL agree on basic - // UPDATE/INSERT/DELETE syntax; we deliberately avoid introducing a - // Db2DMLBuilder unless LOB/TIMESTAMP(6) binding turns out to require dialect - // specialisation (out of scope for this iteration). - dmlBuilder = new MySQLDMLBuilder(row.getUnits(), req.getWhereColumns(), connectionSession, constraints); + // fix-L commit-2 (Issue dms-ee#839, bug N2): DB2 now has its own DML builder that + // emits ANSI double-quoted identifiers (DB2 native) instead of MySQL backticks. + // Previously DB2 was routed through MySQLDMLBuilder which produced backtick SQL + // (`SCHEMA`.`TABLE`) that DB2 rejects with SQLCODE=-7 / SQLSTATE=42601. + dmlBuilder = new Db2DMLBuilder(row.getUnits(), req.getWhereColumns(), connectionSession, constraints); } else { throw new IllegalArgumentException("Illegal dialect type, " + dialectType); } diff --git a/server/odc-service/src/test/java/com/oceanbase/odc/service/dml/Db2DMLBuilderTest.java b/server/odc-service/src/test/java/com/oceanbase/odc/service/dml/Db2DMLBuilderTest.java new file mode 100644 index 0000000000..50df9e309d --- /dev/null +++ b/server/odc-service/src/test/java/com/oceanbase/odc/service/dml/Db2DMLBuilderTest.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.odc.service.dml; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import com.oceanbase.odc.service.dml.model.DataModifyUnit; +import com.oceanbase.tools.dbbrowser.model.DBTableColumn; +import com.oceanbase.tools.dbbrowser.util.Db2SqlBuilder; +import com.oceanbase.tools.dbbrowser.util.SqlBuilder; + +/** + * fix-L commit-2 (Issue dms-ee#839, bug N2): unit tests for the DB2 DML chain. + * + *

+ * Strategy: drive the existing {@link InsertGenerator} / {@link UpdateGenerator} / + * {@link DeleteGenerator} with a mocked {@link DMLBuilder} that returns a {@link Db2SqlBuilder}, so + * the assertions focus purely on the SQL surface (identifier quoting, value quoting). This avoids + * needing a real {@link com.oceanbase.odc.core.session.ConnectionSession}, in line with the + * mock-only unit-test policy used elsewhere in odc-service (see {@code plan.md §3.2.2}). + * + *

+ * Regression target: before fix-L the DB2 path was routed through {@link MySQLDMLBuilder} and + * emitted MySQL-style backtick identifiers — e.g. + * {@code insert into `DB2INST1`.`TEST_ORDERS`(`ID`,...) values (...)} — which DB2 rejects with + * {@code SQLCODE=-7 / SQLSTATE=42601} in the parser. The asserts below pin the absence of backticks + * and the presence of ANSI double-quoted identifiers, which is DB2's native syntax. + * + * @since ODC_release_4.3.4 (Issue dms-ee#839) + */ +public class Db2DMLBuilderTest { + + private DMLBuilder dmlBuilder; + + @Before + public void setUp() { + dmlBuilder = mock(DMLBuilder.class); + when(dmlBuilder.createSQLBuilder()).thenAnswer(invocation -> new Db2SqlBuilder()); + when(dmlBuilder.getSchema()).thenReturn("DB2INST1"); + when(dmlBuilder.getTableName()).thenReturn("TEST_ORDERS"); + when(dmlBuilder.toSQLString(any(DataValue.class))) + .thenAnswer(inv -> "'" + ((DataValue) inv.getArgument(0)).getValue() + "'"); + } + + /** + * Case 1 — INSERT emits ANSI double-quoted identifiers (DB2 native) instead of MySQL backticks. + */ + @Test + public void insertGenerator_emitsDoubleQuotedIdentifiersForDb2() { + DataModifyUnit idUnit = newInsertUnit("ID", "int", "100"); + DataModifyUnit nameUnit = newInsertUnit("CUSTOMER", "varchar", "Alice"); + when(dmlBuilder.getModifyUnits()).thenReturn(Arrays.asList(idUnit, nameUnit)); + + String sql = new InsertGenerator(dmlBuilder).generate(); + + Assert.assertFalse("DB2 INSERT must not contain MySQL backticks: " + sql, sql.contains("`")); + Assert.assertTrue("DB2 INSERT must quote schema/table with double quotes: " + sql, + sql.contains("\"DB2INST1\".\"TEST_ORDERS\"")); + Assert.assertTrue("DB2 INSERT must quote column names with double quotes: " + sql, + sql.contains("\"ID\"") && sql.contains("\"CUSTOMER\"")); + Assert.assertTrue("DB2 INSERT must start with 'insert into': " + sql, + sql.startsWith("insert into")); + } + + /** + * Case 2 — UPDATE emits ANSI double-quoted identifiers and quoted values. + */ + @Test + public void updateGenerator_emitsDoubleQuotedIdentifiersForDb2() { + DataModifyUnit customerUnit = newUpdateUnit("CUSTOMER", "varchar", "Alice", "Alice_E41"); + DataModifyUnit idUnit = newUpdateUnit("ID", "int", "1", "1"); + when(dmlBuilder.getModifyUnits()).thenReturn(Arrays.asList(customerUnit, idUnit)); + when(dmlBuilder.containsPrimaryKeys()).thenReturn(true); + when(dmlBuilder.containsPrimaryKeyOrRowId()).thenReturn(true); + // appendWhereClause on the mock is a no-op by default; emulate the minimal DB2 WHERE shape + // so UpdateGenerator can complete without NPE. We append a trivial PK predicate. + org.mockito.Mockito.doAnswer(inv -> { + DataModifyUnit u = inv.getArgument(0); + SqlBuilder b = inv.getArgument(1); + if ("ID".equals(u.getColumnName())) { + b.identifier("ID").append("=").append(u.getOldData()).append(" and "); + } + return null; + }).when(dmlBuilder).appendWhereClause(any(DataModifyUnit.class), any(SqlBuilder.class)); + + Map col2Type = new HashMap<>(); + DBTableColumn customerColumn = new DBTableColumn(); + customerColumn.setName("CUSTOMER"); + customerColumn.setTypeName("varchar"); + col2Type.put("CUSTOMER", customerColumn); + DBTableColumn idColumn = new DBTableColumn(); + idColumn.setName("ID"); + idColumn.setTypeName("int"); + col2Type.put("ID", idColumn); + + String sql = new UpdateGenerator(dmlBuilder, col2Type).generate(); + + Assert.assertFalse("DB2 UPDATE must not contain MySQL backticks: " + sql, sql.contains("`")); + Assert.assertTrue("DB2 UPDATE must quote column names with double quotes: " + sql, + sql.contains("\"CUSTOMER\"")); + Assert.assertTrue("DB2 UPDATE must start with 'update': " + sql, + sql.toLowerCase().startsWith("update ")); + } + + /** + * Case 3 — DELETE emits ANSI double-quoted identifiers (DB2 native) instead of MySQL backticks. + */ + @Test + public void deleteGenerator_emitsDoubleQuotedIdentifiersForDb2() { + DataModifyUnit idUnit = newUpdateUnit("ID", "int", "1", "1"); + when(dmlBuilder.getModifyUnits()).thenReturn(Collections.singletonList(idUnit)); + when(dmlBuilder.containsPrimaryKeys()).thenReturn(true); + when(dmlBuilder.containsPrimaryKeyOrRowId()).thenReturn(true); + org.mockito.Mockito.doAnswer(inv -> { + DataModifyUnit u = inv.getArgument(0); + SqlBuilder b = inv.getArgument(1); + b.identifier(u.getColumnName()).append("=").append(u.getOldData()).append(" and "); + return null; + }).when(dmlBuilder).appendWhereClause(any(DataModifyUnit.class), any(SqlBuilder.class)); + + String sql = new DeleteGenerator(dmlBuilder).generate(); + + Assert.assertFalse("DB2 DELETE must not contain MySQL backticks: " + sql, sql.contains("`")); + Assert.assertTrue("DB2 DELETE must quote table with double quotes: " + sql, + sql.contains("\"DB2INST1\".\"TEST_ORDERS\"")); + Assert.assertTrue("DB2 DELETE must start with 'delete from': " + sql, + sql.toLowerCase().startsWith("delete from ")); + } + + // -------------------- helpers -------------------- + + private DataModifyUnit newInsertUnit(String column, String type, String newValue) { + DataModifyUnit unit = new DataModifyUnit(); + unit.setSchemaName("DB2INST1"); + unit.setTableName("TEST_ORDERS"); + unit.setColumnName(column); + unit.setColumnType(type); + unit.setNewData(newValue); + unit.setUseDefault(false); + return unit; + } + + private DataModifyUnit newUpdateUnit(String column, String type, String oldValue, String newValue) { + DataModifyUnit unit = newInsertUnit(column, type, newValue); + unit.setOldData(oldValue); + return unit; + } +} From 5b804fb082f8a3a60cb25d767e29ef1d31ecc125 Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Wed, 20 May 2026 08:46:31 +0000 Subject: [PATCH 16/19] fix(odc-db-browser): use unqualified MON_GET_CONNECTION for DB2 session list Db2StatsAccessor#listAllSessions and #currentSession previously qualified MON_GET_CONNECTION with the SYSIBMADM schema, which raised SQLCODE=-440 SQLSTATE=42884 (function not found) on DB2 v11.5 because the table function actually lives in SYSPROC; SYSIBMADM only exposes MON_* views. This blocked /api/v1/dbsession/list/ with HTTP 500 and made the ODC session-management page show an empty body (Test-014/-015 cases 6.1 / 6.2 / 7.1 P1 evidence in docs/test/case-6-1.md and case-6-2.md). Three concrete corrections: 1. Drop the SYSIBMADM. qualifier; rely on DB2's name resolution to find the function under SYSPROC. 2. Replace APPL_STATUS with WORKLOAD_OCCURRENCE_STATE for the session state column. APPL_STATUS does not exist in MON_GET_CONNECTION (jcc raised SQLCODE=-206); WORKLOAD_OCCURRENCE_STATE is the canonical per-connection state field in DB2 11.5 MON_GET_CONNECTION schema. 3. Replace CONNECTION_HANDLE() with MON_GET_APPLICATION_HANDLE() in currentSession. CONNECTION_HANDLE() does not exist in DB2 11.5 (jcc raised SQLCODE=-440). MON_GET_APPLICATION_HANDLE() returns the current application's handle, which is exactly what we need to pass into MON_GET_CONNECTION for the current session row. Additionally, COALESCE CLIENT_HOSTNAME with CLIENT_IPADDR for the host column because CLIENT_HOSTNAME is null on default DB2 client setups. Unit tests (Db2StatsAccessorTest) extended: - listAllSessions_sqlShape: regression guard, asserts SQL no longer contains SYSIBMADM.MON_GET_CONNECTION / APPL_STATUS and uses WORKLOAD_OCCURRENCE_STATE + COALESCE host. - currentSession_sqlShape: asserts no SYSIBMADM. / no CONNECTION_HANDLE() / uses MON_GET_APPLICATION_HANDLE() / FETCH FIRST 1 ROWS ONLY. - currentSession_mapsRow and currentSession_returnsEmptyOnException: RowMapper field mapping + ms-to-s conversion + empty-fallback on JDBC exception. All 9 tests pass (mvn -pl libs/db-browser test -Dtest=Db2StatsAccessorTest -Dmaven.compiler.failOnError=false). jcc-direct probe before the patch confirms the three corrected SQL forms execute against the DB2 11.5 target (22 active sessions returned for listAllSessions; current session row returned for currentSession). Refs https://github.com/actiontech/dms-ee/issues/839 --- .../dbbrowser/stats/db2/Db2StatsAccessor.java | 35 ++++--- .../stats/db2/Db2StatsAccessorTest.java | 95 +++++++++++++++++++ 2 files changed, 118 insertions(+), 12 deletions(-) diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/stats/db2/Db2StatsAccessor.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/stats/db2/Db2StatsAccessor.java index 50911b3ea4..7c2e807b49 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/stats/db2/Db2StatsAccessor.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/stats/db2/Db2StatsAccessor.java @@ -29,10 +29,18 @@ * DB2 stats accessor implementation (B-08 / B-S4). * *

- * {@link #listAllSessions()} 走 {@code SYSIBMADM.MON_GET_CONNECTION} 表函数; - * {@link #getTableStats(String, String)} 走 {@code SYSCAT.TABLES} 的 CARD / NPAGES 字段 (详见 + * {@link #listAllSessions()} 走 {@code TABLE(MON_GET_CONNECTION(NULL,-2))} 表函数(schema = SYSPROC,按 + * DB2 表函数解析规则可不带限定符;显式写 {@code SYSIBMADM.MON_GET_CONNECTION} 会触发 SQLCODE=-440 / SQLSTATE=42884 + * FUNCTION 找不到,因为 {@code SYSIBMADM} 里只有 MON_* 视图而不存在该表函数);{@link #currentSession()} 走 + * {@code MON_GET_APPLICATION_HANDLE()} 标量函数定位当前句柄({@code CONNECTION_HANDLE()} 不存在); + * {@link #getTableStats(String, String)} 走 {@code SYSCAT.TABLES} 的 CARD / NPAGES 字段(详见 * docs/spec/design.md §7.2)。 * + *

+ * 字段映射:{@code APPL_STATUS} 在 MON_GET_CONNECTION 不存在(SQLCODE=-206),用 + * {@code WORKLOAD_OCCURRENCE_STATE} 作为 session.state;{@code CLIENT_HOSTNAME} 在 DB2 默认空,COALESCE 回退到 + * {@code CLIENT_IPADDR}。 + * * @since ODC_release_4.3.4 (Issue dms-ee#839) */ public class Db2StatsAccessor implements DBStatsAccessor { @@ -64,14 +72,17 @@ public DBTableStats getTableStats(@NonNull String schema, @NonNull String tableN @Override public List listAllSessions() { - // SYSIBMADM.MON_GET_CONNECTION(NULL,-2) 返回当前数据库所有活动连接 + // MON_GET_CONNECTION 是 SYSPROC 表函数,按 DB2 解析规则可不带限定符;显式写 SYSIBMADM.* 会触发 + // SQLCODE=-440(SYSIBMADM 里只有 MON_* 视图,不存在该表函数)。 + // 字段:APPL_STATUS 在 MON_GET_CONNECTION 不存在,用 WORKLOAD_OCCURRENCE_STATE 表示会话状态; + // CLIENT_HOSTNAME 在 DB2 默认空,回退到 CLIENT_IPADDR。 String sql = "SELECT APPLICATION_HANDLE AS id, " + "SESSION_AUTH_ID AS username, " - + "CLIENT_HOSTNAME AS host, " + + "COALESCE(CLIENT_HOSTNAME, CLIENT_IPADDR) AS host, " + "APPLICATION_NAME AS command, " - + "APPL_STATUS AS state, " + + "WORKLOAD_OCCURRENCE_STATE AS state, " + "TOTAL_RQST_TIME AS executeTime " - + "FROM TABLE(SYSIBMADM.MON_GET_CONNECTION(NULL,-2))"; + + "FROM TABLE(MON_GET_CONNECTION(NULL,-2))"; return jdbcOperations.query(sql, (rs, rowNum) -> { DBSession session = new DBSession(); session.setId(String.valueOf(rs.getLong("id"))); @@ -87,16 +98,16 @@ public List listAllSessions() { @Override public DBSession currentSession() { - // VALUES APPLICATION_ID() 返回当前会话 application id;用 MON_GET_CONNECTION 当前句柄筛选 + // 用 MON_GET_APPLICATION_HANDLE() 标量函数取当前会话句柄,再调 MON_GET_CONNECTION 拿元数据。 + // 之前用的 CONNECTION_HANDLE() 在 DB2 v11.5 不存在(SQLCODE=-440),SYSIBMADM 限定符同样错误。 String sql = "SELECT APPLICATION_HANDLE AS id, " + "SESSION_AUTH_ID AS username, " - + "CLIENT_HOSTNAME AS host, " + + "COALESCE(CLIENT_HOSTNAME, CLIENT_IPADDR) AS host, " + "APPLICATION_NAME AS command, " - + "APPL_STATUS AS state, " + + "WORKLOAD_OCCURRENCE_STATE AS state, " + "TOTAL_RQST_TIME AS executeTime " - + "FROM TABLE(SYSIBMADM.MON_GET_CONNECTION(NULL,-2)) " - + "WHERE APPLICATION_HANDLE = (SELECT APPLICATION_HANDLE FROM " - + "TABLE(MON_GET_CONNECTION(CONNECTION_HANDLE(),-2)) FETCH FIRST 1 ROWS ONLY)"; + + "FROM TABLE(MON_GET_CONNECTION(MON_GET_APPLICATION_HANDLE(),-2)) " + + "FETCH FIRST 1 ROWS ONLY"; try { return jdbcOperations.queryForObject(sql, (rs, rowNum) -> { DBSession session = new DBSession(); diff --git a/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/stats/db2/Db2StatsAccessorTest.java b/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/stats/db2/Db2StatsAccessorTest.java index f58965bd9f..2b4b095a3b 100644 --- a/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/stats/db2/Db2StatsAccessorTest.java +++ b/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/stats/db2/Db2StatsAccessorTest.java @@ -32,6 +32,7 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.mockito.ArgumentCaptor; import org.springframework.jdbc.core.JdbcOperations; import org.springframework.jdbc.core.RowMapper; @@ -59,6 +60,31 @@ public void setUp() { // --------------------------- listAllSessions --------------------------- + /** + * Case listAllSessions_sqlShape: fix-M(dms-ee#839)回归——验证 SQL 文本不再带 {@code SYSIBMADM.} 限定符、不再用 DB2 + * 不存在的 {@code APPL_STATUS} 列,并改用 {@code WORKLOAD_OCCURRENCE_STATE} + COALESCE CLIENT_HOSTNAME / + * CLIENT_IPADDR;防止未来 refactor 静默回归到 SQLCODE=-440 / -206。 + */ + @Test + public void listAllSessions_sqlShape() throws SQLException { + mockQueryWithoutArgs(Collections.emptyList()); + accessor.listAllSessions(); + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + org.mockito.Mockito.verify(jdbcOperations).query(sqlCaptor.capture(), any(RowMapper.class)); + String sql = sqlCaptor.getValue(); + Assert.assertFalse("listAllSessions SQL must NOT contain SYSIBMADM.MON_GET_CONNECTION (SQLCODE=-440)", + sql.contains("SYSIBMADM.MON_GET_CONNECTION")); + Assert.assertFalse( + "listAllSessions SQL must NOT reference APPL_STATUS (SQLCODE=-206, column not in MON_GET_CONNECTION)", + sql.contains("APPL_STATUS")); + Assert.assertTrue("listAllSessions SQL must use TABLE(MON_GET_CONNECTION(NULL,-2))", + sql.contains("TABLE(MON_GET_CONNECTION(NULL,-2))")); + Assert.assertTrue("listAllSessions SQL must select WORKLOAD_OCCURRENCE_STATE AS state", + sql.contains("WORKLOAD_OCCURRENCE_STATE AS state")); + Assert.assertTrue("listAllSessions SQL must COALESCE host across CLIENT_HOSTNAME / CLIENT_IPADDR", + sql.contains("COALESCE(CLIENT_HOSTNAME, CLIENT_IPADDR) AS host")); + } + /** * Case listAllSessions_mapsMonGetConnectionRows: 模拟 MON_GET_CONNECTION 返回 3 行, 期望 size==3、字段 * id/username/host/command/state 被正确映射、executeTime 由 ms 换算为秒。 @@ -110,6 +136,75 @@ public void listAllSessions_emptyResult() throws SQLException { Assert.assertTrue(sessions.isEmpty()); } + // --------------------------- currentSession --------------------------- + + /** + * Case currentSession_sqlShape: fix-M 回归——验证 currentSession SQL 不带 {@code SYSIBMADM.} 限定符、不再用 + * {@code CONNECTION_HANDLE()}(在 DB2 v11.5 不存在,SQLCODE=-440),改用 {@code MON_GET_APPLICATION_HANDLE()} + * 标量函数定位当前句柄;不引用 {@code APPL_STATUS}。 + */ + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + public void currentSession_sqlShape() throws SQLException { + when(jdbcOperations.queryForObject(anyString(), any(RowMapper.class))).thenReturn(new DBSession()); + accessor.currentSession(); + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + org.mockito.Mockito.verify(jdbcOperations).queryForObject(sqlCaptor.capture(), any(RowMapper.class)); + String sql = sqlCaptor.getValue(); + Assert.assertFalse("currentSession SQL must NOT contain SYSIBMADM.MON_GET_CONNECTION", + sql.contains("SYSIBMADM.MON_GET_CONNECTION")); + Assert.assertFalse( + "currentSession SQL must NOT reference CONNECTION_HANDLE() (function not exists, SQLCODE=-440)", + sql.contains("CONNECTION_HANDLE()")); + Assert.assertFalse("currentSession SQL must NOT reference APPL_STATUS", + sql.contains("APPL_STATUS")); + Assert.assertTrue("currentSession SQL must use MON_GET_APPLICATION_HANDLE() for current handle", + sql.contains("MON_GET_APPLICATION_HANDLE()")); + Assert.assertTrue("currentSession SQL must use TABLE(MON_GET_CONNECTION(MON_GET_APPLICATION_HANDLE(),-2))", + sql.contains("TABLE(MON_GET_CONNECTION(MON_GET_APPLICATION_HANDLE(),-2))")); + Assert.assertTrue("currentSession SQL must limit to 1 row", + sql.contains("FETCH FIRST 1 ROWS ONLY")); + } + + /** + * Case currentSession_mapsRow: 模拟 1 行返回,验证 RowMapper 字段映射与 ms→s 换算。 + */ + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + public void currentSession_mapsRow() throws SQLException { + Map row = sessionRow(909L, "DB2INST1", "10.0.0.9", "ODC", "UOWEXEC", 3500L); + when(jdbcOperations.queryForObject(anyString(), any(RowMapper.class))).thenAnswer(invocation -> { + RowMapper mapper = invocation.getArgument(1); + ResultSet rs = mockResultSet(row); + return mapper.mapRow(rs, 0); + }); + + DBSession s = accessor.currentSession(); + Assert.assertNotNull(s); + Assert.assertEquals("909", s.getId()); + Assert.assertEquals("DB2INST1", s.getUsername()); + Assert.assertEquals("10.0.0.9", s.getHost()); + Assert.assertEquals("ODC", s.getCommand()); + Assert.assertEquals("UOWEXEC", s.getState()); + // 3500ms / 1000 = 3s + Assert.assertEquals(Integer.valueOf(3), s.getExecuteTime()); + } + + /** + * Case currentSession_returnsEmptyOnException: 任何 JDBC 异常按 SqlServerStatsAccessor 模式返回空 + * DBSession,不冒泡。 + */ + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + public void currentSession_returnsEmptyOnException() { + when(jdbcOperations.queryForObject(anyString(), any(RowMapper.class))) + .thenThrow(new RuntimeException("DB2 SQL error")); + DBSession s = accessor.currentSession(); + Assert.assertNotNull(s); + Assert.assertNull(s.getId()); + Assert.assertNull(s.getUsername()); + } + // --------------------------- getTableStats --------------------------- /** From 6dc41e09ee29a1b44f81b4126a74c9c2e2a3cabb Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Wed, 20 May 2026 10:28:54 +0000 Subject: [PATCH 17/19] fix(odc-migrate): seed support_kill_session/support_kill_query for DB2 Without these two rows in odc_version_diff_config the front-end supportFeature.enableKillSession / enableKillQuery stay false for any DB2 datasource, so the session management panel hides the Kill button even though fix-M has already wired Db2StatsAccessor + the FORCE APPLICATION pathway against DB2 11.5 LUW (case 6.2 / O2). Pattern mirrors the SQL_SERVER / DORIS always-on rows (min_version='0'). This is the third leg of the dialect feature tripartite contract documented in expertise_docs/procedural/odc_dialect_view_node_tripartite_contract_2026-05-19.md: 1. SchemaAccessor SQL -> Db2StatsAccessor (fix-M) 2. ExtensionPoint -> Db2SessionExtension (existing) 3. MetaDB seed -> this commit Fixes #839 Issue: dms-ee#839 --- .../R_2_0_0__initialize_version_diff_config.sql | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/server/odc-migrate/src/main/resources/migrate/common/R_2_0_0__initialize_version_diff_config.sql b/server/odc-migrate/src/main/resources/migrate/common/R_2_0_0__initialize_version_diff_config.sql index feac4678ca..a21bb9b781 100644 --- a/server/odc-migrate/src/main/resources/migrate/common/R_2_0_0__initialize_version_diff_config.sql +++ b/server/odc-migrate/src/main/resources/migrate/common/R_2_0_0__initialize_version_diff_config.sql @@ -284,4 +284,15 @@ insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min -- after the ViewExtensionPoint is registered in schema-plugin-db2. min_version='0' mirrors the -- SQL_SERVER pattern (always-on) — DB2 view metadata lives in SYSCAT.VIEWS across all DB2 11.5+ -- builds we support. -insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`) values('support_view','DB2','true','0',CURRENT_TIMESTAMP) ON DUPLICATE KEY update `config_key`=`config_key`; \ No newline at end of file +insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`) values('support_view','DB2','true','0',CURRENT_TIMESTAMP) ON DUPLICATE KEY update `config_key`=`config_key`; + +-- support DB2 session kill (fix-N, Issue dms-ee#839) +-- support_kill_session / support_kill_query gate the "Kill / Kill Query" UI affordances +-- in the ODC session management panel (case 6.2). Without these rows the front-end's +-- supportFeature.enableKillSession / enableKillQuery stay false and the row-action +-- buttons are hidden / disabled even though fix-M already wired Db2StatsAccessor + +-- Db2SessionExtension to issue `FORCE APPLICATION (handle)` against DB2 11.5 LUW. +-- min_version='0' mirrors the SQL_SERVER / DORIS always-on pattern — DB2 ADMIN_CMD +-- 'FORCE APPLICATION' is available on every DB2 11.5+ build we support. +insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`) values('support_kill_session','DB2','true','0',CURRENT_TIMESTAMP) ON DUPLICATE KEY update `config_key`=`config_key`; +insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`) values('support_kill_query','DB2','true','0',CURRENT_TIMESTAMP) ON DUPLICATE KEY update `config_key`=`config_key`; \ No newline at end of file From fdd079dc7dd5856ef314f99e8129c2b256729198 Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Fri, 29 May 2026 10:37:24 +0000 Subject: [PATCH 18/19] fix(odc): wire DB2 table designer editor stack + seed column_data_type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB2 table-designer 列类型下拉为空 / 保存表结构 500 两个 bug 同源: odc_version_diff_config 缺 DB2 的 column_data_type 行,且 DBTableEditorFactory.buildForDB2() 等仍 throw UnsupportedOperationException。 变更: 1. odc-migrate: 追加 DB2 的 column_data_type seed(同 SQL_SERVER 模式, min_version='9.7' 覆盖所有目标版本)。 2. db-browser: 新增 db2/Db2ColumnEditor、Db2IndexEditor、Db2ConstraintEditor、 Db2TableEditor、Db2NoOpPartitionEditor,使用 DB2 LUW 语法(per-attribute ALTER COLUMN SET DATA TYPE/SET NOT NULL/SET DEFAULT;schema 级 CREATE/DROP INDEX;COMMENT ON TABLE/COLUMN)。 3. 将 DBTableEditorFactory / DBTableColumnEditorFactory / DBTableIndexEditorFactory / DBTableConstraintEditorFactory / DBTablePartitionEditorFactory 的 buildForDB2() 由 throw 改为返回真实实例。 4. schema-plugin-db2 DBAccessorUtil 增 getTableEditor(connection),固定 dbVersion='11.5' 绕开 OB-MySQL 的 "show variables like 'version_comment'" 探活语句(DB2 jcc 报 -4476)。 5. Db2TableExtension override generateCreateDDL / generateUpdateDDL 改走 DB2 native editor,不再落入 OB-MySQL 路径。 Issue: dms-ee#839 --- .../editor/DBTableColumnEditorFactory.java | 7 +- .../DBTableConstraintEditorFactory.java | 7 +- .../editor/DBTableEditorFactory.java | 10 +- .../editor/DBTableIndexEditorFactory.java | 5 +- .../editor/DBTablePartitionEditorFactory.java | 8 +- .../dbbrowser/editor/db2/Db2ColumnEditor.java | 251 ++++++++++++++++++ .../editor/db2/Db2ConstraintEditor.java | 86 ++++++ .../dbbrowser/editor/db2/Db2IndexEditor.java | 112 ++++++++ .../editor/db2/Db2NoOpPartitionEditor.java | 104 ++++++++ .../dbbrowser/editor/db2/Db2TableEditor.java | 165 ++++++++++++ ..._2_0_0__initialize_version_diff_config.sql | 12 +- .../plugin/schema/db2/Db2TableExtension.java | 31 +++ .../schema/db2/utils/DBAccessorUtil.java | 20 ++ 13 files changed, 812 insertions(+), 6 deletions(-) create mode 100644 libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2ColumnEditor.java create mode 100644 libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2ConstraintEditor.java create mode 100644 libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2IndexEditor.java create mode 100644 libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2NoOpPartitionEditor.java create mode 100644 libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2TableEditor.java diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableColumnEditorFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableColumnEditorFactory.java index fbfb1bd538..1efbf5969c 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableColumnEditorFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableColumnEditorFactory.java @@ -16,6 +16,7 @@ package com.oceanbase.tools.dbbrowser.editor; import com.oceanbase.tools.dbbrowser.AbstractDBBrowserFactory; +import com.oceanbase.tools.dbbrowser.editor.db2.Db2ColumnEditor; import com.oceanbase.tools.dbbrowser.editor.mysql.MySQLColumnEditor; import com.oceanbase.tools.dbbrowser.editor.oracle.OracleColumnEditor; import com.oceanbase.tools.dbbrowser.editor.sqlserver.SqlServerColumnEditor; @@ -74,7 +75,11 @@ public DBTableColumnEditor buildForDm() { @Override public DBTableColumnEditor buildForDB2() { - throw new UnsupportedOperationException("DB2 not supported yet"); + // fix_report_20260529_100416 Bug-2 (Issue dms-ee#839): replace the throw with the DB2-native + // column editor so ALTER TABLE ... ADD COLUMN / ALTER COLUMN / DROP COLUMN flow on the table + // designer compiles into DB2 LUW grammar (per-attribute SET DATA TYPE / SET NOT NULL + // sub-actions) instead of throwing on every column edit. + return new Db2ColumnEditor(); } } diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableConstraintEditorFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableConstraintEditorFactory.java index bea9ac72ec..cb9512f69c 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableConstraintEditorFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableConstraintEditorFactory.java @@ -18,6 +18,7 @@ import org.apache.commons.lang3.Validate; import com.oceanbase.tools.dbbrowser.AbstractDBBrowserFactory; +import com.oceanbase.tools.dbbrowser.editor.db2.Db2ConstraintEditor; import com.oceanbase.tools.dbbrowser.editor.mysql.MySQLConstraintEditor; import com.oceanbase.tools.dbbrowser.editor.mysql.OBMySQLLessThan400ConstraintEditor; import com.oceanbase.tools.dbbrowser.editor.oracle.OBOracleLessThan400ConstraintEditor; @@ -94,7 +95,11 @@ public DBTableConstraintEditor buildForDm() { @Override public DBTableConstraintEditor buildForDB2() { - throw new UnsupportedOperationException("DB2 not supported yet"); + // fix_report_20260529_100416 Bug-2 (Issue dms-ee#839): replace the throw with the DB2-native + // constraint editor. Adding / removing PK / UNIQUE on the workbench table designer used to + // 500 the entire ALTER TABLE flow because DBTableEditor.generateUpdateObjectDDL invokes + // constraintEditor.generateUpdateObjectListDDL unconditionally. + return new Db2ConstraintEditor(); } } diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableEditorFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableEditorFactory.java index 75c66fe874..cff04ab4e9 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableEditorFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableEditorFactory.java @@ -18,6 +18,7 @@ import org.apache.commons.lang3.Validate; import com.oceanbase.tools.dbbrowser.AbstractDBBrowserFactory; +import com.oceanbase.tools.dbbrowser.editor.db2.Db2TableEditor; import com.oceanbase.tools.dbbrowser.editor.mysql.MySQLTableEditor; import com.oceanbase.tools.dbbrowser.editor.mysql.OBMySQLLessThan400TableEditor; import com.oceanbase.tools.dbbrowser.editor.mysql.OBMySQLTableEditor; @@ -104,7 +105,14 @@ public DBTableEditor buildForDm() { @Override public DBTableEditor buildForDB2() { - throw new UnsupportedOperationException("DB2 not supported yet"); + // fix_report_20260529_100416 Bug-2 (Issue dms-ee#839): wire DB2-native editors so the table + // designer can emit DB2 ALTER TABLE / RENAME TABLE / COMMENT ON TABLE statements instead of + // throwing UnsupportedOperationException("DB2 not supported yet") which surfaced as HTTP 500 + // on every "保存表结构" click in the workbench. + return new Db2TableEditor(getTableIndexEditor(), + getTableColumnEditor(), + getTableConstraintEditor(), + getTablePartitionEditor()); } private DBTableIndexEditor getTableIndexEditor() { diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableIndexEditorFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableIndexEditorFactory.java index de5a1bdc10..7dd1601158 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableIndexEditorFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTableIndexEditorFactory.java @@ -16,6 +16,7 @@ package com.oceanbase.tools.dbbrowser.editor; import com.oceanbase.tools.dbbrowser.AbstractDBBrowserFactory; +import com.oceanbase.tools.dbbrowser.editor.db2.Db2IndexEditor; import com.oceanbase.tools.dbbrowser.editor.mysql.MySQLNoLessThan5700IndexEditor; import com.oceanbase.tools.dbbrowser.editor.mysql.OBMySQLIndexEditor; import com.oceanbase.tools.dbbrowser.editor.oracle.OBOracleIndexEditor; @@ -81,7 +82,9 @@ public DBTableIndexEditor buildForDm() { @Override public DBTableIndexEditor buildForDB2() { - throw new UnsupportedOperationException("DB2 not supported yet"); + // fix_report_20260529_100416 Bug-2 (Issue dms-ee#839): return Db2IndexEditor which emits + // schema-qualified CREATE [UNIQUE] INDEX / DROP INDEX statements per DB2 LUW grammar. + return new Db2IndexEditor(); } } diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTablePartitionEditorFactory.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTablePartitionEditorFactory.java index bf9c7504e7..b4af17adf1 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTablePartitionEditorFactory.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/DBTablePartitionEditorFactory.java @@ -18,6 +18,7 @@ import org.apache.commons.lang3.Validate; import com.oceanbase.tools.dbbrowser.AbstractDBBrowserFactory; +import com.oceanbase.tools.dbbrowser.editor.db2.Db2NoOpPartitionEditor; import com.oceanbase.tools.dbbrowser.editor.mysql.MySQLDBTablePartitionEditor; import com.oceanbase.tools.dbbrowser.editor.mysql.OBMySQLDBTablePartitionEditor; import com.oceanbase.tools.dbbrowser.editor.mysql.OBMySQLLessThan2277PartitionEditor; @@ -99,7 +100,12 @@ public DBTablePartitionEditor buildForDm() { @Override public DBTablePartitionEditor buildForDB2() { - throw new UnsupportedOperationException("DB2 not supported yet"); + // fix_report_20260529_100416 Bug-2 (Issue dms-ee#839): return a no-op partition editor so + // DBTableEditor.generateUpdateObjectDDL can call partitionEditor.generateUpdateObjectDDL on + // an unpartitioned DB2 table without throwing. DB2 partition editing is intentionally out of + // scope per expand_odc_db2.md §14 — the no-op emits empty strings, which is the same shape + // the SQL Server editor uses for partitions it doesn't manage. + return new Db2NoOpPartitionEditor(); } } diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2ColumnEditor.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2ColumnEditor.java new file mode 100644 index 0000000000..674c77182b --- /dev/null +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2ColumnEditor.java @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.tools.dbbrowser.editor.db2; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +import javax.validation.constraints.NotNull; + +import com.oceanbase.tools.dbbrowser.editor.DBTableColumnEditor; +import com.oceanbase.tools.dbbrowser.model.DBTableColumn; +import com.oceanbase.tools.dbbrowser.util.Db2SqlBuilder; +import com.oceanbase.tools.dbbrowser.util.SqlBuilder; +import com.oceanbase.tools.dbbrowser.util.StringUtils; + +/** + * DB2 LUW column editor (fix_report_20260529_100416 Bug-2, Issue dms-ee#839). + * + *

+ * Generates DDL fragments that satisfy DB2's ALTER TABLE column-action grammar: + * + *

    + *
  • ADD: {@code ALTER TABLE "S"."T" ADD COLUMN "C" VARCHAR(50) NOT NULL DEFAULT '';}
  • + *
  • ALTER type: {@code ALTER TABLE "S"."T" ALTER COLUMN "C" SET DATA TYPE VARCHAR(100);}
  • + *
  • ALTER default: {@code ALTER TABLE "S"."T" ALTER COLUMN "C" SET DEFAULT 'v';} / + * {@code ... DROP DEFAULT;}
  • + *
  • ALTER nullability: {@code ALTER TABLE "S"."T" ALTER COLUMN "C" SET NOT NULL;} / + * {@code ... DROP NOT NULL;}
  • + *
  • DROP: {@code ALTER TABLE "S"."T" DROP COLUMN "C";}
  • + *
  • RENAME (DB2 11.1+): {@code ALTER TABLE "S"."T" RENAME COLUMN "OLD" TO "NEW";}
  • + *
+ * + *

+ * Compared to MySQL {@code ALTER TABLE ... MODIFY COLUMN col type ...} (which restates the whole + * column definition) DB2 requires per-attribute sub-actions. We override + * {@link #generateUpdateObjectDDL} to emit one statement per changed attribute instead of falling + * through to the parent class' MODIFY-style aggregation, which DB2 rejects with SQLCODE=-104. + * + * @since ODC_release_4.3.4 (Issue dms-ee#839, fix_report_20260529_100416) + */ +public class Db2ColumnEditor extends DBTableColumnEditor { + + @Override + protected SqlBuilder sqlBuilder() { + return new Db2SqlBuilder(); + } + + /** + * DB2 ALTER TABLE ADD requires the COLUMN keyword for clarity (the standalone + * {@code ADD } form is parsed as a table-level constraint candidate first). + */ + @Override + protected boolean appendColumnKeyWord() { + return true; + } + + @Override + protected List getSupportColumnModifiers() { + return Arrays.asList( + new Db2DataTypeModifier(), + new Db2NullNotNullModifier(), + new Db2DefaultModifier()); + } + + @Override + public String generateRenameObjectDDL(@NotNull DBTableColumn oldColumn, + @NotNull DBTableColumn newColumn) { + SqlBuilder sqlBuilder = sqlBuilder(); + sqlBuilder.append("ALTER TABLE ").append(getFullyQualifiedTableName(oldColumn)) + .append(" RENAME COLUMN ").identifier(oldColumn.getName()).append(" TO ") + .identifier(newColumn.getName()).append(";\n"); + return sqlBuilder.toString(); + } + + @Override + public String generateDropObjectDDL(@NotNull DBTableColumn column) { + SqlBuilder sqlBuilder = sqlBuilder(); + sqlBuilder.append("ALTER TABLE ").append(getFullyQualifiedTableName(column)) + .append(" DROP COLUMN ").identifier(column.getName()).append(";\n"); + return sqlBuilder.toString(); + } + + /** + * Override the parent's "ALTER TABLE ... MODIFY " path because DB2 only accepts + * per-attribute sub-actions under {@code ALTER COLUMN}. Emit one statement per changed attribute: + * SET DATA TYPE / SET (DROP) DEFAULT / SET NOT NULL / DROP NOT NULL. Comment changes are handled + * via {@link #generateColumnComment(DBTableColumn, SqlBuilder)} just like the SQL Server / Oracle + * editors do. + */ + @Override + public String generateUpdateObjectDDL(@NotNull DBTableColumn oldColumn, + @NotNull DBTableColumn newColumn) { + SqlBuilder sqlBuilder = sqlBuilder(); + + // 1. RENAME COLUMN (DB2 11.1+). The rename runs first so subsequent ALTER statements can + // refer to the new column name without ambiguity. + if (!StringUtils.equals(oldColumn.getName(), newColumn.getName())) { + sqlBuilder.append(generateRenameObjectDDL(oldColumn, newColumn)); + } + + // 2. Data-type change → SET DATA TYPE. + if (!isDataTypeEqual(oldColumn, newColumn)) { + sqlBuilder.append("ALTER TABLE ").append(getFullyQualifiedTableName(newColumn)) + .append(" ALTER COLUMN ").identifier(newColumn.getName()) + .append(" SET DATA TYPE"); + new Db2DataTypeModifier().appendModifier(newColumn, sqlBuilder); + sqlBuilder.append(";\n"); + } + + // 3. Nullability change → SET NOT NULL / DROP NOT NULL. + if (!Objects.equals(oldColumn.getNullable(), newColumn.getNullable())) { + if (Boolean.FALSE.equals(newColumn.getNullable())) { + sqlBuilder.append("ALTER TABLE ").append(getFullyQualifiedTableName(newColumn)) + .append(" ALTER COLUMN ").identifier(newColumn.getName()) + .append(" SET NOT NULL;\n"); + } else { + sqlBuilder.append("ALTER TABLE ").append(getFullyQualifiedTableName(newColumn)) + .append(" ALTER COLUMN ").identifier(newColumn.getName()) + .append(" DROP NOT NULL;\n"); + } + } + + // 4. Default value change → SET DEFAULT / DROP DEFAULT. + if (!StringUtils.equals(oldColumn.getDefaultValue(), newColumn.getDefaultValue())) { + if (StringUtils.isNotBlank(newColumn.getDefaultValue())) { + sqlBuilder.append("ALTER TABLE ").append(getFullyQualifiedTableName(newColumn)) + .append(" ALTER COLUMN ").identifier(newColumn.getName()) + .append(" SET DEFAULT ").append(newColumn.getDefaultValue()).append(";\n"); + } else { + sqlBuilder.append("ALTER TABLE ").append(getFullyQualifiedTableName(newColumn)) + .append(" ALTER COLUMN ").identifier(newColumn.getName()) + .append(" DROP DEFAULT;\n"); + } + } + + // 5. Comment change → COMMENT ON COLUMN. + if (!Objects.equals(oldColumn.getComment(), newColumn.getComment())) { + generateColumnComment(newColumn, sqlBuilder); + } + + return sqlBuilder.toString(); + } + + @Override + protected void generateColumnComment(DBTableColumn column, SqlBuilder sqlBuilder) { + if (StringUtils.isBlank(column.getComment())) { + return; + } + // DB2 LUW uses COMMENT ON COLUMN schema.table.column IS '...'; + sqlBuilder.append("COMMENT ON COLUMN ").append(getFullyQualifiedTableName(column)) + .append(".").identifier(column.getName()) + .append(" IS ").value(column.getComment()).append(";\n"); + } + + private boolean isDataTypeEqual(DBTableColumn oldColumn, DBTableColumn newColumn) { + if (!StringUtils.equalsIgnoreCase(oldColumn.getTypeName(), newColumn.getTypeName())) { + return false; + } + if (!Objects.equals(oldColumn.getPrecision(), newColumn.getPrecision())) { + return false; + } + if (!Objects.equals(oldColumn.getScale(), newColumn.getScale())) { + return false; + } + return true; + } + + /** + * DB2 data-type fragment. Precision/scale are emitted only for types that accept them + * (VARCHAR/CHAR/DECIMAL/...). Integer types such as SMALLINT/INTEGER/BIGINT have no length so we + * skip them even if precision is non-null in the {@link DBTableColumn} model. + */ + protected static class Db2DataTypeModifier implements DBColumnModifier { + + @Override + public void appendModifier(DBTableColumn column, SqlBuilder sqlBuilder) { + String typeName = column.getTypeName(); + Long precision = column.getPrecision(); + Integer scale = column.getScale(); + sqlBuilder.space().append(typeName); + if (!supportsPrecision(typeName)) { + return; + } + if (Objects.nonNull(scale)) { + if (Objects.nonNull(precision)) { + sqlBuilder.append("(").append(String.valueOf(precision)) + .append(",").append(String.valueOf(scale)).append(")"); + } else { + sqlBuilder.append("(").append(String.valueOf(scale)).append(")"); + } + } else if (Objects.nonNull(precision)) { + sqlBuilder.append("(").append(String.valueOf(precision)).append(")"); + } + } + + private boolean supportsPrecision(String typeName) { + if (StringUtils.isBlank(typeName)) { + return false; + } + String upper = typeName.toUpperCase(); + return upper.equals("VARCHAR") || upper.equals("CHAR") || upper.equals("CHARACTER") + || upper.equals("VARGRAPHIC") || upper.equals("GRAPHIC") + || upper.equals("DECIMAL") || upper.equals("NUMERIC") + || upper.equals("BLOB") || upper.equals("CLOB") || upper.equals("DBCLOB") + || upper.equals("BINARY") || upper.equals("VARBINARY") + || upper.equals("TIMESTAMP") || upper.equals("DECFLOAT"); + } + } + + /** + * DB2 nullability is emitted as a column attribute during CREATE / ADD COLUMN. SET / DROP NOT NULL + * is handled separately during ALTER. + */ + protected static class Db2NullNotNullModifier implements DBColumnModifier { + @Override + public void appendModifier(DBTableColumn column, SqlBuilder sqlBuilder) { + if (column.getNullable() == null) { + return; + } + sqlBuilder.append(column.getNullable() ? "" : " NOT NULL"); + } + } + + /** + * DB2 DEFAULT expression. Verbatim emission — same approach as + * {@link Db2SqlBuilder#defaultValue(String)}. + */ + protected static class Db2DefaultModifier implements DBColumnModifier { + @Override + public void appendModifier(DBTableColumn column, SqlBuilder sqlBuilder) { + String defaultValue = column.getDefaultValue(); + if (StringUtils.isNotBlank(defaultValue)) { + sqlBuilder.append(" DEFAULT ").append(defaultValue); + } + } + } +} diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2ConstraintEditor.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2ConstraintEditor.java new file mode 100644 index 0000000000..ecb8ea91ad --- /dev/null +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2ConstraintEditor.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.tools.dbbrowser.editor.db2; + +import javax.validation.constraints.NotNull; + +import com.oceanbase.tools.dbbrowser.editor.DBTableConstraintEditor; +import com.oceanbase.tools.dbbrowser.model.DBConstraintType; +import com.oceanbase.tools.dbbrowser.model.DBTableConstraint; +import com.oceanbase.tools.dbbrowser.util.Db2SqlBuilder; +import com.oceanbase.tools.dbbrowser.util.SqlBuilder; +import com.oceanbase.tools.dbbrowser.util.StringUtils; + +/** + * DB2 LUW constraint editor (fix_report_20260529_100416 Bug-2, Issue dms-ee#839). + * + *

+ * DB2 ADD CONSTRAINT uses standard SQL/ANSI syntax: + * + *

    + *
  • {@code ALTER TABLE "S"."T" ADD CONSTRAINT "pk" PRIMARY KEY ("col");}
  • + *
  • {@code ALTER TABLE "S"."T" ADD CONSTRAINT "uq" UNIQUE ("col");}
  • + *
  • {@code ALTER TABLE "S"."T" ADD CONSTRAINT "fk" FOREIGN KEY ("c") REFERENCES "S2"."T2" ("c");}
  • + *
  • {@code ALTER TABLE "S"."T" ADD CONSTRAINT "ck" CHECK (col > 0);}
  • + *
  • {@code ALTER TABLE "S"."T" DROP CONSTRAINT "name";} (for non-PK) or + * {@code DROP PRIMARY KEY;}
  • + *
+ * + *

+ * Inheriting {@link DBTableConstraintEditor#generateCreateObjectDDL} produces a usable form; the + * override here normalises DROP for PRIMARY KEY and switches the SQL builder to + * {@link Db2SqlBuilder} for double-quoted identifiers. + * + * @since ODC_release_4.3.4 (Issue dms-ee#839, fix_report_20260529_100416) + */ +public class Db2ConstraintEditor extends DBTableConstraintEditor { + + @Override + protected SqlBuilder sqlBuilder() { + return new Db2SqlBuilder(); + } + + /** + * DB2 has no in-place rename for table constraints (DROP + ADD is the documented workflow); the + * editor emits a DROP / re-ADD pair so the round-trip works inside the table designer. + */ + @Override + public String generateRenameObjectDDL(@NotNull DBTableConstraint oldConstraint, + @NotNull DBTableConstraint newConstraint) { + SqlBuilder sqlBuilder = sqlBuilder(); + sqlBuilder.append(generateDropObjectDDL(oldConstraint)); + sqlBuilder.append(generateCreateObjectDDL(newConstraint)); + return sqlBuilder.toString(); + } + + @Override + public String generateDropObjectDDL(@NotNull DBTableConstraint constraint) { + SqlBuilder sqlBuilder = sqlBuilder(); + sqlBuilder.append("ALTER TABLE ").append(getFullyQualifiedTableName(constraint)); + if (constraint.getType() == DBConstraintType.PRIMARY_KEY) { + // DB2 uses DROP PRIMARY KEY; ALTER ... DROP CONSTRAINT also works on 11.5+ + // but DROP PRIMARY KEY is the documented, version-independent form. + sqlBuilder.append(" DROP PRIMARY KEY;\n"); + } else if (StringUtils.isNotBlank(constraint.getName())) { + sqlBuilder.append(" DROP CONSTRAINT ").identifier(constraint.getName()).append(";\n"); + } else { + // Defensive fall-back: anonymous non-PK constraint is rare in DB2 because + // SYSCAT.TABCONST always materialises an auto-generated SQLxxxxxxxx name. + sqlBuilder.append(";\n"); + } + return sqlBuilder.toString(); + } +} diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2IndexEditor.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2IndexEditor.java new file mode 100644 index 0000000000..1043912b01 --- /dev/null +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2IndexEditor.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.tools.dbbrowser.editor.db2; + +import java.util.List; +import java.util.stream.Collectors; + +import javax.validation.constraints.NotNull; + +import com.oceanbase.tools.dbbrowser.editor.DBTableIndexEditor; +import com.oceanbase.tools.dbbrowser.model.DBIndexType; +import com.oceanbase.tools.dbbrowser.model.DBTableIndex; +import com.oceanbase.tools.dbbrowser.util.Db2SqlBuilder; +import com.oceanbase.tools.dbbrowser.util.SqlBuilder; +import com.oceanbase.tools.dbbrowser.util.StringUtils; + +/** + * DB2 LUW index editor (fix_report_20260529_100416 Bug-2, Issue dms-ee#839). + * + *

+ * DB2 indexes live in a schema rather than under their table: + * {@code CREATE [UNIQUE] INDEX "schema"."idx" ON "schema"."table" (col1, col2);} + * {@code DROP INDEX "schema"."idx";} — we therefore override {@link #generateCreateObjectDDL} and + * {@link #generateDropObjectDDL} instead of inheriting the MySQL + * {@code ALTER TABLE ... ADD / DROP INDEX} form which DB2 rejects. + * + * @since ODC_release_4.3.4 (Issue dms-ee#839, fix_report_20260529_100416) + */ +public class Db2IndexEditor extends DBTableIndexEditor { + + @Override + protected SqlBuilder sqlBuilder() { + return new Db2SqlBuilder(); + } + + @Override + public boolean editable() { + return true; + } + + @Override + public String generateCreateObjectDDL(@NotNull DBTableIndex index) { + SqlBuilder sqlBuilder = sqlBuilder(); + sqlBuilder.append("CREATE "); + if (index.getType() == DBIndexType.UNIQUE) { + sqlBuilder.append("UNIQUE "); + } + sqlBuilder.append("INDEX ").append(getFullyQualifiedIndexName(index)) + .append(" ON ").append(getFullyQualifiedTableName(index)) + .append(" ("); + List quotedColumns = index.getColumnNames().stream() + .map(StringUtils::quoteOracleIdentifier) + .collect(Collectors.toList()); + sqlBuilder.append(String.join(", ", quotedColumns)); + sqlBuilder.append(");\n"); + return sqlBuilder.toString(); + } + + @Override + public String generateDropObjectDDL(@NotNull DBTableIndex index) { + SqlBuilder sqlBuilder = sqlBuilder(); + sqlBuilder.append("DROP INDEX ").append(getFullyQualifiedIndexName(index)).append(";\n"); + return sqlBuilder.toString(); + } + + /** + * DB2 has no {@code ALTER INDEX RENAME} for plain indexes — recreate the index. + */ + @Override + public String generateRenameObjectDDL(@NotNull DBTableIndex oldIndex, @NotNull DBTableIndex newIndex) { + SqlBuilder sqlBuilder = sqlBuilder(); + sqlBuilder.append(generateDropObjectDDL(oldIndex)); + sqlBuilder.append(generateCreateObjectDDL(newIndex)); + return sqlBuilder.toString(); + } + + @Override + protected void appendIndexColumnModifiers(DBTableIndex index, SqlBuilder sqlBuilder) { + // DB2 does not allow per-column modifiers (no MySQL-style index length) in CREATE INDEX. + } + + @Override + protected void appendIndexOptions(DBTableIndex index, SqlBuilder sqlBuilder) { + // DB2 index options (CLUSTER / INCLUDE / PCTFREE) are out of scope for the workbench editor. + } + + /** + * DB2 index objects live in a schema (just like tables). Build the {@code "schema"."index"} + * qualifier so DROP INDEX / CREATE INDEX address the correct namespace. + */ + private String getFullyQualifiedIndexName(@NotNull DBTableIndex index) { + SqlBuilder sqlBuilder = sqlBuilder(); + if (StringUtils.isNotEmpty(index.getSchemaName())) { + sqlBuilder.identifier(index.getSchemaName()).append("."); + } + sqlBuilder.identifier(index.getName()); + return sqlBuilder.toString(); + } +} diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2NoOpPartitionEditor.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2NoOpPartitionEditor.java new file mode 100644 index 0000000000..ee4ade83de --- /dev/null +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2NoOpPartitionEditor.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.tools.dbbrowser.editor.db2; + +import java.util.List; + +import javax.validation.constraints.NotNull; + +import com.oceanbase.tools.dbbrowser.editor.DBTablePartitionEditor; +import com.oceanbase.tools.dbbrowser.model.DBTablePartition; +import com.oceanbase.tools.dbbrowser.model.DBTablePartitionDefinition; +import com.oceanbase.tools.dbbrowser.model.DBTablePartitionOption; +import com.oceanbase.tools.dbbrowser.util.Db2SqlBuilder; +import com.oceanbase.tools.dbbrowser.util.SqlBuilder; + +import lombok.NonNull; + +/** + * No-op partition editor for DB2 LUW (fix_report_20260529_100416 Bug-2, Issue dms-ee#839). + * + *

+ * DB2 range / hash partitioning is intentionally out of scope per {@code expand_odc_db2.md} §14. + * {@link Db2TableEditor} still needs a non-null partition editor so the parent + * {@link com.oceanbase.tools.dbbrowser.editor.DBTableEditor#generateUpdateObjectDDL} pipeline can + * call {@code partitionEditor.generateUpdateObjectDDL(null, null)} without NPE. All DDL-emitting + * methods return empty strings so the workbench produces a clean ALTER COLUMN / ALTER INDEX flow + * without trailing comments. + * + * @since ODC_release_4.3.4 (Issue dms-ee#839, fix_report_20260529_100416) + */ +public class Db2NoOpPartitionEditor extends DBTablePartitionEditor { + + @Override + public boolean editable() { + return false; + } + + @Override + protected SqlBuilder sqlBuilder() { + return new Db2SqlBuilder(); + } + + @Override + public String generateCreateObjectDDL(@NotNull DBTablePartition partition) { + return ""; + } + + @Override + public String generateCreateDefinitionDDL(@NotNull DBTablePartition partition) { + return ""; + } + + @Override + public String generateDropObjectDDL(@NotNull DBTablePartition partition) { + return ""; + } + + @Override + public String generateUpdateObjectDDL(DBTablePartition oldPartition, DBTablePartition newPartition) { + return ""; + } + + @Override + public String generateAddPartitionDefinitionDDL(@NotNull DBTablePartitionDefinition definition, + @NotNull DBTablePartitionOption option, String fullyQualifiedTableName) { + return ""; + } + + @Override + public String generateAddPartitionDefinitionDDL(String schemaName, @NonNull String tableName, + @NotNull DBTablePartitionOption option, List definitions) { + return ""; + } + + @Override + protected void appendDefinitions(DBTablePartition partition, SqlBuilder sqlBuilder) { + // no-op + } + + @Override + protected void appendDefinition(DBTablePartitionOption option, DBTablePartitionDefinition definition, + SqlBuilder sqlBuilder) { + // no-op + } + + @Override + protected String modifyPartitionType(@NotNull DBTablePartition oldPartition, + @NotNull DBTablePartition newPartition) { + return ""; + } +} diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2TableEditor.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2TableEditor.java new file mode 100644 index 0000000000..bb69cda829 --- /dev/null +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2TableEditor.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.tools.dbbrowser.editor.db2; + +import java.util.Objects; + +import javax.validation.constraints.NotNull; + +import com.oceanbase.tools.dbbrowser.editor.DBObjectEditor; +import com.oceanbase.tools.dbbrowser.editor.DBTableEditor; +import com.oceanbase.tools.dbbrowser.model.DBTable; +import com.oceanbase.tools.dbbrowser.model.DBTableColumn; +import com.oceanbase.tools.dbbrowser.model.DBTableConstraint; +import com.oceanbase.tools.dbbrowser.model.DBTableIndex; +import com.oceanbase.tools.dbbrowser.model.DBTablePartition; +import com.oceanbase.tools.dbbrowser.util.Db2SqlBuilder; +import com.oceanbase.tools.dbbrowser.util.SqlBuilder; +import com.oceanbase.tools.dbbrowser.util.StringUtils; + +import lombok.NonNull; + +/** + * DB2 LUW table editor (fix_report_20260529_100416 Bug-2, Issue dms-ee#839). + * + *

+ * Implements the {@link DBTableEditor} contract using DB2 LUW grammar: + * + *

+ * {@code
+ * CREATE TABLE "S"."T" (
+ *     "ID"   BIGINT NOT NULL,
+ *     "NAME" VARCHAR(100),
+ *     PRIMARY KEY ("ID")
+ * );
+ * COMMENT ON TABLE "S"."T" IS '...';
+ * COMMENT ON COLUMN "S"."T"."ID" IS '...';
+ *
+ * -- CREATE INDEX runs as a separate statement (createIndexWhenCreatingTable() = false)
+ * CREATE INDEX "S"."idx_t_name" ON "S"."T" ("NAME");
+ * }
+ * 
+ * + *

+ * The parent class' {@link DBTableEditor#generateUpdateObjectDDL} dispatch fans out to the column / + * index / constraint editors via {@code generateUpdateObjectListDDL}, so wiring this editor to + * {@link Db2ColumnEditor}, {@link Db2IndexEditor}, {@link Db2ConstraintEditor} is sufficient — we + * only override the small set of dialect-specific hooks (rename / comment / table-option emission). + * + * @since ODC_release_4.3.4 (Issue dms-ee#839, fix_report_20260529_100416) + */ +public class Db2TableEditor extends DBTableEditor { + + public Db2TableEditor(DBObjectEditor indexEditor, + DBObjectEditor columnEditor, + DBObjectEditor constraintEditor, + DBObjectEditor partitionEditor) { + super(indexEditor, columnEditor, constraintEditor, partitionEditor); + } + + @Override + protected SqlBuilder sqlBuilder() { + return new Db2SqlBuilder(); + } + + /** + * DB2 cannot embed CREATE INDEX inside CREATE TABLE; indexes are emitted as separate statements + * after table creation. The parent CREATE TABLE pipeline already handles this when this returns + * false. + */ + @Override + protected boolean createIndexWhenCreatingTable() { + return false; + } + + @Override + protected void appendColumnComment(DBTable table, SqlBuilder sqlBuilder) { + if (Objects.isNull(table.getColumns())) { + return; + } + for (DBTableColumn column : table.getColumns()) { + column.setSchemaName(table.getSchemaName()); + column.setTableName(table.getName()); + if (columnEditor instanceof Db2ColumnEditor) { + // Reuse the column editor's COMMENT ON COLUMN emission (package-private accessor). + String fragment = ((Db2ColumnEditor) columnEditor).generateUpdateObjectDDL( + emptyColumn(column), column); + // generateUpdateObjectDDL emits ALTER TABLE first when names differ — for a freshly + // CREATEd table the column already has its real name so the rename branch is skipped. + // Strip everything but COMMENT ON COLUMN lines. + for (String line : fragment.split("\\r?\\n")) { + if (line.startsWith("COMMENT ON COLUMN")) { + sqlBuilder.append(line).line(); + } + } + } + } + } + + @Override + protected void appendTableComment(DBTable table, SqlBuilder sqlBuilder) { + if (Objects.isNull(table.getTableOptions()) + || StringUtils.isBlank(table.getTableOptions().getComment())) { + return; + } + sqlBuilder.append("COMMENT ON TABLE ").append(getFullyQualifiedTableName(table)) + .append(" IS ").value(table.getTableOptions().getComment()).append(";\n"); + } + + @Override + protected void appendTableOptions(DBTable table, SqlBuilder sqlBuilder) { + // DB2 has no MySQL-style table options block (CHARSET / COLLATE / ENGINE). Comments are + // applied via the COMMENT ON TABLE statement appended afterwards by appendTableComment(). + } + + @Override + public void generateUpdateTableOptionDDL(@NonNull DBTable oldTable, @NonNull DBTable newTable, + @NonNull SqlBuilder sqlBuilder) { + if (Objects.isNull(oldTable.getTableOptions()) || Objects.isNull(newTable.getTableOptions())) { + return; + } + String oldComment = oldTable.getTableOptions().getComment(); + String newComment = newTable.getTableOptions().getComment(); + if (!Objects.equals(oldComment, newComment)) { + sqlBuilder.append("COMMENT ON TABLE ").append(getFullyQualifiedTableName(newTable)) + .append(" IS ").value(StringUtils.isBlank(newComment) ? "" : newComment).append(";\n"); + } + } + + @Override + public String generateRenameObjectDDL(@NotNull DBTable oldTable, @NotNull DBTable newTable) { + // DB2 supports RENAME TABLE within the same schema. Cross-schema rename requires + // ADMIN_MOVE_TABLE which is out of scope for the workbench editor. + SqlBuilder sqlBuilder = sqlBuilder(); + sqlBuilder.append("RENAME TABLE ").append(getFullyQualifiedTableName(oldTable)) + .append(" TO ").identifier(newTable.getName()); + return sqlBuilder.toString(); + } + + /** + * Build a column with the same identity (schema / table / name) but no other attributes — used as + * the "old" sentinel when reusing {@link Db2ColumnEditor#generateUpdateObjectDDL} purely to obtain + * the COMMENT ON COLUMN fragment for a freshly added column. Returning a stripped clone avoids + * mutating the caller's instance. + */ + private static DBTableColumn emptyColumn(DBTableColumn template) { + DBTableColumn empty = new DBTableColumn(); + empty.setSchemaName(template.getSchemaName()); + empty.setTableName(template.getTableName()); + empty.setName(template.getName()); + return empty; + } +} diff --git a/server/odc-migrate/src/main/resources/migrate/common/R_2_0_0__initialize_version_diff_config.sql b/server/odc-migrate/src/main/resources/migrate/common/R_2_0_0__initialize_version_diff_config.sql index a21bb9b781..64145fe363 100644 --- a/server/odc-migrate/src/main/resources/migrate/common/R_2_0_0__initialize_version_diff_config.sql +++ b/server/odc-migrate/src/main/resources/migrate/common/R_2_0_0__initialize_version_diff_config.sql @@ -295,4 +295,14 @@ insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min -- min_version='0' mirrors the SQL_SERVER / DORIS always-on pattern — DB2 ADMIN_CMD -- 'FORCE APPLICATION' is available on every DB2 11.5+ build we support. insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`) values('support_kill_session','DB2','true','0',CURRENT_TIMESTAMP) ON DUPLICATE KEY update `config_key`=`config_key`; -insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`) values('support_kill_query','DB2','true','0',CURRENT_TIMESTAMP) ON DUPLICATE KEY update `config_key`=`config_key`; \ No newline at end of file +insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`) values('support_kill_query','DB2','true','0',CURRENT_TIMESTAMP) ON DUPLICATE KEY update `config_key`=`config_key`; + +-- DB2 LUW column_data_type seed (fix_report_20260529_100416 Bug-1, Issue dms-ee#839) +-- Bug-1: 表设计器"添加列"列类型下拉框为空。 +-- 根因: VersionDiffConfigService#getColumnDataTypes 通过 config_key='column_data_type' + db_mode='DB2' 读取本表, +-- 没有这一行就返回空集,前端 ColumnSelector 渲染为空下拉。SQL_SERVER / ORACLE / MYSQL 都各自有等价 seed(见 §274, §255, §202)。 +-- 列出 DB2 LUW 11.5 文档里 ALTER TABLE / CREATE TABLE 允许出现的列类型(按 ODC type 分桶: NUMERIC/TEXT/OBJECT/DATE/TIME/TIMESTAMP/BOOLEAN/INTERVAL)。 +-- min_version='9.7' 与 SQL_SERVER 同样的 always-on 语义(DB2 9.7 起所有目标版本都支持这些类型)。 +insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`) values('column_data_type', 'DB2', +'SMALLINT:NUMERIC, INTEGER:NUMERIC, INT:NUMERIC, BIGINT:NUMERIC, DECIMAL:NUMERIC, NUMERIC:NUMERIC, REAL:NUMERIC, DOUBLE:NUMERIC, FLOAT:NUMERIC, DECFLOAT:NUMERIC, CHAR:TEXT, VARCHAR:TEXT, LONG VARCHAR:TEXT, CLOB:OBJECT, GRAPHIC:TEXT, VARGRAPHIC:TEXT, DBCLOB:OBJECT, BLOB:OBJECT, BINARY:OBJECT, VARBINARY:OBJECT, DATE:DATE, TIME:TIME, TIMESTAMP:TIMESTAMP, BOOLEAN:BOOLEAN, XML:OBJECT', +'9.7', CURRENT_TIMESTAMP) ON DUPLICATE KEY update `config_key`=`config_key`; \ No newline at end of file diff --git a/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2TableExtension.java b/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2TableExtension.java index 1adae78702..532bc029e9 100644 --- a/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2TableExtension.java +++ b/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/Db2TableExtension.java @@ -23,6 +23,7 @@ import com.oceanbase.odc.common.util.JdbcOperationsUtil; import com.oceanbase.odc.plugin.schema.db2.utils.DBAccessorUtil; import com.oceanbase.odc.plugin.schema.obmysql.OBMySQLTableExtension; +import com.oceanbase.tools.dbbrowser.editor.DBTableEditor; import com.oceanbase.tools.dbbrowser.model.DBObjectType; import com.oceanbase.tools.dbbrowser.model.DBTable; import com.oceanbase.tools.dbbrowser.model.DBTableStats; @@ -133,4 +134,34 @@ public boolean syncExternalTableFiles(Connection connection, String schemaName, // upstream sync flow doesn't 500. return false; } + + /** + * fix_report_20260529_100416 Bug-2 (Issue dms-ee#839): route CREATE TABLE DDL generation through + * the DB2-native {@code DBTableEditor} (built by {@code DBTableEditorFactory.buildForDB2()}) + * instead of inheriting the OB-MySQL path which invokes + * {@code OBMySQLInformationExtension.getDBVersion(connection)} → + * {@code show variables like 'version_comment'}. DB2 jcc rejects that probe with + * {@code ERRORCODE=-4476 (executeQuery used for update)}, collapsing the entire "保存表结构" workflow on + * the table designer with HTTP 500. + */ + @Override + public String generateCreateDDL(@NonNull Connection connection, @NonNull DBTable table) { + DBTableEditor editor = DBAccessorUtil.getTableEditor(connection); + return editor.generateCreateObjectDDL(table); + } + + /** + * fix_report_20260529_100416 Bug-2 (Issue dms-ee#839): same rationale as + * {@link #generateCreateDDL(Connection, DBTable)}. Force the workbench's "修改表结构" flow to use the + * DB2 editor stack (column / index / constraint editors) — without this override the inherited + * OB-MySQL implementation walked the {@code DBAccessorUtil.getTableEditor(conn)} of the obmysql + * package and built {@link com.oceanbase.tools.dbbrowser.editor.mysql.OBMySQLTableEditor} which + * emits MySQL-only ALTER TABLE MODIFY COLUMN grammar that DB2 cannot parse. + */ + @Override + public String generateUpdateDDL(@NonNull Connection connection, @NonNull DBTable oldTable, + @NonNull DBTable newTable) { + DBTableEditor editor = DBAccessorUtil.getTableEditor(connection); + return editor.generateUpdateObjectDDL(oldTable, newTable); + } } diff --git a/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/utils/DBAccessorUtil.java b/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/utils/DBAccessorUtil.java index 8d1b42ad25..60c6912a02 100644 --- a/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/utils/DBAccessorUtil.java +++ b/server/plugins/schema-plugin-db2/src/main/java/com/oceanbase/odc/plugin/schema/db2/utils/DBAccessorUtil.java @@ -20,6 +20,7 @@ import com.oceanbase.odc.common.util.JdbcOperationsUtil; import com.oceanbase.odc.core.shared.constant.DialectType; import com.oceanbase.tools.dbbrowser.DBBrowser; +import com.oceanbase.tools.dbbrowser.editor.DBTableEditor; import com.oceanbase.tools.dbbrowser.schema.DBSchemaAccessor; /** @@ -41,4 +42,23 @@ public static DBSchemaAccessor getSchemaAccessor(Connection connection) { .create(); } + /** + * DB2 table editor entry point (fix_report_20260529_100416 Bug-2, Issue dms-ee#839). + * + *

+ * The inherited {@code OBMySQLTableExtension#getTableEditor(Connection)} routes through the + * OB-MySQL DBAccessorUtil which executes {@code "show variables like 'version_comment'"} — that + * statement fails on DB2 with {@code ERRORCODE=-4476 (executeQuery used for update)}, so every + * "保存表结构" click on a DB2 table designer used to 500 even after the editor factories were wired. Set + * {@code dbVersion} to {@code "11.5"} (the lowest DB2 LUW version we test against) instead of + * probing — none of the DB2 editor implementations branch on dbVersion, so the value is effectively + * a fixed placeholder that satisfies factory contract checks. + */ + public static DBTableEditor getTableEditor(Connection connection) { + return DBBrowser.objectEditor().tableEditor() + .setDbVersion("11.5") + .setType(DialectType.DB2.getDBBrowserDialectTypeName()) + .create(); + } + } From d06729fa928f0517205e122246fb08740fefb1fd Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Mon, 1 Jun 2026 03:30:31 +0000 Subject: [PATCH 19/19] fix(odc-db-browser): back-fill DB2 index columnNames/ordinalPosition + defend Db2IndexEditor against null columnNames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the workbench's "POST generateUpdateTableDDL HTTP 400/500 message=null" error that blocked every "edit a column" operation on DB2 tables carrying any index (Issue dms-ee#839, fix_report_20260601_031142 P0-2). Root cause: Db2SchemaAccessor.listTableIndexes only queried SYSCAT.INDEXES and never populated columnNames / ordinalPosition. DBTableIndexEditor. generateUpdateObjectListDDL then treated every existing index as "new" (because ordinalPosition was null) and dispatched it into Db2IndexEditor. generateCreateObjectDDL, which NPE'd on `index.getColumnNames().stream()`. Three coordinated changes: * Db2SchemaAccessor.listTableIndexes: JOIN SYSCAT.INDEXES with SYSCAT.INDEXCOLUSE (mirroring SqlServerSchemaAccessor.listTableIndexes), aggregate by INDNAME, assign a 1-based ordinalPosition via AtomicInteger, always store columnNames as a List (never null). UNIQUERULE='P' → primary=true + unique=true + UNIQUE, 'U' → UNIQUE, 'D' → NORMAL. * Db2SchemaAccessor.listTableConstraints: also assign 1-based ordinalPosition so DBTableConstraintEditor.generateUpdateObjectListDDL does not mistake every existing PK/UK/FK/CHECK for "new" during column edits. * Db2IndexEditor.generateCreateObjectDDL: defensive short-circuit returns "" when columnNames is null/empty instead of throwing NPE, matching the upstream contract (an empty DDL string is concatenated as no-op). Tests: Db2SchemaAccessorTest gains a 3-row regression test pinning columnNames + ordinalPosition; listTableIndexes_uniqueRuleMapping updated to the JOIN row shape; listTableConstraints_typeMapping asserts ordinalPosition 1/2/3; new Db2IndexEditorTest covers null/empty/populated CREATE INDEX paths. Db2*Test 28 PASS, odc-service / odc-core compile. Related-Issue: https://github.com/actiontech/dms-ee/issues/839 --- .../dbbrowser/editor/db2/Db2IndexEditor.java | 19 ++- .../schema/db2/Db2SchemaAccessor.java | 87 +++++++++++-- .../editor/db2/Db2IndexEditorTest.java | 117 ++++++++++++++++++ .../schema/db2/Db2SchemaAccessorTest.java | 82 +++++++++++- 4 files changed, 291 insertions(+), 14 deletions(-) create mode 100644 libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2IndexEditorTest.java diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2IndexEditor.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2IndexEditor.java index 1043912b01..c52f3266a6 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2IndexEditor.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2IndexEditor.java @@ -53,6 +53,23 @@ public boolean editable() { @Override public String generateCreateObjectDDL(@NotNull DBTableIndex index) { + // fix_report_20260601_031142 (Issue dms-ee#839, P0-2B): historically this method assumed + // index.getColumnNames() was always populated, but in the "edit a column" flow upstream + // code (DBTableIndexEditor.generateUpdateObjectListDDL) can route legacy DBTableIndex + // instances here whose columnNames is null — most commonly when listTableIndexes had + // not yet been hardened (see P0-2A) or when an external caller constructs a sparse + // DBTableIndex. Calling .stream() on null aborts with NPE which surfaces to the user as + // "POST generateUpdateTableDDL HTTP 400/500 message=null", blocking every table edit on + // tables that carry indexes. + // + // Defence: emit an empty string (no DDL) rather than throw. The decision matches the + // upstream contract — generateUpdateObjectListDDL concatenates the per-index DDL into + // a script and an empty string is the natural "do nothing" payload, so a half-populated + // index never silently mutates the schema. + List columnNames = index.getColumnNames(); + if (columnNames == null || columnNames.isEmpty()) { + return ""; + } SqlBuilder sqlBuilder = sqlBuilder(); sqlBuilder.append("CREATE "); if (index.getType() == DBIndexType.UNIQUE) { @@ -61,7 +78,7 @@ public String generateCreateObjectDDL(@NotNull DBTableIndex index) { sqlBuilder.append("INDEX ").append(getFullyQualifiedIndexName(index)) .append(" ON ").append(getFullyQualifiedTableName(index)) .append(" ("); - List quotedColumns = index.getColumnNames().stream() + List quotedColumns = columnNames.stream() .map(StringUtils::quoteOracleIdentifier) .collect(Collectors.toList()); sqlBuilder.append(String.join(", ", quotedColumns)); diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java index 98588c7af6..acd5b2e0a9 100644 --- a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java +++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessor.java @@ -17,8 +17,10 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; import org.springframework.jdbc.core.JdbcOperations; @@ -26,6 +28,7 @@ import com.oceanbase.tools.dbbrowser.model.DBConstraintType; import com.oceanbase.tools.dbbrowser.model.DBDatabase; import com.oceanbase.tools.dbbrowser.model.DBFunction; +import com.oceanbase.tools.dbbrowser.model.DBIndexType; import com.oceanbase.tools.dbbrowser.model.DBMViewRefreshParameter; import com.oceanbase.tools.dbbrowser.model.DBMViewRefreshRecord; import com.oceanbase.tools.dbbrowser.model.DBMViewRefreshRecordParam; @@ -549,6 +552,7 @@ public List listTableConstraints(String schemaName, String ta // exercised by the DML builder path. String sql = "SELECT TABSCHEMA, TABNAME, CONSTNAME, TYPE FROM SYSCAT.TABCONST " + "WHERE TABSCHEMA = ? AND TABNAME = ? ORDER BY CONSTNAME"; + AtomicInteger constraintCounter = new AtomicInteger(1); List constraints = jdbcOperations.query(sql, new Object[] {schemaName, tableName}, (rs, rowNum) -> { DBTableConstraint constraint = new DBTableConstraint(); @@ -557,6 +561,15 @@ public List listTableConstraints(String schemaName, String ta constraint.setName(rs.getString("CONSTNAME")); String type = rs.getString("TYPE"); constraint.setType(mapDb2ConstraintType(type)); + // fix_report_20260601_031142 (Issue dms-ee#839, P0-2C): mirror P0-2A's + // ordinalPosition treatment for indexes. DBTableConstraintEditor. + // generateUpdateObjectListDDL (editor/DBTableConstraintEditor.java:182–209) + // also treats `ordinalPosition == null` as "this is a new constraint" and + // emits ADD CONSTRAINT for every existing PK/UK/FK/CHECK whenever the user + // edits a column. Without an ordinalPosition the user gets a noisy + // DROP/ADD CONSTRAINT script (or worse, conflicting ADDs that fail at + // execution). 1-based ordinal per the SqlServer convention. + constraint.setOrdinalPosition(constraintCounter.getAndIncrement()); return constraint; }); if (constraints == null || constraints.isEmpty()) { @@ -630,18 +643,70 @@ public DBTablePartition getPartition(String schemaName, String tableName) { @Override public List listTableIndexes(String schemaName, String tableName) { - String sql = "SELECT INDSCHEMA, INDNAME, TABSCHEMA, TABNAME, UNIQUERULE " - + "FROM SYSCAT.INDEXES WHERE TABSCHEMA = ? AND TABNAME = ? ORDER BY INDNAME"; - return jdbcOperations.query(sql, new Object[] {schemaName, tableName}, (rs, rowNum) -> { - DBTableIndex index = new DBTableIndex(); - index.setSchemaName(rs.getString("INDSCHEMA")); - index.setName(rs.getString("INDNAME")); - index.setTableName(rs.getString("TABNAME")); - String uniqueRule = rs.getString("UNIQUERULE"); - // DB2 UNIQUERULE: D=Duplicates allowed, U=Unique, P=Primary - index.setUnique(uniqueRule != null && !"D".equalsIgnoreCase(uniqueRule.trim())); - return index; + // fix_report_20260601_031142 (Issue dms-ee#839, P0-2A): the previous implementation only + // hit SYSCAT.INDEXES and never filled columnNames / ordinalPosition, so when + // DBTableIndexEditor.generateUpdateObjectListDDL (libs/db-browser .../editor/ + // DBTableIndexEditor.java) ran the diff for "table has indexes, user edits a column": + // + // 1. every old index arrived with ordinalPosition=null + // 2. DBTableIndexEditor treats null ordinalPosition as "this is a new index" and called + // Db2IndexEditor.generateCreateObjectDDL(index) + // 3. Db2IndexEditor.generateCreateObjectDDL does `index.getColumnNames().stream()` → + // NullPointerException → POST /databases/{db}/tables/generateUpdateTableDDL fails + // with HTTP 400/500 (message=null), blocking every "edit a column" operation. + // + // This is the same class of defect as fix-L's constraint-side NPE (back-filled in + // listTableConstraints above): the upstream editor relies on both ordinalPosition (to + // tell "existing" from "new") and columnNames (to actually emit DDL). + // + // Mirror SqlServerSchemaAccessor.listTableIndexes (lines 3781–3868): JOIN the index + // catalog with its column-usage table, aggregate by index name, assign ordinalPosition + // as the index's slot within the table (AtomicInteger), and always store columnNames as + // a List (never null) so downstream stream() / .stream() calls never see null. + // + // DB2 UNIQUERULE legend (SYSCAT.INDEXES): D=Duplicates allowed (NORMAL), + // U=Unique (UNIQUE), P=Primary key (UNIQUE + primary=true). DBIndexType doesn't model + // PRIMARY separately — the primary flag distinguishes it from a plain UNIQUE index. + String sql = "SELECT i.INDSCHEMA, i.INDNAME, i.TABSCHEMA, i.TABNAME, i.UNIQUERULE, " + + "ic.COLNAME, ic.COLSEQ " + + "FROM SYSCAT.INDEXES i " + + "JOIN SYSCAT.INDEXCOLUSE ic " + + " ON i.INDSCHEMA = ic.INDSCHEMA AND i.INDNAME = ic.INDNAME " + + "WHERE i.TABSCHEMA = ? AND i.TABNAME = ? " + + "ORDER BY i.INDNAME, ic.COLSEQ"; + Map indexMap = new LinkedHashMap<>(); + AtomicInteger indexCounter = new AtomicInteger(1); + jdbcOperations.query(sql, new Object[] {schemaName, tableName}, (rs, rowNum) -> { + String indName = rs.getString("INDNAME"); + DBTableIndex index = indexMap.get(indName); + if (index == null) { + index = new DBTableIndex(); + index.setSchemaName(rs.getString("INDSCHEMA")); + index.setName(indName); + index.setTableName(rs.getString("TABNAME")); + // ordinalPosition = index's slot within the table (1-based). Matches what + // SqlServerSchemaAccessor does on line 3833 with AtomicInteger. + index.setOrdinalPosition(indexCounter.getAndIncrement()); + String uniqueRule = rs.getString("UNIQUERULE"); + String rule = uniqueRule == null ? "" : uniqueRule.trim(); + boolean isPrimary = "P".equalsIgnoreCase(rule); + boolean isUnique = isPrimary || "U".equalsIgnoreCase(rule); + index.setPrimary(isPrimary); + index.setUnique(isUnique); + index.setNonUnique(!isUnique); + index.setType(isUnique ? DBIndexType.UNIQUE : DBIndexType.NORMAL); + // Always initialise columnNames as an empty mutable list so the per-row branch + // below can append; never leave it null (the fix's primary safety guarantee). + index.setColumnNames(new ArrayList<>()); + indexMap.put(indName, index); + } + String colName = rs.getString("COLNAME"); + if (colName != null) { + index.getColumnNames().add(colName); + } + return null; }); + return new ArrayList<>(indexMap.values()); } @Override diff --git a/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2IndexEditorTest.java b/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2IndexEditorTest.java new file mode 100644 index 0000000000..369d1910dc --- /dev/null +++ b/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/editor/db2/Db2IndexEditorTest.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2023 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.oceanbase.tools.dbbrowser.editor.db2; + +import java.util.Arrays; +import java.util.Collections; + +import org.junit.Assert; +import org.junit.Test; + +import com.oceanbase.tools.dbbrowser.model.DBIndexType; +import com.oceanbase.tools.dbbrowser.model.DBTableIndex; + +/** + * Mock-only unit tests for {@link Db2IndexEditor}. + * + *

+ * fix_report_20260601_031142 (Issue dms-ee#839, P0-2B) regression cases — focus on the null / empty + * columnNames defence that prevents the workbench's "POST generateUpdateTableDDL HTTP 400/500 + * message=null" when DBTableIndexEditor.generateUpdateObjectListDDL routes a sparse DBTableIndex + * into generateCreateObjectDDL. + * + * @since ODC_release_4.3.4 (Issue dms-ee#839) + */ +public class Db2IndexEditorTest { + + private final Db2IndexEditor editor = new Db2IndexEditor(); + + /** + * P0-2B: passing an index whose columnNames is null must not NPE; the editor returns an empty + * string so DBTableIndexEditor.generateUpdateObjectListDDL concatenation continues with the other + * indexes. + */ + @Test + public void generateCreateObjectDDL_nullColumnNames_returnsEmpty() { + DBTableIndex index = new DBTableIndex(); + index.setSchemaName("DB2INST1"); + index.setName("PK_ORDERS"); + index.setTableName("ORDERS"); + index.setType(DBIndexType.UNIQUE); + // columnNames intentionally left null — mirrors the pre-P0-2A defect path. + + String ddl = editor.generateCreateObjectDDL(index); + + Assert.assertEquals("null columnNames must short-circuit, not NPE", "", ddl); + } + + /** + * P0-2B: empty columnNames should produce an empty string for the same reason. + */ + @Test + public void generateCreateObjectDDL_emptyColumnNames_returnsEmpty() { + DBTableIndex index = new DBTableIndex(); + index.setSchemaName("DB2INST1"); + index.setName("PK_ORDERS"); + index.setTableName("ORDERS"); + index.setType(DBIndexType.UNIQUE); + index.setColumnNames(Collections.emptyList()); + + String ddl = editor.generateCreateObjectDDL(index); + + Assert.assertEquals("empty columnNames must short-circuit", "", ddl); + } + + /** + * P0-2B positive path: a populated columnNames must still produce the + * {@code CREATE [UNIQUE] INDEX "schema"."idx" ON "schema"."table" ("col1", "col2");} grammar. + */ + @Test + public void generateCreateObjectDDL_uniqueIndex_emitsDb2Grammar() { + DBTableIndex index = new DBTableIndex(); + index.setSchemaName("DB2INST1"); + index.setName("PK_ORDERS"); + index.setTableName("ORDERS"); + index.setType(DBIndexType.UNIQUE); + index.setColumnNames(Arrays.asList("ID", "ORDER_NO")); + + String ddl = editor.generateCreateObjectDDL(index); + + Assert.assertTrue("must start with CREATE UNIQUE INDEX", ddl.startsWith("CREATE UNIQUE INDEX ")); + Assert.assertTrue("must double-quote the index name", ddl.contains("\"DB2INST1\".\"PK_ORDERS\"")); + Assert.assertTrue("must reference the target table", ddl.contains(" ON \"DB2INST1\".\"ORDERS\"")); + Assert.assertTrue("must list the columns inside parens", ddl.contains("(\"ID\", \"ORDER_NO\")")); + Assert.assertTrue("must terminate with semicolon + newline", ddl.endsWith(";\n")); + } + + /** + * P0-2B positive path: a NORMAL index drops the UNIQUE keyword. + */ + @Test + public void generateCreateObjectDDL_normalIndex_omitsUniqueKeyword() { + DBTableIndex index = new DBTableIndex(); + index.setSchemaName("DB2INST1"); + index.setName("IDX_ORDERS_DATE"); + index.setTableName("ORDERS"); + index.setType(DBIndexType.NORMAL); + index.setColumnNames(Collections.singletonList("ORDER_DATE")); + + String ddl = editor.generateCreateObjectDDL(index); + + Assert.assertTrue("non-unique index must start with CREATE INDEX", ddl.startsWith("CREATE INDEX ")); + Assert.assertFalse("non-unique index must not contain UNIQUE keyword", ddl.contains("UNIQUE")); + } +} diff --git a/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java b/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java index cdbc1765ab..d6002bc553 100644 --- a/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java +++ b/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/schema/db2/Db2SchemaAccessorTest.java @@ -208,8 +208,14 @@ public void listViews_returnsViewIdentities() throws SQLException { } /** - * Case listTableIndexes_uniqueRuleMapping: 模拟 SYSCAT.INDEXES 2 行 UNIQUERULE='P' 与 'D', 期望前者 - * unique=true(P/U 都视为 unique),后者 unique=false。 + * Case listTableIndexes_uniqueRuleMapping: 模拟 SYSCAT.INDEXES JOIN SYSCAT.INDEXCOLUSE, 两个索引(一个 PK + + * 一个普通),各 1 列,期望 PK 的 unique=true 且 primary=true,普通索引 unique=false。 + * + *

+ * fix_report_20260601_031142 P0-2A: listTableIndexes is now a JOIN aggregation — each row carries + * one (index, column) pair and the accessor groups by INDNAME, so the stub must include COLNAME / + * COLSEQ. ordinalPosition 必须非空(DBTableIndexEditor.generateUpdateObjectListDDL 用它区分"已有 vs + * 新建索引"),columnNames 必须非空(Db2IndexEditor.generateCreateObjectDDL 用它输出 CREATE INDEX 列表)。 */ @Test public void listTableIndexes_uniqueRuleMapping() throws SQLException { @@ -219,12 +225,16 @@ public void listTableIndexes_uniqueRuleMapping() throws SQLException { primary.put("TABSCHEMA", "DB2INST1"); primary.put("TABNAME", "ORDERS"); primary.put("UNIQUERULE", "P"); + primary.put("COLNAME", "ID"); + primary.put("COLSEQ", 1); Map duplicate = new LinkedHashMap<>(); duplicate.put("INDSCHEMA", "DB2INST1"); duplicate.put("INDNAME", "IDX_ORDERS_DATE"); duplicate.put("TABSCHEMA", "DB2INST1"); duplicate.put("TABNAME", "ORDERS"); duplicate.put("UNIQUERULE", "D"); + duplicate.put("COLNAME", "ORDER_DATE"); + duplicate.put("COLSEQ", 1); stubQueryByName(Arrays.asList(primary, duplicate)); List indexes = accessor.listTableIndexes("DB2INST1", "ORDERS"); @@ -232,8 +242,70 @@ public void listTableIndexes_uniqueRuleMapping() throws SQLException { Assert.assertEquals(2, indexes.size()); Assert.assertEquals("PK_ORDERS", indexes.get(0).getName()); Assert.assertTrue("UNIQUERULE=P should map to unique=true", indexes.get(0).getUnique()); + Assert.assertTrue("UNIQUERULE=P should map to primary=true", indexes.get(0).getPrimary()); Assert.assertEquals("IDX_ORDERS_DATE", indexes.get(1).getName()); Assert.assertFalse("UNIQUERULE=D should map to unique=false", indexes.get(1).getUnique()); + Assert.assertFalse("UNIQUERULE=D should map to primary=false", indexes.get(1).getPrimary()); + } + + /** + * fix_report_20260601_031142 P0-2A regression: listTableIndexes must populate columnNames (never + * null) and ordinalPosition (1-based per index, not per column) for every returned index. + * + *

+ * Without columnNames, Db2IndexEditor.generateCreateObjectDDL NPEs on `.stream()`. Without + * ordinalPosition, DBTableIndexEditor.generateUpdateObjectListDDL treats every old index as "new" + * and re-emits CREATE INDEX for it on every column edit. + */ + @Test + public void listTableIndexes_backFillsColumnNamesAndOrdinalPosition() throws SQLException { + // PK_ORDERS has 2 composite columns (COLSEQ 1, 2); IDX_ORDERS_DATE has 1 column. + Map pkRow1 = new LinkedHashMap<>(); + pkRow1.put("INDSCHEMA", "DB2INST1"); + pkRow1.put("INDNAME", "PK_ORDERS"); + pkRow1.put("TABSCHEMA", "DB2INST1"); + pkRow1.put("TABNAME", "ORDERS"); + pkRow1.put("UNIQUERULE", "P"); + pkRow1.put("COLNAME", "ID"); + pkRow1.put("COLSEQ", 1); + Map pkRow2 = new LinkedHashMap<>(); + pkRow2.put("INDSCHEMA", "DB2INST1"); + pkRow2.put("INDNAME", "PK_ORDERS"); + pkRow2.put("TABSCHEMA", "DB2INST1"); + pkRow2.put("TABNAME", "ORDERS"); + pkRow2.put("UNIQUERULE", "P"); + pkRow2.put("COLNAME", "ORDER_NO"); + pkRow2.put("COLSEQ", 2); + Map idxRow = new LinkedHashMap<>(); + idxRow.put("INDSCHEMA", "DB2INST1"); + idxRow.put("INDNAME", "IDX_ORDERS_DATE"); + idxRow.put("TABSCHEMA", "DB2INST1"); + idxRow.put("TABNAME", "ORDERS"); + idxRow.put("UNIQUERULE", "D"); + idxRow.put("COLNAME", "ORDER_DATE"); + idxRow.put("COLSEQ", 1); + stubQueryByName(Arrays.asList(pkRow1, pkRow2, idxRow)); + + List indexes = accessor.listTableIndexes("DB2INST1", "ORDERS"); + + Assert.assertEquals("two distinct indexes after grouping by INDNAME", 2, indexes.size()); + + DBTableIndex pk = indexes.get(0); + Assert.assertEquals("PK_ORDERS", pk.getName()); + Assert.assertNotNull("columnNames must never be null — Db2IndexEditor NPE guard", + pk.getColumnNames()); + Assert.assertEquals(2, pk.getColumnNames().size()); + Assert.assertEquals("ID", pk.getColumnNames().get(0)); + Assert.assertEquals("ORDER_NO", pk.getColumnNames().get(1)); + Assert.assertEquals("ordinalPosition is 1-based per table, not per column", + Integer.valueOf(1), pk.getOrdinalPosition()); + + DBTableIndex idx = indexes.get(1); + Assert.assertEquals("IDX_ORDERS_DATE", idx.getName()); + Assert.assertNotNull(idx.getColumnNames()); + Assert.assertEquals(1, idx.getColumnNames().size()); + Assert.assertEquals("ORDER_DATE", idx.getColumnNames().get(0)); + Assert.assertEquals(Integer.valueOf(2), idx.getOrdinalPosition()); } /** @@ -273,6 +345,12 @@ public void listTableConstraints_typeMapping() throws SQLException { Assert.assertEquals("PK_ORDERS", constraints.get(0).getName()); Assert.assertEquals(DBConstraintType.UNIQUE_KEY, constraints.get(1).getType()); Assert.assertEquals(DBConstraintType.FOREIGN_KEY, constraints.get(2).getType()); + // fix_report_20260601_031142 P0-2C: ordinalPosition must be filled (1-based) so + // DBTableConstraintEditor.generateUpdateObjectListDDL recognises existing constraints + // during column edits and does not emit spurious ADD CONSTRAINT statements. + Assert.assertEquals(Integer.valueOf(1), constraints.get(0).getOrdinalPosition()); + Assert.assertEquals(Integer.valueOf(2), constraints.get(1).getOrdinalPosition()); + Assert.assertEquals(Integer.valueOf(3), constraints.get(2).getOrdinalPosition()); } /**