mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(sqlserver): preserve table and column comments when cloning
This commit is contained in:
+63
-1
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user