From 20541fa505f879f302dc09dfa9c096311161cc9d Mon Sep 17 00:00:00 2001 From: HandSonic <8078023+handsonic@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:54:43 +0800 Subject: [PATCH 1/8] fix(xugudb): escape SQL identifiers and literals in metadata/DDL paths (#1914) --- .../chat2db/plugin/xugudb/XUGUDBManager.java | 24 +-- .../chat2db/plugin/xugudb/XUGUDBMetaData.java | 30 ++-- .../plugin/xugudb/XugudbSqlEscapes.java | 26 +++ .../xugudb/builder/XUGUDBSqlBuilder.java | 15 +- .../enums/type/XUGUDBColumnTypeEnum.java | 41 +++-- .../enums/type/XUGUDBIndexTypeEnum.java | 20 ++- .../plugin/xugudb/XugudbSqlEscapesTest.java | 151 ++++++++++++++++++ 7 files changed, 257 insertions(+), 50 deletions(-) create mode 100644 chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapes.java create mode 100644 chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapesTest.java diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBManager.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBManager.java index 27edaae5d7..84f4d31d51 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBManager.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBManager.java @@ -27,7 +27,7 @@ public class XUGUDBManager extends DefaultDBManager implements IDbManager { private String format(String tableName) { - return "\"" + tableName + "\""; + return XugudbSqlEscapes.quoteIdentifier(tableName); } @@ -54,7 +54,7 @@ public void exportDatabase(Connection connection, String databaseName, String sc } private void exportTables(Connection connection, String schemaName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(SQL_SELECT_TABLE_NAME_ALL_TABLES, schemaName); + String sql = String.format(SQL_SELECT_TABLE_NAME_ALL_TABLES, XugudbSqlEscapes.escapeSqlLiteral(schemaName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { while (resultSet.next()) { String tableName = resultSet.getString("TABLE_NAME"); @@ -71,7 +71,7 @@ private void exportTable(Connection connection, String tableName, String schemaN (SELECT dbms_metadata.get_ddl('TABLE', '%s', '%s') FROM dual) AS ddl FROM dual; """; - try (PreparedStatement statement = connection.prepareStatement(String.format(sql, tableName, tableName, schemaName)); ResultSet resultSet = statement.executeQuery()) { + try (PreparedStatement statement = connection.prepareStatement(String.format(sql, XugudbSqlEscapes.escapeSqlLiteral(tableName), XugudbSqlEscapes.escapeSqlLiteral(tableName), XugudbSqlEscapes.escapeSqlLiteral(schemaName))); ResultSet resultSet = statement.executeQuery()) { String formatSchemaName = format(schemaName); String formatTableName = format(tableName); if (resultSet.next()) { @@ -82,7 +82,7 @@ private void exportTable(Connection connection, String tableName, String schemaN String comment = resultSet.getString("comments"); if (StringUtils.isNotBlank(comment)) { sqlBuilder.append(SQL_COMMENT_TABLE).append(formatSchemaName).append(".").append(formatTableName) - .append(" IS ").append("'").append(comment).append("';"); + .append(" IS ").append("'").append(XugudbSqlEscapes.escapeSqlLiteral(comment)).append("';"); } asyncContext.write(sqlBuilder.toString()); exportTableColumnComment(connection, schemaName, tableName, asyncContext); @@ -95,14 +95,14 @@ private void exportTable(Connection connection, String tableName, String schemaN private void exportTableColumnComment(Connection connection, String schemaName, String tableName, AsyncContext asyncContext) throws SQLException { String sql = String.format(SQL_SELECT_COLNAME_COMMENT_SYS_SYSCOLUMNCOMMENTS + - "where SCHNAME = '%s' and TVNAME = '%s'and TABLE_TYPE = 'TABLE';", schemaName, tableName); + "where SCHNAME = '%s' and TVNAME = '%s'and TABLE_TYPE = 'TABLE';", XugudbSqlEscapes.escapeSqlLiteral(schemaName), XugudbSqlEscapes.escapeSqlLiteral(tableName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { while (resultSet.next()) { String columnName = resultSet.getString("COLNAME"); String comment = resultSet.getString("COMMENT$"); StringBuilder sqlBuilder = new StringBuilder(); sqlBuilder.append(SQL_COMMENT_COLUMN).append(format(schemaName)).append(".").append(format(tableName)) - .append(".").append(format(columnName)).append(" IS ").append("'").append(comment).append("';").append("\n"); + .append(".").append(format(columnName)).append(" IS ").append("'").append(XugudbSqlEscapes.escapeSqlLiteral(comment)).append("';").append("\n"); asyncContext.write(sqlBuilder.toString()); } } @@ -119,7 +119,7 @@ private void exportViews(Connection connection, String schemaName, AsyncContext } private void exportView(Connection connection, String viewName, String schemaName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(SQL_SELECT_DBMS_METADATA_GET_DDL, viewName, schemaName); + String sql = String.format(SQL_SELECT_DBMS_METADATA_GET_DDL, XugudbSqlEscapes.escapeSqlLiteral(viewName), XugudbSqlEscapes.escapeSqlLiteral(schemaName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { if (resultSet.next()) { StringBuilder sqlBuilder = new StringBuilder(); @@ -139,7 +139,7 @@ private void exportProcedures(Connection connection, String schemaName, AsyncCon } private void exportProcedure(Connection connection, String schemaName, String procedureName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(ROUTINES_SQL, "PROC", schemaName, procedureName); + String sql = String.format(ROUTINES_SQL, "PROC", XugudbSqlEscapes.escapeSqlLiteral(schemaName), XugudbSqlEscapes.escapeSqlLiteral(procedureName)); try (PreparedStatement statement = connection.prepareStatement(sql); ResultSet resultSet = statement.executeQuery()) { if (resultSet.next()) { StringBuilder sqlBuilder = new StringBuilder(); @@ -150,7 +150,7 @@ private void exportProcedure(Connection connection, String schemaName, String pr } private void exportTriggers(Connection connection, String schemaName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(TRIGGER_SQL_LIST, schemaName); + String sql = String.format(TRIGGER_SQL_LIST, XugudbSqlEscapes.escapeSqlLiteral(schemaName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { while (resultSet.next()) { String triggerName = resultSet.getString("TRIGGER_NAME"); @@ -160,7 +160,7 @@ private void exportTriggers(Connection connection, String schemaName, AsyncConte } private void exportTrigger(Connection connection, String schemaName, String triggerName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(TRIGGER_SQL, schemaName, triggerName); + String sql = String.format(TRIGGER_SQL, XugudbSqlEscapes.escapeSqlLiteral(schemaName), XugudbSqlEscapes.escapeSqlLiteral(triggerName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { if (resultSet.next()) { StringBuilder sqlBuilder = new StringBuilder(); @@ -179,7 +179,7 @@ public void connectDatabase(Connection connection, String database) { } String schemaName = connectInfo.getSchemaName(); try { - DefaultSQLExecutor.getInstance().execute(connection, String.format(SQL_SET_SCHEMA, schemaName)); + DefaultSQLExecutor.getInstance().execute(connection, String.format(SQL_SET_SCHEMA, format(schemaName))); } catch (SQLException e) { throw new RuntimeException(e); } @@ -187,6 +187,6 @@ public void connectDatabase(Connection connection, String database) { @Override public String dropTable(Connection connection, String databaseName, String schemaName, String tableName) { - return String.format(SQL_DROP_TABLE_EXISTS, tableName); + return String.format(SQL_DROP_TABLE_EXISTS, format(tableName)); } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBMetaData.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBMetaData.java index 3bda420a03..b4f630a31d 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBMetaData.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBMetaData.java @@ -45,7 +45,7 @@ public List databases(Connection connection) { @Override public List schemas(Connection connection, String databaseName) { - String sql = "select s.schema_name, db.db_name from all_schemas s left join all_databases db on db.db_id = s.db_id where db.db_name = '" + databaseName + "'"; + String sql = "select s.schema_name, db.db_name from all_schemas s left join all_databases db on db.db_id = s.db_id where db.db_name = '" + XugudbSqlEscapes.escapeSqlLiteral(databaseName) + "'"; List schemas = DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { List databases = new ArrayList<>(); @@ -63,7 +63,7 @@ public List schemas(Connection connection, String databaseName) { } private String format(String tableName) { - return "\"" + tableName + "\""; + return XugudbSqlEscapes.quoteIdentifier(tableName); } @Override @@ -74,7 +74,7 @@ public String tableDDL(Connection connection, String databaseName, String schema FROM dual; """; StringBuilder ddlBuilder = new StringBuilder(); - String tableDDLSql = String.format(sql, schemaName, tableName); + String tableDDLSql = String.format(sql, XugudbSqlEscapes.escapeSqlLiteral(schemaName), XugudbSqlEscapes.escapeSqlLiteral(tableName)); DefaultSQLExecutor.getInstance().execute(connection, tableDDLSql, resultSet -> { if (resultSet.next()) { String ddl = resultSet.getString("ddl"); @@ -90,7 +90,7 @@ public String tableDDL(Connection connection, String databaseName, String schema @Override public List functions(Connection connection, String databaseName, String schemaName) { List functions = new ArrayList<>(); - String sql = String.format(FUNCTIONS_SQL, databaseName, schemaName); + String sql = String.format(FUNCTIONS_SQL, XugudbSqlEscapes.escapeSqlLiteral(databaseName), XugudbSqlEscapes.escapeSqlLiteral(schemaName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { while (resultSet.next()) { Function function = new Function(); @@ -109,7 +109,7 @@ public List functions(Connection connection, String databaseName, Stri public Function function(Connection connection, @NotEmpty String databaseName, String schemaName, String functionName) { - String sql = String.format(ROUTINES_SQL, databaseName, schemaName, functionName); + String sql = String.format(ROUTINES_SQL, XugudbSqlEscapes.escapeSqlLiteral(databaseName), XugudbSqlEscapes.escapeSqlLiteral(schemaName), XugudbSqlEscapes.escapeSqlLiteral(functionName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { StringBuilder sb = new StringBuilder(); while (resultSet.next()) { @@ -131,7 +131,7 @@ public Function function(Connection connection, @NotEmpty String databaseName, S @Override public Procedure procedure(Connection connection, @NotEmpty String databaseName, String schemaName, String procedureName) { - String sql = String.format(PROCEDURE_SQL, databaseName, schemaName, procedureName); + String sql = String.format(PROCEDURE_SQL, XugudbSqlEscapes.escapeSqlLiteral(databaseName), XugudbSqlEscapes.escapeSqlLiteral(schemaName), XugudbSqlEscapes.escapeSqlLiteral(procedureName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { StringBuilder sb = new StringBuilder(); while (resultSet.next()) { @@ -152,7 +152,7 @@ public Procedure procedure(Connection connection, @NotEmpty String databaseName, @Override public List procedures(Connection connection, String databaseName, String schemaName) { List procedures = new ArrayList<>(); - String sql = String.format(PROCEDURES_SQL, databaseName, schemaName); + String sql = String.format(PROCEDURES_SQL, XugudbSqlEscapes.escapeSqlLiteral(databaseName), XugudbSqlEscapes.escapeSqlLiteral(schemaName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { while (resultSet.next()) { Procedure procedure = new Procedure(); @@ -173,7 +173,7 @@ public List procedures(Connection connection, String databaseName, St @Override public List triggers(Connection connection, String databaseName, String schemaName) { List triggers = new ArrayList<>(); - String sql = String.format(TRIGGER_SQL_LIST, databaseName, schemaName); + String sql = String.format(TRIGGER_SQL_LIST, XugudbSqlEscapes.escapeSqlLiteral(databaseName), XugudbSqlEscapes.escapeSqlLiteral(schemaName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { while (resultSet.next()) { Trigger trigger = new Trigger(); @@ -190,7 +190,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, databaseName, schemaName, triggerName); + String sql = String.format(TRIGGER_SQL, XugudbSqlEscapes.escapeSqlLiteral(databaseName), XugudbSqlEscapes.escapeSqlLiteral(schemaName), XugudbSqlEscapes.escapeSqlLiteral(triggerName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Trigger trigger = new Trigger(); trigger.setDatabaseName(databaseName); @@ -207,7 +207,7 @@ public Trigger trigger(Connection connection, @NotEmpty String databaseName, Str @Override public List views(Connection connection, String databaseName, String schemaName) { - String sql = String.format(VIEW_SQL_LIST, databaseName, schemaName); + String sql = String.format(VIEW_SQL_LIST, XugudbSqlEscapes.escapeSqlLiteral(databaseName), XugudbSqlEscapes.escapeSqlLiteral(schemaName)); List
tables = new ArrayList<>(); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Table table = new Table(); @@ -227,7 +227,7 @@ public List
views(Connection connection, String databaseName, String sche @Override public Table view(Connection connection, String databaseName, String schemaName, String viewName) { - String sql = String.format(VIEW_SQL, databaseName, schemaName, viewName); + String sql = String.format(VIEW_SQL, XugudbSqlEscapes.escapeSqlLiteral(databaseName), XugudbSqlEscapes.escapeSqlLiteral(schemaName), XugudbSqlEscapes.escapeSqlLiteral(viewName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Table table = new Table(); table.setDatabaseName(databaseName); @@ -244,7 +244,7 @@ public Table view(Connection connection, String databaseName, String schemaName, @Override public List indexes(Connection connection, String databaseName, String schemaName, String tableName) { - String sql = String.format(INDEX_SQL, schemaName, tableName); + String sql = String.format(INDEX_SQL, XugudbSqlEscapes.escapeSqlLiteral(schemaName), XugudbSqlEscapes.escapeSqlLiteral(tableName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { LinkedHashMap map = new LinkedHashMap(); while (resultSet.next()) { @@ -294,7 +294,7 @@ private List getTableIndexColumn(ResultSet resultSet) throws S @Override public List columns(Connection connection, String databaseName, String schemaName, String tableName) { - String sql = String.format(SELECT_TABLE_COLUMNS, databaseName, schemaName, tableName); + String sql = String.format(SELECT_TABLE_COLUMNS, XugudbSqlEscapes.escapeSqlLiteral(databaseName), XugudbSqlEscapes.escapeSqlLiteral(schemaName), XugudbSqlEscapes.escapeSqlLiteral(tableName)); List tableColumns = new ArrayList<>(); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { while (resultSet.next()) { @@ -342,9 +342,9 @@ public TableMeta getTableMeta(String databaseName, String schemaName, String tab @Override public String getMetaDataName(String... names) { if (Arrays.stream(names).count() > 1) { - return Arrays.stream(names).skip(1).filter(name -> StringUtils.isNotBlank(name)).map(name -> "\"" + name + "\"").collect(Collectors.joining(".")); + return Arrays.stream(names).skip(1).filter(name -> StringUtils.isNotBlank(name)).map(name -> XugudbSqlEscapes.quoteIdentifier(name)).collect(Collectors.joining(".")); } - 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(name -> XugudbSqlEscapes.quoteIdentifier(name)).collect(Collectors.joining(".")); } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapes.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapes.java new file mode 100644 index 0000000000..d421efe531 --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapes.java @@ -0,0 +1,26 @@ +package ai.chat2db.plugin.xugudb; + +public final class XugudbSqlEscapes { + + private XugudbSqlEscapes() { + } + + public static String escapeSqlLiteral(String value) { + return value == null ? "" : value.replace("'", "''"); + } + + public static String escapeIdentifier(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 stripped.replace("\"", "\"\""); + } + + public static String quoteIdentifier(String identifier) { + return "\"" + escapeIdentifier(identifier) + "\""; + } +} diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilder.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilder.java index d5c54d82c3..81ee3a1cd4 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilder.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilder.java @@ -2,6 +2,7 @@ import ai.chat2db.spi.constant.SQLConstants; +import ai.chat2db.plugin.xugudb.XugudbSqlEscapes; import ai.chat2db.plugin.xugudb.enums.type.XUGUDBColumnTypeEnum; import ai.chat2db.plugin.xugudb.enums.type.XUGUDBIndexTypeEnum; import ai.chat2db.community.domain.api.enums.plugin.EditStatusEnum; @@ -34,7 +35,7 @@ public class XUGUDBSqlBuilder extends DefaultSqlBuilder { public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfig) { StringBuilder script = new StringBuilder(); - script.append(SQL_CREATE_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(table.getSchemaName()).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(table.getName()).append(VALUE_DOUBLE_QUOTE_OPEN_PAREN).append(SQLConstants.LINE_SEPARATOR); + script.append(SQL_CREATE_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(XugudbSqlEscapes.escapeIdentifier(table.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(XugudbSqlEscapes.escapeIdentifier(table.getName())).append(VALUE_DOUBLE_QUOTE_OPEN_PAREN).append(SQLConstants.LINE_SEPARATOR); for (TableColumn column : table.getColumnList()) { if (StringUtils.isBlank(column.getName()) || StringUtils.isBlank(column.getColumnType())) { @@ -78,13 +79,13 @@ public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfi private String buildTableComment(Table table) { StringBuilder script = new StringBuilder(); - script.append(SQL_COMMENT_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(table.getSchemaName()).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(table.getName()).append(VALUE_DOUBLE_QUOTE_IS_SINGLE_QUOTE).append(table.getComment()).append(SQLConstants.SINGLE_QUOTE); + script.append(SQL_COMMENT_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(XugudbSqlEscapes.escapeIdentifier(table.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(XugudbSqlEscapes.escapeIdentifier(table.getName())).append(VALUE_DOUBLE_QUOTE_IS_SINGLE_QUOTE).append(XugudbSqlEscapes.escapeSqlLiteral(table.getComment())).append(SQLConstants.SINGLE_QUOTE); return script.toString(); } private String buildComment(TableColumn column) { StringBuilder script = new StringBuilder(); - script.append(SQL_COMMENT_COLUMN).append(SQLConstants.DOUBLE_QUOTE).append(column.getSchemaName()).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(column.getTableName()).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(column.getName()).append(VALUE_DOUBLE_QUOTE_IS_SINGLE_QUOTE).append(column.getComment()).append(SQLConstants.SINGLE_QUOTE); + script.append(SQL_COMMENT_COLUMN).append(SQLConstants.DOUBLE_QUOTE).append(XugudbSqlEscapes.escapeIdentifier(column.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(XugudbSqlEscapes.escapeIdentifier(column.getTableName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(XugudbSqlEscapes.escapeIdentifier(column.getName())).append(VALUE_DOUBLE_QUOTE_IS_SINGLE_QUOTE).append(XugudbSqlEscapes.escapeSqlLiteral(column.getComment())).append(SQLConstants.SINGLE_QUOTE); return script.toString(); } @@ -93,8 +94,8 @@ public String buildAlterTable(Table oldTable, Table newTable) { StringBuilder script = new StringBuilder(); if (!StringUtils.equalsIgnoreCase(oldTable.getName(), newTable.getName())) { - script.append(SQL_ALTER_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(oldTable.getSchemaName()).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(oldTable.getName()).append(SQLConstants.DOUBLE_QUOTE); - script.append(SQLConstants.SPACE).append(SQL_RENAME).append(SQLConstants.DOUBLE_QUOTE).append(newTable.getName()).append(SQLConstants.DOUBLE_QUOTE).append(SQLConstants.SEMICOLON_LINE_SEPARATOR); + script.append(SQL_ALTER_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(XugudbSqlEscapes.escapeIdentifier(oldTable.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(XugudbSqlEscapes.escapeIdentifier(oldTable.getName())).append(SQLConstants.DOUBLE_QUOTE); + script.append(SQLConstants.SPACE).append(SQL_RENAME).append(SQLConstants.DOUBLE_QUOTE).append(XugudbSqlEscapes.escapeIdentifier(newTable.getName())).append(SQLConstants.DOUBLE_QUOTE).append(SQLConstants.SEMICOLON_LINE_SEPARATOR); } if (!StringUtils.equalsIgnoreCase(oldTable.getComment(), newTable.getComment())) { script.append(SQLConstants.EMPTY).append(buildTableComment(newTable)).append(SQLConstants.SEMICOLON_LINE_SEPARATOR); @@ -153,9 +154,9 @@ public String buildPageLimit(PageLimitRequest request) { @Override public String buildCreateSchema(Schema schema) { StringBuilder sqlBuilder = new StringBuilder(); - sqlBuilder.append(SQL_CREATE_SCHEMA+schema.getName()+SQLConstants.DOUBLE_QUOTE); + sqlBuilder.append(SQL_CREATE_SCHEMA+XugudbSqlEscapes.escapeIdentifier(schema.getName())+SQLConstants.DOUBLE_QUOTE); if(StringUtils.isNotBlank(schema.getOwner())){ - sqlBuilder.append(SQLConstants.SCHEMA_AUTHORIZATION_SQL).append(schema.getOwner()); + sqlBuilder.append(SQLConstants.SCHEMA_AUTHORIZATION_SQL).append(XugudbSqlEscapes.quoteIdentifier(schema.getOwner())); } return sqlBuilder.toString(); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java index db3ce24557..fad14254ed 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java @@ -1,6 +1,7 @@ package ai.chat2db.plugin.xugudb.enums.type; import ai.chat2db.spi.IColumnBuilder; +import ai.chat2db.plugin.xugudb.XugudbSqlEscapes; 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; @@ -8,6 +9,7 @@ import org.apache.commons.lang3.StringUtils; import java.util.*; +import java.util.regex.Pattern; import static ai.chat2db.plugin.xugudb.constant.XUGUDBColumnTypeEnumConstants.*; public enum XUGUDBColumnTypeEnum implements IColumnBuilder { @@ -121,7 +123,7 @@ public String buildCreateColumnSql(TableColumn column) { } StringBuilder script = new StringBuilder(); - script.append("\"").append(column.getName()).append("\"").append(" "); + script.append("\"").append(XugudbSqlEscapes.escapeIdentifier(column.getName())).append("\"").append(" "); script.append(buildDataType(column, type)).append(" "); @@ -140,8 +142,8 @@ public String buildUpdateColumnSql(TableColumn column) { return ""; } StringBuilder script = new StringBuilder(); - script.append(SQL_ALTER_TABLE).append("\"").append(column.getSchemaName()).append("\".\"").append(column.getTableName()).append("\""); - script.append(" ").append("MODIFY (").append("\"").append(column.getName()).append("\"").append(" "); + script.append(SQL_ALTER_TABLE).append("\"").append(XugudbSqlEscapes.escapeIdentifier(column.getSchemaName())).append("\".\"").append(XugudbSqlEscapes.escapeIdentifier(column.getTableName())).append("\""); + script.append(" ").append("MODIFY (").append("\"").append(XugudbSqlEscapes.escapeIdentifier(column.getName())).append("\"").append(" "); boolean isModify = false; Integer oldColumnSize = Optional.ofNullable(column.getOldColumn()) .map(TableColumn::getColumnSize) @@ -209,7 +211,26 @@ private String buildDefaultValue(TableColumn column, XUGUDBColumnTypeEnum type) return StringUtils.join("DEFAULT NULL"); } - return StringUtils.join("DEFAULT ", column.getDefaultValue()); + return StringUtils.join("DEFAULT ", validateDefaultValue(column.getDefaultValue())); + } + + private static final Pattern DEFAULT_VALUE_PATTERN = Pattern.compile( + "^(-?\\d+(\\.\\d+)?|'([^']|'')*'|[A-Za-z_][A-Za-z0-9_]*(\\([A-Za-z0-9_ ',.+\\-]*\\))?)$"); + + private static final Pattern UNIT_PATTERN = Pattern.compile("^[A-Za-z]+$"); + + private static String validateDefaultValue(String defaultValue) { + if (!DEFAULT_VALUE_PATTERN.matcher(defaultValue.trim()).matches()) { + throw new IllegalArgumentException("Unsupported column default value: " + defaultValue); + } + return defaultValue; + } + + private static String validateUnit(String unit) { + if (!UNIT_PATTERN.matcher(unit.trim()).matches()) { + throw new IllegalArgumentException("Unsupported length unit: " + unit); + } + return unit; } private String buildDataType(TableColumn column, XUGUDBColumnTypeEnum type) { @@ -220,7 +241,7 @@ private String buildDataType(TableColumn column, XUGUDBColumnTypeEnum type) { if (column.getColumnSize() != null && StringUtils.isEmpty(column.getUnit())) { script.append("(").append(column.getColumnSize()).append(")"); } else if (column.getColumnSize() != null && !StringUtils.isEmpty(column.getUnit())) { - script.append("(").append(column.getColumnSize()).append(" ").append(column.getUnit()).append(")"); + script.append("(").append(column.getColumnSize()).append(" ").append(validateUnit(column.getUnit())).append(")"); } return script.toString(); } @@ -274,21 +295,21 @@ public String buildModifyColumn(TableColumn tableColumn) { if (EditStatusEnum.DELETE.name().equals(tableColumn.getEditStatus())) { StringBuilder script = new StringBuilder(); - script.append(SQL_ALTER_TABLE).append("\"").append(tableColumn.getSchemaName()).append("\".\"").append(tableColumn.getTableName()).append("\""); - script.append(" ").append(SQL_DROP_COLUMN).append("\"").append(tableColumn.getName()).append("\""); + script.append(SQL_ALTER_TABLE).append("\"").append(XugudbSqlEscapes.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(XugudbSqlEscapes.escapeIdentifier(tableColumn.getTableName())).append("\""); + script.append(" ").append(SQL_DROP_COLUMN).append("\"").append(XugudbSqlEscapes.escapeIdentifier(tableColumn.getName())).append("\""); return script.toString(); } if (EditStatusEnum.ADD.name().equals(tableColumn.getEditStatus())) { StringBuilder script = new StringBuilder(); - script.append(SQL_ALTER_TABLE).append("\"").append(tableColumn.getSchemaName()).append("\".\"").append(tableColumn.getTableName()).append("\""); + script.append(SQL_ALTER_TABLE).append("\"").append(XugudbSqlEscapes.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(XugudbSqlEscapes.escapeIdentifier(tableColumn.getTableName())).append("\""); script.append(" ").append("ADD (").append(buildCreateColumnSql(tableColumn)).append(")"); return script.toString(); } if (EditStatusEnum.MODIFY.name().equals(tableColumn.getEditStatus())) { StringBuilder script = new StringBuilder(); if (!StringUtils.equalsIgnoreCase(tableColumn.getOldName(), tableColumn.getName())) { - script.append(SQL_ALTER_TABLE).append("\"").append(tableColumn.getSchemaName()).append("\".\"").append(tableColumn.getTableName()).append("\""); - script.append(" ").append(SQL_RENAME_COLUMN).append("\"").append(tableColumn.getOldName()).append("\"").append(" TO ").append("\"").append(tableColumn.getName()).append("\" ").append(";\n").append(buildUpdateColumnSql(tableColumn)); + script.append(SQL_ALTER_TABLE).append("\"").append(XugudbSqlEscapes.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(XugudbSqlEscapes.escapeIdentifier(tableColumn.getTableName())).append("\""); + script.append(" ").append(SQL_RENAME_COLUMN).append("\"").append(XugudbSqlEscapes.escapeIdentifier(tableColumn.getOldName())).append("\"").append(" TO ").append("\"").append(XugudbSqlEscapes.escapeIdentifier(tableColumn.getName())).append("\" ").append(";\n").append(buildUpdateColumnSql(tableColumn)); } else { script.append(buildUpdateColumnSql(tableColumn)).append("\n"); } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBIndexTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBIndexTypeEnum.java index 34c59d6e5b..f6ede5105e 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBIndexTypeEnum.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBIndexTypeEnum.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.xugudb.XugudbSqlEscapes; import org.apache.commons.lang3.StringUtils; import java.util.Arrays; @@ -72,14 +73,14 @@ public static XUGUDBIndexTypeEnum getByType(String type) { public String buildIndexScript(TableIndex tableIndex) { StringBuilder script = new StringBuilder(); if (PRIMARY_KEY.equals(this)) { - script.append(SQL_ALTER_TABLE_2).append(tableIndex.getSchemaName()).append("\".\"").append(tableIndex.getTableName()).append("\" ADD PRIMARY KEY ").append(buildIndexColumn(tableIndex)); + script.append(SQL_ALTER_TABLE_2).append(XugudbSqlEscapes.escapeIdentifier(tableIndex.getSchemaName())).append("\".\"").append(XugudbSqlEscapes.escapeIdentifier(tableIndex.getTableName())).append("\" ADD PRIMARY KEY ").append(buildIndexColumn(tableIndex)); } else { if (UNIQUE.equals(this)) { script.append(SQL_CREATE_UNIQUE_INDEX); } else { script.append(SQL_CREATE_INDEX); } - script.append(buildIndexName(tableIndex)).append(SQL_ON).append(tableIndex.getSchemaName()).append("\".\"").append(tableIndex.getTableName()).append("\" ").append(buildIndexColumn(tableIndex)); + script.append(buildIndexName(tableIndex)).append(SQL_ON).append(XugudbSqlEscapes.escapeIdentifier(tableIndex.getSchemaName())).append("\".\"").append(XugudbSqlEscapes.escapeIdentifier(tableIndex.getTableName())).append("\" ").append(buildIndexColumn(tableIndex)); } return script.toString(); } @@ -90,9 +91,9 @@ 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("\"").append(XugudbSqlEscapes.escapeIdentifier(column.getColumnName())).append("\""); if (!StringUtils.isBlank(column.getAscOrDesc()) && !PRIMARY_KEY.equals(this)) { - script.append(" ").append(column.getAscOrDesc()); + script.append(" ").append(validateAscOrDesc(column.getAscOrDesc())); } script.append(","); } @@ -103,7 +104,14 @@ private String buildIndexColumn(TableIndex tableIndex) { } private String buildIndexName(TableIndex tableIndex) { - return "\"" + tableIndex.getSchemaName() + "\"." + "\"" + tableIndex.getName() + "\""; + return "\"" + XugudbSqlEscapes.escapeIdentifier(tableIndex.getSchemaName()) + "\"." + "\"" + XugudbSqlEscapes.escapeIdentifier(tableIndex.getName()) + "\""; + } + + private static String validateAscOrDesc(String ascOrDesc) { + if (!"ASC".equalsIgnoreCase(ascOrDesc.trim()) && !"DESC".equalsIgnoreCase(ascOrDesc.trim())) { + throw new IllegalArgumentException("Unsupported index sort order: " + ascOrDesc); + } + return ascOrDesc; } public String buildModifyIndex(TableIndex tableIndex) { @@ -121,7 +129,7 @@ public String buildModifyIndex(TableIndex tableIndex) { private String buildDropIndex(TableIndex tableIndex) { if (XUGUDBIndexTypeEnum.PRIMARY_KEY.getName().equals(tableIndex.getType())) { - String tableName = "\"" + tableIndex.getSchemaName() + "\"." + "\"" + tableIndex.getTableName() + "\""; + String tableName = "\"" + XugudbSqlEscapes.escapeIdentifier(tableIndex.getSchemaName()) + "\"." + "\"" + XugudbSqlEscapes.escapeIdentifier(tableIndex.getTableName()) + "\""; return StringUtils.join(SQL_ALTER_TABLE,tableName,SQL_DROP_PRIMARY_KEY); } StringBuilder script = new StringBuilder(); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapesTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapesTest.java new file mode 100644 index 0000000000..fd25656e51 --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapesTest.java @@ -0,0 +1,151 @@ +package ai.chat2db.plugin.xugudb; + +import ai.chat2db.community.domain.api.config.TableBuilderConfig; +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.model.metadata.TableIndexColumn; +import ai.chat2db.plugin.xugudb.builder.XUGUDBSqlBuilder; +import ai.chat2db.plugin.xugudb.enums.type.XUGUDBColumnTypeEnum; +import ai.chat2db.plugin.xugudb.enums.type.XUGUDBIndexTypeEnum; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class XugudbSqlEscapesTest { + + private final XUGUDBSqlBuilder builder = new XUGUDBSqlBuilder(); + + @Test + void escapeSqlLiteralDoublesSingleQuotes() { + assertEquals("o''brien", XugudbSqlEscapes.escapeSqlLiteral("o'brien")); + assertEquals("''", XugudbSqlEscapes.escapeSqlLiteral("'")); + assertEquals("plain", XugudbSqlEscapes.escapeSqlLiteral("plain")); + assertEquals("", XugudbSqlEscapes.escapeSqlLiteral(null)); + } + + @Test + void escapeIdentifierDoublesEmbeddedQuotesAndStripsSurroundingQuotes() { + assertEquals("ta\"\"ble", XugudbSqlEscapes.escapeIdentifier("ta\"ble")); + assertEquals("foo", XugudbSqlEscapes.escapeIdentifier("\"foo\"")); + assertEquals("fo\"\"o", XugudbSqlEscapes.escapeIdentifier("\"fo\"o\"")); + assertEquals("plain", XugudbSqlEscapes.escapeIdentifier("plain")); + assertEquals("", XugudbSqlEscapes.escapeIdentifier(null)); + } + + @Test + void quoteIdentifierWrapsEscapedIdentifier() { + assertEquals("\"plain\"", XugudbSqlEscapes.quoteIdentifier("plain")); + assertEquals("\"ta\"\"ble\"", XugudbSqlEscapes.quoteIdentifier("ta\"ble")); + } + + @Test + void createTableNeutralizesMaliciousSchemaName() { + Table table = Table.builder() + .schemaName("evil\";DROP TABLE t;--") + .name("sample_table") + .columnList(List.of(column("id", "INTEGER"))) + .indexList(List.of()) + .build(); + + String sql = builder.buildCreateTable(table, TableBuilderConfig.defaultConfig()); + + assertTrue(sql.contains("\"evil\"\";DROP TABLE t;--\".\"sample_table\""), sql); + assertFalse(sql.contains("\"evil\";"), sql); + } + + @Test + void createSchemaNeutralizesMaliciousNameAndOwner() { + Schema schema = new Schema(); + schema.setName("sch\"; DROP TABLE x; --"); + schema.setOwner("own\"; GRANT; --"); + + String sql = builder.buildCreateSchema(schema); + + assertTrue(sql.contains("CREATE SCHEMA \"sch\"\"; DROP TABLE x; --\""), sql); + assertTrue(sql.contains("AUTHORIZATION \"own\"\"; GRANT; --\""), sql); + assertFalse(sql.contains("\"sch\";"), sql); + assertFalse(sql.contains("AUTHORIZATION \"own\";"), sql); + } + + @Test + void indexScriptNeutralizesMaliciousColumnName() { + TableIndex tableIndex = TableIndex.builder() + .schemaName("app") + .tableName("sample_table") + .name("idx") + .type("Normal") + .columnList(List.of(TableIndexColumn.builder() + .columnName("col\"; DROP TABLE t; --") + .build())) + .build(); + + String sql = XUGUDBIndexTypeEnum.NORMAL.buildIndexScript(tableIndex); + + assertTrue(sql.contains("(\"col\"\"; DROP TABLE t; --\")"), sql); + assertFalse(sql.contains("\"col\";"), sql); + } + + @Test + void maliciousDefaultValueIsRejected() { + TableColumn column = column("id", "INTEGER"); + column.setDefaultValue("0; DROP TABLE users; --"); + + assertThrows(IllegalArgumentException.class, + () -> XUGUDBColumnTypeEnum.INTEGER.buildCreateColumnSql(column)); + } + + @Test + void validDefaultValuesAreAccepted() { + TableColumn numeric = column("id", "INTEGER"); + numeric.setDefaultValue("0"); + assertTrue(XUGUDBColumnTypeEnum.INTEGER.buildCreateColumnSql(numeric).contains("DEFAULT 0")); + + TableColumn keyword = column("created", "TIMESTAMP"); + keyword.setDefaultValue("CURRENT_TIMESTAMP"); + assertTrue(XUGUDBColumnTypeEnum.TIMESTAMP.buildCreateColumnSql(keyword).contains("DEFAULT CURRENT_TIMESTAMP")); + } + + @Test + void maliciousUnitIsRejected() { + TableColumn column = column("name_col", "VARCHAR"); + column.setColumnSize(10); + column.setUnit("BYTE); DROP TABLE t; --"); + + assertThrows(IllegalArgumentException.class, + () -> XUGUDBColumnTypeEnum.VARCHAR.buildCreateColumnSql(column)); + } + + @Test + void maliciousIndexSortOrderIsRejected() { + TableIndex tableIndex = TableIndex.builder() + .schemaName("app") + .tableName("sample_table") + .name("idx") + .type("Normal") + .columnList(List.of(TableIndexColumn.builder() + .columnName("id") + .ascOrDesc("DESC; DROP TABLE t; --") + .build())) + .build(); + + assertThrows(IllegalArgumentException.class, + () -> XUGUDBIndexTypeEnum.NORMAL.buildIndexScript(tableIndex)); + } + + private static TableColumn column(String name, String type) { + return TableColumn.builder() + .schemaName("app") + .tableName("sample_table") + .name(name) + .columnType(type) + .nullable(1) + .build(); + } +} From afab0be6b3974a2da992f134a9f44aa782ee5385 Mon Sep 17 00:00:00 2001 From: HandSonic <8078023+handsonic@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:16:19 +0800 Subject: [PATCH 2/8] fix(xugudb): quote identifiers in select/insert builders and harden column fallback (#1914) - Override quoteIdentifier/quoteQualifiedIdentifier/buildSelectTable/buildTableName/ buildColumns in XUGUDBSqlBuilder so inherited DefaultSqlBuilder DQL/DML paths (exportTableData, insert, update) no longer concatenate raw identifiers - Replace raw IColumnBuilder.buildDefaultColumn fallback with escaped name + anchored type-shape validation (reject otherwise) - Return trimmed values from default-value/unit validators - Document raw-identifier contract on XugudbSqlEscapes.escapeIdentifier - Add attack-string tests: malicious schema/table/column via buildSelectTable and buildInsert, comment literal end-to-end, fallback name/type, validator trimming --- .../plugin/xugudb/XugudbSqlEscapes.java | 9 +++ .../xugudb/builder/XUGUDBSqlBuilder.java | 37 +++++++++++ .../enums/type/XUGUDBColumnTypeEnum.java | 22 +++++-- .../plugin/xugudb/XugudbSqlEscapesTest.java | 66 +++++++++++++++++++ 4 files changed, 129 insertions(+), 5 deletions(-) diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapes.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapes.java index d421efe531..d01046ecbf 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapes.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapes.java @@ -9,6 +9,15 @@ public static String escapeSqlLiteral(String value) { return value == null ? "" : value.replace("'", "''"); } + /** + * Escapes an identifier for use inside double quotes by doubling embedded {@code "}. + *

+ * Callers MUST pass raw (unescaped) identifier values. Do NOT pass text that has already + * been escaped or quoted for SQL — it will be escaped again. For compatibility with + * database metadata that arrives already wrapped in double quotes, a single pair of + * surrounding {@code "} is stripped before escaping; an identifier that legitimately + * begins and ends with a {@code "} character cannot be represented through this API. + */ public static String escapeIdentifier(String identifier) { if (identifier == null) { return ""; diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilder.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilder.java index 81ee3a1cd4..753ec706e0 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilder.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilder.java @@ -15,7 +15,10 @@ import ai.chat2db.community.domain.api.config.TableBuilderConfig; import org.apache.commons.lang3.StringUtils; +import java.util.Arrays; +import java.util.List; import java.util.Objects; +import java.util.stream.Collectors; import static ai.chat2db.plugin.xugudb.constant.XUGUDBSqlBuilderConstants.*; public class XUGUDBSqlBuilder extends DefaultSqlBuilder { @@ -161,4 +164,38 @@ public String buildCreateSchema(Schema schema) { return sqlBuilder.toString(); } + + @Override + public String quoteIdentifier(String identifier) { + return XugudbSqlEscapes.quoteIdentifier(identifier); + } + + @Override + public String quoteQualifiedIdentifier(String... identifiers) { + return Arrays.stream(identifiers) + .filter(StringUtils::isNotBlank) + .map(XugudbSqlEscapes::quoteIdentifier) + .collect(Collectors.joining(SQLConstants.DOT)); + } + + @Override + public String buildSelectTable(String databaseName, String schemaName, String tableName) { + return SQLConstants.SELECT_ALL_FROM_SQL_PREFIX + quoteQualifiedIdentifier(databaseName, schemaName, tableName); + } + + @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 (columnList != null && !columnList.isEmpty()) { + script.append(SQLConstants.SPACE_OPEN_PARENTHESIS) + .append(columnList.stream() + .map(XugudbSqlEscapes::quoteIdentifier) + .collect(Collectors.joining(SQLConstants.COMMA))) + .append(SQLConstants.CLOSE_PARENTHESIS_SPACE); + } + } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java index fad14254ed..7a63ff7426 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java @@ -119,7 +119,7 @@ public ColumnType getColumnType() { public String buildCreateColumnSql(TableColumn column) { XUGUDBColumnTypeEnum type = COLUMN_TYPE_MAP.get(column.getColumnType().toUpperCase()); if (type == null) { - return buildDefaultColumn(column, false); + return buildFallbackColumn(column); } StringBuilder script = new StringBuilder(); @@ -165,6 +165,16 @@ public String buildUpdateColumnSql(TableColumn column) { return isModify ? script.toString() : ""; } + private static final Pattern FALLBACK_TYPE_PATTERN = Pattern.compile("\\A[A-Za-z]+(\\(\\d+(,\\d+)?\\))?\\z"); + + private static String buildFallbackColumn(TableColumn column) { + String columnType = column.getColumnType() == null ? "" : column.getColumnType().trim(); + if (!FALLBACK_TYPE_PATTERN.matcher(columnType).matches()) { + throw new IllegalArgumentException("Unsupported column type: " + column.getColumnType()); + } + return XugudbSqlEscapes.quoteIdentifier(column.getName()) + " " + columnType; + } + private String buildAutoIncrement(TableColumn column, XUGUDBColumnTypeEnum type) { if (!type.getColumnType().isSupportAutoIncrement()) { return ""; @@ -220,17 +230,19 @@ private String buildDefaultValue(TableColumn column, XUGUDBColumnTypeEnum type) private static final Pattern UNIT_PATTERN = Pattern.compile("^[A-Za-z]+$"); private static String validateDefaultValue(String defaultValue) { - if (!DEFAULT_VALUE_PATTERN.matcher(defaultValue.trim()).matches()) { + String trimmed = defaultValue.trim(); + if (!DEFAULT_VALUE_PATTERN.matcher(trimmed).matches()) { throw new IllegalArgumentException("Unsupported column default value: " + defaultValue); } - return defaultValue; + return trimmed; } private static String validateUnit(String unit) { - if (!UNIT_PATTERN.matcher(unit.trim()).matches()) { + String trimmed = unit.trim(); + if (!UNIT_PATTERN.matcher(trimmed).matches()) { throw new IllegalArgumentException("Unsupported length unit: " + unit); } - return unit; + return trimmed; } private String buildDataType(TableColumn column, XUGUDBColumnTypeEnum type) { diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapesTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapesTest.java index fd25656e51..616c727e26 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapesTest.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapesTest.java @@ -9,6 +9,7 @@ import ai.chat2db.plugin.xugudb.builder.XUGUDBSqlBuilder; import ai.chat2db.plugin.xugudb.enums.type.XUGUDBColumnTypeEnum; import ai.chat2db.plugin.xugudb.enums.type.XUGUDBIndexTypeEnum; +import ai.chat2db.spi.model.request.SingleInsertSqlRequest; import org.junit.jupiter.api.Test; import java.util.List; @@ -139,6 +140,71 @@ void maliciousIndexSortOrderIsRejected() { () -> XUGUDBIndexTypeEnum.NORMAL.buildIndexScript(tableIndex)); } + @Test + void selectTableNeutralizesMaliciousSchemaName() { + String sql = builder.dql().buildSelectTable(null, "evil\";DROP TABLE t;--", "sample_table"); + + assertEquals("SELECT * FROM \"evil\"\";DROP TABLE t;--\".\"sample_table\"", sql); + } + + @Test + void insertNeutralizesMaliciousTableAndColumnNames() { + SingleInsertSqlRequest request = SingleInsertSqlRequest.builder() + .schemaName("app\";DROP TABLE t;--") + .tableName("tab\";DROP TABLE t;--") + .columnList(List.of("col\"; DROP TABLE t; --")) + .valueList(List.of("1")) + .build(); + + String sql = builder.dml().buildInsert(request); + + assertTrue(sql.contains("INSERT INTO \"app\"\";DROP TABLE t;--\".\"tab\"\";DROP TABLE t;--\""), sql); + assertTrue(sql.contains("(\"col\"\"; DROP TABLE t; --\")"), sql); + assertFalse(sql.contains("INTO \"app\";"), sql); + } + + @Test + void columnCommentLiteralIsEscapedEndToEnd() { + TableColumn col = column("id", "INTEGER"); + col.setComment("x'; DROP TABLE t; --"); + Table table = Table.builder() + .schemaName("app") + .name("sample_table") + .columnList(List.of(col)) + .indexList(List.of()) + .build(); + + String sql = builder.buildCreateTable(table, TableBuilderConfig.defaultConfig()); + + assertTrue(sql.contains("IS 'x''; DROP TABLE t; --'"), sql); + assertFalse(sql.contains("IS 'x';"), sql); + } + + @Test + void fallbackColumnEscapesNameAndRejectsMaliciousType() { + TableColumn weirdName = column("na\"me", "FOOTYPE"); + assertTrue(XUGUDBColumnTypeEnum.INTEGER.buildCreateColumnSql(weirdName).startsWith("\"na\"\"me\" FOOTYPE")); + + TableColumn maliciousType = column("id", "INT); DROP TABLE t; --"); + assertThrows(IllegalArgumentException.class, + () -> XUGUDBColumnTypeEnum.INTEGER.buildCreateColumnSql(maliciousType)); + } + + @Test + void validatorsReturnTrimmedValues() { + TableColumn numeric = column("id", "INTEGER"); + numeric.setDefaultValue(" 0 "); + String columnSql = XUGUDBColumnTypeEnum.INTEGER.buildCreateColumnSql(numeric); + assertTrue(columnSql.contains("DEFAULT 0 "), columnSql); + assertFalse(columnSql.contains("DEFAULT 0"), columnSql); + + TableColumn varchar = column("name_col", "VARCHAR"); + varchar.setColumnSize(10); + varchar.setUnit(" BYTE "); + String varcharSql = XUGUDBColumnTypeEnum.VARCHAR.buildCreateColumnSql(varchar); + assertTrue(varcharSql.contains("(10 BYTE)"), varcharSql); + } + private static TableColumn column(String name, String type) { return TableColumn.builder() .schemaName("app") From 1aa4e40ffe49b784f8c8ee3a5c0a0027a9647748 Mon Sep 17 00:00:00 2001 From: HandSonic <8078023+handsonic@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:45:50 +0800 Subject: [PATCH 3/8] fix(xugudb): tighten function-default pattern validation + round-trip tests (#1914) - DEFAULT_VALUE_PATTERN function-call branch now requires balanced single-quoted literals as arguments ('([^']|'')*' allowed, bare quotes rejected), closing the unbalanced-quote gap in default-value validation - Add tests: unbalancedQuoteInFunctionDefaultIsRejected, balancedQuotedArgsInFunctionDefaultAreAccepted - Fix extra closing paren typo in the c3 case --- .../enums/type/XUGUDBColumnTypeEnum.java | 2 +- .../plugin/xugudb/XugudbSqlEscapesTest.java | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java index 7a63ff7426..79326ba22a 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java @@ -225,7 +225,7 @@ private String buildDefaultValue(TableColumn column, XUGUDBColumnTypeEnum type) } private static final Pattern DEFAULT_VALUE_PATTERN = Pattern.compile( - "^(-?\\d+(\\.\\d+)?|'([^']|'')*'|[A-Za-z_][A-Za-z0-9_]*(\\([A-Za-z0-9_ ',.+\\-]*\\))?)$"); + "^(-?\\d+(\\.\\d+)?|'([^']|'')*'|[A-Za-z_][A-Za-z0-9_]*(\\(([A-Za-z0-9_ ,.\\-+]|'([^']|'')*')*\\))?)$"); private static final Pattern UNIT_PATTERN = Pattern.compile("^[A-Za-z]+$"); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapesTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapesTest.java index 616c727e26..dfc489375e 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapesTest.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapesTest.java @@ -102,6 +102,41 @@ void maliciousDefaultValueIsRejected() { () -> XUGUDBColumnTypeEnum.INTEGER.buildCreateColumnSql(column)); } + @Test + void unbalancedQuoteInFunctionDefaultIsRejected() { + TableColumn c1 = column("id", "INTEGER"); + c1.setDefaultValue("length(')"); + assertThrows(IllegalArgumentException.class, + () -> XUGUDBColumnTypeEnum.INTEGER.buildCreateColumnSql(c1)); + + TableColumn c2 = column("id", "INTEGER"); + c2.setDefaultValue("f(x')"); + assertThrows(IllegalArgumentException.class, + () -> XUGUDBColumnTypeEnum.INTEGER.buildCreateColumnSql(c2)); + + TableColumn c3 = column("id", "INTEGER"); + c3.setDefaultValue("f('ok'"); + assertThrows(IllegalArgumentException.class, + () -> XUGUDBColumnTypeEnum.INTEGER.buildCreateColumnSql(c3)); + } + + @Test + void balancedQuotedArgsInFunctionDefaultAreAccepted() { + TableColumn noArgs = column("created", "TIMESTAMP"); + noArgs.setDefaultValue("now()"); + assertTrue(XUGUDBColumnTypeEnum.TIMESTAMP.buildCreateColumnSql(noArgs).contains("DEFAULT now()")); + + TableColumn quotedArg = column("name_col", "VARCHAR"); + quotedArg.setColumnSize(10); + quotedArg.setDefaultValue("substr('abc')"); + assertTrue(XUGUDBColumnTypeEnum.VARCHAR.buildCreateColumnSql(quotedArg).contains("DEFAULT substr('abc')")); + + TableColumn escapedQuoteArg = column("name_col", "VARCHAR"); + escapedQuoteArg.setColumnSize(10); + escapedQuoteArg.setDefaultValue("f('it''s')"); + assertTrue(XUGUDBColumnTypeEnum.VARCHAR.buildCreateColumnSql(escapedQuoteArg).contains("DEFAULT f('it''s')")); + } + @Test void validDefaultValuesAreAccepted() { TableColumn numeric = column("id", "INTEGER"); From 137fa346865709e976320d0f793770a3ae2cf6bb Mon Sep 17 00:00:00 2001 From: HandSonic <8078023+handsonic@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:59:50 +0800 Subject: [PATCH 4/8] refactor(xugudb): move escaping into XugudbIdentifierProcessor per maintainer review (#1914) - new XugudbIdentifierProcessor (SPI ISQLIdentifierProcessor): quoteIdentifier with double-quote doubling, escapeString with single-quote doubling - XUGUDBMetaData overrides getSQLIdentifierProcessor(); metadata call sites use it - builder/manager/enums use XugudbIdentifierProcessor.INSTANCE - non-escapable validation moved to XugudbSqlGuards (column defaults, length units) - XugudbSqlEscapes removed; tests migrated (21 green) --- .../chat2db/plugin/xugudb/XUGUDBManager.java | 21 ++++--- .../chat2db/plugin/xugudb/XUGUDBMetaData.java | 37 ++++++----- .../plugin/xugudb/XugudbSqlEscapes.java | 35 ----------- .../plugin/xugudb/XugudbSqlGuards.java | 56 +++++++++++++++++ .../xugudb/builder/XUGUDBSqlBuilder.java | 22 +++---- .../enums/type/XUGUDBColumnTypeEnum.java | 46 ++++---------- .../enums/type/XUGUDBIndexTypeEnum.java | 12 ++-- .../identifier/XugudbIdentifierProcessor.java | 62 +++++++++++++++++++ ...ava => XugudbIdentifierProcessorTest.java} | 46 ++++++++++---- 9 files changed, 215 insertions(+), 122 deletions(-) delete mode 100644 chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapes.java create mode 100644 chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlGuards.java create mode 100644 chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/identifier/XugudbIdentifierProcessor.java rename chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/{XugudbSqlEscapesTest.java => XugudbIdentifierProcessorTest.java} (81%) diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBManager.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBManager.java index 84f4d31d51..d76f4b83d5 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBManager.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBManager.java @@ -1,5 +1,6 @@ package ai.chat2db.plugin.xugudb; +import ai.chat2db.plugin.xugudb.identifier.XugudbIdentifierProcessor; import ai.chat2db.spi.IDbManager; import ai.chat2db.spi.DefaultDBManager; import ai.chat2db.community.domain.api.model.async.AsyncContext; @@ -27,7 +28,7 @@ public class XUGUDBManager extends DefaultDBManager implements IDbManager { private String format(String tableName) { - return XugudbSqlEscapes.quoteIdentifier(tableName); + return XugudbIdentifierProcessor.INSTANCE.quoteIdentifier(tableName); } @@ -54,7 +55,7 @@ public void exportDatabase(Connection connection, String databaseName, String sc } private void exportTables(Connection connection, String schemaName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(SQL_SELECT_TABLE_NAME_ALL_TABLES, XugudbSqlEscapes.escapeSqlLiteral(schemaName)); + String sql = String.format(SQL_SELECT_TABLE_NAME_ALL_TABLES, XugudbIdentifierProcessor.INSTANCE.escapeString(schemaName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { while (resultSet.next()) { String tableName = resultSet.getString("TABLE_NAME"); @@ -71,7 +72,7 @@ private void exportTable(Connection connection, String tableName, String schemaN (SELECT dbms_metadata.get_ddl('TABLE', '%s', '%s') FROM dual) AS ddl FROM dual; """; - try (PreparedStatement statement = connection.prepareStatement(String.format(sql, XugudbSqlEscapes.escapeSqlLiteral(tableName), XugudbSqlEscapes.escapeSqlLiteral(tableName), XugudbSqlEscapes.escapeSqlLiteral(schemaName))); ResultSet resultSet = statement.executeQuery()) { + try (PreparedStatement statement = connection.prepareStatement(String.format(sql, XugudbIdentifierProcessor.INSTANCE.escapeString(tableName), XugudbIdentifierProcessor.INSTANCE.escapeString(tableName), XugudbIdentifierProcessor.INSTANCE.escapeString(schemaName))); ResultSet resultSet = statement.executeQuery()) { String formatSchemaName = format(schemaName); String formatTableName = format(tableName); if (resultSet.next()) { @@ -82,7 +83,7 @@ private void exportTable(Connection connection, String tableName, String schemaN String comment = resultSet.getString("comments"); if (StringUtils.isNotBlank(comment)) { sqlBuilder.append(SQL_COMMENT_TABLE).append(formatSchemaName).append(".").append(formatTableName) - .append(" IS ").append("'").append(XugudbSqlEscapes.escapeSqlLiteral(comment)).append("';"); + .append(" IS ").append("'").append(XugudbIdentifierProcessor.INSTANCE.escapeString(comment)).append("';"); } asyncContext.write(sqlBuilder.toString()); exportTableColumnComment(connection, schemaName, tableName, asyncContext); @@ -95,14 +96,14 @@ private void exportTable(Connection connection, String tableName, String schemaN private void exportTableColumnComment(Connection connection, String schemaName, String tableName, AsyncContext asyncContext) throws SQLException { String sql = String.format(SQL_SELECT_COLNAME_COMMENT_SYS_SYSCOLUMNCOMMENTS + - "where SCHNAME = '%s' and TVNAME = '%s'and TABLE_TYPE = 'TABLE';", XugudbSqlEscapes.escapeSqlLiteral(schemaName), XugudbSqlEscapes.escapeSqlLiteral(tableName)); + "where SCHNAME = '%s' and TVNAME = '%s'and TABLE_TYPE = 'TABLE';", XugudbIdentifierProcessor.INSTANCE.escapeString(schemaName), XugudbIdentifierProcessor.INSTANCE.escapeString(tableName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { while (resultSet.next()) { String columnName = resultSet.getString("COLNAME"); String comment = resultSet.getString("COMMENT$"); StringBuilder sqlBuilder = new StringBuilder(); sqlBuilder.append(SQL_COMMENT_COLUMN).append(format(schemaName)).append(".").append(format(tableName)) - .append(".").append(format(columnName)).append(" IS ").append("'").append(XugudbSqlEscapes.escapeSqlLiteral(comment)).append("';").append("\n"); + .append(".").append(format(columnName)).append(" IS ").append("'").append(XugudbIdentifierProcessor.INSTANCE.escapeString(comment)).append("';").append("\n"); asyncContext.write(sqlBuilder.toString()); } } @@ -119,7 +120,7 @@ private void exportViews(Connection connection, String schemaName, AsyncContext } private void exportView(Connection connection, String viewName, String schemaName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(SQL_SELECT_DBMS_METADATA_GET_DDL, XugudbSqlEscapes.escapeSqlLiteral(viewName), XugudbSqlEscapes.escapeSqlLiteral(schemaName)); + String sql = String.format(SQL_SELECT_DBMS_METADATA_GET_DDL, XugudbIdentifierProcessor.INSTANCE.escapeString(viewName), XugudbIdentifierProcessor.INSTANCE.escapeString(schemaName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { if (resultSet.next()) { StringBuilder sqlBuilder = new StringBuilder(); @@ -139,7 +140,7 @@ private void exportProcedures(Connection connection, String schemaName, AsyncCon } private void exportProcedure(Connection connection, String schemaName, String procedureName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(ROUTINES_SQL, "PROC", XugudbSqlEscapes.escapeSqlLiteral(schemaName), XugudbSqlEscapes.escapeSqlLiteral(procedureName)); + String sql = String.format(ROUTINES_SQL, "PROC", XugudbIdentifierProcessor.INSTANCE.escapeString(schemaName), XugudbIdentifierProcessor.INSTANCE.escapeString(procedureName)); try (PreparedStatement statement = connection.prepareStatement(sql); ResultSet resultSet = statement.executeQuery()) { if (resultSet.next()) { StringBuilder sqlBuilder = new StringBuilder(); @@ -150,7 +151,7 @@ private void exportProcedure(Connection connection, String schemaName, String pr } private void exportTriggers(Connection connection, String schemaName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(TRIGGER_SQL_LIST, XugudbSqlEscapes.escapeSqlLiteral(schemaName)); + String sql = String.format(TRIGGER_SQL_LIST, XugudbIdentifierProcessor.INSTANCE.escapeString(schemaName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { while (resultSet.next()) { String triggerName = resultSet.getString("TRIGGER_NAME"); @@ -160,7 +161,7 @@ private void exportTriggers(Connection connection, String schemaName, AsyncConte } private void exportTrigger(Connection connection, String schemaName, String triggerName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(TRIGGER_SQL, XugudbSqlEscapes.escapeSqlLiteral(schemaName), XugudbSqlEscapes.escapeSqlLiteral(triggerName)); + String sql = String.format(TRIGGER_SQL, XugudbIdentifierProcessor.INSTANCE.escapeString(schemaName), XugudbIdentifierProcessor.INSTANCE.escapeString(triggerName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { if (resultSet.next()) { StringBuilder sqlBuilder = new StringBuilder(); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBMetaData.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBMetaData.java index b4f630a31d..beabc67a65 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBMetaData.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBMetaData.java @@ -4,7 +4,9 @@ import ai.chat2db.plugin.xugudb.enums.type.XUGUDBColumnTypeEnum; import ai.chat2db.plugin.xugudb.enums.type.XUGUDBDefaultValueEnum; import ai.chat2db.plugin.xugudb.enums.type.XUGUDBIndexTypeEnum; +import ai.chat2db.plugin.xugudb.identifier.XugudbIdentifierProcessor; 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.*; @@ -37,6 +39,11 @@ public class XUGUDBMetaData extends DefaultMetaService implements IDbMetaData { + @Override + public ISQLIdentifierProcessor getSQLIdentifierProcessor() { + return XugudbIdentifierProcessor.INSTANCE; + } + @Override public List databases(Connection connection) { List databases = DefaultSQLExecutor.getInstance().databases(connection); @@ -45,7 +52,7 @@ public List databases(Connection connection) { @Override public List schemas(Connection connection, String databaseName) { - String sql = "select s.schema_name, db.db_name from all_schemas s left join all_databases db on db.db_id = s.db_id where db.db_name = '" + XugudbSqlEscapes.escapeSqlLiteral(databaseName) + "'"; + String sql = "select s.schema_name, db.db_name from all_schemas s left join all_databases db on db.db_id = s.db_id where db.db_name = '" + getSQLIdentifierProcessor().escapeString(databaseName) + "'"; List schemas = DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { List databases = new ArrayList<>(); @@ -63,7 +70,7 @@ public List schemas(Connection connection, String databaseName) { } private String format(String tableName) { - return XugudbSqlEscapes.quoteIdentifier(tableName); + return getSQLIdentifierProcessor().quoteIdentifier(tableName); } @Override @@ -74,7 +81,7 @@ public String tableDDL(Connection connection, String databaseName, String schema FROM dual; """; StringBuilder ddlBuilder = new StringBuilder(); - String tableDDLSql = String.format(sql, XugudbSqlEscapes.escapeSqlLiteral(schemaName), XugudbSqlEscapes.escapeSqlLiteral(tableName)); + String tableDDLSql = String.format(sql, getSQLIdentifierProcessor().escapeString(schemaName), getSQLIdentifierProcessor().escapeString(tableName)); DefaultSQLExecutor.getInstance().execute(connection, tableDDLSql, resultSet -> { if (resultSet.next()) { String ddl = resultSet.getString("ddl"); @@ -90,7 +97,7 @@ public String tableDDL(Connection connection, String databaseName, String schema @Override public List functions(Connection connection, String databaseName, String schemaName) { List functions = new ArrayList<>(); - String sql = String.format(FUNCTIONS_SQL, XugudbSqlEscapes.escapeSqlLiteral(databaseName), XugudbSqlEscapes.escapeSqlLiteral(schemaName)); + String sql = String.format(FUNCTIONS_SQL, getSQLIdentifierProcessor().escapeString(databaseName), getSQLIdentifierProcessor().escapeString(schemaName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { while (resultSet.next()) { Function function = new Function(); @@ -109,7 +116,7 @@ public List functions(Connection connection, String databaseName, Stri public Function function(Connection connection, @NotEmpty String databaseName, String schemaName, String functionName) { - String sql = String.format(ROUTINES_SQL, XugudbSqlEscapes.escapeSqlLiteral(databaseName), XugudbSqlEscapes.escapeSqlLiteral(schemaName), XugudbSqlEscapes.escapeSqlLiteral(functionName)); + String sql = String.format(ROUTINES_SQL, getSQLIdentifierProcessor().escapeString(databaseName), getSQLIdentifierProcessor().escapeString(schemaName), getSQLIdentifierProcessor().escapeString(functionName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { StringBuilder sb = new StringBuilder(); while (resultSet.next()) { @@ -131,7 +138,7 @@ public Function function(Connection connection, @NotEmpty String databaseName, S @Override public Procedure procedure(Connection connection, @NotEmpty String databaseName, String schemaName, String procedureName) { - String sql = String.format(PROCEDURE_SQL, XugudbSqlEscapes.escapeSqlLiteral(databaseName), XugudbSqlEscapes.escapeSqlLiteral(schemaName), XugudbSqlEscapes.escapeSqlLiteral(procedureName)); + String sql = String.format(PROCEDURE_SQL, getSQLIdentifierProcessor().escapeString(databaseName), getSQLIdentifierProcessor().escapeString(schemaName), getSQLIdentifierProcessor().escapeString(procedureName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { StringBuilder sb = new StringBuilder(); while (resultSet.next()) { @@ -152,7 +159,7 @@ public Procedure procedure(Connection connection, @NotEmpty String databaseName, @Override public List procedures(Connection connection, String databaseName, String schemaName) { List procedures = new ArrayList<>(); - String sql = String.format(PROCEDURES_SQL, XugudbSqlEscapes.escapeSqlLiteral(databaseName), XugudbSqlEscapes.escapeSqlLiteral(schemaName)); + String sql = String.format(PROCEDURES_SQL, getSQLIdentifierProcessor().escapeString(databaseName), getSQLIdentifierProcessor().escapeString(schemaName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { while (resultSet.next()) { Procedure procedure = new Procedure(); @@ -173,7 +180,7 @@ public List procedures(Connection connection, String databaseName, St @Override public List triggers(Connection connection, String databaseName, String schemaName) { List triggers = new ArrayList<>(); - String sql = String.format(TRIGGER_SQL_LIST, XugudbSqlEscapes.escapeSqlLiteral(databaseName), XugudbSqlEscapes.escapeSqlLiteral(schemaName)); + String sql = String.format(TRIGGER_SQL_LIST, getSQLIdentifierProcessor().escapeString(databaseName), getSQLIdentifierProcessor().escapeString(schemaName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { while (resultSet.next()) { Trigger trigger = new Trigger(); @@ -190,7 +197,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, XugudbSqlEscapes.escapeSqlLiteral(databaseName), XugudbSqlEscapes.escapeSqlLiteral(schemaName), XugudbSqlEscapes.escapeSqlLiteral(triggerName)); + String sql = String.format(TRIGGER_SQL, getSQLIdentifierProcessor().escapeString(databaseName), getSQLIdentifierProcessor().escapeString(schemaName), getSQLIdentifierProcessor().escapeString(triggerName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Trigger trigger = new Trigger(); trigger.setDatabaseName(databaseName); @@ -207,7 +214,7 @@ public Trigger trigger(Connection connection, @NotEmpty String databaseName, Str @Override public List

views(Connection connection, String databaseName, String schemaName) { - String sql = String.format(VIEW_SQL_LIST, XugudbSqlEscapes.escapeSqlLiteral(databaseName), XugudbSqlEscapes.escapeSqlLiteral(schemaName)); + String sql = String.format(VIEW_SQL_LIST, getSQLIdentifierProcessor().escapeString(databaseName), getSQLIdentifierProcessor().escapeString(schemaName)); List
tables = new ArrayList<>(); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Table table = new Table(); @@ -227,7 +234,7 @@ public List
views(Connection connection, String databaseName, String sche @Override public Table view(Connection connection, String databaseName, String schemaName, String viewName) { - String sql = String.format(VIEW_SQL, XugudbSqlEscapes.escapeSqlLiteral(databaseName), XugudbSqlEscapes.escapeSqlLiteral(schemaName), XugudbSqlEscapes.escapeSqlLiteral(viewName)); + String sql = String.format(VIEW_SQL, getSQLIdentifierProcessor().escapeString(databaseName), getSQLIdentifierProcessor().escapeString(schemaName), getSQLIdentifierProcessor().escapeString(viewName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Table table = new Table(); table.setDatabaseName(databaseName); @@ -244,7 +251,7 @@ public Table view(Connection connection, String databaseName, String schemaName, @Override public List indexes(Connection connection, String databaseName, String schemaName, String tableName) { - String sql = String.format(INDEX_SQL, XugudbSqlEscapes.escapeSqlLiteral(schemaName), XugudbSqlEscapes.escapeSqlLiteral(tableName)); + String sql = String.format(INDEX_SQL, getSQLIdentifierProcessor().escapeString(schemaName), getSQLIdentifierProcessor().escapeString(tableName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { LinkedHashMap map = new LinkedHashMap(); while (resultSet.next()) { @@ -294,7 +301,7 @@ private List getTableIndexColumn(ResultSet resultSet) throws S @Override public List columns(Connection connection, String databaseName, String schemaName, String tableName) { - String sql = String.format(SELECT_TABLE_COLUMNS, XugudbSqlEscapes.escapeSqlLiteral(databaseName), XugudbSqlEscapes.escapeSqlLiteral(schemaName), XugudbSqlEscapes.escapeSqlLiteral(tableName)); + String sql = String.format(SELECT_TABLE_COLUMNS, getSQLIdentifierProcessor().escapeString(databaseName), getSQLIdentifierProcessor().escapeString(schemaName), getSQLIdentifierProcessor().escapeString(tableName)); List tableColumns = new ArrayList<>(); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { while (resultSet.next()) { @@ -342,9 +349,9 @@ public TableMeta getTableMeta(String databaseName, String schemaName, String tab @Override public String getMetaDataName(String... names) { if (Arrays.stream(names).count() > 1) { - return Arrays.stream(names).skip(1).filter(name -> StringUtils.isNotBlank(name)).map(name -> XugudbSqlEscapes.quoteIdentifier(name)).collect(Collectors.joining(".")); + return Arrays.stream(names).skip(1).filter(name -> StringUtils.isNotBlank(name)).map(getSQLIdentifierProcessor()::quoteIdentifier).collect(Collectors.joining(".")); } - return Arrays.stream(names).filter(name -> StringUtils.isNotBlank(name)).map(name -> XugudbSqlEscapes.quoteIdentifier(name)).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-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapes.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapes.java deleted file mode 100644 index d01046ecbf..0000000000 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapes.java +++ /dev/null @@ -1,35 +0,0 @@ -package ai.chat2db.plugin.xugudb; - -public final class XugudbSqlEscapes { - - private XugudbSqlEscapes() { - } - - public static String escapeSqlLiteral(String value) { - return value == null ? "" : value.replace("'", "''"); - } - - /** - * Escapes an identifier for use inside double quotes by doubling embedded {@code "}. - *

- * Callers MUST pass raw (unescaped) identifier values. Do NOT pass text that has already - * been escaped or quoted for SQL — it will be escaped again. For compatibility with - * database metadata that arrives already wrapped in double quotes, a single pair of - * surrounding {@code "} is stripped before escaping; an identifier that legitimately - * begins and ends with a {@code "} character cannot be represented through this API. - */ - public static String escapeIdentifier(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 stripped.replace("\"", "\"\""); - } - - public static String quoteIdentifier(String identifier) { - return "\"" + escapeIdentifier(identifier) + "\""; - } -} diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlGuards.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlGuards.java new file mode 100644 index 0000000000..5ed6774317 --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlGuards.java @@ -0,0 +1,56 @@ +package ai.chat2db.plugin.xugudb; + +import java.util.regex.Pattern; + +import ai.chat2db.plugin.xugudb.identifier.XugudbIdentifierProcessor; + +/** + * Validation helpers for non-escapable SQL positions in XUGUDB DDL generation + * (column default expressions and length units supplied through table metadata). + * Escaping itself lives in {@link XugudbIdentifierProcessor}. + */ +public final class XugudbSqlGuards { + + /** + * Conservative allow-list for unquoted column default expressions: numbers, + * single-quoted literals with correctly doubled inner quotes, and identifiers + * or function calls whose arguments are drawn from a safe character set. + * Anything else is rejected so a hostile default cannot break out of the DDL statement. + */ + private static final Pattern DEFAULT_VALUE_PATTERN = Pattern.compile( + "^(-?\\d+(\\.\\d+)?|'([^']|'')*'|[A-Za-z_][A-Za-z0-9_]*(\\(([A-Za-z0-9_ ,.\\-+]|'([^']|'')*')*\\))?)$"); + + /** + * Conservative allow-list for length units (e.g. {@code BYTE}, {@code CHAR}). + */ + private static final Pattern UNIT_PATTERN = Pattern.compile("^[A-Za-z]+$"); + + private XugudbSqlGuards() { + } + + /** + * Validates a column default expression before it is embedded into generated DDL. + * Returns the trimmed default unchanged when it matches the allow-list; throws + * otherwise (fail closed). + */ + public static String requireDefaultValue(String defaultValue) { + String trimmed = defaultValue.trim(); + if (!DEFAULT_VALUE_PATTERN.matcher(trimmed).matches()) { + throw new IllegalArgumentException("Unsupported column default value: " + defaultValue); + } + return trimmed; + } + + /** + * Validates a length unit before it is embedded into generated DDL. Returns the + * trimmed unit unchanged when it matches the allow-list; throws otherwise + * (fail closed). + */ + public static String requireUnit(String unit) { + String trimmed = unit.trim(); + if (!UNIT_PATTERN.matcher(trimmed).matches()) { + throw new IllegalArgumentException("Unsupported length unit: " + unit); + } + return trimmed; + } +} diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilder.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilder.java index 753ec706e0..5a5b33d259 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilder.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilder.java @@ -2,7 +2,7 @@ import ai.chat2db.spi.constant.SQLConstants; -import ai.chat2db.plugin.xugudb.XugudbSqlEscapes; +import ai.chat2db.plugin.xugudb.identifier.XugudbIdentifierProcessor; import ai.chat2db.plugin.xugudb.enums.type.XUGUDBColumnTypeEnum; import ai.chat2db.plugin.xugudb.enums.type.XUGUDBIndexTypeEnum; import ai.chat2db.community.domain.api.enums.plugin.EditStatusEnum; @@ -38,7 +38,7 @@ public class XUGUDBSqlBuilder extends DefaultSqlBuilder { public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfig) { StringBuilder script = new StringBuilder(); - script.append(SQL_CREATE_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(XugudbSqlEscapes.escapeIdentifier(table.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(XugudbSqlEscapes.escapeIdentifier(table.getName())).append(VALUE_DOUBLE_QUOTE_OPEN_PAREN).append(SQLConstants.LINE_SEPARATOR); + script.append(SQL_CREATE_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(XugudbIdentifierProcessor.escapeIdentifier(table.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(XugudbIdentifierProcessor.escapeIdentifier(table.getName())).append(VALUE_DOUBLE_QUOTE_OPEN_PAREN).append(SQLConstants.LINE_SEPARATOR); for (TableColumn column : table.getColumnList()) { if (StringUtils.isBlank(column.getName()) || StringUtils.isBlank(column.getColumnType())) { @@ -82,13 +82,13 @@ public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfi private String buildTableComment(Table table) { StringBuilder script = new StringBuilder(); - script.append(SQL_COMMENT_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(XugudbSqlEscapes.escapeIdentifier(table.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(XugudbSqlEscapes.escapeIdentifier(table.getName())).append(VALUE_DOUBLE_QUOTE_IS_SINGLE_QUOTE).append(XugudbSqlEscapes.escapeSqlLiteral(table.getComment())).append(SQLConstants.SINGLE_QUOTE); + script.append(SQL_COMMENT_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(XugudbIdentifierProcessor.escapeIdentifier(table.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(XugudbIdentifierProcessor.escapeIdentifier(table.getName())).append(VALUE_DOUBLE_QUOTE_IS_SINGLE_QUOTE).append(XugudbIdentifierProcessor.INSTANCE.escapeString(table.getComment())).append(SQLConstants.SINGLE_QUOTE); return script.toString(); } private String buildComment(TableColumn column) { StringBuilder script = new StringBuilder(); - script.append(SQL_COMMENT_COLUMN).append(SQLConstants.DOUBLE_QUOTE).append(XugudbSqlEscapes.escapeIdentifier(column.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(XugudbSqlEscapes.escapeIdentifier(column.getTableName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(XugudbSqlEscapes.escapeIdentifier(column.getName())).append(VALUE_DOUBLE_QUOTE_IS_SINGLE_QUOTE).append(XugudbSqlEscapes.escapeSqlLiteral(column.getComment())).append(SQLConstants.SINGLE_QUOTE); + script.append(SQL_COMMENT_COLUMN).append(SQLConstants.DOUBLE_QUOTE).append(XugudbIdentifierProcessor.escapeIdentifier(column.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(XugudbIdentifierProcessor.escapeIdentifier(column.getTableName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(XugudbIdentifierProcessor.escapeIdentifier(column.getName())).append(VALUE_DOUBLE_QUOTE_IS_SINGLE_QUOTE).append(XugudbIdentifierProcessor.INSTANCE.escapeString(column.getComment())).append(SQLConstants.SINGLE_QUOTE); return script.toString(); } @@ -97,8 +97,8 @@ public String buildAlterTable(Table oldTable, Table newTable) { StringBuilder script = new StringBuilder(); if (!StringUtils.equalsIgnoreCase(oldTable.getName(), newTable.getName())) { - script.append(SQL_ALTER_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(XugudbSqlEscapes.escapeIdentifier(oldTable.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(XugudbSqlEscapes.escapeIdentifier(oldTable.getName())).append(SQLConstants.DOUBLE_QUOTE); - script.append(SQLConstants.SPACE).append(SQL_RENAME).append(SQLConstants.DOUBLE_QUOTE).append(XugudbSqlEscapes.escapeIdentifier(newTable.getName())).append(SQLConstants.DOUBLE_QUOTE).append(SQLConstants.SEMICOLON_LINE_SEPARATOR); + script.append(SQL_ALTER_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(XugudbIdentifierProcessor.escapeIdentifier(oldTable.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(XugudbIdentifierProcessor.escapeIdentifier(oldTable.getName())).append(SQLConstants.DOUBLE_QUOTE); + script.append(SQLConstants.SPACE).append(SQL_RENAME).append(SQLConstants.DOUBLE_QUOTE).append(XugudbIdentifierProcessor.escapeIdentifier(newTable.getName())).append(SQLConstants.DOUBLE_QUOTE).append(SQLConstants.SEMICOLON_LINE_SEPARATOR); } if (!StringUtils.equalsIgnoreCase(oldTable.getComment(), newTable.getComment())) { script.append(SQLConstants.EMPTY).append(buildTableComment(newTable)).append(SQLConstants.SEMICOLON_LINE_SEPARATOR); @@ -157,9 +157,9 @@ public String buildPageLimit(PageLimitRequest request) { @Override public String buildCreateSchema(Schema schema) { StringBuilder sqlBuilder = new StringBuilder(); - sqlBuilder.append(SQL_CREATE_SCHEMA+XugudbSqlEscapes.escapeIdentifier(schema.getName())+SQLConstants.DOUBLE_QUOTE); + sqlBuilder.append(SQL_CREATE_SCHEMA+XugudbIdentifierProcessor.escapeIdentifier(schema.getName())+SQLConstants.DOUBLE_QUOTE); if(StringUtils.isNotBlank(schema.getOwner())){ - sqlBuilder.append(SQLConstants.SCHEMA_AUTHORIZATION_SQL).append(XugudbSqlEscapes.quoteIdentifier(schema.getOwner())); + sqlBuilder.append(SQLConstants.SCHEMA_AUTHORIZATION_SQL).append(XugudbIdentifierProcessor.INSTANCE.quoteIdentifier(schema.getOwner())); } return sqlBuilder.toString(); @@ -167,14 +167,14 @@ public String buildCreateSchema(Schema schema) { @Override public String quoteIdentifier(String identifier) { - return XugudbSqlEscapes.quoteIdentifier(identifier); + return XugudbIdentifierProcessor.INSTANCE.quoteIdentifier(identifier); } @Override public String quoteQualifiedIdentifier(String... identifiers) { return Arrays.stream(identifiers) .filter(StringUtils::isNotBlank) - .map(XugudbSqlEscapes::quoteIdentifier) + .map(XugudbIdentifierProcessor.INSTANCE::quoteIdentifier) .collect(Collectors.joining(SQLConstants.DOT)); } @@ -193,7 +193,7 @@ protected void buildColumns(List columnList, StringBuilder script) { if (columnList != null && !columnList.isEmpty()) { script.append(SQLConstants.SPACE_OPEN_PARENTHESIS) .append(columnList.stream() - .map(XugudbSqlEscapes::quoteIdentifier) + .map(XugudbIdentifierProcessor.INSTANCE::quoteIdentifier) .collect(Collectors.joining(SQLConstants.COMMA))) .append(SQLConstants.CLOSE_PARENTHESIS_SPACE); } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java index 79326ba22a..4da238a973 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java @@ -1,7 +1,8 @@ package ai.chat2db.plugin.xugudb.enums.type; import ai.chat2db.spi.IColumnBuilder; -import ai.chat2db.plugin.xugudb.XugudbSqlEscapes; +import ai.chat2db.plugin.xugudb.XugudbSqlGuards; +import ai.chat2db.plugin.xugudb.identifier.XugudbIdentifierProcessor; 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; @@ -123,7 +124,7 @@ public String buildCreateColumnSql(TableColumn column) { } StringBuilder script = new StringBuilder(); - script.append("\"").append(XugudbSqlEscapes.escapeIdentifier(column.getName())).append("\"").append(" "); + script.append("\"").append(XugudbIdentifierProcessor.escapeIdentifier(column.getName())).append("\"").append(" "); script.append(buildDataType(column, type)).append(" "); @@ -142,8 +143,8 @@ public String buildUpdateColumnSql(TableColumn column) { return ""; } StringBuilder script = new StringBuilder(); - script.append(SQL_ALTER_TABLE).append("\"").append(XugudbSqlEscapes.escapeIdentifier(column.getSchemaName())).append("\".\"").append(XugudbSqlEscapes.escapeIdentifier(column.getTableName())).append("\""); - script.append(" ").append("MODIFY (").append("\"").append(XugudbSqlEscapes.escapeIdentifier(column.getName())).append("\"").append(" "); + script.append(SQL_ALTER_TABLE).append("\"").append(XugudbIdentifierProcessor.escapeIdentifier(column.getSchemaName())).append("\".\"").append(XugudbIdentifierProcessor.escapeIdentifier(column.getTableName())).append("\""); + script.append(" ").append("MODIFY (").append("\"").append(XugudbIdentifierProcessor.escapeIdentifier(column.getName())).append("\"").append(" "); boolean isModify = false; Integer oldColumnSize = Optional.ofNullable(column.getOldColumn()) .map(TableColumn::getColumnSize) @@ -172,7 +173,7 @@ private static String buildFallbackColumn(TableColumn column) { if (!FALLBACK_TYPE_PATTERN.matcher(columnType).matches()) { throw new IllegalArgumentException("Unsupported column type: " + column.getColumnType()); } - return XugudbSqlEscapes.quoteIdentifier(column.getName()) + " " + columnType; + return XugudbIdentifierProcessor.INSTANCE.quoteIdentifier(column.getName()) + " " + columnType; } private String buildAutoIncrement(TableColumn column, XUGUDBColumnTypeEnum type) { @@ -221,28 +222,7 @@ private String buildDefaultValue(TableColumn column, XUGUDBColumnTypeEnum type) return StringUtils.join("DEFAULT NULL"); } - return StringUtils.join("DEFAULT ", validateDefaultValue(column.getDefaultValue())); - } - - private static final Pattern DEFAULT_VALUE_PATTERN = Pattern.compile( - "^(-?\\d+(\\.\\d+)?|'([^']|'')*'|[A-Za-z_][A-Za-z0-9_]*(\\(([A-Za-z0-9_ ,.\\-+]|'([^']|'')*')*\\))?)$"); - - private static final Pattern UNIT_PATTERN = Pattern.compile("^[A-Za-z]+$"); - - private static String validateDefaultValue(String defaultValue) { - String trimmed = defaultValue.trim(); - if (!DEFAULT_VALUE_PATTERN.matcher(trimmed).matches()) { - throw new IllegalArgumentException("Unsupported column default value: " + defaultValue); - } - return trimmed; - } - - private static String validateUnit(String unit) { - String trimmed = unit.trim(); - if (!UNIT_PATTERN.matcher(trimmed).matches()) { - throw new IllegalArgumentException("Unsupported length unit: " + unit); - } - return trimmed; + return StringUtils.join("DEFAULT ", XugudbSqlGuards.requireDefaultValue(column.getDefaultValue())); } private String buildDataType(TableColumn column, XUGUDBColumnTypeEnum type) { @@ -253,7 +233,7 @@ private String buildDataType(TableColumn column, XUGUDBColumnTypeEnum type) { if (column.getColumnSize() != null && StringUtils.isEmpty(column.getUnit())) { script.append("(").append(column.getColumnSize()).append(")"); } else if (column.getColumnSize() != null && !StringUtils.isEmpty(column.getUnit())) { - script.append("(").append(column.getColumnSize()).append(" ").append(validateUnit(column.getUnit())).append(")"); + script.append("(").append(column.getColumnSize()).append(" ").append(XugudbSqlGuards.requireUnit(column.getUnit())).append(")"); } return script.toString(); } @@ -307,21 +287,21 @@ public String buildModifyColumn(TableColumn tableColumn) { if (EditStatusEnum.DELETE.name().equals(tableColumn.getEditStatus())) { StringBuilder script = new StringBuilder(); - script.append(SQL_ALTER_TABLE).append("\"").append(XugudbSqlEscapes.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(XugudbSqlEscapes.escapeIdentifier(tableColumn.getTableName())).append("\""); - script.append(" ").append(SQL_DROP_COLUMN).append("\"").append(XugudbSqlEscapes.escapeIdentifier(tableColumn.getName())).append("\""); + script.append(SQL_ALTER_TABLE).append("\"").append(XugudbIdentifierProcessor.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(XugudbIdentifierProcessor.escapeIdentifier(tableColumn.getTableName())).append("\""); + script.append(" ").append(SQL_DROP_COLUMN).append("\"").append(XugudbIdentifierProcessor.escapeIdentifier(tableColumn.getName())).append("\""); return script.toString(); } if (EditStatusEnum.ADD.name().equals(tableColumn.getEditStatus())) { StringBuilder script = new StringBuilder(); - script.append(SQL_ALTER_TABLE).append("\"").append(XugudbSqlEscapes.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(XugudbSqlEscapes.escapeIdentifier(tableColumn.getTableName())).append("\""); + script.append(SQL_ALTER_TABLE).append("\"").append(XugudbIdentifierProcessor.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(XugudbIdentifierProcessor.escapeIdentifier(tableColumn.getTableName())).append("\""); script.append(" ").append("ADD (").append(buildCreateColumnSql(tableColumn)).append(")"); return script.toString(); } if (EditStatusEnum.MODIFY.name().equals(tableColumn.getEditStatus())) { StringBuilder script = new StringBuilder(); if (!StringUtils.equalsIgnoreCase(tableColumn.getOldName(), tableColumn.getName())) { - script.append(SQL_ALTER_TABLE).append("\"").append(XugudbSqlEscapes.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(XugudbSqlEscapes.escapeIdentifier(tableColumn.getTableName())).append("\""); - script.append(" ").append(SQL_RENAME_COLUMN).append("\"").append(XugudbSqlEscapes.escapeIdentifier(tableColumn.getOldName())).append("\"").append(" TO ").append("\"").append(XugudbSqlEscapes.escapeIdentifier(tableColumn.getName())).append("\" ").append(";\n").append(buildUpdateColumnSql(tableColumn)); + script.append(SQL_ALTER_TABLE).append("\"").append(XugudbIdentifierProcessor.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(XugudbIdentifierProcessor.escapeIdentifier(tableColumn.getTableName())).append("\""); + script.append(" ").append(SQL_RENAME_COLUMN).append("\"").append(XugudbIdentifierProcessor.escapeIdentifier(tableColumn.getOldName())).append("\"").append(" TO ").append("\"").append(XugudbIdentifierProcessor.escapeIdentifier(tableColumn.getName())).append("\" ").append(";\n").append(buildUpdateColumnSql(tableColumn)); } else { script.append(buildUpdateColumnSql(tableColumn)).append("\n"); } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBIndexTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBIndexTypeEnum.java index f6ede5105e..3baf1181d7 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBIndexTypeEnum.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBIndexTypeEnum.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.xugudb.XugudbSqlEscapes; +import ai.chat2db.plugin.xugudb.identifier.XugudbIdentifierProcessor; import org.apache.commons.lang3.StringUtils; import java.util.Arrays; @@ -73,14 +73,14 @@ public static XUGUDBIndexTypeEnum getByType(String type) { public String buildIndexScript(TableIndex tableIndex) { StringBuilder script = new StringBuilder(); if (PRIMARY_KEY.equals(this)) { - script.append(SQL_ALTER_TABLE_2).append(XugudbSqlEscapes.escapeIdentifier(tableIndex.getSchemaName())).append("\".\"").append(XugudbSqlEscapes.escapeIdentifier(tableIndex.getTableName())).append("\" ADD PRIMARY KEY ").append(buildIndexColumn(tableIndex)); + script.append(SQL_ALTER_TABLE_2).append(XugudbIdentifierProcessor.escapeIdentifier(tableIndex.getSchemaName())).append("\".\"").append(XugudbIdentifierProcessor.escapeIdentifier(tableIndex.getTableName())).append("\" ADD PRIMARY KEY ").append(buildIndexColumn(tableIndex)); } else { if (UNIQUE.equals(this)) { script.append(SQL_CREATE_UNIQUE_INDEX); } else { script.append(SQL_CREATE_INDEX); } - script.append(buildIndexName(tableIndex)).append(SQL_ON).append(XugudbSqlEscapes.escapeIdentifier(tableIndex.getSchemaName())).append("\".\"").append(XugudbSqlEscapes.escapeIdentifier(tableIndex.getTableName())).append("\" ").append(buildIndexColumn(tableIndex)); + script.append(buildIndexName(tableIndex)).append(SQL_ON).append(XugudbIdentifierProcessor.escapeIdentifier(tableIndex.getSchemaName())).append("\".\"").append(XugudbIdentifierProcessor.escapeIdentifier(tableIndex.getTableName())).append("\" ").append(buildIndexColumn(tableIndex)); } return script.toString(); } @@ -91,7 +91,7 @@ private String buildIndexColumn(TableIndex tableIndex) { script.append("("); for (TableIndexColumn column : tableIndex.getColumnList()) { if (StringUtils.isNotBlank(column.getColumnName())) { - script.append("\"").append(XugudbSqlEscapes.escapeIdentifier(column.getColumnName())).append("\""); + script.append("\"").append(XugudbIdentifierProcessor.escapeIdentifier(column.getColumnName())).append("\""); if (!StringUtils.isBlank(column.getAscOrDesc()) && !PRIMARY_KEY.equals(this)) { script.append(" ").append(validateAscOrDesc(column.getAscOrDesc())); } @@ -104,7 +104,7 @@ private String buildIndexColumn(TableIndex tableIndex) { } private String buildIndexName(TableIndex tableIndex) { - return "\"" + XugudbSqlEscapes.escapeIdentifier(tableIndex.getSchemaName()) + "\"." + "\"" + XugudbSqlEscapes.escapeIdentifier(tableIndex.getName()) + "\""; + return "\"" + XugudbIdentifierProcessor.escapeIdentifier(tableIndex.getSchemaName()) + "\"." + "\"" + XugudbIdentifierProcessor.escapeIdentifier(tableIndex.getName()) + "\""; } private static String validateAscOrDesc(String ascOrDesc) { @@ -129,7 +129,7 @@ public String buildModifyIndex(TableIndex tableIndex) { private String buildDropIndex(TableIndex tableIndex) { if (XUGUDBIndexTypeEnum.PRIMARY_KEY.getName().equals(tableIndex.getType())) { - String tableName = "\"" + XugudbSqlEscapes.escapeIdentifier(tableIndex.getSchemaName()) + "\"." + "\"" + XugudbSqlEscapes.escapeIdentifier(tableIndex.getTableName()) + "\""; + String tableName = "\"" + XugudbIdentifierProcessor.escapeIdentifier(tableIndex.getSchemaName()) + "\"." + "\"" + XugudbIdentifierProcessor.escapeIdentifier(tableIndex.getTableName()) + "\""; return StringUtils.join(SQL_ALTER_TABLE,tableName,SQL_DROP_PRIMARY_KEY); } StringBuilder script = new StringBuilder(); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/identifier/XugudbIdentifierProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/identifier/XugudbIdentifierProcessor.java new file mode 100644 index 0000000000..5fbb2bcabf --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/identifier/XugudbIdentifierProcessor.java @@ -0,0 +1,62 @@ +package ai.chat2db.plugin.xugudb.identifier; + +import ai.chat2db.spi.DefaultSQLIdentifierProcessor; +import org.apache.commons.lang3.StringUtils; + +/** + * XUGUDB dialect identifier processor: double-quoted identifiers with embedded-quote + * doubling, and single-quote doubling for string literals. Shared stateless + * instance available via {@link #INSTANCE} for call sites without MetaData access. + */ +public class XugudbIdentifierProcessor extends DefaultSQLIdentifierProcessor { + + public static final XugudbIdentifierProcessor INSTANCE = new XugudbIdentifierProcessor(); + + /** + * Always quotes with double quotes, stripping one surrounding quote pair and + * doubling every embedded double quote. + */ + @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 SQL string literal by + * doubling every single quote. + */ + @Override + public String escapeString(String str) { + return str == null ? "" : 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 double + * quotes: strips one surrounding quote pair, then doubles every embedded + * double quote. + */ + public static String escapeIdentifier(String identifier) { + return escapeIdentifierContent(identifier); + } +} diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapesTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbIdentifierProcessorTest.java similarity index 81% rename from chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapesTest.java rename to chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbIdentifierProcessorTest.java index dfc489375e..67d2be311e 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbSqlEscapesTest.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbIdentifierProcessorTest.java @@ -9,6 +9,7 @@ import ai.chat2db.plugin.xugudb.builder.XUGUDBSqlBuilder; import ai.chat2db.plugin.xugudb.enums.type.XUGUDBColumnTypeEnum; import ai.chat2db.plugin.xugudb.enums.type.XUGUDBIndexTypeEnum; +import ai.chat2db.plugin.xugudb.identifier.XugudbIdentifierProcessor; import ai.chat2db.spi.model.request.SingleInsertSqlRequest; import org.junit.jupiter.api.Test; @@ -19,31 +20,31 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -class XugudbSqlEscapesTest { +class XugudbIdentifierProcessorTest { private final XUGUDBSqlBuilder builder = new XUGUDBSqlBuilder(); @Test void escapeSqlLiteralDoublesSingleQuotes() { - assertEquals("o''brien", XugudbSqlEscapes.escapeSqlLiteral("o'brien")); - assertEquals("''", XugudbSqlEscapes.escapeSqlLiteral("'")); - assertEquals("plain", XugudbSqlEscapes.escapeSqlLiteral("plain")); - assertEquals("", XugudbSqlEscapes.escapeSqlLiteral(null)); + assertEquals("o''brien", XugudbIdentifierProcessor.INSTANCE.escapeString("o'brien")); + assertEquals("''", XugudbIdentifierProcessor.INSTANCE.escapeString("'")); + assertEquals("plain", XugudbIdentifierProcessor.INSTANCE.escapeString("plain")); + assertEquals("", XugudbIdentifierProcessor.INSTANCE.escapeString(null)); } @Test void escapeIdentifierDoublesEmbeddedQuotesAndStripsSurroundingQuotes() { - assertEquals("ta\"\"ble", XugudbSqlEscapes.escapeIdentifier("ta\"ble")); - assertEquals("foo", XugudbSqlEscapes.escapeIdentifier("\"foo\"")); - assertEquals("fo\"\"o", XugudbSqlEscapes.escapeIdentifier("\"fo\"o\"")); - assertEquals("plain", XugudbSqlEscapes.escapeIdentifier("plain")); - assertEquals("", XugudbSqlEscapes.escapeIdentifier(null)); + assertEquals("ta\"\"ble", XugudbIdentifierProcessor.escapeIdentifier("ta\"ble")); + assertEquals("foo", XugudbIdentifierProcessor.escapeIdentifier("\"foo\"")); + assertEquals("fo\"\"o", XugudbIdentifierProcessor.escapeIdentifier("\"fo\"o\"")); + assertEquals("plain", XugudbIdentifierProcessor.escapeIdentifier("plain")); + assertEquals("", XugudbIdentifierProcessor.escapeIdentifier(null)); } @Test void quoteIdentifierWrapsEscapedIdentifier() { - assertEquals("\"plain\"", XugudbSqlEscapes.quoteIdentifier("plain")); - assertEquals("\"ta\"\"ble\"", XugudbSqlEscapes.quoteIdentifier("ta\"ble")); + assertEquals("\"plain\"", XugudbIdentifierProcessor.INSTANCE.quoteIdentifier("plain")); + assertEquals("\"ta\"\"ble\"", XugudbIdentifierProcessor.INSTANCE.quoteIdentifier("ta\"ble")); } @Test @@ -240,6 +241,27 @@ void validatorsReturnTrimmedValues() { assertTrue(varcharSql.contains("(10 BYTE)"), varcharSql); } + @Test + void requireDefaultValueAcceptsValidExpressionsAndRejectsInjection() { + assertEquals("0", XugudbSqlGuards.requireDefaultValue("0")); + assertEquals("-1.5", XugudbSqlGuards.requireDefaultValue("-1.5")); + assertEquals("CURRENT_TIMESTAMP", XugudbSqlGuards.requireDefaultValue("CURRENT_TIMESTAMP")); + assertEquals("now()", XugudbSqlGuards.requireDefaultValue("now()")); + assertEquals("f('it''s')", XugudbSqlGuards.requireDefaultValue("f('it''s')")); + assertThrows(IllegalArgumentException.class, + () -> XugudbSqlGuards.requireDefaultValue("0; DROP TABLE users; --")); + assertThrows(IllegalArgumentException.class, + () -> XugudbSqlGuards.requireDefaultValue("length(')")); + } + + @Test + void requireUnitAcceptsLettersAndRejectsInjection() { + assertEquals("BYTE", XugudbSqlGuards.requireUnit(" BYTE ")); + assertEquals("CHAR", XugudbSqlGuards.requireUnit("CHAR")); + assertThrows(IllegalArgumentException.class, + () -> XugudbSqlGuards.requireUnit("BYTE); DROP TABLE t; --")); + } + private static TableColumn column(String name, String type) { return TableColumn.builder() .schemaName("app") From a437934d80484e970dfb41f2651b1180498c9581 Mon Sep 17 00:00:00 2001 From: HandSonic <8078023+handsonic@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:20:20 +0800 Subject: [PATCH 5/8] fix(xugudb): unroll quoted-literal regex to avoid CodeQL polynomial-regex alert (#1914) --- .../src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlGuards.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlGuards.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlGuards.java index 5ed6774317..0ac3e0bd3f 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlGuards.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlGuards.java @@ -18,7 +18,7 @@ public final class XugudbSqlGuards { * Anything else is rejected so a hostile default cannot break out of the DDL statement. */ private static final Pattern DEFAULT_VALUE_PATTERN = Pattern.compile( - "^(-?\\d+(\\.\\d+)?|'([^']|'')*'|[A-Za-z_][A-Za-z0-9_]*(\\(([A-Za-z0-9_ ,.\\-+]|'([^']|'')*')*\\))?)$"); + "^(-?\\d+(\\.\\d+)?|'[^']*(?:''[^']*)*'|[A-Za-z_][A-Za-z0-9_]*(\\(([A-Za-z0-9_ ,.\\-+]|'[^']*(?:''[^']*)*')*\\))?)$"); /** * Conservative allow-list for length units (e.g. {@code BYTE}, {@code CHAR}). From ac6f9545326a73a0fe3ce03d0ff38379d72dcaa3 Mon Sep 17 00:00:00 2001 From: HandSonic <8078023+handsonic@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:10:54 +0800 Subject: [PATCH 6/8] fix(xugudb): replace DEFAULT validator regex with linear scanners (CodeQL ReDoS) (#1914) --- .../plugin/xugudb/XugudbSqlGuards.java | 116 ++++++++++++++++-- 1 file changed, 104 insertions(+), 12 deletions(-) diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlGuards.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlGuards.java index 0ac3e0bd3f..66fdb070c8 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlGuards.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlGuards.java @@ -8,18 +8,11 @@ * Validation helpers for non-escapable SQL positions in XUGUDB DDL generation * (column default expressions and length units supplied through table metadata). * Escaping itself lives in {@link XugudbIdentifierProcessor}. + * Quoted-literal and function-call shapes are recognized with linear-time scanners + * (no regex), so the checks cannot be driven into regex backtracking. */ public final class XugudbSqlGuards { - /** - * Conservative allow-list for unquoted column default expressions: numbers, - * single-quoted literals with correctly doubled inner quotes, and identifiers - * or function calls whose arguments are drawn from a safe character set. - * Anything else is rejected so a hostile default cannot break out of the DDL statement. - */ - private static final Pattern DEFAULT_VALUE_PATTERN = Pattern.compile( - "^(-?\\d+(\\.\\d+)?|'[^']*(?:''[^']*)*'|[A-Za-z_][A-Za-z0-9_]*(\\(([A-Za-z0-9_ ,.\\-+]|'[^']*(?:''[^']*)*')*\\))?)$"); - /** * Conservative allow-list for length units (e.g. {@code BYTE}, {@code CHAR}). */ @@ -30,17 +23,116 @@ private XugudbSqlGuards() { /** * Validates a column default expression before it is embedded into generated DDL. - * Returns the trimmed default unchanged when it matches the allow-list; throws - * otherwise (fail closed). + * Accepts negative/positive numeric literals, single-quoted string literals with + * '' escapes, and identifiers or function calls whose arguments are drawn from a + * safe character set. Anything else is rejected so a hostile default cannot break + * out of the DDL statement. */ public static String requireDefaultValue(String defaultValue) { String trimmed = defaultValue.trim(); - if (!DEFAULT_VALUE_PATTERN.matcher(trimmed).matches()) { + if (!isNumericLiteral(trimmed) && !isQuotedStringLiteral(trimmed) && !isIdentifierOrCall(trimmed)) { throw new IllegalArgumentException("Unsupported column default value: " + defaultValue); } return trimmed; } + private static boolean isNumericLiteral(String s) { + int n = s.length(); + int i = s.startsWith("-") ? 1 : 0; + boolean intDigits = false; + while (i < n && Character.isDigit(s.charAt(i))) { + i++; + intDigits = true; + } + if (!intDigits) { + return false; + } + if (i == n) { + return true; + } + if (s.charAt(i) != '.') { + return false; + } + i++; + int fracStart = i; + while (i < n && Character.isDigit(s.charAt(i))) { + i++; + } + return i == n && i > fracStart; + } + + /** + * True when {@code s} is exactly one single-quoted string literal with '' escapes. + * Linear scan, no backtracking. + */ + static boolean isQuotedStringLiteral(String s) { + return s.length() >= 2 && s.charAt(0) == '\'' && quotedLiteralEnd(s, 0) == s.length(); + } + + /** + * Returns the index just past the single-quoted literal that starts at + * {@code start} (where {@code s.charAt(start) == '\''}), or -1 when the literal + * is unterminated. Doubled quotes are consumed as escapes. + */ + private static int quotedLiteralEnd(String s, int start) { + int i = start + 1; + int n = s.length(); + while (i < n) { + if (s.charAt(i) == '\'') { + if (i + 1 < n && s.charAt(i + 1) == '\'') { + i += 2; + continue; + } + return i + 1; + } + i++; + } + return -1; + } + + /** + * True for an identifier ({@code [A-Za-z_][A-Za-z0-9_]*}) optionally followed by + * a parenthesized argument list whose characters are letters, digits, underscores, + * spaces, commas, dots, '-', '+' or single-quoted string literals (no nested + * parentheses). Linear scan. + */ + static boolean isIdentifierOrCall(String s) { + int n = s.length(); + if (n == 0 || !(Character.isLetter(s.charAt(0)) || s.charAt(0) == '_')) { + return false; + } + int i = 1; + while (i < n && (Character.isLetterOrDigit(s.charAt(i)) || s.charAt(i) == '_')) { + i++; + } + if (i == n) { + return true; + } + if (s.charAt(i) != '(') { + return false; + } + i++; + while (i < n) { + char c = s.charAt(i); + if (c == ')') { + return i == n - 1; + } + if (c == '\'') { + int end = quotedLiteralEnd(s, i); + if (end < 0) { + return false; + } + i = end; + } else if (Character.isLetterOrDigit(c) || c == '_' || c == ' ' || c == ',' + || c == '.' || c == '-' || c == '+') { + i++; + } else { + return false; + } + } + return false; + } + /** * Validates a length unit before it is embedded into generated DDL. Returns the * trimmed unit unchanged when it matches the allow-list; throws otherwise From 8d7ae52dc27a4dc321f1b4ae06dfeadf79168111 Mon Sep 17 00:00:00 2001 From: HandSonic <8078023+handsonic@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:50:04 +0800 Subject: [PATCH 7/8] fix(xugudb): restore dual-track quoteIdentifier contract (null passthrough, conditional for SPI, always for DDL) (#1914) Found in re-review: processor was always-quote with null->"" garbage, breaking SPI completion paths; no quoteIdentifierAlways existed. --- .../chat2db/plugin/xugudb/XUGUDBManager.java | 2 +- .../chat2db/plugin/xugudb/XUGUDBMetaData.java | 2 +- .../xugudb/builder/XUGUDBSqlBuilder.java | 4 +-- .../enums/type/XUGUDBColumnTypeEnum.java | 2 +- .../identifier/XugudbIdentifierProcessor.java | 26 ++++++++++++++++--- .../xugudb/XugudbIdentifierProcessorTest.java | 19 +++++++++++--- 6 files changed, 43 insertions(+), 12 deletions(-) diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBManager.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBManager.java index d76f4b83d5..09ddbcaa5f 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBManager.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBManager.java @@ -28,7 +28,7 @@ public class XUGUDBManager extends DefaultDBManager implements IDbManager { private String format(String tableName) { - return XugudbIdentifierProcessor.INSTANCE.quoteIdentifier(tableName); + return XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName); } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBMetaData.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBMetaData.java index beabc67a65..2e9561d977 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBMetaData.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBMetaData.java @@ -70,7 +70,7 @@ public List schemas(Connection connection, String databaseName) { } private String format(String tableName) { - return getSQLIdentifierProcessor().quoteIdentifier(tableName); + return XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName); } @Override diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilder.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilder.java index 5a5b33d259..05ecfb4a39 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilder.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilder.java @@ -159,7 +159,7 @@ public String buildCreateSchema(Schema schema) { StringBuilder sqlBuilder = new StringBuilder(); sqlBuilder.append(SQL_CREATE_SCHEMA+XugudbIdentifierProcessor.escapeIdentifier(schema.getName())+SQLConstants.DOUBLE_QUOTE); if(StringUtils.isNotBlank(schema.getOwner())){ - sqlBuilder.append(SQLConstants.SCHEMA_AUTHORIZATION_SQL).append(XugudbIdentifierProcessor.INSTANCE.quoteIdentifier(schema.getOwner())); + sqlBuilder.append(SQLConstants.SCHEMA_AUTHORIZATION_SQL).append(XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways(schema.getOwner())); } return sqlBuilder.toString(); @@ -167,7 +167,7 @@ public String buildCreateSchema(Schema schema) { @Override public String quoteIdentifier(String identifier) { - return XugudbIdentifierProcessor.INSTANCE.quoteIdentifier(identifier); + return XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways(identifier); } @Override diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java index 4da238a973..505fffa15d 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java @@ -173,7 +173,7 @@ private static String buildFallbackColumn(TableColumn column) { if (!FALLBACK_TYPE_PATTERN.matcher(columnType).matches()) { throw new IllegalArgumentException("Unsupported column type: " + column.getColumnType()); } - return XugudbIdentifierProcessor.INSTANCE.quoteIdentifier(column.getName()) + " " + columnType; + return XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName()) + " " + columnType; } private String buildAutoIncrement(TableColumn column, XUGUDBColumnTypeEnum type) { diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/identifier/XugudbIdentifierProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/identifier/XugudbIdentifierProcessor.java index 5fbb2bcabf..f1be47dd2a 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/identifier/XugudbIdentifierProcessor.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/identifier/XugudbIdentifierProcessor.java @@ -13,12 +13,19 @@ public class XugudbIdentifierProcessor extends DefaultSQLIdentifierProcessor { public static final XugudbIdentifierProcessor INSTANCE = new XugudbIdentifierProcessor(); /** - * Always quotes with double quotes, stripping one surrounding quote pair and - * doubling every embedded double quote. + * SPI-facing conditional quoting: null/blank pass through unchanged; valid plain + * identifiers stay unquoted (completion and matching paths rely on this); anything + * else is double-quoted with one surrounding pair stripped and embedded quotes doubled. */ @Override public String quoteIdentifier(String identifier) { - return "\"" + escapeIdentifierContent(identifier) + "\""; + if (StringUtils.isBlank(identifier)) { + return identifier; + } + if (isValidIdentifier(identifier) && !isReservedKeyword(identifier, null, null)) { + return identifier; + } + return quoteIdentifierAlways(identifier); } @Override @@ -28,7 +35,18 @@ public String quoteIdentifier(String identifier, Integer majorVersion, Integer m @Override public String quoteIdentifierIgnoreCase(String identifier) { - return quoteIdentifier(identifier); + return quoteIdentifierAlways(identifier); + } + + /** + * Unconditional quoting for DDL-generation call sites: null passes through, + * anything else is wrapped in double quotes with doubling. + */ + public String quoteIdentifierAlways(String identifier) { + if (StringUtils.isBlank(identifier)) { + return identifier; + } + return "\"" + escapeIdentifierContent(identifier) + "\""; } /** diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbIdentifierProcessorTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbIdentifierProcessorTest.java index 67d2be311e..b59be89bcd 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbIdentifierProcessorTest.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbIdentifierProcessorTest.java @@ -17,6 +17,7 @@ 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; @@ -42,9 +43,19 @@ void escapeIdentifierDoublesEmbeddedQuotesAndStripsSurroundingQuotes() { } @Test - void quoteIdentifierWrapsEscapedIdentifier() { - assertEquals("\"plain\"", XugudbIdentifierProcessor.INSTANCE.quoteIdentifier("plain")); + void quoteIdentifierIsConditionalForSpiConsumers() { + assertEquals("plain", XugudbIdentifierProcessor.INSTANCE.quoteIdentifier("plain")); assertEquals("\"ta\"\"ble\"", XugudbIdentifierProcessor.INSTANCE.quoteIdentifier("ta\"ble")); + assertNull(XugudbIdentifierProcessor.INSTANCE.quoteIdentifier(null)); + assertEquals("", XugudbIdentifierProcessor.INSTANCE.quoteIdentifier("")); + } + + @Test + void quoteIdentifierAlwaysQuotesExceptNullAndBlank() { + assertEquals("\"plain\"", XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways("plain")); + assertEquals("\"ta\"\"ble\"", XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways("ta\"ble")); + assertNull(XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways(null)); + assertEquals("", XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways("")); } @Test @@ -180,7 +191,9 @@ void maliciousIndexSortOrderIsRejected() { void selectTableNeutralizesMaliciousSchemaName() { String sql = builder.dql().buildSelectTable(null, "evil\";DROP TABLE t;--", "sample_table"); - assertEquals("SELECT * FROM \"evil\"\";DROP TABLE t;--\".\"sample_table\"", sql); + // Hostile names are quoted and escaped; benign plain identifiers stay unquoted + // (conditional SPI quoting, identical to pre-branch output for valid names). + assertEquals("SELECT * FROM \"evil\"\";DROP TABLE t;--\".sample_table", sql); } @Test From 5aa92fee9da67fa0f9948241b75a2ae9f44fce35 Mon Sep 17 00:00:00 2001 From: zgq Date: Wed, 29 Jul 2026 16:56:11 +0800 Subject: [PATCH 8/8] fix(xugudb): complete identifier escaping coverage --- .../chat2db/plugin/xugudb/XUGUDBMetaData.java | 24 +- .../plugin/xugudb/XugudbSqlGuards.java | 267 +++++++++++------- .../xugudb/builder/XUGUDBSqlBuilder.java | 48 +++- .../enums/type/XUGUDBColumnTypeEnum.java | 34 +-- .../enums/type/XUGUDBIndexTypeEnum.java | 12 +- .../identifier/XugudbIdentifierProcessor.java | 61 +++- .../xugudb/value/XugudbValueProcessor.java | 23 ++ .../xugudb/XugudbIdentifierProcessorTest.java | 214 +++++++++++++- .../xugudb/builder/XUGUDBSqlBuilderTest.java | 8 +- 9 files changed, 520 insertions(+), 171 deletions(-) create mode 100644 chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/value/XugudbValueProcessor.java diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBMetaData.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBMetaData.java index 2e9561d977..ef149bf660 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBMetaData.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XUGUDBMetaData.java @@ -5,9 +5,11 @@ import ai.chat2db.plugin.xugudb.enums.type.XUGUDBDefaultValueEnum; import ai.chat2db.plugin.xugudb.enums.type.XUGUDBIndexTypeEnum; import ai.chat2db.plugin.xugudb.identifier.XugudbIdentifierProcessor; +import ai.chat2db.plugin.xugudb.value.XugudbValueProcessor; import ai.chat2db.spi.IDbMetaData; import ai.chat2db.spi.ISQLIdentifierProcessor; import ai.chat2db.spi.ISqlBuilder; +import ai.chat2db.spi.IValueProcessor; import ai.chat2db.spi.DefaultMetaService; import ai.chat2db.community.domain.api.model.account.*; import ai.chat2db.community.domain.api.model.async.*; @@ -32,6 +34,7 @@ import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.stream.Collectors; import static ai.chat2db.spi.util.SortUtils.sortDatabase; @@ -44,6 +47,11 @@ public ISQLIdentifierProcessor getSQLIdentifierProcessor() { return XugudbIdentifierProcessor.INSTANCE; } + @Override + public IValueProcessor getValueProcessor() { + return new XugudbValueProcessor(); + } + @Override public List databases(Connection connection) { List databases = DefaultSQLExecutor.getInstance().databases(connection); @@ -311,13 +319,13 @@ public List columns(Connection connection, String databaseName, Str column.setOldName(resultSet.getString("COL_NAME")); column.setName(resultSet.getString("COL_NAME")); if (resultSet.getBoolean("VARYING")) { - if (resultSet.getString("TYPE_NAME").toUpperCase().equals(XUGUDBColumnTypeEnum.CHAR.name())) { - column.setColumnType("VAR" + resultSet.getString("TYPE_NAME").toUpperCase()); + if (resultSet.getString("TYPE_NAME").toUpperCase(Locale.ROOT).equals(XUGUDBColumnTypeEnum.CHAR.name())) { + column.setColumnType("VAR" + resultSet.getString("TYPE_NAME").toUpperCase(Locale.ROOT)); } else { - column.setColumnType(resultSet.getString("TYPE_NAME").toUpperCase()); + column.setColumnType(resultSet.getString("TYPE_NAME").toUpperCase(Locale.ROOT)); } } else { - column.setColumnType(resultSet.getString("TYPE_NAME").toUpperCase()); + column.setColumnType(resultSet.getString("TYPE_NAME").toUpperCase(Locale.ROOT)); } column.setDefaultValue(resultSet.getString("DEF_VAL")); column.setComment(resultSet.getString("COMMENTS")); @@ -349,9 +357,13 @@ public TableMeta getTableMeta(String databaseName, String schemaName, String tab @Override public String getMetaDataName(String... names) { if (Arrays.stream(names).count() > 1) { - return Arrays.stream(names).skip(1).filter(name -> StringUtils.isNotBlank(name)).map(getSQLIdentifierProcessor()::quoteIdentifier).collect(Collectors.joining(".")); + return Arrays.stream(names).skip(1).filter(StringUtils::isNotBlank) + .map(XugudbIdentifierProcessor.INSTANCE::quoteIdentifierAlways) + .collect(Collectors.joining(".")); } - return Arrays.stream(names).filter(name -> StringUtils.isNotBlank(name)).map(getSQLIdentifierProcessor()::quoteIdentifier).collect(Collectors.joining(".")); + return Arrays.stream(names).filter(StringUtils::isNotBlank) + .map(XugudbIdentifierProcessor.INSTANCE::quoteIdentifierAlways) + .collect(Collectors.joining(".")); } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlGuards.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlGuards.java index 66fdb070c8..2d59d9efd9 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlGuards.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlGuards.java @@ -1,148 +1,199 @@ package ai.chat2db.plugin.xugudb; -import java.util.regex.Pattern; +import org.apache.commons.lang3.StringUtils; -import ai.chat2db.plugin.xugudb.identifier.XugudbIdentifierProcessor; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.Locale; +import java.util.Set; /** - * Validation helpers for non-escapable SQL positions in XUGUDB DDL generation - * (column default expressions and length units supplied through table metadata). - * Escaping itself lives in {@link XugudbIdentifierProcessor}. - * Quoted-literal and function-call shapes are recognized with linear-time scanners - * (no regex), so the checks cannot be driven into regex backtracking. + * Structural validation for XuguDB SQL fragments emitted as syntax rather + * than identifiers or string literals. */ public final class XugudbSqlGuards { - /** - * Conservative allow-list for length units (e.g. {@code BYTE}, {@code CHAR}). - */ - private static final Pattern UNIT_PATTERN = Pattern.compile("^[A-Za-z]+$"); + private static final Set COLUMN_CLAUSE_KEYWORDS = Set.of( + "COLLATE", "CONSTRAINT", "DEFAULT", "GENERATED", "IDENTITY", + "PRIMARY", "REFERENCES", "UNIQUE"); + + private static final Set STATEMENT_KEYWORDS = Set.of( + "ALTER", "CREATE", "DELETE", "DROP", "GRANT", "INSERT", "MERGE", + "REVOKE", "SELECT", "TRUNCATE", "UPDATE"); private XugudbSqlGuards() { } /** - * Validates a column default expression before it is embedded into generated DDL. - * Accepts negative/positive numeric literals, single-quoted string literals with - * '' escapes, and identifiers or function calls whose arguments are drawn from a - * safe character set. Anything else is rejected so a hostile default cannot break - * out of the DDL statement. + * Validates one DEFAULT expression while preserving quoted literal content. + * Nested calls, casts, sequence expressions, qualified names, and outer + * parentheses are accepted when all delimiters are balanced. */ public static String requireDefaultValue(String defaultValue) { - String trimmed = defaultValue.trim(); - if (!isNumericLiteral(trimmed) && !isQuotedStringLiteral(trimmed) && !isIdentifierOrCall(trimmed)) { - throw new IllegalArgumentException("Unsupported column default value: " + defaultValue); + String trimmed = StringUtils.trimToNull(defaultValue); + if (trimmed == null) { + throw invalid("DEFAULT expression", defaultValue); } + scanExpression(trimmed, false, "DEFAULT expression"); return trimmed; } - private static boolean isNumericLiteral(String s) { - int n = s.length(); - int i = s.startsWith("-") ? 1 : 0; - boolean intDigits = false; - while (i < n && Character.isDigit(s.charAt(i))) { - i++; - intDigits = true; - } - if (!intDigits) { - return false; - } - if (i == n) { - return true; + /** + * Validates a complete XuguDB column type expression, including + * parameterized and schema-qualified user-defined types. + */ + public static String requireColumnTypeExpression(String columnType) { + String trimmed = StringUtils.trimToNull(columnType); + if (trimmed == null) { + throw invalid("column type", columnType); } - if (s.charAt(i) != '.') { - return false; + scanExpression(trimmed, true, "column type"); + return trimmed; + } + + public static String requireUnit(String unit) { + String trimmed = StringUtils.trimToEmpty(unit); + if ("BYTE".equalsIgnoreCase(trimmed)) { + return "BYTE"; } - i++; - int fracStart = i; - while (i < n && Character.isDigit(s.charAt(i))) { - i++; + if ("CHAR".equalsIgnoreCase(trimmed)) { + return "CHAR"; } - return i == n && i > fracStart; + throw new IllegalArgumentException("Unsupported XuguDB length unit: " + unit); } - /** - * True when {@code s} is exactly one single-quoted string literal with '' escapes. - * Linear scan, no backtracking. - */ - static boolean isQuotedStringLiteral(String s) { - return s.length() >= 2 && s.charAt(0) == '\'' && quotedLiteralEnd(s, 0) == s.length(); - } + private static void scanExpression(String expression, boolean typeExpression, String description) { + Deque parentheses = new ArrayDeque<>(); + List topLevelWords = new ArrayList<>(); + boolean sawToken = false; - /** - * Returns the index just past the single-quoted literal that starts at - * {@code start} (where {@code s.charAt(start) == '\''}), or -1 when the literal - * is unterminated. Doubled quotes are consumed as escapes. - */ - private static int quotedLiteralEnd(String s, int start) { - int i = start + 1; - int n = s.length(); - while (i < n) { - if (s.charAt(i) == '\'') { - if (i + 1 < n && s.charAt(i + 1) == '\'') { - i += 2; - continue; + for (int i = 0; i < expression.length(); i++) { + char c = expression.charAt(i); + if (Character.isWhitespace(c)) { + continue; + } + sawToken = true; + + if (!typeExpression && isAlternativeQuoteStart(expression, i)) { + i = scanAlternativeQuote(expression, i, description); + continue; + } + if (c == '\'' || c == '"') { + if (typeExpression && c == '\'') { + throw invalid(description, expression); } - return i + 1; + i = scanQuoted(expression, i, c, description); + continue; + } + if (c == ';' || Character.isISOControl(c) + || startsWith(expression, i, "--") + || startsWith(expression, i, "/*") + || startsWith(expression, i, "*/")) { + throw invalid(description, expression); + } + if (c == '(') { + parentheses.push(c); + continue; + } + if (c == ')') { + if (parentheses.isEmpty()) { + throw invalid(description, expression); + } + parentheses.pop(); + continue; + } + if (c == '[' || c == ']' || c == '{' || c == '}') { + throw invalid(description, expression); + } + if (c == ',' && parentheses.isEmpty()) { + throw invalid(description, expression); + } + if (typeExpression && !isTypeCharacter(c)) { + throw invalid(description, expression); + } + if (Character.isLetter(c) || c == '_') { + int wordEnd = i + 1; + while (wordEnd < expression.length() && isWordCharacter(expression.charAt(wordEnd))) { + wordEnd++; + } + String word = expression.substring(i, wordEnd).toUpperCase(Locale.ROOT); + if (STATEMENT_KEYWORDS.contains(word)) { + throw invalid(description, expression); + } + if (parentheses.isEmpty()) { + topLevelWords.add(word); + } + i = wordEnd - 1; } - i++; } - return -1; - } - /** - * True for an identifier ({@code [A-Za-z_][A-Za-z0-9_]*}) optionally followed by - * a parenthesized argument list whose characters are letters, digits, underscores, - * spaces, commas, dots, '-', '+' or single-quoted string literals (no nested - * parentheses). Linear scan. - */ - static boolean isIdentifierOrCall(String s) { - int n = s.length(); - if (n == 0 || !(Character.isLetter(s.charAt(0)) || s.charAt(0) == '_')) { - return false; - } - int i = 1; - while (i < n && (Character.isLetterOrDigit(s.charAt(i)) || s.charAt(i) == '_')) { - i++; - } - if (i == n) { - return true; + if (!sawToken || !parentheses.isEmpty()) { + throw invalid(description, expression); } - if (s.charAt(i) != '(') { - return false; + for (String word : topLevelWords) { + if (COLUMN_CLAUSE_KEYWORDS.contains(word)) { + throw invalid(description, expression); + } } - i++; - while (i < n) { - char c = s.charAt(i); - if (c == ')') { - return i == n - 1; + for (int i = 0; i + 1 < topLevelWords.size(); i++) { + if ("NOT".equals(topLevelWords.get(i)) && "NULL".equals(topLevelWords.get(i + 1))) { + throw invalid(description, expression); } - if (c == '\'') { - int end = quotedLiteralEnd(s, i); - if (end < 0) { - return false; + } + } + + private static int scanQuoted(String expression, int start, char quote, String description) { + for (int i = start + 1; i < expression.length(); i++) { + if (expression.charAt(i) == quote) { + if (i + 1 < expression.length() && expression.charAt(i + 1) == quote) { + i++; + continue; } - i = end; - } else if (Character.isLetterOrDigit(c) || c == '_' || c == ' ' || c == ',' - || c == '.' || c == '-' || c == '+') { - i++; - } else { - return false; + return i; } } - return false; + throw invalid(description, expression); } - /** - * Validates a length unit before it is embedded into generated DDL. Returns the - * trimmed unit unchanged when it matches the allow-list; throws otherwise - * (fail closed). - */ - public static String requireUnit(String unit) { - String trimmed = unit.trim(); - if (!UNIT_PATTERN.matcher(trimmed).matches()) { - throw new IllegalArgumentException("Unsupported length unit: " + unit); + private static boolean isAlternativeQuoteStart(String expression, int offset) { + return offset + 2 < expression.length() + && (expression.charAt(offset) == 'q' || expression.charAt(offset) == 'Q') + && expression.charAt(offset + 1) == '\''; + } + + private static int scanAlternativeQuote(String expression, int start, String description) { + char open = expression.charAt(start + 2); + char close = switch (open) { + case '[' -> ']'; + case '{' -> '}'; + case '(' -> ')'; + case '<' -> '>'; + default -> open; + }; + for (int i = start + 3; i + 1 < expression.length(); i++) { + if (expression.charAt(i) == close && expression.charAt(i + 1) == '\'') { + return i + 1; + } } - return trimmed; + throw invalid(description, expression); + } + + private static boolean isTypeCharacter(char c) { + return Character.isLetterOrDigit(c) || c == '_' || c == '$' || c == '#' + || c == '.' || c == '%' || c == '*' || c == '+' || c == '-' || c == ','; + } + + private static boolean isWordCharacter(char c) { + return Character.isLetterOrDigit(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 IllegalArgumentException invalid(String description, String value) { + return new IllegalArgumentException("Invalid XuguDB " + description + ": " + value); } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilder.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilder.java index 05ecfb4a39..aceb2d6ca8 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilder.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilder.java @@ -8,11 +8,14 @@ import ai.chat2db.community.domain.api.enums.plugin.EditStatusEnum; import ai.chat2db.spi.DefaultSqlBuilder; import ai.chat2db.spi.model.request.PageLimitRequest; +import ai.chat2db.spi.model.request.UpdateSqlRequest; +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.MapUtils; import org.apache.commons.lang3.StringUtils; import java.util.Arrays; @@ -21,6 +24,10 @@ import java.util.stream.Collectors; import static ai.chat2db.plugin.xugudb.constant.XUGUDBSqlBuilderConstants.*; +import static ai.chat2db.spi.constant.DefaultSqlBuilderConstants.SQL_AND; +import static ai.chat2db.spi.constant.DefaultSqlBuilderConstants.SQL_SET_2; +import static ai.chat2db.spi.constant.DefaultSqlBuilderConstants.SQL_UPDATE; +import static ai.chat2db.spi.constant.DefaultSqlBuilderConstants.SQL_WHERE_2; public class XUGUDBSqlBuilder extends DefaultSqlBuilder { @@ -40,15 +47,18 @@ public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfi script.append(SQL_CREATE_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(XugudbIdentifierProcessor.escapeIdentifier(table.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(XugudbIdentifierProcessor.escapeIdentifier(table.getName())).append(VALUE_DOUBLE_QUOTE_OPEN_PAREN).append(SQLConstants.LINE_SEPARATOR); + int columnCount = 0; for (TableColumn column : table.getColumnList()) { if (StringUtils.isBlank(column.getName()) || StringUtils.isBlank(column.getColumnType())) { continue; } XUGUDBColumnTypeEnum typeEnum = XUGUDBColumnTypeEnum.getByType(column.getColumnType()); - if (typeEnum == null) { - continue; - } + typeEnum = typeEnum == null ? XUGUDBColumnTypeEnum.VARCHAR : typeEnum; script.append(SQLConstants.TAB).append(typeEnum.buildCreateColumnSql(column)).append(SQLConstants.COMMA_LINE_SEPARATOR); + columnCount++; + } + if (columnCount == 0) { + throw new IllegalArgumentException("XuguDB table requires at least one valid column"); } script = new StringBuilder(script.substring(0, script.length() - 2)); @@ -96,7 +106,7 @@ private String buildComment(TableColumn column) { public String buildAlterTable(Table oldTable, Table newTable) { StringBuilder script = new StringBuilder(); - if (!StringUtils.equalsIgnoreCase(oldTable.getName(), newTable.getName())) { + if (!StringUtils.equals(oldTable.getName(), newTable.getName())) { script.append(SQL_ALTER_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(XugudbIdentifierProcessor.escapeIdentifier(oldTable.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(XugudbIdentifierProcessor.escapeIdentifier(oldTable.getName())).append(SQLConstants.DOUBLE_QUOTE); script.append(SQLConstants.SPACE).append(SQL_RENAME).append(SQLConstants.DOUBLE_QUOTE).append(XugudbIdentifierProcessor.escapeIdentifier(newTable.getName())).append(SQLConstants.DOUBLE_QUOTE).append(SQLConstants.SEMICOLON_LINE_SEPARATOR); } @@ -107,9 +117,7 @@ public String buildAlterTable(Table oldTable, Table newTable) { String editStatus = tableColumn.getEditStatus(); if (StringUtils.isNotBlank(editStatus)) { XUGUDBColumnTypeEnum typeEnum = XUGUDBColumnTypeEnum.getByType(tableColumn.getColumnType()); - if (typeEnum == null) { - continue; - } + typeEnum = typeEnum == null ? XUGUDBColumnTypeEnum.VARCHAR : typeEnum; script.append(SQLConstants.TAB).append(typeEnum.buildModifyColumn(tableColumn)).append(SQLConstants.SEMICOLON_LINE_SEPARATOR); if (StringUtils.isNotBlank(tableColumn.getComment())&&!Objects.equals(EditStatusEnum.DELETE.toString(),editStatus)) { script.append(SQLConstants.LINE_SEPARATOR).append(buildComment(tableColumn)).append(SQLConstants.SEMICOLON_LINE_SEPARATOR); @@ -165,6 +173,11 @@ public String buildCreateSchema(Schema schema) { return sqlBuilder.toString(); } + @Override + public String buildCreateDatabase(Database database) { + return SQLConstants.CREATE_DATABASE_SQL_PREFIX + quoteIdentifier(database.getName()); + } + @Override public String quoteIdentifier(String identifier) { return XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways(identifier); @@ -174,7 +187,7 @@ public String quoteIdentifier(String identifier) { public String quoteQualifiedIdentifier(String... identifiers) { return Arrays.stream(identifiers) .filter(StringUtils::isNotBlank) - .map(XugudbIdentifierProcessor.INSTANCE::quoteIdentifier) + .map(XugudbIdentifierProcessor.INSTANCE::quoteIdentifierAlways) .collect(Collectors.joining(SQLConstants.DOT)); } @@ -193,9 +206,26 @@ protected void buildColumns(List columnList, StringBuilder script) { if (columnList != null && !columnList.isEmpty()) { script.append(SQLConstants.SPACE_OPEN_PARENTHESIS) .append(columnList.stream() - .map(XugudbIdentifierProcessor.INSTANCE::quoteIdentifier) + .map(XugudbIdentifierProcessor.INSTANCE::quoteIdentifierAlways) .collect(Collectors.joining(SQLConstants.COMMA))) .append(SQLConstants.CLOSE_PARENTHESIS_SPACE); } } + + @Override + public String buildUpdate(UpdateSqlRequest request) { + StringBuilder script = new StringBuilder(SQL_UPDATE); + buildTableName(request.getDatabaseName(), request.getSchemaName(), request.getTableName(), script); + script.append(SQL_SET_2); + script.append(request.getRow().entrySet().stream() + .map(entry -> quoteIdentifier(entry.getKey()) + SQLConstants.EQUAL_SQL + entry.getValue()) + .collect(Collectors.joining(SQLConstants.COMMA))); + if (MapUtils.isNotEmpty(request.getPrimaryKeyMap())) { + script.append(SQL_WHERE_2); + script.append(request.getPrimaryKeyMap().entrySet().stream() + .map(entry -> quoteIdentifier(entry.getKey()) + SQLConstants.EQUAL_SQL + entry.getValue()) + .collect(Collectors.joining(SQL_AND))); + } + return script.toString(); + } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java index 505fffa15d..0d672473ea 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBColumnTypeEnum.java @@ -10,7 +10,6 @@ import org.apache.commons.lang3.StringUtils; import java.util.*; -import java.util.regex.Pattern; import static ai.chat2db.plugin.xugudb.constant.XUGUDBColumnTypeEnumConstants.*; public enum XUGUDBColumnTypeEnum implements IColumnBuilder { @@ -96,7 +95,10 @@ public enum XUGUDBColumnTypeEnum implements IColumnBuilder { private ColumnType columnType; public static XUGUDBColumnTypeEnum getByType(String dataType) { - return COLUMN_TYPE_MAP.get(dataType.toUpperCase()); + if (dataType == null) { + return null; + } + return COLUMN_TYPE_MAP.get(dataType.trim().toUpperCase(Locale.ROOT)); } private static Map COLUMN_TYPE_MAP = Maps.newHashMap(); @@ -118,7 +120,7 @@ public ColumnType getColumnType() { @Override public String buildCreateColumnSql(TableColumn column) { - XUGUDBColumnTypeEnum type = COLUMN_TYPE_MAP.get(column.getColumnType().toUpperCase()); + XUGUDBColumnTypeEnum type = getByType(column.getColumnType()); if (type == null) { return buildFallbackColumn(column); } @@ -138,10 +140,7 @@ public String buildCreateColumnSql(TableColumn column) { } public String buildUpdateColumnSql(TableColumn column) { - XUGUDBColumnTypeEnum type = COLUMN_TYPE_MAP.get(column.getColumnType().toUpperCase()); - if (type == null) { - return ""; - } + XUGUDBColumnTypeEnum type = getByType(column.getColumnType()); StringBuilder script = new StringBuilder(); script.append(SQL_ALTER_TABLE).append("\"").append(XugudbIdentifierProcessor.escapeIdentifier(column.getSchemaName())).append("\".\"").append(XugudbIdentifierProcessor.escapeIdentifier(column.getTableName())).append("\""); script.append(" ").append("MODIFY (").append("\"").append(XugudbIdentifierProcessor.escapeIdentifier(column.getName())).append("\"").append(" "); @@ -152,13 +151,15 @@ public String buildUpdateColumnSql(TableColumn column) { Integer newColumnSize = Optional.ofNullable(column.getColumnSize()) .orElse(null); - if (!column.getOldColumn().getColumnType().equals(column.getColumnType()) + if (!Objects.equals(column.getOldColumn().getColumnType(), column.getColumnType()) || !Objects.equals(oldColumnSize, newColumnSize)) { - script.append(buildDataType(column, type)).append(" "); + script.append(type == null + ? XugudbSqlGuards.requireColumnTypeExpression(column.getColumnType()) + : buildDataType(column, type)).append(" "); isModify = true; } if (!Objects.equals(column.getOldColumn().getNullable(), column.getNullable())) { - script.append(buildNullable(column, type)).append(" "); + script.append(type == null ? buildFallbackNullable(column) : buildNullable(column, type)).append(" "); isModify = true; } script.append(") \n"); @@ -166,16 +167,15 @@ public String buildUpdateColumnSql(TableColumn column) { return isModify ? script.toString() : ""; } - private static final Pattern FALLBACK_TYPE_PATTERN = Pattern.compile("\\A[A-Za-z]+(\\(\\d+(,\\d+)?\\))?\\z"); - private static String buildFallbackColumn(TableColumn column) { - String columnType = column.getColumnType() == null ? "" : column.getColumnType().trim(); - if (!FALLBACK_TYPE_PATTERN.matcher(columnType).matches()) { - throw new IllegalArgumentException("Unsupported column type: " + column.getColumnType()); - } + String columnType = XugudbSqlGuards.requireColumnTypeExpression(column.getColumnType()); return XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName()) + " " + columnType; } + private static String buildFallbackNullable(TableColumn column) { + return column.getNullable() != null && column.getNullable() == 1 ? "DROP NOT NULL" : "SET NOT NULL"; + } + private String buildAutoIncrement(TableColumn column, XUGUDBColumnTypeEnum type) { if (!type.getColumnType().isSupportAutoIncrement()) { return ""; @@ -299,7 +299,7 @@ public String buildModifyColumn(TableColumn tableColumn) { } if (EditStatusEnum.MODIFY.name().equals(tableColumn.getEditStatus())) { StringBuilder script = new StringBuilder(); - if (!StringUtils.equalsIgnoreCase(tableColumn.getOldName(), tableColumn.getName())) { + if (!StringUtils.equals(tableColumn.getOldName(), tableColumn.getName())) { script.append(SQL_ALTER_TABLE).append("\"").append(XugudbIdentifierProcessor.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(XugudbIdentifierProcessor.escapeIdentifier(tableColumn.getTableName())).append("\""); script.append(" ").append(SQL_RENAME_COLUMN).append("\"").append(XugudbIdentifierProcessor.escapeIdentifier(tableColumn.getOldName())).append("\"").append(" TO ").append("\"").append(XugudbIdentifierProcessor.escapeIdentifier(tableColumn.getName())).append("\" ").append(";\n").append(buildUpdateColumnSql(tableColumn)); } else { diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBIndexTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBIndexTypeEnum.java index 3baf1181d7..f2b3b28e91 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBIndexTypeEnum.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/enums/type/XUGUDBIndexTypeEnum.java @@ -108,10 +108,14 @@ private String buildIndexName(TableIndex tableIndex) { } private static String validateAscOrDesc(String ascOrDesc) { - if (!"ASC".equalsIgnoreCase(ascOrDesc.trim()) && !"DESC".equalsIgnoreCase(ascOrDesc.trim())) { - throw new IllegalArgumentException("Unsupported index sort order: " + ascOrDesc); + String trimmed = ascOrDesc.trim(); + if ("ASC".equalsIgnoreCase(trimmed)) { + return "ASC"; } - return ascOrDesc; + if ("DESC".equalsIgnoreCase(trimmed)) { + return "DESC"; + } + throw new IllegalArgumentException("Unsupported index sort order: " + ascOrDesc); } public String buildModifyIndex(TableIndex tableIndex) { @@ -128,7 +132,7 @@ public String buildModifyIndex(TableIndex tableIndex) { } private String buildDropIndex(TableIndex tableIndex) { - if (XUGUDBIndexTypeEnum.PRIMARY_KEY.getName().equals(tableIndex.getType())) { + if (PRIMARY_KEY.equals(this)) { String tableName = "\"" + XugudbIdentifierProcessor.escapeIdentifier(tableIndex.getSchemaName()) + "\"." + "\"" + XugudbIdentifierProcessor.escapeIdentifier(tableIndex.getTableName()) + "\""; return StringUtils.join(SQL_ALTER_TABLE,tableName,SQL_DROP_PRIMARY_KEY); } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/identifier/XugudbIdentifierProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/identifier/XugudbIdentifierProcessor.java index f1be47dd2a..73e9996cdb 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/identifier/XugudbIdentifierProcessor.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/identifier/XugudbIdentifierProcessor.java @@ -3,6 +3,9 @@ import ai.chat2db.spi.DefaultSQLIdentifierProcessor; import org.apache.commons.lang3.StringUtils; +import java.util.Locale; +import java.util.Set; + /** * XUGUDB dialect identifier processor: double-quoted identifiers with embedded-quote * doubling, and single-quote doubling for string literals. Shared stateless @@ -12,6 +15,21 @@ public class XugudbIdentifierProcessor extends DefaultSQLIdentifierProcessor { public static final XugudbIdentifierProcessor INSTANCE = new XugudbIdentifierProcessor(); + private static final Set RESERVED_KEYWORDS = Set.of( + "ALL", "ALTER", "AND", "ANY", "AS", "ASC", "BETWEEN", "BY", "CASE", "CHECK", + "COLUMN", "CONNECT", "CREATE", "CURRENT", "DATABASE", "DATE", "DEFAULT", "DELETE", + "DESC", "DISTINCT", "DROP", "ELSE", "END", "EXISTS", "FALSE", "FOR", "FROM", + "GRANT", "GROUP", "HAVING", "IN", "INDEX", "INNER", "INSERT", "INTERSECT", "INTO", + "IS", "JOIN", "LEFT", "LIKE", "LIMIT", "NOT", "NULL", "OFFSET", "ON", "OR", + "ORDER", "OUTER", "PRIMARY", "PROCEDURE", "REFERENCES", "RIGHT", "ROW", "SCHEMA", + "SELECT", "SET", "TABLE", "THEN", "TO", "TRIGGER", "TRUE", "UNION", "UNIQUE", + "UPDATE", "USER", "VALUES", "VIEW", "WHEN", "WHERE", "WITH"); + + @Override + public boolean isReservedKeyword(String identifier, Integer majorVersion, Integer minorVersion) { + return identifier != null && RESERVED_KEYWORDS.contains(identifier.toUpperCase(Locale.ROOT)); + } + /** * SPI-facing conditional quoting: null/blank pass through unchanged; valid plain * identifiers stay unquoted (completion and matching paths rely on this); anything @@ -19,7 +37,10 @@ public class XugudbIdentifierProcessor extends DefaultSQLIdentifierProcessor { */ @Override public String quoteIdentifier(String identifier) { - if (StringUtils.isBlank(identifier)) { + if (identifier == null || StringUtils.isBlank(identifier)) { + return identifier; + } + if (isValidQuotedIdentifier(identifier)) { return identifier; } if (isValidIdentifier(identifier) && !isReservedKeyword(identifier, null, null)) { @@ -35,16 +56,17 @@ public String quoteIdentifier(String identifier, Integer majorVersion, Integer m @Override public String quoteIdentifierIgnoreCase(String identifier) { - return quoteIdentifierAlways(identifier); + return quoteIdentifier(identifier); } /** * Unconditional quoting for DDL-generation call sites: null passes through, * anything else is wrapped in double quotes with doubling. */ + @Override public String quoteIdentifierAlways(String identifier) { - if (StringUtils.isBlank(identifier)) { - return identifier; + if (identifier == null) { + return null; } return "\"" + escapeIdentifierContent(identifier) + "\""; } @@ -55,18 +77,15 @@ public String quoteIdentifierAlways(String identifier) { */ @Override public String escapeString(String str) { - return str == null ? "" : StringUtils.replace(str, "'", "''"); + return str == null ? null : StringUtils.replace(str, "'", "''"); + } + + public String quoteStringLiteral(String str) { + return str == null ? null : "'" + escapeString(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 identifier == null ? null : StringUtils.replace(identifier, "\"", "\"\""); } /** @@ -77,4 +96,20 @@ private static String escapeIdentifierContent(String identifier) { public static String escapeIdentifier(String identifier) { return escapeIdentifierContent(identifier); } + + private static boolean isValidQuotedIdentifier(String identifier) { + if (identifier.length() < 2 || identifier.charAt(0) != '"' + || identifier.charAt(identifier.length() - 1) != '"') { + return false; + } + for (int i = 1; i < identifier.length() - 1; i++) { + if (identifier.charAt(i) == '"') { + if (i + 1 >= identifier.length() - 1 || identifier.charAt(i + 1) != '"') { + return false; + } + i++; + } + } + return true; + } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/value/XugudbValueProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/value/XugudbValueProcessor.java new file mode 100644 index 0000000000..0288fa2416 --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/value/XugudbValueProcessor.java @@ -0,0 +1,23 @@ +package ai.chat2db.plugin.xugudb.value; + +import ai.chat2db.community.domain.api.model.value.SQLDataValue; +import ai.chat2db.plugin.xugudb.identifier.XugudbIdentifierProcessor; +import ai.chat2db.spi.DefaultValueProcessor; +import ai.chat2db.spi.model.value.JDBCDataValue; + +/** + * Preserves backslashes in XuguDB string literals while doubling single quotes. + */ +public class XugudbValueProcessor extends DefaultValueProcessor { + + @Override + public String convertSQLValueByType(SQLDataValue dataValue) { + return XugudbIdentifierProcessor.INSTANCE.quoteStringLiteral(dataValue.getValue()); + } + + @Override + public String convertJDBCValueStrByType(JDBCDataValue dataValue) { + String value = dataValue.getString(); + return value == null ? "NULL" : XugudbIdentifierProcessor.INSTANCE.quoteStringLiteral(value); + } +} diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbIdentifierProcessorTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbIdentifierProcessorTest.java index b59be89bcd..8141a320fc 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbIdentifierProcessorTest.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbIdentifierProcessorTest.java @@ -1,19 +1,27 @@ package ai.chat2db.plugin.xugudb; import ai.chat2db.community.domain.api.config.TableBuilderConfig; +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.model.metadata.TableIndexColumn; +import ai.chat2db.community.domain.api.model.value.SQLDataValue; import ai.chat2db.plugin.xugudb.builder.XUGUDBSqlBuilder; import ai.chat2db.plugin.xugudb.enums.type.XUGUDBColumnTypeEnum; import ai.chat2db.plugin.xugudb.enums.type.XUGUDBIndexTypeEnum; import ai.chat2db.plugin.xugudb.identifier.XugudbIdentifierProcessor; +import ai.chat2db.spi.model.request.DropTableRequest; +import ai.chat2db.spi.model.request.MultiInsertSqlRequest; import ai.chat2db.spi.model.request.SingleInsertSqlRequest; +import ai.chat2db.spi.model.request.TruncateTableRequest; +import ai.chat2db.spi.model.request.UpdateSqlRequest; import org.junit.jupiter.api.Test; import java.util.List; +import java.util.Locale; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -30,32 +38,63 @@ void escapeSqlLiteralDoublesSingleQuotes() { assertEquals("o''brien", XugudbIdentifierProcessor.INSTANCE.escapeString("o'brien")); assertEquals("''", XugudbIdentifierProcessor.INSTANCE.escapeString("'")); assertEquals("plain", XugudbIdentifierProcessor.INSTANCE.escapeString("plain")); - assertEquals("", XugudbIdentifierProcessor.INSTANCE.escapeString(null)); + assertNull(XugudbIdentifierProcessor.INSTANCE.escapeString(null)); } @Test - void escapeIdentifierDoublesEmbeddedQuotesAndStripsSurroundingQuotes() { + void valueProcessorPreservesBackslashesAndEscapesSingleQuotes() { + SQLDataValue value = new SQLDataValue(); + value.setValue("C:\\tmp\\o'Brien"); + + assertEquals("'C:\\tmp\\o''Brien'", new XUGUDBMetaData().getValueProcessor().getSqlValueString(value)); + } + + @Test + void escapeIdentifierDoublesEveryRawQuote() { assertEquals("ta\"\"ble", XugudbIdentifierProcessor.escapeIdentifier("ta\"ble")); - assertEquals("foo", XugudbIdentifierProcessor.escapeIdentifier("\"foo\"")); - assertEquals("fo\"\"o", XugudbIdentifierProcessor.escapeIdentifier("\"fo\"o\"")); + assertEquals("\"\"foo\"\"", XugudbIdentifierProcessor.escapeIdentifier("\"foo\"")); + assertEquals("\"\"fo\"\"o\"\"", XugudbIdentifierProcessor.escapeIdentifier("\"fo\"o\"")); assertEquals("plain", XugudbIdentifierProcessor.escapeIdentifier("plain")); - assertEquals("", XugudbIdentifierProcessor.escapeIdentifier(null)); + assertNull(XugudbIdentifierProcessor.escapeIdentifier(null)); } @Test void quoteIdentifierIsConditionalForSpiConsumers() { assertEquals("plain", XugudbIdentifierProcessor.INSTANCE.quoteIdentifier("plain")); assertEquals("\"ta\"\"ble\"", XugudbIdentifierProcessor.INSTANCE.quoteIdentifier("ta\"ble")); + assertEquals("\"SELECT\"", XugudbIdentifierProcessor.INSTANCE.quoteIdentifier("SELECT")); + assertEquals("\"already\"", XugudbIdentifierProcessor.INSTANCE.quoteIdentifier("\"already\"")); assertNull(XugudbIdentifierProcessor.INSTANCE.quoteIdentifier(null)); assertEquals("", XugudbIdentifierProcessor.INSTANCE.quoteIdentifier("")); } @Test - void quoteIdentifierAlwaysQuotesExceptNullAndBlank() { + void quoteIdentifierAlwaysRoundTripsEveryNonNullValue() { assertEquals("\"plain\"", XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways("plain")); assertEquals("\"ta\"\"ble\"", XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways("ta\"ble")); assertNull(XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways(null)); - assertEquals("", XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways("")); + assertEquals("\"\"", XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways("")); + assertEquals("\" \"", XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways(" ")); + assertEquals("\"\"\"abc\"\"\"", XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways("\"abc\"")); + + for (String raw : List.of("", " ", "\"abc\"", "A\"B")) { + assertEquals(raw, XugudbIdentifierProcessor.INSTANCE.removeIdentifierQuote( + XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways(raw))); + } + } + + @Test + void reservedWordAndIgnoreCaseQuotingAreLocaleStable() { + Locale original = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + assertEquals("\"insert\"", XugudbIdentifierProcessor.INSTANCE.quoteIdentifier("insert")); + assertEquals("plain", XugudbIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("plain")); + assertEquals("\"select\"", XugudbIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("select")); + assertEquals(XUGUDBColumnTypeEnum.TINYINT, XUGUDBColumnTypeEnum.getByType("tinyint")); + } finally { + Locale.setDefault(original); + } } @Test @@ -149,6 +188,28 @@ void balancedQuotedArgsInFunctionDefaultAreAccepted() { assertTrue(XUGUDBColumnTypeEnum.VARCHAR.buildCreateColumnSql(escapedQuoteArg).contains("DEFAULT f('it''s')")); } + @Test + void nestedCastSequenceAndParenthesizedDefaultsAreAccepted() { + assertEquals("COALESCE(NULLIF(name, ''), 'unknown')", + XugudbSqlGuards.requireDefaultValue("COALESCE(NULLIF(name, ''), 'unknown')")); + assertEquals("CAST(1 AS DECIMAL(10,2))", + XugudbSqlGuards.requireDefaultValue("CAST(1 AS DECIMAL(10,2))")); + assertEquals("app.seq.NEXTVAL", XugudbSqlGuards.requireDefaultValue("app.seq.NEXTVAL")); + assertEquals("(0)", XugudbSqlGuards.requireDefaultValue("(0)")); + } + + @Test + void defaultScannerRejectsCommentsStatementsAndUnbalancedDelimiters() { + assertThrows(IllegalArgumentException.class, + () -> XugudbSqlGuards.requireDefaultValue("f(1--comment)")); + assertThrows(IllegalArgumentException.class, + () -> XugudbSqlGuards.requireDefaultValue("f(1, DROP TABLE users)")); + assertThrows(IllegalArgumentException.class, + () -> XugudbSqlGuards.requireDefaultValue("f(/* comment */1)")); + assertThrows(IllegalArgumentException.class, + () -> XugudbSqlGuards.requireDefaultValue("(0")); + } + @Test void validDefaultValuesAreAccepted() { TableColumn numeric = column("id", "INTEGER"); @@ -191,9 +252,7 @@ void maliciousIndexSortOrderIsRejected() { void selectTableNeutralizesMaliciousSchemaName() { String sql = builder.dql().buildSelectTable(null, "evil\";DROP TABLE t;--", "sample_table"); - // Hostile names are quoted and escaped; benign plain identifiers stay unquoted - // (conditional SPI quoting, identical to pre-branch output for valid names). - assertEquals("SELECT * FROM \"evil\"\";DROP TABLE t;--\".sample_table", sql); + assertEquals("SELECT * FROM \"evil\"\";DROP TABLE t;--\".\"sample_table\"", sql); } @Test @@ -212,6 +271,68 @@ void insertNeutralizesMaliciousTableAndColumnNames() { assertFalse(sql.contains("INTO \"app\";"), sql); } + @Test + void inheritedBuildersAlwaysQuoteIdentifiers() { + assertEquals("SELECT * FROM \"App\".\"MixedTable\"", + builder.dql().buildSelectTable(null, "App", "MixedTable")); + assertEquals("SELECT COUNT(1) FROM \"App\".\"MixedTable\"", + builder.dql().buildSelectCount(null, "App", "MixedTable")); + + SingleInsertSqlRequest insert = SingleInsertSqlRequest.builder() + .schemaName("App") + .tableName("MixedTable") + .columnList(List.of("MixedColumn")) + .valueList(List.of("1")) + .build(); + assertEquals("INSERT INTO \"App\".\"MixedTable\" (\"MixedColumn\") VALUES (1)", + builder.dml().buildInsert(insert)); + + MultiInsertSqlRequest batchInsert = MultiInsertSqlRequest.builder() + .schemaName("App") + .tableName("MixedTable") + .columnList(List.of("MixedColumn")) + .valueLists(List.of(List.of("1"), List.of("2"))) + .build(); + String batchSql = builder.dml().buildBatchInsert(batchInsert); + assertTrue(batchSql.startsWith("INSERT INTO \"App\".\"MixedTable\" (\"MixedColumn\") VALUES "), batchSql); + + assertEquals("DROP TABLE \"App\".\"MixedTable\"", + builder.ddl().table().buildDropTable(new DropTableRequest(null, "App", "MixedTable"))); + assertEquals("TRUNCATE TABLE \"App\".\"MixedTable\"", + builder.ddl().table().buildTruncateTable(new TruncateTableRequest(null, "App", "MixedTable"))); + + Database database = new Database(); + database.setName("MixedDatabase"); + assertEquals("CREATE DATABASE \"MixedDatabase\"", builder.ddl().database().buildCreateDatabase(database)); + } + + @Test + void updateQuotesSetAndPrimaryKeyColumns() { + UpdateSqlRequest request = UpdateSqlRequest.builder() + .schemaName("App") + .tableName("MixedTable") + .row(Map.of("set\"; DROP TABLE t; --", "1")) + .primaryKeyMap(Map.of("pk\"; DROP TABLE t; --", "2")) + .build(); + + String sql = builder.dml().buildUpdate(request); + + assertEquals("UPDATE \"App\".\"MixedTable\" SET \"set\"\"; DROP TABLE t; --\" = 1" + + " WHERE \"pk\"\"; DROP TABLE t; --\" = 2", sql); + } + + @Test + void metadataAndManagerRespectRawVersusPrequotedNames() throws Exception { + XUGUDBMetaData metaData = new XUGUDBMetaData(); + assertEquals("\"App\".\"MixedTable\"", metaData.getMetaDataName("ignored", "App", "MixedTable")); + + XUGUDBManager manager = new XUGUDBManager(); + assertEquals("DROP TABLE IF EXISTS \"ta\"\"ble\"", + manager.dropTable(null, null, null, "ta\"ble")); + assertEquals("TRUNCATE TABLE \"App\".\"MixedTable\"", + manager.truncateTable(null, null, null, "\"App\".\"MixedTable\"")); + } + @Test void columnCommentLiteralIsEscapedEndToEnd() { TableColumn col = column("id", "INTEGER"); @@ -239,6 +360,79 @@ void fallbackColumnEscapesNameAndRejectsMaliciousType() { () -> XUGUDBColumnTypeEnum.INTEGER.buildCreateColumnSql(maliciousType)); } + @Test + void createTableUsesSafeFallbackForUnknownTypes() { + Table valid = Table.builder() + .schemaName("App") + .name("CustomTable") + .columnList(List.of(column("custom_col", "types.CustomType(10,2)"))) + .indexList(List.of()) + .build(); + String sql = builder.buildCreateTable(valid, TableBuilderConfig.defaultConfig()); + assertTrue(sql.contains("\"custom_col\" types.CustomType(10,2)"), sql); + + Table invalid = Table.builder() + .schemaName("App") + .name("CustomTable") + .columnList(List.of(column("custom_col", "CustomType); DROP TABLE t; --"))) + .indexList(List.of()) + .build(); + assertThrows(IllegalArgumentException.class, + () -> builder.buildCreateTable(invalid, TableBuilderConfig.defaultConfig())); + } + + @Test + void caseOnlyTableAndColumnRenamesAreGenerated() { + Table oldTable = Table.builder() + .schemaName("App") + .name("MixedTable") + .columnList(List.of()) + .indexList(List.of()) + .build(); + Table newTable = Table.builder() + .schemaName("App") + .name("mixedTable") + .columnList(List.of()) + .indexList(List.of()) + .build(); + assertTrue(builder.buildAlterTable(oldTable, newTable) + .contains("RENAME TO \"mixedTable\"")); + + TableColumn oldColumn = column("MixedColumn", "INTEGER"); + TableColumn renamed = column("mixedColumn", "INTEGER"); + renamed.setOldName("MixedColumn"); + renamed.setOldColumn(oldColumn); + renamed.setEditStatus("MODIFY"); + assertTrue(XUGUDBColumnTypeEnum.INTEGER.buildModifyColumn(renamed) + .contains("RENAME COLUMN \"MixedColumn\" TO \"mixedColumn\"")); + } + + @Test + void primaryDropIsCaseInsensitiveAndSortDirectionIsCanonical() { + TableIndex primary = TableIndex.builder() + .schemaName("App") + .tableName("MixedTable") + .type("primary") + .editStatus("DELETE") + .build(); + assertTrue(XUGUDBIndexTypeEnum.getByType(primary.getType()).buildModifyIndex(primary) + .contains("DROP PRIMARY KEY")); + + TableIndex normal = TableIndex.builder() + .schemaName("App") + .tableName("MixedTable") + .name("idx") + .type("Normal") + .columnList(List.of(TableIndexColumn.builder() + .columnName("id") + .ascOrDesc(" desc ") + .build())) + .build(); + String sql = XUGUDBIndexTypeEnum.NORMAL.buildIndexScript(normal); + assertTrue(sql.contains("\"id\" DESC"), sql); + assertFalse(sql.contains(" desc "), sql); + } + @Test void validatorsReturnTrimmedValues() { TableColumn numeric = column("id", "INTEGER"); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilderTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilderTest.java index 95479bee22..4b7994f2ca 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilderTest.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/builder/XUGUDBSqlBuilderTest.java @@ -22,7 +22,7 @@ class XUGUDBSqlBuilderTest { private final XUGUDBSqlBuilder builder = new XUGUDBSqlBuilder(); @Test - void shouldSkipUnknownColumnAndIndexTypesWhenCreatingTable() { + void shouldKeepSafeUnknownColumnTypesAndSkipUnknownIndexTypesWhenCreatingTable() { Table table = Table.builder() .schemaName(SCHEMA) .name(TABLE) @@ -39,12 +39,12 @@ void shouldSkipUnknownColumnAndIndexTypesWhenCreatingTable() { assertTrue(sql.contains("\"known_column\" INTEGER"), sql); assertTrue(sql.contains("\"known_index\""), sql); - assertFalse(sql.contains("unknown_column"), sql); + assertTrue(sql.contains("\"unknown_column\" UNSUPPORTED_COLUMN_TYPE"), sql); assertFalse(sql.contains("unknown_index"), sql); } @Test - void shouldSkipUnknownColumnAndIndexTypesWhenAlteringTable() { + void shouldKeepSafeUnknownColumnTypesAndSkipUnknownIndexTypesWhenAlteringTable() { Table oldTable = table(List.of(), List.of()); Table newTable = table( List.of( @@ -58,7 +58,7 @@ void shouldSkipUnknownColumnAndIndexTypesWhenAlteringTable() { assertTrue(sql.contains("ADD (\"known_column\" INTEGER"), sql); assertTrue(sql.contains("\"known_index\""), sql); - assertFalse(sql.contains("unknown_column"), sql); + assertTrue(sql.contains("ADD (\"unknown_column\" UNSUPPORTED_COLUMN_TYPE)"), sql); assertFalse(sql.contains("unknown_index"), sql); }