From 7a18701cfb146a45091cfb4299fa50b08da073d2 Mon Sep 17 00:00:00 2001 From: HandSonic <8078023+handsonic@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:27:39 +0800 Subject: [PATCH 1/6] fix(dm): escape SQL identifiers and literals in metadata/DDL paths (#1914) --- .../chat2db-community-dm/pom.xml | 5 + .../ai/chat2db/plugin/dm/DMDBManager.java | 24 +-- .../java/ai/chat2db/plugin/dm/DMMetaData.java | 24 +-- .../ai/chat2db/plugin/dm/DMSqlEscapes.java | 69 ++++++++ .../plugin/dm/builder/DMSqlBuilder.java | 17 +- .../dm/enums/type/DMColumnTypeEnum.java | 19 +-- .../plugin/dm/enums/type/DMIndexTypeEnum.java | 17 +- .../chat2db/plugin/dm/DMSqlEscapesTest.java | 148 ++++++++++++++++++ 8 files changed, 276 insertions(+), 47 deletions(-) create mode 100644 chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/DMSqlEscapes.java create mode 100644 chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/test/java/ai/chat2db/plugin/dm/DMSqlEscapesTest.java 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..ca4620ec58 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 @@ -40,7 +40,7 @@ public class DMDBManager extends DefaultDBManager implements IDbManager { private String format(String tableName) { - return "\"" + tableName + "\""; + return DMSqlEscapes.quoteIdentifier(tableName); } @@ -66,7 +66,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, DMSqlEscapes.escapeSqlLiteral(schemaName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { while (resultSet.next()) { String tableName = resultSet.getString("TABLE_NAME"); @@ -77,7 +77,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, DMSqlEscapes.escapeSqlLiteral(tableName), DMSqlEscapes.escapeSqlLiteral(schemaName)); StringBuilder ddlBuilder = new StringBuilder(); DefaultSQLExecutor.getInstance().execute(connection, tableDDLSql, resultSet -> { if (resultSet.next()) { @@ -91,7 +91,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(DMSqlEscapes.escapeSqlLiteral(tableComment)).append("'").append(";").append("\n"); } } List columns = metaData.columns(connection, @@ -103,7 +103,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(DMSqlEscapes.escapeSqlLiteral(comment)) .append("';").append("\n"); } } @@ -135,7 +135,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, DMSqlEscapes.escapeSqlLiteral(indexName)), resultSet -> { if (resultSet.next()) { ddlBuilder.append(resultSet.getString("INDEX_DDL")).append("\n"); } @@ -167,7 +167,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, DMSqlEscapes.escapeSqlLiteral(viewName), DMSqlEscapes.escapeSqlLiteral(schemaName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { if (resultSet.next()) { StringBuilder sqlBuilder = new StringBuilder(); @@ -187,7 +187,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", DMSqlEscapes.escapeSqlLiteral(schemaName), DMSqlEscapes.escapeSqlLiteral(procedureName)); try (PreparedStatement statement = connection.prepareStatement(sql); ResultSet resultSet = statement.executeQuery()) { if (resultSet.next()) { StringBuilder sqlBuilder = new StringBuilder(); @@ -198,7 +198,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, DMSqlEscapes.escapeSqlLiteral(schemaName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { while (resultSet.next()) { String triggerName = resultSet.getString("TRIGGER_NAME"); @@ -208,7 +208,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, DMSqlEscapes.escapeSqlLiteral(schemaName), DMSqlEscapes.escapeSqlLiteral(triggerName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { if (resultSet.next()) { StringBuilder sqlBuilder = new StringBuilder(); @@ -226,7 +226,7 @@ public void connectDatabase(Connection connection, String database) { } String schemaName = connectInfo.getSchemaName(); try { - DefaultSQLExecutor.getInstance().execute(connection, String.format(SQL_SET_SCHEMA, schemaName)); + DefaultSQLExecutor.getInstance().execute(connection, String.format(SQL_SET_SCHEMA, DMSqlEscapes.escapeIdentifier(schemaName))); } catch (SQLException e) { log.error("connectDatabase error", e); } @@ -234,6 +234,6 @@ public void connectDatabase(Connection connection, String database) { @Override public String dropTable(Connection connection, String databaseName, String schemaName, String tableName) { - return String.format(SQL_DROP_TABLE_EXISTS, tableName); + return String.format(SQL_DROP_TABLE_EXISTS, DMSqlEscapes.quoteIdentifier(tableName)); } } 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..61e99441b1 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 @@ -50,13 +50,13 @@ public List schemas(Connection connection, String databaseName) { } private String format(String tableName) { - return "\"" + tableName + "\""; + return DMSqlEscapes.quoteIdentifier(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, DMSqlEscapes.escapeSqlLiteral(tableName), DMSqlEscapes.escapeSqlLiteral(schemaName)); StringBuilder ddlBuilder = new StringBuilder(); DefaultSQLExecutor.getInstance().execute(connection, tableDDLSql, resultSet -> { if (resultSet.next()) { @@ -69,7 +69,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(DMSqlEscapes.escapeSqlLiteral(tableComment)).append("'").append(";").append("\n"); } } List columns = this.columns(connection, databaseName, schemaName, tableName); @@ -80,7 +80,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(DMSqlEscapes.escapeSqlLiteral(comment)) .append("';").append("\n"); } } @@ -115,7 +115,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, DMSqlEscapes.escapeSqlLiteral(indexName)), resultSet -> { if (resultSet.next()) { ddlBuilder.append(resultSet.getString("INDEX_DDL")).append("\n"); } @@ -151,7 +151,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", DMSqlEscapes.escapeSqlLiteral(schemaName), DMSqlEscapes.escapeSqlLiteral(functionName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { StringBuilder sb = new StringBuilder(); while (resultSet.next()) { @@ -171,7 +171,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", DMSqlEscapes.escapeSqlLiteral(schemaName), DMSqlEscapes.escapeSqlLiteral(procedureName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { StringBuilder sb = new StringBuilder(); while (resultSet.next()) { @@ -193,7 +193,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, DMSqlEscapes.escapeSqlLiteral(schemaName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { while (resultSet.next()) { Trigger trigger = new Trigger(); @@ -210,7 +210,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, DMSqlEscapes.escapeSqlLiteral(schemaName), DMSqlEscapes.escapeSqlLiteral(triggerName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Trigger trigger = new Trigger(); trigger.setDatabaseName(databaseName); @@ -227,7 +227,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, DMSqlEscapes.escapeSqlLiteral(schemaName), DMSqlEscapes.escapeSqlLiteral(viewName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Table table = new Table(); table.setDatabaseName(databaseName); @@ -244,7 +244,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, DMSqlEscapes.escapeSqlLiteral(schemaName), DMSqlEscapes.escapeSqlLiteral(tableName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { LinkedHashMap map = new LinkedHashMap(); while (resultSet.next()) { @@ -324,7 +324,7 @@ public ISQLIdentifierProcessor getSQLIdentifierProcessor() { @Override public String getMetaDataName(String... names) { - return Arrays.stream(names).filter(name -> StringUtils.isNotBlank(name)).map(name -> "\"" + name + "\"").collect(Collectors.joining(".")); + return Arrays.stream(names).filter(name -> StringUtils.isNotBlank(name)).map(name -> DMSqlEscapes.quoteIdentifier(name)).collect(Collectors.joining(".")); } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/DMSqlEscapes.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/DMSqlEscapes.java new file mode 100644 index 0000000000..c6fa97467f --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/DMSqlEscapes.java @@ -0,0 +1,69 @@ +package ai.chat2db.plugin.dm; + +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; + +/** + * Escaping helpers for DM SQL text: single-quoted string literals and + * double-quoted identifiers. + */ +public final class DMSqlEscapes { + + /** + * Legitimate column DEFAULT expressions: quoted string literals (with '' + * escapes), numeric literals, or keyword/function forms such as + * CURRENT_TIMESTAMP, SYSDATE, USER, SEQ.NEXTVAL. Anything else is rejected + * because DEFAULT values are emitted verbatim. + */ + private static final Pattern DEFAULT_EXPRESSION = Pattern.compile( + "'([^']|'')*'|[+-]?(\\d+(\\.\\d+)?|\\.\\d+)|[A-Za-z_][A-Za-z0-9_]*([.][A-Za-z_][A-Za-z0-9_]*)*(\\s*\\([^;)]*\\))?"); + + private DMSqlEscapes() { + } + + /** + * Escapes a value interpolated into a single-quoted SQL string literal by + * doubling every single quote. + */ + public static String escapeSqlLiteral(String value) { + return StringUtils.replace(value, "'", "''"); + } + + /** + * Escapes identifier content for a position already surrounded by double + * quotes: strips one surrounding quote pair, then doubles every embedded + * double quote. + */ + public static String escapeIdentifier(String identifier) { + if (identifier == null) { + return null; + } + String unquoted = identifier; + if (unquoted.length() >= 2 && unquoted.startsWith("\"") && unquoted.endsWith("\"")) { + unquoted = unquoted.substring(1, unquoted.length() - 1); + } + return unquoted.replace("\"", "\"\""); + } + + /** + * Quotes an identifier with double quotes, doubling every embedded double + * quote. + */ + public static String quoteIdentifier(String identifier) { + return "\"" + escapeIdentifier(identifier) + "\""; + } + + /** + * Validates a column DEFAULT expression that is emitted verbatim into DDL. + * Accepts quoted string literals (escaped via doubling), numeric literals, + * and keyword/function forms; rejects everything else. + */ + public static String requireDefaultExpression(String defaultValue) { + String trimmed = defaultValue.trim(); + if (!DEFAULT_EXPRESSION.matcher(trimmed).matches()) { + throw new IllegalArgumentException("Invalid DM default expression: " + defaultValue); + } + return trimmed; + } +} 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..94a03130cd 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,6 +2,7 @@ import ai.chat2db.spi.constant.SQLConstants; +import ai.chat2db.plugin.dm.DMSqlEscapes; import ai.chat2db.plugin.dm.enums.type.DMColumnTypeEnum; import ai.chat2db.plugin.dm.enums.type.DMIndexTypeEnum; import ai.chat2db.community.domain.api.enums.plugin.EditStatusEnum; @@ -42,7 +43,7 @@ public class DMSqlBuilder extends DefaultSqlBuilder { public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfig) { StringBuilder script = new StringBuilder(); - script.append(SQL_CREATE_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(table.getSchemaName()).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(table.getName()).append(VALUE_DOUBLE_QUOTE_OPEN_PAREN).append(SQLConstants.LINE_SEPARATOR); + script.append(SQL_CREATE_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(table.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(table.getName())).append(VALUE_DOUBLE_QUOTE_OPEN_PAREN).append(SQLConstants.LINE_SEPARATOR); for (TableColumn column : table.getColumnList()) { if (StringUtils.isBlank(column.getName()) || StringUtils.isBlank(column.getColumnType())) { @@ -98,7 +99,7 @@ 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(SQLConstants.DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(table.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(table.getName())).append(VALUE_DOUBLE_QUOTE_OPEN_PAREN).append(SQLConstants.LINE_SEPARATOR); for (TableColumn column : table.getColumnList()) { if (StringUtils.isBlank(column.getName()) || StringUtils.isBlank(column.getColumnType())) { @@ -136,13 +137,13 @@ 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); + script.append(SQL_COMMENT_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(table.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(table.getName())).append(VALUE_DOUBLE_QUOTE_IS_SINGLE_QUOTE).append(DMSqlEscapes.escapeSqlLiteral(table.getComment())).append(SQLConstants.SINGLE_QUOTE); return script.toString(); } private String buildComment(TableColumn column) { StringBuilder script = new StringBuilder(); - script.append(SQL_COMMENT_COLUMN).append(SQLConstants.DOUBLE_QUOTE).append(column.getSchemaName()).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(column.getTableName()).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(column.getName()).append(VALUE_DOUBLE_QUOTE_IS_SINGLE_QUOTE).append(column.getComment()).append(SQLConstants.SINGLE_QUOTE); + script.append(SQL_COMMENT_COLUMN).append(SQLConstants.DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(column.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(column.getTableName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(column.getName())).append(VALUE_DOUBLE_QUOTE_IS_SINGLE_QUOTE).append(DMSqlEscapes.escapeSqlLiteral(column.getComment())).append(SQLConstants.SINGLE_QUOTE); return script.toString(); } @@ -151,8 +152,8 @@ public String buildAlterTable(Table oldTable, Table newTable) { StringBuilder script = new StringBuilder(); if (!StringUtils.equalsIgnoreCase(oldTable.getName(), newTable.getName())) { - script.append(SQL_ALTER_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(oldTable.getSchemaName()).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(oldTable.getName()).append(SQLConstants.DOUBLE_QUOTE); - script.append(SQLConstants.SPACE).append(SQL_RENAME).append(SQLConstants.DOUBLE_QUOTE).append(newTable.getName()).append(SQLConstants.DOUBLE_QUOTE).append(SQLConstants.SEMICOLON_LINE_SEPARATOR); + script.append(SQL_ALTER_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(oldTable.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(oldTable.getName())).append(SQLConstants.DOUBLE_QUOTE); + script.append(SQLConstants.SPACE).append(SQL_RENAME).append(SQLConstants.DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(newTable.getName())).append(SQLConstants.DOUBLE_QUOTE).append(SQLConstants.SEMICOLON_LINE_SEPARATOR); } if (!StringUtils.equalsIgnoreCase(oldTable.getComment(), newTable.getComment())) { script.append(SQLConstants.EMPTY).append(buildTableComment(newTable)).append(SQLConstants.SEMICOLON_LINE_SEPARATOR); @@ -211,9 +212,9 @@ public String buildPageLimit(PageLimitRequest request) { @Override public String buildCreateSchema(Schema schema) { StringBuilder sqlBuilder = new StringBuilder(); - sqlBuilder.append(SQL_CREATE_SCHEMA+schema.getName()+SQLConstants.DOUBLE_QUOTE); + sqlBuilder.append(SQL_CREATE_SCHEMA+DMSqlEscapes.escapeIdentifier(schema.getName())+SQLConstants.DOUBLE_QUOTE); if(StringUtils.isNotBlank(schema.getOwner())){ - sqlBuilder.append(SQLConstants.SCHEMA_AUTHORIZATION_SQL).append(schema.getOwner()); + sqlBuilder.append(SQLConstants.SCHEMA_AUTHORIZATION_SQL).append(DMSqlEscapes.quoteIdentifier(schema.getOwner())); } return sqlBuilder.toString(); 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..0f9cd053c3 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,5 +1,6 @@ package ai.chat2db.plugin.dm.enums.type; +import ai.chat2db.plugin.dm.DMSqlEscapes; import ai.chat2db.spi.IColumnBuilder; import ai.chat2db.community.domain.api.enums.plugin.EditStatusEnum; import ai.chat2db.community.domain.api.model.metadata.ColumnType; @@ -158,7 +159,7 @@ public String buildCreateColumnSql(TableColumn column) { } StringBuilder script = new StringBuilder(); - script.append("\"").append(column.getName()).append("\"").append(" "); + script.append(DMSqlEscapes.quoteIdentifier(column.getName())).append(" "); script.append(buildDataType(column, type)).append(" "); @@ -179,7 +180,7 @@ public String buildAICreateColumnSql(TableColumn column) { } StringBuilder script = new StringBuilder(); - script.append("\"").append(column.getName()).append("\"").append(" "); + script.append(DMSqlEscapes.quoteIdentifier(column.getName())).append(" "); script.append(buildDataType(column, type)).append(" "); @@ -232,7 +233,7 @@ private String buildDefaultValue(TableColumn column, DMColumnTypeEnum type) { return StringUtils.join("DEFAULT NULL"); } - return StringUtils.join("DEFAULT ", column.getDefaultValue()); + return StringUtils.join("DEFAULT ", DMSqlEscapes.requireDefaultExpression(column.getDefaultValue())); } private String buildDataType(TableColumn column, DMColumnTypeEnum type) { @@ -297,25 +298,25 @@ public String buildModifyColumn(TableColumn tableColumn) { if (EditStatusEnum.DELETE.name().equals(tableColumn.getEditStatus())) { StringBuilder script = new StringBuilder(); - script.append(SQL_ALTER_TABLE).append("\"").append(tableColumn.getSchemaName()).append("\".\"").append(tableColumn.getTableName()).append("\""); - script.append(" ").append(SQL_DROP_COLUMN).append("\"").append(tableColumn.getName()).append("\""); + script.append(SQL_ALTER_TABLE).append("\"").append(DMSqlEscapes.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(DMSqlEscapes.escapeIdentifier(tableColumn.getTableName())).append("\""); + script.append(" ").append(SQL_DROP_COLUMN).append("\"").append(DMSqlEscapes.escapeIdentifier(tableColumn.getName())).append("\""); return script.toString(); } if (EditStatusEnum.ADD.name().equals(tableColumn.getEditStatus())) { StringBuilder script = new StringBuilder(); - script.append(SQL_ALTER_TABLE).append("\"").append(tableColumn.getSchemaName()).append("\".\"").append(tableColumn.getTableName()).append("\""); + script.append(SQL_ALTER_TABLE).append("\"").append(DMSqlEscapes.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(DMSqlEscapes.escapeIdentifier(tableColumn.getTableName())).append("\""); script.append(" ").append("ADD (").append(buildCreateColumnSql(tableColumn)).append(")"); return script.toString(); } if (EditStatusEnum.MODIFY.name().equals(tableColumn.getEditStatus())) { StringBuilder script = new StringBuilder(); if (!StringUtils.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("\"").append(DMSqlEscapes.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(DMSqlEscapes.escapeIdentifier(tableColumn.getTableName())).append("\""); + script.append(" ").append(SQL_RENAME_COLUMN).append("\"").append(DMSqlEscapes.escapeIdentifier(tableColumn.getOldName())).append("\"").append(" TO ").append("\"").append(DMSqlEscapes.escapeIdentifier(tableColumn.getName())).append("\""); script.append(";\n"); } - script.append(SQL_ALTER_TABLE).append("\"").append(tableColumn.getSchemaName()).append("\".\"").append(tableColumn.getTableName()).append("\""); + script.append(SQL_ALTER_TABLE).append("\"").append(DMSqlEscapes.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(DMSqlEscapes.escapeIdentifier(tableColumn.getTableName())).append("\""); script.append(" ").append("MODIFY (").append(buildCreateColumnSql(tableColumn)).append(") \n"); return script.toString(); 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..6a1fbfe25d 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,6 @@ package ai.chat2db.plugin.dm.enums.type; +import ai.chat2db.plugin.dm.DMSqlEscapes; 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 +73,14 @@ 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_2).append(DMSqlEscapes.escapeIdentifier(tableIndex.getSchemaName())).append("\".\"").append(DMSqlEscapes.escapeIdentifier(tableIndex.getTableName())).append("\" ADD PRIMARY KEY ").append(buildIndexColumn(tableIndex)); } else { if (UNIQUE.equals(this)) { script.append(SQL_CREATE_UNIQUE_INDEX); } else { script.append(SQL_CREATE_INDEX); } - script.append(buildIndexName(tableIndex)).append(SQL_ON).append(tableIndex.getSchemaName()).append("\".\"").append(tableIndex.getTableName()).append("\" ").append(buildIndexColumn(tableIndex)); + script.append(buildIndexName(tableIndex)).append(SQL_ON).append(DMSqlEscapes.escapeIdentifier(tableIndex.getSchemaName())).append("\".\"").append(DMSqlEscapes.escapeIdentifier(tableIndex.getTableName())).append("\" ").append(buildIndexColumn(tableIndex)); } return script.toString(); } @@ -90,9 +91,13 @@ 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(DMSqlEscapes.quoteIdentifier(column.getColumnName())); if (!StringUtils.isBlank(column.getAscOrDesc()) && !PRIMARY_KEY.equals(this)) { - script.append(" ").append(column.getAscOrDesc()); + String ascOrDesc = column.getAscOrDesc(); + if (!"ASC".equalsIgnoreCase(ascOrDesc) && !"DESC".equalsIgnoreCase(ascOrDesc)) { + throw new IllegalArgumentException("Invalid index column sort order: " + ascOrDesc); + } + script.append(" ").append(ascOrDesc); } script.append(","); } @@ -103,7 +108,7 @@ private String buildIndexColumn(TableIndex tableIndex) { } private String buildIndexName(TableIndex tableIndex) { - return "\"" + tableIndex.getSchemaName() + "\"." + "\"" + tableIndex.getName() + "\""; + return DMSqlEscapes.quoteIdentifier(tableIndex.getSchemaName()) + "." + DMSqlEscapes.quoteIdentifier(tableIndex.getName()); } public String buildModifyIndex(TableIndex tableIndex) { @@ -121,7 +126,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 = DMSqlEscapes.quoteIdentifier(tableIndex.getSchemaName()) + "." + DMSqlEscapes.quoteIdentifier(tableIndex.getTableName()); return StringUtils.join(SQL_ALTER_TABLE,tableName,SQL_DROP_PRIMARY_KEY); } StringBuilder script = new StringBuilder(); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/test/java/ai/chat2db/plugin/dm/DMSqlEscapesTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/test/java/ai/chat2db/plugin/dm/DMSqlEscapesTest.java new file mode 100644 index 0000000000..42f4dd4c15 --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/test/java/ai/chat2db/plugin/dm/DMSqlEscapesTest.java @@ -0,0 +1,148 @@ +package ai.chat2db.plugin.dm; + +import ai.chat2db.community.domain.api.model.metadata.Schema; +import ai.chat2db.community.domain.api.model.metadata.Table; +import ai.chat2db.community.domain.api.model.metadata.TableColumn; +import ai.chat2db.community.domain.api.model.metadata.TableIndex; +import ai.chat2db.community.domain.api.model.metadata.TableIndexColumn; +import ai.chat2db.plugin.dm.builder.DMSqlBuilder; +import ai.chat2db.plugin.dm.enums.type.DMColumnTypeEnum; +import ai.chat2db.plugin.dm.enums.type.DMIndexTypeEnum; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DMSqlEscapesTest { + + @Test + void escapeSqlLiteralDoublesSingleQuotes() { + assertEquals("O''Brien", DMSqlEscapes.escapeSqlLiteral("O'Brien")); + assertEquals("x'' OR ''1''=''1", DMSqlEscapes.escapeSqlLiteral("x' OR '1'='1")); + assertEquals("plain", DMSqlEscapes.escapeSqlLiteral("plain")); + } + + @Test + void quoteIdentifierDoublesEmbeddedDoubleQuotes() { + assertEquals("\"plain\"", DMSqlEscapes.quoteIdentifier("plain")); + assertEquals("\"we\"\"ird\"", DMSqlEscapes.quoteIdentifier("we\"ird")); + } + + @Test + void escapeIdentifierStripsOneSurroundingQuotePairBeforeDoubling() { + assertEquals("already", DMSqlEscapes.escapeIdentifier("\"already\"")); + assertEquals("a\"\"b", DMSqlEscapes.escapeIdentifier("a\"b")); + assertEquals("plain", DMSqlEscapes.escapeIdentifier("plain")); + } + + @Test + void createTableSqlNeutralizesMaliciousSchemaAndComment() { + Table table = new Table(); + table.setSchemaName("s\"; DROP TABLE t; --"); + table.setName("tab"); + table.setComment("x'; DROP TABLE t; --"); + TableColumn column = new TableColumn(); + column.setName("c1"); + column.setColumnType("INT"); + table.setColumnList(List.of(column)); + table.setIndexList(List.of()); + + String sql = new DMSqlBuilder().buildCreateTable(table, null); + + assertTrue(sql.contains("\"s\"\"; DROP TABLE t; --\".\"tab\"")); + assertFalse(sql.contains("\"s\"; DROP TABLE t; --\"")); + assertTrue(sql.contains("IS 'x''; DROP TABLE t; --'")); + assertFalse(sql.contains("IS 'x'; DROP TABLE t; --'")); + } + + @Test + void dropTableQuotesAndEscapesTableName() { + String sql = new DMDBManager().dropTable(null, null, null, "a\"; DROP TABLE b; --"); + + assertEquals("DROP TABLE IF EXISTS \"a\"\"; DROP TABLE b; --\"", sql); + } + + @Test + void metaDataNameEscapesEachIdentifierPart() { + String name = new DMMetaData().getMetaDataName("sch\"ema", "ta\"ble"); + + assertEquals("\"sch\"\"ema\".\"ta\"\"ble\"", name); + } + + @Test + void createSchemaQuotesOwnerAsIdentifier() { + Schema schema = new Schema(); + schema.setName("app"); + schema.setOwner("owner; DROP USER x; --"); + + String sql = new DMSqlBuilder().buildCreateSchema(schema); + + assertEquals("CREATE SCHEMA \"app\" AUTHORIZATION \"owner; DROP USER x; --\"", sql); + } + + @Test + void indexSortOrderAcceptsAscDescAndRejectsInjection() { + TableIndex index = new TableIndex(); + index.setSchemaName("s"); + index.setTableName("t"); + index.setName("i"); + TableIndexColumn column = new TableIndexColumn(); + column.setColumnName("c"); + column.setAscOrDesc("desc"); + index.setColumnList(List.of(column)); + + assertTrue(DMIndexTypeEnum.NORMAL.buildIndexScript(index).contains("\"c\" desc")); + + column.setAscOrDesc("DESC; DROP TABLE x; --"); + assertThrows(IllegalArgumentException.class, () -> DMIndexTypeEnum.NORMAL.buildIndexScript(index)); + } + + @Test + void quotedStringDefaultsAndUnitPassThroughUnchanged() { + TableColumn column = new TableColumn(); + column.setName("c1"); + column.setColumnType("VARCHAR"); + column.setColumnSize(10); + column.setUnit("BYTE"); + column.setDefaultValue("'O''Brien'"); + + String sql = DMColumnTypeEnum.VARCHAR.buildCreateColumnSql(column); + + assertTrue(sql.contains("VARCHAR(10 BYTE)")); + assertTrue(sql.contains("DEFAULT 'O''Brien'")); + + TableColumn emptyStringDefault = new TableColumn(); + emptyStringDefault.setName("c2"); + emptyStringDefault.setColumnType("VARCHAR"); + emptyStringDefault.setDefaultValue("EMPTY_STRING"); + + assertTrue(DMColumnTypeEnum.VARCHAR.buildCreateColumnSql(emptyStringDefault).contains("DEFAULT ''")); + } + + @Test + void defaultExpressionAcceptsLegitimateAndRejectsInjection() { + org.junit.jupiter.api.Assertions.assertEquals("'abc'", DMSqlEscapes.requireDefaultExpression("'abc'")); + org.junit.jupiter.api.Assertions.assertEquals("'O''Brien'", DMSqlEscapes.requireDefaultExpression("'O''Brien'")); + org.junit.jupiter.api.Assertions.assertEquals("-1.5", DMSqlEscapes.requireDefaultExpression("-1.5")); + org.junit.jupiter.api.Assertions.assertEquals("CURRENT_TIMESTAMP", DMSqlEscapes.requireDefaultExpression("CURRENT_TIMESTAMP")); + org.junit.jupiter.api.Assertions.assertEquals("SEQ.NEXTVAL", DMSqlEscapes.requireDefaultExpression("SEQ.NEXTVAL")); + org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, + () -> DMSqlEscapes.requireDefaultExpression("1; DROP TABLE t")); + org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, + () -> DMSqlEscapes.requireDefaultExpression("x' OR '1'='1")); + } + + @Test + void createColumnRejectsMaliciousDefault() { + TableColumn column = new TableColumn(); + column.setName("c"); + column.setColumnType("INT"); + column.setDefaultValue("1; DROP TABLE t;--"); + org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, + () -> DMColumnTypeEnum.INT.buildCreateColumnSql(column)); + } +} From 4d5a318a2840a2767716e3c1c7285a22c2c2086f Mon Sep 17 00:00:00 2001 From: HandSonic <8078023+handsonic@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:00:07 +0800 Subject: [PATCH 2/6] refactor(dm): move escaping into DMIdentifierProcessor per maintainer review (#1914) - strengthen DMIdentifierProcessor (SPI ISQLIdentifierProcessor): always-quote quoteIdentifier with embedded-quote doubling, escapeString with single-quote doubling, static escapeIdentifier for pre-quoted templates, INSTANCE - DMMetaData.getSQLIdentifierProcessor() returns the shared INSTANCE; metadata call sites use it (quoteIdentifier / escapeString / method refs) - builders/DBManager/enums use DMIdentifierProcessor.INSTANCE / static escapeIdentifier - non-escapable DEFAULT-expression validation moved to public DMSqlGuards - DMSqlEscapes removed; tests migrated to DMIdentifierProcessorTest (11 green) --- .../ai/chat2db/plugin/dm/DMDBManager.java | 25 +++---- .../java/ai/chat2db/plugin/dm/DMMetaData.java | 30 ++++---- .../ai/chat2db/plugin/dm/DMSqlEscapes.java | 69 ------------------- .../ai/chat2db/plugin/dm/DMSqlGuards.java | 36 ++++++++++ .../plugin/dm/builder/DMSqlBuilder.java | 18 ++--- .../dm/enums/type/DMColumnTypeEnum.java | 21 +++--- .../plugin/dm/enums/type/DMIndexTypeEnum.java | 12 ++-- .../dm/identifier/DMIdentifierProcessor.java | 64 ++++++++++------- ...st.java => DMIdentifierProcessorTest.java} | 33 ++++----- 9 files changed, 146 insertions(+), 162 deletions(-) delete mode 100644 chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/DMSqlEscapes.java create mode 100644 chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/DMSqlGuards.java rename chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/test/java/ai/chat2db/plugin/dm/{DMSqlEscapesTest.java => DMIdentifierProcessorTest.java} (80%) 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 ca4620ec58..737840def5 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 DMSqlEscapes.quoteIdentifier(tableName); + return DMIdentifierProcessor.INSTANCE.quoteIdentifier(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, DMSqlEscapes.escapeSqlLiteral(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, DMSqlEscapes.escapeSqlLiteral(tableName), DMSqlEscapes.escapeSqlLiteral(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(DMSqlEscapes.escapeSqlLiteral(tableComment)).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(DMSqlEscapes.escapeSqlLiteral(comment)) + .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, DMSqlEscapes.escapeSqlLiteral(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, DMSqlEscapes.escapeSqlLiteral(viewName), DMSqlEscapes.escapeSqlLiteral(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", DMSqlEscapes.escapeSqlLiteral(schemaName), DMSqlEscapes.escapeSqlLiteral(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, DMSqlEscapes.escapeSqlLiteral(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, DMSqlEscapes.escapeSqlLiteral(schemaName), DMSqlEscapes.escapeSqlLiteral(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,7 +227,7 @@ public void connectDatabase(Connection connection, String database) { } String schemaName = connectInfo.getSchemaName(); try { - DefaultSQLExecutor.getInstance().execute(connection, String.format(SQL_SET_SCHEMA, DMSqlEscapes.escapeIdentifier(schemaName))); + DefaultSQLExecutor.getInstance().execute(connection, String.format(SQL_SET_SCHEMA, DMIdentifierProcessor.escapeIdentifier(schemaName))); } catch (SQLException e) { log.error("connectDatabase error", e); } @@ -234,6 +235,6 @@ public void connectDatabase(Connection connection, String database) { @Override public String dropTable(Connection connection, String databaseName, String schemaName, String tableName) { - return String.format(SQL_DROP_TABLE_EXISTS, DMSqlEscapes.quoteIdentifier(tableName)); + return String.format(SQL_DROP_TABLE_EXISTS, DMIdentifierProcessor.INSTANCE.quoteIdentifier(tableName)); } } 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 61e99441b1..0e5455be28 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 DMSqlEscapes.quoteIdentifier(tableName); + return getSQLIdentifierProcessor().quoteIdentifier(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, DMSqlEscapes.escapeSqlLiteral(tableName), DMSqlEscapes.escapeSqlLiteral(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(DMSqlEscapes.escapeSqlLiteral(tableComment)).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(DMSqlEscapes.escapeSqlLiteral(comment)) + .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, DMSqlEscapes.escapeSqlLiteral(indexName)), resultSet -> { + DefaultSQLExecutor.getInstance().execute(connection, String.format(sql, getSQLIdentifierProcessor().escapeString(indexName)), resultSet -> { if (resultSet.next()) { ddlBuilder.append(resultSet.getString("INDEX_DDL")).append("\n"); } @@ -151,7 +147,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", DMSqlEscapes.escapeSqlLiteral(schemaName), DMSqlEscapes.escapeSqlLiteral(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 +167,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", DMSqlEscapes.escapeSqlLiteral(schemaName), DMSqlEscapes.escapeSqlLiteral(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 +189,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, DMSqlEscapes.escapeSqlLiteral(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 +206,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, DMSqlEscapes.escapeSqlLiteral(schemaName), DMSqlEscapes.escapeSqlLiteral(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 +223,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, DMSqlEscapes.escapeSqlLiteral(schemaName), DMSqlEscapes.escapeSqlLiteral(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 +240,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, DMSqlEscapes.escapeSqlLiteral(schemaName), DMSqlEscapes.escapeSqlLiteral(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 +315,12 @@ 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 -> DMSqlEscapes.quoteIdentifier(name)).collect(Collectors.joining(".")); + return Arrays.stream(names).filter(name -> StringUtils.isNotBlank(name)).map(getSQLIdentifierProcessor()::quoteIdentifier).collect(Collectors.joining(".")); } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/DMSqlEscapes.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/DMSqlEscapes.java deleted file mode 100644 index c6fa97467f..0000000000 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/DMSqlEscapes.java +++ /dev/null @@ -1,69 +0,0 @@ -package ai.chat2db.plugin.dm; - -import java.util.regex.Pattern; - -import org.apache.commons.lang3.StringUtils; - -/** - * Escaping helpers for DM SQL text: single-quoted string literals and - * double-quoted identifiers. - */ -public final class DMSqlEscapes { - - /** - * Legitimate column DEFAULT expressions: quoted string literals (with '' - * escapes), numeric literals, or keyword/function forms such as - * CURRENT_TIMESTAMP, SYSDATE, USER, SEQ.NEXTVAL. Anything else is rejected - * because DEFAULT values are emitted verbatim. - */ - private static final Pattern DEFAULT_EXPRESSION = Pattern.compile( - "'([^']|'')*'|[+-]?(\\d+(\\.\\d+)?|\\.\\d+)|[A-Za-z_][A-Za-z0-9_]*([.][A-Za-z_][A-Za-z0-9_]*)*(\\s*\\([^;)]*\\))?"); - - private DMSqlEscapes() { - } - - /** - * Escapes a value interpolated into a single-quoted SQL string literal by - * doubling every single quote. - */ - public static String escapeSqlLiteral(String value) { - return StringUtils.replace(value, "'", "''"); - } - - /** - * Escapes identifier content for a position already surrounded by double - * quotes: strips one surrounding quote pair, then doubles every embedded - * double quote. - */ - public static String escapeIdentifier(String identifier) { - if (identifier == null) { - return null; - } - String unquoted = identifier; - if (unquoted.length() >= 2 && unquoted.startsWith("\"") && unquoted.endsWith("\"")) { - unquoted = unquoted.substring(1, unquoted.length() - 1); - } - return unquoted.replace("\"", "\"\""); - } - - /** - * Quotes an identifier with double quotes, doubling every embedded double - * quote. - */ - public static String quoteIdentifier(String identifier) { - return "\"" + escapeIdentifier(identifier) + "\""; - } - - /** - * Validates a column DEFAULT expression that is emitted verbatim into DDL. - * Accepts quoted string literals (escaped via doubling), numeric literals, - * and keyword/function forms; rejects everything else. - */ - public static String requireDefaultExpression(String defaultValue) { - String trimmed = defaultValue.trim(); - if (!DEFAULT_EXPRESSION.matcher(trimmed).matches()) { - throw new IllegalArgumentException("Invalid DM default expression: " + defaultValue); - } - return trimmed; - } -} 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..af9fcaaad7 --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/main/java/ai/chat2db/plugin/dm/DMSqlGuards.java @@ -0,0 +1,36 @@ +package ai.chat2db.plugin.dm; + +import java.util.regex.Pattern; + +/** + * Validation helpers for non-escapable SQL positions in DM DDL generation + * (column DEFAULT expressions emitted verbatim). Escaping itself lives in + * {@link ai.chat2db.plugin.dm.identifier.DMIdentifierProcessor}. + */ +public final class DMSqlGuards { + + /** + * Legitimate column DEFAULT expressions: quoted string literals (with '' + * escapes), numeric literals, or keyword/function forms such as + * CURRENT_TIMESTAMP, SYSDATE, USER, SEQ.NEXTVAL. Anything else is rejected + * because DEFAULT values are emitted verbatim. + */ + private static final Pattern DEFAULT_EXPRESSION = Pattern.compile( + "'([^']|'')*'|[+-]?(\\d+(\\.\\d+)?|\\.\\d+)|[A-Za-z_][A-Za-z0-9_]*([.][A-Za-z_][A-Za-z0-9_]*)*(\\s*\\([^;)]*\\))?"); + + private DMSqlGuards() { + } + + /** + * Validates a column DEFAULT expression that is emitted verbatim into DDL. + * Accepts quoted string literals (escaped via doubling), numeric literals, + * and keyword/function forms; rejects everything else. + */ + public static String requireDefaultExpression(String defaultValue) { + String trimmed = defaultValue.trim(); + if (!DEFAULT_EXPRESSION.matcher(trimmed).matches()) { + throw new IllegalArgumentException("Invalid DM default expression: " + defaultValue); + } + return trimmed; + } +} 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 94a03130cd..ddd18e11c3 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,7 +2,7 @@ import ai.chat2db.spi.constant.SQLConstants; -import ai.chat2db.plugin.dm.DMSqlEscapes; +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.EditStatusEnum; @@ -43,7 +43,7 @@ public class DMSqlBuilder extends DefaultSqlBuilder { public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfig) { StringBuilder script = new StringBuilder(); - script.append(SQL_CREATE_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(table.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(table.getName())).append(VALUE_DOUBLE_QUOTE_OPEN_PAREN).append(SQLConstants.LINE_SEPARATOR); + script.append(SQL_CREATE_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(DMIdentifierProcessor.escapeIdentifier(table.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMIdentifierProcessor.escapeIdentifier(table.getName())).append(VALUE_DOUBLE_QUOTE_OPEN_PAREN).append(SQLConstants.LINE_SEPARATOR); for (TableColumn column : table.getColumnList()) { if (StringUtils.isBlank(column.getName()) || StringUtils.isBlank(column.getColumnType())) { @@ -99,7 +99,7 @@ public String buildAITableSchema(Table table) { } StringBuilder script = new StringBuilder(); - script.append(SQL_CREATE_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(table.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(table.getName())).append(VALUE_DOUBLE_QUOTE_OPEN_PAREN).append(SQLConstants.LINE_SEPARATOR); + script.append(SQL_CREATE_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(DMIdentifierProcessor.escapeIdentifier(table.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMIdentifierProcessor.escapeIdentifier(table.getName())).append(VALUE_DOUBLE_QUOTE_OPEN_PAREN).append(SQLConstants.LINE_SEPARATOR); for (TableColumn column : table.getColumnList()) { if (StringUtils.isBlank(column.getName()) || StringUtils.isBlank(column.getColumnType())) { @@ -137,13 +137,13 @@ public String buildAITableSchema(Table table) { private String buildTableComment(Table table) { StringBuilder script = new StringBuilder(); - script.append(SQL_COMMENT_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(table.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(table.getName())).append(VALUE_DOUBLE_QUOTE_IS_SINGLE_QUOTE).append(DMSqlEscapes.escapeSqlLiteral(table.getComment())).append(SQLConstants.SINGLE_QUOTE); + script.append(SQL_COMMENT_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(DMIdentifierProcessor.escapeIdentifier(table.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMIdentifierProcessor.escapeIdentifier(table.getName())).append(VALUE_DOUBLE_QUOTE_IS_SINGLE_QUOTE).append(DMIdentifierProcessor.INSTANCE.escapeString(table.getComment())).append(SQLConstants.SINGLE_QUOTE); return script.toString(); } private String buildComment(TableColumn column) { StringBuilder script = new StringBuilder(); - script.append(SQL_COMMENT_COLUMN).append(SQLConstants.DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(column.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(column.getTableName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(column.getName())).append(VALUE_DOUBLE_QUOTE_IS_SINGLE_QUOTE).append(DMSqlEscapes.escapeSqlLiteral(column.getComment())).append(SQLConstants.SINGLE_QUOTE); + script.append(SQL_COMMENT_COLUMN).append(SQLConstants.DOUBLE_QUOTE).append(DMIdentifierProcessor.escapeIdentifier(column.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMIdentifierProcessor.escapeIdentifier(column.getTableName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMIdentifierProcessor.escapeIdentifier(column.getName())).append(VALUE_DOUBLE_QUOTE_IS_SINGLE_QUOTE).append(DMIdentifierProcessor.INSTANCE.escapeString(column.getComment())).append(SQLConstants.SINGLE_QUOTE); return script.toString(); } @@ -152,8 +152,8 @@ public String buildAlterTable(Table oldTable, Table newTable) { StringBuilder script = new StringBuilder(); if (!StringUtils.equalsIgnoreCase(oldTable.getName(), newTable.getName())) { - script.append(SQL_ALTER_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(oldTable.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(oldTable.getName())).append(SQLConstants.DOUBLE_QUOTE); - script.append(SQLConstants.SPACE).append(SQL_RENAME).append(SQLConstants.DOUBLE_QUOTE).append(DMSqlEscapes.escapeIdentifier(newTable.getName())).append(SQLConstants.DOUBLE_QUOTE).append(SQLConstants.SEMICOLON_LINE_SEPARATOR); + script.append(SQL_ALTER_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(DMIdentifierProcessor.escapeIdentifier(oldTable.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMIdentifierProcessor.escapeIdentifier(oldTable.getName())).append(SQLConstants.DOUBLE_QUOTE); + script.append(SQLConstants.SPACE).append(SQL_RENAME).append(SQLConstants.DOUBLE_QUOTE).append(DMIdentifierProcessor.escapeIdentifier(newTable.getName())).append(SQLConstants.DOUBLE_QUOTE).append(SQLConstants.SEMICOLON_LINE_SEPARATOR); } if (!StringUtils.equalsIgnoreCase(oldTable.getComment(), newTable.getComment())) { script.append(SQLConstants.EMPTY).append(buildTableComment(newTable)).append(SQLConstants.SEMICOLON_LINE_SEPARATOR); @@ -212,9 +212,9 @@ public String buildPageLimit(PageLimitRequest request) { @Override public String buildCreateSchema(Schema schema) { StringBuilder sqlBuilder = new StringBuilder(); - sqlBuilder.append(SQL_CREATE_SCHEMA+DMSqlEscapes.escapeIdentifier(schema.getName())+SQLConstants.DOUBLE_QUOTE); + sqlBuilder.append(SQL_CREATE_SCHEMA+DMIdentifierProcessor.escapeIdentifier(schema.getName())+SQLConstants.DOUBLE_QUOTE); if(StringUtils.isNotBlank(schema.getOwner())){ - sqlBuilder.append(SQLConstants.SCHEMA_AUTHORIZATION_SQL).append(DMSqlEscapes.quoteIdentifier(schema.getOwner())); + sqlBuilder.append(SQLConstants.SCHEMA_AUTHORIZATION_SQL).append(DMIdentifierProcessor.INSTANCE.quoteIdentifier(schema.getOwner())); } return sqlBuilder.toString(); 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 0f9cd053c3..906c390793 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,6 +1,7 @@ package ai.chat2db.plugin.dm.enums.type; -import ai.chat2db.plugin.dm.DMSqlEscapes; +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; @@ -159,7 +160,7 @@ public String buildCreateColumnSql(TableColumn column) { } StringBuilder script = new StringBuilder(); - script.append(DMSqlEscapes.quoteIdentifier(column.getName())).append(" "); + script.append(DMIdentifierProcessor.INSTANCE.quoteIdentifier(column.getName())).append(" "); script.append(buildDataType(column, type)).append(" "); @@ -180,7 +181,7 @@ public String buildAICreateColumnSql(TableColumn column) { } StringBuilder script = new StringBuilder(); - script.append(DMSqlEscapes.quoteIdentifier(column.getName())).append(" "); + script.append(DMIdentifierProcessor.INSTANCE.quoteIdentifier(column.getName())).append(" "); script.append(buildDataType(column, type)).append(" "); @@ -233,7 +234,7 @@ private String buildDefaultValue(TableColumn column, DMColumnTypeEnum type) { return StringUtils.join("DEFAULT NULL"); } - return StringUtils.join("DEFAULT ", DMSqlEscapes.requireDefaultExpression(column.getDefaultValue())); + return StringUtils.join("DEFAULT ", DMSqlGuards.requireDefaultExpression(column.getDefaultValue())); } private String buildDataType(TableColumn column, DMColumnTypeEnum type) { @@ -298,25 +299,25 @@ public String buildModifyColumn(TableColumn tableColumn) { if (EditStatusEnum.DELETE.name().equals(tableColumn.getEditStatus())) { StringBuilder script = new StringBuilder(); - script.append(SQL_ALTER_TABLE).append("\"").append(DMSqlEscapes.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(DMSqlEscapes.escapeIdentifier(tableColumn.getTableName())).append("\""); - script.append(" ").append(SQL_DROP_COLUMN).append("\"").append(DMSqlEscapes.escapeIdentifier(tableColumn.getName())).append("\""); + script.append(SQL_ALTER_TABLE).append("\"").append(DMIdentifierProcessor.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(DMIdentifierProcessor.escapeIdentifier(tableColumn.getTableName())).append("\""); + script.append(" ").append(SQL_DROP_COLUMN).append("\"").append(DMIdentifierProcessor.escapeIdentifier(tableColumn.getName())).append("\""); return script.toString(); } if (EditStatusEnum.ADD.name().equals(tableColumn.getEditStatus())) { StringBuilder script = new StringBuilder(); - script.append(SQL_ALTER_TABLE).append("\"").append(DMSqlEscapes.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(DMSqlEscapes.escapeIdentifier(tableColumn.getTableName())).append("\""); + script.append(SQL_ALTER_TABLE).append("\"").append(DMIdentifierProcessor.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(DMIdentifierProcessor.escapeIdentifier(tableColumn.getTableName())).append("\""); script.append(" ").append("ADD (").append(buildCreateColumnSql(tableColumn)).append(")"); return script.toString(); } if (EditStatusEnum.MODIFY.name().equals(tableColumn.getEditStatus())) { StringBuilder script = new StringBuilder(); if (!StringUtils.equals(tableColumn.getOldName(), tableColumn.getName())) { - script.append(SQL_ALTER_TABLE).append("\"").append(DMSqlEscapes.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(DMSqlEscapes.escapeIdentifier(tableColumn.getTableName())).append("\""); - script.append(" ").append(SQL_RENAME_COLUMN).append("\"").append(DMSqlEscapes.escapeIdentifier(tableColumn.getOldName())).append("\"").append(" TO ").append("\"").append(DMSqlEscapes.escapeIdentifier(tableColumn.getName())).append("\""); + script.append(SQL_ALTER_TABLE).append("\"").append(DMIdentifierProcessor.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(DMIdentifierProcessor.escapeIdentifier(tableColumn.getTableName())).append("\""); + script.append(" ").append(SQL_RENAME_COLUMN).append("\"").append(DMIdentifierProcessor.escapeIdentifier(tableColumn.getOldName())).append("\"").append(" TO ").append("\"").append(DMIdentifierProcessor.escapeIdentifier(tableColumn.getName())).append("\""); script.append(";\n"); } - script.append(SQL_ALTER_TABLE).append("\"").append(DMSqlEscapes.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(DMSqlEscapes.escapeIdentifier(tableColumn.getTableName())).append("\""); + script.append(SQL_ALTER_TABLE).append("\"").append(DMIdentifierProcessor.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(DMIdentifierProcessor.escapeIdentifier(tableColumn.getTableName())).append("\""); script.append(" ").append("MODIFY (").append(buildCreateColumnSql(tableColumn)).append(") \n"); return script.toString(); 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 6a1fbfe25d..a747b900b2 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,6 +1,6 @@ package ai.chat2db.plugin.dm.enums.type; -import ai.chat2db.plugin.dm.DMSqlEscapes; +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; @@ -73,14 +73,14 @@ 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(DMSqlEscapes.escapeIdentifier(tableIndex.getSchemaName())).append("\".\"").append(DMSqlEscapes.escapeIdentifier(tableIndex.getTableName())).append("\" ADD PRIMARY KEY ").append(buildIndexColumn(tableIndex)); + script.append(SQL_ALTER_TABLE_2).append(DMIdentifierProcessor.escapeIdentifier(tableIndex.getSchemaName())).append("\".\"").append(DMIdentifierProcessor.escapeIdentifier(tableIndex.getTableName())).append("\" ADD PRIMARY KEY ").append(buildIndexColumn(tableIndex)); } else { if (UNIQUE.equals(this)) { script.append(SQL_CREATE_UNIQUE_INDEX); } else { script.append(SQL_CREATE_INDEX); } - script.append(buildIndexName(tableIndex)).append(SQL_ON).append(DMSqlEscapes.escapeIdentifier(tableIndex.getSchemaName())).append("\".\"").append(DMSqlEscapes.escapeIdentifier(tableIndex.getTableName())).append("\" ").append(buildIndexColumn(tableIndex)); + script.append(buildIndexName(tableIndex)).append(SQL_ON).append(DMIdentifierProcessor.escapeIdentifier(tableIndex.getSchemaName())).append("\".\"").append(DMIdentifierProcessor.escapeIdentifier(tableIndex.getTableName())).append("\" ").append(buildIndexColumn(tableIndex)); } return script.toString(); } @@ -91,7 +91,7 @@ private String buildIndexColumn(TableIndex tableIndex) { script.append("("); for (TableIndexColumn column : tableIndex.getColumnList()) { if (StringUtils.isNotBlank(column.getColumnName())) { - script.append(DMSqlEscapes.quoteIdentifier(column.getColumnName())); + script.append(DMIdentifierProcessor.INSTANCE.quoteIdentifier(column.getColumnName())); if (!StringUtils.isBlank(column.getAscOrDesc()) && !PRIMARY_KEY.equals(this)) { String ascOrDesc = column.getAscOrDesc(); if (!"ASC".equalsIgnoreCase(ascOrDesc) && !"DESC".equalsIgnoreCase(ascOrDesc)) { @@ -108,7 +108,7 @@ private String buildIndexColumn(TableIndex tableIndex) { } private String buildIndexName(TableIndex tableIndex) { - return DMSqlEscapes.quoteIdentifier(tableIndex.getSchemaName()) + "." + DMSqlEscapes.quoteIdentifier(tableIndex.getName()); + return DMIdentifierProcessor.INSTANCE.quoteIdentifier(tableIndex.getSchemaName()) + "." + DMIdentifierProcessor.INSTANCE.quoteIdentifier(tableIndex.getName()); } public String buildModifyIndex(TableIndex tableIndex) { @@ -126,7 +126,7 @@ public String buildModifyIndex(TableIndex tableIndex) { private String buildDropIndex(TableIndex tableIndex) { if (DMIndexTypeEnum.PRIMARY_KEY.getName().equals(tableIndex.getType())) { - String tableName = DMSqlEscapes.quoteIdentifier(tableIndex.getSchemaName()) + "." + DMSqlEscapes.quoteIdentifier(tableIndex.getTableName()); + String tableName = DMIdentifierProcessor.INSTANCE.quoteIdentifier(tableIndex.getSchemaName()) + "." + DMIdentifierProcessor.INSTANCE.quoteIdentifier(tableIndex.getTableName()); return StringUtils.join(SQL_ALTER_TABLE,tableName,SQL_DROP_PRIMARY_KEY); } StringBuilder script = new StringBuilder(); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-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..fb16939afe 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 @@ -8,6 +8,11 @@ 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 { @@ -252,39 +257,32 @@ public boolean isReservedKeyword(String identifier, Integer majorVersion, Intege return DM_RESERVED_KEYWORDS.contains(identifier); } + /** + * Always quotes 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, '"'); - } - return identifier; - } - return StringUtils.wrap(identifier, '"'); - + return "\"" + escapeIdentifierContent(identifier) + "\""; } @Override public String quoteIdentifierIgnoreCase(String identifier) { - if (isValidIdentifier(identifier)) { - if (isReservedKeyword(identifier.toUpperCase(), null, null)) { - return StringUtils.wrap(identifier, '"'); - } - return identifier; - } - return StringUtils.wrap(identifier, '"'); + return quoteIdentifier(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 @@ -295,4 +293,24 @@ public String convertIdentifierCase(String identifier) { return identifier.toUpperCase(); } } + + private static String escapeIdentifierContent(String identifier) { + if (identifier == null) { + return null; + } + String unquoted = identifier; + if (unquoted.length() >= 2 && unquoted.startsWith("\"") && unquoted.endsWith("\"")) { + unquoted = unquoted.substring(1, unquoted.length() - 1); + } + return unquoted.replace("\"", "\"\""); + } + + /** + * Escapes identifier content for a position already surrounded by double + * quotes: strips one surrounding quote pair, then doubles every embedded + * double quote. Returns {@code null} for {@code null}. + */ + public static String escapeIdentifier(String identifier) { + return escapeIdentifierContent(identifier); + } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/test/java/ai/chat2db/plugin/dm/DMSqlEscapesTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/test/java/ai/chat2db/plugin/dm/DMIdentifierProcessorTest.java similarity index 80% rename from chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/test/java/ai/chat2db/plugin/dm/DMSqlEscapesTest.java rename to chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/test/java/ai/chat2db/plugin/dm/DMIdentifierProcessorTest.java index 42f4dd4c15..66b8e8ae82 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/test/java/ai/chat2db/plugin/dm/DMSqlEscapesTest.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-dm/src/test/java/ai/chat2db/plugin/dm/DMIdentifierProcessorTest.java @@ -8,6 +8,7 @@ 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 org.junit.jupiter.api.Test; import java.util.List; @@ -17,26 +18,26 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -class DMSqlEscapesTest { +class DMIdentifierProcessorTest { @Test void escapeSqlLiteralDoublesSingleQuotes() { - assertEquals("O''Brien", DMSqlEscapes.escapeSqlLiteral("O'Brien")); - assertEquals("x'' OR ''1''=''1", DMSqlEscapes.escapeSqlLiteral("x' OR '1'='1")); - assertEquals("plain", DMSqlEscapes.escapeSqlLiteral("plain")); + assertEquals("O''Brien", DMIdentifierProcessor.INSTANCE.escapeString("O'Brien")); + assertEquals("x'' OR ''1''=''1", DMIdentifierProcessor.INSTANCE.escapeString("x' OR '1'='1")); + assertEquals("plain", DMIdentifierProcessor.INSTANCE.escapeString("plain")); } @Test void quoteIdentifierDoublesEmbeddedDoubleQuotes() { - assertEquals("\"plain\"", DMSqlEscapes.quoteIdentifier("plain")); - assertEquals("\"we\"\"ird\"", DMSqlEscapes.quoteIdentifier("we\"ird")); + assertEquals("\"plain\"", DMIdentifierProcessor.INSTANCE.quoteIdentifier("plain")); + assertEquals("\"we\"\"ird\"", DMIdentifierProcessor.INSTANCE.quoteIdentifier("we\"ird")); } @Test void escapeIdentifierStripsOneSurroundingQuotePairBeforeDoubling() { - assertEquals("already", DMSqlEscapes.escapeIdentifier("\"already\"")); - assertEquals("a\"\"b", DMSqlEscapes.escapeIdentifier("a\"b")); - assertEquals("plain", DMSqlEscapes.escapeIdentifier("plain")); + assertEquals("already", DMIdentifierProcessor.escapeIdentifier("\"already\"")); + assertEquals("a\"\"b", DMIdentifierProcessor.escapeIdentifier("a\"b")); + assertEquals("plain", DMIdentifierProcessor.escapeIdentifier("plain")); } @Test @@ -125,15 +126,15 @@ void quotedStringDefaultsAndUnitPassThroughUnchanged() { @Test void defaultExpressionAcceptsLegitimateAndRejectsInjection() { - org.junit.jupiter.api.Assertions.assertEquals("'abc'", DMSqlEscapes.requireDefaultExpression("'abc'")); - org.junit.jupiter.api.Assertions.assertEquals("'O''Brien'", DMSqlEscapes.requireDefaultExpression("'O''Brien'")); - org.junit.jupiter.api.Assertions.assertEquals("-1.5", DMSqlEscapes.requireDefaultExpression("-1.5")); - org.junit.jupiter.api.Assertions.assertEquals("CURRENT_TIMESTAMP", DMSqlEscapes.requireDefaultExpression("CURRENT_TIMESTAMP")); - org.junit.jupiter.api.Assertions.assertEquals("SEQ.NEXTVAL", DMSqlEscapes.requireDefaultExpression("SEQ.NEXTVAL")); + org.junit.jupiter.api.Assertions.assertEquals("'abc'", DMSqlGuards.requireDefaultExpression("'abc'")); + org.junit.jupiter.api.Assertions.assertEquals("'O''Brien'", DMSqlGuards.requireDefaultExpression("'O''Brien'")); + org.junit.jupiter.api.Assertions.assertEquals("-1.5", DMSqlGuards.requireDefaultExpression("-1.5")); + org.junit.jupiter.api.Assertions.assertEquals("CURRENT_TIMESTAMP", DMSqlGuards.requireDefaultExpression("CURRENT_TIMESTAMP")); + org.junit.jupiter.api.Assertions.assertEquals("SEQ.NEXTVAL", DMSqlGuards.requireDefaultExpression("SEQ.NEXTVAL")); org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, - () -> DMSqlEscapes.requireDefaultExpression("1; DROP TABLE t")); + () -> DMSqlGuards.requireDefaultExpression("1; DROP TABLE t")); org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, - () -> DMSqlEscapes.requireDefaultExpression("x' OR '1'='1")); + () -> DMSqlGuards.requireDefaultExpression("x' OR '1'='1")); } @Test From 91e86713c943fbd832cd25aca0c1da9c2795dd41 Mon Sep 17 00:00:00 2001 From: HandSonic <8078023+handsonic@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:50:49 +0800 Subject: [PATCH 3/6] fix(dm): keep SPI quoteIdentifier conditional, reserve always-quote for DDL paths (#1914) --- .../ai/chat2db/plugin/dm/DMDBManager.java | 4 +-- .../java/ai/chat2db/plugin/dm/DMMetaData.java | 4 +-- .../ai/chat2db/plugin/dm/DMSqlGuards.java | 8 +++-- .../plugin/dm/builder/DMSqlBuilder.java | 2 +- .../dm/enums/type/DMColumnTypeEnum.java | 4 +-- .../plugin/dm/enums/type/DMIndexTypeEnum.java | 6 ++-- .../dm/identifier/DMIdentifierProcessor.java | 29 +++++++++++++++++-- .../plugin/dm/DMIdentifierProcessorTest.java | 27 +++++++++++++++-- 8 files changed, 67 insertions(+), 17 deletions(-) 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 737840def5..1b96c0cd4e 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 @@ -41,7 +41,7 @@ public class DMDBManager extends DefaultDBManager implements IDbManager { private String format(String tableName) { - return DMIdentifierProcessor.INSTANCE.quoteIdentifier(tableName); + return DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName); } @@ -235,6 +235,6 @@ public void connectDatabase(Connection connection, String database) { @Override public String dropTable(Connection connection, String databaseName, String schemaName, String tableName) { - return String.format(SQL_DROP_TABLE_EXISTS, DMIdentifierProcessor.INSTANCE.quoteIdentifier(tableName)); + return String.format(SQL_DROP_TABLE_EXISTS, DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName)); } } 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 0e5455be28..06fcbc30e7 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 @@ -46,7 +46,7 @@ public List schemas(Connection connection, String databaseName) { } private String format(String tableName) { - return getSQLIdentifierProcessor().quoteIdentifier(tableName); + return DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName); } protected static String tableDDL = "SELECT dbms_metadata.get_ddl('TABLE', '%s','%s') as ddl FROM dual ;"; @@ -320,7 +320,7 @@ public ISQLIdentifierProcessor getSQLIdentifierProcessor() { @Override public String getMetaDataName(String... names) { - return Arrays.stream(names).filter(name -> StringUtils.isNotBlank(name)).map(getSQLIdentifierProcessor()::quoteIdentifier).collect(Collectors.joining(".")); + 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 index af9fcaaad7..ee6fbfdccb 100644 --- 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 @@ -12,11 +12,13 @@ public final class DMSqlGuards { /** * Legitimate column DEFAULT expressions: quoted string literals (with '' * escapes), numeric literals, or keyword/function forms such as - * CURRENT_TIMESTAMP, SYSDATE, USER, SEQ.NEXTVAL. Anything else is rejected - * because DEFAULT values are emitted verbatim. + * CURRENT_TIMESTAMP, SYSDATE, USER, SEQ.NEXTVAL. Function-call arguments + * tolerate quoted string literals and one level of nested parentheses + * (e.g. NVL(SUM(x),0)); semicolons are never allowed. Anything else is + * rejected because DEFAULT values are emitted verbatim. */ private static final Pattern DEFAULT_EXPRESSION = Pattern.compile( - "'([^']|'')*'|[+-]?(\\d+(\\.\\d+)?|\\.\\d+)|[A-Za-z_][A-Za-z0-9_]*([.][A-Za-z_][A-Za-z0-9_]*)*(\\s*\\([^;)]*\\))?"); + "'([^']|'')*'|[+-]?(\\d+(\\.\\d+)?|\\.\\d+)|[A-Za-z_][A-Za-z0-9_]*([.][A-Za-z_][A-Za-z0-9_]*)*(\\s*\\((?:'(?:[^']|'')*'|\\((?:'(?:[^']|'')*'|[^()';])*\\)|[^()';])*\\))?"); private DMSqlGuards() { } 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 ddd18e11c3..fae4505b86 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 @@ -214,7 +214,7 @@ public String buildCreateSchema(Schema schema) { StringBuilder sqlBuilder = new StringBuilder(); sqlBuilder.append(SQL_CREATE_SCHEMA+DMIdentifierProcessor.escapeIdentifier(schema.getName())+SQLConstants.DOUBLE_QUOTE); if(StringUtils.isNotBlank(schema.getOwner())){ - sqlBuilder.append(SQLConstants.SCHEMA_AUTHORIZATION_SQL).append(DMIdentifierProcessor.INSTANCE.quoteIdentifier(schema.getOwner())); + sqlBuilder.append(SQLConstants.SCHEMA_AUTHORIZATION_SQL).append(DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(schema.getOwner())); } return sqlBuilder.toString(); 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 906c390793..161c373209 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 @@ -160,7 +160,7 @@ public String buildCreateColumnSql(TableColumn column) { } StringBuilder script = new StringBuilder(); - script.append(DMIdentifierProcessor.INSTANCE.quoteIdentifier(column.getName())).append(" "); + script.append(DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName())).append(" "); script.append(buildDataType(column, type)).append(" "); @@ -181,7 +181,7 @@ public String buildAICreateColumnSql(TableColumn column) { } StringBuilder script = new StringBuilder(); - script.append(DMIdentifierProcessor.INSTANCE.quoteIdentifier(column.getName())).append(" "); + script.append(DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName())).append(" "); script.append(buildDataType(column, type)).append(" "); 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 a747b900b2..19ee1214ba 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 @@ -91,7 +91,7 @@ private String buildIndexColumn(TableIndex tableIndex) { script.append("("); for (TableIndexColumn column : tableIndex.getColumnList()) { if (StringUtils.isNotBlank(column.getColumnName())) { - script.append(DMIdentifierProcessor.INSTANCE.quoteIdentifier(column.getColumnName())); + script.append(DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getColumnName())); if (!StringUtils.isBlank(column.getAscOrDesc()) && !PRIMARY_KEY.equals(this)) { String ascOrDesc = column.getAscOrDesc(); if (!"ASC".equalsIgnoreCase(ascOrDesc) && !"DESC".equalsIgnoreCase(ascOrDesc)) { @@ -108,7 +108,7 @@ private String buildIndexColumn(TableIndex tableIndex) { } private String buildIndexName(TableIndex tableIndex) { - return DMIdentifierProcessor.INSTANCE.quoteIdentifier(tableIndex.getSchemaName()) + "." + DMIdentifierProcessor.INSTANCE.quoteIdentifier(tableIndex.getName()); + return DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableIndex.getSchemaName()) + "." + DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableIndex.getName()); } public String buildModifyIndex(TableIndex tableIndex) { @@ -126,7 +126,7 @@ public String buildModifyIndex(TableIndex tableIndex) { private String buildDropIndex(TableIndex tableIndex) { if (DMIndexTypeEnum.PRIMARY_KEY.getName().equals(tableIndex.getType())) { - String tableName = DMIdentifierProcessor.INSTANCE.quoteIdentifier(tableIndex.getSchemaName()) + "." + DMIdentifierProcessor.INSTANCE.quoteIdentifier(tableIndex.getTableName()); + String tableName = DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableIndex.getSchemaName()) + "." + DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableIndex.getTableName()); return StringUtils.join(SQL_ALTER_TABLE,tableName,SQL_DROP_PRIMARY_KEY); } StringBuilder script = new StringBuilder(); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-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 fb16939afe..d787c5cc18 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 @@ -258,7 +258,10 @@ public boolean isReservedKeyword(String identifier, Integer majorVersion, Intege } /** - * Always quotes with double quotes, stripping one surrounding quote pair and + * 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 @@ -268,12 +271,34 @@ public String quoteIdentifier(String identifier, Integer majorVersion, Integer m @Override public String quoteIdentifier(String identifier) { + if (StringUtils.isBlank(identifier)) { + return identifier; + } + if (isValidIdentifier(identifier) + && !isReservedKeyword(identifier.toUpperCase(java.util.Locale.ROOT), null, null)) { + return identifier; + } + return quoteIdentifierAlways(identifier); + } + + /** + * Unconditional quoting for DDL-generation call sites: wraps with double + * quotes, stripping one surrounding quote pair and doubling every embedded + * double quote. Returns {@code null} for {@code null}. + */ + public String quoteIdentifierAlways(String identifier) { + if (identifier == null) { + return null; + } return "\"" + escapeIdentifierContent(identifier) + "\""; } + /** + * Always-quote SPI variant that preserves the original identifier case. + */ @Override public String quoteIdentifierIgnoreCase(String identifier) { - return quoteIdentifier(identifier); + return quoteIdentifierAlways(identifier); } /** 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 index 66b8e8ae82..fe315939ab 100644 --- 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 @@ -15,6 +15,7 @@ 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; @@ -28,11 +29,31 @@ void escapeSqlLiteralDoublesSingleQuotes() { } @Test - void quoteIdentifierDoublesEmbeddedDoubleQuotes() { - assertEquals("\"plain\"", DMIdentifierProcessor.INSTANCE.quoteIdentifier("plain")); + void quoteIdentifierIsConditionalForSpiConsumers() { + assertNull(DMIdentifierProcessor.INSTANCE.quoteIdentifier(null)); + assertEquals("", DMIdentifierProcessor.INSTANCE.quoteIdentifier("")); + assertEquals("plain", DMIdentifierProcessor.INSTANCE.quoteIdentifier("plain")); + assertEquals("plain", DMIdentifierProcessor.INSTANCE.quoteIdentifier("plain", null, null)); + assertEquals("\"SELECT\"", DMIdentifierProcessor.INSTANCE.quoteIdentifier("SELECT")); + assertEquals("\"select\"", DMIdentifierProcessor.INSTANCE.quoteIdentifier("select")); + assertEquals("\"weird name\"", DMIdentifierProcessor.INSTANCE.quoteIdentifier("weird name")); assertEquals("\"we\"\"ird\"", DMIdentifierProcessor.INSTANCE.quoteIdentifier("we\"ird")); } + @Test + void quoteIdentifierAlwaysQuotesUnconditionally() { + assertNull(DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(null)); + assertEquals("\"plain\"", DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways("plain")); + assertEquals("\"SELECT\"", DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways("SELECT")); + assertEquals("\"we\"\"ird\"", DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways("we\"ird")); + } + + @Test + void quoteIdentifierIgnoreCaseIsTheAlwaysQuoteVariant() { + assertEquals("\"plain\"", DMIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("plain")); + assertEquals("\"MixedCase\"", DMIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("MixedCase")); + } + @Test void escapeIdentifierStripsOneSurroundingQuotePairBeforeDoubling() { assertEquals("already", DMIdentifierProcessor.escapeIdentifier("\"already\"")); @@ -131,6 +152,8 @@ void defaultExpressionAcceptsLegitimateAndRejectsInjection() { org.junit.jupiter.api.Assertions.assertEquals("-1.5", DMSqlGuards.requireDefaultExpression("-1.5")); org.junit.jupiter.api.Assertions.assertEquals("CURRENT_TIMESTAMP", DMSqlGuards.requireDefaultExpression("CURRENT_TIMESTAMP")); org.junit.jupiter.api.Assertions.assertEquals("SEQ.NEXTVAL", DMSqlGuards.requireDefaultExpression("SEQ.NEXTVAL")); + org.junit.jupiter.api.Assertions.assertEquals("NVL(SUM(x),0)", DMSqlGuards.requireDefaultExpression("NVL(SUM(x),0)")); + org.junit.jupiter.api.Assertions.assertEquals("NVL(SUM('a;b'),0)", DMSqlGuards.requireDefaultExpression("NVL(SUM('a;b'),0)")); org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, () -> DMSqlGuards.requireDefaultExpression("1; DROP TABLE t")); org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, From 24228a48a68c38d25949918bbef477a110b9cbb7 Mon Sep 17 00:00:00 2001 From: HandSonic <8078023+handsonic@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:24:47 +0800 Subject: [PATCH 4/6] fix(dm): unroll quoted-literal regex to avoid CodeQL polynomial-regex alert (#1914) --- .../src/main/java/ai/chat2db/plugin/dm/DMSqlGuards.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index ee6fbfdccb..c5da0fb052 100644 --- 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 @@ -18,7 +18,7 @@ public final class DMSqlGuards { * rejected because DEFAULT values are emitted verbatim. */ private static final Pattern DEFAULT_EXPRESSION = Pattern.compile( - "'([^']|'')*'|[+-]?(\\d+(\\.\\d+)?|\\.\\d+)|[A-Za-z_][A-Za-z0-9_]*([.][A-Za-z_][A-Za-z0-9_]*)*(\\s*\\((?:'(?:[^']|'')*'|\\((?:'(?:[^']|'')*'|[^()';])*\\)|[^()';])*\\))?"); + "'[^']*(?:''[^']*)*'|[+-]?(\\d+(\\.\\d+)?|\\.\\d+)|[A-Za-z_][A-Za-z0-9_]*([.][A-Za-z_][A-Za-z0-9_]*)*(\\s*\\((?:'[^']*(?:''[^']*)*'|\\((?:'[^']*(?:''[^']*)*'|[^()';])*\\)|[^()';])*\\))?"); private DMSqlGuards() { } From 88984b40a21a4db13733e08de5488851cfe19130 Mon Sep 17 00:00:00 2001 From: HandSonic <8078023+handsonic@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:13:26 +0800 Subject: [PATCH 5/6] fix(dm): replace DEFAULT validator regex with linear scanners (CodeQL ReDoS) (#1914) Balanced-paren + quoted-literal aware scanning keeps NVL(SUM(x),0) and quoted defaults accepted; semicolons and unbalanced input rejected. --- .../ai/chat2db/plugin/dm/DMSqlGuards.java | 149 ++++++++++++++++-- 1 file changed, 133 insertions(+), 16 deletions(-) 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 index c5da0fb052..89c98baf86 100644 --- 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 @@ -1,38 +1,155 @@ package ai.chat2db.plugin.dm; -import java.util.regex.Pattern; - /** * Validation helpers for non-escapable SQL positions in DM DDL generation * (column DEFAULT expressions emitted verbatim). Escaping itself lives in * {@link ai.chat2db.plugin.dm.identifier.DMIdentifierProcessor}. + * All shapes are recognized with linear-time scanners (no regex), so the + * checks cannot be driven into regex backtracking. */ public final class DMSqlGuards { - /** - * Legitimate column DEFAULT expressions: quoted string literals (with '' - * escapes), numeric literals, or keyword/function forms such as - * CURRENT_TIMESTAMP, SYSDATE, USER, SEQ.NEXTVAL. Function-call arguments - * tolerate quoted string literals and one level of nested parentheses - * (e.g. NVL(SUM(x),0)); semicolons are never allowed. Anything else is - * rejected because DEFAULT values are emitted verbatim. - */ - private static final Pattern DEFAULT_EXPRESSION = Pattern.compile( - "'[^']*(?:''[^']*)*'|[+-]?(\\d+(\\.\\d+)?|\\.\\d+)|[A-Za-z_][A-Za-z0-9_]*([.][A-Za-z_][A-Za-z0-9_]*)*(\\s*\\((?:'[^']*(?:''[^']*)*'|\\((?:'[^']*(?:''[^']*)*'|[^()';])*\\)|[^()';])*\\))?"); - private DMSqlGuards() { } /** * Validates a column DEFAULT expression that is emitted verbatim into DDL. - * Accepts quoted string literals (escaped via doubling), numeric literals, - * and keyword/function forms; rejects everything else. + * Legitimate forms: quoted string literals (with '' escapes), numeric literals, + * or keyword/function forms such as CURRENT_TIMESTAMP, SYSDATE, USER, SEQ.NEXTVAL. + * Function-call arguments tolerate quoted string literals and nested balanced + * parentheses (e.g. NVL(SUM(x),0)); semicolons are never allowed. Anything else + * is rejected because DEFAULT values are emitted verbatim. */ public static String requireDefaultExpression(String defaultValue) { String trimmed = defaultValue.trim(); - if (!DEFAULT_EXPRESSION.matcher(trimmed).matches()) { + if (!isQuotedStringLiteral(trimmed) && !isNumericLiteral(trimmed) && !isIdentChainOrCall(trimmed)) { throw new IllegalArgumentException("Invalid DM default expression: " + defaultValue); } return trimmed; } + + /** + * True when {@code s} is exactly one single-quoted string literal with '' escapes. + * Linear scan, no backtracking. + */ + static boolean isQuotedStringLiteral(String s) { + return s.length() >= 2 && s.charAt(0) == '\'' && quotedLiteralEnd(s, 0) == s.length(); + } + + /** + * Returns the index just past the single-quoted literal that starts at + * {@code start} (where {@code s.charAt(start) == '\''}), or -1 when the literal + * is unterminated. Doubled quotes are consumed as escapes. + */ + private static int quotedLiteralEnd(String s, int start) { + int i = start + 1; + int n = s.length(); + while (i < n) { + if (s.charAt(i) == '\'') { + if (i + 1 < n && s.charAt(i + 1) == '\'') { + i += 2; + continue; + } + return i + 1; + } + i++; + } + return -1; + } + + /** + * Numeric literal: optional sign, digits with optional fractional part, or a + * leading-dot fraction (e.g. {@code -1}, {@code 1.5}, {@code .5}). + */ + private static boolean isNumericLiteral(String s) { + int n = s.length(); + int i = (s.startsWith("+") || s.startsWith("-")) ? 1 : 0; + boolean intDigits = false; + while (i < n && Character.isDigit(s.charAt(i))) { + i++; + intDigits = true; + } + boolean dotSeen = false; + boolean fracDigits = false; + if (i < n && s.charAt(i) == '.') { + dotSeen = true; + i++; + while (i < n && Character.isDigit(s.charAt(i))) { + i++; + fracDigits = true; + } + } + if (i != n) { + return false; + } + return dotSeen ? (intDigits || fracDigits) && fracDigits : intDigits; + } + + /** + * True for an identifier chain {@code ident(.ident)*} (e.g. SYSDATE, SEQ.NEXTVAL) + * optionally followed by a parenthesized argument list. Arguments may contain + * single-quoted string literals and nested balanced parentheses; semicolons and + * stray quotes/parens are rejected. Linear scan. + */ + static boolean isIdentChainOrCall(String s) { + int n = s.length(); + if (n == 0 || !isIdentStart(s.charAt(0))) { + return false; + } + int i = 1; + while (i < n) { + char c = s.charAt(i); + if (isIdentPart(c)) { + i++; + } else if (c == '.' && i + 1 < n && isIdentStart(s.charAt(i + 1))) { + i++; + } else { + break; + } + } + while (i < n && Character.isWhitespace(s.charAt(i))) { + i++; + } + if (i == n) { + return true; + } + if (s.charAt(i) != '(') { + return false; + } + int depth = 0; + while (i < n) { + char c = s.charAt(i); + if (c == '\'') { + int end = quotedLiteralEnd(s, i); + if (end < 0) { + return false; + } + i = end; + continue; + } + if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + if (depth == 0) { + return i == n - 1; + } + if (depth < 0) { + return false; + } + } else if (c == ';') { + return false; + } + i++; + } + return false; + } + + private static boolean isIdentStart(char c) { + return Character.isLetter(c) || c == '_'; + } + + private static boolean isIdentPart(char c) { + return Character.isLetterOrDigit(c) || c == '_'; + } } From b490b5bef61d4e92326badc668e27460eeb17112 Mon Sep 17 00:00:00 2001 From: zgq Date: Wed, 29 Jul 2026 18:24:33 +0800 Subject: [PATCH 6/6] fix(dm): complete identifier-safe SQL generation --- .../ai/chat2db/plugin/dm/DMDBManager.java | 40 +- .../java/ai/chat2db/plugin/dm/DMMetaData.java | 7 +- .../ai/chat2db/plugin/dm/DMSqlGuards.java | 320 +++++++++------ .../plugin/dm/builder/DMSqlBuilder.java | 135 +++++-- .../dm/constant/DMDBManagerConstants.java | 2 +- .../dm/constant/DMSqlBuilderConstants.java | 2 +- .../dm/enums/type/DMColumnTypeEnum.java | 64 ++- .../plugin/dm/enums/type/DMIndexTypeEnum.java | 32 +- .../dm/identifier/DMIdentifierProcessor.java | 80 ++-- .../plugin/dm/value/sub/DMBitProcessor.java | 3 +- .../plugin/dm/DMIdentifierProcessorTest.java | 365 ++++++++++++++---- 11 files changed, 770 insertions(+), 280 deletions(-) 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 1b96c0cd4e..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 @@ -227,14 +227,50 @@ public void connectDatabase(Connection connection, String database) { } String schemaName = connectInfo.getSchemaName(); try { - DefaultSQLExecutor.getInstance().execute(connection, String.format(SQL_SET_SCHEMA, DMIdentifierProcessor.escapeIdentifier(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, DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(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 06fcbc30e7..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 @@ -134,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()); } } @@ -320,6 +321,10 @@ public ISQLIdentifierProcessor getSQLIdentifierProcessor() { @Override public String getMetaDataName(String... names) { + 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 index 89c98baf86..357c873b6c 100644 --- 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 @@ -1,155 +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; + /** - * Validation helpers for non-escapable SQL positions in DM DDL generation - * (column DEFAULT expressions emitted verbatim). Escaping itself lives in - * {@link ai.chat2db.plugin.dm.identifier.DMIdentifierProcessor}. - * All shapes are recognized with linear-time scanners (no regex), so the - * checks cannot be driven into regex backtracking. + * 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 a column DEFAULT expression that is emitted verbatim into DDL. - * Legitimate forms: quoted string literals (with '' escapes), numeric literals, - * or keyword/function forms such as CURRENT_TIMESTAMP, SYSDATE, USER, SEQ.NEXTVAL. - * Function-call arguments tolerate quoted string literals and nested balanced - * parentheses (e.g. NVL(SUM(x),0)); semicolons are never allowed. Anything else - * is rejected because DEFAULT values are emitted verbatim. + * Validates one DM DEFAULT expression without re-encoding serialized + * literals returned by metadata. */ - public static String requireDefaultExpression(String defaultValue) { - String trimmed = defaultValue.trim(); - if (!isQuotedStringLiteral(trimmed) && !isNumericLiteral(trimmed) && !isIdentChainOrCall(trimmed)) { - throw new IllegalArgumentException("Invalid DM default expression: " + defaultValue); + public static String requireDefaultExpression(String value) { + if (StringUtils.isBlank(value)) { + throw invalid("DEFAULT expression", value); } - return trimmed; + scanExpression(value.trim(), false, "DEFAULT expression"); + return value; } /** - * True when {@code s} is exactly one single-quoted string literal with '' escapes. - * Linear scan, no backtracking. + * Validates one complete DM column type expression, including + * parameterized built-in and schema-qualified user-defined types. */ - static boolean isQuotedStringLiteral(String s) { - return s.length() >= 2 && s.charAt(0) == '\'' && quotedLiteralEnd(s, 0) == s.length(); - } - - /** - * Returns the index just past the single-quoted literal that starts at - * {@code start} (where {@code s.charAt(start) == '\''}), or -1 when the literal - * is unterminated. Doubled quotes are consumed as escapes. - */ - private static int quotedLiteralEnd(String s, int start) { - int i = start + 1; - int n = s.length(); - while (i < n) { - if (s.charAt(i) == '\'') { - if (i + 1 < n && s.charAt(i + 1) == '\'') { - i += 2; - continue; - } - return i + 1; - } - i++; + public static String requireColumnTypeExpression(String typeName) { + if (StringUtils.isBlank(typeName)) { + throw invalid("column type", typeName); } - return -1; + scanExpression(typeName.trim(), true, "column type"); + return typeName; } - /** - * Numeric literal: optional sign, digits with optional fractional part, or a - * leading-dot fraction (e.g. {@code -1}, {@code 1.5}, {@code .5}). - */ - private static boolean isNumericLiteral(String s) { - int n = s.length(); - int i = (s.startsWith("+") || s.startsWith("-")) ? 1 : 0; - boolean intDigits = false; - while (i < n && Character.isDigit(s.charAt(i))) { - i++; - intDigits = true; - } - boolean dotSeen = false; - boolean fracDigits = false; - if (i < n && s.charAt(i) == '.') { - dotSeen = true; - i++; - while (i < n && Character.isDigit(s.charAt(i))) { - i++; - fracDigits = true; - } - } - if (i != n) { - return false; + 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 dotSeen ? (intDigits || fracDigits) && fracDigits : intDigits; + return trimmed; } - /** - * True for an identifier chain {@code ident(.ident)*} (e.g. SYSDATE, SEQ.NEXTVAL) - * optionally followed by a parenthesized argument list. Arguments may contain - * single-quoted string literals and nested balanced parentheses; semicolons and - * stray quotes/parens are rejected. Linear scan. - */ - static boolean isIdentChainOrCall(String s) { - int n = s.length(); - if (n == 0 || !isIdentStart(s.charAt(0))) { - return false; + public static String requireAscOrDesc(String value) { + String trimmed = StringUtils.trimToEmpty(value); + if ("ASC".equalsIgnoreCase(trimmed)) { + return "ASC"; } - int i = 1; - while (i < n) { - char c = s.charAt(i); - if (isIdentPart(c)) { - i++; - } else if (c == '.' && i + 1 < n && isIdentStart(s.charAt(i + 1))) { - i++; - } else { - break; - } + if ("DESC".equalsIgnoreCase(trimmed)) { + return "DESC"; } - while (i < n && Character.isWhitespace(s.charAt(i))) { - i++; + throw new IllegalArgumentException("Invalid DM index sort direction: " + value); + } + + public static String requireBitLiteral(String value) { + if (StringUtils.isBlank(value)) { + return "NULL"; } - if (i == n) { - return true; + String trimmed = StringUtils.trimToEmpty(value); + if ("0".equals(trimmed) || "false".equalsIgnoreCase(trimmed)) { + return "0"; } - if (s.charAt(i) != '(') { - return false; + if ("1".equals(trimmed) || "true".equalsIgnoreCase(trimmed)) { + return "1"; } - int depth = 0; - while (i < n) { - char c = s.charAt(i); - if (c == '\'') { - int end = quotedLiteralEnd(s, i); - if (end < 0) { - return false; + 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 == '(') { - depth++; - } else if (c == ')') { - depth--; - if (depth == 0) { - return i == n - 1; + 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 (depth < 0) { - return false; + 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; } - } else if (c == ';') { - return false; + 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; } - i++; } return false; } - private static boolean isIdentStart(char c) { - return Character.isLetter(c) || c == '_'; + 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 boolean isIdentPart(char c) { - return Character.isLetterOrDigit(c) || c == '_'; + 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 fae4505b86..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 @@ -5,9 +5,11 @@ 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.*; @@ -20,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(DMIdentifierProcessor.escapeIdentifier(table.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMIdentifierProcessor.escapeIdentifier(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); } @@ -99,16 +139,16 @@ public String buildAITableSchema(Table table) { } StringBuilder script = new StringBuilder(); - script.append(SQL_CREATE_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(DMIdentifierProcessor.escapeIdentifier(table.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMIdentifierProcessor.escapeIdentifier(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); } @@ -136,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(DMIdentifierProcessor.escapeIdentifier(table.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMIdentifierProcessor.escapeIdentifier(table.getName())).append(VALUE_DOUBLE_QUOTE_IS_SINGLE_QUOTE).append(DMIdentifierProcessor.INSTANCE.escapeString(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(DMIdentifierProcessor.escapeIdentifier(column.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMIdentifierProcessor.escapeIdentifier(column.getTableName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMIdentifierProcessor.escapeIdentifier(column.getName())).append(VALUE_DOUBLE_QUOTE_IS_SINGLE_QUOTE).append(DMIdentifierProcessor.INSTANCE.escapeString(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(DMIdentifierProcessor.escapeIdentifier(oldTable.getSchemaName())).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(DMIdentifierProcessor.escapeIdentifier(oldTable.getName())).append(SQLConstants.DOUBLE_QUOTE); - script.append(SQLConstants.SPACE).append(SQL_RENAME).append(SQLConstants.DOUBLE_QUOTE).append(DMIdentifierProcessor.escapeIdentifier(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); @@ -162,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); @@ -209,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+DMIdentifierProcessor.escapeIdentifier(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(DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(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 161c373209..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 @@ -6,12 +6,12 @@ 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.*; @@ -131,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(); @@ -154,9 +156,9 @@ 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(); @@ -175,9 +177,9 @@ 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(); @@ -245,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(); } @@ -299,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(DMIdentifierProcessor.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(DMIdentifierProcessor.escapeIdentifier(tableColumn.getTableName())).append("\""); - script.append(" ").append(SQL_DROP_COLUMN).append("\"").append(DMIdentifierProcessor.escapeIdentifier(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(DMIdentifierProcessor.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(DMIdentifierProcessor.escapeIdentifier(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(DMIdentifierProcessor.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(DMIdentifierProcessor.escapeIdentifier(tableColumn.getTableName())).append("\""); - script.append(" ").append(SQL_RENAME_COLUMN).append("\"").append(DMIdentifierProcessor.escapeIdentifier(tableColumn.getOldName())).append("\"").append(" TO ").append("\"").append(DMIdentifierProcessor.escapeIdentifier(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(DMIdentifierProcessor.escapeIdentifier(tableColumn.getSchemaName())).append("\".\"").append(DMIdentifierProcessor.escapeIdentifier(tableColumn.getTableName())).append("\""); + script.append(SQL_ALTER_TABLE).append(qualifiedTableName(tableColumn)); script.append(" ").append("MODIFY (").append(buildCreateColumnSql(tableColumn)).append(") \n"); return script.toString(); @@ -326,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 19ee1214ba..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,6 @@ 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; @@ -73,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(DMIdentifierProcessor.escapeIdentifier(tableIndex.getSchemaName())).append("\".\"").append(DMIdentifierProcessor.escapeIdentifier(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(DMIdentifierProcessor.escapeIdentifier(tableIndex.getSchemaName())).append("\".\"").append(DMIdentifierProcessor.escapeIdentifier(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(); } @@ -89,26 +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())) { + hasColumn = true; script.append(DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getColumnName())); if (!StringUtils.isBlank(column.getAscOrDesc()) && !PRIMARY_KEY.equals(this)) { - String ascOrDesc = column.getAscOrDesc(); - if (!"ASC".equalsIgnoreCase(ascOrDesc) && !"DESC".equalsIgnoreCase(ascOrDesc)) { - throw new IllegalArgumentException("Invalid index column sort order: " + ascOrDesc); - } - script.append(" ").append(ascOrDesc); + 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 DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableIndex.getSchemaName()) + "." + DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableIndex.getName()); + return qualifiedName(tableIndex.getSchemaName(), tableIndex.getName()); } public String buildModifyIndex(TableIndex tableIndex) { @@ -126,7 +132,7 @@ public String buildModifyIndex(TableIndex tableIndex) { private String buildDropIndex(TableIndex tableIndex) { if (DMIndexTypeEnum.PRIMARY_KEY.getName().equals(tableIndex.getType())) { - String tableName = DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableIndex.getSchemaName()) + "." + DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableIndex.getTableName()); + String tableName = qualifiedName(tableIndex.getSchemaName(), tableIndex.getTableName()); return StringUtils.join(SQL_ALTER_TABLE,tableName,SQL_DROP_PRIMARY_KEY); } StringBuilder script = new StringBuilder(); @@ -136,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 d787c5cc18..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,6 +4,7 @@ import org.apache.commons.lang3.StringUtils; import java.util.HashSet; +import java.util.Locale; import java.util.Set; public class DMIdentifierProcessor extends DefaultSQLIdentifierProcessor { @@ -254,7 +255,7 @@ 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)); } /** @@ -271,11 +272,18 @@ public String quoteIdentifier(String identifier, Integer majorVersion, Integer m @Override public String quoteIdentifier(String identifier) { + if (identifier == null) { + return null; + } if (StringUtils.isBlank(identifier)) { return identifier; } + if (isValidQuotedIdentifier(identifier)) { + return identifier; + } if (isValidIdentifier(identifier) - && !isReservedKeyword(identifier.toUpperCase(java.util.Locale.ROOT), null, null)) { + && !containsLowerCase(identifier) + && !isReservedKeyword(identifier, null, null)) { return identifier; } return quoteIdentifierAlways(identifier); @@ -283,21 +291,23 @@ public String quoteIdentifier(String identifier) { /** * Unconditional quoting for DDL-generation call sites: wraps with double - * quotes, stripping one surrounding quote pair and doubling every embedded - * double quote. Returns {@code null} for {@code null}. + * quotes and doubling every embedded double quote, including quotes at the + * raw name boundaries. Returns {@code null} for {@code null}. */ - public String quoteIdentifierAlways(String identifier) { + @Override + public String quoteIdentifierIgnoreCase(String identifier) { if (identifier == null) { return null; } - return "\"" + escapeIdentifierContent(identifier) + "\""; - } - - /** - * Always-quote SPI variant that preserves the original identifier case. - */ - @Override - public String quoteIdentifierIgnoreCase(String identifier) { + if (StringUtils.isBlank(identifier)) { + return identifier; + } + if (isValidQuotedIdentifier(identifier)) { + return identifier; + } + if (isValidIdentifier(identifier) && !isReservedKeyword(identifier, null, null)) { + return identifier; + } return quoteIdentifierAlways(identifier); } @@ -314,28 +324,48 @@ public String escapeString(String str) { 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) { - if (identifier == null) { - return null; - } - String unquoted = identifier; - if (unquoted.length() >= 2 && unquoted.startsWith("\"") && unquoted.endsWith("\"")) { - unquoted = unquoted.substring(1, unquoted.length() - 1); - } - return unquoted.replace("\"", "\"\""); + return identifier == null ? null : StringUtils.replace(identifier, "\"", "\"\""); } /** * Escapes identifier content for a position already surrounded by double - * quotes: strips one surrounding quote pair, then doubles every embedded - * double quote. Returns {@code null} for {@code null}. + * 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 index fe315939ab..d5a8833709 100644 --- 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 @@ -1,17 +1,28 @@ 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; @@ -22,151 +33,335 @@ class DMIdentifierProcessorTest { @Test - void escapeSqlLiteralDoublesSingleQuotes() { - assertEquals("O''Brien", DMIdentifierProcessor.INSTANCE.escapeString("O'Brien")); - assertEquals("x'' OR ''1''=''1", DMIdentifierProcessor.INSTANCE.escapeString("x' OR '1'='1")); - assertEquals("plain", DMIdentifierProcessor.INSTANCE.escapeString("plain")); + 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 quoteIdentifierIsConditionalForSpiConsumers() { - assertNull(DMIdentifierProcessor.INSTANCE.quoteIdentifier(null)); - assertEquals("", DMIdentifierProcessor.INSTANCE.quoteIdentifier("")); - assertEquals("plain", DMIdentifierProcessor.INSTANCE.quoteIdentifier("plain")); - assertEquals("plain", DMIdentifierProcessor.INSTANCE.quoteIdentifier("plain", null, null)); - assertEquals("\"SELECT\"", DMIdentifierProcessor.INSTANCE.quoteIdentifier("SELECT")); - assertEquals("\"select\"", DMIdentifierProcessor.INSTANCE.quoteIdentifier("select")); - assertEquals("\"weird name\"", DMIdentifierProcessor.INSTANCE.quoteIdentifier("weird name")); - assertEquals("\"we\"\"ird\"", DMIdentifierProcessor.INSTANCE.quoteIdentifier("we\"ird")); + 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 quoteIdentifierAlwaysQuotesUnconditionally() { - assertNull(DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways(null)); - assertEquals("\"plain\"", DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways("plain")); - assertEquals("\"SELECT\"", DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways("SELECT")); - assertEquals("\"we\"\"ird\"", DMIdentifierProcessor.INSTANCE.quoteIdentifierAlways("we\"ird")); + 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 quoteIdentifierIgnoreCaseIsTheAlwaysQuoteVariant() { - assertEquals("\"plain\"", DMIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("plain")); - assertEquals("\"MixedCase\"", DMIdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("MixedCase")); + 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 escapeIdentifierStripsOneSurroundingQuotePairBeforeDoubling() { - assertEquals("already", DMIdentifierProcessor.escapeIdentifier("\"already\"")); + 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("plain", DMIdentifierProcessor.escapeIdentifier("plain")); + assertEquals("\"\"already\"\"", DMIdentifierProcessor.escapeIdentifier("\"already\"")); } @Test - void createTableSqlNeutralizesMaliciousSchemaAndComment() { - Table table = new Table(); - table.setSchemaName("s\"; DROP TABLE t; --"); - table.setName("tab"); - table.setComment("x'; DROP TABLE t; --"); - TableColumn column = new TableColumn(); - column.setName("c1"); - column.setColumnType("INT"); - table.setColumnList(List.of(column)); - table.setIndexList(List.of()); + 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.contains("\"s\"\"; DROP TABLE t; --\".\"tab\"")); - assertFalse(sql.contains("\"s\"; DROP TABLE t; --\"")); - assertTrue(sql.contains("IS 'x''; DROP TABLE t; --'")); - assertFalse(sql.contains("IS 'x'; DROP TABLE t; --'")); + assertTrue(sql.startsWith("CREATE TABLE \"ORDERS\" ("), sql); + assertFalse(sql.contains("\"null\""), sql); } @Test - void dropTableQuotesAndEscapesTableName() { - String sql = new DMDBManager().dropTable(null, null, null, "a\"; DROP TABLE b; --"); + 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; --")); + } - assertEquals("DROP TABLE IF EXISTS \"a\"\"; DROP TABLE b; --\"", sql); + @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 metaDataNameEscapesEachIdentifierPart() { - String name = new DMMetaData().getMetaDataName("sch\"ema", "ta\"ble"); + 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())); + } - assertEquals("\"sch\"\"ema\".\"ta\"\"ble\"", 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 createSchemaQuotesOwnerAsIdentifier() { + void createSchemaQuotesNameAndOwner() { Schema schema = new Schema(); schema.setName("app"); schema.setOwner("owner; DROP USER x; --"); - String sql = new DMSqlBuilder().buildCreateSchema(schema); - - assertEquals("CREATE SCHEMA \"app\" AUTHORIZATION \"owner; DROP USER x; --\"", sql); + assertEquals("CREATE SCHEMA \"app\" AUTHORIZATION \"owner; DROP USER x; --\"", + new DMSqlBuilder().buildCreateSchema(schema)); } @Test - void indexSortOrderAcceptsAscDescAndRejectsInjection() { + void indexScriptEscapesNamesAndCanonicalizesDirection() { TableIndex index = new TableIndex(); - index.setSchemaName("s"); - index.setTableName("t"); - index.setName("i"); + index.setType(DMIndexTypeEnum.NORMAL.getName()); + index.setSchemaName("S\"; X"); + index.setTableName("T"); + index.setName("I\"X"); TableIndexColumn column = new TableIndexColumn(); - column.setColumnName("c"); - column.setAscOrDesc("desc"); + column.setColumnName("C\"D"); + column.setAscOrDesc(" desc "); index.setColumnList(List.of(column)); - assertTrue(DMIndexTypeEnum.NORMAL.buildIndexScript(index).contains("\"c\" desc")); + 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 quotedStringDefaultsAndUnitPassThroughUnchanged() { - TableColumn column = new TableColumn(); - column.setName("c1"); - column.setColumnType("VARCHAR"); + void knownColumnTypeAcceptsOnlySupportedUnit() { + TableColumn column = column("c1", "VARCHAR"); column.setColumnSize(10); - column.setUnit("BYTE"); + column.setUnit("byte"); column.setDefaultValue("'O''Brien'"); String sql = DMColumnTypeEnum.VARCHAR.buildCreateColumnSql(column); - assertTrue(sql.contains("VARCHAR(10 BYTE)")); - assertTrue(sql.contains("DEFAULT 'O''Brien'")); + 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); - TableColumn emptyStringDefault = new TableColumn(); - emptyStringDefault.setName("c2"); - emptyStringDefault.setColumnType("VARCHAR"); - emptyStringDefault.setDefaultValue("EMPTY_STRING"); + 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())); - assertTrue(DMColumnTypeEnum.VARCHAR.buildCreateColumnSql(emptyStringDefault).contains("DEFAULT ''")); + assertThrows(IllegalArgumentException.class, + () -> DMIndexTypeEnum.NORMAL.buildIndexScript(index)); } @Test - void defaultExpressionAcceptsLegitimateAndRejectsInjection() { - org.junit.jupiter.api.Assertions.assertEquals("'abc'", DMSqlGuards.requireDefaultExpression("'abc'")); - org.junit.jupiter.api.Assertions.assertEquals("'O''Brien'", DMSqlGuards.requireDefaultExpression("'O''Brien'")); - org.junit.jupiter.api.Assertions.assertEquals("-1.5", DMSqlGuards.requireDefaultExpression("-1.5")); - org.junit.jupiter.api.Assertions.assertEquals("CURRENT_TIMESTAMP", DMSqlGuards.requireDefaultExpression("CURRENT_TIMESTAMP")); - org.junit.jupiter.api.Assertions.assertEquals("SEQ.NEXTVAL", DMSqlGuards.requireDefaultExpression("SEQ.NEXTVAL")); - org.junit.jupiter.api.Assertions.assertEquals("NVL(SUM(x),0)", DMSqlGuards.requireDefaultExpression("NVL(SUM(x),0)")); - org.junit.jupiter.api.Assertions.assertEquals("NVL(SUM('a;b'),0)", DMSqlGuards.requireDefaultExpression("NVL(SUM('a;b'),0)")); - org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, - () -> DMSqlGuards.requireDefaultExpression("1; DROP TABLE t")); - org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, - () -> DMSqlGuards.requireDefaultExpression("x' OR '1'='1")); + 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 createColumnRejectsMaliciousDefault() { + 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("c"); - column.setColumnType("INT"); - column.setDefaultValue("1; DROP TABLE t;--"); - org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, - () -> DMColumnTypeEnum.INT.buildCreateColumnSql(column)); + column.setName(name); + column.setColumnType(columnType); + return column; } }