From 3e9f21ae4b70d29bc5971cbd86bb492601341a36 Mon Sep 17 00:00:00 2001 From: HandSonic <8078023+handsonic@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:17:28 +0800 Subject: [PATCH 1/6] fix(sqlserver): preserve IDENTITY columns when copying table (#1691) Use Chat2DBContext.getDbMetaData().tableDDL() to generate proper CREATE TABLE DDL that preserves IDENTITY, computed columns, constraints, indexes, etc. For data copying, wrap INSERT INTO...SELECT with SET IDENTITY_INSERT ON/OFF to allow explicit values in identity columns. The previous SELECT * INTO approach silently dropped IDENTITY properties. --- .../plugin/sqlserver/SqlServerDBManager.java | 84 +++++++++++++++++-- .../constant/SqlServerDBManagerConstants.java | 5 +- 2 files changed, 78 insertions(+), 11 deletions(-) diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java index fcd341dc3..1d9ea35cf 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java @@ -17,6 +17,7 @@ import java.util.Date; import static ai.chat2db.plugin.sqlserver.constant.SQLConstant.*; +import static ai.chat2db.plugin.sqlserver.constant.SqlServerDBManagerConstants.*; import static cn.hutool.core.date.DatePattern.NORM_DATETIME_PATTERN; import static ai.chat2db.plugin.sqlserver.constant.SqlServerDBManagerConstants.*; @@ -162,17 +163,82 @@ public void connectDatabase(Connection connection, String database) { @Override public void copyTable(Connection connection, String databaseName, String schemaName, String tableName, String newTableName, boolean copyData) throws SQLException { - String sourceTable = buildFullTableName(databaseName, schemaName, tableName); - String targetTable = buildFullTableName(databaseName, schemaName, newTableName); - String sql; - if (copyData) { - sql = String.format(SQL_COPY_TABLE_DATA, targetTable, sourceTable); - } else { - sql = String.format(SQL_COPY_TABLE_STRUCTURE, targetTable, sourceTable); + String ddl = Chat2DBContext.getDbMetaData().tableDDL(connection, + new TableMetadataRequest(databaseName, schemaName, tableName)); + // Replace only the CREATE TABLE [tableName] line, not other references + String formatOldTable = "[" + tableName + "]"; + String formatNewTable = "[" + newTableName + "]"; + String createDdl = ddl.replaceFirst( + "(?i)CREATE\\s+TABLE\\s+" + java.util.regex.Pattern.quote(formatOldTable), + "CREATE TABLE " + formatNewTable); + log.info("copy table DDL: {}", createDdl); + + // tableDDL() uses 'go' as batch separator which is not valid JDBC SQL. + // Split by 'go' on its own line and execute each batch separately. + String[] batches = createDdl.split("(?m)^\\s*go\\s*$"); + for (String batch : batches) { + String trimmed = batch.trim(); + if (!trimmed.isEmpty()) { + DefaultSQLExecutor.getInstance().execute(connection, trimmed, resultSet -> null); + } } - log.info(" copy table sql : {}", sql); - DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> null); + if (copyData) { + // Query column metadata to determine which columns are copyable + java.util.List copyableColumns = new java.util.ArrayList<>(); + boolean hasIdentity = false; + try (PreparedStatement ps = connection.prepareStatement(SELECT_TABLE_COLUMNS)) { + ps.setString(1, schemaName); + ps.setString(2, tableName); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + String computedDef = rs.getString("COMPUTED_DEFINITION"); + String dataType = rs.getString("DATA_TYPE"); + if (computedDef != null && !computedDef.isEmpty()) { + continue; // skip computed columns + } + if ("timestamp".equalsIgnoreCase(dataType) || "rowversion".equalsIgnoreCase(dataType)) { + continue; // skip timestamp/rowversion (auto-generated) + } + if (rs.getBoolean("IS_IDENTITY")) { + hasIdentity = true; + } + copyableColumns.add("[" + rs.getString("COLUMN_NAME") + "]"); + } + } + } + + if (copyableColumns.isEmpty()) { + log.warn("No copyable columns found for table {}", tableName); + return; + } + + String columnList = String.join(", ", copyableColumns); + String sourceTable = buildFullTableName(databaseName, schemaName, tableName); + String targetTable = buildFullTableName(databaseName, schemaName, newTableName); + String insertSql = String.format(SQL_COPY_TABLE_DATA_WITH_COLUMNS, targetTable, columnList, sourceTable); + log.info("copy table data sql: {}", insertSql); + + if (hasIdentity) { + String identityOn = String.format(SQL_SET_IDENTITY_INSERT, targetTable, "ON"); + String identityOff = String.format(SQL_SET_IDENTITY_INSERT, targetTable, "OFF"); + try { + DefaultSQLExecutor.getInstance().execute(connection, identityOn, resultSet -> null); + DefaultSQLExecutor.getInstance().execute(connection, insertSql, resultSet -> null); + } catch (Exception e) { + log.error("Failed to copy data with identity insert", e); + throw e; + } finally { + try { + DefaultSQLExecutor.getInstance().execute(connection, identityOff, resultSet -> null); + } catch (Exception e) { + log.warn("Failed to turn off identity insert", e); + } + } + } else { + DefaultSQLExecutor.getInstance().execute(connection, insertSql, resultSet -> null); + } + } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SqlServerDBManagerConstants.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SqlServerDBManagerConstants.java index 2a3f74d1f..02b21c93d 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SqlServerDBManagerConstants.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SqlServerDBManagerConstants.java @@ -22,8 +22,9 @@ public final class SqlServerDBManagerConstants { public static final String SQL_DROP_TABLE_EXISTS = "DROP TABLE IF EXISTS "; public static final String SQL_DROP_VIEW_EXISTS = "DROP VIEW IF EXISTS "; - public static final String SQL_COPY_TABLE_DATA = "SELECT * INTO %s FROM %s"; - public static final String SQL_COPY_TABLE_STRUCTURE = "SELECT * INTO %s FROM %s WHERE 1=0"; + public static final String SQL_COPY_TABLE_DATA = "INSERT INTO %s SELECT * FROM %s"; + public static final String SQL_COPY_TABLE_DATA_WITH_COLUMNS = "INSERT INTO %s (%s) SELECT %s FROM %s"; + public static final String SQL_SET_IDENTITY_INSERT = "SET IDENTITY_INSERT %s %s"; public static final String SQL_DROP_TABLE = "DROP TABLE %s"; public static final String SQL_DROP_VIEW = "DROP VIEW %s"; public static final String SQL_USE_DATABASE = "use [%s];"; From ef80cfb85d5ef67840e94d696675d28b48c6e09a Mon Sep 17 00:00:00 2001 From: zgq Date: Sat, 25 Jul 2026 12:36:04 +0800 Subject: [PATCH 2/6] fix(sqlserver): harden table copy semantics --- .../plugin/sqlserver/SqlServerDBManager.java | 245 +++++++++++++++--- .../plugin/sqlserver/SqlServerMetaData.java | 26 +- .../constant/SqlServerDBManagerConstants.java | 1 - .../sqlserver/SqlServerDBManagerTest.java | 105 ++++++++ .../sqlserver/SqlServerMetaDataTest.java | 23 ++ 5 files changed, 352 insertions(+), 48 deletions(-) create mode 100644 chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerDBManagerTest.java create mode 100644 chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerMetaDataTest.java diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java index 1d9ea35cf..991d89679 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java @@ -11,19 +11,33 @@ import org.apache.commons.lang3.StringUtils; import java.sql.Connection; +import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; -import java.sql.PreparedStatement; +import java.util.ArrayList; import java.util.Date; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; import static ai.chat2db.plugin.sqlserver.constant.SQLConstant.*; import static ai.chat2db.plugin.sqlserver.constant.SqlServerDBManagerConstants.*; import static cn.hutool.core.date.DatePattern.NORM_DATETIME_PATTERN; -import static ai.chat2db.plugin.sqlserver.constant.SqlServerDBManagerConstants.*; @Slf4j public class SqlServerDBManager extends DefaultDBManager implements IDbManager { + private static final Pattern GO_BATCH_LINE = Pattern.compile("(?i)^\\s*go\\s*;?\\s*(?:--.*)?$"); + private static final Pattern CREATE_INDEX_BATCH = Pattern.compile( + "(?is)^\\s*CREATE\\s+(?:UNIQUE\\s+)?(?:CLUSTERED\\s+|NONCLUSTERED\\s+|SPATIAL\\s+|XML\\s+)?INDEX\\b"); + private static final Pattern NAMED_TABLE_CONSTRAINT = Pattern.compile( + "(?im)^(\\s*)constraint\\s+[^\\r\\n]+\\R(?=\\s*(?:primary\\s+key|unique|check|foreign\\s+key)\\b)"); + private static final Pattern CONSTRAINT_COMMENT = Pattern.compile( + "(?i)'CONSTRAINT'\\s*,"); + @@ -165,27 +179,14 @@ public void connectDatabase(Connection connection, String database) { public void copyTable(Connection connection, String databaseName, String schemaName, String tableName, String newTableName, boolean copyData) throws SQLException { String ddl = Chat2DBContext.getDbMetaData().tableDDL(connection, new TableMetadataRequest(databaseName, schemaName, tableName)); - // Replace only the CREATE TABLE [tableName] line, not other references - String formatOldTable = "[" + tableName + "]"; - String formatNewTable = "[" + newTableName + "]"; - String createDdl = ddl.replaceFirst( - "(?i)CREATE\\s+TABLE\\s+" + java.util.regex.Pattern.quote(formatOldTable), - "CREATE TABLE " + formatNewTable); - log.info("copy table DDL: {}", createDdl); - - // tableDDL() uses 'go' as batch separator which is not valid JDBC SQL. - // Split by 'go' on its own line and execute each batch separately. - String[] batches = createDdl.split("(?m)^\\s*go\\s*$"); + List batches = prepareCopyDdlBatches(ddl, databaseName, schemaName, tableName, newTableName); for (String batch : batches) { - String trimmed = batch.trim(); - if (!trimmed.isEmpty()) { - DefaultSQLExecutor.getInstance().execute(connection, trimmed, resultSet -> null); - } + log.info("copy table DDL batch: {}", batch); + DefaultSQLExecutor.getInstance().execute(connection, batch, resultSet -> null); } if (copyData) { - // Query column metadata to determine which columns are copyable - java.util.List copyableColumns = new java.util.ArrayList<>(); + List copyableColumns = new ArrayList<>(); boolean hasIdentity = false; try (PreparedStatement ps = connection.prepareStatement(SELECT_TABLE_COLUMNS)) { ps.setString(1, schemaName); @@ -194,16 +195,13 @@ public void copyTable(Connection connection, String databaseName, String schemaN while (rs.next()) { String computedDef = rs.getString("COMPUTED_DEFINITION"); String dataType = rs.getString("DATA_TYPE"); - if (computedDef != null && !computedDef.isEmpty()) { - continue; // skip computed columns - } - if ("timestamp".equalsIgnoreCase(dataType) || "rowversion".equalsIgnoreCase(dataType)) { - continue; // skip timestamp/rowversion (auto-generated) + if (!isCopyableColumn(computedDef, dataType)) { + continue; } if (rs.getBoolean("IS_IDENTITY")) { hasIdentity = true; } - copyableColumns.add("[" + rs.getString("COLUMN_NAME") + "]"); + copyableColumns.add(quoteIdentifier(rs.getString("COLUMN_NAME"))); } } } @@ -216,23 +214,34 @@ public void copyTable(Connection connection, String databaseName, String schemaN String columnList = String.join(", ", copyableColumns); String sourceTable = buildFullTableName(databaseName, schemaName, tableName); String targetTable = buildFullTableName(databaseName, schemaName, newTableName); - String insertSql = String.format(SQL_COPY_TABLE_DATA_WITH_COLUMNS, targetTable, columnList, sourceTable); + String insertSql = buildCopyDataSql(targetTable, columnList, sourceTable); log.info("copy table data sql: {}", insertSql); if (hasIdentity) { String identityOn = String.format(SQL_SET_IDENTITY_INSERT, targetTable, "ON"); String identityOff = String.format(SQL_SET_IDENTITY_INSERT, targetTable, "OFF"); + boolean identityEnabled = false; + RuntimeException copyFailure = null; try { DefaultSQLExecutor.getInstance().execute(connection, identityOn, resultSet -> null); + identityEnabled = true; DefaultSQLExecutor.getInstance().execute(connection, insertSql, resultSet -> null); - } catch (Exception e) { - log.error("Failed to copy data with identity insert", e); - throw e; + } catch (RuntimeException exception) { + copyFailure = exception; + log.error("Failed to copy data with identity insert", exception); + throw exception; } finally { - try { - DefaultSQLExecutor.getInstance().execute(connection, identityOff, resultSet -> null); - } catch (Exception e) { - log.warn("Failed to turn off identity insert", e); + if (identityEnabled) { + try { + DefaultSQLExecutor.getInstance().execute(connection, identityOff, resultSet -> null); + } catch (RuntimeException exception) { + if (copyFailure != null) { + copyFailure.addSuppressed(exception); + log.warn("Failed to turn off identity insert", exception); + } else { + throw exception; + } + } } } } else { @@ -241,28 +250,182 @@ public void copyTable(Connection connection, String databaseName, String schemaN } } + static List prepareCopyDdlBatches(String ddl, String databaseName, String schemaName, + String tableName, String newTableName) { + if (StringUtils.isBlank(ddl)) { + throw new IllegalArgumentException("Source table DDL is empty"); + } - private String buildFullTableName(String databaseName, String schemaName, String tableName) { + List sourceReferences = tableReferences(databaseName, schemaName, tableName); + String targetTable = buildFullTableName(null, schemaName, newTableName); + List rewrittenBatches = new ArrayList<>(); + boolean createTableRewritten = false; + + for (String batch : splitDdlBatches(ddl)) { + String rewritten = batch; + if (startsWithKeyword(batch, "CREATE\\s+TABLE")) { + rewritten = replaceObjectAfterKeyword(batch, "CREATE\\s+TABLE\\s+", sourceReferences, + targetTable); + rewritten = rewriteSelfReference(rewritten, sourceReferences, tableName, targetTable); + // Constraint names are schema-scoped, so copied declarations must receive fresh server-generated names. + rewritten = NAMED_TABLE_CONSTRAINT.matcher(rewritten).replaceAll("$1"); + createTableRewritten = true; + } else if (CREATE_INDEX_BATCH.matcher(batch).find()) { + rewritten = replaceObjectAfterKeyword(batch, "ON\\s+", sourceReferences, targetTable); + } else if (isExtendedPropertyBatch(batch)) { + if (CONSTRAINT_COMMENT.matcher(batch).find()) { + // Auto-generated target constraint names cannot be addressed by the source constraint comment. + continue; + } + rewritten = rewriteExtendedPropertyTable(batch, tableName, newTableName); + } + rewrittenBatches.add(rewritten.trim()); + } + + if (!createTableRewritten) { + throw new IllegalArgumentException("Unable to locate the source CREATE TABLE statement"); + } + return rewrittenBatches; + } + + static List splitDdlBatches(String ddl) { + List batches = new ArrayList<>(); + StringBuilder batch = new StringBuilder(); + boolean inString = false; + String[] lines = ddl.split("\\R", -1); + for (String line : lines) { + if (!inString && GO_BATCH_LINE.matcher(line).matches()) { + addBatch(batches, batch); + continue; + } + batch.append(line).append('\n'); + inString = updateStringState(line, inString); + } + addBatch(batches, batch); + return batches; + } + + private static boolean updateStringState(String line, boolean inString) { + for (int i = 0; i < line.length(); i++) { + if (line.charAt(i) != '\'') { + continue; + } + if (inString && i + 1 < line.length() && line.charAt(i + 1) == '\'') { + i++; + } else { + inString = !inString; + } + } + return inString; + } + + private static void addBatch(List batches, StringBuilder batch) { + String sql = batch.toString().trim(); + if (StringUtils.isNotBlank(sql)) { + batches.add(sql); + } + batch.setLength(0); + } + + private static boolean startsWithKeyword(String sql, String keywordPattern) { + return Pattern.compile("(?is)^\\s*" + keywordPattern + "\\b").matcher(sql).find(); + } + + private static boolean isExtendedPropertyBatch(String sql) { + return Pattern.compile("(?is)^\\s*exec\\s+sp_addextendedproperty\\b").matcher(sql).find(); + } + + private static String replaceObjectAfterKeyword(String sql, String keywordPattern, List sourceReferences, + String targetReference) { + String alternatives = sourceReferences.stream() + .map(Pattern::quote) + .collect(Collectors.joining("|")); + Pattern pattern = Pattern.compile("(?i)(\\b" + keywordPattern + ")(?:(?:" + alternatives + "))"); + Matcher matcher = pattern.matcher(sql); + if (!matcher.find()) { + throw new IllegalArgumentException("Unable to rewrite source table reference in DDL batch: " + sql); + } + return matcher.replaceFirst(Matcher.quoteReplacement(matcher.group(1) + targetReference)); + } + + private static String rewriteSelfReference(String sql, List sourceReferences, String tableName, + String targetReference) { + Set references = new LinkedHashSet<>(sourceReferences); + references.add(quoteIdentifier(tableName)); + references.add(tableName); + String alternatives = references.stream() + .filter(StringUtils::isNotBlank) + .map(Pattern::quote) + .collect(Collectors.joining("|")); + Pattern pattern = Pattern.compile("(?i)(\\breferences\\s+)(?:(?:" + alternatives + "))(?=\\s*\\()"); + Matcher matcher = pattern.matcher(sql); + StringBuffer rewritten = new StringBuffer(); + while (matcher.find()) { + matcher.appendReplacement(rewritten, Matcher.quoteReplacement(matcher.group(1) + targetReference)); + } + matcher.appendTail(rewritten); + return rewritten.toString(); + } + + private static String rewriteExtendedPropertyTable(String sql, String tableName, String newTableName) { + Pattern pattern = Pattern.compile("(?i)('TABLE'\\s*,\\s*N')" + Pattern.quote(tableName) + "(')"); + Matcher matcher = pattern.matcher(sql); + if (!matcher.find()) { + throw new IllegalArgumentException("Unable to rewrite table extended property: " + sql); + } + String replacement = matcher.group(1) + newTableName.replace("'", "''") + matcher.group(2); + return matcher.replaceFirst(Matcher.quoteReplacement(replacement)); + } + + static boolean isCopyableColumn(String computedDefinition, String dataType) { + return StringUtils.isBlank(computedDefinition) + && !"timestamp".equalsIgnoreCase(dataType) + && !"rowversion".equalsIgnoreCase(dataType); + } + + static String buildCopyDataSql(String targetTable, String columnList, String sourceTable) { + return String.format(SQL_COPY_TABLE_DATA_WITH_COLUMNS, targetTable, columnList, columnList, sourceTable); + } + + private static List tableReferences(String databaseName, String schemaName, String tableName) { + Set references = new LinkedHashSet<>(); + references.add(buildFullTableName(databaseName, schemaName, tableName)); + references.add(buildFullTableName(null, schemaName, tableName)); + references.add(quoteIdentifier(tableName)); + return references.stream() + .filter(StringUtils::isNotBlank) + .sorted((left, right) -> Integer.compare(right.length(), left.length())) + .toList(); + } + + static String buildFullTableName(String databaseName, String schemaName, String tableName) { StringBuilder fullTableName = new StringBuilder(); if (StringUtils.isNotBlank(databaseName)) { - fullTableName.append("[").append(databaseName).append("]."); + fullTableName.append(quoteIdentifier(databaseName)).append('.'); } if (StringUtils.isNotBlank(schemaName)) { - fullTableName.append("[").append(schemaName).append("]."); + fullTableName.append(quoteIdentifier(schemaName)).append('.'); } if (StringUtils.isNotBlank(tableName)) { - if (!tableName.startsWith("[") || !tableName.endsWith("]")) { - fullTableName.append("[").append(tableName).append("]"); - } else { - fullTableName.append(tableName); - } + fullTableName.append(quoteIdentifier(tableName)); } return fullTableName.toString(); } + private static String quoteIdentifier(String identifier) { + if (StringUtils.isBlank(identifier)) { + return identifier; + } + String value = identifier; + if (value.length() >= 2 && value.startsWith("[") && value.endsWith("]")) { + value = value.substring(1, value.length() - 1).replace("]]", "]"); + } + return "[" + value.replace("]", "]]" ) + "]"; + } + @Override public String dropTable(Connection connection, String databaseName, String schemaName, String tableName) { String fullTableName = buildFullTableName(databaseName, schemaName, tableName); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java index af12b9826..2d9d69a94 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java @@ -38,6 +38,7 @@ import org.apache.commons.collections4.MapUtils; import org.apache.commons.lang3.StringUtils; +import java.math.BigDecimal; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; @@ -98,18 +99,15 @@ public String tableDDL(Connection connection, String databaseName, String schema boolean isPersisted = resultSet.getBoolean("IS_PERSISTED"); String dataType = resultSet.getString("DATA_TYPE").toUpperCase(); boolean isIdentity = resultSet.getBoolean("IS_IDENTITY"); - int seedValue = resultSet.getInt("SEED_VALUE"); - int incrementValue = resultSet.getInt("INCREMENT_VALUE"); + BigDecimal seedValue = resultSet.getBigDecimal("SEED_VALUE"); + BigDecimal incrementValue = resultSet.getBigDecimal("INCREMENT_VALUE"); if (StringUtils.isNotBlank(computedDefinition)) { dataType = "AS " + computedDefinition; if (isPersisted) { dataType += " PERSISTED"; } } else if (isIdentity) { - dataType += " identity"; - if (seedValue != 1 && incrementValue != 1) { - dataType += " (" + seedValue + "," + incrementValue + ")"; - } + dataType = buildIdentityDataType(dataType, seedValue, incrementValue); } tableColumn.setColumnType(dataType); tableColumn.setSparse(resultSet.getBoolean("IS_SPARSE")); @@ -384,6 +382,22 @@ public String tableDDL(Connection connection, String databaseName, String schema return ddlBuilder.toString(); } + static String buildIdentityDataType(String dataType, BigDecimal seedValue, BigDecimal incrementValue) { + String identityType = dataType + " identity"; + if (seedValue == null || incrementValue == null) { + return identityType; + } + if (seedValue.compareTo(BigDecimal.ONE) != 0 || incrementValue.compareTo(BigDecimal.ONE) != 0) { + return identityType + " (" + formatIdentityValue(seedValue) + "," + + formatIdentityValue(incrementValue) + ")"; + } + return identityType; + } + + private static String formatIdentityValue(BigDecimal value) { + return value.stripTrailingZeros().toPlainString(); + } + private String buildReferentialAction(int actionCode) { switch (actionCode) { case 1 -> { diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SqlServerDBManagerConstants.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SqlServerDBManagerConstants.java index 02b21c93d..46d346a82 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SqlServerDBManagerConstants.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SqlServerDBManagerConstants.java @@ -22,7 +22,6 @@ public final class SqlServerDBManagerConstants { public static final String SQL_DROP_TABLE_EXISTS = "DROP TABLE IF EXISTS "; public static final String SQL_DROP_VIEW_EXISTS = "DROP VIEW IF EXISTS "; - public static final String SQL_COPY_TABLE_DATA = "INSERT INTO %s SELECT * FROM %s"; public static final String SQL_COPY_TABLE_DATA_WITH_COLUMNS = "INSERT INTO %s (%s) SELECT %s FROM %s"; public static final String SQL_SET_IDENTITY_INSERT = "SET IDENTITY_INSERT %s %s"; public static final String SQL_DROP_TABLE = "DROP TABLE %s"; diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerDBManagerTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerDBManagerTest.java new file mode 100644 index 000000000..34b55a7e7 --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerDBManagerTest.java @@ -0,0 +1,105 @@ +package ai.chat2db.plugin.sqlserver; + +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.assertTrue; + +class SqlServerDBManagerTest { + + @Test + void shouldRewriteQualifiedTableDdlAndPreserveCopyableStructure() { + String ddl = """ + CREATE TABLE [sales].[orders] + ( + [id] BIGINT identity, + [computed_total] AS ([id] + 1), + [version] timestamp, + [status] int default ((1)), + constraint PK_orders + primary key ([id]), + constraint FK_orders_parent + foreign key ([parent_id]) + references [sales].[orders] ([id]), + constraint FK_orders_customer + foreign key ([customer_id]) + references customers ([id]) + ) + GO + CREATE NONCLUSTERED INDEX [IX_orders_status] + ON [sales].[orders] ([status]) + go + exec sp_addextendedproperty 'MS_Description',N'index comment','SCHEMA',N'sales','TABLE',N'orders','INDEX',N'IX_orders_status' + GO + exec sp_addextendedproperty 'MS_Description',N'constraint comment','SCHEMA',N'sales','TABLE',N'orders','CONSTRAINT',N'PK_orders' + GO + """; + + List batches = SqlServerDBManager.prepareCopyDdlBatches( + ddl, "catalog", "sales", "orders", "orders_copy"); + + assertEquals(3, batches.size()); + assertTrue(batches.get(0).startsWith("CREATE TABLE [sales].[orders_copy]")); + assertTrue(batches.get(0).contains("[id] BIGINT identity")); + assertTrue(batches.get(0).contains("[computed_total] AS ([id] + 1)")); + assertTrue(batches.get(0).contains("[version] timestamp")); + assertTrue(batches.get(0).contains("[status] int default ((1))")); + assertTrue(batches.get(0).contains("primary key ([id])")); + assertTrue(batches.get(0).contains("references [sales].[orders_copy] ([id])")); + assertTrue(batches.get(0).contains("references customers ([id])")); + assertFalse(batches.get(0).contains("constraint PK_orders")); + assertFalse(batches.get(0).contains("constraint FK_orders_parent")); + assertTrue(batches.get(1).contains("INDEX [IX_orders_status]")); + assertTrue(batches.get(1).contains("ON [sales].[orders_copy] ([status])")); + assertTrue(batches.get(2).contains("'TABLE',N'orders_copy','INDEX',N'IX_orders_status'")); + assertFalse(batches.stream().anyMatch(batch -> batch.contains("constraint comment"))); + } + + @Test + void shouldRewriteCurrentUnqualifiedCreateTableFormat() { + List batches = SqlServerDBManager.prepareCopyDdlBatches( + "CREATE TABLE [orders]\n([id] int)\ngo\n", + "catalog", "sales", "orders", "orders_copy"); + + assertEquals(List.of("CREATE TABLE [sales].[orders_copy]\n([id] int)"), batches); + } + + @Test + void shouldNotSplitGoInsideStringLiteral() { + List batches = SqlServerDBManager.splitDdlBatches( + "exec log_comment N'line one\ngo\nline three'\nGO ; -- batch\nSELECT 1;"); + + assertEquals(List.of( + "exec log_comment N'line one\ngo\nline three'", + "SELECT 1;"), batches); + } + + @Test + void shouldUseTheSameExplicitColumnsForInsertAndSelect() { + assertEquals( + "INSERT INTO [catalog].[sales].[orders_copy] ([id], [name]) " + + "SELECT [id], [name] FROM [catalog].[sales].[orders]", + SqlServerDBManager.buildCopyDataSql( + "[catalog].[sales].[orders_copy]", "[id], [name]", "[catalog].[sales].[orders]")); + } + + @Test + void shouldExcludeGeneratedColumnsButKeepIdentityAndDefaultBackedColumns() { + assertFalse(SqlServerDBManager.isCopyableColumn("([quantity] * [price])", "decimal")); + assertFalse(SqlServerDBManager.isCopyableColumn(null, "timestamp")); + assertFalse(SqlServerDBManager.isCopyableColumn(null, "rowversion")); + assertTrue(SqlServerDBManager.isCopyableColumn(null, "bigint")); + assertTrue(SqlServerDBManager.isCopyableColumn(null, "int")); + } + + @Test + void shouldEscapeEveryQualifiedIdentifierPart() { + assertEquals("[catalog]]archive].[sales].[orders]]2026]", + SqlServerDBManager.buildFullTableName("catalog]archive", "sales", "orders]2026")); + assertEquals("[catalog].[sales].[orders]", + SqlServerDBManager.buildFullTableName("[catalog]", "[sales]", "[orders]")); + } +} diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerMetaDataTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerMetaDataTest.java new file mode 100644 index 000000000..32a25da7e --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerMetaDataTest.java @@ -0,0 +1,23 @@ +package ai.chat2db.plugin.sqlserver; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SqlServerMetaDataTest { + + @Test + void shouldPreserveEitherNonDefaultIdentityParameterWithoutIntegerTruncation() { + assertEquals("BIGINT identity", + SqlServerMetaData.buildIdentityDataType("BIGINT", BigDecimal.ONE, BigDecimal.ONE)); + assertEquals("BIGINT identity (10,1)", + SqlServerMetaData.buildIdentityDataType("BIGINT", BigDecimal.TEN, BigDecimal.ONE)); + assertEquals("BIGINT identity (1,5)", + SqlServerMetaData.buildIdentityDataType("BIGINT", BigDecimal.ONE, BigDecimal.valueOf(5))); + assertEquals("DECIMAL identity (9223372036854775808,-2)", + SqlServerMetaData.buildIdentityDataType("DECIMAL", + new BigDecimal("9223372036854775808"), new BigDecimal("-2"))); + } +} From 0418f13af7862fc29fdd2166cdf547dee03a269e Mon Sep 17 00:00:00 2001 From: zgq Date: Sat, 25 Jul 2026 12:49:08 +0800 Subject: [PATCH 3/6] fix(sqlserver): harden copy-table SQL handling --- .../plugin/sqlserver/SqlServerDBManager.java | 201 ++++++++++++++---- .../plugin/sqlserver/SqlServerMetaData.java | 12 +- .../sqlserver/SqlServerDBManagerTest.java | 80 +++++++ .../sqlserver/SqlServerMetaDataTest.java | 10 + 4 files changed, 259 insertions(+), 44 deletions(-) diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java index 991d89679..4d4b3a115 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java @@ -19,6 +19,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import java.util.function.Consumer; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -218,32 +219,8 @@ public void copyTable(Connection connection, String databaseName, String schemaN log.info("copy table data sql: {}", insertSql); if (hasIdentity) { - String identityOn = String.format(SQL_SET_IDENTITY_INSERT, targetTable, "ON"); - String identityOff = String.format(SQL_SET_IDENTITY_INSERT, targetTable, "OFF"); - boolean identityEnabled = false; - RuntimeException copyFailure = null; - try { - DefaultSQLExecutor.getInstance().execute(connection, identityOn, resultSet -> null); - identityEnabled = true; - DefaultSQLExecutor.getInstance().execute(connection, insertSql, resultSet -> null); - } catch (RuntimeException exception) { - copyFailure = exception; - log.error("Failed to copy data with identity insert", exception); - throw exception; - } finally { - if (identityEnabled) { - try { - DefaultSQLExecutor.getInstance().execute(connection, identityOff, resultSet -> null); - } catch (RuntimeException exception) { - if (copyFailure != null) { - copyFailure.addSuppressed(exception); - log.warn("Failed to turn off identity insert", exception); - } else { - throw exception; - } - } - } - } + executeIdentityCopy(targetTable, insertSql, + sql -> DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> null)); } else { DefaultSQLExecutor.getInstance().execute(connection, insertSql, resultSet -> null); } @@ -268,7 +245,7 @@ static List prepareCopyDdlBatches(String ddl, String databaseName, Strin targetTable); rewritten = rewriteSelfReference(rewritten, sourceReferences, tableName, targetTable); // Constraint names are schema-scoped, so copied declarations must receive fresh server-generated names. - rewritten = NAMED_TABLE_CONSTRAINT.matcher(rewritten).replaceAll("$1"); + rewritten = removeNamedTableConstraints(rewritten); createTableRewritten = true; } else if (CREATE_INDEX_BATCH.matcher(batch).find()) { rewritten = replaceObjectAfterKeyword(batch, "ON\\s+", sourceReferences, targetTable); @@ -291,32 +268,54 @@ static List prepareCopyDdlBatches(String ddl, String databaseName, Strin static List splitDdlBatches(String ddl) { List batches = new ArrayList<>(); StringBuilder batch = new StringBuilder(); - boolean inString = false; + DdlLexicalState state = new DdlLexicalState(); String[] lines = ddl.split("\\R", -1); for (String line : lines) { - if (!inString && GO_BATCH_LINE.matcher(line).matches()) { + if (state.isCode() && GO_BATCH_LINE.matcher(line).matches()) { addBatch(batches, batch); continue; } batch.append(line).append('\n'); - inString = updateStringState(line, inString); + updateLexicalState(line, state); } addBatch(batches, batch); return batches; } - private static boolean updateStringState(String line, boolean inString) { + private static void updateLexicalState(String line, DdlLexicalState state) { for (int i = 0; i < line.length(); i++) { - if (line.charAt(i) != '\'') { + char current = line.charAt(i); + char next = i + 1 < line.length() ? line.charAt(i + 1) : '\0'; + if (state.inString) { + if (current == '\'' && next == '\'') { + i++; + } else if (current == '\'') { + state.inString = false; + } continue; } - if (inString && i + 1 < line.length() && line.charAt(i + 1) == '\'') { + + if (state.blockCommentDepth > 0) { + if (current == '/' && next == '*') { + state.blockCommentDepth++; + i++; + } else if (current == '*' && next == '/') { + state.blockCommentDepth--; + i++; + } + continue; + } + + if (current == '-' && next == '-') { + return; + } + if (current == '/' && next == '*') { + state.blockCommentDepth++; i++; - } else { - inString = !inString; + } else if (current == '\'') { + state.inString = true; } } - return inString; } private static void addBatch(List batches, StringBuilder batch) { @@ -342,10 +341,13 @@ private static String replaceObjectAfterKeyword(String sql, String keywordPatter .collect(Collectors.joining("|")); Pattern pattern = Pattern.compile("(?i)(\\b" + keywordPattern + ")(?:(?:" + alternatives + "))"); Matcher matcher = pattern.matcher(sql); - if (!matcher.find()) { - throw new IllegalArgumentException("Unable to rewrite source table reference in DDL batch: " + sql); + while (matcher.find()) { + if (isSqlCodeAt(sql, matcher.start())) { + return sql.substring(0, matcher.start()) + matcher.group(1) + targetReference + + sql.substring(matcher.end()); + } } - return matcher.replaceFirst(Matcher.quoteReplacement(matcher.group(1) + targetReference)); + throw new IllegalArgumentException("Unable to rewrite source table reference in DDL batch: " + sql); } private static String rewriteSelfReference(String sql, List sourceReferences, String tableName, @@ -361,12 +363,91 @@ private static String rewriteSelfReference(String sql, List sourceRefere Matcher matcher = pattern.matcher(sql); StringBuffer rewritten = new StringBuffer(); while (matcher.find()) { - matcher.appendReplacement(rewritten, Matcher.quoteReplacement(matcher.group(1) + targetReference)); + if (isSqlCodeAt(sql, matcher.start())) { + matcher.appendReplacement(rewritten, Matcher.quoteReplacement(matcher.group(1) + targetReference)); + } } matcher.appendTail(rewritten); return rewritten.toString(); } + private static String removeNamedTableConstraints(String sql) { + Matcher matcher = NAMED_TABLE_CONSTRAINT.matcher(sql); + StringBuffer rewritten = new StringBuffer(); + while (matcher.find()) { + if (isSqlCodeAt(sql, matcher.start())) { + matcher.appendReplacement(rewritten, Matcher.quoteReplacement(matcher.group(1))); + } + } + matcher.appendTail(rewritten); + return rewritten.toString(); + } + + private static boolean isSqlCodeAt(String sql, int position) { + DdlLexicalState state = new DdlLexicalState(); + boolean inLineComment = false; + boolean inBracketIdentifier = false; + boolean inQuotedIdentifier = false; + for (int i = 0; i < position; i++) { + char current = sql.charAt(i); + char next = i + 1 < position ? sql.charAt(i + 1) : '\0'; + if (inLineComment) { + if (current == '\n' || current == '\r') { + inLineComment = false; + } + continue; + } + if (state.inString) { + if (current == '\'' && next == '\'') { + i++; + } else if (current == '\'') { + state.inString = false; + } + continue; + } + if (inBracketIdentifier) { + if (current == ']' && next == ']') { + i++; + } else if (current == ']') { + inBracketIdentifier = false; + } + continue; + } + if (inQuotedIdentifier) { + if (current == '"' && next == '"') { + i++; + } else if (current == '"') { + inQuotedIdentifier = false; + } + continue; + } + if (state.blockCommentDepth > 0) { + if (current == '/' && next == '*') { + state.blockCommentDepth++; + i++; + } else if (current == '*' && next == '/') { + state.blockCommentDepth--; + i++; + } + continue; + } + if (current == '-' && next == '-') { + inLineComment = true; + i++; + } else if (current == '/' && next == '*') { + state.blockCommentDepth++; + i++; + } else if (current == '\'') { + state.inString = true; + } else if (current == '[') { + inBracketIdentifier = true; + } else if (current == '"') { + inQuotedIdentifier = true; + } + } + return !inLineComment && state.isCode() && !inBracketIdentifier && !inQuotedIdentifier; + } + private static String rewriteExtendedPropertyTable(String sql, String tableName, String newTableName) { Pattern pattern = Pattern.compile("(?i)('TABLE'\\s*,\\s*N')" + Pattern.quote(tableName) + "(')"); Matcher matcher = pattern.matcher(sql); @@ -387,6 +468,35 @@ static String buildCopyDataSql(String targetTable, String columnList, String sou return String.format(SQL_COPY_TABLE_DATA_WITH_COLUMNS, targetTable, columnList, columnList, sourceTable); } + static void executeIdentityCopy(String targetTable, String insertSql, Consumer executor) { + String identityOn = String.format(SQL_SET_IDENTITY_INSERT, targetTable, "ON"); + String identityOff = String.format(SQL_SET_IDENTITY_INSERT, targetTable, "OFF"); + boolean identityEnableAttempted = false; + RuntimeException copyFailure = null; + try { + identityEnableAttempted = true; + executor.accept(identityOn); + executor.accept(insertSql); + } catch (RuntimeException exception) { + copyFailure = exception; + log.error("Failed to copy data with identity insert", exception); + throw exception; + } finally { + if (identityEnableAttempted) { + try { + executor.accept(identityOff); + } catch (RuntimeException exception) { + if (copyFailure != null) { + copyFailure.addSuppressed(exception); + log.warn("Failed to turn off identity insert", exception); + } else { + throw exception; + } + } + } + } + } + private static List tableReferences(String databaseName, String schemaName, String tableName) { Set references = new LinkedHashSet<>(); references.add(buildFullTableName(databaseName, schemaName, tableName)); @@ -423,7 +533,16 @@ private static String quoteIdentifier(String identifier) { if (value.length() >= 2 && value.startsWith("[") && value.endsWith("]")) { value = value.substring(1, value.length() - 1).replace("]]", "]"); } - return "[" + value.replace("]", "]]" ) + "]"; + return "[" + value.replace("]", "]]") + "]"; + } + + private static final class DdlLexicalState { + private boolean inString; + private int blockCommentDepth; + + private boolean isCode() { + return !inString && blockCommentDepth == 0; + } } @Override diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java index 2d9d69a94..a6632681a 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java @@ -417,9 +417,7 @@ private String buildReferentialAction(int actionCode) { } private void configureColumnSize(ResultSet columns, TableColumn tableColumn) throws SQLException { - if (Arrays.asList(SqlServerColumnTypeEnum.FLOAT.name(), - SqlServerColumnTypeEnum.REAL.name()) - .contains(tableColumn.getColumnType())) { + if (shouldOmitColumnSize(tableColumn.getColumnType())) { return; } int columnSize = columns.getInt("COLUMN_SIZE"); @@ -460,6 +458,14 @@ private void configureColumnSize(ResultSet columns, TableColumn tableColumn) thr tableColumn.setDecimalDigits(numericScale); } + static boolean shouldOmitColumnSize(String columnType) { + return Arrays.asList(SqlServerColumnTypeEnum.FLOAT.name(), + SqlServerColumnTypeEnum.REAL.name(), + SqlServerColumnTypeEnum.TIMESTAMP.name(), + "ROWVERSION") + .contains(columnType); + } + @Override diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerDBManagerTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerDBManagerTest.java index 34b55a7e7..7d5e8b73f 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerDBManagerTest.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerDBManagerTest.java @@ -2,10 +2,13 @@ import org.junit.jupiter.api.Test; +import java.util.ArrayList; 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.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class SqlServerDBManagerTest { @@ -77,6 +80,39 @@ void shouldNotSplitGoInsideStringLiteral() { "SELECT 1;"), batches); } + @Test + void shouldIgnoreQuotesInCommentsAndKeepGoInsideBlockComments() { + List batches = SqlServerDBManager.splitDdlBatches( + "SELECT 1; -- user's note\nGO\n" + + "SELECT 2; /* owner's block\nGO\nstill a comment */\nGO\nSELECT 3;"); + + assertEquals(List.of( + "SELECT 1; -- user's note", + "SELECT 2; /* owner's block\nGO\nstill a comment */", + "SELECT 3;"), batches); + } + + @Test + void shouldNotRewriteSelfReferenceTextInsideSqlLiteral() { + String ddl = """ + CREATE TABLE [sales].[orders] + ( + [id] int, + [description] AS ('references [orders] ('), + constraint FK_orders_parent + foreign key ([id]) + references [sales].[orders] ([id]) + ) + GO + """; + + String createTable = SqlServerDBManager.prepareCopyDdlBatches( + ddl, "catalog", "sales", "orders", "orders_copy").get(0); + + assertTrue(createTable.contains("AS ('references [orders] (')")); + assertTrue(createTable.contains("references [sales].[orders_copy] ([id])")); + } + @Test void shouldUseTheSameExplicitColumnsForInsertAndSelect() { assertEquals( @@ -102,4 +138,48 @@ void shouldEscapeEveryQualifiedIdentifierPart() { assertEquals("[catalog].[sales].[orders]", SqlServerDBManager.buildFullTableName("[catalog]", "[sales]", "[orders]")); } + + @Test + void shouldTurnIdentityInsertOffAndPreserveTheCopyFailure() { + List statements = new ArrayList<>(); + RuntimeException insertFailure = new RuntimeException("insert failed"); + RuntimeException offFailure = new RuntimeException("off failed"); + + RuntimeException thrown = assertThrows(RuntimeException.class, + () -> SqlServerDBManager.executeIdentityCopy("[dbo].[orders_copy]", "INSERT DATA", sql -> { + statements.add(sql); + if ("INSERT DATA".equals(sql)) { + throw insertFailure; + } + if (sql.endsWith(" OFF")) { + throw offFailure; + } + })); + + assertSame(insertFailure, thrown); + assertEquals(List.of( + "SET IDENTITY_INSERT [dbo].[orders_copy] ON", + "INSERT DATA", + "SET IDENTITY_INSERT [dbo].[orders_copy] OFF"), statements); + assertEquals(List.of(offFailure), List.of(thrown.getSuppressed())); + } + + @Test + void shouldAttemptIdentityInsertOffWhenEnablingFails() { + List statements = new ArrayList<>(); + RuntimeException onFailure = new RuntimeException("on failed"); + + RuntimeException thrown = assertThrows(RuntimeException.class, + () -> SqlServerDBManager.executeIdentityCopy("[dbo].[orders_copy]", "INSERT DATA", sql -> { + statements.add(sql); + if (sql.endsWith(" ON")) { + throw onFailure; + } + })); + + assertSame(onFailure, thrown); + assertEquals(List.of( + "SET IDENTITY_INSERT [dbo].[orders_copy] ON", + "SET IDENTITY_INSERT [dbo].[orders_copy] OFF"), statements); + } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerMetaDataTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerMetaDataTest.java index 32a25da7e..9d5004a7b 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerMetaDataTest.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerMetaDataTest.java @@ -5,6 +5,8 @@ import java.math.BigDecimal; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class SqlServerMetaDataTest { @@ -20,4 +22,12 @@ void shouldPreserveEitherNonDefaultIdentityParameterWithoutIntegerTruncation() { SqlServerMetaData.buildIdentityDataType("DECIMAL", new BigDecimal("9223372036854775808"), new BigDecimal("-2"))); } + + @Test + void shouldNotRenderJdbcLengthForFixedSizeOrRowVersionTypes() { + assertTrue(SqlServerMetaData.shouldOmitColumnSize("TIMESTAMP")); + assertTrue(SqlServerMetaData.shouldOmitColumnSize("ROWVERSION")); + assertTrue(SqlServerMetaData.shouldOmitColumnSize("FLOAT")); + assertFalse(SqlServerMetaData.shouldOmitColumnSize("DECIMAL")); + } } From 0e1de3c17a6fe414f92f862dca23b2477addd5e3 Mon Sep 17 00:00:00 2001 From: zgq Date: Sat, 25 Jul 2026 13:06:10 +0800 Subject: [PATCH 4/6] fix(sqlserver): preserve copied DDL boundaries --- .../plugin/sqlserver/SqlServerDBManager.java | 34 +++++++++- .../plugin/sqlserver/SqlServerMetaData.java | 2 +- .../sqlserver/SqlServerDBManagerTest.java | 67 +++++++++++++++++++ 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java index 4d4b3a115..4c7a47115 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java @@ -32,6 +32,8 @@ public class SqlServerDBManager extends DefaultDBManager implements IDbManager { private static final Pattern GO_BATCH_LINE = Pattern.compile("(?i)^\\s*go\\s*;?\\s*(?:--.*)?$"); + private static final Pattern GO_EXTENDED_PROPERTY_LINE = Pattern.compile( + "(?i)^\\s*go\\s*;?\\s+(exec\\s+sp_addextendedproperty\\b.*)$"); private static final Pattern CREATE_INDEX_BATCH = Pattern.compile( "(?is)^\\s*CREATE\\s+(?:UNIQUE\\s+)?(?:CLUSTERED\\s+|NONCLUSTERED\\s+|SPATIAL\\s+|XML\\s+)?INDEX\\b"); private static final Pattern NAMED_TABLE_CONSTRAINT = Pattern.compile( @@ -271,9 +273,19 @@ static List splitDdlBatches(String ddl) { DdlLexicalState state = new DdlLexicalState(); String[] lines = ddl.split("\\R", -1); for (String line : lines) { - if (state.isCode() && GO_BATCH_LINE.matcher(line).matches()) { - addBatch(batches, batch); - continue; + if (state.isCode()) { + if (GO_BATCH_LINE.matcher(line).matches()) { + addBatch(batches, batch); + continue; + } + Matcher inlineBatch = GO_EXTENDED_PROPERTY_LINE.matcher(line); + if (inlineBatch.matches()) { + addBatch(batches, batch); + String extendedProperty = inlineBatch.group(1); + batch.append(extendedProperty).append('\n'); + updateLexicalState(extendedProperty, state); + continue; + } } batch.append(line).append('\n'); updateLexicalState(line, state); @@ -502,12 +514,28 @@ private static List tableReferences(String databaseName, String schemaNa references.add(buildFullTableName(databaseName, schemaName, tableName)); references.add(buildFullTableName(null, schemaName, tableName)); references.add(quoteIdentifier(tableName)); + references.add(buildUnquotedFullTableName(databaseName, schemaName, tableName)); + references.add(buildUnquotedFullTableName(null, schemaName, tableName)); return references.stream() .filter(StringUtils::isNotBlank) .sorted((left, right) -> Integer.compare(right.length(), left.length())) .toList(); } + private static String buildUnquotedFullTableName(String databaseName, String schemaName, String tableName) { + List parts = new ArrayList<>(3); + if (StringUtils.isNotBlank(databaseName)) { + parts.add(databaseName); + } + if (StringUtils.isNotBlank(schemaName)) { + parts.add(schemaName); + } + if (StringUtils.isNotBlank(tableName)) { + parts.add(tableName); + } + return String.join(".", parts); + } + static String buildFullTableName(String databaseName, String schemaName, String tableName) { StringBuilder fullTableName = new StringBuilder(); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java index a6632681a..6ba88efb8 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java @@ -373,7 +373,7 @@ public String tableDDL(Connection connection, String databaseName, String schema ddlBuilder.append("\n").append(sqlServerIndexTypeEnum.buildIndexScript(index)); String comment = index.getComment(); if (StringUtils.isNotBlank(comment)) { - ddlBuilder.append("\t").append(SQLConstant.buildIndexComment(comment, schemaName, tableName, index.getName())); + ddlBuilder.append("\n").append(SQLConstant.buildIndexComment(comment, schemaName, tableName, index.getName())); } } }); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerDBManagerTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerDBManagerTest.java index 7d5e8b73f..d379c4550 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerDBManagerTest.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerDBManagerTest.java @@ -113,6 +113,63 @@ void shouldNotRewriteSelfReferenceTextInsideSqlLiteral() { assertTrue(createTable.contains("references [sales].[orders_copy] ([id])")); } + @Test + void shouldRewriteGeneratedUnquotedSelfReferencesOnlyInSqlCode() { + String ddl = """ + CREATE TABLE [orders] + ( + [id] int, + [description] AS ('references sales.orders ('), + constraint FK_orders_parent + foreign key ([id]) + references sales.orders ([id]), + constraint FK_orders_catalog_parent + foreign key ([id]) + references catalog.sales.orders ([id]), + constraint FK_orders_external + foreign key ([id]) + references audit.orders ([id]) + -- references sales.orders ([id]) + /* references catalog.sales.orders ([id]) */ + ) + GO + """; + + String createTable = SqlServerDBManager.prepareCopyDdlBatches( + ddl, "catalog", "sales", "orders", "orders_copy").get(0); + + assertTrue(createTable.contains("AS ('references sales.orders (')")); + assertTrue(createTable.contains("references [sales].[orders_copy] ([id])")); + assertTrue(createTable.contains("references audit.orders ([id])")); + assertTrue(createTable.contains("-- references sales.orders ([id])")); + assertTrue(createTable.contains("/* references catalog.sales.orders ([id]) */")); + assertEquals(2, countOccurrences(createTable, "references [sales].[orders_copy] ([id])")); + } + + @Test + void shouldSplitInlineGoFromGeneratedIndexComment() { + String ddl = """ + CREATE TABLE [orders] + ( + [id] int + ) + go + CREATE NONCLUSTERED INDEX [IX_orders_id] + ON [sales].[orders] ([id] ASC) + go\texec sp_addextendedproperty 'MS_Description',N'index comment','SCHEMA',N'sales','TABLE',N'orders','INDEX',N'IX_orders_id' + go + """; + + List batches = SqlServerDBManager.prepareCopyDdlBatches( + ddl, "catalog", "sales", "orders", "orders_copy"); + + assertEquals(3, batches.size()); + assertTrue(batches.get(1).startsWith("CREATE NONCLUSTERED INDEX [IX_orders_id]")); + assertTrue(batches.get(1).contains("ON [sales].[orders_copy] ([id] ASC)")); + assertTrue(batches.get(2).startsWith("exec sp_addextendedproperty")); + assertTrue(batches.get(2).contains("'TABLE',N'orders_copy','INDEX',N'IX_orders_id'")); + } + @Test void shouldUseTheSameExplicitColumnsForInsertAndSelect() { assertEquals( @@ -182,4 +239,14 @@ void shouldAttemptIdentityInsertOffWhenEnablingFails() { "SET IDENTITY_INSERT [dbo].[orders_copy] ON", "SET IDENTITY_INSERT [dbo].[orders_copy] OFF"), statements); } + + private static int countOccurrences(String value, String expected) { + int count = 0; + int index = 0; + while ((index = value.indexOf(expected, index)) >= 0) { + count++; + index += expected.length(); + } + return count; + } } From c49428f52c40554844cd8b29bc675327e5d70b29 Mon Sep 17 00:00:00 2001 From: zgq Date: Sat, 25 Jul 2026 13:38:50 +0800 Subject: [PATCH 5/6] fix(sqlserver): normalize copied table identifiers --- .../plugin/sqlserver/SqlServerDBManager.java | 12 +- .../plugin/sqlserver/SqlServerMetaData.java | 129 +++++++++--------- .../sqlserver/constant/SQLConstant.java | 3 +- .../sqlserver/SqlServerDBManagerTest.java | 124 +++++++++++++++++ .../sqlserver/SqlServerMetaDataTest.java | 20 +++ 5 files changed, 221 insertions(+), 67 deletions(-) diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java index 4c7a47115..8fb51db73 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerDBManager.java @@ -180,6 +180,8 @@ public void connectDatabase(Connection connection, String database) { @Override public void copyTable(Connection connection, String databaseName, String schemaName, String tableName, String newTableName, boolean copyData) throws SQLException { + tableName = unquoteIdentifier(tableName); + newTableName = unquoteIdentifier(newTableName); String ddl = Chat2DBContext.getDbMetaData().tableDDL(connection, new TableMetadataRequest(databaseName, schemaName, tableName)); List batches = prepareCopyDdlBatches(ddl, databaseName, schemaName, tableName, newTableName); @@ -554,6 +556,14 @@ static String buildFullTableName(String databaseName, String schemaName, String } private static String quoteIdentifier(String identifier) { + if (StringUtils.isBlank(identifier)) { + return identifier; + } + String value = unquoteIdentifier(identifier); + return "[" + value.replace("]", "]]") + "]"; + } + + static String unquoteIdentifier(String identifier) { if (StringUtils.isBlank(identifier)) { return identifier; } @@ -561,7 +571,7 @@ private static String quoteIdentifier(String identifier) { if (value.length() >= 2 && value.startsWith("[") && value.endsWith("]")) { value = value.substring(1, value.length() - 1).replace("]]", "]"); } - return "[" + value.replace("]", "]]") + "]"; + return value; } private static final class DdlLexicalState { diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java index 6ba88efb8..5fe69c225 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java @@ -11,6 +11,7 @@ import ai.chat2db.plugin.sqlserver.enums.SqlServerViewAttributeOptionEnum; import ai.chat2db.plugin.sqlserver.enums.SqlServerViewCheckOptionEnum; import ai.chat2db.plugin.sqlserver.identifier.SqlServerIdentifierProcessor; +import ai.chat2db.plugin.sqlserver.identifier.SqlServerIdentifierUtils; import ai.chat2db.plugin.sqlserver.enums.type.SqlServerColumnTypeEnum; import ai.chat2db.plugin.sqlserver.enums.type.SqlServerDefaultValueEnum; import ai.chat2db.plugin.sqlserver.enums.type.SqlServerIndexTypeEnum; @@ -33,7 +34,6 @@ import ai.chat2db.spi.util.SortUtils; import ai.chat2db.spi.util.SqlUtils; import jakarta.validation.constraints.NotEmpty; -import net.sf.jsqlparser.statement.ReferentialAction; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.collections4.MapUtils; import org.apache.commons.lang3.StringUtils; @@ -46,6 +46,7 @@ import java.util.stream.Collectors; import static ai.chat2db.plugin.sqlserver.constant.SQLConstant.*; +import static ai.chat2db.plugin.sqlserver.identifier.SqlServerIdentifierUtils.quoteIdentifierPart; import static ai.chat2db.spi.util.SortUtils.sortDatabase; import static ai.chat2db.plugin.sqlserver.constant.SqlServerMetaDataConstants.*; @@ -75,7 +76,7 @@ public List schemas(Connection connection, String databaseName) { private String format(String objectName) { - return "[" + objectName + "]"; + return quoteIdentifierPart(objectName); } @@ -142,11 +143,7 @@ public String tableDDL(Connection connection, String databaseName, String schema if (StringUtils.isNotBlank(indexType)) { clusteredMap.computeIfAbsent(constraintName, k -> indexType); } - if (isDesc) { - columnName += " desc"; - } else { - columnName += " asc"; - } + columnName = quoteIdentifierPart(columnName) + (isDesc ? " desc" : " asc"); if ("PK".equals(constraintType)) { PKConstraintsMap.computeIfAbsent(constraintName, k -> new ArrayList<>()).add(columnName); } else if ("UQ".equals(constraintType)) { @@ -158,7 +155,7 @@ public String tableDDL(Connection connection, String databaseName, String schema if (MapUtils.isNotEmpty(PKConstraintsMap)) { PKConstraintsMap.forEach((key, value) -> { tempBuilder.append("constraint ") - .append(key) + .append(quoteIdentifierPart(key)) .append("\n") .append("primary key "); if (clusteredMap.containsKey(key)) { @@ -174,7 +171,7 @@ public String tableDDL(Connection connection, String databaseName, String schema if (MapUtils.isNotEmpty(UQConstraintsMap)) { UQConstraintsMap.forEach((key, value) -> { tempBuilder.append("constraint ") - .append(key) + .append(quoteIdentifierPart(key)) .append("\n") .append("unique "); if (clusteredMap.containsKey(key)) { @@ -214,7 +211,7 @@ public String tableDDL(Connection connection, String databaseName, String schema ddlBuilder.append(",\n"); isFirst = false; } - tempBuilder.append("constraint ").append(constraintName).append("\n") + tempBuilder.append("constraint ").append(quoteIdentifierPart(constraintName)).append("\n") .append("check ").append(constraintDefinition); tempList.add(tempBuilder.toString()); tempBuilder.setLength(0); @@ -226,60 +223,39 @@ public String tableDDL(Connection connection, String databaseName, String schema }); DefaultSQLExecutor.getInstance().preExecute(connection, FOREIGN_KEY_SQL, new String[]{schemaName, tableName}, resultSet -> { - HashMap foreignMap = new HashMap<>(); + HashMap referencedSchemaMap = new HashMap<>(); + HashMap referencedTableMap = new HashMap<>(); HashMap> columnMap = new HashMap<>(); HashMap> referencedColumnMap = new HashMap<>(); - HashMap> actionMap = new HashMap<>(); + HashMap updateActionMap = new HashMap<>(); + HashMap deleteActionMap = new HashMap<>(); while (resultSet.next()) { String constraintName = resultSet.getString("CONSTRAINT_NAME"); + String referencedSchemaName = resultSet.getString("REFERENCED_SCHEMA_NAME"); String referencedTableName = resultSet.getString("REFERENCED_TABLE_NAME"); - foreignMap.computeIfAbsent(constraintName, k -> referencedTableName); + referencedSchemaMap.putIfAbsent(constraintName, referencedSchemaName); + referencedTableMap.putIfAbsent(constraintName, referencedTableName); String columnName = resultSet.getString("COLUMN_NAME"); columnMap.computeIfAbsent(constraintName, k -> new ArrayList<>()).add(columnName); String referencedColumnName = resultSet.getString("REFERENCED_COLUMN_NAME"); referencedColumnMap.computeIfAbsent(constraintName, k -> new ArrayList<>()).add(referencedColumnName); - int updateAction = resultSet.getInt("UPDATE_ACTION"); - if (updateAction != 0) { - actionMap.computeIfAbsent(constraintName, k -> new ArrayList<>()).add(buildReferentialAction(updateAction)); - } - int deleteAction = resultSet.getInt("DELETE_ACTION"); - if (updateAction != 0) { - actionMap.computeIfAbsent(constraintName, k -> new ArrayList<>()).add(buildReferentialAction(deleteAction)); - } + updateActionMap.putIfAbsent(constraintName, resultSet.getInt("UPDATE_ACTION")); + deleteActionMap.putIfAbsent(constraintName, resultSet.getInt("DELETE_ACTION")); } - if (MapUtils.isNotEmpty(foreignMap)) { + if (MapUtils.isNotEmpty(referencedTableMap)) { ddlBuilder.append(",\n"); - foreignMap.forEach((key, value) -> { - tempBuilder.append("constraint ").append(key).append("\n") - .append("foreign key (") - .append(String.join(" , ", columnMap.get(key))) - .append(")\n") - .append("references ") - .append(value) - .append(" (") - .append(String.join(" , ", referencedColumnMap.get(key))) - .append(")"); - if (actionMap.containsKey(key)) { - for (int i = 0; i < actionMap.get(key).size(); i++) { - if (i == 0) { - tempBuilder.append(SQL_UPDATE).append(actionMap.get(key).get(i)); - } else if (i == 1) { - tempBuilder.append(SQL_DELETE).append(actionMap.get(key).get(i)); - } else { - break; - } - } - } - tempList.add(tempBuilder.toString()); - tempBuilder.setLength(0); + referencedTableMap.forEach((constraintName, referencedTableName) -> { + tempList.add(buildForeignKeyDefinition( + constraintName, + columnMap.get(constraintName), + referencedSchemaMap.get(constraintName), + referencedTableName, + referencedColumnMap.get(constraintName), + updateActionMap.getOrDefault(constraintName, 0), + deleteActionMap.getOrDefault(constraintName, 0))); }); ddlBuilder.append(String.join(",\n", tempList)); tempList.clear(); - foreignMap.clear(); - columnMap.clear(); - referencedColumnMap.clear(); - actionMap.clear(); - foreignMap.clear(); } }); @@ -398,21 +374,43 @@ private static String formatIdentityValue(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); } - private String buildReferentialAction(int actionCode) { - switch (actionCode) { - case 1 -> { - return ReferentialAction.Action.CASCADE.toString().toLowerCase(); - } - case 2 -> { - return ReferentialAction.Action.SET_NULL.toString().toLowerCase(); - } - case 3 -> { - return ReferentialAction.Action.SET_DEFAULT.toString().toLowerCase(); - } - default -> { - return ReferentialAction.Action.NO_ACTION.toString().toLowerCase(); - } + static String buildForeignKeyDefinition(String constraintName, List columnNames, + String referencedSchemaName, String referencedTableName, + List referencedColumnNames, int updateAction, + int deleteAction) { + String referencedTable = quoteIdentifierPart(referencedTableName); + if (StringUtils.isNotBlank(referencedSchemaName)) { + referencedTable = quoteIdentifierPart(referencedSchemaName) + "." + referencedTable; } + return "constraint " + quoteIdentifierPart(constraintName) + "\n" + + "foreign key (" + quoteIdentifierList(columnNames) + ")\n" + + "references " + referencedTable + " (" + quoteIdentifierList(referencedColumnNames) + ")" + + buildReferentialActions(updateAction, deleteAction); + } + + private static String quoteIdentifierList(List identifiers) { + return identifiers.stream().map(SqlServerIdentifierUtils::quoteIdentifierPart) + .collect(Collectors.joining(" , ")); + } + + static String buildReferentialActions(int updateAction, int deleteAction) { + StringBuilder actions = new StringBuilder(); + if (updateAction != 0) { + actions.append(SQL_UPDATE).append(buildReferentialAction(updateAction)); + } + if (deleteAction != 0) { + actions.append(SQL_DELETE).append(buildReferentialAction(deleteAction)); + } + return actions.toString(); + } + + private static String buildReferentialAction(int actionCode) { + return switch (actionCode) { + case 1 -> "cascade"; + case 2 -> "set null"; + case 3 -> "set default"; + default -> "no action"; + }; } @@ -769,7 +767,8 @@ 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(StringUtils::isNotBlank) + .map(SqlServerIdentifierUtils::quoteIdentifierPart).collect(Collectors.joining(".")); } @Override diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SQLConstant.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SQLConstant.java index 4e4b3fd21..45aea20aa 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SQLConstant.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/constant/SQLConstant.java @@ -139,7 +139,8 @@ WHERE o.type in ('C', 'F', 'PK', 'UQ') SELECT fk.name AS CONSTRAINT_NAME, c.name AS COLUMN_NAME, - SCHEMA_NAME(ro.schema_id) + '.' + OBJECT_NAME(fk.referenced_object_id) AS REFERENCED_TABLE_NAME, + SCHEMA_NAME(ro.schema_id) AS REFERENCED_SCHEMA_NAME, + OBJECT_NAME(fk.referenced_object_id) AS REFERENCED_TABLE_NAME, rc.name AS REFERENCED_COLUMN_NAME, fk.delete_referential_action as DELETE_ACTION, fk.update_referential_action as UPDATE_ACTION diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerDBManagerTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerDBManagerTest.java index d379c4550..21f4e963a 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerDBManagerTest.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerDBManagerTest.java @@ -1,9 +1,20 @@ package ai.chat2db.plugin.sqlserver; +import ai.chat2db.community.domain.api.config.DBConfig; +import ai.chat2db.community.domain.api.config.DriverConfig; +import ai.chat2db.spi.IPlugin; +import ai.chat2db.spi.model.datasource.ConnectInfo; +import ai.chat2db.spi.model.request.TableMetadataRequest; +import ai.chat2db.spi.sql.Chat2DBContext; import org.junit.jupiter.api.Test; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -196,6 +207,54 @@ void shouldEscapeEveryQualifiedIdentifierPart() { SqlServerDBManager.buildFullTableName("[catalog]", "[sales]", "[orders]")); } + @Test + void shouldNormalizeMetadataFormattedNamesAtCopyBoundary() throws Exception { + AtomicReference ddlRequest = new AtomicReference<>(); + AtomicReference> queryParameters = new AtomicReference<>(); + List preparedSql = new ArrayList<>(); + SqlServerMetaData metadata = new SqlServerMetaData() { + @Override + public String tableDDL(Connection connection, TableMetadataRequest request) { + ddlRequest.set(request); + return "CREATE TABLE [orders]\n([id] int)\ngo\n"; + } + }; + String dbType = "SQLSERVER_COPY_TEST"; + IPlugin previousPlugin = Chat2DBContext.PLUGIN_MAP.put(dbType, new IPlugin() { + @Override + public DBConfig getDBConfig() { + return null; + } + + @Override + public SqlServerMetaData getDbMetaData() { + return metadata; + } + }); + ConnectInfo context = new ConnectInfo(); + context.setDbType(dbType); + context.setDriverConfig(new DriverConfig()); + Chat2DBContext.putContext(context); + + try { + new SqlServerDBManager().copyTable( + recordingConnection(preparedSql, queryParameters), + "catalog", "sales", "[orders]", "[orders_copy]", true); + + assertEquals("orders", ddlRequest.get().getTableName()); + assertEquals("CREATE TABLE [sales].[orders_copy]\n([id] int)", preparedSql.get(0)); + assertEquals(List.of("sales", "orders"), queryParameters.get()); + } finally { + context.setConnection(null); + Chat2DBContext.removeContext(); + if (previousPlugin == null) { + Chat2DBContext.PLUGIN_MAP.remove(dbType); + } else { + Chat2DBContext.PLUGIN_MAP.put(dbType, previousPlugin); + } + } + } + @Test void shouldTurnIdentityInsertOffAndPreserveTheCopyFailure() { List statements = new ArrayList<>(); @@ -249,4 +308,69 @@ private static int countOccurrences(String value, String expected) { } return count; } + + private static Connection recordingConnection(List preparedSql, + AtomicReference> queryParameters) { + return (Connection) Proxy.newProxyInstance( + SqlServerDBManagerTest.class.getClassLoader(), + new Class[]{Connection.class}, + (proxy, method, args) -> { + if ("prepareStatement".equals(method.getName())) { + String sql = (String) args[0]; + preparedSql.add(sql); + List parameters = new ArrayList<>(); + return preparedStatement(parameters, queryParameters); + } + if ("isClosed".equals(method.getName())) { + return false; + } + return defaultValue(method.getReturnType()); + }); + } + + private static PreparedStatement preparedStatement(List parameters, + AtomicReference> queryParameters) { + return (PreparedStatement) Proxy.newProxyInstance( + SqlServerDBManagerTest.class.getClassLoader(), + new Class[]{PreparedStatement.class}, + (proxy, method, args) -> { + switch (method.getName()) { + case "setString": + int index = (Integer) args[0]; + while (parameters.size() < index) { + parameters.add(null); + } + parameters.set(index - 1, (String) args[1]); + return null; + case "execute": + return false; + case "executeQuery": + queryParameters.set(List.copyOf(parameters)); + return emptyResultSet(); + default: + return defaultValue(method.getReturnType()); + } + }); + } + + private static ResultSet emptyResultSet() { + return (ResultSet) Proxy.newProxyInstance( + SqlServerDBManagerTest.class.getClassLoader(), + new Class[]{ResultSet.class}, + (proxy, method, args) -> "next".equals(method.getName()) + ? false : defaultValue(method.getReturnType())); + } + + private static Object defaultValue(Class type) { + if (!type.isPrimitive() || type == void.class) { + return null; + } + if (type == boolean.class) { + return false; + } + if (type == char.class) { + return '\0'; + } + return 0; + } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerMetaDataTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerMetaDataTest.java index 9d5004a7b..fc99a5fc7 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerMetaDataTest.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerMetaDataTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.Test; import java.math.BigDecimal; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -30,4 +31,23 @@ void shouldNotRenderJdbcLengthForFixedSizeOrRowVersionTypes() { assertTrue(SqlServerMetaData.shouldOmitColumnSize("FLOAT")); assertFalse(SqlServerMetaData.shouldOmitColumnSize("DECIMAL")); } + + @Test + void shouldRenderForeignKeyActionsIndependently() { + assertEquals("", SqlServerMetaData.buildReferentialActions(0, 0)); + assertEquals(" on update cascade", SqlServerMetaData.buildReferentialActions(1, 0)); + assertEquals(" on delete set null", SqlServerMetaData.buildReferentialActions(0, 2)); + assertEquals(" on update set default on delete cascade", + SqlServerMetaData.buildReferentialActions(3, 1)); + } + + @Test + void shouldQuoteEveryForeignKeyIdentifierPart() { + assertEquals("constraint [FK orders]]owner]\n" + + "foreign key ([order id] , [line]]id])\n" + + "references [sales archive].[order]]history] ([id]) on delete set null", + SqlServerMetaData.buildForeignKeyDefinition( + "FK orders]owner", List.of("order id", "line]id"), + "sales archive", "order]history", List.of("id"), 0, 2)); + } } From f3d8b3ad1a05722296cb201cdaec846844a871b3 Mon Sep 17 00:00:00 2001 From: zgq Date: Sat, 25 Jul 2026 13:45:20 +0800 Subject: [PATCH 6/6] fix(sqlserver): order foreign key actions correctly --- .../java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java | 6 +++--- .../ai/chat2db/plugin/sqlserver/SqlServerMetaDataTest.java | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java index 5fe69c225..d4a79ba15 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/main/java/ai/chat2db/plugin/sqlserver/SqlServerMetaData.java @@ -395,12 +395,12 @@ private static String quoteIdentifierList(List identifiers) { static String buildReferentialActions(int updateAction, int deleteAction) { StringBuilder actions = new StringBuilder(); - if (updateAction != 0) { - actions.append(SQL_UPDATE).append(buildReferentialAction(updateAction)); - } if (deleteAction != 0) { actions.append(SQL_DELETE).append(buildReferentialAction(deleteAction)); } + if (updateAction != 0) { + actions.append(SQL_UPDATE).append(buildReferentialAction(updateAction)); + } return actions.toString(); } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerMetaDataTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerMetaDataTest.java index fc99a5fc7..9be4aebb9 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerMetaDataTest.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlserver/src/test/java/ai/chat2db/plugin/sqlserver/SqlServerMetaDataTest.java @@ -37,7 +37,7 @@ void shouldRenderForeignKeyActionsIndependently() { assertEquals("", SqlServerMetaData.buildReferentialActions(0, 0)); assertEquals(" on update cascade", SqlServerMetaData.buildReferentialActions(1, 0)); assertEquals(" on delete set null", SqlServerMetaData.buildReferentialActions(0, 2)); - assertEquals(" on update set default on delete cascade", + assertEquals(" on delete cascade on update set default", SqlServerMetaData.buildReferentialActions(3, 1)); }