diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/pom.xml b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/pom.xml index 3b0e93f665..4a90701922 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/pom.xml +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/pom.xml @@ -19,6 +19,11 @@ ai.chat2db chat2db-community-oracle + + org.junit.jupiter + junit-jupiter + test + chat2db-community-dm diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/DMDBManager.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/DMDBManager.java index 04ba0859ba..de26cd35f4 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/DMDBManager.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/DMDBManager.java @@ -1,6 +1,7 @@ package ai.chat2db.plugin.dm; import ai.chat2db.plugin.dm.enums.type.DMIndexTypeEnum; +import ai.chat2db.plugin.dm.identifier.DMIdentifierProcessor; import ai.chat2db.spi.IDbManager; import ai.chat2db.spi.IDbMetaData; import ai.chat2db.spi.DefaultDBManager; @@ -40,7 +41,7 @@ public class DMDBManager extends DefaultDBManager implements IDbManager { private String format(String tableName) { - return "\"" + tableName + "\""; + return DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName); } @@ -66,7 +67,7 @@ public void exportDatabase(Connection connection, String databaseName, String sc } private void exportTables(Connection connection, String databaseName, String schemaName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(SQL_SELECT_TABLE_NAME_ALL_TABLES, schemaName); + String sql = String.format(SQL_SELECT_TABLE_NAME_ALL_TABLES, DMIdentifierProcessor.INSTANCE.escapeString(schemaName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { while (resultSet.next()) { String tableName = resultSet.getString("TABLE_NAME"); @@ -77,7 +78,7 @@ private void exportTables(Connection connection, String databaseName, String sch @Override public void exportTable(Connection connection, String databaseName, String schemaName, String tableName, AsyncContext asyncContext) throws SQLException { - String tableDDLSql = String.format(tableDDL, tableName, schemaName); + String tableDDLSql = String.format(tableDDL, DMIdentifierProcessor.INSTANCE.escapeString(tableName), DMIdentifierProcessor.INSTANCE.escapeString(schemaName)); StringBuilder ddlBuilder = new StringBuilder(); DefaultSQLExecutor.getInstance().execute(connection, tableDDLSql, resultSet -> { if (resultSet.next()) { @@ -91,7 +92,7 @@ public void exportTable(Connection connection, String databaseName, String schem String tableComment = tables.get(0).getComment(); if (StringUtils.isNotBlank(tableComment)) { ddlBuilder.append(SQL_COMMENT_TABLE).append(format(schemaName)).append(".").append(format(tableName)) - .append(" IS '").append(tableComment.replace("'", "''")).append("'").append(";").append("\n"); + .append(" IS '").append(DMIdentifierProcessor.INSTANCE.escapeString(tableComment)).append("'").append(";").append("\n"); } } List columns = metaData.columns(connection, @@ -103,7 +104,7 @@ public void exportTable(Connection connection, String databaseName, String schem if (StringUtils.isNotBlank(comment)) { ddlBuilder.append(SQL_COMMENT_COLUMN).append(format(schemaName)).append(".").append(format(tableName)) .append(".").append(format(columnName)).append(" IS ") - .append("'").append(comment.replace("'", "''")) + .append("'").append(DMIdentifierProcessor.INSTANCE.escapeString(comment)) .append("';").append("\n"); } } @@ -135,7 +136,7 @@ public void exportTable(Connection connection, String databaseName, String schem && (CollectionUtils.isNotEmpty(uniqueConstraintIndexName) && !uniqueConstraintIndexName.contains(indexName))) { String sql = "select DBMS_METADATA.GET_DDL('INDEX','%s') as INDEX_DDL"; try { - DefaultSQLExecutor.getInstance().execute(connection, String.format(sql, indexName), resultSet -> { + DefaultSQLExecutor.getInstance().execute(connection, String.format(sql, DMIdentifierProcessor.INSTANCE.escapeString(indexName)), resultSet -> { if (resultSet.next()) { ddlBuilder.append(resultSet.getString("INDEX_DDL")).append("\n"); } @@ -167,7 +168,7 @@ private void exportViews(Connection connection, String schemaName, AsyncContext } private void exportView(Connection connection, String viewName, String schemaName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(SQL_SELECT_DBMS_METADATA_GET_DDL, viewName, schemaName); + String sql = String.format(SQL_SELECT_DBMS_METADATA_GET_DDL, DMIdentifierProcessor.INSTANCE.escapeString(viewName), DMIdentifierProcessor.INSTANCE.escapeString(schemaName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { if (resultSet.next()) { StringBuilder sqlBuilder = new StringBuilder(); @@ -187,7 +188,7 @@ private void exportProcedures(Connection connection, String schemaName, AsyncCon } private void exportProcedure(Connection connection, String schemaName, String procedureName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(ROUTINES_SQL, "PROC", schemaName, procedureName); + String sql = String.format(ROUTINES_SQL, "PROC", DMIdentifierProcessor.INSTANCE.escapeString(schemaName), DMIdentifierProcessor.INSTANCE.escapeString(procedureName)); try (PreparedStatement statement = connection.prepareStatement(sql); ResultSet resultSet = statement.executeQuery()) { if (resultSet.next()) { StringBuilder sqlBuilder = new StringBuilder(); @@ -198,7 +199,7 @@ private void exportProcedure(Connection connection, String schemaName, String pr } private void exportTriggers(Connection connection, String schemaName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(TRIGGER_SQL_LIST, schemaName); + String sql = String.format(TRIGGER_SQL_LIST, DMIdentifierProcessor.INSTANCE.escapeString(schemaName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { while (resultSet.next()) { String triggerName = resultSet.getString("TRIGGER_NAME"); @@ -208,7 +209,7 @@ private void exportTriggers(Connection connection, String schemaName, AsyncConte } private void exportTrigger(Connection connection, String schemaName, String triggerName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(TRIGGER_SQL, schemaName, triggerName); + String sql = String.format(TRIGGER_SQL, DMIdentifierProcessor.INSTANCE.escapeString(schemaName), DMIdentifierProcessor.INSTANCE.escapeString(triggerName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { if (resultSet.next()) { StringBuilder sqlBuilder = new StringBuilder(); @@ -226,14 +227,50 @@ public void connectDatabase(Connection connection, String database) { } String schemaName = connectInfo.getSchemaName(); try { - DefaultSQLExecutor.getInstance().execute(connection, String.format(SQL_SET_SCHEMA, schemaName)); + DefaultSQLExecutor.getInstance().execute(connection, + String.format(SQL_SET_SCHEMA, DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(schemaName))); } catch (SQLException e) { log.error("connectDatabase error", e); } } + @Override + public void copyTable(Connection connection, String databaseName, String schemaName, String tableName, + String newTableName, boolean copyData) throws SQLException { + String source = qualifiedName(schemaName, tableName, true); + String target = qualifiedName(schemaName, newTableName, true); + String sql; + if (copyData) { + sql = "CREATE TABLE " + target + " AS SELECT * FROM " + source; + } else { + sql = "CREATE TABLE " + target + " AS SELECT * FROM " + source + " WHERE 1=0"; + } + DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> null); + } + @Override public String dropTable(Connection connection, String databaseName, String schemaName, String tableName) { - return String.format(SQL_DROP_TABLE_EXISTS, tableName); + return String.format(SQL_DROP_TABLE_EXISTS, qualifiedName(schemaName, tableName, false)); + } + + @Override + public String truncateTable(Connection connection, String databaseName, String schemaName, String tableName) { + return "TRUNCATE TABLE " + qualifiedName(schemaName, tableName, true); + } + + private static String qualifiedName(String schemaName, String objectName, boolean normalizeQuotedObject) { + String normalizedObject = normalizeQuotedObject ? normalizeQuotedIdentifier(objectName) : objectName; + String quotedObject = DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(normalizedObject); + if (StringUtils.isBlank(schemaName)) { + return quotedObject; + } + return DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(schemaName) + "." + quotedObject; + } + + private static String normalizeQuotedIdentifier(String identifier) { + if (DMIdentifierProcessor.INSTANCE.isQuoteIdentifier(identifier)) { + return DMIdentifierProcessor.INSTANCE.removeIdentifierQuote(identifier); + } + return identifier; } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/DMMetaData.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/DMMetaData.java index e041d6a4eb..43e6422da4 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/DMMetaData.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/DMMetaData.java @@ -39,10 +39,6 @@ @Slf4j public class DMMetaData extends DefaultMetaService implements IDbMetaData { - - - private static final ISQLIdentifierProcessor DM_IDENTIFIER_PROCESSOR = new DMIdentifierProcessor(); - @Override public List schemas(Connection connection, String databaseName) { List schemas = DefaultSQLExecutor.getInstance().schemas(connection, databaseName, null); @@ -50,13 +46,13 @@ public List schemas(Connection connection, String databaseName) { } private String format(String tableName) { - return "\"" + tableName + "\""; + return DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName); } protected static String tableDDL = "SELECT dbms_metadata.get_ddl('TABLE', '%s','%s') as ddl FROM dual ;"; public String tableDDL(Connection connection, String databaseName, String schemaName, String tableName) { - String tableDDLSql = String.format(tableDDL, tableName, schemaName); + String tableDDLSql = String.format(tableDDL, getSQLIdentifierProcessor().escapeString(tableName), getSQLIdentifierProcessor().escapeString(schemaName)); StringBuilder ddlBuilder = new StringBuilder(); DefaultSQLExecutor.getInstance().execute(connection, tableDDLSql, resultSet -> { if (resultSet.next()) { @@ -69,7 +65,7 @@ public String tableDDL(Connection connection, String databaseName, String schema String tableComment = tables.get(0).getComment(); if (StringUtils.isNotBlank(tableComment)) { ddlBuilder.append(SQL_COMMENT_TABLE).append(format(schemaName)).append(".").append(format(tableName)) - .append(" IS '").append(tableComment.replace("'", "''")).append("'").append(";").append("\n"); + .append(" IS '").append(getSQLIdentifierProcessor().escapeString(tableComment)).append("'").append(";").append("\n"); } } List columns = this.columns(connection, databaseName, schemaName, tableName); @@ -80,7 +76,7 @@ public String tableDDL(Connection connection, String databaseName, String schema if (StringUtils.isNotBlank(comment)) { ddlBuilder.append(SQL_COMMENT_COLUMN).append(format(schemaName)).append(".").append(format(tableName)) .append(".").append(format(columnName)).append(" IS ") - .append("'").append(comment.replace("'", "''")) + .append("'").append(getSQLIdentifierProcessor().escapeString(comment)) .append("';").append("\n"); } } @@ -115,7 +111,7 @@ public String tableDDL(Connection connection, String databaseName, String schema if (StringUtils.isNotBlank(indexName) && !isPrimaryKey && !isUniqueConstraint) { String sql = "select DBMS_METADATA.GET_DDL('INDEX','%s') as INDEX_DDL"; try { - DefaultSQLExecutor.getInstance().execute(connection, String.format(sql, indexName), resultSet -> { + DefaultSQLExecutor.getInstance().execute(connection, String.format(sql, getSQLIdentifierProcessor().escapeString(indexName)), resultSet -> { if (resultSet.next()) { ddlBuilder.append(resultSet.getString("INDEX_DDL")).append("\n"); } @@ -138,7 +134,8 @@ public List columns(Connection connection, String databaseName, Str List columns = super.columns(connection, databaseName, schemaName, tableName); for (TableColumn column : columns) { String columnType = column.getColumnType(); - if (StringUtils.equals(columnType.toUpperCase(), DMColumnTypeEnum.TIMESTAMP.name())) { + if (columnType != null + && StringUtils.equals(columnType.toUpperCase(Locale.ROOT), DMColumnTypeEnum.TIMESTAMP.name())) { column.setColumnSize(column.getDecimalDigits()); } } @@ -151,7 +148,7 @@ public List columns(Connection connection, String databaseName, Str public Function function(Connection connection, @NotEmpty String databaseName, String schemaName, String functionName) { - String sql = String.format(ROUTINES_SQL, "PROC", schemaName, functionName); + String sql = String.format(ROUTINES_SQL, "PROC", getSQLIdentifierProcessor().escapeString(schemaName), getSQLIdentifierProcessor().escapeString(functionName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { StringBuilder sb = new StringBuilder(); while (resultSet.next()) { @@ -171,7 +168,7 @@ public Function function(Connection connection, @NotEmpty String databaseName, S @Override public Procedure procedure(Connection connection, @NotEmpty String databaseName, String schemaName, String procedureName) { - String sql = String.format(ROUTINES_SQL, "PROC", schemaName, procedureName); + String sql = String.format(ROUTINES_SQL, "PROC", getSQLIdentifierProcessor().escapeString(schemaName), getSQLIdentifierProcessor().escapeString(procedureName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { StringBuilder sb = new StringBuilder(); while (resultSet.next()) { @@ -193,7 +190,7 @@ public Procedure procedure(Connection connection, @NotEmpty String databaseName, @Override public List triggers(Connection connection, String databaseName, String schemaName) { List triggers = new ArrayList<>(); - String sql = String.format(TRIGGER_SQL_LIST, schemaName); + String sql = String.format(TRIGGER_SQL_LIST, getSQLIdentifierProcessor().escapeString(schemaName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { while (resultSet.next()) { Trigger trigger = new Trigger(); @@ -210,7 +207,7 @@ public List triggers(Connection connection, String databaseName, String public Trigger trigger(Connection connection, @NotEmpty String databaseName, String schemaName, String triggerName) { - String sql = String.format(TRIGGER_SQL, schemaName, triggerName); + String sql = String.format(TRIGGER_SQL, getSQLIdentifierProcessor().escapeString(schemaName), getSQLIdentifierProcessor().escapeString(triggerName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Trigger trigger = new Trigger(); trigger.setDatabaseName(databaseName); @@ -227,7 +224,7 @@ public Trigger trigger(Connection connection, @NotEmpty String databaseName, Str @Override public Table view(Connection connection, String databaseName, String schemaName, String viewName) { - String sql = String.format(VIEW_SQL, schemaName, viewName); + String sql = String.format(VIEW_SQL, getSQLIdentifierProcessor().escapeString(schemaName), getSQLIdentifierProcessor().escapeString(viewName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Table table = new Table(); table.setDatabaseName(databaseName); @@ -244,7 +241,7 @@ public Table view(Connection connection, String databaseName, String schemaName, @Override public List indexes(Connection connection, String databaseName, String schemaName, String tableName) { - String sql = String.format(INDEX_SQL, schemaName, tableName); + String sql = String.format(INDEX_SQL, getSQLIdentifierProcessor().escapeString(schemaName), getSQLIdentifierProcessor().escapeString(tableName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { LinkedHashMap map = new LinkedHashMap(); while (resultSet.next()) { @@ -319,12 +316,16 @@ public IValueProcessor getValueProcessor() { @Override public ISQLIdentifierProcessor getSQLIdentifierProcessor() { - return DM_IDENTIFIER_PROCESSOR; + return DMIdentifierProcessor.INSTANCE; } @Override public String getMetaDataName(String... names) { - return Arrays.stream(names).filter(name -> StringUtils.isNotBlank(name)).map(name -> "\"" + name + "\"").collect(Collectors.joining(".")); + if (names.length == 3) { + String qualifier = StringUtils.isNotBlank(names[1]) ? names[1] : names[0]; + return getMetaDataName(qualifier, names[2]); + } + return Arrays.stream(names).filter(name -> StringUtils.isNotBlank(name)).map(DMIdentifierProcessor.INSTANCE::quoteIdentifierAlways).collect(Collectors.joining(".")); } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/DMSqlGuards.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/DMSqlGuards.java new file mode 100644 index 0000000000..357c873b6c --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/DMSqlGuards.java @@ -0,0 +1,251 @@ +package ai.chat2db.plugin.dm; + +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Structural validation for DM SQL fragments that are emitted as syntax + * rather than as identifiers or string literals. + */ +public final class DMSqlGuards { + + private static final Set COLUMN_CLAUSE_KEYWORDS = Set.of( + "COLLATE", "CONSTRAINT", "CHECK", "DEFAULT", "DISABLE", "ENABLE", "GENERATED", + "IDENTITY", "INVISIBLE", "PRIMARY", "REFERENCES", "UNIQUE", "VISIBLE"); + + private DMSqlGuards() { + } + + /** + * Validates one DM DEFAULT expression without re-encoding serialized + * literals returned by metadata. + */ + public static String requireDefaultExpression(String value) { + if (StringUtils.isBlank(value)) { + throw invalid("DEFAULT expression", value); + } + scanExpression(value.trim(), false, "DEFAULT expression"); + return value; + } + + /** + * Validates one complete DM column type expression, including + * parameterized built-in and schema-qualified user-defined types. + */ + public static String requireColumnTypeExpression(String typeName) { + if (StringUtils.isBlank(typeName)) { + throw invalid("column type", typeName); + } + scanExpression(typeName.trim(), true, "column type"); + return typeName; + } + + public static String requireUnit(String unit) { + String trimmed = StringUtils.trimToEmpty(unit); + if (!"CHAR".equalsIgnoreCase(trimmed) && !"BYTE".equalsIgnoreCase(trimmed)) { + throw new IllegalArgumentException("Unsupported DM VARCHAR unit: " + unit); + } + return trimmed; + } + + public static String requireAscOrDesc(String value) { + String trimmed = StringUtils.trimToEmpty(value); + if ("ASC".equalsIgnoreCase(trimmed)) { + return "ASC"; + } + if ("DESC".equalsIgnoreCase(trimmed)) { + return "DESC"; + } + throw new IllegalArgumentException("Invalid DM index sort direction: " + value); + } + + public static String requireBitLiteral(String value) { + if (StringUtils.isBlank(value)) { + return "NULL"; + } + String trimmed = StringUtils.trimToEmpty(value); + if ("0".equals(trimmed) || "false".equalsIgnoreCase(trimmed)) { + return "0"; + } + if ("1".equals(trimmed) || "true".equalsIgnoreCase(trimmed)) { + return "1"; + } + throw new IllegalArgumentException("Invalid DM BIT literal: " + value); + } + + private static void scanExpression(String expression, boolean typeExpression, String description) { + Deque delimiters = new ArrayDeque<>(); + List topLevelWords = new ArrayList<>(); + boolean sawToken = false; + + for (int i = 0; i < expression.length(); i++) { + char c = expression.charAt(i); + if (Character.isISOControl(c)) { + throw invalid(description, expression); + } + if (Character.isWhitespace(c)) { + continue; + } + sawToken = true; + + if (isAlternativeQuoteStart(expression, i)) { + if (typeExpression) { + throw invalid(description, expression); + } + i = scanAlternativeQuote(expression, i, description); + continue; + } + if (c == '\'' || c == '"') { + if (typeExpression && c == '\'') { + throw invalid(description, expression); + } + int end = scanQuoted(expression, i, c, description); + if (c == '\'' && hasInvalidAttachedLiteralPrefix(expression, i, end)) { + throw invalid(description, expression); + } + i = end; + continue; + } + if (c == ';' + || startsWith(expression, i, "--") + || startsWith(expression, i, "/*") + || startsWith(expression, i, "*/")) { + throw invalid(description, expression); + } + if (c == '(') { + delimiters.push(c); + continue; + } + if (c == ')') { + if (delimiters.isEmpty()) { + throw invalid(description, expression); + } + delimiters.pop(); + continue; + } + if (c == '[' || c == ']' || c == '{' || c == '}') { + throw invalid(description, expression); + } + if (c == ',' && delimiters.isEmpty()) { + throw invalid(description, expression); + } + if (typeExpression && !isTypeCharacter(c)) { + throw invalid(description, expression); + } + if (Character.isLetter(c) || c == '_') { + int wordEnd = i + 1; + while (wordEnd < expression.length() && isWordCharacter(expression.charAt(wordEnd))) { + wordEnd++; + } + if (delimiters.isEmpty()) { + topLevelWords.add(expression.substring(i, wordEnd).toUpperCase(Locale.ROOT)); + } + i = wordEnd - 1; + } + } + + if (!sawToken || !delimiters.isEmpty()) { + throw invalid(description, expression); + } + rejectColumnClauseTokens(topLevelWords, description, expression); + } + + private static int scanQuoted(String expression, int start, char quote, String description) { + for (int i = start + 1; i < expression.length(); i++) { + if (expression.charAt(i) == quote) { + if (i + 1 < expression.length() && expression.charAt(i + 1) == quote) { + i++; + continue; + } + return i; + } + } + throw invalid(description, expression); + } + + private static boolean hasInvalidAttachedLiteralPrefix(String expression, int quoteStart, int quoteEnd) { + if (quoteStart == 0 || Character.isWhitespace(expression.charAt(quoteStart - 1))) { + return false; + } + if (!isWordCharacter(expression.charAt(quoteStart - 1))) { + return false; + } + int prefixStart = quoteStart - 1; + while (prefixStart > 0 && isWordCharacter(expression.charAt(prefixStart - 1))) { + prefixStart--; + } + String prefix = expression.substring(prefixStart, quoteStart); + if ("N".equalsIgnoreCase(prefix)) { + return false; + } + if (!"X".equalsIgnoreCase(prefix)) { + return true; + } + for (int i = quoteStart + 1; i < quoteEnd; i++) { + char c = expression.charAt(i); + if ((c < '0' || c > '9') && (c < 'A' || c > 'F') && (c < 'a' || c > 'f')) { + return true; + } + } + return false; + } + + private static boolean isAlternativeQuoteStart(String expression, int offset) { + return offset + 2 < expression.length() + && (expression.charAt(offset) == 'q' || expression.charAt(offset) == 'Q') + && expression.charAt(offset + 1) == '\''; + } + + private static int scanAlternativeQuote(String expression, int start, String description) { + char open = expression.charAt(start + 2); + char close = switch (open) { + case '[' -> ']'; + case '{' -> '}'; + case '(' -> ')'; + case '<' -> '>'; + default -> open; + }; + for (int i = start + 3; i + 1 < expression.length(); i++) { + if (expression.charAt(i) == close && expression.charAt(i + 1) == '\'') { + return i + 1; + } + } + throw invalid(description, expression); + } + + private static 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++) { + if ("NOT".equals(words.get(i)) && "NULL".equals(words.get(i + 1))) { + throw invalid(description, expression); + } + } + } + + private static boolean isTypeCharacter(char c) { + return Character.isLetterOrDigit(c) || c == '_' || c == '$' || c == '#' + || c == '.' || c == '%' || c == '*' || c == '+' || c == '-' || c == ','; + } + + private static boolean isWordCharacter(char c) { + return Character.isLetterOrDigit(c) || c == '_' || c == '$' || c == '#'; + } + + private static boolean startsWith(String value, int offset, String candidate) { + return offset + candidate.length() <= value.length() && value.startsWith(candidate, offset); + } + + private static IllegalArgumentException invalid(String description, String value) { + return new IllegalArgumentException("Invalid DM " + description + ": " + value); + } +} diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/builder/DMSqlBuilder.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/builder/DMSqlBuilder.java index 4920f62517..eae8c148fd 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/builder/DMSqlBuilder.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/builder/DMSqlBuilder.java @@ -2,11 +2,14 @@ import ai.chat2db.spi.constant.SQLConstants; +import ai.chat2db.plugin.dm.identifier.DMIdentifierProcessor; import ai.chat2db.plugin.dm.enums.type.DMColumnTypeEnum; import ai.chat2db.plugin.dm.enums.type.DMIndexTypeEnum; +import ai.chat2db.community.domain.api.enums.plugin.DmlTypeEnum; import ai.chat2db.community.domain.api.enums.plugin.EditStatusEnum; import ai.chat2db.spi.DefaultSqlBuilder; import ai.chat2db.spi.model.request.PageLimitRequest; +import ai.chat2db.spi.model.request.UpdateSqlRequest; import ai.chat2db.community.domain.api.model.account.*; import ai.chat2db.community.domain.api.model.async.*; import ai.chat2db.community.domain.api.config.*; @@ -19,39 +22,77 @@ import ai.chat2db.community.domain.api.model.view.*; import ai.chat2db.community.domain.api.config.TableBuilderConfig; 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.Objects; +import java.util.stream.Collectors; import static ai.chat2db.plugin.dm.constant.DMSqlBuilderConstants.*; public class DMSqlBuilder extends DefaultSqlBuilder { + @Override + public String quoteIdentifier(String identifier) { + return DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(identifier); + } + @Override + public String quoteQualifiedIdentifier(String... identifiers) { + if (identifiers.length == 3) { + String qualifier = StringUtils.isNotBlank(identifiers[1]) ? identifiers[1] : identifiers[0]; + return quoteQualifiedIdentifier(qualifier, identifiers[2]); + } + return Arrays.stream(identifiers) + .filter(StringUtils::isNotBlank) + .map(DMIdentifierProcessor.INSTANCE::quoteIdentifierAlways) + .collect(Collectors.joining(SQLConstants.DOT)); + } - - - - - - - + @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.getSchemaName(), table.getName()); + List columnNames = table.getColumnList().stream() + .map(column -> quoteIdentifier(column.getName())) + .toList(); + if (DmlTypeEnum.INSERT.name().equalsIgnoreCase(type)) { + return "INSERT INTO " + tableName + " (" + String.join(SQLConstants.COMMA, columnNames) + + ") VALUES (" + columnNames.stream().map(name -> SQLConstants.SPACE) + .collect(Collectors.joining(SQLConstants.COMMA)) + ")"; + } + if (DmlTypeEnum.UPDATE.name().equalsIgnoreCase(type)) { + return "UPDATE " + tableName + " SET " + columnNames.stream() + .map(name -> name + SQLConstants.EQUAL_SQL + SQLConstants.SPACE) + .collect(Collectors.joining(SQLConstants.COMMA)) + " WHERE "; + } + if (DmlTypeEnum.DELETE.name().equalsIgnoreCase(type)) { + return "DELETE FROM " + tableName + " WHERE "; + } + if (DmlTypeEnum.SELECT.name().equalsIgnoreCase(type)) { + return "SELECT " + String.join(SQLConstants.COMMA, columnNames) + " FROM " + tableName; + } + return SQLConstants.EMPTY; + } @Override public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfig) { StringBuilder script = new StringBuilder(); - script.append(SQL_CREATE_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(table.getSchemaName()).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(table.getName()).append(VALUE_DOUBLE_QUOTE_OPEN_PAREN).append(SQLConstants.LINE_SEPARATOR); + script.append(SQL_CREATE_TABLE) + .append(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())) { continue; } DMColumnTypeEnum typeEnum = DMColumnTypeEnum.getByType(column.getColumnType()); - if(typeEnum == null){ - continue; - } + typeEnum = typeEnum == null ? DMColumnTypeEnum.VARCHAR : typeEnum; script.append(SQLConstants.TAB).append(typeEnum.buildCreateColumnSql(column)).append(SQLConstants.COMMA_LINE_SEPARATOR); } @@ -98,16 +139,16 @@ public String buildAITableSchema(Table table) { } StringBuilder script = new StringBuilder(); - script.append(SQL_CREATE_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(table.getSchemaName()).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(table.getName()).append(VALUE_DOUBLE_QUOTE_OPEN_PAREN).append(SQLConstants.LINE_SEPARATOR); + script.append(SQL_CREATE_TABLE) + .append(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())) { continue; } DMColumnTypeEnum typeEnum = DMColumnTypeEnum.getByType(column.getColumnType()); - if(typeEnum == null){ - continue; - } + typeEnum = typeEnum == null ? DMColumnTypeEnum.VARCHAR : typeEnum; script.append(SQLConstants.TAB).append(typeEnum.buildAICreateColumnSql(column)).append(SQLConstants.COMMA_LINE_SEPARATOR); } @@ -135,24 +176,27 @@ public String buildAITableSchema(Table table) { } private String buildTableComment(Table table) { - StringBuilder script = new StringBuilder(); - script.append(SQL_COMMENT_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(table.getSchemaName()).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(table.getName()).append(VALUE_DOUBLE_QUOTE_IS_SINGLE_QUOTE).append(table.getComment()).append(SQLConstants.SINGLE_QUOTE); - return script.toString(); + return SQL_COMMENT_TABLE + + quoteQualifiedIdentifier(table.getDatabaseName(), table.getSchemaName(), table.getName()) + + " IS " + quoteStringLiteral(table.getComment()); } private String buildComment(TableColumn column) { - StringBuilder script = new StringBuilder(); - script.append(SQL_COMMENT_COLUMN).append(SQLConstants.DOUBLE_QUOTE).append(column.getSchemaName()).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(column.getTableName()).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(column.getName()).append(VALUE_DOUBLE_QUOTE_IS_SINGLE_QUOTE).append(column.getComment()).append(SQLConstants.SINGLE_QUOTE); - return script.toString(); + return SQL_COMMENT_COLUMN + + quoteQualifiedIdentifier(column.getSchemaName(), column.getTableName()) + + SQLConstants.DOT + quoteIdentifier(column.getName()) + + " IS " + quoteStringLiteral(column.getComment()); } @Override public String buildAlterTable(Table oldTable, Table newTable) { StringBuilder script = new StringBuilder(); - if (!StringUtils.equalsIgnoreCase(oldTable.getName(), newTable.getName())) { - script.append(SQL_ALTER_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(oldTable.getSchemaName()).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(oldTable.getName()).append(SQLConstants.DOUBLE_QUOTE); - script.append(SQLConstants.SPACE).append(SQL_RENAME).append(SQLConstants.DOUBLE_QUOTE).append(newTable.getName()).append(SQLConstants.DOUBLE_QUOTE).append(SQLConstants.SEMICOLON_LINE_SEPARATOR); + if (!StringUtils.equals(oldTable.getName(), newTable.getName())) { + script.append(SQL_ALTER_TABLE) + .append(quoteQualifiedIdentifier(oldTable.getDatabaseName(), oldTable.getSchemaName(), oldTable.getName())); + script.append(SQLConstants.SPACE).append(SQL_RENAME).append(quoteIdentifier(newTable.getName())) + .append(SQLConstants.SEMICOLON_LINE_SEPARATOR); } if (!StringUtils.equalsIgnoreCase(oldTable.getComment(), newTable.getComment())) { script.append(SQLConstants.EMPTY).append(buildTableComment(newTable)).append(SQLConstants.SEMICOLON_LINE_SEPARATOR); @@ -161,9 +205,7 @@ public String buildAlterTable(Table oldTable, Table newTable) { String editStatus = tableColumn.getEditStatus(); if (StringUtils.isNotBlank(editStatus)) { DMColumnTypeEnum typeEnum = DMColumnTypeEnum.getByType(tableColumn.getColumnType()); - if(typeEnum == null){ - continue; - } + typeEnum = typeEnum == null ? DMColumnTypeEnum.VARCHAR : typeEnum; script.append(SQLConstants.TAB).append(typeEnum.buildModifyColumn(tableColumn)).append(SQLConstants.SEMICOLON_LINE_SEPARATOR); if (StringUtils.isNotBlank(tableColumn.getComment())&&!Objects.equals(EditStatusEnum.DELETE.toString(),editStatus)) { script.append(SQLConstants.LINE_SEPARATOR).append(buildComment(tableColumn)).append(SQLConstants.SEMICOLON_LINE_SEPARATOR); @@ -208,14 +250,50 @@ public String buildPageLimit(PageLimitRequest request) { return sqlStr.toString(); } + @Override + protected void buildTableName(String databaseName, String schemaName, String tableName, StringBuilder script) { + script.append(quoteQualifiedIdentifier(databaseName, schemaName, tableName)); + } + + @Override + protected void buildColumns(List columnList, StringBuilder script) { + if (CollectionUtils.isNotEmpty(columnList)) { + script.append(SQLConstants.SPACE_OPEN_PARENTHESIS) + .append(columnList.stream().map(this::quoteIdentifier) + .collect(Collectors.joining(SQLConstants.COMMA))) + .append(SQLConstants.CLOSE_PARENTHESIS); + } + } + + @Override + public String buildUpdate(UpdateSqlRequest request) { + StringBuilder script = new StringBuilder("UPDATE "); + buildTableName(request.getDatabaseName(), request.getSchemaName(), request.getTableName(), script); + script.append(" SET "); + script.append(request.getRow().entrySet().stream() + .map(entry -> quoteIdentifier(entry.getKey()) + SQLConstants.EQUAL_SQL + entry.getValue()) + .collect(Collectors.joining(SQLConstants.COMMA))); + if (MapUtils.isNotEmpty(request.getPrimaryKeyMap())) { + script.append(" WHERE "); + script.append(request.getPrimaryKeyMap().entrySet().stream() + .map(entry -> quoteIdentifier(entry.getKey()) + SQLConstants.EQUAL_SQL + entry.getValue()) + .collect(Collectors.joining(SQLConstants.SQL_AND))); + } + return script.toString(); + } + @Override public String buildCreateSchema(Schema schema) { StringBuilder sqlBuilder = new StringBuilder(); - sqlBuilder.append(SQL_CREATE_SCHEMA+schema.getName()+SQLConstants.DOUBLE_QUOTE); + sqlBuilder.append(SQL_CREATE_SCHEMA).append(quoteIdentifier(schema.getName())); if(StringUtils.isNotBlank(schema.getOwner())){ - sqlBuilder.append(SQLConstants.SCHEMA_AUTHORIZATION_SQL).append(schema.getOwner()); + sqlBuilder.append(SQLConstants.SCHEMA_AUTHORIZATION_SQL).append(quoteIdentifier(schema.getOwner())); } return sqlBuilder.toString(); } + + private static String quoteStringLiteral(String value) { + return DMIdentifierProcessor.INSTANCE.quoteStringLiteral(value); + } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/constant/DMDBManagerConstants.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/constant/DMDBManagerConstants.java index 14bc656884..14848f7e43 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/constant/DMDBManagerConstants.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/constant/DMDBManagerConstants.java @@ -35,7 +35,7 @@ public final class DMDBManagerConstants { public static final String SQL_COMMENT_COLUMN = "COMMENT ON COLUMN "; public static final String SQL_COMMENT_TABLE = "COMMENT ON TABLE "; public static final String SQL_DROP_TABLE_EXISTS = "DROP TABLE IF EXISTS %s"; - public static final String SQL_SET_SCHEMA = "SET SCHEMA \"%s\""; + public static final String SQL_SET_SCHEMA = "SET SCHEMA %s"; public static final String SQL_SELECT_DBMS_METADATA_GET_DDL = "SELECT DBMS_METADATA.GET_DDL('VIEW','%s','%s') as ddl FROM DUAL;"; public static final String SQL_SELECT_TABLE_NAME_ALL_TABLES = "SELECT TABLE_NAME FROM ALL_TABLES where OWNER='%s' "; public static final String ROUTINES_SQL = "SELECT OWNER, NAME, TEXT FROM ALL_SOURCE WHERE TYPE = '%s' AND OWNER = '%s' AND NAME = '%s' ORDER BY LINE"; diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/constant/DMSqlBuilderConstants.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/constant/DMSqlBuilderConstants.java index 893445b707..1f09273913 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/constant/DMSqlBuilderConstants.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/constant/DMSqlBuilderConstants.java @@ -32,7 +32,7 @@ public final class DMSqlBuilderConstants { public static final String SQL_ALTER_TABLE = "ALTER TABLE "; public static final String SQL_COMMENT_COLUMN = "COMMENT ON COLUMN "; public static final String SQL_COMMENT_TABLE = "COMMENT ON TABLE "; - public static final String SQL_CREATE_SCHEMA = "CREATE SCHEMA \""; + public static final String SQL_CREATE_SCHEMA = "CREATE SCHEMA "; public static final String SQL_CREATE_TABLE = "CREATE TABLE "; public static final String SQL_LIMIT = " LIMIT "; public static final String SQL_OFFSET = " OFFSET "; diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/enums/type/DMColumnTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/enums/type/DMColumnTypeEnum.java index 55e02416a6..eab5732bb4 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/enums/type/DMColumnTypeEnum.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/enums/type/DMColumnTypeEnum.java @@ -1,15 +1,17 @@ package ai.chat2db.plugin.dm.enums.type; +import ai.chat2db.plugin.dm.DMSqlGuards; +import ai.chat2db.plugin.dm.identifier.DMIdentifierProcessor; 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 static ai.chat2db.plugin.dm.constant.DMColumnTypeEnumConstants.*; @@ -129,8 +131,10 @@ public enum DMColumnTypeEnum implements IColumnBuilder { private ColumnType columnType; public static DMColumnTypeEnum getByType(String dataType) { - String type = SqlUtils.removeDigits(dataType.toUpperCase()); - return COLUMN_TYPE_MAP.get(type); + if (StringUtils.isBlank(dataType)) { + return null; + } + return COLUMN_TYPE_MAP.get(StringUtils.normalizeSpace(dataType).toUpperCase(Locale.ROOT)); } private static Map COLUMN_TYPE_MAP = Maps.newHashMap(); @@ -152,13 +156,13 @@ public ColumnType getColumnType() { @Override public String buildCreateColumnSql(TableColumn column) { - DMColumnTypeEnum type = COLUMN_TYPE_MAP.get(column.getColumnType().toUpperCase()); + DMColumnTypeEnum type = getByType(column.getColumnType()); if (type == null) { - return buildDefaultColumn(column, false); + return buildUnknownColumnSql(column); } StringBuilder script = new StringBuilder(); - script.append("\"").append(column.getName()).append("\"").append(" "); + script.append(DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName())).append(" "); script.append(buildDataType(column, type)).append(" "); @@ -173,13 +177,13 @@ public String buildCreateColumnSql(TableColumn column) { @Override public String buildAICreateColumnSql(TableColumn column) { - DMColumnTypeEnum type = COLUMN_TYPE_MAP.get(column.getColumnType().toUpperCase()); + DMColumnTypeEnum type = getByType(column.getColumnType()); if (type == null) { - return buildDefaultColumn(column, false); + return buildUnknownColumnSql(column); } StringBuilder script = new StringBuilder(); - script.append("\"").append(column.getName()).append("\"").append(" "); + script.append(DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName())).append(" "); script.append(buildDataType(column, type)).append(" "); @@ -232,7 +236,7 @@ private String buildDefaultValue(TableColumn column, DMColumnTypeEnum type) { return StringUtils.join("DEFAULT NULL"); } - return StringUtils.join("DEFAULT ", column.getDefaultValue()); + return StringUtils.join("DEFAULT ", DMSqlGuards.requireDefaultExpression(column.getDefaultValue())); } private String buildDataType(TableColumn column, DMColumnTypeEnum type) { @@ -243,7 +247,8 @@ private String buildDataType(TableColumn column, DMColumnTypeEnum type) { if (column.getColumnSize() != null && StringUtils.isEmpty(column.getUnit())) { script.append("(").append(column.getColumnSize()).append(")"); } else if (column.getColumnSize() != null && !StringUtils.isEmpty(column.getUnit())) { - script.append("(").append(column.getColumnSize()).append(" ").append(column.getUnit()).append(")"); + script.append("(").append(column.getColumnSize()).append(" ") + .append(DMSqlGuards.requireUnit(column.getUnit())).append(")"); } return script.toString(); } @@ -297,25 +302,29 @@ public String buildModifyColumn(TableColumn tableColumn) { if (EditStatusEnum.DELETE.name().equals(tableColumn.getEditStatus())) { StringBuilder script = new StringBuilder(); - script.append(SQL_ALTER_TABLE).append("\"").append(tableColumn.getSchemaName()).append("\".\"").append(tableColumn.getTableName()).append("\""); - script.append(" ").append(SQL_DROP_COLUMN).append("\"").append(tableColumn.getName()).append("\""); + script.append(SQL_ALTER_TABLE).append(qualifiedTableName(tableColumn)); + script.append(" ").append(SQL_DROP_COLUMN) + .append(DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableColumn.getName())); return script.toString(); } if (EditStatusEnum.ADD.name().equals(tableColumn.getEditStatus())) { StringBuilder script = new StringBuilder(); - script.append(SQL_ALTER_TABLE).append("\"").append(tableColumn.getSchemaName()).append("\".\"").append(tableColumn.getTableName()).append("\""); + script.append(SQL_ALTER_TABLE).append(qualifiedTableName(tableColumn)); script.append(" ").append("ADD (").append(buildCreateColumnSql(tableColumn)).append(")"); return script.toString(); } if (EditStatusEnum.MODIFY.name().equals(tableColumn.getEditStatus())) { StringBuilder script = new StringBuilder(); if (!StringUtils.equals(tableColumn.getOldName(), tableColumn.getName())) { - script.append(SQL_ALTER_TABLE).append("\"").append(tableColumn.getSchemaName()).append("\".\"").append(tableColumn.getTableName()).append("\""); - script.append(" ").append(SQL_RENAME_COLUMN).append("\"").append(tableColumn.getOldName()).append("\"").append(" TO ").append("\"").append(tableColumn.getName()).append("\""); + script.append(SQL_ALTER_TABLE).append(qualifiedTableName(tableColumn)); + script.append(" ").append(SQL_RENAME_COLUMN) + .append(DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableColumn.getOldName())) + .append(" TO ") + .append(DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableColumn.getName())); script.append(";\n"); } - script.append(SQL_ALTER_TABLE).append("\"").append(tableColumn.getSchemaName()).append("\".\"").append(tableColumn.getTableName()).append("\""); + script.append(SQL_ALTER_TABLE).append(qualifiedTableName(tableColumn)); script.append(" ").append("MODIFY (").append(buildCreateColumnSql(tableColumn)).append(") \n"); return script.toString(); @@ -324,6 +333,35 @@ public String buildModifyColumn(TableColumn tableColumn) { return ""; } + private static String buildUnknownColumnSql(TableColumn column) { + StringBuilder script = new StringBuilder(); + script.append(DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName())) + .append(" ") + .append(DMSqlGuards.requireColumnTypeExpression(column.getColumnType())); + if (StringUtils.isNotEmpty(column.getDefaultValue())) { + String defaultValue = column.getDefaultValue(); + if ("EMPTY_STRING".equalsIgnoreCase(defaultValue.trim())) { + script.append(" DEFAULT ''"); + } else if ("NULL".equalsIgnoreCase(defaultValue.trim())) { + script.append(" DEFAULT NULL"); + } else { + script.append(" DEFAULT ").append(DMSqlGuards.requireDefaultExpression(defaultValue)); + } + } + if (column.getNullable() != null) { + script.append(column.getNullable() == 1 ? " NULL" : " NOT NULL"); + } + return script.toString(); + } + + private static String qualifiedTableName(TableColumn column) { + String tableName = DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getTableName()); + if (StringUtils.isBlank(column.getSchemaName())) { + return tableName; + } + return DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getSchemaName()) + "." + tableName; + } + public static List getTypes() { return Arrays.stream(DMColumnTypeEnum.values()).map(columnTypeEnum -> columnTypeEnum.getColumnType() diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/enums/type/DMIndexTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/enums/type/DMIndexTypeEnum.java index 873a931460..e291be7396 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/enums/type/DMIndexTypeEnum.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/enums/type/DMIndexTypeEnum.java @@ -1,5 +1,7 @@ package ai.chat2db.plugin.dm.enums.type; +import ai.chat2db.plugin.dm.DMSqlGuards; +import ai.chat2db.plugin.dm.identifier.DMIdentifierProcessor; import ai.chat2db.community.domain.api.enums.plugin.EditStatusEnum; import ai.chat2db.community.domain.api.model.metadata.IndexType; import ai.chat2db.community.domain.api.model.metadata.TableIndex; @@ -72,14 +74,18 @@ public static DMIndexTypeEnum getByType(String type) { public String buildIndexScript(TableIndex tableIndex) { StringBuilder script = new StringBuilder(); if (PRIMARY_KEY.equals(this)) { - script.append(SQL_ALTER_TABLE_2).append(tableIndex.getSchemaName()).append("\".\"").append(tableIndex.getTableName()).append("\" ADD PRIMARY KEY ").append(buildIndexColumn(tableIndex)); + script.append(SQL_ALTER_TABLE) + .append(qualifiedName(tableIndex.getSchemaName(), tableIndex.getTableName())) + .append(" ADD PRIMARY KEY ").append(buildIndexColumn(tableIndex)); } else { if (UNIQUE.equals(this)) { script.append(SQL_CREATE_UNIQUE_INDEX); } else { script.append(SQL_CREATE_INDEX); } - script.append(buildIndexName(tableIndex)).append(SQL_ON).append(tableIndex.getSchemaName()).append("\".\"").append(tableIndex.getTableName()).append("\" ").append(buildIndexColumn(tableIndex)); + script.append(buildIndexName(tableIndex)).append(" ON ") + .append(qualifiedName(tableIndex.getSchemaName(), tableIndex.getTableName())) + .append(" ").append(buildIndexColumn(tableIndex)); } return script.toString(); } @@ -88,22 +94,27 @@ public String buildIndexScript(TableIndex tableIndex) { private String buildIndexColumn(TableIndex tableIndex) { StringBuilder script = new StringBuilder(); script.append("("); + boolean hasColumn = false; for (TableIndexColumn column : tableIndex.getColumnList()) { if (StringUtils.isNotBlank(column.getColumnName())) { - script.append("\"").append(column.getColumnName()).append("\""); + hasColumn = true; + script.append(DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getColumnName())); if (!StringUtils.isBlank(column.getAscOrDesc()) && !PRIMARY_KEY.equals(this)) { - script.append(" ").append(column.getAscOrDesc()); + script.append(" ").append(DMSqlGuards.requireAscOrDesc(column.getAscOrDesc())); } script.append(","); } } + if (!hasColumn) { + throw new IllegalArgumentException("DM index must contain at least one named column"); + } script.deleteCharAt(script.length() - 1); script.append(")"); return script.toString(); } private String buildIndexName(TableIndex tableIndex) { - return "\"" + tableIndex.getSchemaName() + "\"." + "\"" + tableIndex.getName() + "\""; + return qualifiedName(tableIndex.getSchemaName(), tableIndex.getName()); } public String buildModifyIndex(TableIndex tableIndex) { @@ -121,7 +132,7 @@ public String buildModifyIndex(TableIndex tableIndex) { private String buildDropIndex(TableIndex tableIndex) { if (DMIndexTypeEnum.PRIMARY_KEY.getName().equals(tableIndex.getType())) { - String tableName = "\"" + tableIndex.getSchemaName() + "\"." + "\"" + tableIndex.getTableName() + "\""; + String tableName = qualifiedName(tableIndex.getSchemaName(), tableIndex.getTableName()); return StringUtils.join(SQL_ALTER_TABLE,tableName,SQL_DROP_PRIMARY_KEY); } StringBuilder script = new StringBuilder(); @@ -131,6 +142,14 @@ private String buildDropIndex(TableIndex tableIndex) { return script.toString(); } + private static String qualifiedName(String schemaName, String objectName) { + String quotedObject = DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(objectName); + if (StringUtils.isBlank(schemaName)) { + return quotedObject; + } + return DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(schemaName) + "." + quotedObject; + } + public static List getIndexTypes() { return Arrays.asList(DMIndexTypeEnum.values()).stream().map(DMIndexTypeEnum::getIndexType).collect(java.util.stream.Collectors.toList()); } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/identifier/DMIdentifierProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/identifier/DMIdentifierProcessor.java index e84c08e74f..765933ede9 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/identifier/DMIdentifierProcessor.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/identifier/DMIdentifierProcessor.java @@ -4,10 +4,16 @@ import org.apache.commons.lang3.StringUtils; import java.util.HashSet; +import java.util.Locale; import java.util.Set; public class DMIdentifierProcessor extends DefaultSQLIdentifierProcessor { + /** + * Shared stateless instance for call sites without MetaData access. + */ + public static final DMIdentifierProcessor INSTANCE = new DMIdentifierProcessor(); + public static final Set DM_RESERVED_KEYWORDS = new HashSet<>(); static { @@ -249,50 +255,117 @@ public class DMIdentifierProcessor extends DefaultSQLIdentifierProcessor { @Override public boolean isReservedKeyword(String identifier, Integer majorVersion, Integer minorVersion) { - return DM_RESERVED_KEYWORDS.contains(identifier); + return identifier != null && DM_RESERVED_KEYWORDS.contains(identifier.toUpperCase(Locale.ROOT)); } + /** + * SPI-facing conditional quoting: {@code null} and blank identifiers are + * returned unchanged; identifiers that are already valid for the dialect + * and are not reserved keywords are returned unquoted; anything else is + * wrapped with double quotes, stripping one surrounding quote pair and + * doubling every embedded double quote. + */ @Override public String quoteIdentifier(String identifier, Integer majorVersion, Integer minorVersion) { - if (isValidIdentifier(identifier)) { - if (containsLowerCase(identifier) || isReservedKeyword(identifier.toUpperCase(), majorVersion, minorVersion)) { - return StringUtils.wrap(identifier, '"'); - } - return identifier; - } - return StringUtils.wrap(identifier, '"'); - + return quoteIdentifier(identifier); } @Override public String quoteIdentifier(String identifier) { - if (isValidIdentifier(identifier)) { - if (containsLowerCase(identifier) || isReservedKeyword(identifier.toUpperCase(), null, null)) { - return StringUtils.wrap(identifier, '"'); - } + if (identifier == null) { + return null; + } + if (StringUtils.isBlank(identifier)) { return identifier; } - return StringUtils.wrap(identifier, '"'); - + if (isValidQuotedIdentifier(identifier)) { + return identifier; + } + if (isValidIdentifier(identifier) + && !containsLowerCase(identifier) + && !isReservedKeyword(identifier, null, null)) { + return identifier; + } + return quoteIdentifierAlways(identifier); } + /** + * Unconditional quoting for DDL-generation call sites: wraps with double + * quotes and doubling every embedded double quote, including quotes at the + * raw name boundaries. Returns {@code null} for {@code null}. + */ @Override public String quoteIdentifierIgnoreCase(String identifier) { - if (isValidIdentifier(identifier)) { - if (isReservedKeyword(identifier.toUpperCase(), null, null)) { - return StringUtils.wrap(identifier, '"'); - } + if (identifier == null) { + return null; + } + if (StringUtils.isBlank(identifier)) { + return identifier; + } + if (isValidQuotedIdentifier(identifier)) { + return identifier; + } + if (isValidIdentifier(identifier) && !isReservedKeyword(identifier, null, null)) { return identifier; } - return StringUtils.wrap(identifier, '"'); + return quoteIdentifierAlways(identifier); + } + + /** + * Escapes a value interpolated into a single-quoted SQL string literal by + * doubling every single quote. Returns {@code null} for {@code null}. + */ + @Override + public String escapeString(String str) { + return StringUtils.replace(str, "'", "''"); } @Override public String convertIdentifierCase(String identifier) { if (StringUtils.isBlank(identifier)) { return identifier; - }else { - return identifier.toUpperCase(); + } else { + return identifier.toUpperCase(Locale.ROOT); + } + } + + private static String escapeIdentifierContent(String identifier) { + return identifier == null ? null : StringUtils.replace(identifier, "\"", "\"\""); + } + + /** + * Escapes identifier content for a position already surrounded by double + * quotes. Returns {@code null} for {@code null}. + */ + public static String escapeIdentifier(String identifier) { + return escapeIdentifierContent(identifier); + } + + @Override + public String quoteIdentifierAlways(String identifier) { + if (identifier == null) { + return null; + } + return "\"" + escapeIdentifierContent(identifier) + "\""; + } + + public String quoteStringLiteral(String value) { + return value == null ? null : "'" + escapeString(value) + "'"; + } + + private static boolean isValidQuotedIdentifier(String identifier) { + if (identifier.length() < 2 || identifier.charAt(0) != '"' + || identifier.charAt(identifier.length() - 1) != '"') { + return false; + } + for (int i = 1; i < identifier.length() - 1; i++) { + if (identifier.charAt(i) == '"') { + if (i + 1 >= identifier.length() - 1 || identifier.charAt(i + 1) != '"') { + return false; + } + i++; + } } + return true; } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/value/sub/DMBitProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/value/sub/DMBitProcessor.java index 649082411d..c4cc4c54af 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/value/sub/DMBitProcessor.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/value/sub/DMBitProcessor.java @@ -1,5 +1,6 @@ package ai.chat2db.plugin.dm.value.sub; +import ai.chat2db.plugin.dm.DMSqlGuards; 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 DMBitProcessor extends DefaultValueProcessor { @Override public String convertSQLValueByType(SQLDataValue dataValue) { - return dataValue.getValue(); + return DMSqlGuards.requireBitLiteral(dataValue.getValue()); } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/test/java/ai/chat2db/plugin/dm/DMIdentifierProcessorTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/test/java/ai/chat2db/plugin/dm/DMIdentifierProcessorTest.java new file mode 100644 index 0000000000..d5a8833709 --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/test/java/ai/chat2db/plugin/dm/DMIdentifierProcessorTest.java @@ -0,0 +1,367 @@ +package ai.chat2db.plugin.dm; + +import ai.chat2db.community.domain.api.model.metadata.DataType; +import ai.chat2db.community.domain.api.enums.plugin.DmlTypeEnum; +import ai.chat2db.community.domain.api.model.metadata.Schema; +import ai.chat2db.community.domain.api.model.metadata.Table; +import ai.chat2db.community.domain.api.model.metadata.TableColumn; +import ai.chat2db.community.domain.api.model.metadata.TableIndex; +import ai.chat2db.community.domain.api.model.metadata.TableIndexColumn; +import ai.chat2db.community.domain.api.model.value.SQLDataValue; +import ai.chat2db.plugin.dm.builder.DMSqlBuilder; +import ai.chat2db.plugin.dm.enums.type.DMColumnTypeEnum; +import ai.chat2db.plugin.dm.enums.type.DMIndexTypeEnum; +import ai.chat2db.plugin.dm.identifier.DMIdentifierProcessor; +import ai.chat2db.plugin.dm.value.DMValueProcessor; +import ai.chat2db.plugin.dm.value.sub.DMBitProcessor; +import ai.chat2db.spi.model.request.DropTableRequest; +import ai.chat2db.spi.model.request.SingleInsertSqlRequest; +import ai.chat2db.spi.model.request.TruncateTableRequest; +import ai.chat2db.spi.model.request.UpdateSqlRequest; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DMIdentifierProcessorTest { + + @Test + void quoteIdentifierPassesThroughNullAndBlank() { + assertNull(DMIdentifierProcessor.INSTANCE.quoteIdentifier(null)); + assertNull(DMIdentifierProcessor.INSTANCE.quoteIdentifier(null, null, null)); + assertNull(DMIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase(null)); + assertEquals("", DMIdentifierProcessor.INSTANCE.quoteIdentifier("")); + assertEquals(" ", DMIdentifierProcessor.INSTANCE.quoteIdentifier(" ")); + assertEquals(" ", DMIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase(" ")); + } + + @Test + void quoteIdentifierPreservesDmCaseSemantics() { + DMIdentifierProcessor processor = DMIdentifierProcessor.INSTANCE; + assertEquals("EMPLOYEES", processor.quoteIdentifier("EMPLOYEES")); + assertEquals("\"employees\"", processor.quoteIdentifier("employees")); + assertEquals("\"MixedCase\"", processor.quoteIdentifier("MixedCase")); + assertEquals("\"SELECT\"", processor.quoteIdentifier("SELECT")); + assertEquals("\"select\"", processor.quoteIdentifier("select", null, null)); + assertEquals("\"A\"\"B\"", processor.quoteIdentifier("A\"B")); + assertEquals("\"ALREADY\"", processor.quoteIdentifier("\"ALREADY\"")); + } + + @Test + void quoteIdentifierIgnoreCaseIsConditional() { + DMIdentifierProcessor processor = DMIdentifierProcessor.INSTANCE; + assertEquals("employees", processor.quoteIdentifierIgnoreCase("employees")); + assertEquals("EMPLOYEES", processor.quoteIdentifierIgnoreCase("EMPLOYEES")); + assertEquals("\"select\"", processor.quoteIdentifierIgnoreCase("select")); + assertEquals("\"A\"\"B\"", processor.quoteIdentifierIgnoreCase("A\"B")); + } + + @Test + void quoteIdentifierAlwaysRoundTripsEveryRawName() { + DMIdentifierProcessor processor = DMIdentifierProcessor.INSTANCE; + assertNull(processor.quoteIdentifierAlways(null)); + String[] rawIdentifiers = {"", "plain", "SELECT", "MixedCase", "A\"B", "\"ALREADY\"", + "\"A", "A\"", "\"\"", "A\"\"B"}; + for (String raw : rawIdentifiers) { + assertEquals(raw, processor.removeIdentifierQuote(processor.quoteIdentifierAlways(raw)), + "always-quote round trip must preserve the raw identifier"); + } + } + + @Test + void stringAndIdentifierContentEscapersEncodeEveryDelimiter() { + assertEquals("O''Brien", DMIdentifierProcessor.INSTANCE.escapeString("O'Brien")); + assertEquals("'C:\\tmp\\O''Brien'", DMIdentifierProcessor.INSTANCE.quoteStringLiteral("C:\\tmp\\O'Brien")); + assertNull(DMIdentifierProcessor.INSTANCE.escapeString(null)); + assertEquals("a\"\"b", DMIdentifierProcessor.escapeIdentifier("a\"b")); + assertEquals("\"\"already\"\"", DMIdentifierProcessor.escapeIdentifier("\"already\"")); + } + + @Test + void reservedWordsAndCaseConversionAreLocaleIndependent() { + Locale original = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + assertTrue(DMIdentifierProcessor.INSTANCE.isReservedKeyword("insert", null, null)); + assertEquals("ID", DMIdentifierProcessor.INSTANCE.convertIdentifierCase("id")); + assertEquals("\"insert\"", DMIdentifierProcessor.INSTANCE.quoteIdentifier("insert")); + } finally { + Locale.setDefault(original); + } + } + + @Test + void createTableEscapesNamesAndComments() { + Table table = table("S\"CHEMA", "T\"ABLE", "VARCHAR"); + table.setComment("x'); DROP TABLE USERS; --"); + TableColumn column = table.getColumnList().get(0); + column.setName("C\"OL"); + column.setComment("c'); DROP TABLE U; --"); + + String sql = new DMSqlBuilder().buildCreateTable(table, null); + + assertTrue(sql.startsWith("CREATE TABLE \"S\"\"CHEMA\".\"T\"\"ABLE\" ("), sql); + assertTrue(sql.contains("COMMENT ON COLUMN \"S\"\"CHEMA\".\"T\"\"ABLE\".\"C\"\"OL\" IS 'c''); DROP TABLE U; --'"), sql); + assertTrue(sql.contains("COMMENT ON TABLE \"S\"\"CHEMA\".\"T\"\"ABLE\" IS 'x''); DROP TABLE USERS; --'"), sql); + } + + @Test + void createTableOmitsBlankQualifierInsteadOfRenderingNullSchema() { + Table table = table(null, "ORDERS", "INT"); + + String sql = new DMSqlBuilder().buildCreateTable(table, null); + + assertTrue(sql.startsWith("CREATE TABLE \"ORDERS\" ("), sql); + assertFalse(sql.contains("\"null\""), sql); + } + + @Test + void managerUsesSchemaQualifiedNames() throws Exception { + DMDBManager manager = new DMDBManager(); + assertEquals("DROP TABLE IF EXISTS \"SA\"\"LES\".\"ORDERS\"", + manager.dropTable(null, "ignored_database", "SA\"LES", "ORDERS")); + assertEquals("TRUNCATE TABLE \"SA\"\"LES\".\"OR\"\"DERS\"", + manager.truncateTable(null, "ignored_database", "SA\"LES", "\"OR\"\"DERS\"")); + assertEquals("DROP TABLE IF EXISTS \"T\"\"; DROP TABLE U; --\"", + manager.dropTable(null, null, null, "T\"; DROP TABLE U; --")); + } + + @Test + void inheritedBuilderPathsUseDmQualification() { + DMSqlBuilder builder = new DMSqlBuilder(); + assertEquals("SELECT * FROM \"SA\"\"LES\".\"ORDERS\"", + builder.buildSelectTable("ignored_database", "SA\"LES", "ORDERS")); + assertEquals("SELECT COUNT(1) FROM \"SA\"\"LES\".\"ORDERS\"", + builder.buildSelectCount("ignored_database", "SA\"LES", "ORDERS")); + assertEquals("DROP TABLE \"SA\"\"LES\".\"ORDERS\"", + builder.buildDropTable(new DropTableRequest("ignored_database", "SA\"LES", "ORDERS"))); + assertEquals("TRUNCATE TABLE \"SA\"\"LES\".\"ORDERS\"", + builder.buildTruncateTable(new TruncateTableRequest("ignored_database", "SA\"LES", "ORDERS"))); + assertEquals("INSERT INTO \"SA\"\"LES\".\"ORDERS\" (\"C\"\"OL\") VALUES (1)", + builder.buildInsert(SingleInsertSqlRequest.builder() + .databaseName("ignored_database") + .schemaName("SA\"LES") + .tableName("ORDERS") + .columnList(List.of("C\"OL")) + .valueList(List.of("1")) + .build())); + assertEquals("UPDATE \"SA\"\"LES\".\"ORDERS\" SET \"C\"\"OL\" = 1 WHERE \"I\"\"D\" = 2", + builder.buildUpdate(UpdateSqlRequest.builder() + .databaseName("ignored_database") + .schemaName("SA\"LES") + .tableName("ORDERS") + .row(Map.of("C\"OL", "1")) + .primaryKeyMap(Map.of("I\"D", "2")) + .build())); + } + + @Test + void dmlTemplatesAlwaysQuoteSchemaTableAndColumns() { + Table table = table("SA\"LES", "OR\"DERS", "INT"); + table.getColumnList().get(0).setName("C\"OL"); + DMSqlBuilder builder = new DMSqlBuilder(); + + assertEquals("INSERT INTO \"SA\"\"LES\".\"OR\"\"DERS\" (\"C\"\"OL\") VALUES ( )", + builder.buildTemplate(table, DmlTypeEnum.INSERT.name())); + assertEquals("UPDATE \"SA\"\"LES\".\"OR\"\"DERS\" SET \"C\"\"OL\" = WHERE ", + builder.buildTemplate(table, DmlTypeEnum.UPDATE.name())); + assertEquals("DELETE FROM \"SA\"\"LES\".\"OR\"\"DERS\" WHERE ", + builder.buildTemplate(table, DmlTypeEnum.DELETE.name())); + assertEquals("SELECT \"C\"\"OL\" FROM \"SA\"\"LES\".\"OR\"\"DERS\"", + builder.buildTemplate(table, DmlTypeEnum.SELECT.name())); + } + + @Test + void metadataQualifiedNamesAreLimitedToSchemaAndObject() { + DMMetaData metaData = new DMMetaData(); + assertEquals("\"SALES\".\"ORDERS\"", + metaData.getMetaDataName("ignored_database", "SALES", "ORDERS")); + assertEquals("\"SA\"\"LES\".\"OR\"\"DERS\"", + metaData.getMetaDataName("SA\"LES", "OR\"DERS")); + } + + @Test + void createSchemaQuotesNameAndOwner() { + Schema schema = new Schema(); + schema.setName("app"); + schema.setOwner("owner; DROP USER x; --"); + + assertEquals("CREATE SCHEMA \"app\" AUTHORIZATION \"owner; DROP USER x; --\"", + new DMSqlBuilder().buildCreateSchema(schema)); + } + + @Test + void indexScriptEscapesNamesAndCanonicalizesDirection() { + TableIndex index = new TableIndex(); + index.setType(DMIndexTypeEnum.NORMAL.getName()); + index.setSchemaName("S\"; X"); + index.setTableName("T"); + index.setName("I\"X"); + TableIndexColumn column = new TableIndexColumn(); + column.setColumnName("C\"D"); + column.setAscOrDesc(" desc "); + index.setColumnList(List.of(column)); + + assertEquals("CREATE INDEX \"S\"\"; X\".\"I\"\"X\" ON \"S\"\"; X\".\"T\" (\"C\"\"D\" DESC)", + DMIndexTypeEnum.NORMAL.buildIndexScript(index)); + + column.setAscOrDesc("DESC; DROP TABLE x; --"); + assertThrows(IllegalArgumentException.class, () -> DMIndexTypeEnum.NORMAL.buildIndexScript(index)); + } + + @Test + void knownColumnTypeAcceptsOnlySupportedUnit() { + TableColumn column = column("c1", "VARCHAR"); + column.setColumnSize(10); + column.setUnit("byte"); + column.setDefaultValue("'O''Brien'"); + + String sql = DMColumnTypeEnum.VARCHAR.buildCreateColumnSql(column); + + assertTrue(sql.contains("VARCHAR(10 byte)"), sql); + assertTrue(sql.contains("DEFAULT 'O''Brien'"), sql); + + column.setUnit("BYTE); DROP TABLE U; --"); + assertThrows(IllegalArgumentException.class, + () -> DMColumnTypeEnum.VARCHAR.buildCreateColumnSql(column)); + } + + @Test + void defaultExpressionAcceptsLegitimateDmForms() { + String[] valid = {"SYSDATE", "CURRENT_TIMESTAMP", "USER", "SEQ.NEXTVAL", "-1", "1.5", + "'Y'", "'O''Brien'", "N'abc'", "X'1A'", "SYS_GUID()", + "NVL(SUM(x),0)", "TO_DATE('1970-01-01', 'YYYY-MM-DD')", + "CAST('1' AS NUMBER(10,2))", "\"My Seq\".NEXTVAL", + "TIMESTAMP '2020-01-01 00:00:00'", "INTERVAL '1' DAY", + "q'[O'Brien]'", "'a'||'b'", "now()"}; + for (String defaultValue : valid) { + assertEquals(defaultValue, DMSqlGuards.requireDefaultExpression(defaultValue), defaultValue); + } + } + + @Test + void defaultExpressionRejectsFragmentsThatReshapeDdl() { + String[] payloads = {"0) --", "0 --", "1, x INT", "0); DROP TABLE x--", "'abc", "0\n+1", + "'a'--", "'a'; DROP TABLE x--", "0 NOT NULL", "0 CHECK (1=1)", + "0 CONSTRAINT injected UNIQUE", "x' OR '1'='1", "NVL(1,/*comment*/0)"}; + for (String payload : payloads) { + assertThrows(IllegalArgumentException.class, + () -> DMSqlGuards.requireDefaultExpression(payload), payload); + } + } + + @Test + void unknownColumnTypesArePreservedOnlyWhenStructurallySafe() { + String[] valid = {"MYCUSTOMTYPE", "VARCHAR(20)", "NUMBER(10,2)", + "TIMESTAMP(6) WITH TIME ZONE", "INTERVAL DAY(2) TO SECOND(6)", + "VARCHAR(20 CHAR)", "\"APP\".\"Order Type\"", "REF \"APP\".\"Object Type\""}; + for (String typeName : valid) { + TableColumn column = column("c1", typeName); + assertEquals("\"c1\" " + typeName, + DMColumnTypeEnum.VARCHAR.buildCreateColumnSql(column), typeName); + } + + String[] payloads = {"INTEGER); DROP TABLE U; --", "INT, x INT", "INT'--", "INT\"--", + "0) --", "INTEGER NOT NULL", "VARCHAR(20) DEFAULT 0", "INTEGER CHECK(1=1)"}; + for (String typeName : payloads) { + TableColumn column = column("c1", typeName); + assertThrows(IllegalArgumentException.class, + () -> DMColumnTypeEnum.VARCHAR.buildCreateColumnSql(column), typeName); + } + } + + @Test + void createTableKeepsSafeUnknownTypeInsteadOfDroppingColumn() { + Table table = table("S", "T", "\"APP\".\"Order Type\""); + + String sql = new DMSqlBuilder().buildCreateTable(table, null); + + assertTrue(sql.contains("\"C\" \"APP\".\"Order Type\""), sql); + } + + @Test + void caseOnlyColumnRenameIsNotSkipped() { + TableColumn column = column("mixedcase", "VARCHAR"); + column.setEditStatus("MODIFY"); + column.setSchemaName("S"); + column.setTableName("T"); + column.setOldName("MixedCase"); + + String sql = DMColumnTypeEnum.VARCHAR.buildModifyColumn(column); + + assertTrue(sql.contains("RENAME COLUMN \"MixedCase\" TO \"mixedcase\""), sql); + } + + @Test + void caseOnlyTableRenameIsNotSkipped() { + Table oldTable = table("S", "MixedCase", "VARCHAR"); + Table newTable = table("S", "mixedcase", "VARCHAR"); + + String sql = new DMSqlBuilder().buildAlterTable(oldTable, newTable); + + assertTrue(sql.startsWith("ALTER TABLE \"S\".\"MixedCase\" RENAME TO \"mixedcase\""), sql); + } + + @Test + void indexRequiresAtLeastOneNamedColumn() { + TableIndex index = new TableIndex(); + index.setSchemaName("S"); + index.setTableName("T"); + index.setName("IDX"); + index.setColumnList(List.of(new TableIndexColumn())); + + assertThrows(IllegalArgumentException.class, + () -> DMIndexTypeEnum.NORMAL.buildIndexScript(index)); + } + + @Test + void bitValuesAreCanonicalizedInsteadOfEmittedAsRawSql() { + DMBitProcessor processor = new DMBitProcessor(); + SQLDataValue value = new SQLDataValue(); + value.setValue(" true "); + assertEquals("1", processor.convertSQLValueByType(value)); + value.setValue("false"); + assertEquals("0", processor.convertSQLValueByType(value)); + value.setValue(" "); + assertEquals("NULL", processor.convertSQLValueByType(value)); + value.setValue("1); DROP TABLE U; --"); + assertThrows(IllegalArgumentException.class, () -> processor.convertSQLValueByType(value)); + } + + @Test + void dmlValueFallbackEscapesStringLiteralContent() { + SQLDataValue value = new SQLDataValue(); + value.setValue("O'Brien"); + DataType type = new DataType(); + type.setDataTypeName("VARCHAR"); + value.setDataType(type); + + assertEquals("'O''Brien'", new DMValueProcessor().convertSQLValueByType(value)); + } + + private static Table table(String schemaName, String tableName, String columnType) { + Table table = new Table(); + table.setSchemaName(schemaName); + table.setName(tableName); + TableColumn column = column("C", columnType); + column.setSchemaName(schemaName); + column.setTableName(tableName); + table.setColumnList(List.of(column)); + table.setIndexList(List.of()); + return table; + } + + private static TableColumn column(String name, String columnType) { + TableColumn column = new TableColumn(); + column.setName(name); + column.setColumnType(columnType); + return column; + } +}