feat(mysql): edit foreign keys and triggers

This commit is contained in:
t8y2 committed 2026-06-12 12:29:46 +08:00
1 parent 1d2d348dd8
commit a858a6bd18
23 files changed
+838 -22

No files matched your search

@@ -21,12 +21,24 @@ import { useToast } from "@/composables/useToast";
import { type SqlHighlighter, createShikiSqlHighlighter } from "@/lib/sqlHighlighter";
import { copyToClipboard } from "@/lib/clipboard";
import { queryTimeoutSecsForConnection } from "@/lib/queryTimeout";
import { type EditableStructureColumn, type EditableStructureIndex } from "@/lib/tableStructureEditorSql";
import { type EditableStructureColumn, type EditableStructureForeignKey, type EditableStructureIndex, type EditableStructureTrigger } from "@/lib/tableStructureEditorSql";
import { getTableMetadataCapabilities } from "@/lib/tableMetadataCapabilities";
import { getTableStructureCapabilities } from "@/lib/tableStructureCapabilities";
import { connectionObjectTreeQuerySchema, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
import { buildStructureTargetLabel, combineDataTypeForDatabase, createColumnDrafts, createIndexDrafts, generateIndexName, generateUniqueIndexName, getDataTypeOptions, getDefaultLengthForType, splitDataType, toColumnNames } from "@/lib/tableStructureEditorState";
import type { ForeignKeyInfo, TriggerInfo } from "@/types/database";
import {
buildStructureTargetLabel,
combineDataTypeForDatabase,
createColumnDrafts,
createForeignKeyDrafts,
createIndexDrafts,
createTriggerDrafts,
generateIndexName,
generateUniqueIndexName,
getDataTypeOptions,
getDefaultLengthForType,
splitDataType,
toColumnNames,
} from "@/lib/tableStructureEditorState";
import * as api from "@/lib/api";
const { t } = useI18n();
@@ -90,8 +102,8 @@ const columns = ref<EditableStructureColumn[]>([]);
const indexes = ref<EditableStructureIndex[]>([]);
const pendingStatements = ref<string[]>([]);
const warnings = ref<string[]>([]);
const foreignKeys = ref<ForeignKeyInfo[]>([]);
const triggers = ref<TriggerInfo[]>([]);
const foreignKeys = ref<EditableStructureForeignKey[]>([]);
const triggers = ref<EditableStructureTrigger[]>([]);
function isPlainModShortcut(event: KeyboardEvent, key: string): boolean {
if (event.isComposing || event.altKey || event.shiftKey) return false;
@@ -307,6 +319,9 @@ const colLabels = computed(() => {
return labels;
});
const indexColLabels = computed(() => [t("structureEditor.indexName"), t("structureEditor.indexColumns"), t("structureEditor.unique"), t("structureEditor.indexType"), t("structureEditor.includedColumns"), t("structureEditor.filter"), t("structureEditor.comment"), t("structureEditor.actions")]);
const foreignKeyActionOptions = ["", "CASCADE", "SET NULL", "RESTRICT", "NO ACTION"];
const triggerTimingOptions = ["BEFORE", "AFTER"];
const triggerEventOptions = ["INSERT", "UPDATE", "DELETE"];
const metadataSchema = computed(() => connectionObjectTreeQuerySchema(connection.value, props.database, props.schema));
const refreshVersion = computed(() => (props.connectionId && props.tableName ? queryStore.tableStructureRefreshVersion(props.connectionId, props.database, props.schema, props.tableName) : 0));
const isCreateMode = computed(() => !props.tableName);
@@ -327,6 +342,8 @@ async function refreshSqlPreview() {
tableName: isCreateMode.value ? newTableName.value : props.tableName || "",
columns: columns.value,
indexes: indexes.value,
foreignKeys: foreignKeys.value,
triggers: triggers.value,
tableComment: tableComment.value,
originalTableComment: isCreateMode.value ? undefined : originalTableComment.value,
};
@@ -379,8 +396,8 @@ async function loadStructure(silent = false) {
]);
columns.value = createColumnDrafts(nextColumns, databaseType.value);
indexes.value = createIndexDrafts(nextIndexes);
foreignKeys.value = nextForeignKeys;
triggers.value = nextTriggers;
foreignKeys.value = createForeignKeyDrafts(nextForeignKeys);
triggers.value = createTriggerDrafts(nextTriggers);
try {
const tables = await api.listTables(props.connectionId, props.database, metadataSchema.value);
const table = tables.find((t) => t.name.toLowerCase() === props.tableName!.toLowerCase() && t.table_type !== "VIEW");
@@ -583,6 +600,80 @@ function canDropIndex(index: EditableStructureIndex): boolean {
return !!index.original && !index.isPrimary && structureCapabilities.value.dropIndex;
}
const canEditMysqlForeignKeys = computed(() => structureDialect.value === "mysql");
const canEditMysqlTriggers = computed(() => structureDialect.value === "mysql");
function generatedForeignKeyName(column = ""): string {
const table = structureIndexTableName() || "table";
const suffix = column || "column";
const base = `fk_${table}_${suffix}`
.replace(/[^a-zA-Z0-9_]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
const taken = new Set(foreignKeys.value.map((item) => item.name.trim().toLowerCase()).filter(Boolean));
if (!taken.has(base.toLowerCase())) return base;
for (let counter = 2; counter < 10_000; counter++) {
const candidate = `${base}_${counter}`;
if (!taken.has(candidate.toLowerCase())) return candidate;
}
return base;
}
function addForeignKey() {
if (!canEditMysqlForeignKeys.value) return;
activeTab.value = "foreignKeys";
foreignKeys.value.push({
id: `new:${uuid()}`,
name: generatedForeignKeyName(),
column: "",
refSchema: "",
refTable: "",
refColumn: "",
onUpdate: "",
onDelete: "",
markedForDrop: false,
});
}
function removeNewForeignKey(foreignKey: EditableStructureForeignKey) {
foreignKeys.value = foreignKeys.value.filter((item) => item.id !== foreignKey.id);
}
function toggleDropForeignKey(foreignKey: EditableStructureForeignKey) {
if (!foreignKey.original) return;
foreignKey.markedForDrop = !foreignKey.markedForDrop;
}
function canEditForeignKeyDraft(foreignKey: EditableStructureForeignKey): boolean {
return canEditMysqlForeignKeys.value && !foreignKey.markedForDrop;
}
function addTrigger() {
if (!canEditMysqlTriggers.value) return;
activeTab.value = "triggers";
triggers.value.push({
id: `new:${uuid()}`,
name: "",
timing: "BEFORE",
event: "INSERT",
statement: "BEGIN\n \nEND",
markedForDrop: false,
});
}
function removeNewTrigger(trigger: EditableStructureTrigger) {
triggers.value = triggers.value.filter((item) => item.id !== trigger.id);
}
function toggleDropTrigger(trigger: EditableStructureTrigger) {
if (!trigger.original) return;
trigger.markedForDrop = !trigger.markedForDrop;
}
function canEditTriggerDraft(trigger: EditableStructureTrigger): boolean {
return canEditMysqlTriggers.value && !trigger.markedForDrop;
}
function primarySqlOperation(sql: string): string {
const statement = sql
.split(";")
@@ -657,6 +748,14 @@ function addItemForActiveTab(): boolean {
addIndex();
return true;
}
if (activeTab.value === "foreignKeys" && canEditMysqlForeignKeys.value) {
addForeignKey();
return true;
}
if (activeTab.value === "triggers" && canEditMysqlTriggers.value) {
addTrigger();
return true;
}
return false;
}
@@ -721,7 +820,7 @@ watch(tableMetadataCapabilities, (capabilities) => {
});
watch(
[isCreateMode, databaseType, () => props.schema, () => props.tableName, newTableName, tableComment, columns, indexes],
[isCreateMode, databaseType, () => props.schema, () => props.tableName, newTableName, tableComment, columns, indexes, foreignKeys, triggers],
() => {
void refreshSqlPreview();
},
@@ -812,6 +911,14 @@ watch(activeTab, (tab) => {
<Plus :class="structureIconClass" />
{{ t("structureEditor.addIndex") }}
</Button>
<Button v-if="activeTab === 'foreignKeys'" size="sm" :class="structureToolbarButtonClass" :disabled="!canEditMysqlForeignKeys" @click="addForeignKey">
<Plus :class="structureIconClass" />
{{ t("structureEditor.addForeignKey") }}
</Button>
<Button v-if="activeTab === 'triggers'" size="sm" :class="structureToolbarButtonClass" :disabled="!canEditMysqlTriggers" @click="addTrigger">
<Plus :class="structureIconClass" />
{{ t("structureEditor.addTrigger") }}
</Button>
</div>
</div>
@@ -1138,9 +1245,43 @@ watch(activeTab, (tab) => {
{{ t("structureEditor.emptyReadonly") }}
</div>
<div v-else class="space-y-1.5">
<div v-for="fk in foreignKeys" :key="fk.name" class="rounded-md border px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)]">
<div class="font-medium">{{ fk.name }}</div>
<div class="mt-1 font-mono text-muted-foreground">{{ fk.column }} -> {{ fk.ref_table }}.{{ fk.ref_column }}</div>
<div v-for="fk in foreignKeys" :key="fk.id" class="rounded-md border px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)]" :class="fk.markedForDrop ? 'bg-destructive/5 opacity-60' : ''">
<div class="grid grid-cols-[minmax(110px,1fr)_minmax(110px,1fr)_minmax(110px,1fr)_minmax(90px,0.8fr)_minmax(90px,0.8fr)_auto] gap-1.5">
<Input v-model="fk.name" :class="structureControlClass" :placeholder="t('structureEditor.foreignKeyName')" :disabled="!canEditForeignKeyDraft(fk)" />
<Input v-model="fk.column" :class="structureControlClass" :placeholder="t('structureEditor.columnName')" :disabled="!canEditForeignKeyDraft(fk)" />
<Input v-model="fk.refTable" :class="structureControlClass" :placeholder="t('structureEditor.referencedTable')" :disabled="!canEditForeignKeyDraft(fk)" />
<Input v-model="fk.refColumn" :class="structureControlClass" :placeholder="t('structureEditor.referencedColumn')" :disabled="!canEditForeignKeyDraft(fk)" />
<Input v-model="fk.refSchema" :class="structureControlClass" :placeholder="t('structureEditor.referencedSchema')" :disabled="!canEditForeignKeyDraft(fk)" />
<div class="flex items-center justify-end gap-1">
<Button v-if="fk.original" variant="ghost" size="sm" :class="structureToolbarButtonClass" @click="toggleDropForeignKey(fk)">
<Trash2 :class="structureIconClass" />
{{ fk.markedForDrop ? t("structureEditor.restore") : t("structureEditor.drop") }}
</Button>
<Button v-else variant="ghost" size="sm" :class="structureToolbarButtonClass" @click="removeNewForeignKey(fk)">
<X :class="structureIconClass" />
{{ t("structureEditor.remove") }}
</Button>
</div>
</div>
<div class="mt-1.5 grid grid-cols-[minmax(110px,0.5fr)_minmax(110px,0.5fr)_1fr] gap-1.5">
<Select :model-value="fk.onDelete || '__default'" :disabled="!canEditForeignKeyDraft(fk)" @update:model-value="(v: any) => (fk.onDelete = String(v === '__default' ? '' : (v ?? '')))">
<SelectTrigger class="h-[var(--structure-control-height)] rounded-md px-[var(--structure-control-px)] text-[length:var(--structure-font-size)]">
<SelectValue :placeholder="t('structureEditor.onDelete')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="action in foreignKeyActionOptions" :key="`delete-${action || 'default'}`" :value="action || '__default'">{{ action || t("structureEditor.defaultAction") }}</SelectItem>
</SelectContent>
</Select>
<Select :model-value="fk.onUpdate || '__default'" :disabled="!canEditForeignKeyDraft(fk)" @update:model-value="(v: any) => (fk.onUpdate = String(v === '__default' ? '' : (v ?? '')))">
<SelectTrigger class="h-[var(--structure-control-height)] rounded-md px-[var(--structure-control-px)] text-[length:var(--structure-font-size)]">
<SelectValue :placeholder="t('structureEditor.onUpdate')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="action in foreignKeyActionOptions" :key="`update-${action || 'default'}`" :value="action || '__default'">{{ action || t("structureEditor.defaultAction") }}</SelectItem>
</SelectContent>
</Select>
<div class="truncate font-mono text-muted-foreground">{{ fk.column }} -> {{ fk.refSchema ? `${fk.refSchema}.` : "" }}{{ fk.refTable }}.{{ fk.refColumn }}</div>
</div>
</div>
</div>
</TabsContent>
@@ -1150,9 +1291,42 @@ watch(activeTab, (tab) => {
{{ t("structureEditor.emptyReadonly") }}
</div>
<div v-else class="space-y-1.5">
<div v-for="trigger in triggers" :key="trigger.name" class="rounded-md border px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)]">
<div class="font-medium">{{ trigger.name }}</div>
<div class="mt-1 font-mono text-muted-foreground">{{ trigger.timing }} {{ trigger.event }}</div>
<div v-for="trigger in triggers" :key="trigger.id" class="rounded-md border px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)]" :class="trigger.markedForDrop ? 'bg-destructive/5 opacity-60' : ''">
<div class="grid grid-cols-[minmax(140px,1fr)_110px_110px_auto] gap-1.5">
<Input v-model="trigger.name" :class="structureControlClass" :placeholder="t('structureEditor.triggerName')" :disabled="!canEditTriggerDraft(trigger)" />
<Select v-model="trigger.timing" :disabled="!canEditTriggerDraft(trigger)">
<SelectTrigger class="h-[var(--structure-control-height)] rounded-md px-[var(--structure-control-px)] text-[length:var(--structure-font-size)]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="timing in triggerTimingOptions" :key="timing" :value="timing">{{ timing }}</SelectItem>
</SelectContent>
</Select>
<Select v-model="trigger.event" :disabled="!canEditTriggerDraft(trigger)">
<SelectTrigger class="h-[var(--structure-control-height)] rounded-md px-[var(--structure-control-px)] text-[length:var(--structure-font-size)]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="event in triggerEventOptions" :key="event" :value="event">{{ event }}</SelectItem>
</SelectContent>
</Select>
<div class="flex items-center justify-end gap-1">
<Button v-if="trigger.original" variant="ghost" size="sm" :class="structureToolbarButtonClass" @click="toggleDropTrigger(trigger)">
<Trash2 :class="structureIconClass" />
{{ trigger.markedForDrop ? t("structureEditor.restore") : t("structureEditor.drop") }}
</Button>
<Button v-else variant="ghost" size="sm" :class="structureToolbarButtonClass" @click="removeNewTrigger(trigger)">
<X :class="structureIconClass" />
{{ t("structureEditor.remove") }}
</Button>
</div>
</div>
<textarea
v-model="trigger.statement"
class="mt-1.5 min-h-28 w-full resize-y rounded-md border bg-background px-[var(--structure-control-px)] py-[var(--structure-cell-py)] font-mono text-[length:var(--structure-font-size)] leading-5 outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50"
:placeholder="t('structureEditor.triggerStatement')"
:disabled="!canEditTriggerDraft(trigger)"
/>
</div>
</div>
</TabsContent>