mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(schema): preview DDL for selected columns
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user