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 27edaae5d..09ddbcaa5 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 "\"" + tableName + "\""; + return XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways(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, 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, tableName, tableName, 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(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';", schemaName, 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(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, viewName, 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", schemaName, 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, 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, schemaName, 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(); @@ -179,7 +180,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 +188,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 3bda420a0..2e9561d97 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 = '" + 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 "\"" + tableName + "\""; + return XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways(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, schemaName, 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, databaseName, 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, databaseName, schemaName, 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, databaseName, schemaName, 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, databaseName, 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, databaseName, 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, databaseName, schemaName, 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, databaseName, 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, databaseName, schemaName, 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, schemaName, 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, databaseName, schemaName, 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 -> "\"" + 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 -> "\"" + 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/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 000000000..66fdb070c --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlGuards.java @@ -0,0 +1,148 @@ +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}. + * 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 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. + * 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 (!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 + * (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 d5c54d82c..05ecfb4a3 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.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; @@ -14,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 { @@ -34,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(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(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())) { @@ -78,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(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(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(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(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(); } @@ -93,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(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(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); @@ -153,11 +157,45 @@ 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+XugudbIdentifierProcessor.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(XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways(schema.getOwner())); } return sqlBuilder.toString(); } + + @Override + public String quoteIdentifier(String identifier) { + return XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways(identifier); + } + + @Override + public String quoteQualifiedIdentifier(String... identifiers) { + return Arrays.stream(identifiers) + .filter(StringUtils::isNotBlank) + .map(XugudbIdentifierProcessor.INSTANCE::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(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 db3ce2455..505fffa15 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,8 @@ package ai.chat2db.plugin.xugudb.enums.type; import ai.chat2db.spi.IColumnBuilder; +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; @@ -8,6 +10,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 { @@ -117,11 +120,11 @@ 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(); - script.append("\"").append(column.getName()).append("\"").append(" "); + script.append("\"").append(XugudbIdentifierProcessor.escapeIdentifier(column.getName())).append("\"").append(" "); script.append(buildDataType(column, type)).append(" "); @@ -140,8 +143,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(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) @@ -163,6 +166,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 XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName()) + " " + columnType; + } + private String buildAutoIncrement(TableColumn column, XUGUDBColumnTypeEnum type) { if (!type.getColumnType().isSupportAutoIncrement()) { return ""; @@ -209,7 +222,7 @@ private String buildDefaultValue(TableColumn column, XUGUDBColumnTypeEnum type) return StringUtils.join("DEFAULT NULL"); } - return StringUtils.join("DEFAULT ", column.getDefaultValue()); + return StringUtils.join("DEFAULT ", XugudbSqlGuards.requireDefaultValue(column.getDefaultValue())); } private String buildDataType(TableColumn column, XUGUDBColumnTypeEnum type) { @@ -220,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(column.getUnit()).append(")"); + script.append("(").append(column.getColumnSize()).append(" ").append(XugudbSqlGuards.requireUnit(column.getUnit())).append(")"); } return script.toString(); } @@ -274,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(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(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(tableColumn.getSchemaName()).append("\".\"").append(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(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(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 34c59d6e5..3baf1181d 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.identifier.XugudbIdentifierProcessor; 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(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(tableIndex.getSchemaName()).append("\".\"").append(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(); } @@ -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(XugudbIdentifierProcessor.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 "\"" + XugudbIdentifierProcessor.escapeIdentifier(tableIndex.getSchemaName()) + "\"." + "\"" + XugudbIdentifierProcessor.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 = "\"" + 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 000000000..f1be47dd2 --- /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,80 @@ +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(); + + /** + * 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) { + if (StringUtils.isBlank(identifier)) { + return identifier; + } + if (isValidIdentifier(identifier) && !isReservedKeyword(identifier, null, null)) { + return identifier; + } + return quoteIdentifierAlways(identifier); + } + + @Override + public String quoteIdentifier(String identifier, Integer majorVersion, Integer minorVersion) { + return quoteIdentifier(identifier); + } + + @Override + public String quoteIdentifierIgnoreCase(String 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) + "\""; + } + + /** + * 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/XugudbIdentifierProcessorTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbIdentifierProcessorTest.java new file mode 100644 index 000000000..b59be89bc --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbIdentifierProcessorTest.java @@ -0,0 +1,287 @@ +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 ai.chat2db.plugin.xugudb.identifier.XugudbIdentifierProcessor; +import ai.chat2db.spi.model.request.SingleInsertSqlRequest; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class XugudbIdentifierProcessorTest { + + private final XUGUDBSqlBuilder builder = new XUGUDBSqlBuilder(); + + @Test + 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)); + } + + @Test + void escapeIdentifierDoublesEmbeddedQuotesAndStripsSurroundingQuotes() { + 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 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 + 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 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"); + 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)); + } + + @Test + 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); + } + + @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); + } + + @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") + .tableName("sample_table") + .name(name) + .columnType(type) + .nullable(1) + .build(); + } +}