feat: add primary key editing to table structure editor

Add PK checkbox column in Vue dialog, alter_primary_key capability flag,
build_primary_key_sql() for PostgreSQL/MySQL ALTER TABLE PK changes,
and i18n labels in zh-CN/en/es.

Includes flicker fix (silent reload after apply) and move ready badge
to SQL preview header.
This commit is contained in:
t8y2 committed 2026-05-24 20:37:59 +08:00
1 parent 479f4623db
commit 72d5435535
5 files changed
+272 -8

No files matched your search

@@ -188,9 +188,9 @@ function resetState() {
newTableName.value = ""; newTableName.value = "";
} }
async function loadStructure() { async function loadStructure(silent = false) {
if (!props.prefillConnectionId || !props.prefillDatabase || !props.prefillTable) return; if (!props.prefillConnectionId || !props.prefillDatabase || !props.prefillTable) return;
loading.value = true; if (!silent) loading.value = true;
errorMessage.value = ""; errorMessage.value = "";
try { try {
await store.ensureConnected(props.prefillConnectionId); await store.ensureConnected(props.prefillConnectionId);
@@ -218,7 +218,7 @@ async function loadStructure() {
} catch (e: any) { } catch (e: any) {
errorMessage.value = e?.message || String(e); errorMessage.value = e?.message || String(e);
} finally { } finally {
loading.value = false; if (!silent) loading.value = false;
} }
} }
@@ -282,6 +282,12 @@ function isColumnCommentDisabled(column: EditableStructureColumn): boolean {
return column.markedForDrop || !structureCapabilities.value.comment; return column.markedForDrop || !structureCapabilities.value.comment;
} }
function isPrimaryKeyDisabled(column: EditableStructureColumn): boolean {
if (column.markedForDrop) return true;
if (!column.original) return false;
return !structureCapabilities.value.alterPrimaryKey;
}
function canDropColumn(column: EditableStructureColumn): boolean { function canDropColumn(column: EditableStructureColumn): boolean {
return !!column.original && !column.isPrimaryKey && structureCapabilities.value.dropColumn; return !!column.original && !column.isPrimaryKey && structureCapabilities.value.dropColumn;
} }
@@ -371,7 +377,7 @@ async function applyChanges() {
if (isCreateMode.value) { if (isCreateMode.value) {
open.value = false; open.value = false;
} else { } else {
await loadStructure(); await loadStructure(true);
} }
} catch (e: any) { } catch (e: any) {
errorMessage.value = e?.message || String(e); errorMessage.value = e?.message || String(e);
@@ -503,6 +509,9 @@ watch(
<th class="w-16 whitespace-nowrap border-b border-r px-1.5 py-1.5 text-left"> <th class="w-16 whitespace-nowrap border-b border-r px-1.5 py-1.5 text-left">
{{ t("structureEditor.nullable") }} {{ t("structureEditor.nullable") }}
</th> </th>
<th class="w-14 whitespace-nowrap border-b border-r px-1.5 py-1.5 text-center">
{{ t("structureEditor.primaryKey") }}
</th>
<th class="min-w-28 border-b border-r px-1.5 py-1.5 text-left"> <th class="min-w-28 border-b border-r px-1.5 py-1.5 text-left">
{{ t("structureEditor.defaultValue") }} {{ t("structureEditor.defaultValue") }}
</th> </th>
@@ -551,6 +560,15 @@ watch(
<span>{{ column.isNullable ? t("structureEditor.yes") : t("structureEditor.no") }}</span> <span>{{ column.isNullable ? t("structureEditor.yes") : t("structureEditor.no") }}</span>
</label> </label>
</td> </td>
<td class="border-b border-r px-1.5 py-1 text-center">
<input
v-model="column.isPrimaryKey"
type="checkbox"
class="h-3.5 w-3.5"
:disabled="isPrimaryKeyDisabled(column)"
@change="() => { if (column.isPrimaryKey) column.isNullable = false; }"
/>
</td>
<td class="border-b border-r px-1.5 py-1"> <td class="border-b border-r px-1.5 py-1">
<Input <Input
v-model="column.defaultValue" v-model="column.defaultValue"
@@ -874,7 +892,13 @@ watch(
<div class="flex min-w-0 flex-col rounded-md border"> <div class="flex min-w-0 flex-col rounded-md border">
<div class="flex items-center justify-between border-b px-2 py-1.5 text-[11px] font-medium"> <div class="flex items-center justify-between border-b px-2 py-1.5 text-[11px] font-medium">
<div class="flex items-center gap-1.5">
<span>{{ t("structureEditor.sqlPreview") }}</span> <span>{{ t("structureEditor.sqlPreview") }}</span>
<Badge v-if="!saving && pendingStatements.length && warnings.length === 0" variant="outline" class="h-4 px-1 text-[10px]">
<Check class="h-3 w-3" />
{{ t("structureEditor.ready") }}
</Badge>
</div>
<Badge variant="secondary"> <Badge variant="secondary">
<Loader2 v-if="sqlPreviewLoading" class="h-3 w-3 animate-spin" /> <Loader2 v-if="sqlPreviewLoading" class="h-3 w-3 animate-spin" />
<span v-else>{{ pendingStatements.length }}</span> <span v-else>{{ pendingStatements.length }}</span>
@@ -921,10 +945,6 @@ watch(
<Save v-else class="mr-1.5 h-3.5 w-3.5" /> <Save v-else class="mr-1.5 h-3.5 w-3.5" />
{{ t("structureEditor.apply") }} {{ t("structureEditor.apply") }}
</Button> </Button>
<Badge v-if="!saving && pendingStatements.length && warnings.length === 0" variant="outline" class="h-8">
<Check class="h-3.5 w-3.5" />
{{ t("structureEditor.ready") }}
</Badge>
</DialogFooter> </DialogFooter>
</DialogScrollContent> </DialogScrollContent>
</Dialog> </Dialog>
+1
View File
@@ -888,6 +888,7 @@ export default {
columnName: "Column", columnName: "Column",
dataType: "Type", dataType: "Type",
nullable: "Nullable", nullable: "Nullable",
primaryKey: "Primary Key",
defaultValue: "Default", defaultValue: "Default",
comment: "Comment", comment: "Comment",
editComment: "Edit comment", editComment: "Edit comment",
+1
View File
@@ -785,6 +785,7 @@ export default {
columnName: "Columna", columnName: "Columna",
dataType: "Tipo", dataType: "Tipo",
nullable: "Admite nulos", nullable: "Admite nulos",
primaryKey: "Clave primaria",
defaultValue: "Valor por defecto", defaultValue: "Valor por defecto",
comment: "Comentario", comment: "Comentario",
editComment: "Editar comentario", editComment: "Editar comentario",
+1
View File
@@ -869,6 +869,7 @@ export default {
columnName: "字段名", columnName: "字段名",
dataType: "类型", dataType: "类型",
nullable: "可为空", nullable: "可为空",
primaryKey: "主键",
defaultValue: "默认值", defaultValue: "默认值",
comment: "注释", comment: "注释",
editComment: "编辑注释", editComment: "编辑注释",
+241
View File
@@ -131,6 +131,7 @@ struct TableStructureCapabilities {
index_include: bool, index_include: bool,
index_filter: bool, index_filter: bool,
index_comment: bool, index_comment: bool,
alter_primary_key: bool,
} }
impl Default for TableStructureCapabilities { impl Default for TableStructureCapabilities {
@@ -150,6 +151,7 @@ impl Default for TableStructureCapabilities {
index_include: false, index_include: false,
index_filter: false, index_filter: false,
index_comment: false, index_comment: false,
alter_primary_key: false,
} }
} }
} }
@@ -175,6 +177,7 @@ fn capabilities_for(database_type: Option<DatabaseType>) -> TableStructureCapabi
drop_index: true, drop_index: true,
rebuild_index: true, rebuild_index: true,
index_type: true, index_type: true,
alter_primary_key: true,
..base ..base
}, },
Some( Some(
@@ -198,6 +201,7 @@ fn capabilities_for(database_type: Option<DatabaseType>) -> TableStructureCapabi
index_include: true, index_include: true,
index_filter: true, index_filter: true,
index_comment: true, index_comment: true,
alter_primary_key: true,
..base ..base
}, },
Some(DatabaseType::Redshift) => TableStructureCapabilities { Some(DatabaseType::Redshift) => TableStructureCapabilities {
@@ -469,6 +473,74 @@ fn build_column_sql(options: &TableStructureSqlOptions, warnings: &mut Vec<Strin
} }
} }
// Emit primary key constraint changes after individual column changes
statements.extend(build_primary_key_sql(options, dialect, &table, warnings));
statements
}
fn build_primary_key_sql(
options: &TableStructureSqlOptions,
dialect: StructureDialect,
table: &str,
warnings: &mut Vec<String>,
) -> Vec<String> {
let capabilities = capabilities_for(options.database_type);
let old_pk_names: Vec<&str> = options
.columns
.iter()
.filter(|c| c.original.as_ref().is_some_and(|o| o.is_primary_key))
.map(|c| c.name.as_str())
.collect();
let new_pk_names: Vec<&str> = options
.columns
.iter()
.filter(|c| !c.marked_for_drop && c.is_primary_key)
.map(|c| c.name.as_str())
.collect();
if old_pk_names == new_pk_names {
return Vec::new();
}
if !capabilities.alter_primary_key {
warnings.push(format!(
"Changing primary keys is not supported for {} from this editor.",
database_label(options.database_type)
));
return Vec::new();
}
let mut statements = Vec::new();
if !old_pk_names.is_empty() {
match dialect {
StructureDialect::Postgres => {
let raw_table = options.table_name.split('.').last().unwrap_or(&options.table_name);
let pk_name = format!("{}_pkey", clean(raw_table));
statements.push(format!(
"ALTER TABLE {table} DROP CONSTRAINT {};",
quote_ident(dialect, &pk_name)
));
}
StructureDialect::Mysql => {
statements.push(format!("ALTER TABLE {table} DROP PRIMARY KEY;"));
}
_ => {}
}
}
if !new_pk_names.is_empty() {
let pk_list = new_pk_names
.iter()
.map(|n| quote_ident(dialect, n))
.collect::<Vec<_>>()
.join(", ");
statements.push(format!("ALTER TABLE {table} ADD PRIMARY KEY ({pk_list});"));
}
statements statements
} }
@@ -1492,4 +1564,173 @@ mod tests {
] ]
); );
} }
#[test]
fn builds_postgres_alter_table_add_primary_key() {
let mut id = column("id");
id.data_type = "integer".to_string();
id.is_nullable = false;
id.is_primary_key = true;
id.original = Some(ColumnInfo {
name: "id".to_string(),
data_type: "integer".to_string(),
is_nullable: false,
column_default: None,
is_primary_key: false,
extra: None,
comment: None,
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Postgres),
schema: Some("public".to_string()),
table_name: "users".to_string(),
columns: vec![id],
indexes: Vec::new(),
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec!["ALTER TABLE \"public\".\"users\" ADD PRIMARY KEY (\"id\");"]
);
}
#[test]
fn builds_postgres_alter_table_drop_primary_key() {
let mut id = column("id");
id.data_type = "integer".to_string();
id.is_nullable = false;
id.is_primary_key = false;
id.original = Some(ColumnInfo {
name: "id".to_string(),
data_type: "integer".to_string(),
is_nullable: false,
column_default: None,
is_primary_key: true,
extra: None,
comment: None,
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Postgres),
schema: Some("public".to_string()),
table_name: "users".to_string(),
columns: vec![id],
indexes: Vec::new(),
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec!["ALTER TABLE \"public\".\"users\" DROP CONSTRAINT \"users_pkey\";"]
);
}
#[test]
fn builds_mysql_alter_table_change_primary_key() {
let mut old_pk = column("id");
old_pk.id = "old_id".to_string();
old_pk.data_type = "int".to_string();
old_pk.is_nullable = false;
old_pk.is_primary_key = false;
old_pk.original = Some(ColumnInfo {
name: "id".to_string(),
data_type: "int".to_string(),
is_nullable: false,
column_default: None,
is_primary_key: true,
extra: None,
comment: None,
});
let mut new_pk = column("uuid");
new_pk.id = "new_uuid".to_string();
new_pk.data_type = "varchar(36)".to_string();
new_pk.is_nullable = false;
new_pk.is_primary_key = true;
new_pk.original = Some(ColumnInfo {
name: "uuid".to_string(),
data_type: "varchar(36)".to_string(),
is_nullable: false,
column_default: None,
is_primary_key: false,
extra: None,
comment: None,
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Mysql),
schema: None,
table_name: "users".to_string(),
columns: vec![old_pk, new_pk],
indexes: Vec::new(),
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec![
"ALTER TABLE `users` DROP PRIMARY KEY;",
"ALTER TABLE `users` ADD PRIMARY KEY (`uuid`);",
]
);
}
#[test]
fn builds_no_statements_when_primary_key_unchanged() {
let mut id = column("id");
id.data_type = "integer".to_string();
id.is_nullable = false;
id.is_primary_key = true;
id.original = Some(ColumnInfo {
name: "id".to_string(),
data_type: "integer".to_string(),
is_nullable: false,
column_default: None,
is_primary_key: true,
extra: None,
comment: None,
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Postgres),
schema: None,
table_name: "users".to_string(),
columns: vec![id],
indexes: Vec::new(),
});
assert_eq!(result.warnings, Vec::<String>::new());
assert!(result.statements.is_empty());
}
#[test]
fn warns_sqlite_cannot_alter_primary_key() {
let mut id = column("id");
id.data_type = "integer".to_string();
id.is_nullable = false;
id.is_primary_key = true;
id.original = Some(ColumnInfo {
name: "id".to_string(),
data_type: "integer".to_string(),
is_nullable: false,
column_default: None,
is_primary_key: false,
extra: None,
comment: None,
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Sqlite),
schema: None,
table_name: "users".to_string(),
columns: vec![id],
indexes: Vec::new(),
});
assert_eq!(result.statements, Vec::<String>::new());
assert_eq!(result.warnings.len(), 1);
assert!(result.warnings[0].contains("primary key"));
}
} }