diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index c831c7ad59..8089d0b3b6 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -213,6 +213,8 @@ import { runBatchTableDrop } from "@/lib/table/batchTableDrop"; import { buildSidebarDdlTemplateSql, formatSidebarDdlTemplateForDisplay } from "@/lib/sidebar/sidebarDdlTemplate"; import { resolveSidebarDdlTargets } from "@/lib/sidebar/sidebarDdlTargets"; import { sidebarTableDataExportTargets } from "@/lib/sidebar/sidebarExportRuntime"; +import { createColumnDrafts } from "@/lib/table/tableStructureEditorState"; +import type { BuildSingleColumnAlterSqlOptions } from "@/lib/table/tableStructureEditorSql"; import { formatSidebarTableCopyText, type FormatSidebarTableNamesOptions } from "@/lib/sidebar/sidebarTableNameCopy"; import { supportsScheduledDatabaseBackup } from "@/lib/backup/scheduledDatabaseBackup"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; @@ -256,6 +258,8 @@ import { showStructureDocCopyDialog, structurePreviewSql, structurePreviewTitle, + structurePreviewDefaultFileName, + structurePreviewDdlStorageType, structurePreviewError, structureDocCopyText, structureDocCopyTitle, @@ -2405,6 +2409,71 @@ async function openDdlForSelection(node: TreeNode, selectedNodeIds: readonly str return true; } +function selectedColumnNodes(node: TreeNode): Array { + if (node.type !== "column" || !node.connectionId || !node.database || !node.tableName) return []; + const selectedIds = acceptedSelectionIds ?? connectionStore.selectedTreeNodeIds; + const selected = orderSelectedTreeNodes(visibleTreeNodes(), selectedIds).filter( + (candidate): candidate is TreeNode & { connectionId: string; database: string; tableName: string } => + candidate.type === "column" && + (candidate.id === node.id || (candidate.connectionId === node.connectionId && candidate.database === node.database && (candidate.schema || "") === (node.schema || "") && (candidate.catalog || "") === (node.catalog || "") && candidate.tableName === node.tableName)), + ); + return selected.length > 0 ? selected : [node as TreeNode & { connectionId: string; database: string; tableName: string }]; +} + +async function openColumnDdl() { + const node = activeNode.value; + const columnNodes = selectedColumnNodes(node); + if (!columnNodes.length) return; + const firstNode = columnNodes[0]!; + const config = connectionStore.getConfig(firstNode.connectionId); + const databaseType = effectiveDatabaseTypeForConnection(config); + const columns = columnNodes.map((columnNode) => columnNode.meta as ColumnInfo | undefined); + if (columns.some((column) => !column)) return; + const drafts = createColumnDrafts(columns as ColumnInfo[], databaseType); + if (!drafts.length || drafts.length !== columnNodes.length) return; + for (const draft of drafts) { + // Keep the original snapshot so MySQL generated-column expressions remain + // available to the SQL builder. The internal id marks this request as an + // ADD preview without changing the meaning of originalPosition. + draft.id = `ddl-preview:${draft.id}`; + draft.markedForDrop = false; + } + + const tableSchema = connectionObjectTreeNodeSchema(config, firstNode.database, firstNode.schema); + const options: BuildSingleColumnAlterSqlOptions[] = drafts.map((columnDraft) => ({ + databaseType, + driverProfile: config?.driver_profile, + schema: tableSchema, + tableName: firstNode.tableName, + column: columnDraft, + })); + const names = drafts.map((draft) => draft.name).join(", "); + const tableLabel = `${firstNode.tableName}.${names}`; + structurePreviewTitle.value = t("contextMenu.exportStructurePreviewTitle", { name: tableLabel }); + structurePreviewDefaultFileName.value = `${firstNode.tableName}.columns.sql`; + structurePreviewDdlStorageType.value = undefined; + structurePreviewError.value = ""; + structurePreviewSql.value = ""; + isLoadingStructurePreview.value = true; + showStructurePreviewDialog.value = true; + + try { + await connectionStore.ensureConnected(firstNode.connectionId); + const results = await Promise.all(options.map((option) => api.buildSingleColumnAlterSql(option))); + structurePreviewSql.value = joinExportedDdls(results.flatMap((result) => result.statements)); + const warnings = results.flatMap((result) => result.warnings); + if (!structurePreviewSql.value) { + structurePreviewError.value = warnings.join("\n") || t("customType.ddl.empty"); + } else if (warnings.length > 0) { + toast(warnings.join("\n"), 5000); + } + } catch (error: any) { + structurePreviewError.value = translateBackendError(t, error); + } finally { + isLoadingStructurePreview.value = false; + } +} + function openElasticsearchIndexMetadata(kind: ElasticsearchIndexMetadataKind) { emit("open-elasticsearch-index-metadata", createSidebarActionTarget(activeNode.value), kind); } @@ -6600,6 +6669,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { if (node.type === "column") { items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); const columnActions: ContextMenuItem[] = []; + columnActions.push({ label: t("contextMenu.viewDdl"), action: openColumnDdl, icon: FileCode }); if (canOpenStructureEditor.value) { columnActions.push({ label: t("contextMenu.editColumn"), action: openStructureEditor, icon: PencilRuler }); } diff --git a/crates/dbx-sql-schema/src/table_structure_sql/column_alter.rs b/crates/dbx-sql-schema/src/table_structure_sql/column_alter.rs index c7d2704a3a..5d96aadd35 100644 --- a/crates/dbx-sql-schema/src/table_structure_sql/column_alter.rs +++ b/crates/dbx-sql-schema/src/table_structure_sql/column_alter.rs @@ -3,7 +3,7 @@ use super::column_format::{ is_dameng_identity_compatible_type, is_mysql_character_data_type, original_is_mysql_generated_column, original_mysql_generated_clause, }; -use super::columns::build_drop_column_sql; +use super::columns::{build_add_column_sql, build_drop_column_sql}; use super::comments::build_sqlserver_column_comment_sql_for_profile; use super::dialect::{capabilities_for, database_label, StructureDialect}; use super::types::{EditableStructureColumn, SingleColumnAlterSqlOptions, TableStructureSqlResult}; @@ -13,6 +13,8 @@ use super::util::{ }; use crate::table_structure_sql::ColumnExtra; +const SINGLE_COLUMN_ADD_PREVIEW_ID_PREFIX: &str = "ddl-preview:"; + pub fn build_single_column_alter_sql(options: SingleColumnAlterSqlOptions) -> TableStructureSqlResult { let capabilities = capabilities_for(options.database_type, options.driver_profile.as_deref()); let dialect = capabilities.dialect; @@ -42,6 +44,43 @@ pub fn build_single_column_alter_sql(options: SingleColumnAlterSqlOptions) -> Ta return TableStructureSqlResult { statements, warnings }; } + // The sidebar marks field-DDL previews explicitly in the draft id. Keep + // `original` in that request so generated-column expressions can be copied + // into the ADD definition without changing the meaning of position data + // for existing API callers. + if options.column.id.starts_with(SINGLE_COLUMN_ADD_PREVIEW_ID_PREFIX) { + if !capabilities.add_column { + warnings.push(format!("Adding columns is not supported for {database_label} from this editor.")); + return TableStructureSqlResult { statements, warnings }; + } + if options.column.name.trim().is_empty() { + warnings.push("Column name cannot be empty.".to_string()); + return TableStructureSqlResult { statements, warnings }; + } + if options.column.data_type.trim().is_empty() { + warnings.push("Column type cannot be empty.".to_string()); + return TableStructureSqlResult { statements, warnings }; + } + if !capabilities.comment && !clean(&options.column.comment).is_empty() { + warnings.push(format!( + "Column comments are not supported for {database_label} from this editor; the comment for \"{}\" was ignored.", + options.column.name + )); + } + statements.extend(build_add_column_sql( + dialect, + options.database_type, + capabilities.comment, + &table, + &options.column, + "", + options.schema.as_deref(), + &options.table_name, + options.driver_profile.as_deref(), + )); + return TableStructureSqlResult { statements, warnings }; + } + let Some(original) = &options.column.original else { warnings.push( "This column has no original state — ALTER statements are only available for existing columns.".to_string(), diff --git a/crates/dbx-sql-schema/src/table_structure_sql/tests.rs b/crates/dbx-sql-schema/src/table_structure_sql/tests.rs index 0a4b67ff09..ab791fa046 100644 --- a/crates/dbx-sql-schema/src/table_structure_sql/tests.rs +++ b/crates/dbx-sql-schema/src/table_structure_sql/tests.rs @@ -7053,6 +7053,88 @@ fn mysql_character_column_add_with_charset_collation() { ); } +#[test] +fn single_column_alter_builder_generates_add_for_new_mysql_column() { + let mut col = column("category"); + col.id = "ddl-preview:new:category".to_string(); + col.data_type = "varchar(50)".to_string(); + col.character_set = "utf8mb4".to_string(); + col.collation = "utf8mb4_general_ci".to_string(); + col.comment = "所属类别".to_string(); + + let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions { + database_type: Some(DatabaseType::Mysql), + driver_profile: None, + schema: None, + table_name: "apis".to_string(), + column: col, + }); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec![ + "ALTER TABLE `apis` ADD COLUMN `category` varchar(50) CHARACTER SET `utf8mb4` COLLATE `utf8mb4_general_ci` COMMENT '所属类别';" + ] + ); +} + +#[test] +fn single_column_alter_builder_preserves_mysql_generated_expression_for_add_preview() { + let mut col = column("total"); + col.id = "ddl-preview:existing:total".to_string(); + col.data_type = "int".to_string(); + col.original = Some(ColumnInfo { + name: "total".to_string(), + data_type: "int".to_string(), + is_nullable: true, + column_default: None, + is_primary_key: false, + extra: Some("GENERATED ALWAYS AS (`quantity` * `price`) STORED".to_string()), + comment: None, + character_set: None, + collation: None, + }); + col.original_position = None; + + let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions { + database_type: Some(DatabaseType::Mysql), + driver_profile: None, + schema: None, + table_name: "orders".to_string(), + column: col, + }); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec!["ALTER TABLE `orders` ADD COLUMN `total` int GENERATED ALWAYS AS (`quantity` * `price`) STORED;"] + ); +} + +#[test] +fn single_column_alter_builder_keeps_existing_column_without_position_as_edit() { + let mut col = column("id"); + col.data_type = "INTEGER".to_string(); + col.original = Some(ColumnInfo { + name: "id".to_string(), + data_type: "SMALLINT".to_string(), + is_nullable: true, + ..Default::default() + }); + + let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions { + database_type: Some(DatabaseType::DuckDb), + driver_profile: None, + schema: None, + table_name: "issue_9980".to_string(), + column: col, + }); + + assert!(result.statements.is_empty()); + assert_eq!(result.warnings, vec!["Editing existing columns is not supported for duckdb yet."]); +} + #[test] fn mysql_numeric_column_omits_charset_collation_in_column_definition() { let mut col = column("score");