From 6c0db6f517b674372cb572f6570e0598eed6be4e Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 29 May 2026 15:08:59 +0800 Subject: [PATCH] feat: add "Copy as SQL ALTER" to DataGrid column header context menu Right-click a column header in the data grid to copy the ALTER TABLE statement for that column, making it easy to replicate column definitions across databases. Supports all major dialects (MySQL, Postgres, SQL Server, Oracle, ClickHouse, SQLite, DuckDB, H2). Closes #516 --- apps/desktop/src/components/grid/DataGrid.vue | 53 ++++++++ apps/desktop/src/i18n/locales/en.ts | 5 + apps/desktop/src/i18n/locales/zh-CN.ts | 5 + apps/desktop/src/lib/api.ts | 2 + apps/desktop/src/lib/http.ts | 16 ++- .../src/lib/tableStructureEditorSql.ts | 7 ++ apps/desktop/src/lib/tauri.ts | 16 ++- crates/dbx-core/src/table_structure_sql.rs | 113 ++++++++++++++++++ crates/dbx-web/src/main.rs | 4 + crates/dbx-web/src/routes/query.rs | 12 ++ src-tauri/src/commands/query.rs | 7 ++ src-tauri/src/lib.rs | 8 ++ 12 files changed, 246 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 9eec787b60..17822506e9 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -65,6 +65,8 @@ import ImagePreviewDialog from "@/components/grid/ImagePreviewDialog.vue"; import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue"; import type { QueryResult, ColumnInfo, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo } from "@/types/database"; import * as api from "@/lib/api"; +import { createColumnDrafts } from "@/lib/tableStructureEditorState"; +import type { BuildSingleColumnAlterSqlOptions } from "@/lib/tableStructureEditorSql"; import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql"; import { uuid } from "@/lib/utils"; import { @@ -3224,6 +3226,54 @@ async function copyHeaderColumn() { if (!contextHeaderColumn.value) return; await copyText(contextHeaderColumn.value); } + +const canCopyAlterColumnSql = computed(() => { + if (!contextHeaderColumn.value || !props.tableMeta?.columns) return false; + return props.tableMeta.columns.some((c) => c.name.toLowerCase() === contextHeaderColumn.value!.toLowerCase()); +}); + +async function copyAlterColumnSql() { + if (!contextHeaderColumn.value) return; + const colName = contextHeaderColumn.value; + const columnInfo = props.tableMeta?.columns.find((c) => c.name.toLowerCase() === colName.toLowerCase()); + if (!columnInfo) return; + + const [draft] = createColumnDrafts([columnInfo], props.databaseType); + draft.original = { ...columnInfo }; + draft.original.data_type = ""; + draft.original.is_nullable = !columnInfo.is_nullable; + draft.original.column_default = null; + draft.original.comment = null; + draft.original.extra = null; + + const options: BuildSingleColumnAlterSqlOptions = { + databaseType: props.databaseType, + schema: props.tableMeta?.schema, + tableName: props.tableMeta!.tableName, + column: draft, + }; + + const sqlPromise = api.buildSingleColumnAlterSql(options).then((result) => { + const sql = result.statements.join("\n"); + if (!sql) throw new Error(t("grid.noAlterSqlAvailable")); + return { sql, warnings: result.warnings }; + }); + + try { + const item = new ClipboardItem({ + "text/plain": sqlPromise.then(({ sql }) => new Blob([sql], { type: "text/plain" })), + }); + await navigator.clipboard.write([item]); + const { warnings } = await sqlPromise; + if (warnings.length > 0) { + toast(t("grid.alterSqlCopiedWithWarnings", { count: warnings.length }), 3000); + } else { + toast(t("grid.alterSqlCopied"), 2000); + } + } catch (e: any) { + toast(t("grid.copyAlterSqlFailed", { message: e?.message || String(e) }), 5000); + } +} function onCellContext(rowId: number, rowIndex: number, colIdx: number, visibleColIdx: number) { contextHeaderColumn.value = null; contextCell.value = { rowId, rowIndex, col: colIdx }; @@ -3731,6 +3781,9 @@ const gridContextMenuItems = computed(() => { // 1. Copy column name if (contextHeaderColumn.value) { items.push({ label: t("grid.copyColumnName"), action: copyHeaderColumn, icon: Copy }); + if (canCopyAlterColumnSql.value) { + items.push({ label: t("grid.copyAlterColumnSql"), action: copyAlterColumnSql, icon: Copy }); + } } // 2. Column sort & filter diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 7afdab5d63..8285ff0a5b 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -526,6 +526,11 @@ export default { setNull: "Set NULL", restoreOriginalValue: "Restore Original", copyColumnName: "Copy Column Name", + copyAlterColumnSql: "Copy as SQL ALTER", + alterSqlCopied: "SQL ALTER copied to clipboard", + alterSqlCopiedWithWarnings: "SQL ALTER copied to clipboard ({count} warning(s))", + noAlterSqlAvailable: "No ALTER SQL available for this column", + copyAlterSqlFailed: "Copy failed: {message}", copySqlCondition: "Copy SQL Condition", transpose: "Transpose Row", rowsPerPageShort: " rows", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 5a7764f3fe..f624f4e064 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -521,6 +521,11 @@ export default { setNull: "设为 NULL", restoreOriginalValue: "恢复原值", copyColumnName: "复制列名", + copyAlterColumnSql: "复制为SQL ALTER", + alterSqlCopied: "SQL ALTER 已复制到剪贴板", + alterSqlCopiedWithWarnings: "SQL ALTER 已复制到剪贴板(含 {count} 条警告)", + noAlterSqlAvailable: "该列无 ALTER SQL 可复制", + copyAlterSqlFailed: "复制失败:{message}", copySqlCondition: "复制 SQL 条件", transpose: "转置查看", rowsPerPageShort: " 行/页", diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 6ac2af5396..f9a604d29d 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -34,6 +34,7 @@ function forward(name: K): Backend[K] { export const testConnection = forward("testConnection"); export const connectDb = forward("connectDb"); export const disconnectDb = forward("disconnectDb"); +export const refreshConnections = forward("refreshConnections"); export const saveConnections = forward("saveConnections"); export const loadConnections = forward("loadConnections"); export const listPlugins = forward("listPlugins"); @@ -116,6 +117,7 @@ export const buildRoutineRenameObjectSourceStatements = forward("buildRoutineRen export const buildViewDdlSql = forward("buildViewDdlSql"); export const buildTableStructureChangeSql = forward("buildTableStructureChangeSql"); export const buildCreateTableSql = forward("buildCreateTableSql"); +export const buildSingleColumnAlterSql = forward("buildSingleColumnAlterSql"); export const analyzeEditableQueryEditability = forward("analyzeEditableQueryEditability"); export const prepareDataGridSave = forward("prepareDataGridSave"); export const buildDataGridCopyUpdateStatements = forward("buildDataGridCopyUpdateStatements"); diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 6ca8693935..61273581b2 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -68,7 +68,11 @@ import type { DataGridSaveStatementOptions, HiveTablePropertiesSqlOptions, } from "@/lib/dataGridSql"; -import type { BuildTableStructureChangeSqlOptions, TableStructureChangeSql } from "@/lib/tableStructureEditorSql"; +import type { + BuildTableStructureChangeSqlOptions, + BuildSingleColumnAlterSqlOptions, + TableStructureChangeSql, +} from "@/lib/tableStructureEditorSql"; import type { BuildTableSelectSqlOptions } from "@/lib/tableSelectSql"; import type { DatabaseSearchSql, DatabaseSearchSqlOptions, SearchResultWhereOptions } from "@/lib/databaseSearch"; import type { BuildEditableObjectSourceSqlInput, BuildRoutineRenameObjectSourceInput } from "@/lib/objectSourceEditor"; @@ -636,6 +640,12 @@ export async function buildCreateTableSql( return post("/api/query/build-create-table-sql", { options }); } +export async function buildSingleColumnAlterSql( + options: BuildSingleColumnAlterSqlOptions, +): Promise { + return post("/api/query/build-single-column-alter-sql", { options }); +} + export async function analyzeEditableQueryEditability(sql: string): Promise { return post("/api/query/analyze-editability", { sql }); } @@ -1353,3 +1363,7 @@ export async function saveSidebarLayout(layout: SidebarLayout): Promise { export async function loadSidebarLayout(): Promise { return get("/api/layout/sidebar"); } + +export async function refreshConnections(): Promise { + // Web mode doesn't maintain persistent connection pools — no-op +} diff --git a/apps/desktop/src/lib/tableStructureEditorSql.ts b/apps/desktop/src/lib/tableStructureEditorSql.ts index bbfb7f6d66..7cbfc62331 100644 --- a/apps/desktop/src/lib/tableStructureEditorSql.ts +++ b/apps/desktop/src/lib/tableStructureEditorSql.ts @@ -54,3 +54,10 @@ export interface TableStructureChangeSql { statements: string[]; warnings: string[]; } + +export interface BuildSingleColumnAlterSqlOptions { + databaseType?: DatabaseType; + schema?: string; + tableName: string; + column: EditableStructureColumn; +} diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 4ccb927f22..15e6437673 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -41,7 +41,11 @@ import type { DataComparePreparationOptions, } from "@/lib/dataCompare"; import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff } from "@/lib/schemaDiff"; -import type { BuildTableStructureChangeSqlOptions, TableStructureChangeSql } from "@/lib/tableStructureEditorSql"; +import type { + BuildTableStructureChangeSqlOptions, + BuildSingleColumnAlterSqlOptions, + TableStructureChangeSql, +} from "@/lib/tableStructureEditorSql"; import type { BuildTableSelectSqlOptions } from "@/lib/tableSelectSql"; import type { DatabaseSearchSql, DatabaseSearchSqlOptions, SearchResultWhereOptions } from "@/lib/databaseSearch"; import type { BuildEditableObjectSourceSqlInput, BuildRoutineRenameObjectSourceInput } from "@/lib/objectSourceEditor"; @@ -452,6 +456,10 @@ export async function executeMulti( return invoke("execute_multi", { connectionId, database, sql, schema, executionId, ...options }); } +export async function refreshConnections(): Promise { + return invoke("refresh_connections"); +} + export async function cancelQuery(executionId: string): Promise { return invoke("cancel_query", { executionId }); } @@ -619,6 +627,12 @@ export async function buildCreateTableSql( return invoke("build_create_table_sql", { options }); } +export async function buildSingleColumnAlterSql( + options: BuildSingleColumnAlterSqlOptions, +): Promise { + return invoke("build_single_column_alter_sql", { options }); +} + export async function analyzeEditableQueryEditability(sql: string): Promise { return invoke("analyze_editable_query_editability", { sql }); } diff --git a/crates/dbx-core/src/table_structure_sql.rs b/crates/dbx-core/src/table_structure_sql.rs index e57de5b08f..75d2e6b73a 100644 --- a/crates/dbx-core/src/table_structure_sql.rs +++ b/crates/dbx-core/src/table_structure_sql.rs @@ -496,6 +496,119 @@ pub fn build_create_table_sql(options: TableStructureSqlOptions) -> TableStructu TableStructureSqlResult { statements, warnings } } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SingleColumnAlterSqlOptions { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub database_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schema: Option, + pub table_name: String, + pub column: EditableStructureColumn, +} + +pub fn build_single_column_alter_sql(options: SingleColumnAlterSqlOptions) -> TableStructureSqlResult { + let capabilities = capabilities_for(options.database_type); + let dialect = capabilities.dialect; + let table = qualified_table(dialect, options.schema.as_deref(), &options.table_name); + let database_label = database_label(options.database_type); + let mut warnings = Vec::new(); + let mut statements = Vec::new(); + + if options.column.marked_for_drop { + let Some(original) = &options.column.original else { + warnings.push("No original column info available.".to_string()); + return TableStructureSqlResult { statements, warnings }; + }; + if !capabilities.drop_column { + warnings.push(format!("Dropping columns is not supported for {database_label} from this editor.")); + return TableStructureSqlResult { statements, warnings }; + } + if original.is_primary_key { + warnings.push(format!("Primary key column \"{}\" cannot be dropped from this editor.", original.name)); + return TableStructureSqlResult { statements, warnings }; + } + statements.push(format!("ALTER TABLE {table} DROP COLUMN {};", quote_ident(dialect, &original.name))); + 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()); + return TableStructureSqlResult { statements, warnings }; + }; + + if !has_existing_column_attribute_change(&options.column) && !has_column_extra_change(&options.column) { + warnings.push("No changes detected for this column.".to_string()); + return TableStructureSqlResult { statements, warnings }; + } + + let has_rename = options.column.name != original.name; + let has_attribute_change = options.column.data_type.trim() != original.data_type.trim() + || options.column.is_nullable != original.is_nullable + || normalize_default(Some(&options.column.default_value)) != original_default(&options.column) + || clean(&options.column.comment) != original_comment(&options.column); + + if has_rename && !capabilities.rename_column { + warnings.push(format!("Renaming columns is not supported for {database_label} from this editor.")); + } + if has_attribute_change && !capabilities.alter_existing_column && dialect != StructureDialect::Sqlite { + warnings.push(format!("Editing existing columns is not supported for {database_label} yet.")); + } + + if (has_rename && !capabilities.rename_column) + || (has_attribute_change && !capabilities.alter_existing_column && dialect != StructureDialect::Sqlite) + { + return TableStructureSqlResult { statements, warnings }; + } + + match dialect { + StructureDialect::Mysql => statements.extend(build_mysql_existing_column_sql(&table, &options.column, "")), + StructureDialect::Postgres => statements.extend(build_postgres_existing_column_sql(&table, &options.column)), + StructureDialect::Oracle => { + statements.extend(build_oracle_like_existing_column_sql(dialect, &table, &options.column)) + } + StructureDialect::H2 => statements.extend(build_h2_existing_column_sql(&table, &options.column)), + StructureDialect::ClickHouse => { + statements.extend(build_clickhouse_existing_column_sql(&table, &options.column, "")) + } + StructureDialect::SqlServer => statements.extend(build_sqlserver_existing_column_sql( + &table, + &options.column, + options.schema.as_deref(), + &options.table_name, + )), + StructureDialect::Sqlite => { + statements.extend(build_sqlite_existing_column_sql(&table, &options.column, &mut warnings)) + } + _ => warnings.push(format!("Editing existing columns is not supported for {database_label} yet.")), + } + + TableStructureSqlResult { statements, warnings } +} + +fn has_column_extra_change(column: &EditableStructureColumn) -> bool { + let Some(original) = &column.original else { return false }; + let current_extra = column.extra.as_ref(); + match (current_extra, original.extra.as_deref()) { + // Neither has extra → no change + (None, None | Some("")) => false, + // Extra added or removed + (Some(_), None | Some("")) => true, + (None, Some(_)) => true, + // Both have extra → check auto_increment and on_update_current_timestamp flags + (Some(curr), Some(orig)) => { + let orig_lower = orig.to_lowercase(); + let curr_has_ai = curr.auto_increment.unwrap_or(false); + let orig_has_ai = orig_lower.contains("auto_increment"); + let curr_has_on_update = curr.on_update_current_timestamp.unwrap_or(false); + let orig_has_on_update = orig_lower.contains("on update"); + let curr_has_identity = curr.identity.is_some(); + // identity is harder to detect in free-form original.extra, so treat it as changed if present + curr_has_ai != orig_has_ai || curr_has_on_update != orig_has_on_update || curr_has_identity + } + } +} + fn build_column_sql(options: &TableStructureSqlOptions, warnings: &mut Vec) -> Vec { let capabilities = capabilities_for(options.database_type); let dialect = capabilities.dialect; diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 11e479150b..436e2ac66b 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -168,6 +168,10 @@ async fn main() { .route("/query/build-view-ddl-sql", post(routes::query::build_view_ddl_sql)) .route("/query/build-table-structure-change-sql", post(routes::query::build_table_structure_change_sql)) .route("/query/build-create-table-sql", post(routes::query::build_create_table_sql)) + .route( + "/query/build-single-column-alter-sql", + post(routes::query::build_single_column_alter_sql), + ) .route("/query/analyze-editability", post(routes::query::analyze_editable_query_editability)) .route("/query/prepare-data-grid-save", post(routes::query::prepare_data_grid_save)) .route( diff --git a/crates/dbx-web/src/routes/query.rs b/crates/dbx-web/src/routes/query.rs index da0cf4f0df..2602214540 100644 --- a/crates/dbx-web/src/routes/query.rs +++ b/crates/dbx-web/src/routes/query.rs @@ -190,6 +190,12 @@ pub struct BuildTableStructureSqlRequest { pub options: dbx_core::table_structure_sql::TableStructureSqlOptions, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildSingleColumnAlterSqlRequest { + pub options: dbx_core::table_structure_sql::SingleColumnAlterSqlOptions, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct PrepareDataGridSaveRequest { @@ -543,6 +549,12 @@ pub async fn build_create_table_sql( Json(dbx_core::table_structure_sql::build_create_table_sql(req.options)) } +pub async fn build_single_column_alter_sql( + Json(req): Json, +) -> Json { + Json(dbx_core::table_structure_sql::build_single_column_alter_sql(req.options)) +} + pub async fn analyze_editable_query_editability( Json(req): Json, ) -> Json { diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index f83c9b9b5f..ecd6c47297 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -352,6 +352,13 @@ pub fn build_create_table_sql( Ok(dbx_core::table_structure_sql::build_create_table_sql(options)) } +#[tauri::command] +pub fn build_single_column_alter_sql( + options: dbx_core::table_structure_sql::SingleColumnAlterSqlOptions, +) -> Result { + Ok(dbx_core::table_structure_sql::build_single_column_alter_sql(options)) +} + #[tauri::command] pub fn analyze_editable_query_editability(sql: String) -> Result { Ok(dbx_core::sql_editability::analyze_editable_query_editability(&sql)) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ee5751433c..b53912cba3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -258,6 +258,7 @@ pub fn run() { commands::connection::test_connection, commands::connection::connect_db, commands::connection::disconnect_db, + commands::connection::refresh_connections, commands::connection::save_connections, commands::connection::load_connections, commands::connection::save_sidebar_layout, @@ -319,6 +320,7 @@ pub fn run() { commands::query::build_view_ddl_sql, commands::query::build_table_structure_change_sql, commands::query::build_create_table_sql, + commands::query::build_single_column_alter_sql, commands::query::analyze_editable_query_editability, commands::query::prepare_data_grid_save, commands::query::build_data_grid_copy_update_statements, @@ -442,6 +444,12 @@ pub fn run() { if !has_visible_windows { show_main_window(app_handle); } + let app_handle = app_handle.clone(); + tauri::async_runtime::spawn(async move { + if let Some(state) = app_handle.try_state::() { + state.refresh_connections().await; + } + }); } }); }