feat(schema): preview DDL for selected columns

This commit is contained in:
二丫讲梵
2026-10-05 13:45:23 +08:00
committed by GitHub
parent c3c53fee9a
commit 36b372891f
3 changed files with 192 additions and 1 deletions
@@ -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<TreeNode & { connectionId: string; database: string; tableName: string }> {
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 });
}
@@ -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(),
@@ -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::<String>::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::<String>::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");