Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
Expand All @@ -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<TableIndex> 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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading