fix(sqlserver): preserve table and column comments when cloning

This commit is contained in:
zipg
2026-10-03 06:53:53 +08:00
committed by GitHub
parent 5dc11f26ca
commit 31a4a3a1a8
5 changed files with 167 additions and 5 deletions
+63 -1
View File
@@ -486,6 +486,8 @@ describe("buildDuplicateTableStructurePlan", () => {
});
it("recreates the SQL Server primary key that SELECT INTO drops", async () => {
apiMock.getColumns.mockResolvedValue([]);
apiMock.getTableComment.mockResolvedValue(null);
apiMock.listIndexes.mockResolvedValue([
{ name: "PK_ORDERS", columns: ["ID", "SEQ"], is_unique: true, is_primary: true },
{ name: "IDX_ORDERS_CUSTOMER", columns: ["CUSTOMER"], is_unique: false, is_primary: false },
@@ -507,6 +509,8 @@ describe("buildDuplicateTableStructurePlan", () => {
});
it("renames the SQL Server clone PK when it would collide with a source index", async () => {
apiMock.getColumns.mockResolvedValue([]);
apiMock.getTableComment.mockResolvedValue(null);
apiMock.listIndexes.mockResolvedValue([
{ name: "PK_ORDERS", columns: ["ID"], is_unique: true, is_primary: true },
{ name: "PK_orders_copy", columns: ["FLAG"], is_unique: false, is_primary: false },
@@ -549,6 +553,8 @@ describe("buildDuplicateTableStructurePlan", () => {
});
it("keeps primary-key-free SQL Server clones on a single statement", async () => {
apiMock.getColumns.mockResolvedValue([]);
apiMock.getTableComment.mockResolvedValue(null);
apiMock.listIndexes.mockResolvedValue([{ name: "IDX_ORDERS_CUSTOMER", columns: ["CUSTOMER"], is_unique: false, is_primary: false }]);
apiMock.buildDuplicateTableStructureSql.mockResolvedValue("SELECT TOP 0 * INTO [orders_copy] FROM [orders];");
@@ -562,7 +568,63 @@ describe("buildDuplicateTableStructurePlan", () => {
});
expect(apiMock.buildDuplicateTableStructureSql).toHaveBeenCalledWith(expect.objectContaining({ databaseType: "sqlserver", primaryKeyColumns: [] }));
expect(plan).toEqual({ sql: "SELECT TOP 0 * INTO [orders_copy] FROM [orders];", sourceColumns: undefined, executeAsScript: false });
expect(plan).toEqual({ sql: "SELECT TOP 0 * INTO [orders_copy] FROM [orders];", sourceColumns: [], executeAsScript: false });
});
it("loads SQL Server table and column comments and runs a comment-only clone as a script", async () => {
const columns = [
{ name: "value field", comment: " 字段 O'Brien;注释 ", data_type: "nvarchar", is_nullable: true, column_default: null, is_primary_key: false },
{ name: "id", comment: null, data_type: "int", is_nullable: false, column_default: null, is_primary_key: false },
];
const sql = "SELECT TOP 0 * INTO [dbo].[copy] FROM [dbo].[source];\nEXEC sys.sp_addextendedproperty ...;";
apiMock.getColumns.mockResolvedValue(columns);
apiMock.getTableComment.mockResolvedValue(" 表 O'Brien;注释 ");
apiMock.listIndexes.mockResolvedValue([]);
apiMock.buildDuplicateTableStructureSql.mockResolvedValue(sql);
const plan = await buildDuplicateTableStructurePlan({ connectionId: "mssql-1", database: "app", catalog: "catalog", databaseType: "sqlserver", schema: "dbo", sourceName: "source", targetName: "copy" });
expect(apiMock.getColumns).toHaveBeenCalledExactlyOnceWith("mssql-1", "app", "dbo", "source", "catalog");
expect(apiMock.getTableComment).toHaveBeenCalledExactlyOnceWith("mssql-1", "app", "dbo", "source", "catalog");
expect(apiMock.buildDuplicateTableStructureSql).toHaveBeenCalledWith(expect.objectContaining({ tableComment: " 表 O'Brien;注释 ", columnComments: [{ name: "value field", comment: " 字段 O'Brien;注释 " }], primaryKeyColumns: [] }));
expect(plan).toEqual({ sql, sourceColumns: columns, executeAsScript: true });
});
it("reuses supplied SQL Server column and table comments for data-copy callers", async () => {
const columns = [{ name: "id", comment: "编号", data_type: "int", is_nullable: false, column_default: null, is_primary_key: true }];
apiMock.listIndexes.mockResolvedValue([{ name: "PK_source", columns: ["id"], is_unique: true, is_primary: true }]);
apiMock.buildDuplicateTableStructureSql.mockResolvedValue("SELECT TOP 0 * INTO [copy] FROM [source];");
const plan = await buildDuplicateTableStructurePlan({ connectionId: "mssql-1", database: "app", databaseType: "sqlserver", sourceName: "source", targetName: "copy", sourceColumns: columns, tableComment: "表注释" });
expect(apiMock.getColumns).not.toHaveBeenCalled();
expect(apiMock.getTableComment).not.toHaveBeenCalled();
expect(apiMock.buildDuplicateTableStructureSql).toHaveBeenCalledWith(expect.objectContaining({ tableComment: "表注释", columnComments: [{ name: "id", comment: "编号" }], primaryKeyColumns: ["id"] }));
expect(plan.sourceColumns).toBe(columns);
expect(plan.executeAsScript).toBe(true);
});
it("does not create a SQL Server clone when column metadata cannot be loaded", async () => {
apiMock.listIndexes.mockResolvedValue([]);
apiMock.getColumns.mockResolvedValue([]);
apiMock.getTableComment.mockResolvedValue(null);
const error = new Error("comment metadata unavailable");
apiMock.getColumns.mockRejectedValueOnce(error);
await expect(buildDuplicateTableStructurePlan({ connectionId: "mssql-1", database: "app", databaseType: "sqlserver", sourceName: "source", targetName: "copy" })).rejects.toBe(error);
expect(apiMock.buildDuplicateTableStructureSql).not.toHaveBeenCalled();
});
it("keeps SQL Server cloning available when optional table comment loading fails", async () => {
const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
apiMock.listIndexes.mockResolvedValue([]);
apiMock.getColumns.mockResolvedValue([]);
apiMock.getTableComment.mockRejectedValue(new Error("Table comment lookup is not available in the web backend"));
apiMock.buildDuplicateTableStructureSql.mockResolvedValue("SELECT TOP 0 * INTO [copy] FROM [source];");
const plan = await buildDuplicateTableStructurePlan({ connectionId: "mssql-web", database: "app", databaseType: "sqlserver", sourceName: "source", targetName: "copy" });
expect(apiMock.buildDuplicateTableStructureSql).toHaveBeenCalledWith(expect.objectContaining({ tableComment: null, columnComments: [], primaryKeyColumns: [] }));
expect(plan).toEqual({ sql: "SELECT TOP 0 * INTO [copy] FROM [source];", sourceColumns: [], executeAsScript: false });
expect(warning).toHaveBeenCalledOnce();
warning.mockRestore();
});
it("forwards the connection identifier quote so dual-dialect clones stay executable", async () => {
+16 -4
View File
@@ -297,7 +297,19 @@ export async function buildDuplicateTableStructurePlan(options: DuplicateTableSt
// cloned table silently loses its primary key (t8y2/dbx#8931). Load the source primary key and
// let the backend append an `ALTER TABLE ... ADD CONSTRAINT ... PRIMARY KEY` for it.
if (options.databaseType === "sqlserver") {
const indexes = await api.listIndexes(options.connectionId, options.database, options.schema || "", options.sourceName, options.catalog);
const [columns, indexes, tableComment] = await Promise.all([
options.sourceColumns ? Promise.resolve(options.sourceColumns) : api.getColumns(options.connectionId, options.database, options.schema || "", options.sourceName, options.catalog),
api.listIndexes(options.connectionId, options.database, options.schema || "", options.sourceName, options.catalog),
// The web backend's getTableComment is a throwing placeholder, and SQL Server table lists
// report `comment: null` for tables without one, so a hard failure would break web cloning.
options.tableComment == null
? api.getTableComment(options.connectionId, options.database, options.schema || "", options.sourceName, options.catalog).catch((error) => {
console.warn(`Failed to load SQL Server table comment for table clone: ${options.sourceName}`, error);
return null;
})
: Promise.resolve(options.tableComment),
]);
const columnComments = collectDuplicateTableColumnComments(columns);
const primaryKeyColumns = indexes.find((index) => index.is_primary && index.columns.length > 0)?.columns ?? [];
const primaryKeyConstraintName = sqlServerClonePrimaryKeyConstraintName(indexes, options.targetName);
const sql = await buildDuplicateTableStructureSql({
@@ -305,13 +317,13 @@ export async function buildDuplicateTableStructurePlan(options: DuplicateTableSt
schema: options.schema,
sourceName: options.sourceName,
targetName: options.targetName,
tableComment: options.tableComment,
columnComments: [],
tableComment,
columnComments,
primaryKeyColumns,
primaryKeyConstraintName,
identifierQuote: options.identifierQuote,
});
return { sql, sourceColumns: options.sourceColumns, executeAsScript: primaryKeyColumns.length > 0 || duplicateTableStructureRequiresScript(sql) };
return { sql, sourceColumns: columns, executeAsScript: primaryKeyColumns.length > 0 || !!tableComment?.trim() || columnComments.length > 0 || duplicateTableStructureRequiresScript(sql) };
}
let sourceColumns = options.sourceColumns;
+88
View File
@@ -841,6 +841,30 @@ pub fn build_duplicate_table_structure_sql(options: DuplicateTableStructureSqlOp
}));
}
// SELECT INTO does not copy MS_Description extended properties. The
// target is newly created, so add each supplied comment without updating
// or deleting any pre-existing object's properties.
if options.database_type == Some(DatabaseType::SqlServer) {
if let Some(comment) = options.table_comment.as_deref().filter(|comment| !comment.trim().is_empty()) {
comment_sql.push(sqlserver_clone_comment_sql(
options.schema.as_deref(),
&options.target_name,
None,
comment,
));
}
for column in &options.column_comments {
if !column.comment.trim().is_empty() {
comment_sql.push(sqlserver_clone_comment_sql(
options.schema.as_deref(),
&options.target_name,
Some(&column.name),
&column.comment,
));
}
}
}
// `SELECT ... INTO` copies the IDENTITY property but not constraints, so the cloned table
// would silently lose its primary key (t8y2/dbx#8931). Recreate it from the source metadata.
let mut constraint_sql = Vec::new();
@@ -868,6 +892,19 @@ pub fn build_duplicate_table_structure_sql(options: DuplicateTableStructureSqlOp
format!("{};\n{};", structure_sql.trim_end_matches(';'), trailing_sql.join(";\n"))
}
fn sqlserver_clone_comment_sql(schema: Option<&str>, table: &str, column: Option<&str>, comment: &str) -> String {
let schema = schema.filter(|value| !value.trim().is_empty()).unwrap_or("dbo").replace('\'', "''");
let table = table.replace('\'', "''");
let comment = comment.replace('\'', "''");
let mut sql = format!(
"EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'{comment}', @level0type=N'SCHEMA', @level0name=N'{schema}', @level1type=N'TABLE', @level1name=N'{table}'"
);
if let Some(column) = column {
sql.push_str(&format!(", @level2type=N'COLUMN', @level2name=N'{}'", column.replace('\'', "''")));
}
sql
}
pub fn build_copy_table_data_sql(options: CopyTableDataSqlOptions) -> String {
let source = qualified_name_with_quote(
options.database_type,
@@ -2352,6 +2389,57 @@ mod tests {
);
}
#[test]
fn sqlserver_clone_preserves_unicode_comments_with_quoted_object_names() {
let sql = build_duplicate_table_structure_sql(DuplicateTableStructureSqlOptions {
database_type: Some(DatabaseType::SqlServer),
schema: Some("业务]".to_string()),
source_name: "source".to_string(),
target_name: "副本]".to_string(),
table_comment: Some(" 表 O'Brien;\n注释 ".to_string()),
column_comments: vec![
DuplicateTableColumnComment {
name: "value' field]".to_string(),
comment: " 字段 O'Brien ".to_string(),
},
DuplicateTableColumnComment { name: "empty".to_string(), comment: " \t\n ".to_string() },
],
primary_key_columns: vec!["id".to_string()],
primary_key_constraint_name: Some("PK_copy".to_string()),
identifier_quote: None,
});
let statements = crate::sql::split_sql_statements_for_database(&sql, DatabaseType::SqlServer);
assert_eq!(statements.len(), 4);
assert_eq!(statements[0], "SELECT TOP 0 * INTO [业务]]].[副本]]] FROM [业务]]].[source]");
assert!(statements[1].contains("PRIMARY KEY ([id])"));
assert!(statements[2].contains("@value=N' 表 O''Brien;\n注释 '"));
assert!(statements[2].contains("@level0name=N'业务]'"));
assert!(statements[2].contains("@level1name=N'副本]'"));
assert!(statements[3].contains("@level2name=N'value'' field]'"));
assert!(statements[3].contains("@value=N' 字段 O''Brien '"));
assert!(statements[2..].iter().all(|statement| statement.starts_with("EXEC sys.sp_addextendedproperty")));
}
#[test]
fn sqlserver_clone_column_only_comments_use_default_schema() {
let sql = build_duplicate_table_structure_sql(DuplicateTableStructureSqlOptions {
database_type: Some(DatabaseType::SqlServer),
schema: None,
source_name: "source".to_string(),
target_name: "copy".to_string(),
table_comment: None,
column_comments: vec![DuplicateTableColumnComment {
name: "id".to_string(), comment: "编号".to_string()
}],
primary_key_columns: vec![],
primary_key_constraint_name: None,
identifier_quote: None,
});
assert_eq!(crate::sql::split_sql_statements_for_database(&sql, DatabaseType::SqlServer).len(), 2);
assert!(sql.contains("@level0name=N'dbo'"));
assert!(sql.contains("@level2name=N'id'"));
}
#[test]
fn duplicate_table_structure_vastbase_preserves_table_and_column_comments() {
let sql = build_duplicate_table_structure_sql(DuplicateTableStructureSqlOptions {
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB