getTypes() {
@@ -155,7 +156,7 @@ public String buildCreateColumnSql(TableColumn column) {
}
StringBuilder script = new StringBuilder();
- script.append(ClickHouseSqlEscapes.quoteIdentifier(column.getName())).append(" ");
+ script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(column.getName())).append(" ");
script.append(buildNullableAndDataType(column, type)).append(" ");
@@ -170,7 +171,7 @@ public String buildCreateColumnSql(TableColumn column) {
public String buildModifyColumn(TableColumn tableColumn) {
if (EditStatusEnum.DELETE.name().equals(tableColumn.getEditStatus())) {
- return StringUtils.join("DROP COLUMN ", ClickHouseSqlEscapes.quoteIdentifier(tableColumn.getName()));
+ return StringUtils.join("DROP COLUMN ", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(tableColumn.getName()));
}
if (EditStatusEnum.ADD.name().equals(tableColumn.getEditStatus())) {
return StringUtils.join("ADD COLUMN ", buildCreateColumnSql(tableColumn));
@@ -178,7 +179,7 @@ public String buildModifyColumn(TableColumn tableColumn) {
if (EditStatusEnum.MODIFY.name().equals(tableColumn.getEditStatus())) {
String modifyColumn = "";
if (!StringUtils.equalsIgnoreCase(tableColumn.getOldName(), tableColumn.getName())) {
- modifyColumn = StringUtils.join("RENAME COLUMN ", ClickHouseSqlEscapes.quoteIdentifier(tableColumn.getOldName()), " TO ", ClickHouseSqlEscapes.quoteIdentifier(tableColumn.getName()),
+ modifyColumn = StringUtils.join("RENAME COLUMN ", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(tableColumn.getOldName()), " TO ", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(tableColumn.getName()),
", ", buildCreateColumnSql(tableColumn));
}
return StringUtils.join(modifyColumn, "MODIFY COLUMN ", buildCreateColumnSql(tableColumn));
@@ -190,7 +191,7 @@ private String buildComment(TableColumn column, ClickHouseColumnTypeEnum type) {
if (!type.columnType.isSupportComments() || StringUtils.isEmpty(column.getComment())) {
return "";
}
- return StringUtils.join(SQL_COMMENT_2, ClickHouseSqlEscapes.escapeSqlLiteral(column.getComment()), "'");
+ return StringUtils.join(SQL_COMMENT_2, ClickHouseIdentifierProcessor.INSTANCE.escapeString(column.getComment()), "'");
}
private String buildDefaultValue(TableColumn column, ClickHouseColumnTypeEnum type) {
@@ -207,33 +208,21 @@ private String buildDefaultValue(TableColumn column, ClickHouseColumnTypeEnum ty
}
if (Arrays.asList(Enum8,Enum16).contains(type)) {
- return StringUtils.join("DEFAULT '", ClickHouseSqlEscapes.escapeSqlLiteral(column.getDefaultValue()), "'");
+ return StringUtils.join("DEFAULT '", ClickHouseIdentifierProcessor.INSTANCE.escapeString(column.getDefaultValue()), "'");
}
if (Arrays.asList(Date).contains(type)) {
- return StringUtils.join("DEFAULT '", ClickHouseSqlEscapes.escapeSqlLiteral(column.getDefaultValue()), "'");
+ return StringUtils.join("DEFAULT '", ClickHouseIdentifierProcessor.INSTANCE.escapeString(column.getDefaultValue()), "'");
}
if (Arrays.asList(DateTime).contains(type)) {
if ("CURRENT_TIMESTAMP".equalsIgnoreCase(column.getDefaultValue().trim())) {
return StringUtils.join("DEFAULT ", column.getDefaultValue());
}
- return StringUtils.join("DEFAULT '", ClickHouseSqlEscapes.escapeSqlLiteral(column.getDefaultValue()), "'");
+ return StringUtils.join("DEFAULT '", ClickHouseIdentifierProcessor.INSTANCE.escapeString(column.getDefaultValue()), "'");
}
- return StringUtils.join("DEFAULT ", validateDefaultExpression(column.getDefaultValue()));
- }
-
- private static String validateDefaultExpression(String defaultValue) {
- String trimmed = defaultValue.trim();
- // Quoted string literals stay literals; the content is escaped before re-quoting.
- if (trimmed.length() >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'")) {
- return "'" + ClickHouseSqlEscapes.escapeSqlLiteral(trimmed.substring(1, trimmed.length() - 1)) + "'";
- }
- if (!trimmed.matches("[-+]?(\\d+(\\.\\d+)?|[A-Za-z_][A-Za-z0-9_]*(\\s*\\([^;)]*\\))?)")) {
- throw new IllegalArgumentException("Invalid ClickHouse default expression: " + defaultValue);
- }
- return trimmed;
+ return StringUtils.join("DEFAULT ", ClickHouseSqlGuards.escapeDefaultExpression(column.getDefaultValue()));
}
private String buildNullableAndDataType(TableColumn column, ClickHouseColumnTypeEnum type) {
@@ -279,10 +268,10 @@ public String buildColumn(TableColumn column) {
}
StringBuilder script = new StringBuilder();
- script.append(ClickHouseSqlEscapes.quoteIdentifier(column.getName())).append(" ");
+ script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(column.getName())).append(" ");
script.append(buildDataType(column, type)).append(" ");
if (StringUtils.isNoneBlank(column.getComment())) {
- script.append(SQL_COMMENT).append(" ").append("'").append(ClickHouseSqlEscapes.escapeSqlLiteral(column.getComment())).append("'").append(" ");
+ script.append(SQL_COMMENT).append(" ").append("'").append(ClickHouseIdentifierProcessor.INSTANCE.escapeString(column.getComment())).append("'").append(" ");
}
return script.toString();
}
diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/enums/type/ClickHouseIndexTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/enums/type/ClickHouseIndexTypeEnum.java
index bf0e27e2d5..6b5748835b 100644
--- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/enums/type/ClickHouseIndexTypeEnum.java
+++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/enums/type/ClickHouseIndexTypeEnum.java
@@ -4,7 +4,7 @@
import ai.chat2db.community.domain.api.model.metadata.IndexType;
import ai.chat2db.community.domain.api.model.metadata.TableIndex;
import ai.chat2db.community.domain.api.model.metadata.TableIndexColumn;
-import ai.chat2db.plugin.clickhouse.ClickHouseSqlEscapes;
+import ai.chat2db.plugin.clickhouse.identifier.ClickHouseIdentifierProcessor;
import org.apache.commons.lang3.StringUtils;
import java.util.Arrays;
@@ -90,7 +90,7 @@ private String buildIndexColumn(TableIndex tableIndex) {
script.append("(");
for (TableIndexColumn column : tableIndex.getColumnList()) {
if (StringUtils.isNotBlank(column.getColumnName())) {
- script.append(ClickHouseSqlEscapes.quoteIdentifier(column.getColumnName()));
+ script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(column.getColumnName()));
script.append(",");
}
}
@@ -103,7 +103,7 @@ private String buildIndexName(TableIndex tableIndex) {
if (this.equals(PRIMARY)) {
return "";
} else {
- return ClickHouseSqlEscapes.quoteIdentifier(tableIndex.getName());
+ return ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(tableIndex.getName());
}
}
@@ -112,10 +112,10 @@ public String buildModifyIndex(TableIndex tableIndex) {
return "";
}
if (EditStatusEnum.DELETE.name().equals(tableIndex.getEditStatus())) {
- return StringUtils.join("DROP INDEX ", ClickHouseSqlEscapes.quoteIdentifier(tableIndex.getOldName()));
+ return StringUtils.join("DROP INDEX ", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(tableIndex.getOldName()));
}
if (EditStatusEnum.MODIFY.name().equals(tableIndex.getEditStatus())) {
- return StringUtils.join("DROP INDEX ", ClickHouseSqlEscapes.quoteIdentifier(tableIndex.getOldName()),
+ return StringUtils.join("DROP INDEX ", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(tableIndex.getOldName()),
",\n ADD ", buildIndexScript(tableIndex));
}
if (EditStatusEnum.ADD.name().equals(tableIndex.getEditStatus())) {
diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/identifier/ClickHouseIdentifierProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/identifier/ClickHouseIdentifierProcessor.java
new file mode 100644
index 0000000000..f7fbba3ef4
--- /dev/null
+++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/identifier/ClickHouseIdentifierProcessor.java
@@ -0,0 +1,65 @@
+package ai.chat2db.plugin.clickhouse.identifier;
+
+import ai.chat2db.spi.DefaultSQLIdentifierProcessor;
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * ClickHouse dialect identifier processor: backtick-quoted identifiers with
+ * embedded-backtick doubling, and backslash/single-quote doubling for string
+ * literals (ClickHouse treats backslash as an escape character). Shared
+ * stateless instance available via {@link #INSTANCE} for call sites without
+ * MetaData access.
+ */
+public class ClickHouseIdentifierProcessor extends DefaultSQLIdentifierProcessor {
+
+ public static final ClickHouseIdentifierProcessor INSTANCE = new ClickHouseIdentifierProcessor();
+
+ /**
+ * Always quotes with backticks, stripping one surrounding backtick pair and
+ * doubling every embedded backtick.
+ */
+ @Override
+ public String quoteIdentifier(String identifier) {
+ return "`" + escapeIdentifierContent(identifier) + "`";
+ }
+
+ @Override
+ public String quoteIdentifier(String identifier, Integer majorVersion, Integer minorVersion) {
+ return quoteIdentifier(identifier);
+ }
+
+ @Override
+ public String quoteIdentifierIgnoreCase(String identifier) {
+ return quoteIdentifier(identifier);
+ }
+
+ /**
+ * Escapes a value interpolated into a single-quoted ClickHouse string
+ * literal: backslashes are doubled first (ClickHouse treats backslash as an
+ * escape character), then single quotes are doubled.
+ */
+ @Override
+ public String escapeString(String str) {
+ return str == null ? "" : StringUtils.replace(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
+ * backticks: strips one surrounding backtick pair, then doubles every
+ * embedded backtick.
+ */
+ public static String escapeIdentifier(String identifier) {
+ return escapeIdentifierContent(identifier);
+ }
+}
diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/test/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlEscapesTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/test/java/ai/chat2db/plugin/clickhouse/ClickHouseIdentifierProcessorTest.java
similarity index 93%
rename from chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/test/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlEscapesTest.java
rename to chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/test/java/ai/chat2db/plugin/clickhouse/ClickHouseIdentifierProcessorTest.java
index 739736740f..98bc7971b6 100644
--- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/test/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlEscapesTest.java
+++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/test/java/ai/chat2db/plugin/clickhouse/ClickHouseIdentifierProcessorTest.java
@@ -5,6 +5,7 @@
import ai.chat2db.community.domain.api.model.metadata.TableColumn;
import ai.chat2db.plugin.clickhouse.builder.ClickHouseSqlBuilder;
import ai.chat2db.plugin.clickhouse.enums.type.ClickHouseColumnTypeEnum;
+import ai.chat2db.plugin.clickhouse.identifier.ClickHouseIdentifierProcessor;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
@@ -13,29 +14,29 @@
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
-class ClickHouseSqlEscapesTest {
+class ClickHouseIdentifierProcessorTest {
@Test
void shouldDoubleSingleQuotesInLiterals() {
- assertEquals("owner''s", ClickHouseSqlEscapes.escapeSqlLiteral("owner's"));
- assertEquals("", ClickHouseSqlEscapes.escapeSqlLiteral(null));
+ assertEquals("owner''s", ClickHouseIdentifierProcessor.INSTANCE.escapeString("owner's"));
+ assertEquals("", ClickHouseIdentifierProcessor.INSTANCE.escapeString(null));
}
@Test
void shouldEscapeBackslashesInLiterals() {
- assertEquals("a\\\\b''c", ClickHouseSqlEscapes.escapeSqlLiteral("a\\b'c"));
+ assertEquals("a\\\\b''c", ClickHouseIdentifierProcessor.INSTANCE.escapeString("a\\b'c"));
}
@Test
void shouldDoubleBackticksInIdentifiers() {
- assertEquals("`a``b`", ClickHouseSqlEscapes.quoteIdentifier("a`b"));
- assertEquals("`plain`", ClickHouseSqlEscapes.quoteIdentifier("plain"));
- assertEquals("``", ClickHouseSqlEscapes.quoteIdentifier(null));
+ assertEquals("`a``b`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier("a`b"));
+ assertEquals("`plain`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier("plain"));
+ assertEquals("``", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(null));
}
@Test
void shouldStripSurroundingBackticksBeforeDoubling() {
- assertEquals("`a``b`", ClickHouseSqlEscapes.quoteIdentifier("`a`b`"));
+ assertEquals("`a``b`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier("`a`b`"));
}
@Test
From 2be883c466fed3ec24188b8f29844cf9d4404fef Mon Sep 17 00:00:00 2001
From: HandSonic <8078023+handsonic@users.noreply.github.com>
Date: Mon, 27 Jul 2026 03:56:14 +0800
Subject: [PATCH 5/6] fix(clickhouse): keep SPI quoteIdentifier conditional,
reserve always-quote for DDL paths (#1914)
- quoteIdentifier(String) is conditional again: null/blank pass through,
plain non-keyword identifiers stay unquoted, everything else is wrapped
in backticks with embedded backticks doubled; versioned overload delegates
- new quoteIdentifierAlways(String) carries the old unconditional semantics
for DDL-generation call sites migrated from ClickHouseSqlEscapes
- quoteIdentifierIgnoreCase stays the always-quote, case-preserving variant
- override removeIdentifierQuote/isQuoteIdentifier so backtick-quoted
identifiers round-trip through completion/matching consumers
- add ClickHouse reserved keyword set used by the conditional check
- tests cover conditional vs always behavior incl. null passthrough (30 green)
---
.../clickhouse/ClickHouseDBManager.java | 12 +-
.../plugin/clickhouse/ClickHouseMetaData.java | 4 +-
.../builder/ClickHouseSqlBuilder.java | 12 +-
.../enums/type/ClickHouseColumnTypeEnum.java | 10 +-
.../enums/type/ClickHouseIndexTypeEnum.java | 8 +-
.../ClickHouseIdentifierProcessor.java | 212 +++++++++++++++++-
.../ClickHouseIdentifierProcessorTest.java | 55 ++++-
7 files changed, 279 insertions(+), 34 deletions(-)
diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseDBManager.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseDBManager.java
index 6eddca688f..20b30e8a7a 100644
--- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseDBManager.java
+++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseDBManager.java
@@ -38,7 +38,7 @@ private void exportFunctions(Connection connection, AsyncContext asyncContext) t
try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) {
while (resultSet.next()) {
StringBuilder sqlBuilder = new StringBuilder();
- sqlBuilder.append(SQL_DROP_FUNCTION_EXISTS).append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(resultSet.getString("name"))).append(";")
+ sqlBuilder.append(SQL_DROP_FUNCTION_EXISTS).append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(resultSet.getString("name"))).append(";")
.append("\n")
.append(resultSet.getString("create_query")).append(";").append("\n");
asyncContext.write(sqlBuilder.toString());
@@ -57,17 +57,17 @@ private void exportTablesOrViewsOrDictionaries(Connection connection, String dat
String tableOrViewName = resultSet.getString("name");
if (Objects.equals("View", tableType)) {
StringBuilder sqlBuilder = new StringBuilder();
- sqlBuilder.append(SQL_DROP_VIEW_EXISTS).append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(databaseName)).append(".").append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(tableOrViewName))
+ sqlBuilder.append(SQL_DROP_VIEW_EXISTS).append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(databaseName)).append(".").append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableOrViewName))
.append(";").append("\n").append(ddl).append(";").append("\n");
asyncContext.write(sqlBuilder.toString());
} else if (Objects.equals("Dictionary", tableType)) {
StringBuilder sqlBuilder = new StringBuilder();
- sqlBuilder.append(SQL_DROP_DICTIONARY_EXISTS).append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(databaseName)).append(".").append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(tableOrViewName))
+ sqlBuilder.append(SQL_DROP_DICTIONARY_EXISTS).append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(databaseName)).append(".").append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableOrViewName))
.append(";").append("\n").append(ddl).append(";").append("\n");
asyncContext.write(sqlBuilder.toString());
} else {
StringBuilder sqlBuilder = new StringBuilder();
- sqlBuilder.append(SQL_DROP_TABLE_EXISTS).append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(databaseName)).append(".").append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(tableOrViewName))
+ sqlBuilder.append(SQL_DROP_TABLE_EXISTS).append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(databaseName)).append(".").append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableOrViewName))
.append(";").append("\n").append(ddl).append(";").append("\n");
asyncContext.write(sqlBuilder.toString());
if (asyncContext.isContainsData() && dataFlag) {
@@ -120,10 +120,10 @@ public String dropTable(Connection connection, String databaseName, String schem
@Override
public void copyTable(Connection connection, String databaseName, String schemaName, String tableName, String newTableName, boolean copyData) throws SQLException {
- String sql = "CREATE TABLE " + ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(newTableName) + " AS " + ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(tableName) + "";
+ String sql = "CREATE TABLE " + ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(newTableName) + " AS " + ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName) + "";
DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> null);
if (copyData) {
- sql = "INSERT INTO " + ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(newTableName) + " SELECT * FROM " + ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(tableName);
+ sql = "INSERT INTO " + ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(newTableName) + " SELECT * FROM " + ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName);
DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> null);
}
}
diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseMetaData.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseMetaData.java
index d1c6015e31..540e783d81 100644
--- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseMetaData.java
+++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseMetaData.java
@@ -46,7 +46,7 @@ public ISQLIdentifierProcessor getSQLIdentifierProcessor() {
}
public static String format(String tableName) {
- return ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(tableName);
+ return ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName);
}
@Override
@@ -305,7 +305,7 @@ public TableMeta getTableMeta(String databaseName, String schemaName, String tab
@Override
public String getMetaDataName(String... names) {
- return Arrays.stream(names).filter(name -> StringUtils.isNotBlank(name)).map(getSQLIdentifierProcessor()::quoteIdentifier).collect(Collectors.joining("."));
+ return Arrays.stream(names).filter(name -> StringUtils.isNotBlank(name)).map(ClickHouseIdentifierProcessor.INSTANCE::quoteIdentifierAlways).collect(Collectors.joining("."));
}
diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/builder/ClickHouseSqlBuilder.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/builder/ClickHouseSqlBuilder.java
index 31810e59f1..5226f26fd3 100644
--- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/builder/ClickHouseSqlBuilder.java
+++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/builder/ClickHouseSqlBuilder.java
@@ -29,9 +29,9 @@ public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfi
StringBuilder script = new StringBuilder();
script.append(SQL_CREATE_TABLE);
if (StringUtils.isNotBlank(table.getDatabaseName())) {
- script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(table.getDatabaseName())).append(SQLConstants.DOT);
+ script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(table.getDatabaseName())).append(SQLConstants.DOT);
}
- script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(table.getName())).append(SQLConstants.SPACE_OPEN_PARENTHESIS).append(SQLConstants.LINE_SEPARATOR);
+ script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(table.getName())).append(SQLConstants.SPACE_OPEN_PARENTHESIS).append(SQLConstants.LINE_SEPARATOR);
for (TableColumn column : table.getColumnList()) {
if (StringUtils.isBlank(column.getName()) || StringUtils.isBlank(column.getColumnType())) {
continue;
@@ -79,9 +79,9 @@ public String buildAlterTable(Table oldTable, Table newTable) {
StringBuilder script = new StringBuilder();
script.append(SQL_ALTER_TABLE);
if (StringUtils.isNotBlank(oldTable.getDatabaseName())) {
- script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(oldTable.getDatabaseName())).append(SQLConstants.DOT);
+ script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(oldTable.getDatabaseName())).append(SQLConstants.DOT);
}
- script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(oldTable.getName())).append(SQLConstants.LINE_SEPARATOR);
+ script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(oldTable.getName())).append(SQLConstants.LINE_SEPARATOR);
if (!StringUtils.equalsIgnoreCase(oldTable.getComment(), newTable.getComment())) {
script.append(SQLConstants.TAB).append(SQL_MODIFY_COMMENT).append(SQLConstants.SINGLE_QUOTE).append(ClickHouseIdentifierProcessor.INSTANCE.escapeString(newTable.getComment())).append(SQLConstants.SINGLE_QUOTE).append(SQLConstants.COMMA_LINE_SEPARATOR);
@@ -139,9 +139,9 @@ public String buildPageLimit(PageLimitRequest request) {
@Override
public String buildCreateDatabase(Database database) {
StringBuilder sqlBuilder = new StringBuilder();
- sqlBuilder.append(SQL_CREATE_DATABASE).append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(database.getName()));
+ sqlBuilder.append(SQL_CREATE_DATABASE).append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(database.getName()));
if(StringUtils.isNotBlank(database.getComment())){
- sqlBuilder.append(SQL_SEMICOLON_ALTER_DATABASE).append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(database.getName())).append(SQL_COMMENT).append(ClickHouseIdentifierProcessor.INSTANCE.escapeString(database.getComment())).append(SQLConstants.SINGLE_QUOTE_SEMICOLON);
+ sqlBuilder.append(SQL_SEMICOLON_ALTER_DATABASE).append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(database.getName())).append(SQL_COMMENT).append(ClickHouseIdentifierProcessor.INSTANCE.escapeString(database.getComment())).append(SQLConstants.SINGLE_QUOTE_SEMICOLON);
}
return sqlBuilder.toString();
}
diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/enums/type/ClickHouseColumnTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/enums/type/ClickHouseColumnTypeEnum.java
index 102c1d639c..60c75e5934 100644
--- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/enums/type/ClickHouseColumnTypeEnum.java
+++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/enums/type/ClickHouseColumnTypeEnum.java
@@ -102,7 +102,7 @@ public static String buildCreateColumnSqlSafely(TableColumn column) {
private static String buildValidatedFallbackColumn(TableColumn column) {
StringBuilder script = new StringBuilder();
- script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(column.getName())).append(" ");
+ script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName())).append(" ");
String columnType = ClickHouseSqlGuards.requireColumnTypeExpression(column.getColumnType());
if (column.getNullable() != null && 1 == column.getNullable() && isNullableWrappable(columnType)) {
columnType = "Nullable(" + columnType + ")";
@@ -156,7 +156,7 @@ public String buildCreateColumnSql(TableColumn column) {
}
StringBuilder script = new StringBuilder();
- script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(column.getName())).append(" ");
+ script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName())).append(" ");
script.append(buildNullableAndDataType(column, type)).append(" ");
@@ -171,7 +171,7 @@ public String buildCreateColumnSql(TableColumn column) {
public String buildModifyColumn(TableColumn tableColumn) {
if (EditStatusEnum.DELETE.name().equals(tableColumn.getEditStatus())) {
- return StringUtils.join("DROP COLUMN ", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(tableColumn.getName()));
+ return StringUtils.join("DROP COLUMN ", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableColumn.getName()));
}
if (EditStatusEnum.ADD.name().equals(tableColumn.getEditStatus())) {
return StringUtils.join("ADD COLUMN ", buildCreateColumnSql(tableColumn));
@@ -179,7 +179,7 @@ public String buildModifyColumn(TableColumn tableColumn) {
if (EditStatusEnum.MODIFY.name().equals(tableColumn.getEditStatus())) {
String modifyColumn = "";
if (!StringUtils.equalsIgnoreCase(tableColumn.getOldName(), tableColumn.getName())) {
- modifyColumn = StringUtils.join("RENAME COLUMN ", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(tableColumn.getOldName()), " TO ", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(tableColumn.getName()),
+ modifyColumn = StringUtils.join("RENAME COLUMN ", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableColumn.getOldName()), " TO ", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableColumn.getName()),
", ", buildCreateColumnSql(tableColumn));
}
return StringUtils.join(modifyColumn, "MODIFY COLUMN ", buildCreateColumnSql(tableColumn));
@@ -268,7 +268,7 @@ public String buildColumn(TableColumn column) {
}
StringBuilder script = new StringBuilder();
- script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(column.getName())).append(" ");
+ script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName())).append(" ");
script.append(buildDataType(column, type)).append(" ");
if (StringUtils.isNoneBlank(column.getComment())) {
script.append(SQL_COMMENT).append(" ").append("'").append(ClickHouseIdentifierProcessor.INSTANCE.escapeString(column.getComment())).append("'").append(" ");
diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/enums/type/ClickHouseIndexTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/enums/type/ClickHouseIndexTypeEnum.java
index 6b5748835b..f3054fb6d7 100644
--- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/enums/type/ClickHouseIndexTypeEnum.java
+++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/enums/type/ClickHouseIndexTypeEnum.java
@@ -90,7 +90,7 @@ private String buildIndexColumn(TableIndex tableIndex) {
script.append("(");
for (TableIndexColumn column : tableIndex.getColumnList()) {
if (StringUtils.isNotBlank(column.getColumnName())) {
- script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(column.getColumnName()));
+ script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getColumnName()));
script.append(",");
}
}
@@ -103,7 +103,7 @@ private String buildIndexName(TableIndex tableIndex) {
if (this.equals(PRIMARY)) {
return "";
} else {
- return ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(tableIndex.getName());
+ return ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableIndex.getName());
}
}
@@ -112,10 +112,10 @@ public String buildModifyIndex(TableIndex tableIndex) {
return "";
}
if (EditStatusEnum.DELETE.name().equals(tableIndex.getEditStatus())) {
- return StringUtils.join("DROP INDEX ", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(tableIndex.getOldName()));
+ return StringUtils.join("DROP INDEX ", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableIndex.getOldName()));
}
if (EditStatusEnum.MODIFY.name().equals(tableIndex.getEditStatus())) {
- return StringUtils.join("DROP INDEX ", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(tableIndex.getOldName()),
+ return StringUtils.join("DROP INDEX ", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableIndex.getOldName()),
",\n ADD ", buildIndexScript(tableIndex));
}
if (EditStatusEnum.ADD.name().equals(tableIndex.getEditStatus())) {
diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/identifier/ClickHouseIdentifierProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/identifier/ClickHouseIdentifierProcessor.java
index f7fbba3ef4..345c14cf46 100644
--- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/identifier/ClickHouseIdentifierProcessor.java
+++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/identifier/ClickHouseIdentifierProcessor.java
@@ -3,24 +3,184 @@
import ai.chat2db.spi.DefaultSQLIdentifierProcessor;
import org.apache.commons.lang3.StringUtils;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.regex.Pattern;
+
/**
- * ClickHouse dialect identifier processor: backtick-quoted identifiers with
- * embedded-backtick doubling, and backslash/single-quote doubling for string
- * literals (ClickHouse treats backslash as an escape character). Shared
- * stateless instance available via {@link #INSTANCE} for call sites without
- * MetaData access.
+ * ClickHouse dialect identifier processor.
+ *
+ * SPI-facing {@link #quoteIdentifier(String)} is conditional: plain
+ * identifiers that are not reserved keywords pass through unquoted so
+ * completion/matching consumers see raw names; anything else is wrapped in
+ * backticks with embedded backticks doubled. {@link #quoteIdentifierAlways(String)}
+ * is the unconditional variant reserved for DDL-generation call sites that
+ * historically always quoted. Backslash/single-quote doubling is used for
+ * string literals (ClickHouse treats backslash as an escape character).
+ * Shared stateless instance available via {@link #INSTANCE} for call sites
+ * without MetaData access.
*/
public class ClickHouseIdentifierProcessor extends DefaultSQLIdentifierProcessor {
public static final ClickHouseIdentifierProcessor INSTANCE = new ClickHouseIdentifierProcessor();
+ private static final Pattern CLICKHOUSE_PATTERN = Pattern.compile("[`\"](.*?)[`\"]");
+
+ private static final Set CLICKHOUSE_RESERVED_KEYWORDS = new HashSet<>();
+
+ static {
+ CLICKHOUSE_RESERVED_KEYWORDS.add("ALIAS");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("ALL");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("ALTER");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("AND");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("ANTI");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("ANY");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("ARRAY");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("AS");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("ASC");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("ASOF");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("BETWEEN");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("BOTH");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("BY");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("CASE");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("CAST");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("CHECK");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("CLUSTER");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("COMMENT");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("CONSTRAINT");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("CREATE");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("CROSS");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("CUBE");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("DATABASE");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("DATABASES");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("DEFAULT");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("DELETE");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("DESC");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("DESCRIBE");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("DETACH");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("DICTIONARY");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("DISTINCT");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("DISTRIBUTED");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("DROP");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("ELSE");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("END");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("ENGINE");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("EPHEMERAL");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("EXCEPT");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("EXISTS");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("EXPLAIN");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("FINAL");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("FIRST");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("FOLLOWING");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("FORMAT");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("FROM");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("FULL");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("FUNCTION");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("GLOBAL");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("GRANULARITY");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("GROUP");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("HAVING");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("IF");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("ILIKE");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("IN");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("INDEX");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("INNER");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("INSERT");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("INTERSECT");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("INTERVAL");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("INTO");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("IS");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("JOIN");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("KEY");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("LAST");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("LEADING");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("LEFT");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("LIKE");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("LIMIT");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("LIVE");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("LOCAL");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("MATERIALIZED");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("MODIFY");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("MUTATION");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("NOT");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("NULL");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("NULLS");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("OFFSET");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("ON");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("OPTIMIZE");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("OR");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("ORDER");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("OUTER");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("OVER");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("PARTITION");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("POPULATE");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("PRECEDING");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("PREWHERE");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("PRIMARY");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("PROJECTION");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("RANGE");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("RENAME");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("REPLACE");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("REPLICA");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("RIGHT");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("ROLLUP");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("SAMPLE");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("SELECT");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("SEMI");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("SET");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("SETTINGS");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("SHOW");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("TABLE");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("TABLES");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("TEMPORARY");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("THEN");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("TIES");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("TO");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("TOP");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("TOTALS");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("TRAILING");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("TRIM");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("TRUNCATE");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("TTL");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("UNBOUNDED");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("UNION");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("UPDATE");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("USE");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("USING");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("VALUES");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("VIEW");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("VOLUME");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("WATCH");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("WHEN");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("WHERE");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("WINDOW");
+ CLICKHOUSE_RESERVED_KEYWORDS.add("WITH");
+ }
+
+ @Override
+ public boolean isReservedKeyword(String identifier, Integer majorVersion, Integer minorVersion) {
+ return identifier != null && CLICKHOUSE_RESERVED_KEYWORDS.contains(identifier.toUpperCase());
+ }
+
/**
- * Always quotes with backticks, stripping one surrounding backtick pair and
- * doubling every embedded backtick.
+ * SPI-facing conditional quoting: {@code null} passes through, blank is
+ * returned unchanged, valid plain identifiers that are not reserved
+ * keywords are returned unquoted, and everything else is wrapped in
+ * backticks with one surrounding pair stripped and embedded backticks
+ * doubled.
*/
@Override
public String quoteIdentifier(String identifier) {
- return "`" + escapeIdentifierContent(identifier) + "`";
+ if (identifier == null) {
+ return null;
+ }
+ if (StringUtils.isBlank(identifier)) {
+ return identifier;
+ }
+ if (isValidIdentifier(identifier) && !isReservedKeyword(identifier, null, null)) {
+ return identifier;
+ }
+ return quoteIdentifierAlways(identifier);
}
@Override
@@ -28,9 +188,43 @@ public String quoteIdentifier(String identifier, Integer majorVersion, Integer m
return quoteIdentifier(identifier);
}
+ /**
+ * Unconditional quoting for DDL-generation call sites: {@code null} passes
+ * through, every other value is wrapped in backticks with one surrounding
+ * backtick pair stripped and every embedded backtick doubled.
+ */
+ public String quoteIdentifierAlways(String identifier) {
+ if (identifier == null) {
+ return null;
+ }
+ return "`" + escapeIdentifierContent(identifier) + "`";
+ }
+
+ /**
+ * Always-quote variant that preserves the original identifier case.
+ */
@Override
public String quoteIdentifierIgnoreCase(String identifier) {
- return quoteIdentifier(identifier);
+ return quoteIdentifierAlways(identifier);
+ }
+
+ @Override
+ public String removeIdentifierQuote(String identifier) {
+ if (StringUtils.isBlank(identifier)) {
+ return identifier;
+ }
+ return removePattern(identifier, CLICKHOUSE_PATTERN);
+ }
+
+ @Override
+ public boolean isQuoteIdentifier(String identifier) {
+ if (StringUtils.isBlank(identifier)) {
+ return false;
+ }
+ if (identifier.startsWith("`") && identifier.endsWith("`")) {
+ return true;
+ }
+ return identifier.startsWith("\"") && identifier.endsWith("\"");
}
/**
diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/test/java/ai/chat2db/plugin/clickhouse/ClickHouseIdentifierProcessorTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/test/java/ai/chat2db/plugin/clickhouse/ClickHouseIdentifierProcessorTest.java
index 98bc7971b6..4f2ef28c97 100644
--- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/test/java/ai/chat2db/plugin/clickhouse/ClickHouseIdentifierProcessorTest.java
+++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/test/java/ai/chat2db/plugin/clickhouse/ClickHouseIdentifierProcessorTest.java
@@ -11,6 +11,7 @@
import java.util.ArrayList;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -27,16 +28,66 @@ void shouldEscapeBackslashesInLiterals() {
assertEquals("a\\\\b''c", ClickHouseIdentifierProcessor.INSTANCE.escapeString("a\\b'c"));
}
+ @Test
+ void shouldPassThroughNullAndBlankIdentifiers() {
+ assertEquals(null, ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(null));
+ assertEquals("", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(""));
+ assertEquals(" ", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(" "));
+ }
+
+ @Test
+ void shouldLeavePlainIdentifiersUnquoted() {
+ assertEquals("plain", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier("plain"));
+ assertEquals("table_1", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier("table_1"));
+ assertEquals("plain", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier("plain", 24, 3));
+ }
+
+ @Test
+ void shouldQuoteReservedKeywords() {
+ assertEquals("`select`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier("select"));
+ assertEquals("`SELECT`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier("SELECT"));
+ assertEquals("`order`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier("order", null, null));
+ }
+
@Test
void shouldDoubleBackticksInIdentifiers() {
assertEquals("`a``b`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier("a`b"));
- assertEquals("`plain`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier("plain"));
- assertEquals("``", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(null));
+ assertEquals("`with space`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier("with space"));
+ }
+
+ @Test
+ void shouldAlwaysQuoteInIgnoreCaseAndAlwaysVariants() {
+ assertEquals("`plain`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("plain"));
+ assertEquals("`a``b`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("a`b"));
+ assertEquals(null, ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase(null));
+ assertEquals("`plain`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways("plain"));
+ assertEquals("`a``b`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways("a`b"));
+ assertEquals(null, ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(null));
}
@Test
void shouldStripSurroundingBackticksBeforeDoubling() {
assertEquals("`a``b`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier("`a`b`"));
+ assertEquals("`a``b`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways("`a`b`"));
+ }
+
+ @Test
+ void shouldRecognizeAndRemoveBacktickQuotes() {
+ assertTrue(ClickHouseIdentifierProcessor.INSTANCE.isQuoteIdentifier("`name`"));
+ assertTrue(ClickHouseIdentifierProcessor.INSTANCE.isQuoteIdentifier("\"name\""));
+ assertFalse(ClickHouseIdentifierProcessor.INSTANCE.isQuoteIdentifier("name"));
+ assertFalse(ClickHouseIdentifierProcessor.INSTANCE.isQuoteIdentifier(null));
+ assertEquals("name", ClickHouseIdentifierProcessor.INSTANCE.removeIdentifierQuote("`name`"));
+ assertEquals("name", ClickHouseIdentifierProcessor.INSTANCE.removeIdentifierQuote("\"name\""));
+ assertEquals("name", ClickHouseIdentifierProcessor.INSTANCE.removeIdentifierQuote("name"));
+ assertEquals(null, ClickHouseIdentifierProcessor.INSTANCE.removeIdentifierQuote(null));
+ }
+
+ @Test
+ void shouldRoundTripThroughAlwaysQuoteAndRemove() {
+ assertEquals("plain", ClickHouseIdentifierProcessor.INSTANCE
+ .removeIdentifierQuote(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways("plain")));
+ assertEquals("db.table", ClickHouseIdentifierProcessor.INSTANCE.removeIdentifierQuote("`db`.`table`"));
}
@Test
From f73da33c8be848278f7c99049305365e2e60164d Mon Sep 17 00:00:00 2001
From: zgq
Date: Wed, 29 Jul 2026 15:26:15 +0800
Subject: [PATCH 6/6] fix(clickhouse): preserve legal SQL escaping semantics
(#1914)
---
.../clickhouse/ClickHouseDBManager.java | 44 +++-
.../plugin/clickhouse/ClickHouseMetaData.java | 3 +-
.../clickhouse/ClickHouseSqlGuards.java | 212 +++++++++++++-----
.../builder/ClickHouseSqlBuilder.java | 133 +++++++----
.../enums/type/ClickHouseColumnTypeEnum.java | 79 +++++--
.../ClickHouseIdentifierProcessor.java | 82 +++++--
.../ClickHouseIdentifierProcessorTest.java | 181 +++++++++++++--
7 files changed, 580 insertions(+), 154 deletions(-)
diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseDBManager.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseDBManager.java
index 20b30e8a7a..fe636e3643 100644
--- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseDBManager.java
+++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseDBManager.java
@@ -12,6 +12,8 @@
import org.slf4j.LoggerFactory;
import java.sql.*;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Objects;
import static ai.chat2db.plugin.clickhouse.constant.ClickHouseDBManagerConstants.*;
@@ -113,18 +115,48 @@ private String setDatabaseInJdbcUrl(ConnectInfo connectInfo) {
@Override
public String dropTable(Connection connection, String databaseName, String schemaName, String tableName) {
- String sql = "DROP TABLE IF EXISTS " + ClickHouseMetaData.format(schemaName) + "." + ClickHouseMetaData.format(tableName);
- return sql;
+ return "DROP TABLE IF EXISTS " + qualifiedTableName(databaseName, schemaName, tableName, false);
}
+ @Override
+ public String truncateTable(Connection connection, String databaseName, String schemaName, String tableName) {
+ return "TRUNCATE TABLE " + qualifiedTableName(databaseName, schemaName, tableName, true);
+ }
@Override
public void copyTable(Connection connection, String databaseName, String schemaName, String tableName, String newTableName, boolean copyData) throws SQLException {
- String sql = "CREATE TABLE " + ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(newTableName) + " AS " + ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName) + "";
- DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> null);
- if (copyData) {
- sql = "INSERT INTO " + ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(newTableName) + " SELECT * FROM " + ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName);
+ for (String sql : buildCopyTableStatements(databaseName, schemaName, tableName, newTableName, copyData)) {
DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> null);
}
}
+
+ static List buildCopyTableStatements(String databaseName, String schemaName, String tableName,
+ String newTableName, boolean copyData) {
+ String source = qualifiedTableName(databaseName, schemaName, tableName, true);
+ String target = qualifiedTableName(databaseName, schemaName, newTableName, true);
+ List statements = new ArrayList<>();
+ statements.add("CREATE TABLE " + target + " AS " + source);
+ if (copyData) {
+ statements.add("INSERT INTO " + target + " SELECT * FROM " + source);
+ }
+ return statements;
+ }
+
+ private static String qualifiedTableName(String databaseName, String schemaName, String tableName,
+ boolean normalizeQuotedTable) {
+ String qualifier = StringUtils.isNotBlank(schemaName) ? schemaName : databaseName;
+ String normalizedTable = normalizeQuotedTable ? normalizeQuotedIdentifier(tableName) : tableName;
+ if (StringUtils.isBlank(qualifier)) {
+ return ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(normalizedTable);
+ }
+ return ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(qualifier)
+ + "." + ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(normalizedTable);
+ }
+
+ private static String normalizeQuotedIdentifier(String identifier) {
+ if (ClickHouseIdentifierProcessor.INSTANCE.isQuoteIdentifier(identifier)) {
+ return ClickHouseIdentifierProcessor.INSTANCE.removeIdentifierQuote(identifier);
+ }
+ return identifier;
+ }
}
diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseMetaData.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseMetaData.java
index 540e783d81..956980e94d 100644
--- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseMetaData.java
+++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseMetaData.java
@@ -115,8 +115,7 @@ public List views(Connection connection, String databaseName, String sche
@Override
public String tableDDL(Connection connection, @NotEmpty String databaseName, String schemaName,
@NotEmpty String tableName) {
- String sql = "SHOW CREATE TABLE " + format(schemaName) + "."
- + format(tableName);
+ String sql = "SHOW CREATE TABLE " + getMetaDataName(schemaName, tableName);
return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> {
if (resultSet.next()) {
return resultSet.getString("statement");
diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlGuards.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlGuards.java
index 492e12bc0f..0a7f19ff29 100644
--- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlGuards.java
+++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlGuards.java
@@ -1,12 +1,13 @@
package ai.chat2db.plugin.clickhouse;
-import ai.chat2db.plugin.clickhouse.identifier.ClickHouseIdentifierProcessor;
import org.apache.commons.lang3.StringUtils;
+import java.util.ArrayDeque;
+import java.util.Deque;
+
/**
- * Validation helpers for non-escapable SQL positions in ClickHouse DDL
- * generation (column type expressions, table engines, column default
- * expressions). Escaping itself lives in {@link ClickHouseIdentifierProcessor}.
+ * Structural validation for ClickHouse expressions that cannot be escaped
+ * because they are emitted as SQL syntax.
*/
public final class ClickHouseSqlGuards {
@@ -14,72 +15,179 @@ private ClickHouseSqlGuards() {
}
/**
- * Validates a column type expression that is emitted verbatim into DDL
- * (e.g. Int32, Decimal(10,2), Array(Nullable(String)), Enum8('a'=1)).
- * Only letters/digits/underscore are allowed at the top level; spaces,
- * commas, single quotes and equals signs are only allowed inside balanced
- * parentheses. Semicolons, dashes, double quotes and backticks are always
- * rejected.
+ * Accepts one ClickHouse column type expression, including nested types,
+ * enum literals, time zones, and quoted tuple field names.
*/
public static String requireColumnTypeExpression(String columnType) {
if (StringUtils.isBlank(columnType)) {
- throw new IllegalArgumentException("Invalid ClickHouse column type: " + columnType);
+ throw invalid("column type", columnType);
}
- int depth = 0;
- for (int i = 0; i < columnType.length(); i++) {
- char c = columnType.charAt(i);
- if (c == '(') {
- depth++;
- continue;
- }
- if (c == ')') {
- depth--;
- if (depth < 0) {
- throw new IllegalArgumentException("Invalid ClickHouse column type: " + columnType);
- }
- continue;
- }
- boolean ok = Character.isLetterOrDigit(c) || c == '_'
- || (depth > 0 && (c == ' ' || c == ',' || c == '\'' || c == '='));
- if (!ok) {
- throw new IllegalArgumentException("Invalid ClickHouse column type: " + columnType);
- }
+ String expression = columnType.trim();
+ int nameEnd = scanIdentifier(expression, 0);
+ if (nameEnd == 0) {
+ throw invalid("column type", columnType);
}
- if (depth != 0 || !Character.isLetter(columnType.charAt(0))) {
- throw new IllegalArgumentException("Invalid ClickHouse column type: " + columnType);
+ int argumentsStart = skipWhitespace(expression, nameEnd);
+ if (argumentsStart == expression.length()) {
+ return expression;
}
- return columnType;
+ if (expression.charAt(argumentsStart) != '(') {
+ throw invalid("column type", columnType);
+ }
+ int argumentsEnd = scanExpression(expression, argumentsStart, true, true, false, "column type");
+ if (skipWhitespace(expression, argumentsEnd + 1) != expression.length()) {
+ throw invalid("column type", columnType);
+ }
+ return expression;
}
/**
- * Validates a table engine expression emitted verbatim into CREATE TABLE
- * DDL (e.g. MergeTree, ReplicatedMergeTree('/path','replica')). Only a
- * dotted-free identifier with an optional balanced argument list is
- * accepted; semicolons are never allowed inside the arguments.
+ * Accepts one engine name with an optional, fully balanced argument list.
*/
public static String requireEngine(String engine) {
- if (!engine.matches("[A-Za-z0-9_]+(\\s*\\([^;)]*\\))?")) {
- throw new IllegalArgumentException("Invalid ClickHouse engine: " + engine);
+ if (StringUtils.isBlank(engine)) {
+ throw invalid("engine", engine);
+ }
+ String expression = engine.trim();
+ int nameEnd = scanIdentifier(expression, 0);
+ if (nameEnd == 0) {
+ throw invalid("engine", engine);
+ }
+ int argumentsStart = skipWhitespace(expression, nameEnd);
+ if (argumentsStart == expression.length()) {
+ return expression;
+ }
+ if (expression.charAt(argumentsStart) != '(') {
+ throw invalid("engine", engine);
+ }
+ int argumentsEnd = scanExpression(expression, argumentsStart, true, false, false, "engine");
+ if (skipWhitespace(expression, argumentsEnd + 1) != expression.length()) {
+ throw invalid("engine", engine);
}
- return engine;
+ return expression;
}
/**
- * Renders a column default expression safe for inclusion in generated DDL:
- * values wrapped in single quotes are treated as string literals whose
- * inner content is re-escaped; unquoted values must match a conservative
- * allow-list (numbers, identifiers, function calls without semicolons);
- * anything else is rejected (fail closed).
+ * Validates one default expression without re-encoding serialized SQL
+ * literals returned by {@code system.columns.default_expression}.
*/
public static String escapeDefaultExpression(String defaultValue) {
- String trimmed = defaultValue.trim();
- // Quoted string literals stay literals; the content is escaped before re-quoting.
- if (trimmed.length() >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'")) {
- return "'" + ClickHouseIdentifierProcessor.INSTANCE.escapeString(trimmed.substring(1, trimmed.length() - 1)) + "'";
+ if (StringUtils.isBlank(defaultValue)) {
+ throw invalid("default expression", defaultValue);
}
- if (!trimmed.matches("[-+]?(\\d+(\\.\\d+)?|[A-Za-z_][A-Za-z0-9_]*(\\s*\\([^;)]*\\))?)")) {
- throw new IllegalArgumentException("Invalid ClickHouse default expression: " + defaultValue);
+ String expression = defaultValue.trim();
+ scanExpression(expression, 0, false, false, true, "default expression");
+ return expression;
+ }
+
+ private static int scanIdentifier(String expression, int offset) {
+ if (offset >= expression.length()
+ || !(Character.isLetter(expression.charAt(offset)) || expression.charAt(offset) == '_')) {
+ return offset;
+ }
+ int current = offset + 1;
+ while (current < expression.length()) {
+ char c = expression.charAt(current);
+ if (!Character.isLetterOrDigit(c) && c != '_') {
+ break;
+ }
+ current++;
+ }
+ return current;
+ }
+
+ private static int skipWhitespace(String expression, int offset) {
+ int current = offset;
+ while (current < expression.length() && Character.isWhitespace(expression.charAt(current))) {
+ current++;
}
- return trimmed;
+ return current;
+ }
+
+ /**
+ * Scans a quote-aware expression. When {@code stopAtRootClose} is true,
+ * scanning starts on an opening parenthesis and returns its matching close.
+ */
+ private static int scanExpression(String expression, int start, boolean stopAtRootClose,
+ boolean typeCharactersOnly, boolean rejectTopLevelComma,
+ String description) {
+ Deque delimiters = new ArrayDeque<>();
+ char quote = 0;
+ for (int i = start; i < expression.length(); i++) {
+ char c = expression.charAt(i);
+ if (quote != 0) {
+ if (c == '\\') {
+ if (i + 1 >= expression.length()) {
+ throw invalid(description, expression);
+ }
+ i++;
+ continue;
+ }
+ if (c == quote) {
+ if (i + 1 < expression.length() && expression.charAt(i + 1) == quote) {
+ i++;
+ } else {
+ quote = 0;
+ }
+ }
+ continue;
+ }
+
+ if (c == '\'' || c == '`' || c == '"') {
+ quote = c;
+ continue;
+ }
+ if (c == ';' || c == '#' || c == '\n' || c == '\r'
+ || startsWith(expression, i, "--")
+ || startsWith(expression, i, "/*")
+ || startsWith(expression, i, "*/")) {
+ throw invalid(description, expression);
+ }
+ if (c == '(' || c == '[' || c == '{') {
+ delimiters.push(c);
+ continue;
+ }
+ if (c == ')' || c == ']' || c == '}') {
+ if (delimiters.isEmpty() || !matches(delimiters.pop(), c)) {
+ throw invalid(description, expression);
+ }
+ if (stopAtRootClose && delimiters.isEmpty()) {
+ return i;
+ }
+ continue;
+ }
+ if (rejectTopLevelComma && c == ',' && delimiters.isEmpty()) {
+ throw invalid(description, expression);
+ }
+ if (typeCharactersOnly && !isTypeCharacter(c)) {
+ throw invalid(description, expression);
+ }
+ if (Character.isISOControl(c)) {
+ throw invalid(description, expression);
+ }
+ }
+ if (quote != 0 || !delimiters.isEmpty() || stopAtRootClose) {
+ throw invalid(description, expression);
+ }
+ return expression.length();
+ }
+
+ private static boolean isTypeCharacter(char c) {
+ return Character.isLetterOrDigit(c) || Character.isWhitespace(c)
+ || c == '_' || c == ',' || c == '=' || c == '+' || c == '-'
+ || c == '.';
+ }
+
+ private static boolean startsWith(String value, int offset, String candidate) {
+ return offset + candidate.length() <= value.length()
+ && value.startsWith(candidate, offset);
+ }
+
+ private static boolean matches(char open, char close) {
+ return open == '(' && close == ')' || open == '[' && close == ']' || open == '{' && close == '}';
+ }
+
+ private static IllegalArgumentException invalid(String description, String value) {
+ return new IllegalArgumentException("Invalid ClickHouse " + description + ": " + value);
}
}
diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/builder/ClickHouseSqlBuilder.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/builder/ClickHouseSqlBuilder.java
index 5226f26fd3..a59dcb2d78 100644
--- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/builder/ClickHouseSqlBuilder.java
+++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/builder/ClickHouseSqlBuilder.java
@@ -9,16 +9,55 @@
import ai.chat2db.spi.DefaultSqlBuilder;
import ai.chat2db.spi.model.request.PageLimitRequest;
import ai.chat2db.community.domain.api.model.metadata.Database;
+import ai.chat2db.community.domain.api.model.metadata.Schema;
import ai.chat2db.community.domain.api.model.metadata.Table;
import ai.chat2db.community.domain.api.model.metadata.TableColumn;
import ai.chat2db.community.domain.api.model.metadata.TableIndex;
import ai.chat2db.community.domain.api.config.TableBuilderConfig;
+import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
import static ai.chat2db.plugin.clickhouse.constant.ClickHouseSqlBuilderConstants.*;
public class ClickHouseSqlBuilder extends DefaultSqlBuilder {
+ @Override
+ public String quoteIdentifier(String identifier) {
+ return ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(identifier);
+ }
+
+ @Override
+ public String quoteQualifiedIdentifier(String... identifiers) {
+ if (identifiers.length == 3) {
+ String qualifier = StringUtils.isNotBlank(identifiers[1]) ? identifiers[1] : identifiers[0];
+ return quoteQualifiedIdentifier(qualifier, identifiers[2]);
+ }
+ return Arrays.stream(identifiers)
+ .filter(StringUtils::isNotBlank)
+ .map(ClickHouseIdentifierProcessor.INSTANCE::quoteIdentifierAlways)
+ .collect(Collectors.joining(SQLConstants.DOT));
+ }
+
+ @Override
+ protected void buildTableName(String databaseName, String schemaName, String tableName, StringBuilder script) {
+ script.append(quoteQualifiedIdentifier(databaseName, schemaName, tableName));
+ }
+
+ @Override
+ protected void buildColumns(List columnList, StringBuilder script) {
+ if (CollectionUtils.isNotEmpty(columnList)) {
+ script.append(SQLConstants.SPACE_OPEN_PARENTHESIS)
+ .append(columnList.stream()
+ .map(ClickHouseIdentifierProcessor.INSTANCE::quoteIdentifierAlways)
+ .collect(Collectors.joining(SQLConstants.COMMA)))
+ .append(SQLConstants.CLOSE_PARENTHESIS_SPACE);
+ }
+ }
@@ -28,27 +67,27 @@ public class ClickHouseSqlBuilder extends DefaultSqlBuilder {
public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfig) {
StringBuilder script = new StringBuilder();
script.append(SQL_CREATE_TABLE);
- if (StringUtils.isNotBlank(table.getDatabaseName())) {
- script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(table.getDatabaseName())).append(SQLConstants.DOT);
- }
- script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(table.getName())).append(SQLConstants.SPACE_OPEN_PARENTHESIS).append(SQLConstants.LINE_SEPARATOR);
+ script.append(quoteQualifiedIdentifier(table.getDatabaseName(), table.getSchemaName(), table.getName()))
+ .append(SQLConstants.SPACE_OPEN_PARENTHESIS).append(SQLConstants.LINE_SEPARATOR);
+ List definitions = new ArrayList<>();
for (TableColumn column : table.getColumnList()) {
if (StringUtils.isBlank(column.getName()) || StringUtils.isBlank(column.getColumnType())) {
continue;
}
- script.append(SQLConstants.TAB).append(ClickHouseColumnTypeEnum.buildCreateColumnSqlSafely(column)).append(SQLConstants.COMMA_LINE_SEPARATOR);
+ definitions.add(ClickHouseColumnTypeEnum.buildCreateColumnSqlSafely(column).stripTrailing());
}
for (TableIndex tableIndex : table.getIndexList()) {
if (StringUtils.isBlank(tableIndex.getName()) || StringUtils.isBlank(tableIndex.getType())) {
continue;
}
- ClickHouseIndexTypeEnum mysqlIndexTypeEnum = ClickHouseIndexTypeEnum.getByType(tableIndex.getType());
- if (!ClickHouseIndexTypeEnum.PRIMARY.equals(mysqlIndexTypeEnum) ) {
- script.append(SQLConstants.TAB).append(SQLConstants.EMPTY).append(mysqlIndexTypeEnum.buildIndexScript(tableIndex)).append(SQLConstants.COMMA_LINE_SEPARATOR);
+ ClickHouseIndexTypeEnum indexType = ClickHouseIndexTypeEnum.getByType(tableIndex.getType());
+ if (indexType != null && !ClickHouseIndexTypeEnum.PRIMARY.equals(indexType)) {
+ definitions.add(indexType.buildIndexScript(tableIndex).stripTrailing());
}
}
-
- script = new StringBuilder(script.substring(0, script.length() - 2));
+ script.append(definitions.stream()
+ .map(definition -> SQLConstants.TAB + definition)
+ .collect(Collectors.joining(SQLConstants.COMMA_LINE_SEPARATOR)));
script.append(SQLConstants.LINE_SEPARATOR_CLOSE_PARENTHESIS);
@@ -59,9 +98,10 @@ public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfi
if (StringUtils.isBlank(tableIndex.getName()) || StringUtils.isBlank(tableIndex.getType())) {
continue;
}
- ClickHouseIndexTypeEnum mysqlIndexTypeEnum = ClickHouseIndexTypeEnum.getByType(tableIndex.getType());
- if (ClickHouseIndexTypeEnum.PRIMARY.equals(mysqlIndexTypeEnum) ) {
- script.append(SQLConstants.TAB).append(SQLConstants.EMPTY).append(mysqlIndexTypeEnum.buildIndexScript(tableIndex)).append(SQLConstants.LINE_SEPARATOR);
+ ClickHouseIndexTypeEnum indexType = ClickHouseIndexTypeEnum.getByType(tableIndex.getType());
+ if (ClickHouseIndexTypeEnum.PRIMARY.equals(indexType)) {
+ script.append(SQLConstants.TAB).append(SQLConstants.EMPTY)
+ .append(indexType.buildIndexScript(tableIndex)).append(SQLConstants.LINE_SEPARATOR);
}
}
@@ -76,42 +116,47 @@ public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfi
@Override
public String buildAlterTable(Table oldTable, Table newTable) {
- StringBuilder script = new StringBuilder();
- script.append(SQL_ALTER_TABLE);
- if (StringUtils.isNotBlank(oldTable.getDatabaseName())) {
- script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(oldTable.getDatabaseName())).append(SQLConstants.DOT);
- }
- script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(oldTable.getName())).append(SQLConstants.LINE_SEPARATOR);
+ List actions = new ArrayList<>();
- if (!StringUtils.equalsIgnoreCase(oldTable.getComment(), newTable.getComment())) {
- script.append(SQLConstants.TAB).append(SQL_MODIFY_COMMENT).append(SQLConstants.SINGLE_QUOTE).append(ClickHouseIdentifierProcessor.INSTANCE.escapeString(newTable.getComment())).append(SQLConstants.SINGLE_QUOTE).append(SQLConstants.COMMA_LINE_SEPARATOR);
+ if (!StringUtils.equals(oldTable.getComment(), newTable.getComment())) {
+ actions.add(SQL_MODIFY_COMMENT + SQLConstants.SPACE + SQLConstants.SINGLE_QUOTE
+ + ClickHouseIdentifierProcessor.INSTANCE.escapeString(StringUtils.defaultString(newTable.getComment()))
+ + SQLConstants.SINGLE_QUOTE);
}
- for (TableColumn tableColumn : newTable.getColumnList()) {
- if (StringUtils.isNotBlank(tableColumn.getEditStatus()) && StringUtils.isNotBlank(tableColumn.getColumnType()) && StringUtils.isNotBlank(tableColumn.getName())) {
- ClickHouseColumnTypeEnum typeEnum = ClickHouseColumnTypeEnum.getByType(tableColumn.getColumnType());
- if(typeEnum == null){
- continue;
+ if (newTable.getColumnList() != null) {
+ for (TableColumn tableColumn : newTable.getColumnList()) {
+ if (StringUtils.isNotBlank(tableColumn.getEditStatus())
+ && StringUtils.isNotBlank(tableColumn.getColumnType())
+ && StringUtils.isNotBlank(tableColumn.getName())) {
+ String action = ClickHouseColumnTypeEnum.buildModifyColumnSqlSafely(tableColumn);
+ if (StringUtils.isNotBlank(action)) {
+ actions.add(action);
+ }
}
- script.append(SQLConstants.TAB).append(typeEnum.buildModifyColumn(tableColumn)).append(SQLConstants.COMMA_LINE_SEPARATOR);
}
}
- for (TableIndex tableIndex : newTable.getIndexList()) {
- if (StringUtils.isNotBlank(tableIndex.getEditStatus()) && StringUtils.isNotBlank(tableIndex.getType())) {
- ClickHouseIndexTypeEnum clickHouseIndexTypeEnum = ClickHouseIndexTypeEnum
- .getByType(tableIndex.getType());
- if(clickHouseIndexTypeEnum == null){
- continue;
+ if (newTable.getIndexList() != null) {
+ for (TableIndex tableIndex : newTable.getIndexList()) {
+ if (StringUtils.isNotBlank(tableIndex.getEditStatus()) && StringUtils.isNotBlank(tableIndex.getType())) {
+ ClickHouseIndexTypeEnum indexType = ClickHouseIndexTypeEnum.getByType(tableIndex.getType());
+ if (indexType != null) {
+ String action = indexType.buildModifyIndex(tableIndex);
+ if (StringUtils.isNotBlank(action)) {
+ actions.add(action);
+ }
+ }
}
- script.append(SQLConstants.TAB).append(clickHouseIndexTypeEnum.buildModifyIndex(tableIndex)).append(SQLConstants.COMMA_LINE_SEPARATOR);
}
}
- if (script.length() > 2) {
- script = new StringBuilder(script.substring(0, script.length() - 2));
- script.append(SQLConstants.SEMICOLON);
+ if (actions.isEmpty()) {
+ return "";
}
-
- return script.toString();
+ return SQL_ALTER_TABLE
+ + quoteQualifiedIdentifier(oldTable.getDatabaseName(), oldTable.getSchemaName(), oldTable.getName())
+ + SQLConstants.LINE_SEPARATOR + SQLConstants.TAB
+ + String.join(SQLConstants.COMMA_LINE_SEPARATOR + SQLConstants.TAB, actions)
+ + SQLConstants.SEMICOLON;
}
@@ -146,4 +191,14 @@ public String buildCreateDatabase(Database database) {
return sqlBuilder.toString();
}
+ @Override
+ public String buildCreateSchema(Schema schema) {
+ return SQL_CREATE_DATABASE + quoteIdentifier(schema.getName());
+ }
+
+ @Override
+ public String buildDropSchema(String schemaName) {
+ return "DROP DATABASE " + quoteIdentifier(schemaName);
+ }
+
}
diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/enums/type/ClickHouseColumnTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/enums/type/ClickHouseColumnTypeEnum.java
index 60c75e5934..d92b439805 100644
--- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/enums/type/ClickHouseColumnTypeEnum.java
+++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/enums/type/ClickHouseColumnTypeEnum.java
@@ -11,6 +11,7 @@
import java.util.Arrays;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import static ai.chat2db.plugin.clickhouse.constant.ClickHouseColumnTypeEnumConstants.*;
@@ -85,7 +86,7 @@ public static ClickHouseColumnTypeEnum getByType(String dataType) {
// and checked instead of silently dropped.
return null;
}
- return COLUMN_TYPE_MAP.get(normalized.toUpperCase());
+ return COLUMN_TYPE_MAP.get(normalized.toUpperCase(Locale.ROOT));
}
/**
@@ -100,6 +101,14 @@ public static String buildCreateColumnSqlSafely(TableColumn column) {
return type.buildCreateColumnSql(column);
}
+ /**
+ * Builds an ALTER action while preserving parameterized and newer
+ * ClickHouse types that are not represented by this enum.
+ */
+ public static String buildModifyColumnSqlSafely(TableColumn column) {
+ return buildModifyColumnSql(column, buildCreateColumnSqlSafely(column).stripTrailing());
+ }
+
private static String buildValidatedFallbackColumn(TableColumn column) {
StringBuilder script = new StringBuilder();
script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName())).append(" ");
@@ -119,7 +128,7 @@ private static String buildValidatedFallbackColumn(TableColumn column) {
}
private static boolean isNullableWrappable(String columnType) {
- String upper = columnType.toUpperCase();
+ String upper = columnType.toUpperCase(Locale.ROOT);
return !upper.startsWith("ARRAY(") && !upper.startsWith("MAP(") && !upper.startsWith("TUPLE(")
&& !upper.startsWith("NESTED(") && !upper.startsWith("NULLABLE(")
&& !upper.startsWith("AGGREGATEFUNCTION(") && !upper.startsWith("SIMPLEAGGREGATEFUNCTION(");
@@ -135,6 +144,22 @@ private static String buildFallbackDefaultValue(TableColumn column) {
if ("NULL".equalsIgnoreCase(column.getDefaultValue().trim())) {
return "DEFAULT NULL";
}
+ String typeName = column.getColumnType().trim();
+ int arguments = typeName.indexOf('(');
+ if (arguments >= 0) {
+ typeName = typeName.substring(0, arguments);
+ }
+ if ("ENUM8".equalsIgnoreCase(typeName) || "ENUM16".equalsIgnoreCase(typeName)
+ || "DATE".equalsIgnoreCase(typeName) || "DATE32".equalsIgnoreCase(typeName)) {
+ return buildTextDefault(column.getDefaultValue());
+ }
+ if ("DATETIME".equalsIgnoreCase(typeName) || "DATETIME64".equalsIgnoreCase(typeName)) {
+ String trimmed = column.getDefaultValue().trim();
+ if ("CURRENT_TIMESTAMP".equalsIgnoreCase(trimmed) || trimmed.indexOf('(') >= 0) {
+ return "DEFAULT " + ClickHouseSqlGuards.escapeDefaultExpression(trimmed);
+ }
+ return buildTextDefault(column.getDefaultValue());
+ }
return "DEFAULT " + ClickHouseSqlGuards.escapeDefaultExpression(column.getDefaultValue());
}
@@ -169,20 +194,25 @@ public String buildCreateColumnSql(TableColumn column) {
@Override
public String buildModifyColumn(TableColumn tableColumn) {
+ return buildModifyColumnSql(tableColumn, buildCreateColumnSql(tableColumn).stripTrailing());
+ }
- if (EditStatusEnum.DELETE.name().equals(tableColumn.getEditStatus())) {
- return StringUtils.join("DROP COLUMN ", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableColumn.getName()));
+ private static String buildModifyColumnSql(TableColumn column, String definition) {
+ if (EditStatusEnum.DELETE.name().equals(column.getEditStatus())) {
+ return "DROP COLUMN " + ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName());
}
- if (EditStatusEnum.ADD.name().equals(tableColumn.getEditStatus())) {
- return StringUtils.join("ADD COLUMN ", buildCreateColumnSql(tableColumn));
+ if (EditStatusEnum.ADD.name().equals(column.getEditStatus())) {
+ return "ADD COLUMN " + definition;
}
- if (EditStatusEnum.MODIFY.name().equals(tableColumn.getEditStatus())) {
- String modifyColumn = "";
- if (!StringUtils.equalsIgnoreCase(tableColumn.getOldName(), tableColumn.getName())) {
- modifyColumn = StringUtils.join("RENAME COLUMN ", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableColumn.getOldName()), " TO ", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableColumn.getName()),
- ", ", buildCreateColumnSql(tableColumn));
+ if (EditStatusEnum.MODIFY.name().equals(column.getEditStatus())) {
+ String modify = "MODIFY COLUMN " + definition;
+ if (!StringUtils.equals(column.getOldName(), column.getName())) {
+ return "RENAME COLUMN "
+ + ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getOldName())
+ + " TO " + ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName())
+ + ",\n\t" + modify;
}
- return StringUtils.join(modifyColumn, "MODIFY COLUMN ", buildCreateColumnSql(tableColumn));
+ return modify;
}
return "";
}
@@ -207,24 +237,33 @@ private String buildDefaultValue(TableColumn column, ClickHouseColumnTypeEnum ty
return StringUtils.join("DEFAULT NULL");
}
- if (Arrays.asList(Enum8,Enum16).contains(type)) {
- return StringUtils.join("DEFAULT '", ClickHouseIdentifierProcessor.INSTANCE.escapeString(column.getDefaultValue()), "'");
+ if (Arrays.asList(Enum8, Enum16).contains(type)) {
+ return buildTextDefault(column.getDefaultValue());
}
- if (Arrays.asList(Date).contains(type)) {
- return StringUtils.join("DEFAULT '", ClickHouseIdentifierProcessor.INSTANCE.escapeString(column.getDefaultValue()), "'");
+ if (Arrays.asList(Date, DATE32).contains(type)) {
+ return buildTextDefault(column.getDefaultValue());
}
- if (Arrays.asList(DateTime).contains(type)) {
- if ("CURRENT_TIMESTAMP".equalsIgnoreCase(column.getDefaultValue().trim())) {
- return StringUtils.join("DEFAULT ", column.getDefaultValue());
+ if (Arrays.asList(DateTime, DateTime64).contains(type)) {
+ String trimmed = column.getDefaultValue().trim();
+ if ("CURRENT_TIMESTAMP".equalsIgnoreCase(trimmed) || trimmed.indexOf('(') >= 0) {
+ return "DEFAULT " + ClickHouseSqlGuards.escapeDefaultExpression(trimmed);
}
- return StringUtils.join("DEFAULT '", ClickHouseIdentifierProcessor.INSTANCE.escapeString(column.getDefaultValue()), "'");
+ return buildTextDefault(column.getDefaultValue());
}
return StringUtils.join("DEFAULT ", ClickHouseSqlGuards.escapeDefaultExpression(column.getDefaultValue()));
}
+ private static String buildTextDefault(String value) {
+ String trimmed = value.trim();
+ if (trimmed.startsWith("'") || trimmed.endsWith("'")) {
+ return "DEFAULT " + ClickHouseSqlGuards.escapeDefaultExpression(trimmed);
+ }
+ return "DEFAULT '" + ClickHouseIdentifierProcessor.INSTANCE.escapeString(value) + "'";
+ }
+
private String buildNullableAndDataType(TableColumn column, ClickHouseColumnTypeEnum type) {
StringBuilder script = new StringBuilder();
script.append(buildDataType(column, type));
diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/identifier/ClickHouseIdentifierProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/identifier/ClickHouseIdentifierProcessor.java
index 345c14cf46..534886e097 100644
--- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/identifier/ClickHouseIdentifierProcessor.java
+++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/identifier/ClickHouseIdentifierProcessor.java
@@ -4,8 +4,8 @@
import org.apache.commons.lang3.StringUtils;
import java.util.HashSet;
+import java.util.Locale;
import java.util.Set;
-import java.util.regex.Pattern;
/**
* ClickHouse dialect identifier processor.
@@ -24,8 +24,6 @@ public class ClickHouseIdentifierProcessor extends DefaultSQLIdentifierProcessor
public static final ClickHouseIdentifierProcessor INSTANCE = new ClickHouseIdentifierProcessor();
- private static final Pattern CLICKHOUSE_PATTERN = Pattern.compile("[`\"](.*?)[`\"]");
-
private static final Set CLICKHOUSE_RESERVED_KEYWORDS = new HashSet<>();
static {
@@ -159,15 +157,14 @@ public class ClickHouseIdentifierProcessor extends DefaultSQLIdentifierProcessor
@Override
public boolean isReservedKeyword(String identifier, Integer majorVersion, Integer minorVersion) {
- return identifier != null && CLICKHOUSE_RESERVED_KEYWORDS.contains(identifier.toUpperCase());
+ return identifier != null && CLICKHOUSE_RESERVED_KEYWORDS.contains(identifier.toUpperCase(Locale.ROOT));
}
/**
* SPI-facing conditional quoting: {@code null} passes through, blank is
* returned unchanged, valid plain identifiers that are not reserved
* keywords are returned unquoted, and everything else is wrapped in
- * backticks with one surrounding pair stripped and embedded backticks
- * doubled.
+ * backticks with every raw backtick doubled.
*/
@Override
public String quoteIdentifier(String identifier) {
@@ -190,8 +187,8 @@ public String quoteIdentifier(String identifier, Integer majorVersion, Integer m
/**
* Unconditional quoting for DDL-generation call sites: {@code null} passes
- * through, every other value is wrapped in backticks with one surrounding
- * backtick pair stripped and every embedded backtick doubled.
+ * through, every other raw value is wrapped in backticks with every
+ * backtick doubled.
*/
public String quoteIdentifierAlways(String identifier) {
if (identifier == null) {
@@ -201,11 +198,11 @@ public String quoteIdentifierAlways(String identifier) {
}
/**
- * Always-quote variant that preserves the original identifier case.
+ * Conditional quote variant that preserves the original identifier case.
*/
@Override
public String quoteIdentifierIgnoreCase(String identifier) {
- return quoteIdentifierAlways(identifier);
+ return quoteIdentifier(identifier);
}
@Override
@@ -213,7 +210,59 @@ public String removeIdentifierQuote(String identifier) {
if (StringUtils.isBlank(identifier)) {
return identifier;
}
- return removePattern(identifier, CLICKHOUSE_PATTERN);
+ StringBuilder unquoted = new StringBuilder(identifier.length());
+ boolean removedQuote = false;
+ int offset = 0;
+ while (offset < identifier.length()) {
+ char first = identifier.charAt(offset);
+ if (first == '`' || first == '"') {
+ char delimiter = first;
+ StringBuilder part = new StringBuilder();
+ boolean closed = false;
+ offset++;
+ while (offset < identifier.length()) {
+ char current = identifier.charAt(offset);
+ if (current == delimiter) {
+ if (offset + 1 < identifier.length()
+ && identifier.charAt(offset + 1) == delimiter) {
+ part.append(delimiter);
+ offset += 2;
+ continue;
+ }
+ offset++;
+ closed = true;
+ break;
+ }
+ part.append(current);
+ offset++;
+ }
+ if (!closed || (offset < identifier.length() && identifier.charAt(offset) != '.')) {
+ return identifier;
+ }
+ unquoted.append(part);
+ removedQuote = true;
+ } else {
+ int partEnd = identifier.indexOf('.', offset);
+ if (partEnd < 0) {
+ partEnd = identifier.length();
+ }
+ String part = identifier.substring(offset, partEnd);
+ if (part.indexOf('`') >= 0 || part.indexOf('"') >= 0) {
+ return identifier;
+ }
+ unquoted.append(part);
+ offset = partEnd;
+ }
+
+ if (offset < identifier.length()) {
+ unquoted.append('.');
+ offset++;
+ if (offset == identifier.length()) {
+ return identifier;
+ }
+ }
+ }
+ return removedQuote ? unquoted.toString() : identifier;
}
@Override
@@ -234,24 +283,19 @@ public boolean isQuoteIdentifier(String identifier) {
*/
@Override
public String escapeString(String str) {
- return str == null ? "" : StringUtils.replace(StringUtils.replace(str, "\\", "\\\\"), "'", "''");
+ return str == null ? null : StringUtils.replace(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, "`", "``");
+ return StringUtils.replace(identifier, "`", "``");
}
/**
* Escapes identifier content for a position already surrounded by
- * backticks: strips one surrounding backtick pair, then doubles every
- * embedded backtick.
+ * backticks: doubles every embedded backtick.
*/
public static String escapeIdentifier(String identifier) {
return escapeIdentifierContent(identifier);
diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/test/java/ai/chat2db/plugin/clickhouse/ClickHouseIdentifierProcessorTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/test/java/ai/chat2db/plugin/clickhouse/ClickHouseIdentifierProcessorTest.java
index 4f2ef28c97..87539cf970 100644
--- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/test/java/ai/chat2db/plugin/clickhouse/ClickHouseIdentifierProcessorTest.java
+++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/test/java/ai/chat2db/plugin/clickhouse/ClickHouseIdentifierProcessorTest.java
@@ -1,17 +1,24 @@
package ai.chat2db.plugin.clickhouse;
+import ai.chat2db.community.domain.api.enums.plugin.EditStatusEnum;
import ai.chat2db.community.domain.api.model.metadata.Database;
+import ai.chat2db.community.domain.api.model.metadata.Schema;
import ai.chat2db.community.domain.api.model.metadata.Table;
import ai.chat2db.community.domain.api.model.metadata.TableColumn;
import ai.chat2db.plugin.clickhouse.builder.ClickHouseSqlBuilder;
import ai.chat2db.plugin.clickhouse.enums.type.ClickHouseColumnTypeEnum;
import ai.chat2db.plugin.clickhouse.identifier.ClickHouseIdentifierProcessor;
+import ai.chat2db.spi.model.request.DropTableRequest;
+import ai.chat2db.spi.model.request.TruncateTableRequest;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -20,7 +27,7 @@ class ClickHouseIdentifierProcessorTest {
@Test
void shouldDoubleSingleQuotesInLiterals() {
assertEquals("owner''s", ClickHouseIdentifierProcessor.INSTANCE.escapeString("owner's"));
- assertEquals("", ClickHouseIdentifierProcessor.INSTANCE.escapeString(null));
+ assertNull(ClickHouseIdentifierProcessor.INSTANCE.escapeString(null));
}
@Test
@@ -56,19 +63,27 @@ void shouldDoubleBackticksInIdentifiers() {
}
@Test
- void shouldAlwaysQuoteInIgnoreCaseAndAlwaysVariants() {
- assertEquals("`plain`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("plain"));
+ void shouldKeepConditionalSemanticsInIgnoreCaseVariant() {
+ assertEquals("plain", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("plain"));
assertEquals("`a``b`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("a`b"));
+ assertEquals("`select`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("select"));
+ assertEquals("", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase(""));
assertEquals(null, ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase(null));
+ }
+
+ @Test
+ void shouldAlwaysQuoteWithoutDiscardingRawBoundaryBackticks() {
assertEquals("`plain`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways("plain"));
assertEquals("`a``b`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways("a`b"));
+ assertEquals("```quoted```", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways("`quoted`"));
+ assertEquals("```a``b```", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways("`a`b`"));
assertEquals(null, ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(null));
}
@Test
- void shouldStripSurroundingBackticksBeforeDoubling() {
- assertEquals("`a``b`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier("`a`b`"));
- assertEquals("`a``b`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways("`a`b`"));
+ void shouldTreatQuotedLookingInputAsRawIdentifierContent() {
+ assertEquals("```a``b```", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier("`a`b`"));
+ assertEquals("```a``b```", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways("`a`b`"));
}
@Test
@@ -81,15 +96,34 @@ void shouldRecognizeAndRemoveBacktickQuotes() {
assertEquals("name", ClickHouseIdentifierProcessor.INSTANCE.removeIdentifierQuote("\"name\""));
assertEquals("name", ClickHouseIdentifierProcessor.INSTANCE.removeIdentifierQuote("name"));
assertEquals(null, ClickHouseIdentifierProcessor.INSTANCE.removeIdentifierQuote(null));
+ assertEquals("a`b", ClickHouseIdentifierProcessor.INSTANCE.removeIdentifierQuote("`a``b`"));
+ assertEquals("db.ta`ble", ClickHouseIdentifierProcessor.INSTANCE.removeIdentifierQuote("`db`.`ta``ble`"));
+ assertEquals("prefix`a`suffix",
+ ClickHouseIdentifierProcessor.INSTANCE.removeIdentifierQuote("prefix`a`suffix"));
+ assertEquals("`mixed\"",
+ ClickHouseIdentifierProcessor.INSTANCE.removeIdentifierQuote("`mixed\""));
}
@Test
void shouldRoundTripThroughAlwaysQuoteAndRemove() {
- assertEquals("plain", ClickHouseIdentifierProcessor.INSTANCE
- .removeIdentifierQuote(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways("plain")));
+ for (String raw : List.of("plain", "a`b", "`quoted`", "`a`b`", "db.table")) {
+ assertEquals(raw, ClickHouseIdentifierProcessor.INSTANCE
+ .removeIdentifierQuote(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways(raw)));
+ }
assertEquals("db.table", ClickHouseIdentifierProcessor.INSTANCE.removeIdentifierQuote("`db`.`table`"));
}
+ @Test
+ void shouldCheckReservedKeywordsWithLocaleIndependentCaseFolding() {
+ Locale original = Locale.getDefault();
+ try {
+ Locale.setDefault(Locale.forLanguageTag("tr-TR"));
+ assertEquals("`in`", ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier("in"));
+ } finally {
+ Locale.setDefault(original);
+ }
+ }
+
@Test
void shouldNeutralizeMaliciousTableNameInCreateTable() {
ClickHouseSqlBuilder builder = new ClickHouseSqlBuilder();
@@ -203,15 +237,27 @@ void shouldAcceptQuotedStringDefaults() {
}
@Test
- void shouldEscapeQuotedStringDefaultContent() {
+ void shouldRejectMalformedQuotedDefaultExpression() {
TableColumn column = new TableColumn();
column.setName("c");
column.setColumnType("String");
column.setDefaultValue("'a');DROP TABLE t;--'");
+ assertThrows(IllegalArgumentException.class,
+ () -> ClickHouseColumnTypeEnum.String.buildCreateColumnSql(column));
+ }
+
+ @Test
+ void shouldPreserveSerializedStringDefaultExactly() {
+ TableColumn column = new TableColumn();
+ column.setName("c");
+ column.setColumnType("String");
+ column.setDefaultValue("'O''Brien'");
+
String sql = ClickHouseColumnTypeEnum.String.buildCreateColumnSql(column);
- assertTrue(sql.contains("DEFAULT 'a'');DROP TABLE t;--'"), sql);
+ assertTrue(sql.contains("DEFAULT 'O''Brien'"), sql);
+ assertEquals("'a\\\\b'", ClickHouseSqlGuards.escapeDefaultExpression("'a\\\\b'"));
}
@Test
@@ -333,15 +379,13 @@ void shouldNotWrapNonNullableCapableTypesInFallback() {
}
@Test
- void shouldRejectNegativeEnumValues() {
- // Deliberate fail-closed trade-off: dashes are always rejected so that
- // comment injection ("--") is impossible, at the cost of rejecting
- // legal ClickHouse enum forms like Enum8('a' = -1).
+ void shouldAcceptNegativeEnumValues() {
TableColumn column = new TableColumn();
column.setName("e");
column.setColumnType("Enum8('a' = -1)");
- assertThrows(IllegalArgumentException.class,
- () -> ClickHouseColumnTypeEnum.buildCreateColumnSqlSafely(column));
+
+ assertTrue(ClickHouseColumnTypeEnum.buildCreateColumnSqlSafely(column)
+ .startsWith("`e` Enum8('a' = -1)"));
}
@Test
@@ -355,4 +399,109 @@ void shouldNotWrapAggregateFunctionInFallback() {
assertTrue(sql.startsWith("`agg` AggregateFunction(uniq, String)"), sql);
}
+
+ @Test
+ void shouldAcceptLegalQuotedAndNestedTypeSyntax() {
+ for (String type : List.of(
+ "Enum16('min' = -32768, 'max' = 32767)",
+ "DateTime64(3, 'Asia/Shanghai')",
+ "Tuple(`a-b` String, inner UInt8)",
+ "Enum8(')' = 1)",
+ "Map(String, Array(Tuple(x UInt8, y DateTime64(3, 'UTC'))))")) {
+ assertEquals(type, ClickHouseSqlGuards.requireColumnTypeExpression(type));
+ }
+ }
+
+ @Test
+ void shouldAcceptNestedEngineAndDefaultExpressions() {
+ assertEquals("S3('https://bucket/path(test).csv', 'CSV')",
+ ClickHouseSqlGuards.requireEngine("S3('https://bucket/path(test).csv', 'CSV')"));
+ assertEquals("Distributed(cluster, db, table, cityHash64(id))",
+ ClickHouseSqlGuards.requireEngine("Distributed(cluster, db, table, cityHash64(id))"));
+ assertEquals("toDateTime64(now(), 3)",
+ ClickHouseSqlGuards.escapeDefaultExpression("toDateTime64(now(), 3)"));
+ assertEquals("if(length(')') > 0, 1, 0)",
+ ClickHouseSqlGuards.escapeDefaultExpression("if(length(')') > 0, 1, 0)"));
+ }
+
+ @Test
+ void shouldEscapeInheritedBuilderIdentifierPaths() {
+ ClickHouseSqlBuilder builder = new ClickHouseSqlBuilder();
+ String schema = "analytics`x";
+ String table = "orders`x";
+
+ assertEquals("SELECT COUNT(1) FROM `analytics``x`.`orders``x`",
+ builder.buildSelectCount(null, schema, table));
+ assertEquals("SELECT COUNT(1) FROM `analytics``x`.`orders``x`",
+ builder.buildSelectCount("ignored_catalog", schema, table));
+ assertEquals("SELECT * FROM `analytics``x`.`orders``x`",
+ builder.buildSelectTable(null, schema, table));
+ assertEquals("DROP TABLE `analytics``x`.`orders``x`",
+ builder.buildDropTable(new DropTableRequest(null, schema, table)));
+ assertEquals("TRUNCATE TABLE `analytics``x`.`orders``x`",
+ builder.buildTruncateTable(new TruncateTableRequest(null, schema, table)));
+
+ Schema schemaModel = new Schema();
+ schemaModel.setName(schema);
+ assertEquals("CREATE DATABASE `analytics``x`", builder.buildCreateSchema(schemaModel));
+ assertEquals("DROP DATABASE `analytics``x`", builder.buildDropSchema(schema));
+ }
+
+ @Test
+ void shouldBuildSchemaQualifiedManagerStatementsOnce() throws Exception {
+ ClickHouseDBManager manager = new ClickHouseDBManager();
+ String source = ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways("ord`ers");
+ String target = ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifierAlways("ord`ers_copy");
+
+ assertEquals("DROP TABLE IF EXISTS `analytics`.`ord``ers`",
+ manager.dropTable(null, null, "analytics", "ord`ers"));
+ assertEquals("DROP TABLE IF EXISTS ```analytics```.```orders```",
+ manager.dropTable(null, null, "`analytics`", "`orders`"));
+ assertEquals("TRUNCATE TABLE `analytics`.`ord``ers`",
+ manager.truncateTable(null, null, "analytics", source));
+ assertEquals(List.of(
+ "CREATE TABLE `analytics`.`ord``ers_copy` AS `analytics`.`ord``ers`",
+ "INSERT INTO `analytics`.`ord``ers_copy` SELECT * FROM `analytics`.`ord``ers`"),
+ ClickHouseDBManager.buildCopyTableStatements(null, "analytics", source, target, true));
+ }
+
+ @Test
+ void shouldBuildAlterCommentWithValidSpacingAndEscaping() {
+ Table oldTable = new Table();
+ oldTable.setName("events");
+ oldTable.setComment("old");
+
+ Table newTable = new Table();
+ newTable.setName("events");
+ newTable.setComment("owner's");
+
+ assertEquals("ALTER TABLE `events`\n\tMODIFY COMMENT 'owner''s';",
+ new ClickHouseSqlBuilder().buildAlterTable(oldTable, newTable));
+ }
+
+ @Test
+ void shouldAlterParameterizedTypeAndRenameWithoutMalformedFragment() {
+ Table oldTable = new Table();
+ oldTable.setSchemaName("analytics");
+ oldTable.setName("events");
+ oldTable.setColumnList(List.of());
+ oldTable.setIndexList(List.of());
+
+ TableColumn column = new TableColumn();
+ column.setOldName("old");
+ column.setName("new");
+ column.setColumnType("Decimal(10,2)");
+ column.setEditStatus(EditStatusEnum.MODIFY.name());
+
+ Table newTable = new Table();
+ newTable.setSchemaName("analytics");
+ newTable.setName("events");
+ newTable.setColumnList(List.of(column));
+ newTable.setIndexList(List.of());
+
+ assertEquals("ALTER TABLE `analytics`.`events`\n"
+ + "\tRENAME COLUMN `old` TO `new`,\n"
+ + "\tMODIFY COLUMN `new` Decimal(10,2);",
+ new ClickHouseSqlBuilder().buildAlterTable(oldTable, newTable));
+ }
}