feat(diagram): enhance ER diagram editing and exports
This commit is contained in:
parent
9fda1d690a
commit
212cf1da7e
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "設定資料庫別名",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 })}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"] };
|
||||
}
|
||||
}
|
||||
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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, \
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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><Dialog :open="open" @update:open="(v) => model = v">
|
||||
<DialogContent class="w-[94vw] h-[86vh] flex flex-col p-0">
|
||||
<DialogHeader class="px-4 py-3 border-b">
|
||||
<DialogTitle>Network图标 + "ER 图"标题</DialogTitle>
|
||||
</DialogHeader>
|
||||
<!-- 工具栏 -->
|
||||
<div class="flex items-center gap-2 border-b px-3 py-2 shrink-0 overflow-x-auto">
|
||||
...按钮和选择器...
|
||||
</div>
|
||||
<!-- 可折叠面板(关系建模 / 匹配管理) -->
|
||||
<div v-if="showPanel" class="shrink-0 border-b">...</div>
|
||||
<!-- 画布 -->
|
||||
<div class="min-h-0 flex-1 bg-muted/20">...</div>
|
||||
</DialogContent>
|
||||
</Dialog></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:<connectionId>:<database>:<schema></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、ClickHouse),ER 图上大量表呈现为孤岛。</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>FlowGram(Canvas)</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>(lucide,h-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>(lucide,h-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><Controls /></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:<connId>:<db>:<schema></code></td><td>现有(保持不变)</td></tr>
|
||||
<tr><td>匹配确认记录</td><td><code>dbx:diagram:match-confirms:v1:<connId>:<db>:<schema></code></td><td>新增</td></tr>
|
||||
<tr><td>匹配忽略记录</td><td><code>dbx:diagram:match-ignores:v1:<connId>:<db>:<schema></code></td><td>新增</td></tr>
|
||||
<tr><td>用户自定义正则规则</td><td><code>dbx:diagram:match-rules:v1:<connId>:<db>:<schema></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.ts,0.6x-1.5x</td></tr>
|
||||
<tr><td>框选多选</td><td><code>SelectionMode.Partial</code></td><td>缺失</td></tr>
|
||||
<tr><td>MiniMap</td><td><code><MiniMap /></code> 浮动组件</td><td>缺失</td></tr>
|
||||
<tr><td>Controls</td><td><code><Controls /></code> 浮动组件(替代工具栏 +/- 按钮)</td><td>手动按钮</td></tr>
|
||||
<tr><td>背景网格</td><td><code><Background /></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<LayoutResult> {
|
||||
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>不引入第三方撤销库。在 GraphStore(Pinia)中手动维护 <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<DiagramNode[]>([]);
|
||||
const edges = ref<DiagramEdge[]>([]);
|
||||
const historyStack = ref<HistorySnapshot[]>([]);
|
||||
const redoStack = ref<HistorySnapshot[]>([]);
|
||||
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.id,confidence='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">拖拽,undo,redo。期望 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">拖拽 A,undo,拖拽 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 随安装包分发,增量约 300KB(gzip ~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>
|
||||
|
|
@ -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 ───────────────────────────── #
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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"), {});
|
||||
});
|
||||
|
|
@ -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, []);
|
||||
});
|
||||
|
|
@ -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 });
|
||||
});
|
||||
|
|
@ -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`);
|
||||
}
|
||||
});
|
||||
|
|
@ -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"],
|
||||
);
|
||||
});
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
|
|
@ -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"],
|
||||
);
|
||||
});
|
||||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
|
@ -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")));
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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\(\)/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"));
|
||||
});
|
||||
|
|
@ -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\) -> users\.id/);
|
||||
assert.match(svg, />users</);
|
||||
assert.match(svg, />orders</);
|
||||
assert.match(svg, />name & 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\) -> 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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
@ -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));
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
178
pnpm-lock.yaml
178
pnpm-lock.yaml
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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>>,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue