diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 1cbf77513..df2fe3e6d 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -60,7 +60,18 @@ import { supportsSchemaDiagram, supportsTableImport, supportsTableStructureEditi import { codeMirrorSqlDialect, connectionObjectTreeNodeSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { getTableMetadataCapabilities, type TableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities"; import { buildTableSelectSql } from "@/lib/table/tableSelectSql"; -import { buildDropObjectSql, buildDropTableSql, buildDuplicateTableStructureSql, buildCopyTableDataSql, buildEmptyTableSql, buildTruncateTableSql, supportsDropTableCascade, supportsTruncateTableCascade, type TableAdminSqlOptions } from "@/lib/database/dbAdminSql"; +import { + buildDropObjectSql, + buildDropTableSql, + buildDuplicateTableStructureSql, + buildCopyTableDataSql, + buildEmptyTableSql, + buildTruncateTableSql, + collectDuplicateTableColumnComments, + supportsDropTableCascade, + supportsTruncateTableCascade, + type TableAdminSqlOptions, +} from "@/lib/database/dbAdminSql"; import { useToast } from "@/composables/useToast"; import { buildExecutableObjectSourceStatements, buildRoutineRenameObjectSourceStatements, executeObjectSourceSave, formatObjectSourceSaveError, supportsSourceBackedRoutineRename } from "@/lib/table/objectSourceEditor"; import { buildRenameObjectSql, supportsObjectRename } from "@/lib/table/objectRenameSql"; @@ -1809,6 +1820,30 @@ function requestDuplicateStructure(row: ObjectBrowserRow) { showDuplicateDialog.value = true; } +async function buildDuplicateStructurePlan(sourceName: string, targetName: string, schema: string | undefined, sourceColumns?: ColumnInfo[]) { + let columns = sourceColumns; + if (effectiveDatabaseType.value === "dameng" && !columns) { + try { + columns = await api.getColumns(props.connection.id, props.database, schema || "", sourceName, props.catalog); + } catch (error) { + console.warn(`Failed to load Dameng column comments for table clone: ${sourceName}`, error); + } + } + const columnComments = effectiveDatabaseType.value === "dameng" ? collectDuplicateTableColumnComments(columns ?? []) : []; + const sql = await buildDuplicateTableStructureSql({ + databaseType: effectiveDatabaseType.value, + schema, + sourceName, + targetName, + columnComments, + }); + return { sql, sourceColumns: columns, executeAsScript: columnComments.length > 0 }; +} + +function executeDuplicateStructurePlan(plan: { sql: string; executeAsScript: boolean }, schema: string | undefined) { + return plan.executeAsScript ? api.executeScript(props.connection.id, props.database, plan.sql, schema) : api.executeQuery(props.connection.id, props.database, plan.sql, schema); +} + async function confirmDuplicateStructure() { const row = duplicateTarget.value; const newName = duplicateTableName.value.trim(); @@ -1816,13 +1851,8 @@ async function confirmDuplicateStructure() { showDuplicateDialog.value = false; try { const schema = row.schema || selectedSchema.value; - const sql = await buildDuplicateTableStructureSql({ - databaseType: effectiveDatabaseType.value, - schema, - sourceName: row.name, - targetName: newName, - }); - const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql, schema)); + const plan = await buildDuplicateStructurePlan(row.name, newName, schema); + const executed = await executeObjectBrowserSqlWithProductionGuard(plan.sql, () => executeDuplicateStructurePlan(plan, schema)); if (!executed) return; toast(t("contextMenu.duplicateStructureSuccess", { name: newName })); await reload(); @@ -1919,18 +1949,15 @@ async function confirmPasteTable() { const targetName = entry.targetName.trim(); const schema = entry.schema || selectedSchema.value; try { + let sourceColumns: ColumnInfo[] | undefined; if (mode === "structure-and-data" || mode === "structure-only") { - const structureSql = await buildDuplicateTableStructureSql({ - databaseType: effectiveDatabaseType.value, - schema, - sourceName: entry.sourceName, - targetName, - }); - const executed = await executeObjectBrowserSqlWithProductionGuard(structureSql, () => api.executeQuery(props.connection.id, props.database, structureSql, schema)); + const plan = await buildDuplicateStructurePlan(entry.sourceName, targetName, schema, sourceColumns); + sourceColumns = plan.sourceColumns; + const executed = await executeObjectBrowserSqlWithProductionGuard(plan.sql, () => executeDuplicateStructurePlan(plan, schema)); if (!executed) return; } if (copyData) { - const sourceColumns = await api.getColumns(props.connection.id, props.database, schema || "", entry.sourceName, props.catalog); + sourceColumns ??= await api.getColumns(props.connection.id, props.database, schema || "", entry.sourceName, props.catalog); const dataCopyColumnOptions = tableDataCopyColumnOptions(effectiveDatabaseType.value, sourceColumns); if (dataCopyColumnOptions.columns.length === 0) { throw new Error("No writable columns available for table data copy."); diff --git a/apps/desktop/src/lib/__tests__/database/dbAdminSql.spec.ts b/apps/desktop/src/lib/__tests__/database/dbAdminSql.spec.ts new file mode 100644 index 000000000..b5e0b528c --- /dev/null +++ b/apps/desktop/src/lib/__tests__/database/dbAdminSql.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { collectDuplicateTableColumnComments } from "@/lib/database/dbAdminSql"; + +describe("collectDuplicateTableColumnComments", () => { + it("preserves meaningful whitespace and excludes whitespace-only comments", () => { + expect( + collectDuplicateTableColumnComments([ + { name: "LEADING", comment: " leading" }, + { name: "TRAILING", comment: "trailing " }, + { name: "BOTH", comment: " Owner's; display name " }, + { name: "WHITESPACE_ONLY", comment: " \t\n" }, + { name: "EMPTY", comment: "" }, + { name: "NULL", comment: null }, + ]), + ).toEqual([ + { name: "LEADING", comment: " leading" }, + { name: "TRAILING", comment: "trailing " }, + { name: "BOTH", comment: " Owner's; display name " }, + ]); + }); +}); diff --git a/apps/desktop/src/lib/database/dbAdminSql.ts b/apps/desktop/src/lib/database/dbAdminSql.ts index ffd0312a6..fabca86cb 100644 --- a/apps/desktop/src/lib/database/dbAdminSql.ts +++ b/apps/desktop/src/lib/database/dbAdminSql.ts @@ -1,4 +1,4 @@ -import type { DatabaseObjectType, DatabaseType } from "@/types/database"; +import type { ColumnInfo, DatabaseObjectType, DatabaseType } from "@/types/database"; import * as api from "@/lib/backend/api"; export interface DropObjectSqlOptions { @@ -55,6 +55,14 @@ export interface DuplicateTableStructureSqlOptions { schema?: string | null; sourceName: string; targetName: string; + columnComments?: Array<{ name: string; comment: string }>; +} + +export function collectDuplicateTableColumnComments(columns: readonly Pick[]): Array<{ name: string; comment: string }> { + return columns.flatMap((column) => { + const comment = column.comment; + return comment?.trim() ? [{ name: column.name, comment }] : []; + }); } export interface CopyTableDataSqlOptions { diff --git a/crates/dbx-core/src/db_admin_sql.rs b/crates/dbx-core/src/db_admin_sql.rs index 9f62b3bfb..d564170d4 100644 --- a/crates/dbx-core/src/db_admin_sql.rs +++ b/crates/dbx-core/src/db_admin_sql.rs @@ -162,6 +162,15 @@ pub struct DuplicateTableStructureSqlOptions { pub schema: Option, pub source_name: String, pub target_name: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub column_comments: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DuplicateTableColumnComment { + pub name: String, + pub comment: String, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -582,22 +591,38 @@ pub fn build_drop_schema_sql(options: SchemaNameSqlOptions) -> String { pub fn build_duplicate_table_structure_sql(options: DuplicateTableStructureSqlOptions) -> String { let source = qualified_name(options.database_type, options.schema.as_deref(), &options.source_name); let target = qualified_name(options.database_type, options.schema.as_deref(), &options.target_name); - if options.database_type == Some(DatabaseType::Mysql) { - return format!("CREATE TABLE {target} LIKE {source};"); + let structure_sql = if options.database_type == Some(DatabaseType::Mysql) { + format!("CREATE TABLE {target} LIKE {source};") + } else if options.database_type == Some(DatabaseType::Questdb) { + format!("CREATE TABLE {target} (LIKE {source});") + } else if options.database_type.is_some_and(is_postgres_like_structure_copy) { + format!("CREATE TABLE {target} (LIKE {source} INCLUDING ALL);") + } else if options.database_type == Some(DatabaseType::SqlServer) { + format!("SELECT TOP 0 * INTO {target} FROM {source};") + } else if options.database_type.is_some_and(uses_false_predicate_duplicate_structure) { + format!("CREATE TABLE {target} AS SELECT * FROM {source} WHERE 1=0") + } else { + format!("CREATE TABLE {target} AS SELECT * FROM {source} WHERE 0;") + }; + + if options.database_type != Some(DatabaseType::Dameng) { + return structure_sql; } - if options.database_type == Some(DatabaseType::Questdb) { - return format!("CREATE TABLE {target} (LIKE {source});"); + let comment_sql = 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))) + }) + .collect::>(); + if comment_sql.is_empty() { + return structure_sql; } - if options.database_type.is_some_and(is_postgres_like_structure_copy) { - return format!("CREATE TABLE {target} (LIKE {source} INCLUDING ALL);"); - } - if options.database_type == Some(DatabaseType::SqlServer) { - return format!("SELECT TOP 0 * INTO {target} FROM {source};"); - } - if options.database_type.is_some_and(uses_false_predicate_duplicate_structure) { - return format!("CREATE TABLE {target} AS SELECT * FROM {source} WHERE 1=0"); - } - format!("CREATE TABLE {target} AS SELECT * FROM {source} WHERE 0;") + format!("{};\n{};", structure_sql.trim_end_matches(';'), comment_sql.join(";\n")) } pub fn build_copy_table_data_sql(options: CopyTableDataSqlOptions) -> String { @@ -1419,6 +1444,7 @@ mod tests { schema: None, source_name: "users".to_string(), target_name: "users_copy".to_string(), + column_comments: vec![], }), "CREATE TABLE `users_copy` LIKE `users`;" ); @@ -1428,6 +1454,7 @@ mod tests { schema: Some("public".to_string()), source_name: "users".to_string(), target_name: "users_copy".to_string(), + column_comments: vec![], }), "CREATE TABLE \"public\".\"users_copy\" (LIKE \"public\".\"users\" INCLUDING ALL);" ); @@ -1437,6 +1464,7 @@ mod tests { schema: Some("public".to_string()), source_name: "users".to_string(), target_name: "users_copy".to_string(), + column_comments: vec![], }), "CREATE TABLE \"public\".\"users_copy\" (LIKE \"public\".\"users\" INCLUDING ALL);" ); @@ -1446,6 +1474,7 @@ mod tests { schema: Some("dbo".to_string()), source_name: "users".to_string(), target_name: "users_copy".to_string(), + column_comments: vec![], }), "SELECT TOP 0 * INTO [dbo].[users_copy] FROM [dbo].[users];" ); @@ -1455,15 +1484,44 @@ mod tests { schema: Some("HR".to_string()), source_name: "USERS".to_string(), target_name: "USERS_COPY".to_string(), + column_comments: vec![], }), "CREATE TABLE \"HR\".\"USERS_COPY\" AS SELECT * FROM \"HR\".\"USERS\" WHERE 1=0" ); + let dameng_sql = build_duplicate_table_structure_sql(DuplicateTableStructureSqlOptions { + database_type: Some(DatabaseType::Dameng), + schema: Some("APP".to_string()), + source_name: "USERS".to_string(), + target_name: "USERS_COPY".to_string(), + column_comments: vec![ + DuplicateTableColumnComment { + name: "DISPLAY\"NAME".to_string(), + comment: " Owner's; display name".to_string(), + }, + DuplicateTableColumnComment { name: "STATUS".to_string(), comment: "active ".to_string() }, + DuplicateTableColumnComment { name: "EMPTY".to_string(), comment: " \t\n".to_string() }, + ], + }); + assert_eq!( + dameng_sql, + "CREATE TABLE \"APP\".\"USERS_COPY\" AS SELECT * FROM \"APP\".\"USERS\" WHERE 1=0;\nCOMMENT ON COLUMN \"APP\".\"USERS_COPY\".\"DISPLAY\"\"NAME\" IS ' Owner''s; display name';\nCOMMENT ON COLUMN \"APP\".\"USERS_COPY\".\"STATUS\" IS 'active ';" + ); + assert_eq!( + crate::sql::split_sql_statements_for_database(&dameng_sql, DatabaseType::Dameng), + vec![ + "CREATE TABLE \"APP\".\"USERS_COPY\" AS SELECT * FROM \"APP\".\"USERS\" WHERE 1=0".to_string(), + "COMMENT ON COLUMN \"APP\".\"USERS_COPY\".\"DISPLAY\"\"NAME\" IS ' Owner''s; display name'" + .to_string(), + "COMMENT ON COLUMN \"APP\".\"USERS_COPY\".\"STATUS\" IS 'active '".to_string(), + ] + ); assert_eq!( build_duplicate_table_structure_sql(DuplicateTableStructureSqlOptions { database_type: Some(DatabaseType::Iris), schema: Some("SQLUSER".to_string()), source_name: "tb_a".to_string(), target_name: "tb_a_copy".to_string(), + column_comments: vec![], }), "CREATE TABLE \"SQLUSER\".\"tb_a_copy\" AS SELECT * FROM \"SQLUSER\".\"tb_a\" WHERE 1=0" ); @@ -1473,6 +1531,7 @@ mod tests { schema: None, source_name: "users".to_string(), target_name: "users_copy".to_string(), + column_comments: vec![], }), "CREATE TABLE `users_copy` (LIKE `users`);" );