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..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 @@ -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 3bda420a03..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 @@ -4,8 +4,12 @@ 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.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.*; @@ -30,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; @@ -37,6 +42,16 @@ public class XUGUDBMetaData extends DefaultMetaService implements IDbMetaData { + @Override + 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); @@ -45,7 +60,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 +78,7 @@ public List schemas(Connection connection, String databaseName) { } private String format(String tableName) { - return "\"" + tableName + "\""; + return XugudbIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName); } @Override @@ -74,7 +89,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 +105,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 +124,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 +146,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 +167,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 +188,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 +205,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 +222,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 +242,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 +259,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 +309,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()) { @@ -304,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")); @@ -342,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(name -> "\"" + name + "\"").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(name -> "\"" + name + "\"").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 new file mode 100644 index 0000000000..2d59d9efd9 --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/main/java/ai/chat2db/plugin/xugudb/XugudbSqlGuards.java @@ -0,0 +1,199 @@ +package ai.chat2db.plugin.xugudb; + +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Structural validation for XuguDB SQL fragments emitted as syntax rather + * than identifiers or string literals. + */ +public final class XugudbSqlGuards { + + 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 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 = StringUtils.trimToNull(defaultValue); + if (trimmed == null) { + throw invalid("DEFAULT expression", defaultValue); + } + scanExpression(trimmed, false, "DEFAULT expression"); + return trimmed; + } + + /** + * 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); + } + scanExpression(trimmed, true, "column type"); + return trimmed; + } + + public static String requireUnit(String unit) { + String trimmed = StringUtils.trimToEmpty(unit); + if ("BYTE".equalsIgnoreCase(trimmed)) { + return "BYTE"; + } + if ("CHAR".equalsIgnoreCase(trimmed)) { + return "CHAR"; + } + throw new IllegalArgumentException("Unsupported XuguDB length unit: " + unit); + } + + private static void scanExpression(String expression, boolean typeExpression, String description) { + Deque parentheses = new ArrayDeque<>(); + List topLevelWords = new ArrayList<>(); + boolean sawToken = false; + + 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); + } + 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; + } + } + + if (!sawToken || !parentheses.isEmpty()) { + throw invalid(description, expression); + } + for (String word : topLevelWords) { + if (COLUMN_CLAUSE_KEYWORDS.contains(word)) { + throw invalid(description, expression); + } + } + 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); + } + } + } + + 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; + } + return i; + } + } + throw invalid(description, expression); + } + + 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; + } + } + 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 d5c54d82c3..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 @@ -2,21 +2,32 @@ 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; 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; +import java.util.List; import java.util.Objects; +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 { @@ -34,17 +45,20 @@ 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); + 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)); @@ -78,13 +92,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(); } @@ -92,9 +106,9 @@ private String buildComment(TableColumn column) { 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); + 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); } if (!StringUtils.equalsIgnoreCase(oldTable.getComment(), newTable.getComment())) { script.append(SQLConstants.EMPTY).append(buildTableComment(newTable)).append(SQLConstants.SEMICOLON_LINE_SEPARATOR); @@ -103,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); @@ -153,11 +165,67 @@ 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 buildCreateDatabase(Database database) { + return SQLConstants.CREATE_DATABASE_SQL_PREFIX + quoteIdentifier(database.getName()); + } + + @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::quoteIdentifierAlways) + .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::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 db3ce24557..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 @@ -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; @@ -93,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(); @@ -115,13 +120,13 @@ 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 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(" "); @@ -135,13 +140,10 @@ 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(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) @@ -149,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"); @@ -163,6 +167,15 @@ public String buildUpdateColumnSql(TableColumn column) { return isModify ? script.toString() : ""; } + private static String buildFallbackColumn(TableColumn column) { + 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 ""; @@ -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)); + 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 { 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..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 @@ -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,18 @@ 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) { + String trimmed = ascOrDesc.trim(); + if ("ASC".equalsIgnoreCase(trimmed)) { + return "ASC"; + } + if ("DESC".equalsIgnoreCase(trimmed)) { + return "DESC"; + } + throw new IllegalArgumentException("Unsupported index sort order: " + ascOrDesc); } public String buildModifyIndex(TableIndex tableIndex) { @@ -120,8 +132,8 @@ public String buildModifyIndex(TableIndex tableIndex) { } private String buildDropIndex(TableIndex tableIndex) { - if (XUGUDBIndexTypeEnum.PRIMARY_KEY.getName().equals(tableIndex.getType())) { - String tableName = "\"" + tableIndex.getSchemaName() + "\"." + "\"" + tableIndex.getTableName() + "\""; + 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); } 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..73e9996cdb --- /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,115 @@ +package ai.chat2db.plugin.xugudb.identifier; + +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 + * instance available via {@link #INSTANCE} for call sites without MetaData access. + */ +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 + * else is double-quoted with one surrounding pair stripped and embedded quotes doubled. + */ + @Override + public String quoteIdentifier(String identifier) { + if (identifier == null || StringUtils.isBlank(identifier)) { + return identifier; + } + if (isValidQuotedIdentifier(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 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 (identifier == null) { + return null; + } + 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 ? null : StringUtils.replace(str, "'", "''"); + } + + public String quoteStringLiteral(String str) { + return str == null ? null : "'" + escapeString(str) + "'"; + } + + private static String escapeIdentifierContent(String identifier) { + return identifier == null ? null : StringUtils.replace(identifier, "\"", "\"\""); + } + + /** + * 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); + } + + 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 new file mode 100644 index 0000000000..8141a320fc --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-xugudb/src/test/java/ai/chat2db/plugin/xugudb/XugudbIdentifierProcessorTest.java @@ -0,0 +1,481 @@ +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; +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")); + assertNull(XugudbIdentifierProcessor.INSTANCE.escapeString(null)); + } + + @Test + 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("plain", XugudbIdentifierProcessor.escapeIdentifier("plain")); + 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 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("\"\"\"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 + 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 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"); + 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"); + + 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 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"); + 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 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"); + 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(); + } +} 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); }