From 6e493563acc58062b46660e632da3dc0111466f8 Mon Sep 17 00:00:00 2001 From: HandSonic <8078023+handsonic@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:51:46 +0800 Subject: [PATCH 1/6] fix(clickhouse): escape SQL identifiers and literals in metadata/DDL paths (#1914) --- .../clickhouse/ClickHouseDBManager.java | 14 +- .../plugin/clickhouse/ClickHouseMetaData.java | 16 +- .../clickhouse/ClickHouseSqlEscapes.java | 73 ++++++ .../builder/ClickHouseSqlBuilder.java | 33 +-- .../ClickHouseSqlBuilderConstants.java | 2 +- .../enums/type/ClickHouseColumnTypeEnum.java | 76 ++++-- .../enums/type/ClickHouseIndexTypeEnum.java | 11 +- .../clickhouse/ClickHouseSqlEscapesTest.java | 230 ++++++++++++++++++ 8 files changed, 403 insertions(+), 52 deletions(-) create mode 100644 chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlEscapes.java create mode 100644 chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/test/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlEscapesTest.java 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 3294356262..c437990e24 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 @@ -37,7 +37,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(resultSet.getString("name")).append(";") + sqlBuilder.append(SQL_DROP_FUNCTION_EXISTS).append(ClickHouseSqlEscapes.quoteIdentifier(resultSet.getString("name"))).append(";") .append("\n") .append(resultSet.getString("create_query")).append(";").append("\n"); asyncContext.write(sqlBuilder.toString()); @@ -46,7 +46,7 @@ private void exportFunctions(Connection connection, AsyncContext asyncContext) t } private void exportTablesOrViewsOrDictionaries(Connection connection, String databaseName, String schemaName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(SQL_SELECT_CREATE_TABLE_QUERY_HAS, databaseName); + String sql = String.format(SQL_SELECT_CREATE_TABLE_QUERY_HAS, ClickHouseSqlEscapes.escapeSqlLiteral(databaseName)); try (PreparedStatement statement = connection.prepareStatement(sql); ResultSet resultSet = statement.executeQuery()) { while (resultSet.next()) { @@ -56,17 +56,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(databaseName).append(".").append(tableOrViewName) + sqlBuilder.append(SQL_DROP_VIEW_EXISTS).append(ClickHouseSqlEscapes.quoteIdentifier(databaseName)).append(".").append(ClickHouseSqlEscapes.quoteIdentifier(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(databaseName).append(".").append(tableOrViewName) + sqlBuilder.append(SQL_DROP_DICTIONARY_EXISTS).append(ClickHouseSqlEscapes.quoteIdentifier(databaseName)).append(".").append(ClickHouseSqlEscapes.quoteIdentifier(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(databaseName).append(".").append(tableOrViewName) + sqlBuilder.append(SQL_DROP_TABLE_EXISTS).append(ClickHouseSqlEscapes.quoteIdentifier(databaseName)).append(".").append(ClickHouseSqlEscapes.quoteIdentifier(tableOrViewName)) .append(";").append("\n").append(ddl).append(";").append("\n"); asyncContext.write(sqlBuilder.toString()); if (asyncContext.isContainsData() && dataFlag) { @@ -119,10 +119,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 " + newTableName + " AS " + tableName + ""; + String sql = "CREATE TABLE " + ClickHouseSqlEscapes.quoteIdentifier(newTableName) + " AS " + ClickHouseSqlEscapes.quoteIdentifier(tableName) + ""; DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> null); if (copyData) { - sql = "INSERT INTO " + newTableName + " SELECT * FROM " + tableName; + sql = "INSERT INTO " + ClickHouseSqlEscapes.quoteIdentifier(newTableName) + " SELECT * FROM " + ClickHouseSqlEscapes.quoteIdentifier(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 0d06159514..51b789e7b9 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 @@ -39,7 +39,7 @@ public class ClickHouseMetaData extends DefaultMetaService implements IDbMetaDat private static final Logger log = LoggerFactory.getLogger(ClickHouseMetaData.class); public static String format(String tableName) { - return "`" + tableName + "`"; + return ClickHouseSqlEscapes.quoteIdentifier(tableName); } @Override @@ -86,7 +86,7 @@ public List tables(Connection connection, String databaseName, String sch @Override public List
views(Connection connection, String databaseName, String schemaName) { - String sql = "select name from system.`tables` WHERE `database`='" + schemaName + "' and engine='View'"; + String sql = "select name from system.`tables` WHERE `database`='" + ClickHouseSqlEscapes.escapeSqlLiteral(schemaName) + "' and engine='View'"; return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { List
tables = new ArrayList<>(); try { @@ -139,7 +139,7 @@ public Function function(Connection connection, @NotEmpty String databaseName, S @Override public List triggers(Connection connection, String databaseName, String schemaName) { List triggers = new ArrayList<>(); - String sql = String.format(TRIGGER_SQL_LIST, schemaName); + String sql = String.format(TRIGGER_SQL_LIST, ClickHouseSqlEscapes.escapeSqlLiteral(schemaName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { while (resultSet.next()) { Trigger trigger = new Trigger(); @@ -156,7 +156,7 @@ public List triggers(Connection connection, String databaseName, String public Trigger trigger(Connection connection, @NotEmpty String databaseName, String schemaName, String triggerName) { - String sql = String.format(TRIGGER_SQL, schemaName, triggerName); + String sql = String.format(TRIGGER_SQL, ClickHouseSqlEscapes.escapeSqlLiteral(schemaName), ClickHouseSqlEscapes.escapeSqlLiteral(triggerName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Trigger trigger = new Trigger(); trigger.setDatabaseName(databaseName); @@ -172,7 +172,7 @@ public Trigger trigger(Connection connection, @NotEmpty String databaseName, Str @Override public Procedure procedure(Connection connection, @NotEmpty String databaseName, String schemaName, String procedureName) { - String sql = String.format(ROUTINES_SQL, "PROCEDURE", schemaName, procedureName); + String sql = String.format(ROUTINES_SQL, "PROCEDURE", ClickHouseSqlEscapes.escapeSqlLiteral(schemaName), ClickHouseSqlEscapes.escapeSqlLiteral(procedureName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Procedure procedure = new Procedure(); procedure.setDatabaseName(databaseName); @@ -189,7 +189,7 @@ public Procedure procedure(Connection connection, @NotEmpty String databaseName, @Override public List columns(Connection connection, String databaseName, String schemaName, String tableName) { - String sql = String.format(SELECT_TABLE_COLUMNS, tableName, schemaName); + String sql = String.format(SELECT_TABLE_COLUMNS, ClickHouseSqlEscapes.escapeSqlLiteral(tableName), ClickHouseSqlEscapes.escapeSqlLiteral(schemaName)); List tableColumns = new ArrayList<>(); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { @@ -245,7 +245,7 @@ private void setColumnSize(TableColumn column, String columnType) { @Override public Table view(Connection connection, String databaseName, String schemaName, String viewName) { - String sql = String.format(VIEW_SQL, schemaName, viewName); + String sql = String.format(VIEW_SQL, ClickHouseSqlEscapes.escapeSqlLiteral(schemaName), ClickHouseSqlEscapes.escapeSqlLiteral(viewName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Table table = new Table(); table.setDatabaseName(databaseName); @@ -298,7 +298,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(name -> "`" + name + "`").collect(Collectors.joining(".")); + return Arrays.stream(names).filter(name -> StringUtils.isNotBlank(name)).map(ClickHouseSqlEscapes::quoteIdentifier).collect(Collectors.joining(".")); } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlEscapes.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlEscapes.java new file mode 100644 index 0000000000..97cc64bcbf --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlEscapes.java @@ -0,0 +1,73 @@ +package ai.chat2db.plugin.clickhouse; + +import org.apache.commons.lang3.StringUtils; + +public final class ClickHouseSqlEscapes { + + private ClickHouseSqlEscapes() { + } + + /** + * Escapes a value interpolated into a single-quoted ClickHouse string literal. + * ClickHouse treats backslash as an escape character, so backslashes are + * doubled before single quotes are doubled. + */ + public static String escapeSqlLiteral(String value) { + if (value == null) { + return ""; + } + return StringUtils.replace(StringUtils.replace(value, "\\", "\\\\"), "'", "''"); + } + + /** + * Quotes an identifier with backticks, stripping any surrounding backticks + * and doubling every embedded backtick. + */ + public static String quoteIdentifier(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, "`", "``") + "`"; + } + + /** + * Validates a column type expression that is emitted verbatim into DDL + * (e.g. Int32, Decimal(10,2), Array(Nullable(String)), Enum8('a','b')). + * Only letters/digits/underscore are allowed at the top level; spaces, + * commas and single quotes are only allowed inside balanced parentheses. + * Semicolons, dashes, double quotes and backticks are always rejected. + */ + public static String requireColumnTypeExpression(String columnType) { + if (StringUtils.isBlank(columnType)) { + throw new IllegalArgumentException("Invalid ClickHouse 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 == '\'')); + if (!ok) { + throw new IllegalArgumentException("Invalid ClickHouse column type: " + columnType); + } + } + if (depth != 0 || !Character.isLetter(columnType.charAt(0))) { + throw new IllegalArgumentException("Invalid ClickHouse column type: " + columnType); + } + return columnType; + } +} 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 bd49e83c1a..a4638cc435 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 @@ -2,6 +2,7 @@ import ai.chat2db.spi.constant.SQLConstants; +import ai.chat2db.plugin.clickhouse.ClickHouseSqlEscapes; import ai.chat2db.plugin.clickhouse.enums.type.ClickHouseColumnTypeEnum; import ai.chat2db.plugin.clickhouse.enums.type.ClickHouseIndexTypeEnum; import ai.chat2db.spi.DefaultSqlBuilder; @@ -27,19 +28,14 @@ public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfi StringBuilder script = new StringBuilder(); script.append(SQL_CREATE_TABLE); if (StringUtils.isNotBlank(table.getDatabaseName())) { - script.append(SQLConstants.BACK_QUOTE).append(table.getDatabaseName()).append(SQLConstants.BACK_QUOTE).append(SQLConstants.DOT); + script.append(ClickHouseSqlEscapes.quoteIdentifier(table.getDatabaseName())).append(SQLConstants.DOT); } - script.append(SQLConstants.BACK_QUOTE).append(table.getName()).append(SQLConstants.BACK_QUOTE).append(SQLConstants.SPACE_OPEN_PARENTHESIS).append(SQLConstants.LINE_SEPARATOR); + script.append(ClickHouseSqlEscapes.quoteIdentifier(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; } - ClickHouseColumnTypeEnum typeEnum = ClickHouseColumnTypeEnum.getByType(column.getColumnType()); - if (typeEnum == null){ - script.append(SQLConstants.TAB).append(buildDefaultCreateColumnSql(column)).append(SQLConstants.COMMA_LINE_SEPARATOR); - }else { - script.append(SQLConstants.TAB).append(typeEnum.buildCreateColumnSql(column)).append(SQLConstants.COMMA_LINE_SEPARATOR); - } + script.append(SQLConstants.TAB).append(ClickHouseColumnTypeEnum.buildCreateColumnSqlSafely(column)).append(SQLConstants.COMMA_LINE_SEPARATOR); } for (TableIndex tableIndex : table.getIndexList()) { if (StringUtils.isBlank(tableIndex.getName()) || StringUtils.isBlank(tableIndex.getType())) { @@ -56,7 +52,7 @@ public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfi if (StringUtils.isNotBlank(table.getEngine())) { - script.append(SQLConstants.ENGINE_SQL).append(table.getEngine()).append(SQLConstants.LINE_SEPARATOR); + script.append(SQLConstants.ENGINE_SQL).append(validateEngine(table.getEngine())).append(SQLConstants.LINE_SEPARATOR); } for (TableIndex tableIndex : table.getIndexList()) { if (StringUtils.isBlank(tableIndex.getName()) || StringUtils.isBlank(tableIndex.getType())) { @@ -69,7 +65,7 @@ public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfi } if (StringUtils.isNotBlank(table.getComment())) { - script.append(SQL_COMMENT).append(table.getComment()).append(SQLConstants.SINGLE_QUOTE); + script.append(SQL_COMMENT).append(ClickHouseSqlEscapes.escapeSqlLiteral(table.getComment())).append(SQLConstants.SINGLE_QUOTE); } script.append(SQLConstants.SEMICOLON); @@ -82,12 +78,12 @@ public String buildAlterTable(Table oldTable, Table newTable) { StringBuilder script = new StringBuilder(); script.append(SQL_ALTER_TABLE); if (StringUtils.isNotBlank(oldTable.getDatabaseName())) { - script.append(SQLConstants.BACK_QUOTE).append(oldTable.getDatabaseName()).append(SQLConstants.BACK_QUOTE).append(SQLConstants.DOT); + script.append(ClickHouseSqlEscapes.quoteIdentifier(oldTable.getDatabaseName())).append(SQLConstants.DOT); } - script.append(SQLConstants.BACK_QUOTE).append(oldTable.getName()).append(SQLConstants.BACK_QUOTE).append(SQLConstants.LINE_SEPARATOR); + script.append(ClickHouseSqlEscapes.quoteIdentifier(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(newTable.getComment()).append(SQLConstants.SINGLE_QUOTE).append(SQLConstants.COMMA_LINE_SEPARATOR); + script.append(SQLConstants.TAB).append(SQL_MODIFY_COMMENT).append(SQLConstants.SINGLE_QUOTE).append(ClickHouseSqlEscapes.escapeSqlLiteral(newTable.getComment())).append(SQLConstants.SINGLE_QUOTE).append(SQLConstants.COMMA_LINE_SEPARATOR); } for (TableColumn tableColumn : newTable.getColumnList()) { if (StringUtils.isNotBlank(tableColumn.getEditStatus()) && StringUtils.isNotBlank(tableColumn.getColumnType()) && StringUtils.isNotBlank(tableColumn.getName())) { @@ -142,11 +138,18 @@ public String buildPageLimit(PageLimitRequest request) { @Override public String buildCreateDatabase(Database database) { StringBuilder sqlBuilder = new StringBuilder(); - sqlBuilder.append(SQL_CREATE_DATABASE + database.getName() + SQLConstants.BACK_QUOTE); + sqlBuilder.append(SQL_CREATE_DATABASE).append(ClickHouseSqlEscapes.quoteIdentifier(database.getName())); if(StringUtils.isNotBlank(database.getComment())){ - sqlBuilder.append(SQL_SEMICOLON_ALTER_DATABASE).append(database.getName()).append(SQL_COMMENT).append(database.getComment()).append(SQLConstants.SINGLE_QUOTE_SEMICOLON); + sqlBuilder.append(SQL_SEMICOLON_ALTER_DATABASE).append(ClickHouseSqlEscapes.quoteIdentifier(database.getName())).append(SQL_COMMENT).append(ClickHouseSqlEscapes.escapeSqlLiteral(database.getComment())).append(SQLConstants.SINGLE_QUOTE_SEMICOLON); } return sqlBuilder.toString(); } + private static String validateEngine(String engine) { + if (!engine.matches("[A-Za-z0-9_]+(\\s*\\([^;)]*\\))?")) { + throw new IllegalArgumentException("Invalid ClickHouse engine: " + engine); + } + return engine; + } + } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/constant/ClickHouseSqlBuilderConstants.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/constant/ClickHouseSqlBuilderConstants.java index d5d552034c..0621eb2e9c 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/constant/ClickHouseSqlBuilderConstants.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/constant/ClickHouseSqlBuilderConstants.java @@ -21,7 +21,7 @@ public final class ClickHouseSqlBuilderConstants { public static final String SQL_SEMICOLON_ALTER_DATABASE = ";ALTER DATABASE "; public static final String SQL_ALTER_TABLE = "ALTER TABLE "; public static final String SQL_COMMENT = " COMMENT '"; - public static final String SQL_CREATE_DATABASE = "CREATE DATABASE `"; + public static final String SQL_CREATE_DATABASE = "CREATE DATABASE "; public static final String SQL_CREATE_TABLE = "CREATE TABLE "; private ClickHouseSqlBuilderConstants() { 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 0d4c2957ec..b33af6d661 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 @@ -1,10 +1,10 @@ package ai.chat2db.plugin.clickhouse.enums.type; +import ai.chat2db.plugin.clickhouse.ClickHouseSqlEscapes; import ai.chat2db.spi.IColumnBuilder; import ai.chat2db.community.domain.api.enums.plugin.EditStatusEnum; import ai.chat2db.community.domain.api.model.metadata.ColumnType; import ai.chat2db.community.domain.api.model.metadata.TableColumn; -import ai.chat2db.spi.util.SqlUtils; import com.google.common.collect.Maps; import org.apache.commons.lang3.StringUtils; @@ -74,7 +74,39 @@ public enum ClickHouseColumnTypeEnum implements IColumnBuilder { } public static ClickHouseColumnTypeEnum getByType(String dataType) { - return COLUMN_TYPE_MAP.get(SqlUtils.removeDigits(dataType.toUpperCase())); + if (dataType == null) { + return null; + } + String normalized = dataType.trim(); + if (normalized.indexOf('(') >= 0) { + // Parameterized forms (Decimal(10,2), Array(...), Enum8('a','b'), ...) + // go through the validated fallback so the arguments are preserved + // and checked instead of silently dropped. + return null; + } + return COLUMN_TYPE_MAP.get(normalized.toUpperCase()); + } + + /** + * Builds the column definition for any declared column type, falling back + * to a fail-closed validated expression for types outside the enum. + */ + public static String buildCreateColumnSqlSafely(TableColumn column) { + ClickHouseColumnTypeEnum type = getByType(column.getColumnType()); + if (type == null) { + return buildValidatedFallbackColumn(column); + } + return type.buildCreateColumnSql(column); + } + + private static String buildValidatedFallbackColumn(TableColumn column) { + StringBuilder script = new StringBuilder(); + script.append(ClickHouseSqlEscapes.quoteIdentifier(column.getName())).append(" "); + script.append(ClickHouseSqlEscapes.requireColumnTypeExpression(column.getColumnType())).append(" "); + if (StringUtils.isNotBlank(column.getComment())) { + script.append("COMMENT '").append(ClickHouseSqlEscapes.escapeSqlLiteral(column.getComment())).append("' "); + } + return script.toString(); } public static List getTypes() { @@ -89,13 +121,13 @@ public ColumnType getColumnType() { @Override public String buildCreateColumnSql(TableColumn column) { - ClickHouseColumnTypeEnum type = COLUMN_TYPE_MAP.get(column.getColumnType()); + ClickHouseColumnTypeEnum type = getByType(column.getColumnType()); if (type == null) { - return buildDefaultColumn(column,true); + return buildValidatedFallbackColumn(column); } StringBuilder script = new StringBuilder(); - script.append("`").append(column.getName()).append("`").append(" "); + script.append(ClickHouseSqlEscapes.quoteIdentifier(column.getName())).append(" "); script.append(buildNullableAndDataType(column, type)).append(" "); @@ -110,7 +142,7 @@ public String buildCreateColumnSql(TableColumn column) { public String buildModifyColumn(TableColumn tableColumn) { if (EditStatusEnum.DELETE.name().equals(tableColumn.getEditStatus())) { - return StringUtils.join(SQL_DROP_COLUMN, tableColumn.getName() + "`"); + return StringUtils.join("DROP COLUMN ", ClickHouseSqlEscapes.quoteIdentifier(tableColumn.getName())); } if (EditStatusEnum.ADD.name().equals(tableColumn.getEditStatus())) { return StringUtils.join("ADD COLUMN ", buildCreateColumnSql(tableColumn)); @@ -118,8 +150,8 @@ public String buildModifyColumn(TableColumn tableColumn) { if (EditStatusEnum.MODIFY.name().equals(tableColumn.getEditStatus())) { String modifyColumn = ""; if (!StringUtils.equalsIgnoreCase(tableColumn.getOldName(), tableColumn.getName())) { - modifyColumn = StringUtils.join(SQL_RENAME_COLUMN, tableColumn.getOldName(), "` TO `", tableColumn.getName(), - "`, ", buildCreateColumnSql(tableColumn)); + modifyColumn = StringUtils.join("RENAME COLUMN ", ClickHouseSqlEscapes.quoteIdentifier(tableColumn.getOldName()), " TO ", ClickHouseSqlEscapes.quoteIdentifier(tableColumn.getName()), + ", ", buildCreateColumnSql(tableColumn)); } return StringUtils.join(modifyColumn, "MODIFY COLUMN ", buildCreateColumnSql(tableColumn)); } @@ -130,7 +162,7 @@ private String buildComment(TableColumn column, ClickHouseColumnTypeEnum type) { if (!type.columnType.isSupportComments() || StringUtils.isEmpty(column.getComment())) { return ""; } - return StringUtils.join(SQL_COMMENT_2, column.getComment(), "'"); + return StringUtils.join(SQL_COMMENT_2, ClickHouseSqlEscapes.escapeSqlLiteral(column.getComment()), "'"); } private String buildDefaultValue(TableColumn column, ClickHouseColumnTypeEnum type) { @@ -147,21 +179,33 @@ private String buildDefaultValue(TableColumn column, ClickHouseColumnTypeEnum ty } if (Arrays.asList(Enum8,Enum16).contains(type)) { - return StringUtils.join("DEFAULT '", column.getDefaultValue(), "'"); + return StringUtils.join("DEFAULT '", ClickHouseSqlEscapes.escapeSqlLiteral(column.getDefaultValue()), "'"); } if (Arrays.asList(Date).contains(type)) { - return StringUtils.join("DEFAULT '", column.getDefaultValue(), "'"); + return StringUtils.join("DEFAULT '", ClickHouseSqlEscapes.escapeSqlLiteral(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 '", column.getDefaultValue(), "'"); + return StringUtils.join("DEFAULT '", ClickHouseSqlEscapes.escapeSqlLiteral(column.getDefaultValue()), "'"); } - return StringUtils.join("DEFAULT ", 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; } private String buildNullableAndDataType(TableColumn column, ClickHouseColumnTypeEnum type) { @@ -201,16 +245,16 @@ private String buildDataType(TableColumn column, ClickHouseColumnTypeEnum type) } public String buildColumn(TableColumn column) { - ClickHouseColumnTypeEnum type = COLUMN_TYPE_MAP.get(column.getColumnType()); + ClickHouseColumnTypeEnum type = getByType(column.getColumnType()); if (type == null) { return ""; } StringBuilder script = new StringBuilder(); - script.append("`").append(column.getName()).append("`").append(" "); + script.append(ClickHouseSqlEscapes.quoteIdentifier(column.getName())).append(" "); script.append(buildDataType(column, type)).append(" "); if (StringUtils.isNoneBlank(column.getComment())) { - script.append(SQL_COMMENT).append(" ").append("'").append(column.getComment()).append("'").append(" "); + script.append(SQL_COMMENT).append(" ").append("'").append(ClickHouseSqlEscapes.escapeSqlLiteral(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 38346a8a60..bf0e27e2d5 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,6 +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 org.apache.commons.lang3.StringUtils; import java.util.Arrays; @@ -89,7 +90,7 @@ private String buildIndexColumn(TableIndex tableIndex) { script.append("("); for (TableIndexColumn column : tableIndex.getColumnList()) { if (StringUtils.isNotBlank(column.getColumnName())) { - script.append("`").append(column.getColumnName()).append("`"); + script.append(ClickHouseSqlEscapes.quoteIdentifier(column.getColumnName())); script.append(","); } } @@ -102,7 +103,7 @@ private String buildIndexName(TableIndex tableIndex) { if (this.equals(PRIMARY)) { return ""; } else { - return "`" + tableIndex.getName() + "`"; + return ClickHouseSqlEscapes.quoteIdentifier(tableIndex.getName()); } } @@ -111,11 +112,11 @@ public String buildModifyIndex(TableIndex tableIndex) { return ""; } if (EditStatusEnum.DELETE.name().equals(tableIndex.getEditStatus())) { - return StringUtils.join(SQL_DROP_INDEX, tableIndex.getOldName(), "`"); + return StringUtils.join("DROP INDEX ", ClickHouseSqlEscapes.quoteIdentifier(tableIndex.getOldName())); } if (EditStatusEnum.MODIFY.name().equals(tableIndex.getEditStatus())) { - return StringUtils.join(SQL_DROP_INDEX, tableIndex.getOldName(), - "`,\n ADD ", buildIndexScript(tableIndex)); + return StringUtils.join("DROP INDEX ", ClickHouseSqlEscapes.quoteIdentifier(tableIndex.getOldName()), + ",\n ADD ", buildIndexScript(tableIndex)); } if (EditStatusEnum.ADD.name().equals(tableIndex.getEditStatus())) { return StringUtils.join("ADD ", buildIndexScript(tableIndex)); 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/ClickHouseSqlEscapesTest.java new file mode 100644 index 0000000000..93528f614e --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/test/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlEscapesTest.java @@ -0,0 +1,230 @@ +package ai.chat2db.plugin.clickhouse; + +import ai.chat2db.community.domain.api.model.metadata.Database; +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 org.junit.jupiter.api.Test; + +import java.util.ArrayList; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ClickHouseSqlEscapesTest { + + @Test + void shouldDoubleSingleQuotesInLiterals() { + assertEquals("owner''s", ClickHouseSqlEscapes.escapeSqlLiteral("owner's")); + assertEquals("", ClickHouseSqlEscapes.escapeSqlLiteral(null)); + } + + @Test + void shouldEscapeBackslashesInLiterals() { + assertEquals("a\\\\b''c", ClickHouseSqlEscapes.escapeSqlLiteral("a\\b'c")); + } + + @Test + void shouldDoubleBackticksInIdentifiers() { + assertEquals("`a``b`", ClickHouseSqlEscapes.quoteIdentifier("a`b")); + assertEquals("`plain`", ClickHouseSqlEscapes.quoteIdentifier("plain")); + assertEquals("``", ClickHouseSqlEscapes.quoteIdentifier(null)); + } + + @Test + void shouldStripSurroundingBackticksBeforeDoubling() { + assertEquals("`a``b`", ClickHouseSqlEscapes.quoteIdentifier("`a`b`")); + } + + @Test + void shouldNeutralizeMaliciousTableNameInCreateTable() { + ClickHouseSqlBuilder builder = new ClickHouseSqlBuilder(); + Table table = new Table(); + table.setName("evil` , (id Int32) ENGINE=Memory; -- "); + table.setDatabaseName("db`x"); + table.setColumnList(new ArrayList<>()); + table.setIndexList(new ArrayList<>()); + + String sql = builder.buildCreateTable(table, null); + + assertTrue(sql.contains("`db``x`.`evil`` , (id Int32) ENGINE=Memory; -- `"), + "identifier backticks must be doubled: " + sql); + } + + @Test + void shouldEscapeCommentLiteralInCreateTable() { + ClickHouseSqlBuilder builder = new ClickHouseSqlBuilder(); + Table table = new Table(); + table.setName("t"); + table.setColumnList(new ArrayList<>()); + table.setIndexList(new ArrayList<>()); + table.setComment("x' OR '1'='1"); + + String sql = builder.buildCreateTable(table, null); + + assertTrue(sql.contains("COMMENT 'x'' OR ''1''=''1'"), "comment quote must be doubled: " + sql); + } + + @Test + void shouldEscapeColumnNameAndCommentInCreateColumn() { + TableColumn column = new TableColumn(); + column.setName("col`drop"); + column.setColumnType("STRING"); + column.setComment("it's"); + + String sql = ClickHouseColumnTypeEnum.String.buildCreateColumnSql(column); + + assertTrue(sql.startsWith("`col``drop` "), "column identifier backticks must be doubled: " + sql); + assertTrue(sql.contains("COMMENT 'it''s'"), "column comment quote must be doubled: " + sql); + } + + @Test + void shouldRejectMaliciousEngine() { + ClickHouseSqlBuilder builder = new ClickHouseSqlBuilder(); + Table table = new Table(); + table.setName("t"); + table.setColumnList(new ArrayList<>()); + table.setIndexList(new ArrayList<>()); + table.setEngine("Memory; DROP TABLE users"); + + assertThrows(IllegalArgumentException.class, () -> builder.buildCreateTable(table, null)); + } + + @Test + void shouldAcceptKnownEngine() { + ClickHouseSqlBuilder builder = new ClickHouseSqlBuilder(); + Table table = new Table(); + table.setName("t"); + table.setColumnList(new ArrayList<>()); + table.setIndexList(new ArrayList<>()); + table.setEngine("MergeTree"); + + String sql = builder.buildCreateTable(table, null); + + assertTrue(sql.contains("ENGINE=MergeTree"), sql); + } + + @Test + void shouldNeutralizeMaliciousDatabaseNameInCreateDatabase() { + ClickHouseSqlBuilder builder = new ClickHouseSqlBuilder(); + Database database = new Database(); + database.setName("db`; DROP TABLE x; --"); + database.setComment("c' OR '1'='1"); + + String sql = builder.buildCreateDatabase(database); + + assertTrue(sql.contains("CREATE DATABASE `db``; DROP TABLE x; --`"), sql); + assertTrue(sql.contains("COMMENT 'c'' OR ''1''=''1'"), sql); + } + + @Test + void shouldQuoteMetadataNameParts() { + ClickHouseMetaData metaData = new ClickHouseMetaData(); + + assertEquals("`db`.`ta``ble`", metaData.getMetaDataName("db", "ta`ble")); + } + + @Test + void shouldAcceptNumericAndFunctionDefaults() { + TableColumn column = new TableColumn(); + column.setName("c"); + column.setColumnType("INT32"); + column.setDefaultValue("-1"); + + String sql = ClickHouseColumnTypeEnum.Int32.buildCreateColumnSql(column); + + assertTrue(sql.contains("DEFAULT -1"), sql); + } + + @Test + void shouldAcceptQuotedStringDefaults() { + TableColumn column = new TableColumn(); + column.setName("c"); + column.setColumnType("String"); + column.setDefaultValue("'abc'"); + + String sql = ClickHouseColumnTypeEnum.String.buildCreateColumnSql(column); + + assertTrue(sql.contains("DEFAULT 'abc'"), sql); + } + + @Test + void shouldEscapeQuotedStringDefaultContent() { + TableColumn column = new TableColumn(); + column.setName("c"); + column.setColumnType("String"); + column.setDefaultValue("'a');DROP TABLE t;--'"); + + String sql = ClickHouseColumnTypeEnum.String.buildCreateColumnSql(column); + + assertTrue(sql.contains("DEFAULT 'a'');DROP TABLE t;--'"), sql); + } + + @Test + void shouldResolveMixedCaseAndDigitTypeNames() { + TableColumn mixed = new TableColumn(); + mixed.setName("c1"); + mixed.setColumnType("String"); + assertTrue(ClickHouseColumnTypeEnum.buildCreateColumnSqlSafely(mixed).startsWith("`c1` String"), + "mixed-case type must resolve to the enum"); + + TableColumn digits = new TableColumn(); + digits.setName("c2"); + digits.setColumnType("Int32"); + assertTrue(ClickHouseColumnTypeEnum.buildCreateColumnSqlSafely(digits).startsWith("`c2` Int32"), + "digit-containing type must resolve to the enum"); + } + + @Test + void shouldEmitValidatedFallbackForUnknownType() { + TableColumn nested = new TableColumn(); + nested.setName("c"); + nested.setColumnType("Array(Nullable(String))"); + String sql = ClickHouseColumnTypeEnum.buildCreateColumnSqlSafely(nested); + assertTrue(sql.startsWith("`c` Array(Nullable(String))"), sql); + + TableColumn malicious = new TableColumn(); + malicious.setName("c"); + malicious.setColumnType("Int32); DROP TABLE x; --"); + assertThrows(IllegalArgumentException.class, + () -> ClickHouseColumnTypeEnum.buildCreateColumnSqlSafely(malicious)); + + TableColumn breakout = new TableColumn(); + breakout.setName("c"); + breakout.setColumnType("Int32, injected Int32"); + assertThrows(IllegalArgumentException.class, + () -> ClickHouseColumnTypeEnum.buildCreateColumnSqlSafely(breakout)); + } + + @Test + void shouldRejectEngineAndDefaultParenBreakout() { + ClickHouseSqlBuilder builder = new ClickHouseSqlBuilder(); + Table table = new Table(); + table.setName("t"); + table.setColumnList(new ArrayList<>()); + table.setIndexList(new ArrayList<>()); + table.setEngine("Memory() ORDER BY tuple() -- x"); + assertThrows(IllegalArgumentException.class, () -> builder.buildCreateTable(table, null)); + + TableColumn column = new TableColumn(); + column.setName("c"); + column.setColumnType("INT32"); + column.setDefaultValue("f(1)) ENGINE=Memory -- x"); + assertThrows(IllegalArgumentException.class, + () -> ClickHouseColumnTypeEnum.Int32.buildCreateColumnSql(column)); + } + + @Test + void shouldEscapeQuotedDefaultLiteral() { + TableColumn column = new TableColumn(); + column.setName("d"); + column.setColumnType("DATE"); + column.setDefaultValue("2024-01-01' OR '1'='1"); + + String sql = ClickHouseColumnTypeEnum.Date.buildCreateColumnSql(column); + + assertTrue(sql.contains("DEFAULT '2024-01-01'' OR ''1''=''1'"), sql); + } +} From 96571c138d57e438d1efd2ed0398089402945117 Mon Sep 17 00:00:00 2001 From: HandSonic <8078023+handsonic@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:26:35 +0800 Subject: [PATCH 2/6] fix(clickhouse): allow enum '=' type args and keep DEFAULT/Nullable in type fallback (#1914) --- .../clickhouse/ClickHouseSqlEscapes.java | 9 ++-- .../enums/type/ClickHouseColumnTypeEnum.java | 29 ++++++++++- .../clickhouse/ClickHouseSqlEscapesTest.java | 52 +++++++++++++++++++ 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlEscapes.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlEscapes.java index 97cc64bcbf..494018e3fd 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlEscapes.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlEscapes.java @@ -36,10 +36,11 @@ public static String quoteIdentifier(String identifier) { /** * Validates a column type expression that is emitted verbatim into DDL - * (e.g. Int32, Decimal(10,2), Array(Nullable(String)), Enum8('a','b')). + * (e.g. Int32, Decimal(10,2), Array(Nullable(String)), Enum8('a'=1)). * Only letters/digits/underscore are allowed at the top level; spaces, - * commas and single quotes are only allowed inside balanced parentheses. - * Semicolons, dashes, double quotes and backticks are always rejected. + * commas, single quotes and equals signs are only allowed inside balanced + * parentheses. Semicolons, dashes, double quotes and backticks are always + * rejected. */ public static String requireColumnTypeExpression(String columnType) { if (StringUtils.isBlank(columnType)) { @@ -60,7 +61,7 @@ public static String requireColumnTypeExpression(String columnType) { continue; } boolean ok = Character.isLetterOrDigit(c) || c == '_' - || (depth > 0 && (c == ' ' || c == ',' || c == '\'')); + || (depth > 0 && (c == ' ' || c == ',' || c == '\'' || c == '=')); if (!ok) { throw new IllegalArgumentException("Invalid ClickHouse column type: " + columnType); } 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 b33af6d661..5bc74367fb 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,13 +102,40 @@ public static String buildCreateColumnSqlSafely(TableColumn column) { private static String buildValidatedFallbackColumn(TableColumn column) { StringBuilder script = new StringBuilder(); script.append(ClickHouseSqlEscapes.quoteIdentifier(column.getName())).append(" "); - script.append(ClickHouseSqlEscapes.requireColumnTypeExpression(column.getColumnType())).append(" "); + String columnType = ClickHouseSqlEscapes.requireColumnTypeExpression(column.getColumnType()); + if (column.getNullable() != null && 1 == column.getNullable() && isNullableWrappable(columnType)) { + columnType = "Nullable(" + columnType + ")"; + } + script.append(columnType).append(" "); + String defaultValue = buildFallbackDefaultValue(column); + if (StringUtils.isNotBlank(defaultValue)) { + script.append(defaultValue).append(" "); + } if (StringUtils.isNotBlank(column.getComment())) { script.append("COMMENT '").append(ClickHouseSqlEscapes.escapeSqlLiteral(column.getComment())).append("' "); } return script.toString(); } + private static boolean isNullableWrappable(String columnType) { + String upper = columnType.toUpperCase(); + return !upper.startsWith("ARRAY(") && !upper.startsWith("MAP(") && !upper.startsWith("TUPLE(") + && !upper.startsWith("NESTED(") && !upper.startsWith("NULLABLE("); + } + + private static String buildFallbackDefaultValue(TableColumn column) { + if (StringUtils.isEmpty(column.getDefaultValue())) { + return ""; + } + if ("EMPTY_STRING".equalsIgnoreCase(column.getDefaultValue().trim())) { + return "DEFAULT ''"; + } + if ("NULL".equalsIgnoreCase(column.getDefaultValue().trim())) { + return "DEFAULT NULL"; + } + return "DEFAULT " + validateDefaultExpression(column.getDefaultValue()); + } + public static List getTypes() { return Arrays.stream(ClickHouseColumnTypeEnum.values()).map(columnTypeEnum -> columnTypeEnum.getColumnType() 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/ClickHouseSqlEscapesTest.java index 93528f614e..9043e6edaf 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/ClickHouseSqlEscapesTest.java @@ -227,4 +227,56 @@ void shouldEscapeQuotedDefaultLiteral() { assertTrue(sql.contains("DEFAULT '2024-01-01'' OR ''1''=''1'"), sql); } + + @Test + void shouldAllowEqualsInsideEnumTypeArguments() { + TableColumn column = new TableColumn(); + column.setName("e"); + column.setColumnType("Enum8('a'=1,'b'=2)"); + + String sql = ClickHouseColumnTypeEnum.buildCreateColumnSqlSafely(column); + + assertTrue(sql.startsWith("`e` Enum8('a'=1,'b'=2)"), sql); + } + + @Test + void shouldRejectEqualsOutsideTypeArguments() { + TableColumn topLevel = new TableColumn(); + topLevel.setName("e"); + topLevel.setColumnType("Int32=1"); + assertThrows(IllegalArgumentException.class, + () -> ClickHouseColumnTypeEnum.buildCreateColumnSqlSafely(topLevel)); + + TableColumn afterClose = new TableColumn(); + afterClose.setName("e"); + afterClose.setColumnType("Enum8('a'=1)=2"); + assertThrows(IllegalArgumentException.class, + () -> ClickHouseColumnTypeEnum.buildCreateColumnSqlSafely(afterClose)); + } + + @Test + void shouldPreserveNullableAndDefaultInValidatedFallback() { + TableColumn column = new TableColumn(); + column.setName("d"); + column.setColumnType("Decimal(10,2)"); + column.setNullable(1); + column.setDefaultValue("1.5"); + + String sql = ClickHouseColumnTypeEnum.buildCreateColumnSqlSafely(column); + + assertTrue(sql.startsWith("`d` Nullable(Decimal(10,2))"), sql); + assertTrue(sql.contains("DEFAULT 1.5"), sql); + } + + @Test + void shouldNotWrapNonNullableCapableTypesInFallback() { + TableColumn column = new TableColumn(); + column.setName("a"); + column.setColumnType("Array(Nullable(String))"); + column.setNullable(1); + + String sql = ClickHouseColumnTypeEnum.buildCreateColumnSqlSafely(column); + + assertTrue(sql.startsWith("`a` Array(Nullable(String))"), sql); + } } From bde310f81bcd967f6d5bb4145bb78973ab027a8f Mon Sep 17 00:00:00 2001 From: HandSonic <8078023+handsonic@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:35:12 +0800 Subject: [PATCH 3/6] fix(clickhouse): skip Nullable wrap for AggregateFunction fallback, lock enum dash rejection (#1914) --- .../enums/type/ClickHouseColumnTypeEnum.java | 3 ++- .../clickhouse/ClickHouseSqlEscapesTest.java | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) 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 5bc74367fb..7dfbf9c78d 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 @@ -120,7 +120,8 @@ private static String buildValidatedFallbackColumn(TableColumn column) { private static boolean isNullableWrappable(String columnType) { String upper = columnType.toUpperCase(); return !upper.startsWith("ARRAY(") && !upper.startsWith("MAP(") && !upper.startsWith("TUPLE(") - && !upper.startsWith("NESTED(") && !upper.startsWith("NULLABLE("); + && !upper.startsWith("NESTED(") && !upper.startsWith("NULLABLE(") + && !upper.startsWith("AGGREGATEFUNCTION(") && !upper.startsWith("SIMPLEAGGREGATEFUNCTION("); } private static String buildFallbackDefaultValue(TableColumn column) { 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/ClickHouseSqlEscapesTest.java index 9043e6edaf..739736740f 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/ClickHouseSqlEscapesTest.java @@ -279,4 +279,28 @@ void shouldNotWrapNonNullableCapableTypesInFallback() { assertTrue(sql.startsWith("`a` Array(Nullable(String))"), sql); } + + @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). + TableColumn column = new TableColumn(); + column.setName("e"); + column.setColumnType("Enum8('a' = -1)"); + assertThrows(IllegalArgumentException.class, + () -> ClickHouseColumnTypeEnum.buildCreateColumnSqlSafely(column)); + } + + @Test + void shouldNotWrapAggregateFunctionInFallback() { + TableColumn column = new TableColumn(); + column.setName("agg"); + column.setColumnType("AggregateFunction(uniq, String)"); + column.setNullable(1); + + String sql = ClickHouseColumnTypeEnum.buildCreateColumnSqlSafely(column); + + assertTrue(sql.startsWith("`agg` AggregateFunction(uniq, String)"), sql); + } } From 1970288425cf117fc73957b98a3fad123112a375 Mon Sep 17 00:00:00 2001 From: HandSonic <8078023+handsonic@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:55:05 +0800 Subject: [PATCH 4/6] refactor(clickhouse): move escaping into ClickHouseIdentifierProcessor per maintainer review (#1914) - new ClickHouseIdentifierProcessor (SPI ISQLIdentifierProcessor): quoteIdentifier with backtick doubling, escapeString with backslash + single-quote doubling - ClickHouseMetaData overrides getSQLIdentifierProcessor(); metadata call sites use it - builders/managers/enums use ClickHouseIdentifierProcessor.INSTANCE - non-escapable validation moved to ClickHouseSqlGuards (column type expressions, engine whitelist, column default expressions) - ClickHouseSqlEscapes removed; tests migrated (24 green) --- .../clickhouse/ClickHouseDBManager.java | 15 ++-- .../plugin/clickhouse/ClickHouseMetaData.java | 23 +++-- .../clickhouse/ClickHouseSqlEscapes.java | 74 ---------------- .../clickhouse/ClickHouseSqlGuards.java | 85 +++++++++++++++++++ .../builder/ClickHouseSqlBuilder.java | 28 +++--- .../enums/type/ClickHouseColumnTypeEnum.java | 43 ++++------ .../enums/type/ClickHouseIndexTypeEnum.java | 10 +-- .../ClickHouseIdentifierProcessor.java | 65 ++++++++++++++ ...=> ClickHouseIdentifierProcessorTest.java} | 17 ++-- 9 files changed, 214 insertions(+), 146 deletions(-) delete mode 100644 chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlEscapes.java create mode 100644 chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlGuards.java create mode 100644 chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/identifier/ClickHouseIdentifierProcessor.java rename chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/test/java/ai/chat2db/plugin/clickhouse/{ClickHouseSqlEscapesTest.java => ClickHouseIdentifierProcessorTest.java} (93%) 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 c437990e24..6eddca688f 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 @@ -1,6 +1,7 @@ package ai.chat2db.plugin.clickhouse; import ai.chat2db.spi.IDbManager; +import ai.chat2db.plugin.clickhouse.identifier.ClickHouseIdentifierProcessor; import ai.chat2db.spi.DefaultDBManager; import ai.chat2db.community.domain.api.model.async.AsyncContext; import ai.chat2db.spi.model.datasource.ConnectInfo; @@ -37,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(ClickHouseSqlEscapes.quoteIdentifier(resultSet.getString("name"))).append(";") + sqlBuilder.append(SQL_DROP_FUNCTION_EXISTS).append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(resultSet.getString("name"))).append(";") .append("\n") .append(resultSet.getString("create_query")).append(";").append("\n"); asyncContext.write(sqlBuilder.toString()); @@ -46,7 +47,7 @@ private void exportFunctions(Connection connection, AsyncContext asyncContext) t } private void exportTablesOrViewsOrDictionaries(Connection connection, String databaseName, String schemaName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(SQL_SELECT_CREATE_TABLE_QUERY_HAS, ClickHouseSqlEscapes.escapeSqlLiteral(databaseName)); + String sql = String.format(SQL_SELECT_CREATE_TABLE_QUERY_HAS, ClickHouseIdentifierProcessor.INSTANCE.escapeString(databaseName)); try (PreparedStatement statement = connection.prepareStatement(sql); ResultSet resultSet = statement.executeQuery()) { while (resultSet.next()) { @@ -56,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(ClickHouseSqlEscapes.quoteIdentifier(databaseName)).append(".").append(ClickHouseSqlEscapes.quoteIdentifier(tableOrViewName)) + sqlBuilder.append(SQL_DROP_VIEW_EXISTS).append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(databaseName)).append(".").append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(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(ClickHouseSqlEscapes.quoteIdentifier(databaseName)).append(".").append(ClickHouseSqlEscapes.quoteIdentifier(tableOrViewName)) + sqlBuilder.append(SQL_DROP_DICTIONARY_EXISTS).append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(databaseName)).append(".").append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(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(ClickHouseSqlEscapes.quoteIdentifier(databaseName)).append(".").append(ClickHouseSqlEscapes.quoteIdentifier(tableOrViewName)) + sqlBuilder.append(SQL_DROP_TABLE_EXISTS).append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(databaseName)).append(".").append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(tableOrViewName)) .append(";").append("\n").append(ddl).append(";").append("\n"); asyncContext.write(sqlBuilder.toString()); if (asyncContext.isContainsData() && dataFlag) { @@ -119,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 " + ClickHouseSqlEscapes.quoteIdentifier(newTableName) + " AS " + ClickHouseSqlEscapes.quoteIdentifier(tableName) + ""; + String sql = "CREATE TABLE " + ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(newTableName) + " AS " + ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(tableName) + ""; DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> null); if (copyData) { - sql = "INSERT INTO " + ClickHouseSqlEscapes.quoteIdentifier(newTableName) + " SELECT * FROM " + ClickHouseSqlEscapes.quoteIdentifier(tableName); + sql = "INSERT INTO " + ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(newTableName) + " SELECT * FROM " + ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(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 51b789e7b9..d1c6015e31 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 @@ -4,8 +4,10 @@ import ai.chat2db.plugin.clickhouse.enums.type.ClickHouseColumnTypeEnum; import ai.chat2db.plugin.clickhouse.enums.type.ClickHouseEngineTypeEnum; import ai.chat2db.plugin.clickhouse.enums.type.ClickHouseIndexTypeEnum; +import ai.chat2db.plugin.clickhouse.identifier.ClickHouseIdentifierProcessor; import ai.chat2db.spi.ICommandExecutor; import ai.chat2db.spi.IDbMetaData; +import ai.chat2db.spi.ISQLIdentifierProcessor; import ai.chat2db.spi.ISqlBuilder; import ai.chat2db.spi.DefaultMetaService; import ai.chat2db.community.domain.api.model.account.*; @@ -38,8 +40,13 @@ public class ClickHouseMetaData extends DefaultMetaService implements IDbMetaData { private static final Logger log = LoggerFactory.getLogger(ClickHouseMetaData.class); + @Override + public ISQLIdentifierProcessor getSQLIdentifierProcessor() { + return ClickHouseIdentifierProcessor.INSTANCE; + } + public static String format(String tableName) { - return ClickHouseSqlEscapes.quoteIdentifier(tableName); + return ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(tableName); } @Override @@ -86,7 +93,7 @@ public List
tables(Connection connection, String databaseName, String sch @Override public List
views(Connection connection, String databaseName, String schemaName) { - String sql = "select name from system.`tables` WHERE `database`='" + ClickHouseSqlEscapes.escapeSqlLiteral(schemaName) + "' and engine='View'"; + String sql = "select name from system.`tables` WHERE `database`='" + getSQLIdentifierProcessor().escapeString(schemaName) + "' and engine='View'"; return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { List
tables = new ArrayList<>(); try { @@ -139,7 +146,7 @@ public Function function(Connection connection, @NotEmpty String databaseName, S @Override public List triggers(Connection connection, String databaseName, String schemaName) { List triggers = new ArrayList<>(); - String sql = String.format(TRIGGER_SQL_LIST, ClickHouseSqlEscapes.escapeSqlLiteral(schemaName)); + String sql = String.format(TRIGGER_SQL_LIST, getSQLIdentifierProcessor().escapeString(schemaName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { while (resultSet.next()) { Trigger trigger = new Trigger(); @@ -156,7 +163,7 @@ public List triggers(Connection connection, String databaseName, String public Trigger trigger(Connection connection, @NotEmpty String databaseName, String schemaName, String triggerName) { - String sql = String.format(TRIGGER_SQL, ClickHouseSqlEscapes.escapeSqlLiteral(schemaName), ClickHouseSqlEscapes.escapeSqlLiteral(triggerName)); + String sql = String.format(TRIGGER_SQL, getSQLIdentifierProcessor().escapeString(schemaName), getSQLIdentifierProcessor().escapeString(triggerName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Trigger trigger = new Trigger(); trigger.setDatabaseName(databaseName); @@ -172,7 +179,7 @@ public Trigger trigger(Connection connection, @NotEmpty String databaseName, Str @Override public Procedure procedure(Connection connection, @NotEmpty String databaseName, String schemaName, String procedureName) { - String sql = String.format(ROUTINES_SQL, "PROCEDURE", ClickHouseSqlEscapes.escapeSqlLiteral(schemaName), ClickHouseSqlEscapes.escapeSqlLiteral(procedureName)); + String sql = String.format(ROUTINES_SQL, "PROCEDURE", getSQLIdentifierProcessor().escapeString(schemaName), getSQLIdentifierProcessor().escapeString(procedureName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Procedure procedure = new Procedure(); procedure.setDatabaseName(databaseName); @@ -189,7 +196,7 @@ public Procedure procedure(Connection connection, @NotEmpty String databaseName, @Override public List columns(Connection connection, String databaseName, String schemaName, String tableName) { - String sql = String.format(SELECT_TABLE_COLUMNS, ClickHouseSqlEscapes.escapeSqlLiteral(tableName), ClickHouseSqlEscapes.escapeSqlLiteral(schemaName)); + String sql = String.format(SELECT_TABLE_COLUMNS, getSQLIdentifierProcessor().escapeString(tableName), getSQLIdentifierProcessor().escapeString(schemaName)); List tableColumns = new ArrayList<>(); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { @@ -245,7 +252,7 @@ private void setColumnSize(TableColumn column, String columnType) { @Override public Table view(Connection connection, String databaseName, String schemaName, String viewName) { - String sql = String.format(VIEW_SQL, ClickHouseSqlEscapes.escapeSqlLiteral(schemaName), ClickHouseSqlEscapes.escapeSqlLiteral(viewName)); + String sql = String.format(VIEW_SQL, getSQLIdentifierProcessor().escapeString(schemaName), getSQLIdentifierProcessor().escapeString(viewName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Table table = new Table(); table.setDatabaseName(databaseName); @@ -298,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(ClickHouseSqlEscapes::quoteIdentifier).collect(Collectors.joining(".")); + return Arrays.stream(names).filter(name -> StringUtils.isNotBlank(name)).map(getSQLIdentifierProcessor()::quoteIdentifier).collect(Collectors.joining(".")); } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlEscapes.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlEscapes.java deleted file mode 100644 index 494018e3fd..0000000000 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlEscapes.java +++ /dev/null @@ -1,74 +0,0 @@ -package ai.chat2db.plugin.clickhouse; - -import org.apache.commons.lang3.StringUtils; - -public final class ClickHouseSqlEscapes { - - private ClickHouseSqlEscapes() { - } - - /** - * Escapes a value interpolated into a single-quoted ClickHouse string literal. - * ClickHouse treats backslash as an escape character, so backslashes are - * doubled before single quotes are doubled. - */ - public static String escapeSqlLiteral(String value) { - if (value == null) { - return ""; - } - return StringUtils.replace(StringUtils.replace(value, "\\", "\\\\"), "'", "''"); - } - - /** - * Quotes an identifier with backticks, stripping any surrounding backticks - * and doubling every embedded backtick. - */ - public static String quoteIdentifier(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, "`", "``") + "`"; - } - - /** - * 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. - */ - public static String requireColumnTypeExpression(String columnType) { - if (StringUtils.isBlank(columnType)) { - throw new IllegalArgumentException("Invalid ClickHouse 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); - } - } - if (depth != 0 || !Character.isLetter(columnType.charAt(0))) { - throw new IllegalArgumentException("Invalid ClickHouse column type: " + columnType); - } - return columnType; - } -} 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 new file mode 100644 index 0000000000..492e12bc0f --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-clickhouse/src/main/java/ai/chat2db/plugin/clickhouse/ClickHouseSqlGuards.java @@ -0,0 +1,85 @@ +package ai.chat2db.plugin.clickhouse; + +import ai.chat2db.plugin.clickhouse.identifier.ClickHouseIdentifierProcessor; +import org.apache.commons.lang3.StringUtils; + +/** + * 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}. + */ +public final class ClickHouseSqlGuards { + + 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. + */ + public static String requireColumnTypeExpression(String columnType) { + if (StringUtils.isBlank(columnType)) { + throw new IllegalArgumentException("Invalid ClickHouse 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); + } + } + if (depth != 0 || !Character.isLetter(columnType.charAt(0))) { + throw new IllegalArgumentException("Invalid ClickHouse column type: " + columnType); + } + return columnType; + } + + /** + * 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. + */ + public static String requireEngine(String engine) { + if (!engine.matches("[A-Za-z0-9_]+(\\s*\\([^;)]*\\))?")) { + throw new IllegalArgumentException("Invalid ClickHouse engine: " + engine); + } + return engine; + } + + /** + * 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). + */ + 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 (!trimmed.matches("[-+]?(\\d+(\\.\\d+)?|[A-Za-z_][A-Za-z0-9_]*(\\s*\\([^;)]*\\))?)")) { + throw new IllegalArgumentException("Invalid ClickHouse default expression: " + defaultValue); + } + return trimmed; + } +} 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 a4638cc435..31810e59f1 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 @@ -2,9 +2,10 @@ import ai.chat2db.spi.constant.SQLConstants; -import ai.chat2db.plugin.clickhouse.ClickHouseSqlEscapes; +import ai.chat2db.plugin.clickhouse.ClickHouseSqlGuards; import ai.chat2db.plugin.clickhouse.enums.type.ClickHouseColumnTypeEnum; import ai.chat2db.plugin.clickhouse.enums.type.ClickHouseIndexTypeEnum; +import ai.chat2db.plugin.clickhouse.identifier.ClickHouseIdentifierProcessor; import ai.chat2db.spi.DefaultSqlBuilder; import ai.chat2db.spi.model.request.PageLimitRequest; import ai.chat2db.community.domain.api.model.metadata.Database; @@ -28,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(ClickHouseSqlEscapes.quoteIdentifier(table.getDatabaseName())).append(SQLConstants.DOT); + script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(table.getDatabaseName())).append(SQLConstants.DOT); } - script.append(ClickHouseSqlEscapes.quoteIdentifier(table.getName())).append(SQLConstants.SPACE_OPEN_PARENTHESIS).append(SQLConstants.LINE_SEPARATOR); + script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(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; @@ -52,7 +53,7 @@ public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfi if (StringUtils.isNotBlank(table.getEngine())) { - script.append(SQLConstants.ENGINE_SQL).append(validateEngine(table.getEngine())).append(SQLConstants.LINE_SEPARATOR); + script.append(SQLConstants.ENGINE_SQL).append(ClickHouseSqlGuards.requireEngine(table.getEngine())).append(SQLConstants.LINE_SEPARATOR); } for (TableIndex tableIndex : table.getIndexList()) { if (StringUtils.isBlank(tableIndex.getName()) || StringUtils.isBlank(tableIndex.getType())) { @@ -65,7 +66,7 @@ public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfi } if (StringUtils.isNotBlank(table.getComment())) { - script.append(SQL_COMMENT).append(ClickHouseSqlEscapes.escapeSqlLiteral(table.getComment())).append(SQLConstants.SINGLE_QUOTE); + script.append(SQL_COMMENT).append(ClickHouseIdentifierProcessor.INSTANCE.escapeString(table.getComment())).append(SQLConstants.SINGLE_QUOTE); } script.append(SQLConstants.SEMICOLON); @@ -78,12 +79,12 @@ public String buildAlterTable(Table oldTable, Table newTable) { StringBuilder script = new StringBuilder(); script.append(SQL_ALTER_TABLE); if (StringUtils.isNotBlank(oldTable.getDatabaseName())) { - script.append(ClickHouseSqlEscapes.quoteIdentifier(oldTable.getDatabaseName())).append(SQLConstants.DOT); + script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(oldTable.getDatabaseName())).append(SQLConstants.DOT); } - script.append(ClickHouseSqlEscapes.quoteIdentifier(oldTable.getName())).append(SQLConstants.LINE_SEPARATOR); + script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(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(ClickHouseSqlEscapes.escapeSqlLiteral(newTable.getComment())).append(SQLConstants.SINGLE_QUOTE).append(SQLConstants.COMMA_LINE_SEPARATOR); + 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); } for (TableColumn tableColumn : newTable.getColumnList()) { if (StringUtils.isNotBlank(tableColumn.getEditStatus()) && StringUtils.isNotBlank(tableColumn.getColumnType()) && StringUtils.isNotBlank(tableColumn.getName())) { @@ -138,18 +139,11 @@ public String buildPageLimit(PageLimitRequest request) { @Override public String buildCreateDatabase(Database database) { StringBuilder sqlBuilder = new StringBuilder(); - sqlBuilder.append(SQL_CREATE_DATABASE).append(ClickHouseSqlEscapes.quoteIdentifier(database.getName())); + sqlBuilder.append(SQL_CREATE_DATABASE).append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(database.getName())); if(StringUtils.isNotBlank(database.getComment())){ - sqlBuilder.append(SQL_SEMICOLON_ALTER_DATABASE).append(ClickHouseSqlEscapes.quoteIdentifier(database.getName())).append(SQL_COMMENT).append(ClickHouseSqlEscapes.escapeSqlLiteral(database.getComment())).append(SQLConstants.SINGLE_QUOTE_SEMICOLON); + 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); } return sqlBuilder.toString(); } - private static String validateEngine(String engine) { - if (!engine.matches("[A-Za-z0-9_]+(\\s*\\([^;)]*\\))?")) { - throw new IllegalArgumentException("Invalid ClickHouse engine: " + engine); - } - return engine; - } - } 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 7dfbf9c78d..102c1d639c 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 @@ -1,6 +1,7 @@ package ai.chat2db.plugin.clickhouse.enums.type; -import ai.chat2db.plugin.clickhouse.ClickHouseSqlEscapes; +import ai.chat2db.plugin.clickhouse.ClickHouseSqlGuards; +import ai.chat2db.plugin.clickhouse.identifier.ClickHouseIdentifierProcessor; import ai.chat2db.spi.IColumnBuilder; import ai.chat2db.community.domain.api.enums.plugin.EditStatusEnum; import ai.chat2db.community.domain.api.model.metadata.ColumnType; @@ -101,8 +102,8 @@ public static String buildCreateColumnSqlSafely(TableColumn column) { private static String buildValidatedFallbackColumn(TableColumn column) { StringBuilder script = new StringBuilder(); - script.append(ClickHouseSqlEscapes.quoteIdentifier(column.getName())).append(" "); - String columnType = ClickHouseSqlEscapes.requireColumnTypeExpression(column.getColumnType()); + script.append(ClickHouseIdentifierProcessor.INSTANCE.quoteIdentifier(column.getName())).append(" "); + String columnType = ClickHouseSqlGuards.requireColumnTypeExpression(column.getColumnType()); if (column.getNullable() != null && 1 == column.getNullable() && isNullableWrappable(columnType)) { columnType = "Nullable(" + columnType + ")"; } @@ -112,7 +113,7 @@ private static String buildValidatedFallbackColumn(TableColumn column) { script.append(defaultValue).append(" "); } if (StringUtils.isNotBlank(column.getComment())) { - script.append("COMMENT '").append(ClickHouseSqlEscapes.escapeSqlLiteral(column.getComment())).append("' "); + script.append("COMMENT '").append(ClickHouseIdentifierProcessor.INSTANCE.escapeString(column.getComment())).append("' "); } return script.toString(); } @@ -134,7 +135,7 @@ private static String buildFallbackDefaultValue(TableColumn column) { if ("NULL".equalsIgnoreCase(column.getDefaultValue().trim())) { return "DEFAULT NULL"; } - return "DEFAULT " + validateDefaultExpression(column.getDefaultValue()); + return "DEFAULT " + ClickHouseSqlGuards.escapeDefaultExpression(column.getDefaultValue()); } public static List 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)); + } }