feat(diagram): enhance ER diagram editing and exports

This commit is contained in:
CGerAJ 2026-08-03 16:05:57 +08:00 committed by GitHub
parent 9fda1d690a
commit 212cf1da7e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
92 changed files with 12231 additions and 943 deletions

View File

@ -0,0 +1,90 @@
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { DiagramLayer } from "@/types/diagram";
const { t } = useI18n();
const props = defineProps<{
open: boolean;
layers: DiagramLayer[];
activeLayerId: string | null;
existingNames: string[];
}>();
const emit = defineEmits<{
(e: "update:open", value: boolean): void;
(e: "create", payload: { name: string; layerId: string | null; withDefaultId: boolean }): void;
}>();
const openModel = computed({
get: () => props.open,
set: (v: boolean) => emit("update:open", v),
});
const name = ref("");
const layerId = ref<string | "">("");
const withDefaultId = ref(true);
const error = ref("");
watch(
() => props.open,
(open) => {
if (open) {
name.value = "";
layerId.value = props.activeLayerId || "";
withDefaultId.value = true;
error.value = "";
}
},
);
function submit() {
const trimmed = name.value.trim();
if (!trimmed) {
error.value = t("diagram.tableNameRequired");
return;
}
if (props.existingNames.some((n) => n.toLowerCase() === trimmed.toLowerCase())) {
error.value = t("diagram.tableNameExists");
return;
}
emit("create", { name: trimmed, layerId: layerId.value || null, withDefaultId: withDefaultId.value });
openModel.value = false;
}
</script>
<template>
<Dialog v-model:open="openModel">
<DialogContent class="max-w-md gap-3">
<DialogHeader>
<DialogTitle>{{ t("diagram.createTable") }}</DialogTitle>
</DialogHeader>
<div class="space-y-3">
<div class="space-y-1">
<label class="text-xs text-muted-foreground">{{ t("diagram.tableName") }}</label>
<Input v-model="name" class="h-8 text-xs" @keydown.enter="submit" />
</div>
<div class="space-y-1">
<label class="text-xs text-muted-foreground">{{ t("diagram.assignLayer") }}</label>
<select v-model="layerId" class="h-8 w-full rounded border border-border bg-background px-2 text-xs">
<option value="">{{ t("diagram.noLayer") }}</option>
<option v-for="layer in layers" :key="layer.id" :value="layer.id">{{ layer.name }}</option>
</select>
</div>
<label class="flex items-center gap-2 text-xs">
<input v-model="withDefaultId" type="checkbox" />
{{ t("diagram.withDefaultIdPk") }}
</label>
<p v-if="error" class="text-xs text-destructive">{{ error }}</p>
</div>
<DialogFooter>
<Button type="button" variant="ghost" size="sm" @click="openModel = false">{{ t("common.cancel") }}</Button>
<Button type="button" size="sm" @click="submit">{{ t("diagram.create") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,721 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Plus, Trash2, X, KeyRound } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { SearchableSelect } from "@/components/ui/searchable-select";
import type { DiagramTable, DiagramRelationship, CustomDiagramRelationship } from "@/lib/diagram/erDiagram";
import { editableStructureIndexes, hasDroppedColumns, hasPendingColumns, isDraftTable, isDroppedColumn, isPendingColumn } from "@/lib/diagram/erDiagram";
import type { InferredRelationship } from "@/types/diagram";
import type { InspectorTarget } from "@/types/diagram";
import type { ColumnInfo, DatabaseType } from "@/types/database";
import type { EditableStructureIndex } from "@/lib/table/tableStructureEditorSql";
import { createDraftIndex, nextUniqueColumnName } from "@/lib/diagram/draft-table";
import { resolveDiagramDialectAdapter } from "@/lib/diagram/diagram-dialect-adapter";
import { cardinalityChoiceFromPair, cardinalityPairFromChoice, edgeCardinalityPair, type CardinalityChoice } from "@/lib/diagram/cardinality";
import { canAddTableStructureColumn, getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities";
import { combineDataTypeForDatabase, combineDataTypeForDatabaseWithLengthUnit, dataTypeLengthInputValue, dataTypeLengthUnitValue, getDataTypeLengthUnitOptions, getDataTypeOptions, getDefaultLengthForType, isDataTypeLengthDisabled, splitDataType } from "@/lib/table/tableStructureEditorState";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const { t } = useI18n();
const props = defineProps<{
target: InspectorTarget;
tables: DiagramTable[];
relationships: (DiagramRelationship | InferredRelationship | CustomDiagramRelationship)[];
databaseType?: DatabaseType;
}>();
const emit = defineEmits<{
(e: "close"): void;
(e: "update-table", table: DiagramTable): void;
(e: "delete-draft-table", tableName: string): void;
(e: "delete-live-table", tableName: string): void;
(e: "update-relationship", payload: { id: string; patch: Partial<CustomDiagramRelationship> }): void;
(e: "remove-relationship", id: string): void;
(e: "save-relationship", payload: Omit<CustomDiagramRelationship, "id"> & { id?: string }): void;
(e: "confirm-relationship", payload: { id: string; sourceCardinality: "1" | "N"; targetCardinality: "1" | "N" }): void;
(e: "ignore-relationship", id: string): void;
}>();
type InspectorTab = "fields" | "indexes";
const tableTab = ref<InspectorTab>("fields");
const confirmingDelete = ref(false);
const confirmingDropTable = ref(false);
const relationshipDraft = reactive({
sourceTable: "",
sourceColumn: "",
targetTable: "",
targetColumn: "",
cardinality: "many-to-one" as CardinalityChoice,
});
const adapter = computed(() => resolveDiagramDialectAdapter(props.databaseType));
const databaseType = computed(() => props.databaseType);
const structureCapabilities = computed(() => getTableStructureCapabilities(props.databaseType));
const selectedTable = computed(() => {
const target = props.target;
if (target?.kind !== "table") return null;
return props.tables.find((t) => t.name === target.tableName) ?? null;
});
const selectedEdge = computed(() => {
const target = props.target;
if (target?.kind !== "edge") return null;
return props.relationships.find((r) => r.id === target.edgeId) ?? null;
});
const relationshipKind = computed(() => {
const rel = selectedEdge.value;
if (!rel) return "unknown";
if ("kind" in rel) return rel.kind;
return "inferred";
});
const relationshipEditable = computed(() => relationshipKind.value === "custom" || relationshipKind.value === "inferred");
const tableMap = computed(() => new Map(props.tables.map((table) => [table.name, table])));
const sourceColumns = computed(() => tableMap.value.get(relationshipDraft.sourceTable)?.columns ?? []);
const targetColumns = computed(() => tableMap.value.get(relationshipDraft.targetTable)?.columns ?? []);
/** Full draft table edit (rename, indexes, delete draft). */
const editable = computed(() => selectedTable.value != null && isDraftTable(selectedTable.value));
const isLive = computed(() => selectedTable.value != null && !isDraftTable(selectedTable.value));
const canAddField = computed(() => {
if (!selectedTable.value) return false;
return canAddTableStructureColumn(props.databaseType, isDraftTable(selectedTable.value));
});
const showLivePendingHint = computed(() => isLive.value && selectedTable.value != null && canAddField.value);
const canDropLiveColumn = computed(() => isLive.value && structureCapabilities.value.dropColumn);
const canDropLiveTable = computed(() => isLive.value && structureCapabilities.value.createTable);
const supportsCreateIndex = computed(() => structureCapabilities.value.createIndex);
const supportsComment = computed(() => structureCapabilities.value.comment);
function isColumnEditable(columnName: string): boolean {
const table = selectedTable.value;
if (!table) return false;
if (isDroppedColumn(table, columnName)) return false;
if (isDraftTable(table)) return true;
return isPendingColumn(table, columnName);
}
function canRemoveColumn(columnName: string): boolean {
const table = selectedTable.value;
if (!table) return false;
if (isDraftTable(table)) return true;
if (isPendingColumn(table, columnName)) return true;
return canDropLiveColumn.value;
}
const tableIndexes = computed(() => editableStructureIndexes(selectedTable.value ?? { name: "", columns: [], foreignKeys: [] }).filter((index) => !index.markedForDrop));
function syncRelationshipDraft() {
const rel = selectedEdge.value;
if (!rel) return;
relationshipDraft.sourceTable = rel.sourceTable;
relationshipDraft.sourceColumn = rel.sourceColumn;
relationshipDraft.targetTable = rel.targetTable;
relationshipDraft.targetColumn = rel.targetColumn;
relationshipDraft.cardinality = cardinalityChoiceFromPair("sourceCardinality" in rel && "targetCardinality" in rel ? { sourceCardinality: rel.sourceCardinality, targetCardinality: rel.targetCardinality } : undefined);
confirmingDelete.value = false;
}
watch(
() => props.target,
() => {
tableTab.value = "fields";
confirmingDropTable.value = false;
},
);
watch(selectedEdge, syncRelationshipDraft, { immediate: true });
watch(
() => relationshipDraft.sourceTable,
() => {
if (!sourceColumns.value.some((c) => c.name === relationshipDraft.sourceColumn)) {
relationshipDraft.sourceColumn = sourceColumns.value[0]?.name ?? "";
}
},
);
watch(
() => relationshipDraft.targetTable,
() => {
if (!targetColumns.value.some((c) => c.name === relationshipDraft.targetColumn)) {
relationshipDraft.targetColumn = targetColumns.value[0]?.name ?? "";
}
},
);
function patchTable(mutator: (table: DiagramTable) => void, options?: { requireDraft?: boolean }) {
const table = selectedTable.value;
if (!table) return;
if (options?.requireDraft && !isDraftTable(table)) return;
const next: DiagramTable = {
...table,
columns: table.columns.map((c) => ({ ...c })),
foreignKeys: [...table.foreignKeys],
indexes: editableStructureIndexes(table).map((index) => ({
...index,
columns: [...index.columns],
includedColumns: [...index.includedColumns],
})),
pendingColumnNames: table.pendingColumnNames ? [...table.pendingColumnNames] : undefined,
droppedColumnNames: table.droppedColumnNames ? [...table.droppedColumnNames] : undefined,
pendingDrop: table.pendingDrop,
};
mutator(next);
emit("update-table", next);
}
function renameTable(name: string) {
patchTable(
(table) => {
table.name = name.trim() || table.name;
},
{ requireDraft: true },
);
}
function updateColumn(index: number, patch: Partial<ColumnInfo>) {
const table = selectedTable.value;
if (!table) return;
const col = table.columns[index];
if (!col || !isColumnEditable(col.name)) return;
patchTable((next) => {
const target = next.columns[index];
if (!target) return;
const oldName = target.name;
Object.assign(target, patch);
if (patch.name !== undefined && next.pendingColumnNames) {
const idx = next.pendingColumnNames.indexOf(oldName);
if (idx >= 0) next.pendingColumnNames[idx] = String(patch.name);
}
});
}
function addColumn() {
if (!canAddField.value) return;
patchTable((table) => {
const name = nextUniqueColumnName(table.columns);
table.columns.push(adapter.value.createEmptyColumn(name));
if (!isDraftTable(table)) {
table.pendingColumnNames = [...(table.pendingColumnNames ?? []), name];
}
});
}
function removeColumn(index: number) {
const table = selectedTable.value;
if (!table) return;
const removed = table.columns[index]?.name;
if (!removed || !canRemoveColumn(removed)) return;
// Pending add / draft: hard delete (no DB impact yet).
if (isDraftTable(table) || isPendingColumn(table, removed)) {
patchTable((next) => {
next.columns.splice(index, 1);
if (next.pendingColumnNames) {
next.pendingColumnNames = next.pendingColumnNames.filter((name) => name !== removed);
if (next.pendingColumnNames.length === 0) delete next.pendingColumnNames;
}
if (!next.indexes) return;
for (const idx of next.indexes) {
idx.columns = idx.columns.filter((col) => col !== removed);
}
});
return;
}
// Live existing column: soft-mark for DROP COLUMN (toggle).
patchTable((next) => {
const dropped = new Set(next.droppedColumnNames ?? []);
if (dropped.has(removed)) {
dropped.delete(removed);
} else {
dropped.add(removed);
}
next.droppedColumnNames = dropped.size ? [...dropped] : undefined;
});
}
function requestDeleteLiveTable() {
confirmingDropTable.value = true;
}
function confirmDeleteLiveTable() {
const table = selectedTable.value;
if (!table || isDraftTable(table)) return;
confirmingDropTable.value = false;
emit("delete-live-table", table.name);
}
function cancelDeleteLiveTable() {
confirmingDropTable.value = false;
}
function addIndex() {
if (!supportsCreateIndex.value) return;
patchTable(
(table) => {
const existing = editableStructureIndexes(table);
const firstCol = table.columns[0]?.name;
table.indexes = [...existing, createDraftIndex(table.name, firstCol ? [firstCol] : [], existing)];
},
{ requireDraft: true },
);
}
function updateIndex(indexId: string, patch: Partial<EditableStructureIndex>) {
patchTable(
(table) => {
const index = editableStructureIndexes(table).find((item) => item.id === indexId);
if (!index) return;
Object.assign(index, patch);
table.indexes = editableStructureIndexes(table).map((item) => (item.id === indexId ? { ...item, ...patch } : item));
},
{ requireDraft: true },
);
}
function toggleIndexColumn(indexId: string, columnName: string) {
patchTable(
(table) => {
table.indexes = editableStructureIndexes(table).map((index) => {
if (index.id !== indexId) return index;
if (index.columns.includes(columnName)) {
return { ...index, columns: index.columns.filter((col) => col !== columnName) };
}
return { ...index, columns: [...index.columns, columnName] };
});
},
{ requireDraft: true },
);
}
function removeIndex(indexId: string) {
patchTable(
(table) => {
table.indexes = editableStructureIndexes(table).filter((index) => index.id !== indexId);
},
{ requireDraft: true },
);
}
const draftCardinality = computed(() => cardinalityPairFromChoice(relationshipDraft.cardinality));
function directedCardinalityLabel(source: "1" | "N", target: "1" | "N"): string {
return t("diagram.cardinalityDirected", { source, target });
}
const edgeDirectedCardinalityLabel = computed(() => {
const rel = selectedEdge.value;
if (!rel) return directedCardinalityLabel("N", "1");
if (relationshipEditable.value) {
const pair = draftCardinality.value;
return directedCardinalityLabel(pair.sourceCardinality, pair.targetCardinality);
}
const pair = edgeCardinalityPair(rel);
return directedCardinalityLabel(pair.sourceCardinality, pair.targetCardinality);
});
function handleSaveRelationship() {
const rel = selectedEdge.value;
if (!rel) return;
const card = cardinalityPairFromChoice(relationshipDraft.cardinality);
emit("save-relationship", {
id: "kind" in rel && rel.kind === "custom" ? rel.id : undefined,
name: "name" in rel ? rel.name : `${relationshipDraft.sourceTable}_${relationshipDraft.sourceColumn}_${relationshipDraft.targetTable}_${relationshipDraft.targetColumn}`,
sourceTable: relationshipDraft.sourceTable,
sourceColumn: relationshipDraft.sourceColumn,
targetTable: relationshipDraft.targetTable,
targetColumn: relationshipDraft.targetColumn,
...card,
});
}
function handleConfirmRelationship() {
const rel = selectedEdge.value;
if (!rel) return;
emit("confirm-relationship", { id: rel.id, ...cardinalityPairFromChoice(relationshipDraft.cardinality) });
}
function handleIgnoreRelationship() {
const rel = selectedEdge.value;
if (!rel) return;
emit("ignore-relationship", rel.id);
}
function confirmDeleteRelationship() {
const rel = selectedEdge.value;
if (!rel) return;
confirmingDelete.value = false;
emit("remove-relationship", rel.id);
}
function columnBaseType(dataType: string): string {
return splitDataType(dataType).baseType;
}
function columnLengthEnabled(dataType: string): boolean {
return !isDataTypeLengthDisabled(databaseType.value, columnBaseType(dataType));
}
function columnLengthUnitOptions(dataType: string): readonly string[] {
return getDataTypeLengthUnitOptions(databaseType.value, dataType);
}
function updateColumnBaseType(index: number, baseType: string) {
const next = combineDataTypeForDatabase(databaseType.value, baseType, getDefaultLengthForType(databaseType.value, baseType));
updateColumn(index, { data_type: next });
}
function updateColumnLength(index: number, value: string | number) {
const table = selectedTable.value;
const col = table?.columns[index];
if (!col) return;
const baseType = columnBaseType(col.data_type);
const next = combineDataTypeForDatabaseWithLengthUnit(databaseType.value, baseType, String(value), dataTypeLengthUnitValue(databaseType.value, col.data_type));
updateColumn(index, { data_type: next });
}
function updateColumnLengthUnit(index: number, value: unknown) {
const table = selectedTable.value;
const col = table?.columns[index];
if (!col) return;
const unit = value === "__default" || value == null ? "" : String(value);
const baseType = columnBaseType(col.data_type);
const next = combineDataTypeForDatabaseWithLengthUnit(databaseType.value, baseType, dataTypeLengthInputValue(databaseType.value, col.data_type), unit);
updateColumn(index, { data_type: next });
}
const dataTypeOptionsForColumn = computed(() => {
const options = getDataTypeOptions(props.databaseType);
const table = selectedTable.value;
if (!table) return options;
const seen = new Set(options.map((type) => type.toLowerCase()));
const extras: string[] = [];
for (const col of table.columns) {
const base = columnBaseType(col.data_type);
if (!base || seen.has(base.toLowerCase())) continue;
seen.add(base.toLowerCase());
extras.push(base);
}
return extras.length ? [...options, ...extras] : options;
});
</script>
<template>
<div class="flex h-full min-w-0 flex-col overflow-hidden">
<div class="flex items-center justify-between gap-2 border-b border-border px-3 py-2 shrink-0">
<div class="flex min-w-0 flex-1 items-center gap-2">
<h3 class="min-w-0 truncate text-sm font-semibold text-foreground">
<template v-if="selectedTable">{{ t("diagram.inspectorTable") }} · {{ selectedTable.name }}</template>
<template v-else-if="selectedEdge">{{ t("diagram.inspectorRelationship") }}</template>
</h3>
<Badge v-if="selectedTable && editable" variant="outline" class="h-5 shrink-0 text-[10px]">Draft</Badge>
<Badge v-else-if="selectedTable && selectedTable.pendingDrop" variant="destructive" class="h-5 shrink-0 text-[10px]">{{ t("diagram.pendingDropTableBadge") }}</Badge>
<Badge v-else-if="selectedTable && (hasPendingColumns(selectedTable) || hasDroppedColumns(selectedTable))" variant="outline" class="h-5 shrink-0 text-[10px]">{{ t("diagram.pendingColumnsBadge") }}</Badge>
</div>
<button type="button" class="p-1.5 rounded-md hover:bg-muted transition-colors" @click="emit('close')">
<X class="h-4 w-4 text-muted-foreground" />
</button>
</div>
<div class="flex-1 overflow-y-auto p-3 space-y-3">
<template v-if="selectedTable">
<div class="space-y-1">
<label class="text-[10px] text-muted-foreground">{{ t("diagram.tableName") }}</label>
<Input class="h-8 text-xs" :model-value="selectedTable.name" :disabled="!editable" @update:model-value="(v: string | number) => renameTable(String(v))" />
</div>
<div class="flex border-b border-border">
<button type="button" class="flex-1 px-1 py-1.5 text-[10px] transition-colors" :class="tableTab === 'fields' ? 'border-b-2 border-primary text-primary font-medium' : 'text-muted-foreground hover:text-foreground'" @click="tableTab = 'fields'">
{{ t("diagram.tabFields") }}
</button>
<button type="button" class="flex-1 px-1 py-1.5 text-[10px] transition-colors" :class="tableTab === 'indexes' ? 'border-b-2 border-primary text-primary font-medium' : 'text-muted-foreground hover:text-foreground'" @click="tableTab = 'indexes'">
{{ t("diagram.tabIndexes") }}
</button>
</div>
<template v-if="tableTab === 'fields'">
<div class="flex items-center justify-between">
<span class="text-xs font-medium">{{ t("diagram.fields") }}</span>
<Button v-if="canAddField" type="button" size="sm" variant="outline" class="h-7 text-[11px]" @click="addColumn">
<Plus class="mr-1 h-3 w-3" />
{{ t("diagram.addField") }}
</Button>
</div>
<p v-if="showLivePendingHint" class="text-[11px] text-muted-foreground">{{ t("diagram.liveTableAddColumnsHint") }}</p>
<div class="space-y-2">
<div v-for="(col, index) in selectedTable.columns" :key="`field-${index}`" class="rounded border p-2 space-y-1.5" :class="isDroppedColumn(selectedTable, col.name) ? 'border-destructive/50 bg-destructive/5 opacity-70' : 'border-border/80'">
<div class="flex items-center gap-1">
<KeyRound v-if="col.is_primary_key" class="h-3 w-3 shrink-0 text-amber-500" />
<Input class="h-7 flex-1 font-mono text-[11px]" :class="isDroppedColumn(selectedTable, col.name) ? 'line-through' : ''" :model-value="col.name" :disabled="!isColumnEditable(col.name)" @update:model-value="(v: string | number) => updateColumn(index, { name: String(v) })" />
<Badge v-if="isDroppedColumn(selectedTable, col.name)" variant="outline" class="h-5 shrink-0 text-[10px] text-destructive">{{ t("diagram.pendingDropColumnBadge") }}</Badge>
<button v-if="canRemoveColumn(col.name)" type="button" class="rounded p-1 hover:bg-muted" :title="isDroppedColumn(selectedTable, col.name) ? t('diagram.undoDropField') : t('diagram.deleteField')" @click="removeColumn(index)">
<Trash2 class="h-3 w-3 text-red-500" />
</button>
</div>
<div class="flex flex-wrap items-center gap-2">
<SearchableSelect
class="min-w-0 flex-1 basis-[7rem]"
:model-value="columnBaseType(col.data_type)"
:options="dataTypeOptionsForColumn"
:placeholder="t('structureEditor.typePlaceholder')"
:search-placeholder="t('structureEditor.typePlaceholder')"
:empty-text="t('structureEditor.noMatchingType')"
:allow-custom="true"
:disabled="!isColumnEditable(col.name)"
:trigger-class="['h-7 w-full justify-between px-2 text-[11px] font-mono']"
@update:model-value="(v: string) => updateColumnBaseType(index, v)"
/>
<div v-if="columnLengthEnabled(col.data_type)" class="flex min-w-0 items-center gap-1">
<Input
class="h-7 w-16 shrink-0 font-mono text-[11px]"
:model-value="dataTypeLengthInputValue(databaseType, col.data_type)"
:disabled="!isColumnEditable(col.name)"
:placeholder="t('structureEditor.length')"
@update:model-value="(v: string | number) => updateColumnLength(index, v)"
/>
<Select v-if="columnLengthUnitOptions(col.data_type).length" :model-value="dataTypeLengthUnitValue(databaseType, col.data_type) || '__default'" :disabled="!isColumnEditable(col.name)" @update:model-value="(v: unknown) => updateColumnLengthUnit(index, v)">
<SelectTrigger class="h-7 w-14 shrink-0 px-1 text-[10px] font-mono" :aria-label="t('structureEditor.lengthUnit')" :title="t('structureEditor.lengthUnit')">
<SelectValue :placeholder="t('structureEditor.unitPlaceholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default">{{ t("structureEditor.defaultAction") }}</SelectItem>
<SelectItem v-for="unit in columnLengthUnitOptions(col.data_type)" :key="unit" :value="unit">{{ unit }}</SelectItem>
</SelectContent>
</Select>
</div>
<label class="flex items-center gap-1 text-[10px] text-muted-foreground">
<input type="checkbox" :checked="col.is_primary_key" :disabled="!isColumnEditable(col.name)" @change="updateColumn(index, { is_primary_key: ($event.target as HTMLInputElement).checked })" />
PK
</label>
<label class="flex items-center gap-1 text-[10px] text-muted-foreground">
<input type="checkbox" :checked="col.is_nullable" :disabled="!isColumnEditable(col.name)" @change="updateColumn(index, { is_nullable: ($event.target as HTMLInputElement).checked })" />
NULL
</label>
</div>
<Input v-if="supportsComment" class="h-7 text-[11px]" :model-value="col.comment ?? ''" :disabled="!isColumnEditable(col.name)" :placeholder="t('diagram.fieldComment')" @update:model-value="(v: string | number) => updateColumn(index, { comment: String(v) })" />
</div>
<p v-if="selectedTable.columns.length === 0" class="text-[11px] text-muted-foreground">{{ t("diagram.noFieldsYet") }}</p>
<p v-else-if="!canAddField && isLive" class="text-[11px] text-muted-foreground">{{ t("diagram.addColumnNotSupported") }}</p>
</div>
</template>
<template v-else>
<div v-if="!supportsCreateIndex" class="text-[11px] text-muted-foreground">
{{ t("diagram.indexesNotSupported") }}
</div>
<template v-else>
<div class="flex items-center justify-between">
<span class="text-xs font-medium">{{ t("diagram.tabIndexes") }}</span>
<Button v-if="editable" type="button" size="sm" variant="outline" class="h-7 text-[11px]" @click="addIndex">
<Plus class="mr-1 h-3 w-3" />
{{ t("diagram.addIndex") }}
</Button>
</div>
<div v-if="!editable" class="text-[11px] text-muted-foreground">{{ t("diagram.liveTableIndexesReadOnly") }}</div>
<div v-else class="space-y-2">
<div v-for="index in tableIndexes" :key="index.id" class="rounded border border-border/80 p-2 space-y-1.5">
<div class="flex items-center gap-1">
<Input class="h-7 flex-1 font-mono text-[11px]" :model-value="index.name" :placeholder="t('diagram.indexName')" @update:model-value="(v: string | number) => updateIndex(index.id, { name: String(v) })" />
<button type="button" class="rounded p-1 hover:bg-muted" :title="t('diagram.deleteField')" @click="removeIndex(index.id)">
<Trash2 class="h-3 w-3 text-red-500" />
</button>
</div>
<label class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<input type="checkbox" :checked="index.isUnique" @change="updateIndex(index.id, { isUnique: ($event.target as HTMLInputElement).checked })" />
{{ t("diagram.indexUnique") }}
</label>
<div class="space-y-1">
<div class="text-[10px] text-muted-foreground">{{ t("diagram.indexColumns") }}</div>
<div v-if="selectedTable.columns.length === 0" class="text-[10px] text-muted-foreground">{{ t("diagram.noFieldsYet") }}</div>
<div v-else class="max-h-32 space-y-0.5 overflow-y-auto rounded border border-border/60 p-1.5">
<label v-for="col in selectedTable.columns" :key="`${index.id}-${col.name}`" class="flex items-center gap-1.5 rounded px-1 py-0.5 text-[11px] hover:bg-muted/50">
<input type="checkbox" :checked="index.columns.includes(col.name)" @change="toggleIndexColumn(index.id, col.name)" />
<span class="font-mono truncate">{{ col.name }}</span>
</label>
</div>
</div>
</div>
<p v-if="tableIndexes.length === 0" class="text-[11px] text-muted-foreground">{{ t("diagram.noIndexesYet") }}</p>
</div>
</template>
</template>
<Button v-if="editable" type="button" variant="destructive" size="sm" class="w-full h-8 text-xs" @click="emit('delete-draft-table', selectedTable.name)">
{{ t("diagram.deleteDraftTable") }}
</Button>
<template v-else-if="isLive && canDropLiveTable">
<div v-if="!confirmingDropTable" class="space-y-1">
<Button type="button" variant="destructive" size="sm" class="w-full h-8 text-xs" @click="requestDeleteLiveTable">
{{ t("diagram.deleteLiveTable") }}
</Button>
<p class="text-[10px] text-muted-foreground">{{ t("diagram.deleteLiveTableHint") }}</p>
</div>
<div v-else class="space-y-2 rounded border border-destructive/40 bg-destructive/5 p-2">
<p class="text-[11px] text-destructive">{{ t("diagram.deleteLiveTableConfirm") }}</p>
<div class="flex gap-2">
<Button type="button" variant="destructive" size="sm" class="h-7 flex-1 text-[11px]" @click="confirmDeleteLiveTable">{{ t("common.confirm") }}</Button>
<Button type="button" variant="outline" size="sm" class="h-7 flex-1 text-[11px]" @click="cancelDeleteLiveTable">{{ t("common.cancel") }}</Button>
</div>
</div>
</template>
<p v-else-if="isLive && !canDropLiveTable" class="text-[11px] text-muted-foreground">{{ t("diagram.dropTableNotSupported") }}</p>
</template>
<template v-else-if="selectedEdge">
<div class="space-y-3 text-xs">
<div class="text-[10px] text-muted-foreground">
<template v-if="relationshipKind === 'foreign-key'">{{ t("diagram.relationshipKindFk") }}</template>
<template v-else-if="relationshipKind === 'custom'">{{ t("diagram.relationshipKindCustom") }}</template>
<template v-else-if="relationshipKind === 'inferred'">{{ t("diagram.relationshipKindInferred") }}</template>
<span> · {{ edgeDirectedCardinalityLabel }}</span>
<span v-if="'confidence' in selectedEdge"> · {{ selectedEdge.confidence }}</span>
</div>
<p v-if="relationshipKind === 'foreign-key'" class="text-[11px] text-muted-foreground">
{{ t("diagram.relationshipReadOnlyFk") }}
</p>
<template v-if="relationshipKind === 'foreign-key'">
<div>
<div class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span>{{ t("diagram.source") }}</span>
<span class="inline-flex min-w-[1.1rem] items-center justify-center rounded border border-border/80 bg-background px-1 py-0.5 font-mono text-[10px] font-semibold leading-none text-foreground">
{{ edgeCardinalityPair(selectedEdge).sourceCardinality }}
</span>
</div>
<div class="font-mono">{{ selectedEdge.sourceTable }}.{{ selectedEdge.sourceColumn }}</div>
</div>
<div>
<div class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span>{{ t("diagram.target") }}</span>
<span class="inline-flex min-w-[1.1rem] items-center justify-center rounded border border-border/80 bg-background px-1 py-0.5 font-mono text-[10px] font-semibold leading-none text-foreground">
{{ edgeCardinalityPair(selectedEdge).targetCardinality }}
</span>
</div>
<div class="font-mono">{{ selectedEdge.targetTable }}.{{ selectedEdge.targetColumn }}</div>
</div>
<div>
<div class="text-[10px] text-muted-foreground">{{ t("diagram.cardinality") }}</div>
<div>{{ edgeDirectedCardinalityLabel }}</div>
</div>
</template>
<template v-else-if="relationshipEditable">
<div class="space-y-1.5">
<label class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span>{{ t("diagram.sourceTable") }}</span>
<span class="inline-flex min-w-[1.1rem] items-center justify-center rounded border border-border/80 bg-background px-1 py-0.5 font-mono text-[10px] font-semibold leading-none text-foreground">
{{ draftCardinality.sourceCardinality }}
</span>
</label>
<Select v-model="relationshipDraft.sourceTable">
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="table in tables" :key="`s-${table.name}`" :value="table.name" :disabled="table.columns.length === 0">{{ table.name }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<label class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span>{{ t("diagram.sourceColumn") }}</span>
<span class="inline-flex min-w-[1.1rem] items-center justify-center rounded border border-border/80 bg-background px-1 py-0.5 font-mono text-[10px] font-semibold leading-none text-foreground">
{{ draftCardinality.sourceCardinality }}
</span>
</label>
<Select v-model="relationshipDraft.sourceColumn">
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="col in sourceColumns" :key="`sc-${col.name}`" :value="col.name">{{ col.name }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<label class="text-[10px] text-muted-foreground">{{ t("diagram.cardinality") }}</label>
<Select v-model="relationshipDraft.cardinality">
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="one-to-one">{{ t("diagram.cardinalityOneToOne") }}</SelectItem>
<SelectItem value="one-to-many">{{ t("diagram.cardinalityOneToMany") }}</SelectItem>
<SelectItem value="many-to-one">{{ t("diagram.cardinalityManyToOne") }}</SelectItem>
<SelectItem value="many-to-many">{{ t("diagram.cardinalityManyToMany") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<label class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span>{{ t("diagram.targetTable") }}</span>
<span class="inline-flex min-w-[1.1rem] items-center justify-center rounded border border-border/80 bg-background px-1 py-0.5 font-mono text-[10px] font-semibold leading-none text-foreground">
{{ draftCardinality.targetCardinality }}
</span>
</label>
<Select v-model="relationshipDraft.targetTable">
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="table in tables" :key="`t-${table.name}`" :value="table.name" :disabled="table.columns.length === 0">{{ table.name }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<label class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span>{{ t("diagram.targetColumn") }}</span>
<span class="inline-flex min-w-[1.1rem] items-center justify-center rounded border border-border/80 bg-background px-1 py-0.5 font-mono text-[10px] font-semibold leading-none text-foreground">
{{ draftCardinality.targetCardinality }}
</span>
</label>
<Select v-model="relationshipDraft.targetColumn">
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="col in targetColumns" :key="`tc-${col.name}`" :value="col.name">{{ col.name }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-col gap-1.5 pt-1">
<Button v-if="relationshipKind === 'custom' || relationshipKind === 'inferred'" type="button" size="sm" class="h-8 w-full text-xs" @click="handleSaveRelationship">
{{ t("diagram.saveRelationship") }}
</Button>
<Button v-if="relationshipKind === 'inferred'" type="button" size="sm" variant="outline" class="h-8 w-full text-xs" @click="handleConfirmRelationship">
{{ t("diagram.confirmRelationship") }}
</Button>
<Button v-if="relationshipKind === 'inferred'" type="button" size="sm" variant="outline" class="h-8 w-full text-xs" @click="handleIgnoreRelationship">
{{ t("diagram.ignoreRelationship") }}
</Button>
</div>
<template v-if="relationshipKind === 'custom'">
<div v-if="confirmingDelete" class="space-y-2 rounded border border-destructive/30 bg-destructive/5 p-2">
<p class="text-[11px] text-muted-foreground">{{ t("diagram.confirmDeleteRelationship") }}</p>
<div class="flex gap-1.5">
<Button type="button" size="sm" variant="destructive" class="h-7 flex-1 text-xs" @click="confirmDeleteRelationship">
{{ t("diagram.confirmDeleteRelationshipAction") }}
</Button>
<Button type="button" size="sm" variant="outline" class="h-7 flex-1 text-xs" @click="confirmingDelete = false">
{{ t("common.cancel") }}
</Button>
</div>
</div>
<Button v-else type="button" variant="destructive" size="sm" class="h-8 w-full text-xs" @click="confirmingDelete = true">
{{ t("diagram.removeRelationship") }}
</Button>
</template>
</template>
</div>
</template>
</div>
</div>
</template>

View File

@ -0,0 +1,199 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import type { DiagramTable } from "@/lib/diagram/erDiagram";
import { isDraftTable, needsDiagramSync } from "@/lib/diagram/erDiagram";
import { draftTableToCreateSqlOptions, hasLiveColumnChanges, liveTableToAlterSqlOptions, validateDraftTable, validateLivePendingColumns } from "@/lib/diagram/draft-table";
import { buildDropTableSql } from "@/lib/database/dbAdminSql";
import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities";
import type { DatabaseType } from "@/types/database";
import * as api from "@/lib/backend/api";
import { copyToClipboard } from "@/lib/common/clipboard";
const { t } = useI18n();
const props = defineProps<{
open: boolean;
tables: DiagramTable[];
connectionId: string;
database: string;
schema: string;
databaseType?: DatabaseType;
}>();
const emit = defineEmits<{
(e: "update:open", value: boolean): void;
(e: "synced", tableNames: string[]): void;
}>();
const openModel = computed({
get: () => props.open,
set: (v: boolean) => emit("update:open", v),
});
const syncTables = computed(() => props.tables.filter(needsDiagramSync));
const draftTables = computed(() => syncTables.value.filter(isDraftTable));
const liveDropTables = computed(() => syncTables.value.filter((table) => !isDraftTable(table) && !!table.pendingDrop));
const liveAlterTables = computed(() => syncTables.value.filter((table) => !isDraftTable(table) && !table.pendingDrop && hasLiveColumnChanges(table)));
const structureCapabilities = computed(() => getTableStructureCapabilities(props.databaseType));
const validationErrors = ref<string[]>([]);
const sqlText = ref("");
const warnings = ref<string[]>([]);
const building = ref(false);
const executing = ref(false);
const execError = ref("");
function validateStructureCapabilities(): string[] {
const caps = structureCapabilities.value;
const errors: string[] = [];
if (draftTables.value.length && !caps.createTable) {
errors.push(t("diagram.createTableNotSupported"));
}
if (liveAlterTables.value.some((table) => (table.pendingColumnNames?.length ?? 0) > 0) && !caps.addColumn) {
errors.push(t("diagram.addColumnNotSupported"));
}
if (liveAlterTables.value.some((table) => (table.droppedColumnNames?.length ?? 0) > 0) && !caps.dropColumn) {
errors.push(t("diagram.dropColumnNotSupported"));
}
if (liveDropTables.value.length && !caps.createTable) {
errors.push(t("diagram.dropTableNotSupported"));
}
return errors;
}
async function rebuildSql() {
building.value = true;
validationErrors.value = [];
warnings.value = [];
sqlText.value = "";
execError.value = "";
try {
const capabilityErrors = validateStructureCapabilities();
const errors = [...capabilityErrors, ...draftTables.value.flatMap(validateDraftTable), ...liveAlterTables.value.flatMap(validateLivePendingColumns)];
if (errors.length) {
validationErrors.value = errors;
return;
}
const allStatements: string[] = [];
const allWarnings: string[] = [];
// Same SQL APIs as TableStructureEditor do not generate dialect SQL in the diagram layer.
for (const table of draftTables.value) {
const result = await api.buildCreateTableSql(draftTableToCreateSqlOptions(table, props.databaseType, props.schema || undefined));
allStatements.push(...result.statements);
allWarnings.push(...result.warnings);
}
for (const table of liveAlterTables.value) {
// Live ER sync only maps ADD/DROP COLUMN. Existing-column type changes stay in TableStructureEditor
// (including SQLite rebuild via previewSqliteTableStructureChange).
const result = await api.buildTableStructureChangeSql(liveTableToAlterSqlOptions(table, props.databaseType, props.schema || undefined));
allStatements.push(...result.statements);
allWarnings.push(...result.warnings);
}
for (const table of liveDropTables.value) {
const sql = await buildDropTableSql({
databaseType: props.databaseType,
schema: props.schema || undefined,
tableName: table.name,
cascade: false,
});
if (sql.trim()) allStatements.push(sql.trim());
}
sqlText.value = allStatements.join("\n\n");
warnings.value = allWarnings;
} catch (e: any) {
validationErrors.value = [e?.message || String(e)];
} finally {
building.value = false;
}
}
watch(
() => props.open,
(open) => {
if (open) void rebuildSql();
},
);
async function copySql() {
if (!sqlText.value) return;
await copyToClipboard(sqlText.value);
}
async function execute() {
if (!sqlText.value || validationErrors.value.length) return;
executing.value = true;
execError.value = "";
try {
const statements = sqlText.value
.split(/;\s*\n/)
.map((s) => s.trim())
.filter(Boolean)
.map((s) => (s.endsWith(";") ? s : `${s};`));
await api.executeBatch(props.connectionId, props.database, statements, props.schema || undefined);
emit(
"synced",
syncTables.value.map((table) => table.name),
);
openModel.value = false;
} catch (e: any) {
execError.value = e?.message || String(e);
} finally {
executing.value = false;
}
}
function syncTableLabel(table: DiagramTable): string {
if (isDraftTable(table)) {
return `${table.name} (${table.columns.length} cols, CREATE)`;
}
if (table.pendingDrop) {
return `${table.name} (DROP TABLE)`;
}
const added = table.pendingColumnNames?.length ?? 0;
const dropped = table.droppedColumnNames?.length ?? 0;
const parts: string[] = [];
if (added) parts.push(`+${added}`);
if (dropped) parts.push(`-${dropped}`);
return `${table.name} (${parts.join("/") || "0"} cols, ALTER)`;
}
</script>
<template>
<Dialog v-model:open="openModel">
<DialogContent class="max-w-2xl max-h-[80vh] flex flex-col gap-3">
<DialogHeader>
<DialogTitle>{{ t("diagram.syncToDatabase") }}</DialogTitle>
</DialogHeader>
<div class="text-xs text-muted-foreground">
{{ t("diagram.syncDraftCount", { count: syncTables.length }) }}
</div>
<ul v-if="syncTables.length" class="text-xs list-disc pl-4 space-y-0.5">
<li v-for="table in syncTables" :key="table.name" class="font-mono">{{ syncTableLabel(table) }}</li>
</ul>
<div v-if="validationErrors.length" class="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive space-y-1">
<div v-for="(err, i) in validationErrors" :key="i">{{ err }}</div>
</div>
<div v-if="warnings.length" class="rounded border border-amber-500/40 bg-amber-500/5 p-2 text-xs text-amber-700 dark:text-amber-300 space-y-1">
<div v-for="(w, i) in warnings" :key="i">{{ w }}</div>
</div>
<pre class="flex-1 min-h-[160px] max-h-[40vh] overflow-auto rounded border bg-muted/30 p-3 text-[11px] font-mono whitespace-pre-wrap">{{ building ? t("diagram.buildingSql") : sqlText || t("diagram.noSqlYet") }}</pre>
<p v-if="execError" class="text-xs text-destructive">{{ execError }}</p>
<DialogFooter class="gap-2 sm:gap-2">
<Button type="button" variant="outline" size="sm" :disabled="!sqlText" @click="copySql">{{ t("diagram.copySql") }}</Button>
<Button type="button" variant="ghost" size="sm" @click="openModel = false">{{ t("common.cancel") }}</Button>
<Button type="button" size="sm" :disabled="!sqlText || !!validationErrors.length || executing || building" @click="execute">
{{ executing ? t("diagram.syncing") : t("diagram.executeSync") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,230 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Copy, Download, Link2, Loader2, Maximize2, Minimize2, Network, Plus, RefreshCw, Search, Table2, Upload, X, ZoomIn, ZoomOut, LayoutGrid } from "@lucide/vue";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import ConnectionGroupBadge from "@/components/connection/ConnectionGroupBadge.vue";
import type { ConnectionConfig } from "@/types/database";
import type { DiagramExportFormat } from "@/lib/export/diagramFormats";
const { t } = useI18n();
const props = defineProps<{
connectionId: string;
database: string;
schema: string;
databases: string[];
schemas: string[];
sqlConnections: ConnectionConfig[];
selectedConnection: ConnectionConfig | undefined;
isSchemaAware: boolean;
loadingDatabases: boolean;
loadingSchemas: boolean;
loadingDiagram: boolean;
diagramReady: boolean;
tablesCount: number;
relationshipsCount: number;
customRelationshipCount: number;
matchRelationshipCount: number;
diagramMode: "table" | "engineering";
tableSearch: string;
showMatchPanel: boolean;
showLayersPanel: boolean;
showAllTables: boolean;
focusTableName: string;
generatedJoinSql: string;
isFullscreen?: boolean;
draftTableCount?: number;
canCreateTable?: boolean;
canSyncToDatabase?: boolean;
}>();
const emit = defineEmits<{
(e: "set-connection", value: string): void;
(e: "set-database", value: string): void;
(e: "set-schema", value: string): void;
(e: "update:table-search", value: string): void;
(e: "set-diagram-mode", value: "table" | "engineering"): void;
(e: "toggle-match-panel"): void;
(e: "toggle-layers-panel"): void;
(e: "copy-join-sql"): void;
(e: "toggle-show-all-tables"): void;
(e: "export-format", format: DiagramExportFormat): void;
(e: "refresh"): void;
(e: "zoom-out"): void;
(e: "zoom-in"): void;
(e: "toggle-fullscreen"): void;
(e: "auto-layout"): void;
(e: "create-table"): void;
(e: "sync-to-database"): void;
}>();
const EXPORT_FORMATS: DiagramExportFormat[] = ["svg", "png", "json", "dbml", "mermaid"];
function exportFormatLabel(format: DiagramExportFormat): string {
switch (format) {
case "svg":
return t("diagram.exportSvg");
case "png":
return t("diagram.exportPng");
case "json":
return t("diagram.exportJson");
case "dbml":
return t("diagram.exportDbml");
case "mermaid":
return t("diagram.exportMermaid");
}
}
function connectionIconType(id: string) {
const config = props.sqlConnections.find((c) => c.id === id);
return config?.driver_profile || config?.db_type || "mysql";
}
</script>
<template>
<div class="flex items-center gap-2 border-b px-3 py-2 shrink-0 overflow-x-auto">
<Select :model-value="connectionId" @update:model-value="(value: any) => emit('set-connection', String(value))">
<SelectTrigger class="h-8 w-48 text-xs">
<div v-if="connectionId" class="flex min-w-0 items-center gap-2">
<DatabaseIcon :db-type="connectionIconType(connectionId)" class="w-3.5 h-3.5 shrink-0" />
<span class="truncate">{{ selectedConnection?.name }}</span>
</div>
<SelectValue v-else :placeholder="t('diagram.selectConnection')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="connection in sqlConnections" :key="connection.id" :value="connection.id">
<div class="flex min-w-0 items-center gap-2">
<DatabaseIcon :db-type="connection.driver_profile || connection.db_type" class="w-3.5 h-3.5 shrink-0" />
<ConnectionGroupBadge :connection-id="connection.id" />
<span class="min-w-0 flex-1 truncate">{{ connection.name }}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<Select :model-value="database" :disabled="!databases.length || loadingDatabases" @update:model-value="(value: any) => emit('set-database', String(value))">
<SelectTrigger class="h-8 w-44 text-xs">
<SelectValue :placeholder="loadingDatabases ? t('common.loading') : t('diagram.selectDatabase')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="db in databases" :key="db" :value="db">{{ db }}</SelectItem>
</SelectContent>
</Select>
<Select v-if="isSchemaAware" :model-value="schema" :disabled="!schemas.length || loadingSchemas" @update:model-value="(value: any) => emit('set-schema', String(value))">
<SelectTrigger class="h-8 w-40 text-xs">
<SelectValue :placeholder="loadingSchemas ? t('common.loading') : t('diagram.selectSchema')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="name in schemas" :key="name" :value="name">{{ name }}</SelectItem>
</SelectContent>
</Select>
<div class="relative min-w-40 flex-1">
<Search class="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input :value="tableSearch" @input="(e: Event) => emit('update:table-search', (e.target as HTMLInputElement).value)" class="h-8 pl-7 pr-9 text-xs" :placeholder="t('diagram.searchTables')" />
<button v-if="tableSearch" type="button" class="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 rounded-full bg-muted-foreground/20 hover:bg-muted-foreground/40 flex items-center justify-center transition-colors" @click="emit('update:table-search', '')">
<X class="h-3 w-3 text-muted-foreground" />
</button>
</div>
<div class="flex h-8 shrink-0 items-center overflow-hidden rounded-md border bg-background">
<Button variant="ghost" size="sm" class="h-8 rounded-none px-2 text-xs" :class="diagramMode === 'table' ? 'bg-accent' : ''" @click="emit('set-diagram-mode', 'table')">
<Table2 class="mr-1 h-3.5 w-3.5" />
{{ t("diagram.tableMode") }}
</Button>
<Button variant="ghost" size="sm" class="h-8 rounded-none border-l px-2 text-xs" :class="diagramMode === 'engineering' ? 'bg-accent' : ''" @click="emit('set-diagram-mode', 'engineering')">
<Network class="mr-1 h-3.5 w-3.5" />
{{ t("diagram.engineeringMode") }}
</Button>
</div>
<Button variant="outline" size="sm" class="h-8 px-2 text-xs" :disabled="!diagramReady || canCreateTable === false" :title="canCreateTable === false ? t('diagram.createTableNotSupported') : t('diagram.createTable')" @click="emit('create-table')">
<Plus class="mr-1 h-3.5 w-3.5" />
{{ t("diagram.createTable") }}
</Button>
<Button
variant="outline"
size="sm"
class="h-8 px-2 text-xs"
:disabled="!diagramReady || !(draftTableCount ?? 0) || canSyncToDatabase === false"
:title="canSyncToDatabase === false ? t('diagram.structureSyncNotSupported') : t('diagram.syncToDatabase')"
:class="(draftTableCount ?? 0) > 0 ? 'bg-primary/10 border-primary text-primary' : ''"
@click="emit('sync-to-database')"
>
<Upload class="mr-1 h-3.5 w-3.5" />
{{ t("diagram.syncToDatabase") }}
<Badge v-if="(draftTableCount ?? 0) > 0" variant="secondary" class="ml-1 h-4 px-1 text-[10px]">{{ draftTableCount }}</Badge>
</Button>
<Button variant="outline" size="sm" class="h-8 px-2 text-xs" :disabled="tablesCount === 0" :title="t('diagram.modelRelationships')" :class="showMatchPanel ? 'bg-primary/10 border-primary text-primary' : ''" @click="emit('toggle-match-panel')">
<Link2 class="mr-1 h-3.5 w-3.5" />
{{ t("diagram.modelRelationships") }}
</Button>
<Button variant="outline" size="sm" class="h-8 px-2 text-xs" :disabled="!diagramReady" :title="t('diagram.layers')" :class="showLayersPanel ? 'bg-primary/10 border-primary text-primary' : ''" @click="emit('toggle-layers-panel')">
<LayoutGrid class="mr-1 h-3.5 w-3.5" />
{{ t("diagram.layers") }}
</Button>
<Button variant="outline" size="sm" class="h-8 px-2 text-xs" :disabled="tablesCount === 0" :title="t('diagram.autoLayout')" @click="emit('auto-layout')">
<LayoutGrid class="mr-1 h-3.5 w-3.5" />
{{ t("diagram.autoLayout") }}
</Button>
<Button variant="outline" size="sm" class="h-8 px-2 text-xs" :disabled="!generatedJoinSql.trim()" :title="t('diagram.copyJoinSql')" @click="emit('copy-join-sql')">
<Copy class="mr-1 h-3.5 w-3.5" />
{{ t("diagram.copyJoinSql") }}
</Button>
<Button v-if="focusTableName && tablesCount > 0" variant="outline" size="sm" class="h-8 px-2 text-xs" @click="emit('toggle-show-all-tables')">
{{ showAllTables ? t("diagram.relatedTables") : t("diagram.allTables") }}
</Button>
<Badge variant="secondary" class="h-6 shrink-0">
{{ t("diagram.tablesCount", { count: tablesCount }) }}
</Badge>
<Badge variant="secondary" class="h-6 shrink-0">
{{ t("diagram.relationshipsCount", { count: relationshipsCount }) }}
</Badge>
<Badge v-if="matchRelationshipCount > 0" variant="outline" class="h-6 shrink-0">
{{ t("diagram.matchRelationshipsCount", { count: matchRelationshipCount }) }}
</Badge>
<Badge v-if="customRelationshipCount > 0" variant="outline" class="h-6 shrink-0">
{{ t("diagram.customRelationshipsCount", { count: customRelationshipCount }) }}
</Badge>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="loadingDiagram || tablesCount === 0" :title="t('diagram.export')">
<Download class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-44">
<DropdownMenuItem v-for="format in EXPORT_FORMATS" :key="format" @click="emit('export-format', format)">
{{ exportFormatLabel(format) }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="!diagramReady || loadingDiagram" :title="t('diagram.refresh')" @click="emit('refresh')">
<Loader2 v-if="loadingDiagram" class="h-4 w-4 animate-spin" />
<RefreshCw v-else class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="diagramMode === 'engineering' || tablesCount === 0" :title="t('diagram.zoomOut')" @click="emit('zoom-out')">
<ZoomOut class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="diagramMode === 'engineering' || tablesCount === 0" :title="t('diagram.zoomIn')" @click="emit('zoom-in')">
<ZoomIn class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" :title="isFullscreen ? t('diagram.exitFullscreen') : t('diagram.fullscreen')" @click="emit('toggle-fullscreen')">
<Minimize2 v-if="isFullscreen" class="h-4 w-4" />
<Maximize2 v-else class="h-4 w-4" />
</Button>
</div>
</template>

View File

@ -0,0 +1,38 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { Lock, Unlock } from "@lucide/vue";
import type { DiagramLayer } from "@/types/diagram";
defineProps<{
data: {
layer: DiagramLayer;
};
}>();
const { t } = useI18n();
function isLocked(layer: DiagramLayer): boolean {
return (layer.layoutMode ?? "auto") === "free";
}
</script>
<template>
<div
class="relative rounded-lg border-2 bg-background/40 box-border pointer-events-none overflow-hidden"
:style="{
borderColor: data.layer.color,
width: '100%',
height: '100%',
}"
>
<div class="layer-drag-handle absolute top-0 left-0 right-0 h-10 flex items-center gap-2 px-4 rounded-t-lg cursor-grab active:cursor-grabbing pointer-events-auto" :style="{ backgroundColor: data.layer.color + '33' }">
<span class="text-sm font-semibold truncate" :style="{ color: data.layer.color }">
{{ data.layer.name }}
</span>
<span class="ml-auto shrink-0 flex items-center gap-1 text-[10px] font-normal opacity-70" :title="isLocked(data.layer) ? t('diagram.layerLocked') : t('diagram.layerUnlocked')">
<Lock v-if="isLocked(data.layer)" class="h-3 w-3" :style="{ color: data.layer.color }" />
<Unlock v-else class="h-3 w-3" :style="{ color: data.layer.color }" />
</span>
</div>
</div>
</template>

View File

@ -0,0 +1,450 @@
<script setup lang="ts">
import { ref, reactive, computed, nextTick } from "vue";
import { useI18n } from "vue-i18n";
import { Plus, Trash2, Eye, EyeOff, ChevronDown, ChevronRight, Edit3, Check, X, CheckSquare, Square, Lock, Unlock } from "@lucide/vue";
import { useLayerStore } from "@/lib/diagram/layer-store";
import type { DiagramTable } from "@/lib/diagram/erDiagram";
import { filterAssignableDiagramTables } from "@/lib/diagram/erDiagram";
import type { LayerLayoutMode } from "@/types/diagram";
import { useToast } from "@/composables/useToast";
const props = defineProps<{
tables: DiagramTable[];
recordHistory?: () => void;
}>();
const emit = defineEmits<{
(e: "layer-changed"): void;
(e: "add-layer"): void;
(e: "layout-mode-changed", payload: { layerId: string; layoutMode: LayerLayoutMode }): void;
(e: "focus-layer", layerId: string): void;
(e: "create-draft-table", payload: { name: string; layerId: string | null; withDefaultId: boolean }): void;
(e: "delete-table", tableName: string): void;
}>();
const store = useLayerStore();
const { toast } = useToast();
const { t } = useI18n();
function beforeMutate() {
props.recordHistory?.();
}
type AddTableTab = "existing" | "create";
const editingLayerId = ref<string | null>(null);
const editingName = ref("");
const renameInputRef = ref<HTMLInputElement | null>(null);
const selectedTableNames = ref<Map<string, Set<string>>>(new Map());
const addTableFilter = ref("");
/** Layer ids whose "tables in other layers" section is expanded (default: collapsed). */
const otherLayersExpandedByLayer = ref<Set<string>>(new Set());
const addTableTabByLayerId = reactive<Record<string, AddTableTab>>({});
const createNameByLayerId = reactive<Record<string, string>>({});
const createWithIdByLayerId = reactive<Record<string, boolean>>({});
const createErrorByLayerId = reactive<Record<string, string>>({});
function getAddTableTab(layerId: string): AddTableTab {
return addTableTabByLayerId[layerId] || "existing";
}
function ensureCreateFormDefaults(layerId: string) {
if (createNameByLayerId[layerId] === undefined) createNameByLayerId[layerId] = "";
if (createWithIdByLayerId[layerId] === undefined) createWithIdByLayerId[layerId] = true;
}
function setAddTableTab(layerId: string, tab: AddTableTab) {
addTableTabByLayerId[layerId] = tab;
if (tab === "create") ensureCreateFormDefaults(layerId);
}
function submitCreateDraft(layerId: string) {
const trimmed = (createNameByLayerId[layerId] || "").trim();
if (!trimmed) {
createErrorByLayerId[layerId] = t("diagram.tableNameRequired");
return;
}
if (props.tables.some((tbl) => tbl.name.toLowerCase() === trimmed.toLowerCase())) {
createErrorByLayerId[layerId] = t("diagram.tableNameExists");
return;
}
delete createErrorByLayerId[layerId];
createNameByLayerId[layerId] = "";
emit("create-draft-table", {
name: trimmed,
layerId,
withDefaultId: createWithIdByLayerId[layerId] ?? true,
});
}
function getSelectedTables(layerId: string): Set<string> {
return selectedTableNames.value.get(layerId) || new Set();
}
function toggleSelectedTable(layerId: string, tableName: string) {
const selected = selectedTableNames.value.get(layerId) || new Set();
if (selected.has(tableName)) {
selected.delete(tableName);
} else {
selected.add(tableName);
}
selectedTableNames.value.set(layerId, selected);
}
function isTableSelected(layerId: string, tableName: string): boolean {
return getSelectedTables(layerId).has(tableName);
}
const assignableTables = computed(() => filterAssignableDiagramTables(props.tables));
const availableTables = computed(() => {
return assignableTables.value.filter((table) => !store.getLayerByTable(table.name));
});
function matchesAddTableFilter(name: string): boolean {
const q = addTableFilter.value.trim().toLowerCase();
if (!q) return true;
return name.toLowerCase().includes(q);
}
const filteredAvailableTables = computed(() => {
return availableTables.value.filter((table) => matchesAddTableFilter(table.name));
});
function getOtherLayerTables(excludeLayerId: string): DiagramTable[] {
return assignableTables.value.filter((table) => getOtherLayerForTable(table.name, excludeLayerId));
}
function getFilteredOtherLayerTables(excludeLayerId: string): DiagramTable[] {
return getOtherLayerTables(excludeLayerId).filter((table) => matchesAddTableFilter(table.name));
}
function isOtherLayersExpanded(layerId: string): boolean {
return otherLayersExpandedByLayer.value.has(layerId);
}
function toggleOtherLayersExpanded(layerId: string) {
const next = new Set(otherLayersExpandedByLayer.value);
if (next.has(layerId)) {
next.delete(layerId);
} else {
next.add(layerId);
}
otherLayersExpandedByLayer.value = next;
}
function getOtherLayerForTable(tableName: string, excludeLayerId: string): string | null {
const layer = store.layers.find((l) => l.id !== excludeLayerId && l.tableNames.includes(tableName));
return layer?.name || null;
}
function handleAddSelectedTables(layerId: string) {
const selected = getSelectedTables(layerId);
if (selected.size === 0) return;
beforeMutate();
selected.forEach((tableName) => {
const currentLayer = store.getLayerByTable(tableName);
if (currentLayer) {
store.removeTableFromLayer(currentLayer.id, tableName);
}
store.addTableToLayer(layerId, tableName);
});
selectedTableNames.value.delete(layerId);
const next = new Set(otherLayersExpandedByLayer.value);
next.delete(layerId);
otherLayersExpandedByLayer.value = next;
addTableFilter.value = "";
emit("layer-changed");
}
function selectAllAvailableTables(layerId: string) {
const all = new Set(filteredAvailableTables.value.map((t) => t.name));
selectedTableNames.value.set(layerId, all);
}
function selectAllTablesFromOtherLayers(layerId: string) {
const selected = getSelectedTables(layerId);
getFilteredOtherLayerTables(layerId).forEach((table) => {
selected.add(table.name);
});
selectedTableNames.value.set(layerId, selected);
}
function clearSelection(layerId: string) {
selectedTableNames.value.set(layerId, new Set());
}
function handleAddLayer() {
emit("add-layer");
}
function handleRemoveLayer(layerId: string) {
beforeMutate();
store.removeLayer(layerId);
selectedTableNames.value.delete(layerId);
delete addTableTabByLayerId[layerId];
delete createNameByLayerId[layerId];
delete createWithIdByLayerId[layerId];
delete createErrorByLayerId[layerId];
const next = new Set(otherLayersExpandedByLayer.value);
next.delete(layerId);
otherLayersExpandedByLayer.value = next;
emit("layer-changed");
}
function handleRenameLayer(layerId: string) {
const layer = store.layers.find((l) => l.id === layerId);
if (!layer) return;
const newName = editingName.value.trim();
if (!newName) {
cancelRename();
return;
}
if (newName.length > 50) {
toast(t("diagram.layerNameTooLong"), 3000);
return;
}
// Unicode letters/digits (incl. Chinese); allow space, _, -, . in the middle
const nameRegex = /^[\p{L}\p{N}][\p{L}\p{N}_\-.\s]*$/u;
if (!nameRegex.test(newName)) {
toast(t("diagram.layerNameInvalid"), 3000);
return;
}
const existingLayer = store.layers.find((l) => l.id !== layerId && l.name === newName);
if (existingLayer) {
toast(t("diagram.layerNameExists"), 3000);
return;
}
if (newName === layer.name) {
cancelRename();
return;
}
beforeMutate();
store.renameLayer(layerId, newName);
editingLayerId.value = null;
editingName.value = "";
emit("layer-changed");
}
function cancelRename() {
editingLayerId.value = null;
editingName.value = "";
}
async function startRename(layerId: string) {
const layer = store.layers.find((l) => l.id === layerId);
if (!layer) return;
editingLayerId.value = layerId;
editingName.value = layer.name;
await nextTick();
const focusRenameInput = () => {
renameInputRef.value?.focus();
renameInputRef.value?.select();
};
focusRenameInput();
// dblclick click focus-layer fitView
requestAnimationFrame(focusRenameInput);
}
function handleRemoveTable(layerId: string, tableName: string) {
beforeMutate();
store.removeTableFromLayer(layerId, tableName);
emit("layer-changed");
}
function handleDeleteTable(tableName: string) {
emit("delete-table", tableName);
}
function handleToggleVisibility(layerId: string) {
beforeMutate();
store.toggleLayerVisibility(layerId);
emit("layer-changed");
}
function isLayerLocked(layer: { layoutMode?: LayerLayoutMode }): boolean {
return (layer.layoutMode ?? "auto") === "free";
}
function handleToggleLayoutLock(layerId: string) {
const layer = store.layers.find((l) => l.id === layerId);
if (!layer) return;
const nextMode: LayerLayoutMode = isLayerLocked(layer) ? "auto" : "free";
emit("layout-mode-changed", { layerId, layoutMode: nextMode });
}
function handleToggleCollapse(layerId: string) {
store.toggleLayerCollapse(layerId);
}
function handleSetActiveLayer(layerId: string) {
store.setActiveLayer(layerId);
emit("focus-layer", layerId);
}
function getLayerTables(layerId: string): DiagramTable[] {
const layer = store.layers.find((l) => l.id === layerId);
if (!layer) return [];
return assignableTables.value.filter((table) => layer.tableNames.includes(table.name));
}
function getLayerColor(layerId: string): string {
const layer = store.layers.find((l) => l.id === layerId);
return layer?.color || "#ccc";
}
</script>
<template>
<div class="flex flex-col h-full max-h-full min-h-0 bg-background border-r border-border">
<div class="flex items-center justify-between px-3 py-2 border-b border-border shrink-0">
<h3 class="text-sm font-semibold text-foreground">Layers</h3>
<button type="button" class="p-1.5 rounded-md hover:bg-muted transition-colors" title="Add Layer" @click="handleAddLayer">
<Plus class="h-4 w-4 text-foreground" />
</button>
</div>
<div class="flex-1 overflow-y-auto p-2 space-y-1">
<div v-if="store.layers.length === 0" class="text-xs text-muted-foreground text-center py-4">No layers yet. Click + to create one.</div>
<div v-for="layer in store.layers" :key="layer.id" class="rounded-md border transition-all" :class="store.activeLayerId === layer.id ? 'border-primary bg-primary/5' : 'border-border hover:border-muted-foreground/50'">
<div class="flex items-center gap-1 px-2 py-1.5 cursor-pointer" @click="handleSetActiveLayer(layer.id)">
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors" @click.stop="handleToggleCollapse(layer.id)">
<ChevronDown v-if="!layer.collapsed" class="h-3.5 w-3.5 text-muted-foreground" />
<ChevronRight v-else class="h-3.5 w-3.5 text-muted-foreground" />
</button>
<div class="w-3 h-3 rounded-full shrink-0" :style="{ backgroundColor: layer.color }" />
<template v-if="editingLayerId === layer.id">
<input ref="renameInputRef" v-model="editingName" class="flex-1 min-w-0 text-xs bg-background border border-border rounded px-1.5 py-0.5 outline-none focus:border-primary" @mousedown.stop @click.stop @keydown.enter="handleRenameLayer(layer.id)" @keydown.escape="cancelRename" />
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors" @click.stop="handleRenameLayer(layer.id)">
<Check class="h-3 w-3 text-green-500" />
</button>
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors" @click.stop="cancelRename">
<X class="h-3 w-3 text-red-500" />
</button>
</template>
<template v-else>
<span class="flex-1 min-w-0 text-xs font-medium truncate cursor-pointer hover:bg-muted/50 rounded px-1" @dblclick.stop="startRename(layer.id)">{{ layer.name }}</span>
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors opacity-0 hover:opacity-100" @click.stop="startRename(layer.id)">
<Edit3 class="h-3 w-3 text-muted-foreground" />
</button>
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors" @click.stop="handleToggleLayoutLock(layer.id)" :title="isLayerLocked(layer) ? t('diagram.layerUnlock') : t('diagram.layerLock')">
<Lock v-if="isLayerLocked(layer)" class="h-3 w-3 text-muted-foreground" />
<Unlock v-else class="h-3 w-3 text-muted-foreground" />
</button>
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors" @click.stop="handleToggleVisibility(layer.id)" :title="layer.visible ? t('diagram.hideLayer') : t('diagram.showLayer')">
<Eye v-if="layer.visible" class="h-3 w-3 text-muted-foreground" />
<EyeOff v-else class="h-3 w-3 text-muted-foreground/50" />
</button>
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors" @click.stop="handleRemoveLayer(layer.id)" :title="t('diagram.deleteLayer')">
<Trash2 class="h-3 w-3 text-red-500" />
</button>
</template>
</div>
<div v-if="!layer.collapsed" class="px-2 pb-2">
<div class="space-y-0.5">
<div v-for="table in getLayerTables(layer.id)" :key="table.name" class="flex items-center gap-1 px-2 py-1 rounded text-xs bg-muted/50">
<span class="flex-1 min-w-0 truncate">{{ table.name }}</span>
<button type="button" class="p-0.5 rounded hover:bg-background transition-colors" @click.stop="handleRemoveTable(layer.id, table.name)" :title="t('diagram.removeFromLayer')">
<X class="h-3 w-3 text-muted-foreground" />
</button>
<button type="button" class="p-0.5 rounded hover:bg-background transition-colors" @click.stop="handleDeleteTable(table.name)" :title="t('diagram.deleteLiveTable')">
<Trash2 class="h-3 w-3 text-red-500" />
</button>
</div>
</div>
<div class="mt-2 space-y-1.5">
<div class="flex border-b border-border">
<button type="button" class="flex-1 px-1 py-1 text-[10px] transition-colors" :class="getAddTableTab(layer.id) === 'existing' ? 'border-b-2 border-primary text-primary font-medium' : 'text-muted-foreground hover:text-foreground'" @click.stop="setAddTableTab(layer.id, 'existing')">
{{ t("diagram.tabSelectExisting") }}
</button>
<button type="button" class="flex-1 px-1 py-1 text-[10px] transition-colors" :class="getAddTableTab(layer.id) === 'create' ? 'border-b-2 border-primary text-primary font-medium' : 'text-muted-foreground hover:text-foreground'" @click.stop="setAddTableTab(layer.id, 'create')">
{{ t("diagram.tabCreateTable") }}
</button>
</div>
<div v-if="getAddTableTab(layer.id) === 'existing'" class="space-y-1">
<div class="flex gap-1">
<button type="button" class="flex-1 text-[9px] bg-muted hover:bg-muted/80 py-0.5 rounded transition-colors" @click.stop="selectAllAvailableTables(layer.id)">
{{ t("diagram.selectAll") }}
</button>
<button type="button" class="flex-1 text-[9px] bg-muted hover:bg-muted/80 py-0.5 rounded transition-colors" @click.stop="clearSelection(layer.id)">
{{ t("diagram.clearSelection") }}
</button>
</div>
<input v-model="addTableFilter" type="search" class="w-full h-7 text-[11px] bg-background border border-border rounded px-2 outline-none focus:border-primary" :placeholder="t('diagram.filterTables')" @click.stop />
<div class="space-y-0.5 max-h-48 overflow-y-auto border border-border rounded">
<div v-for="table in filteredAvailableTables" :key="table.name" class="flex items-center gap-1.5 px-2 py-1 text-xs hover:bg-muted/50 cursor-pointer" @click.stop="toggleSelectedTable(layer.id, table.name)">
<CheckSquare v-if="isTableSelected(layer.id, table.name)" class="h-3 w-3 text-primary shrink-0" />
<Square v-else class="h-3 w-3 text-muted-foreground shrink-0" />
<span class="flex-1 min-w-0 truncate">{{ table.name }}</span>
</div>
<div v-if="filteredAvailableTables.length === 0" class="px-2 py-2 text-[10px] text-muted-foreground text-center">
{{ t("diagram.noMatchingTables") }}
</div>
</div>
<div v-if="store.layers.length > 1" class="mt-1">
<div class="flex items-center justify-between mb-1 gap-1">
<button type="button" class="flex items-center gap-0.5 min-w-0 text-[9px] text-muted-foreground hover:text-foreground" @click.stop="toggleOtherLayersExpanded(layer.id)">
<ChevronDown v-if="isOtherLayersExpanded(layer.id)" class="h-3 w-3 shrink-0" />
<ChevronRight v-else class="h-3 w-3 shrink-0" />
<span class="truncate">{{ t("diagram.tablesInOtherLayers") }}</span>
</button>
<button type="button" class="text-[9px] text-primary/70 hover:text-primary shrink-0" @click.stop="selectAllTablesFromOtherLayers(layer.id)">
{{ t("diagram.selectAll") }}
</button>
</div>
<div v-if="isOtherLayersExpanded(layer.id)" class="space-y-0.5 max-h-32 overflow-y-auto border border-border rounded bg-muted/20">
<div v-for="table in getFilteredOtherLayerTables(layer.id)" :key="table.name" class="flex items-center gap-1.5 px-2 py-1 text-xs hover:bg-muted/50 cursor-pointer" @click.stop="toggleSelectedTable(layer.id, table.name)">
<div class="relative shrink-0">
<CheckSquare v-if="isTableSelected(layer.id, table.name)" class="h-3 w-3 text-primary" />
<Square v-else class="h-3 w-3 text-muted-foreground" />
<div class="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full border border-background" :style="{ backgroundColor: getLayerColor(store.layers.find((l) => l.id !== layer.id && l.tableNames.includes(table.name))?.id || '') }" />
</div>
<span class="flex-1 min-w-0 truncate opacity-70">{{ table.name }}</span>
<span class="text-[9px] px-1 py-0.5 rounded bg-muted-foreground/10 text-muted-foreground shrink-0">
{{ getOtherLayerForTable(table.name, layer.id) }}
</span>
</div>
<div v-if="getFilteredOtherLayerTables(layer.id).length === 0" class="px-2 py-2 text-[10px] text-muted-foreground text-center">
{{ t("diagram.noMatchingTables") }}
</div>
</div>
</div>
<button type="button" class="w-full mt-1 text-xs bg-primary text-primary-foreground py-1 rounded hover:bg-primary/90 transition-colors" :disabled="getSelectedTables(layer.id).size === 0" @click.stop="handleAddSelectedTables(layer.id)">
{{ t("diagram.addSelectedCount", { count: getSelectedTables(layer.id).size }) }}
</button>
</div>
<div v-else class="space-y-1.5" @click.stop>
<input v-model="createNameByLayerId[layer.id]" class="w-full h-7 text-[11px] bg-background border border-border rounded px-2 outline-none focus:border-primary" :placeholder="t('diagram.tableName')" @keydown.enter="submitCreateDraft(layer.id)" />
<label class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<input v-model="createWithIdByLayerId[layer.id]" type="checkbox" />
{{ t("diagram.withDefaultIdPk") }}
</label>
<p v-if="createErrorByLayerId[layer.id]" class="text-[10px] text-destructive">{{ createErrorByLayerId[layer.id] }}</p>
<button type="button" class="w-full text-xs bg-primary text-primary-foreground py-1 rounded hover:bg-primary/90 transition-colors" @click.stop="submitCreateDraft(layer.id)">
{{ t("diagram.create") }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,163 @@
<script setup lang="ts">
import { computed, reactive, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Check, X, Trash2, Zap } from "@lucide/vue";
import type { InferredRelationship } from "@/types/diagram";
import { cardinalityPairFromChoice, type CardinalityChoice, type CardinalityPair } from "@/lib/diagram/cardinality";
const DEFAULT_CHOICE: CardinalityChoice = "many-to-one";
const { t } = useI18n();
const props = defineProps<{
relationships: InferredRelationship[];
conflicts: InferredRelationship[];
pending: InferredRelationship[];
confirmedIds: string[];
ignoredIds: string[];
}>();
const emit = defineEmits<{
(e: "confirm", payload: { id: string } & CardinalityPair): void;
(e: "ignore", id: string): void;
(e: "confirm-all", payload: Array<{ id: string } & CardinalityPair>): void;
(e: "ignore-all"): void;
(e: "clear-all"): void;
}>();
/** Per-row cardinality selection; defaults to N:1 */
const cardinalityById = reactive<Record<string, CardinalityChoice>>({});
watch(
() => props.relationships.map((r) => r.id).join(","),
() => {
for (const rel of props.relationships) {
if (!cardinalityById[rel.id]) {
cardinalityById[rel.id] = DEFAULT_CHOICE;
}
}
},
{ immediate: true },
);
const isConfirmed = (id: string) => props.confirmedIds.includes(id);
const isIgnored = (id: string) => props.ignoredIds.includes(id);
const confidenceBadge = (confidence: string) => {
if (confidence === "high") return { class: "bg-green-100 text-green-800", text: t("diagram.confidenceHigh") };
if (confidence === "medium") return { class: "bg-amber-100 text-amber-800", text: t("diagram.confidenceMedium") };
return { class: "bg-red-100 text-red-800", text: t("diagram.confidenceLow") };
};
const relationshipTitle = (relationship: InferredRelationship) => {
return `${relationship.sourceTable}.${relationship.sourceColumn} -> ${relationship.targetTable}.${relationship.targetColumn}`;
};
const confidenceOrder = { high: 2, medium: 1, low: 0 };
const sortedRelationships = computed(() => {
return [...props.relationships].sort((a, b) => {
if (isConfirmed(a.id) !== isConfirmed(b.id)) return isConfirmed(a.id) ? -1 : 1;
if (isIgnored(a.id) !== isIgnored(b.id)) return isIgnored(a.id) ? 1 : -1;
return (confidenceOrder[b.confidence] || 0) - (confidenceOrder[a.confidence] || 0);
});
});
function choiceFor(id: string): CardinalityChoice {
return cardinalityById[id] ?? DEFAULT_CHOICE;
}
function emitConfirm(id: string) {
emit("confirm", { id, ...cardinalityPairFromChoice(choiceFor(id)) });
}
function emitConfirmAll() {
const payload = props.pending.map((rel) => ({
id: rel.id,
...cardinalityPairFromChoice(choiceFor(rel.id)),
}));
emit("confirm-all", payload);
}
</script>
<template>
<div class="flex h-full min-w-0 flex-col overflow-hidden">
<div class="shrink-0 border-b border-border px-3 py-2">
<p class="mb-2 text-[11px] text-muted-foreground">{{ t("diagram.matchPickCardinality") }}</p>
<div class="mb-2 flex min-w-0 items-center justify-between">
<div class="flex min-w-0 flex-wrap items-center gap-1">
<Badge variant="secondary" class="h-5 px-2 text-xs"> {{ relationships.length }} {{ t("diagram.total") }} </Badge>
<Badge variant="default" class="h-5 px-2 text-xs"> {{ confirmedIds.length }} {{ t("diagram.confirmed") }} </Badge>
<Badge variant="outline" class="h-5 px-2 text-xs"> {{ pending.length }} {{ t("diagram.pending") }} </Badge>
<Badge v-if="conflicts.length > 0" variant="destructive" class="h-5 px-2 text-xs"> {{ conflicts.length }} {{ t("diagram.conflicts") }} </Badge>
</div>
</div>
<div class="flex min-w-0 items-center gap-1.5">
<Button variant="outline" size="sm" class="h-7 min-w-0 flex-1 truncate px-2 text-xs" :disabled="pending.length === 0" @click="emitConfirmAll">
<Check class="mr-1 h-3 w-3 shrink-0" />
<span class="truncate">{{ t("diagram.confirmAll") }}</span>
</Button>
<Button variant="outline" size="sm" class="h-7 min-w-0 flex-1 truncate px-2 text-xs" :disabled="pending.length === 0" @click="emit('ignore-all')">
<X class="mr-1 h-3 w-3 shrink-0" />
<span class="truncate">{{ t("diagram.ignoreAll") }}</span>
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :disabled="confirmedIds.length === 0 && ignoredIds.length === 0" :title="t('diagram.clearAll')" @click="emit('clear-all')">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div class="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto p-2">
<div v-if="relationships.length === 0" class="flex flex-col items-center justify-center py-8 text-xs text-muted-foreground">
<Zap class="mb-2 h-5 w-5" />
<span>{{ t("diagram.noInferred") }}</span>
</div>
<div v-else class="flex min-w-0 flex-col gap-1">
<div
v-for="relationship in sortedRelationships"
:key="relationship.id"
class="flex min-w-0 items-stretch gap-2 rounded-md border p-2 text-xs transition-all hover:bg-muted/50"
:class="[
isConfirmed(relationship.id) ? 'border-primary/30 bg-primary/5' : '',
isIgnored(relationship.id) ? 'border-border opacity-50' : '',
props.conflicts.includes(relationship) && !isConfirmed(relationship.id) && !isIgnored(relationship.id) ? 'border-red-400/50 bg-red-50/50' : '',
!isConfirmed(relationship.id) && !isIgnored(relationship.id) && !props.conflicts.includes(relationship) ? 'border-border hover:border-muted-foreground/50' : '',
]"
:title="`${relationshipTitle(relationship)}\nConfidence: ${relationship.confidence}\nStrategy: ${relationship.strategy}`"
>
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
<div class="flex min-w-0 items-center gap-1.5">
<Badge :class="confidenceBadge(relationship.confidence).class" class="h-4 shrink-0 px-1.5 text-[10px]">
{{ confidenceBadge(relationship.confidence).text }}
</Badge>
<span class="min-w-0 flex-1 truncate font-mono text-[11px]" :title="`${relationship.sourceTable}.${relationship.sourceColumn}`"> {{ relationship.sourceTable }}.{{ relationship.sourceColumn }} </span>
</div>
<Select v-if="!isConfirmed(relationship.id) && !isIgnored(relationship.id)" :model-value="choiceFor(relationship.id)" @update:model-value="(value: any) => (cardinalityById[relationship.id] = String(value) as CardinalityChoice)">
<SelectTrigger class="h-7 w-full text-[11px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="one-to-one">{{ t("diagram.cardinalityOneToOne") }}</SelectItem>
<SelectItem value="one-to-many">{{ t("diagram.cardinalityOneToMany") }}</SelectItem>
<SelectItem value="many-to-one">{{ t("diagram.cardinalityManyToOne") }}</SelectItem>
<SelectItem value="many-to-many">{{ t("diagram.cardinalityManyToMany") }}</SelectItem>
</SelectContent>
</Select>
<div v-else class="text-center text-[10px] text-muted-foreground"></div>
<span class="min-w-0 truncate font-mono text-[11px]" :title="`${relationship.targetTable}.${relationship.targetColumn}`"> {{ relationship.targetTable }}.{{ relationship.targetColumn }} </span>
</div>
<div v-if="!isConfirmed(relationship.id) && !isIgnored(relationship.id)" class="flex shrink-0 flex-col justify-between">
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('diagram.ignoreMatch')" @click="emit('ignore', relationship.id)">
<X class="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('diagram.confirmMatch')" @click="emitConfirm(relationship.id)">
<Check class="h-3.5 w-3.5 text-green-600" />
</Button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,212 @@
<script setup lang="ts">
import { computed, inject, type Ref, type ComputedRef } from "vue";
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type EdgeProps } from "@vue-flow/core";
import type { DiagramRelationship } from "@/lib/diagram/erDiagram";
import type { InferredRelationship } from "@/types/diagram";
import { DIAGRAM_HOVERED_EDGE_KEY, DIAGRAM_EDGE_OBSTACLES_KEY, EDGE_ROUTE_OFFSET, EDGE_STROKE_IDLE, EDGE_STROKE_HOVER } from "@/lib/diagram/diagram-constants";
import type { RelationshipEdgeData } from "@/lib/diagram/vue-flow-adapter";
import { alignWaypointsToEndpoints, endpointRectsFromObstacles, pathSkimsEndpoints, pointAlongPolyline, pointsToSvgPath, polylineLength, routeOrthogonalAroundObstacles, type ObstacleRect, type Point } from "@/lib/diagram/edge-obstacle-router";
const HOVER_BLUE = "#2563eb";
/** Prefer live obstacle routing when it is at most this fraction of aligned ELK path length. */
const LIVE_ROUTE_LENGTH_RATIO = 0.85;
/** Cardinality badge positions along the edge (arc-length fraction). */
const SOURCE_CARDINALITY_T = 0.18;
const TARGET_CARDINALITY_T = 0.82;
const props = defineProps<EdgeProps<RelationshipEdgeData>>();
const hoveredEdgeId = inject<Ref<string | null> | null>(DIAGRAM_HOVERED_EDGE_KEY, null);
const obstacles = inject<ComputedRef<ObstacleRect[]> | Ref<ObstacleRect[]> | null>(DIAGRAM_EDGE_OBSTACLES_KEY, null);
const isHovered = computed(() => hoveredEdgeId?.value === props.id);
function isDiagramRelationship(rel: DiagramRelationship | InferredRelationship): rel is DiagramRelationship {
return "kind" in rel;
}
function snapRoutedToHandles(points: Point[]): Point[] {
if (points.length < 2) return points;
const snapped = points.map((p) => ({ ...p }));
snapped[0] = { x: props.sourceX, y: props.sourceY };
snapped[snapped.length - 1] = { x: props.targetX, y: props.targetY };
return snapped;
}
function buildRoutedPoints(): Point[] | null {
const obstacleList = obstacles?.value ?? [];
const endpointIds: [string, string] = [props.source, props.target];
const endpointRects = endpointRectsFromObstacles(obstacleList, endpointIds);
const live = routeOrthogonalAroundObstacles({
source: { x: props.sourceX, y: props.sourceY },
target: { x: props.targetX, y: props.targetY },
sourcePosition: props.sourcePosition,
targetPosition: props.targetPosition,
obstacles: obstacleList,
endpointIds,
offset: EDGE_ROUTE_OFFSET,
});
const stored = props.data?.waypoints;
let aligned: Point[] | null = null;
if (stored?.length) {
aligned = alignWaypointsToEndpoints(stored, props.sourceX, props.sourceY, props.targetX, props.targetY, {
obstacles: obstacleList,
endpointIds,
});
// Drop ELK paths that skim endpoint table borders (align also rejects when obstacles present)
if (aligned?.length && pathSkimsEndpoints(aligned, endpointRects)) {
aligned = null;
}
}
if (aligned?.length && live?.length) {
const alignedLen = polylineLength(aligned);
const liveLen = polylineLength(live);
if (alignedLen > 0 && liveLen <= alignedLen * LIVE_ROUTE_LENGTH_RATIO) {
return snapRoutedToHandles(live);
}
return snapRoutedToHandles(aligned);
}
if (aligned?.length) return snapRoutedToHandles(aligned);
if (live?.length) return snapRoutedToHandles(live);
return null;
}
const pathResult = computed(() => {
const routed = buildRoutedPoints();
if (routed?.length) {
return {
path: pointsToSvgPath(routed),
points: routed,
};
}
const [path] = getSmoothStepPath({
sourceX: props.sourceX,
sourceY: props.sourceY,
targetX: props.targetX,
targetY: props.targetY,
sourcePosition: props.sourcePosition,
targetPosition: props.targetPosition,
borderRadius: 0,
offset: EDGE_ROUTE_OFFSET,
});
// Smooth-step fallback: approximate with endpoint mid endpoint for badge placement
const mid = {
x: (props.sourceX + props.targetX) / 2,
y: (props.sourceY + props.targetY) / 2,
};
return {
path,
points: [{ x: props.sourceX, y: props.sourceY }, mid, { x: props.targetX, y: props.targetY }],
};
});
const path = computed(() => pathResult.value.path);
const sourceBadgePos = computed(() => pointAlongPolyline(pathResult.value.points, SOURCE_CARDINALITY_T));
const targetBadgePos = computed(() => pointAlongPolyline(pathResult.value.points, TARGET_CARDINALITY_T));
const idleStroke = computed(() => {
const rel = props.data?.relationship;
if (!rel) {
return "color-mix(in srgb, var(--muted-foreground) 45%, transparent)";
}
if (isDiagramRelationship(rel)) {
if (rel.kind === "foreign-key") {
return "color-mix(in srgb, var(--primary) 55%, transparent)";
}
if (rel.kind === "custom") {
return "color-mix(in srgb, var(--primary) 70%, transparent)";
}
}
return "color-mix(in srgb, var(--muted-foreground) 45%, transparent)";
});
const strokeColor = computed(() => (isHovered.value ? HOVER_BLUE : idleStroke.value));
const strokeWidth = computed(() => (isHovered.value ? EDGE_STROKE_HOVER : EDGE_STROKE_IDLE));
const strokeDasharray = computed(() => {
const rel = props.data?.relationship;
if (rel && isDiagramRelationship(rel) && (rel.kind === "foreign-key" || rel.kind === "custom")) {
return "none";
}
return "5,5";
});
const sourceCardinality = computed(() => {
const rel = props.data?.relationship;
if (rel && isDiagramRelationship(rel) && rel.sourceCardinality) return rel.sourceCardinality;
return "N";
});
const targetCardinality = computed(() => {
const rel = props.data?.relationship;
if (rel && isDiagramRelationship(rel) && rel.targetCardinality) return rel.targetCardinality;
return "1";
});
const badgeClass = computed(() => (isHovered.value ? "border-blue-500 text-blue-600" : "border-border/80 text-foreground"));
</script>
<template>
<BaseEdge
:id="id"
:path="path"
:interaction-width="28"
:style="{
stroke: strokeColor,
strokeWidth: strokeWidth,
strokeDasharray: strokeDasharray === 'none' ? undefined : strokeDasharray,
}"
/>
<EdgeLabelRenderer>
<div
class="nopan nodrag pointer-events-none absolute z-10 min-w-[1.1rem] rounded border bg-background/95 px-1 py-0.5 text-center font-mono text-[10px] font-semibold leading-none shadow-sm"
:class="badgeClass"
:style="{
transform: `translate(-50%, -50%) translate(${sourceBadgePos.x}px, ${sourceBadgePos.y}px)`,
}"
>
{{ sourceCardinality }}
</div>
<div
class="nopan nodrag pointer-events-none absolute z-10 min-w-[1.1rem] rounded border bg-background/95 px-1 py-0.5 text-center font-mono text-[10px] font-semibold leading-none shadow-sm"
:class="badgeClass"
:style="{
transform: `translate(-50%, -50%) translate(${targetBadgePos.x}px, ${targetBadgePos.y}px)`,
}"
>
{{ targetCardinality }}
</div>
</EdgeLabelRenderer>
</template>
<style>
.diagram-flow .vue-flow__edge.relationship-edge,
.diagram-flow .vue-flow__edge.relationship-edge.inactive {
pointer-events: stroke !important;
cursor: pointer;
}
.diagram-flow .vue-flow__edge.relationship-edge .vue-flow__edge-interaction {
stroke: #000 !important;
stroke-opacity: 0 !important;
pointer-events: stroke !important;
}
.diagram-flow .vue-flow__edge.relationship-edge:hover .vue-flow__edge-path,
.diagram-flow .vue-flow__edge.relationship-edge.updating .vue-flow__edge-path {
stroke: #2563eb !important;
stroke-width: 3.5px !important;
}
.diagram-flow .vue-flow__node-layer {
pointer-events: none !important;
}
.diagram-flow .vue-flow__node-layer .layer-drag-handle {
pointer-events: auto !important;
}
</style>

View File

@ -0,0 +1,38 @@
<script setup lang="ts">
import { ref } from "vue";
const emit = defineEmits<{
(e: "resize", delta: number): void;
}>();
const isResizing = ref(false);
const startX = ref(0);
function onMouseDown(e: MouseEvent) {
isResizing.value = true;
startX.value = e.clientX;
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
}
function onMouseMove(e: MouseEvent) {
if (!isResizing.value) return;
const delta = e.clientX - startX.value;
startX.value = e.clientX;
emit("resize", delta);
}
function onMouseUp() {
isResizing.value = false;
document.body.style.userSelect = "";
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
}
</script>
<template>
<div class="w-1 cursor-col-resize flex items-center justify-center hover:bg-muted/50 transition-colors group" :class="{ 'bg-muted': isResizing }" @mousedown="onMouseDown">
<div class="w-0.5 h-8 bg-border group-hover:bg-muted-foreground/50 transition-colors" />
</div>
</template>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed } from "vue";
import { Handle, Position } from "@vue-flow/core";
import { Table2, KeyRound, Link2 } from "@lucide/vue";
import { Badge } from "@/components/ui/badge";
import type { DiagramTable, DiagramRelationship } from "@/lib/diagram/erDiagram";
import { isDraftTable, isDroppedColumn } from "@/lib/diagram/erDiagram";
import type { InferredRelationship } from "@/types/diagram";
import { useLayerStore } from "@/lib/diagram/layer-store";
import { CARD_WIDTH, COLUMN_TYPE_WIDTH, COLUMN_NAME_MAX_CHARS, COLUMN_TYPE_MAX_CHARS, TABLE_NAME_MAX_CHARS, EDGE_HANDLE_OUTSET } from "@/lib/diagram/diagram-constants";
const layerStore = useLayerStore();
const props = defineProps<{
data: {
table: DiagramTable;
relationships?: (DiagramRelationship | InferredRelationship)[];
};
selected?: boolean;
}>();
const emit = defineEmits<{
(e: "dblclick", event: MouseEvent): void;
}>();
const isDraft = computed(() => isDraftTable(props.data.table));
function visibleColumns(table: DiagramTable) {
return table.columns.filter((column) => !isDroppedColumn(table, column.name));
}
function isForeignKeyColumn(table: DiagramTable, columnName: string): boolean {
return table.foreignKeys.some((fk) => fk.column === columnName);
}
function isRelationshipColumn(table: DiagramTable, columnName: string): boolean {
if (!props.data.relationships) return false;
return props.data.relationships.some((relationship) => (relationship.sourceTable === table.name && relationship.sourceColumn === columnName) || (relationship.targetTable === table.name && relationship.targetColumn === columnName));
}
function truncateLabel(value: string, maxChars: number): string {
if (value.length <= maxChars) return value;
return `${value.slice(0, Math.max(1, maxChars - 1))}`;
}
const layerColor = computed(() => layerStore.getLayerColor(props.data.table.name));
const handleOffsetStyle = computed(() =>
EDGE_HANDLE_OUTSET > 0
? {
left: { left: `-${EDGE_HANDLE_OUTSET}px` },
right: { right: `-${EDGE_HANDLE_OUTSET}px` },
top: { top: `-${EDGE_HANDLE_OUTSET}px` },
bottom: { bottom: `-${EDGE_HANDLE_OUTSET}px` },
}
: { left: undefined, right: undefined, top: undefined, bottom: undefined },
);
</script>
<template>
<div class="relative rounded-md border bg-background shadow-sm" :class="selected ? 'border-primary ring-1 ring-primary/30' : 'border-border'" :style="{ width: `${CARD_WIDTH}px`, borderLeft: `3px solid ${layerColor}` }" @dblclick.stop="emit('dblclick', $event)">
<Handle id="left" type="source" :position="Position.Left" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.left" />
<Handle id="left-target" type="target" :position="Position.Left" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.left" />
<Handle id="right" type="source" :position="Position.Right" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.right" />
<Handle id="right-target" type="target" :position="Position.Right" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.right" />
<Handle id="top" type="source" :position="Position.Top" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.top" />
<Handle id="top-target" type="target" :position="Position.Top" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.top" />
<Handle id="bottom" type="source" :position="Position.Bottom" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.bottom" />
<Handle id="bottom-target" type="target" :position="Position.Bottom" class="!h-2 !w-2 !min-h-0 !min-w-0 !border-0 !bg-transparent !opacity-0" :style="handleOffsetStyle.bottom" />
<div class="overflow-hidden rounded-[inherit]">
<div class="flex h-11 cursor-grab items-center gap-2 border-b bg-muted/40 px-3 active:cursor-grabbing">
<Table2 class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-sm font-medium" :title="data.table.name">
{{ truncateLabel(data.table.name, TABLE_NAME_MAX_CHARS) }}
</span>
<Badge v-if="isDraft" variant="outline" class="h-5 shrink-0 px-1.5 text-[10px] border-amber-500/50 text-amber-700 dark:text-amber-400">Draft</Badge>
<Badge variant="outline" class="h-5 px-1.5 text-[10px]">{{ visibleColumns(data.table).length }}</Badge>
</div>
<div>
<div v-for="column in visibleColumns(data.table)" :key="column.name" class="flex h-6 min-w-0 items-center gap-1.5 border-b border-border/40 px-3 text-xs last:border-b-0">
<KeyRound v-if="column.is_primary_key" class="h-3 w-3 shrink-0 text-amber-500" />
<Link2 v-else-if="isForeignKeyColumn(data.table, column.name)" class="h-3 w-3 shrink-0 text-primary" />
<Link2 v-else-if="isRelationshipColumn(data.table, column.name)" class="h-3 w-3 shrink-0 text-muted-foreground" />
<span v-else class="h-3 w-3 shrink-0" />
<span class="min-w-0 flex-1 truncate font-mono" :title="column.name">
{{ truncateLabel(column.name, COLUMN_NAME_MAX_CHARS) }}
</span>
<span class="shrink-0 truncate text-right text-[10px] text-muted-foreground" :style="{ width: `${COLUMN_TYPE_WIDTH}px` }" :title="column.data_type">
{{ truncateLabel(column.data_type, COLUMN_TYPE_MAX_CHARS) }}
</span>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,62 @@
<script setup lang="ts">
import { useVueFlow } from "@vue-flow/core";
import { Button } from "@/components/ui/button";
import { ZoomIn, ZoomOut, Maximize2, RotateCcw, Undo2, Redo2 } from "@lucide/vue";
import { useI18n } from "vue-i18n";
defineProps<{
canUndo?: boolean;
canRedo?: boolean;
}>();
const emit = defineEmits<{
(e: "undo"): void;
(e: "redo"): void;
}>();
const { t } = useI18n();
const { zoomIn: vfZoomIn, zoomOut: vfZoomOut, fitView } = useVueFlow();
const FIT_OPTIONS = { padding: 0.15, duration: 150, minZoom: 0.05, maxZoom: 2 } as const;
function zoomIn() {
vfZoomIn({ duration: 150 });
}
function zoomOut() {
vfZoomOut({ duration: 150 });
}
function resetZoom() {
void fitView({ ...FIT_OPTIONS });
}
function fitToView() {
void fitView({ ...FIT_OPTIONS });
}
</script>
<template>
<div class="absolute bottom-3 left-3 z-50 flex flex-col items-center gap-1">
<Button variant="outline" size="icon" class="h-7 w-7" :title="t('diagram.undo')" :disabled="!canUndo" @click="emit('undo')">
<Undo2 class="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="icon" class="h-7 w-7" :title="t('diagram.redo')" :disabled="!canRedo" @click="emit('redo')">
<Redo2 class="h-3.5 w-3.5" />
</Button>
<div class="w-px h-1 bg-border my-1" />
<Button variant="outline" size="icon" class="h-7 w-7" :title="t('diagram.zoomIn')" @click="zoomIn">
<ZoomIn class="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="icon" class="h-7 w-7" :title="t('diagram.zoomOut')" @click="zoomOut">
<ZoomOut class="h-3.5 w-3.5" />
</Button>
<div class="w-px h-1 bg-border my-1" />
<Button variant="outline" size="icon" class="h-7 w-7" :title="t('diagram.fitView')" @click="fitToView">
<Maximize2 class="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="icon" class="h-7 w-7" :title="t('diagram.resetZoom')" @click="resetZoom">
<RotateCcw class="h-3.5 w-3.5" />
</Button>
</div>
</template>

View File

@ -0,0 +1,118 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { DiagramTable } from "@/lib/diagram/erDiagram";
const mocks = vi.hoisted(() => ({
buildDropTableSql: vi.fn(async (options: { schema?: string; tableName: string; cascade?: boolean }) => {
const qualifiedName = options.schema ? `"${options.schema}"."${options.tableName}"` : `"${options.tableName}"`;
return `DROP TABLE ${qualifiedName}${options.cascade ? " CASCADE" : ""};`;
}),
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
}));
vi.mock("@/components/ui/dialog", async () => {
const { defineComponent, h } = await import("vue");
const passthrough = defineComponent({
setup(_props, { slots }) {
return () => h("div", slots.default?.());
},
});
return {
Dialog: passthrough,
DialogContent: passthrough,
DialogFooter: passthrough,
DialogHeader: passthrough,
DialogTitle: passthrough,
};
});
vi.mock("@/components/ui/button", async () => {
const { defineComponent, h } = await import("vue");
return {
Button: defineComponent({
inheritAttrs: false,
setup(_props, { attrs, slots }) {
return () => h("button", attrs, slots.default?.());
},
}),
};
});
vi.mock("@/lib/backend/api", () => ({
buildCreateTableSql: vi.fn(),
buildTableStructureChangeSql: vi.fn(),
executeBatch: vi.fn(),
}));
vi.mock("@/lib/database/dbAdminSql", () => ({
buildDropTableSql: mocks.buildDropTableSql,
supportsDropTableCascade: vi.fn(() => true),
}));
import DiagramSyncDialog from "../DiagramSyncDialog.vue";
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
afterEach(() => {
for (const { app, host } of mountedApps.splice(0)) {
app.unmount();
host.remove();
}
mocks.buildDropTableSql.mockClear();
});
async function flushAsyncUpdates() {
await nextTick();
await Promise.resolve();
await nextTick();
}
describe("DiagramSyncDialog", () => {
it("previews live table deletion without silently enabling CASCADE", async () => {
const liveTable: DiagramTable = {
name: "orders",
columns: [],
foreignKeys: [],
origin: "live",
pendingDrop: true,
};
const open = ref(false);
const host = document.createElement("div");
document.body.append(host);
const app = createApp(
defineComponent({
setup() {
return () =>
h(DiagramSyncDialog, {
open: open.value,
tables: [liveTable],
connectionId: "connection-1",
database: "app",
schema: "public",
databaseType: "postgres",
"onUpdate:open": (value: boolean) => (open.value = value),
});
},
}),
);
app.mount(host);
mountedApps.push({ app, host });
open.value = true;
await flushAsyncUpdates();
expect(mocks.buildDropTableSql).toHaveBeenCalledWith({
databaseType: "postgres",
schema: "public",
tableName: "orders",
cascade: false,
});
expect(host.textContent).toContain('DROP TABLE "public"."orders";');
expect(host.textContent).not.toContain("CASCADE");
});
});

View File

@ -0,0 +1,73 @@
// @vitest-environment happy-dom
import { createApp, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import ZoomControls from "@/components/diagram/ZoomControls.vue";
const vueFlowMock = vi.hoisted(() => ({
zoomIn: vi.fn(),
zoomOut: vi.fn(),
fitView: vi.fn(async () => {}),
}));
vi.mock("@vue-flow/core", () => ({
useVueFlow: () => vueFlowMock,
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
}));
const mountedApps: App[] = [];
function mountZoom(props: { canUndo?: boolean; canRedo?: boolean } = {}) {
const emits: string[] = [];
const container = document.createElement("div");
document.body.append(container);
const app = createApp(ZoomControls, {
canUndo: props.canUndo ?? true,
canRedo: props.canRedo ?? true,
onUndo: () => emits.push("undo"),
onRedo: () => emits.push("redo"),
});
mountedApps.push(app);
app.mount(container);
return { container, emits };
}
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
vueFlowMock.zoomIn.mockClear();
vueFlowMock.zoomOut.mockClear();
vueFlowMock.fitView.mockClear();
});
describe("ZoomControls", () => {
it("emits undo/redo and calls vue-flow zoom helpers", async () => {
const { container, emits } = mountZoom();
await nextTick();
const buttons = container.querySelectorAll("button");
expect(buttons.length).toBeGreaterThanOrEqual(6);
buttons[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
buttons[1].dispatchEvent(new MouseEvent("click", { bubbles: true }));
buttons[2].dispatchEvent(new MouseEvent("click", { bubbles: true }));
buttons[3].dispatchEvent(new MouseEvent("click", { bubbles: true }));
buttons[4].dispatchEvent(new MouseEvent("click", { bubbles: true }));
buttons[5].dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(emits).toEqual(["undo", "redo"]);
expect(vueFlowMock.zoomIn).toHaveBeenCalled();
expect(vueFlowMock.zoomOut).toHaveBeenCalled();
expect(vueFlowMock.fitView).toHaveBeenCalledTimes(2);
});
it("disables undo/redo when props are false", async () => {
const { container } = mountZoom({ canUndo: false, canRedo: false });
await nextTick();
const buttons = container.querySelectorAll("button");
expect((buttons[0] as HTMLButtonElement).disabled).toBe(true);
expect((buttons[1] as HTMLButtonElement).disabled).toBe(true);
});
});

View File

@ -2878,6 +2878,7 @@ export default {
selectSchema: "Select schema",
searchTables: "Search tables, columns, keys...",
refresh: "Refresh diagram",
refreshConfirm: "Refreshing reloads table metadata and resets the canvas layout and undo history. Saved modeled relationships are kept. Continue?",
loading: "Reading relationships...",
loadingProgress: "Reading relationships... {loaded}/{total}",
partialError: "Skipped metadata for {count} tables that failed to load",
@ -2889,6 +2890,10 @@ export default {
tableMode: "Table View",
engineeringMode: "Engineering ER",
modelRelationships: "Model Relationships",
layers: "Layers",
autoMatch: "Auto Match",
autoLayout: "Auto Layout",
matchRelationshipsCount: "{count} matches",
copyJoinSql: "Copy SQL",
customRelationshipsCount: "{count} modeled",
relationshipName: "Name",
@ -2898,10 +2903,15 @@ export default {
targetTable: "Target table",
targetColumn: "Target column",
cardinality: "Cardinality",
cardinalityOneToMany: "1:N",
cardinalityManyToOne: "N:1",
cardinalityManyToMany: "N:N",
cardinalityOneToOne: "1:1",
cardinalityOneToMany: "Source 1 · Target N",
cardinalityManyToOne: "Source N · Target 1",
cardinalityOneToOne: "Source 1 · Target 1",
cardinalityManyToMany: "Source N · Target N",
cardinalityDirected: "Source {source} · Target {target}",
matchPickCardinality: "Confirm a match after choosing cardinality (1:1 / 1:N / N:1 / N:N).",
manualAddRelationship: "Manual add",
confirmMatch: "Confirm",
ignoreMatch: "Ignore",
addRelationship: "Add relationship",
removeRelationship: "Remove relationship",
relationshipIncomplete: "Select both source and target columns",
@ -2913,11 +2923,125 @@ export default {
relatedTables: "Related Tables",
moreColumns: "+ {count} columns",
exportSvg: "Export SVG",
exportPng: "Export PNG",
exportJson: "Export JSON",
exportDbml: "Export DBML",
exportMermaid: "Export Mermaid",
export: "Export",
exportedSvg: "SVG exported",
exportedFormat: "{format} exported",
exportSvgFailed: "Failed to export SVG: {message}",
exportFailed: "Failed to export {format}: {message}",
zoomIn: "Zoom in",
zoomOut: "Zoom out",
fitView: "Fit view",
resetZoom: "Reset zoom",
undo: "Undo",
redo: "Redo",
resetLayout: "Reset layout",
fullscreen: "Fullscreen",
exitFullscreen: "Exit fullscreen",
addLayer: "Add Layer",
noLayersYet: "No layers yet. Click + to create one.",
layoutFree: "Free",
layoutAuto: "Auto",
layerLock: "Lock layer (skip auto-layout inside)",
layerUnlock: "Unlock layer (allow auto-layout inside)",
layerLocked: "Locked",
layerUnlocked: "Unlocked",
hideLayer: "Hide layer",
showLayer: "Show layer",
deleteLayer: "Delete layer",
layerHiddenCannotFocus: "Layer is hidden — show it to focus on the canvas",
createTable: "New table",
createTableNotSupported: "Creating tables is not supported for this database",
create: "Create",
syncToDatabase: "Sync to database",
structureSyncNotSupported: "Table structure sync is not supported for this database",
syncDraftCount: "{count} change(s) to sync (create / alter / drop)",
executeSync: "Execute",
syncing: "Syncing…",
buildingSql: "Building SQL…",
noSqlYet: "No SQL yet",
copySql: "Copy SQL",
tableName: "Table name",
tableNameRequired: "Table name is required",
tableNameExists: "A table with this name already exists",
assignLayer: "Layer",
noLayer: "No layer",
withDefaultIdPk: "Include default id primary key",
inspectorTable: "Table",
inspectorRelationship: "Relationship",
fieldComment: "Comment",
fields: "Fields",
tabFields: "Fields",
tabIndexes: "Indexes",
addIndex: "Add index",
indexName: "Index name",
indexUnique: "UNIQUE",
indexColumns: "Columns",
noIndexesYet: "No indexes yet",
indexesNotSupported: "Creating indexes is not supported for this database from the diagram editor",
addColumnNotSupported: "Adding columns is not supported for this database",
dropColumnNotSupported: "Dropping columns is not supported for this database",
dropTableNotSupported: "Dropping tables is not supported for this database from the diagram editor",
addField: "Add field",
deleteField: "Delete field",
undoDropField: "Undo drop field",
noFieldsYet: "No fields yet — add one to sync",
deleteDraftTable: "Delete draft table",
deleteLiveTable: "Delete table",
deleteLiveTableHint: "Marks the table for DROP TABLE on the next sync. Undo via history if needed.",
deleteLiveTableConfirm: "Mark this table for DROP TABLE when you sync to the database?",
liveTableReadOnly: "Live table — edit structure in the table editor",
liveTableAddColumnsHint: "Add columns (ALTER ADD) or mark existing ones for drop (ALTER DROP), then sync. Editing existing column types remains in the table structure editor.",
liveTableIndexesReadOnly: "Edit indexes for live tables in the table structure editor",
pendingColumnsBadge: "Pending changes",
pendingDropColumnBadge: "Drop",
pendingDropTableBadge: "Drop table",
source: "Source",
target: "Target",
emptyDesignHint: "No tables yet. Create a layer and tables, edit fields in the inspector, then sync to the database.",
removeFromLayer: "Remove from layer",
addTables: "Add table(s)",
tabSelectExisting: "Select existing",
tabCreateTable: "New table",
multiSelect: "Multi select",
closeMultiSelect: "Close",
selectAll: "Select all",
clearSelection: "Clear",
tablesInOtherLayers: "Tables in other layers",
filterTables: "Filter tables...",
noMatchingTables: "No matching tables",
addSelectedCount: "Add {count} selected",
layerNameTooLong: "Layer name must be less than 50 characters",
layerNameInvalid: "Layer name may include letters, numbers, Chinese, spaces, underscores, hyphens, and periods",
layerNameExists: "Layer name already exists",
total: "total",
confirmed: "confirmed",
pending: "pending",
conflicts: "conflicts",
conflict: "Conflict",
ignored: "ignored",
confirmAll: "Confirm all",
ignoreAll: "Ignore all",
clearAll: "Clear all",
confidenceHigh: "High",
confidenceMedium: "Medium",
confidenceLow: "Low",
noInferred: "No inferred relationships",
editRelationship: "Edit",
saveRelationship: "Save",
deleteRelationship: "Delete",
confirmDeleteRelationship: "Delete this relationship?",
confirmDeleteRelationshipAction: "Delete",
confirmRelationship: "Confirm",
ignoreRelationship: "Ignore",
relationshipKindFk: "Foreign key",
relationshipKindCustom: "Custom",
relationshipKindInferred: "Inferred",
relationshipReadOnlyFk: "This relationship comes from a database foreign key and cannot be edited here.",
relationshipUpdated: "Relationship updated",
},
etcd: {
prefixPlaceholder: "Search Key path, e.g. /app/ or service",

View File

@ -2758,10 +2758,10 @@ export default withEnglishFallback({
targetTable: "Tabla de destino",
targetColumn: "Columna de destino",
cardinality: "Cardinalidad",
cardinalityOneToMany: "1:N",
cardinalityManyToOne: "N:1",
cardinalityManyToMany: "N:N",
cardinalityOneToOne: "1:1",
cardinalityOneToMany: "Origen 1 · Destino N",
cardinalityManyToOne: "Origen N · Destino 1",
cardinalityManyToMany: "Origen N · Destino N",
cardinalityOneToOne: "Origen 1 · Destino 1",
addRelationship: "Agregar relación",
removeRelationship: "Eliminar relación",
relationshipIncomplete: "Selecciona ambas columnas de origen y destino",
@ -2769,6 +2769,130 @@ export default withEnglishFallback({
relationshipExists: "Esta relación ya existe",
relationshipAdded: "Relación agregada",
noJoinSql: "No hay SQL de relación para copiar",
refreshConfirm: "Al actualizar se recargará la estructura de las tablas y se restablecerá el diseño del lienzo y el historial de deshacer. Las relaciones de modelado guardadas localmente no se verán afectadas. ¿Desea continuar?",
layers: "Capas",
autoMatch: "Coincidencia automática",
autoLayout: "Diseño automático",
matchRelationshipsCount: "{count} coincidencias",
cardinalityDirected: "Origen {source} · Destino {target}",
matchPickCardinality: "Antes de confirmar la coincidencia, seleccione la cardinalidad (1:1 / 1:N / N:1 / N:N).",
manualAddRelationship: "Añadir manualmente",
confirmMatch: "Confirmar",
ignoreMatch: "Ignorar",
exportPng: "Exportar PNG",
exportJson: "Exportar JSON",
exportDbml: "Exportar DBML",
exportMermaid: "Exportar Mermaid",
export: "Exportar",
exportedFormat: "{format} exportado",
exportFailed: "Error al exportar {format}: {message}",
fitView: "Ajustar al lienzo",
resetZoom: "Restablecer zoom",
undo: "Deshacer",
redo: "Rehacer",
fullscreen: "Pantalla completa",
exitFullscreen: "Salir de pantalla completa",
addLayer: "Añadir capa",
noLayersYet: "Aún no hay capas, haga clic en + para crear",
layoutFree: "Libre",
layoutAuto: "Automático",
layerLock: "Bloquear capa (sin diseño automático dentro de la capa)",
layerUnlock: "Desbloquear capa (permitir diseño automático dentro de la capa)",
layerLocked: "Bloqueada",
layerUnlocked: "Desbloqueada",
hideLayer: "Ocultar capa",
showLayer: "Mostrar capa",
deleteLayer: "Eliminar capa",
layerHiddenCannotFocus: "La capa está oculta. Muéstrela primero para ubicarla en el lienzo.",
createTable: "Crear tabla",
create: "Crear",
syncToDatabase: "Sincronizar con la base de datos",
syncDraftCount: "Se sincronizarán {count} cambios (crear/modificar/eliminar tablas)",
executeSync: "Ejecutar",
syncing: "Sincronizando…",
buildingSql: "Generando SQL…",
noSqlYet: "Aún no hay SQL",
copySql: "Copiar SQL",
tableName: "Nombre de la tabla",
tableNameRequired: "Por favor, ingrese el nombre de la tabla",
tableNameExists: "Ya existe una tabla con el mismo nombre",
assignLayer: "Capa asignada",
noLayer: "Sin capa",
withDefaultIdPk: "Incluir clave primaria id predeterminada",
inspectorTable: "Tabla",
inspectorRelationship: "Relación",
fieldComment: "Comentario",
fields: "Campos",
tabFields: "Campos",
tabIndexes: "Índices",
addIndex: "Agregar índice",
indexName: "Nombre del índice",
indexUnique: "UNIQUE",
indexColumns: "Columnas",
noIndexesYet: "Aún no hay índices",
indexesNotSupported: "La base de datos actual no admite la creación de índices en el editor de diagramas",
addField: "Agregar campo",
deleteField: "Eliminar campo",
undoDropField: "Deshacer eliminación de campo",
noFieldsYet: "Aún no hay campos. Agregue uno antes de sincronizar",
deleteDraftTable: "Eliminar tabla de borrador",
deleteLiveTable: "Eliminar tabla",
deleteLiveTableHint: "Marcar como pendiente de eliminación. La próxima vez que se sincronice con la base de datos se ejecutará DROP TABLE. Puede deshacer para restaurar.",
deleteLiveTableConfirm: "¿Confirmar que desea marcar esta tabla para eliminación? Al sincronizar se ejecutará DROP TABLE.",
liveTableReadOnly: "Tabla en la base de datos — Modifíquela en el editor de estructura de tabla",
liveTableAddColumnsHint: "Puede agregar campos (ALTER ADD) o marcar campos existentes para eliminar (ALTER DROP) y luego sincronizar. Para cambios como tipos, use el editor de estructura de tabla.",
liveTableIndexesReadOnly: "Los índices de tablas en la base de datos deben modificarse en el editor de estructura de tabla",
pendingColumnsBadge: "Cambios pendientes",
pendingDropColumnBadge: "Pendiente de eliminar",
pendingDropTableBadge: "Tabla pendiente de eliminar",
source: "Origen",
target: "Destino",
emptyDesignHint: "Aún no hay tablas. Puede crear capas y tablas, editar campos en el panel de propiedades y sincronizar con la base de datos al finalizar.",
removeFromLayer: "Quitar de la capa",
addTables: "Agregar tablas",
tabSelectExisting: "Seleccionar existente",
tabCreateTable: "Crear tabla",
multiSelect: "Selección múltiple",
closeMultiSelect: "Cerrar",
selectAll: "Seleccionar todo",
clearSelection: "Limpiar",
tablesInOtherLayers: "Tablas en otras capas",
filterTables: "Filtrar tablas...",
noMatchingTables: "No hay tablas coincidentes",
addSelectedCount: "Agregar {count} elementos seleccionados",
layerNameTooLong: "El nombre de la capa no puede superar los 50 caracteres",
layerNameInvalid: "El nombre de la capa puede contener caracteres chinos, letras, números, espacios, guiones bajos, guiones y puntos",
layerNameExists: "El nombre de la capa ya existe",
total: "Total",
confirmed: "Confirmado",
pending: "Pendiente",
conflicts: "Conflictos",
conflict: "Conflicto",
ignored: "Ignorado",
confirmAll: "Confirmar todo",
ignoreAll: "Ignorar todo",
clearAll: "Limpiar todo",
confidenceHigh: "Alta",
confidenceMedium: "Media",
confidenceLow: "Baja",
noInferred: "No hay relaciones inferidas",
editRelationship: "Editar",
saveRelationship: "Guardar",
deleteRelationship: "Eliminar",
confirmDeleteRelationship: "¿Confirmar eliminación de esta relación?",
confirmDeleteRelationshipAction: "Confirmar eliminación",
confirmRelationship: "Confirmar",
ignoreRelationship: "Ignorar",
relationshipKindFk: "Clave externa",
relationshipKindCustom: "Personalizada",
relationshipKindInferred: "Inferida",
relationshipReadOnlyFk: "Esta relación proviene de una restricción de clave externa de la base de datos y no se puede modificar aquí.",
relationshipUpdated: "Relación actualizada",
createTableNotSupported: "La base de datos actual no admite la creación de tablas",
structureSyncNotSupported: "La base de datos actual no admite sincronizar la estructura de tablas desde el diagrama",
addColumnNotSupported: "La base de datos actual no admite agregar columnas",
dropColumnNotSupported: "La base de datos actual no admite eliminar columnas",
dropTableNotSupported: "La base de datos actual no admite eliminar tablas desde el diagrama",
},
etcd: {
prefixPlaceholder: "Buscar ruta de Key, p. ej. /app/ o servicio",

View File

@ -2747,10 +2747,10 @@ export default withEnglishFallback({
targetTable: "Tabella destinazione",
targetColumn: "Colonna destinazione",
cardinality: "Cardinalita",
cardinalityOneToMany: "1:N",
cardinalityManyToOne: "N:1",
cardinalityManyToMany: "N:N",
cardinalityOneToOne: "1:1",
cardinalityOneToMany: "Origine 1 · Destinazione N",
cardinalityManyToOne: "Origine N · Destinazione 1",
cardinalityManyToMany: "Origine N · Destinazione N",
cardinalityOneToOne: "Origine 1 · Destinazione 1",
addRelationship: "Aggiungi relazione",
removeRelationship: "Rimuovi relazione",
relationshipIncomplete: "Seleziona sia la colonna sorgente che quella di destinazione",
@ -2767,6 +2767,130 @@ export default withEnglishFallback({
zoomIn: "Ingrandisci",
zoomOut: "Rimpicciolisci",
resetLayout: "Reimposta layout",
refreshConfirm: "L'aggiornamento ricaricherà la struttura delle tabelle e reimposterà il layout della tela e la cronologia delle azioni annullate; le relazioni di modellazione salvate localmente non saranno influenzate. Continuare?",
layers: "Livelli",
autoMatch: "Corrispondenza automatica",
autoLayout: "Layout automatico",
matchRelationshipsCount: "{count} corrispondenze",
cardinalityDirected: "Origine {source} · Destinazione {target}",
matchPickCardinality: "Prima di confermare la corrispondenza, selezionare la cardinalità (1:1 / 1:N / N:1 / N:N).",
manualAddRelationship: "Aggiunta manuale",
confirmMatch: "Conferma",
ignoreMatch: "Ignora",
exportPng: "Esporta PNG",
exportJson: "Esporta JSON",
exportDbml: "Esporta DBML",
exportMermaid: "Esporta Mermaid",
export: "Esporta",
exportedFormat: "{format} esportato",
exportFailed: "Esportazione di {format} non riuscita: {message}",
fitView: "Adatta alla tela",
resetZoom: "Reimposta zoom",
undo: "Annulla",
redo: "Ripeti",
fullscreen: "Schermo intero",
exitFullscreen: "Esci da schermo intero",
addLayer: "Aggiungi livello",
noLayersYet: "Nessun livello ancora, fare clic su + per crearne uno",
layoutFree: "Libera",
layoutAuto: "Auto",
layerLock: "Blocca livello (nessun layout automatico all'interno del livello)",
layerUnlock: "Sblocca livello (consenti layout automatico all'interno del livello)",
layerLocked: "Bloccato",
layerUnlocked: "Sbloccato",
hideLayer: "Nascondi livello",
showLayer: "Mostra livello",
deleteLayer: "Elimina livello",
layerHiddenCannotFocus: "Il livello è nascosto; mostrarlo prima di centrarlo sulla tela.",
createTable: "Crea tabella",
create: "Crea",
syncToDatabase: "Sincronizza con il database",
syncDraftCount: "Verranno sincronizzate {count} modifiche (nuove / modifica tabella / eliminazione tabella)",
executeSync: "Esegui",
syncing: "Sincronizzazione in corso…",
buildingSql: "Generazione SQL in corso…",
noSqlYet: "Nessun SQL al momento",
copySql: "Copia SQL",
tableName: "Nome tabella",
tableNameRequired: "Inserire il nome della tabella",
tableNameExists: "Esiste già una tabella con lo stesso nome",
assignLayer: "Livello di appartenenza",
noLayer: "Nessun livello",
withDefaultIdPk: "Includi chiave primaria id predefinita",
inspectorTable: "Tabella",
inspectorRelationship: "Relazione",
fieldComment: "Commento",
fields: "Campi",
tabFields: "Campi",
tabIndexes: "Indici",
addIndex: "Aggiungi indice",
indexName: "Nome indice",
indexUnique: "UNIQUE",
indexColumns: "Colonne",
noIndexesYet: "Nessun indice ancora",
indexesNotSupported: "Il database corrente non supporta la creazione di indici nell'editor di diagrammi",
addField: "Aggiungi campo",
deleteField: "Elimina campo",
undoDropField: "Annulla eliminazione campo",
noFieldsYet: "Nessun campo ancora; aggiungere campi per sincronizzare",
deleteDraftTable: "Elimina tabella bozza",
deleteLiveTable: "Elimina tabella",
deleteLiveTableHint: "Marcata per l'eliminazione; DROP TABLE verrà eseguito alla prossima 'Sincronizzazione con il database'. È possibile annullare l'operazione.",
deleteLiveTableConfirm: "Confermi di marcare questa tabella per l'eliminazione? Durante la sincronizzazione verrà eseguito DROP TABLE.",
liveTableReadOnly: "Tabella già nel database — modificare nell'editor della struttura della tabella",
liveTableAddColumnsHint: "È possibile aggiungere campi (ALTER ADD) o marcare per l'eliminazione campi esistenti (ALTER DROP), quindi sincronizzare. Per modifiche come i tipi, utilizzare l'editor della struttura della tabella.",
liveTableIndexesReadOnly: "Per gli indici di tabelle già nel database, modificarli nell'editor della struttura della tabella.",
pendingColumnsBadge: "Modifiche in attesa di sincronizzazione",
pendingDropColumnBadge: "Da eliminare",
pendingDropTableBadge: "Tabella da eliminare",
source: "Origine",
target: "Destinazione",
emptyDesignHint: "Nessuna tabella ancora. È possibile creare livelli e tabelle, modificare i campi nel pannello delle proprietà e quindi sincronizzare con il database.",
removeFromLayer: "Rimuovi dal livello",
addTables: "Aggiungi tabelle",
tabSelectExisting: "Seleziona esistente",
tabCreateTable: "Crea tabella",
multiSelect: "Selezione multipla",
closeMultiSelect: "Chiudi",
selectAll: "Seleziona tutto",
clearSelection: "Cancella selezione",
tablesInOtherLayers: "Tabelle in altri livelli",
filterTables: "Filtra tabelle...",
noMatchingTables: "Nessuna tabella corrispondente",
addSelectedCount: "Aggiungi {count} elementi selezionati",
layerNameTooLong: "Il nome del livello non può superare 50 caratteri",
layerNameInvalid: "Il nome del livello può contenere caratteri cinesi, lettere, numeri, spazi, trattini bassi, trattini e punti",
layerNameExists: "Il nome del livello esiste già",
total: "Totale",
confirmed: "Confermato",
pending: "In attesa di conferma",
conflicts: "Conflitti",
conflict: "Conflitto",
ignored: "Ignorato",
confirmAll: "Conferma tutto",
ignoreAll: "Ignora tutto",
clearAll: "Cancella tutto",
confidenceHigh: "Alta",
confidenceMedium: "Media",
confidenceLow: "Bassa",
noInferred: "Nessuna relazione inferita",
editRelationship: "Modifica",
saveRelationship: "Salva",
deleteRelationship: "Elimina",
confirmDeleteRelationship: "Confermi l'eliminazione di questa relazione?",
confirmDeleteRelationshipAction: "Conferma eliminazione",
confirmRelationship: "Conferma",
ignoreRelationship: "Ignora",
relationshipKindFk: "Chiave esterna",
relationshipKindCustom: "Personalizzata",
relationshipKindInferred: "Inferita",
relationshipReadOnlyFk: "Questa relazione deriva da un vincolo di chiave esterna del database; non può essere modificata qui.",
relationshipUpdated: "Relazione aggiornata",
createTableNotSupported: "Il database corrente non supporta la creazione di nuove tabelle",
structureSyncNotSupported: "Il database corrente non supporta la sincronizzazione della struttura delle tabelle dal diagramma",
addColumnNotSupported: "Il database corrente non supporta l'aggiunta di colonne",
dropColumnNotSupported: "Il database corrente non supporta l'eliminazione di colonne",
dropTableNotSupported: "Il database corrente non supporta l'eliminazione di tabelle dal diagramma",
},
etcd: {
prefixPlaceholder: "Cerca percorso Key, ad es. /app/ o servizio",

View File

@ -2813,10 +2813,10 @@ export default withEnglishFallback({
targetTable: "ターゲットテーブル",
targetColumn: "ターゲット列",
cardinality: "カーディナリティ",
cardinalityOneToMany: "1:N",
cardinalityManyToOne: "N:1",
cardinalityManyToMany: "N:N",
cardinalityOneToOne: "1:1",
cardinalityOneToMany: "ソース 1 · ターゲット N",
cardinalityManyToOne: "ソース N · ターゲット 1",
cardinalityManyToMany: "ソース N · ターゲット N",
cardinalityOneToOne: "ソース 1 · ターゲット 1",
addRelationship: "関係を追加",
removeRelationship: "関係を削除",
relationshipIncomplete: "ソース列とターゲット列を選択してください",
@ -2833,6 +2833,130 @@ export default withEnglishFallback({
zoomIn: "拡大",
zoomOut: "縮小",
resetLayout: "レイアウトをリセット",
refreshConfirm: "更新するとテーブル構造が再読み込みされ、キャンバスレイアウトと元に戻す履歴がリセットされます。ローカルに保存されたモデリング関係には影響しません。続行しますか?",
layers: "レイヤー",
autoMatch: "自動マッチング",
autoLayout: "自動レイアウト",
matchRelationshipsCount: "{count} 件の一致",
cardinalityDirected: "ソース {source} · ターゲット {target}",
matchPickCardinality: "一致を確認する前に基数1:1 / 1:N / N:1 / N:Nを選択してください。",
manualAddRelationship: "手動追加",
confirmMatch: "確認",
ignoreMatch: "無視",
exportPng: "PNG エクスポート",
exportJson: "JSON エクスポート",
exportDbml: "DBML エクスポート",
exportMermaid: "Mermaid エクスポート",
export: "エクスポート",
exportedFormat: "{format} をエクスポートしました",
exportFailed: "{format} のエクスポートに失敗しました:{message}",
fitView: "キャンバスにフィット",
resetZoom: "ズームをリセット",
undo: "元に戻す",
redo: "やり直す",
fullscreen: "全画面",
exitFullscreen: "全画面を終了",
addLayer: "レイヤーを追加",
noLayersYet: "レイヤーがありません。+ をクリックして作成",
layoutFree: "フリー",
layoutAuto: "自動",
layerLock: "レイヤーをロック(レイヤー内で自動レイアウトしない)",
layerUnlock: "レイヤーのロックを解除(レイヤー内で自動レイアウトを許可)",
layerLocked: "ロック済み",
layerUnlocked: "未ロック",
hideLayer: "レイヤーを非表示",
showLayer: "レイヤーを表示",
deleteLayer: "レイヤーを削除",
layerHiddenCannotFocus: "レイヤーは非表示です。表示してからキャンバスに移動してください",
createTable: "テーブルを作成",
create: "作成",
syncToDatabase: "データベースに同期",
syncDraftCount: "{count} 件の変更を同期します(新規作成 / 変更 / 削除)",
executeSync: "実行",
syncing: "同期中…",
buildingSql: "SQL を生成中…",
noSqlYet: "SQL はまだありません",
copySql: "SQL をコピー",
tableName: "テーブル名",
tableNameRequired: "テーブル名を入力してください",
tableNameExists: "同名のテーブルが既に存在します",
assignLayer: "所属レイヤー",
noLayer: "レイヤーなし",
withDefaultIdPk: "デフォルトの id 主キーを含める",
inspectorTable: "テーブル",
inspectorRelationship: "リレーションシップ",
fieldComment: "コメント",
fields: "フィールド",
tabFields: "フィールド",
tabIndexes: "インデックス",
addIndex: "インデックスを追加",
indexName: "インデックス名",
indexUnique: "UNIQUE",
indexColumns: "列",
noIndexesYet: "インデックスはまだありません",
indexesNotSupported: "現在のデータベースはダイアグラムエディタでのインデックス作成をサポートしていません",
addField: "フィールドを追加",
deleteField: "フィールドを削除",
undoDropField: "フィールド削除を元に戻す",
noFieldsYet: "フィールドがまだありません。追加後に同期できます",
deleteDraftTable: "下書きテーブルを削除",
deleteLiveTable: "テーブルを削除",
deleteLiveTableHint: "削除待ちとしてマークされ、次回「データベースに同期」時に DROP TABLE を実行します。元に戻すことで復元できます。",
deleteLiveTableConfirm: "このテーブルを削除としてマークしますか?同期時に DROP TABLE が実行されます。",
liveTableReadOnly: "登録済みテーブル — テーブル構造エディタで変更してください",
liveTableAddColumnsHint: "フィールドの追加ALTER ADDまたは既存フィールドの削除マークALTER DROPが可能です。その後同期します。型変更などはテーブル構造エディタを使用してください。",
liveTableIndexesReadOnly: "登録済みテーブルのインデックスはテーブル構造エディタで変更してください",
pendingColumnsBadge: "同期待ちの変更",
pendingDropColumnBadge: "削除待ち",
pendingDropTableBadge: "削除待ちテーブル",
source: "ソース",
target: "ターゲット",
emptyDesignHint: "テーブルがまだありません。レイヤーとテーブルを作成し、プロパティパネルでフィールドを編集して、完了後にデータベースに同期できます。",
removeFromLayer: "レイヤーから削除",
addTables: "テーブルを追加",
tabSelectExisting: "既存を選択",
tabCreateTable: "新規テーブル",
multiSelect: "複数選択",
closeMultiSelect: "閉じる",
selectAll: "すべて選択",
clearSelection: "クリア",
tablesInOtherLayers: "他のレイヤーのテーブル",
filterTables: "テーブルをフィルタ...",
noMatchingTables: "一致するテーブルがありません",
addSelectedCount: "選択した {count} 件を追加",
layerNameTooLong: "レイヤー名は50文字以内にしてください",
layerNameInvalid: "レイヤー名には中国語、英字、数字、スペース、アンダースコア、ハイフン、ピリオドを使用できます",
layerNameExists: "レイヤー名が既に存在します",
total: "合計",
confirmed: "確認済み",
pending: "確認待ち",
conflicts: "競合",
conflict: "競合",
ignored: "無視済み",
confirmAll: "すべて確認",
ignoreAll: "すべて無視",
clearAll: "すべてクリア",
confidenceHigh: "高",
confidenceMedium: "中",
confidenceLow: "低",
noInferred: "推論された関係はありません",
editRelationship: "編集",
saveRelationship: "保存",
deleteRelationship: "削除",
confirmDeleteRelationship: "このリレーションシップを削除しますか?",
confirmDeleteRelationshipAction: "削除を確認",
confirmRelationship: "確認",
ignoreRelationship: "無視",
relationshipKindFk: "外部キー",
relationshipKindCustom: "カスタム",
relationshipKindInferred: "推論",
relationshipReadOnlyFk: "このリレーションシップはデータベースの外部キー制約に基づいており、ここでは変更できません。",
relationshipUpdated: "リレーションシップが更新されました",
createTableNotSupported: "現在のデータベースは新しいテーブルの作成をサポートしていません",
structureSyncNotSupported: "現在のデータベースは図からテーブル構造を同期することをサポートしていません",
addColumnNotSupported: "現在のデータベースは列の追加をサポートしていません",
dropColumnNotSupported: "現在のデータベースは列の削除をサポートしていません",
dropTableNotSupported: "現在のデータベースは図からテーブルを削除することをサポートしていません",
},
etcd: {
prefixPlaceholder: "Key パスを検索(例: /app/、service",

View File

@ -2749,10 +2749,10 @@ export default withEnglishFallback({
targetTable: "Tabela de destino",
targetColumn: "Coluna de destino",
cardinality: "Cardinalidade",
cardinalityOneToMany: "1:N",
cardinalityManyToOne: "N:1",
cardinalityManyToMany: "N:N",
cardinalityOneToOne: "1:1",
cardinalityOneToMany: "Origem 1 · Destino N",
cardinalityManyToOne: "Origem N · Destino 1",
cardinalityManyToMany: "Origem N · Destino N",
cardinalityOneToOne: "Origem 1 · Destino 1",
addRelationship: "Adicionar relacionamento",
removeRelationship: "Remover relacionamento",
relationshipIncomplete: "Selecione as colunas de origem e destino",
@ -2769,6 +2769,130 @@ export default withEnglishFallback({
zoomIn: "Aumentar zoom",
zoomOut: "Diminuir zoom",
resetLayout: "Redefinir layout",
refreshConfirm: "Atualizar recarregará a estrutura da tabela e redefinirá o layout do canvas e o histórico de desfazer. Os relacionamentos de modelagem salvos localmente não serão afetados. Continuar?",
layers: "Camadas",
autoMatch: "Correspondência automática",
autoLayout: "Layout automático",
matchRelationshipsCount: "{count} correspondências",
cardinalityDirected: "Origem {source} · Destino {target}",
matchPickCardinality: "Selecione a cardinalidade antes de confirmar a correspondência (1:1 / 1:N / N:1 / N:N).",
manualAddRelationship: "Adicionar manualmente",
confirmMatch: "Confirmar",
ignoreMatch: "Ignorar",
exportPng: "Exportar PNG",
exportJson: "Exportar JSON",
exportDbml: "Exportar DBML",
exportMermaid: "Exportar Mermaid",
export: "Exportar",
exportedFormat: "{format} exportado",
exportFailed: "Falha ao exportar {format}: {message}",
fitView: "Ajustar ao canvas",
resetZoom: "Redefinir zoom",
undo: "Desfazer",
redo: "Refazer",
fullscreen: "Tela cheia",
exitFullscreen: "Sair da tela cheia",
addLayer: "Adicionar camada",
noLayersYet: "Nenhuma camada ainda, clique em + para criar",
layoutFree: "Livre",
layoutAuto: "Automático",
layerLock: "Bloquear camada (sem layout automático dentro da camada)",
layerUnlock: "Desbloquear camada (permite layout automático dentro da camada)",
layerLocked: "Bloqueada",
layerUnlocked: "Desbloqueada",
hideLayer: "Ocultar camada",
showLayer: "Mostrar camada",
deleteLayer: "Excluir camada",
layerHiddenCannotFocus: "A camada está oculta. Mostre-a antes de centralizar no canvas.",
createTable: "Nova tabela",
create: "Criar",
syncToDatabase: "Sincronizar com o banco de dados",
syncDraftCount: "Serão sincronizadas {count} alterações (nova tabela / alterar tabela / excluir tabela)",
executeSync: "Executar",
syncing: "Sincronizando…",
buildingSql: "Gerando SQL…",
noSqlYet: "Nenhum SQL ainda",
copySql: "Copiar SQL",
tableName: "Nome da tabela",
tableNameRequired: "Informe o nome da tabela",
tableNameExists: "Já existe uma tabela com esse nome",
assignLayer: "Camada",
noLayer: "Sem camada",
withDefaultIdPk: "Incluir chave primária id padrão",
inspectorTable: "Tabela",
inspectorRelationship: "Relacionamento",
fieldComment: "Comentário",
fields: "Campos",
tabFields: "Campos",
tabIndexes: "Índices",
addIndex: "Adicionar índice",
indexName: "Nome do índice",
indexUnique: "UNIQUE",
indexColumns: "Colunas",
noIndexesYet: "Nenhum índice ainda",
indexesNotSupported: "O banco de dados atual não suporta criar índices no editor de diagramas",
addField: "Adicionar campo",
deleteField: "Excluir campo",
undoDropField: "Desfazer exclusão do campo",
noFieldsYet: "Nenhum campo ainda. Adicione antes de sincronizar",
deleteDraftTable: "Excluir tabela de rascunho",
deleteLiveTable: "Excluir tabela",
deleteLiveTableHint: "Marcada para exclusão. O DROP TABLE será executado na próxima sincronização com o banco de dados. Você pode desfazer para restaurar.",
deleteLiveTableConfirm: "Confirmar marcação desta tabela para exclusão? O DROP TABLE será executado durante a sincronização.",
liveTableReadOnly: "Tabela no banco — edite no editor de estrutura da tabela",
liveTableAddColumnsHint: "Você pode adicionar campos (ALTER ADD) ou marcar campos existentes para exclusão (ALTER DROP) e depois sincronizar. Para alterar tipos, use o editor de estrutura da tabela.",
liveTableIndexesReadOnly: "Índices de tabelas no banco devem ser alterados no editor de estrutura da tabela",
pendingColumnsBadge: "Alterações pendentes de sincronização",
pendingDropColumnBadge: "A excluir",
pendingDropTableBadge: "Tabela a excluir",
source: "Origem",
target: "Destino",
emptyDesignHint: "Nenhuma tabela ainda. Você pode criar camadas e tabelas, editar campos no painel de propriedades e sincronizar com o banco de dados ao concluir.",
removeFromLayer: "Remover da camada",
addTables: "Adicionar tabelas",
tabSelectExisting: "Selecionar existente",
tabCreateTable: "Nova tabela",
multiSelect: "Seleção múltipla",
closeMultiSelect: "Fechar",
selectAll: "Selecionar tudo",
clearSelection: "Limpar seleção",
tablesInOtherLayers: "Tabelas em outras camadas",
filterTables: "Filtrar tabelas...",
noMatchingTables: "Nenhuma tabela correspondente",
addSelectedCount: "Adicionar {count} selecionados",
layerNameTooLong: "O nome da camada não pode exceder 50 caracteres",
layerNameInvalid: "O nome da camada pode conter caracteres chineses, letras, números, espaços, sublinhados, hífens e pontos finais.",
layerNameExists: "O nome da camada já existe",
total: "Total",
confirmed: "Confirmado",
pending: "Pendente",
conflicts: "Conflitos",
conflict: "Conflito",
ignored: "Ignorado",
confirmAll: "Confirmar tudo",
ignoreAll: "Ignorar tudo",
clearAll: "Limpar tudo",
confidenceHigh: "Alta",
confidenceMedium: "Média",
confidenceLow: "Baixa",
noInferred: "Nenhum relacionamento inferido ainda",
editRelationship: "Editar",
saveRelationship: "Salvar",
deleteRelationship: "Excluir",
confirmDeleteRelationship: "Confirmar exclusão deste relacionamento?",
confirmDeleteRelationshipAction: "Confirmar exclusão",
confirmRelationship: "Confirmar",
ignoreRelationship: "Ignorar",
relationshipKindFk: "Chave estrangeira",
relationshipKindCustom: "Personalizado",
relationshipKindInferred: "Inferido",
relationshipReadOnlyFk: "Este relacionamento vem de uma restrição de chave estrangeira do banco de dados e não pode ser editado aqui.",
relationshipUpdated: "Relacionamento atualizado",
createTableNotSupported: "O banco de dados atual não suporta criar nova tabela",
structureSyncNotSupported: "O banco de dados atual não suporta sincronizar a estrutura da tabela a partir do diagrama",
addColumnNotSupported: "O banco de dados atual não suporta adicionar coluna",
dropColumnNotSupported: "O banco de dados atual não suporta excluir coluna",
dropTableNotSupported: "O banco de dados atual não suporta excluir tabela do diagrama",
},
etcd: {
prefixPlaceholder: "Buscar caminho da Key, por exemplo /app/ ou serviço",

View File

@ -2878,6 +2878,7 @@ export default withEnglishFallback({
selectSchema: "选择模式",
searchTables: "搜索表/字段/外键...",
refresh: "刷新关系图",
refreshConfirm: "刷新将重新加载表结构并重置画布布局与撤回历史,本地已保存的建模关系不受影响。确定继续?",
loading: "正在读取关系...",
loadingProgress: "正在读取关系... {loaded}/{total}",
partialError: "{count} 张表的元数据读取失败,已跳过",
@ -2889,6 +2890,10 @@ export default withEnglishFallback({
tableMode: "表结构图",
engineeringMode: "工程 ER 图",
modelRelationships: "建模关系",
layers: "Layers",
autoMatch: "自动匹配",
autoLayout: "自动布局",
matchRelationshipsCount: "{count} 条匹配",
copyJoinSql: "复制 SQL",
customRelationshipsCount: "{count} 条建模",
relationshipName: "名称",
@ -2898,10 +2903,15 @@ export default withEnglishFallback({
targetTable: "目标表",
targetColumn: "目标字段",
cardinality: "基数",
cardinalityOneToMany: "1:N",
cardinalityManyToOne: "N:1",
cardinalityManyToMany: "N:N",
cardinalityOneToOne: "1:1",
cardinalityOneToMany: "源 1 · 目标 N",
cardinalityManyToOne: "源 N · 目标 1",
cardinalityOneToOne: "源 1 · 目标 1",
cardinalityManyToMany: "源 N · 目标 N",
cardinalityDirected: "源 {source} · 目标 {target}",
matchPickCardinality: "确认匹配前请选择基数1:1 / 1:N / N:1 / N:N。",
manualAddRelationship: "手动补录",
confirmMatch: "确认",
ignoreMatch: "忽略",
addRelationship: "添加关系",
removeRelationship: "删除关系",
relationshipIncomplete: "请选择源字段和目标字段",
@ -2913,11 +2923,125 @@ export default withEnglishFallback({
relatedTables: "相关表",
moreColumns: "+ {count} 个字段",
exportSvg: "导出 SVG",
exportPng: "导出 PNG",
exportJson: "导出 JSON",
exportDbml: "导出 DBML",
exportMermaid: "导出 Mermaid",
export: "导出",
exportedSvg: "SVG 已导出",
exportedFormat: "{format} 已导出",
exportSvgFailed: "导出 SVG 失败:{message}",
exportFailed: "导出 {format} 失败:{message}",
zoomIn: "放大",
zoomOut: "缩小",
fitView: "适应画布",
resetZoom: "重置缩放",
undo: "撤回",
redo: "前进",
resetLayout: "重置布局",
fullscreen: "全屏",
exitFullscreen: "退出全屏",
addLayer: "新增层",
noLayersYet: "暂无图层,点击 + 创建",
layoutFree: "自由",
layoutAuto: "自动",
layerLock: "锁定层(层内不自动布局)",
layerUnlock: "解锁层(层内允许自动布局)",
layerLocked: "已锁定",
layerUnlocked: "未锁定",
hideLayer: "隐藏层",
showLayer: "显示层",
deleteLayer: "删除层",
layerHiddenCannotFocus: "层已隐藏,请先显示后再定位到画布",
createTable: "新建表",
createTableNotSupported: "当前数据库不支持新建表",
create: "创建",
syncToDatabase: "同步到数据库",
structureSyncNotSupported: "当前数据库不支持从图同步表结构",
syncDraftCount: "将同步 {count} 项变更(新建 / 改表 / 删表)",
executeSync: "执行",
syncing: "同步中…",
buildingSql: "正在生成 SQL…",
noSqlYet: "暂无 SQL",
copySql: "复制 SQL",
tableName: "表名",
tableNameRequired: "请填写表名",
tableNameExists: "已存在同名表",
assignLayer: "所属层",
noLayer: "不分层",
withDefaultIdPk: "包含默认 id 主键",
inspectorTable: "表",
inspectorRelationship: "关系",
fieldComment: "注释",
fields: "字段",
tabFields: "字段",
tabIndexes: "索引",
addIndex: "添加索引",
indexName: "索引名",
indexUnique: "UNIQUE",
indexColumns: "列",
noIndexesYet: "还没有索引",
indexesNotSupported: "当前数据库不支持在图编辑器中创建索引",
addColumnNotSupported: "当前数据库不支持添加列",
dropColumnNotSupported: "当前数据库不支持删除列",
dropTableNotSupported: "当前数据库不支持从图删除表",
addField: "添加字段",
deleteField: "删除字段",
undoDropField: "撤销删除字段",
noFieldsYet: "还没有字段,添加后才能同步",
deleteDraftTable: "删除草稿表",
deleteLiveTable: "删除表",
deleteLiveTableHint: "标记为待删除,下次「同步到数据库」时执行 DROP TABLE。可用撤销恢复。",
deleteLiveTableConfirm: "确认将此表标记为删除?同步时将执行 DROP TABLE。",
liveTableReadOnly: "已入库表 — 请在表结构编辑器中修改",
liveTableAddColumnsHint: "可新增字段ALTER ADD或标记已有字段删除ALTER DROP然后同步。改类型等请用表结构编辑器。",
liveTableIndexesReadOnly: "已入库表索引请在表结构编辑器中修改",
pendingColumnsBadge: "待同步变更",
pendingDropColumnBadge: "待删",
pendingDropTableBadge: "待删表",
source: "源",
target: "目标",
emptyDesignHint: "还没有表。可新建层与表,在属性面板编辑字段,完成后同步到数据库。",
removeFromLayer: "从层中移除",
addTables: "添加表",
tabSelectExisting: "选择已有",
tabCreateTable: "新建表",
multiSelect: "多选",
closeMultiSelect: "关闭",
selectAll: "全选",
clearSelection: "清空",
tablesInOtherLayers: "其他层中的表",
filterTables: "筛选表...",
noMatchingTables: "无匹配的表",
addSelectedCount: "添加已选 {count} 项",
layerNameTooLong: "图层名称不能超过 50 个字符",
layerNameInvalid: "图层名称可含中文、字母、数字、空格、下划线、连字符和句点",
layerNameExists: "图层名称已存在",
total: "总计",
confirmed: "已确认",
pending: "待确认",
conflicts: "冲突",
conflict: "冲突",
ignored: "已忽略",
confirmAll: "全部确认",
ignoreAll: "全部忽略",
clearAll: "清除全部",
confidenceHigh: "高",
confidenceMedium: "中",
confidenceLow: "低",
noInferred: "暂无推断关系",
editRelationship: "编辑",
saveRelationship: "保存",
deleteRelationship: "删除",
confirmDeleteRelationship: "确认删除此关系?",
confirmDeleteRelationshipAction: "确认删除",
confirmRelationship: "确认",
ignoreRelationship: "忽略",
relationshipKindFk: "外键",
relationshipKindCustom: "自定义",
relationshipKindInferred: "推断",
relationshipReadOnlyFk: "该关系来自数据库外键约束,无法在此修改。",
relationshipUpdated: "关系已更新",
},
etcd: {
prefixPlaceholder: "搜索 Key 路径,例如 /app/ 或 service",

View File

@ -2626,10 +2626,10 @@ export default withEnglishFallback({
targetTable: "目標資料表",
targetColumn: "目標欄位",
cardinality: "基數",
cardinalityOneToMany: "1:N",
cardinalityManyToOne: "N:1",
cardinalityManyToMany: "N:N",
cardinalityOneToOne: "1:1",
cardinalityOneToMany: "來源 1 · 目標 N",
cardinalityManyToOne: "來源 N · 目標 1",
cardinalityManyToMany: "來源 N · 目標 N",
cardinalityOneToOne: "來源 1 · 目標 1",
addRelationship: "新增關係",
removeRelationship: "移除關係",
relationshipIncomplete: "請同時選擇來源和目標欄位",
@ -2637,6 +2637,130 @@ export default withEnglishFallback({
relationshipExists: "此關係已存在",
relationshipAdded: "關係已新增",
noJoinSql: "沒有可複製的關係 SQL",
refreshConfirm: "重新整理將重新載入表結構並重設畫布佈局與復原歷史,本機已儲存的建模關係不受影響。確定繼續?",
layers: "圖層",
autoMatch: "自動匹配",
autoLayout: "自動佈局",
matchRelationshipsCount: "{count} 條匹配",
cardinalityDirected: "來源 {source} · 目標 {target}",
matchPickCardinality: "確認匹配前請選擇基數1:1 / 1:N / N:1 / N:N。",
manualAddRelationship: "手動補錄",
confirmMatch: "確認",
ignoreMatch: "忽略",
exportPng: "匯出 PNG",
exportJson: "匯出 JSON",
exportDbml: "匯出 DBML",
exportMermaid: "匯出 Mermaid",
export: "匯出",
exportedFormat: "{format} 已匯出",
exportFailed: "匯出 {format} 失敗:{message}",
fitView: "適應畫布",
resetZoom: "重設縮放",
undo: "復原",
redo: "重做",
fullscreen: "全螢幕",
exitFullscreen: "退出全螢幕",
addLayer: "新增圖層",
noLayersYet: "尚無圖層,點擊 + 建立",
layoutFree: "自由",
layoutAuto: "自動",
layerLock: "鎖定圖層(圖層內不自動佈局)",
layerUnlock: "解鎖圖層(圖層內允許自動佈局)",
layerLocked: "已鎖定",
layerUnlocked: "未鎖定",
hideLayer: "隱藏圖層",
showLayer: "顯示圖層",
deleteLayer: "刪除圖層",
layerHiddenCannotFocus: "圖層已隱藏,請先顯示後再定位到畫布",
createTable: "新增資料表",
create: "建立",
syncToDatabase: "同步到資料庫",
syncDraftCount: "將同步 {count} 項變更(新增 / 修改表 / 刪除表)",
executeSync: "執行",
syncing: "同步中...",
buildingSql: "正在產生 SQL...",
noSqlYet: "暫無 SQL",
copySql: "複製 SQL",
tableName: "資料表名稱",
tableNameRequired: "請填寫資料表名稱",
tableNameExists: "已存在相同名稱的資料表",
assignLayer: "所屬圖層",
noLayer: "不分圖層",
withDefaultIdPk: "包含預設 id 主鍵",
inspectorTable: "資料表",
inspectorRelationship: "關係",
fieldComment: "註解",
fields: "欄位",
tabFields: "欄位",
tabIndexes: "索引",
addIndex: "新增索引",
indexName: "索引名稱",
indexUnique: "UNIQUE",
indexColumns: "欄",
noIndexesYet: "尚無索引",
indexesNotSupported: "目前資料庫不支援在圖形編輯器中建立索引",
addField: "新增欄位",
deleteField: "刪除欄位",
undoDropField: "復原刪除欄位",
noFieldsYet: "尚無欄位,新增後才能同步",
deleteDraftTable: "刪除草稿資料表",
deleteLiveTable: "刪除資料表",
deleteLiveTableHint: "標記為待刪除,下次「同步到資料庫」時執行 DROP TABLE。可用復原恢復。",
deleteLiveTableConfirm: "確認將此資料表標記為刪除?同步時將執行 DROP TABLE。",
liveTableReadOnly: "已入庫資料表 — 請在資料表結構編輯器中修改",
liveTableAddColumnsHint: "可新增欄位ALTER ADD或標記已有欄位刪除ALTER DROP然後同步。變更類型等請使用資料表結構編輯器。",
liveTableIndexesReadOnly: "已入庫資料表索引請在資料表結構編輯器中修改",
pendingColumnsBadge: "待同步變更",
pendingDropColumnBadge: "待刪除",
pendingDropTableBadge: "待刪除資料表",
source: "來源",
target: "目標",
emptyDesignHint: "尚無資料表。可新增圖層與資料表,在屬性面板編輯欄位,完成後同步到資料庫。",
removeFromLayer: "從圖層中移除",
addTables: "新增資料表",
tabSelectExisting: "選擇既有",
tabCreateTable: "新增資料表",
multiSelect: "多選",
closeMultiSelect: "關閉",
selectAll: "全選",
clearSelection: "清除",
tablesInOtherLayers: "其他圖層中的資料表",
filterTables: "篩選資料表...",
noMatchingTables: "無匹配的資料表",
addSelectedCount: "新增已選 {count} 項",
layerNameTooLong: "圖層名稱不能超過 50 個字元",
layerNameInvalid: "圖層名稱可含中文、字母、數字、空格、底線、連字符和句點",
layerNameExists: "圖層名稱已存在",
total: "總計",
confirmed: "已確認",
pending: "待確認",
conflicts: "衝突",
conflict: "衝突",
ignored: "已忽略",
confirmAll: "全部確認",
ignoreAll: "全部忽略",
clearAll: "清除全部",
confidenceHigh: "高",
confidenceMedium: "中",
confidenceLow: "低",
noInferred: "暫無推斷關係",
editRelationship: "編輯",
saveRelationship: "儲存",
deleteRelationship: "刪除",
confirmDeleteRelationship: "確認刪除此關係?",
confirmDeleteRelationshipAction: "確認刪除",
confirmRelationship: "確認",
ignoreRelationship: "忽略",
relationshipKindFk: "外鍵",
relationshipKindCustom: "自訂",
relationshipKindInferred: "推斷",
relationshipReadOnlyFk: "該關係來自資料庫外鍵約束,無法在此修改。",
relationshipUpdated: "關係已更新",
createTableNotSupported: "目前資料庫不支援建立新資料表",
structureSyncNotSupported: "目前資料庫不支援從圖同步資料表結構",
addColumnNotSupported: "目前資料庫不支援新增欄位",
dropColumnNotSupported: "目前資料庫不支援刪除欄位",
dropTableNotSupported: "目前資料庫不支援從圖刪除資料表",
},
redis: {
setDatabaseAlias: "設定資料庫別名",

View File

@ -288,12 +288,17 @@ describe("tableStructureEditorState", () => {
expect(getDefaultLengthForType("mysql", "float")).toBe("10,2");
});
it("uses TEXT for a new native SQLite column without changing compatible defaults", () => {
it("uses TEXT for SQLite-family columns and dialect defaults elsewhere", () => {
expect(DATA_TYPE_OPTIONS.sqlite).toContain("text");
expect(DATA_TYPE_OPTIONS.duckdb).toContain("TEXT");
expect(DATA_TYPE_OPTIONS.h2).toContain("VARCHAR");
expect(defaultNewColumnDataType("sqlite")).toBe("text");
expect(defaultNewColumnDataType("rqlite")).toBe("varchar(255)");
expect(defaultNewColumnDataType("turso")).toBe("varchar(255)");
expect(defaultNewColumnDataType("rqlite")).toBe("text");
expect(defaultNewColumnDataType("turso")).toBe("text");
expect(defaultNewColumnDataType("duckdb")).toBe("TEXT");
expect(defaultNewColumnDataType("mysql")).toBe("varchar(255)");
expect(defaultNewColumnDataType("h2").toLowerCase()).toContain("varchar");
expect(defaultNewColumnDataType("clickhouse")).toBe("String");
});
it("requires a SQLite rebuild only for a retained existing column type change", () => {

View File

@ -158,6 +158,7 @@ export const listCompletionObjects = forward("listCompletionObjects");
export const completionAssistantSearch = forward("completionAssistantSearch");
export const getObjectSource = forward("getObjectSource");
export const getColumns = forward("getColumns");
export const getAllColumns = forward("getAllColumns");
export const getSqlServerColumnMetadata = forward("getSqlServerColumnMetadata");
export const listDataTypes = forward("listDataTypes");
export const listIndexes = forward("listIndexes");

View File

@ -744,6 +744,16 @@ export async function getSqlServerColumnMetadata(connectionId: string, database:
return get(`/api/schema/sqlserver/column-metadata?${qs({ connection_id: connectionId, database, schema, table })}`);
}
export interface TableColumnsResult {
table_name: string;
columns: ColumnInfo[];
error?: string;
}
export async function getAllColumns(connectionId: string, database: string, schema: string): Promise<TableColumnsResult[]> {
return get(`/api/schema/all-columns?${qs({ connection_id: connectionId, database, schema })}`);
}
export async function listDataTypes(connectionId: string, database: string): Promise<string[]> {
return get(`/api/schema/data-types?${qs({ connection_id: connectionId, database })}`);
}

View File

@ -1042,6 +1042,16 @@ export async function getSqlServerColumnMetadata(connectionId: string, database:
});
}
export interface TableColumnsResult {
table_name: string;
columns: ColumnInfo[];
error?: string;
}
export async function getAllColumns(connectionId: string, database: string, schema: string): Promise<TableColumnsResult[]> {
return invoke("get_all_columns", { connectionId, database, schema });
}
export async function listDataTypes(connectionId: string, database: string): Promise<string[]> {
return invoke("list_data_types", { connectionId, database });
}

View File

@ -0,0 +1,35 @@
export type CardinalityChoice = "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many";
export type CardinalityPair = {
sourceCardinality: "1" | "N";
targetCardinality: "1" | "N";
};
export function cardinalityPairFromChoice(choice: CardinalityChoice): CardinalityPair {
if (choice === "one-to-one") return { sourceCardinality: "1", targetCardinality: "1" };
if (choice === "one-to-many") return { sourceCardinality: "1", targetCardinality: "N" };
if (choice === "many-to-many") return { sourceCardinality: "N", targetCardinality: "N" };
return { sourceCardinality: "N", targetCardinality: "1" };
}
export function cardinalityChoiceFromPair(pair: Partial<CardinalityPair> | null | undefined): CardinalityChoice {
if (!pair) return "many-to-one";
const s = pair.sourceCardinality;
const t = pair.targetCardinality;
if (s === "1" && t === "1") return "one-to-one";
if (s === "1" && t === "N") return "one-to-many";
if (s === "N" && t === "N") return "many-to-many";
return "many-to-one";
}
/** Resolve endpoint cardinalities from a relationship; FK/inferred without explicit pair defaults to N:1. */
export function edgeCardinalityPair(rel: Partial<CardinalityPair> | object | null | undefined): CardinalityPair {
if (rel && typeof rel === "object" && "sourceCardinality" in rel && "targetCardinality" in rel) {
const sourceCardinality = (rel as Partial<CardinalityPair>).sourceCardinality;
const targetCardinality = (rel as Partial<CardinalityPair>).targetCardinality;
if (sourceCardinality && targetCardinality) {
return { sourceCardinality, targetCardinality };
}
}
return { sourceCardinality: "N", targetCardinality: "1" };
}

View File

@ -0,0 +1,52 @@
/** provide/inject key for the currently hovered relationship edge id */
export const DIAGRAM_HOVERED_EDGE_KEY = "diagramHoveredEdgeId";
/** provide/inject key for table/layer obstacle rects used by edge routing */
export const DIAGRAM_EDGE_OBSTACLES_KEY = "diagramEdgeObstacles";
export const CARD_WIDTH = 360;
export const CARD_HEADER_HEIGHT = 44;
export const COLUMN_ROW_HEIGHT = 24;
export const CARD_BOTTOM_PADDING = 12;
/** Fixed width for the data-type column inside a table card */
export const COLUMN_TYPE_WIDTH = 112;
/** Display truncation caps (CSS truncate + char limit) */
export const COLUMN_NAME_MAX_CHARS = 36;
export const COLUMN_TYPE_MAX_CHARS = 22;
export const TABLE_NAME_MAX_CHARS = 40;
export const GAP_X = 80;
export const GAP_Y = 60;
export const MARGIN = 40;
/** Handle inset from card edge (0 = centered on border) */
export const EDGE_HANDLE_OUTSET = 0;
/** Extra clearance before orthogonal bends around a node */
export const EDGE_ROUTE_OFFSET = 36;
/** Idle / hover stroke widths for relationship edges */
export const EDGE_STROKE_IDLE = 1.6;
export const EDGE_STROKE_HOVER = 3.5;
/** Delay before hover opens edge detail popover / highlight */
export const EDGE_POPOVER_OPEN_DELAY_MS = 400;
/** Delay before hover popover closes after leaving edge / popover */
export const EDGE_POPOVER_CLOSE_DELAY_MS = 220;
/** Padding used by ELK nested layer boxes */
export const LAYER_PADDING = 30;
export const LAYER_HEADER_HEIGHT = 40;
/** Tighter padding for Size-to-Fit / empty layer chrome */
export const LAYER_CONTENT_PADDING = 20;
export const EMPTY_LAYER_WIDTH = 240;
export const EMPTY_LAYER_HEIGHT = LAYER_HEADER_HEIGHT + LAYER_CONTENT_PADDING;
export function tableCardHeight(columnCount: number): number {
return CARD_HEADER_HEIGHT + columnCount * COLUMN_ROW_HEIGHT + CARD_BOTTOM_PADDING;
}
/** LTR wrap column count from available canvas width (at least 1). */
export function columnsPerRowForWidth(viewportWidth?: number): number {
if (!viewportWidth || viewportWidth <= 0) return 6;
const usable = viewportWidth - MARGIN * 2;
const cols = Math.floor(usable / (CARD_WIDTH + GAP_X));
return Math.max(1, cols < 1 ? 1 : cols);
}

View File

@ -0,0 +1,75 @@
/**
* Thin column factories for the ER diagram.
*
* Dialect capability / SQL truth lives in the shared table-structure stack:
* - `tableStructureCapabilities` (UI gates)
* - `tableStructureEditorState` (type options / defaults)
* - Rust `table_structure_sql` via `buildCreateTableSql` / `buildTableStructureChangeSql`
*
* Do not add ER-specific dialect matrices or SQL generation here.
* Adding a dialect: update that shared stack (same checklist as TableStructureEditor).
*/
import type { ColumnInfo, DatabaseType } from "@/types/database";
import { getTableStructureCapabilities, type TableStructureDialect } from "@/lib/table/tableStructureCapabilities";
import { defaultNewColumnDataType, getDataTypeOptions } from "@/lib/table/tableStructureEditorState";
export interface DiagramDialectAdapter {
databaseType: DatabaseType | undefined;
createDefaultIdColumn(): ColumnInfo;
createEmptyColumn(name?: string): ColumnInfo;
}
const DEFAULT_ID_TYPE_BY_DIALECT: Partial<Record<TableStructureDialect, string>> = {
mysql: "bigint",
postgres: "bigint",
sqlserver: "bigint",
h2: "bigint",
informix: "bigint",
oracle: "NUMBER",
sqlite: "INTEGER",
duckdb: "INTEGER",
clickhouse: "UInt64",
};
function resolveDefaultIdType(dialect: TableStructureDialect, dataTypeOptions: readonly string[]): string {
const preferred = DEFAULT_ID_TYPE_BY_DIALECT[dialect];
if (preferred) {
if (dataTypeOptions.length === 0) return preferred;
const matched = dataTypeOptions.find((type) => type.trim().toLowerCase() === preferred.toLowerCase());
if (matched) return matched;
}
const integerLike = dataTypeOptions.find((type) => /^(bigint|int|integer|number|uint64|int64)/i.test(type.trim()));
if (integerLike) return integerLike;
return preferred ?? dataTypeOptions[0] ?? "bigint";
}
export function resolveDiagramDialectAdapter(databaseType?: DatabaseType): DiagramDialectAdapter {
const caps = getTableStructureCapabilities(databaseType);
const dataTypeOptions = getDataTypeOptions(databaseType);
return {
databaseType,
createDefaultIdColumn() {
return {
name: "id",
data_type: resolveDefaultIdType(caps.dialect, dataTypeOptions),
is_nullable: false,
column_default: null,
is_primary_key: true,
comment: null,
extra: null,
};
},
createEmptyColumn(name = "column_1") {
return {
name,
data_type: defaultNewColumnDataType(databaseType, dataTypeOptions),
is_nullable: true,
column_default: null,
is_primary_key: false,
comment: null,
extra: null,
};
},
};
}

View File

@ -0,0 +1,185 @@
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import type { ColumnInfo } from "@/types/database";
import type { DiagramPosition, DiagramTable } from "./erDiagram";
import { isLiveTable, needsDiagramSync } from "./erDiagram";
import type { DiagramLayer } from "@/types/diagram";
export interface LiveTablePatch {
tableName: string;
pendingColumns: ColumnInfo[];
droppedColumnNames?: string[];
pendingDrop?: boolean;
}
function storageKey(kind: "draft-tables" | "layers" | "positions" | "live-patches", connectionId: string, database: string, schema: string): string {
return ["dbx", "diagram", kind, "v1", connectionId, database, schema].join(":");
}
function isValidColumn(value: unknown): value is ColumnInfo {
if (!value || typeof value !== "object") return false;
const col = value as Partial<ColumnInfo>;
return typeof col.name === "string" && typeof col.data_type === "string" && typeof col.is_nullable === "boolean";
}
function sanitizeStringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === "string" && item.length > 0);
}
function sanitizePositions(raw: unknown): Record<string, DiagramPosition> {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const out: Record<string, DiagramPosition> = {};
for (const [name, value] of Object.entries(raw as Record<string, unknown>)) {
if (!name || typeof value !== "object" || value == null || Array.isArray(value)) continue;
const x = (value as { x?: unknown }).x;
const y = (value as { y?: unknown }).y;
if (typeof x !== "number" || typeof y !== "number" || !Number.isFinite(x) || !Number.isFinite(y)) continue;
out[name] = { x, y };
}
return out;
}
function parseJson<T>(raw: string | null, fallback: T): T {
if (!raw) return fallback;
try {
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
export function loadDraftTables(connectionId: string, database: string, schema: string): DiagramTable[] {
if (!connectionId || !database) return [];
const key = storageKey("draft-tables", connectionId, database, schema);
const list = parseJson<DiagramTable[]>(safeLocalStorageGet(key), []);
return list
.filter((t) => t && typeof t.name === "string" && Array.isArray(t.columns))
.map((t) => ({
...t,
foreignKeys: t.foreignKeys || [],
origin: "draft" as const,
syncStatus: t.syncStatus === "error" ? "error" : "pending",
}));
}
export function saveDraftTables(tables: DiagramTable[], connectionId: string, database: string, schema: string): void {
if (!connectionId || !database) return;
const key = storageKey("draft-tables", connectionId, database, schema);
const drafts = tables.filter((t) => (t.origin ?? "live") === "draft");
safeLocalStorageSet(key, JSON.stringify(drafts));
}
export function loadPersistedLayers(connectionId: string, database: string, schema: string): DiagramLayer[] {
if (!connectionId || !database) return [];
const key = storageKey("layers", connectionId, database, schema);
return parseJson<DiagramLayer[]>(safeLocalStorageGet(key), []);
}
export function savePersistedLayers(layers: DiagramLayer[], connectionId: string, database: string, schema: string): void {
if (!connectionId || !database) return;
const key = storageKey("layers", connectionId, database, schema);
safeLocalStorageSet(key, JSON.stringify(layers));
}
export function loadPersistedPositions(connectionId: string, database: string, schema: string): Record<string, DiagramPosition> {
if (!connectionId || !database) return {};
const key = storageKey("positions", connectionId, database, schema);
return sanitizePositions(parseJson<unknown>(safeLocalStorageGet(key), {}));
}
export function savePersistedPositions(positions: Record<string, DiagramPosition>, connectionId: string, database: string, schema: string): void {
if (!connectionId || !database) return;
const key = storageKey("positions", connectionId, database, schema);
safeLocalStorageSet(key, JSON.stringify(sanitizePositions(positions)));
}
/** True when at least one known table name has a finite saved position. */
export function hasUsablePersistedPositions(saved: Record<string, DiagramPosition>, tableNames: string[]): boolean {
for (const name of tableNames) {
const pos = saved[name];
if (pos && Number.isFinite(pos.x) && Number.isFinite(pos.y)) return true;
}
return false;
}
export function loadLiveTablePatches(connectionId: string, database: string, schema: string): LiveTablePatch[] {
if (!connectionId || !database) return [];
const key = storageKey("live-patches", connectionId, database, schema);
const list = parseJson<unknown[]>(safeLocalStorageGet(key), []);
if (!Array.isArray(list)) return [];
return list
.filter((item): item is LiveTablePatch => {
if (!item || typeof item !== "object") return false;
const patch = item as Partial<LiveTablePatch>;
if (typeof patch.tableName !== "string") return false;
const pendingColumns = Array.isArray(patch.pendingColumns) ? patch.pendingColumns : [];
if (!pendingColumns.every(isValidColumn)) return false;
const droppedColumnNames = sanitizeStringList(patch.droppedColumnNames);
const pendingDrop = patch.pendingDrop === true;
return pendingColumns.length > 0 || droppedColumnNames.length > 0 || pendingDrop;
})
.map((patch) => {
const pendingColumns = Array.isArray(patch.pendingColumns) ? patch.pendingColumns.filter(isValidColumn) : [];
const droppedColumnNames = sanitizeStringList(patch.droppedColumnNames);
const next: LiveTablePatch = {
tableName: patch.tableName,
pendingColumns: pendingColumns.map((col) => ({ ...col })),
};
if (droppedColumnNames.length) next.droppedColumnNames = droppedColumnNames;
if (patch.pendingDrop === true) next.pendingDrop = true;
return next;
});
}
export function saveLiveTablePatches(tables: DiagramTable[], connectionId: string, database: string, schema: string): void {
if (!connectionId || !database) return;
const key = storageKey("live-patches", connectionId, database, schema);
const patches: LiveTablePatch[] = [];
for (const table of tables) {
if (!isLiveTable(table) || !needsDiagramSync(table)) continue;
const pendingNames = new Set(table.pendingColumnNames ?? []);
const pendingColumns = table.columns.filter((col) => pendingNames.has(col.name));
const droppedColumnNames = [...(table.droppedColumnNames ?? [])];
if (pendingColumns.length === 0 && droppedColumnNames.length === 0 && !table.pendingDrop) continue;
const patch: LiveTablePatch = {
tableName: table.name,
pendingColumns: pendingColumns.map((col) => ({ ...col })),
};
if (droppedColumnNames.length) patch.droppedColumnNames = droppedColumnNames;
if (table.pendingDrop) patch.pendingDrop = true;
patches.push(patch);
}
safeLocalStorageSet(key, JSON.stringify(patches));
}
/** Merge saved pending adds/drops onto live tables loaded from DB metadata. */
export function applyLiveTablePatches(tables: DiagramTable[], patches: LiveTablePatch[]): DiagramTable[] {
if (patches.length === 0) return tables;
const byName = new Map(patches.map((p) => [p.tableName, p]));
return tables.map((table) => {
if (!isLiveTable(table)) return table;
const patch = byName.get(table.name);
if (!patch) return table;
const existing = new Set(table.columns.map((c) => c.name.toLowerCase()));
const pendingColumns = patch.pendingColumns.filter((col) => !existing.has(col.name.toLowerCase()));
const columnNames = new Set([...existing, ...pendingColumns.map((col) => col.name.toLowerCase())]);
const droppedColumnNames = (patch.droppedColumnNames ?? []).filter((name) => columnNames.has(name.toLowerCase()));
let next: DiagramTable = table;
if (pendingColumns.length > 0) {
next = {
...next,
columns: [...next.columns, ...pendingColumns.map((col) => ({ ...col }))],
pendingColumnNames: pendingColumns.map((col) => col.name),
};
}
if (droppedColumnNames.length > 0) {
next = { ...next, droppedColumnNames };
}
if (patch.pendingDrop) {
next = { ...next, pendingDrop: true };
}
return next;
});
}

View File

@ -0,0 +1,177 @@
import type { ColumnInfo, DatabaseType } from "@/types/database";
import type { DiagramTable } from "./erDiagram";
import { editableStructureIndexes, hasDroppedColumns, hasPendingColumns, isPendingColumn } from "./erDiagram";
import type { BuildTableStructureChangeSqlOptions, EditableStructureColumn, EditableStructureIndex } from "@/lib/table/tableStructureEditorSql";
import { generateUniqueIndexName } from "@/lib/table/tableStructureEditorState";
import { resolveDiagramDialectAdapter } from "./diagram-dialect-adapter";
export function createDefaultIdColumn(databaseType?: DatabaseType): ColumnInfo {
return resolveDiagramDialectAdapter(databaseType).createDefaultIdColumn();
}
export function createEmptyColumn(name = "column_1", databaseType?: DatabaseType): ColumnInfo {
return resolveDiagramDialectAdapter(databaseType).createEmptyColumn(name);
}
export function createDraftTable(name: string, options?: { withDefaultId?: boolean; databaseType?: DatabaseType }): DiagramTable {
const withDefaultId = options?.withDefaultId !== false;
const adapter = resolveDiagramDialectAdapter(options?.databaseType);
return {
name: name.trim(),
columns: withDefaultId ? [adapter.createDefaultIdColumn()] : [],
foreignKeys: [],
indexes: [],
origin: "draft",
syncStatus: "pending",
};
}
export function createDraftIndex(tableName: string, columns: string[], existingIndexes: EditableStructureIndex[] = []): EditableStructureIndex {
const existingNames = existingIndexes.map((index) => index.name);
const name = generateUniqueIndexName(tableName, columns, existingNames) || `idx_${tableName}`;
return {
id: `idx-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name,
columns: [...columns],
isUnique: false,
isPrimary: false,
filter: "",
indexType: "",
includedColumns: [],
comment: "",
markedForDrop: false,
};
}
export function columnToEditable(column: ColumnInfo, index: number): EditableStructureColumn {
return {
id: `col-${index}-${column.name}`,
name: column.name,
dataType: column.data_type,
enumValues: column.enum_values ?? undefined,
isNullable: column.is_nullable,
defaultValue: column.column_default ?? "",
comment: column.comment ?? "",
isPrimaryKey: column.is_primary_key,
extra: {},
characterSet: column.character_set ?? undefined,
collation: column.collation ?? undefined,
markedForDrop: false,
};
}
export function draftTableToCreateSqlOptions(table: DiagramTable, databaseType: DatabaseType | undefined, schema: string | undefined): BuildTableStructureChangeSqlOptions {
return {
databaseType,
schema: schema || undefined,
tableName: table.name,
columns: table.columns.map(columnToEditable),
indexes: editableStructureIndexes(table).filter((index) => !index.markedForDrop),
foreignKeys: [],
triggers: [],
};
}
/** Live table pending adds/drops → ALTER ADD / DROP COLUMN. */
export function liveTableToAlterSqlOptions(table: DiagramTable, databaseType: DatabaseType | undefined, schema: string | undefined): BuildTableStructureChangeSqlOptions {
const pending = new Set(table.pendingColumnNames ?? []);
const dropped = new Set(table.droppedColumnNames ?? []);
return {
databaseType,
schema: schema || undefined,
tableName: table.name,
columns: table.columns.map((column, index) => {
const editable = columnToEditable(column, index);
if (pending.has(column.name)) {
return editable;
}
return {
...editable,
original: { ...column },
originalPosition: index + 1,
markedForDrop: dropped.has(column.name),
};
}),
indexes: [],
foreignKeys: [],
triggers: [],
};
}
export function nextUniqueColumnName(columns: ColumnInfo[], base = "column"): string {
const names = new Set(columns.map((c) => c.name.toLowerCase()));
let i = 1;
while (names.has(`${base}_${i}`.toLowerCase())) i += 1;
return `${base}_${i}`;
}
export function validateDraftTable(table: DiagramTable): string[] {
const errors: string[] = [];
if (!table.name.trim()) errors.push("Table name is required");
if (table.columns.length === 0) errors.push(`Table "${table.name}" needs at least one column`);
const seen = new Set<string>();
for (const col of table.columns) {
if (!col.name.trim()) errors.push(`Table "${table.name}" has an empty column name`);
const key = col.name.toLowerCase();
if (seen.has(key)) errors.push(`Table "${table.name}" has duplicate column "${col.name}"`);
seen.add(key);
if (!col.data_type.trim()) errors.push(`Column "${table.name}.${col.name}" needs a type`);
}
const columnNames = new Set(table.columns.map((c) => c.name.toLowerCase()));
for (const index of editableStructureIndexes(table)) {
if (index.markedForDrop) continue;
if (!index.name.trim()) errors.push(`Table "${table.name}" has an index with an empty name`);
if (index.columns.length === 0) errors.push(`Index "${index.name || "(unnamed)"}" on "${table.name}" needs at least one column`);
for (const col of index.columns) {
if (!columnNames.has(col.toLowerCase())) {
errors.push(`Index "${index.name}" references missing column "${col}"`);
}
}
}
return errors;
}
/** Validate pending ADD columns and DROP column marks on a live table before ALTER sync. */
export function validateLivePendingColumns(table: DiagramTable): string[] {
const errors: string[] = [];
if (!hasPendingColumns(table) && !hasDroppedColumns(table)) return errors;
const pendingNames = table.pendingColumnNames ?? [];
const existingNames = new Set(table.columns.filter((col) => !isPendingColumn(table, col.name)).map((col) => col.name.toLowerCase()));
const seenPending = new Set<string>();
for (const name of pendingNames) {
const col = table.columns.find((c) => c.name === name);
if (!col) {
errors.push(`Table "${table.name}" pending column "${name}" is missing`);
continue;
}
if (!col.name.trim()) errors.push(`Table "${table.name}" has an empty pending column name`);
const key = col.name.toLowerCase();
if (seenPending.has(key)) errors.push(`Table "${table.name}" has duplicate pending column "${col.name}"`);
seenPending.add(key);
if (existingNames.has(key)) {
errors.push(`Table "${table.name}" pending column conflicts with existing "${col.name}"`);
}
if (!col.data_type.trim()) errors.push(`Column "${table.name}.${col.name}" needs a type`);
}
const seenDropped = new Set<string>();
for (const name of table.droppedColumnNames ?? []) {
const key = name.toLowerCase();
if (seenDropped.has(key)) {
errors.push(`Table "${table.name}" has duplicate dropped column "${name}"`);
continue;
}
seenDropped.add(key);
if (isPendingColumn(table, name)) {
errors.push(`Table "${table.name}" cannot drop pending column "${name}"`);
continue;
}
if (!table.columns.some((col) => col.name === name)) {
errors.push(`Table "${table.name}" dropped column "${name}" is missing`);
}
}
return errors;
}
export function hasLiveColumnChanges(table: DiagramTable): boolean {
return hasPendingColumns(table) || hasDroppedColumns(table);
}

View File

@ -0,0 +1,356 @@
import { Position } from "@vue-flow/core";
import { EDGE_ROUTE_OFFSET } from "./diagram-constants";
export type Point = { x: number; y: number };
export type ObstacleRect = {
id: string;
x: number;
y: number;
width: number;
height: number;
kind: "table" | "layer";
/** For layers: tables contained (used to skip same-layer fill) */
tableNames?: string[];
};
export type RouteInput = {
source: Point;
target: Point;
sourcePosition: Position;
targetPosition: Position;
obstacles: ObstacleRect[];
/** Endpoint table ids to ignore as obstacles */
endpointIds: [string, string];
offset?: number;
};
const AXIS_EPS = 0.5;
function nearlyEqual(a: number, b: number): boolean {
return Math.abs(a - b) <= AXIS_EPS;
}
function inflate(rect: ObstacleRect, pad: number): ObstacleRect {
return {
...rect,
x: rect.x - pad,
y: rect.y - pad,
width: rect.width + pad * 2,
height: rect.height + pad * 2,
};
}
function segmentIntersectsRect(a: Point, b: Point, rect: ObstacleRect): boolean {
const minX = Math.min(a.x, b.x);
const maxX = Math.max(a.x, b.x);
const minY = Math.min(a.y, b.y);
const maxY = Math.max(a.y, b.y);
const rx2 = rect.x + rect.width;
const ry2 = rect.y + rect.height;
if (maxX < rect.x || minX > rx2 || maxY < rect.y || minY > ry2) return false;
if (nearlyEqual(a.x, b.x)) {
const x = a.x;
return x >= rect.x && x <= rx2 && maxY >= rect.y && minY <= ry2;
}
if (nearlyEqual(a.y, b.y)) {
const y = a.y;
return y >= rect.y && y <= ry2 && maxX >= rect.x && minX <= rx2;
}
return true;
}
export function pathHitsObstacles(points: Point[], obstacles: ObstacleRect[]): boolean {
for (let i = 0; i < points.length - 1; i++) {
for (const rect of obstacles) {
if (segmentIntersectsRect(points[i], points[i + 1], rect)) return true;
}
}
return false;
}
function relevantObstacles(input: RouteInput, pad: number): ObstacleRect[] {
const [srcId, tgtId] = input.endpointIds;
return input.obstacles
.filter((o) => {
if (o.kind === "table") {
return o.id !== srcId && o.id !== tgtId;
}
const names = o.tableNames || [];
if (names.includes(srcId) || names.includes(tgtId)) return false;
return true;
})
.map((o) => inflate(o, pad));
}
/** Table rects for the edge endpoints (source/target), optionally inflated. */
export function endpointRectsFromObstacles(obstacles: ObstacleRect[], endpointIds: [string, string], pad = 0): ObstacleRect[] {
const [srcId, tgtId] = endpointIds;
return obstacles.filter((o) => o.kind === "table" && (o.id === srcId || o.id === tgtId)).map((o) => (pad > 0 ? inflate(o, pad) : { ...o }));
}
export function stubOut(point: Point, position: Position, offset: number): Point {
if (position === Position.Left) return { x: point.x - offset, y: point.y };
if (position === Position.Top) return { x: point.x, y: point.y - offset };
if (position === Position.Bottom) return { x: point.x, y: point.y + offset };
return { x: point.x + offset, y: point.y };
}
/** Point just outside the target handle before the final inbound stub. */
export function stubIn(point: Point, position: Position, offset: number): Point {
if (position === Position.Right) return { x: point.x + offset, y: point.y };
if (position === Position.Top) return { x: point.x, y: point.y - offset };
if (position === Position.Bottom) return { x: point.x, y: point.y + offset };
return { x: point.x - offset, y: point.y };
}
function pointOnRectBorder(p: Point, rect: ObstacleRect): boolean {
const rx2 = rect.x + rect.width;
const ry2 = rect.y + rect.height;
const onVertical = (nearlyEqual(p.x, rect.x) || nearlyEqual(p.x, rx2)) && p.y >= rect.y - AXIS_EPS && p.y <= ry2 + AXIS_EPS;
const onHorizontal = (nearlyEqual(p.y, rect.y) || nearlyEqual(p.y, ry2)) && p.x >= rect.x - AXIS_EPS && p.x <= rx2 + AXIS_EPS;
return onVertical || onHorizontal;
}
/**
* True when a segment runs along/through an endpoint table beyond a short handle stub.
* Short outward/inward stubs (length stubLen) that touch the border are allowed.
*/
export function pathSkimsEndpoints(points: Point[], endpointRects: ObstacleRect[], stubLen = EDGE_ROUTE_OFFSET): boolean {
if (endpointRects.length === 0 || points.length < 2) return false;
for (let i = 0; i < points.length - 1; i++) {
const a = points[i];
const b = points[i + 1];
const segLen = Math.hypot(b.x - a.x, b.y - a.y);
for (const rect of endpointRects) {
if (!segmentIntersectsRect(a, b, rect)) continue;
const stubOk = segLen <= stubLen + AXIS_EPS && (pointOnRectBorder(a, rect) || pointOnRectBorder(b, rect));
if (stubOk) continue;
return true;
}
}
return false;
}
/**
* Build candidate orthogonal polylines with exit/entry stubs; return the shortest that
* clears non-endpoint obstacles and does not skim endpoint tables on middle segments.
*/
export function routeOrthogonalAroundObstacles(input: RouteInput): Point[] | null {
const offset = input.offset ?? EDGE_ROUTE_OFFSET;
const obstacles = relevantObstacles(input, 6);
const endpoints = endpointRectsFromObstacles(input.obstacles, input.endpointIds, 0);
const { source: s, target: t } = input;
const so = stubOut(s, input.sourcePosition, offset);
const si = stubIn(t, input.targetPosition, offset);
// Corridors from stubOut → stubIn (handles attached outside)
const corridors: Point[][] = [
[so, { x: si.x, y: so.y }, si],
[so, { x: so.x, y: si.y }, si],
[so, { x: so.x + offset, y: so.y }, { x: so.x + offset, y: si.y }, si],
[so, { x: so.x - offset, y: so.y }, { x: so.x - offset, y: si.y }, si],
[so, { x: so.x, y: so.y + offset }, { x: si.x, y: so.y + offset }, si],
[so, { x: so.x, y: so.y - offset }, { x: si.x, y: so.y - offset }, si],
[so, { x: si.x + offset, y: so.y }, { x: si.x + offset, y: si.y }, si],
[so, { x: si.x - offset, y: so.y }, { x: si.x - offset, y: si.y }, si],
[so, { x: so.x, y: Math.min(so.y, si.y) - offset }, { x: si.x, y: Math.min(so.y, si.y) - offset }, si],
[so, { x: so.x, y: Math.max(so.y, si.y) + offset }, { x: si.x, y: Math.max(so.y, si.y) + offset }, si],
[so, { x: Math.min(so.x, si.x) - offset, y: so.y }, { x: Math.min(so.x, si.x) - offset, y: si.y }, si],
[so, { x: Math.max(so.x, si.x) + offset, y: so.y }, { x: Math.max(so.x, si.x) + offset, y: si.y }, si],
];
let best: Point[] | null = null;
let bestLen = Infinity;
for (const corridor of corridors) {
const cleaned = collapseColinearPoints(dedupePoints([s, ...corridor, t]));
if (cleaned.length < 2) continue;
if (pathHitsObstacles(cleaned, obstacles)) continue;
if (pathSkimsEndpoints(cleaned, endpoints, offset)) continue;
const len = polylineLength(cleaned);
if (len < bestLen) {
bestLen = len;
best = cleaned;
}
}
return best;
}
export function polylineLength(points: Point[]): number {
let total = 0;
for (let i = 1; i < points.length; i++) {
total += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
}
return total;
}
export function dedupePoints(points: Point[]): Point[] {
const out: Point[] = [];
for (const p of points) {
const last = out[out.length - 1];
if (!last || !nearlyEqual(last.x, p.x) || !nearlyEqual(last.y, p.y)) {
out.push({ x: p.x, y: p.y });
}
}
return out;
}
/** Collapse consecutive collinear points on axis-aligned polylines. */
export function collapseColinearPoints(points: Point[]): Point[] {
if (points.length <= 2) return points.map((p) => ({ ...p }));
const out: Point[] = [{ ...points[0] }];
for (let i = 1; i < points.length - 1; i++) {
const prev = out[out.length - 1];
const cur = points[i];
const next = points[i + 1];
const colinearH = nearlyEqual(prev.y, cur.y) && nearlyEqual(cur.y, next.y);
const colinearV = nearlyEqual(prev.x, cur.x) && nearlyEqual(cur.x, next.x);
if (colinearH || colinearV) continue;
out.push({ ...cur });
}
out.push({ ...points[points.length - 1] });
return dedupePoints(out);
}
function isOrthogonalPolyline(points: Point[]): boolean {
for (let i = 0; i < points.length - 1; i++) {
const a = points[i];
const b = points[i + 1];
if (!nearlyEqual(a.x, b.x) && !nearlyEqual(a.y, b.y)) return false;
}
return true;
}
/** Orthogonal elbow from a → b (try both corners; prefer shorter). */
function orthogonalConnect(a: Point, b: Point): Point[] {
if (nearlyEqual(a.x, b.x) || nearlyEqual(a.y, b.y)) return [a, b];
const viaH: Point[] = [a, { x: b.x, y: a.y }, b];
const viaV: Point[] = [a, { x: a.x, y: b.y }, b];
const len = (pts: Point[]) => pts.reduce((sum, p, i) => (i === 0 ? 0 : sum + Math.hypot(p.x - pts[i - 1].x, p.y - pts[i - 1].y)), 0);
return len(viaH) <= len(viaV) ? viaH : viaV;
}
/**
* Infer Vue Flow handle ids from ELK waypoint exit/entry directions.
*/
export function handlesFromWaypoints(waypoints: Point[]): { sourceHandle: string; targetHandle: string } | null {
if (waypoints.length < 2) return null;
const a = waypoints[0];
const b = waypoints[1];
const c = waypoints[waypoints.length - 2];
const d = waypoints[waypoints.length - 1];
const outDx = b.x - a.x;
const outDy = b.y - a.y;
let sourceHandle: string;
if (Math.abs(outDx) >= Math.abs(outDy)) {
sourceHandle = outDx >= 0 ? "right" : "left";
} else {
sourceHandle = outDy >= 0 ? "bottom" : "top";
}
const inDx = d.x - c.x;
const inDy = d.y - c.y;
let targetHandle: string;
if (Math.abs(inDx) >= Math.abs(inDy)) {
// Arriving with +dx means coming from the left → hit left side
targetHandle = inDx >= 0 ? "left-target" : "right-target";
} else {
targetHandle = inDy >= 0 ? "top-target" : "bottom-target";
}
return { sourceHandle, targetHandle };
}
export type AlignWaypointsOptions = {
obstacles?: ObstacleRect[];
endpointIds?: [string, string];
};
/**
* Attach live Vue Flow handle endpoints to ELK interior bends with orthogonal elbows.
* Does NOT translate the whole polyline by source delta (that caused diagonals / V shapes).
* Returns null when the result is unusable (caller should fall back to obstacle router).
*/
export function alignWaypointsToEndpoints(waypoints: Point[], sourceX: number, sourceY: number, targetX: number, targetY: number, options?: AlignWaypointsOptions): Point[] | null {
if (waypoints.length < 2) return null;
const source = { x: sourceX, y: sourceY };
const target = { x: targetX, y: targetY };
const interior = waypoints.slice(1, -1);
let merged: Point[];
if (interior.length === 0) {
merged = dedupePoints(orthogonalConnect(source, target));
} else {
const head = orthogonalConnect(source, interior[0]);
const tail = orthogonalConnect(interior[interior.length - 1], target);
merged = dedupePoints([...head.slice(0, -1), ...interior, ...tail.slice(1)]);
}
if (merged.length < 2 || !isOrthogonalPolyline(merged)) return null;
const cleaned = collapseColinearPoints(merged);
if (options?.obstacles?.length && options.endpointIds) {
const obstacles = relevantObstacles(
{
source,
target,
sourcePosition: Position.Right,
targetPosition: Position.Left,
obstacles: options.obstacles,
endpointIds: options.endpointIds,
},
4,
);
if (pathHitsObstacles(cleaned, obstacles)) return null;
const endpoints = endpointRectsFromObstacles(options.obstacles, options.endpointIds, 0);
if (pathSkimsEndpoints(cleaned, endpoints, EDGE_ROUTE_OFFSET)) return null;
}
return cleaned;
}
export function pointsToSvgPath(points: Point[]): string {
if (points.length === 0) return "";
return points.map((p, i) => `${i === 0 ? "M" : "L"}${p.x},${p.y}`).join(" ");
}
/**
* Point at fraction `t` (0..1) along the polyline by arc length.
* Out-of-range t is clamped.
*/
export function pointAlongPolyline(points: Point[], t: number): Point {
if (points.length === 0) return { x: 0, y: 0 };
if (points.length === 1) return { ...points[0] };
const clamped = Math.min(1, Math.max(0, t));
let total = 0;
const segs: number[] = [];
for (let i = 0; i < points.length - 1; i++) {
const d = Math.hypot(points[i + 1].x - points[i].x, points[i + 1].y - points[i].y);
segs.push(d);
total += d;
}
if (total === 0) return { ...points[0] };
let remain = total * clamped;
for (let i = 0; i < segs.length; i++) {
if (remain <= segs[i]) {
const ratio = segs[i] === 0 ? 0 : remain / segs[i];
return {
x: points[i].x + (points[i + 1].x - points[i].x) * ratio,
y: points[i].y + (points[i + 1].y - points[i].y) * ratio,
};
}
remain -= segs[i];
}
return { ...points[points.length - 1] };
}
export function midpointAlongPolyline(points: Point[]): Point {
return pointAlongPolyline(points, 0.5);
}

View File

@ -0,0 +1,407 @@
import ELK from "elkjs/lib/elk.bundled.js";
import type { LayoutOptions, DiagramNode, DiagramEdge, DiagramLayer } from "@/types/diagram";
import { CARD_WIDTH, COLUMN_ROW_HEIGHT, CARD_HEADER_HEIGHT, CARD_BOTTOM_PADDING, LAYER_PADDING, LAYER_HEADER_HEIGHT } from "./diagram-constants";
import { handlesFromWaypoints, type Point } from "./edge-obstacle-router";
const elk = new ELK();
export interface ElkNode {
id: string;
width: number;
height: number;
x?: number;
y?: number;
children?: ElkNode[];
labels?: { text: string }[];
layoutOptions?: Record<string, string>;
}
export interface ElkEdge {
id: string;
sources: string[];
targets: string[];
sections?: {
id?: string;
startPoint: { x: number; y: number };
endPoint: { x: number; y: number };
bendPoints?: { x: number; y: number }[];
}[];
}
export interface ElkGraph {
id: string;
children: ElkNode[];
edges: ElkEdge[];
layoutOptions?: Record<string, string>;
}
export interface LayerLayoutInfo {
layerId: string;
layerName: string;
color: string;
x: number;
y: number;
width: number;
height: number;
}
function calculateTableHeight(tableColumns: unknown[]): number {
return CARD_HEADER_HEIGHT + tableColumns.length * COLUMN_ROW_HEIGHT + CARD_BOTTOM_PADDING;
}
export async function computeLayout(nodes: DiagramNode[], edges: DiagramEdge[], options: LayoutOptions = {}): Promise<{ nodes: DiagramNode[]; edges: DiagramEdge[] }> {
const elkGraph = buildElkGraph(nodes, edges);
const elkOptions = buildElkOptions(options);
const result = (await elk.layout({
...elkGraph,
layoutOptions: elkOptions,
} as Parameters<typeof elk.layout>[0])) as ElkGraph;
return extractLayoutResult(result, nodes, edges);
}
/**
* Layout with layers.
* - auto layers: nested in ELK hierarchy (children get relative coords on write-back)
* - free layers: tables keep existing absolute positions; layer box is size-to-fit after
* - unassigned tables: laid out at root with auto layers
*/
export async function computeLayoutWithLayers(nodes: DiagramNode[], edges: DiagramEdge[], layers: DiagramLayer[], options: LayoutOptions = {}): Promise<{ nodes: DiagramNode[]; edges: DiagramEdge[]; layerLayouts: LayerLayoutInfo[] }> {
const visibleLayers = layers.filter((l) => l.visible && l.tableNames.length > 0);
const autoLayers = visibleLayers.filter((l) => (l.layoutMode ?? "auto") === "auto");
const freeLayers = visibleLayers.filter((l) => (l.layoutMode ?? "auto") === "free");
if (visibleLayers.length === 0) {
const result = await computeLayout(nodes, edges, options);
return { ...result, layerLayouts: [] };
}
const freeTableIds = new Set(freeLayers.flatMap((l) => l.tableNames));
const nodesForElk = nodes.filter((n) => !freeTableIds.has(n.id));
const edgesForElk = edges.filter((e) => !freeTableIds.has(e.source) && !freeTableIds.has(e.target));
const positionMap = new Map<string, { x: number; y: number }>();
for (const node of nodes) {
positionMap.set(node.id, { ...node.position });
}
const layerLayouts: LayerLayoutInfo[] = [];
/** Edges that went through ELK, keyed by id (may include waypoints). */
const elkEdgeById = new Map<string, DiagramEdge>();
if (nodesForElk.length > 0) {
const elkGraph = buildHierarchicalElkGraph(nodesForElk, edgesForElk, autoLayers);
const elkOptions = buildElkOptions(options);
const result = (await elk.layout({
...elkGraph,
layoutOptions: elkOptions,
} as Parameters<typeof elk.layout>[0])) as ElkGraph;
const extracted = extractHierarchicalLayoutResult(result, nodesForElk, edgesForElk, autoLayers);
for (const node of extracted.nodes) {
positionMap.set(node.id, { ...node.position });
}
layerLayouts.push(...extracted.layerLayouts);
for (const edge of extracted.edges) {
elkEdgeById.set(edge.id, edge);
}
}
// Free layers: keep table positions; compute size-to-fit boxes
for (const layer of freeLayers) {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
let hasAny = false;
for (const tableName of layer.tableNames) {
const node = nodes.find((n) => n.id === tableName);
const pos = positionMap.get(tableName) || node?.position;
if (!pos) continue;
hasAny = true;
const height = calculateTableHeight(node?.data?.table?.columns || []);
minX = Math.min(minX, pos.x);
minY = Math.min(minY, pos.y);
maxX = Math.max(maxX, pos.x + CARD_WIDTH);
maxY = Math.max(maxY, pos.y + height);
}
if (!hasAny) continue;
const x = minX - LAYER_PADDING;
const y = minY - LAYER_HEADER_HEIGHT - LAYER_PADDING;
const width = Math.max(160, maxX - minX + LAYER_PADDING * 2);
const height = Math.max(100, maxY - minY + LAYER_HEADER_HEIGHT + LAYER_PADDING * 2);
layerLayouts.push({
layerId: layer.id,
layerName: layer.name,
color: layer.color,
x,
y,
width,
height,
});
}
const newNodes = nodes.map((node) => ({
...node,
position: positionMap.get(node.id) || node.position,
}));
const newEdges = edges.map((edge) => {
const fromElk = elkEdgeById.get(edge.id);
if (!fromElk) return edge;
return {
...edge,
waypoints: fromElk.waypoints,
sourceHandle: fromElk.sourceHandle,
targetHandle: fromElk.targetHandle,
};
});
return {
nodes: newNodes,
edges: newEdges,
layerLayouts,
};
}
function buildElkGraph(nodes: DiagramNode[], edges: DiagramEdge[]): ElkGraph {
const elkNodes: ElkNode[] = nodes.map((node) => ({
id: node.id,
width: CARD_WIDTH,
height: calculateTableHeight(node.data?.table?.columns || []),
}));
const elkEdges: ElkEdge[] = edges.map((edge) => ({
id: edge.id,
sources: [edge.source],
targets: [edge.target],
}));
return {
id: "diagram",
children: elkNodes,
edges: elkEdges,
};
}
function buildHierarchicalElkGraph(nodes: DiagramNode[], edges: DiagramEdge[], autoLayers: DiagramLayer[]): ElkGraph {
const elkLayers: ElkNode[] = [];
const tableIdSet = new Set<string>();
for (const layer of autoLayers) {
const layerTables = nodes.filter((n) => layer.tableNames.includes(n.id));
if (layerTables.length === 0) continue;
const elkTableNodes = layerTables.map((tableNode) => ({
id: tableNode.id,
width: CARD_WIDTH,
height: calculateTableHeight(tableNode.data?.table?.columns || []),
}));
layerTables.forEach((n) => tableIdSet.add(n.id));
elkLayers.push({
id: layer.id,
width: 0,
height: 0,
children: elkTableNodes,
labels: [{ text: layer.name }],
layoutOptions: {
"elk.padding": `[top=${LAYER_HEADER_HEIGHT + LAYER_PADDING},left=${LAYER_PADDING},bottom=${LAYER_PADDING},right=${LAYER_PADDING}]`,
},
});
}
const unassignedNodes = nodes.filter((n) => !tableIdSet.has(n.id));
const unassignedElkNodes = unassignedNodes.map((node) => ({
id: node.id,
width: CARD_WIDTH,
height: calculateTableHeight(node.data?.table?.columns || []),
}));
return {
id: "diagram",
children: [...elkLayers, ...unassignedElkNodes],
edges: edges.map((edge) => ({
id: edge.id,
sources: [edge.source],
targets: [edge.target],
})),
};
}
function buildElkOptions(options: LayoutOptions): Record<string, string> {
const directionMap: Record<string, string> = {
LR: "RIGHT",
TB: "DOWN",
RL: "LEFT",
BT: "UP",
};
return {
"elk.algorithm": "layered",
"elk.direction": directionMap[options.direction || "LR"],
"elk.layered.edgeRouting": "ORTHOGONAL",
"elk.layered.nodePlacement": "BRANDES_KOEPF",
"elk.layered.crossingMinimization": "LAYER_SWEEP",
"elk.layered.layering.strategy": "NETWORK_SIMPLEX",
"elk.layered.separateConnectedComponents": "true",
"elk.spacing.nodeNode": "60",
"elk.spacing.layerLayer": "80",
"elk.padding": `${LAYER_PADDING}`,
"elk.layered.edgeSpacing": "20",
"elk.layered.nodeSpacing": "50",
"elk.layered.bendPointSpacing": "10",
"elk.hierarchyHandling": "INCLUDE_CHILDREN",
};
}
function extractLayoutResult(result: ElkGraph, originalNodes: DiagramNode[], originalEdges: DiagramEdge[]): { nodes: DiagramNode[]; edges: DiagramEdge[] } {
const nodePositionMap = new Map<string, { x: number; y: number }>();
for (const child of result.children || []) {
if (child.x !== undefined && child.y !== undefined) {
nodePositionMap.set(child.id, { x: child.x, y: child.y });
}
}
const edgeBendPoints = new Map<string, { x: number; y: number }[][]>();
for (const edge of result.edges || []) {
if (edge.sections) {
const points: { x: number; y: number }[][] = [];
for (const section of edge.sections) {
const sectionPoints: { x: number; y: number }[] = [];
sectionPoints.push(section.startPoint);
if (section.bendPoints) {
sectionPoints.push(...section.bendPoints);
}
sectionPoints.push(section.endPoint);
points.push(sectionPoints);
}
edgeBendPoints.set(edge.id, points);
}
}
const newNodes = originalNodes.map((node) => {
const position = nodePositionMap.get(node.id);
return {
...node,
position: position || node.position,
};
});
const newEdges = originalEdges.map((edge) => {
const sections = edgeBendPoints.get(edge.id);
const waypoints: Point[] | undefined = sections?.length ? sections.flatMap((section, index) => (index === 0 ? section : section.slice(1))) : undefined;
const handles = waypoints ? handlesFromWaypoints(waypoints) : null;
return {
...edge,
waypoints,
sourceHandle: handles?.sourceHandle,
targetHandle: handles?.targetHandle,
};
});
return {
nodes: newNodes,
edges: newEdges,
};
}
/**
* Write back ABSOLUTE positions for tables (parent absolute + child relative from ELK).
* Adapter converts to relative for Vue Flow parentNode.
*/
function extractHierarchicalLayoutResult(result: ElkGraph, originalNodes: DiagramNode[], originalEdges: DiagramEdge[], layers: DiagramLayer[]): { nodes: DiagramNode[]; edges: DiagramEdge[]; layerLayouts: LayerLayoutInfo[] } {
const absolutePositions = new Map<string, { x: number; y: number }>();
const layerLayouts: LayerLayoutInfo[] = [];
for (const child of result.children || []) {
const childX = child.x || 0;
const childY = child.y || 0;
const layer = layers.find((l) => l.id === child.id);
if (layer) {
layerLayouts.push({
layerId: layer.id,
layerName: layer.name,
color: layer.color,
x: childX,
y: childY,
width: child.width || 320,
height: child.height || 200,
});
// Children coords from ELK are relative to the layer node
for (const nested of child.children || []) {
absolutePositions.set(nested.id, {
x: childX + (nested.x || 0),
y: childY + (nested.y || 0),
});
}
} else {
absolutePositions.set(child.id, { x: childX, y: childY });
}
}
const edgeBendPoints = new Map<string, { x: number; y: number }[][]>();
for (const edge of result.edges || []) {
if (edge.sections) {
const points: { x: number; y: number }[][] = [];
for (const section of edge.sections) {
const sectionPoints: { x: number; y: number }[] = [];
sectionPoints.push(section.startPoint);
if (section.bendPoints) {
sectionPoints.push(...section.bendPoints);
}
sectionPoints.push(section.endPoint);
points.push(sectionPoints);
}
edgeBendPoints.set(edge.id, points);
}
}
const newNodes = originalNodes.map((node) => {
const position = absolutePositions.get(node.id);
return {
...node,
position: position || node.position,
};
});
const newEdges = originalEdges.map((edge) => {
const sections = edgeBendPoints.get(edge.id);
let waypoints: Point[] | undefined = sections?.length ? sections.flatMap((section, index) => (index === 0 ? section : section.slice(1))) : undefined;
if (waypoints?.length) {
const srcLayer = layers.find((l) => l.tableNames.includes(edge.source));
const tgtLayer = layers.find((l) => l.tableNames.includes(edge.target));
// Same-layer edges: ELK section coords are relative to the layer compound node
if (srcLayer && tgtLayer && srcLayer.id === tgtLayer.id) {
const layout = layerLayouts.find((l) => l.layerId === srcLayer.id);
if (layout) {
waypoints = waypoints.map((p) => ({ x: p.x + layout.x, y: p.y + layout.y }));
}
}
}
const handles = waypoints ? handlesFromWaypoints(waypoints) : null;
return {
...edge,
waypoints,
sourceHandle: handles?.sourceHandle,
targetHandle: handles?.targetHandle,
};
});
return {
nodes: newNodes,
edges: newEdges,
layerLayouts,
};
}

View File

@ -65,7 +65,9 @@ export interface EngineeringRelationshipNode {
id: string;
label: string;
sourceTable: string;
sourceColumn: string;
targetTable: string;
targetColumn: string;
sourceCardinality: "1" | "N";
targetCardinality: "1" | "N";
x: number;
@ -283,6 +285,13 @@ function normalizeDiagram(diagram: Omit<EngineeringDiagram, "canvas">): Engineer
};
}
function attributeCenter(attribute: EngineeringAttributeNode): DiagramPosition {
return {
x: attribute.x + attribute.width / 2,
y: attribute.y + attribute.height / 2,
};
}
export function buildEngineeringDiagram(tables: DiagramTable[], relationships: DiagramRelationship[], positions: Record<string, DiagramPosition>): EngineeringDiagram {
const clusters = new Map(tables.map((table) => [table.name, buildEngineeringCluster(table)]));
const rows = orderedTableRows(tables, positions);
@ -321,21 +330,26 @@ export function buildEngineeringDiagram(tables: DiagramTable[], relationships: D
nextRowY += rowHeight + ENGINEERING_CLUSTER_GAP_Y;
});
const entityMap = new Map(entities.map((entity) => [entity.name, entity]));
const attributeMap = new Map(attributes.map((attr) => [`${attr.tableName}:${attr.columnName}`, attr]));
const orderedEntities = tables.map((table) => entityMap.get(table.name)).filter((entity): entity is EngineeringEntityNode => entity !== undefined);
const relationshipNodes: EngineeringRelationshipNode[] = relationships.flatMap((relationship) => {
const source = entityMap.get(relationship.sourceTable);
const target = entityMap.get(relationship.targetTable);
if (!source || !target) return [];
const sourceEntity = entityMap.get(relationship.sourceTable);
const targetEntity = entityMap.get(relationship.targetTable);
const sourceAttr = attributeMap.get(`${relationship.sourceTable}:${relationship.sourceColumn}`);
const targetAttr = attributeMap.get(`${relationship.targetTable}:${relationship.targetColumn}`);
if (!sourceEntity || !targetEntity) return [];
const sourceCenter = entityCenter(source);
const targetCenter = entityCenter(target);
const sourceCenter = sourceAttr ? attributeCenter(sourceAttr) : entityCenter(sourceEntity);
const targetCenter = targetAttr ? attributeCenter(targetAttr) : entityCenter(targetEntity);
return [
{
id: relationship.id,
label: relationshipLabel(relationship),
sourceTable: relationship.sourceTable,
sourceColumn: relationship.sourceColumn,
targetTable: relationship.targetTable,
targetColumn: relationship.targetColumn,
sourceCardinality: relationship.sourceCardinality ?? "N",
targetCardinality: relationship.targetCardinality ?? "1",
x: (sourceCenter.x + targetCenter.x) / 2 - ENGINEERING_RELATIONSHIP_WIDTH / 2,

View File

@ -1,16 +1,84 @@
import type { ColumnInfo, ForeignKeyInfo, IndexInfo } from "@/types/database";
import type { EditableStructureIndex } from "@/lib/table/tableStructureEditorSql";
export type DiagramTableOrigin = "live" | "draft";
/** Live metadata (`IndexInfo`) or draft editor indexes (`EditableStructureIndex`). */
export type DiagramTableIndex = IndexInfo | EditableStructureIndex;
export function isEditableStructureIndex(index: DiagramTableIndex): index is EditableStructureIndex {
return "isUnique" in index || "markedForDrop" in index;
}
export function editableStructureIndexes(table: DiagramTable): EditableStructureIndex[] {
return (table.indexes ?? []).filter(isEditableStructureIndex);
}
export interface DiagramTable {
name: string;
columns: ColumnInfo[];
foreignKeys: ForeignKeyInfo[];
indexes?: IndexInfo[];
/** Unique/PK indexes used for FK cardinality; drafts also sync via buildCreateTableSql */
indexes?: DiagramTableIndex[];
/** live = from DB metadata; draft = local design not yet synced */
origin?: DiagramTableOrigin;
syncStatus?: "pending" | "synced" | "error";
/**
* Live tables only: column names not yet in DB (subset of `columns`).
* Editable in Inspector; synced via ALTER ADD COLUMN.
*/
pendingColumnNames?: string[];
/**
* Live tables only: existing column names marked for DROP COLUMN on sync.
* Columns remain in `columns` until sync/reload.
*/
droppedColumnNames?: string[];
/** Live tables only: marked for DROP TABLE on sync (hidden from canvas). */
pendingDrop?: boolean;
}
export function isDraftTable(table: DiagramTable): boolean {
return (table.origin ?? "live") === "draft";
}
export function isLiveTable(table: DiagramTable): boolean {
return !isDraftTable(table);
}
export function hasPendingColumns(table: DiagramTable): boolean {
return (table.pendingColumnNames?.length ?? 0) > 0;
}
export function isPendingColumn(table: DiagramTable, columnName: string): boolean {
return (table.pendingColumnNames ?? []).some((name) => name === columnName);
}
export function hasDroppedColumns(table: DiagramTable): boolean {
return (table.droppedColumnNames?.length ?? 0) > 0;
}
export function isDroppedColumn(table: DiagramTable, columnName: string): boolean {
return (table.droppedColumnNames ?? []).some((name) => name === columnName);
}
/** Tables that need sync: draft creates, live column adds/drops, or pending DROP TABLE. */
export function needsDiagramSync(table: DiagramTable): boolean {
return isDraftTable(table) || hasPendingColumns(table) || hasDroppedColumns(table) || !!table.pendingDrop;
}
/** Soft-deleted tables stay in state for Sync but must not be re-added to layers/canvas pickers. */
export function isDiagramTableAssignable(table: DiagramTable): boolean {
return !table.pendingDrop;
}
export function filterAssignableDiagramTables(tables: DiagramTable[]): DiagramTable[] {
return tables.filter(isDiagramTableAssignable);
}
export interface DiagramRelationship {
id: string;
name: string;
kind: "foreign-key" | "custom";
kind: "foreign-key" | "custom" | "inferred";
sourceTable: string;
sourceColumn: string;
targetTable: string;
@ -43,6 +111,7 @@ export interface DiagramPosition {
export interface DiagramLayoutOptions {
columnsPerRow?: number;
cardWidth?: number;
/** When set, every row advances by this height (legacy/tests). When omitted, row height follows tallest table in the row. */
rowHeight?: number;
gapX?: number;
gapY?: number;
@ -57,6 +126,7 @@ function columnExists(table: DiagramTable | undefined, columnName: string): bool
return !!table?.columns.some((column) => column.name === columnName);
}
/** True when every unique-key column appears among the FK source columns (unique ⊆ FK). */
function sourceColumnsContainUniqueKey(sourceColumns: string[], keyColumns: string[]): boolean {
if (keyColumns.length === 0) return false;
const sourceColumnSet = new Set(sourceColumns);
@ -68,11 +138,31 @@ function foreignKeySourceColumns(table: DiagramTable, foreignKey: ForeignKeyInfo
return table.foreignKeys.filter((candidate) => candidate.name === foreignKey.name && candidate.ref_table === foreignKey.ref_table).map((candidate) => candidate.column);
}
function foreignKeySourceCardinality(table: DiagramTable, foreignKey: ForeignKeyInfo): "1" | "N" {
function diagramIndexIsUnique(index: DiagramTableIndex): boolean {
if ("isUnique" in index) return !!(index.isUnique || index.isPrimary);
return !!(index.is_unique || index.is_primary);
}
function diagramIndexFilter(index: DiagramTableIndex): string {
return (index.filter ?? "").trim();
}
function diagramIndexMarkedForDrop(index: DiagramTableIndex): boolean {
return "markedForDrop" in index && !!index.markedForDrop;
}
export function foreignKeySourceCardinality(table: DiagramTable, foreignKey: ForeignKeyInfo): "1" | "N" {
const sourceColumns = foreignKeySourceColumns(table, foreignKey);
const primaryKeyColumns = table.columns.filter((column) => column.is_primary_key).map((column) => column.name);
if (sourceColumnsContainUniqueKey(sourceColumns, primaryKeyColumns)) return "1";
return table.indexes?.some((index) => (index.is_unique || index.is_primary) && !index.filter?.trim() && sourceColumnsContainUniqueKey(sourceColumns, index.columns)) ? "1" : "N";
if (sourceColumns.length === 1) {
const col = table.columns.find((column) => column.name === sourceColumns[0]);
if (col?.is_unique) return "1";
}
const hasUniqueIndex = (table.indexes ?? []).some((index) => diagramIndexIsUnique(index) && !diagramIndexFilter(index) && !diagramIndexMarkedForDrop(index) && sourceColumnsContainUniqueKey(sourceColumns, index.columns));
return hasUniqueIndex ? "1" : "N";
}
function customRelationshipId(relationship: Omit<CustomDiagramRelationship, "id">): string {
@ -114,7 +204,60 @@ export function buildDiagramRelationships(tables: DiagramTable[], customRelation
kind: "custom" as const,
}));
return [...foreignKeyRelationships, ...custom];
return deduplicateRelationships([...foreignKeyRelationships, ...custom]);
}
export function deduplicateRelationships(relationships: DiagramRelationship[]): DiagramRelationship[] {
const seen = new Set<string>();
const unique: DiagramRelationship[] = [];
for (const rel of relationships) {
const key = `${rel.sourceTable}:${rel.sourceColumn}:${rel.targetTable}:${rel.targetColumn}`;
if (!seen.has(key)) {
seen.add(key);
unique.push(rel);
}
}
return unique;
}
export interface InferredRelationshipInput {
id: string;
sourceTable: string;
sourceColumn: string;
targetTable: string;
targetColumn: string;
confidence?: "high" | "medium";
strategy?: string;
}
export function toDiagramRelationship(input: InferredRelationshipInput): DiagramRelationship {
return {
id: input.id,
name: `${input.sourceTable}_${input.sourceColumn}_${input.targetTable}_${input.targetColumn}`,
kind: "inferred",
sourceTable: input.sourceTable,
sourceColumn: input.sourceColumn,
targetTable: input.targetTable,
targetColumn: input.targetColumn,
sourceCardinality: "N",
targetCardinality: "1",
};
}
export function mergeRelationshipsWithInferred(existing: DiagramRelationship[], inferred: InferredRelationshipInput[]): DiagramRelationship[] {
const existingIds = new Set(existing.map((r) => r.id));
const merged: DiagramRelationship[] = [...existing];
for (const inf of inferred) {
if (!existingIds.has(inf.id)) {
merged.push(toDiagramRelationship(inf));
existingIds.add(inf.id);
}
}
return deduplicateRelationships(merged);
}
export function filterDiagramTables(tables: DiagramTable[], query: string): DiagramTable[] {
@ -131,24 +274,32 @@ export function filterDiagramTables(tables: DiagramTable[], query: string): Diag
export function layoutDiagramTables(tables: Pick<DiagramTable, "name" | "columns">[], options: DiagramLayoutOptions = {}): Record<string, DiagramPosition> {
const columnsPerRow = Math.max(1, options.columnsPerRow ?? Math.ceil(Math.sqrt(Math.max(tables.length, 1))));
const cardWidth = options.cardWidth ?? 260;
const rowHeight = options.rowHeight ?? 220;
const gapX = options.gapX ?? 56;
const gapY = options.gapY ?? 40;
const margin = options.margin ?? 40;
const fixedRowHeight = options.rowHeight;
return Object.fromEntries(
tables.map((table, index) => {
const col = index % columnsPerRow;
const row = Math.floor(index / columnsPerRow);
return [
table.name,
{
x: margin + col * (cardWidth + gapX),
y: margin + row * (rowHeight + gapY),
},
];
}),
);
const measureHeight = (table: Pick<DiagramTable, "columns">): number => {
if (fixedRowHeight != null) return fixedRowHeight;
const columnCount = table.columns?.length ?? 0;
return 44 + columnCount * 24 + 12;
};
const positions: Record<string, DiagramPosition> = {};
let y = margin;
for (let start = 0; start < tables.length; start += columnsPerRow) {
const rowTables = tables.slice(start, start + columnsPerRow);
const rowContentHeight = Math.max(...rowTables.map(measureHeight));
rowTables.forEach((table, col) => {
positions[table.name] = {
x: margin + col * (cardWidth + gapX),
y,
};
});
y += rowContentHeight + gapY;
}
return positions;
}
function quoteIdentifier(value: string): string {

View File

@ -0,0 +1,136 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import type { DiagramNode, DiagramEdge, HistorySnapshot, LayoutOptions, DiagramLayer } from "@/types/diagram";
import { LayoutManager } from "./layout-manager";
import type { LayerLayoutInfo } from "./elk-layout";
function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
function emptySnapshotExtras(): Pick<HistorySnapshot, "positions" | "layers" | "tables" | "customRelationships" | "edgeWaypoints" | "edgeHandleHints" | "matchConfirms" | "matchIgnores"> {
return {
positions: {},
layers: [],
tables: [],
customRelationships: [],
edgeWaypoints: {},
edgeHandleHints: {},
matchConfirms: [],
matchIgnores: [],
};
}
export const useGraphStore = defineStore("diagram-graph", () => {
const nodes = ref<DiagramNode[]>([]);
const edges = ref<DiagramEdge[]>([]);
const layerLayouts = ref<LayerLayoutInfo[]>([]);
const historyStack = ref<HistorySnapshot[]>([]);
const redoStack = ref<HistorySnapshot[]>([]);
const maxHistorySize = 50;
const layoutManager = new LayoutManager();
const canUndo = computed(() => historyStack.value.length > 0);
const canRedo = computed(() => redoStack.value.length > 0);
function snapshotFromNodesEdges(): HistorySnapshot {
return {
nodes: deepClone(nodes.value),
edges: deepClone(edges.value),
...emptySnapshotExtras(),
positions: Object.fromEntries(nodes.value.map((n) => [n.id, { ...n.position }])),
};
}
function pushHistory(snapshot: HistorySnapshot) {
historyStack.value.push(deepClone(snapshot));
if (historyStack.value.length > maxHistorySize) {
historyStack.value.shift();
}
redoStack.value = [];
}
/** Push current store nodes/edges (used by store-owned layout helpers). */
function pushStoreHistory() {
pushHistory(snapshotFromNodesEdges());
}
function undo(current: HistorySnapshot): HistorySnapshot | null {
if (historyStack.value.length === 0) return null;
redoStack.value.push(deepClone(current));
const prev = historyStack.value.pop()!;
nodes.value = deepClone(prev.nodes);
edges.value = deepClone(prev.edges);
return deepClone(prev);
}
function redo(current: HistorySnapshot): HistorySnapshot | null {
if (redoStack.value.length === 0) return null;
historyStack.value.push(deepClone(current));
const next = redoStack.value.pop()!;
nodes.value = deepClone(next.nodes);
edges.value = deepClone(next.edges);
return deepClone(next);
}
function setNodes(newNodes: DiagramNode[]) {
nodes.value = newNodes;
}
function setEdges(newEdges: DiagramEdge[]) {
edges.value = newEdges;
}
function updateNodePosition(nodeId: string, position: { x: number; y: number }) {
const node = nodes.value.find((n) => n.id === nodeId);
if (node) {
pushStoreHistory();
node.position = position;
}
}
async function applyLayout(direction?: LayoutOptions["direction"]) {
pushStoreHistory();
const result = await layoutManager.applyElkLayout(nodes.value, edges.value, direction);
nodes.value = result.nodes;
edges.value = result.edges;
layerLayouts.value = [];
}
async function applyElkLayoutWithLayers(nodesParam: DiagramNode[], edgesParam: DiagramEdge[], layers: DiagramLayer[]) {
pushStoreHistory();
const result = await layoutManager.applyElkLayoutWithLayers(nodesParam, edgesParam, layers);
nodes.value = result.nodes;
edges.value = result.edges;
layerLayouts.value = result.layerLayouts || [];
return result;
}
function applyGridLayout() {
pushStoreHistory();
nodes.value = layoutManager.applyGridLayout(nodes.value);
}
function clearHistory() {
historyStack.value = [];
redoStack.value = [];
}
return {
nodes,
edges,
layerLayouts,
undo,
redo,
canUndo,
canRedo,
pushHistory,
setNodes,
setEdges,
updateNodePosition,
applyLayout,
applyElkLayoutWithLayers,
applyGridLayout,
clearHistory,
};
});

View File

@ -0,0 +1,182 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import type { DiagramLayer, LayerLayoutMode } from "@/types/diagram";
import { LAYER_COLORS } from "@/types/diagram";
function generateLayerId(): string {
return `layer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
function getNextColor(existingColors: string[]): string {
const usedColors = new Set(existingColors);
for (const color of LAYER_COLORS) {
if (!usedColors.has(color)) {
return color;
}
}
return LAYER_COLORS[existingColors.length % LAYER_COLORS.length];
}
function getNextLayerName(existingNames: string[]): string {
let maxIndex = 0;
const layerNameRegex = /^Layer (\d+)$/;
for (const name of existingNames) {
const match = name.match(layerNameRegex);
if (match) {
const index = parseInt(match[1], 10);
if (index > maxIndex) {
maxIndex = index;
}
}
}
return `Layer ${maxIndex + 1}`;
}
export const useLayerStore = defineStore("diagram-layer", () => {
const layers = ref<DiagramLayer[]>([]);
const activeLayerId = ref<string | null>(null);
const activeLayer = computed(() => layers.value.find((l) => l.id === activeLayerId.value));
function getLayerByTable(tableName: string): DiagramLayer | undefined {
return layers.value.find((layer) => layer.tableNames.includes(tableName));
}
function getLayerColor(tableName: string): string {
const layer = getLayerByTable(tableName);
return layer?.color || "#9ca3af";
}
function addLayer(name?: string, position?: { x: number; y: number }, size?: { width?: number; height?: number }): DiagramLayer {
for (const layer of layers.value) {
layer.collapsed = true;
}
const newLayer: DiagramLayer = {
id: generateLayerId(),
name: name || getNextLayerName(layers.value.map((l) => l.name)),
color: getNextColor(layers.value.map((l) => l.color)),
tableNames: [],
collapsed: false,
visible: true,
layoutMode: "auto",
position: position || { x: 40, y: 40 },
width: size?.width ?? 240,
height: size?.height ?? 52,
};
layers.value.push(newLayer);
activeLayerId.value = newLayer.id;
return newLayer;
}
function setLayoutMode(layerId: string, layoutMode: LayerLayoutMode) {
const layer = layers.value.find((l) => l.id === layerId);
if (layer) {
layer.layoutMode = layoutMode;
}
}
function updateLayerGeometry(layerId: string, geometry: { position?: { x: number; y: number }; width?: number; height?: number }) {
const layer = layers.value.find((l) => l.id === layerId);
if (!layer) return;
if (geometry.position) layer.position = geometry.position;
if (geometry.width !== undefined) layer.width = geometry.width;
if (geometry.height !== undefined) layer.height = geometry.height;
}
function removeLayer(layerId: string) {
const index = layers.value.findIndex((l) => l.id === layerId);
if (index === -1) return;
layers.value.splice(index, 1);
if (activeLayerId.value === layerId) {
activeLayerId.value = layers.value[0]?.id || null;
}
}
function renameLayer(layerId: string, newName: string) {
const layer = layers.value.find((l) => l.id === layerId);
if (layer) {
layer.name = newName;
}
}
function setActiveLayer(layerId: string | null) {
activeLayerId.value = layerId;
}
function addTableToLayer(layerId: string, tableName: string) {
const layer = layers.value.find((l) => l.id === layerId);
if (layer && !layer.tableNames.includes(tableName)) {
layer.tableNames.push(tableName);
}
}
function removeTableFromLayer(layerId: string, tableName: string) {
const layer = layers.value.find((l) => l.id === layerId);
if (layer) {
layer.tableNames = layer.tableNames.filter((name) => name !== tableName);
}
}
function moveTableToLayer(tableName: string, targetLayerId: string) {
const currentLayer = getLayerByTable(tableName);
if (currentLayer && currentLayer.id !== targetLayerId) {
removeTableFromLayer(currentLayer.id, tableName);
}
addTableToLayer(targetLayerId, tableName);
}
function toggleLayerVisibility(layerId: string) {
const layer = layers.value.find((l) => l.id === layerId);
if (layer) {
layer.visible = !layer.visible;
}
}
function toggleLayerCollapse(layerId: string) {
const layer = layers.value.find((l) => l.id === layerId);
if (layer) {
layer.collapsed = !layer.collapsed;
}
}
function clearLayers() {
layers.value = [];
activeLayerId.value = null;
}
function loadLayers(savedLayers: DiagramLayer[]) {
layers.value = savedLayers.map((layer) => ({
...layer,
layoutMode: layer.layoutMode ?? "auto",
}));
activeLayerId.value = layers.value[0]?.id || null;
}
function toJSON(): DiagramLayer[] {
return JSON.parse(JSON.stringify(layers.value));
}
return {
layers,
activeLayerId,
activeLayer,
getLayerByTable,
getLayerColor,
addLayer,
removeLayer,
renameLayer,
setActiveLayer,
setLayoutMode,
updateLayerGeometry,
addTableToLayer,
removeTableFromLayer,
moveTableToLayer,
toggleLayerVisibility,
toggleLayerCollapse,
clearLayers,
loadLayers,
toJSON,
};
});

View File

@ -0,0 +1,46 @@
import type { DiagramNode, DiagramEdge, LayoutOptions, DiagramLayer } from "@/types/diagram";
import { computeLayout, computeLayoutWithLayers, type LayerLayoutInfo } from "./elk-layout";
export interface LayoutManagerOptions {
defaultDirection?: LayoutOptions["direction"];
gridColumns?: number;
}
export class LayoutManager {
private defaultDirection: LayoutOptions["direction"];
private gridColumns: number;
constructor(options: LayoutManagerOptions = {}) {
this.defaultDirection = options.defaultDirection || "LR";
this.gridColumns = options.gridColumns || 4;
}
async applyElkLayout(nodes: DiagramNode[], edges: DiagramEdge[], direction?: LayoutOptions["direction"]): Promise<{ nodes: DiagramNode[]; edges: DiagramEdge[] }> {
return computeLayout(nodes, edges, { direction: direction || this.defaultDirection });
}
async applyElkLayoutWithLayers(nodes: DiagramNode[], edges: DiagramEdge[], layers: DiagramLayer[], direction?: LayoutOptions["direction"]): Promise<{ nodes: DiagramNode[]; edges: DiagramEdge[]; layerLayouts: LayerLayoutInfo[] }> {
return computeLayoutWithLayers(nodes, edges, layers, { direction: direction || this.defaultDirection });
}
applyGridLayout(nodes: DiagramNode[]): DiagramNode[] {
const columns = Math.max(1, Math.min(this.gridColumns, Math.ceil(Math.sqrt(nodes.length))));
const cardWidth = 270;
const rowHeight = 240;
const gapX = 64;
const gapY = 44;
const margin = 40;
return nodes.map((node, index) => {
const col = index % columns;
const row = Math.floor(index / columns);
return {
...node,
position: {
x: margin + col * (cardWidth + gapX),
y: margin + row * (rowHeight + gapY),
},
};
});
}
}

View File

@ -0,0 +1,345 @@
import type { DiagramLayer } from "@/types/diagram";
import type { DiagramPosition, DiagramTable } from "@/lib/diagram/erDiagram";
import { layoutDiagramTables } from "@/lib/diagram/erDiagram";
import { sizeLayerToFit } from "@/lib/diagram/size-layer";
import { CARD_WIDTH, EMPTY_LAYER_HEIGHT, EMPTY_LAYER_WIDTH, GAP_X, GAP_Y, LAYER_CONTENT_PADDING, LAYER_HEADER_HEIGHT, MARGIN, columnsPerRowForWidth, tableCardHeight } from "@/lib/diagram/diagram-constants";
export interface LtrAutoLayoutInput {
tables: DiagramTable[];
positions: Record<string, DiagramPosition>;
layers: DiagramLayer[];
paneWidth: number;
/** Optional edges used to cluster related tables in LTR order. */
relationships?: Array<{ sourceTable: string; targetTable: string }>;
}
export interface LtrAutoLayoutResult {
positions: Record<string, DiagramPosition>;
/** Mutated copies of visible layers with updated geometry (caller should apply to store). */
layers: DiagramLayer[];
}
type LayerBox = {
layer: DiagramLayer;
width: number;
height: number;
/** Absolute table positions relative to a provisional origin (0,0) for the layer box. */
localTablePositions: Record<string, DiagramPosition>;
};
function tableHeightsMap(tables: DiagramTable[]): Record<string, number> {
const heights: Record<string, number> = {};
for (const table of tables) {
heights[table.name] = tableCardHeight(table.columns?.length ?? 0);
}
return heights;
}
/** Order tables so connected components stay adjacent (for friendlier LTR grids). */
export function orderTablesByConnectivity(tables: DiagramTable[], relationships: Array<{ sourceTable: string; targetTable: string }> = []): DiagramTable[] {
if (tables.length <= 1 || relationships.length === 0) return [...tables];
const nameSet = new Set(tables.map((t) => t.name));
const adj = new Map<string, Set<string>>();
for (const name of nameSet) adj.set(name, new Set());
for (const rel of relationships) {
if (!nameSet.has(rel.sourceTable) || !nameSet.has(rel.targetTable)) continue;
if (rel.sourceTable === rel.targetTable) continue;
adj.get(rel.sourceTable)!.add(rel.targetTable);
adj.get(rel.targetTable)!.add(rel.sourceTable);
}
const byName = new Map(tables.map((t) => [t.name, t]));
const visited = new Set<string>();
const ordered: DiagramTable[] = [];
const visitComponent = (start: string) => {
const stack = [start];
const component: string[] = [];
while (stack.length > 0) {
const name = stack.pop()!;
if (visited.has(name)) continue;
visited.add(name);
component.push(name);
for (const next of adj.get(name) ?? []) {
if (!visited.has(next)) stack.push(next);
}
}
component.sort((a, b) => a.localeCompare(b));
for (const name of component) {
const table = byName.get(name);
if (table) ordered.push(table);
}
};
// Prefer starting from tables that have edges, then isolates (stable name order)
const withEdges = tables.filter((t) => (adj.get(t.name)?.size ?? 0) > 0).sort((a, b) => a.name.localeCompare(b.name));
const isolates = tables.filter((t) => (adj.get(t.name)?.size ?? 0) === 0).sort((a, b) => a.name.localeCompare(b.name));
for (const table of withEdges) {
if (!visited.has(table.name)) visitComponent(table.name);
}
for (const table of isolates) {
if (!visited.has(table.name)) ordered.push(table);
}
return ordered;
}
/**
* Reflow unassigned (not in any visible layer) tables into an LTR grid,
* preserving approximate visual order (sort by y then x).
*/
export function reflowUnassignedTables(input: { tables: DiagramTable[]; positions: Record<string, DiagramPosition>; layers: DiagramLayer[]; paneWidth: number; yOrigin?: number }): Record<string, DiagramPosition> {
const { tables, positions, layers, paneWidth } = input;
const columnsPerRow = columnsPerRowForWidth(paneWidth);
const assigned = new Set(layers.filter((l) => l.visible).flatMap((l) => l.tableNames));
const unassigned = tables.filter((t) => !assigned.has(t.name));
if (unassigned.length === 0) return { ...positions };
const sorted = [...unassigned].sort((a, b) => {
const pa = positions[a.name] ?? { x: 0, y: 0 };
const pb = positions[b.name] ?? { x: 0, y: 0 };
if (pa.y !== pb.y) return pa.y - pb.y;
return pa.x - pb.x;
});
let layersBottom = MARGIN;
for (const layer of layers.filter((l) => l.visible)) {
if (layer.position == null || layer.height == null) continue;
layersBottom = Math.max(layersBottom, layer.position.y + layer.height);
}
const yOrigin = input.yOrigin ?? (layers.some((l) => l.visible) ? layersBottom + GAP_Y : MARGIN);
const grid = layoutDiagramTables(sorted, {
columnsPerRow,
cardWidth: CARD_WIDTH,
gapX: GAP_X,
gapY: GAP_Y,
margin: MARGIN,
});
const next = { ...positions };
const yOffset = yOrigin - MARGIN;
for (const [name, pos] of Object.entries(grid)) {
next[name] = { x: pos.x, y: pos.y + yOffset };
}
return next;
}
function measureFreeLocal(layer: DiagramLayer, positions: Record<string, DiagramPosition>, heights: Record<string, number>): LayerBox {
if (layer.tableNames.length === 0) {
return {
layer,
width: EMPTY_LAYER_WIDTH,
height: EMPTY_LAYER_HEIGHT,
localTablePositions: {},
};
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const absolute: Record<string, DiagramPosition> = {};
for (const name of layer.tableNames) {
const position = positions[name];
if (!position) continue;
const height = heights[name] ?? 120;
absolute[name] = { ...position };
minX = Math.min(minX, position.x);
minY = Math.min(minY, position.y);
maxX = Math.max(maxX, position.x + CARD_WIDTH);
maxY = Math.max(maxY, position.y + height);
}
if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
return {
layer,
width: EMPTY_LAYER_WIDTH,
height: EMPTY_LAYER_HEIGHT,
localTablePositions: {},
};
}
const originX = minX - LAYER_CONTENT_PADDING;
const originY = minY - LAYER_HEADER_HEIGHT - LAYER_CONTENT_PADDING;
const localTablePositions: Record<string, DiagramPosition> = {};
for (const [name, pos] of Object.entries(absolute)) {
localTablePositions[name] = { x: pos.x - originX, y: pos.y - originY };
}
return {
layer,
width: Math.max(EMPTY_LAYER_WIDTH, maxX - minX + LAYER_CONTENT_PADDING * 2),
height: Math.max(EMPTY_LAYER_HEIGHT, maxY - minY + LAYER_HEADER_HEIGHT + LAYER_CONTENT_PADDING * 2),
localTablePositions,
};
}
function measureAutoLocal(layer: DiagramLayer, tablesByName: Map<string, DiagramTable>, columnsPerRow: number): LayerBox {
if (layer.tableNames.length === 0) {
return {
layer,
width: EMPTY_LAYER_WIDTH,
height: EMPTY_LAYER_HEIGHT,
localTablePositions: {},
};
}
const layerTables = layer.tableNames.map((name) => tablesByName.get(name)).filter((t): t is DiagramTable => !!t);
if (layerTables.length === 0) {
return {
layer,
width: EMPTY_LAYER_WIDTH,
height: EMPTY_LAYER_HEIGHT,
localTablePositions: {},
};
}
// Layout with margin 0, then offset into layer content area
const grid = layoutDiagramTables(layerTables, {
columnsPerRow,
cardWidth: CARD_WIDTH,
gapX: GAP_X,
gapY: GAP_Y,
margin: 0,
});
const localTablePositions: Record<string, DiagramPosition> = {};
for (const [name, pos] of Object.entries(grid)) {
localTablePositions[name] = {
x: pos.x + LAYER_CONTENT_PADDING,
y: pos.y + LAYER_HEADER_HEIGHT + LAYER_CONTENT_PADDING,
};
}
// Provisional size via sizeLayerToFit against local coords (origin 0,0)
const scratch: DiagramLayer = {
...layer,
position: { x: 0, y: 0 },
tableNames: [...layer.tableNames],
};
const heights = tableHeightsMap(layerTables);
sizeLayerToFit(scratch, localTablePositions, heights);
return {
layer,
width: scratch.width ?? EMPTY_LAYER_WIDTH,
height: scratch.height ?? EMPTY_LAYER_HEIGHT,
localTablePositions,
};
}
/**
* Auto-layout: visible layers packed at top (LTR wrap), unassigned tables
* in an LTR grid below same column logic as initial resetLayout.
*/
export function computeLtrAutoLayout(input: LtrAutoLayoutInput): LtrAutoLayoutResult {
const { tables, positions: prevPositions, layers, paneWidth, relationships = [] } = input;
const columnsPerRow = columnsPerRowForWidth(paneWidth);
const heights = tableHeightsMap(tables);
const tablesByName = new Map(tables.map((t) => [t.name, t]));
const nextPositions: Record<string, DiagramPosition> = { ...prevPositions };
const visibleLayers = layers.filter((l) => l.visible);
const assigned = new Set(visibleLayers.flatMap((l) => l.tableNames));
const orderedTables = orderTablesByConnectivity(tables, relationships);
const unassigned = orderedTables.filter((t) => !assigned.has(t.name));
if (visibleLayers.length === 0) {
const grid = layoutDiagramTables(orderedTables, {
columnsPerRow,
cardWidth: CARD_WIDTH,
gapX: GAP_X,
gapY: GAP_Y,
margin: MARGIN,
});
return { positions: { ...nextPositions, ...grid }, layers: [] };
}
const boxes: LayerBox[] = visibleLayers.map((layer) => {
const mode = layer.layoutMode ?? "free";
if (mode === "auto") {
const layerTables = orderTablesByConnectivity(
layer.tableNames.map((n) => tablesByName.get(n)).filter((t): t is DiagramTable => !!t),
relationships,
);
const scratchLayer = { ...layer, tableNames: layerTables.map((t) => t.name) };
return measureAutoLocal(scratchLayer, tablesByName, columnsPerRow);
}
return measureFreeLocal(layer, prevPositions, heights);
});
const usableWidth = Math.max(CARD_WIDTH, paneWidth - MARGIN * 2);
let cursorX = MARGIN;
let cursorY = MARGIN;
let rowHeight = 0;
let layersBottom = MARGIN;
const updatedLayers: DiagramLayer[] = [];
for (const box of boxes) {
const { width, height, localTablePositions, layer } = box;
if (cursorX > MARGIN && cursorX + width > MARGIN + usableWidth) {
cursorX = MARGIN;
cursorY += rowHeight + GAP_Y;
rowHeight = 0;
}
const placed: DiagramLayer = {
...layer,
position: { x: cursorX, y: cursorY },
width,
height,
tableNames: [...layer.tableNames],
};
for (const [name, local] of Object.entries(localTablePositions)) {
nextPositions[name] = {
x: cursorX + local.x,
y: cursorY + local.y,
};
}
// Reconcile size from absolute table positions (auto / free with tables)
if (placed.tableNames.length > 0) {
sizeLayerToFit(placed, nextPositions, heights);
} else {
placed.width = EMPTY_LAYER_WIDTH;
placed.height = EMPTY_LAYER_HEIGHT;
placed.position = { x: cursorX, y: cursorY };
}
const finalW = placed.width ?? width;
const finalH = placed.height ?? height;
const finalX = placed.position?.x ?? cursorX;
const finalY = placed.position?.y ?? cursorY;
updatedLayers.push(placed);
cursorX = finalX + finalW + GAP_X;
rowHeight = Math.max(rowHeight, finalH);
layersBottom = Math.max(layersBottom, finalY + finalH);
}
if (unassigned.length > 0) {
const grid = layoutDiagramTables(unassigned, {
columnsPerRow,
cardWidth: CARD_WIDTH,
gapX: GAP_X,
gapY: GAP_Y,
margin: MARGIN,
});
const yOffset = layersBottom + GAP_Y - MARGIN;
for (const [name, pos] of Object.entries(grid)) {
nextPositions[name] = { x: pos.x, y: pos.y + yOffset };
}
}
return { positions: nextPositions, layers: updatedLayers };
}

View File

@ -0,0 +1,131 @@
import type { DiagramTable, DiagramRelationship } from "./erDiagram";
import type { InferredRelationship, MatchResult } from "@/types/diagram";
import { buildPrimaryKeyIndex, extractTableNameFromColumn, isTypeCompatible, toSnakeCase } from "./match-strategies";
export function buildRelationshipId(sourceTable: string, sourceColumn: string, targetTable: string, targetColumn: string): string {
return ["inferred", sourceTable, sourceColumn, targetTable, targetColumn].join(":");
}
export function inferRelationships(tables: DiagramTable[]): InferredRelationship[] {
const results: InferredRelationship[] = [];
const tableNameSet = new Set(tables.map((t) => t.name));
const primaryKeys = buildPrimaryKeyIndex(tables);
for (const table of tables) {
for (const column of table.columns) {
if (column.is_primary_key) continue;
const candidateTableName = extractTableNameFromColumn(column.name);
if (!candidateTableName) continue;
const candidateTable = toSnakeCase(candidateTableName);
if (!tableNameSet.has(candidateTable)) {
const partialMatch = tables.find((t) => t.name.toLowerCase().includes(candidateTableName.toLowerCase()));
if (partialMatch) {
const targetPK = primaryKeys.get(partialMatch.name);
if (targetPK) {
const strategy = isTypeCompatible(column.data_type, targetPK.data_type) ? "type_signature" : "naming_convention";
const confidence = "medium";
results.push({
id: buildRelationshipId(table.name, column.name, partialMatch.name, targetPK.name),
sourceTable: table.name,
sourceColumn: column.name,
targetTable: partialMatch.name,
targetColumn: targetPK.name,
confidence,
strategy,
});
}
}
continue;
}
const targetPK = primaryKeys.get(candidateTable);
if (!targetPK) continue;
const strategy = isTypeCompatible(column.data_type, targetPK.data_type) ? "type_signature" : "naming_convention";
const confidence = strategy === "type_signature" ? "high" : "high";
results.push({
id: buildRelationshipId(table.name, column.name, candidateTable, targetPK.name),
sourceTable: table.name,
sourceColumn: column.name,
targetTable: candidateTable,
targetColumn: targetPK.name,
confidence,
strategy,
});
}
}
return deduplicate(results);
}
function deduplicate(relationships: InferredRelationship[]): InferredRelationship[] {
const seen = new Set<string>();
const unique: InferredRelationship[] = [];
for (const rel of relationships) {
const key = `${rel.sourceTable}:${rel.sourceColumn}:${rel.targetTable}:${rel.targetColumn}`;
if (!seen.has(key)) {
seen.add(key);
unique.push(rel);
}
}
return unique;
}
export function filterByStorage(inferred: InferredRelationship[], confirms: string[], ignores: string[]): MatchResult {
const confirmed = inferred.filter((r) => confirms.includes(r.id));
const pending = inferred.filter((r) => !confirms.includes(r.id) && !ignores.includes(r.id));
const conflictList = findConflicts(pending.filter((r) => r.confidence === "high"));
const conflictIds = new Set(conflictList.map((r) => r.id));
const actionablePending = pending.filter((r) => !conflictIds.has(r.id));
return {
relationships: [...confirmed, ...actionablePending],
conflicts: conflictList,
pending: actionablePending,
stats: {
total: inferred.length,
high: inferred.filter((r) => r.confidence === "high").length,
medium: inferred.filter((r) => r.confidence === "medium").length,
},
};
}
function findConflicts(relationships: InferredRelationship[]): InferredRelationship[] {
const conflicts: InferredRelationship[] = [];
const sourceMap = new Map<string, InferredRelationship[]>();
for (const rel of relationships) {
const key = `${rel.sourceTable}:${rel.sourceColumn}`;
const existing = sourceMap.get(key) || [];
existing.push(rel);
sourceMap.set(key, existing);
}
for (const [, rels] of sourceMap) {
if (rels.length > 1) {
conflicts.push(...rels);
}
}
return conflicts;
}
export function mergeRelationships(existing: DiagramRelationship[], inferred: InferredRelationship[]): (DiagramRelationship | InferredRelationship)[] {
const existingIds = new Set(existing.map((r) => r.id));
const merged: (DiagramRelationship | InferredRelationship)[] = [...existing];
for (const rel of inferred) {
if (!existingIds.has(rel.id)) {
merged.push(rel);
}
}
return merged;
}

View File

@ -0,0 +1,63 @@
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
function matchStorageKey(type: "match-confirms" | "match-ignores" | "match-rules", connectionId: string, database: string, schema: string): string {
return ["dbx", "diagram", type, "v1", connectionId, database, schema].join(":");
}
export function loadMatchConfirms(connectionId: string, database: string, schema: string): string[] {
const key = matchStorageKey("match-confirms", connectionId, database, schema);
try {
return JSON.parse(safeLocalStorageGet(key) || "[]");
} catch {
return [];
}
}
export function saveMatchConfirms(ids: string[], connectionId: string, database: string, schema: string): void {
const key = matchStorageKey("match-confirms", connectionId, database, schema);
safeLocalStorageSet(key, JSON.stringify(ids));
}
export function loadMatchIgnores(connectionId: string, database: string, schema: string): string[] {
const key = matchStorageKey("match-ignores", connectionId, database, schema);
try {
return JSON.parse(safeLocalStorageGet(key) || "[]");
} catch {
return [];
}
}
export function saveMatchIgnores(ids: string[], connectionId: string, database: string, schema: string): void {
const key = matchStorageKey("match-ignores", connectionId, database, schema);
safeLocalStorageSet(key, JSON.stringify(ids));
}
export function loadMatchRules(connectionId: string, database: string, schema: string): MatchRule[] {
const key = matchStorageKey("match-rules", connectionId, database, schema);
try {
return JSON.parse(safeLocalStorageGet(key) || "[]");
} catch {
return [];
}
}
export function saveMatchRules(rules: MatchRule[], connectionId: string, database: string, schema: string): void {
const key = matchStorageKey("match-rules", connectionId, database, schema);
safeLocalStorageSet(key, JSON.stringify(rules));
}
export function isAutoMatchEnabled(): boolean {
return safeLocalStorageGet("dbx:diagram:match-enabled") !== "false";
}
export function setAutoMatchEnabled(enabled: boolean): void {
safeLocalStorageSet("dbx:diagram:match-enabled", String(enabled));
}
export interface MatchRule {
id: string;
name: string;
pattern: string;
enabled: boolean;
priority: number;
}

View File

@ -0,0 +1,71 @@
import type { DiagramTable } from "./erDiagram";
const TYPE_COMPATIBLE_MAP: Record<string, string[]> = {
bigint: ["bigint", "int", "smallint", "tinyint", "integer", "long"],
int: ["int", "bigint", "smallint", "tinyint", "integer"],
smallint: ["smallint", "int", "bigint", "tinyint"],
tinyint: ["tinyint", "smallint", "int", "bigint"],
integer: ["integer", "int", "bigint", "smallint"],
uuid: ["uuid", "char", "varchar", "nvarchar", "uniqueidentifier", "guid"],
char: ["char", "varchar", "uuid", "uniqueidentifier"],
varchar: ["varchar", "char", "uuid", "uniqueidentifier"],
nvarchar: ["nvarchar", "varchar", "char", "uuid"],
uniqueidentifier: ["uniqueidentifier", "uuid", "char", "varchar"],
};
export function isTypeCompatible(sourceType: string, targetType: string): boolean {
const source = sourceType.toLowerCase();
const target = targetType.toLowerCase();
if (source === target) return true;
const compatibleWithSource = TYPE_COMPATIBLE_MAP[source];
if (compatibleWithSource && compatibleWithSource.includes(target)) {
return true;
}
const compatibleWithTarget = TYPE_COMPATIBLE_MAP[target];
if (compatibleWithTarget && compatibleWithTarget.includes(source)) {
return true;
}
return false;
}
export function toSnakeCase(str: string): string {
return str
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
.toLowerCase();
}
export interface PrimaryKeyInfo {
name: string;
data_type: string;
}
export function buildPrimaryKeyIndex(tables: DiagramTable[]): Map<string, PrimaryKeyInfo> {
const index = new Map<string, PrimaryKeyInfo>();
for (const table of tables) {
const pk = table.columns.find((col) => col.is_primary_key);
if (pk) {
index.set(table.name, { name: pk.name, data_type: pk.data_type });
}
}
return index;
}
export function extractTableNameFromColumn(columnName: string): string | null {
const camelCaseMatch = columnName.match(/^(.+?)(?:ID|UID)$/);
if (camelCaseMatch) return camelCaseMatch[1];
const underscoreMatch = columnName.match(/^(.+?)_(?:id|uuid|pk)$/i);
if (underscoreMatch) return underscoreMatch[1];
const lowercaseMatch = columnName.match(/^(.+?)(?:id|uid)$/i);
if (lowercaseMatch) return lowercaseMatch[1];
return null;
}

View File

@ -0,0 +1,116 @@
import type { DiagramLayer } from "@/types/diagram";
import { CARD_WIDTH, EMPTY_LAYER_HEIGHT, EMPTY_LAYER_WIDTH, LAYER_CONTENT_PADDING, LAYER_HEADER_HEIGHT, MARGIN } from "./diagram-constants";
type Rect = { x: number; y: number; width: number; height: number };
function rectsOverlap(a: Rect, b: Rect, gap = 8): boolean {
return !(a.x + a.width + gap <= b.x || b.x + b.width + gap <= a.x || a.y + a.height + gap <= b.y || b.y + b.height + gap <= a.y);
}
function collectOccupiedRects(layers: DiagramLayer[], tablePositions: Record<string, { x: number; y: number }>, tableHeights: Record<string, number>, excludeLayerId?: string): Rect[] {
const rects: Rect[] = [];
for (const [name, position] of Object.entries(tablePositions)) {
const parented = layers.some((l) => l.id !== excludeLayerId && l.tableNames.includes(name));
// Tables inside a layer are covered by the layer rect; still include unassigned tables
if (parented) continue;
rects.push({
x: position.x,
y: position.y,
width: CARD_WIDTH,
height: tableHeights[name] ?? 120,
});
}
for (const layer of layers) {
if (excludeLayerId && layer.id === excludeLayerId) continue;
if (!layer.visible || layer.position == null || layer.width == null || layer.height == null) continue;
rects.push({
x: layer.position.x,
y: layer.position.y,
width: layer.width,
height: layer.height,
});
}
return rects;
}
export function sizeLayerToFit(layer: DiagramLayer, tablePositions: Record<string, { x: number; y: number }>, tableHeights: Record<string, number>): void {
if (layer.tableNames.length === 0) {
layer.width = EMPTY_LAYER_WIDTH;
layer.height = EMPTY_LAYER_HEIGHT;
return;
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const name of layer.tableNames) {
const position = tablePositions[name];
if (!position) continue;
const height = tableHeights[name] ?? 120;
minX = Math.min(minX, position.x);
minY = Math.min(minY, position.y);
maxX = Math.max(maxX, position.x + CARD_WIDTH);
maxY = Math.max(maxY, position.y + height);
}
if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
layer.width = EMPTY_LAYER_WIDTH;
layer.height = EMPTY_LAYER_HEIGHT;
return;
}
layer.position = {
x: minX - LAYER_CONTENT_PADDING,
y: minY - LAYER_HEADER_HEIGHT - LAYER_CONTENT_PADDING,
};
layer.width = Math.max(EMPTY_LAYER_WIDTH, maxX - minX + LAYER_CONTENT_PADDING * 2);
layer.height = Math.max(EMPTY_LAYER_HEIGHT, maxY - minY + LAYER_HEADER_HEIGHT + LAYER_CONTENT_PADDING * 2);
}
export function pointInLayerBounds(point: { x: number; y: number }, layer: DiagramLayer): boolean {
if (!layer.visible || layer.position == null || layer.width == null || layer.height == null) {
return false;
}
const { x, y } = layer.position;
return point.x >= x && point.x <= x + layer.width && point.y >= y && point.y <= y + layer.height;
}
export function findLayerAtPoint(point: { x: number; y: number }, layers: DiagramLayer[], excludeLayerId?: string): DiagramLayer | undefined {
for (let i = layers.length - 1; i >= 0; i -= 1) {
const layer = layers[i];
if (excludeLayerId && layer.id === excludeLayerId) continue;
if (pointInLayerBounds(point, layer)) return layer;
}
return undefined;
}
/**
* Place a new empty layer near the top-left of the canvas without overlapping
* existing tables or layers.
*/
export function placeNewLayer(layers: DiagramLayer[], tablePositions: Record<string, { x: number; y: number }>, tableHeights: Record<string, number>, options?: { width?: number; height?: number; startY?: number }): { position: { x: number; y: number }; width: number; height: number } {
const width = options?.width ?? EMPTY_LAYER_WIDTH;
const height = options?.height ?? EMPTY_LAYER_HEIGHT;
const startY = options?.startY ?? MARGIN;
const occupied = collectOccupiedRects(layers, tablePositions, tableHeights);
const stepX = width + 16;
const stepY = height + 16;
const maxX = 2400;
for (let row = 0; row < 40; row += 1) {
const y = startY + row * stepY;
for (let x = MARGIN; x + width <= maxX; x += stepX) {
const candidate: Rect = { x, y, width, height };
if (!occupied.some((rect) => rectsOverlap(candidate, rect))) {
return { position: { x, y }, width, height };
}
}
}
return { position: { x: MARGIN, y: startY + occupied.length * stepY }, width, height };
}

View File

@ -0,0 +1,167 @@
import type { DiagramTable, DiagramRelationship } from "./erDiagram";
import type { InferredRelationship, DiagramNode, DiagramEdge, DiagramLayer } from "@/types/diagram";
import type { Node, Edge } from "@vue-flow/core";
import { useLayerStore } from "./layer-store";
import { CARD_WIDTH, tableCardHeight } from "./diagram-constants";
import { handlesFromWaypoints, type Point } from "./edge-obstacle-router";
const LAYER_Z_INDEX = 0;
const TABLE_Z_INDEX = 10;
export type RelationshipEdgeData = {
relationship: DiagramRelationship | InferredRelationship;
waypoints?: Point[];
};
/** Table is on canvas when unlayered, or when its layer is visible. */
export function isTableCanvasVisible(tableName: string, layers: DiagramLayer[]): boolean {
const layer = layers.find((l) => l.tableNames.includes(tableName));
if (!layer) return true;
return layer.visible;
}
/** positions are absolute canvas coordinates; converted to relative when parented to a visible layer */
export function toVueFlowNodes(tables: DiagramTable[], positions?: Record<string, { x: number; y: number }>): Node<{ table: DiagramTable }>[] {
const layerStore = useLayerStore();
const layers = layerStore.layers;
return tables
.filter((table) => !table.pendingDrop && isTableCanvasVisible(table.name, layers))
.map((table) => {
const layer = layerStore.getLayerByTable(table.name);
const absolute = positions?.[table.name] || { x: 0, y: 0 };
const visibleParent = layer?.visible ? layer : undefined;
const layerPos = visibleParent?.position || { x: 0, y: 0 };
const relative = visibleParent ? { x: absolute.x - layerPos.x, y: absolute.y - layerPos.y } : absolute;
return {
id: table.name,
type: "table",
position: relative,
parentNode: visibleParent?.id,
expandParent: false,
zIndex: TABLE_Z_INDEX,
data: { table },
};
});
}
export function toDiagramNodes(vueFlowNodes: Node<{ table: DiagramTable }>[]): DiagramNode[] {
return vueFlowNodes
.filter((node) => (node.type === "table" || !node.type) && node.data?.table)
.map((node) => ({
id: node.id,
type: node.type || "table",
position: node.position,
data: { table: node.data!.table },
}));
}
/** Pick L/R or T/B handles from table center deltas. */
export function pickHandles(sourcePos: { x: number; y: number } | undefined, targetPos: { x: number; y: number } | undefined, sourceHeight = 120, targetHeight = 120, cardWidth = CARD_WIDTH): { sourceHandle: string; targetHandle: string } {
if (!sourcePos || !targetPos) {
return { sourceHandle: "right", targetHandle: "left-target" };
}
const scx = sourcePos.x + cardWidth / 2;
const scy = sourcePos.y + sourceHeight / 2;
const tcx = targetPos.x + cardWidth / 2;
const tcy = targetPos.y + targetHeight / 2;
const dx = tcx - scx;
const dy = tcy - scy;
if (Math.abs(dx) >= Math.abs(dy)) {
if (dx >= 0) return { sourceHandle: "right", targetHandle: "left-target" };
return { sourceHandle: "left", targetHandle: "right-target" };
}
if (dy >= 0) return { sourceHandle: "bottom", targetHandle: "top-target" };
return { sourceHandle: "top", targetHandle: "bottom-target" };
}
export function toVueFlowEdges(
relationships: (DiagramRelationship | InferredRelationship)[],
positions?: Record<string, { x: number; y: number }>,
waypointsById?: Record<string, Point[]>,
tableHeights?: Record<string, number>,
handleHintsById?: Record<string, { sourceHandle?: string; targetHandle?: string }>,
): Edge<RelationshipEdgeData>[] {
const layers = useLayerStore().layers;
return relationships
.filter((rel) => isTableCanvasVisible(rel.sourceTable, layers) && isTableCanvasVisible(rel.targetTable, layers))
.map((rel) => {
const sourceHeight = tableHeights?.[rel.sourceTable] ?? tableCardHeight(0);
const targetHeight = tableHeights?.[rel.targetTable] ?? tableCardHeight(0);
const waypoints = waypointsById?.[rel.id];
const fromWaypoints = waypoints?.length ? handlesFromWaypoints(waypoints) : null;
const hint = handleHintsById?.[rel.id];
const picked = pickHandles(positions?.[rel.sourceTable], positions?.[rel.targetTable], sourceHeight, targetHeight);
const sourceHandle = fromWaypoints?.sourceHandle || hint?.sourceHandle || picked.sourceHandle;
const targetHandle = fromWaypoints?.targetHandle || hint?.targetHandle || picked.targetHandle;
return {
id: rel.id,
type: "relationship",
source: rel.sourceTable,
target: rel.targetTable,
sourceHandle,
targetHandle,
class: "relationship-edge",
selectable: true,
interactionWidth: 24,
data: {
relationship: rel,
...(waypoints?.length ? { waypoints } : {}),
},
};
});
}
export function toDiagramEdges(vueFlowEdges: Edge<RelationshipEdgeData>[]): DiagramEdge[] {
return vueFlowEdges
.filter((edge) => edge.data?.relationship)
.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle ?? undefined,
targetHandle: edge.targetHandle ?? undefined,
waypoints: edge.data?.waypoints,
data: { relationship: edge.data!.relationship },
}));
}
export function toVueFlowLayerNodes(layers: DiagramLayer[]): Node<{ layer: DiagramLayer }>[] {
return layers
.filter((layer) => layer.visible)
.map((layer) => ({
id: layer.id,
type: "layer",
position: layer.position || { x: 0, y: 0 },
width: layer.width || 240,
height: layer.height || 52,
zIndex: LAYER_Z_INDEX,
draggable: true,
selectable: true,
dragHandle: ".layer-drag-handle",
data: { layer },
style: {
width: `${layer.width || 240}px`,
height: `${layer.height || 52}px`,
pointerEvents: "none",
},
}));
}
/** Convert Vue Flow node position (possibly relative) to absolute canvas coords */
export function toAbsolutePosition(nodeId: string, relativeOrAbsolute: { x: number; y: number }, layers: DiagramLayer[]): { x: number; y: number } {
const layer = layers.find((l) => l.id === nodeId);
if (layer) return relativeOrAbsolute;
const parent = layers.find((l) => l.visible && l.tableNames.includes(nodeId));
if (parent?.position) {
return {
x: parent.position.x + relativeOrAbsolute.x,
y: parent.position.y + relativeOrAbsolute.y,
};
}
return relativeOrAbsolute;
}

View File

@ -0,0 +1,194 @@
import type { CustomDiagramRelationship, DiagramPosition, DiagramRelationship, DiagramTable } from "@/lib/diagram/erDiagram";
import type { DiagramLayer } from "@/types/diagram";
export type DiagramExportFormat = "svg" | "png" | "json" | "dbml" | "mermaid";
export interface DiagramJsonSnapshot {
meta: {
connectionName: string;
database: string;
schema: string;
mode: "table" | "engineering";
exportedAt: string;
};
tables: Array<{
name: string;
columns: Array<{
name: string;
dataType: string;
nullable: boolean;
primaryKey: boolean;
}>;
foreignKeys: Array<{
name: string;
column: string;
refTable: string;
refColumn: string;
}>;
}>;
relationships: DiagramRelationship[];
positions: Record<string, DiagramPosition>;
layers: DiagramLayer[];
customRelationships: CustomDiagramRelationship[];
matchConfirms: string[];
matchIgnores: string[];
}
function fileToken(value: string): string {
return value
.trim()
.replace(/[^\p{L}\p{N}._-]+/gu, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
function quoteIdent(name: string): string {
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return name;
return `"${name.replace(/"/g, '\\"')}"`;
}
function mermaidIdent(name: string): string {
const cleaned = name.replace(/[^A-Za-z0-9_]/g, "_");
return cleaned || "entity";
}
function dbmlType(dataType: string): string {
const t = dataType.trim() || "varchar";
// Keep parentheses content; quote if spaces / special chars
if (/^[A-Za-z_][A-Za-z0-9_]*(?:\([^)]*\))?$/.test(t)) return t;
return `"${t.replace(/"/g, '\\"')}"`;
}
function dbmlRefOp(sourceCardinality: "1" | "N", targetCardinality: "1" | "N"): string {
if (sourceCardinality === "1" && targetCardinality === "1") return "-";
if (sourceCardinality === "N" && targetCardinality === "N") return "<>";
if (sourceCardinality === "N" && targetCardinality === "1") return ">";
return "<";
}
function mermaidCardinality(sourceCardinality: "1" | "N", targetCardinality: "1" | "N"): string {
// Mermaid: ENTITY1 ||--o{ ENTITY2 : "label"
if (sourceCardinality === "1" && targetCardinality === "1") return "||--||";
if (sourceCardinality === "1" && targetCardinality === "N") return "||--o{";
if (sourceCardinality === "N" && targetCardinality === "1") return "}o--||";
return "}o--o{";
}
export function diagramExportFileName(connectionName: string, databaseName: string, mode: "table" | "engineering", format: DiagramExportFormat): string {
const context = [connectionName, databaseName].map(fileToken).filter(Boolean);
const modeSuffix = mode === "engineering" ? "engineering-er" : "table-structure";
const ext = format === "svg" ? "svg" : format === "png" ? "png" : format === "json" ? "json" : format === "dbml" ? "dbml" : "mmd";
const kind = format === "svg" || format === "png" ? modeSuffix : format === "json" ? "diagram" : format === "dbml" ? "schema" : "er";
return ["dbx", ...(context.length > 0 ? context : ["diagram"]), kind].join("-") + `.${ext}`;
}
export function buildDiagramJson(snapshot: DiagramJsonSnapshot): string {
return `${JSON.stringify(snapshot, null, 2)}\n`;
}
export function buildDiagramDbml(tables: DiagramTable[], relationships: DiagramRelationship[]): string {
const lines: string[] = ["// Generated by DBX", ""];
for (const table of tables) {
lines.push(`Table ${quoteIdent(table.name)} {`);
for (const column of table.columns) {
const attrs: string[] = [];
if (column.is_primary_key) attrs.push("pk");
if (!column.is_nullable) attrs.push("not null");
const attrSuffix = attrs.length > 0 ? ` [${attrs.join(", ")}]` : "";
lines.push(` ${quoteIdent(column.name)} ${dbmlType(column.data_type)}${attrSuffix}`);
}
lines.push("}", "");
}
for (const rel of relationships) {
const op = dbmlRefOp(rel.sourceCardinality, rel.targetCardinality);
lines.push(`Ref: ${quoteIdent(rel.sourceTable)}.${quoteIdent(rel.sourceColumn)} ${op} ${quoteIdent(rel.targetTable)}.${quoteIdent(rel.targetColumn)}`);
}
if (relationships.length > 0) lines.push("");
return lines.join("\n");
}
export function buildDiagramMermaid(tables: DiagramTable[], relationships: DiagramRelationship[]): string {
const lines: string[] = ["erDiagram"];
for (const table of tables) {
const entity = mermaidIdent(table.name);
lines.push(` ${entity} {`);
for (const column of table.columns) {
const typeToken = (column.data_type || "unknown").replace(/\s+/g, "_").replace(/[^A-Za-z0-9_]/g, "") || "unknown";
const key = column.is_primary_key ? " PK" : "";
lines.push(` ${typeToken} ${mermaidIdent(column.name)}${key}`);
}
lines.push(" }");
}
for (const rel of relationships) {
const left = mermaidIdent(rel.sourceTable);
const right = mermaidIdent(rel.targetTable);
const card = mermaidCardinality(rel.sourceCardinality, rel.targetCardinality);
const label = `${rel.sourceColumn} -> ${rel.targetColumn}`.replace(/"/g, "'");
lines.push(` ${left} ${card} ${right} : "${label}"`);
}
lines.push("");
return lines.join("\n");
}
export function svgToPngBlob(svg: string, scale = 2): Promise<Blob> {
return new Promise((resolve, reject) => {
const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
try {
const width = Math.max(1, Math.ceil(img.naturalWidth * scale));
const height = Math.max(1, Math.ceil(img.naturalHeight * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
URL.revokeObjectURL(url);
reject(new Error("Canvas unsupported"));
return;
}
ctx.fillStyle = "#fafafa";
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
URL.revokeObjectURL(url);
canvas.toBlob((png) => {
if (!png) {
reject(new Error("PNG encode failed"));
return;
}
resolve(png);
}, "image/png");
} catch (err) {
URL.revokeObjectURL(url);
reject(err);
}
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("Failed to load SVG for PNG export"));
};
img.src = url;
});
}
export function diagramExportDialogFilter(format: DiagramExportFormat): { name: string; extensions: string[] } {
switch (format) {
case "svg":
return { name: "SVG", extensions: ["svg"] };
case "png":
return { name: "PNG", extensions: ["png"] };
case "json":
return { name: "JSON", extensions: ["json"] };
case "dbml":
return { name: "DBML", extensions: ["dbml"] };
case "mermaid":
return { name: "Mermaid", extensions: ["mmd", "md"] };
}
}

View File

@ -1,34 +1,51 @@
import type { EngineeringDiagram, EngineeringEntityNode } from "@/lib/diagram/engineeringDiagram";
import type { DiagramPosition, DiagramRelationship, DiagramTable } from "@/lib/diagram/erDiagram";
import { pickHandles } from "@/lib/diagram/vue-flow-adapter";
import { pointAlongPolyline, pointsToSvgPath, type Point } from "@/lib/diagram/edge-obstacle-router";
import { CARD_BOTTOM_PADDING, CARD_HEADER_HEIGHT, CARD_WIDTH, COLUMN_ROW_HEIGHT, MARGIN } from "@/lib/diagram/diagram-constants";
type DiagramSvgMode = "table" | "engineering";
const SOURCE_CARDINALITY_T = 0.18;
const TARGET_CARDINALITY_T = 0.82;
interface DiagramCanvas {
width: number;
height: number;
/** viewBox origin; defaults to 0 when omitted (engineering mode already normalizes to ~0). */
originX?: number;
originY?: number;
}
export interface TableDiagramRelationshipLayout {
path: string;
routePoints: DiagramPosition[];
sourceCardinality: DiagramPosition;
targetCardinality: DiagramPosition;
export interface DiagramSvgLayer {
id: string;
name: string;
color: string;
x: number;
y: number;
width: number;
height: number;
}
export interface TableDiagramSvgOptions {
tables: DiagramTable[];
relationships: DiagramRelationship[];
positions: Record<string, DiagramPosition>;
relationshipLayouts: Record<string, TableDiagramRelationshipLayout>;
relationshipPaths: Record<string, string>;
/** Polyline points for endpoint cardinality badges (aligned with relationshipPaths). */
relationshipPolylines?: Record<string, Point[]>;
canvas: DiagramCanvas;
cardWidth: number;
cardHeaderHeight: number;
columnRowHeight: number;
maxVisibleColumns: number;
cardBottomPadding?: number;
moreColumnsLabel?: (count: number) => string;
layers?: DiagramSvgLayer[];
}
type CardHeightMetrics = {
cardHeaderHeight: number;
columnRowHeight: number;
cardBottomPadding?: number;
};
function escapeXml(value: string | number): string {
return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
}
@ -37,17 +54,11 @@ function svgNumber(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, "");
}
interface SvgViewport {
x: number;
y: number;
width: number;
height: number;
}
function svgHeader(canvas: DiagramCanvas, viewport: SvgViewport = { x: 0, y: 0, width: canvas.width, height: canvas.height }): string {
function svgHeader(canvas: DiagramCanvas): string {
// Always viewBox 0 0 — callers that use non-zero canvas.origin must translate content (see buildTableDiagramSvg).
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${svgNumber(viewport.width)}" height="${svgNumber(viewport.height)}" viewBox="${svgNumber(viewport.x)} ${svgNumber(viewport.y)} ${svgNumber(viewport.width)} ${svgNumber(viewport.height)}">`,
`<rect x="${svgNumber(viewport.x)}" y="${svgNumber(viewport.y)}" width="${svgNumber(viewport.width)}" height="${svgNumber(viewport.height)}" fill="#fafafa"/>`,
`<svg xmlns="http://www.w3.org/2000/svg" width="${svgNumber(canvas.width)}" height="${svgNumber(canvas.height)}" viewBox="0 0 ${svgNumber(canvas.width)} ${svgNumber(canvas.height)}">`,
`<rect x="0" y="0" width="${svgNumber(canvas.width)}" height="${svgNumber(canvas.height)}" fill="#fafafa"/>`,
].join("");
}
@ -62,101 +73,211 @@ function svgText(
anchor?: "start" | "middle" | "end";
family?: string;
decoration?: string;
stroke?: string;
strokeWidth?: number;
paintOrder?: string;
attributes?: Record<string, string>;
} = {},
): string {
const attrs = [`x="${svgNumber(x)}"`, `y="${svgNumber(y)}"`, `fill="${options.fill ?? "#18181b"}"`, `font-size="${options.size ?? 12}"`, `font-family="${options.family ?? "Arial, Helvetica, sans-serif"}"`, 'dominant-baseline="middle"'];
if (options.weight) attrs.push(`font-weight="${options.weight}"`);
if (options.anchor) attrs.push(`text-anchor="${options.anchor}"`);
if (options.decoration) attrs.push(`text-decoration="${options.decoration}"`);
if (options.stroke) attrs.push(`stroke="${options.stroke}"`);
if (options.strokeWidth) attrs.push(`stroke-width="${svgNumber(options.strokeWidth)}"`);
if (options.paintOrder) attrs.push(`paint-order="${options.paintOrder}"`);
for (const [name, value] of Object.entries(options.attributes ?? {})) {
attrs.push(`${name}="${escapeXml(value)}"`);
}
return `<text ${attrs.join(" ")}>${escapeXml(label)}</text>`;
}
function tableHeight(table: DiagramTable, options: TableDiagramSvgOptions): number {
const visibleCount = Math.min(table.columns.length, options.maxVisibleColumns);
const overflowHeight = table.columns.length > options.maxVisibleColumns ? options.columnRowHeight : 0;
return options.cardHeaderHeight + visibleCount * options.columnRowHeight + overflowHeight + (options.cardBottomPadding ?? 12);
}
function tableDiagramDefs(): string {
return ["<defs>", '<marker id="dbx-diagram-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="strokeWidth">', '<path d="M 0 0 L 8 4 L 0 8 z" fill="#2563eb"/>', "</marker>", "</defs>"].join("");
/** Shared table card height for SVG canvas / paths / cards. */
function svgCardHeight(columnCount: number, metrics: CardHeightMetrics): number {
return metrics.cardHeaderHeight + columnCount * metrics.columnRowHeight + (metrics.cardBottomPadding ?? CARD_BOTTOM_PADDING);
}
function isForeignKeyColumn(table: DiagramTable, columnName: string): boolean {
return table.foreignKeys.some((fk) => fk.column === columnName);
}
function tableDiagramViewport(options: TableDiagramSvgOptions): SvgViewport {
const relationshipPadding = 20;
let minX = 0;
let minY = 0;
let maxX = options.canvas.width;
let maxY = options.canvas.height;
function handleAnchor(pos: DiagramPosition, handle: string, width: number, height: number): Point {
const cx = pos.x + width / 2;
const cy = pos.y + height / 2;
if (handle.startsWith("right")) return { x: pos.x + width, y: cy };
if (handle.startsWith("left")) return { x: pos.x, y: cy };
if (handle.startsWith("bottom")) return { x: cx, y: pos.y + height };
return { x: cx, y: pos.y };
}
for (const layout of Object.values(options.relationshipLayouts)) {
const points = [...layout.routePoints, layout.sourceCardinality, layout.targetCardinality];
/** Orthogonal fallback polyline when no ELK/obstacle waypoints are stored. */
function orthogonalPointsBetweenTables(sourcePos: DiagramPosition, targetPos: DiagramPosition, sourceHeight: number, targetHeight: number, cardWidth: number): Point[] {
const { sourceHandle, targetHandle } = pickHandles(sourcePos, targetPos, sourceHeight, targetHeight, cardWidth);
const s = handleAnchor(sourcePos, sourceHandle, cardWidth, sourceHeight);
const t = handleAnchor(targetPos, targetHandle.replace(/-target$/, ""), cardWidth, targetHeight);
const mid: Point = Math.abs(s.x - t.x) >= Math.abs(s.y - t.y) ? { x: t.x, y: s.y } : { x: s.x, y: t.y };
return [s, mid, t];
}
type RelationshipGeometryInput = {
relationships: DiagramRelationship[];
positions: Record<string, DiagramPosition>;
tables: DiagramTable[];
waypoints?: Record<string, Point[]>;
cardWidth?: number;
cardHeaderHeight?: number;
columnRowHeight?: number;
cardBottomPadding?: number;
};
/**
* Build relationship polylines from live waypoints or table positions.
*/
export function buildTableRelationshipPolylines(input: RelationshipGeometryInput): Record<string, Point[]> {
const cardWidth = input.cardWidth ?? CARD_WIDTH;
const metrics: CardHeightMetrics = {
cardHeaderHeight: input.cardHeaderHeight ?? CARD_HEADER_HEIGHT,
columnRowHeight: input.columnRowHeight ?? COLUMN_ROW_HEIGHT,
cardBottomPadding: input.cardBottomPadding ?? CARD_BOTTOM_PADDING,
};
const heightByName = new Map(input.tables.map((t) => [t.name, svgCardHeight(t.columns.length, metrics)]));
const polylines: Record<string, Point[]> = {};
for (const rel of input.relationships) {
const stored = input.waypoints?.[rel.id];
if (stored && stored.length >= 2) {
polylines[rel.id] = stored.map((p) => ({ ...p }));
continue;
}
const sourcePos = input.positions[rel.sourceTable];
const targetPos = input.positions[rel.targetTable];
if (!sourcePos || !targetPos) continue;
const sh = heightByName.get(rel.sourceTable) ?? svgCardHeight(0, metrics);
const th = heightByName.get(rel.targetTable) ?? svgCardHeight(0, metrics);
polylines[rel.id] = orthogonalPointsBetweenTables(sourcePos, targetPos, sh, th, cardWidth);
}
return polylines;
}
/**
* Build SVG path `d` strings for relationships from live waypoints or table positions.
*/
export function buildTableRelationshipPaths(input: RelationshipGeometryInput): Record<string, string> {
const polylines = buildTableRelationshipPolylines(input);
const paths: Record<string, string> = {};
for (const [id, points] of Object.entries(polylines)) {
paths[id] = pointsToSvgPath(points);
}
return paths;
}
/** Compute canvas size that fits tables + layers + relationship polylines with padding. */
export function computeTableDiagramCanvas(
tables: DiagramTable[],
positions: Record<string, DiagramPosition>,
options: {
cardWidth: number;
cardHeaderHeight: number;
columnRowHeight: number;
cardBottomPadding?: number;
layers?: DiagramSvgLayer[];
relationshipPolylines?: Record<string, Point[]>;
padding?: number;
},
): DiagramCanvas {
const padding = options.padding ?? MARGIN;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const expand = (x1: number, y1: number, x2: number, y2: number) => {
minX = Math.min(minX, x1);
minY = Math.min(minY, y1);
maxX = Math.max(maxX, x2);
maxY = Math.max(maxY, y2);
};
for (const layer of options.layers ?? []) {
if (layer.width <= 0 || layer.height <= 0) continue;
expand(layer.x, layer.y, layer.x + layer.width, layer.y + layer.height);
}
for (const table of tables) {
const pos = positions[table.name] ?? { x: 0, y: 0 };
const height = svgCardHeight(table.columns.length, options);
expand(pos.x, pos.y, pos.x + options.cardWidth, pos.y + height);
}
for (const points of Object.values(options.relationshipPolylines ?? {})) {
for (const point of points) {
minX = Math.min(minX, point.x - relationshipPadding);
minY = Math.min(minY, point.y - relationshipPadding);
maxX = Math.max(maxX, point.x + relationshipPadding);
maxY = Math.max(maxY, point.y + relationshipPadding);
expand(point.x, point.y, point.x, point.y);
}
}
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
return { width: 400 + padding, height: 300 + padding, originX: 0, originY: 0 };
}
return {
width: Math.ceil(maxX - minX + 2 * padding),
height: Math.ceil(maxY - minY + 2 * padding),
originX: minX - padding,
originY: minY - padding,
};
}
export function buildTableDiagramSvg(options: TableDiagramSvgOptions): string {
const parts = [svgHeader(options.canvas, tableDiagramViewport(options)), tableDiagramDefs()];
const ox = options.canvas.originX ?? 0;
const oy = options.canvas.originY ?? 0;
const parts = [svgHeader(options.canvas)];
parts.push(`<g transform="translate(${svgNumber(-ox)} ${svgNumber(-oy)})">`);
for (const relationship of options.relationships) {
const layout = options.relationshipLayouts[relationship.id];
if (!layout?.path) continue;
parts.push(`<g data-relationship-id="${escapeXml(relationship.id)}">`);
parts.push(
`<path d="${escapeXml(layout.path)}" fill="none" stroke="#2563eb" stroke-opacity="0.58" stroke-width="1.6" marker-end="url(#dbx-diagram-arrow)">` +
`<title>${escapeXml(`${relationship.sourceTable}.${relationship.sourceColumn} (${relationship.sourceCardinality}:${relationship.targetCardinality}) -> ${relationship.targetTable}.${relationship.targetColumn}`)}</title>` +
"</path>",
);
parts.push(
svgText(relationship.sourceCardinality, layout.sourceCardinality.x, layout.sourceCardinality.y, {
size: 12,
weight: "600",
anchor: "middle",
stroke: "#fafafa",
strokeWidth: 4,
paintOrder: "stroke",
attributes: { "data-cardinality-end": "source" },
}),
);
parts.push(
svgText(relationship.targetCardinality, layout.targetCardinality.x, layout.targetCardinality.y, {
size: 12,
weight: "600",
anchor: "middle",
stroke: "#fafafa",
strokeWidth: 4,
paintOrder: "stroke",
attributes: { "data-cardinality-end": "target" },
}),
);
const layers = (options.layers ?? []).filter((l) => l.width > 0 && l.height > 0);
if (layers.length > 0) {
parts.push('<g class="diagram-layers">');
for (const layer of layers) {
const fill = layer.color || "#9ca3af";
parts.push(`<rect x="${svgNumber(layer.x)}" y="${svgNumber(layer.y)}" width="${svgNumber(layer.width)}" height="${svgNumber(layer.height)}" ` + `rx="8" fill="${escapeXml(fill)}" fill-opacity="0.08" stroke="${escapeXml(fill)}" stroke-opacity="0.55" stroke-width="1.5"/>`);
parts.push(
svgText(layer.name, layer.x + 12, layer.y + 18, {
size: 12,
weight: "600",
fill: fill,
}),
);
}
parts.push("</g>");
}
parts.push('<g fill="none" stroke="#2563eb" stroke-opacity="0.58" stroke-width="1.6">');
for (const relationship of options.relationships) {
const path = options.relationshipPaths[relationship.id];
if (!path) continue;
parts.push(`<path d="${escapeXml(path)}">` + `<title>${escapeXml(`${relationship.sourceTable}.${relationship.sourceColumn} -> ${relationship.targetTable}.${relationship.targetColumn}`)}</title>` + "</path>");
}
parts.push("</g>");
parts.push('<g class="diagram-cardinality">');
for (const relationship of options.relationships) {
const points = options.relationshipPolylines?.[relationship.id];
if (!points || points.length < 2) continue;
const sourcePos = pointAlongPolyline(points, SOURCE_CARDINALITY_T);
const targetPos = pointAlongPolyline(points, TARGET_CARDINALITY_T);
const sourceCard = relationship.sourceCardinality || "N";
const targetCard = relationship.targetCardinality || "1";
parts.push(
svgText(sourceCard, sourcePos.x, sourcePos.y, {
size: 11,
weight: "700",
anchor: "middle",
fill: "#18181b",
}),
);
parts.push(
svgText(targetCard, targetPos.x, targetPos.y, {
size: 11,
weight: "700",
anchor: "middle",
fill: "#18181b",
}),
);
}
parts.push("</g>");
for (const table of options.tables) {
const position = options.positions[table.name] ?? { x: 0, y: 0 };
const height = tableHeight(table, options);
const visibleColumns = table.columns.slice(0, options.maxVisibleColumns);
const hiddenCount = Math.max(0, table.columns.length - options.maxVisibleColumns);
const height = svgCardHeight(table.columns.length, options);
parts.push(`<g transform="translate(${svgNumber(position.x)} ${svgNumber(position.y)})">`);
parts.push(`<rect width="${options.cardWidth}" height="${svgNumber(height)}" rx="6" fill="#ffffff" stroke="#d4d4d8"/>`);
parts.push(`<rect width="${options.cardWidth}" height="${options.cardHeaderHeight}" rx="6" fill="#f4f4f5"/>`);
@ -170,7 +291,7 @@ export function buildTableDiagramSvg(options: TableDiagramSvgOptions): string {
}),
);
visibleColumns.forEach((column, index) => {
table.columns.forEach((column, index) => {
const rowTop = options.cardHeaderHeight + index * options.columnRowHeight;
const rowCenter = rowTop + options.columnRowHeight / 2;
parts.push(`<path d="M 0 ${svgNumber(rowTop)} H ${options.cardWidth}" stroke="#f0f0f1"/>`);
@ -188,19 +309,10 @@ export function buildTableDiagramSvg(options: TableDiagramSvgOptions): string {
}),
);
});
if (hiddenCount > 0) {
const y = options.cardHeaderHeight + visibleColumns.length * options.columnRowHeight + options.columnRowHeight / 2;
parts.push(
svgText(options.moreColumnsLabel?.(hiddenCount) ?? `+ ${hiddenCount} columns`, 12, y, {
size: 11,
fill: "#71717a",
}),
);
}
parts.push("</g>");
}
parts.push("</g>");
parts.push("</svg>");
return parts.join("");
}
@ -311,6 +423,8 @@ export function buildEngineeringDiagramSvg(diagram: EngineeringDiagram): string
return parts.join("");
}
type DiagramSvgMode = "table" | "engineering";
function fileToken(value: string): string {
return value
.trim()
@ -319,6 +433,7 @@ function fileToken(value: string): string {
.replace(/^-|-$/g, "");
}
/** Stable download name for SVG exports (shared with upstream contract tests). */
export function diagramSvgFileName(connectionName: string, databaseName: string, mode: DiagramSvgMode): string {
const context = [connectionName, databaseName].map(fileToken).filter(Boolean);
const suffix = mode === "engineering" ? "engineering-er" : "table-structure";

View File

@ -0,0 +1,46 @@
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { diagramExportDialogFilter, type DiagramExportFormat } from "./diagramFormats";
export async function saveDiagramTextExport(defaultPath: string, content: string, format: DiagramExportFormat): Promise<boolean> {
if (isTauriRuntime()) {
const [{ save }, { writeTextFile }] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
const path = await save({
defaultPath,
filters: [diagramExportDialogFilter(format)],
});
if (!path) return false;
await writeTextFile(path, content);
return true;
}
const mime = format === "svg" ? "image/svg+xml" : format === "json" ? "application/json" : "text/plain";
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = defaultPath;
a.click();
URL.revokeObjectURL(url);
return true;
}
export async function saveDiagramBinaryExport(defaultPath: string, data: Blob, format: DiagramExportFormat): Promise<boolean> {
if (isTauriRuntime()) {
const [{ save }, { writeFile }] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
const path = await save({
defaultPath,
filters: [diagramExportDialogFilter(format)],
});
if (!path) return false;
await writeFile(path, new Uint8Array(await data.arrayBuffer()));
return true;
}
const url = URL.createObjectURL(data);
const a = document.createElement("a");
a.href = url;
a.download = defaultPath;
a.click();
URL.revokeObjectURL(url);
return true;
}

View File

@ -273,6 +273,8 @@ export const DATA_TYPE_OPTIONS: Record<string, string[]> = {
],
questdb: ["boolean", "ipv4", "byte", "short", "char", "int", "float", "symbol", "varchar", "string", "long", "date", "timestamp", "timestamp_ns", "double", "uuid", "binary", "long256", "geohash", "array", "interval", "decimal"],
xugu: ["BOOLEAN", "INTEGER", "SMALLINT", "BIGINT", "FLOAT", "NUMERIC", "CHAR", "VARCHAR", "CLOB", "DATE", "TIME", "TIMESTAMP", "BINARY", "VARBINARY", "BLOB", "XML", "BOOL", "INT", "SHORT", "LONGINT", "LONG", "REAL", "DECIMAL", "TEXT", "NCHAR", "NVARCHAR", "NVARCHAR2"],
duckdb: ["BOOLEAN", "TINYINT", "SMALLINT", "INTEGER", "BIGINT", "HUGEINT", "UTINYINT", "USMALLINT", "UINTEGER", "UBIGINT", "FLOAT", "DOUBLE", "DECIMAL", "VARCHAR", "TEXT", "BLOB", "DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL", "UUID", "JSON"],
h2: ["BOOLEAN", "TINYINT", "SMALLINT", "INTEGER", "BIGINT", "IDENTITY", "DECIMAL", "NUMERIC", "REAL", "DOUBLE", "FLOAT", "CHAR", "CHARACTER", "VARCHAR", "VARCHAR_IGNORECASE", "CLOB", "BINARY", "VARBINARY", "BLOB", "DATE", "TIME", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "UUID", "ARRAY", "JSON"],
};
const DATA_TYPE_OPTION_ALIASES: Partial<Record<DatabaseType, string>> = {
@ -287,13 +289,20 @@ const DATA_TYPE_OPTION_ALIASES: Partial<Record<DatabaseType, string>> = {
opengauss: "postgres",
questdb: "questdb",
redshift: "postgres",
vertica: "postgres",
highgo: "postgres",
uxdb: "postgres",
vastbase: "postgres",
kingbase: "postgres",
firebird: "postgres",
dameng: "oracle",
"oceanbase-oracle": "oracle",
iris: "oracle",
yashandb: "oracle",
rqlite: "sqlite",
turso: "sqlite",
"cloudflare-d1": "sqlite",
access: "h2",
};
export function getDataTypeOptions(dbType: DatabaseType | undefined): string[] {
@ -1104,6 +1113,22 @@ export function defaultNewColumnDataType(dbType: DatabaseType | undefined, dataT
const baseType = dataTypeOptions[0] ?? "text";
return combineDataTypeForDatabase(dbType, baseType, getDefaultLengthForType(dbType, baseType));
}
const options = dataTypeOptions.length > 0 ? dataTypeOptions : getDataTypeOptions(dbType);
const dialectKey = dbType ? (DATA_TYPE_OPTION_ALIASES[dbType] ?? dbType) : "";
if (dialectKey === "sqlite" || dialectKey === "duckdb") {
const textType = options.find((type) => /^text$/i.test(type.trim()));
return textType ?? "text";
}
if (options.length > 0) {
const preferred = options.find((type) => /^(varchar|character varying|nvarchar)$/i.test(type.trim())) ?? options.find((type) => /^(string|clob|lvarchar|text)$/i.test(type.trim())) ?? options.find((type) => /^varchar/i.test(type.trim()));
if (preferred) {
return combineDataTypeForDatabase(dbType, preferred, getDefaultLengthForType(dbType, preferred));
}
}
return dbType === "sqlite" ? "text" : "varchar(255)";
}

View File

@ -443,6 +443,7 @@ export interface ColumnInfo {
is_nullable: boolean;
column_default: string | null;
is_primary_key: boolean;
is_unique?: boolean;
extra: string | null;
comment?: string | null;
numeric_precision?: number | null;

View File

@ -0,0 +1,92 @@
import type { CustomDiagramRelationship, DiagramPosition, DiagramTable, DiagramRelationship } from "@/lib/diagram/erDiagram";
export interface InferredRelationship {
id: string;
sourceTable: string;
sourceColumn: string;
targetTable: string;
targetColumn: string;
confidence: "high" | "medium";
strategy: "naming_convention" | "type_signature" | "regex";
}
export interface MatchResult {
relationships: InferredRelationship[];
conflicts: InferredRelationship[];
pending: InferredRelationship[];
stats: { total: number; high: number; medium: number };
}
export interface LayoutOptions {
direction?: "LR" | "TB" | "RL" | "BT";
}
export interface HistorySnapshot {
nodes: DiagramNode[];
edges: DiagramEdge[];
positions: Record<string, DiagramPosition>;
layers: DiagramLayer[];
tables: DiagramTable[];
customRelationships: CustomDiagramRelationship[];
edgeWaypoints: Record<string, { x: number; y: number }[]>;
edgeHandleHints: Record<string, { sourceHandle?: string; targetHandle?: string }>;
matchConfirms: string[];
matchIgnores: string[];
}
export interface DiagramNode {
id: string;
type: string;
position: { x: number; y: number };
data: { table: DiagramTable };
selected?: boolean;
}
export interface DiagramEdge {
id: string;
source: string;
target: string;
sourceHandle?: string;
targetHandle?: string;
/** Absolute canvas waypoints from ELK / obstacle router (includes endpoints). */
waypoints?: { x: number; y: number }[];
data: { relationship: DiagramRelationship | InferredRelationship };
}
export type RelationshipKind = "foreign-key" | "custom" | "inferred";
export interface MatchRule {
id: string;
name: string;
pattern: string;
enabled: boolean;
priority: number;
}
export interface MatchStorageKeys {
confirms: string;
ignores: string;
rules: string;
enabled: string;
}
export type LayerLayoutMode = "free" | "auto";
export interface DiagramLayer {
id: string;
name: string;
color: string;
tableNames: string[];
collapsed: boolean;
visible: boolean;
/** free = locked (keep relative table positions); auto = unlocked (auto-layout rearranges tables in this layer) */
layoutMode: LayerLayoutMode;
position?: { x: number; y: number };
width?: number;
height?: number;
}
export const LAYER_COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"];
/** Canvas selection driving the diagram inspector panel */
export type InspectorTarget = { kind: "table"; tableName: string } | { kind: "edge"; edgeId: string } | null;

View File

@ -3408,6 +3408,7 @@ pub async fn get_columns(pool: &MySqlPool, database: &str, table: &str) -> Resul
};
Some(ColumnInfo {
is_primary_key: column_key.eq_ignore_ascii_case("PRI"),
is_unique: column_key.eq_ignore_ascii_case("UNI"),
name,
data_type: column_type,
is_nullable: get_str_by_name(row, "IS_NULLABLE") == "YES",
@ -3468,6 +3469,7 @@ pub async fn get_columns_show(pool: &MySqlPool, database: &str, table: &str) ->
is_nullable: get_str_by_name(row, "Null").eq_ignore_ascii_case("YES"),
column_default: get_opt_str(row, "Default"),
is_primary_key: key.eq_ignore_ascii_case("PRI"),
is_unique: key.eq_ignore_ascii_case("UNI"),
extra: get_opt_str(row, "Extra"),
comment: get_opt_str(row, "Comment")
.map(|s| fix_potential_double_encoding(&s))
@ -4439,6 +4441,438 @@ pub async fn show_create_table_ddl(pool: &MySqlPool, database: &str, table: &str
.ok_or_else(|| "Failed to read DDL".to_string())
}
// ---------------------------------------------------------------------------
// Doris / StarRocks multi-catalog support.
//
// These engines expose external catalogs (iceberg, hive, jdbc, ...) alongside
// the native `internal` catalog via `SHOW CATALOGS`. The functions below address
// objects in a specific catalog using 3-part qualified names
// (`<catalog>.<database>.<table>`), which the engines accept directly without
// needing to `SWITCH` the session catalog.
// ---------------------------------------------------------------------------
/// Build a 2-part qualified identifier `` `<catalog>`.`<database>` ``.
fn doris_catalog_database_ref(catalog: &str, database: &str) -> String {
format!("{}.{}", quote_identifier(catalog), quote_identifier(database))
}
/// Build a 3-part qualified identifier `` `<catalog>`.`<database>`.`<table>` ``.
fn doris_catalog_table_ref(catalog: &str, database: &str, table: &str) -> String {
format!("{}.{}.{}", quote_identifier(catalog), quote_identifier(database), quote_identifier(table))
}
/// `SHOW CATALOGS` → list of catalogs visible to the current user.
///
/// Column layouts differ between engines: Doris exposes `CatalogName` (with
/// `CatalogId`/`IsCurrent`/`CreateTime`/`LastUpdateTime`), while StarRocks
/// exposes `Catalog` (only `Type`/`Comment`, no `IsCurrent`). The name is read
/// from either column; missing trailing columns degrade gracefully to
/// empty/None. The built-in catalog is named `internal` in Doris and
/// `default_catalog` in StarRocks (both with `Type=internal`); detection is
/// type-based (see `CatalogInfo::is_internal`), not name-based.
pub async fn list_doris_catalogs(pool: &MySqlPool) -> Result<Vec<crate::db::CatalogInfo>, String> {
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
let result = conn.query_iter("SHOW CATALOGS").await.map_err(|e| e.to_string())?;
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
let catalogs: Vec<crate::db::CatalogInfo> = rows
.iter()
.filter_map(|row| {
// Doris column is `CatalogName`; StarRocks column is `Catalog`.
let name = first_nonempty_str_by_name(row, &["CatalogName", "Catalog"]).trim().to_string();
if name.is_empty() {
return None;
}
let catalog_type = get_str_by_name(row, "Type").trim().to_string();
let is_current = {
let value = get_str_by_name(row, "IsCurrent").trim().to_ascii_lowercase();
!value.is_empty() && value != "no" && value != "false" && value != "0"
};
let comment = get_opt_str(row, "Comment").map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
Some(crate::db::CatalogInfo { name, catalog_type, is_current, comment })
})
.collect();
Ok(normalize_doris_catalogs(catalogs))
}
/// Sort with the built-in catalog first, then the rest alphabetically by name.
/// The built-in catalog is identified by `CatalogInfo::is_internal` (type-based)
/// rather than by name, so StarRocks `default_catalog` sorts first just like
/// Doris `internal`. No synthetic catalog is injected: `SHOW CATALOGS` always
/// lists the built-in catalog on both engines, and a single-catalog result is
/// handled by the flat-sidebar fallback in the caller.
fn normalize_doris_catalogs(mut catalogs: Vec<crate::db::CatalogInfo>) -> Vec<crate::db::CatalogInfo> {
catalogs.sort_by(|a, b| match (a.is_internal(), b.is_internal()) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
});
catalogs
}
/// `SHOW DATABASES FROM <catalog>` → databases in the given catalog.
pub async fn list_databases_show_from(pool: &MySqlPool, catalog: &str) -> Result<Vec<DatabaseInfo>, String> {
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
let sql = format!("SHOW DATABASES FROM {}", quote_identifier(catalog));
let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?;
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
Ok(database_infos_from_names(rows.iter().map(|row| get_str(row, 0)), false))
}
/// `SHOW TABLES FROM <catalog>.<database>` → tables in an external catalog.
///
/// External catalogs do not support `SHOW TABLE STATUS`, so comments/status are
/// not fetched (the caller only needs names + types for browsing).
pub async fn list_tables_show_from(pool: &MySqlPool, catalog: &str, database: &str) -> Result<Vec<TableInfo>, String> {
let sql = format!("SHOW TABLES FROM {}", doris_catalog_database_ref(catalog, database));
let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?;
let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?;
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
let mut tables: Vec<TableInfo> = rows
.iter()
.filter_map(|row| {
let name = get_str(row, 0).trim().to_string();
if name.is_empty() {
return None;
}
// SHOW FULL TABLES exposes a type column; plain SHOW TABLES does not.
let table_type = get_str(row, 1);
Some(TableInfo {
name,
table_type: if table_type.trim().is_empty() { "TABLE".to_string() } else { table_type },
comment: None,
parent_schema: None,
parent_name: None,
})
})
.collect();
tables.sort_by(|a, b| a.name.cmp(&b.name));
Ok(tables)
}
/// `SHOW COLUMNS FROM <catalog>.<database>.<table>` → columns of an external
/// catalog table. Falls back to `DESCRIBE` if `SHOW COLUMNS` is rejected.
pub async fn get_columns_show_from(
pool: &MySqlPool,
catalog: &str,
database: &str,
table: &str,
) -> Result<Vec<ColumnInfo>, String> {
let qualified = doris_catalog_table_ref(catalog, database, table);
let full_sql = format!("SHOW FULL COLUMNS FROM {qualified}");
let plain_sql = format!("SHOW COLUMNS FROM {qualified}");
let describe_sql = format!("DESCRIBE {qualified}");
let mut conn = get_conn_with_health_check(pool).await?;
let rows: Vec<mysql_async::Row> = match conn.query_iter(&full_sql).await {
Ok(result) => result.collect_and_drop().await.map_err(|e| e.to_string())?,
Err(_) => match conn.query_iter(&plain_sql).await {
Ok(result) => result.collect_and_drop().await.map_err(|e| e.to_string())?,
Err(_) => {
let result = conn.query_iter(&describe_sql).await.map_err(|e| e.to_string())?;
result.collect_and_drop().await.map_err(|e| e.to_string())?
}
},
};
Ok(rows
.iter()
.filter_map(|row| {
let name = get_str_by_name(row, "Field").trim().to_string();
if name.is_empty() {
return None;
}
let key = get_str_by_name(row, "Key");
let collation = get_opt_str(row, "Collation").filter(|s| !s.is_empty());
Some(ColumnInfo {
name,
data_type: get_str_by_name(row, "Type"),
is_nullable: get_str_by_name(row, "Null").eq_ignore_ascii_case("YES"),
column_default: get_opt_str(row, "Default"),
is_primary_key: key.eq_ignore_ascii_case("PRI"),
is_unique: key.eq_ignore_ascii_case("UNI"),
extra: get_opt_str(row, "Extra"),
comment: get_opt_str(row, "Comment")
.map(|s| fix_potential_double_encoding(&s))
.filter(|s| !s.is_empty()),
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
character_set: collation
.as_deref()
.and_then(|c| c.split_once('_').map(|(charset, _)| charset.to_string()))
.filter(|s| !s.is_empty()),
collation,
})
})
.collect())
}
/// `SHOW CREATE TABLE <catalog>.<database>.<table>` → DDL for an external
/// catalog table.
pub async fn show_create_table_ddl_from(
pool: &MySqlPool,
catalog: &str,
database: &str,
table: &str,
) -> Result<String, String> {
let sql = format!("SHOW CREATE TABLE {}", doris_catalog_table_ref(catalog, database, table));
let mut conn = get_conn_with_health_check(pool).await?;
let result = conn.query_iter(&sql).await.map_err(|e| e.to_string())?;
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
let row = rows.first().ok_or("DDL not found")?;
row.get_opt::<String, usize>(1)
.and_then(|result| result.ok())
.or_else(|| {
row.get_opt::<Vec<u8>, usize>(1)
.and_then(|result| result.ok())
.map(|b| String::from_utf8_lossy(&b).to_string())
})
.ok_or_else(|| "Failed to read DDL".to_string())
}
/// Best-effort index listing for an external catalog table. External catalogs
/// generally do not expose MySQL-style index metadata via `information_schema`
/// (that view is scoped to the internal catalog), so indexes are derived from
/// `SHOW CREATE TABLE` parsing. Returns empty on failure (graceful degradation
/// — indexes are informational for external tables).
pub async fn list_doris_catalog_indexes(
pool: &MySqlPool,
catalog: &str,
database: &str,
table: &str,
) -> Result<Vec<IndexInfo>, String> {
let ddl = show_create_table_ddl_from(pool, catalog, database, table).await?;
Ok(doris_indexes_from_create_table_ddl(&ddl))
}
fn doris_indexes_from_create_table_ddl(ddl: &str) -> Vec<IndexInfo> {
let mut indexes = Vec::new();
for raw_line in ddl.lines() {
let line = trim_ddl_definition_line(raw_line);
if line.is_empty() {
continue;
}
let upper = line.to_ascii_uppercase();
if upper.starts_with("PRIMARY KEY") {
if let Some(index) = doris_table_key_index("PRIMARY", line, true, true, "PRIMARY KEY") {
indexes.push(index);
}
} else if upper.starts_with("UNIQUE KEY") {
if let Some(index) = doris_table_key_index("UNIQUE KEY", line, true, false, "UNIQUE KEY") {
indexes.push(index);
}
} else if upper.starts_with("INDEX ") {
if let Some(index) = doris_secondary_index(line) {
indexes.push(index);
}
}
}
indexes
}
fn trim_ddl_definition_line(line: &str) -> &str {
let mut trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix(',') {
trimmed = rest.trim_start();
}
while let Some(rest) = trimmed.strip_suffix(',') {
trimmed = rest.trim_end();
}
trimmed
}
fn doris_table_key_index(
name: &str,
line: &str,
is_unique: bool,
is_primary: bool,
index_type: &str,
) -> Option<IndexInfo> {
let columns = parse_mysql_index_columns(first_parenthesized_content(line)?);
if columns.is_empty() {
return None;
}
Some(IndexInfo {
name: name.to_string(),
columns,
is_unique,
is_primary,
filter: None,
index_type: Some(index_type.to_string()),
included_columns: None,
comment: None,
})
}
fn doris_secondary_index(line: &str) -> Option<IndexInfo> {
let (_, rest) = split_keyword_prefix(line, "INDEX")?;
let (name, after_name) = read_mysql_identifier(rest.trim_start())?;
let columns = parse_mysql_index_columns(first_parenthesized_content(after_name)?);
if columns.is_empty() {
return None;
}
Some(IndexInfo {
name,
columns,
is_unique: false,
is_primary: false,
filter: None,
index_type: mysql_keyword_argument(after_name, "USING").or_else(|| Some("INDEX".to_string())),
included_columns: None,
comment: mysql_quoted_string_argument(after_name, "COMMENT"),
})
}
fn split_keyword_prefix<'a>(line: &'a str, keyword: &str) -> Option<(&'a str, &'a str)> {
if line.len() < keyword.len() || !line[..keyword.len()].eq_ignore_ascii_case(keyword) {
return None;
}
let rest = &line[keyword.len()..];
if !rest.is_empty() && is_mysql_identifier_byte(rest.as_bytes()[0]) {
return None;
}
Some((&line[..keyword.len()], rest))
}
fn read_mysql_identifier(input: &str) -> Option<(String, &str)> {
let input = input.trim_start();
if input.is_empty() {
return None;
}
let bytes = input.as_bytes();
if bytes[0] == b'`' {
let mut i = 1;
let mut value = String::new();
while i < bytes.len() {
if bytes[i] == b'`' {
if i + 1 < bytes.len() && bytes[i + 1] == b'`' {
value.push('`');
i += 2;
continue;
}
return Some((value, &input[i + 1..]));
}
let ch = input[i..].chars().next()?;
value.push(ch);
i += ch.len_utf8();
}
return None;
}
let end = input.find(|ch: char| ch.is_whitespace() || matches!(ch, '(' | ')' | ',')).unwrap_or(input.len());
if end == 0 {
return None;
}
Some((input[..end].to_string(), &input[end..]))
}
fn first_parenthesized_content(input: &str) -> Option<&str> {
let bytes = input.as_bytes();
let mut depth = 0usize;
let mut start = None;
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
b'\'' | b'"' | b'`' => {
i = skip_mysql_quoted(input, i, bytes[i]);
continue;
}
b'(' => {
if depth == 0 {
start = Some(i + 1);
}
depth += 1;
}
b')' if depth > 0 => {
depth -= 1;
if depth == 0 {
return start.map(|start| &input[start..i]);
}
}
_ => {}
}
i += 1;
}
None
}
fn split_top_level_csv(input: &str) -> Vec<&str> {
let bytes = input.as_bytes();
let mut parts = Vec::new();
let mut depth = 0usize;
let mut start = 0usize;
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
b'\'' | b'"' | b'`' => {
i = skip_mysql_quoted(input, i, bytes[i]);
continue;
}
b'(' => depth += 1,
b')' if depth > 0 => depth -= 1,
b',' if depth == 0 => {
parts.push(input[start..i].trim());
start = i + 1;
}
_ => {}
}
i += 1;
}
parts.push(input[start..].trim());
parts
}
fn parse_mysql_index_columns(input: &str) -> Vec<String> {
split_top_level_csv(input)
.into_iter()
.filter_map(|part| read_mysql_identifier(part).map(|(column, _)| column))
.filter(|column| !column.is_empty())
.collect()
}
fn mysql_keyword_argument(input: &str, keyword: &str) -> Option<String> {
let bytes = input.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
b'\'' | b'"' | b'`' => {
i = skip_mysql_quoted(input, i, bytes[i]);
continue;
}
_ if mysql_keyword_at(input, i, keyword) => {
return read_mysql_identifier(&input[i + keyword.len()..]).map(|(value, _)| value);
}
_ => i += 1,
}
}
None
}
fn mysql_quoted_string_argument(input: &str, keyword: &str) -> Option<String> {
let bytes = input.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
b'\'' | b'"' | b'`' => {
i = skip_mysql_quoted(input, i, bytes[i]);
continue;
}
_ if mysql_keyword_at(input, i, keyword) => {
let rest = input[i + keyword.len()..].trim_start();
if rest.as_bytes().first().copied() != Some(b'\'') {
return None;
}
let end = skip_mysql_quoted(rest, 0, b'\'');
if end <= 1 || end > rest.len() {
return None;
}
return Some(rest[1..end - 1].replace("\\'", "'").replace("''", "'"));
}
_ => i += 1,
}
}
None
}
pub async fn list_foreign_keys(pool: &MySqlPool, database: &str, table: &str) -> Result<Vec<ForeignKeyInfo>, String> {
let column_sql = format!(
"SELECT CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_SCHEMA, \

View File

@ -191,6 +191,7 @@ pub async fn get_columns_show_from(
is_nullable: get_str_by_name(row, "Null").eq_ignore_ascii_case("YES"),
column_default: get_opt_str(row, "Default"),
is_primary_key: key.eq_ignore_ascii_case("PRI"),
is_unique: key.eq_ignore_ascii_case("UNI"),
extra: get_opt_str(row, "Extra"),
comment: get_opt_str(row, "Comment")
.map(|s| fix_potential_double_encoding(&s))

View File

@ -5415,6 +5415,7 @@ fn deduplicate_column_infos(columns: Vec<db::ColumnInfo>) -> Vec<db::ColumnInfo>
for column in columns {
if let Some(existing) = result.iter_mut().find(|existing| existing.name == column.name) {
existing.is_primary_key |= column.is_primary_key;
existing.is_unique |= column.is_unique;
existing.is_nullable &= column.is_nullable;
merge_optional_string(&mut existing.column_default, column.column_default);
merge_optional_string(&mut existing.extra, column.extra);
@ -5438,6 +5439,37 @@ fn deduplicate_column_infos(columns: Vec<db::ColumnInfo>) -> Vec<db::ColumnInfo>
result
}
pub async fn get_all_columns_core(
state: &AppState,
connection_id: &str,
database: &str,
schema: &str,
) -> Result<Vec<db::TableColumnsResult>, String> {
let tables = list_tables_core(state, connection_id, database, schema, None, None, None, None, None).await?;
let mut result: Vec<db::TableColumnsResult> = Vec::with_capacity(tables.len());
for table in tables {
match get_columns_core(state, connection_id, database, schema, &table.name).await {
Ok(columns) => {
result.push(db::TableColumnsResult { table_name: table.name, columns, error: None });
}
Err(e) => {
log::warn!(
"[schema][get_all_columns] connection_id={} database={} schema={} table={} error={}",
connection_id,
database,
schema,
table.name,
e
);
result.push(db::TableColumnsResult { table_name: table.name, columns: Vec::new(), error: Some(e) });
}
}
}
Ok(result)
}
fn merge_optional_string(target: &mut Option<String>, candidate: Option<String>) {
let Some(candidate) = candidate else {
return;

View File

@ -3899,6 +3899,7 @@ mod tests {
is_nullable: false,
column_default: None,
is_primary_key: false,
is_unique: false,
extra: None,
comment: comment.map(str::to_string),
numeric_precision: None,
@ -5091,6 +5092,7 @@ mod tests {
is_nullable: true,
column_default: None,
is_primary_key: false,
is_unique: false,
extra: None,
comment: None,
numeric_precision: None,
@ -7385,6 +7387,7 @@ mod tests {
column_default: Some("'default'".into()),
comment: Some("new".into()),
is_primary_key: false,
is_unique: false,
extra: None,
numeric_precision: None,
numeric_scale: None,
@ -7400,6 +7403,7 @@ mod tests {
column_default: None,
comment: Some("old".into()),
is_primary_key: false,
is_unique: false,
extra: None,
numeric_precision: None,
numeric_scale: None,
@ -7642,6 +7646,7 @@ mod tests {
data_type: "int(11)".into(),
is_nullable: false,
is_primary_key: true,
is_unique: false,
extra: Some("auto_increment".into()),
..Default::default()
}),

View File

@ -1363,6 +1363,7 @@ mod tests {
is_nullable: true,
column_default: None,
is_primary_key: false,
is_unique: false,
extra: None,
comment: None,
numeric_precision: None,
@ -2234,6 +2235,7 @@ mod tests {
is_nullable: true,
column_default: None,
is_primary_key: false,
is_unique: false,
extra: None,
comment: None,
numeric_precision: None,
@ -2249,6 +2251,7 @@ mod tests {
is_nullable: true,
column_default: None,
is_primary_key: false,
is_unique: false,
extra: None,
comment: None,
numeric_precision: None,

View File

@ -127,6 +127,8 @@ pub struct ColumnInfo {
pub is_nullable: bool,
pub column_default: Option<String>,
pub is_primary_key: bool,
#[serde(default)]
pub is_unique: bool,
pub extra: Option<String>,
pub comment: Option<String>,
pub numeric_precision: Option<i32>,
@ -140,6 +142,14 @@ pub struct ColumnInfo {
pub collation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableColumnsResult {
pub table_name: String,
pub columns: Vec<ColumnInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CompletionAssistantObjectKind {

View File

@ -231,6 +231,7 @@ fn column_info_serialization_roundtrip() {
is_nullable: false,
column_default: None,
is_primary_key: true,
is_unique: true,
extra: None,
comment: None,
numeric_precision: Some(10),
@ -241,9 +242,65 @@ fn column_info_serialization_roundtrip() {
collation: None,
};
let json = serde_json::to_value(&col).unwrap();
assert_eq!(json.get("is_unique"), Some(&serde_json::json!(true)));
let deserialized: ColumnInfo = serde_json::from_value(json).unwrap();
assert_eq!(col.name, deserialized.name);
assert_eq!(col.numeric_precision, deserialized.numeric_precision);
assert!(deserialized.is_unique);
let legacy = serde_json::json!({
"name": "email",
"data_type": "varchar",
"is_nullable": true,
"column_default": null,
"is_primary_key": false,
"extra": null,
"comment": null,
"numeric_precision": null,
"numeric_scale": null,
"character_maximum_length": 255
});
let from_legacy: ColumnInfo = serde_json::from_value(legacy).unwrap();
assert!(!from_legacy.is_unique);
}
/// TableColumnsResult (get_all_columns) uses snake_case `table_name`, not camelCase.
#[test]
fn table_columns_result_serialization_contract() {
use dbx_core::db::TableColumnsResult;
let result = TableColumnsResult {
table_name: "users".to_string(),
columns: vec![ColumnInfo {
name: "id".to_string(),
data_type: "int".to_string(),
is_nullable: false,
column_default: None,
is_primary_key: true,
is_unique: false,
extra: None,
comment: None,
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
character_set: None,
collation: None,
}],
error: Some("partial".to_string()),
};
let json = serde_json::to_value(&result).unwrap();
let obj = json.as_object().expect("object");
assert!(obj.contains_key("table_name"));
assert!(!obj.contains_key("tableName"));
assert!(obj.contains_key("columns"));
assert!(obj.contains_key("error"));
assert_eq!(json["columns"][0]["is_unique"], false);
let roundtrip: TableColumnsResult = serde_json::from_value(json).unwrap();
assert_eq!(roundtrip.table_name, "users");
assert_eq!(roundtrip.error.as_deref(), Some("partial"));
assert_eq!(roundtrip.columns.len(), 1);
}
// ============================================================================

View File

@ -19,6 +19,7 @@ fn col(name: &str, data_type: &str) -> ColumnInfo {
is_nullable: false,
column_default: None,
is_primary_key: false,
is_unique: false,
extra: None,
comment: None,
numeric_precision: None,

View File

@ -22,6 +22,7 @@ fn col(name: &str, data_type: &str) -> ColumnInfo {
is_nullable: false,
column_default: None,
is_primary_key: false,
is_unique: false,
extra: None,
comment: None,
numeric_precision: None,

View File

@ -37,6 +37,7 @@ fn generate_details(tables: &[TableInfo], columns_per_table: usize) -> Vec<Table
is_nullable: j % 2 == 0,
column_default: if j == 0 { Some("0".to_string()) } else { None },
is_primary_key: j == 0,
is_unique: false,
extra: None,
comment: None,
numeric_precision: if j % 3 == 0 { Some(10) } else { None },

View File

@ -28,6 +28,7 @@ fn col(name: &str, data_type: &str) -> ColumnInfo {
is_nullable: false,
column_default: None,
is_primary_key: false,
is_unique: false,
extra: None,
comment: None,
numeric_precision: None,

View File

@ -1471,6 +1471,7 @@ fn infer_document_columns(documents: &[Value]) -> Vec<ColumnInfo> {
is_nullable: true,
column_default: None,
is_primary_key: false,
is_unique: false,
extra: None,
comment: None,
numeric_precision: None,

View File

@ -360,6 +360,7 @@ async fn main() {
.route("/schema/completion-assistant", post(routes::schema::completion_assistant_search))
.route("/schema/object-source", get(routes::schema::get_object_source))
.route("/schema/columns", get(routes::schema::list_columns))
.route("/schema/all-columns", get(routes::schema::get_all_columns))
.route("/schema/data-types", get(routes::schema::list_data_types))
.route("/schema/indexes", get(routes::schema::list_indexes))
.route("/schema/foreign-keys", get(routes::schema::list_foreign_keys))

View File

@ -356,6 +356,18 @@ pub async fn list_columns(
Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?))
}
pub async fn get_all_columns(
State(state): State<Arc<WebState>>,
Query(q): Query<SchemaQuery>,
) -> Result<Json<serde_json::Value>, AppError> {
let database = q.database.as_deref().unwrap_or("");
let schema = q.schema.as_deref().unwrap_or("");
let result = dbx_core::schema::get_all_columns_core(&state.app, &q.connection_id, database, schema)
.await
.map_err(AppError::from)?;
Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?))
}
pub async fn list_data_types(
State(state): State<Arc<WebState>>,
Query(q): Query<SchemaQuery>,

View File

@ -0,0 +1,800 @@
<!-- Generated by Trae Work -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DBX ER 图增强架构方案 v4</title>
<style>
@font-face { font-family: 'InstrumentSans'; src: url('./_shared/fonts/InstrumentSans-Regular.ttf') format('truetype'); font-weight: 400; }
@font-face { font-family: 'InstrumentSans'; src: url('./_shared/fonts/InstrumentSans-Bold.ttf') format('truetype'); font-weight: 700; }
@font-face { font-family: 'JetBrainsMono'; src: url('./_shared/fonts/JetBrainsMono-Regular.ttf') format('truetype'); font-weight: 400; }
</style>
<style>
:root {
--bg: #0f1117; --bg2: #1a1d28; --bg3: #232736;
--ink: #e4e6ef; --muted: #8b8fa7; --rule: #2e3348;
--accent: #38bdf8; --accent2: #a78bfa;
--accent-dim: rgba(56,189,248,0.12); --accent2-dim: rgba(167,139,250,0.12);
--green: #4ade80; --orange: #fb923c; --red: #f87171; --yellow: #facc15;
--font: 'InstrumentSans', -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: 'JetBrainsMono', 'Fira Code', monospace;
--max: 960px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 16px; scroll-behavior: smooth; }
body { font-family: var(--font); color: var(--ink); background: var(--bg); line-height: 1.75; }
.cover { min-height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; padding: 4rem 2rem; position: relative; overflow: hidden; }
.cover::before { content: ''; position: absolute; inset: 0; background: radial-gradient(ellipse 60% 50% at 20% 50%, rgba(56,189,248,0.08) 0%, transparent 70%), radial-gradient(ellipse 50% 40% at 80% 30%, rgba(167,139,250,0.06) 0%, transparent 70%); pointer-events: none; }
.cover-badge { display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.35rem 1rem; border: 1px solid var(--rule); border-radius: 999px; font-size: 0.8rem; color: var(--muted); margin-bottom: 2rem; }
.cover-badge .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--green); }
.cover h1 { font-size: clamp(2rem, 5vw, 3.2rem); font-weight: 700; line-height: 1.2; letter-spacing: -0.02em; margin-bottom: 1rem; color: #fff; }
.cover h1 span { color: var(--accent); }
.cover .subtitle { font-size: 1.1rem; color: var(--muted); max-width: 600px; line-height: 1.7; }
.cover-meta { margin-top: 3rem; display: flex; gap: 2rem; font-size: 0.82rem; color: var(--muted); }
.cover-meta div { display: flex; flex-direction: column; align-items: center; gap: 0.2rem; }
.cover-meta strong { color: var(--ink); font-size: 0.95rem; }
.cover-version { margin-top: 1.5rem; font-size: 0.78rem; color: var(--accent2); }
article.page { max-width: var(--max); margin: 0 auto; padding: 2rem 1.5rem 6rem; }
h2 { font-size: 1.5rem; font-weight: 700; color: #fff; margin-top: 4rem; margin-bottom: 1.5rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--rule); }
h3 { font-size: 1.15rem; font-weight: 700; color: var(--accent); margin-top: 2.5rem; margin-bottom: 1rem; }
h4 { font-size: 1rem; font-weight: 700; color: var(--ink); margin-top: 2rem; margin-bottom: 0.75rem; }
p { margin-bottom: 1rem; color: var(--ink); }
strong { color: #fff; font-weight: 600; }
a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; }
code { font-family: var(--font-mono); font-size: 0.85em; background: var(--bg3); padding: 0.15em 0.4em; border-radius: 4px; color: var(--accent); }
pre { background: var(--bg2); border: 1px solid var(--rule); border-radius: 8px; padding: 1.25rem; overflow-x: auto; margin: 1.25rem 0; }
pre code { background: none; padding: 0; font-size: 0.82rem; color: var(--ink); line-height: 1.6; }
.table-wrap { overflow-x: auto; overflow-y: auto; max-height: 600px; margin: 1.25rem 0; border: 1px solid var(--rule); border-radius: 8px; }
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
thead { position: sticky; top: 0; z-index: 2; }
th { background: var(--bg3); color: #fff; font-weight: 600; text-align: left; padding: 0.75rem 1rem; border-bottom: 2px solid var(--rule); white-space: nowrap; }
td { padding: 0.65rem 1rem; border-bottom: 1px solid var(--rule); color: var(--ink); vertical-align: top; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(56,189,248,0.03); }
.callout { border-left: 3px solid var(--accent); background: var(--accent-dim); padding: 1rem 1.25rem; border-radius: 0 8px 8px 0; margin: 1.5rem 0; font-size: 0.92rem; }
.callout.warn { border-left-color: var(--orange); background: rgba(251,146,60,0.08); }
.callout.danger { border-left-color: var(--red); background: rgba(248,113,113,0.08); }
.callout.success { border-left-color: var(--green); background: rgba(74,222,128,0.08); }
.callout strong { color: var(--accent); }
.callout.warn strong { color: var(--orange); }
.callout.danger strong { color: var(--red); }
.callout.success strong { color: var(--green); }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; margin: 1.5rem 0; }
.card { background: var(--bg2); border: 1px solid var(--rule); border-radius: 10px; padding: 1.25rem; }
.card h4 { margin-top: 0; margin-bottom: 0.5rem; color: #fff; font-size: 0.95rem; }
.card p { font-size: 0.88rem; color: var(--muted); margin-bottom: 0; }
.diagram { margin: 2rem 0; text-align: center; }
.diagram figcaption { font-size: 0.82rem; color: var(--muted); margin-top: 0.75rem; }
.mermaid { background: var(--bg2); border: 1px solid var(--rule); border-radius: 10px; padding: 1.5rem; overflow-x: auto; }
.gap-tag { display: inline-block; font-size: 0.72rem; padding: 0.1rem 0.5rem; border-radius: 999px; font-weight: 600; vertical-align: middle; margin-left: 0.3rem; }
.gap-tag.missing { background: rgba(248,113,113,0.15); color: var(--red); }
.gap-tag.ok { background: rgba(74,222,128,0.15); color: var(--green); }
.audit-table td:nth-child(1) { white-space: nowrap; }
.audit-table .old-val { color: var(--red); }
.audit-table .new-val { color: var(--green); }
.phases { display: flex; flex-direction: column; gap: 0; margin: 2rem 0; position: relative; }
.phases::before { content: ''; position: absolute; left: 18px; top: 8px; bottom: 8px; width: 2px; background: var(--rule); }
.phase { display: flex; gap: 1.25rem; padding: 1.25rem 0; }
.phase-dot { flex-shrink: 0; width: 38px; height: 38px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.85rem; color: #fff; position: relative; z-index: 1; }
.phase-dot.p1 { background: var(--accent); }
.phase-dot.p2 { background: var(--accent2); }
.phase-dot.p3 { background: var(--green); }
.phase-body h4 { margin-top: 0; color: #fff; }
.phase-body p { font-size: 0.9rem; margin-bottom: 0.5rem; }
.phase-body ul { padding-left: 1.2rem; }
.phase-body li { font-size: 0.88rem; color: var(--muted); margin-bottom: 0.3rem; }
.phase-body li strong { color: var(--ink); }
mark.key { background: none; color: var(--accent); font-weight: 600; }
.new-tag { display: inline-block; font-size: 0.7rem; padding: 0.05rem 0.4rem; border-radius: 4px; background: rgba(56,189,248,0.15); color: var(--accent); font-weight: 600; vertical-align: middle; margin-left: 0.2rem; }
sup a { color: var(--accent); text-decoration: none; font-size: 0.75em; font-weight: 600; }
sup a:hover { text-decoration: underline; }
.test-case { background: var(--bg2); border: 1px solid var(--rule); border-radius: 8px; padding: 1rem 1.25rem; margin: 0.75rem 0; }
.test-case .tc-name { font-weight: 600; color: #fff; font-size: 0.9rem; margin-bottom: 0.3rem; }
.test-case .tc-desc { font-size: 0.85rem; color: var(--muted); }
.test-case .tc-assert { font-size: 0.82rem; color: var(--accent); margin-top: 0.3rem; }
.toolbar-wire { background: var(--bg2); border: 1px solid var(--rule); border-radius: 8px; padding: 0.75rem 1rem; margin: 1rem 0; font-family: var(--font-mono); font-size: 0.78rem; line-height: 2; color: var(--muted); overflow-x: auto; white-space: nowrap; }
.toolbar-wire .sep { display: inline-block; width: 1px; height: 20px; background: var(--rule); vertical-align: middle; margin: 0 0.3rem; }
.toolbar-wire .added { color: var(--green); }
.toolbar-wire .removed { color: var(--red); text-decoration: line-through; }
footer .sources { margin-top: 4rem; padding-top: 2rem; border-top: 1px solid var(--rule); }
footer .sources h2 { font-size: 1.1rem; border: none; margin-top: 0; margin-bottom: 1rem; }
footer .sources ol { padding-left: 1.2rem; font-size: 0.82rem; color: var(--muted); }
footer .sources li { margin-bottom: 0.5rem; overflow-wrap: break-word; word-break: break-all; }
footer .sources .src-title { color: var(--ink); word-break: normal; }
footer .sources .src-url { display: block; margin-top: 0.15rem; font-size: 0.82rem; color: var(--accent); word-break: break-all; }
footer .sources a { word-break: break-all; }
@media (max-width: 768px) {
.cover h1 { font-size: 1.8rem; }
.cards { grid-template-columns: 1fr; }
.table-wrap { min-width: 100%; }
table { min-width: 600px; }
}
</style>
</head>
<body>
<section class="cover">
<div class="cover-badge"><span class="dot"></span> 开源贡献提案</div>
<h1>DBX <span>ER 图增强</span>架构方案</h1>
<p class="subtitle">面向 t8y2/dbx 项目的 ER 图模块重构与 ID 关联智能匹配方案,遵循 DBX 现有交互规范与存储模式</p>
<div class="cover-meta">
<div><strong>项目</strong>dbx v0.5.56</div>
<div><strong>技术栈</strong>Tauri 2 + Vue 3 + Rust</div>
<div><strong>协议</strong>Apache-2.0</div>
<div><strong>日期</strong>2026-07-15</div>
</div>
<div class="cover-version">v4 — 遵循 DBX 操作规范与存储模式</div>
</section>
<article class="page">
<h2>方案审计与修订说明</h2>
<div class="table-wrap audit-table">
<table class="audit-table">
<thead>
<tr><th style="width:5%">#</th><th style="width:15%">审计项</th><th style="width:30%">v3 方案(原)</th><th style="width:30%">v4 方案(修订)</th><th style="width:20%">修订原因</th></tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td><strong>工具栏交互</strong></td>
<td class="old-val">未对齐 DBX 现有按钮风格、布局结构和图标规范</td>
<td class="new-val">严格遵循现有 shadcn-vue Button 规范、lucide 图标、工具栏布局</td>
<td>PR 需要与现有 UI 风格完全一致,否则会被 maintainer 要求修改</td>
</tr>
<tr>
<td>2</td>
<td><strong>配置存储</strong></td>
<td class="old-val">新增 Rust 后端 <code>match_rules.rs</code> 持久化匹配规则</td>
<td class="new-val">沿用 <code>localStorage</code> + <code>dbx:diagram:...</code> key 前缀 + <code>safeLocalStorageGet/Set</code></td>
<td>DBX 自定义关系已用此模式存储,不引入新的存储机制</td>
</tr>
<tr>
<td>3</td>
<td><strong>后端改动</strong></td>
<td class="old-val">新增 <code>match_rules.rs</code>Tauri Command、修改 <code>schema.rs</code></td>
<td class="new-val">仅修改 <code>schema.rs</code>:新增 <code>get_all_columns</code><code>ColumnInfo.is_unique</code></td>
<td>删除存储相关后端改动,存储全部在前端 localStorage 完成</td>
</tr>
</tbody>
</table>
</div>
<h2>DBX 现有操作规范</h2>
<p>在描述新方案之前,先明确需要遵循的现有规范。以下内容基于对 <code>SchemaDiagramDialog.vue</code>v0.5.58)的源码分析。</p>
<h3>Dialog 容器结构</h3>
<pre><code>&lt;Dialog :open="open" @update:open="(v) =&gt; model = v"&gt;
&lt;DialogContent class="w-[94vw] h-[86vh] flex flex-col p-0"&gt;
&lt;DialogHeader class="px-4 py-3 border-b"&gt;
&lt;DialogTitle&gt;Network图标 + "ER 图"标题&lt;/DialogTitle&gt;
&lt;/DialogHeader&gt;
&lt;!-- 工具栏 --&gt;
&lt;div class="flex items-center gap-2 border-b px-3 py-2 shrink-0 overflow-x-auto"&gt;
...按钮和选择器...
&lt;/div&gt;
&lt;!-- 可折叠面板(关系建模 / 匹配管理) --&gt;
&lt;div v-if="showPanel" class="shrink-0 border-b"&gt;...&lt;/div&gt;
&lt;!-- 画布 --&gt;
&lt;div class="min-h-0 flex-1 bg-muted/20"&gt;...&lt;/div&gt;
&lt;/DialogContent&gt;
&lt;/Dialog&gt;</code></pre>
<h3>按钮规范</h3>
<div class="table-wrap">
<table>
<thead><tr><th>场景</th><th>variant</th><th>size</th><th>额外 class</th><th>图标尺寸</th></tr></thead>
<tbody>
<tr><td>带文字的操作按钮(建模关系、复制 SQL</td><td><code>outline</code></td><td><code>sm</code></td><td><code>h-8 px-2 text-xs</code></td><td><code>h-3.5 w-3.5</code> + <code>mr-1</code></td></tr>
<tr><td>纯图标按钮(缩放、刷新、导出)</td><td><code>ghost</code></td><td><code>icon</code></td><td><code>h-8 w-8</code></td><td><code>h-4 w-4</code></td></tr>
<tr><td>模式切换按钮组(表模式/工程模式)</td><td><code>ghost</code></td><td><code>sm</code></td><td><code>h-8 rounded-none px-2 text-xs</code></td><td><code>h-3.5 w-3.5</code> + <code>mr-1</code></td></tr>
<tr><td>面板内主要操作按钮(添加关系)</td><td><code>default</code></td><td><code>sm</code></td><td><code>h-8 px-2 text-xs</code></td><td><code>h-3.5 w-3.5</code> + <code>mr-1</code></td></tr>
</tbody>
</table>
</div>
<h3>现有工具栏布局(改造前)</h3>
<div class="toolbar-wire">
[Select:连接] [Select:数据库] [Select:Schema] | [🔍 搜索框] | [表模式][工程模式] | [🔗 建模关系] [📋 复制SQL] [关联表/全部表] | [Badge:表数] [Badge:关系数] [Badge:自定义关系数] | [⬇导出SVG] [🔄刷新] [➖缩小] [➕放大] [⧉重置布局]
</div>
<h3>现有存储模式</h3>
<p>DBX 的自定义关系存储使用 <strong>原生 <code>localStorage</code></strong>key 格式为 <code>dbx:diagram:relationships:v1:&lt;connectionId&gt;:&lt;database&gt;:&lt;schema&gt;</code>。全项目另有一层安全封装 <code>safeLocalStorageGet/Set/Remove</code>(位于 <code>lib/backend/safeStorage.ts</code>),使用 <code>globalThis.localStorage</code> + try-catch。当前 ER 图模块直接使用原生 <code>localStorage</code>,本方案统一迁移到 <code>safeLocalStorage</code> 封装。</p>
<div class="callout success">
<strong>存储约束</strong>:不引入 Tauri plugin-store不新增 Rust 后端存储命令。匹配规则的存储沿用 <code>localStorage</code> + <code>dbx:diagram:...</code> key 前缀,按"连接 + 数据库 + schema"粒度隔离,与自定义关系保持一致的存储范式。
</div>
<h2>现状分析与核心问题</h2>
<h3>DBX ER 图当前实现</h3>
<p>
DBX 的 ER 图功能完全自研,基于原生 HTML/CSS + SVG 渲染,没有引入任何第三方图可视化库。整个功能封装在一个约 1150 行的 <code>SchemaDiagramDialog.vue</code> 组件中。支持两种视图模式Table View 和 Engineering View正交折线连线路由自动绕开中间表卡片简单网格布局缩放范围 0.6x - 1.5x搜索过滤聚焦模式自定义关系建模localStorage 持久化)和 JOIN SQL 自动生成。
</p>
<h3>与 DataGrip / Navicat 的差距</h3>
<div class="table-wrap">
<table>
<thead><tr><th style="width:22%">交互维度</th><th style="width:22%">DataGrip</th><th style="width:22%">Navicat</th><th style="width:34%">DBX 现状</th></tr></thead>
<tbody>
<tr><td><strong>自动关系推断</strong></td><td>物理外键 + 正则匹配虚拟外键</td><td>仅物理外键</td><td>仅物理外键 + 手动自定义 <span class="gap-tag missing">缺少智能匹配</span></td></tr>
<tr><td><strong>布局算法</strong></td><td>多种可选布局 + 方向控制</td><td>Auto-Layout 一键排列</td><td>简单网格 <span class="gap-tag missing">无分层布局</span></td></tr>
<tr><td><strong>缩放范围</strong></td><td>无硬限制</td><td>无硬限制</td><td>0.6x - 1.5x <span class="gap-tag missing">范围过窄</span></td></tr>
<tr><td><strong>框选</strong></td><td>框选复制</td><td>搜索筛选</td><td><span class="gap-tag missing">完全缺失</span></td></tr>
<tr><td><strong>撤销/重做</strong></td><td>Ctrl+Z/Y</td><td>无限次 Undo/Redo</td><td><span class="gap-tag missing">完全缺失</span></td></tr>
<tr><td><strong>连线交互</strong></td><td>显示/隐藏虚拟外键</td><td>悬停高亮、编辑折点</td><td>SVG 箭头连线不可交互 <span class="gap-tag missing">连线无交互</span></td></tr>
</tbody>
</table>
</div>
<h3>核心问题</h3>
<div class="cards">
<div class="card"><h4>关系发现能力弱</h4><p>仅依赖物理外键。大量项目不建外键或使用不支持外键的数据库MongoDB、ClickHouseER 图上大量表呈现为孤岛。</p></div>
<div class="card"><h4>布局与交互原始</h4><p>网格布局无法体现表间逻辑关系50+ 张表时连线交叉严重。缺少框选、撤销、连线交互等基本操作。</p></div>
<div class="card"><h4>单体组件架构瓶颈</h4><p>全部逻辑集中在单个 1150 行 Vue 组件中,渲染、布局、路由、交互、状态管理耦合,难以扩展和测试。</p></div>
</div>
<h2>整体架构设计</h2>
<h3>技术选型</h3>
<div class="table-wrap">
<table>
<thead><tr><th>能力层</th><th>Dify</th><th>Coze</th><th>DBX v4</th><th>选型理由</th></tr></thead>
<tbody>
<tr><td><strong>画布框架</strong></td><td>ReactFlow</td><td>FlowGramCanvas</td><td><strong>Vue Flow</strong></td><td>Vue 3 项目ReactFlow 忠实移植<sup><a href="#cite-6">[6]</a></sup>gzip 49.8KB</td></tr>
<tr><td><strong>布局引擎</strong></td><td>ELK.js懒加载</td><td>自研</td><td><strong>ELK.js打包</strong></td><td>布局 + 正交边路由<sup><a href="#cite-7">[7]</a></sup>;打包确保离线可用</td></tr>
<tr><td><strong>状态管理</strong></td><td>Zustand + Immer</td><td>MobX</td><td><strong>Pinia现有</strong></td><td>DBX 已用 Pinia撤销/重做内嵌到 store</td></tr>
<tr><td><strong>配置存储</strong></td><td>localStorage + CRDT</td><td>未知</td><td><strong>localStorage现有</strong></td><td>沿用 <code>dbx:diagram:...</code> key + <code>safeLocalStorage</code></td></tr>
</tbody>
</table>
</div>
<h3>模块拆分</h3>
<figure class="diagram">
<pre class="mermaid">
graph TB
subgraph UI["UI 层 (Vue Components)"]
A["SchemaDiagramDialog.vue<br/>Dialog 容器 + 工具栏"]
B["TableNode.vue<br/>表卡片Vue Flow 自定义节点)"]
C["RelationshipEdge.vue<br/>关系线Vue Flow 自定义边)"]
D["MatchPanel.vue<br/>匹配规则管理面板"]
end
subgraph Adapter["适配层"]
E["VueFlowAdapter<br/>图数据 ↔ VueFlow 格式转换"]
end
subgraph Core["核心引擎 (lib/diagram/)"]
F["GraphStore<br/>Pinia + 撤销/重做历史栈"]
G["LayoutManager<br/>ELK.js 布局 + 正交边路由"]
H["MatchEngine<br/>ID 关联智能匹配"]
I["MatchStorage<br/>匹配规则 localStorage 读写"]
end
subgraph Backend["后端 (Rust) — 仅微调"]
J["schema.rs<br/>+get_all_columns<br/>+ColumnInfo.is_unique"]
end
A --> E
B --> E
C --> E
F --> E
A --> G
A --> H
H --> I
F --> J
</pre>
<figcaption>图 1: v4 模块拆分架构</figcaption>
</figure>
<h2>工具栏改造方案</h2>
<p>新工具栏严格沿用 DBX 现有布局:选择器 → 搜索 → 模式切换 → 操作按钮 → Badge → 图标按钮。新增按钮遵循现有 Button 规范,新增图标使用 lucide-vue。Vue Flow 的 Controls 和 MiniMap 作为浮动组件叠加在画布上,不占用工具栏空间。</p>
<h3>改造后工具栏布局</h3>
<div class="toolbar-wire">
[Select:连接] [Select:数据库] [Select:Schema] <span class="sep">|</span> [🔍 搜索框] <span class="sep">|</span> [表模式][工程模式] <span class="sep">|</span> [🔗 建模关系] [<span class="added">🔍 自动匹配</span>] [<span class="added">📊 自动布局 ▾</span>] [📋 复制SQL] [关联表/全部表] <span class="sep">|</span> [Badge:表数] [Badge:关系数] [<span class="added">Badge:匹配关系数</span>] [Badge:自定义关系数] <span class="sep">|</span> [⬇导出SVG] [🔄刷新] <span class="removed">➖缩小 ➕放大</span> [<span class="removed">⧉重置布局</span>]
</div>
<h3>新增/变更按钮明细</h3>
<div class="table-wrap">
<table>
<thead><tr><th>按钮</th><th>位置</th><th>规范</th><th>图标</th><th>说明</th></tr></thead>
<tbody>
<tr>
<td><strong>自动匹配</strong><span class="new-tag">新增</span></td>
<td>"建模关系"按钮右侧</td>
<td><code>variant="outline" size="sm" class="h-8 px-2 text-xs"</code></td>
<td><code>ScanSearch</code>lucideh-3.5 w-3.5</td>
<td>切换打开/关闭 MatchPanel 面板,复用现有关系面板的条件渲染模式</td>
</tr>
<tr>
<td><strong>自动布局</strong><span class="new-tag">新增</span></td>
<td>"自动匹配"按钮右侧</td>
<td><code>variant="outline" size="sm" class="h-8 px-2 text-xs"</code></td>
<td><code>LayoutGrid</code>lucideh-3.5 w-3.5</td>
<td>点击触发 ELK 自动布局下拉可选方向LR / TB / RL / BT</td>
</tr>
<tr>
<td><strong>匹配关系 Badge</strong><span class="new-tag">新增</span></td>
<td>Badge 区域,自定义关系 Badge 前</td>
<td><code>variant="secondary" class="h-6 text-xs"</code></td>
<td></td>
<td>显示当前自动匹配推断的关系数,点击切换显示/隐藏</td>
</tr>
<tr>
<td><strong>缩放按钮</strong><span class="new-tag">变更</span></td>
<td>工具栏右侧图标区</td>
<td>移除,由 Vue Flow Controls 替代</td>
<td></td>
<td>Vue Flow 的 <code>&lt;Controls /&gt;</code> 浮动在画布右下角,包含 +/-/fit/lock 按钮</td>
</tr>
<tr>
<td><strong>重置布局</strong><span class="new-tag">变更</span></td>
<td>工具栏右侧</td>
<td>移除,由"自动布局"按钮替代</td>
<td></td>
<td>"自动布局"按钮已包含重排功能,无需单独的重置按钮</td>
</tr>
</tbody>
</table>
</div>
<div class="callout">
<strong>面板复用模式</strong>MatchPanel 面板的交互模式完全复用现有关系建模面板的设计——通过 <code>v-if="showMatchPanel"</code> 条件渲染在工具栏和画布之间,包含 Select 选择器 + Button 操作 + Badge 列表。用户在 DBX 中看到的是一个与现有"建模关系"面板风格完全一致的"自动匹配"面板。
</div>
<h2>存储方案</h2>
<p>所有匹配相关数据存储在前端 <code>localStorage</code>,使用 DBX 现有的 <code>safeLocalStorageGet/Set/Remove</code> 封装。key 格式与自定义关系保持一致的 <code>dbx:diagram:...</code> 前缀 + "连接 + 数据库 + schema"粒度。</p>
<h3>存储 key 设计</h3>
<div class="table-wrap">
<table>
<thead><tr><th>数据类型</th><th>key 格式</th><th>现有/新增</th></tr></thead>
<tbody>
<tr><td>自定义关系</td><td><code>dbx:diagram:relationships:v1:&lt;connId&gt;:&lt;db&gt;:&lt;schema&gt;</code></td><td>现有(保持不变)</td></tr>
<tr><td>匹配确认记录</td><td><code>dbx:diagram:match-confirms:v1:&lt;connId&gt;:&lt;db&gt;:&lt;schema&gt;</code></td><td>新增</td></tr>
<tr><td>匹配忽略记录</td><td><code>dbx:diagram:match-ignores:v1:&lt;connId&gt;:&lt;db&gt;:&lt;schema&gt;</code></td><td>新增</td></tr>
<tr><td>用户自定义正则规则</td><td><code>dbx:diagram:match-rules:v1:&lt;connId&gt;:&lt;db&gt;:&lt;schema&gt;</code></td><td>新增</td></tr>
<tr><td>匹配全局开关</td><td><code>dbx:diagram:match-enabled</code></td><td>新增</td></tr>
</tbody>
</table>
</div>
<h3>存储实现代码</h3>
<pre><code>// match-storage.ts
import { safeLocalStorageGet, safeLocalStorageSet, safeLocalStorageRemove }
from "@/lib/backend/safeStorage";
function matchStorageKey(
type: "match-confirms" | "match-ignores" | "match-rules",
connectionId: string,
database: string,
schema: string,
): string {
return ["dbx", "diagram", type, "v1", connectionId, database, schema].join(":");
}
// 加载已确认的匹配关系
export function loadMatchConfirms(
connectionId: string, database: string, schema: string
): string[] {
const key = matchStorageKey("match-confirms", connectionId, database, schema);
try {
return JSON.parse(safeLocalStorageGet(key) || "[]");
} catch { return []; }
}
// 保存已确认的匹配关系
export function saveMatchConfirms(
ids: string[], connectionId: string, database: string, schema: string
): void {
const key = matchStorageKey("match-confirms", connectionId, database, schema);
safeLocalStorageSet(key, JSON.stringify(ids));
}
// 加载已忽略的匹配关系
export function loadMatchIgnores(
connectionId: string, database: string, schema: string
): string[] {
const key = matchStorageKey("match-ignores", connectionId, database, schema);
try {
return JSON.parse(safeLocalStorageGet(key) || "[]");
} catch { return []; }
}
// 保存已忽略的匹配关系
export function saveMatchIgnores(
ids: string[], connectionId: string, database: string, schema: string
): void {
const key = matchStorageKey("match-ignores", connectionId, database, schema);
safeLocalStorageSet(key, JSON.stringify(ids));
}
// 匹配全局开关(跨连接共享)
export function isAutoMatchEnabled(): boolean {
return safeLocalStorageGet("dbx:diagram:match-enabled") !== "false";
}
export function setAutoMatchEnabled(enabled: boolean): void {
safeLocalStorageSet("dbx:diagram:match-enabled", String(enabled));
}</code></pre>
<div class="callout warn">
<strong>不新增 Rust 后端存储</strong>v3 方案中的 <code>match_rules.rs</code>Tauri Command: save_match_rules / load_match_rules已删除。匹配规则的存储量很小通常几十条 JSON<code>localStorage</code> 完全胜任,且与 DBX 现有的自定义关系存储方式保持一致。
</div>
<h2>智能 ID 关联匹配引擎</h2>
<p>本方案中<strong>最有价值的增量能力</strong>。DataGrip 通过正则表达式虚拟外键实现了类似功能<sup><a href="#cite-1">[1]</a></sup>,但需要用户手动配置。本方案内置开箱即用的自动匹配策略。</p>
<h3>匹配策略分层</h3>
<div class="table-wrap">
<table>
<thead><tr><th style="width:10%">优先级</th><th style="width:18%">策略</th><th style="width:42%">规则</th><th style="width:30%">置信度</th></tr></thead>
<tbody>
<tr><td><strong>P0</strong></td><td>物理外键</td><td>读取 <code>INFORMATION_SCHEMA</code> 外键约束</td><td>100%</td></tr>
<tr><td><strong>P1</strong></td><td>命名约定</td><td><code>{table}_id</code> / <code>{table}_uuid</code> → 目标表主键,支持 snake_case / camelCase</td><td>高(自动确认)</td></tr>
<tr><td><strong>P2</strong></td><td>类型签名</td><td>P1 + 源列与目标列类型兼容(如都是 <code>bigint</code></td><td>高(自动确认)</td></tr>
<tr><td><strong>P3</strong></td><td>正则规则</td><td>用户自定义正则,如 <code>(.*)_id</code><code>$1.id</code><sup><a href="#cite-1">[1]</a></sup></td><td>中(需确认)</td></tr>
</tbody>
</table>
</div>
<h3>匹配算法核心逻辑</h3>
<pre><code>function inferRelationships(tables: TableMeta[]): InferredRelationship[] {
const results: InferredRelationship[] = [];
const tableNameSet = new Set(tables.map(t => t.name));
const primaryKeys = buildPrimaryKeyIndex(tables);
for (const table of tables) {
for (const column of table.columns) {
if (column.is_primary_key) continue;
// P1: 命名约定匹配
const match = column.name.match(/^(.+?)_(?:id|uuid|pk)$/i);
if (!match) continue;
const candidateTable = toSnakeCase(match[1]);
if (!tableNameSet.has(candidateTable)) continue;
const targetPK = primaryKeys.get(candidateTable);
if (!targetPK) continue;
// P2: 类型签名校验
if (!isTypeCompatible(column.data_type, targetPK.data_type)) continue;
results.push({
sourceTable: table.name,
sourceColumn: column.name,
targetTable: candidateTable,
targetColumn: targetPK.name,
confidence: 'high',
strategy: 'naming_convention',
});
}
}
return deduplicate(results);
}</code></pre>
<h3>匹配结果与存储交互</h3>
<p>匹配引擎运行时需要与 <code>match-storage.ts</code> 交互,过滤已确认和已忽略的记录:</p>
<pre><code>// match-engine.ts 中的过滤逻辑
function filterByStorage(
inferred: InferredRelationship[],
confirms: string[], // 从 localStorage 加载
ignores: string[], // 从 localStorage 加载
): MatchResult {
const confirmed = inferred.filter(r => confirms.includes(r.id));
const pending = inferred.filter(r =>
!confirms.includes(r.id) && !ignores.includes(r.id)
&& r.confidence === 'high'
);
const conflicts = pending.filter(r => hasMultipleTargets(r, pending));
return {
relationships: [...confirmed, ...pending.filter(r => !conflicts.includes(r))],
conflicts,
pending: conflicts,
stats: { total: inferred.length, high: confirmed.length + pending.length, ... },
};
}</code></pre>
<div class="callout">
<strong>视觉区分</strong>:物理外键实线高亮;自动匹配(高置信)虚线半透明;待确认关系点线灰色。工具栏 Badge 区的"匹配关系"Badge 点击可切换显示/隐藏。
</div>
<h2>Vue Flow + ELK.js 交互设计</h2>
<h3>Vue Flow 提供的开箱即用能力</h3>
<div class="table-wrap">
<table>
<thead><tr><th>能力</th><th>Vue Flow 原生</th><th>DBX v1 中</th></tr></thead>
<tbody>
<tr><td>节点拖拽</td><td>内置 <code>draggable</code></td><td>手动 mousedown/move/up</td></tr>
<tr><td>缩放与平移</td><td>内置 viewport无范围限制</td><td>自研 diagramZoom.ts0.6x-1.5x</td></tr>
<tr><td>框选多选</td><td><code>SelectionMode.Partial</code></td><td>缺失</td></tr>
<tr><td>MiniMap</td><td><code>&lt;MiniMap /&gt;</code> 浮动组件</td><td>缺失</td></tr>
<tr><td>Controls</td><td><code>&lt;Controls /&gt;</code> 浮动组件(替代工具栏 +/- 按钮)</td><td>手动按钮</td></tr>
<tr><td>背景网格</td><td><code>&lt;Background /&gt;</code></td><td>CSS 背景</td></tr>
<tr><td>虚拟化</td><td><code>onlyRenderVisibleElements</code></td><td>缺失</td></tr>
</tbody>
</table>
</div>
<h3>ELK.js 布局配置</h3>
<pre><code>// elk-layout.ts
import ELK from 'elkjs/lib/elk.bundled.js';
const elk = new ELK();
export async function computeLayout(
graph: DiagramGraph, options: LayoutOptions
): Promise&lt;LayoutResult&gt; {
const elkGraph = buildElkGraph(graph, options);
const result = await elk.layout(elkGraph);
return extractLayoutResult(result);
}</code></pre>
<div class="table-wrap">
<table>
<thead><tr><th>配置项</th><th></th><th>说明</th></tr></thead>
<tbody>
<tr><td><code>elk.algorithm</code></td><td><code>layered</code></td><td>Sugiyama 分层布局</td></tr>
<tr><td><code>elk.direction</code></td><td><code>RIGHT</code> / <code>DOWN</code></td><td>工具栏下拉切换</td></tr>
<tr><td><code>edgeRouting</code></td><td><code>ORTHOGONAL</code></td><td>正交折线,自动避开节点</td></tr>
<tr><td><code>nodePlacement</code></td><td><code>BRANDES_KOEPF</code></td><td>平衡对齐(同 Dify</td></tr>
<tr><td><code>crossingMinimization</code></td><td><code>LAYER_SWEEP</code></td><td>交叉最小化</td></tr>
<tr><td><code>layering.strategy</code></td><td><code>NETWORK_SIMPLEX</code></td><td>最小化边跨度</td></tr>
<tr><td><code>separateConnectedComponents</code></td><td><code>true</code></td><td>自动分离孤立子图</td></tr>
</tbody>
</table>
</div>
<h2>Pinia 原生撤销/重做</h2>
<p>不引入第三方撤销库。在 GraphStorePinia中手动维护 <code>historyStack</code> + <code>redoStack</code>,使用 <code>lodash-es</code><code>cloneDeep</code> 做快照DBX 已通过 shadcn-vue 间接依赖 lodash-es无新增依赖</p>
<pre><code>// graph-store.ts核心片段
export const useGraphStore = defineStore('diagram-graph', () => {
const nodes = ref&lt;DiagramNode[]&gt;([]);
const edges = ref&lt;DiagramEdge[]&gt;([]);
const historyStack = ref&lt;HistorySnapshot[]&gt;([]);
const redoStack = ref&lt;HistorySnapshot[]&gt;([]);
const maxHistorySize = 50;
function pushHistory() {
historyStack.value.push({
nodes: cloneDeep(nodes.value),
edges: cloneDeep(edges.value),
});
if (historyStack.value.length > maxHistorySize) historyStack.value.shift();
redoStack.value = [];
}
function undo() {
if (!historyStack.value.length) return;
redoStack.value.push({ nodes: cloneDeep(nodes.value), edges: cloneDeep(edges.value) });
const prev = historyStack.value.pop()!;
nodes.value = prev.nodes;
edges.value = prev.edges;
}
function redo() { /* 对称实现 */ }
// 仅布局调整和关系操作记录历史,选择/缩放不记录
function applyLayout(newNodes: DiagramNode[], newEdges: DiagramEdge[]) {
pushHistory();
nodes.value = newNodes;
edges.value = newEdges;
}
});</code></pre>
<h2>后端改动(最小化)</h2>
<p>v4 方案的后端改动仅限于 <code>schema.rs</code>,不新增任何 Tauri Command 或存储模块:</p>
<div class="table-wrap">
<table>
<thead><tr><th>改动点</th><th>文件</th><th>内容</th><th>说明</th></tr></thead>
<tbody>
<tr><td>批量列查询</td><td><code>schema.rs</code></td><td>新增 <code>get_all_columns</code> 命令</td><td>一次返回 Schema 下所有表的列,避免匹配引擎逐表 IPC</td></tr>
<tr><td>列唯一键标识</td><td><code>schema.rs</code></td><td><code>ColumnInfo</code> 新增 <code>is_unique</code> 字段</td><td>辅助匹配引擎判断目标列是否为主键或唯一键</td></tr>
</tbody>
</table>
</div>
<h2>文件结构规划</h2>
<pre><code>apps/desktop/src/
├── components/diagram/
│ ├── SchemaDiagramDialog.vue (重构:拆分为容器 + 工具栏)
│ ├── **TableNode.vue** (新增Vue Flow 自定义节点)
│ ├── **RelationshipEdge.vue** (新增Vue Flow 自定义边)
│ ├── **MatchPanel.vue** (新增:匹配管理面板)
│ └── **DiagramToolbar.vue** (新增:工具栏提取)
├── lib/diagram/
│ ├── erDiagram.ts (保留:核心数据模型)
│ ├── engineeringDiagram.ts (保留:工程 ER 图)
│ ├── **vue-flow-adapter.ts** (新增Vue Flow 适配层)
│ ├── **graph-store.ts** (新增Pinia + 撤销/重做历史栈)
│ ├── **layout-manager.ts** (新增:布局调度)
│ ├── **elk-layout.ts** (新增ELK.js 布局配置)
│ ├── **layout-grid.ts** (新增:网格布局 fallback)
│ ├── **match-engine.ts** (新增:智能匹配引擎)
│ ├── **match-strategies.ts** (新增:匹配策略)
│ ├── **match-storage.ts** (新增localStorage 读写,复用 safeLocalStorage)
│ └── fieldLineage.ts (保留)
├── types/
│ └── **diagram.ts** (新增:类型定义)
└── tests/
├── unit/
│ ├── **match-engine.test.ts**
│ ├── **match-storage.test.ts**
│ ├── **layout-manager.test.ts**
│ ├── **vue-flow-adapter.test.ts**
│ └── **graph-store.test.ts**
└── e2e/
└── **er-diagram.spec.ts**
src-tauri/src/commands/
└── schema.rs (微调:+get_all_columns, +is_unique)
// v3 中的 match_rules.rs 已删除
// v1 中的 edge-router.ts / interaction-manager.ts 已删除Vue Flow / ELK 替代)</code></pre>
<h2>测试方案</h2>
<h3>单元测试Vitest</h3>
<h4>MatchEngine</h4>
<div class="test-case"><div class="tc-name">TC-M1: 命名约定匹配 — user_id → users.id</div><div class="tc-desc">输入 users(id PK)、orders(user_id)。期望匹配到 users.idconfidence='high'。</div><div class="tc-assert">assert(result.relationships[0].targetTable === 'users')</div></div>
<div class="test-case"><div class="tc-name">TC-M2: 类型不匹配时拒绝</div><div class="tc-desc">输入 users(id bigint)、orders(user_id varchar)。期望无匹配。</div><div class="tc-assert">assert(result.relationships.length === 0)</div></div>
<div class="test-case"><div class="tc-name">TC-M3: 冲突检测 — type_id 指向多表</div><div class="tc-desc">types 和 user_types 同时存在。期望标记冲突。</div><div class="tc-assert">assert(result.conflicts.length > 0)</div></div>
<div class="test-case"><div class="tc-name">TC-M4: 物理外键优先不重复</div><div class="tc-desc">物理外键 + 命名约定同时匹配。期望仅一条。</div><div class="tc-assert">assert(result.relationships.length === 1)</div></div>
<div class="test-case"><div class="tc-name">TC-M5: camelCase 列名</div><div class="tc-desc">Users(Id PK)、Orders(UserId)。期望匹配成功。</div><div class="tc-assert">assert(result.relationships.length === 1)</div></div>
<h4>MatchStorage</h4>
<div class="test-case"><div class="tc-name">TC-MS1: confirms 存储与加载</div><div class="tc-desc">保存 3 条 confirm ID 后加载。期望返回相同 3 条。</div><div class="tc-assert">assert(loaded.length === 3 && loaded.every(id => saved.includes(id)))</div></div>
<div class="test-case"><div class="tc-name">TC-MS2: ignores 与 confirms 互不干扰</div><div class="tc-desc">分别保存 confirms 和 ignores。期望各自加载正确。</div><div class="tc-assert">assert(confirms !== ignores)</div></div>
<div class="test-case"><div class="tc-name">TC-MS3: 全局开关默认开启</div><div class="tc-desc">未设置时调用 isAutoMatchEnabled()。期望 true。</div><div class="tc-assert">assert(isAutoMatchEnabled() === true)</div></div>
<h4>LayoutManager</h4>
<div class="test-case"><div class="tc-name">TC-L1: ELK 输出无重叠</div><div class="tc-desc">10 节点 + 9 边。期望无碰撞。</div><div class="tc-assert">assert(!hasCollision(result.nodes))</div></div>
<div class="test-case"><div class="tc-name">TC-L2: 正交边路径含 L 命令</div><div class="tc-desc">带端口的节点和边。期望 SVG path 包含 'L'。</div><div class="tc-assert">assert(result.edges.every(e => e.path.includes('L')))</div></div>
<div class="test-case"><div class="tc-name">TC-L3: pinned 节点位置不变</div><div class="tc-desc">2 个 pinned 节点。期望坐标不变。</div><div class="tc-assert">assert(pinned.every(n => n.position === original))</div></div>
<h4>GraphStore撤销/重做)</h4>
<div class="test-case"><div class="tc-name">TC-S1: 撤销恢复位置</div><div class="tc-desc">拖拽 A 到 (100,100)undo。期望 A 回 (0,0)。</div><div class="tc-assert">assert(nodes[0].position.x === 0)</div></div>
<div class="test-case"><div class="tc-name">TC-S2: redo 恢复新位置</div><div class="tc-desc">拖拽undoredo。期望 A 在 (100,100)。</div><div class="tc-assert">assert(nodes[0].position.x === 100)</div></div>
<div class="test-case"><div class="tc-name">TC-S3: 新操作清空 redo</div><div class="tc-desc">拖拽 Aundo拖拽 B。期望 canRedo = false。</div><div class="tc-assert">assert(!canRedo)</div></div>
<div class="test-case"><div class="tc-name">TC-S4: 栈深度限制 50</div><div class="tc-desc">连续 55 次 pushHistory。期望 historyStack.length === 50。</div><div class="tc-assert">assert(historyStack.length === 50)</div></div>
<h3>e2e 测试Playwright</h3>
<div class="test-case"><div class="tc-name">TC-E1: 完整 ER 图加载</div><div class="tc-desc">打开连接 → 选择数据库 → 点击"ER 图"。期望节点数 = 表数。</div><div class="tc-assert">assert(locator('.vue-flow__node').count() === tableCount)</div></div>
<div class="test-case"><div class="tc-name">TC-E2: 无外键库的智能匹配</div><div class="tc-desc">连接 SQLite → 打开 ER 图 → 点击"自动匹配"。期望虚线连线可见。</div><div class="tc-assert">assert(locator('[data-kind="inferred"]').count() > 0)</div></div>
<div class="test-case"><div class="tc-name">TC-E3: ELK 自动布局</div><div class="tc-desc">点击"自动布局"。期望节点按层级排列,连线无交叉。</div><div class="tc-assert">assert(noOverlapping() && noIntersectingEdges())</div></div>
<div class="test-case"><div class="tc-name">TC-E4: 框选 + 批量拖拽</div><div class="tc-desc">拖拽框选 3 节点 → 移动。期望 3 个同时移动。</div><div class="tc-assert">assert(selectedCount === 3)</div></div>
<div class="test-case"><div class="tc-name">TC-E5: Ctrl+Z/Y 撤销重做</div><div class="tc-desc">拖拽 → Ctrl+Z → Ctrl+Y。期望先回原位再回新位。</div><div class="tc-assert">assert(posAfterRedo === draggedPos)</div></div>
<div class="test-case"><div class="tc-name">TC-E6: 匹配面板交互</div><div class="tc-desc">点击"自动匹配"按钮 → 面板出现 → 点击确认一条 → 虚线变实线。</div><div class="tc-assert">assert(confirmedEdge.getStyle().strokeDasharray === 'none')</div></div>
<h2>分阶段实施计划</h2>
<div class="phases">
<div class="phase">
<div class="phase-dot p1">1</div>
<div class="phase-body">
<h4>Phase 1: Vue Flow 迁移 + 智能匹配 + 撤销重做</h4>
<p><strong>目标</strong>Vue Flow 替换自研渲染智能匹配核心可用Pinia 撤销/重做。</p>
<ul>
<li><strong>Vue Flow 集成</strong>:安装 <code>@vue-flow/core</code> + <code>@vue-flow/minimap</code> + <code>@vue-flow/controls</code>,实现适配层</li>
<li><strong>ELK 集成</strong>npm 安装 <code>elkjs</code>(直接打包),实现分层布局 + 正交边路由</li>
<li><strong>GraphStore</strong>Pinia + Immer包含手动历史栈的撤销/重做</li>
<li><strong>匹配引擎</strong>:实现 P1/P2 策略,<code>match-storage.ts</code> 使用 <code>safeLocalStorage</code></li>
<li><strong>MatchPanel</strong>:复用现有关系面板的 UI 模式Select + Button + Badge</li>
<li><strong>工具栏</strong>:新增"自动匹配"和"自动布局"按钮,遵循现有 Button 规范</li>
<li><strong>后端</strong><code>schema.rs</code> 新增 <code>get_all_columns</code><code>is_unique</code></li>
<li><strong>单元测试</strong>MatchEngine、MatchStorage、LayoutManager、GraphStore</li>
</ul>
</div>
</div>
<div class="phase">
<div class="phase-dot p2">2</div>
<div class="phase-body">
<h4>Phase 2: 交互增强 + e2e 测试</h4>
<p><strong>目标</strong>框选、连线交互、MiniMap、e2e 覆盖。</p>
<ul>
<li><strong>框选</strong>:启用 <code>SelectionMode.Partial</code></li>
<li><strong>连线交互</strong><code>RelationshipEdge.vue</code> 悬停高亮</li>
<li><strong>MiniMap</strong>50+ 表时自动显示</li>
<li><strong>显示控制</strong>:工具栏开关控制列/注释/匹配关系</li>
<li><strong>e2e 测试</strong>Playwright 覆盖 6 个核心流程</li>
</ul>
</div>
</div>
<div class="phase">
<div class="phase-dot p3">3</div>
<div class="phase-body">
<h4>Phase 3: 高级功能 + 性能优化</h4>
<p><strong>目标</strong>:大 Schema 支持、高级分析、P3 正则规则。</p>
<ul>
<li><strong>虚拟化</strong><code>onlyRenderVisibleElements</code>,支持 200+ 表</li>
<li><strong>路径过滤</strong>:选中两节点,仅显示关联路径</li>
<li><strong>正则规则</strong>P3 用户自定义正则匹配,存储到 localStorage</li>
<li><strong>侧边栏拖入</strong>Vue Flow DnD 增量添加表</li>
</ul>
</div>
</div>
</div>
<h2>关键设计决策</h2>
<h3>为什么工具栏新增按钮而非重新设计</h3>
<p>
DBX 的工具栏已有固定的布局节奏:选择器 → 搜索 → 模式切换 → 操作按钮 → Badge → 图标按钮。新增的"自动匹配"和"自动布局"按钮插入到操作按钮区域("建模关系"按钮右侧),遵循 <code>variant="outline" size="sm" class="h-8 px-2 text-xs"</code> + lucide 图标 <code>h-3.5 w-3.5</code> + <code>mr-1</code> 的规范。缩放和重置按钮移除后由 Vue Flow 的浮动 Controls 组件替代,不占用工具栏空间。这种增量式改动与现有 UI 风格完全一致PR 审查时不会因为"风格不统一"被要求返工。
</p>
<h3>为什么用 localStorage 而非新增后端存储</h3>
<p>
DBX 的自定义关系已使用 <code>localStorage</code> + <code>dbx:diagram:relationships:v1:...</code> key 模式存储。匹配规则的存储需求与自定义关系完全相同(按连接+数据库+schema 隔离、数据量小、JSON 序列化),没有必要引入新的存储机制。使用已有的 <code>safeLocalStorageGet/Set/Remove</code> 封装(而非直接 <code>localStorage</code>),可以统一错误处理,比现有自定义关系代码更健壮。
</p>
<h3>为什么 ELK.js 直接打包</h3>
<p>
DBA 常在无网络的内网环境使用数据库管理工具。懒加载在离线场景下会导致布局功能不可用。直接打包后 ELK.js 随安装包分发,增量约 300KBgzip ~50KB对 20MB 的 DBX 影响约 1.7%。
</p>
<h3>为什么不选 Coze 的 FlowGram</h3>
<p>
FlowGram 基于 Canvas 自研渲染引擎,定位是 AI 工作流编排内置变量引擎、表单引擎。Canvas 渲染文本排版远不如 HTML不适合包含多行列信息的表卡片。对 ER 图来说严重过度设计。
</p>
<h2>测试用例索引</h2>
<div class="table-wrap">
<table>
<thead><tr><th>编号</th><th>模块</th><th>类型</th><th>数量</th></tr></thead>
<tbody>
<tr><td>TC-M1 ~ M5</td><td>MatchEngine</td><td>单元</td><td>5</td></tr>
<tr><td>TC-MS1 ~ MS3</td><td>MatchStorage</td><td>单元</td><td>3</td></tr>
<tr><td>TC-L1 ~ L3</td><td>LayoutManager</td><td>单元</td><td>3</td></tr>
<tr><td>TC-S1 ~ S4</td><td>GraphStore</td><td>单元</td><td>4</td></tr>
<tr><td>TC-E1 ~ E6</td><td>ER 图全流程</td><td>e2e</td><td>6</td></tr>
</tbody>
</table>
</div>
<footer>
<div class="sources">
<h2>Sources</h2>
<ol>
<li id="cite-1"><span class="src-title">JetBrains, Virtual foreign keys | DataGrip 2026.1 Documentation</span><a class="src-url" href="https://www.jetbrains.com/help/datagrip/2026.1/virtual-foreign-keys.html" target="_blank" rel="noopener">https://www.jetbrains.com/help/datagrip/2026.1/virtual-foreign-keys.html</a></li>
<li id="cite-2"><span class="src-title">JetBrains, Database diagrams | DataGrip 2026.1 Documentation</span><a class="src-url" href="https://www.jetbrains.com/help/datagrip/2026.1/creating-diagrams.html" target="_blank" rel="noopener">https://www.jetbrains.com/help/datagrip/2026.1/creating-diagrams.html</a></li>
<li id="cite-5"><span class="src-title">t8y2, DBX GitHub Repository</span><a class="src-url" href="https://github.com/t8y2/dbx" target="_blank" rel="noopener">https://github.com/t8y2/dbx</a></li>
<li id="cite-6"><span class="src-title">bcakmakoglu, Vue Flow — ReactFlow 的 Vue 3 移植</span><a class="src-url" href="https://github.com/bcakmakoglu/vue-flow" target="_blank" rel="noopener">https://github.com/bcakmakoglu/vue-flow</a></li>
<li id="cite-7"><span class="src-title">Eclipse Foundation, ELK Layout Engine</span><a class="src-url" href="https://eclipse.dev/elk/" target="_blank" rel="noopener">https://eclipse.dev/elk/</a></li>
<li id="cite-8"><span class="src-title">langgenius, Dify — ReactFlow + ELK.js 实践</span><a class="src-url" href="https://github.com/langgenius/dify" target="_blank" rel="noopener">https://github.com/langgenius/dify</a></li>
</ol>
</div>
</footer>
</article>
<script src="./_shared/js/mermaid.min.js"></script>
<script>
mermaid.initialize({ startOnLoad: true, theme: 'dark', securityLevel: 'loose', themeVariables: { darkMode: true, background: '#1a1d28', primaryColor: '#2e3348', primaryTextColor: '#e4e6ef', primaryBorderColor: '#38bdf8', lineColor: '#8b8fa7', secondaryColor: '#232736', tertiaryColor: '#1a1d28', fontFamily: 'InstrumentSans, sans-serif', fontSize: '13px' } });
</script>
</body>
</html>

View File

@ -197,7 +197,7 @@
fetcherVersion = 4;
# Update with the hash reported by a failed fixed-output build:
# nix build .#dbx-pnpm-deps 2>&1 | grep 'got:'
hash = "sha256-JtyKCHXkgQ/xfPrfi2DhQJ3wzmux40RfNivyfSlhgkY=";
hash = "sha256-NKYI5zHV7rnFhK2Elm2On6ffRJty4BbJcYaW9gRqPa8=";
};
# ── Step 2: vendor Cargo dependencies ───────────────────────────── #

View File

@ -74,6 +74,10 @@
"@uiw/codemirror-theme-okaidia": "^4.25.10",
"@uiw/codemirror-theme-vscode": "^4.25.10",
"@uiw/codemirror-theme-xcode": "^4.25.10",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2",
"@vue-flow/minimap": "^1.5.4",
"@vueuse/core": "^14.2.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -82,6 +86,7 @@
"diff": "^9.0.0",
"dom-to-image-more": "^3.7.2",
"echarts": "^6.1.0",
"elkjs": "^0.11.1",
"leaflet": "^1.9.4",
"marked": "^18.0.4",
"pinia": "^3.0.0",

View File

@ -0,0 +1,44 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import {
cardinalityChoiceFromPair,
cardinalityPairFromChoice,
edgeCardinalityPair,
type CardinalityChoice,
} from "../../apps/desktop/src/lib/diagram/cardinality.ts";
const CHOICES: CardinalityChoice[] = ["one-to-one", "one-to-many", "many-to-one", "many-to-many"];
test("cardinalityPairFromChoice maps all four choices", () => {
assert.deepEqual(cardinalityPairFromChoice("one-to-one"), { sourceCardinality: "1", targetCardinality: "1" });
assert.deepEqual(cardinalityPairFromChoice("one-to-many"), { sourceCardinality: "1", targetCardinality: "N" });
assert.deepEqual(cardinalityPairFromChoice("many-to-one"), { sourceCardinality: "N", targetCardinality: "1" });
assert.deepEqual(cardinalityPairFromChoice("many-to-many"), { sourceCardinality: "N", targetCardinality: "N" });
});
test("cardinalityChoiceFromPair maps all four pairs and falls back to many-to-one", () => {
assert.equal(cardinalityChoiceFromPair({ sourceCardinality: "1", targetCardinality: "1" }), "one-to-one");
assert.equal(cardinalityChoiceFromPair({ sourceCardinality: "1", targetCardinality: "N" }), "one-to-many");
assert.equal(cardinalityChoiceFromPair({ sourceCardinality: "N", targetCardinality: "1" }), "many-to-one");
assert.equal(cardinalityChoiceFromPair({ sourceCardinality: "N", targetCardinality: "N" }), "many-to-many");
assert.equal(cardinalityChoiceFromPair(undefined), "many-to-one");
assert.equal(cardinalityChoiceFromPair(null), "many-to-one");
assert.equal(cardinalityChoiceFromPair({}), "many-to-one");
assert.equal(cardinalityChoiceFromPair({ sourceCardinality: "1" }), "many-to-one");
});
test("edgeCardinalityPair returns explicit pair or defaults to N:1", () => {
assert.deepEqual(edgeCardinalityPair({ sourceCardinality: "1", targetCardinality: "N" }), {
sourceCardinality: "1",
targetCardinality: "N",
});
assert.deepEqual(edgeCardinalityPair({}), { sourceCardinality: "N", targetCardinality: "1" });
assert.deepEqual(edgeCardinalityPair(undefined), { sourceCardinality: "N", targetCardinality: "1" });
assert.deepEqual(edgeCardinalityPair(null), { sourceCardinality: "N", targetCardinality: "1" });
});
test("choice → pair → choice round-trips for all choices", () => {
for (const choice of CHOICES) {
assert.equal(cardinalityChoiceFromPair(cardinalityPairFromChoice(choice)), choice);
}
});

View File

@ -0,0 +1,42 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { resolveDiagramDialectAdapter } from "../../apps/desktop/src/lib/diagram/diagram-dialect-adapter.ts";
test("resolveDiagramDialectAdapter default id types by dialect", () => {
assert.equal(resolveDiagramDialectAdapter("postgres").createDefaultIdColumn().data_type, "bigint");
assert.equal(resolveDiagramDialectAdapter("mysql").createDefaultIdColumn().data_type, "bigint");
// Prefer dialect default when listed; otherwise first integer-like option (Oracle often exposes "number").
assert.match(resolveDiagramDialectAdapter("oracle").createDefaultIdColumn().data_type, /^number$/i);
assert.match(resolveDiagramDialectAdapter("sqlite").createDefaultIdColumn().data_type, /^integer$/i);
assert.match(resolveDiagramDialectAdapter("clickhouse").createDefaultIdColumn().data_type, /^uint64$/i);
assert.match(resolveDiagramDialectAdapter("duckdb").createDefaultIdColumn().data_type, /^integer$/i);
assert.match(resolveDiagramDialectAdapter("h2").createDefaultIdColumn().data_type, /^bigint$/i);
});
test("resolveDiagramDialectAdapter unknown dialect falls back", () => {
const adapter = resolveDiagramDialectAdapter(undefined);
const id = adapter.createDefaultIdColumn();
assert.equal(id.name, "id");
assert.equal(id.is_primary_key, true);
assert.ok(typeof id.data_type === "string" && id.data_type.length > 0);
assert.equal(adapter.databaseType, undefined);
});
test("resolveDiagramDialectAdapter createEmptyColumn defaults", () => {
const col = resolveDiagramDialectAdapter("postgres").createEmptyColumn("foo");
assert.equal(col.name, "foo");
assert.equal(col.is_primary_key, false);
assert.equal(col.is_nullable, true);
assert.ok(col.data_type);
});
test("adapter remains a thin column factory without capability fields", () => {
const adapter = resolveDiagramDialectAdapter("postgres") as Record<string, unknown>;
assert.equal(typeof adapter.createDefaultIdColumn, "function");
assert.equal(typeof adapter.createEmptyColumn, "function");
assert.equal("supportsCreateTable" in adapter, false);
assert.equal("supportsCreateIndex" in adapter, false);
assert.equal("supportsComment" in adapter, false);
assert.equal("supportsDropColumn" in adapter, false);
assert.equal("dataTypeOptions" in adapter, false);
});

View File

@ -0,0 +1,122 @@
import { strict as assert } from "node:assert";
import { beforeEach, test, vi } from "vitest";
import {
hasUsablePersistedPositions,
loadDraftTables,
loadPersistedLayers,
loadPersistedPositions,
saveDraftTables,
savePersistedLayers,
savePersistedPositions,
} from "../../apps/desktop/src/lib/diagram/draft-storage.ts";
import { createDraftTable } from "../../apps/desktop/src/lib/diagram/draft-table.ts";
import type { DiagramTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts";
import type { DiagramLayer } from "../../apps/desktop/src/types/diagram.ts";
const store = new Map<string, string>();
beforeEach(() => {
store.clear();
vi.stubGlobal("localStorage", {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
});
});
test("savePersistedPositions / loadPersistedPositions round-trip", () => {
const positions = {
users: { x: 10, y: 20 },
roles: { x: 100.5, y: -3 },
};
savePersistedPositions(positions, "conn-1", "db", "public");
const loaded = loadPersistedPositions("conn-1", "db", "public");
assert.deepEqual(loaded, positions);
});
test("loadPersistedPositions drops invalid entries", () => {
store.set(
["dbx", "diagram", "positions", "v1", "c", "d", "s"].join(":"),
JSON.stringify({
ok: { x: 1, y: 2 },
bad: { x: "no", y: 2 },
missingY: { x: 1 },
nan: { x: Number.NaN, y: 0 },
}),
);
const loaded = loadPersistedPositions("c", "d", "s");
assert.deepEqual(loaded, { ok: { x: 1, y: 2 } });
});
test("hasUsablePersistedPositions requires at least one known table", () => {
assert.equal(hasUsablePersistedPositions({ users: { x: 0, y: 0 } }, ["roles"]), false);
assert.equal(hasUsablePersistedPositions({ users: { x: 0, y: 0 } }, ["users", "roles"]), true);
assert.equal(hasUsablePersistedPositions({}, ["users"]), false);
});
test("saveDraftTables / loadDraftTables round-trip and only persists drafts", () => {
const draft = createDraftTable("orders");
const live: DiagramTable = {
name: "users",
columns: draft.columns,
foreignKeys: [],
origin: "live",
};
saveDraftTables([draft, live], "conn-1", "db", "public");
const loaded = loadDraftTables("conn-1", "db", "public");
assert.equal(loaded.length, 1);
assert.equal(loaded[0].name, "orders");
assert.equal(loaded[0].origin, "draft");
assert.equal(loaded[0].syncStatus, "pending");
});
test("loadDraftTables drops invalid entries and normalizes origin/syncStatus", () => {
store.set(
["dbx", "diagram", "draft-tables", "v1", "c", "d", "s"].join(":"),
JSON.stringify([
{ name: "ok", columns: [], foreignKeys: [], syncStatus: "error" },
{ name: 123, columns: [] },
{ name: "no-cols" },
null,
]),
);
const loaded = loadDraftTables("c", "d", "s");
assert.equal(loaded.length, 1);
assert.equal(loaded[0].name, "ok");
assert.equal(loaded[0].origin, "draft");
assert.equal(loaded[0].syncStatus, "error");
});
test("savePersistedLayers / loadPersistedLayers round-trip", () => {
const layers: DiagramLayer[] = [
{
id: "l1",
name: "Core",
color: "#3b82f6",
tableNames: ["users"],
collapsed: false,
visible: true,
layoutMode: "auto",
position: { x: 10, y: 20 },
width: 240,
height: 100,
},
];
savePersistedLayers(layers, "conn-1", "db", "public");
assert.deepEqual(loadPersistedLayers("conn-1", "db", "public"), layers);
});
test("empty connectionId or database skips write and load returns empty", () => {
saveDraftTables([createDraftTable("t")], "", "db", "public");
saveDraftTables([createDraftTable("t")], "c", "", "public");
savePersistedLayers([], "", "db", "public");
savePersistedPositions({ t: { x: 1, y: 2 } }, "c", "", "public");
assert.equal(store.size, 0);
assert.deepEqual(loadDraftTables("", "db", "public"), []);
assert.deepEqual(loadPersistedLayers("c", "", "public"), []);
assert.deepEqual(loadPersistedPositions("", "db", "public"), {});
});

View File

@ -0,0 +1,86 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import {
createDraftIndex,
createDraftTable,
createEmptyColumn,
draftTableToCreateSqlOptions,
nextUniqueColumnName,
validateDraftTable,
} from "../../apps/desktop/src/lib/diagram/draft-table.ts";
import { isDraftTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts";
import type { EditableStructureIndex } from "../../apps/desktop/src/lib/table/tableStructureEditorSql.ts";
test("createDraftTable marks origin draft and optional id pk", () => {
const withId = createDraftTable("users");
assert.equal(withId.origin, "draft");
assert.equal(isDraftTable(withId), true);
assert.equal(withId.columns.length, 1);
assert.equal(withId.columns[0].name, "id");
assert.equal(withId.columns[0].is_primary_key, true);
const empty = createDraftTable("orders", { withDefaultId: false });
assert.equal(empty.columns.length, 0);
assert.deepEqual(validateDraftTable(empty), ['Table "orders" needs at least one column']);
});
test("validateDraftTable catches duplicates", () => {
const table = createDraftTable("t", { withDefaultId: false });
table.columns = [createEmptyColumn("a"), createEmptyColumn("a")];
const errors = validateDraftTable(table);
assert.ok(errors.some((e) => e.includes("duplicate")));
});
test("nextUniqueColumnName increments", () => {
assert.equal(nextUniqueColumnName([{ name: "column_1", data_type: "int", is_nullable: true, column_default: null, is_primary_key: false, extra: null }]), "column_2");
});
test("createDraftIndex generates unique names", () => {
const first = createDraftIndex("users", ["email"]);
const second = createDraftIndex("users", ["email"], [first]);
assert.ok(first.name);
assert.ok(second.name);
assert.notEqual(first.name, second.name);
assert.deepEqual(first.columns, ["email"]);
});
test("validateDraftTable catches empty name and index errors", () => {
const table = createDraftTable(" ", { withDefaultId: false });
table.columns = [createEmptyColumn("id")];
const emptyNameIndex: EditableStructureIndex = {
id: "i1",
name: " ",
columns: [],
isUnique: false,
isPrimary: false,
filter: "",
indexType: "",
includedColumns: [],
comment: "",
markedForDrop: false,
};
const missingColIndex: EditableStructureIndex = {
...emptyNameIndex,
id: "i2",
name: "idx_missing",
columns: ["nope"],
};
table.indexes = [emptyNameIndex, missingColIndex];
const errors = validateDraftTable(table);
assert.ok(errors.some((e) => e.includes("Table name is required")));
assert.ok(errors.some((e) => e.includes("empty name")));
assert.ok(errors.some((e) => e.includes("needs at least one column")));
assert.ok(errors.some((e) => e.includes("missing column")));
});
test("draftTableToCreateSqlOptions shape", () => {
const table = createDraftTable("users");
table.indexes = [createDraftIndex("users", ["id"])];
const options = draftTableToCreateSqlOptions(table, "postgres", "public");
assert.equal(options.tableName, "users");
assert.equal(options.schema, "public");
assert.equal(options.databaseType, "postgres");
assert.equal(options.columns.length, 1);
assert.equal(options.indexes.length, 1);
assert.deepEqual(options.foreignKeys, []);
});

View File

@ -0,0 +1,224 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { Position } from "@vue-flow/core";
import {
alignWaypointsToEndpoints,
collapseColinearPoints,
dedupePoints,
handlesFromWaypoints,
pathHitsObstacles,
pathSkimsEndpoints,
pointsToSvgPath,
polylineLength,
routeOrthogonalAroundObstacles,
type ObstacleRect,
} from "../../apps/desktop/src/lib/diagram/edge-obstacle-router.ts";
import { EDGE_ROUTE_OFFSET } from "../../apps/desktop/src/lib/diagram/diagram-constants.ts";
test("pointsToSvgPath and dedupePoints", () => {
assert.equal(pointsToSvgPath([]), "");
assert.equal(pointsToSvgPath([{ x: 1, y: 2 }, { x: 3, y: 4 }]), "M1,2 L3,4");
assert.deepEqual(
dedupePoints([
{ x: 0, y: 0 },
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10.2, y: 0 },
]),
[
{ x: 0, y: 0 },
{ x: 10, y: 0 },
],
);
});
test("collapseColinearPoints removes middle points on a straight run", () => {
assert.deepEqual(
collapseColinearPoints([
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 20, y: 0 },
{ x: 20, y: 10 },
]),
[
{ x: 0, y: 0 },
{ x: 20, y: 0 },
{ x: 20, y: 10 },
],
);
});
test("pathHitsObstacles detects crossing segments", () => {
const obstacle: ObstacleRect = {
id: "block",
x: 40,
y: 40,
width: 100,
height: 100,
kind: "table",
};
assert.equal(
pathHitsObstacles(
[
{ x: 0, y: 90 },
{ x: 200, y: 90 },
],
[obstacle],
),
true,
);
assert.equal(
pathHitsObstacles(
[
{ x: 0, y: 10 },
{ x: 200, y: 10 },
],
[obstacle],
),
false,
);
});
test("pathSkimsEndpoints detects middle segment on table border", () => {
const tall: ObstacleRect = { id: "tall", x: 0, y: 0, width: 360, height: 600, kind: "table" };
assert.equal(
pathSkimsEndpoints(
[
{ x: 360, y: 100 },
{ x: 396, y: 100 },
{ x: 360, y: 100 },
{ x: 360, y: 400 },
{ x: 396, y: 400 },
{ x: 500, y: 400 },
],
[tall],
),
true,
);
assert.equal(
pathSkimsEndpoints(
[
{ x: 360, y: 100 },
{ x: 396, y: 100 },
{ x: 396, y: 400 },
{ x: 464, y: 400 },
{ x: 500, y: 400 },
],
[tall],
),
false,
);
});
test("routeOrthogonalAroundObstacles returns a clear orthogonal path", () => {
const obstacle: ObstacleRect = {
id: "mid",
x: 80,
y: 40,
width: 40,
height: 40,
kind: "table",
};
const path = routeOrthogonalAroundObstacles({
source: { x: 0, y: 0 },
target: { x: 200, y: 0 },
sourcePosition: Position.Right,
targetPosition: Position.Left,
obstacles: [obstacle],
endpointIds: ["a", "b"],
});
assert.ok(path);
assert.ok(path!.length >= 2);
assert.equal(pathHitsObstacles(path!, [obstacle]), false);
});
test("routeOrthogonalAroundObstacles prefers short stubbed corridor over far detour", () => {
const path = routeOrthogonalAroundObstacles({
source: { x: 0, y: 0 },
target: { x: 100, y: 50 },
sourcePosition: Position.Right,
targetPosition: Position.Left,
obstacles: [],
endpointIds: ["a", "b"],
});
assert.ok(path);
const farDetourLen = polylineLength([
{ x: 0, y: 0 },
{ x: EDGE_ROUTE_OFFSET, y: 0 },
{ x: 100 + EDGE_ROUTE_OFFSET, y: 0 },
{ x: 100 + EDGE_ROUTE_OFFSET, y: 50 },
{ x: 100 - EDGE_ROUTE_OFFSET, y: 50 },
{ x: 100, y: 50 },
]);
assert.ok(polylineLength(path!) < farDetourLen);
});
test("routeOrthogonalAroundObstacles does not skim tall endpoint table border", () => {
const tall: ObstacleRect = { id: "tall", x: 0, y: 0, width: 360, height: 600, kind: "table" };
const other: ObstacleRect = { id: "other", x: 500, y: 400, width: 360, height: 120, kind: "table" };
const rightEdge = 360;
const path = routeOrthogonalAroundObstacles({
source: { x: rightEdge, y: 120 },
target: { x: 500, y: 460 },
sourcePosition: Position.Right,
targetPosition: Position.Left,
obstacles: [tall, other],
endpointIds: ["tall", "other"],
offset: EDGE_ROUTE_OFFSET,
});
assert.ok(path);
assert.equal(pathSkimsEndpoints(path!, [tall, other]), false);
for (let i = 1; i < path!.length - 2; i++) {
const a = path![i];
const b = path![i + 1];
const verticalOnBorder = Math.abs(a.x - rightEdge) <= 0.5 && Math.abs(b.x - rightEdge) <= 0.5;
const run = Math.abs(a.y - b.y);
assert.ok(!(verticalOnBorder && run > EDGE_ROUTE_OFFSET), `middle segment skims right edge: ${JSON.stringify([a, b])}`);
}
});
test("alignWaypointsToEndpoints rejects paths that skim endpoints", () => {
const tall: ObstacleRect = { id: "tall", x: 0, y: 0, width: 360, height: 600, kind: "table" };
const other: ObstacleRect = { id: "other", x: 500, y: 400, width: 360, height: 120, kind: "table" };
const aligned = alignWaypointsToEndpoints(
[
{ x: 360, y: 120 },
{ x: 360, y: 460 },
{ x: 500, y: 460 },
],
360,
120,
500,
460,
{ obstacles: [tall, other], endpointIds: ["tall", "other"] },
);
assert.equal(aligned, null);
});
test("handlesFromWaypoints and alignWaypointsToEndpoints", () => {
const handles = handlesFromWaypoints([
{ x: 0, y: 50 },
{ x: 40, y: 50 },
{ x: 40, y: 100 },
{ x: 120, y: 100 },
]);
assert.deepEqual(handles, { sourceHandle: "right", targetHandle: "left-target" });
assert.equal(handlesFromWaypoints([{ x: 0, y: 0 }]), null);
const aligned = alignWaypointsToEndpoints(
[
{ x: 0, y: 0 },
{ x: 50, y: 0 },
{ x: 50, y: 80 },
{ x: 100, y: 80 },
],
10,
20,
200,
90,
);
assert.ok(aligned);
assert.deepEqual(aligned![0], { x: 10, y: 20 });
assert.deepEqual(aligned![aligned!.length - 1], { x: 200, y: 90 });
});

View File

@ -0,0 +1,61 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { computeLayoutWithLayers } from "../../apps/desktop/src/lib/diagram/elk-layout.ts";
import type { DiagramEdge, DiagramLayer, DiagramNode } from "../../apps/desktop/src/types/diagram.ts";
import type { DiagramTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts";
function table(name: string): DiagramTable {
return {
name,
columns: [
{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
],
foreignKeys: [],
};
}
test("computeLayoutWithLayers returns finite coords and layerLayouts for nested auto layer", async () => {
const nodes: DiagramNode[] = [
{ id: "users", type: "table", position: { x: 0, y: 0 }, data: { table: table("users") } },
{ id: "orders", type: "table", position: { x: 0, y: 0 }, data: { table: table("orders") } },
];
const edges: DiagramEdge[] = [
{ id: "e1", source: "orders", target: "users", sourceHandle: "right", targetHandle: "left-target" },
];
const layers: DiagramLayer[] = [
{
id: "layer-1",
name: "Core",
color: "#3b82f6",
tableNames: ["users", "orders"],
collapsed: false,
visible: true,
layoutMode: "auto",
position: { x: 0, y: 0 },
width: 240,
height: 52,
},
];
const result = await computeLayoutWithLayers(nodes, edges, layers);
assert.equal(result.nodes.length, 2);
assert.equal(result.layerLayouts.length, 1);
assert.equal(result.layerLayouts[0].layerId, "layer-1");
assert.ok(Number.isFinite(result.layerLayouts[0].x));
assert.ok(Number.isFinite(result.layerLayouts[0].width));
assert.ok(result.layerLayouts[0].width > 0);
assert.ok(result.layerLayouts[0].height > 0);
for (const node of result.nodes) {
assert.ok(Number.isFinite(node.position.x));
assert.ok(Number.isFinite(node.position.y));
}
const layer = result.layerLayouts[0];
for (const node of result.nodes) {
assert.ok(node.position.x >= layer.x - 1, `${node.id} x outside layer`);
assert.ok(node.position.y >= layer.y - 1, `${node.id} y outside layer`);
assert.ok(node.position.x <= layer.x + layer.width + 1, `${node.id} exceeds layer width`);
assert.ok(node.position.y <= layer.y + layer.height + 1, `${node.id} exceeds layer height`);
}
});

View File

@ -0,0 +1,103 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import {
filterAssignableDiagramTables,
hasDroppedColumns,
hasPendingColumns,
isDiagramTableAssignable,
isDraftTable,
isDroppedColumn,
isLiveTable,
isPendingColumn,
needsDiagramSync,
type DiagramTable,
} from "../../apps/desktop/src/lib/diagram/erDiagram.ts";
import type { ColumnInfo } from "../../apps/desktop/src/types/database.ts";
function col(name: string): ColumnInfo {
return {
name,
data_type: "varchar(255)",
is_nullable: true,
column_default: null,
is_primary_key: false,
extra: null,
};
}
function table(partial: Partial<DiagramTable> & { name: string }): DiagramTable {
return {
columns: [],
foreignKeys: [],
...partial,
};
}
test("isDraftTable / isLiveTable treat missing origin as live", () => {
const draft = table({ name: "d", origin: "draft" });
const live = table({ name: "l", origin: "live" });
const legacy = table({ name: "x" });
assert.equal(isDraftTable(draft), true);
assert.equal(isLiveTable(draft), false);
assert.equal(isDraftTable(live), false);
assert.equal(isLiveTable(live), true);
assert.equal(isDraftTable(legacy), false);
assert.equal(isLiveTable(legacy), true);
});
test("hasPendingColumns / isPendingColumn", () => {
const withPending = table({
name: "users",
origin: "live",
columns: [col("id"), col("nickname")],
pendingColumnNames: ["nickname"],
});
assert.equal(hasPendingColumns(withPending), true);
assert.equal(isPendingColumn(withPending, "nickname"), true);
assert.equal(isPendingColumn(withPending, "id"), false);
const empty = table({ name: "users", origin: "live", pendingColumnNames: [] });
assert.equal(hasPendingColumns(empty), false);
assert.equal(hasPendingColumns(table({ name: "users" })), false);
});
test("needsDiagramSync for draft and live pending", () => {
assert.equal(needsDiagramSync(table({ name: "d", origin: "draft" })), true);
assert.equal(
needsDiagramSync(table({ name: "l", origin: "live", pendingColumnNames: ["x"], columns: [col("x")] })),
true,
);
assert.equal(needsDiagramSync(table({ name: "l", origin: "live" })), false);
assert.equal(needsDiagramSync(table({ name: "legacy" })), false);
});
test("hasDroppedColumns / isDroppedColumn / needsDiagramSync for drops", () => {
const withDrop = table({
name: "users",
origin: "live",
columns: [col("id"), col("nickname")],
droppedColumnNames: ["nickname"],
});
assert.equal(hasDroppedColumns(withDrop), true);
assert.equal(isDroppedColumn(withDrop, "nickname"), true);
assert.equal(isDroppedColumn(withDrop, "id"), false);
assert.equal(needsDiagramSync(withDrop), true);
const pendingDrop = table({ name: "orders", origin: "live", pendingDrop: true });
assert.equal(needsDiagramSync(pendingDrop), true);
});
test("filterAssignableDiagramTables excludes pendingDrop tables", () => {
const live = table({ name: "users", origin: "live" });
const pendingDrop = table({ name: "orders", origin: "live", pendingDrop: true });
const draft = table({ name: "draft_t", origin: "draft" });
assert.equal(isDiagramTableAssignable(live), true);
assert.equal(isDiagramTableAssignable(pendingDrop), false);
assert.equal(isDiagramTableAssignable(draft), true);
assert.deepEqual(
filterAssignableDiagramTables([live, pendingDrop, draft]).map((t) => t.name),
["users", "draft_t"],
);
});

View File

@ -0,0 +1,165 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { buildDiagramDbml, buildDiagramJson, buildDiagramMermaid, diagramExportDialogFilter, diagramExportFileName, type DiagramJsonSnapshot } from "../../apps/desktop/src/lib/export/diagramFormats.ts";
import { buildDiagramRelationships, type DiagramRelationship, type DiagramTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts";
const tables: DiagramTable[] = [
{
name: "users",
columns: [
{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "created at", data_type: "timestamp with time zone", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
],
foreignKeys: [],
},
{
name: "order-items",
columns: [
{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
],
foreignKeys: [],
},
];
function rel(partial: Pick<DiagramRelationship, "sourceCardinality" | "targetCardinality"> & Partial<Pick<DiagramRelationship, "id" | "sourceTable" | "sourceColumn" | "targetTable" | "targetColumn">>): DiagramRelationship {
return {
id: partial.id ?? "rel-1",
sourceTable: partial.sourceTable ?? "users",
sourceColumn: partial.sourceColumn ?? "id",
targetTable: partial.targetTable ?? "order-items",
targetColumn: partial.targetColumn ?? "user_id",
sourceCardinality: partial.sourceCardinality,
targetCardinality: partial.targetCardinality,
};
}
test("diagramExportFileName builds safe names for each format and mode", () => {
assert.equal(diagramExportFileName("", "", "table", "svg"), "dbx-diagram-table-structure.svg");
assert.equal(diagramExportFileName("prod/main", "billing db", "engineering", "png"), "dbx-prod-main-billing-db-engineering-er.png");
assert.equal(diagramExportFileName("a", "b", "table", "json"), "dbx-a-b-diagram.json");
assert.equal(diagramExportFileName("a", "b", "table", "dbml"), "dbx-a-b-schema.dbml");
assert.equal(diagramExportFileName("a", "b", "engineering", "mermaid"), "dbx-a-b-er.mmd");
});
test("buildDiagramJson pretty-prints with trailing newline", () => {
const snapshot: DiagramJsonSnapshot = {
meta: {
connectionName: "local",
database: "app",
schema: "public",
mode: "table",
exportedAt: "2026-01-01T00:00:00.000Z",
},
tables: [],
relationships: [],
positions: {},
layers: [],
customRelationships: [],
matchConfirms: [],
matchIgnores: [],
};
const text = buildDiagramJson(snapshot);
assert.ok(text.endsWith("\n"));
assert.deepEqual(JSON.parse(text), snapshot);
});
test("buildDiagramJson round-trips non-empty layers", () => {
const snapshot: DiagramJsonSnapshot = {
meta: {
connectionName: "local",
database: "app",
schema: "public",
mode: "table",
exportedAt: "2026-01-01T00:00:00.000Z",
},
tables: [],
relationships: [],
positions: {},
layers: [
{
id: "layer-1",
name: "Core",
color: "#3b82f6",
tableNames: ["users"],
collapsed: false,
visible: true,
layoutMode: "auto",
position: { x: 40, y: 40 },
width: 400,
height: 240,
},
],
customRelationships: [],
matchConfirms: [],
matchIgnores: [],
};
const parsed = JSON.parse(buildDiagramJson(snapshot)) as DiagramJsonSnapshot;
assert.equal(parsed.layers.length, 1);
assert.deepEqual(parsed.layers[0], snapshot.layers[0]);
});
test("buildDiagramDbml emits tables, quoted types, and Ref operators", () => {
const dbml = buildDiagramDbml(tables, [rel({ sourceCardinality: "1", targetCardinality: "1" }), rel({ id: "r2", sourceCardinality: "N", targetCardinality: "1" }), rel({ id: "r3", sourceCardinality: "1", targetCardinality: "N" }), rel({ id: "r4", sourceCardinality: "N", targetCardinality: "N" })]);
assert.match(dbml, /Table users \{/);
assert.match(dbml, /id bigint \[pk, not null\]/);
assert.match(dbml, /"created at" "timestamp with time zone"/);
assert.match(dbml, /Table "order-items"/);
assert.match(dbml, /Ref: users\.id - "order-items"\.user_id/);
assert.match(dbml, /Ref: users\.id > "order-items"\.user_id/);
assert.match(dbml, /Ref: users\.id < "order-items"\.user_id/);
assert.match(dbml, /Ref: users\.id <> "order-items"\.user_id/);
});
test("buildDiagramMermaid emits erDiagram entities, PK markers, and cardinalities", () => {
const relationships = buildDiagramRelationships([
tables[0],
{
...tables[1],
name: "orders",
foreignKeys: [{ name: "fk", column: "user_id", ref_table: "users", ref_column: "id" }],
},
]);
const mermaid = buildDiagramMermaid([tables[0], { ...tables[1], name: "orders", foreignKeys: [{ name: "fk", column: "user_id", ref_table: "users", ref_column: "id" }] }], relationships);
assert.ok(mermaid.startsWith("erDiagram\n"));
assert.match(mermaid, /bigint id PK/);
assert.match(mermaid, /\}o--\|\|/);
});
test("buildDiagramMermaid maps all cardinality pairs", () => {
const oneOne = buildDiagramMermaid(tables, [rel({ sourceCardinality: "1", targetCardinality: "1" })]);
const oneN = buildDiagramMermaid(tables, [rel({ sourceCardinality: "1", targetCardinality: "N" })]);
const nOne = buildDiagramMermaid(tables, [rel({ sourceCardinality: "N", targetCardinality: "1" })]);
const nN = buildDiagramMermaid(tables, [rel({ sourceCardinality: "N", targetCardinality: "N" })]);
assert.match(oneOne, /\|\|--\|\|/);
assert.match(oneN, /\|\|--o\{/);
assert.match(nOne, /\}o--\|\|/);
assert.match(nN, /\}o--o\{/);
});
test("buildDiagramRelationships unique FK exports as 1:1 in Mermaid/DBML", () => {
const profileTables: DiagramTable[] = [
tables[0],
{
name: "profiles",
columns: [{ name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
foreignKeys: [{ name: "profiles_user_fk", column: "user_id", ref_table: "users", ref_column: "id" }],
},
];
const relationships = buildDiagramRelationships(profileTables);
assert.equal(relationships[0].sourceCardinality, "1");
assert.equal(relationships[0].targetCardinality, "1");
assert.match(buildDiagramMermaid(profileTables, relationships), /\|\|--\|\|/);
assert.match(buildDiagramDbml(profileTables, relationships), /Ref: profiles\.user_id - users\.id/);
});
test("diagramExportDialogFilter returns expected extensions", () => {
assert.deepEqual(diagramExportDialogFilter("svg").extensions, ["svg"]);
assert.deepEqual(diagramExportDialogFilter("png").extensions, ["png"]);
assert.deepEqual(diagramExportDialogFilter("json").extensions, ["json"]);
assert.deepEqual(diagramExportDialogFilter("dbml").extensions, ["dbml"]);
assert.deepEqual(diagramExportDialogFilter("mermaid").extensions, ["mmd", "md"]);
});

View File

@ -0,0 +1,130 @@
import { strict as assert } from "node:assert";
import { beforeEach, test } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { useGraphStore } from "../../apps/desktop/src/lib/diagram/graph-store.ts";
import type { HistorySnapshot } from "../../apps/desktop/src/types/diagram.ts";
import type { DiagramTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts";
function emptyTable(name: string): DiagramTable {
return { name, columns: [], foreignKeys: [] };
}
function snapshot(partial: Partial<HistorySnapshot> = {}): HistorySnapshot {
return {
nodes: [
{
id: "users",
type: "table",
position: { x: 10, y: 20 },
data: { table: emptyTable("users") },
},
],
edges: [],
positions: { users: { x: 10, y: 20 } },
layers: [
{
id: "l1",
name: "Core",
color: "#3b82f6",
tableNames: ["users"],
collapsed: false,
visible: true,
layoutMode: "auto",
position: { x: 0, y: 0 },
width: 200,
height: 100,
},
],
tables: [emptyTable("users")],
customRelationships: [
{
id: "custom-1",
name: "c",
sourceTable: "users",
sourceColumn: "id",
targetTable: "orders",
targetColumn: "user_id",
sourceCardinality: "1",
targetCardinality: "N",
},
],
edgeWaypoints: { "e-1": [{ x: 0, y: 0 }, { x: 10, y: 10 }] },
edgeHandleHints: { "e-1": { sourceHandle: "right", targetHandle: "left-target" } },
matchConfirms: ["rel-a"],
matchIgnores: ["rel-b"],
...partial,
};
}
beforeEach(() => {
setActivePinia(createPinia());
});
test("pushHistory enables undo and clears redo", () => {
const store = useGraphStore();
assert.equal(store.canUndo, false);
store.pushHistory(snapshot());
assert.equal(store.canUndo, true);
const current = snapshot({ positions: { users: { x: 99, y: 99 } } });
const undone = store.undo(current);
assert.ok(undone);
assert.deepEqual(undone!.positions.users, { x: 10, y: 20 });
assert.equal(store.canRedo, true);
store.pushHistory(snapshot({ positions: { users: { x: 1, y: 1 } } }));
assert.equal(store.canRedo, false);
});
test("undo/redo round-trips snapshot extras", () => {
const store = useGraphStore();
const older = snapshot({
positions: { users: { x: 1, y: 1 } },
matchConfirms: ["old"],
});
const newer = snapshot({
positions: { users: { x: 2, y: 2 } },
matchConfirms: ["new"],
layers: [],
});
store.pushHistory(older);
const afterUndo = store.undo(newer);
assert.ok(afterUndo);
assert.deepEqual(afterUndo!.positions.users, { x: 1, y: 1 });
assert.deepEqual(afterUndo!.layers[0]?.tableNames, ["users"]);
assert.deepEqual(afterUndo!.edgeWaypoints["e-1"]?.length, 2);
assert.deepEqual(afterUndo!.customRelationships[0]?.id, "custom-1");
assert.deepEqual(afterUndo!.matchConfirms, ["old"]);
assert.deepEqual(afterUndo!.matchIgnores, ["rel-b"]);
assert.ok(afterUndo!.edgeHandleHints["e-1"]);
const afterRedo = store.redo(afterUndo!);
assert.ok(afterRedo);
assert.deepEqual(afterRedo!.positions.users, { x: 2, y: 2 });
assert.deepEqual(afterRedo!.matchConfirms, ["new"]);
assert.deepEqual(afterRedo!.layers, []);
});
test("undo/redo round-trips tables field", () => {
const store = useGraphStore();
const olderTables = [emptyTable("users"), emptyTable("draft_a")];
const newerTables = [emptyTable("users"), emptyTable("draft_b")];
const older = snapshot({ tables: olderTables, positions: { users: { x: 1, y: 1 } } });
const newer = snapshot({ tables: newerTables, positions: { users: { x: 2, y: 2 } } });
store.pushHistory(older);
const afterUndo = store.undo(newer);
assert.ok(afterUndo);
assert.deepEqual(
afterUndo!.tables.map((t) => t.name),
["users", "draft_a"],
);
const afterRedo = store.redo(afterUndo!);
assert.ok(afterRedo);
assert.deepEqual(
afterRedo!.tables.map((t) => t.name),
["users", "draft_b"],
);
});

160
packages/app-tests/diagramLayer.test.ts vendored Normal file
View File

@ -0,0 +1,160 @@
import { strict as assert } from "node:assert";
import { beforeEach, test } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { useLayerStore } from "../../apps/desktop/src/lib/diagram/layer-store.ts";
import { findLayerAtPoint, placeNewLayer, sizeLayerToFit } from "../../apps/desktop/src/lib/diagram/size-layer.ts";
import { CARD_WIDTH, EMPTY_LAYER_HEIGHT, EMPTY_LAYER_WIDTH, LAYER_CONTENT_PADDING, LAYER_HEADER_HEIGHT, MARGIN } from "../../apps/desktop/src/lib/diagram/diagram-constants.ts";
import type { DiagramLayer } from "../../apps/desktop/src/types/diagram.ts";
import { LAYER_COLORS } from "../../apps/desktop/src/types/diagram.ts";
beforeEach(() => {
setActivePinia(createPinia());
});
test("addLayer defaults visible, auto layout, Layer N name, and unique colors", () => {
const store = useLayerStore();
const a = store.addLayer();
const b = store.addLayer();
assert.equal(a.visible, true);
assert.equal(a.layoutMode, "auto");
assert.equal(a.name, "Layer 1");
assert.equal(b.name, "Layer 2");
assert.notEqual(a.color, b.color);
assert.ok(LAYER_COLORS.includes(a.color));
assert.equal(store.activeLayerId, b.id);
});
test("addLayer collapses all existing layers", () => {
const store = useLayerStore();
const a = store.addLayer("A");
assert.equal(a.collapsed, false);
const b = store.addLayer("B");
assert.equal(store.layers.find((l) => l.id === a.id)?.collapsed, true);
assert.equal(b.collapsed, false);
store.addLayer("C");
assert.equal(store.layers.find((l) => l.id === a.id)?.collapsed, true);
assert.equal(store.layers.find((l) => l.id === b.id)?.collapsed, true);
});
test("moveTableToLayer enforces single-layer membership", () => {
const store = useLayerStore();
const layerA = store.addLayer("A");
const layerB = store.addLayer("B");
store.addTableToLayer(layerA.id, "users");
store.moveTableToLayer("users", layerB.id);
assert.deepEqual(store.getLayerByTable("users")?.id, layerB.id);
assert.ok(!layerA.tableNames.includes("users"));
assert.ok(layerB.tableNames.includes("users"));
});
test("removeTableFromLayer, setLayoutMode, geometry, and visibility toggle", () => {
const store = useLayerStore();
const layer = store.addLayer("Core", { x: 10, y: 20 }, { width: 300, height: 100 });
store.addTableToLayer(layer.id, "orders");
store.setLayoutMode(layer.id, "free");
store.updateLayerGeometry(layer.id, { position: { x: 50, y: 60 }, width: 400, height: 200 });
store.toggleLayerVisibility(layer.id);
store.removeTableFromLayer(layer.id, "orders");
const current = store.layers.find((l) => l.id === layer.id)!;
assert.equal(current.layoutMode, "free");
assert.deepEqual(current.position, { x: 50, y: 60 });
assert.equal(current.width, 400);
assert.equal(current.height, 200);
assert.equal(current.visible, false);
assert.deepEqual(current.tableNames, []);
});
test("sizeLayerToFit uses empty size or wraps table bbox with padding", () => {
const empty: DiagramLayer = {
id: "l0",
name: "Empty",
color: "#3b82f6",
tableNames: [],
collapsed: false,
visible: true,
layoutMode: "auto",
position: { x: 0, y: 0 },
width: 1,
height: 1,
};
sizeLayerToFit(empty, {}, {});
assert.equal(empty.width, EMPTY_LAYER_WIDTH);
assert.equal(empty.height, EMPTY_LAYER_HEIGHT);
const filled: DiagramLayer = {
...empty,
id: "l1",
tableNames: ["users", "orders"],
};
sizeLayerToFit(
filled,
{ users: { x: 100, y: 100 }, orders: { x: 200, y: 180 } },
{ users: 120, orders: 140 },
);
assert.equal(filled.position?.x, 100 - LAYER_CONTENT_PADDING);
assert.equal(filled.position?.y, 100 - LAYER_HEADER_HEIGHT - LAYER_CONTENT_PADDING);
assert.ok((filled.width ?? 0) >= CARD_WIDTH + LAYER_CONTENT_PADDING * 2);
assert.ok((filled.height ?? 0) >= EMPTY_LAYER_HEIGHT);
});
test("findLayerAtPoint returns topmost hit and placeNewLayer avoids overlap", () => {
const layers: DiagramLayer[] = [
{
id: "bottom",
name: "Bottom",
color: "#3b82f6",
tableNames: [],
collapsed: false,
visible: true,
layoutMode: "auto",
position: { x: 0, y: 0 },
width: 200,
height: 100,
},
{
id: "top",
name: "Top",
color: "#10b981",
tableNames: [],
collapsed: false,
visible: true,
layoutMode: "auto",
position: { x: 50, y: 20 },
width: 200,
height: 100,
},
];
assert.equal(findLayerAtPoint({ x: 60, y: 30 }, layers)?.id, "top");
assert.equal(findLayerAtPoint({ x: 10, y: 10 }, layers)?.id, "bottom");
assert.equal(findLayerAtPoint({ x: 60, y: 30 }, layers, "top")?.id, "bottom");
const placement = placeNewLayer(layers, {}, {});
assert.equal(placement.width, EMPTY_LAYER_WIDTH);
assert.equal(placement.height, EMPTY_LAYER_HEIGHT);
assert.ok(placement.position.x >= MARGIN);
assert.equal(
findLayerAtPoint(
{ x: placement.position.x + 1, y: placement.position.y + 1 },
[
...layers,
{
id: "new",
name: "New",
color: "#000",
tableNames: [],
collapsed: false,
visible: true,
layoutMode: "auto",
position: placement.position,
width: placement.width,
height: placement.height,
},
],
)?.id,
"new",
);
});

View File

@ -0,0 +1,175 @@
import { strict as assert } from "node:assert";
import { beforeEach, test, vi } from "vitest";
import {
applyLiveTablePatches,
loadLiveTablePatches,
saveLiveTablePatches,
} from "../../apps/desktop/src/lib/diagram/draft-storage.ts";
import { liveTableToAlterSqlOptions, validateLivePendingColumns } from "../../apps/desktop/src/lib/diagram/draft-table.ts";
import type { DiagramTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts";
import type { ColumnInfo } from "../../apps/desktop/src/types/database.ts";
const store = new Map<string, string>();
beforeEach(() => {
store.clear();
vi.stubGlobal("localStorage", {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
});
});
function col(name: string, dataType = "varchar(255)"): ColumnInfo {
return {
name,
data_type: dataType,
is_nullable: true,
column_default: null,
is_primary_key: false,
extra: null,
};
}
function liveTable(name: string, columns: ColumnInfo[], pendingColumnNames?: string[]): DiagramTable {
return {
name,
columns,
foreignKeys: [],
origin: "live",
pendingColumnNames,
};
}
test("save/load live patches round-trip", () => {
const tables = [
liveTable("users", [col("id", "bigint"), col("nickname")], ["nickname"]),
];
saveLiveTablePatches(tables, "c1", "db", "public");
const loaded = loadLiveTablePatches("c1", "db", "public");
assert.equal(loaded.length, 1);
assert.equal(loaded[0].tableName, "users");
assert.equal(loaded[0].pendingColumns.length, 1);
assert.equal(loaded[0].pendingColumns[0].name, "nickname");
});
test("applyLiveTablePatches merges missing pending columns", () => {
const tables = [liveTable("users", [col("id", "bigint")])];
const merged = applyLiveTablePatches(tables, [
{ tableName: "users", pendingColumns: [col("nickname")] },
]);
assert.equal(merged[0].columns.length, 2);
assert.deepEqual(merged[0].pendingColumnNames, ["nickname"]);
});
test("applyLiveTablePatches skips columns already in DB", () => {
const tables = [liveTable("users", [col("id", "bigint"), col("nickname")])];
const merged = applyLiveTablePatches(tables, [
{ tableName: "users", pendingColumns: [col("nickname")] },
]);
assert.equal(merged[0].columns.length, 2);
assert.equal(merged[0].pendingColumnNames, undefined);
});
test("liveTableToAlterSqlOptions marks only pending without original", () => {
const table = liveTable("users", [col("id", "bigint"), col("nickname")], ["nickname"]);
const options = liveTableToAlterSqlOptions(table, "postgres", "public");
assert.ok(options.columns[0].original);
assert.equal(options.columns[1].original, undefined);
});
test("validateLivePendingColumns catches empty type", () => {
const table = liveTable("users", [col("id", "bigint"), { ...col("x"), data_type: "" }], ["x"]);
const errors = validateLivePendingColumns(table);
assert.ok(errors.some((e) => e.includes("needs a type")));
});
test("validateLivePendingColumns catches missing, duplicate, and conflict", () => {
const missing = liveTable("users", [col("id")], ["ghost"]);
assert.ok(validateLivePendingColumns(missing).some((e) => e.includes("is missing")));
// Pending "name" collides with existing non-pending "Name" (case-insensitive).
const conflict = liveTable("users", [col("id"), col("Name"), col("name")], ["name"]);
assert.ok(validateLivePendingColumns(conflict).some((e) => e.includes("conflicts")));
const duplicate = liveTable("users", [col("id"), col("nick")], ["nick", "nick"]);
assert.ok(validateLivePendingColumns(duplicate).some((e) => e.includes("duplicate")));
});
test("saveLiveTablePatches ignores draft tables", () => {
const draft: DiagramTable = {
name: "draft_t",
columns: [col("id")],
foreignKeys: [],
origin: "draft",
pendingColumnNames: ["id"],
};
const live = liveTable("users", [col("id"), col("nickname")], ["nickname"]);
saveLiveTablePatches([draft, live], "c1", "db", "public");
const loaded = loadLiveTablePatches("c1", "db", "public");
assert.equal(loaded.length, 1);
assert.equal(loaded[0].tableName, "users");
});
test("applyLiveTablePatches skips case-insensitive name collisions", () => {
const tables = [liveTable("users", [col("ID", "bigint")])];
const merged = applyLiveTablePatches(tables, [
{ tableName: "users", pendingColumns: [col("id", "bigint"), col("nickname")] },
]);
assert.equal(merged[0].columns.length, 2);
assert.deepEqual(merged[0].pendingColumnNames, ["nickname"]);
});
test("save/load persists dropped columns and pendingDrop", () => {
const tables: DiagramTable[] = [
{
...liveTable("users", [col("id"), col("nickname")]),
droppedColumnNames: ["nickname"],
},
{
...liveTable("orders", [col("id")]),
pendingDrop: true,
},
];
saveLiveTablePatches(tables, "c1", "db", "public");
const loaded = loadLiveTablePatches("c1", "db", "public");
assert.equal(loaded.length, 2);
const users = loaded.find((p) => p.tableName === "users");
const orders = loaded.find((p) => p.tableName === "orders");
assert.deepEqual(users?.droppedColumnNames, ["nickname"]);
assert.equal(orders?.pendingDrop, true);
});
test("applyLiveTablePatches restores dropped columns and pendingDrop", () => {
const tables = [liveTable("users", [col("id"), col("nickname")]), liveTable("orders", [col("id")])];
const merged = applyLiveTablePatches(tables, [
{ tableName: "users", pendingColumns: [], droppedColumnNames: ["nickname"] },
{ tableName: "orders", pendingColumns: [], pendingDrop: true },
]);
assert.deepEqual(merged[0].droppedColumnNames, ["nickname"]);
assert.equal(merged[1].pendingDrop, true);
});
test("liveTableToAlterSqlOptions marks dropped columns for drop", () => {
const table: DiagramTable = {
...liveTable("users", [col("id", "bigint"), col("nickname")]),
droppedColumnNames: ["nickname"],
};
const options = liveTableToAlterSqlOptions(table, "postgres", "public");
assert.equal(options.columns[0].markedForDrop, false);
assert.ok(options.columns[0].original);
assert.equal(options.columns[1].markedForDrop, true);
assert.ok(options.columns[1].original);
});
test("validateLivePendingColumns catches missing dropped column", () => {
const table: DiagramTable = {
...liveTable("users", [col("id")]),
droppedColumnNames: ["ghost"],
};
assert.ok(validateLivePendingColumns(table).some((e) => e.includes("dropped column") && e.includes("is missing")));
});

View File

@ -0,0 +1,140 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import {
computeLtrAutoLayout,
orderTablesByConnectivity,
reflowUnassignedTables,
} from "../../apps/desktop/src/lib/diagram/ltr-auto-layout.ts";
import { CARD_WIDTH } from "../../apps/desktop/src/lib/diagram/diagram-constants.ts";
import type { DiagramTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts";
import type { DiagramLayer } from "../../apps/desktop/src/types/diagram.ts";
function table(name: string, columns = 2): DiagramTable {
return {
name,
columns: Array.from({ length: columns }, (_, i) => ({
name: i === 0 ? "id" : `c${i}`,
data_type: "bigint",
is_nullable: false,
column_default: null,
is_primary_key: i === 0,
extra: null,
})),
foreignKeys: [],
};
}
test("orderTablesByConnectivity keeps connected tables adjacent", () => {
const tables = [table("zeta"), table("users"), table("orders"), table("alpha")];
const ordered = orderTablesByConnectivity(tables, [
{ sourceTable: "orders", targetTable: "users" },
]);
const names = ordered.map((t) => t.name);
const usersIdx = names.indexOf("users");
const ordersIdx = names.indexOf("orders");
assert.equal(Math.abs(usersIdx - ordersIdx), 1);
assert.ok(names.includes("alpha"));
assert.ok(names.includes("zeta"));
});
test("computeLtrAutoLayout places tables without overlap and respects CARD_WIDTH", () => {
const tables = [table("a"), table("b"), table("c")];
const { positions } = computeLtrAutoLayout({
tables,
positions: {},
layers: [],
paneWidth: 1200,
relationships: [{ sourceTable: "b", targetTable: "a" }],
});
const names = Object.keys(positions);
assert.equal(names.length, 3);
for (const name of names) {
assert.ok(Number.isFinite(positions[name].x));
assert.ok(Number.isFinite(positions[name].y));
}
// Axis-aligned cards using CARD_WIDTH should not overlap
const boxes = names.map((name) => ({
name,
x: positions[name].x,
y: positions[name].y,
w: CARD_WIDTH,
h: 100,
}));
for (let i = 0; i < boxes.length; i++) {
for (let j = i + 1; j < boxes.length; j++) {
const a = boxes[i];
const b = boxes[j];
const overlap = !(a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y);
assert.equal(overlap, false, `${a.name} overlaps ${b.name}`);
}
}
});
test("reflowUnassignedTables preserves positions of layered tables", () => {
const layers: DiagramLayer[] = [
{
id: "l1",
name: "Core",
color: "#3b82f6",
tableNames: ["users"],
collapsed: false,
visible: true,
layoutMode: "auto",
position: { x: 40, y: 40 },
width: 400,
height: 200,
},
];
const prev = {
users: { x: 99, y: 88 },
orders: { x: 500, y: 500 },
payments: { x: 800, y: 600 },
};
const next = reflowUnassignedTables({
tables: [table("users"), table("orders"), table("payments")],
positions: prev,
layers,
paneWidth: 1200,
});
assert.deepEqual(next.users, prev.users);
assert.notDeepEqual(next.orders, prev.orders);
assert.ok(Number.isFinite(next.payments.x));
});
test("computeLtrAutoLayout keeps layer-assigned tables inside updated layer geometry", () => {
const layers: DiagramLayer[] = [
{
id: "l1",
name: "Core",
color: "#3b82f6",
tableNames: ["users", "orders"],
collapsed: false,
visible: true,
layoutMode: "auto",
position: { x: 0, y: 0 },
width: 100,
height: 80,
},
];
const { positions, layers: nextLayers } = computeLtrAutoLayout({
tables: [table("users"), table("orders"), table("orphan")],
positions: {
users: { x: 0, y: 0 },
orders: { x: 0, y: 0 },
orphan: { x: 0, y: 0 },
},
layers,
paneWidth: 1400,
relationships: [{ sourceTable: "orders", targetTable: "users" }],
});
assert.equal(nextLayers.length, 1);
const layer = nextLayers[0];
assert.ok((layer.width ?? 0) > 100);
assert.ok(positions.users.x >= (layer.position?.x ?? 0));
assert.ok(positions.orders.x >= (layer.position?.x ?? 0));
assert.ok(positions.orphan.y > (layer.position?.y ?? 0) + (layer.height ?? 0) - 1);
});

View File

@ -0,0 +1,24 @@
import { readFileSync } from "node:fs";
import { strict as assert } from "node:assert";
import { describe, test } from "vitest";
const dialogSource = readFileSync(new URL("../../apps/desktop/src/components/diagram/SchemaDiagramDialog.vue", import.meta.url), "utf8");
const toolbarSource = readFileSync(new URL("../../apps/desktop/src/components/diagram/DiagramToolbar.vue", import.meta.url), "utf8");
describe("diagram refresh confirmation wiring", () => {
test("toolbar emits refresh from the refresh button", () => {
assert.match(toolbarSource, /@click="emit\('refresh'\)"/);
assert.match(toolbarSource, /diagram\.refresh/);
});
test("dialog opens DangerConfirmDialog before reload", () => {
assert.match(dialogSource, /function requestRefreshDiagram\(\)/);
assert.match(dialogSource, /showRefreshConfirm\.value = true/);
assert.match(dialogSource, /@refresh="requestRefreshDiagram"/);
assert.match(
dialogSource,
/DangerConfirmDialog[^>]*v-model:open="showRefreshConfirm"[^>]*@confirm="confirmRefreshDiagram"/,
);
assert.match(dialogSource, /function confirmRefreshDiagram\(\)[\s\S]*?void loadDiagram\(\)/);
});
});

View File

@ -0,0 +1,80 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import {
createDraftTable,
createEmptyColumn,
draftTableToCreateSqlOptions,
liveTableToAlterSqlOptions,
} from "../../apps/desktop/src/lib/diagram/draft-table.ts";
import type { DiagramTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts";
import { canAddTableStructureColumn, getTableStructureCapabilities } from "../../apps/desktop/src/lib/table/tableStructureCapabilities.ts";
import { supportsTableStructureEditing } from "../../apps/desktop/src/lib/database/databaseFeatureSupport.ts";
import { defaultNewColumnDataType, getDataTypeOptions } from "../../apps/desktop/src/lib/table/tableStructureEditorState.ts";
import type { DatabaseType } from "../../apps/desktop/src/types/database.ts";
const READY_DIALECTS: DatabaseType[] = ["mysql", "postgres", "sqlite", "sqlserver", "oracle"];
test("diagram structure gates reuse TableStructure capabilities", () => {
for (const dbType of READY_DIALECTS) {
const caps = getTableStructureCapabilities(dbType);
assert.equal(caps.createTable, true, `${dbType} createTable`);
assert.equal(canAddTableStructureColumn(dbType, true), caps.createTable);
assert.equal(canAddTableStructureColumn(dbType, false), caps.addColumn);
assert.equal(supportsTableStructureEditing(dbType), true, `${dbType} structure editing`);
}
const unsupported = getTableStructureCapabilities("mongodb");
assert.equal(unsupported.createTable, false);
assert.equal(canAddTableStructureColumn("mongodb", true), false);
assert.equal(canAddTableStructureColumn("mongodb", false), false);
assert.equal(supportsTableStructureEditing("mongodb"), false);
});
test("draft CREATE options match table-structure SQL API shape", () => {
for (const dbType of READY_DIALECTS) {
const table = createDraftTable("users", { databaseType: dbType });
const options = draftTableToCreateSqlOptions(table, dbType, dbType === "postgres" ? "public" : undefined);
assert.equal(options.databaseType, dbType);
assert.equal(options.tableName, "users");
assert.ok(Array.isArray(options.columns));
assert.ok(Array.isArray(options.indexes));
assert.deepEqual(options.foreignKeys, []);
assert.deepEqual(options.triggers, []);
assert.equal(options.columns[0]?.isPrimaryKey, true);
assert.ok(options.columns[0]?.dataType);
}
});
test("live ALTER options mark pending add and drop for shared change SQL API", () => {
const table: DiagramTable = {
name: "orders",
columns: [
{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "note", data_type: "text", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
],
foreignKeys: [],
origin: "database",
pendingColumnNames: ["note"],
droppedColumnNames: ["id"],
};
const options = liveTableToAlterSqlOptions(table, "postgres", "public");
assert.equal(options.databaseType, "postgres");
assert.equal(options.tableName, "orders");
assert.deepEqual(options.indexes, []);
assert.deepEqual(options.foreignKeys, []);
const pending = options.columns.find((column) => column.name === "note");
const dropped = options.columns.find((column) => column.name === "id");
assert.ok(pending);
assert.equal(pending?.original, undefined);
assert.ok(dropped?.original);
assert.equal(dropped?.markedForDrop, true);
});
test("empty column defaults come from shared tableStructureEditorState", () => {
assert.equal(createEmptyColumn("x", "postgres").data_type, defaultNewColumnDataType("postgres", getDataTypeOptions("postgres")));
assert.equal(createEmptyColumn("x", "duckdb").data_type, defaultNewColumnDataType("duckdb", getDataTypeOptions("duckdb")));
assert.equal(createEmptyColumn("x", "h2").data_type, defaultNewColumnDataType("h2", getDataTypeOptions("h2")));
assert.ok(getDataTypeOptions("duckdb").length > 0);
assert.ok(getDataTypeOptions("h2").length > 0);
assert.ok(getDataTypeOptions("rqlite").includes("text"));
});

View File

@ -1,8 +1,10 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { buildEngineeringDiagram } from "../../apps/desktop/src/lib/diagram/engineeringDiagram.ts";
import { buildEngineeringDiagramSvg, buildTableDiagramSvg, diagramSvgFileName } from "../../apps/desktop/src/lib/export/diagramSvgExport.ts";
import { buildEngineeringDiagramSvg, buildTableDiagramSvg, buildTableRelationshipPaths, computeTableDiagramCanvas, diagramSvgFileName } from "../../apps/desktop/src/lib/export/diagramSvgExport.ts";
import { buildDiagramRelationships, normalizeCustomDiagramRelationship, type DiagramTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts";
import { pointsToSvgPath } from "../../apps/desktop/src/lib/diagram/edge-obstacle-router.ts";
import { CARD_BOTTOM_PADDING, CARD_HEADER_HEIGHT, CARD_WIDTH, COLUMN_ROW_HEIGHT, MARGIN } from "../../apps/desktop/src/lib/diagram/diagram-constants.ts";
const tables: DiagramTable[] = [
{
@ -25,6 +27,10 @@ const tables: DiagramTable[] = [
test("exports the table diagram as standalone SVG", () => {
const relationships = buildDiagramRelationships(tables);
const polyline = [
{ x: 360, y: 96 },
{ x: 310, y: 96 },
];
const svg = buildTableDiagramSvg({
tables,
relationships,
@ -32,36 +38,73 @@ test("exports the table diagram as standalone SVG", () => {
users: { x: 40, y: 40 },
orders: { x: 360, y: 40 },
},
relationshipLayouts: {
[relationships[0].id]: {
path: "M 360 96 L 310 96",
routePoints: [
{ x: 360, y: 96 },
{ x: 310, y: 96 },
],
sourceCardinality: { x: 346, y: 86 },
targetCardinality: { x: 324, y: 86 },
},
relationshipPaths: {
[relationships[0].id]: "M 360 96 L 310 96",
},
relationshipPolylines: {
[relationships[0].id]: polyline,
},
canvas: { width: 720, height: 320 },
cardWidth: 270,
cardHeaderHeight: 44,
columnRowHeight: 24,
maxVisibleColumns: 9,
moreColumnsLabel: (count) => `+ ${count} columns`,
});
assert.match(svg, /^<svg /);
assert.match(svg, /<path d="M 360 96 L 310 96"/);
assert.match(svg, /data-cardinality-end="source"[^>]*>N<\/text>/);
assert.match(svg, /data-cardinality-end="target"[^>]*>1<\/text>/);
assert.match(svg, /orders\.user_id \(N:1\) -&gt; users\.id/);
assert.match(svg, />users</);
assert.match(svg, />orders</);
assert.match(svg, />name &amp; note</);
assert.match(svg, />PK</);
assert.match(svg, />FK</);
// Endpoint cardinality badges (FK defaults to N:1 — N near source, 1 near target)
assert.match(svg, /diagram-cardinality/);
assert.match(svg, />N</);
assert.match(svg, />1</);
assert.doesNotMatch(svg, /<foreignObject/);
});
test("omits relationship paths when relationshipPaths entry is missing", () => {
const relationships = buildDiagramRelationships(tables);
const svg = buildTableDiagramSvg({
tables,
relationships,
positions: {
users: { x: 40, y: 40 },
orders: { x: 360, y: 40 },
},
relationshipPaths: {},
canvas: { width: 720, height: 320 },
cardWidth: CARD_WIDTH,
cardHeaderHeight: CARD_HEADER_HEIGHT,
columnRowHeight: COLUMN_ROW_HEIGHT,
});
assert.doesNotMatch(svg, /marker-end=/);
assert.doesNotMatch(svg, /id="dbx-diagram-arrow"/);
});
test("draws visible layers and skips zero-size layers", () => {
const svg = buildTableDiagramSvg({
tables: [tables[0]],
relationships: [],
positions: { users: { x: 40, y: 40 } },
relationshipPaths: {},
canvas: { width: 800, height: 600 },
cardWidth: CARD_WIDTH,
cardHeaderHeight: CARD_HEADER_HEIGHT,
columnRowHeight: COLUMN_ROW_HEIGHT,
layers: [
{ id: "l1", name: "Core", color: "#3b82f6", x: 10, y: 10, width: 400, height: 200 },
{ id: "l0", name: "Empty", color: "#ef4444", x: 0, y: 0, width: 0, height: 0 },
],
});
assert.match(svg, /class="diagram-layers"/);
assert.match(svg, />Core</);
assert.doesNotMatch(svg, />Empty</);
});
test("exports many-to-many cardinalities at both relationship endpoints", () => {
const customRelationship = {
...normalizeCustomDiagramRelationship({
@ -75,6 +118,10 @@ test("exports many-to-many cardinalities at both relationship endpoints", () =>
}),
kind: "custom" as const,
};
const polyline = [
{ x: 310, y: 96 },
{ x: 360, y: 96 },
];
const svg = buildTableDiagramSvg({
tables,
relationships: [customRelationship],
@ -82,27 +129,22 @@ test("exports many-to-many cardinalities at both relationship endpoints", () =>
users: { x: 40, y: 40 },
orders: { x: 360, y: 40 },
},
relationshipLayouts: {
[customRelationship.id]: {
path: "M 310 96 L 360 96",
routePoints: [
{ x: 310, y: 96 },
{ x: 360, y: 96 },
],
sourceCardinality: { x: 324, y: 86 },
targetCardinality: { x: 346, y: 86 },
},
relationshipPaths: {
[customRelationship.id]: "M 310 96 L 360 96",
},
relationshipPolylines: {
[customRelationship.id]: polyline,
},
canvas: { width: 720, height: 320 },
cardWidth: 270,
cardHeaderHeight: 44,
columnRowHeight: 24,
maxVisibleColumns: 9,
});
assert.match(svg, /data-cardinality-end="source"[^>]*>N<\/text>/);
assert.match(svg, /data-cardinality-end="target"[^>]*>N<\/text>/);
assert.match(svg, /users\.id \(N:N\) -&gt; orders\.id/);
assert.match(svg, /diagram-cardinality/);
const cardinalityTexts = [...svg.matchAll(/diagram-cardinality[\s\S]*?<\/g>/g)].join("");
assert.match(svg, />N</);
assert.ok((svg.match(/>N</g) || []).length >= 2, `expected two N badges, got: ${cardinalityTexts.slice(0, 200)}`);
});
test("infers one-to-one foreign keys from primary and unique source columns", () => {
@ -145,40 +187,6 @@ test("infers one-to-one foreign keys from primary and unique source columns", ()
assert.equal(buildDiagramRelationships(partialUniqueIndexTables)[0].sourceCardinality, "N");
});
test("expands the exported viewBox for a relationship routed left of the canvas", () => {
const relationships = buildDiagramRelationships(tables);
const svg = buildTableDiagramSvg({
tables,
relationships,
positions: {
users: { x: 16, y: 40 },
orders: { x: 336, y: 40 },
},
relationshipLayouts: {
[relationships[0].id]: {
path: "M 14 96 L -20 96 L -20 120 L 14 120",
routePoints: [
{ x: 14, y: 96 },
{ x: -20, y: 96 },
{ x: -20, y: 120 },
{ x: 14, y: 120 },
],
sourceCardinality: { x: 0, y: 86 },
targetCardinality: { x: 0, y: 110 },
},
},
canvas: { width: 720, height: 320 },
cardWidth: 270,
cardHeaderHeight: 44,
columnRowHeight: 24,
maxVisibleColumns: 9,
});
assert.match(svg, /viewBox="-40 0 760 320"/);
assert.match(svg, /<rect x="-40" y="0" width="760" height="320" fill="#fafafa"/);
assert.match(svg, /<path d="M 14 96 L -20 96 L -20 120 L 14 120"/);
});
test("exports the engineering ER diagram with Chen-style shapes and cardinalities", () => {
const relationships = buildDiagramRelationships(tables);
const diagram = buildEngineeringDiagram(tables, relationships, {
@ -201,3 +209,172 @@ test("builds safe SVG file names from the active diagram context", () => {
assert.equal(diagramSvgFileName("prod/main", "billing db", "engineering"), "dbx-prod-main-billing-db-engineering-er.svg");
assert.equal(diagramSvgFileName("", "", "table"), "dbx-diagram-table-structure.svg");
});
test("buildTableRelationshipPaths uses waypoints when length >= 2", () => {
const relationships = buildDiagramRelationships(tables);
const waypoints = [
{ x: 0, y: 0 },
{ x: 100, y: 50 },
];
const paths = buildTableRelationshipPaths({
relationships,
positions: {
users: { x: 40, y: 40 },
orders: { x: 400, y: 40 },
},
tables,
waypoints: { [relationships[0].id]: waypoints },
});
assert.equal(paths[relationships[0].id], pointsToSvgPath(waypoints));
});
test("buildTableRelationshipPaths falls back to orthogonal path when waypoints are insufficient", () => {
const relationships = buildDiagramRelationships(tables);
const paths = buildTableRelationshipPaths({
relationships,
positions: {
users: { x: 40, y: 40 },
orders: { x: 400, y: 40 },
},
tables,
waypoints: { [relationships[0].id]: [{ x: 0, y: 0 }] },
});
assert.ok(paths[relationships[0].id]);
assert.match(paths[relationships[0].id], /^M/);
assert.notEqual(paths[relationships[0].id], pointsToSvgPath([{ x: 0, y: 0 }]));
});
test("buildTableRelationshipPaths skips relationships with missing positions", () => {
const relationships = buildDiagramRelationships(tables);
const paths = buildTableRelationshipPaths({
relationships,
positions: { users: { x: 40, y: 40 } },
tables,
});
assert.equal(paths[relationships[0].id], undefined);
});
test("computeTableDiagramCanvas uses default floor and MARGIN padding", () => {
const canvas = computeTableDiagramCanvas(
[],
{},
{
cardWidth: CARD_WIDTH,
cardHeaderHeight: CARD_HEADER_HEIGHT,
columnRowHeight: COLUMN_ROW_HEIGHT,
},
);
assert.deepEqual(canvas, { width: 400 + MARGIN, height: 300 + MARGIN, originX: 0, originY: 0 });
});
test("computeTableDiagramCanvas expands for tables and ignores zero-size layers", () => {
const withTable = computeTableDiagramCanvas(
[tables[0]],
{ users: { x: 1000, y: 800 } },
{
cardWidth: CARD_WIDTH,
cardHeaderHeight: CARD_HEADER_HEIGHT,
columnRowHeight: COLUMN_ROW_HEIGHT,
layers: [{ id: "z", name: "z", color: "#000", x: 0, y: 0, width: 0, height: 0 }],
},
);
const tableHeight = CARD_HEADER_HEIGHT + tables[0].columns.length * COLUMN_ROW_HEIGHT + CARD_BOTTOM_PADDING;
assert.equal(withTable.originX, 1000 - MARGIN);
assert.equal(withTable.originY, 800 - MARGIN);
assert.equal(withTable.width, CARD_WIDTH + 2 * MARGIN);
assert.equal(withTable.height, tableHeight + 2 * MARGIN);
const withLayer = computeTableDiagramCanvas(
[],
{},
{
cardWidth: CARD_WIDTH,
cardHeaderHeight: CARD_HEADER_HEIGHT,
columnRowHeight: COLUMN_ROW_HEIGHT,
layers: [{ id: "big", name: "big", color: "#000", x: 0, y: 0, width: 2000, height: 1500 }],
},
);
assert.equal(withLayer.originX, -MARGIN);
assert.equal(withLayer.originY, -MARGIN);
assert.equal(withLayer.width, 2000 + 2 * MARGIN);
assert.equal(withLayer.height, 1500 + 2 * MARGIN);
});
test("computeTableDiagramCanvas expands for relationship polylines beyond table bounds", () => {
const canvas = computeTableDiagramCanvas(
[tables[0]],
{ users: { x: 40, y: 40 } },
{
cardWidth: CARD_WIDTH,
cardHeaderHeight: CARD_HEADER_HEIGHT,
columnRowHeight: COLUMN_ROW_HEIGHT,
relationshipPolylines: {
edge1: [
{ x: 40, y: 40 },
{ x: 40, y: 500 },
],
},
},
);
assert.ok(canvas.originY <= 40 - MARGIN);
assert.ok(canvas.originY + canvas.height >= 500 + MARGIN);
assert.ok(canvas.height >= 500 - 40 + 2 * MARGIN);
});
test("buildTableDiagramSvg normalizes far-from-origin content to viewBox 0 0", () => {
const table = tables[0];
const positions = { users: { x: 1000, y: 800 } };
const canvas = computeTableDiagramCanvas([table], positions, {
cardWidth: CARD_WIDTH,
cardHeaderHeight: CARD_HEADER_HEIGHT,
columnRowHeight: COLUMN_ROW_HEIGHT,
});
assert.ok((canvas.originX ?? 0) > 0);
assert.ok((canvas.originY ?? 0) > 0);
const svg = buildTableDiagramSvg({
tables: [table],
relationships: [],
positions,
relationshipPaths: {},
canvas,
cardWidth: CARD_WIDTH,
cardHeaderHeight: CARD_HEADER_HEIGHT,
columnRowHeight: COLUMN_ROW_HEIGHT,
});
assert.match(svg, new RegExp(`viewBox="0 0 ${canvas.width} ${canvas.height}"`));
assert.match(svg, /rect x="0" y="0"/);
assert.match(svg, new RegExp(`transform="translate\\(${-(canvas.originX ?? 0)} ${-(canvas.originY ?? 0)}\\)"`));
assert.match(svg, /users/);
});
test("buildTableDiagramSvg normalizes negative-origin content to viewBox 0 0", () => {
const table = tables[0];
const positions = { users: { x: -200, y: -100 } };
const canvas = computeTableDiagramCanvas([table], positions, {
cardWidth: CARD_WIDTH,
cardHeaderHeight: CARD_HEADER_HEIGHT,
columnRowHeight: COLUMN_ROW_HEIGHT,
});
assert.ok((canvas.originX ?? 0) < 0);
assert.ok((canvas.originY ?? 0) < 0);
const svg = buildTableDiagramSvg({
tables: [table],
relationships: [],
positions,
relationshipPaths: {},
canvas,
cardWidth: CARD_WIDTH,
cardHeaderHeight: CARD_HEADER_HEIGHT,
columnRowHeight: COLUMN_ROW_HEIGHT,
});
assert.match(svg, new RegExp(`viewBox="0 0 ${canvas.width} ${canvas.height}"`));
assert.match(svg, /rect x="0" y="0"/);
assert.match(svg, new RegExp(`transform="translate\\(${-(canvas.originX ?? 0)} ${-(canvas.originY ?? 0)}\\)"`));
assert.match(svg, /users/);
});

View File

@ -0,0 +1,58 @@
// @vitest-environment happy-dom
import { strict as assert } from "node:assert";
import { afterEach, test, vi } from "vitest";
afterEach(() => {
vi.restoreAllMocks();
});
test("svgToPngBlob scales canvas, fills opaque background, and returns png blob", async () => {
const fillRect = vi.fn();
const drawImage = vi.fn();
const toBlob = vi.fn((cb: (b: Blob | null) => void) => {
cb(new Blob(["png"], { type: "image/png" }));
});
class FakeImage {
naturalWidth = 100;
naturalHeight = 50;
width = 100;
height = 50;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(_value: string) {
queueMicrotask(() => this.onload?.());
}
}
vi.stubGlobal("Image", FakeImage);
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
fillStyle: "",
fillRect,
drawImage,
} as unknown as CanvasRenderingContext2D);
vi.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation(toBlob as typeof HTMLCanvasElement.prototype.toBlob);
const { svgToPngBlob } = await import("../../apps/desktop/src/lib/export/diagramFormats.ts");
const blob = await svgToPngBlob('<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"/>', 2);
assert.equal(blob.type, "image/png");
assert.equal(fillRect.mock.calls[0]?.slice(0, 4).join(","), "0,0,200,100");
const ctx = HTMLCanvasElement.prototype.getContext.mock.results[0]?.value as { fillStyle: string };
assert.equal(ctx.fillStyle, "#fafafa");
assert.equal(drawImage.mock.calls.length, 1);
});
test("svgToPngBlob rejects when image fails to load", async () => {
class FailingImage {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(_value: string) {
queueMicrotask(() => this.onerror?.());
}
}
vi.stubGlobal("Image", FailingImage);
const { svgToPngBlob } = await import("../../apps/desktop/src/lib/export/diagramFormats.ts");
await assert.rejects(() => svgToPngBlob("<svg/>", 2), /Failed to load SVG/);
});

View File

@ -0,0 +1,110 @@
import { strict as assert } from "node:assert";
import { beforeEach, test } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { useLayerStore } from "../../apps/desktop/src/lib/diagram/layer-store.ts";
import {
isTableCanvasVisible,
toVueFlowEdges,
toVueFlowNodes,
} from "../../apps/desktop/src/lib/diagram/vue-flow-adapter.ts";
import type { DiagramRelationship, DiagramTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts";
import type { DiagramLayer } from "../../apps/desktop/src/types/diagram.ts";
function table(name: string): DiagramTable {
return { name, columns: [], foreignKeys: [] };
}
function layer(partial: Partial<DiagramLayer> & { id: string; name: string; tableNames: string[] }): DiagramLayer {
return {
color: "#3b82f6",
collapsed: false,
visible: true,
layoutMode: "auto",
position: { x: 0, y: 0 },
width: 240,
height: 100,
...partial,
};
}
beforeEach(() => {
setActivePinia(createPinia());
});
test("isTableCanvasVisible: unlayered always visible; hidden layer hides members", () => {
const layers = [
layer({ id: "l1", name: "Core", tableNames: ["users"], visible: false }),
layer({ id: "l2", name: "Open", tableNames: ["orders"], visible: true }),
];
assert.equal(isTableCanvasVisible("orphan", layers), true);
assert.equal(isTableCanvasVisible("users", layers), false);
assert.equal(isTableCanvasVisible("orders", layers), true);
});
test("toVueFlowNodes filters tables on hidden layers", () => {
const store = useLayerStore();
const hidden = store.addLayer("Hidden");
const shown = store.addLayer("Shown");
store.addTableToLayer(hidden.id, "users");
store.addTableToLayer(shown.id, "orders");
store.toggleLayerVisibility(hidden.id);
const nodes = toVueFlowNodes([table("users"), table("orders"), table("orphan")], {
users: { x: 10, y: 10 },
orders: { x: 20, y: 20 },
orphan: { x: 30, y: 30 },
});
assert.deepEqual(nodes.map((n) => n.id).sort(), ["orders", "orphan"]);
});
test("toVueFlowNodes hides tables marked pendingDrop", () => {
const nodes = toVueFlowNodes(
[
{ ...table("users"), pendingDrop: true },
table("orders"),
],
{
users: { x: 10, y: 10 },
orders: { x: 20, y: 20 },
},
);
assert.deepEqual(nodes.map((n) => n.id), ["orders"]);
});
test("toVueFlowEdges filters when either endpoint layer is hidden", () => {
const store = useLayerStore();
const hidden = store.addLayer("Hidden");
const shown = store.addLayer("Shown");
store.addTableToLayer(hidden.id, "users");
store.addTableToLayer(shown.id, "orders");
store.addTableToLayer(shown.id, "items");
store.toggleLayerVisibility(hidden.id);
const relationships: DiagramRelationship[] = [
{
id: "e1",
name: "fk1",
kind: "foreign-key",
sourceTable: "orders",
sourceColumn: "user_id",
targetTable: "users",
targetColumn: "id",
sourceCardinality: "N",
targetCardinality: "1",
},
{
id: "e2",
name: "fk2",
kind: "foreign-key",
sourceTable: "items",
sourceColumn: "order_id",
targetTable: "orders",
targetColumn: "id",
sourceCardinality: "N",
targetCardinality: "1",
},
];
const edges = toVueFlowEdges(relationships);
assert.deepEqual(edges.map((e) => e.id), ["e2"]);
});

View File

@ -206,3 +206,187 @@ FROM orders t1
LEFT JOIN customers t2 ON t1.customer_id = t2.id AND t1.customer_region = t2.region`,
);
});
test("R1: single-column unique FK is 1:1", () => {
const relationships = buildDiagramRelationships([
{
name: "users",
columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
foreignKeys: [],
},
{
name: "user_profiles",
columns: [
{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
],
foreignKeys: [{ name: "user_profiles_user_id_fk", column: "user_id", ref_table: "users", ref_column: "id" }],
indexes: [{ name: "user_profiles_user_id_uq", columns: ["user_id"], is_unique: true, is_primary: false }],
},
]);
assert.equal(relationships[0].sourceCardinality, "1");
assert.equal(relationships[0].targetCardinality, "1");
});
test("R2: composite unique covering composite FK is 1:1", () => {
const relationships = buildDiagramRelationships([
{
name: "tenants",
columns: [
{ name: "org_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
],
foreignKeys: [],
},
{
name: "memberships",
columns: [
{ name: "org_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
{ name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
],
foreignKeys: [
{ name: "memberships_tenant_fk", column: "org_id", ref_table: "tenants", ref_column: "org_id" },
{ name: "memberships_tenant_fk", column: "user_id", ref_table: "tenants", ref_column: "id" },
],
indexes: [{ name: "memberships_uq", columns: ["user_id", "org_id"], is_unique: true, is_primary: false }],
},
]);
assert.ok(relationships.every((r) => r.sourceCardinality === "1" && r.targetCardinality === "1"));
});
test("R3: ordinary FK remains N:1", () => {
const relationships = buildDiagramRelationships([
{
name: "users",
columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
foreignKeys: [],
},
{
name: "orders",
columns: [
{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
],
foreignKeys: [{ name: "orders_user_id_fk", column: "user_id", ref_table: "users", ref_column: "id" }],
},
]);
assert.equal(relationships[0].sourceCardinality, "N");
assert.equal(relationships[0].targetCardinality, "1");
});
test("E1: column.is_unique alone yields 1:1 without indexes", () => {
const relationships = buildDiagramRelationships([
{
name: "users",
columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
foreignKeys: [],
},
{
name: "profiles",
columns: [
{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, is_unique: true, extra: null },
],
foreignKeys: [{ name: "profiles_user_id_fk", column: "user_id", ref_table: "users", ref_column: "id" }],
},
]);
assert.equal(relationships[0].sourceCardinality, "1");
});
test("E2: unique index superset of FK columns stays N:1", () => {
const relationships = buildDiagramRelationships([
{
name: "users",
columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
foreignKeys: [],
},
{
name: "orders",
columns: [
{ name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
{ name: "tenant_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
],
foreignKeys: [{ name: "orders_user_fk", column: "user_id", ref_table: "users", ref_column: "id" }],
indexes: [{ name: "orders_uq", columns: ["user_id", "tenant_id"], is_unique: true, is_primary: false }],
},
]);
assert.equal(relationships[0].sourceCardinality, "N");
});
test("E4/E5: partial unique and markedForDrop unique indexes do not force 1:1", () => {
const partial = buildDiagramRelationships([
{ name: "users", columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], foreignKeys: [] },
{
name: "orders",
columns: [{ name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null }],
foreignKeys: [{ name: "orders_user_fk", column: "user_id", ref_table: "users", ref_column: "id" }],
indexes: [{ name: "orders_uq", columns: ["user_id"], is_unique: true, is_primary: false, filter: "deleted_at IS NULL" }],
},
]);
assert.equal(partial[0].sourceCardinality, "N");
const dropped = buildDiagramRelationships([
{ name: "users", columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], foreignKeys: [] },
{
name: "orders",
columns: [{ name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null }],
foreignKeys: [{ name: "orders_user_fk", column: "user_id", ref_table: "users", ref_column: "id" }],
indexes: [
{
id: "idx-1",
name: "orders_uq",
columns: ["user_id"],
isUnique: true,
isPrimary: false,
filter: "",
indexType: "",
includedColumns: [],
comment: "",
markedForDrop: true,
},
],
},
]);
assert.equal(dropped[0].sourceCardinality, "N");
});
test("E6: PK column set equal to FK columns is 1:1", () => {
const relationships = buildDiagramRelationships([
{ name: "users", columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], foreignKeys: [] },
{
name: "profiles",
columns: [{ name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
foreignKeys: [{ name: "profiles_pk_fk", column: "user_id", ref_table: "users", ref_column: "id" }],
},
]);
assert.equal(relationships[0].sourceCardinality, "1");
});
test("E7: custom relationship cardinalities are preserved", () => {
const custom = normalizeCustomDiagramRelationship({
name: "custom_nn",
sourceTable: "users",
sourceColumn: "id",
targetTable: "orders",
targetColumn: "id",
sourceCardinality: "N",
targetCardinality: "N",
});
const relationships = buildDiagramRelationships(
[
{
name: "users",
columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
foreignKeys: [],
},
{
name: "orders",
columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
foreignKeys: [],
},
],
[custom],
);
assert.equal(relationships[0].sourceCardinality, "N");
assert.equal(relationships[0].targetCardinality, "N");
});

View File

@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import { test } from "vitest";
function source(relativePath: string): string {
return readFileSync(path.resolve(relativePath), "utf8");
}
test("getAllColumns is exported from both backends with table_name", () => {
const http = source("apps/desktop/src/lib/backend/http.ts");
const tauri = source("apps/desktop/src/lib/backend/tauri.ts");
const api = source("apps/desktop/src/lib/backend/api.ts");
assert.match(http, /export async function getAllColumns/);
assert.match(tauri, /export async function getAllColumns/);
assert.match(api, /getAllColumns/);
assert.match(http, /export interface TableColumnsResult \{\n table_name: string;/);
assert.match(tauri, /export interface TableColumnsResult \{\n table_name: string;/);
assert.doesNotMatch(http, /export interface TableColumnsResult \{\n tableName:/);
assert.doesNotMatch(tauri, /export interface TableColumnsResult \{\n tableName:/);
assert.match(tauri, /invoke\("get_all_columns"/);
assert.match(http, /\/api\/schema\/all-columns/);
});
test("get_all_columns is registered in Tauri and mounted on the web API", () => {
const schemaCommands = source("src-tauri/src/commands/schema.rs");
const lib = source("src-tauri/src/lib.rs");
const webMain = source("crates/dbx-web/src/main.rs");
assert.match(schemaCommands, /pub async fn get_all_columns/);
assert.match(lib, /commands::schema::get_all_columns/);
assert.match(webMain, /\/schema\/all-columns/);
});

View File

@ -0,0 +1,37 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { midpointAlongPolyline, pointAlongPolyline } from "../../apps/desktop/src/lib/diagram/edge-obstacle-router.ts";
test("pointAlongPolyline returns endpoints at 0 and 1", () => {
const points = [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
];
assert.deepEqual(pointAlongPolyline(points, 0), { x: 0, y: 0 });
assert.deepEqual(pointAlongPolyline(points, 1), { x: 100, y: 0 });
});
test("pointAlongPolyline interpolates by arc length", () => {
const points = [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
{ x: 100, y: 100 },
];
// Total length 200; t=0.25 → 50 along first segment
const p = pointAlongPolyline(points, 0.25);
assert.equal(p.x, 50);
assert.equal(p.y, 0);
// t=0.75 → 150 → mid of second segment
const q = pointAlongPolyline(points, 0.75);
assert.equal(q.x, 100);
assert.equal(q.y, 50);
});
test("midpointAlongPolyline matches t=0.5", () => {
const points = [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
];
assert.deepEqual(midpointAlongPolyline(points), pointAlongPolyline(points, 0.5));
});

View File

@ -0,0 +1,80 @@
// @vitest-environment happy-dom
import { strict as assert } from "node:assert";
import { beforeEach, test, vi } from "vitest";
const runtimeMock = vi.hoisted(() => ({ isTauri: false }));
const dialogMock = vi.hoisted(() => ({ save: vi.fn() }));
const fsMock = vi.hoisted(() => ({
writeTextFile: vi.fn(async () => {}),
writeFile: vi.fn(async () => {}),
}));
vi.mock("@/lib/backend/tauriRuntime", () => ({
isTauriRuntime: () => runtimeMock.isTauri,
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({
save: (...args: unknown[]) => dialogMock.save(...args),
}));
vi.mock("@tauri-apps/plugin-fs", () => ({
writeTextFile: (...args: unknown[]) => fsMock.writeTextFile(...args),
writeFile: (...args: unknown[]) => fsMock.writeFile(...args),
}));
const { saveDiagramBinaryExport, saveDiagramTextExport } = await import("../../apps/desktop/src/lib/export/saveDiagramExport.ts");
beforeEach(() => {
runtimeMock.isTauri = false;
dialogMock.save.mockReset();
fsMock.writeTextFile.mockReset();
fsMock.writeFile.mockReset();
});
test("Tauri text export returns false when save is cancelled", async () => {
runtimeMock.isTauri = true;
dialogMock.save.mockResolvedValue(null);
const saved = await saveDiagramTextExport("diagram.svg", "<svg/>", "svg");
assert.equal(saved, false);
assert.equal(fsMock.writeTextFile.mock.calls.length, 0);
});
test("Tauri text export writes file when path chosen", async () => {
runtimeMock.isTauri = true;
dialogMock.save.mockResolvedValue("/tmp/out.svg");
const saved = await saveDiagramTextExport("diagram.svg", "<svg/>", "svg");
assert.equal(saved, true);
assert.deepEqual(fsMock.writeTextFile.mock.calls[0]?.slice(0, 2), ["/tmp/out.svg", "<svg/>"]);
});
test("Tauri binary export writes bytes when path chosen", async () => {
runtimeMock.isTauri = true;
dialogMock.save.mockResolvedValue("/tmp/out.png");
const blob = new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" });
const saved = await saveDiagramBinaryExport("diagram.png", blob, "png");
assert.equal(saved, true);
assert.equal(fsMock.writeFile.mock.calls[0]?.[0], "/tmp/out.png");
assert.ok(fsMock.writeFile.mock.calls[0]?.[1] instanceof Uint8Array);
});
test("web text export triggers download and returns true", async () => {
runtimeMock.isTauri = false;
const clicks: string[] = [];
const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:diagram");
const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
const el = originalCreateElement(tag);
if (tag === "a") {
el.click = () => {
clicks.push(el.download);
};
}
return el;
});
const saved = await saveDiagramTextExport("dbx-diagram.svg", "<svg/>", "svg");
assert.equal(saved, true);
assert.deepEqual(clicks, ["dbx-diagram.svg"]);
createObjectURL.mockRestore();
revokeObjectURL.mockRestore();
});

View File

@ -110,6 +110,18 @@ importers:
'@uiw/codemirror-theme-xcode':
specifier: ^4.25.10
version: 4.25.10(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)
'@vue-flow/background':
specifier: ^1.3.2
version: 1.3.2(@vue-flow/core@1.48.2(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3))
'@vue-flow/controls':
specifier: ^1.1.3
version: 1.1.3(@vue-flow/core@1.48.2(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3))
'@vue-flow/core':
specifier: ^1.48.2
version: 1.48.2(vue@3.5.35(typescript@6.0.3))
'@vue-flow/minimap':
specifier: ^1.5.4
version: 1.5.4(@vue-flow/core@1.48.2(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3))
'@vueuse/core':
specifier: ^14.2.1
version: 14.2.1(vue@3.5.35(typescript@6.0.3))
@ -134,6 +146,9 @@ importers:
echarts:
specifier: ^6.1.0
version: 6.1.0
elkjs:
specifier: ^0.11.1
version: 0.11.1
leaflet:
specifier: ^1.9.4
version: 1.9.4
@ -1572,6 +1587,9 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
@ -1659,6 +1677,29 @@ packages:
'@volar/typescript@2.4.28':
resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
'@vue-flow/background@1.3.2':
resolution: {integrity: sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==}
peerDependencies:
'@vue-flow/core': ^1.23.0
vue: ^3.3.0
'@vue-flow/controls@1.1.3':
resolution: {integrity: sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==}
peerDependencies:
'@vue-flow/core': ^1.23.0
vue: ^3.3.0
'@vue-flow/core@1.48.2':
resolution: {integrity: sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==}
peerDependencies:
vue: ^3.3.0
'@vue-flow/minimap@1.5.4':
resolution: {integrity: sha512-l4C+XTAXnRxsRpUdN7cAVFBennC1sVRzq4bDSpVK+ag7tdMczAnhFYGgbLkUw3v3sY6gokyWwMl8CDonp8eB2g==}
peerDependencies:
'@vue-flow/core': ^1.23.0
vue: ^3.3.0
'@vue/compiler-core@3.5.35':
resolution: {integrity: sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==}
@ -1724,14 +1765,23 @@ packages:
'@vuedx/template-ast-types@0.7.1':
resolution: {integrity: sha512-Mqugk/F0lFN2u9bhimH6G1kSu2hhLi2WoqgCVxrMvgxm2kDc30DtdvVGRq+UgEmKVP61OudcMtZqkUoGQeFBUQ==}
'@vueuse/core@10.11.1':
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
'@vueuse/core@14.2.1':
resolution: {integrity: sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==}
peerDependencies:
vue: ^3.5.0
'@vueuse/metadata@10.11.1':
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
'@vueuse/metadata@14.2.1':
resolution: {integrity: sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==}
'@vueuse/shared@10.11.1':
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
'@vueuse/shared@14.2.1':
resolution: {integrity: sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==}
peerDependencies:
@ -2053,6 +2103,44 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
d3-drag@3.0.0:
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
d3-transition@3.0.1:
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
engines: {node: '>=12'}
peerDependencies:
d3-selection: 2 - 3
d3-zoom@3.0.0:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
dayjs@1.11.21:
resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
@ -2177,6 +2265,9 @@ packages:
electron-to-chromium@1.5.382:
resolution: {integrity: sha512-8ETaWbV6SZOrno+G93Ffd9ENsMtetqdnqj4nlfxFW90Sm5GgnuV28Kf62hqQVD6VUgzm7qFQKsTsAPmeUiU3Ug==}
elkjs@0.11.1:
resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@ -4976,6 +5067,8 @@ snapshots:
'@types/unist@3.0.3': {}
'@types/web-bluetooth@0.0.20': {}
'@types/web-bluetooth@0.0.21': {}
'@types/whatwg-mimetype@3.0.2': {}
@ -5120,6 +5213,34 @@ snapshots:
path-browserify: 1.0.1
vscode-uri: 3.1.0
'@vue-flow/background@1.3.2(@vue-flow/core@1.48.2(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3))':
dependencies:
'@vue-flow/core': 1.48.2(vue@3.5.35(typescript@6.0.3))
vue: 3.5.35(typescript@6.0.3)
'@vue-flow/controls@1.1.3(@vue-flow/core@1.48.2(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3))':
dependencies:
'@vue-flow/core': 1.48.2(vue@3.5.35(typescript@6.0.3))
vue: 3.5.35(typescript@6.0.3)
'@vue-flow/core@1.48.2(vue@3.5.35(typescript@6.0.3))':
dependencies:
'@vueuse/core': 10.11.1(vue@3.5.35(typescript@6.0.3))
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-zoom: 3.0.0
vue: 3.5.35(typescript@6.0.3)
transitivePeerDependencies:
- '@vue/composition-api'
'@vue-flow/minimap@1.5.4(@vue-flow/core@1.48.2(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3))':
dependencies:
'@vue-flow/core': 1.48.2(vue@3.5.35(typescript@6.0.3))
d3-selection: 3.0.0
d3-zoom: 3.0.0
vue: 3.5.35(typescript@6.0.3)
'@vue/compiler-core@3.5.35':
dependencies:
'@babel/parser': 7.29.7
@ -5248,6 +5369,16 @@ snapshots:
dependencies:
'@vue/compiler-core': 3.5.39
'@vueuse/core@10.11.1(vue@3.5.35(typescript@6.0.3))':
dependencies:
'@types/web-bluetooth': 0.0.20
'@vueuse/metadata': 10.11.1
'@vueuse/shared': 10.11.1(vue@3.5.35(typescript@6.0.3))
vue-demi: 0.14.10(vue@3.5.35(typescript@6.0.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/core@14.2.1(vue@3.5.35(typescript@6.0.3))':
dependencies:
'@types/web-bluetooth': 0.0.21
@ -5255,8 +5386,17 @@ snapshots:
'@vueuse/shared': 14.2.1(vue@3.5.35(typescript@6.0.3))
vue: 3.5.35(typescript@6.0.3)
'@vueuse/metadata@10.11.1': {}
'@vueuse/metadata@14.2.1': {}
'@vueuse/shared@10.11.1(vue@3.5.35(typescript@6.0.3))':
dependencies:
vue-demi: 0.14.10(vue@3.5.35(typescript@6.0.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/shared@14.2.1(vue@3.5.35(typescript@6.0.3))':
dependencies:
vue: 3.5.35(typescript@6.0.3)
@ -5551,6 +5691,42 @@ snapshots:
csstype@3.2.3: {}
d3-color@3.1.0: {}
d3-dispatch@3.0.1: {}
d3-drag@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-selection: 3.0.0
d3-ease@3.0.1: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-selection@3.0.0: {}
d3-timer@3.0.1: {}
d3-transition@3.0.1(d3-selection@3.0.0):
dependencies:
d3-color: 3.1.0
d3-dispatch: 3.0.1
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-timer: 3.0.1
d3-zoom@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
dayjs@1.11.21: {}
debounce-fn@4.0.0:
@ -5643,6 +5819,8 @@ snapshots:
electron-to-chromium@1.5.382: {}
elkjs@0.11.1: {}
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {}

View File

@ -333,6 +333,16 @@ pub async fn get_columns(
.await
}
#[tauri::command]
pub async fn get_all_columns(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
schema: String,
) -> Result<Vec<db::TableColumnsResult>, String> {
dbx_core::schema::get_all_columns_core(&state, &connection_id, &database, &schema).await
}
#[tauri::command]
pub async fn get_sqlserver_column_metadata(
state: State<'_, Arc<AppState>>,

View File

@ -206,7 +206,8 @@ mod execution_tests {
#[tokio::test]
async fn stops_on_first_failure_by_default() {
let summary = run_fake_script(vec!["ok 1".into(), "fail 2".into(), "ok 3".into()], false, None).await;
let summary: SqlFileSummary =
run_fake_script(vec!["ok 1".into(), "fail 2".into(), "ok 3".into()], false, None).await;
assert_eq!(summary.success_count, 1);
assert_eq!(summary.failure_count, 1);

View File

@ -95,6 +95,10 @@ pub async fn start_transfer(
let total_tables = sorted_tables.len();
log::info!("[transfer] starting transfer_id={} tables={}", transfer_id, total_tables);
let mut failed_tables: Vec<String> = Vec::new();
let mut last_rows_transferred = 0_u64;
let mut last_total_rows = None;
if matches!(source_db_type, dbx_core::models::connection::DatabaseType::Postgres)
&& matches!(target_db_type, dbx_core::models::connection::DatabaseType::Postgres)
{
@ -103,7 +107,11 @@ pub async fn start_transfer(
&request,
&source_pool_key,
&target_pool_key,
|progress| emit_progress(&app, progress),
|progress| {
last_rows_transferred = progress.rows_transferred;
last_total_rows = progress.total_rows;
emit_progress(&app, progress);
},
)
.await
{
@ -116,8 +124,8 @@ pub async fn start_transfer(
table: "schema dependencies".to_string(),
table_index: 0,
total_tables,
rows_transferred: 0,
total_rows: None,
rows_transferred: last_rows_transferred,
total_rows: last_total_rows,
status: TransferStatus::Cancelled,
error: None,
terminal: true,
@ -134,8 +142,8 @@ pub async fn start_transfer(
table: "schema dependencies".to_string(),
table_index: 0,
total_tables,
rows_transferred: 0,
total_rows: None,
rows_transferred: last_rows_transferred,
total_rows: last_total_rows,
status: TransferStatus::Error,
error: Some(e),
terminal: true,
@ -146,8 +154,6 @@ pub async fn start_transfer(
}
}
}
let mut failed_tables: Vec<String> = Vec::new();
for (i, table) in sorted_tables.iter().enumerate() {
if dbx_core::transfer::is_cancelled(&transfer_id).await {
emit_progress(
@ -157,8 +163,8 @@ pub async fn start_transfer(
table: table.clone(),
table_index: i,
total_tables,
rows_transferred: 0,
total_rows: None,
rows_transferred: last_rows_transferred,
total_rows: last_total_rows,
status: TransferStatus::Cancelled,
error: None,
terminal: true,
@ -170,9 +176,6 @@ pub async fn start_transfer(
log::info!("[transfer] table {}/{}: {}", i + 1, total_tables, table);
let mut last_rows_transferred = 0_u64;
let mut last_total_rows = None;
match dbx_core::transfer::transfer_table(
&state,
&request,
@ -215,8 +218,8 @@ pub async fn start_transfer(
table: table.clone(),
table_index: i,
total_tables,
rows_transferred: 0,
total_rows: None,
rows_transferred: last_rows_transferred,
total_rows: last_total_rows,
status: TransferStatus::Cancelled,
error: None,
terminal: true,
@ -316,8 +319,8 @@ pub async fn start_transfer(
table: String::new(),
table_index: total_tables,
total_tables,
rows_transferred: 0,
total_rows: None,
rows_transferred: last_rows_transferred,
total_rows: last_total_rows,
status: if failed_tables.is_empty() { TransferStatus::Done } else { TransferStatus::Error },
error: if failed_tables.is_empty() {
if skip_suffix.is_empty() {

View File

@ -1529,6 +1529,7 @@ pub fn run() {
commands::schema::list_schema_infos,
commands::schema::list_data_types,
commands::schema::get_columns,
commands::schema::get_all_columns,
commands::schema::get_sqlserver_column_metadata,
commands::schema::list_indexes,
commands::schema::list_foreign_keys,