diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java index 8fb51db73a..7eb010d259 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java @@ -1,6 +1,7 @@ package ai.chat2db.plugin.sqlserver; import ai.chat2db.spi.IDbManager; +import ai.chat2db.plugin.sqlserver.identifier.SqlServerIdentifierProcessor; import ai.chat2db.spi.DefaultDBManager; import ai.chat2db.community.domain.api.model.async.AsyncContext; import ai.chat2db.spi.sql.Chat2DBContext; @@ -82,7 +83,8 @@ public void exportTable(Connection connection, String databaseName, String schem String tableDDL = Chat2DBContext.getDbMetaData().tableDDL(connection, new TableMetadataRequest(databaseName, schemaName, tableName)); StringBuilder sqlBuilder = new StringBuilder(); - sqlBuilder.append(SQL_DROP_TABLE_EXISTS).append("[").append(tableName).append("]").append(";").append("\ngo\n") + sqlBuilder.append(SQL_DROP_TABLE_EXISTS) + .append(buildFullTableName(null, schemaName, tableName)).append(";").append("\ngo\n") .append(tableDDL); asyncContext.write(sqlBuilder.toString()); if (asyncContext.isContainsData()) { @@ -103,7 +105,7 @@ private void exportViews(Connection connection, String databaseName, String sche while (resultSet.next()) { StringBuilder sqlBuilder = new StringBuilder(); sqlBuilder.append(SQL_DROP_VIEW_EXISTS) - .append("[").append(resultSet.getString("TABLE_NAME")).append("]") + .append(buildFullTableName(null, schemaName, resultSet.getString("TABLE_NAME"))) .append(";\n").append("go").append("\n") .append(resultSet.getString("VIEW_DEFINITION")).append(";").append("\n") .append("go").append("\n"); @@ -117,18 +119,24 @@ private void exportFunctions(Connection connection, String schemaName, AsyncCont DefaultSQLExecutor.getInstance().preExecute(connection, ROUTINES_SQL, new String[]{"FN", schemaName}, resultSet -> { while (resultSet.next()) { String functionName = resultSet.getString("name"); - exportFunction(connection, functionName, asyncContext); + exportFunction(connection, schemaName, functionName, asyncContext); } }); } - private void exportFunction(Connection connection, String functionName, AsyncContext asyncContext) { - String sql = String.format(ROUTINES_DDL_SQL, "'SQL_SCALAR_FUNCTION', 'SQL_TABLE_VALUED_FUNCTION'", functionName); + private void exportFunction(Connection connection, String schemaName, String functionName, + AsyncContext asyncContext) { + String sql = String.format(ROUTINES_DDL_SQL, + "'SQL_SCALAR_FUNCTION', 'SQL_TABLE_VALUED_FUNCTION'", + SqlServerIdentifierProcessor.INSTANCE.escapeString(functionName), + SqlServerIdentifierProcessor.INSTANCE.escapeString(schemaName)); DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { if (resultSet.next()) { StringBuilder sqlBuilder = new StringBuilder(); - sqlBuilder.append(String.format(DROP_FUNCTION_SQL, functionName, functionName)); + String qualifiedName = buildFullTableName(null, schemaName, functionName); + sqlBuilder.append(String.format(DROP_FUNCTION_SQL, + SqlServerIdentifierProcessor.INSTANCE.escapeString(qualifiedName), qualifiedName)); sqlBuilder.append(resultSet.getString("definition")) .append("\n").append("go").append("\n"); asyncContext.write(sqlBuilder.toString()); @@ -141,17 +149,22 @@ private void exportProcedures(Connection connection, String schemaName, AsyncCon DefaultSQLExecutor.getInstance().preExecute(connection, ROUTINES_SQL, new String[]{"P", schemaName}, resultSet -> { while (resultSet.next()) { String procedureName = resultSet.getString("name"); - exportProcedure(connection, procedureName, asyncContext); + exportProcedure(connection, schemaName, procedureName, asyncContext); } }); } - private void exportProcedure(Connection connection, String procedureName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(ROUTINES_DDL_SQL, "'SQL_STORED_PROCEDURE'", procedureName); + private void exportProcedure(Connection connection, String schemaName, String procedureName, + AsyncContext asyncContext) throws SQLException { + String sql = String.format(ROUTINES_DDL_SQL, "'SQL_STORED_PROCEDURE'", + SqlServerIdentifierProcessor.INSTANCE.escapeString(procedureName), + SqlServerIdentifierProcessor.INSTANCE.escapeString(schemaName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { if (resultSet.next()) { StringBuilder sqlBuilder = new StringBuilder(); - sqlBuilder.append(String.format(DROP_PROCEDURE_SQL, procedureName, procedureName)); + String qualifiedName = buildFullTableName(null, schemaName, procedureName); + sqlBuilder.append(String.format(DROP_PROCEDURE_SQL, + SqlServerIdentifierProcessor.INSTANCE.escapeString(qualifiedName), qualifiedName)); sqlBuilder.append(resultSet.getString("definition")).append("\n").append("go").append("\n"); asyncContext.write(sqlBuilder.toString()); @@ -172,7 +185,9 @@ private void exportTriggers(Connection connection, String schemaName, AsyncConte @Override public void connectDatabase(Connection connection, String database) { try { - DefaultSQLExecutor.getInstance().execute(connection, String.format(SQL_USE_DATABASE, database)); + DefaultSQLExecutor.getInstance().execute(connection, + String.format(SQL_USE_DATABASE, + SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(database))); } catch (SQLException e) { throw new RuntimeException(e); } @@ -559,8 +574,7 @@ private static String quoteIdentifier(String identifier) { if (StringUtils.isBlank(identifier)) { return identifier; } - String value = unquoteIdentifier(identifier); - return "[" + value.replace("]", "]]") + "]"; + return SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(unquoteIdentifier(identifier)); } static String unquoteIdentifier(String identifier) { @@ -589,6 +603,12 @@ public String dropTable(Connection connection, String databaseName, String schem return String.format(SQL_DROP_TABLE, fullTableName); } + @Override + public String truncateTable(Connection connection, String databaseName, String schemaName, String tableName) { + return String.format(SQL_TRUNCATE_TABLE, + buildFullTableName(databaseName, schemaName, tableName)); + } + @Override public void dropView(Connection connection, String databaseName, String schemaName, String viewName) { String fullTableName = buildFullTableName(databaseName, schemaName, viewName); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java index d4a79ba152..6569f45f35 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java @@ -11,12 +11,10 @@ import ai.chat2db.plugin.sqlserver.enums.SqlServerViewAttributeOptionEnum; import ai.chat2db.plugin.sqlserver.enums.SqlServerViewCheckOptionEnum; import ai.chat2db.plugin.sqlserver.identifier.SqlServerIdentifierProcessor; -import ai.chat2db.plugin.sqlserver.identifier.SqlServerIdentifierUtils; import ai.chat2db.plugin.sqlserver.enums.type.SqlServerColumnTypeEnum; import ai.chat2db.plugin.sqlserver.enums.type.SqlServerDefaultValueEnum; import ai.chat2db.plugin.sqlserver.enums.type.SqlServerIndexTypeEnum; import ai.chat2db.plugin.sqlserver.value.SqlServerValueProcessor; -import ai.chat2db.community.tools.util.EasyStringUtils; import ai.chat2db.community.tools.util.I18nUtils; import ai.chat2db.spi.*; import ai.chat2db.spi.DefaultMetaService; @@ -46,7 +44,6 @@ import java.util.stream.Collectors; import static ai.chat2db.plugin.sqlserver.constant.SQLConstant.*; -import static ai.chat2db.plugin.sqlserver.identifier.SqlServerIdentifierUtils.quoteIdentifierPart; import static ai.chat2db.spi.util.SortUtils.sortDatabase; import static ai.chat2db.plugin.sqlserver.constant.SqlServerMetaDataConstants.*; @@ -60,8 +57,6 @@ public class SqlServerMetaData extends DefaultMetaService implements IDbMetaData - private static final ISQLIdentifierProcessor SQL_SERVER_IDENTIFIER_PROCESSOR = new SqlServerIdentifierProcessor(); - @Override public List databases(Connection connection) { List databases = DefaultSQLExecutor.getInstance().databases(connection); @@ -76,7 +71,7 @@ public List schemas(Connection connection, String databaseName) { private String format(String objectName) { - return quoteIdentifierPart(objectName); + return SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(objectName); } @@ -87,7 +82,8 @@ public String tableDDL(Connection connection, String databaseName, String schema List tempList = new ArrayList<>(); String formatSchemaName = format(schemaName); String formatTableName = format(tableName); - ddlBuilder.append(SQL_CREATE_TABLE).append(" ").append(formatTableName).append("\n"); + ddlBuilder.append(SQL_CREATE_TABLE).append(" ") + .append(getMetaDataName(schemaName, tableName)).append("\n"); ddlBuilder.append("(\n"); List tableColumnList = DefaultSQLExecutor.getInstance().preExecute(connection, SELECT_TABLE_COLUMNS, new String[]{schemaName, tableName}, resultSet -> { List columns = new ArrayList<>(); @@ -98,7 +94,7 @@ public String tableDDL(Connection connection, String databaseName, String schema tableColumn.setName(resultSet.getString("COLUMN_NAME")); String computedDefinition = resultSet.getString("COMPUTED_DEFINITION"); boolean isPersisted = resultSet.getBoolean("IS_PERSISTED"); - String dataType = resultSet.getString("DATA_TYPE").toUpperCase(); + String dataType = resultSet.getString("DATA_TYPE").toUpperCase(Locale.ROOT); boolean isIdentity = resultSet.getBoolean("IS_IDENTITY"); BigDecimal seedValue = resultSet.getBigDecimal("SEED_VALUE"); BigDecimal incrementValue = resultSet.getBigDecimal("INCREMENT_VALUE"); @@ -130,7 +126,7 @@ public String tableDDL(Connection connection, String databaseName, String schema tempList.clear(); return columns; }); - Set PKUQConstraintNameSet = DefaultSQLExecutor.getInstance().execute(connection, String.format(PK_UQ_CONSTRAINT_SQL, EasyStringUtils.escapeString(formatSchemaName), EasyStringUtils.escapeString(formatTableName)), resultSet -> { + Set PKUQConstraintNameSet = DefaultSQLExecutor.getInstance().execute(connection, String.format(PK_UQ_CONSTRAINT_SQL, SqlServerIdentifierProcessor.INSTANCE.escapeString(formatSchemaName), SqlServerIdentifierProcessor.INSTANCE.escapeString(formatTableName)), resultSet -> { Map> PKConstraintsMap = new HashMap<>(1); Map> UQConstraintsMap = new HashMap<>(3); HashMap clusteredMap = new HashMap<>(4); @@ -143,7 +139,7 @@ public String tableDDL(Connection connection, String databaseName, String schema if (StringUtils.isNotBlank(indexType)) { clusteredMap.computeIfAbsent(constraintName, k -> indexType); } - columnName = quoteIdentifierPart(columnName) + (isDesc ? " desc" : " asc"); + columnName = SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(columnName) + (isDesc ? " desc" : " asc"); if ("PK".equals(constraintType)) { PKConstraintsMap.computeIfAbsent(constraintName, k -> new ArrayList<>()).add(columnName); } else if ("UQ".equals(constraintType)) { @@ -155,11 +151,11 @@ public String tableDDL(Connection connection, String databaseName, String schema if (MapUtils.isNotEmpty(PKConstraintsMap)) { PKConstraintsMap.forEach((key, value) -> { tempBuilder.append("constraint ") - .append(quoteIdentifierPart(key)) + .append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(key)) .append("\n") .append("primary key "); if (clusteredMap.containsKey(key)) { - tempBuilder.append(" ").append(clusteredMap.get(key).toLowerCase()).append(" "); + tempBuilder.append(" ").append(clusteredMap.get(key).toLowerCase(Locale.ROOT)).append(" "); } tempBuilder.append("(") .append(String.join(" , ", value)) @@ -171,11 +167,11 @@ public String tableDDL(Connection connection, String databaseName, String schema if (MapUtils.isNotEmpty(UQConstraintsMap)) { UQConstraintsMap.forEach((key, value) -> { tempBuilder.append("constraint ") - .append(quoteIdentifierPart(key)) + .append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(key)) .append("\n") .append("unique "); if (clusteredMap.containsKey(key)) { - tempBuilder.append(" ").append(clusteredMap.get(key).toLowerCase()).append(" "); + tempBuilder.append(" ").append(clusteredMap.get(key).toLowerCase(Locale.ROOT)).append(" "); } tempBuilder.append("(") .append(String.join(" , ", value)) @@ -197,8 +193,8 @@ public String tableDDL(Connection connection, String databaseName, String schema }); DefaultSQLExecutor.getInstance().execute(connection, String.format(CHECK_CONSTRAINT_SQL, - EasyStringUtils.escapeString(formatSchemaName), - EasyStringUtils.escapeString(formatTableName)), resultSet -> { + SqlServerIdentifierProcessor.INSTANCE.escapeString(formatSchemaName), + SqlServerIdentifierProcessor.INSTANCE.escapeString(formatTableName)), resultSet -> { boolean isFirst = true; while (resultSet.next()) { @@ -211,7 +207,7 @@ public String tableDDL(Connection connection, String databaseName, String schema ddlBuilder.append(",\n"); isFirst = false; } - tempBuilder.append("constraint ").append(quoteIdentifierPart(constraintName)).append("\n") + tempBuilder.append("constraint ").append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(constraintName)).append("\n") .append("check ").append(constraintDefinition); tempList.add(tempBuilder.toString()); tempBuilder.setLength(0); @@ -378,18 +374,18 @@ static String buildForeignKeyDefinition(String constraintName, List colu String referencedSchemaName, String referencedTableName, List referencedColumnNames, int updateAction, int deleteAction) { - String referencedTable = quoteIdentifierPart(referencedTableName); + String referencedTable = SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(referencedTableName); if (StringUtils.isNotBlank(referencedSchemaName)) { - referencedTable = quoteIdentifierPart(referencedSchemaName) + "." + referencedTable; + referencedTable = SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(referencedSchemaName) + "." + referencedTable; } - return "constraint " + quoteIdentifierPart(constraintName) + "\n" + return "constraint " + SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(constraintName) + "\n" + "foreign key (" + quoteIdentifierList(columnNames) + ")\n" + "references " + referencedTable + " (" + quoteIdentifierList(referencedColumnNames) + ")" + buildReferentialActions(updateAction, deleteAction); } private static String quoteIdentifierList(List identifiers) { - return identifiers.stream().map(SqlServerIdentifierUtils::quoteIdentifierPart) + return identifiers.stream().map(SqlServerIdentifierProcessor.INSTANCE::quoteIdentifierAlways) .collect(Collectors.joining(" , ")); } @@ -469,9 +465,9 @@ static boolean shouldOmitColumnSize(String columnType) { @Override public List tables(Connection connection, String databaseName, String schemaName, String tableName) { List
tables = new ArrayList<>(); - String sql = String.format(SELECT_TABLES_SQL, schemaName); + String sql = String.format(SELECT_TABLES_SQL, getSQLIdentifierProcessor().escapeString(schemaName)); if (StringUtils.isNotBlank(tableName)) { - sql += " AND t.name = '" + tableName + "'"; + sql += " AND t.name = '" + getSQLIdentifierProcessor().escapeString(tableName) + "'"; } else { sql += " ORDER BY t.name"; } @@ -500,7 +496,7 @@ public List columns(Connection connection, String databaseName, Str column.setSchemaName(schemaName); column.setOldName(resultSet.getString("COLUMN_NAME")); column.setName(resultSet.getString("COLUMN_NAME")); - String dataType = resultSet.getString("DATA_TYPE").toUpperCase(); + String dataType = resultSet.getString("DATA_TYPE").toUpperCase(Locale.ROOT); column.setColumnType(SqlUtils.removeDigits(dataType)); column.setDefaultValue(resultSet.getString("COLUMN_DEFAULT")); column.setComment(resultSet.getString("COLUMN_COMMENT")); @@ -540,7 +536,8 @@ public Function function(Connection connection, @NotEmpty String databaseName, S String sql = String.format( ROUTINES_DDL_SQL, "'SQL_SCALAR_FUNCTION', 'SQL_INLINE_TABLE_VALUED_FUNCTION', 'SQL_TABLE_VALUED_FUNCTION'", - functionName + getSQLIdentifierProcessor().escapeString(functionName), + getSQLIdentifierProcessor().escapeString(schemaName) ); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Function function = new Function(); @@ -639,7 +636,9 @@ public Trigger trigger(Connection connection, @NotEmpty String databaseName, Str @Override public Procedure procedure(Connection connection, @NotEmpty String databaseName, String schemaName, String procedureName) { - String sql = String.format(ROUTINES_DDL_SQL, "'SQL_STORED_PROCEDURE'", procedureName); + String sql = String.format(ROUTINES_DDL_SQL, "'SQL_STORED_PROCEDURE'", + getSQLIdentifierProcessor().escapeString(procedureName), + getSQLIdentifierProcessor().escapeString(schemaName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Procedure procedure = new Procedure(); procedure.setDatabaseName(databaseName); @@ -762,13 +761,13 @@ public IValueProcessor getValueProcessor() { @Override public ISQLIdentifierProcessor getSQLIdentifierProcessor() { - return SQL_SERVER_IDENTIFIER_PROCESSOR; + return SqlServerIdentifierProcessor.INSTANCE; } @Override public String getMetaDataName(String... names) { return Arrays.stream(names).filter(StringUtils::isNotBlank) - .map(SqlServerIdentifierUtils::quoteIdentifierPart).collect(Collectors.joining(".")); + .map(SqlServerIdentifierProcessor.INSTANCE::quoteIdentifierAlways).collect(Collectors.joining(".")); } @Override @@ -824,7 +823,7 @@ public ModifyViewConfiguration viewMeta(String databaseName, String schemaName) StringBuilder sqlBuilder = new StringBuilder(100); sqlBuilder.append(SQL_CREATE).append("view "); if (StringUtils.isNotBlank(schemaName)) { - sqlBuilder.append("[").append(schemaName).append("]").append("."); + sqlBuilder.append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(schemaName)).append("."); } sqlBuilder.append("[").append("undefined").append("]"); sqlBuilder.append(" AS \n").append(sql).append(";"); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerSqlGuards.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerSqlGuards.java new file mode 100644 index 0000000000..c2775f15ef --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerSqlGuards.java @@ -0,0 +1,318 @@ +package ai.chat2db.plugin.sqlserver; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; + +import ai.chat2db.plugin.sqlserver.identifier.SqlServerIdentifierProcessor; +import org.apache.commons.lang3.StringUtils; + +/** + * Validation helpers for non-escapable SQL positions in SQL Server DDL + * generation (collation names embedded as bare tokens). + * Escaping itself lives in {@link SqlServerIdentifierProcessor}. + */ +public final class SqlServerSqlGuards { + + /** + * Conservative allow-list for collation names reported by JDBC metadata or + * user input before they are embedded as bare tokens in generated DDL. + */ + private static final Pattern COLLATION_NAME_PATTERN = Pattern.compile("[A-Za-z0-9_]+"); + private static final Set MULTIWORD_TYPE_NAMES = Set.of( + "BINARY VARYING", + "CHAR VARYING", + "CHARACTER VARYING", + "DOUBLE PRECISION", + "NATIONAL CHAR", + "NATIONAL CHAR VARYING", + "NATIONAL CHARACTER", + "NATIONAL CHARACTER VARYING"); + private static final Set COLUMN_CLAUSE_KEYWORDS = Set.of( + "CHECK", "CONSTRAINT", "DEFAULT", "ENCRYPTED", "IDENTITY", "MASKED", + "PERSISTED", "REFERENCES", "ROWGUIDCOL", "SPARSE", "UNIQUE"); + + private SqlServerSqlGuards() { + } + + /** + * Validates a collation name before it is embedded into generated DDL. + * Returns the collation unchanged when it matches the allow-list; throws + * otherwise (fail closed). + */ + public static String validateCollation(String collation) { + if (collation == null || !COLLATION_NAME_PATTERN.matcher(collation).matches()) { + throw new IllegalArgumentException("Invalid SQL Server collation name: " + collation); + } + return collation; + } + + /** + * Accepts a SQL Server built-in or schema-qualified user-defined type, + * with an optional balanced argument list such as {@code decimal(18, 2)}, + * {@code nvarchar(max)}, or {@code xml(CONTENT [dbo].[Collection])}. + */ + public static String requireColumnTypeExpression(String columnType) { + if (StringUtils.isBlank(columnType)) { + throw invalid("column type", columnType); + } + String expression = columnType.trim(); + int argumentsStart = findArgumentsStart(expression); + String typeName = argumentsStart < 0 ? expression : expression.substring(0, argumentsStart).trim(); + if (!isTypeName(typeName)) { + throw invalid("column type", columnType); + } + if (argumentsStart < 0) { + return expression; + } + int argumentsEnd = scanExpression(expression, argumentsStart, true, false, "column type"); + if (skipWhitespace(expression, argumentsEnd + 1) != expression.length()) { + throw invalid("column type", columnType); + } + return expression; + } + + /** + * Validates one DEFAULT expression without rewriting string literals or + * metadata-serialized function calls. + */ + public static String requireDefaultExpression(String defaultValue) { + if (StringUtils.isBlank(defaultValue)) { + throw invalid("default expression", defaultValue); + } + String expression = defaultValue.trim(); + scanExpression(expression, 0, false, true, "default expression"); + return expression; + } + + private static int findArgumentsStart(String expression) { + boolean inBracketIdentifier = false; + boolean inQuotedIdentifier = false; + for (int i = 0; i < expression.length(); i++) { + char current = expression.charAt(i); + char next = i + 1 < expression.length() ? expression.charAt(i + 1) : '\0'; + if (inBracketIdentifier) { + if (current == ']' && next == ']') { + i++; + } else if (current == ']') { + inBracketIdentifier = false; + } + continue; + } + if (inQuotedIdentifier) { + if (current == '"' && next == '"') { + i++; + } else if (current == '"') { + inQuotedIdentifier = false; + } + continue; + } + if (current == '[') { + inBracketIdentifier = true; + } else if (current == '"') { + inQuotedIdentifier = true; + } else if (current == '(') { + return i; + } + } + if (inBracketIdentifier || inQuotedIdentifier) { + throw invalid("column type", expression); + } + return -1; + } + + private static boolean isTypeName(String typeName) { + String normalized = StringUtils.normalizeSpace(typeName).toUpperCase(Locale.ROOT); + if (MULTIWORD_TYPE_NAMES.contains(normalized)) { + return true; + } + int offset = 0; + while (offset < typeName.length()) { + offset = skipWhitespace(typeName, offset); + if (offset >= typeName.length()) { + return false; + } + char first = typeName.charAt(offset); + if (first == '[' || first == '"') { + char close = first == '[' ? ']' : '"'; + boolean closed = false; + offset++; + while (offset < typeName.length()) { + char current = typeName.charAt(offset); + if (current == close) { + if (offset + 1 < typeName.length() && typeName.charAt(offset + 1) == close) { + offset += 2; + continue; + } + offset++; + closed = true; + break; + } + offset++; + } + if (!closed) { + return false; + } + } else { + if (!(Character.isLetter(first) || first == '_' || first == '#' || first == '@')) { + return false; + } + offset++; + while (offset < typeName.length()) { + char current = typeName.charAt(offset); + if (!(Character.isLetterOrDigit(current) || current == '_' || current == '$' + || current == '#' || current == '@')) { + break; + } + offset++; + } + } + offset = skipWhitespace(typeName, offset); + if (offset == typeName.length()) { + return true; + } + if (typeName.charAt(offset) != '.') { + return false; + } + offset++; + } + return false; + } + + private static int scanExpression(String expression, int start, boolean stopAtRootClose, + boolean rejectTopLevelComma, String description) { + Deque delimiters = new ArrayDeque<>(); + List topLevelWords = new ArrayList<>(); + boolean inString = false; + boolean inBracketIdentifier = false; + boolean inQuotedIdentifier = false; + for (int i = start; i < expression.length(); i++) { + char current = expression.charAt(i); + char next = i + 1 < expression.length() ? expression.charAt(i + 1) : '\0'; + if (inString) { + if (current == '\'' && next == '\'') { + i++; + } else if (current == '\'') { + inString = false; + } + continue; + } + if (inBracketIdentifier) { + if (current == ']' && next == ']') { + i++; + } else if (current == ']') { + inBracketIdentifier = false; + } + continue; + } + if (inQuotedIdentifier) { + if (current == '"' && next == '"') { + i++; + } else if (current == '"') { + inQuotedIdentifier = false; + } + continue; + } + + if (current == '\'') { + inString = true; + continue; + } + if (current == '[') { + inBracketIdentifier = true; + continue; + } + if (current == '"') { + inQuotedIdentifier = true; + continue; + } + if (current == ';' || current == '\n' || current == '\r' + || startsWith(expression, i, "--") + || startsWith(expression, i, "/*") + || startsWith(expression, i, "*/")) { + throw invalid(description, expression); + } + if (current == '(' || current == '{') { + delimiters.push(current); + continue; + } + if (current == ')' || current == '}') { + if (delimiters.isEmpty() || !matches(delimiters.pop(), current)) { + throw invalid(description, expression); + } + if (stopAtRootClose && delimiters.isEmpty()) { + return i; + } + continue; + } + if (rejectTopLevelComma && current == ',' && delimiters.isEmpty()) { + throw invalid(description, expression); + } + if (Character.isISOControl(current)) { + throw invalid(description, expression); + } + if (rejectTopLevelComma && delimiters.isEmpty() + && (Character.isLetter(current) || current == '_')) { + int wordEnd = i + 1; + while (wordEnd < expression.length() && isWordCharacter(expression.charAt(wordEnd))) { + wordEnd++; + } + topLevelWords.add(expression.substring(i, wordEnd).toUpperCase(Locale.ROOT)); + i = wordEnd - 1; + } + } + if (inString || inBracketIdentifier || inQuotedIdentifier || !delimiters.isEmpty() || stopAtRootClose) { + throw invalid(description, expression); + } + rejectColumnClauseTokens(topLevelWords, description, expression); + return expression.length(); + } + + private static void rejectColumnClauseTokens(List words, String description, String expression) { + for (String word : words) { + if (COLUMN_CLAUSE_KEYWORDS.contains(word)) { + throw invalid(description, expression); + } + } + for (int i = 0; i + 1 < words.size(); i++) { + String first = words.get(i); + String second = words.get(i + 1); + if (("NOT".equals(first) && "NULL".equals(second)) + || ("PRIMARY".equals(first) && "KEY".equals(second)) + || ("GENERATED".equals(first) && "ALWAYS".equals(second)) + || ("WITH".equals(first) && "VALUES".equals(second)) + || ("FOR".equals(first) && "REPLICATION".equals(second))) { + throw invalid(description, expression); + } + } + } + + private static boolean isWordCharacter(char value) { + return Character.isLetterOrDigit(value) || value == '_' || value == '$' + || value == '#' || value == '@'; + } + + private static int skipWhitespace(String value, int offset) { + int current = offset; + while (current < value.length() && Character.isWhitespace(value.charAt(current))) { + current++; + } + return current; + } + + private static boolean startsWith(String value, int offset, String candidate) { + return offset + candidate.length() <= value.length() && value.startsWith(candidate, offset); + } + + private static boolean matches(char open, char close) { + return open == '(' && close == ')' || open == '{' && close == '}'; + } + + private static IllegalArgumentException invalid(String description, String value) { + return new IllegalArgumentException("Invalid SQL Server " + description + ": " + value); + } +} diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/builder/SqlServerSqlBuilder.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/builder/SqlServerSqlBuilder.java index a04048f6ae..4da82830e5 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/builder/SqlServerSqlBuilder.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/builder/SqlServerSqlBuilder.java @@ -2,10 +2,15 @@ import ai.chat2db.spi.constant.SQLConstants; +import ai.chat2db.plugin.sqlserver.enums.SqlServerViewAttributeOptionEnum; import ai.chat2db.plugin.sqlserver.enums.type.SqlServerColumnTypeEnum; import ai.chat2db.plugin.sqlserver.enums.type.SqlServerIndexTypeEnum; +import ai.chat2db.plugin.sqlserver.SqlServerSqlGuards; +import ai.chat2db.plugin.sqlserver.identifier.SqlServerIdentifierProcessor; 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.enums.plugin.DmlTypeEnum; import ai.chat2db.community.domain.api.model.account.*; import ai.chat2db.community.domain.api.model.async.*; import ai.chat2db.community.domain.api.config.*; @@ -19,16 +24,30 @@ import ai.chat2db.community.domain.api.config.TableBuilderConfig; import ai.chat2db.spi.sql.Chat2DBContext; import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.collections4.MapUtils; import org.apache.commons.lang3.StringUtils; +import java.util.Arrays; import java.util.List; +import java.util.Locale; import java.util.stream.Collectors; -import static ai.chat2db.plugin.sqlserver.identifier.SqlServerIdentifierUtils.escapeStringLiteral; -import static ai.chat2db.plugin.sqlserver.identifier.SqlServerIdentifierUtils.quoteIdentifierPart; import static ai.chat2db.plugin.sqlserver.constant.SqlServerSqlBuilderConstants.*; public class SqlServerSqlBuilder extends DefaultSqlBuilder { + @Override + public String quoteIdentifier(String identifier) { + return SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(identifier); + } + + @Override + public String quoteQualifiedIdentifier(String... identifiers) { + return Arrays.stream(identifiers) + .filter(StringUtils::isNotBlank) + .map(SqlServerIdentifierProcessor.INSTANCE::quoteIdentifierAlways) + .collect(Collectors.joining(SQLConstants.DOT)); + } + @@ -73,10 +92,10 @@ public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfi script.append(SQL_CREATE_TABLE); Boolean needFullTableName = tableBuilderConfig.getNeedFullTableName(); - if (Boolean.TRUE.equals(needFullTableName)) { - script.append(SQLConstants.OPEN_SQUARE_BRACKET).append(table.getDatabaseName()).append(SQLConstants.CLOSE_SQUARE_BRACKET).append(SQLConstants.DOT); - } - script.append(SQLConstants.OPEN_SQUARE_BRACKET).append(table.getSchemaName()).append(SQLConstants.OPEN_SQUARE_BRACKET_DOT_OPEN_SQUARE_BRACKET).append(table.getName()).append(VALUE_CLOSE_BRACKET_OPEN_PAREN).append(SQLConstants.LINE_SEPARATOR); + script.append(Boolean.TRUE.equals(needFullTableName) + ? quoteQualifiedIdentifier(table.getDatabaseName(), table.getSchemaName(), table.getName()) + : quoteQualifiedIdentifier(table.getSchemaName(), table.getName())) + .append(SQLConstants.SPACE_OPEN_PARENTHESIS).append(SQLConstants.LINE_SEPARATOR); for (TableColumn column : table.getColumnList()) { if (StringUtils.isBlank(column.getName()) || StringUtils.isBlank(column.getColumnType())) { @@ -125,8 +144,8 @@ public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfi public String buildAITableSchema(Table table) { StringBuilder script = new StringBuilder(); script.append(SQL_CREATE_TABLE); - script.append(SQLConstants.OPEN_SQUARE_BRACKET).append(table.getDatabaseName()).append(SQLConstants.CLOSE_SQUARE_BRACKET).append(SQLConstants.DOT); - script.append(SQLConstants.OPEN_SQUARE_BRACKET).append(table.getSchemaName()).append(SQLConstants.OPEN_SQUARE_BRACKET_DOT_OPEN_SQUARE_BRACKET).append(table.getName()).append(VALUE_CLOSE_BRACKET_OPEN_PAREN).append(SQLConstants.LINE_SEPARATOR); + script.append(quoteQualifiedIdentifier(table.getDatabaseName(), table.getSchemaName(), table.getName())) + .append(SQLConstants.SPACE_OPEN_PARENTHESIS).append(SQLConstants.LINE_SEPARATOR); for (TableColumn column : table.getColumnList()) { if (StringUtils.isBlank(column.getName()) || StringUtils.isBlank(column.getColumnType())) { @@ -169,20 +188,20 @@ public String buildAITableSchema(Table table) { private String buildIndexComment(TableIndex tableIndex) { - return String.format(INDEX_COMMENT_SCRIPT, tableIndex.getComment(), tableIndex.getSchemaName(), tableIndex.getTableName(), tableIndex.getName()); + return String.format(INDEX_COMMENT_SCRIPT, SqlServerIdentifierProcessor.INSTANCE.escapeString(tableIndex.getComment()), SqlServerIdentifierProcessor.INSTANCE.escapeString(tableIndex.getSchemaName()), SqlServerIdentifierProcessor.INSTANCE.escapeString(tableIndex.getTableName()), SqlServerIdentifierProcessor.INSTANCE.escapeString(tableIndex.getName())); } private String buildTableComment(Table table) { - return String.format(TABLE_COMMENT_SCRIPT, table.getComment(), table.getSchemaName(), table.getName()); + return String.format(TABLE_COMMENT_SCRIPT, SqlServerIdentifierProcessor.INSTANCE.escapeString(table.getComment()), SqlServerIdentifierProcessor.INSTANCE.escapeString(table.getSchemaName()), SqlServerIdentifierProcessor.INSTANCE.escapeString(table.getName())); } private String buildColumnComment(TableColumn column) { - return String.format(COLUMN_COMMENT_SCRIPT, column.getComment(), column.getSchemaName(), column.getTableName(), column.getName()); + return String.format(COLUMN_COMMENT_SCRIPT, SqlServerIdentifierProcessor.INSTANCE.escapeString(column.getComment()), SqlServerIdentifierProcessor.INSTANCE.escapeString(column.getSchemaName()), SqlServerIdentifierProcessor.INSTANCE.escapeString(column.getTableName()), SqlServerIdentifierProcessor.INSTANCE.escapeString(column.getName())); } @Override @@ -227,13 +246,16 @@ public String buildAlterTable(Table oldTable, Table newTable) { private String buildUpdateTableComment(Table newTable) { - return String.format(UPDATE_TABLE_COMMENT_SCRIPT, newTable.getComment(), newTable.getSchemaName(), newTable.getName()); + return String.format(UPDATE_TABLE_COMMENT_SCRIPT, SqlServerIdentifierProcessor.INSTANCE.escapeString(newTable.getComment()), SqlServerIdentifierProcessor.INSTANCE.escapeString(newTable.getSchemaName()), SqlServerIdentifierProcessor.INSTANCE.escapeString(newTable.getName())); } private String buildRenameTable(Table oldTable, Table newTable) { - return String.format(RENAME_TABLE_SCRIPT, oldTable.getName(), newTable.getName()); + return String.format(RENAME_TABLE_SCRIPT, + SqlServerIdentifierProcessor.INSTANCE.escapeString( + quoteQualifiedIdentifier(oldTable.getSchemaName(), oldTable.getName())), + SqlServerIdentifierProcessor.INSTANCE.escapeString(newTable.getName())); } @Override @@ -248,7 +270,7 @@ public String buildPageLimit(PageLimitRequest request) { if (versions.length > 0 && Integer.parseInt(versions[0]) >= 11) { StringBuilder sqlBuilder = new StringBuilder(sql.length() + 14); sqlBuilder.append(sql); - if (!sql.toLowerCase().contains(ORDER_BY_KEYWORD_LOWER)) { + if (!sql.toLowerCase(Locale.ROOT).contains(ORDER_BY_KEYWORD_LOWER)) { sqlBuilder.append(SQL_ORDER_BY_OPEN_PAREN_SELECT_NULL_CLOSE_PAREN); } sqlBuilder.append(SQLConstants.LINE_SEPARATOR_OFFSET_SQL); @@ -277,31 +299,25 @@ public String buildPageLimit(PageLimitRequest request) { @Override public String buildCreateDatabase(Database database) { StringBuilder sqlBuilder = new StringBuilder(); - sqlBuilder.append(SQL_CREATE_DATABASE + database.getName() + SQLConstants.CLOSE_SQUARE_BRACKET); + sqlBuilder.append("CREATE DATABASE ").append(quoteIdentifier(database.getName())); if (StringUtils.isNotBlank(database.getCollation())) { - sqlBuilder.append(SQL_COLLATE).append(database.getCollation()); + sqlBuilder.append(SQL_COLLATE).append(SqlServerSqlGuards.validateCollation(database.getCollation())); } sqlBuilder.append(VALUE_GO); if (StringUtils.isNotBlank(database.getComment())) { - sqlBuilder.append(SQL_EXEC_OPEN_BRACKET + database.getName() + VALUE_CLOSE_BRACKET_DOT_SYS_DOT_SP_ADDEXTENDEDPROPERTY_SINGLE_QUOTE_MS_DESCRIPTION) - .append(database.getComment()).append(SQLConstants.SINGLE_QUOTE).append(VALUE_GO); + sqlBuilder.append("exec ").append(quoteIdentifier(database.getName())) + .append(".sys.sp_addextendedproperty 'MS_Description','") + .append(SqlServerIdentifierProcessor.INSTANCE.escapeString(database.getComment())).append(SQLConstants.SINGLE_QUOTE).append(VALUE_GO); } return sqlBuilder.toString(); } @Override protected void buildTableName(String databaseName, String schemaName, String tableName, StringBuilder script) { - if (StringUtils.isNotBlank(databaseName)) { - script.append(SQLConstants.OPEN_SQUARE_BRACKET).append(databaseName).append(SQLConstants.CLOSE_SQUARE_BRACKET).append('.'); - } - if (StringUtils.isNotBlank(schemaName)) { - script.append(SQLConstants.OPEN_SQUARE_BRACKET).append(schemaName).append(SQLConstants.CLOSE_SQUARE_BRACKET).append('.'); - } - if (!(tableName.startsWith(SQLConstants.OPEN_SQUARE_BRACKET) && tableName.endsWith(SQLConstants.CLOSE_SQUARE_BRACKET))) { - script.append(SQLConstants.OPEN_SQUARE_BRACKET).append(tableName).append(SQLConstants.CLOSE_SQUARE_BRACKET); - } else { - script.append(tableName); - } + String normalizedTableName = SqlServerIdentifierProcessor.INSTANCE.isQuoteIdentifier(tableName) + ? SqlServerIdentifierProcessor.INSTANCE.removeIdentifierQuote(tableName) + : tableName; + script.append(quoteQualifiedIdentifier(databaseName, schemaName, normalizedTableName)); } @@ -309,7 +325,7 @@ protected void buildTableName(String databaseName, String schemaName, String tab protected void buildColumns(List columnList, StringBuilder script) { if (CollectionUtils.isNotEmpty(columnList)) { script.append(SQLConstants.SPACE_OPEN_PARENTHESIS) - .append(columnList.stream().map(s -> SQLConstants.OPEN_SQUARE_BRACKET + s + SQLConstants.CLOSE_SQUARE_BRACKET).collect(Collectors.joining(SQLConstants.COMMA))) + .append(columnList.stream().map(SqlServerIdentifierProcessor.INSTANCE::quoteIdentifierAlways).collect(Collectors.joining(SQLConstants.COMMA))) .append(SQLConstants.CLOSE_PARENTHESIS_SPACE); } } @@ -317,15 +333,64 @@ protected void buildColumns(List columnList, StringBuilder script) { @Override public String buildCreateSchema(Schema schema) { StringBuilder sqlBuilder = new StringBuilder(); - sqlBuilder.append(SQL_CREATE_SCHEMA + schema.getName() + VALUE_CLOSE_BRACKET_GO); + sqlBuilder.append("CREATE SCHEMA ").append(quoteIdentifier(schema.getName())).append(VALUE_GO); if (StringUtils.isNotBlank(schema.getComment())) { sqlBuilder.append(SQL_EXEC_SP_ADDEXTENDEDPROPERTY_SINGLE_QUOTE_MS_DESCRIPTION_SINGLE_QUOTE_COMMA_SINGLE_QUOTE) - .append(schema.getComment()).append(SQLConstants.SINGLE_QUOTE).append(SQL_COMMA_SINGLE_QUOTE_SCHEMA_SINGLE_QUOTE) - .append(VALUE_COMMA_SINGLE_QUOTE).append(schema.getName()).append(SQLConstants.SINGLE_QUOTE).append(VALUE_GO); + .append(SqlServerIdentifierProcessor.INSTANCE.escapeString(schema.getComment())).append(SQLConstants.SINGLE_QUOTE).append(SQL_COMMA_SINGLE_QUOTE_SCHEMA_SINGLE_QUOTE) + .append(VALUE_COMMA_SINGLE_QUOTE).append(SqlServerIdentifierProcessor.INSTANCE.escapeString(schema.getName())).append(SQLConstants.SINGLE_QUOTE).append(VALUE_GO); } return sqlBuilder.toString(); } + @Override + public String buildUpdate(UpdateSqlRequest request) { + StringBuilder script = new StringBuilder(SQLConstants.UPDATE_SQL_PREFIX); + buildTableName(request.getDatabaseName(), request.getSchemaName(), request.getTableName(), script); + script.append(SQLConstants.SET_SQL); + List setClauses = request.getRow().entrySet().stream() + .map(entry -> quoteIdentifier(entry.getKey()) + SQLConstants.EQUAL_SQL + entry.getValue()) + .collect(Collectors.toList()); + script.append(String.join(SQLConstants.COMMA, setClauses)); + if (MapUtils.isNotEmpty(request.getPrimaryKeyMap())) { + script.append(SQLConstants.WHERE_SQL); + List whereClauses = request.getPrimaryKeyMap().entrySet().stream() + .map(entry -> quoteIdentifier(entry.getKey()) + SQLConstants.EQUAL_SQL + entry.getValue()) + .collect(Collectors.toList()); + script.append(String.join(SQLConstants.SQL_AND, whereClauses)); + } + return script.toString(); + } + + @Override + public String buildTemplate(Table table, String type) { + if (table == null || CollectionUtils.isEmpty(table.getColumnList()) || StringUtils.isBlank(type)) { + return SQLConstants.EMPTY; + } + String tableName = quoteQualifiedIdentifier( + table.getDatabaseName(), table.getSchemaName(), table.getName()); + List columnNames = table.getColumnList().stream() + .map(column -> quoteIdentifier(column.getName())) + .collect(Collectors.toList()); + if (DmlTypeEnum.INSERT.name().equalsIgnoreCase(type)) { + return SQLConstants.INSERT_INTO_SQL_PREFIX + tableName + SQLConstants.SPACE_OPEN_PARENTHESIS + + String.join(SQLConstants.COMMA, columnNames) + + SQLConstants.CLOSE_PARENTHESIS + SQLConstants.VALUES_SQL + SQLConstants.OPEN_PARENTHESIS + + columnNames.stream().map(name -> SQLConstants.SPACE) + .collect(Collectors.joining(SQLConstants.COMMA)) + + SQLConstants.CLOSE_PARENTHESIS; + } + if (DmlTypeEnum.UPDATE.name().equalsIgnoreCase(type)) { + String setClause = columnNames.stream() + .map(name -> name + SQLConstants.EQUAL_SQL + SQLConstants.SPACE) + .collect(Collectors.joining(SQLConstants.COMMA)); + return SQLConstants.UPDATE_SQL_PREFIX + tableName + SQLConstants.SET_SQL + setClause; + } + if (DmlTypeEnum.DELETE.name().equalsIgnoreCase(type)) { + return SQLConstants.DELETE_FROM_SQL_PREFIX + tableName; + } + return SQLConstants.EMPTY; + } + @Override public String buildCreateView(ModifyView modifyView) { StringBuilder createViewSqlBuilder = new StringBuilder(100); @@ -337,15 +402,18 @@ public String buildCreateView(ModifyView modifyView) { List viewAttributes = modifyView.getViewAttributes(); if (CollectionUtils.isNotEmpty(viewAttributes)) { + for (String viewAttribute : viewAttributes) { + validateViewAttribute(viewAttribute); + } createViewSqlBuilder.append(SQLConstants.SQL_WITH).append(String.join(SQLConstants.COMMA, viewAttributes)).append(SQLConstants.SPACE); } String schemaName = modifyView.getSchemaName(); if (StringUtils.isNotBlank(schemaName)) { - createViewSqlBuilder.append(quoteIdentifierPart(schemaName)).append(SQLConstants.DOT); + createViewSqlBuilder.append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(schemaName)).append(SQLConstants.DOT); } String viewName = modifyView.getViewName(); if (StringUtils.isNotBlank(viewName)) { - createViewSqlBuilder.append(quoteIdentifierPart(viewName)); + createViewSqlBuilder.append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(viewName)); } else { createViewSqlBuilder.append(UNDEFINED_KEYWORD); } @@ -362,15 +430,15 @@ public String buildCreateView(ModifyView modifyView) { createViewSqlBuilder.append(SQLConstants.LINE_SEPARATOR); createViewSqlBuilder.append(SQL_EXEC_SP_ADDEXTENDEDPROPERTY_SINGLE_QUOTE_MS_DESCRIPTION_SINGLE_QUOTE_COMMA) .append(SQLConstants.SPACE) - .append(SQLConstants.SINGLE_QUOTE).append(escapeStringLiteral(comment)).append(SQLConstants.SINGLE_QUOTE).append(SQLConstants.COMMA) + .append(SQLConstants.SINGLE_QUOTE).append(SqlServerIdentifierProcessor.INSTANCE.escapeString(comment)).append(SQLConstants.SINGLE_QUOTE).append(SQLConstants.COMMA) .append(SQLConstants.SPACE) .append(SQL_SINGLE_QUOTE_SCHEMA_SINGLE_QUOTE).append(SQLConstants.COMMA) .append(SQLConstants.SPACE) - .append(SQLConstants.SINGLE_QUOTE).append(escapeStringLiteral(schemaName)).append(SQLConstants.SINGLE_QUOTE).append(SQLConstants.COMMA) + .append(SQLConstants.SINGLE_QUOTE).append(SqlServerIdentifierProcessor.INSTANCE.escapeString(schemaName)).append(SQLConstants.SINGLE_QUOTE).append(SQLConstants.COMMA) .append(SQLConstants.SPACE) .append(SQL_SINGLE_QUOTE_VIEW_SINGLE_QUOTE).append(SQLConstants.COMMA) .append(SQLConstants.SPACE) - .append(SQLConstants.SINGLE_QUOTE).append(escapeStringLiteral(viewName)).append(SQLConstants.SINGLE_QUOTE); + .append(SQLConstants.SINGLE_QUOTE).append(SqlServerIdentifierProcessor.INSTANCE.escapeString(viewName)).append(SQLConstants.SINGLE_QUOTE); } @@ -381,4 +449,13 @@ public String buildCreateView(ModifyView modifyView) { public String buildExplain(String sql) { return SQL_SET_SHOWPLAN_XML_ON_SEMICOLON_GO + sql + SQL_GO_SET_SHOWPLAN_XML_OFF_SEMICOLON; } + + private static void validateViewAttribute(String viewAttribute) { + for (SqlServerViewAttributeOptionEnum option : SqlServerViewAttributeOptionEnum.values()) { + if (option.name().equalsIgnoreCase(viewAttribute)) { + return; + } + } + throw new IllegalArgumentException("Invalid SQL Server view attribute: " + viewAttribute); + } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SQLConstant.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SQLConstant.java index 45aea20aab..53757ae5ff 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SQLConstant.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SQLConstant.java @@ -1,6 +1,6 @@ package ai.chat2db.plugin.sqlserver.constant; -import ai.chat2db.community.tools.util.EasyStringUtils; +import ai.chat2db.plugin.sqlserver.identifier.SqlServerIdentifierProcessor; public class SQLConstant { @@ -10,19 +10,19 @@ public class SQLConstant { public static final String CONSTRAINT_COMMENT_TEMPLATE = "exec sp_addextendedproperty 'MS_Description',N'%s','SCHEMA',N'%s','TABLE',N'%s','CONSTRAINT',N'%s' \ngo\n"; public static String buildTableComment(String tableComment, String schemaName, String tableName) { - return String.format(TABLE_COMMENT_TEMPLATE, EasyStringUtils.escapeString(tableComment), schemaName, tableName); + return String.format(TABLE_COMMENT_TEMPLATE, SqlServerIdentifierProcessor.INSTANCE.escapeString(tableComment), SqlServerIdentifierProcessor.INSTANCE.escapeString(schemaName), SqlServerIdentifierProcessor.INSTANCE.escapeString(tableName)); } public static String buildIndexComment(String indexComment, String schemaName, String tableName, String indexName) { - return String.format(INDEX_COMMENT_TEMPLATE, EasyStringUtils.escapeString(indexComment), schemaName, tableName, indexName); + return String.format(INDEX_COMMENT_TEMPLATE, SqlServerIdentifierProcessor.INSTANCE.escapeString(indexComment), SqlServerIdentifierProcessor.INSTANCE.escapeString(schemaName), SqlServerIdentifierProcessor.INSTANCE.escapeString(tableName), SqlServerIdentifierProcessor.INSTANCE.escapeString(indexName)); } public static String buildColumnComment(String columnComment, String schemaName, String tableName, String columnName) { - return String.format(COLUMN_COMMENT_TEMPLATE, EasyStringUtils.escapeString(columnComment), schemaName, tableName, columnName); + return String.format(COLUMN_COMMENT_TEMPLATE, SqlServerIdentifierProcessor.INSTANCE.escapeString(columnComment), SqlServerIdentifierProcessor.INSTANCE.escapeString(schemaName), SqlServerIdentifierProcessor.INSTANCE.escapeString(tableName), SqlServerIdentifierProcessor.INSTANCE.escapeString(columnName)); } public static String buildConstraintComment(String constraintComment, String schemaName, String tableName, String constraintName) { - return String.format(CONSTRAINT_COMMENT_TEMPLATE, EasyStringUtils.escapeString(constraintComment), schemaName, tableName, constraintName); + return String.format(CONSTRAINT_COMMENT_TEMPLATE, SqlServerIdentifierProcessor.INSTANCE.escapeString(constraintComment), SqlServerIdentifierProcessor.INSTANCE.escapeString(schemaName), SqlServerIdentifierProcessor.INSTANCE.escapeString(tableName), SqlServerIdentifierProcessor.INSTANCE.escapeString(constraintName)); } public static final String VIEWS_DDL_SQL = "SELECT TABLE_NAME, VIEW_DEFINITION FROM INFORMATION_SCHEMA.VIEWS " + "WHERE TABLE_SCHEMA = ? AND TABLE_CATALOG = ?; "; @@ -30,17 +30,18 @@ public static String buildConstraintComment(String constraintComment, String sch public static final String ROUTINES_DDL_SQL = "SELECT type_desc, OBJECT_NAME(object_id) AS FunctionName, OBJECT_DEFINITION(object_id) AS " - + "definition FROM sys.objects WHERE type_desc IN(%s) and name = '%s' ;"; + + "definition FROM sys.objects WHERE type_desc IN(%s) and name = '%s' " + + "and schema_id = SCHEMA_ID('%s') ;"; public static final String DROP_FUNCTION_SQL = """ - IF EXISTS (SELECT * FROM sys.all_objects WHERE object_id = OBJECT_ID(N'[%s]') AND type IN ('FN', 'FS', 'FT', 'IF', 'TF')) - DROP FUNCTION [%s] + IF EXISTS (SELECT * FROM sys.all_objects WHERE object_id = OBJECT_ID(N'%s') AND type IN ('FN', 'FS', 'FT', 'IF', 'TF')) + DROP FUNCTION %s GO """; public static final String DROP_PROCEDURE_SQL = """ - IF EXISTS (SELECT * FROM sys.all_objects WHERE object_id = OBJECT_ID(N'[%s]') AND type IN ('P', 'PC', 'RF', 'X')) - DROP PROCEDURE [%s] + IF EXISTS (SELECT * FROM sys.all_objects WHERE object_id = OBJECT_ID(N'%s') AND type IN ('P', 'PC', 'RF', 'X')) + DROP PROCEDURE %s GO """; diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SqlServerDBManagerConstants.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SqlServerDBManagerConstants.java index 46d346a82f..9ded201b8c 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SqlServerDBManagerConstants.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SqlServerDBManagerConstants.java @@ -26,7 +26,8 @@ public final class SqlServerDBManagerConstants { public static final String SQL_SET_IDENTITY_INSERT = "SET IDENTITY_INSERT %s %s"; public static final String SQL_DROP_TABLE = "DROP TABLE %s"; public static final String SQL_DROP_VIEW = "DROP VIEW %s"; - public static final String SQL_USE_DATABASE = "use [%s];"; + public static final String SQL_TRUNCATE_TABLE = "TRUNCATE TABLE %s"; + public static final String SQL_USE_DATABASE = "use %s;"; private SqlServerDBManagerConstants() { } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SqlServerIndexTypeEnumConstants.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SqlServerIndexTypeEnumConstants.java index d1f3baf044..97b4afcc2d 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SqlServerIndexTypeEnumConstants.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SqlServerIndexTypeEnumConstants.java @@ -12,10 +12,10 @@ public final class SqlServerIndexTypeEnumConstants { - public static final String SQL_ALTER_TABLE = "ALTER TABLE ["; + public static final String SQL_ALTER_TABLE = "ALTER TABLE "; public static final String SQL_CREATE = "CREATE "; public static final String SQL_DROP_INDEX = "DROP INDEX "; - public static final String SQL_ON = " ON ["; + public static final String SQL_ON = " ON "; private SqlServerIndexTypeEnumConstants() { } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/enums/type/SqlServerColumnTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/enums/type/SqlServerColumnTypeEnum.java index cd7c1ae87b..dfc832b335 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/enums/type/SqlServerColumnTypeEnum.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/enums/type/SqlServerColumnTypeEnum.java @@ -1,21 +1,21 @@ package ai.chat2db.plugin.sqlserver.enums.type; +import ai.chat2db.plugin.sqlserver.SqlServerSqlGuards; +import ai.chat2db.plugin.sqlserver.identifier.SqlServerIdentifierProcessor; import ai.chat2db.spi.IColumnBuilder; import ai.chat2db.community.domain.api.enums.plugin.EditStatusEnum; import ai.chat2db.community.domain.api.model.metadata.ColumnType; import ai.chat2db.community.domain.api.model.metadata.TableColumn; -import ai.chat2db.spi.util.SqlUtils; import com.google.common.collect.Maps; import org.apache.commons.lang3.StringUtils; import java.util.Arrays; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import static ai.chat2db.plugin.sqlserver.constant.SqlServerColumnTypeEnumConstants.*; -import static ai.chat2db.plugin.sqlserver.identifier.SqlServerIdentifierUtils.escapeStringLiteral; -import static ai.chat2db.plugin.sqlserver.identifier.SqlServerIdentifierUtils.quoteIdentifierPart; public enum SqlServerColumnTypeEnum implements IColumnBuilder { BIGINT("BIGINT", false, false, true, false, false, false, true, true), @@ -111,11 +111,15 @@ public enum SqlServerColumnTypeEnum implements IColumnBuilder { private ColumnType columnType; public static SqlServerColumnTypeEnum getByType(String dataType) { - SqlServerColumnTypeEnum typeEnum = COLUMN_TYPE_MAP.get(SqlUtils.removeDigits(dataType.toUpperCase())); - if (typeEnum == null) { - return OTHER; + if (StringUtils.isBlank(dataType)) { + return null; } - return typeEnum; + String normalized = dataType.trim().toUpperCase(Locale.ROOT); + SqlServerColumnTypeEnum exactType = COLUMN_TYPE_MAP.get(normalized); + if (exactType != null) { + return exactType; + } + return normalized.indexOf('(') >= 0 ? OTHER : COLUMN_TYPE_MAP.getOrDefault(normalized, OTHER); } private static Map COLUMN_TYPE_MAP = Maps.newHashMap(); @@ -140,7 +144,7 @@ public String buildCreateColumnSql(TableColumn column) { SqlServerColumnTypeEnum type = this; StringBuilder script = new StringBuilder(); - script.append(quoteIdentifierPart(column.getName())).append(" "); + script.append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName())).append(" "); script.append(buildDataType(column, type)).append(" "); @@ -160,7 +164,7 @@ public String buildAICreateColumnSql(TableColumn column) { SqlServerColumnTypeEnum type = this; StringBuilder script = new StringBuilder(); - script.append(quoteIdentifierPart(column.getName())).append(" "); + script.append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName())).append(" "); script.append(buildDataType(column, type)).append(" "); @@ -182,7 +186,7 @@ public String buildUpdateColumnSql(TableColumn column) { StringBuilder script = new StringBuilder(); - script.append(quoteIdentifierPart(column.getName())).append(" "); + script.append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName())).append(" "); script.append(buildDataType(column, type)).append(" "); @@ -190,30 +194,30 @@ public String buildUpdateColumnSql(TableColumn column) { if (StringUtils.isNotBlank(column.getDefaultValue()) && column.getOldColumn().getDefaultValue() != null && !StringUtils.equalsIgnoreCase(column.getDefaultValue(), column.getOldColumn().getDefaultValue())) { script.append(SQL_ALTER_TABLE).append(qualifiedTableName(column)); - script.append(" ").append(SQL_DROP_CONSTRAINT).append(quoteIdentifierPart(column.getDefaultConstraintName())); + script.append(" ").append(SQL_DROP_CONSTRAINT).append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getDefaultConstraintName())); script.append(SQL_ALTER_TABLE).append(qualifiedTableName(column)); script.append(" ").append("ADD ").append(buildDefaultValue(column, type)).append(" for ") - .append(quoteIdentifierPart(column.getName())).append(" \ngo\n"); + .append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName())).append(" \ngo\n"); } if (StringUtils.isNotBlank(column.getDefaultValue()) && column.getOldColumn().getDefaultValue() == null) { script.append(SQL_ALTER_TABLE).append(qualifiedTableName(column)); script.append(" ").append("ADD ").append(buildDefaultValue(column, type)).append(" for ") - .append(quoteIdentifierPart(column.getName())).append(" \ngo\n"); + .append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName())).append(" \ngo\n"); } if (!Objects.equals(column.getSparse(), column.getOldColumn().getSparse())) { script.append(SQL_ALTER_TABLE).append(qualifiedTableName(column)); - script.append(" ").append(SQL_ALTER_COLUMN).append(quoteIdentifierPart(column.getName())) + script.append(" ").append(SQL_ALTER_COLUMN).append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName())) .append(" add ").append("SPARSE").append(" \ngo\n"); } if (!Objects.equals(column.getCollationName(), column.getOldColumn().getCollationName())) { script.append(SQL_ALTER_TABLE).append(qualifiedTableName(column)); - script.append(" ").append(SQL_ALTER_COLUMN).append(quoteIdentifierPart(column.getName())) - .append(" ").append("COLLATE ").append(column.getCollationName()).append(" \ngo\n"); + script.append(" ").append(SQL_ALTER_COLUMN).append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName())) + .append(" ").append("COLLATE ").append(SqlServerSqlGuards.validateCollation(column.getCollationName())).append(" \ngo\n"); } return script.toString(); } @@ -230,7 +234,7 @@ private String buildCollation(TableColumn column, SqlServerColumnTypeEnum type) if (!type.getColumnType().isSupportCollation() || StringUtils.isEmpty(column.getCollationName())) { return ""; } - return StringUtils.join("COLLATE ", column.getCollationName()); + return StringUtils.join("COLLATE ", SqlServerSqlGuards.validateCollation(column.getCollationName())); } @@ -258,7 +262,8 @@ private String buildDefaultValue(TableColumn column, SqlServerColumnTypeEnum typ return StringUtils.join("DEFAULT NULL"); } - return StringUtils.join("DEFAULT ", column.getDefaultValue()); + return StringUtils.join("DEFAULT ", + SqlServerSqlGuards.requireDefaultExpression(column.getDefaultValue())); } private String buildDataType(TableColumn column, SqlServerColumnTypeEnum type) { @@ -297,8 +302,8 @@ private String buildDataType(TableColumn column, SqlServerColumnTypeEnum type) { return script.toString(); } - if (OTHER.equals(columnType)) { - return column.getColumnType(); + if (OTHER.equals(type)) { + return SqlServerSqlGuards.requireColumnTypeExpression(column.getColumnType()); } return columnType; @@ -309,22 +314,22 @@ private String buildDataType(TableColumn column, SqlServerColumnTypeEnum type) { private String renameColumn(TableColumn tableColumn) { StringBuilder qualifiedColumnName = new StringBuilder(); if (StringUtils.isNotBlank(tableColumn.getSchemaName())) { - qualifiedColumnName.append(quoteIdentifierPart(tableColumn.getSchemaName())).append('.'); + qualifiedColumnName.append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableColumn.getSchemaName())).append('.'); } - qualifiedColumnName.append(quoteIdentifierPart(tableColumn.getTableName())) + qualifiedColumnName.append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableColumn.getTableName())) .append('.') - .append(quoteIdentifierPart(tableColumn.getOldName())); + .append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableColumn.getOldName())); return String.format(RENAME_COLUMN_SCRIPT, - escapeStringLiteral(qualifiedColumnName.toString()), - escapeStringLiteral(tableColumn.getName())); + SqlServerIdentifierProcessor.INSTANCE.escapeString(qualifiedColumnName.toString()), + SqlServerIdentifierProcessor.INSTANCE.escapeString(tableColumn.getName())); } private static String qualifiedTableName(TableColumn tableColumn) { StringBuilder tableName = new StringBuilder(); if (StringUtils.isNotBlank(tableColumn.getSchemaName())) { - tableName.append(quoteIdentifierPart(tableColumn.getSchemaName())).append('.'); + tableName.append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableColumn.getSchemaName())).append('.'); } - return tableName.append(quoteIdentifierPart(tableColumn.getTableName())).toString(); + return tableName.append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableColumn.getTableName())).toString(); } @@ -335,11 +340,11 @@ public String buildModifyColumn(TableColumn tableColumn) { StringBuilder script = new StringBuilder(); if (StringUtils.isNotBlank(tableColumn.getDefaultConstraintName())) { script.append(SQL_ALTER_TABLE).append(qualifiedTableName(tableColumn)); - script.append(" ").append(SQL_DROP_CONSTRAINT).append(quoteIdentifierPart(tableColumn.getDefaultConstraintName())); + script.append(" ").append(SQL_DROP_CONSTRAINT).append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableColumn.getDefaultConstraintName())); script.append("\ngo\n"); } script.append(SQL_ALTER_TABLE).append(qualifiedTableName(tableColumn)); - script.append(" ").append(SQL_DROP_COLUMN).append(quoteIdentifierPart(tableColumn.getName())); + script.append(" ").append(SQL_DROP_COLUMN).append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableColumn.getName())); script.append("\ngo\n"); return script.toString(); } @@ -377,10 +382,10 @@ public String buildModifyColumn(TableColumn tableColumn) { private String buildModifyColumnComment(TableColumn tableColumn) { - String schemaName = escapeStringLiteral(tableColumn.getSchemaName()); - String tableName = escapeStringLiteral(tableColumn.getTableName()); - String columnName = escapeStringLiteral(tableColumn.getName()); - String comment = escapeStringLiteral(tableColumn.getComment()); + String schemaName = SqlServerIdentifierProcessor.INSTANCE.escapeString(tableColumn.getSchemaName()); + String tableName = SqlServerIdentifierProcessor.INSTANCE.escapeString(tableColumn.getTableName()); + String columnName = SqlServerIdentifierProcessor.INSTANCE.escapeString(tableColumn.getName()); + String comment = SqlServerIdentifierProcessor.INSTANCE.escapeString(tableColumn.getComment()); return String.format(COLUMN_MODIFY_COMMENT_SCRIPT, schemaName, tableName, columnName, comment, schemaName, tableName, columnName, comment, schemaName, tableName, columnName); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/enums/type/SqlServerIndexTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/enums/type/SqlServerIndexTypeEnum.java index 620123010e..e1fe0363ed 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/enums/type/SqlServerIndexTypeEnum.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/enums/type/SqlServerIndexTypeEnum.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.sqlserver.identifier.SqlServerIdentifierProcessor; import org.apache.commons.lang3.StringUtils; import java.util.Arrays; @@ -77,13 +78,13 @@ public String buildIndexScript(TableIndex tableIndex) { StringBuilder script = new StringBuilder(); if (PRIMARY_KEY.equals(this)) { script.append(SQL_ALTER_TABLE) - .append(tableIndex.getSchemaName()).append("].[").append(tableIndex.getTableName()) - .append("] ADD CONSTRAINT ") - .append("[").append(tableIndex.getTableName()).append("_pk").append("]") + .append(qualifiedTableName(tableIndex)).append(" ADD CONSTRAINT ") + .append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableIndex.getTableName() + "_pk")) .append(" ").append(keyword).append(" ").append(buildIndexColumn(tableIndex)); } else { script.append(SQL_CREATE).append(keyword).append(" "); - script.append(buildIndexName(tableIndex)).append("\n ON [").append(tableIndex.getSchemaName()).append("].[").append(tableIndex.getTableName()).append("] ").append(buildIndexColumn(tableIndex)); + script.append(buildIndexName(tableIndex)).append("\n ON ") + .append(qualifiedTableName(tableIndex)).append(" ").append(buildIndexColumn(tableIndex)); } script.append("\ngo"); return script.toString(); @@ -95,9 +96,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(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getColumnName())); if (!StringUtils.isBlank(column.getAscOrDesc()) && !PRIMARY_KEY.equals(this)) { - script.append(" ").append(column.getAscOrDesc()); + script.append(" ").append(validateAscOrDesc(column.getAscOrDesc())); } script.append(","); } @@ -107,8 +108,15 @@ private String buildIndexColumn(TableIndex tableIndex) { return script.toString(); } + private static String validateAscOrDesc(String ascOrDesc) { + if (!"ASC".equalsIgnoreCase(ascOrDesc) && !"DESC".equalsIgnoreCase(ascOrDesc)) { + throw new IllegalArgumentException("Invalid SQL Server index column sort order: " + ascOrDesc); + } + return ascOrDesc; + } + private String buildIndexName(TableIndex tableIndex) { - return "[" + tableIndex.getName() + "]"; + return SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableIndex.getName()); } public String buildModifyIndex(TableIndex tableIndex) { @@ -126,16 +134,27 @@ public String buildModifyIndex(TableIndex tableIndex) { private String buildDropIndex(TableIndex tableIndex) { if (SqlServerIndexTypeEnum.PRIMARY_KEY.getName().equals(tableIndex.getType())) { - return StringUtils.join(SQL_ALTER_TABLE, tableIndex.getSchemaName(), "].[", tableIndex.getTableName(), "] DROP CONSTRAINT ", buildIndexName(tableIndex), "\ngo"); + return StringUtils.join(SQL_ALTER_TABLE, qualifiedTableName(tableIndex), + " DROP CONSTRAINT ", buildIndexName(tableIndex), "\ngo"); } StringBuilder script = new StringBuilder(); script.append(SQL_DROP_INDEX); script.append(buildIndexName(tableIndex)); - script.append(SQL_ON).append(tableIndex.getSchemaName()).append("].[").append(tableIndex.getTableName()).append("] \ngo"); + script.append(SQL_ON).append(qualifiedTableName(tableIndex)).append(" \ngo"); return script.toString(); } + private static String qualifiedTableName(TableIndex tableIndex) { + StringBuilder name = new StringBuilder(); + if (StringUtils.isNotBlank(tableIndex.getSchemaName())) { + name.append(SqlServerIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableIndex.getSchemaName())) + .append('.'); + } + return name.append(SqlServerIdentifierProcessor.INSTANCE + .quoteIdentifierAlways(tableIndex.getTableName())).toString(); + } + public static List getIndexTypes() { return Arrays.asList(SqlServerIndexTypeEnum.values()).stream().map(SqlServerIndexTypeEnum::getIndexType).collect(java.util.stream.Collectors.toList()); } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/identifier/SqlServerIdentifierProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/identifier/SqlServerIdentifierProcessor.java index f74b41ab47..932d91ba3e 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/identifier/SqlServerIdentifierProcessor.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/identifier/SqlServerIdentifierProcessor.java @@ -4,12 +4,21 @@ import org.apache.commons.lang3.StringUtils; import java.util.HashSet; +import java.util.Locale; import java.util.Set; -import java.util.regex.Pattern; import static ai.chat2db.plugin.sqlserver.constant.SqlServerIdentifierProcessorConstants.*; + +/** + * SQL Server dialect identifier processor: bracket-quoted identifiers with + * embedded {@code ]} doubling, and single-quote doubling for string literals + * (SQL Server does not treat backslash as an escape character, so backslashes + * are never doubled). Shared stateless instance available via {@link #INSTANCE} + * for call sites without MetaData access. + */ public class SqlServerIdentifierProcessor extends DefaultSQLIdentifierProcessor { + public static final SqlServerIdentifierProcessor INSTANCE = new SqlServerIdentifierProcessor(); public static final Set SQL_SERVER_RESERVED_KEYWORDS = new HashSet<>(); @@ -200,31 +209,76 @@ public class SqlServerIdentifierProcessor extends DefaultSQLIdentifierProcessor @Override public boolean isReservedKeyword(String identifier, Integer majorVersion, Integer minorVersion) { - return SQL_SERVER_RESERVED_KEYWORDS.contains(identifier); + return identifier != null && SQL_SERVER_RESERVED_KEYWORDS.contains(identifier.toUpperCase(Locale.ROOT)); } + /** + * Quotes conditionally per the SPI contract: {@code null} passes through, + * blank input is returned unchanged, and an identifier that is already a + * valid plain identifier and not a reserved keyword is returned unquoted. + * Anything else is wrapped with {@link #quoteIdentifierAlways(String)}. + */ @Override - public String quoteIdentifier(String identifier, Integer majorVersion, Integer minorVersion) { - if (isValidIdentifier(identifier) || !isReservedKeyword(identifier.toUpperCase(), majorVersion, minorVersion)) { + public String quoteIdentifier(String identifier) { + if (identifier == null) { + return null; + } + if (StringUtils.isBlank(identifier)) { return identifier; } - return "[" + identifier + "]"; + if (isValidIdentifier(identifier) && !isReservedKeyword(identifier, null, null)) { + return identifier; + } + return quoteIdentifierAlways(identifier); } @Override - public String quoteIdentifier(String identifier) { - if (isValidIdentifier(identifier) && !isReservedKeyword(identifier.toUpperCase(), null, null)) { - return identifier; - } - return "[" + identifier + "]"; + public String quoteIdentifier(String identifier, Integer majorVersion, Integer minorVersion) { + return quoteIdentifier(identifier); } + /** Conditional quote variant that preserves the original identifier case. */ + @Override + public String quoteIdentifierIgnoreCase(String identifier) { + return quoteIdentifier(identifier); + } + + /** + * Unconditionally quotes with square brackets and doubles every closing + * bracket in the raw identifier. Boundary brackets are data, not existing + * quote syntax, so this satisfies the SPI round-trip contract. + */ @Override public String quoteIdentifierAlways(String identifier) { if (identifier == null) { return null; } - return "[" + identifier.replace("]", "]]") + "]"; + return "[" + escapeIdentifierContent(identifier) + "]"; + } + + /** + * Escapes a value interpolated into a single-quoted SQL string literal by + * doubling every single quote. Backslashes are literal in Transact-SQL and + * are therefore left untouched. + */ + @Override + public String escapeString(String str) { + return str == null ? null : StringUtils.replace(str, "'", "''"); + } + + private static String escapeIdentifierContent(String identifier) { + if (identifier == null) { + return ""; + } + return StringUtils.replace(identifier, "]", "]]"); + } + + /** + * Escapes raw identifier content for a position already surrounded by + * square brackets. + */ + public static String escapeIdentifier(String identifier) { + return escapeIdentifierContent(identifier); } @Override @@ -232,13 +286,59 @@ public String removeIdentifierQuote(String identifier) { if (StringUtils.isBlank(identifier)) { return identifier; } - if (identifier.startsWith("[") && identifier.endsWith("]") && identifier.length() >= 2) { - return identifier.substring(1, identifier.length() - 1).replace("]]", "]"); - } - if (identifier.startsWith("\"") && identifier.endsWith("\"") && identifier.length() >= 2) { - return identifier.substring(1, identifier.length() - 1).replace("\"\"", "\""); + StringBuilder unquoted = new StringBuilder(identifier.length()); + boolean removedQuote = false; + int offset = 0; + while (offset < identifier.length()) { + char first = identifier.charAt(offset); + if (first == '[' || first == '"') { + char closingDelimiter = first == '[' ? ']' : '"'; + StringBuilder part = new StringBuilder(); + boolean closed = false; + offset++; + while (offset < identifier.length()) { + char current = identifier.charAt(offset); + if (current == closingDelimiter) { + if (offset + 1 < identifier.length() + && identifier.charAt(offset + 1) == closingDelimiter) { + part.append(closingDelimiter); + offset += 2; + continue; + } + offset++; + closed = true; + break; + } + part.append(current); + offset++; + } + if (!closed || (offset < identifier.length() && identifier.charAt(offset) != '.')) { + return identifier; + } + unquoted.append(part); + removedQuote = true; + } else { + int partEnd = identifier.indexOf('.', offset); + if (partEnd < 0) { + partEnd = identifier.length(); + } + String part = identifier.substring(offset, partEnd); + if (part.indexOf('[') >= 0 || part.indexOf(']') >= 0 || part.indexOf('"') >= 0) { + return identifier; + } + unquoted.append(part); + offset = partEnd; + } + + if (offset < identifier.length()) { + unquoted.append('.'); + offset++; + if (offset == identifier.length()) { + return identifier; + } + } } - return identifier; + return removedQuote ? unquoted.toString() : identifier; } @Override @@ -246,6 +346,6 @@ public boolean isQuoteIdentifier(String identifier) { if (StringUtils.isBlank(identifier)) { return false; } - return identifier.startsWith("[") && identifier.endsWith("]"); + return !identifier.equals(removeIdentifierQuote(identifier)); } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/identifier/SqlServerIdentifierUtils.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/identifier/SqlServerIdentifierUtils.java deleted file mode 100644 index 38b71a62da..0000000000 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/identifier/SqlServerIdentifierUtils.java +++ /dev/null @@ -1,15 +0,0 @@ -package ai.chat2db.plugin.sqlserver.identifier; - -public final class SqlServerIdentifierUtils { - - private SqlServerIdentifierUtils() { - } - - public static String quoteIdentifierPart(String identifier) { - return "[" + identifier.replace("]", "]]") + "]"; - } - - public static String escapeStringLiteral(String value) { - return value == null ? "" : value.replace("'", "''"); - } -} diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/value/SqlServerValueProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/value/SqlServerValueProcessor.java index df9bea41d7..b55e2bc866 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/value/SqlServerValueProcessor.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/value/SqlServerValueProcessor.java @@ -11,6 +11,7 @@ import org.slf4j.LoggerFactory; import java.util.Objects; +import java.util.Locale; public class SqlServerValueProcessor extends DefaultValueProcessor { @@ -51,7 +52,8 @@ public String getJdbcSqlValueString(JDBCDataValue dataValue) { @Override public String convertSQLValueByType(SQLDataValue dataValue) { try { - DefaultValueProcessor valueProcessor = SqlServerValueProcessorFactory.getValueProcessor(dataValue.getDateTypeName().toUpperCase()); + DefaultValueProcessor valueProcessor = SqlServerValueProcessorFactory.getValueProcessor( + dataValue.getDateTypeName().toUpperCase(Locale.ROOT)); if (Objects.nonNull(valueProcessor)) { return valueProcessor.convertSQLValueByType(dataValue); } @@ -66,7 +68,8 @@ public String convertSQLValueByType(SQLDataValue dataValue) { public String convertJDBCValueByType(JDBCDataValue dataValue) { String type = dataValue.getType(); try { - DefaultValueProcessor valueProcessor = SqlServerValueProcessorFactory.getValueProcessor(type.toUpperCase()); + DefaultValueProcessor valueProcessor = SqlServerValueProcessorFactory.getValueProcessor( + type.toUpperCase(Locale.ROOT)); if (Objects.nonNull(valueProcessor)) { return valueProcessor.convertJDBCValueByType(dataValue); } @@ -83,7 +86,7 @@ public String convertJDBCValueStrByType(JDBCDataValue dataValue) { String type = dataValue.getType(); DefaultValueProcessor valueProcessor; try { - valueProcessor = SqlServerValueProcessorFactory.getValueProcessor(type.toUpperCase()); + valueProcessor = SqlServerValueProcessorFactory.getValueProcessor(type.toUpperCase(Locale.ROOT)); if (Objects.nonNull(valueProcessor)) { return valueProcessor.convertJDBCValueStrByType(dataValue); } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/value/sub/SqlServerGeographyProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/value/sub/SqlServerGeographyProcessor.java index d337edb6d4..d6f62be3ae 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/value/sub/SqlServerGeographyProcessor.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/value/sub/SqlServerGeographyProcessor.java @@ -1,6 +1,7 @@ package ai.chat2db.plugin.sqlserver.value.sub; import ai.chat2db.plugin.sqlserver.value.template.SqlServerDmlValueTemplate; +import ai.chat2db.plugin.sqlserver.identifier.SqlServerIdentifierProcessor; import ai.chat2db.spi.DefaultValueProcessor; import ai.chat2db.spi.model.value.JDBCDataValue; import ai.chat2db.community.domain.api.model.value.SQLDataValue; @@ -14,7 +15,7 @@ public String convertSQLValueByType(SQLDataValue dataValue) { if (value.startsWith("0x")) { return value; } - return SqlServerDmlValueTemplate.wrapGeography(value); + return SqlServerDmlValueTemplate.wrapGeography(SqlServerIdentifierProcessor.INSTANCE.escapeString(value)); } @Override diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/value/sub/SqlServerStringProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/value/sub/SqlServerStringProcessor.java index 2174c1fe38..185be2048e 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/value/sub/SqlServerStringProcessor.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/value/sub/SqlServerStringProcessor.java @@ -1,7 +1,7 @@ package ai.chat2db.plugin.sqlserver.value.sub; import ai.chat2db.plugin.sqlserver.value.template.SqlServerDmlValueTemplate; -import ai.chat2db.community.tools.util.EasyStringUtils; +import ai.chat2db.plugin.sqlserver.identifier.SqlServerIdentifierProcessor; import ai.chat2db.spi.DefaultValueProcessor; import ai.chat2db.spi.model.value.JDBCDataValue; import ai.chat2db.community.domain.api.model.value.SQLDataValue; @@ -11,7 +11,8 @@ public class SqlServerStringProcessor extends DefaultValueProcessor { @Override public String convertSQLValueByType(SQLDataValue dataValue) { - return SqlServerDmlValueTemplate.wrapString(EasyStringUtils.escapeString(dataValue.getValue())); + return SqlServerDmlValueTemplate.wrapString( + SqlServerIdentifierProcessor.INSTANCE.escapeString(dataValue.getValue())); } @Override @@ -21,6 +22,7 @@ public String convertJDBCValueByType(JDBCDataValue dataValue) { @Override public String convertJDBCValueStrByType(JDBCDataValue dataValue) { - return SqlServerDmlValueTemplate.wrapString(EasyStringUtils.escapeString(dataValue.getStringValue())); + return SqlServerDmlValueTemplate.wrapString( + SqlServerIdentifierProcessor.INSTANCE.escapeString(dataValue.getStringValue())); } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/value/sub/SqlServerTextProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/value/sub/SqlServerTextProcessor.java index 6bf4fb132c..6d1ce84863 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/value/sub/SqlServerTextProcessor.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/value/sub/SqlServerTextProcessor.java @@ -1,7 +1,7 @@ package ai.chat2db.plugin.sqlserver.value.sub; import ai.chat2db.plugin.sqlserver.value.template.SqlServerDmlValueTemplate; -import ai.chat2db.community.tools.util.EasyStringUtils; +import ai.chat2db.plugin.sqlserver.identifier.SqlServerIdentifierProcessor; import ai.chat2db.spi.DefaultValueProcessor; import ai.chat2db.spi.model.value.JDBCDataValue; import ai.chat2db.community.domain.api.model.value.SQLDataValue; @@ -10,7 +10,8 @@ public class SqlServerTextProcessor extends DefaultValueProcessor { @Override public String convertSQLValueByType(SQLDataValue dataValue) { - return SqlServerDmlValueTemplate.wrapString(EasyStringUtils.escapeString(dataValue.getValue())); + return SqlServerDmlValueTemplate.wrapString( + SqlServerIdentifierProcessor.INSTANCE.escapeString(dataValue.getValue())); } @Override @@ -20,6 +21,7 @@ public String convertJDBCValueByType(JDBCDataValue dataValue) { @Override public String convertJDBCValueStrByType(JDBCDataValue dataValue) { - return SqlServerDmlValueTemplate.wrapString(EasyStringUtils.escapeString(dataValue.getClobString())); + return SqlServerDmlValueTemplate.wrapString( + SqlServerIdentifierProcessor.INSTANCE.escapeString(dataValue.getClobString())); } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/value/sub/SqlServerXmlProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/value/sub/SqlServerXmlProcessor.java index cb1a6204e0..6b3153cdc2 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/value/sub/SqlServerXmlProcessor.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/value/sub/SqlServerXmlProcessor.java @@ -1,6 +1,7 @@ package ai.chat2db.plugin.sqlserver.value.sub; import ai.chat2db.plugin.sqlserver.value.template.SqlServerDmlValueTemplate; +import ai.chat2db.plugin.sqlserver.identifier.SqlServerIdentifierProcessor; import ai.chat2db.spi.DefaultValueProcessor; import ai.chat2db.spi.model.value.JDBCDataValue; import ai.chat2db.community.domain.api.model.value.SQLDataValue; @@ -11,7 +12,7 @@ public class SqlServerXmlProcessor extends DefaultValueProcessor { @Override public String convertSQLValueByType(SQLDataValue dataValue) { - return SqlServerDmlValueTemplate.wrapString(dataValue.getValue()); + return SqlServerDmlValueTemplate.wrapString(SqlServerIdentifierProcessor.INSTANCE.escapeString(dataValue.getValue())); } @Override @@ -21,6 +22,6 @@ public String convertJDBCValueByType(JDBCDataValue dataValue) { @Override public String convertJDBCValueStrByType(JDBCDataValue dataValue) { - return SqlServerDmlValueTemplate.wrapString(dataValue.getStringValue()); + return SqlServerDmlValueTemplate.wrapString(SqlServerIdentifierProcessor.INSTANCE.escapeString(dataValue.getStringValue())); } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerIdentifierProcessorTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerIdentifierProcessorTest.java new file mode 100644 index 0000000000..ad463c05b6 --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerIdentifierProcessorTest.java @@ -0,0 +1,406 @@ +package ai.chat2db.plugin.sqlserver; + +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.view.ModifyView; +import ai.chat2db.plugin.sqlserver.builder.SqlServerSqlBuilder; +import ai.chat2db.plugin.sqlserver.constant.SQLConstant; +import ai.chat2db.plugin.sqlserver.enums.type.SqlServerColumnTypeEnum; +import ai.chat2db.plugin.sqlserver.enums.type.SqlServerIndexTypeEnum; +import ai.chat2db.plugin.sqlserver.identifier.SqlServerIdentifierProcessor; +import ai.chat2db.spi.model.request.DropTableRequest; +import ai.chat2db.spi.model.request.TruncateTableRequest; +import ai.chat2db.spi.model.request.UpdateSqlRequest; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +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 SqlServerIdentifierProcessorTest { + + @Test + void shouldDoubleSingleQuotesInStringLiterals() { + assertEquals("O''Brien", SqlServerIdentifierProcessor.INSTANCE.escapeString("O'Brien")); + assertNull(SqlServerIdentifierProcessor.INSTANCE.escapeString(null)); + assertEquals("plain", SqlServerIdentifierProcessor.INSTANCE.escapeString("plain")); + } + + @Test + void shouldDoubleClosingBracketsInQuotedIdentifiers() { + assertEquals("[weird]]name]", SqlServerIdentifierProcessor.INSTANCE.quoteIdentifier("weird]name")); + assertEquals("a]]b", SqlServerIdentifierProcessor.escapeIdentifier("a]b")); + assertEquals("", SqlServerIdentifierProcessor.escapeIdentifier(null)); + } + + @Test + void shouldPassThroughNullBlankAndPlainIdentifiersConditionally() { + SqlServerIdentifierProcessor processor = SqlServerIdentifierProcessor.INSTANCE; + assertNull(processor.quoteIdentifier(null)); + assertEquals("", processor.quoteIdentifier("")); + assertEquals(" ", processor.quoteIdentifier(" ")); + assertEquals("users", processor.quoteIdentifier("users")); + assertEquals("dbo", processor.quoteIdentifier("dbo")); + assertEquals("order_details2", processor.quoteIdentifier("order_details2")); + } + + @Test + void shouldQuoteReservedKeywordsAndInvalidIdentifiersConditionally() { + SqlServerIdentifierProcessor processor = SqlServerIdentifierProcessor.INSTANCE; + assertEquals("[SELECT]", processor.quoteIdentifier("SELECT")); + assertEquals("[select]", processor.quoteIdentifier("select")); + assertEquals("[USER]", processor.quoteIdentifier("USER")); + assertEquals("[weird name]", processor.quoteIdentifier("weird name")); + assertEquals("[[users]]]", processor.quoteIdentifier("[users]")); + assertEquals("[a]]b]", processor.quoteIdentifier("a]b")); + } + + @Test + void shouldDelegateVersionedOverloadToConditionalQuote() { + SqlServerIdentifierProcessor processor = SqlServerIdentifierProcessor.INSTANCE; + assertNull(processor.quoteIdentifier(null, null, null)); + assertEquals("users", processor.quoteIdentifier("users", 15, 0)); + assertEquals("[weird name]", processor.quoteIdentifier("weird name", 15, 0)); + } + + @Test + void shouldAlwaysQuoteWithQuoteIdentifierAlways() { + SqlServerIdentifierProcessor processor = SqlServerIdentifierProcessor.INSTANCE; + assertNull(processor.quoteIdentifierAlways(null)); + assertEquals("[users]", processor.quoteIdentifierAlways("users")); + assertEquals("[a]]b]", processor.quoteIdentifierAlways("a]b")); + assertEquals("[[users]]]", processor.quoteIdentifierAlways("[users]")); + assertEquals("[]", processor.quoteIdentifierAlways("")); + } + + @Test + void shouldKeepConditionalSemanticsWithQuoteIdentifierIgnoreCase() { + SqlServerIdentifierProcessor processor = SqlServerIdentifierProcessor.INSTANCE; + assertNull(processor.quoteIdentifierIgnoreCase(null)); + assertEquals("users", processor.quoteIdentifierIgnoreCase("users")); + assertEquals("MixedCase", processor.quoteIdentifierIgnoreCase("MixedCase")); + assertEquals("[select]", processor.quoteIdentifierIgnoreCase("select")); + assertEquals("[a]]b]", processor.quoteIdentifierIgnoreCase("a]b")); + } + + @Test + void shouldNeutralizeMaliciousCommentAndNamesInCreateTable() { + SqlServerSqlBuilder builder = new SqlServerSqlBuilder(); + Table table = new Table(); + table.setSchemaName("dbo"); + table.setName("users];DROP TABLE t;--"); + table.setComment("x'; DROP TABLE users;--"); + TableColumn column = new TableColumn(); + column.setName("id"); + column.setColumnType("INT"); + column.setNullable(1); + table.setColumnList(List.of(column)); + table.setIndexList(List.of()); + + String script = builder.buildCreateTable(table, TableBuilderConfig.defaultConfig()); + + assertTrue(script.contains("CREATE TABLE [dbo].[users]];DROP TABLE t;--] ("), script); + assertTrue(script.contains("'x''; DROP TABLE users;--'"), script); + assertFalse(script.contains("'x'; DROP"), script); + } + + @Test + void shouldNeutralizeMaliciousNamesInIndexScriptAndComment() { + TableIndex tableIndex = new TableIndex(); + tableIndex.setSchemaName("s];DROP"); + tableIndex.setTableName("t"); + tableIndex.setName("ix"); + tableIndex.setType("NONCLUSTERED"); + tableIndex.setComment("c';DROP--"); + TableIndexColumn indexColumn = new TableIndexColumn(); + indexColumn.setColumnName("id"); + indexColumn.setAscOrDesc("ASC"); + tableIndex.setColumnList(List.of(indexColumn)); + + String script = SqlServerIndexTypeEnum.NONCLUSTERED.buildIndexScript(tableIndex); + assertTrue(script.contains("ON [s]];DROP].[t]"), script); + + SqlServerSqlBuilder builder = new SqlServerSqlBuilder(); + Table table = new Table(); + table.setSchemaName("dbo"); + table.setName("t"); + TableColumn column = new TableColumn(); + column.setName("id"); + column.setColumnType("INT"); + column.setNullable(1); + table.setColumnList(List.of(column)); + table.setIndexList(new ArrayList<>(List.of(tableIndex))); + + String createScript = builder.buildCreateTable(table, TableBuilderConfig.defaultConfig()); + assertTrue(createScript.contains("'c'';DROP--'"), createScript); + assertFalse(createScript.contains("'c';DROP--'"), createScript); + } + + @Test + void shouldRejectInvalidIndexColumnSortOrder() { + TableIndex tableIndex = new TableIndex(); + tableIndex.setSchemaName("dbo"); + tableIndex.setTableName("t"); + tableIndex.setName("ix"); + tableIndex.setType("NONCLUSTERED"); + TableIndexColumn indexColumn = new TableIndexColumn(); + indexColumn.setColumnName("id"); + indexColumn.setAscOrDesc("ASC; DROP TABLE t;--"); + tableIndex.setColumnList(List.of(indexColumn)); + + assertThrows(IllegalArgumentException.class, + () -> SqlServerIndexTypeEnum.NONCLUSTERED.buildIndexScript(tableIndex)); + } + + @Test + void shouldEscapeDatabaseNameAndCommentInCreateDatabase() { + SqlServerSqlBuilder builder = new SqlServerSqlBuilder(); + Database database = new Database(); + database.setName("db];DROP"); + database.setCollation("SQL_Latin1_General_CP1_CI_AS"); + database.setComment("it's"); + + String script = builder.buildCreateDatabase(database); + + assertTrue(script.contains("CREATE DATABASE [db]];DROP]"), script); + assertTrue(script.contains("COLLATE SQL_Latin1_General_CP1_CI_AS"), script); + assertTrue(script.contains("exec [db]];DROP].sys."), script); + assertTrue(script.contains("'it''s'"), script); + } + + @Test + void shouldAcceptLegitCollationAndRejectInjection() { + assertEquals("Latin1_General_100_CI_AS_KS_WS_SC", SqlServerSqlGuards.validateCollation("Latin1_General_100_CI_AS_KS_WS_SC")); + + SqlServerSqlBuilder builder = new SqlServerSqlBuilder(); + Database database = new Database(); + database.setName("db"); + database.setCollation("Latin1; DROP TABLE t;--"); + assertThrows(IllegalArgumentException.class, () -> builder.buildCreateDatabase(database)); + } + + @Test + void shouldKeepQuotedStringAndExpressionDefaultsUnchanged() { + TableColumn quotedDefault = new TableColumn(); + quotedDefault.setName("c"); + quotedDefault.setColumnType("VARCHAR"); + quotedDefault.setColumnSize(50); + quotedDefault.setNullable(1); + quotedDefault.setDefaultValue("'O''Brien'"); + assertTrue(SqlServerColumnTypeEnum.VARCHAR.buildCreateColumnSql(quotedDefault) + .contains("DEFAULT 'O''Brien'")); + + TableColumn expressionDefault = new TableColumn(); + expressionDefault.setName("d"); + expressionDefault.setColumnType("DATETIME2"); + expressionDefault.setNullable(1); + expressionDefault.setDefaultValue("(getdate())"); + assertTrue(SqlServerColumnTypeEnum.DATETIME2.buildCreateColumnSql(expressionDefault) + .contains("DEFAULT (getdate())")); + } + + @Test + void shouldWhitelistViewAttributes() { + SqlServerSqlBuilder builder = new SqlServerSqlBuilder(); + ModifyView view = new ModifyView(); + view.setSchemaName("dbo"); + view.setViewName("v"); + view.setViewBody("SELECT 1"); + view.setViewAttributes(List.of("SCHEMABINDING")); + assertTrue(builder.buildCreateView(view).contains("WITH SCHEMABINDING")); + + ModifyView malicious = new ModifyView(); + malicious.setSchemaName("dbo"); + malicious.setViewName("v"); + malicious.setViewBody("SELECT 1"); + malicious.setViewAttributes(List.of("SCHEMABINDING OPTION(RECOMPILE); DROP TABLE t;--")); + assertThrows(IllegalArgumentException.class, () -> builder.buildCreateView(malicious)); + } + + @Test + void shouldEscapeNamesInMetadataCommentBuilders() { + String script = SQLConstant.buildTableComment("c", "s'x", "t"); + assertTrue(script.contains("N's''x'"), script); + + String indexScript = SQLConstant.buildIndexComment("c", "dbo", "t", "i'x"); + assertTrue(indexScript.contains("N'i''x'"), indexScript); + } + + @Test + void shouldRequoteAndEscapeTableNames() { + ExposedBuilder builder = new ExposedBuilder(); + assertEquals("[db].[dbo].[users]", builder.tableName("db", "dbo", "users")); + assertEquals("[db].[dbo].[users]", builder.tableName("db", "dbo", "[users]")); + assertEquals("[us]]ers]", builder.tableName(null, null, "us]ers")); + assertEquals("[us]]ers]", builder.tableName(null, null, "[us]]ers]")); + } + + @Test + void shouldRoundTripRawBoundaryBracketsAndQualifiedQuotes() { + SqlServerIdentifierProcessor processor = SqlServerIdentifierProcessor.INSTANCE; + for (String raw : List.of("plain", "a]b", "]prefix", "suffix]", "[quoted]", "[a]b]", "db.table")) { + assertEquals(raw, processor.removeIdentifierQuote(processor.quoteIdentifierAlways(raw))); + } + assertEquals("db.ta]ble", processor.removeIdentifierQuote("[db].[ta]]ble]")); + assertEquals("db.table", processor.removeIdentifierQuote("\"db\".\"table\"")); + assertEquals("prefix[a]suffix", processor.removeIdentifierQuote("prefix[a]suffix")); + assertEquals("[unclosed", processor.removeIdentifierQuote("[unclosed")); + assertTrue(processor.isQuoteIdentifier("[db].[table]")); + assertFalse(processor.isQuoteIdentifier("prefix[a]suffix")); + } + + @Test + void shouldMatchReservedWordsIndependentlyOfDefaultLocale() { + Locale original = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + assertTrue(SqlServerIdentifierProcessor.INSTANCE.isReservedKeyword("insert", null, null)); + assertEquals("[insert]", SqlServerIdentifierProcessor.INSTANCE.quoteIdentifier("insert")); + } finally { + Locale.setDefault(original); + } + } + + @Test + void shouldAcceptLegalTypeAndDefaultExpressionsWithoutRewritingThem() { + assertEquals("decimal(18, 2)", SqlServerSqlGuards.requireColumnTypeExpression("decimal(18, 2)")); + assertEquals("[types].[Phone Number]", + SqlServerSqlGuards.requireColumnTypeExpression("[types].[Phone Number]")); + assertEquals("xml(CONTENT [dbo].[SchemaCollection])", + SqlServerSqlGuards.requireColumnTypeExpression("xml(CONTENT [dbo].[SchemaCollection])")); + assertEquals("N'O''Brien'", SqlServerSqlGuards.requireDefaultExpression("N'O''Brien'")); + assertEquals("NEXT VALUE FOR [dbo].[seq]", + SqlServerSqlGuards.requireDefaultExpression("NEXT VALUE FOR [dbo].[seq]")); + assertEquals("CONVERT(datetime2, sysdatetime())", + SqlServerSqlGuards.requireDefaultExpression("CONVERT(datetime2, sysdatetime())")); + assertEquals("N'value' COLLATE Latin1_General_100_CI_AS", + SqlServerSqlGuards.requireDefaultExpression( + "N'value' COLLATE Latin1_General_100_CI_AS")); + assertEquals("'semi;colon'", SqlServerSqlGuards.requireDefaultExpression("'semi;colon'")); + } + + @Test + void shouldRejectStatementBoundariesAndUnbalancedExpressions() { + assertThrows(IllegalArgumentException.class, + () -> SqlServerSqlGuards.requireColumnTypeExpression("int); DROP TABLE t;--")); + assertThrows(IllegalArgumentException.class, + () -> SqlServerSqlGuards.requireColumnTypeExpression("varchar(20")); + assertThrows(IllegalArgumentException.class, + () -> SqlServerSqlGuards.requireColumnTypeExpression("int DROP TABLE")); + assertThrows(IllegalArgumentException.class, + () -> SqlServerSqlGuards.requireDefaultExpression("0; DROP TABLE t")); + assertThrows(IllegalArgumentException.class, + () -> SqlServerSqlGuards.requireDefaultExpression("0 -- comment")); + assertThrows(IllegalArgumentException.class, + () -> SqlServerSqlGuards.requireDefaultExpression("getdate(")); + assertThrows(IllegalArgumentException.class, + () -> SqlServerSqlGuards.requireDefaultExpression("0, 1")); + assertThrows(IllegalArgumentException.class, + () -> SqlServerSqlGuards.requireDefaultExpression("0 CONSTRAINT injected UNIQUE")); + assertThrows(IllegalArgumentException.class, + () -> SqlServerSqlGuards.requireDefaultExpression("0 NOT NULL")); + assertThrows(IllegalArgumentException.class, + () -> SqlServerSqlGuards.requireDefaultExpression("0 WITH VALUES")); + } + + @Test + void shouldPreserveValidatedParameterizedAndUserDefinedTypes() { + TableColumn decimal = new TableColumn(); + decimal.setName("amount"); + decimal.setColumnType("decimal(18, 2)"); + decimal.setNullable(1); + decimal.setDefaultValue("CONVERT(decimal(18, 2), (1.25))"); + String decimalSql = SqlServerColumnTypeEnum.getByType(decimal.getColumnType()) + .buildCreateColumnSql(decimal); + assertTrue(decimalSql.contains("[amount] decimal(18, 2)"), decimalSql); + assertTrue(decimalSql.contains("DEFAULT CONVERT(decimal(18, 2), (1.25))"), decimalSql); + + TableColumn custom = new TableColumn(); + custom.setName("phone"); + custom.setColumnType("[types].[Phone Number]"); + custom.setNullable(1); + String customSql = SqlServerColumnTypeEnum.getByType(custom.getColumnType()) + .buildCreateColumnSql(custom); + assertTrue(customSql.contains("[phone] [types].[Phone Number]"), customSql); + } + + @Test + void shouldQuoteInheritedBuilderAndManagerPaths() { + SqlServerSqlBuilder builder = new SqlServerSqlBuilder(); + String database = "catalog]x"; + String schema = "sales]x"; + String table = "orders]x"; + String qualified = "[catalog]]x].[sales]]x].[orders]]x]"; + + assertEquals("SELECT COUNT(1) FROM " + qualified, + builder.buildSelectCount(database, schema, table)); + assertEquals("SELECT * FROM " + qualified, + builder.buildSelectTable(database, schema, table)); + assertEquals("DROP TABLE " + qualified, + builder.buildDropTable(new DropTableRequest(database, schema, table))); + assertEquals("TRUNCATE TABLE " + qualified, + builder.buildTruncateTable(new TruncateTableRequest(database, schema, table))); + + Schema schemaModel = new Schema(); + schemaModel.setName(schema); + assertEquals("CREATE SCHEMA [sales]]x]\ngo\n", builder.buildCreateSchema(schemaModel)); + assertEquals("DROP SCHEMA [sales]]x]", builder.buildDropSchema(schema)); + assertEquals("TRUNCATE TABLE " + qualified, + new SqlServerDBManager().truncateTable(null, database, schema, table)); + } + + @Test + void shouldQuoteInheritedUpdateAndTemplateColumns() { + SqlServerSqlBuilder builder = new SqlServerSqlBuilder(); + Map row = new LinkedHashMap<>(); + row.put("display]name", "'value'"); + Map keys = new LinkedHashMap<>(); + keys.put("id]key", "1"); + UpdateSqlRequest request = UpdateSqlRequest.builder() + .schemaName("dbo") + .tableName("users]archive") + .row(row) + .primaryKeyMap(keys) + .build(); + String update = builder.buildUpdate(request); + assertTrue(update.contains("UPDATE [dbo].[users]]archive] SET [display]]name] = 'value'"), update); + assertTrue(update.contains("WHERE [id]]key] = 1"), update); + + Table table = new Table(); + table.setSchemaName("dbo"); + table.setName("users]archive"); + TableColumn column = new TableColumn(); + column.setName("display]name"); + table.setColumnList(List.of(column)); + assertTrue(builder.buildTemplate(table, "UPDATE") + .contains("UPDATE [dbo].[users]]archive] SET [display]]name] = ")); + } + + @Test + void shouldUseOneSqlServerLiteralEscaperForComments() { + String script = SQLConstant.buildTableComment("C:\\tmp\\O'Brien", "dbo", "orders"); + assertTrue(script.contains("N'C:\\tmp\\O''Brien'"), script); + assertFalse(script.contains("C:\\\\tmp"), script); + } + + private static final class ExposedBuilder extends SqlServerSqlBuilder { + private String tableName(String databaseName, String schemaName, String tableName) { + StringBuilder script = new StringBuilder(); + buildTableName(databaseName, schemaName, tableName, script); + return script.toString(); + } + } +}