diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-redshift/src/main/java/ai/chat2db/plugin/redshift/RedshiftMetaData.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-redshift/src/main/java/ai/chat2db/plugin/redshift/RedshiftMetaData.java index bc90f15865..0b970610af 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-redshift/src/main/java/ai/chat2db/plugin/redshift/RedshiftMetaData.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-redshift/src/main/java/ai/chat2db/plugin/redshift/RedshiftMetaData.java @@ -1,12 +1,14 @@ package ai.chat2db.plugin.redshift; import ai.chat2db.plugin.postgresql.PostgreSQLMetaData; +import ai.chat2db.plugin.redshift.identifier.RedshiftIdentifierProcessor; import ai.chat2db.spi.IDbMetaData; import ai.chat2db.community.domain.api.model.metadata.Function; import ai.chat2db.community.domain.api.model.metadata.Procedure; import ai.chat2db.community.domain.api.model.metadata.TableIndex; import ai.chat2db.community.domain.api.model.metadata.Trigger; import ai.chat2db.spi.DefaultSQLExecutor; +import ai.chat2db.spi.ISQLIdentifierProcessor; import jakarta.validation.constraints.NotEmpty; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; @@ -18,10 +20,15 @@ import static ai.chat2db.plugin.redshift.constant.RedshiftMetaDataConstants.*; @Slf4j public class RedshiftMetaData extends PostgreSQLMetaData implements IDbMetaData { + + @Override + public ISQLIdentifierProcessor getSQLIdentifierProcessor() { + return RedshiftIdentifierProcessor.INSTANCE; + } + @Override public String tableDDL(Connection connection, String databaseName, String schemaName, String tableName) { - String sql = "SHOW CREATE TABLE " + format(schemaName) + "." - + format(tableName); + String sql = buildShowCreateTableSql(schemaName, tableName); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { if (resultSet.next()) { return resultSet.getString(1); @@ -30,6 +37,17 @@ public String tableDDL(Connection connection, String databaseName, String schema }); } + static String buildShowCreateTableSql(String schemaName, String tableName) { + if (tableName == null || tableName.isEmpty()) { + throw new IllegalArgumentException("Redshift table name must not be empty"); + } + String qualifiedName = schemaName == null || schemaName.isEmpty() + ? RedshiftIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName) + : RedshiftIdentifierProcessor.INSTANCE.quoteIdentifierAlways(schemaName) + "." + + RedshiftIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName); + return "SHOW CREATE TABLE " + qualifiedName; + } + @Override public List indexes(Connection connection, String databaseName, String schemaName, String tableName) { return DefaultSQLExecutor.getInstance().indexes(connection, StringUtils.isEmpty(databaseName) ? null : databaseName, StringUtils.isEmpty(schemaName) ? null : schemaName, tableName); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-redshift/src/main/java/ai/chat2db/plugin/redshift/identifier/RedshiftIdentifierProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-redshift/src/main/java/ai/chat2db/plugin/redshift/identifier/RedshiftIdentifierProcessor.java new file mode 100644 index 0000000000..13d18f343b --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-redshift/src/main/java/ai/chat2db/plugin/redshift/identifier/RedshiftIdentifierProcessor.java @@ -0,0 +1,13 @@ +package ai.chat2db.plugin.redshift.identifier; + +import ai.chat2db.plugin.postgresql.identifier.PostgreSQLIdentifierProcessor; + +/** + * Redshift follows PostgreSQL identifier folding, quoting, reserved-word, and + * string-literal rules. Keep a dialect-specific instance while reusing the shared + * PostgreSQL contract so completion and generated SQL cannot drift apart. + */ +public class RedshiftIdentifierProcessor extends PostgreSQLIdentifierProcessor { + + public static final RedshiftIdentifierProcessor INSTANCE = new RedshiftIdentifierProcessor(); +} diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-redshift/src/test/java/ai/chat2db/plugin/redshift/RedshiftIdentifierProcessorTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-redshift/src/test/java/ai/chat2db/plugin/redshift/RedshiftIdentifierProcessorTest.java new file mode 100644 index 0000000000..97faa75408 --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-redshift/src/test/java/ai/chat2db/plugin/redshift/RedshiftIdentifierProcessorTest.java @@ -0,0 +1,126 @@ +package ai.chat2db.plugin.redshift; + +import ai.chat2db.plugin.redshift.identifier.RedshiftIdentifierProcessor; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RedshiftIdentifierProcessorTest { + + @Test + void escapeSqlLiteralDoublesSingleQuotes() { + assertEquals("o''brien", RedshiftIdentifierProcessor.INSTANCE.escapeString("o'brien")); + assertEquals("plain", RedshiftIdentifierProcessor.INSTANCE.escapeString("plain")); + assertEquals("''''", RedshiftIdentifierProcessor.INSTANCE.escapeString("''")); + assertNull(RedshiftIdentifierProcessor.INSTANCE.escapeString(null)); + } + + @Test + void escapeIdentifierDoublesEmbeddedDoubleQuotes() { + assertEquals("we\"\"ird", RedshiftIdentifierProcessor.escapeIdentifier("we\"ird")); + assertEquals("plain", RedshiftIdentifierProcessor.escapeIdentifier("plain")); + assertNull(RedshiftIdentifierProcessor.escapeIdentifier(null)); + } + + @Test + void escapeIdentifierTreatsWrappingQuotesAsRawContent() { + assertEquals("\"\"foo\"\"", RedshiftIdentifierProcessor.escapeIdentifier("\"foo\"")); + assertEquals("\"\"fo\"\"o\"\"", RedshiftIdentifierProcessor.escapeIdentifier("\"fo\"o\"")); + } + + @Test + void quoteIdentifierReturnsPlainIdentifiersUnquoted() { + assertEquals("foo", RedshiftIdentifierProcessor.INSTANCE.quoteIdentifier("foo")); + assertEquals("orders_2", RedshiftIdentifierProcessor.INSTANCE.quoteIdentifier("orders_2")); + } + + @Test + void quoteIdentifierQuotesWhenNotPlain() { + assertEquals("\"we\"\"ird\"", RedshiftIdentifierProcessor.INSTANCE.quoteIdentifier("we\"ird")); + assertEquals("\"has space\"", RedshiftIdentifierProcessor.INSTANCE.quoteIdentifier("has space")); + assertEquals("\"2leading\"", RedshiftIdentifierProcessor.INSTANCE.quoteIdentifier("2leading")); + assertEquals("\"MixedCase\"", RedshiftIdentifierProcessor.INSTANCE.quoteIdentifier("MixedCase")); + assertEquals("\"SELECT\"", RedshiftIdentifierProcessor.INSTANCE.quoteIdentifier("SELECT")); + } + + @Test + void quoteIdentifierRequotesAlreadyQuotedInput() { + assertEquals("\"\"\"foo\"\"\"", RedshiftIdentifierProcessor.INSTANCE.quoteIdentifier("\"foo\"")); + } + + @Test + void quoteIdentifierPassesThroughNullAndBlank() { + assertNull(RedshiftIdentifierProcessor.INSTANCE.quoteIdentifier(null)); + assertEquals("", RedshiftIdentifierProcessor.INSTANCE.quoteIdentifier("")); + } + + @Test + void versionedQuoteIdentifierDelegatesToConditional() { + assertEquals("foo", RedshiftIdentifierProcessor.INSTANCE.quoteIdentifier("foo", null, null)); + assertEquals("\"has space\"", + RedshiftIdentifierProcessor.INSTANCE.quoteIdentifier("has space", 1, 0)); + } + + @Test + void quoteIdentifierIgnoreCaseUsesPostgreSqlConditionalRules() { + assertEquals("foo", RedshiftIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("foo")); + assertEquals("\"we\"\"ird\"", RedshiftIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("we\"ird")); + assertEquals("", RedshiftIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("")); + assertNull(RedshiftIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase(null)); + } + + @Test + void quoteIdentifierAlwaysWrapsAndEscapes() { + assertEquals("\"foo\"", RedshiftIdentifierProcessor.INSTANCE.quoteIdentifierAlways("foo")); + assertEquals("\"\"\"foo\"\"\"", RedshiftIdentifierProcessor.INSTANCE.quoteIdentifierAlways("\"foo\"")); + assertEquals("\"we\"\"ird\"", RedshiftIdentifierProcessor.INSTANCE.quoteIdentifierAlways("we\"ird")); + assertEquals("\"\"", RedshiftIdentifierProcessor.INSTANCE.quoteIdentifierAlways("")); + assertNull(RedshiftIdentifierProcessor.INSTANCE.quoteIdentifierAlways(null)); + } + + @Test + void alwaysQuoteRoundTripsEveryRawIdentifierShape() { + for (String raw : List.of("plain", "a\"b", "\"already\"", "\"", "a.b", "", " ")) { + assertEquals(raw, RedshiftIdentifierProcessor.INSTANCE.removeIdentifierQuote( + RedshiftIdentifierProcessor.INSTANCE.quoteIdentifierAlways(raw)), raw); + } + } + + @Test + void maliciousSchemaNameIsNeutralizedInShowCreateTable() { + String sql = RedshiftMetaData.buildShowCreateTableSql("public\"; DROP TABLE users; --", "t"); + assertEquals("SHOW CREATE TABLE \"public\"\"; DROP TABLE users; --\".\"t\"", sql); + } + + @Test + void maliciousTableNameIsNeutralizedInShowCreateTable() { + String sql = RedshiftMetaData.buildShowCreateTableSql("public", "t\" OR \"1\"=\"1"); + assertEquals("SHOW CREATE TABLE \"public\".\"t\"\" OR \"\"1\"\"=\"\"1\"", sql); + assertTrue(sql.startsWith("SHOW CREATE TABLE \"public\".")); + } + + @Test + void benignNamesProduceSameSqlAsBefore() { + assertEquals("SHOW CREATE TABLE \"public\".\"orders\"", + RedshiftMetaData.buildShowCreateTableSql("public", "orders")); + } + + @Test + void showCreateTableHandlesOptionalSchemaAndRejectsEmptyTable() { + assertEquals("SHOW CREATE TABLE \"orders\"", + RedshiftMetaData.buildShowCreateTableSql(null, "orders")); + assertEquals("SHOW CREATE TABLE \"orders\"", + RedshiftMetaData.buildShowCreateTableSql("", "orders")); + assertEquals("SHOW CREATE TABLE \" \".\" \"", + RedshiftMetaData.buildShowCreateTableSql(" ", " ")); + assertThrows(IllegalArgumentException.class, + () -> RedshiftMetaData.buildShowCreateTableSql("public", "")); + assertThrows(IllegalArgumentException.class, + () -> RedshiftMetaData.buildShowCreateTableSql("public", null)); + } +}