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:
1 parent
479f4623db
commit
72d5435535
5 files changed
+273
-9
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">
|
||||||
<span>{{ t("structureEditor.sqlPreview") }}</span>
|
<div class="flex items-center gap-1.5">
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -869,6 +869,7 @@ export default {
|
|||||||
columnName: "字段名",
|
columnName: "字段名",
|
||||||
dataType: "类型",
|
dataType: "类型",
|
||||||
nullable: "可为空",
|
nullable: "可为空",
|
||||||
|
primaryKey: "主键",
|
||||||
defaultValue: "默认值",
|
defaultValue: "默认值",
|
||||||
comment: "注释",
|
comment: "注释",
|
||||||
editComment: "编辑注释",
|
editComment: "编辑注释",
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user