diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/MysqlDBManager.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/MysqlDBManager.java index b18dea8d04..a2522874e9 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/MysqlDBManager.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/MysqlDBManager.java @@ -2,6 +2,7 @@ import ai.chat2db.community.tools.exception.BusinessException; import ai.chat2db.plugin.mysql.builder.MysqlSqlBuilder; +import ai.chat2db.plugin.mysql.identifier.MysqlIdentifierProcessor; import ai.chat2db.spi.IDbManager; import ai.chat2db.spi.DefaultDBManager; import ai.chat2db.community.domain.api.model.async.AsyncContext; @@ -19,10 +20,13 @@ import java.util.Date; import static cn.hutool.core.date.DatePattern.NORM_DATETIME_PATTERN; +import static ai.chat2db.plugin.mysql.constant.MysqlSqlConstants.SQL_COPY_TABLE_DATA_TEMPLATE; +import static ai.chat2db.plugin.mysql.constant.MysqlSqlConstants.SQL_COPY_TABLE_STRUCTURE_TEMPLATE; import static ai.chat2db.plugin.mysql.constant.MysqlSqlConstants.SQL_DROP_PROCEDURE_TEMPLATE; import static ai.chat2db.plugin.mysql.constant.MysqlSqlConstants.SQL_DROP_TABLE_TEMPLATE; import static ai.chat2db.plugin.mysql.constant.MysqlSqlConstants.SQL_DROP_VIEW_TEMPLATE; import static ai.chat2db.plugin.mysql.constant.MysqlSqlConstants.SQL_SELECT_DATABASE_TEMPLATE; +import static ai.chat2db.plugin.mysql.constant.MysqlSqlConstants.SQL_TRUNCATE_TABLE_TEMPLATE; import static ai.chat2db.plugin.mysql.constant.MysqlSqlConstants.SQL_SET_FOREIGN_KEY_CHECKS_DISABLED; import static ai.chat2db.plugin.mysql.constant.MysqlSqlConstants.SQL_SET_FOREIGN_KEY_CHECKS_ENABLED; import static ai.chat2db.plugin.mysql.constant.MysqlSqlConstants.SQL_SHOW_CREATE_FUNCTION_TEMPLATE; @@ -66,12 +70,12 @@ private void exportFunctions(Connection connection, String databaseName, AsyncCo } private void exportFunction(Connection connection, String functionName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(SQL_SHOW_CREATE_FUNCTION_TEMPLATE, functionName); + String sql = String.format(SQL_SHOW_CREATE_FUNCTION_TEMPLATE, format(functionName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { if (resultSet.next()) { - asyncContext.write(String.format(FUNCTION_TITLE, functionName)); + asyncContext.write(String.format(FUNCTION_TITLE, formatExportTitleValue(functionName))); StringBuilder sqlBuilder = new StringBuilder(); - sqlBuilder.append(SQLConstants.DROP_FUNCTION_IF_EXISTS_SQL_PREFIX).append(functionName).append(SQLConstants.SEMICOLON).append(SQLConstants.LINE_SEPARATOR); + sqlBuilder.append(SQLConstants.DROP_FUNCTION_IF_EXISTS_SQL_PREFIX).append(format(functionName)).append(SQLConstants.SEMICOLON).append(SQLConstants.LINE_SEPARATOR); sqlBuilder.append(DELIMITER_BLOCK_START).append(SQLConstants.LINE_SEPARATOR).append(resultSet.getString(CREATE_FUNCTION_COLUMN)) .append(ROUTINE_DELIMITER) @@ -95,12 +99,12 @@ private void exportTables(Connection connection, String databaseName, String sch public void exportTable(Connection connection, String databaseName, String schemaName, String tableName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(SQL_SHOW_CREATE_TABLE_TEMPLATE, tableName); + String sql = String.format(SQL_SHOW_CREATE_TABLE_TEMPLATE, format(tableName)); asyncContext.info(DateUtil.formatDateTime(new Date()) + EXPORTING_TABLE_MESSAGE + tableName); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { if (resultSet.next()) { StringBuilder sqlBuilder = new StringBuilder(); - asyncContext.write(String.format(TABLE_TITLE, tableName)); + asyncContext.write(String.format(TABLE_TITLE, formatExportTitleValue(tableName))); sqlBuilder.append(SQLConstants.DROP_TABLE_IF_EXISTS_SQL_PREFIX).append(format(tableName)).append(SQLConstants.SEMICOLON).append(SQLConstants.LINE_SEPARATOR) .append(resultSet.getString(CREATE_TABLE_COLUMN)).append(SQLConstants.SEMICOLON).append(SQLConstants.LINE_SEPARATOR); asyncContext.write(sqlBuilder.toString()); @@ -130,10 +134,10 @@ private void exportViews(Connection connection, String databaseName, AsyncContex } private void exportView(Connection connection, String viewName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(SQL_SHOW_CREATE_VIEW_TEMPLATE, viewName); + String sql = String.format(SQL_SHOW_CREATE_VIEW_TEMPLATE, format(viewName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { if (resultSet.next()) { - asyncContext.write(String.format(VIEW_TITLE, viewName)); + asyncContext.write(String.format(VIEW_TITLE, formatExportTitleValue(viewName))); StringBuilder sqlBuilder = new StringBuilder(); sqlBuilder.append(SQLConstants.DROP_VIEW_IF_EXISTS_SQL_PREFIX).append(format(viewName)).append(SQLConstants.SEMICOLON).append(SQLConstants.LINE_SEPARATOR) .append(resultSet.getString(CREATE_VIEW_COLUMN)).append(SQLConstants.SEMICOLON).append(SQLConstants.DOUBLE_LINE_SEPARATOR); @@ -152,10 +156,10 @@ private void exportProcedures(Connection connection, AsyncContext asyncContext) } private void exportProcedure(Connection connection, String procedureName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(SQL_SHOW_CREATE_PROCEDURE_TEMPLATE, procedureName); + String sql = String.format(SQL_SHOW_CREATE_PROCEDURE_TEMPLATE, format(procedureName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { if (resultSet.next()) { - asyncContext.write(String.format(PROCEDURE_TITLE, procedureName)); + asyncContext.write(String.format(PROCEDURE_TITLE, formatExportTitleValue(procedureName))); StringBuilder sqlBuilder = new StringBuilder(); sqlBuilder.append(SQLConstants.DROP_PROCEDURE_IF_EXISTS_SQL_PREFIX).append(format(procedureName)).append(SQLConstants.SEMICOLON).append(SQLConstants.LINE_SEPARATOR) .append(DELIMITER_BLOCK_START).append(SQLConstants.LINE_SEPARATOR).append(resultSet.getString(CREATE_PROCEDURE_COLUMN)) @@ -177,10 +181,10 @@ private void exportTriggers(Connection connection, AsyncContext asyncContext) th } private void exportTrigger(Connection connection, String triggerName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(SQL_SHOW_CREATE_TRIGGER_TEMPLATE, triggerName); + String sql = String.format(SQL_SHOW_CREATE_TRIGGER_TEMPLATE, format(triggerName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { if (resultSet.next()) { - asyncContext.write(String.format(TRIGGER_TITLE, triggerName)); + asyncContext.write(String.format(TRIGGER_TITLE, formatExportTitleValue(triggerName))); StringBuilder sqlBuilder = new StringBuilder(); sqlBuilder.append(SQLConstants.DROP_TRIGGER_IF_EXISTS_SQL_PREFIX).append(format(triggerName)).append(SQLConstants.SEMICOLON).append(SQLConstants.LINE_SEPARATOR) .append(DELIMITER_BLOCK_START).append(SQLConstants.LINE_SEPARATOR).append(resultSet.getString(ORIGINAL_STATEMENT_COLUMN)) @@ -240,13 +244,38 @@ public String dropTable(Connection connection, String databaseName, String schem return String.format(SQL_DROP_TABLE_TEMPLATE, format(tableName)); } + @Override + public String truncateTable(Connection connection, String databaseName, String schemaName, String tableName) { + return String.format(SQL_TRUNCATE_TABLE_TEMPLATE, format(tableName)); + } + + @Override + public void copyTable(Connection connection, String databaseName, String schemaName, String tableName, String newTableName, boolean copyData) { + DefaultSQLExecutor.getInstance().execute(connection, buildCopyTableSql(tableName, newTableName, copyData), resultSet -> null); + } + + static String buildCopyTableSql(String tableName, String newTableName, boolean copyData) { + String template = copyData ? SQL_COPY_TABLE_DATA_TEMPLATE : SQL_COPY_TABLE_STRUCTURE_TEMPLATE; + return String.format(template, format(newTableName), format(tableName)); + } + + static String formatExportTitleValue(String value) { + return value == null ? null : value.replace('\r', ' ').replace('\n', ' '); + } + public static String format(String tableName) { - return SQLConstants.BACK_QUOTE + tableName + SQLConstants.BACK_QUOTE; + return MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName); } @Override public void dropView(Connection connection, String databaseName, String schemaName, String viewName) { - String sql = String.format(SQL_DROP_VIEW_TEMPLATE, format(databaseName), format(viewName)); - DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> null); + DefaultSQLExecutor.getInstance().execute(connection, buildDropViewSql(databaseName, viewName), resultSet -> null); + } + + static String buildDropViewSql(String databaseName, String viewName) { + String qualifiedName = StringUtils.isEmpty(databaseName) + ? format(viewName) + : format(databaseName) + SQLConstants.DOT + format(viewName); + return String.format(SQL_DROP_VIEW_TEMPLATE, qualifiedName); } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/MysqlMetaData.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/MysqlMetaData.java index bf1cbceab6..144b9d60b9 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/MysqlMetaData.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/MysqlMetaData.java @@ -52,7 +52,7 @@ @Slf4j public class MysqlMetaData extends DefaultMetaService implements IDbMetaData { - public static final ISQLIdentifierProcessor MYSQL_IDENTIFIER_PROCESSOR = new MysqlIdentifierProcessor(); + public static final ISQLIdentifierProcessor MYSQL_IDENTIFIER_PROCESSOR = MysqlIdentifierProcessor.INSTANCE; @Override public List databases(Connection connection) { @@ -63,9 +63,9 @@ public List databases(Connection connection) { @Override public List tables(Connection connection, @NotEmpty String databaseName, String schemaName, String tableName) { - String sql = String.format(TABLES_SQL, databaseName); + String sql = String.format(TABLES_SQL, getSQLIdentifierProcessor().escapeString(databaseName)); if (StringUtils.isNotBlank(tableName)) { - sql += SQL_TABLE_NAME_EQUALS_FILTER + tableName + SQL_SINGLE_QUOTE; + sql += SQL_TABLE_NAME_EQUALS_FILTER + getSQLIdentifierProcessor().escapeString(tableName) + SQL_SINGLE_QUOTE; } Map collationMap = getCollationMap(connection); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { @@ -131,14 +131,14 @@ public String tableDDL(Connection connection, @NotEmpty String databaseName, Str } public static String format(String tableName) { - return SQL_METADATA_QUOTE + tableName + SQL_METADATA_QUOTE; + return MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName); } @Override public Function function(Connection connection, @NotEmpty String databaseName, String schemaName, String functionName) { - String functionInfoSql = String.format(ROUTINES_SQL, FUNCTION, databaseName, functionName); + String functionInfoSql = String.format(ROUTINES_SQL, FUNCTION, getSQLIdentifierProcessor().escapeString(databaseName), getSQLIdentifierProcessor().escapeString(functionName)); Function function = DefaultSQLExecutor.getInstance().execute(connection, functionInfoSql, resultSet -> { Function f = new Function(); f.setDatabaseName(databaseName); @@ -162,7 +162,7 @@ public Function function(Connection connection, @NotEmpty String databaseName, S @Override public List triggers(Connection connection, String databaseName, String schemaName) { List triggers = new ArrayList<>(); - String sql = String.format(TRIGGER_SQL_LIST, databaseName); + String sql = String.format(TRIGGER_SQL_LIST, getSQLIdentifierProcessor().escapeString(databaseName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { while (resultSet.next()) { Trigger trigger = new Trigger(); @@ -208,7 +208,7 @@ public List procedures(Connection connection, String databaseName, St @Override public Procedure procedure(Connection connection, @NotEmpty String databaseName, String schemaName, String procedureName) { - String routinesSql = String.format(ROUTINES_SQL, PROCEDURE, databaseName, procedureName); + String routinesSql = String.format(ROUTINES_SQL, PROCEDURE, getSQLIdentifierProcessor().escapeString(databaseName), getSQLIdentifierProcessor().escapeString(procedureName)); String showCreateProcedureSql = SQL_SHOW_CREATE_PROCEDURE + mysqlQualifiedName(databaseName, procedureName); Procedure procedure = DefaultSQLExecutor.getInstance().execute(connection, routinesSql, resultSet -> { Procedure p = new Procedure(); @@ -230,7 +230,7 @@ public Procedure procedure(Connection connection, @NotEmpty String databaseName, } @Override public List columns(Connection connection, String databaseName, String schemaName, String tableName) { - String sql = String.format(SELECT_TABLE_COLUMNS, databaseName, tableName); + String sql = String.format(SELECT_TABLE_COLUMNS, getSQLIdentifierProcessor().escapeString(databaseName), getSQLIdentifierProcessor().escapeString(tableName)); List tableColumns = new ArrayList<>(); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { while (resultSet.next()) { @@ -285,7 +285,7 @@ private void setColumnSize(TableColumn column, String columnType) { @Override public Table view(Connection connection, String databaseName, String schemaName, String viewName) { - String quoteViewName = MYSQL_IDENTIFIER_PROCESSOR.quoteIdentifier(viewName); + String quoteViewName = MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways(viewName); String sql = String.format(VIEW_DDL_SQL, quoteViewName); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Table table = new Table(); @@ -303,9 +303,9 @@ public Table view(Connection connection, String databaseName, String schemaName, @Override public List indexes(Connection connection, String databaseName, String schemaName, String tableName) { StringBuilder queryBuf = new StringBuilder(SQL_SHOW_INDEX_FROM); - queryBuf.append(SQL_METADATA_QUOTE).append(tableName).append(SQL_METADATA_QUOTE); + queryBuf.append(MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName)); queryBuf.append(SQL_FROM); - queryBuf.append(SQL_METADATA_QUOTE).append(databaseName).append(SQL_METADATA_QUOTE); + queryBuf.append(MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways(databaseName)); return DefaultSQLExecutor.getInstance().execute(connection, queryBuf.toString(), resultSet -> { LinkedHashMap map = new LinkedHashMap(); while (resultSet.next()) { @@ -461,7 +461,7 @@ private List queryTableMetaOptions(String sql, IResultSetFunction @Override public String getMetaDataName(String... names) { return Arrays.stream(names).filter(StringUtils::isNotBlank) - .map(name -> SQL_METADATA_QUOTE + name + SQL_METADATA_QUOTE) + .map(MysqlIdentifierProcessor.INSTANCE::quoteIdentifierAlways) .collect(Collectors.joining(SQL_DOT)); } @@ -496,7 +496,7 @@ public ModifyViewConfiguration viewMeta(String databaseName, String schemaName) StringBuilder sqlBuilder = new StringBuilder(100); sqlBuilder.append(SQL_CREATE).append(SQL_VIEW_KEYWORD); if (StringUtils.isNotBlank(databaseName)) { - sqlBuilder.append(SQL_METADATA_QUOTE).append(databaseName).append(SQL_METADATA_QUOTE).append(SQL_DOT); + sqlBuilder.append(MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways(databaseName)).append(SQL_DOT); } sqlBuilder.append(SQL_METADATA_QUOTE).append(SQL_UNDEFINED).append(SQL_METADATA_QUOTE); sqlBuilder.append(SQL_AS).append(sql).append(SQL_SEMICOLON); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/MysqlRoutineManager.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/MysqlRoutineManager.java index 091c0d9d34..97be1b5d2c 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/MysqlRoutineManager.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/MysqlRoutineManager.java @@ -1,6 +1,7 @@ package ai.chat2db.plugin.mysql; import ai.chat2db.plugin.mysql.converter.MysqlRoutineConverter; +import ai.chat2db.plugin.mysql.identifier.MysqlIdentifierProcessor; import ai.chat2db.plugin.mysql.model.RoutineParameter; import ai.chat2db.community.tools.exception.BusinessException; import ai.chat2db.community.tools.util.I18nUtils; @@ -362,11 +363,7 @@ private String mysqlQualifiedName(String databaseName, String routineName) { } private String quoteMysqlIdentifier(String name) { - String trimmed = StringUtils.trimToEmpty(name); - if (trimmed.matches("^`(?:``|[^`])+`$")) { - return trimmed; - } - return "`" + trimmed.replace("`", "``") + "`"; + return MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways(name); } private String routineInvocationName(String name) { diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/MysqlSqlGuards.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/MysqlSqlGuards.java new file mode 100644 index 0000000000..248b63a4e9 --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/MysqlSqlGuards.java @@ -0,0 +1,258 @@ +package ai.chat2db.plugin.mysql; + +import ai.chat2db.plugin.mysql.identifier.MysqlIdentifierProcessor; +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +/** + * Validation helpers for non-escapable SQL positions in MySQL DDL/DML generation + * (engine/charset/collation names, raw numeric defaults, bit/hex literals, definers, + * index sort directions and fixed option sets). Escaping itself lives in + * {@link MysqlIdentifierProcessor}. + */ +public final class MysqlSqlGuards { + + private static final Pattern MYSQL_NAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]+$"); + private static final Pattern NUMERIC_DEFAULT_PATTERN = Pattern.compile( + "^([+-]?(\\d+(\\.\\d+)?|\\.\\d+)([eE][+-]?\\d+)?|0[xX][0-9a-fA-F]+|[xX]'[0-9a-fA-F]*'|[bB]'[01]*'|(?i:TRUE|FALSE))$"); + private static final Pattern BIT_LITERAL_PATTERN = Pattern.compile("^[01]+$"); + private static final Pattern HEX_DIGITS_PATTERN = Pattern.compile("^[0-9a-fA-F]+$"); + private static final Pattern HEX_LITERAL_PATTERN = Pattern.compile("^0[xX][0-9a-fA-F]+$"); + private static final String DEFINER_SINGLE_QUOTED_PART = "'(?:''|[^'\\\\])+'"; + private static final String DEFINER_BACKTICK_QUOTED_PART = "`(?:``|[^`\\\\])+`"; + private static final String DEFINER_QUOTED_PART = "(?:" + DEFINER_SINGLE_QUOTED_PART + "|" + + DEFINER_BACKTICK_QUOTED_PART + ")"; + private static final Pattern DEFINER_PATTERN = Pattern.compile( + "^([A-Za-z0-9_$]+|" + DEFINER_QUOTED_PART + ")@([A-Za-z0-9_.%:$-]+|" + DEFINER_QUOTED_PART + ")$"); + private static final Pattern COLUMN_TYPE_PATTERN = Pattern.compile( + "^[A-Za-z][A-Za-z0-9_]*(?:\\s*\\(\\s*\\d+(?:\\s*,\\s*\\d+)?\\s*\\))?(?:\\s+[A-Za-z][A-Za-z0-9_]*)*$"); + + private MysqlSqlGuards() { + } + + /** + * Validate a strict MySQL name token (ENGINE / CHARACTER SET / COLLATE style positions where + * escaping is impossible by design). + */ + public static String requireMysqlName(String value, String what) { + if (value == null || !MYSQL_NAME_PATTERN.matcher(value).matches()) { + throw new IllegalArgumentException("Invalid MySQL " + what + ": " + value); + } + return value; + } + + /** + * Validate a raw DEFAULT literal for numeric-ish columns (positions where quoting would change + * semantics). Accepts decimal/scientific numbers, hex and bit literals, TRUE/FALSE. + */ + public static String requireNumericDefault(String value) { + if (value == null || !NUMERIC_DEFAULT_PATTERN.matcher(value.trim()).matches()) { + throw new IllegalArgumentException("Invalid MySQL default value: " + value); + } + return value; + } + + /** + * Validate content of a b'...' bit literal. + */ + public static String requireBitLiteral(String value) { + if (value == null || !BIT_LITERAL_PATTERN.matcher(value).matches()) { + throw new IllegalArgumentException("Invalid MySQL bit literal: " + value); + } + return value; + } + + /** + * Validate the digits of a 0x... hex literal (the template adds the 0x prefix). + */ + public static String requireHexDigits(String value) { + if (value == null || !HEX_DIGITS_PATTERN.matcher(value).matches()) { + throw new IllegalArgumentException("Invalid MySQL hex digits: " + value); + } + return value; + } + + /** + * True only when the value is a well-formed 0x... hex literal. Values that merely + * start with 0x but contain non-hex characters must not pass through into SQL raw. + */ + public static boolean isHexLiteral(String value) { + return value != null && HEX_LITERAL_PATTERN.matcher(value).matches(); + } + + /** + * Validate a DEFINER value (user@host, parts optionally single-quoted or backtick-quoted). + */ + public static String requireDefiner(String value) { + if (value == null || !DEFINER_PATTERN.matcher(value).matches()) { + throw new IllegalArgumentException("Invalid MySQL definer: " + value); + } + return value; + } + + /** + * Validate a fallback column type expression while preserving common custom type names, + * optional numeric precision/scale and keyword modifiers. + */ + public static String requireColumnType(String value) { + String trimmed = StringUtils.trimToEmpty(value); + if (!COLUMN_TYPE_PATTERN.matcher(trimmed).matches()) { + throw new IllegalArgumentException("Invalid MySQL column type: " + value); + } + return trimmed; + } + + /** + * Validate an index sort direction: only ASC/DESC are legal, returned in canonical uppercase. + */ + public static String requireAscOrDesc(String value) { + String trimmed = StringUtils.trimToEmpty(value); + if ("ASC".equalsIgnoreCase(trimmed)) { + return "ASC"; + } + if ("DESC".equalsIgnoreCase(trimmed)) { + return "DESC"; + } + throw new IllegalArgumentException("Invalid MySQL index sort direction: " + value); + } + + /** + * Validate an option that must be one of the given enum constants (e.g. view algorithm / + * sql security / check option). Returns the canonical enum name. + */ + public static > String requireEnumConstant(String value, E[] constants, String what) { + for (E constant : constants) { + if (constant.name().equalsIgnoreCase(StringUtils.trimToEmpty(value))) { + return constant.name(); + } + } + throw new IllegalArgumentException("Invalid MySQL " + what + ": " + value); + } + + /** + * Parse and re-escape a comma-separated ENUM/SET value list. Quoted values are decoded before + * they are escaped again, so metadata such as {@code 'can''t'} is not double-escaped. A + * surrounding pair of parentheses is preserved. + */ + public static String quoteEnumValues(String raw) { + if (raw == null) { + return null; + } + String trimmed = raw.trim(); + if (trimmed.isEmpty()) { + return trimmed; + } + boolean startsWithParenthesis = trimmed.startsWith("("); + boolean endsWithParenthesis = trimmed.endsWith(")"); + if (startsWithParenthesis != endsWithParenthesis) { + throw invalidEnumValues(raw); + } + boolean parenthesized = startsWithParenthesis; + String inner = parenthesized ? trimmed.substring(1, trimmed.length() - 1) : trimmed; + List items = parseEnumValues(inner, raw); + StringBuilder result = new StringBuilder(); + if (parenthesized) { + result.append('('); + } + for (int i = 0; i < items.size(); i++) { + if (i > 0) { + result.append(','); + } + result.append('\'').append(MysqlIdentifierProcessor.INSTANCE.escapeString(items.get(i))).append('\''); + } + if (parenthesized) { + result.append(')'); + } + return result.toString(); + } + + private static List parseEnumValues(String inner, String raw) { + List values = new ArrayList<>(); + int index = 0; + while (index < inner.length()) { + while (index < inner.length() && Character.isWhitespace(inner.charAt(index))) { + index++; + } + if (index >= inner.length()) { + throw invalidEnumValues(raw); + } + + String value; + if (inner.charAt(index) == '\'') { + StringBuilder decoded = new StringBuilder(); + index = parseQuotedEnumValue(inner, index + 1, decoded, raw); + value = decoded.toString(); + while (index < inner.length() && Character.isWhitespace(inner.charAt(index))) { + index++; + } + if (index < inner.length() && inner.charAt(index) != ',') { + throw invalidEnumValues(raw); + } + } else { + int comma = inner.indexOf(',', index); + int end = comma < 0 ? inner.length() : comma; + value = inner.substring(index, end).trim(); + if (value.isEmpty()) { + throw invalidEnumValues(raw); + } + index = end; + } + values.add(value); + + if (index >= inner.length()) { + break; + } + index++; + if (index >= inner.length()) { + throw invalidEnumValues(raw); + } + } + if (values.isEmpty()) { + throw invalidEnumValues(raw); + } + return values; + } + + private static int parseQuotedEnumValue(String inner, int index, StringBuilder decoded, String raw) { + while (index < inner.length()) { + char current = inner.charAt(index++); + if (current == '\'') { + if (index < inner.length() && inner.charAt(index) == '\'') { + decoded.append('\''); + index++; + continue; + } + return index; + } + if (current == '\\') { + if (index >= inner.length()) { + throw invalidEnumValues(raw); + } + appendMysqlEscapedCharacter(decoded, inner.charAt(index++)); + continue; + } + decoded.append(current); + } + throw invalidEnumValues(raw); + } + + private static void appendMysqlEscapedCharacter(StringBuilder decoded, char escaped) { + switch (escaped) { + case '0' -> decoded.append('\0'); + case 'b' -> decoded.append('\b'); + case 'n' -> decoded.append('\n'); + case 'r' -> decoded.append('\r'); + case 't' -> decoded.append('\t'); + case 'Z' -> decoded.append((char) 26); + case '%', '_' -> decoded.append('\\').append(escaped); + default -> decoded.append(escaped); + } + } + + private static IllegalArgumentException invalidEnumValues(String raw) { + return new IllegalArgumentException("Invalid MySQL ENUM/SET values: " + raw); + } +} diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/builder/MysqlSqlBuilder.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/builder/MysqlSqlBuilder.java index 4925b607cd..c1a3757208 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/builder/MysqlSqlBuilder.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/builder/MysqlSqlBuilder.java @@ -1,5 +1,10 @@ package ai.chat2db.plugin.mysql.builder; +import ai.chat2db.plugin.mysql.MysqlSqlGuards; +import ai.chat2db.plugin.mysql.identifier.MysqlIdentifierProcessor; +import ai.chat2db.plugin.mysql.enums.MysqlViewAlgorithmOptionEnum; +import ai.chat2db.plugin.mysql.enums.MysqlViewCheckOptionEnum; +import ai.chat2db.plugin.mysql.enums.MysqlViewSqlSecurityOptionEnum; import ai.chat2db.plugin.mysql.enums.type.MysqlColumnTypeEnum; import ai.chat2db.plugin.mysql.enums.type.MysqlIndexTypeEnum; import ai.chat2db.community.domain.api.enums.plugin.EditStatusEnum; @@ -105,15 +110,15 @@ public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfi if (StringUtils.isNotBlank(table.getEngine())) { - script.append(SQLConstants.ENGINE_SQL).append(table.getEngine()); + script.append(SQLConstants.ENGINE_SQL).append(MysqlSqlGuards.requireMysqlName(table.getEngine(), "engine")); } if (StringUtils.isNotBlank(table.getCharset())) { - script.append(SQLConstants.DEFAULT_CHARACTER_SET_SQL).append(table.getCharset()); + script.append(SQLConstants.DEFAULT_CHARACTER_SET_SQL).append(MysqlSqlGuards.requireMysqlName(table.getCharset(), "charset")); } if (StringUtils.isNotBlank(table.getCollate())) { - script.append(SQLConstants.COLLATE_SQL).append(table.getCollate()); + script.append(SQLConstants.COLLATE_SQL).append(MysqlSqlGuards.requireMysqlName(table.getCollate(), "collation")); } if (table.getIncrementValue() != null) { @@ -121,7 +126,7 @@ public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfi } if (StringUtils.isNotBlank(table.getComment())) { - script.append(SQL_COMMENT_WITH_SINGLE_QUOTE).append(table.getComment()).append(SQLConstants.SINGLE_QUOTE); + script.append(SQL_COMMENT_WITH_SINGLE_QUOTE).append(MysqlIdentifierProcessor.INSTANCE.escapeString(table.getComment())).append(SQLConstants.SINGLE_QUOTE); } if (StringUtils.isNotBlank(table.getPartition())) { @@ -152,17 +157,17 @@ public String buildAlterTable(Table oldTable, Table newTable) { script.append(SQLConstants.TAB).append(SQL_RENAME).append(quoteMysqlIdentifier(newTable.getName())).append(SQLConstants.COMMA_LINE_SEPARATOR); } if (!StringUtils.equalsIgnoreCase(oldTable.getComment(), newTable.getComment())) { - script.append(SQLConstants.TAB).append(SQL_COMMENT).append(SQLConstants.SINGLE_QUOTE).append(newTable.getComment()).append(SQLConstants.SINGLE_QUOTE) + script.append(SQLConstants.TAB).append(SQL_COMMENT).append(SQLConstants.SINGLE_QUOTE).append(MysqlIdentifierProcessor.INSTANCE.escapeString(newTable.getComment())).append(SQLConstants.SINGLE_QUOTE) .append(SQLConstants.COMMA_LINE_SEPARATOR); } if (!StringUtils.equalsIgnoreCase(oldTable.getEngine(), newTable.getEngine()) && StringUtils.isNotBlank(newTable.getEngine())) { - script.append(SQLConstants.TAB).append(SQL_ENGINE_ASSIGNMENT).append(newTable.getEngine()).append(SQLConstants.COMMA_LINE_SEPARATOR); + script.append(SQLConstants.TAB).append(SQL_ENGINE_ASSIGNMENT).append(MysqlSqlGuards.requireMysqlName(newTable.getEngine(), "engine")).append(SQLConstants.COMMA_LINE_SEPARATOR); } if (!StringUtils.equalsIgnoreCase(oldTable.getCharset(), newTable.getCharset()) && StringUtils.isNotBlank(newTable.getCharset())) { - script.append(SQLConstants.TAB).append(SQL_DEFAULT_CHARACTER_SET_ASSIGNMENT).append(newTable.getCharset()).append(SQLConstants.COMMA_LINE_SEPARATOR); + script.append(SQLConstants.TAB).append(SQL_DEFAULT_CHARACTER_SET_ASSIGNMENT).append(MysqlSqlGuards.requireMysqlName(newTable.getCharset(), "charset")).append(SQLConstants.COMMA_LINE_SEPARATOR); } if (!StringUtils.equalsIgnoreCase(oldTable.getCollate(), newTable.getCollate()) && StringUtils.isNotBlank(newTable.getCollate())) { - script.append(SQLConstants.TAB).append(SQL_COLLATE_ASSIGNMENT).append(newTable.getCollate()).append(SQLConstants.COMMA_LINE_SEPARATOR); + script.append(SQLConstants.TAB).append(SQL_COLLATE_ASSIGNMENT).append(MysqlSqlGuards.requireMysqlName(newTable.getCollate(), "collation")).append(SQLConstants.COMMA_LINE_SEPARATOR); } if (!Objects.equals(oldTable.getIncrementValue(), newTable.getIncrementValue())) { script.append(SQLConstants.TAB).append(SQL_AUTO_INCREMENT_ASSIGNMENT).append(newTable.getIncrementValue()).append(SQLConstants.COMMA_LINE_SEPARATOR); @@ -256,10 +261,10 @@ public String buildCreateDatabase(Database database) { StringBuilder sqlBuilder = new StringBuilder(); sqlBuilder.append(SQLConstants.CREATE_DATABASE_SQL_PREFIX).append(quoteMysqlIdentifier(database.getName())); if (StringUtils.isNotBlank(database.getCharset())) { - sqlBuilder.append(SQLConstants.DEFAULT_CHARACTER_SET_SQL).append(database.getCharset()); + sqlBuilder.append(SQLConstants.DEFAULT_CHARACTER_SET_SQL).append(MysqlSqlGuards.requireMysqlName(database.getCharset(), "charset")); } if (StringUtils.isNotBlank(database.getCollation())) { - sqlBuilder.append(SQLConstants.COLLATE_SQL).append(database.getCollation()); + sqlBuilder.append(SQLConstants.COLLATE_SQL).append(MysqlSqlGuards.requireMysqlName(database.getCollation(), "collation")); } return sqlBuilder.toString(); } @@ -362,7 +367,7 @@ private String[] buildSql(String[] originalArray, String[] targetArray, StringBu MysqlColumnTypeEnum typeEnum = MysqlColumnTypeEnum.getByType(column.getColumnType()); sql.append(typeEnum.buildColumn(column)); sql.append(SQL_AFTER); - sql.append(oldTable.getColumnList().get(max).getName()); + sql.append(quoteMysqlIdentifier(oldTable.getColumnList().get(max).getName())); sql.append(SQLConstants.SEMICOLON_LINE_SEPARATOR); n++; if (Arrays.equals(newArray, targetArray)) { @@ -394,9 +399,9 @@ private String[] buildSql(String[] originalArray, String[] targetArray, StringBu AtomicInteger continuousDataCount = new AtomicInteger(0); String[] newArray = moveElement(originalArray, i, a, targetArray, continuousDataCount); if (i < a) { - sql.append(originalArray[a + continuousDataCount.get()]); + sql.append(quoteMysqlIdentifier(originalArray[a + continuousDataCount.get()])); } else { - sql.append(originalArray[a - 1]); + sql.append(quoteMysqlIdentifier(originalArray[a - 1])); } sql.append(SQLConstants.SEMICOLON_LINE_SEPARATOR); @@ -487,15 +492,15 @@ public String buildCreateView(ModifyView modifyView) { } String algorithm = modifyView.getAlgorithm(); if (StringUtils.isNotBlank(algorithm)) { - createViewSqlBuilder.append(SQL_ALGORITHM).append(algorithm).append(SQLConstants.SPACE); + createViewSqlBuilder.append(SQL_ALGORITHM).append(MysqlSqlGuards.requireEnumConstant(algorithm, MysqlViewAlgorithmOptionEnum.values(), "algorithm")).append(SQLConstants.SPACE); } String definer = modifyView.getDefiner(); if (StringUtils.isNotBlank(definer)) { - createViewSqlBuilder.append(SQL_DEFINER).append(definer).append(SQLConstants.SPACE); + createViewSqlBuilder.append(SQL_DEFINER).append(MysqlSqlGuards.requireDefiner(definer)).append(SQLConstants.SPACE); } String security = modifyView.getSecurity(); if (StringUtils.isNotBlank(security)) { - createViewSqlBuilder.append(SQL_SECURITY).append(security).append(SQLConstants.SPACE); + createViewSqlBuilder.append(SQL_SECURITY).append(MysqlSqlGuards.requireEnumConstant(security, MysqlViewSqlSecurityOptionEnum.values(), "security")).append(SQLConstants.SPACE); } createViewSqlBuilder.append(SQLConstants.VIEW_KEYWORD); String databaseName = modifyView.getDatabaseName(); @@ -519,24 +524,14 @@ public String buildCreateView(ModifyView modifyView) { } String checkOption = modifyView.getCheckOption(); if (StringUtils.isNotBlank(checkOption)) { - createViewSqlBuilder.append(SQLConstants.LINE_SEPARATOR_SQL_WITH).append(checkOption).append(SQLConstants.CHECK_OPTION_SQL); + createViewSqlBuilder.append(SQLConstants.LINE_SEPARATOR_SQL_WITH).append(MysqlSqlGuards.requireEnumConstant(checkOption, MysqlViewCheckOptionEnum.values(), "check option")).append(SQLConstants.CHECK_OPTION_SQL); } return createViewSqlBuilder + SQLConstants.SEMICOLON; } private static String quoteMysqlIdentifier(String name) { - if (StringUtils.isBlank(name)) { - return name; - } - String identifier = name; - if (identifier.length() >= 2 && identifier.startsWith(SQLConstants.BACK_QUOTE) - && identifier.endsWith(SQLConstants.BACK_QUOTE)) { - identifier = identifier.substring(1, identifier.length() - 1); - } - return SQLConstants.BACK_QUOTE - + identifier.replace(SQLConstants.BACK_QUOTE, SQLConstants.BACK_QUOTE + SQLConstants.BACK_QUOTE) - + SQLConstants.BACK_QUOTE; + return MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways(name); } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/constant/MysqlSqlConstants.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/constant/MysqlSqlConstants.java index a515371835..b71ef69e2b 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/constant/MysqlSqlConstants.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/constant/MysqlSqlConstants.java @@ -20,6 +20,8 @@ public final class MysqlSqlConstants { public static final String SQL_COMMENT_KEYWORD = "COMMENT"; public static final String SQL_COMMENT_SPACE_SINGLE_QUOTE = "COMMENT '"; public static final String SQL_COMMENT_WITH_SINGLE_QUOTE = " COMMENT='"; + public static final String SQL_COPY_TABLE_DATA_TEMPLATE = "CREATE TABLE %s AS SELECT * FROM %s"; + public static final String SQL_COPY_TABLE_STRUCTURE_TEMPLATE = "CREATE TABLE %s AS SELECT * FROM %s WHERE 1=0"; public static final String SQL_CREATE = "create "; public static final String SQL_CREATE_USER = "CREATE USER "; public static final String SQL_DEFAULT_CHARACTER_SET_ASSIGNMENT = "DEFAULT CHARACTER SET="; @@ -32,7 +34,7 @@ public final class MysqlSqlConstants { public static final String SQL_DROP_PROCEDURE_TEMPLATE = "DROP PROCEDURE %s"; public static final String SQL_DROP_TABLE_TEMPLATE = "DROP TABLE %s"; public static final String SQL_DROP_USER = "DROP USER "; - public static final String SQL_DROP_VIEW_TEMPLATE = "DROP VIEW %s%s"; + public static final String SQL_DROP_VIEW_TEMPLATE = "DROP VIEW %s"; public static final String SQL_ENGINE_ASSIGNMENT = "ENGINE="; public static final String SQL_FIRST_TERMINATOR = " FIRST;\n"; public static final String SQL_FROM = " FROM "; @@ -60,6 +62,7 @@ public final class MysqlSqlConstants { public static final String SQL_SHOW_INDEX_FROM = "SHOW INDEX FROM "; public static final String SQL_SHOW_PROCEDURE_STATUS = "SHOW PROCEDURE STATUS WHERE Db = DATABASE()"; public static final String SQL_SHOW_TRIGGERS = "SHOW TRIGGERS"; + public static final String SQL_TRUNCATE_TABLE_TEMPLATE = "TRUNCATE TABLE %s"; public static final String SQL_UNDEFINED = "undefined"; public static final String SQL_WITH_GRANT_OPTION = " WITH GRANT OPTION"; diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/enums/type/MysqlColumnTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/enums/type/MysqlColumnTypeEnum.java index fa9f3efb9e..840130fe38 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/enums/type/MysqlColumnTypeEnum.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/enums/type/MysqlColumnTypeEnum.java @@ -1,5 +1,7 @@ package ai.chat2db.plugin.mysql.enums.type; +import ai.chat2db.plugin.mysql.MysqlSqlGuards; +import ai.chat2db.plugin.mysql.identifier.MysqlIdentifierProcessor; import ai.chat2db.spi.IColumnBuilder; import ai.chat2db.community.domain.api.enums.plugin.EditStatusEnum; import ai.chat2db.community.domain.api.model.metadata.ColumnType; @@ -15,7 +17,6 @@ import static ai.chat2db.plugin.mysql.constant.MysqlSqlConstants.SQL_COMMENT_KEYWORD; import static ai.chat2db.plugin.mysql.constant.MysqlSqlConstants.SQL_COMMENT_SPACE_SINGLE_QUOTE; -import static ai.chat2db.plugin.mysql.constant.MysqlSqlConstants.SQL_DROP_COLUMN_BACK_QUOTE; import static ai.chat2db.plugin.mysql.constant.MysqlColumnTypeEnumConstants.*; @Getter @@ -141,6 +142,21 @@ public static MysqlColumnTypeEnum getByType(String dataType) { } } + @Override + public String buildDefaultColumn(TableColumn column, boolean comment) { + StringBuilder script = new StringBuilder(); + script.append(MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName())) + .append(" ") + .append(MysqlSqlGuards.requireColumnType(column.getColumnType())); + if (comment && StringUtils.isNotBlank(column.getComment())) { + script.append(" ") + .append(SQL_COMMENT_SPACE_SINGLE_QUOTE) + .append(MysqlIdentifierProcessor.INSTANCE.escapeString(column.getComment())) + .append("'"); + } + return script.toString(); + } + @Override public String buildCreateColumnSql(TableColumn column) { @@ -150,7 +166,7 @@ public String buildCreateColumnSql(TableColumn column) { } StringBuilder script = new StringBuilder(); - script.append("`").append(column.getName()).append("`").append(" "); + script.append(MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName())).append(" "); script.append(buildDataType(column, type)).append(" "); @@ -181,7 +197,7 @@ public String buildAICreateColumnSql(TableColumn column) { } StringBuilder script = new StringBuilder(); - script.append("`").append(column.getName()).append("`").append(" "); + script.append(MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName())).append(" "); script.append(buildDataType(column, type)).append(" "); @@ -209,28 +225,28 @@ private String buildCharset(TableColumn column, MysqlColumnTypeEnum type) { if (!type.getColumnType().isSupportCharset() || StringUtils.isEmpty(column.getCharSetName())) { return ""; } - return StringUtils.join("CHARACTER SET ", column.getCharSetName()); + return StringUtils.join("CHARACTER SET ", MysqlSqlGuards.requireMysqlName(column.getCharSetName(), "charset")); } private String buildCollation(TableColumn column, MysqlColumnTypeEnum type) { if (!type.getColumnType().isSupportCollation() || StringUtils.isEmpty(column.getCollationName())) { return ""; } - return StringUtils.join("COLLATE ", column.getCollationName()); + return StringUtils.join("COLLATE ", MysqlSqlGuards.requireMysqlName(column.getCollationName(), "collation")); } @Override public String buildModifyColumn(TableColumn tableColumn) { if (EditStatusEnum.DELETE.name().equals(tableColumn.getEditStatus())) { - return StringUtils.join(SQL_DROP_COLUMN_BACK_QUOTE, tableColumn.getName() + "`"); + return StringUtils.join("DROP COLUMN ", MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableColumn.getName())); } if (EditStatusEnum.ADD.name().equals(tableColumn.getEditStatus())) { return StringUtils.join("ADD COLUMN ", buildCreateColumnSql(tableColumn)); } if (EditStatusEnum.MODIFY.name().equals(tableColumn.getEditStatus())) { if (!StringUtils.equalsIgnoreCase(tableColumn.getOldName(), tableColumn.getName())) { - return StringUtils.join("CHANGE COLUMN `", tableColumn.getOldName(), "` ", buildCreateColumnSql(tableColumn)); + return StringUtils.join("CHANGE COLUMN ", MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableColumn.getOldName()), " ", buildCreateColumnSql(tableColumn)); } else { return StringUtils.join("MODIFY COLUMN ", buildCreateColumnSql(tableColumn)); } @@ -240,14 +256,14 @@ public String buildModifyColumn(TableColumn tableColumn) { public String buildModifyColumn(TableColumn tableColumn, boolean isMove, String columnName) { if (EditStatusEnum.DELETE.name().equals(tableColumn.getEditStatus())) { - return StringUtils.join(SQL_DROP_COLUMN_BACK_QUOTE, tableColumn.getName() + "`"); + return StringUtils.join("DROP COLUMN ", MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableColumn.getName())); } if (EditStatusEnum.ADD.name().equals(tableColumn.getEditStatus())) { if (isMove) { if (columnName.equals("-1")) { return StringUtils.join("ADD COLUMN ", buildCreateColumnSql(tableColumn), " FIRST"); } else { - return StringUtils.join("ADD COLUMN ", buildCreateColumnSql(tableColumn), " AFTER ", "`"+columnName+"`"); + return StringUtils.join("ADD COLUMN ", buildCreateColumnSql(tableColumn), " AFTER ", MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways(columnName)); } } return StringUtils.join("ADD COLUMN ", buildCreateColumnSql(tableColumn)); @@ -255,7 +271,7 @@ public String buildModifyColumn(TableColumn tableColumn, boolean isMove, String if (EditStatusEnum.MODIFY.name().equals(tableColumn.getEditStatus())) { String sql; if (!StringUtils.equalsIgnoreCase(tableColumn.getOldName(), tableColumn.getName())) { - sql = StringUtils.join("CHANGE COLUMN `", tableColumn.getOldName(), "` ", buildCreateColumnSql(tableColumn)); + sql = StringUtils.join("CHANGE COLUMN ", MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableColumn.getOldName()), " ", buildCreateColumnSql(tableColumn)); } else { sql = StringUtils.join("MODIFY COLUMN ", buildCreateColumnSql(tableColumn)); } @@ -275,7 +291,7 @@ private String appendColumnPosition(String sql, boolean isMove, String columnNam if (columnName.equals("-1")) { return StringUtils.join(sql, " FIRST"); } - return StringUtils.join(sql, " AFTER ", "`" + columnName + "`"); + return StringUtils.join(sql, " AFTER ", MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways(columnName)); } private String buildAutoIncrement(TableColumn column, MysqlColumnTypeEnum type) { @@ -292,14 +308,14 @@ private String buildComment(TableColumn column, MysqlColumnTypeEnum type) { if (!type.columnType.isSupportComments() || StringUtils.isEmpty(column.getComment())) { return ""; } - return StringUtils.join(SQL_COMMENT_SPACE_SINGLE_QUOTE, column.getComment(), "'"); + return StringUtils.join(SQL_COMMENT_SPACE_SINGLE_QUOTE, MysqlIdentifierProcessor.INSTANCE.escapeString(column.getComment()), "'"); } private String buildExt(TableColumn column, MysqlColumnTypeEnum type) { if (!type.columnType.isSupportExtent() || StringUtils.isEmpty(column.getExtent())) { return ""; } - return column.getExtent(); + return MysqlSqlGuards.quoteEnumValues(column.getExtent()); } private String buildDefaultValue(TableColumn column, MysqlColumnTypeEnum type) { @@ -316,11 +332,11 @@ private String buildDefaultValue(TableColumn column, MysqlColumnTypeEnum type) { } if (Arrays.asList(CHAR, VARCHAR, BINARY, VARBINARY, SET, ENUM).contains(type)) { - return StringUtils.join("DEFAULT '", column.getDefaultValue(), "'"); + return StringUtils.join("DEFAULT '", MysqlIdentifierProcessor.INSTANCE.escapeString(column.getDefaultValue()), "'"); } if (Arrays.asList(DATE, TIME, YEAR).contains(type)) { - return StringUtils.join("DEFAULT '", column.getDefaultValue(), "'"); + return StringUtils.join("DEFAULT '", MysqlIdentifierProcessor.INSTANCE.escapeString(column.getDefaultValue()), "'"); } if (Arrays.asList(DATETIME, TIMESTAMP).contains(type)) { @@ -328,10 +344,10 @@ private String buildDefaultValue(TableColumn column, MysqlColumnTypeEnum type) { return StringUtils.join("DEFAULT ", column.getDefaultValue()); } - return StringUtils.join("DEFAULT '", column.getDefaultValue(), "'"); + return StringUtils.join("DEFAULT '", MysqlIdentifierProcessor.INSTANCE.escapeString(column.getDefaultValue()), "'"); } - return StringUtils.join("DEFAULT ", column.getDefaultValue()); + return StringUtils.join("DEFAULT ", MysqlSqlGuards.requireNumericDefault(column.getDefaultValue())); } private String OnUpdateCurrentTimestamp(TableColumn column, MysqlColumnTypeEnum type) { @@ -404,7 +420,7 @@ private String buildDataType(TableColumn column, MysqlColumnTypeEnum type) { if (Arrays.asList(SET, ENUM).contains(type)) { if (!StringUtils.isEmpty(column.getValue())) { - return StringUtils.join(columnType, "(", column.getValue(), ")"); + return StringUtils.join(columnType, "(", MysqlSqlGuards.quoteEnumValues(column.getValue()), ")"); } } @@ -418,10 +434,10 @@ public String buildColumn(TableColumn column) { } StringBuilder script = new StringBuilder(); - script.append("`").append(column.getName()).append("`").append(" "); + script.append(MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName())).append(" "); script.append(buildDataType(column, type)).append(" "); if (StringUtils.isNoneBlank(column.getComment())) { - script.append(SQL_COMMENT_KEYWORD).append(" ").append("'").append(column.getComment()).append("'").append(" "); + script.append(SQL_COMMENT_KEYWORD).append(" ").append("'").append(MysqlIdentifierProcessor.INSTANCE.escapeString(column.getComment())).append("'").append(" "); } return script.toString(); } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/enums/type/MysqlIndexTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/enums/type/MysqlIndexTypeEnum.java index 70049a7555..e1d541923c 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/enums/type/MysqlIndexTypeEnum.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/enums/type/MysqlIndexTypeEnum.java @@ -1,5 +1,7 @@ package ai.chat2db.plugin.mysql.enums.type; +import ai.chat2db.plugin.mysql.MysqlSqlGuards; +import ai.chat2db.plugin.mysql.identifier.MysqlIdentifierProcessor; import ai.chat2db.community.domain.api.enums.plugin.EditStatusEnum; import ai.chat2db.community.domain.api.model.metadata.IndexType; import ai.chat2db.community.domain.api.model.metadata.TableIndex; @@ -12,7 +14,6 @@ import java.util.Locale; import static ai.chat2db.plugin.mysql.constant.MysqlSqlConstants.SQL_COMMENT_SPACE_SINGLE_QUOTE; -import static ai.chat2db.plugin.mysql.constant.MysqlSqlConstants.SQL_DROP_INDEX_BACK_QUOTE; import static ai.chat2db.plugin.mysql.constant.MysqlSqlConstants.SQL_DROP_PRIMARY_KEY; @Getter @@ -96,7 +97,7 @@ private String buildIndexComment(TableIndex tableIndex) { if(StringUtils.isBlank(tableIndex.getComment())){ return ""; }else { - return StringUtils.join(SQL_COMMENT_SPACE_SINGLE_QUOTE,tableIndex.getComment(),"'"); + return StringUtils.join(SQL_COMMENT_SPACE_SINGLE_QUOTE, MysqlIdentifierProcessor.INSTANCE.escapeString(tableIndex.getComment()),"'"); } } @@ -106,9 +107,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(MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getColumnName())); if (!StringUtils.isBlank(column.getAscOrDesc()) && !PRIMARY_KEY.equals(this)) { - script.append(" ").append(column.getAscOrDesc()); + script.append(" ").append(MysqlSqlGuards.requireAscOrDesc(column.getAscOrDesc())); } script.append(","); } @@ -122,7 +123,7 @@ private String buildIndexName(TableIndex tableIndex) { if(this.equals(PRIMARY_KEY)){ return ""; }else { - return "`"+tableIndex.getName()+"`"; + return MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableIndex.getName()); } } @@ -143,7 +144,7 @@ private String buildDropIndex(TableIndex tableIndex) { if (MysqlIndexTypeEnum.PRIMARY_KEY.getName().equals(tableIndex.getType())) { return StringUtils.join(SQL_DROP_PRIMARY_KEY); } - return StringUtils.join(SQL_DROP_INDEX_BACK_QUOTE, tableIndex.getOldName(),"`"); + return StringUtils.join("DROP INDEX ", MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableIndex.getOldName())); } public static List getIndexTypes() { return Arrays.asList(MysqlIndexTypeEnum.values()).stream().map(MysqlIndexTypeEnum::getIndexType).collect(java.util.stream.Collectors.toList()); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/identifier/MysqlIdentifierProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/identifier/MysqlIdentifierProcessor.java index 7e87ef42c8..9b4c15de85 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/identifier/MysqlIdentifierProcessor.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/identifier/MysqlIdentifierProcessor.java @@ -11,6 +11,11 @@ import static ai.chat2db.plugin.mysql.constant.MysqlIdentifierProcessorConstants.*; public class MysqlIdentifierProcessor extends DefaultSQLIdentifierProcessor { + /** + * Shared stateless instance for call sites without MetaData access. + */ + public static final MysqlIdentifierProcessor INSTANCE = new MysqlIdentifierProcessor(); + private static final Set MYSQL_RESERVED_KEYWORDS = new HashSet<>(); static { @@ -289,21 +294,53 @@ public boolean isReservedKeyword(String identifier, Integer majorVersion, Intege return MYSQL_RESERVED_KEYWORDS.contains(identifier); } + /** + * SPI-facing conditional quote: identifiers that are already valid plain identifiers + * and not reserved keywords pass through unquoted; anything else is safely quoted. + */ @Override public String quoteIdentifier(String identifier, Integer majorVersion, Integer minorVersion) { - if (isValidIdentifier(identifier) && !isReservedKeyword(identifier.toUpperCase(), majorVersion, minorVersion)) { - return identifier; - } - return "`" + identifier + "`"; + return quoteIdentifier(identifier); } @Override public String quoteIdentifier(String identifier) { + if (identifier == null) { + return null; + } if (isValidIdentifier(identifier) && !isReservedKeyword(identifier.toUpperCase(), null, null)) { return identifier; } - return "`" + identifier + "`"; + return quoteIdentifierAlways(identifier); + } + + /** + * Escapes a value interpolated into a single-quoted SQL string literal (surrounding + * quotes NOT added). MySQL treats backslash as an escape character, so backslashes + * are doubled before single quotes (mirrors MysqlAccountSqlBuilder.stringLiteral). + */ + @Override + public String escapeString(String str) { + if (str == null) { + return null; + } + StringBuilder escaped = new StringBuilder(str.length()); + for (int i = 0; i < str.length(); i++) { + char current = str.charAt(i); + switch (current) { + case '\0' -> escaped.append("\\0"); + case '\b' -> escaped.append("\\b"); + case '\n' -> escaped.append("\\n"); + case '\r' -> escaped.append("\\r"); + case '\t' -> escaped.append("\\t"); + case 26 -> escaped.append("\\Z"); + case '\\' -> escaped.append("\\\\"); + case '\'' -> escaped.append("''"); + default -> escaped.append(current); + } + } + return escaped.toString(); } @Override diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/value/sub/MysqlBinaryProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/value/sub/MysqlBinaryProcessor.java index 9c487cd4f4..8c7e9f7130 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/value/sub/MysqlBinaryProcessor.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/value/sub/MysqlBinaryProcessor.java @@ -1,5 +1,6 @@ package ai.chat2db.plugin.mysql.value.sub; +import ai.chat2db.plugin.mysql.MysqlSqlGuards; import ai.chat2db.plugin.mysql.value.template.MysqlDmlValueTemplate; import ai.chat2db.spi.DefaultValueProcessor; import ai.chat2db.spi.model.value.JDBCDataValue; @@ -11,7 +12,7 @@ public class MysqlBinaryProcessor extends DefaultValueProcessor { @Override public String convertSQLValueByType(SQLDataValue dataValue) { String value = dataValue.getValue(); - if (value.startsWith("0x")) { + if (MysqlSqlGuards.isHexLiteral(value)) { return value; } return MysqlDmlValueTemplate.wrapHex(dataValue.getBlobHexString()); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/value/sub/MysqlVarBinaryProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/value/sub/MysqlVarBinaryProcessor.java index 407a740af3..57b6489034 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/value/sub/MysqlVarBinaryProcessor.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/value/sub/MysqlVarBinaryProcessor.java @@ -1,5 +1,6 @@ package ai.chat2db.plugin.mysql.value.sub; +import ai.chat2db.plugin.mysql.MysqlSqlGuards; import ai.chat2db.plugin.mysql.value.template.MysqlDmlValueTemplate; import ai.chat2db.spi.DefaultValueProcessor; import ai.chat2db.spi.model.value.JDBCDataValue; @@ -13,7 +14,7 @@ public class MysqlVarBinaryProcessor extends DefaultValueProcessor { @Override public String convertSQLValueByType(SQLDataValue dataValue) { String value = dataValue.getValue(); - if (value.startsWith("0x")) { + if (MysqlSqlGuards.isHexLiteral(value)) { return value; } return MysqlDmlValueTemplate.wrapHex(dataValue.getBlobHexString()); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/value/template/MysqlDmlValueTemplate.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/value/template/MysqlDmlValueTemplate.java index 654a86e48b..88dfe9068a 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/value/template/MysqlDmlValueTemplate.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/value/template/MysqlDmlValueTemplate.java @@ -1,7 +1,9 @@ package ai.chat2db.plugin.mysql.value.template; -import static ai.chat2db.plugin.mysql.constant.MysqlDmlValueTemplateConstants.*; +import ai.chat2db.plugin.mysql.MysqlSqlGuards; +import ai.chat2db.plugin.mysql.identifier.MysqlIdentifierProcessor; +import static ai.chat2db.plugin.mysql.constant.MysqlDmlValueTemplateConstants.*; public class MysqlDmlValueTemplate { @@ -9,16 +11,15 @@ public class MysqlDmlValueTemplate { - public static String wrapGeometry(String value) { - return String.format(GEOMETRY_TEMPLATE, value); + return String.format(GEOMETRY_TEMPLATE, MysqlIdentifierProcessor.INSTANCE.escapeString(value)); } public static String wrapBit(String value) { - return String.format(BIT_TEMPLATE, value); + return String.format(BIT_TEMPLATE, MysqlSqlGuards.requireBitLiteral(value)); } public static String wrapHex(String value) { - return String.format(HEX_TEMPLATE, value); + return String.format(HEX_TEMPLATE, MysqlSqlGuards.requireHexDigits(value)); } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/test/java/ai/chat2db/plugin/mysql/MysqlIdentifierProcessorTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/test/java/ai/chat2db/plugin/mysql/MysqlIdentifierProcessorTest.java new file mode 100644 index 0000000000..eb88077860 --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/test/java/ai/chat2db/plugin/mysql/MysqlIdentifierProcessorTest.java @@ -0,0 +1,325 @@ +package ai.chat2db.plugin.mysql; + +import ai.chat2db.community.domain.api.config.TableBuilderConfig; +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.view.ModifyView; +import ai.chat2db.plugin.mysql.builder.MysqlSqlBuilder; +import ai.chat2db.plugin.mysql.enums.MysqlViewAlgorithmOptionEnum; +import ai.chat2db.plugin.mysql.enums.type.MysqlColumnTypeEnum; +import ai.chat2db.plugin.mysql.enums.type.MysqlIndexTypeEnum; +import ai.chat2db.plugin.mysql.identifier.MysqlIdentifierProcessor; +import ai.chat2db.plugin.mysql.value.template.MysqlDmlValueTemplate; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MysqlIdentifierProcessorTest { + + @Test + void escapeStringDoublesBackslashBeforeSingleQuote() { + assertEquals("a''b\\\\c", MysqlIdentifierProcessor.INSTANCE.escapeString("a'b\\c")); + assertEquals("''", MysqlIdentifierProcessor.INSTANCE.escapeString("'")); + assertEquals("plain", MysqlIdentifierProcessor.INSTANCE.escapeString("plain")); + assertNull(MysqlIdentifierProcessor.INSTANCE.escapeString(null)); + } + + @Test + void quoteIdentifierIsConditionalForSpiConsumers() { + // null/blank pass through untouched + assertNull(MysqlIdentifierProcessor.INSTANCE.quoteIdentifier(null)); + assertEquals("", MysqlIdentifierProcessor.INSTANCE.quoteIdentifier("")); + assertEquals(" ", MysqlIdentifierProcessor.INSTANCE.quoteIdentifier(" ")); + // valid plain identifiers that are not reserved keywords stay unquoted + assertEquals("plain", MysqlIdentifierProcessor.INSTANCE.quoteIdentifier("plain")); + assertEquals("plain_name", MysqlIdentifierProcessor.INSTANCE.quoteIdentifier("plain_name")); + // reserved keywords and non-plain identifiers are quoted with doubling + assertEquals("`select`", MysqlIdentifierProcessor.INSTANCE.quoteIdentifier("select")); + assertEquals("`weird``name`", MysqlIdentifierProcessor.INSTANCE.quoteIdentifier("weird`name")); + assertEquals("`a``; DROP TABLE b; --`", MysqlIdentifierProcessor.INSTANCE.quoteIdentifier("a`; DROP TABLE b; --")); + // quoteIdentifier treats its argument as raw identifier content + assertEquals("```a``b```", MysqlIdentifierProcessor.INSTANCE.quoteIdentifier("`a`b`")); + assertEquals("```quoted```", MysqlIdentifierProcessor.INSTANCE.quoteIdentifier("`quoted`")); + // versioned overload delegates to the same conditional behavior + assertEquals("users", MysqlIdentifierProcessor.INSTANCE.quoteIdentifier("users", null, null)); + assertEquals("`a``b`", MysqlIdentifierProcessor.INSTANCE.quoteIdentifier("a`b", null, null)); + } + + @Test + void quoteIdentifierAlwaysWrapsUnconditionally() { + assertNull(MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways(null)); + assertEquals("``", MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways("")); + assertEquals("`plain`", MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways("plain")); + assertEquals("`plain_name`", MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways("plain_name")); + assertEquals("`weird``name`", MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways("weird`name")); + assertEquals("`a``; DROP TABLE b; --`", MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways("a`; DROP TABLE b; --")); + assertEquals("```a``b```", MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways("`a`b`")); + assertEquals("```quoted```", MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways("`quoted`")); + assertEquals("`quoted`", MysqlIdentifierProcessor.INSTANCE.removeIdentifierQuote( + MysqlIdentifierProcessor.INSTANCE.quoteIdentifierAlways("`quoted`"))); + } + + @Test + void quoteIdentifierIgnoreCaseKeepsConditionalSpiSemantics() { + assertEquals("`a``b`", MysqlIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("a`b")); + assertEquals("plain", MysqlIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("plain")); + } + + @Test + void requireMysqlNameRejectsInjection() { + assertEquals("utf8mb4_0900_ai_ci", MysqlSqlGuards.requireMysqlName("utf8mb4_0900_ai_ci", "collation")); + assertThrows(IllegalArgumentException.class, + () -> MysqlSqlGuards.requireMysqlName("InnoDB, COMMENT='x'", "engine")); + assertThrows(IllegalArgumentException.class, + () -> MysqlSqlGuards.requireMysqlName("utf8mb4;DROP TABLE t", "charset")); + } + + @Test + void requireNumericDefaultRejectsNonLiteral() { + assertEquals("42", MysqlSqlGuards.requireNumericDefault("42")); + assertEquals("-1.5", MysqlSqlGuards.requireNumericDefault("-1.5")); + assertEquals("1e3", MysqlSqlGuards.requireNumericDefault("1e3")); + assertThrows(IllegalArgumentException.class, () -> MysqlSqlGuards.requireNumericDefault("0);DROP TABLE t")); + assertThrows(IllegalArgumentException.class, () -> MysqlSqlGuards.requireNumericDefault("(uuid())")); + } + + @Test + void requireBitLiteralRejectsNonBits() { + assertEquals("0101", MysqlSqlGuards.requireBitLiteral("0101")); + assertThrows(IllegalArgumentException.class, () -> MysqlSqlGuards.requireBitLiteral("2")); + assertThrows(IllegalArgumentException.class, () -> MysqlSqlGuards.requireBitLiteral("1' OR '1'='1")); + } + + @Test + void requireDefinerAcceptsOnlyAccountSyntax() { + assertEquals("root@localhost", MysqlSqlGuards.requireDefiner("root@localhost")); + assertEquals("'root'@'%'", MysqlSqlGuards.requireDefiner("'root'@'%'")); + assertEquals("`root`@`localhost`", MysqlSqlGuards.requireDefiner("`root`@`localhost`")); + assertEquals("'ro''ot'@'%'", MysqlSqlGuards.requireDefiner("'ro''ot'@'%'")); + assertThrows(IllegalArgumentException.class, + () -> MysqlSqlGuards.requireDefiner("'root\\\\name'@'localhost'")); + assertThrows(IllegalArgumentException.class, + () -> MysqlSqlGuards.requireDefiner("root@localhost SQL SECURITY INVOKER")); + } + + @Test + void requireEnumConstantRejectsUnknownOption() { + assertEquals("MERGE", + MysqlSqlGuards.requireEnumConstant("merge", MysqlViewAlgorithmOptionEnum.values(), "algorithm")); + assertThrows(IllegalArgumentException.class, () -> MysqlSqlGuards.requireEnumConstant( + "MERGE SQL SECURITY INVOKER", MysqlViewAlgorithmOptionEnum.values(), "algorithm")); + } + + @Test + void quoteEnumValuesDecodesBeforeReEscapingAndRejectsMalformedLists() { + assertEquals("'draft','published'", MysqlSqlGuards.quoteEnumValues("'draft','published'")); + assertEquals("('draft','published')", MysqlSqlGuards.quoteEnumValues("('draft','published')")); + assertEquals("'can''t'", MysqlSqlGuards.quoteEnumValues("'can''t'")); + assertEquals("'can''t'", MysqlSqlGuards.quoteEnumValues("'can\\'t'")); + assertEquals("'a\\\\b'", MysqlSqlGuards.quoteEnumValues("'a\\\\b'")); + assertThrows(IllegalArgumentException.class, + () -> MysqlSqlGuards.quoteEnumValues("'),DROP TABLE t;-- x")); + assertThrows(IllegalArgumentException.class, + () -> MysqlSqlGuards.quoteEnumValues("'a','b'); DROP TABLE t;-- '")); + } + + @Test + void createColumnSqlQuotesColumnNameAndEscapesComment() { + TableColumn column = TableColumn.builder() + .name("a`b") + .columnType("VARCHAR") + .columnSize(255) + .comment("it's") + .build(); + + String sql = MysqlColumnTypeEnum.VARCHAR.buildCreateColumnSql(column); + + assertTrue(sql.contains("`a``b`"), sql); + assertTrue(sql.contains("COMMENT 'it''s'"), sql); + } + + @Test + void createEnumColumnSqlRejectsMalformedValueList() { + TableColumn column = TableColumn.builder() + .name("e") + .columnType("ENUM") + .value("'),DROP TABLE t;-- x") + .build(); + + assertThrows(IllegalArgumentException.class, + () -> MysqlColumnTypeEnum.ENUM.buildCreateColumnSql(column)); + } + + @Test + void fallbackColumnQuotesNameEscapesCommentAndValidatesType() { + TableColumn column = TableColumn.builder() + .name("a`b") + .columnType("CUSTOM_TYPE(10) UNSIGNED") + .comment("it's") + .build(); + assertEquals("`a``b` CUSTOM_TYPE(10) UNSIGNED COMMENT 'it''s'", + MysqlColumnTypeEnum.VARCHAR.buildDefaultColumn(column, true)); + + column.setColumnType("CUSTOM_TYPE); DROP TABLE t;--"); + assertThrows(IllegalArgumentException.class, + () -> MysqlColumnTypeEnum.VARCHAR.buildDefaultColumn(column, true)); + } + + @Test + void createColumnSqlRejectsRawDefaultInjection() { + TableColumn column = TableColumn.builder() + .name("n") + .columnType("INT") + .defaultValue("0);DROP TABLE t") + .build(); + + assertThrows(IllegalArgumentException.class, () -> MysqlColumnTypeEnum.INT.buildCreateColumnSql(column)); + } + + @Test + void indexScriptQuotesIndexNameAndEscapesComment() { + TableIndex tableIndex = TableIndex.builder() + .name("i`x") + .type("Normal") + .method("BTREE") + .comment("c'd") + .columnList(List.of(TableIndexColumn.builder().columnName("col`1").build())) + .build(); + + String sql = MysqlIndexTypeEnum.NORMAL.buildIndexScript(tableIndex); + + assertTrue(sql.contains("`i``x`"), sql); + assertTrue(sql.contains("(`col``1`)"), sql); + assertTrue(sql.contains("COMMENT 'c''d'"), sql); + } + + @Test + void indexScriptCanonicalizesAscOrDescAndRejectsInjection() { + TableIndex benign = TableIndex.builder() + .name("i") + .type("Normal") + .method("BTREE") + .columnList(List.of(TableIndexColumn.builder().columnName("c").ascOrDesc("desc").build())) + .build(); + assertTrue(MysqlIndexTypeEnum.NORMAL.buildIndexScript(benign).contains("(`c` DESC)"), + MysqlIndexTypeEnum.NORMAL.buildIndexScript(benign)); + + TableIndex malicious = TableIndex.builder() + .name("i") + .type("Normal") + .method("BTREE") + .columnList(List.of(TableIndexColumn.builder().columnName("c") + .ascOrDesc("DESC, DROP TABLE t;--").build())) + .build(); + assertThrows(IllegalArgumentException.class, () -> MysqlIndexTypeEnum.NORMAL.buildIndexScript(malicious)); + } + + @Test + void createTableEscapesCommentAndRejectsEngineInjection() { + MysqlSqlBuilder builder = new MysqlSqlBuilder(); + Table table = Table.builder() + .name("t") + .columnList(List.of()) + .indexList(List.of()) + .comment("x'; DROP TABLE u;--") + .build(); + + String sql = builder.ddl().table().buildCreateTable(table, TableBuilderConfig.defaultConfig()); + + assertTrue(sql.contains("COMMENT='x''; DROP TABLE u;--'"), sql); + + Table evilEngine = Table.builder() + .name("t") + .columnList(List.of()) + .indexList(List.of()) + .engine("InnoDB COMMENT='x'") + .build(); + assertThrows(IllegalArgumentException.class, + () -> builder.ddl().table().buildCreateTable(evilEngine, TableBuilderConfig.defaultConfig())); + } + + @Test + void createViewRejectsCheckOptionAndDefinerInjection() { + MysqlSqlBuilder builder = new MysqlSqlBuilder(); + ModifyView modifyView = new ModifyView(); + modifyView.setViewName("v"); + modifyView.setViewBody("select 1"); + modifyView.setCheckOption("CASCADED; DROP TABLE t"); + assertThrows(IllegalArgumentException.class, () -> builder.buildCreateView(modifyView)); + + ModifyView definerView = new ModifyView(); + definerView.setViewName("v"); + definerView.setViewBody("select 1"); + definerView.setDefiner("root@localhost SQL SECURITY INVOKER"); + assertThrows(IllegalArgumentException.class, () -> builder.buildCreateView(definerView)); + } + + @Test + void dropTableAndDropViewQuoteIdentifiers() { + MysqlDBManager manager = new MysqlDBManager(); + assertEquals("DROP TABLE `a``;DROP TABLE b;--`", + manager.dropTable(null, null, null, "a`;DROP TABLE b;--")); + assertEquals("DROP VIEW `db`.`v`", MysqlDBManager.buildDropViewSql("db", "v")); + assertEquals("DROP VIEW `v`", MysqlDBManager.buildDropViewSql(null, "v")); + } + + @Test + void dmlValueTemplatesEscapeOrValidate() { + assertEquals("ST_GeomFromText('POINT(1 1)')", MysqlDmlValueTemplate.wrapGeometry("POINT(1 1)")); + assertEquals("ST_GeomFromText('x''y')", MysqlDmlValueTemplate.wrapGeometry("x'y")); + assertEquals("b'0101'", MysqlDmlValueTemplate.wrapBit("0101")); + assertThrows(IllegalArgumentException.class, () -> MysqlDmlValueTemplate.wrapBit("1' OR '1'='1")); + assertEquals("0x4d7953514c", MysqlDmlValueTemplate.wrapHex("4d7953514c")); + assertThrows(IllegalArgumentException.class, () -> MysqlDmlValueTemplate.wrapHex("41, name=(SELECT user())-- ")); + } + + @Test + void hexLiteralPassthroughRequiresWellFormedHex() { + assertTrue(MysqlSqlGuards.isHexLiteral("0x4D7953514C")); + assertTrue(!MysqlSqlGuards.isHexLiteral("0x41, name=(SELECT user())-- ")); + assertTrue(!MysqlSqlGuards.isHexLiteral("0x")); + } + + @Test + void identifierProcessorDdlPathsKeepAlwaysQuoteSemantics() { + MysqlIdentifierProcessor processor = MysqlIdentifierProcessor.INSTANCE; + // SPI-facing conditional quote leaves plain identifiers bare + assertEquals("plain_name", processor.quoteIdentifier("plain_name")); + // DDL-generation always quote keeps producing quoted output + assertEquals("`a``b`", processor.quoteIdentifierAlways("a`b")); + assertEquals("`plain_name`", processor.quoteIdentifierAlways("plain_name")); + } + + @Test + void truncateTableEscapesBacktickIdentifier() { + MysqlDBManager manager = new MysqlDBManager(); + assertEquals("TRUNCATE TABLE `a``b`", + manager.truncateTable(null, null, null, "a`b")); + assertEquals("TRUNCATE TABLE `a``; DROP TABLE b; --`", + manager.truncateTable(null, null, null, "a`; DROP TABLE b; --")); + } + + @Test + void copyTableSqlEscapesBothIdentifiers() { + assertEquals("CREATE TABLE `n``t` AS SELECT * FROM `o``t`", + MysqlDBManager.buildCopyTableSql("o`t", "n`t", true)); + assertEquals("CREATE TABLE `n``t` AS SELECT * FROM `o``t` WHERE 1=0", + MysqlDBManager.buildCopyTableSql("o`t", "n`t", false)); + assertEquals("CREATE TABLE `c` AS SELECT * FROM `a``; DROP TABLE b; --`", + MysqlDBManager.buildCopyTableSql("a`; DROP TABLE b; --", "c", true)); + } + + @Test + void exportTitleValueCannotTerminateCommentLine() { + assertEquals("orders DROP TABLE users;-- ", + MysqlDBManager.formatExportTitleValue("orders\nDROP TABLE users;--\r")); + assertNull(MysqlDBManager.formatExportTitleValue(null)); + } +}