diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 9eec787b6..17822506e 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 7afdab5d6..8285ff0a5 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 5a7764f3f..f624f4e06 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 6ac2af539..f9a604d29 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 6ca869393..61273581b 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 bbfb7f6d6..7cbfc6233 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 4ccb927f2..15e643767 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 e57de5b08..75d2e6b73 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 11e479150..436e2ac66 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 da0cf4f0d..260221454 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 f83c9b9b5..ecd6c4729 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 ee5751433..b53912cba 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; + } + }); } }); }