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
This commit is contained in:
t8y2 2026-05-29 15:08:59 +08:00
parent 51e62d4c60
commit 6c0db6f517
12 changed files with 246 additions and 2 deletions

View File

@ -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<ContextMenuItem[]>(() => {
// 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

View File

@ -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",

View File

@ -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: " 行/页",

View File

@ -34,6 +34,7 @@ function forward<K extends keyof Backend>(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");

View File

@ -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<TableStructureChangeSql> {
return post("/api/query/build-single-column-alter-sql", { options });
}
export async function analyzeEditableQueryEditability(sql: string): Promise<QueryEditability> {
return post("/api/query/analyze-editability", { sql });
}
@ -1353,3 +1363,7 @@ export async function saveSidebarLayout(layout: SidebarLayout): Promise<void> {
export async function loadSidebarLayout(): Promise<SidebarLayout | null> {
return get("/api/layout/sidebar");
}
export async function refreshConnections(): Promise<void> {
// Web mode doesn't maintain persistent connection pools — no-op
}

View File

@ -54,3 +54,10 @@ export interface TableStructureChangeSql {
statements: string[];
warnings: string[];
}
export interface BuildSingleColumnAlterSqlOptions {
databaseType?: DatabaseType;
schema?: string;
tableName: string;
column: EditableStructureColumn;
}

View File

@ -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<void> {
return invoke("refresh_connections");
}
export async function cancelQuery(executionId: string): Promise<boolean> {
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<TableStructureChangeSql> {
return invoke("build_single_column_alter_sql", { options });
}
export async function analyzeEditableQueryEditability(sql: string): Promise<QueryEditability> {
return invoke("analyze_editable_query_editability", { sql });
}

View File

@ -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<DatabaseType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<String>,
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<String>) -> Vec<String> {
let capabilities = capabilities_for(options.database_type);
let dialect = capabilities.dialect;

View File

@ -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(

View File

@ -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<BuildSingleColumnAlterSqlRequest>,
) -> Json<dbx_core::table_structure_sql::TableStructureSqlResult> {
Json(dbx_core::table_structure_sql::build_single_column_alter_sql(req.options))
}
pub async fn analyze_editable_query_editability(
Json(req): Json<AnalyzeEditableQueryRequest>,
) -> Json<dbx_core::sql_editability::QueryEditability> {

View File

@ -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<dbx_core::table_structure_sql::TableStructureSqlResult, String> {
Ok(dbx_core::table_structure_sql::build_single_column_alter_sql(options))
}
#[tauri::command]
pub fn analyze_editable_query_editability(sql: String) -> Result<dbx_core::sql_editability::QueryEditability, String> {
Ok(dbx_core::sql_editability::analyze_editable_query_editability(&sql))

View File

@ -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::<AppState>() {
state.refresh_connections().await;
}
});
}
});
}