From 6acbcb0d2b61f80bf327d12fbda3874ed392d437 Mon Sep 17 00:00:00 2001 From: t8y2 Date: Wed, 23 Sep 2026 01:54:11 +0800 Subject: [PATCH] fix(vastbase): preserve comments when copying tables Closes #10045 --- .../lib/__tests__/database/dbAdminSql.spec.ts | 64 +++++++- apps/desktop/src/lib/database/dbAdminSql.ts | 8 +- crates/dbx-sql/src/db_admin_sql.rs | 139 +++++++++++++++++- 3 files changed, 204 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/lib/__tests__/database/dbAdminSql.spec.ts b/apps/desktop/src/lib/__tests__/database/dbAdminSql.spec.ts index 8f454876c1..49fe95149a 100644 --- a/apps/desktop/src/lib/__tests__/database/dbAdminSql.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/dbAdminSql.spec.ts @@ -408,19 +408,77 @@ describe("buildDuplicateTableStructurePlan", () => { expect(apiMock.buildDuplicateTableStructureSql).not.toHaveBeenCalled(); }); - it("keeps representative non-Dameng clones on the generic path", async () => { + it("loads Vastbase source comments without rebuilding constraints or defaults", async () => { + const columns = [ + { name: '备"注', comment: " 客户's;备注 ", data_type: "text", is_nullable: true, column_default: "'default'", is_primary_key: false }, + ...[null, "", " \t\n ", undefined].map((comment, index) => ({ name: `empty_${index}`, comment, data_type: "text", is_nullable: true, column_default: null, is_primary_key: false })), + ]; + apiMock.getColumns.mockResolvedValue(columns); + const sql = 'CREATE TABLE "copy" AS SELECT * FROM "source" WHERE 1=0;\nCOMMENT ON TABLE "copy" IS \'订单\';\nCOMMENT ON COLUMN "copy"."note" IS \'备注\';'; + apiMock.buildDuplicateTableStructureSql.mockResolvedValue(sql); + + const plan = await buildDuplicateTableStructurePlan({ + connectionId: "vastbase-1", + database: "app", + catalog: "catalog", + databaseType: "vastbase", + schema: '业"务', + sourceName: '订"单', + targetName: '订"单_副本', + tableComment: " 客户's;订单 ", + identifierQuote: '"', + }); + + expect(apiMock.getColumns).toHaveBeenCalledExactlyOnceWith("vastbase-1", "app", '业"务', '订"单', "catalog"); + expect(apiMock.buildDuplicateTableStructureSql).toHaveBeenCalledExactlyOnceWith({ + databaseType: "vastbase", + schema: '业"务', + sourceName: '订"单', + targetName: '订"单_副本', + tableComment: " 客户's;订单 ", + columnComments: [{ name: '备"注', comment: " 客户's;备注 " }], + identifierQuote: '"', + }); + expect(plan).toEqual({ sql, sourceColumns: columns, executeAsScript: true }); + expect(apiMock.listIndexes).not.toHaveBeenCalled(); + expect(apiMock.buildCreateTableSql).not.toHaveBeenCalled(); + expect(apiMock.getTableComment).not.toHaveBeenCalled(); + }); + + it.each([null, "", " \t\n "])("reuses Vastbase columns and keeps absent comments optional: %j", async (comment) => { + const columns = [{ name: "note", comment, data_type: "text", is_nullable: true, column_default: null, is_primary_key: false }]; + const sql = 'CREATE TABLE "copy" AS SELECT * FROM "source" WHERE 1=0;'; + apiMock.buildDuplicateTableStructureSql.mockResolvedValue(sql); + + const plan = await buildDuplicateTableStructurePlan({ connectionId: "vastbase-1", database: "app", databaseType: "vastbase", sourceName: "source", targetName: "copy", tableComment: comment, sourceColumns: columns }); + + expect(apiMock.getColumns).not.toHaveBeenCalled(); + expect(apiMock.buildDuplicateTableStructureSql).toHaveBeenCalledWith(expect.objectContaining({ tableComment: comment, columnComments: [] })); + expect(plan).toEqual({ sql, sourceColumns: columns, executeAsScript: false }); + }); + + it("does not silently clone Vastbase without comments when column metadata fails", async () => { + const error = new Error("column metadata unavailable"); + apiMock.getColumns.mockRejectedValueOnce(error); + + await expect(buildDuplicateTableStructurePlan({ connectionId: "vastbase-1", database: "app", databaseType: "vastbase", sourceName: "source", targetName: "copy" })).rejects.toBe(error); + expect(apiMock.getColumns).toHaveBeenCalledExactlyOnceWith("vastbase-1", "app", "", "source", undefined); + expect(apiMock.buildDuplicateTableStructureSql).not.toHaveBeenCalled(); + }); + + it.each(["postgres", "mysql", "highgo", "kingbase", undefined] as const)("keeps %s clones on the generic path", async (databaseType) => { apiMock.buildDuplicateTableStructureSql.mockResolvedValue('CREATE TABLE "copy" (LIKE "source" INCLUDING ALL);'); const plan = await buildDuplicateTableStructurePlan({ connectionId: "postgres-1", database: "app", - databaseType: "postgres", + databaseType, schema: "public", sourceName: "source", targetName: "copy", }); - expect(apiMock.buildDuplicateTableStructureSql).toHaveBeenCalledWith(expect.objectContaining({ databaseType: "postgres", sourceName: "source", targetName: "copy" })); + expect(apiMock.buildDuplicateTableStructureSql).toHaveBeenCalledWith(expect.objectContaining({ databaseType, sourceName: "source", targetName: "copy", columnComments: [] })); expect(apiMock.getColumns).not.toHaveBeenCalled(); expect(apiMock.listIndexes).not.toHaveBeenCalled(); expect(apiMock.buildCreateTableSql).not.toHaveBeenCalled(); diff --git a/apps/desktop/src/lib/database/dbAdminSql.ts b/apps/desktop/src/lib/database/dbAdminSql.ts index 6a48cb7eaa..fff8d63a1f 100644 --- a/apps/desktop/src/lib/database/dbAdminSql.ts +++ b/apps/desktop/src/lib/database/dbAdminSql.ts @@ -314,16 +314,20 @@ export async function buildDuplicateTableStructurePlan(options: DuplicateTableSt return { sql, sourceColumns: options.sourceColumns, executeAsScript: primaryKeyColumns.length > 0 || duplicateTableStructureRequiresScript(sql) }; } + let sourceColumns = options.sourceColumns; + if (options.databaseType === "vastbase") { + sourceColumns ??= await api.getColumns(options.connectionId, options.database, options.schema || "", options.sourceName, options.catalog); + } const sql = await buildDuplicateTableStructureSql({ databaseType: options.databaseType, schema: options.schema, sourceName: options.sourceName, targetName: options.targetName, tableComment: options.tableComment, - columnComments: [], + columnComments: options.databaseType === "vastbase" ? collectDuplicateTableColumnComments(sourceColumns ?? []) : [], identifierQuote: options.identifierQuote, }); - return { sql, sourceColumns: options.sourceColumns, executeAsScript: duplicateTableStructureRequiresScript(sql) }; + return { sql, sourceColumns, executeAsScript: duplicateTableStructureRequiresScript(sql) }; } export interface CopyTableDataSqlOptions { diff --git a/crates/dbx-sql/src/db_admin_sql.rs b/crates/dbx-sql/src/db_admin_sql.rs index b303a538a0..bbdaad0df1 100644 --- a/crates/dbx-sql/src/db_admin_sql.rs +++ b/crates/dbx-sql/src/db_admin_sql.rs @@ -817,13 +817,16 @@ pub fn build_duplicate_table_structure_sql(options: DuplicateTableStructureSqlOp )); } } - if options.database_type == Some(DatabaseType::Dameng) { + if let Some(database_type @ (DatabaseType::Dameng | DatabaseType::Vastbase)) = options.database_type { comment_sql.extend(options.column_comments.iter().filter_map(|column| { if column.comment.trim().is_empty() { return None; } let column_name = quote_table_identifier(options.database_type, &column.name); - Some(format!("COMMENT ON COLUMN {target}.{column_name} IS {}", quote_sql_string(&column.comment))) + Some(format!( + "COMMENT ON COLUMN {target}.{column_name} IS {}", + quote_duplicate_table_comment(database_type, &column.comment) + )) })); } @@ -1109,6 +1112,7 @@ fn supports_duplicate_table_comment(database_type: DatabaseType) -> bool { | DatabaseType::Kwdb | DatabaseType::OpenGauss | DatabaseType::Dameng + | DatabaseType::Vastbase ) } @@ -2273,6 +2277,137 @@ mod tests { ); } + #[test] + fn duplicate_table_structure_vastbase_preserves_table_and_column_comments() { + let sql = build_duplicate_table_structure_sql(DuplicateTableStructureSqlOptions { + database_type: Some(DatabaseType::Vastbase), + schema: Some("业\"务".to_string()), + source_name: "订\"单".to_string(), + target_name: "订\"单_副本".to_string(), + table_comment: Some(" 客户's;订单 ".to_string()), + column_comments: vec![ + DuplicateTableColumnComment { name: "备\"注".to_string(), comment: "用户's;备注".to_string() }, + DuplicateTableColumnComment { + name: "路径".to_string(), comment: "C:\\订单\n明细\t'".to_string() + }, + DuplicateTableColumnComment { name: "空".to_string(), comment: String::new() }, + DuplicateTableColumnComment { name: "空白".to_string(), comment: " \t\n ".to_string() }, + ], + primary_key_columns: vec!["编号".to_string()], + primary_key_constraint_name: Some("PK_副本".to_string()), + identifier_quote: Some("\"".to_string()), + }); + let expected_statements = vec![ + "CREATE TABLE \"业\"\"务\".\"订\"\"单_副本\" AS SELECT * FROM \"业\"\"务\".\"订\"\"单\" WHERE 1=0", + "COMMENT ON TABLE \"业\"\"务\".\"订\"\"单_副本\" IS ' 客户''s;订单 '", + "COMMENT ON COLUMN \"业\"\"务\".\"订\"\"单_副本\".\"备\"\"注\" IS '用户''s;备注'", + "COMMENT ON COLUMN \"业\"\"务\".\"订\"\"单_副本\".\"路径\" IS E'C:\\\\订单\\n明细\\t\\''", + ]; + assert_eq!(sql, format!("{};", expected_statements.join(";\n"))); + assert_eq!(crate::sql::split_sql_statements_for_database(&sql, DatabaseType::Vastbase), expected_statements); + } + + #[test] + fn duplicate_table_structure_vastbase_comments_are_independent_and_optional() { + for table_comment in [None, Some(""), Some(" \t\n "), Some("表注释"), Some("路径\\'\n归档")] { + for column_comment in [None, Some(""), Some(" \t\n "), Some("字段注释")] { + for schema in [None, Some("")] { + let sql = build_duplicate_table_structure_sql(DuplicateTableStructureSqlOptions { + database_type: Some(DatabaseType::Vastbase), + schema: schema.map(str::to_string), + source_name: "source".to_string(), + target_name: "copy".to_string(), + table_comment: table_comment.map(str::to_string), + column_comments: column_comment + .map(|comment| DuplicateTableColumnComment { + name: "note".to_string(), + comment: comment.to_string(), + }) + .into_iter() + .collect(), + primary_key_columns: vec![], + primary_key_constraint_name: None, + identifier_quote: None, + }); + let mut expected = "CREATE TABLE \"copy\" AS SELECT * FROM \"source\" WHERE 1=0;".to_string(); + match table_comment { + Some("表注释") => expected.push_str("\nCOMMENT ON TABLE \"copy\" IS '表注释';"), + Some("路径\\'\n归档") => { + expected.push_str("\nCOMMENT ON TABLE \"copy\" IS E'路径\\\\\\'\\n归档';") + } + _ => {} + } + if column_comment == Some("字段注释") { + expected.push_str("\nCOMMENT ON COLUMN \"copy\".\"note\" IS '字段注释';"); + } + assert_eq!(sql, expected); + } + } + } + } + + #[test] + fn duplicate_table_structure_comments_keep_other_dialects_unchanged() { + for (database_type, expected) in [ + ( + Some(DatabaseType::Postgres), + "CREATE TABLE \"copy\" (LIKE \"source\" INCLUDING ALL);\nCOMMENT ON TABLE \"copy\" IS '表注释';", + ), + (Some(DatabaseType::Mysql), "CREATE TABLE `copy` LIKE `source`;"), + (Some(DatabaseType::Highgo), "CREATE TABLE \"copy\" AS SELECT * FROM \"source\" WHERE 1=0;"), + (Some(DatabaseType::Kingbase), "CREATE TABLE \"copy\" AS SELECT * FROM \"source\" WHERE 1=0;"), + (Some(DatabaseType::DuckDb), "CREATE TABLE \"copy\" AS SELECT * FROM \"source\" WHERE 1=0;"), + (None, "CREATE TABLE \"copy\" AS SELECT * FROM \"source\" WHERE 1=0;"), + ] { + assert_eq!( + build_duplicate_table_structure_sql(DuplicateTableStructureSqlOptions { + database_type, + schema: None, + source_name: "source".to_string(), + target_name: "copy".to_string(), + table_comment: Some("表注释".to_string()), + column_comments: vec![DuplicateTableColumnComment { + name: "note".to_string(), + comment: "字段注释".to_string(), + }], + primary_key_columns: vec![], + primary_key_constraint_name: None, + identifier_quote: None, + }), + expected, + "{database_type:?}" + ); + } + } + + #[test] + fn copy_table_data_vastbase_keeps_insert_and_target_quoting() { + for normalize_new_target_name in [false, true] { + for columns in [None, Some(vec![]), Some(vec!["编\"号".to_string(), "备注".to_string()])] { + let expected = if columns.as_ref().is_some_and(|columns| !columns.is_empty()) { + "INSERT INTO \"业\"\"务\".\"订\"\"单_副本\" (\"编\"\"号\", \"备注\") SELECT \"编\"\"号\", \"备注\" FROM \"业\"\"务\".\"订\"\"单\";" + } else { + "INSERT INTO \"业\"\"务\".\"订\"\"单_副本\" SELECT * FROM \"业\"\"务\".\"订\"\"单\";" + }; + assert_eq!( + build_copy_table_data_sql(CopyTableDataSqlOptions { + database_type: Some(DatabaseType::Vastbase), + schema: Some("业\"务".to_string()), + source_name: "订\"单".to_string(), + target_name: "订\"单_副本".to_string(), + columns, + postgres_overriding_system_value: false, + sqlserver_identity_insert: false, + dameng_identity_insert: false, + normalize_new_target_name, + identifier_quote: Some("\"".to_string()), + }), + expected + ); + } + } + } + #[test] fn duplicate_table_structure_uses_boolean_false_predicate_for_pg_family_fallbacks() { // Regression for #9950: the generic fallback used `WHERE 0`. PostgreSQL-family