diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-oceanbase-oracle/pom.xml b/chat2db-community-server/chat2db-community-plugins/chat2db-community-oceanbase-oracle/pom.xml index f0dbc859ce..af0c477c44 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-oceanbase-oracle/pom.xml +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-oceanbase-oracle/pom.xml @@ -19,6 +19,11 @@ ai.chat2db chat2db-community-oracle + + org.junit.jupiter + junit-jupiter + test + diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-oceanbase-oracle/src/main/java/ai/chat2db/plugin/oceanbase/oracle/OceanbaseOracleMetaData.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-oceanbase-oracle/src/main/java/ai/chat2db/plugin/oceanbase/oracle/OceanbaseOracleMetaData.java index 243647521b..353d82759e 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-oceanbase-oracle/src/main/java/ai/chat2db/plugin/oceanbase/oracle/OceanbaseOracleMetaData.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-oceanbase-oracle/src/main/java/ai/chat2db/plugin/oceanbase/oracle/OceanbaseOracleMetaData.java @@ -1,10 +1,11 @@ package ai.chat2db.plugin.oceanbase.oracle; +import ai.chat2db.plugin.oceanbase.oracle.identifier.OceanbaseOracleIdentifierProcessor; import ai.chat2db.plugin.oracle.OracleMetaData; import ai.chat2db.community.tools.util.EasyStringUtils; import ai.chat2db.spi.IDbMetaData; import ai.chat2db.spi.DefaultSQLExecutor; -import ai.chat2db.spi.util.SqlUtils; +import ai.chat2db.spi.ISQLIdentifierProcessor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; @@ -18,19 +19,19 @@ @Slf4j public class OceanbaseOracleMetaData extends OracleMetaData implements IDbMetaData { - - - - + @Override + public ISQLIdentifierProcessor getSQLIdentifierProcessor() { + return OceanbaseOracleIdentifierProcessor.INSTANCE; + } @Override public String tableDDL(Connection connection, String databaseName, String schemaName, String tableName) { - String sql = String.format(TABLE_DDL_SQL, tableName, schemaName); - String tableCommentSql = String.format(TABLE_COMMENT_SQL, schemaName, tableName); - String tableColumnCommentSql = String.format(TABLE_COLUMN_COMMENT_SQL, schemaName, tableName); - String PUIndexSql = String.format(PU_INDEX_NAME_SQL, schemaName, tableName); - String tableIndexNameSql = String.format(TABLE_INDEX_NAME_SQL, schemaName, tableName); + String sql = buildTableDdlSql(tableName, schemaName); + String tableCommentSql = buildTableCommentSql(schemaName, tableName); + String tableColumnCommentSql = buildTableColumnCommentSql(schemaName, tableName); + String PUIndexSql = buildPuIndexNameSql(schemaName, tableName); + String tableIndexNameSql = buildTableIndexNameSql(schemaName, tableName); StringBuilder ddlBuilder = new StringBuilder(); DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { try { @@ -45,7 +46,9 @@ public String tableDDL(Connection connection, String databaseName, String schema if (resultSet.next()) { String tableComment = resultSet.getString("comments"); if (StringUtils.isNotBlank(tableComment)) { - ddlBuilder.append("\nCOMMENT ON TABLE ").append(SqlUtils.quoteObjectName(tableName)).append(" IS ") + ddlBuilder.append("\nCOMMENT ON TABLE ") + .append(OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName)) + .append(" IS ") .append(EasyStringUtils.escapeAndQuoteString(tableComment)).append(";"); } } @@ -56,8 +59,8 @@ public String tableDDL(Connection connection, String databaseName, String schema String columnComment = resultSet.getString("comments"); if (StringUtils.isNotBlank(columnComment)) { ddlBuilder.append("\nCOMMENT ON COLUMN ") - .append(SqlUtils.quoteObjectName(tableName)).append(".") - .append(SqlUtils.quoteObjectName(columnName)).append(" IS ") + .append(OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName)).append(".") + .append(OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifierAlways(columnName)).append(" IS ") .append(EasyStringUtils.escapeAndQuoteString(columnComment)).append(";"); } } @@ -85,7 +88,7 @@ public String tableDDL(Connection connection, String databaseName, String schema return indexNames; }); for (String index : indexes) { - String tableIndexSql = String.format(TABLE_INDEX_DDL_SQL, index, schemaName); + String tableIndexSql = buildTableIndexDdlSql(index, schemaName); DefaultSQLExecutor.getInstance().execute(connection, tableIndexSql, resultSet -> { while (resultSet.next()) { String ddl = resultSet.getString("ddl"); @@ -98,5 +101,35 @@ public String tableDDL(Connection connection, String databaseName, String schema return ddlBuilder.toString(); } + static String buildTableDdlSql(String tableName, String schemaName) { + return String.format(TABLE_DDL_SQL, OceanbaseOracleIdentifierProcessor.INSTANCE.escapeString(tableName), + OceanbaseOracleIdentifierProcessor.INSTANCE.escapeString(schemaName)); + } + + static String buildTableCommentSql(String schemaName, String tableName) { + return String.format(TABLE_COMMENT_SQL, OceanbaseOracleIdentifierProcessor.INSTANCE.escapeString(schemaName), + OceanbaseOracleIdentifierProcessor.INSTANCE.escapeString(tableName)); + } + + static String buildTableColumnCommentSql(String schemaName, String tableName) { + return String.format(TABLE_COLUMN_COMMENT_SQL, OceanbaseOracleIdentifierProcessor.INSTANCE.escapeString(schemaName), + OceanbaseOracleIdentifierProcessor.INSTANCE.escapeString(tableName)); + } + + static String buildPuIndexNameSql(String schemaName, String tableName) { + return String.format(PU_INDEX_NAME_SQL, OceanbaseOracleIdentifierProcessor.INSTANCE.escapeString(schemaName), + OceanbaseOracleIdentifierProcessor.INSTANCE.escapeString(tableName)); + } + + static String buildTableIndexNameSql(String schemaName, String tableName) { + return String.format(TABLE_INDEX_NAME_SQL, OceanbaseOracleIdentifierProcessor.INSTANCE.escapeString(schemaName), + OceanbaseOracleIdentifierProcessor.INSTANCE.escapeString(tableName)); + } + + static String buildTableIndexDdlSql(String indexName, String schemaName) { + return String.format(TABLE_INDEX_DDL_SQL, OceanbaseOracleIdentifierProcessor.INSTANCE.escapeString(indexName), + OceanbaseOracleIdentifierProcessor.INSTANCE.escapeString(schemaName)); + } + } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-oceanbase-oracle/src/main/java/ai/chat2db/plugin/oceanbase/oracle/identifier/OceanbaseOracleIdentifierProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-oceanbase-oracle/src/main/java/ai/chat2db/plugin/oceanbase/oracle/identifier/OceanbaseOracleIdentifierProcessor.java new file mode 100644 index 0000000000..9b9e5eaa98 --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-oceanbase-oracle/src/main/java/ai/chat2db/plugin/oceanbase/oracle/identifier/OceanbaseOracleIdentifierProcessor.java @@ -0,0 +1,12 @@ +package ai.chat2db.plugin.oceanbase.oracle.identifier; + +import ai.chat2db.plugin.oracle.identifier.OracleIdentifierProcessor; + +/** + * OceanBase Oracle mode uses Oracle's identifier, case-folding, and literal + * escaping rules. + */ +public class OceanbaseOracleIdentifierProcessor extends OracleIdentifierProcessor { + + public static final OceanbaseOracleIdentifierProcessor INSTANCE = new OceanbaseOracleIdentifierProcessor(); +} diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-oceanbase-oracle/src/test/java/ai/chat2db/plugin/oceanbase/oracle/OceanbaseOracleIdentifierProcessorTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-oceanbase-oracle/src/test/java/ai/chat2db/plugin/oceanbase/oracle/OceanbaseOracleIdentifierProcessorTest.java new file mode 100644 index 0000000000..852c09dfa4 --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-oceanbase-oracle/src/test/java/ai/chat2db/plugin/oceanbase/oracle/OceanbaseOracleIdentifierProcessorTest.java @@ -0,0 +1,126 @@ +package ai.chat2db.plugin.oceanbase.oracle; + +import ai.chat2db.plugin.oceanbase.oracle.identifier.OceanbaseOracleIdentifierProcessor; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +class OceanbaseOracleIdentifierProcessorTest { + + @Test + void escapeSqlLiteralDoublesSingleQuotes() { + assertEquals("O''Brien", OceanbaseOracleIdentifierProcessor.INSTANCE.escapeString("O'Brien")); + assertEquals("plain", OceanbaseOracleIdentifierProcessor.INSTANCE.escapeString("plain")); + assertNull(OceanbaseOracleIdentifierProcessor.INSTANCE.escapeString(null)); + } + + @Test + void quoteIdentifierPassesThroughNullAndBlank() { + assertNull(OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifier(null)); + assertEquals("", OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifier("")); + assertEquals(" ", OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifier(" ")); + } + + @Test + void quoteIdentifierLeavesPlainIdentifiersUnquoted() { + assertEquals("MY_TABLE", OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifier("MY_TABLE")); + assertEquals("\"my_table\"", OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifier("my_table")); + assertEquals("COL_1", OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifier("COL_1")); + } + + @Test + void quoteIdentifierQuotesReservedKeywordsAndSpecialChars() { + assertEquals("\"TABLE\"", OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifier("TABLE")); + assertEquals("\"select\"", OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifier("select")); + assertEquals("\"has space\"", OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifier("has space")); + assertEquals("\"we\"\"ird\"", OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifier("we\"ird")); + assertEquals("\"we\"\"ird\"", OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifier("\"we\"\"ird\"")); + } + + @Test + void versionedQuoteIdentifierDelegatesToConditional() { + assertEquals("MY_TABLE", OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifier("MY_TABLE", 4, 2)); + assertEquals("\"TABLE\"", OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifier("TABLE", null, null)); + assertNull(OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifier(null, null, null)); + } + + @Test + void quoteIdentifierAlwaysQuotesUnconditionally() { + assertNull(OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifierAlways(null)); + assertEquals("\"MY_TABLE\"", OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifierAlways("MY_TABLE")); + assertEquals("\"we\"\"ird\"", OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifierAlways("we\"ird")); + + String[] rawIdentifiers = {"", "A\"B", "\"ALREADY\"", "\"A", "A\"", "\"\"", "A\"\"B"}; + for (String raw : rawIdentifiers) { + assertEquals(raw, + OceanbaseOracleIdentifierProcessor.INSTANCE.removeIdentifierQuote( + OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifierAlways(raw))); + } + } + + @Test + void quoteIdentifierIgnoreCasePreservesCaseConditionally() { + assertNull(OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase(null)); + assertEquals("MY_TABLE", OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("MY_TABLE")); + assertEquals("my_table", OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("my_table")); + assertEquals("\"select\"", OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("select")); + } + + @Test + void metadataExposesTheOceanbaseOracleProcessor() { + assertSame(OceanbaseOracleIdentifierProcessor.INSTANCE, + new OceanbaseOracleMetaData().getSQLIdentifierProcessor()); + } + + @Test + void buildTableDdlSqlNeutralizesMaliciousNames() { + String sql = OceanbaseOracleMetaData.buildTableDdlSql("T' OR '1'='1", "S' OR '1'='1"); + + assertEquals("select dbms_metadata.get_ddl('TABLE','T'' OR ''1''=''1','S'' OR ''1''=''1') as sql from dual", + sql); + assertFalse(sql.contains("'T' OR '1'='1'")); + } + + @Test + void buildTableCommentSqlNeutralizesMaliciousNames() { + String sql = OceanbaseOracleMetaData.buildTableCommentSql("SCOTT' OR '1'='1", "O'Brien"); + + assertEquals("select owner, table_name, comments from ALL_TAB_COMMENTS where OWNER = 'SCOTT'' OR ''1''=''1'" + + " and TABLE_NAME = 'O''Brien'", sql); + } + + @Test + void buildTableIndexDdlSqlNeutralizesMaliciousIndexName() { + String sql = OceanbaseOracleMetaData.buildTableIndexDdlSql("IDX', 'S', 'X", "SCOTT"); + + assertEquals(String.format( + ai.chat2db.plugin.oceanbase.constant.OceanbaseOracleMetaDataConstants.TABLE_INDEX_DDL_SQL, + "IDX'', ''S'', ''X", "SCOTT"), + sql); + assertFalse(sql.contains("'IDX', 'S', 'X'")); + } + + @Test + void allMetadataQueriesEscapeLiteralArguments() { + assertEquals("SELECT owner, table_name, column_name, comments\n" + + "FROM all_col_comments\n" + + "WHERE owner = 'S''1' and table_name = 'T''1' and comments is not null", + OceanbaseOracleMetaData.buildTableColumnCommentSql("S'1", "T'1")); + assertEquals("SELECT DISTINCT AC.INDEX_NAME\n" + + "FROM ALL_CONSTRAINTS AC\n" + + "WHERE AC.OWNER = 'S''1' AND AC.TABLE_NAME = 'T''1'\n" + + " AND AC.CONSTRAINT_TYPE IN ('P','U')", + OceanbaseOracleMetaData.buildPuIndexNameSql("S'1", "T'1")); + assertEquals("select\n" + + " INDEX_NAME\n" + + "from\n" + + " SYS.ALL_INDEXES\n" + + "where\n" + + " OWNER = 'S''1'\n" + + " and TABLE_NAME = 'T''1'\n", + OceanbaseOracleMetaData.buildTableIndexNameSql("S'1", "T'1")); + } +}