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:
parent
51e62d4c60
commit
6c0db6f517
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: " 行/页",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,3 +54,10 @@ export interface TableStructureChangeSql {
|
|||
statements: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface BuildSingleColumnAlterSqlOptions {
|
||||
databaseType?: DatabaseType;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
column: EditableStructureColumn;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue