diff --git a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/test/java/ai/chat2db/community/domain/core/completion/SqlCompletionMetadataProviderAdapterTest.java b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/test/java/ai/chat2db/community/domain/core/completion/SqlCompletionMetadataProviderAdapterTest.java index c89ff7bf8e..a04c337c82 100644 --- a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/test/java/ai/chat2db/community/domain/core/completion/SqlCompletionMetadataProviderAdapterTest.java +++ b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/test/java/ai/chat2db/community/domain/core/completion/SqlCompletionMetadataProviderAdapterTest.java @@ -248,6 +248,14 @@ public String quoteIdentifier(String identifier) { return identifier == null ? null : "`" + identifier + "`"; } + @Override + public String quoteIdentifierAlways(String identifier) { + if (identifier == null) { + return null; + } + return "`" + identifier.replace("`", "``") + "`"; + } + @Override public String removeIdentifierQuote(String identifier) { return identifier == null ? null : identifier.replace("`", ""); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-kingbase/src/main/java/ai/chat2db/plugin/kingbase/enums/type/KingBaseIndexTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-kingbase/src/main/java/ai/chat2db/plugin/kingbase/enums/type/KingBaseIndexTypeEnum.java index df1080bc8a..1de7d3a7ee 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-kingbase/src/main/java/ai/chat2db/plugin/kingbase/enums/type/KingBaseIndexTypeEnum.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-kingbase/src/main/java/ai/chat2db/plugin/kingbase/enums/type/KingBaseIndexTypeEnum.java @@ -12,6 +12,7 @@ import java.util.List; import static ai.chat2db.plugin.kingbase.constant.KingBaseIndexTypeEnumConstants.*; +import ai.chat2db.plugin.kingbase.KingBaseMetaData; public enum KingBaseIndexTypeEnum { PRIMARY("Primary", "PRIMARY KEY"), @@ -92,10 +93,10 @@ private String buildForeignColum(TableIndex tableIndex) { StringBuilder script = new StringBuilder(); script.append(" REFERENCES "); if (StringUtils.isNotBlank(tableIndex.getForeignSchemaName())) { - script.append(tableIndex.getForeignSchemaName()).append("."); + script.append(KingBaseMetaData.KINGBASE_SQL_IDENTIFIER_PROCESSOR.quoteIdentifierAlways(tableIndex.getForeignSchemaName())).append("."); } if (StringUtils.isNotBlank(tableIndex.getForeignTableName())) { - script.append(tableIndex.getForeignTableName()).append(" "); + script.append(KingBaseMetaData.KINGBASE_SQL_IDENTIFIER_PROCESSOR.quoteIdentifierAlways(tableIndex.getForeignTableName())).append(" "); } if (CollectionUtils.isNotEmpty(tableIndex.getForeignColumnNamelist())) { script.append("("); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/identifier/MysqlIdentifierProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/identifier/MysqlIdentifierProcessor.java index 5b83c4c18d..7e87ef42c8 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/identifier/MysqlIdentifierProcessor.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/identifier/MysqlIdentifierProcessor.java @@ -306,12 +306,26 @@ public String quoteIdentifier(String identifier) { return "`" + identifier + "`"; } + @Override + public String quoteIdentifierAlways(String identifier) { + if (identifier == null) { + return null; + } + return "`" + identifier.replace("`", "``") + "`"; + } + @Override public String removeIdentifierQuote(String identifier) { if (StringUtils.isBlank(identifier)) { return identifier; } - return removePattern(identifier, MYSQL_PATTERN); + if (identifier.startsWith("`") && identifier.endsWith("`") && identifier.length() >= 2) { + return identifier.substring(1, identifier.length() - 1).replace("``", "`"); + } + if (identifier.startsWith("\"") && identifier.endsWith("\"") && identifier.length() >= 2) { + return identifier.substring(1, identifier.length() - 1).replace("\"\"", "\""); + } + return identifier; } @Override diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/test/java/ai/chat2db/plugin/mysql/identifier/MysqlIdentifierProcessorTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/test/java/ai/chat2db/plugin/mysql/identifier/MysqlIdentifierProcessorTest.java new file mode 100644 index 0000000000..aba8fb9915 --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/test/java/ai/chat2db/plugin/mysql/identifier/MysqlIdentifierProcessorTest.java @@ -0,0 +1,70 @@ +package ai.chat2db.plugin.mysql.identifier; + +import ai.chat2db.spi.DefaultSQLIdentifierProcessor; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Contract tests for {@link MysqlIdentifierProcessor}. + * Covers the backtick-based {@code quoteIdentifierAlways} / + * {@code removeIdentifierQuote} round-trip and embedded-delimiter escaping. + */ +class MysqlIdentifierProcessorTest { + + private final MysqlIdentifierProcessor processor = new MysqlIdentifierProcessor(); + + @Test + void quoteIdentifierAlways_wrapsInBacktick() { + assertEquals("`mycol`", processor.quoteIdentifierAlways("mycol")); + } + + @Test + void quoteIdentifierAlways_escapesEmbeddedBacktick() { + assertEquals("`a``b`", processor.quoteIdentifierAlways("a`b")); + } + + @Test + void quoteIdentifierAlways_preservesMixedCase() { + assertEquals("`MixedCase`", processor.quoteIdentifierAlways("MixedCase")); + } + + @Test + void quoteIdentifierAlways_handlesNull() { + assertNull(processor.quoteIdentifierAlways(null)); + } + + @Test + void removeIdentifierQuote_stripsBacktick() { + assertEquals("mycol", processor.removeIdentifierQuote("`mycol`")); + } + + @Test + void removeIdentifierQuote_unescapesBacktick() { + assertEquals("a`b", processor.removeIdentifierQuote("`a``b`")); + } + + @Test + void removeIdentifierQuote_stripsDoubleQuote() { + assertEquals("mycol", processor.removeIdentifierQuote("\"mycol\"")); + } + + @Test + void roundTrip_plainName() { + String raw = "mycol"; + assertEquals(raw, processor.removeIdentifierQuote(processor.quoteIdentifierAlways(raw))); + } + + @Test + void roundTrip_embeddedBacktick() { + String raw = "a`b"; + assertEquals(raw, processor.removeIdentifierQuote(processor.quoteIdentifierAlways(raw))); + } + + @Test + void roundTrip_mixedCase() { + String raw = "MixedCase"; + assertEquals(raw, processor.removeIdentifierQuote(processor.quoteIdentifierAlways(raw))); + } +} diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-postgresql/src/main/java/ai/chat2db/plugin/postgresql/enums/type/PostgreSQLIndexTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-postgresql/src/main/java/ai/chat2db/plugin/postgresql/enums/type/PostgreSQLIndexTypeEnum.java index e4ecc099fa..638894ade9 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-postgresql/src/main/java/ai/chat2db/plugin/postgresql/enums/type/PostgreSQLIndexTypeEnum.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-postgresql/src/main/java/ai/chat2db/plugin/postgresql/enums/type/PostgreSQLIndexTypeEnum.java @@ -12,6 +12,7 @@ import java.util.List; import static ai.chat2db.plugin.postgresql.constant.PostgreSQLIndexTypeEnumConstants.*; +import ai.chat2db.plugin.postgresql.PostgreSQLMetaData; public enum PostgreSQLIndexTypeEnum { PRIMARY("Primary", "PRIMARY KEY"), @@ -92,10 +93,10 @@ private String buildForeignColum(TableIndex tableIndex) { StringBuilder script = new StringBuilder(); script.append(" REFERENCES "); if (StringUtils.isNotBlank(tableIndex.getForeignSchemaName())) { - script.append(tableIndex.getForeignSchemaName()).append("."); + script.append(PostgreSQLMetaData.POSTGRE_SQL_IDENTIFIER_PROCESSOR.quoteIdentifierAlways(tableIndex.getForeignSchemaName())).append("."); } if (StringUtils.isNotBlank(tableIndex.getForeignTableName())) { - script.append(tableIndex.getForeignTableName()).append(" "); + script.append(PostgreSQLMetaData.POSTGRE_SQL_IDENTIFIER_PROCESSOR.quoteIdentifierAlways(tableIndex.getForeignTableName())).append(" "); } if (CollectionUtils.isNotEmpty(tableIndex.getForeignColumnNamelist())) { script.append("("); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-snowflake/src/main/java/ai/chat2db/plugin/snowflake/SnowflakeMetaData.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-snowflake/src/main/java/ai/chat2db/plugin/snowflake/SnowflakeMetaData.java index cd94c4b461..e4251b90dd 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-snowflake/src/main/java/ai/chat2db/plugin/snowflake/SnowflakeMetaData.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-snowflake/src/main/java/ai/chat2db/plugin/snowflake/SnowflakeMetaData.java @@ -3,6 +3,8 @@ import ai.chat2db.plugin.snowflake.builder.SnowflakeSqlBuilder; import ai.chat2db.plugin.snowflake.enums.type.*; import ai.chat2db.spi.IDbMetaData; +import ai.chat2db.spi.DefaultSQLIdentifierProcessor; +import ai.chat2db.spi.ISQLIdentifierProcessor; import ai.chat2db.spi.ISqlBuilder; import ai.chat2db.spi.DefaultMetaService; import ai.chat2db.community.domain.api.model.account.*; @@ -29,6 +31,8 @@ import static ai.chat2db.plugin.snowflake.constant.SnowflakeMetaDataConstants.*; public class SnowflakeMetaData extends DefaultMetaService implements IDbMetaData { + public static final ISQLIdentifierProcessor SNOWFLAKE_SQL_IDENTIFIER_PROCESSOR = new DefaultSQLIdentifierProcessor(); + @Override diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/identifier/SqlServerIdentifierProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/identifier/SqlServerIdentifierProcessor.java index 1c0df48daa..f74b41ab47 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/identifier/SqlServerIdentifierProcessor.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/identifier/SqlServerIdentifierProcessor.java @@ -219,12 +219,26 @@ public String quoteIdentifier(String identifier) { return "[" + identifier + "]"; } + @Override + public String quoteIdentifierAlways(String identifier) { + if (identifier == null) { + return null; + } + return "[" + identifier.replace("]", "]]") + "]"; + } + @Override public String removeIdentifierQuote(String identifier) { if (StringUtils.isBlank(identifier)) { return identifier; } - return removePattern(identifier, SQL_SERVER_PATTERN); + if (identifier.startsWith("[") && identifier.endsWith("]") && identifier.length() >= 2) { + return identifier.substring(1, identifier.length() - 1).replace("]]", "]"); + } + if (identifier.startsWith("\"") && identifier.endsWith("\"") && identifier.length() >= 2) { + return identifier.substring(1, identifier.length() - 1).replace("\"\"", "\""); + } + return identifier; } @Override diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/identifier/SqlServerIdentifierProcessorTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/identifier/SqlServerIdentifierProcessorTest.java new file mode 100644 index 0000000000..31c8249911 --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/identifier/SqlServerIdentifierProcessorTest.java @@ -0,0 +1,69 @@ +package ai.chat2db.plugin.sqlserver.identifier; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Contract tests for {@link SqlServerIdentifierProcessor}. + * Covers the bracket-based {@code quoteIdentifierAlways} / + * {@code removeIdentifierQuote} round-trip and embedded-delimiter escaping. + */ +class SqlServerIdentifierProcessorTest { + + private final SqlServerIdentifierProcessor processor = new SqlServerIdentifierProcessor(); + + @Test + void quoteIdentifierAlways_wrapsInBrackets() { + assertEquals("[mycol]", processor.quoteIdentifierAlways("mycol")); + } + + @Test + void quoteIdentifierAlways_preservesMixedCase() { + assertEquals("[MixedCase]", processor.quoteIdentifierAlways("MixedCase")); + } + + @Test + void quoteIdentifierAlways_escapesEmbeddedCloseBracket() { + assertEquals("[a]]b]", processor.quoteIdentifierAlways("a]b")); + } + + @Test + void quoteIdentifierAlways_handlesNull() { + assertNull(processor.quoteIdentifierAlways(null)); + } + + @Test + void removeIdentifierQuote_stripsBrackets() { + assertEquals("mycol", processor.removeIdentifierQuote("[mycol]")); + } + + @Test + void removeIdentifierQuote_unescapesCloseBracket() { + assertEquals("a]b", processor.removeIdentifierQuote("[a]]b]")); + } + + @Test + void removeIdentifierQuote_stripsDoubleQuote() { + assertEquals("mycol", processor.removeIdentifierQuote("\"mycol\"")); + } + + @Test + void roundTrip_plainName() { + String raw = "mycol"; + assertEquals(raw, processor.removeIdentifierQuote(processor.quoteIdentifierAlways(raw))); + } + + @Test + void roundTrip_embeddedCloseBracket() { + String raw = "a]b"; + assertEquals(raw, processor.removeIdentifierQuote(processor.quoteIdentifierAlways(raw))); + } + + @Test + void roundTrip_mixedCase() { + String raw = "MixedCase"; + assertEquals(raw, processor.removeIdentifierQuote(processor.quoteIdentifierAlways(raw))); + } +} diff --git a/chat2db-community-server/chat2db-community-spi/src/main/java/ai/chat2db/spi/DefaultSQLIdentifierProcessor.java b/chat2db-community-server/chat2db-community-spi/src/main/java/ai/chat2db/spi/DefaultSQLIdentifierProcessor.java index faa8b3b5fd..23fa275a95 100644 --- a/chat2db-community-server/chat2db-community-spi/src/main/java/ai/chat2db/spi/DefaultSQLIdentifierProcessor.java +++ b/chat2db-community-server/chat2db-community-spi/src/main/java/ai/chat2db/spi/DefaultSQLIdentifierProcessor.java @@ -41,12 +41,23 @@ public String quoteIdentifier(String identifier) { return StringUtils.wrap(identifier, '"'); } + @Override + public String quoteIdentifierAlways(String identifier) { + if (identifier == null) { + return null; + } + return "\"" + identifier.replace("\"", "\"\"") + "\""; + } + @Override public String removeIdentifierQuote(String identifier) { if (StringUtils.isBlank(identifier)) { return identifier; } - return removePattern(identifier, STANDARD_PATTERN); + if (identifier.startsWith("\"") && identifier.endsWith("\"") && identifier.length() >= 2) { + return identifier.substring(1, identifier.length() - 1).replace("\"\"", "\""); + } + return identifier; } @Override diff --git a/chat2db-community-server/chat2db-community-spi/src/main/java/ai/chat2db/spi/ISQLIdentifierProcessor.java b/chat2db-community-server/chat2db-community-spi/src/main/java/ai/chat2db/spi/ISQLIdentifierProcessor.java index d87387ea65..7d4cd549a7 100644 --- a/chat2db-community-server/chat2db-community-spi/src/main/java/ai/chat2db/spi/ISQLIdentifierProcessor.java +++ b/chat2db-community-server/chat2db-community-spi/src/main/java/ai/chat2db/spi/ISQLIdentifierProcessor.java @@ -87,4 +87,16 @@ public interface ISQLIdentifierProcessor { */ String escapeString(String str); + + /** + * Quotes an identifier unconditionally, preserving the exact name. + *
+ * Embedded delimiter characters are escaped per the dialect convention. + * Satisfies: {@code removeIdentifierQuote(quoteIdentifierAlways(raw)).equals(raw)} + * + * @param identifier raw identifier text. + * @return unconditionally quoted identifier text with the original case preserved. + */ + String quoteIdentifierAlways(String identifier); + } diff --git a/chat2db-community-server/chat2db-community-spi/src/test/java/ai/chat2db/spi/DefaultSQLIdentifierProcessorTest.java b/chat2db-community-server/chat2db-community-spi/src/test/java/ai/chat2db/spi/DefaultSQLIdentifierProcessorTest.java new file mode 100644 index 0000000000..93198ac695 --- /dev/null +++ b/chat2db-community-server/chat2db-community-spi/src/test/java/ai/chat2db/spi/DefaultSQLIdentifierProcessorTest.java @@ -0,0 +1,110 @@ +package ai.chat2db.spi; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Contract tests for {@link DefaultSQLIdentifierProcessor}. + * Covers the {@code quoteIdentifierAlways} / {@code removeIdentifierQuote} + * round-trip, conditional vs always-quote behavior, and embedded-delimiter + * escaping. + */ +class DefaultSQLIdentifierProcessorTest { + + private final DefaultSQLIdentifierProcessor processor = new DefaultSQLIdentifierProcessor(); + + // ---- quoteIdentifierAlways ---- + + @Test + void quoteIdentifierAlways_wrapsSimpleName() { + assertEquals("\"mycol\"", processor.quoteIdentifierAlways("mycol")); + } + + @Test + void quoteIdentifierAlways_preservesMixedCase() { + assertEquals("\"MixedCase\"", processor.quoteIdentifierAlways("MixedCase")); + } + + @Test + void quoteIdentifierAlways_escapesEmbeddedDoubleQuote() { + assertEquals("\"A\"\"B\"", processor.quoteIdentifierAlways("A\"B")); + } + + @Test + void quoteIdentifierAlways_handlesReservedKeyword() { + assertEquals("\"SELECT\"", processor.quoteIdentifierAlways("SELECT")); + } + + @Test + void quoteIdentifierAlways_handlesNull() { + assertNull(processor.quoteIdentifierAlways(null)); + } + + // ---- quoteIdentifier (conditional) ---- + + @Test + void quoteIdentifier_doesNotQuoteLowercaseValid() { + assertEquals("lower", processor.quoteIdentifier("lower")); + } + + @Test + void quoteIdentifier_quotesInvalidIdentifier() { + assertEquals("\"a b\"", processor.quoteIdentifier("a b")); + } + + @Test + void quoteIdentifierIgnoreCase_doesNotQuoteLowercase() { + assertEquals("lower", processor.quoteIdentifierIgnoreCase("lower")); + } + + // ---- removeIdentifierQuote ---- + + @Test + void removeIdentifierQuote_stripsAndUnescapes() { + assertEquals("A\"B", processor.removeIdentifierQuote("\"A\"\"B\"")); + } + + @Test + void removeIdentifierQuote_passesThroughUnquoted() { + assertEquals("plain", processor.removeIdentifierQuote("plain")); + } + + @Test + void removeIdentifierQuote_stripsSimpleQuoted() { + assertEquals("mycol", processor.removeIdentifierQuote("\"mycol\"")); + } + + // ---- round-trip ---- + + @Test + void roundTrip_plainName() { + String raw = "mycol"; + assertEquals(raw, processor.removeIdentifierQuote(processor.quoteIdentifierAlways(raw))); + } + + @Test + void roundTrip_mixedCase() { + String raw = "MixedCase"; + assertEquals(raw, processor.removeIdentifierQuote(processor.quoteIdentifierAlways(raw))); + } + + @Test + void roundTrip_embeddedDelimiter() { + String raw = "A\"B"; + assertEquals(raw, processor.removeIdentifierQuote(processor.quoteIdentifierAlways(raw))); + } + + @Test + void roundTrip_reservedKeyword() { + String raw = "SELECT"; + assertEquals(raw, processor.removeIdentifierQuote(processor.quoteIdentifierAlways(raw))); + } + + @Test + void roundTrip_empty() { + String raw = ""; + assertEquals(raw, processor.removeIdentifierQuote(processor.quoteIdentifierAlways(raw))); + } +}