+ * {@link #quoteIdentifier(String)} is SPI-facing and conditional: plain, + * non-reserved identifiers pass through unquoted so completion/matching + * consumers keep working; anything else is wrapped with double quotes. + * {@link #quoteIdentifierAlways(String)} is the unconditional variant reserved + * for DDL-generation call sites that must emit quoted output. + * String literals are escaped by single-quote doubling via {@link #escapeString(String)}. + * Shared stateless instance available via {@link #INSTANCE} for call sites without + * MetaData access. + */ +public class OceanbaseOracleIdentifierProcessor extends OracleIdentifierProcessor { + + public static final OceanbaseOracleIdentifierProcessor INSTANCE = new OceanbaseOracleIdentifierProcessor(); + + /** + * Conditional quoting for SPI consumers: {@code null} passes through, blank + * is returned unchanged, a valid plain identifier that is not a reserved + * keyword is returned unquoted; otherwise the identifier is wrapped with + * double quotes (one surrounding quote pair stripped, embedded double + * quotes doubled). + */ + @Override + public String quoteIdentifier(String identifier) { + if (identifier == null) { + return null; + } + if (StringUtils.isBlank(identifier)) { + return identifier; + } + if (isValidIdentifier(identifier) && !isReservedKeyword(identifier.toUpperCase(), null, null)) { + return identifier; + } + return quoteIdentifierAlways(identifier); + } + + @Override + public String quoteIdentifier(String identifier, Integer majorVersion, Integer minorVersion) { + return quoteIdentifier(identifier); + } + + /** + * The always-quote SPI variant: preserves case and unconditionally wraps. + */ + @Override + public String quoteIdentifierIgnoreCase(String identifier) { + return quoteIdentifierAlways(identifier); + } + + /** + * Unconditional quoting for DDL-generation call sites: {@code null}/blank pass + * through unchanged, otherwise wraps with double quotes, stripping one surrounding + * quote pair and doubling every embedded double quote. + */ + public String quoteIdentifierAlways(String identifier) { + if (org.apache.commons.lang3.StringUtils.isBlank(identifier)) { + return identifier; + } + return "\"" + escapeIdentifierContent(identifier) + "\""; + } + + /** + * Escapes a value interpolated into a single-quoted SQL string literal by + * doubling every single quote. + */ + @Override + public String escapeString(String str) { + return str == null ? "" : StringUtils.replace(str, "'", "''"); + } + + private static String escapeIdentifierContent(String identifier) { + if (identifier == null) { + return ""; + } + String stripped = identifier; + if (stripped.length() >= 2 && stripped.startsWith("\"") && stripped.endsWith("\"")) { + stripped = stripped.substring(1, stripped.length() - 1); + } + return StringUtils.replace(stripped, "\"", "\"\""); + } + + /** + * Escapes identifier content for a position already surrounded by double + * quotes: strips one surrounding quote pair, then doubles every embedded + * double quote. + */ + public static String escapeIdentifier(String identifier) { + return escapeIdentifierContent(identifier); + } +} 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..17b2336173 --- /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,91 @@ +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; + +class OceanbaseOracleIdentifierProcessorTest { + + @Test + void escapeSqlLiteralDoublesSingleQuotes() { + assertEquals("O''Brien", OceanbaseOracleIdentifierProcessor.INSTANCE.escapeString("O'Brien")); + assertEquals("plain", OceanbaseOracleIdentifierProcessor.INSTANCE.escapeString("plain")); + assertEquals("", 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")); + assertEquals("\"we\"\"ird\"", OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifierAlways("\"we\"ird\"")); + } + + @Test + void quoteIdentifierIgnoreCaseIsTheAlwaysQuoteVariant() { + assertNull(OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase(null)); + assertEquals("\"MY_TABLE\"", OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("MY_TABLE")); + assertEquals("\"my_table\"", OceanbaseOracleIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("my_table")); + } + + @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'")); + } +}