feat(diagram): add modeled ERD relationships

This commit is contained in:
t8y2 2026-06-26 18:44:47 +08:00
parent 3e8c4273b2
commit 2b99cd3b62
7 changed files with 717 additions and 147 deletions

View File

@ -11,13 +11,14 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import * as api from "@/lib/api";
import { DIAGRAM_SQL_TYPES, isSchemaAware as isSchemaAwareDatabase } from "@/lib/databaseCapabilities";
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
import { buildDiagramRelationships, filterDiagramTables, layoutDiagramTables, type DiagramPosition, type DiagramRelationship, type DiagramTable } from "@/lib/erDiagram";
import { buildDiagramJoinSql, buildDiagramRelationships, filterDiagramTables, layoutDiagramTables, normalizeCustomDiagramRelationship, type CustomDiagramRelationship, type DiagramPosition, type DiagramRelationship, type DiagramTable } from "@/lib/erDiagram";
import { buildEngineeringDiagram } from "@/lib/engineeringDiagram";
import { buildEngineeringDiagramSvg, buildTableDiagramSvg, diagramSvgFileName } from "@/lib/diagramSvgExport";
import { clampDiagramZoom, zoomFromGestureScale, zoomFromWheelDelta } from "@/lib/diagramZoom";
import { Download, KeyRound, Link2, Loader2, Maximize2, Network, RefreshCw, Search, Table2, ZoomIn, ZoomOut } from "@lucide/vue";
import { Copy, Download, KeyRound, Link2, Loader2, Maximize2, Network, Plus, RefreshCw, Search, Table2, Trash2, X, ZoomIn, ZoomOut } from "@lucide/vue";
import { useToast } from "@/composables/useToast";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import { copyToClipboard } from "@/lib/clipboard";
const { t } = useI18n();
const { toast } = useToast();
@ -46,6 +47,7 @@ const schema = ref("");
const databases = ref<string[]>([]);
const schemas = ref<string[]>([]);
const tables = ref<DiagramTable[]>([]);
const customRelationships = ref<CustomDiagramRelationship[]>([]);
const tableSearch = ref("");
const loadingDatabases = ref(false);
const loadingSchemas = ref(false);
@ -57,6 +59,7 @@ const positions = ref<Record<string, DiagramPosition>>({});
const showAllTables = ref(false);
const diagramViewport = ref<HTMLDivElement | null>(null);
const diagramMode = ref<"table" | "engineering">("table");
const showRelationshipPanel = ref(false);
const zoom = ref(1);
const gestureStartZoom = ref(1);
const dragging = ref<{
@ -66,6 +69,14 @@ const dragging = ref<{
originX: number;
originY: number;
} | null>(null);
const relationshipDraft = ref({
name: "",
sourceTable: "",
sourceColumn: "",
targetTable: "",
targetColumn: "",
cardinality: "one-to-many" as "one-to-one" | "one-to-many" | "many-to-one",
});
const sqlConnections = computed(() => store.connections.filter((connection) => DIAGRAM_SQL_TYPES.has(connection.db_type)));
@ -73,7 +84,9 @@ const selectedConnection = computed(() => (connectionId.value ? store.getConfig(
const isSchemaAware = computed(() => isSchemaAwareDatabase(selectedConnection.value?.db_type));
const allRelationships = computed(() => buildDiagramRelationships(tables.value));
const tableMap = computed(() => new Map(tables.value.map((table) => [table.name, table])));
const allRelationships = computed(() => buildDiagramRelationships(tables.value, customRelationships.value));
const relatedTableNames = computed(() => {
const focus = props.focusTableName;
@ -97,12 +110,20 @@ const visibleTables = computed(() => {
const visibleTableMap = computed(() => new Map(visibleTables.value.map((table) => [table.name, table])));
const visibleRelationships = computed(() => buildDiagramRelationships(visibleTables.value));
const visibleRelationships = computed(() => buildDiagramRelationships(visibleTables.value, customRelationships.value));
const diagramReady = computed(() => !!connectionId.value && !!database.value && (!isSchemaAware.value || !!schema.value));
const loadingText = computed(() => (totalTableCount.value > 0 ? t("diagram.loadingProgress", { loaded: loadedTableCount.value, total: totalTableCount.value }) : t("diagram.loading")));
const sourceColumns = computed(() => tableMap.value.get(relationshipDraft.value.sourceTable)?.columns ?? []);
const targetColumns = computed(() => tableMap.value.get(relationshipDraft.value.targetTable)?.columns ?? []);
const generatedJoinSql = computed(() => buildDiagramJoinSql(visibleRelationships.value));
const customRelationshipCount = computed(() => customRelationships.value.length);
function connectionIconType(id: string) {
const config = store.getConfig(id);
return config?.driver_profile || config?.db_type || "mysql";
@ -154,8 +175,130 @@ function isForeignKeyColumn(table: DiagramTable, columnName: string): boolean {
return table.foreignKeys.some((fk) => fk.column === columnName);
}
function isRelationshipColumn(table: DiagramTable, columnName: string): boolean {
return visibleRelationships.value.some((relationship) => (relationship.sourceTable === table.name && relationship.sourceColumn === columnName) || (relationship.targetTable === table.name && relationship.targetColumn === columnName));
}
function relationshipTitle(relationship: DiagramRelationship): string {
return `${relationship.sourceTable}.${relationship.sourceColumn} -> ${relationship.targetTable}.${relationship.targetColumn}`;
return `${relationship.sourceTable}.${relationship.sourceColumn} (${relationship.sourceCardinality}:${relationship.targetCardinality}) -> ${relationship.targetTable}.${relationship.targetColumn}`;
}
function relationshipStorageKey(): string {
if (!connectionId.value || !database.value) return "";
return ["dbx", "diagram", "relationships", "v1", connectionId.value, database.value, schema.value || ""].join(":");
}
function isStoredRelationship(value: unknown): value is CustomDiagramRelationship {
const relationship = value as Partial<CustomDiagramRelationship>;
return (
typeof relationship?.id === "string" &&
typeof relationship.name === "string" &&
typeof relationship.sourceTable === "string" &&
typeof relationship.sourceColumn === "string" &&
typeof relationship.targetTable === "string" &&
typeof relationship.targetColumn === "string" &&
(relationship.sourceCardinality === "1" || relationship.sourceCardinality === "N") &&
(relationship.targetCardinality === "1" || relationship.targetCardinality === "N")
);
}
function loadCustomRelationships() {
const key = relationshipStorageKey();
if (!key || typeof localStorage === "undefined") {
customRelationships.value = [];
return;
}
try {
const parsed = JSON.parse(localStorage.getItem(key) || "[]");
customRelationships.value = Array.isArray(parsed) ? parsed.filter(isStoredRelationship) : [];
} catch {
customRelationships.value = [];
}
}
function saveCustomRelationships() {
const key = relationshipStorageKey();
if (!key || typeof localStorage === "undefined") return;
localStorage.setItem(key, JSON.stringify(customRelationships.value));
}
function defaultRelationshipName(relationship: Omit<CustomDiagramRelationship, "id" | "name">): string {
return `${relationship.sourceTable}_${relationship.sourceColumn}_${relationship.targetTable}_${relationship.targetColumn}`;
}
function relationshipCardinality(): Pick<CustomDiagramRelationship, "sourceCardinality" | "targetCardinality"> {
if (relationshipDraft.value.cardinality === "one-to-one") return { sourceCardinality: "1", targetCardinality: "1" };
if (relationshipDraft.value.cardinality === "many-to-one") return { sourceCardinality: "N", targetCardinality: "1" };
return { sourceCardinality: "1", targetCardinality: "N" };
}
function updateRelationshipDraftDefaults() {
const availableTables = tables.value.filter((table) => table.columns.length > 0);
if (availableTables.length === 0) return;
if (!tableMap.value.has(relationshipDraft.value.sourceTable)) {
relationshipDraft.value.sourceTable = availableTables[0].name;
}
if (!tableMap.value.has(relationshipDraft.value.targetTable)) {
relationshipDraft.value.targetTable = availableTables[1]?.name ?? availableTables[0].name;
}
if (!sourceColumns.value.some((column) => column.name === relationshipDraft.value.sourceColumn)) {
relationshipDraft.value.sourceColumn = sourceColumns.value[0]?.name ?? "";
}
if (!targetColumns.value.some((column) => column.name === relationshipDraft.value.targetColumn)) {
relationshipDraft.value.targetColumn = targetColumns.value[0]?.name ?? "";
}
}
function addCustomRelationship() {
updateRelationshipDraftDefaults();
const { sourceTable, sourceColumn, targetTable, targetColumn } = relationshipDraft.value;
if (!sourceTable || !sourceColumn || !targetTable || !targetColumn) {
toast(t("diagram.relationshipIncomplete"), 3000);
return;
}
if (sourceTable === targetTable && sourceColumn === targetColumn) {
toast(t("diagram.relationshipSelfInvalid"), 3000);
return;
}
const cardinality = relationshipCardinality();
const relationship = normalizeCustomDiagramRelationship({
name: relationshipDraft.value.name.trim() || defaultRelationshipName({ sourceTable, sourceColumn, targetTable, targetColumn, ...cardinality }),
sourceTable,
sourceColumn,
targetTable,
targetColumn,
...cardinality,
});
if (customRelationships.value.some((item) => item.id === relationship.id)) {
toast(t("diagram.relationshipExists"), 3000);
return;
}
customRelationships.value = [...customRelationships.value, relationship];
relationshipDraft.value.name = "";
saveCustomRelationships();
toast(t("diagram.relationshipAdded"), 2000);
}
function removeCustomRelationship(id: string) {
customRelationships.value = customRelationships.value.filter((relationship) => relationship.id !== id);
saveCustomRelationships();
}
async function copyJoinSql() {
if (!generatedJoinSql.value.trim()) {
toast(t("diagram.noJoinSql"), 3000);
return;
}
try {
await copyToClipboard(generatedJoinSql.value);
toast(t("grid.copied"));
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
function columnAnchorY(tableName: string, columnName: string): number {
@ -347,6 +490,7 @@ async function setConnection(id: string) {
database.value = "";
schema.value = "";
tables.value = [];
customRelationships.value = [];
positions.value = {};
await loadDatabases(id);
if (databases.value.length === 1) {
@ -357,6 +501,7 @@ async function setConnection(id: string) {
async function setDatabase(value: string) {
database.value = value;
tables.value = [];
customRelationships.value = [];
positions.value = {};
await loadSchemas();
if (diagramReady.value) await loadDiagram();
@ -365,6 +510,7 @@ async function setDatabase(value: string) {
async function setSchema(value: string) {
schema.value = value;
tables.value = [];
customRelationships.value = [];
positions.value = {};
if (diagramReady.value) await loadDiagram();
}
@ -405,6 +551,8 @@ async function loadDiagram() {
}
tables.value = loadedTables;
loadCustomRelationships();
updateRelationshipDraftDefaults();
showAllTables.value = false;
await nextTick();
resetLayout();
@ -425,8 +573,10 @@ async function initialize() {
databases.value = [];
schemas.value = [];
tables.value = [];
customRelationships.value = [];
tableSearch.value = "";
showAllTables.value = false;
showRelationshipPanel.value = false;
diagramMode.value = "table";
zoom.value = 1;
positions.value = {};
@ -599,6 +749,24 @@ watch(
},
);
watch(
() => relationshipDraft.value.sourceTable,
() => {
if (!sourceColumns.value.some((column) => column.name === relationshipDraft.value.sourceColumn)) {
relationshipDraft.value.sourceColumn = sourceColumns.value[0]?.name ?? "";
}
},
);
watch(
() => relationshipDraft.value.targetTable,
() => {
if (!targetColumns.value.some((column) => column.name === relationshipDraft.value.targetColumn)) {
relationshipDraft.value.targetColumn = targetColumns.value[0]?.name ?? "";
}
},
);
onUnmounted(stopDrag);
</script>
@ -665,6 +833,16 @@ onUnmounted(stopDrag);
</Button>
</div>
<Button variant="outline" size="sm" class="h-8 px-2 text-xs" :disabled="tables.length === 0" :title="t('diagram.modelRelationships')" @click="showRelationshipPanel = !showRelationshipPanel">
<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="!generatedJoinSql.trim()" :title="t('diagram.copyJoinSql')" @click="copyJoinSql">
<Copy class="mr-1 h-3.5 w-3.5" />
{{ t("diagram.copyJoinSql") }}
</Button>
<Button v-if="focusTableName && tables.length > 0" variant="outline" size="sm" class="h-8 px-2 text-xs" @click="showAllTables = !showAllTables">
{{ showAllTables ? t("diagram.relatedTables") : t("diagram.allTables") }}
</Button>
@ -675,6 +853,9 @@ onUnmounted(stopDrag);
<Badge variant="secondary" class="h-6 shrink-0">
{{ t("diagram.relationshipsCount", { count: visibleRelationships.length }) }}
</Badge>
<Badge v-if="customRelationshipCount > 0" variant="outline" class="h-6 shrink-0">
{{ t("diagram.customRelationshipsCount", { count: customRelationshipCount }) }}
</Badge>
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="loadingDiagram || visibleTables.length === 0" :title="t('diagram.exportSvg')" @click="exportSvg">
<Download class="h-4 w-4" />
@ -694,155 +875,246 @@ onUnmounted(stopDrag);
</Button>
</div>
<div class="flex-1 min-h-0 bg-muted/20">
<div v-if="loadingDiagram" class="h-full flex items-center justify-center text-sm text-muted-foreground">
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{{ loadingText }}
<div class="flex min-h-0 flex-1 flex-col bg-muted/20">
<div v-if="showRelationshipPanel && tables.length > 0" class="shrink-0 border-b bg-background/95 px-3 py-2">
<div class="flex flex-wrap items-end gap-2">
<div class="w-44">
<div class="mb-1 text-[11px] font-medium text-muted-foreground">{{ t("diagram.relationshipName") }}</div>
<Input v-model="relationshipDraft.name" class="h-8 text-xs" :placeholder="t('diagram.relationshipNamePlaceholder')" />
</div>
<div class="w-44">
<div class="mb-1 text-[11px] font-medium text-muted-foreground">{{ t("diagram.sourceTable") }}</div>
<Select v-model="relationshipDraft.sourceTable">
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('diagram.sourceTable')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="table in tables" :key="`source-${table.name}`" :value="table.name" :disabled="table.columns.length === 0">{{ table.name }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="w-44">
<div class="mb-1 text-[11px] font-medium text-muted-foreground">{{ t("diagram.sourceColumn") }}</div>
<Select v-model="relationshipDraft.sourceColumn">
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('diagram.sourceColumn')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="column in sourceColumns" :key="`source-column-${column.name}`" :value="column.name">{{ column.name }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="w-32">
<div class="mb-1 text-[11px] font-medium text-muted-foreground">{{ t("diagram.cardinality") }}</div>
<Select v-model="relationshipDraft.cardinality">
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="one-to-many">{{ t("diagram.cardinalityOneToMany") }}</SelectItem>
<SelectItem value="many-to-one">{{ t("diagram.cardinalityManyToOne") }}</SelectItem>
<SelectItem value="one-to-one">{{ t("diagram.cardinalityOneToOne") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="w-44">
<div class="mb-1 text-[11px] font-medium text-muted-foreground">{{ t("diagram.targetTable") }}</div>
<Select v-model="relationshipDraft.targetTable">
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('diagram.targetTable')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="table in tables" :key="`target-${table.name}`" :value="table.name" :disabled="table.columns.length === 0">{{ table.name }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="w-44">
<div class="mb-1 text-[11px] font-medium text-muted-foreground">{{ t("diagram.targetColumn") }}</div>
<Select v-model="relationshipDraft.targetColumn">
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('diagram.targetColumn')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="column in targetColumns" :key="`target-column-${column.name}`" :value="column.name">{{ column.name }}</SelectItem>
</SelectContent>
</Select>
</div>
<Button variant="default" size="sm" class="h-8 px-2 text-xs" @click="addCustomRelationship">
<Plus class="mr-1 h-3.5 w-3.5" />
{{ t("diagram.addRelationship") }}
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" :title="t('common.close')" @click="showRelationshipPanel = false">
<X class="h-4 w-4" />
</Button>
</div>
<div v-if="customRelationships.length > 0" class="mt-2 flex flex-wrap gap-1.5">
<Badge v-for="relationship in customRelationships" :key="relationship.id" variant="secondary" class="gap-1 pr-1">
<span class="max-w-80 truncate">{{ relationship.sourceTable }}.{{ relationship.sourceColumn }} {{ relationship.sourceCardinality }}:{{ relationship.targetCardinality }} {{ relationship.targetTable }}.{{ relationship.targetColumn }}</span>
<button type="button" class="rounded-sm p-0.5 hover:bg-background/80" :title="t('diagram.removeRelationship')" @click="removeCustomRelationship(relationship.id)">
<Trash2 class="h-3 w-3" />
</button>
</Badge>
</div>
</div>
<div v-else-if="!diagramReady" class="h-full flex items-center justify-center text-sm text-muted-foreground">
{{ t("diagram.selectTarget") }}
</div>
<div v-else-if="tables.length === 0" class="h-full flex items-center justify-center text-sm text-muted-foreground">
{{ t("diagram.empty") }}
</div>
<div v-else-if="visibleTables.length === 0" class="h-full flex items-center justify-center text-sm text-muted-foreground">
{{ t("diagram.noMatches") }}
</div>
<div v-else ref="diagramViewport" class="h-full overflow-auto" @wheel="onDiagramWheel" @gesturestart="onDiagramGestureStart" @gesturechange="onDiagramGestureChange">
<div
class="relative"
:style="{
width: `${activeCanvasSize.width * zoom}px`,
height: `${activeCanvasSize.height * zoom}px`,
}"
>
<div class="min-h-0 flex-1">
<div v-if="loadingDiagram" class="h-full flex items-center justify-center text-sm text-muted-foreground">
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{{ loadingText }}
</div>
<div v-else-if="!diagramReady" class="h-full flex items-center justify-center text-sm text-muted-foreground">
{{ t("diagram.selectTarget") }}
</div>
<div v-else-if="tables.length === 0" class="h-full flex items-center justify-center text-sm text-muted-foreground">
{{ t("diagram.empty") }}
</div>
<div v-else-if="visibleTables.length === 0" class="h-full flex items-center justify-center text-sm text-muted-foreground">
{{ t("diagram.noMatches") }}
</div>
<div v-else ref="diagramViewport" class="h-full overflow-auto" @wheel="onDiagramWheel" @gesturestart="onDiagramGestureStart" @gesturechange="onDiagramGestureChange">
<div
class="absolute left-0 top-0 origin-top-left"
class="relative"
:style="{
width: `${activeCanvasSize.width}px`,
height: `${activeCanvasSize.height}px`,
transform: `scale(${zoom})`,
width: `${activeCanvasSize.width * zoom}px`,
height: `${activeCanvasSize.height * zoom}px`,
}"
>
<template v-if="diagramMode === 'table'">
<svg class="absolute inset-0 h-full w-full overflow-visible pointer-events-none">
<defs>
<marker id="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" class="fill-primary/70" />
</marker>
</defs>
<path v-for="relationship in visibleRelationships" :key="relationship.id" :d="relationshipPath(relationship)" class="fill-none stroke-primary/55" stroke-width="1.6" marker-end="url(#diagram-arrow)">
<title>{{ relationshipTitle(relationship) }}</title>
</path>
</svg>
<div
class="absolute left-0 top-0 origin-top-left"
:style="{
width: `${activeCanvasSize.width}px`,
height: `${activeCanvasSize.height}px`,
transform: `scale(${zoom})`,
}"
>
<template v-if="diagramMode === 'table'">
<svg class="absolute inset-0 h-full w-full overflow-visible pointer-events-none">
<defs>
<marker id="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" class="fill-primary/70" />
</marker>
</defs>
<path v-for="relationship in visibleRelationships" :key="relationship.id" :d="relationshipPath(relationship)" class="fill-none stroke-primary/55" stroke-width="1.6" marker-end="url(#diagram-arrow)">
<title>{{ relationshipTitle(relationship) }}</title>
</path>
</svg>
<div
v-for="table in visibleTables"
:key="table.name"
class="absolute overflow-hidden rounded-md border bg-background shadow-sm"
:class="table.name === focusTableName ? 'border-primary ring-1 ring-primary/30' : 'border-border'"
:style="{
width: `${CARD_WIDTH}px`,
transform: `translate(${positions[table.name]?.x ?? 0}px, ${positions[table.name]?.y ?? 0}px)`,
}"
>
<div class="flex h-11 cursor-grab items-center gap-2 border-b bg-muted/40 px-3 active:cursor-grabbing" @mousedown="startDrag(table.name, $event)">
<Table2 class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ table.name }}</span>
<Badge variant="outline" class="h-5 px-1.5 text-[10px]">{{ table.columns.length }}</Badge>
</div>
<div>
<div v-for="column in visibleColumns(table)" :key="column.name" class="flex h-6 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(table, column.name)" class="h-3 w-3 shrink-0 text-primary" />
<span v-else class="h-3 w-3 shrink-0" />
<span class="min-w-0 flex-1 truncate font-mono">{{ column.name }}</span>
<span class="max-w-24 truncate text-[10px] text-muted-foreground">{{ column.data_type }}</span>
<div
v-for="table in visibleTables"
:key="table.name"
class="absolute overflow-hidden rounded-md border bg-background shadow-sm"
:class="table.name === focusTableName ? 'border-primary ring-1 ring-primary/30' : 'border-border'"
:style="{
width: `${CARD_WIDTH}px`,
transform: `translate(${positions[table.name]?.x ?? 0}px, ${positions[table.name]?.y ?? 0}px)`,
}"
>
<div class="flex h-11 cursor-grab items-center gap-2 border-b bg-muted/40 px-3 active:cursor-grabbing" @mousedown="startDrag(table.name, $event)">
<Table2 class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ table.name }}</span>
<Badge variant="outline" class="h-5 px-1.5 text-[10px]">{{ table.columns.length }}</Badge>
</div>
<div v-if="hiddenColumnCount(table) > 0" class="h-6 px-3 text-xs leading-6 text-muted-foreground">
{{ t("diagram.moreColumns", { count: hiddenColumnCount(table) }) }}
<div>
<div v-for="column in visibleColumns(table)" :key="column.name" class="flex h-6 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(table, column.name)" class="h-3 w-3 shrink-0 text-primary" />
<Link2 v-else-if="isRelationshipColumn(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">{{ column.name }}</span>
<span class="max-w-24 truncate text-[10px] text-muted-foreground">{{ column.data_type }}</span>
</div>
<div v-if="hiddenColumnCount(table) > 0" class="h-6 px-3 text-xs leading-6 text-muted-foreground">
{{ t("diagram.moreColumns", { count: hiddenColumnCount(table) }) }}
</div>
</div>
</div>
</div>
</template>
</template>
<template v-else>
<svg class="absolute inset-0 h-full w-full overflow-visible pointer-events-none">
<g class="stroke-foreground/70">
<line
v-for="attribute in engineeringDiagram.attributes"
:key="attribute.id"
:x1="engineeringEntityCenter(attribute.tableName).x"
:y1="engineeringEntityCenter(attribute.tableName).y"
:x2="engineeringAttributeCenter(attribute).x"
:y2="engineeringAttributeCenter(attribute).y"
stroke-width="1.2"
/>
<template v-for="relationship in engineeringDiagram.relationships" :key="relationship.id">
<line :x1="engineeringEntityCenter(relationship.sourceTable).x" :y1="engineeringEntityCenter(relationship.sourceTable).y" :x2="engineeringRelationshipCenter(relationship).x" :y2="engineeringRelationshipCenter(relationship).y" stroke-width="1.4" />
<line :x1="engineeringRelationshipCenter(relationship).x" :y1="engineeringRelationshipCenter(relationship).y" :x2="engineeringEntityCenter(relationship.targetTable).x" :y2="engineeringEntityCenter(relationship.targetTable).y" stroke-width="1.4" />
<text
class="fill-foreground text-[13px] font-semibold"
:x="engineeringCardinalityPoint(engineeringRelationshipCenter(relationship), engineeringEntityCenter(relationship.sourceTable)).x"
:y="engineeringCardinalityPoint(engineeringRelationshipCenter(relationship), engineeringEntityCenter(relationship.sourceTable)).y"
>
{{ relationship.sourceCardinality }}
</text>
<text
class="fill-foreground text-[13px] font-semibold"
:x="engineeringCardinalityPoint(engineeringRelationshipCenter(relationship), engineeringEntityCenter(relationship.targetTable)).x"
:y="engineeringCardinalityPoint(engineeringRelationshipCenter(relationship), engineeringEntityCenter(relationship.targetTable)).y"
>
{{ relationship.targetCardinality }}
</text>
</template>
</g>
</svg>
<template v-else>
<svg class="absolute inset-0 h-full w-full overflow-visible pointer-events-none">
<g class="stroke-foreground/70">
<line
v-for="attribute in engineeringDiagram.attributes"
:key="attribute.id"
:x1="engineeringEntityCenter(attribute.tableName).x"
:y1="engineeringEntityCenter(attribute.tableName).y"
:x2="engineeringAttributeCenter(attribute).x"
:y2="engineeringAttributeCenter(attribute).y"
stroke-width="1.2"
/>
<template v-for="relationship in engineeringDiagram.relationships" :key="relationship.id">
<line :x1="engineeringEntityCenter(relationship.sourceTable).x" :y1="engineeringEntityCenter(relationship.sourceTable).y" :x2="engineeringRelationshipCenter(relationship).x" :y2="engineeringRelationshipCenter(relationship).y" stroke-width="1.4" />
<line :x1="engineeringRelationshipCenter(relationship).x" :y1="engineeringRelationshipCenter(relationship).y" :x2="engineeringEntityCenter(relationship.targetTable).x" :y2="engineeringEntityCenter(relationship.targetTable).y" stroke-width="1.4" />
<text
class="fill-foreground text-[13px] font-semibold"
:x="engineeringCardinalityPoint(engineeringRelationshipCenter(relationship), engineeringEntityCenter(relationship.sourceTable)).x"
:y="engineeringCardinalityPoint(engineeringRelationshipCenter(relationship), engineeringEntityCenter(relationship.sourceTable)).y"
>
{{ relationship.sourceCardinality }}
</text>
<text
class="fill-foreground text-[13px] font-semibold"
:x="engineeringCardinalityPoint(engineeringRelationshipCenter(relationship), engineeringEntityCenter(relationship.targetTable)).x"
:y="engineeringCardinalityPoint(engineeringRelationshipCenter(relationship), engineeringEntityCenter(relationship.targetTable)).y"
>
{{ relationship.targetCardinality }}
</text>
</template>
</g>
</svg>
<div
v-for="attribute in engineeringDiagram.attributes"
:key="attribute.id"
class="absolute flex items-center justify-center rounded-full border border-green-600/55 bg-green-100/80 px-3 text-center text-xs text-green-950 shadow-sm dark:bg-green-950/35 dark:text-green-100"
:class="attribute.primaryKey ? 'font-semibold underline underline-offset-2' : ''"
:title="`${attribute.tableName}.${attribute.columnName}: ${attribute.dataType}`"
:style="{
width: `${attribute.width}px`,
height: `${attribute.height}px`,
transform: `translate(${attribute.x}px, ${attribute.y}px)`,
}"
>
<span class="truncate">{{ attribute.label }}</span>
</div>
<div
v-for="attribute in engineeringDiagram.attributes"
:key="attribute.id"
class="absolute flex items-center justify-center rounded-full border border-green-600/55 bg-green-100/80 px-3 text-center text-xs text-green-950 shadow-sm dark:bg-green-950/35 dark:text-green-100"
:class="attribute.primaryKey ? 'font-semibold underline underline-offset-2' : ''"
:title="`${attribute.tableName}.${attribute.columnName}: ${attribute.dataType}`"
:style="{
width: `${attribute.width}px`,
height: `${attribute.height}px`,
transform: `translate(${attribute.x}px, ${attribute.y}px)`,
}"
>
<span class="truncate">{{ attribute.label }}</span>
</div>
<div
v-for="relationship in engineeringDiagram.relationships"
:key="relationship.id"
class="absolute flex items-center justify-center text-center text-xs font-medium text-red-950 dark:text-red-100"
:style="{
width: `${relationship.width}px`,
height: `${relationship.height}px`,
transform: `translate(${relationship.x}px, ${relationship.y}px)`,
}"
:title="`${relationship.sourceTable} -> ${relationship.targetTable}`"
>
<div class="absolute inset-0 border border-red-500/70 bg-red-100/80 dark:bg-red-950/35" style="clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%)" />
<span class="relative max-w-[70px] truncate">{{ relationship.label }}</span>
</div>
<div
v-for="relationship in engineeringDiagram.relationships"
:key="relationship.id"
class="absolute flex items-center justify-center text-center text-xs font-medium text-red-950 dark:text-red-100"
:style="{
width: `${relationship.width}px`,
height: `${relationship.height}px`,
transform: `translate(${relationship.x}px, ${relationship.y}px)`,
}"
:title="`${relationship.sourceTable} -> ${relationship.targetTable}`"
>
<div class="absolute inset-0 border border-red-500/70 bg-red-100/80 dark:bg-red-950/35" style="clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%)" />
<span class="relative max-w-[70px] truncate">{{ relationship.label }}</span>
</div>
<div
v-for="entity in engineeringDiagram.entities"
:key="entity.id"
class="absolute flex items-center justify-center border border-blue-500/70 bg-blue-100/80 px-3 text-center text-sm font-semibold text-blue-950 shadow-sm dark:bg-blue-950/35 dark:text-blue-100"
:class="entity.name === focusTableName ? 'ring-2 ring-primary/40' : ''"
:style="{
width: `${entity.width}px`,
height: `${entity.height}px`,
transform: `translate(${entity.x}px, ${entity.y}px)`,
}"
>
<span class="truncate">{{ entity.name }}</span>
</div>
</template>
<div
v-for="entity in engineeringDiagram.entities"
:key="entity.id"
class="absolute flex items-center justify-center border border-blue-500/70 bg-blue-100/80 px-3 text-center text-sm font-semibold text-blue-950 shadow-sm dark:bg-blue-950/35 dark:text-blue-100"
:class="entity.name === focusTableName ? 'ring-2 ring-primary/40' : ''"
:style="{
width: `${entity.width}px`,
height: `${entity.height}px`,
transform: `translate(${entity.x}px, ${entity.y}px)`,
}"
>
<span class="truncate">{{ entity.name }}</span>
</div>
</template>
</div>
</div>
</div>
</div>

View File

@ -1566,6 +1566,26 @@ export default {
relationshipsCount: "{count} relationships",
tableMode: "Table View",
engineeringMode: "Engineering ER",
modelRelationships: "Model Relationships",
copyJoinSql: "Copy SQL",
customRelationshipsCount: "{count} modeled",
relationshipName: "Name",
relationshipNamePlaceholder: "Optional",
sourceTable: "Source table",
sourceColumn: "Source column",
targetTable: "Target table",
targetColumn: "Target column",
cardinality: "Cardinality",
cardinalityOneToMany: "1:N",
cardinalityManyToOne: "N:1",
cardinalityOneToOne: "1:1",
addRelationship: "Add relationship",
removeRelationship: "Remove relationship",
relationshipIncomplete: "Select both source and target columns",
relationshipSelfInvalid: "A relationship cannot target the same column",
relationshipExists: "This relationship already exists",
relationshipAdded: "Relationship added",
noJoinSql: "No relationship SQL to copy",
allTables: "All Tables",
relatedTables: "Related Tables",
moreColumns: "+ {count} columns",

View File

@ -1540,6 +1540,26 @@ export default {
relationshipsCount: "{count}リレーション",
tableMode: "テーブルビュー",
engineeringMode: "エンジニアリングER",
modelRelationships: "モデル関係",
copyJoinSql: "SQLをコピー",
customRelationshipsCount: "{count}件モデル化",
relationshipName: "名前",
relationshipNamePlaceholder: "任意",
sourceTable: "ソーステーブル",
sourceColumn: "ソース列",
targetTable: "ターゲットテーブル",
targetColumn: "ターゲット列",
cardinality: "カーディナリティ",
cardinalityOneToMany: "1:N",
cardinalityManyToOne: "N:1",
cardinalityOneToOne: "1:1",
addRelationship: "関係を追加",
removeRelationship: "関係を削除",
relationshipIncomplete: "ソース列とターゲット列を選択してください",
relationshipSelfInvalid: "同じ列への関係は作成できません",
relationshipExists: "この関係はすでに存在します",
relationshipAdded: "関係を追加しました",
noJoinSql: "コピーできる関係SQLがありません",
allTables: "全テーブル",
relatedTables: "関連テーブル",
moreColumns: "+ {count}列",

View File

@ -1565,6 +1565,26 @@ export default {
relationshipsCount: "{count} 条关系",
tableMode: "表结构图",
engineeringMode: "工程 ER 图",
modelRelationships: "建模关系",
copyJoinSql: "复制 SQL",
customRelationshipsCount: "{count} 条建模",
relationshipName: "名称",
relationshipNamePlaceholder: "可选",
sourceTable: "源表",
sourceColumn: "源字段",
targetTable: "目标表",
targetColumn: "目标字段",
cardinality: "基数",
cardinalityOneToMany: "1:N",
cardinalityManyToOne: "N:1",
cardinalityOneToOne: "1:1",
addRelationship: "添加关系",
removeRelationship: "删除关系",
relationshipIncomplete: "请选择源字段和目标字段",
relationshipSelfInvalid: "关系不能指向同一个字段",
relationshipExists: "该关系已存在",
relationshipAdded: "关系已添加",
noJoinSql: "暂无可复制的关系 SQL",
allTables: "全部表",
relatedTables: "相关表",
moreColumns: "+ {count} 个字段",

View File

@ -336,8 +336,8 @@ export function buildEngineeringDiagram(tables: DiagramTable[], relationships: D
label: relationshipLabel(relationship),
sourceTable: relationship.sourceTable,
targetTable: relationship.targetTable,
sourceCardinality: "N",
targetCardinality: "1",
sourceCardinality: relationship.sourceCardinality ?? "N",
targetCardinality: relationship.targetCardinality ?? "1",
x: (sourceCenter.x + targetCenter.x) / 2 - ENGINEERING_RELATIONSHIP_WIDTH / 2,
y: (sourceCenter.y + targetCenter.y) / 2 - ENGINEERING_RELATIONSHIP_HEIGHT / 2,
width: ENGINEERING_RELATIONSHIP_WIDTH,

View File

@ -9,10 +9,29 @@ export interface DiagramTable {
export interface DiagramRelationship {
id: string;
name: string;
kind: "foreign-key" | "custom";
sourceTable: string;
sourceColumn: string;
targetTable: string;
targetColumn: string;
sourceCardinality: "1" | "N";
targetCardinality: "1" | "N";
}
export interface CustomDiagramRelationship {
id: string;
name: string;
sourceTable: string;
sourceColumn: string;
targetTable: string;
targetColumn: string;
sourceCardinality: "1" | "N";
targetCardinality: "1" | "N";
}
export interface DiagramJoinSqlOptions {
joinType?: "INNER JOIN" | "LEFT JOIN";
rootTable?: string;
}
export interface DiagramPosition {
@ -33,21 +52,50 @@ function relationshipId(sourceTable: string, fk: ForeignKeyInfo): string {
return [sourceTable, fk.name || "foreign_key", fk.column, fk.ref_table, fk.ref_column].join(":");
}
export function buildDiagramRelationships(tables: DiagramTable[]): DiagramRelationship[] {
const visibleTableNames = new Set(tables.map((table) => table.name));
function columnExists(table: DiagramTable | undefined, columnName: string): boolean {
return !!table?.columns.some((column) => column.name === columnName);
}
return tables.flatMap((table) =>
function customRelationshipId(relationship: Omit<CustomDiagramRelationship, "id">): string {
return ["custom", relationship.sourceTable, relationship.sourceColumn, relationship.targetTable, relationship.targetColumn, relationship.sourceCardinality, relationship.targetCardinality].join(":");
}
export function normalizeCustomDiagramRelationship(input: Omit<CustomDiagramRelationship, "id"> & { id?: string }): CustomDiagramRelationship {
return {
...input,
id: input.id || customRelationshipId(input),
};
}
export function buildDiagramRelationships(tables: DiagramTable[], customRelationships: CustomDiagramRelationship[] = []): DiagramRelationship[] {
const visibleTableNames = new Set(tables.map((table) => table.name));
const tableMap = new Map(tables.map((table) => [table.name, table]));
const foreignKeyRelationships = tables.flatMap((table) =>
table.foreignKeys
.filter((fk) => visibleTableNames.has(fk.ref_table))
.map((fk) => ({
id: relationshipId(table.name, fk),
name: fk.name,
kind: "foreign-key" as const,
sourceTable: table.name,
sourceColumn: fk.column,
targetTable: fk.ref_table,
targetColumn: fk.ref_column,
sourceCardinality: "N" as const,
targetCardinality: "1" as const,
})),
);
const custom = customRelationships
.filter((relationship) => visibleTableNames.has(relationship.sourceTable) && visibleTableNames.has(relationship.targetTable))
.filter((relationship) => columnExists(tableMap.get(relationship.sourceTable), relationship.sourceColumn) && columnExists(tableMap.get(relationship.targetTable), relationship.targetColumn))
.map((relationship) => ({
...relationship,
kind: "custom" as const,
}));
return [...foreignKeyRelationships, ...custom];
}
export function filterDiagramTables(tables: DiagramTable[], query: string): DiagramTable[] {
@ -83,3 +131,62 @@ export function layoutDiagramTables(tables: Pick<DiagramTable, "name" | "columns
}),
);
}
function quoteIdentifier(value: string): string {
if (/^[A-Za-z_][A-Za-z0-9_$]*$/.test(value)) return value;
return `"${value.replace(/"/g, '""')}"`;
}
function relationshipCondition(relationship: DiagramRelationship, aliases: Map<string, string>): string {
const sourceAlias = aliases.get(relationship.sourceTable) ?? quoteIdentifier(relationship.sourceTable);
const targetAlias = aliases.get(relationship.targetTable) ?? quoteIdentifier(relationship.targetTable);
return `${sourceAlias}.${quoteIdentifier(relationship.sourceColumn)} = ${targetAlias}.${quoteIdentifier(relationship.targetColumn)}`;
}
function nextJoinableRelationship(relationships: DiagramRelationship[], joinedTables: Set<string>, consumedRelationships: Set<string>): DiagramRelationship | undefined {
return relationships.find((relationship) => {
if (consumedRelationships.has(relationship.id)) return false;
const sourceJoined = joinedTables.has(relationship.sourceTable);
const targetJoined = joinedTables.has(relationship.targetTable);
return sourceJoined !== targetJoined || (!sourceJoined && !targetJoined && joinedTables.size === 0);
});
}
export function buildDiagramJoinSql(relationships: DiagramRelationship[], options: DiagramJoinSqlOptions = {}): string {
const joinableRelationships = relationships.filter((relationship) => relationship.sourceTable && relationship.sourceColumn && relationship.targetTable && relationship.targetColumn);
if (joinableRelationships.length === 0) return "";
const joinType = options.joinType ?? "LEFT JOIN";
const rootTable = options.rootTable && joinableRelationships.some((relationship) => relationship.sourceTable === options.rootTable || relationship.targetTable === options.rootTable) ? options.rootTable : joinableRelationships[0].sourceTable;
const joinedTables = new Set<string>([rootTable]);
const aliases = new Map<string, string>([[rootTable, "t1"]]);
const consumedRelationships = new Set<string>();
const joinLines: string[] = [];
while (consumedRelationships.size < joinableRelationships.length) {
const relationship = nextJoinableRelationship(joinableRelationships, joinedTables, consumedRelationships);
if (!relationship) break;
const sourceJoined = joinedTables.has(relationship.sourceTable);
const targetJoined = joinedTables.has(relationship.targetTable);
const tableToJoin = sourceJoined && !targetJoined ? relationship.targetTable : relationship.sourceTable;
if (!joinedTables.has(tableToJoin)) {
const previousJoinedTables = new Set(joinedTables);
aliases.set(tableToJoin, `t${aliases.size + 1}`);
joinedTables.add(tableToJoin);
const joinConditions = joinableRelationships.filter((item) => !consumedRelationships.has(item.id) && ((item.sourceTable === tableToJoin && previousJoinedTables.has(item.targetTable)) || (item.targetTable === tableToJoin && previousJoinedTables.has(item.sourceTable))));
joinConditions.forEach((item) => consumedRelationships.add(item.id));
joinLines.push(`${joinType} ${quoteIdentifier(tableToJoin)} ${aliases.get(tableToJoin)} ON ${joinConditions.map((item) => relationshipCondition(item, aliases)).join(" AND ")}`);
}
consumedRelationships.add(relationship.id);
}
const whereRelationships = joinableRelationships.filter((relationship) => !consumedRelationships.has(relationship.id) && joinedTables.has(relationship.sourceTable) && joinedTables.has(relationship.targetTable));
whereRelationships.forEach((relationship) => consumedRelationships.add(relationship.id));
const whereLine = whereRelationships.length > 0 ? `WHERE ${whereRelationships.map((relationship) => relationshipCondition(relationship, aliases)).join(" AND ")}` : "";
const selectList = [...joinedTables].map((table) => ` ${aliases.get(table)}.*`).join(",\n");
const disconnectedRelationships = joinableRelationships.filter((relationship) => !consumedRelationships.has(relationship.id));
const disconnectedNotes = disconnectedRelationships.map((relationship) => `-- Disconnected relationship skipped: ${relationship.sourceTable}.${relationship.sourceColumn} = ${relationship.targetTable}.${relationship.targetColumn}`);
return [`SELECT`, selectList, `FROM ${quoteIdentifier(rootTable)} ${aliases.get(rootTable)}`, ...joinLines, whereLine, ...disconnectedNotes].filter(Boolean).join("\n");
}

View File

@ -1,6 +1,6 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { buildDiagramRelationships, filterDiagramTables, layoutDiagramTables } from "../../apps/desktop/src/lib/erDiagram.ts";
import { buildDiagramJoinSql, buildDiagramRelationships, filterDiagramTables, layoutDiagramTables, normalizeCustomDiagramRelationship } from "../../apps/desktop/src/lib/erDiagram.ts";
test("builds relationships only between tables in the diagram", () => {
const relationships = buildDiagramRelationships([
@ -23,14 +23,86 @@ test("builds relationships only between tables in the diagram", () => {
{
id: "orders:orders_user_id_fk:user_id:users:id",
name: "orders_user_id_fk",
kind: "foreign-key",
sourceTable: "orders",
sourceColumn: "user_id",
targetTable: "users",
targetColumn: "id",
sourceCardinality: "N",
targetCardinality: "1",
},
]);
});
test("merges valid custom relationships with foreign key relationships", () => {
const relationship = normalizeCustomDiagramRelationship({
name: "users_audit",
sourceTable: "users",
sourceColumn: "email",
targetTable: "audit_log",
targetColumn: "actor_email",
sourceCardinality: "1",
targetCardinality: "N",
});
const relationships = buildDiagramRelationships(
[
{
name: "users",
columns: [{ name: "email", data_type: "varchar", is_nullable: false, column_default: null, is_primary_key: false, extra: null }],
foreignKeys: [],
},
{
name: "audit_log",
columns: [{ name: "actor_email", data_type: "varchar", is_nullable: true, column_default: null, is_primary_key: false, extra: null }],
foreignKeys: [],
},
],
[relationship],
);
assert.deepEqual(relationships, [
{
...relationship,
kind: "custom",
},
]);
});
test("ignores custom relationships with missing tables or columns", () => {
const relationships = buildDiagramRelationships(
[
{
name: "users",
columns: [{ name: "id", data_type: "int", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
foreignKeys: [],
},
],
[
normalizeCustomDiagramRelationship({
name: "missing_table",
sourceTable: "users",
sourceColumn: "id",
targetTable: "orders",
targetColumn: "user_id",
sourceCardinality: "1",
targetCardinality: "N",
}),
normalizeCustomDiagramRelationship({
name: "missing_column",
sourceTable: "users",
sourceColumn: "email",
targetTable: "users",
targetColumn: "id",
sourceCardinality: "1",
targetCardinality: "1",
}),
],
);
assert.deepEqual(relationships, []);
});
test("filters diagram tables by table, column, and foreign key names", () => {
const tables = [
{
@ -75,3 +147,62 @@ test("lays out diagram tables in stable rows", () => {
line_items: { x: 40, y: 250 },
});
});
test("generates join SQL from diagram relationships", () => {
const relationships = buildDiagramRelationships(
[
{
name: "users",
columns: [{ name: "id", data_type: "int", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
foreignKeys: [],
},
{
name: "orders",
columns: [{ name: "user_id", data_type: "int", 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(
buildDiagramJoinSql(relationships),
`SELECT
t1.*,
t2.*
FROM orders t1
LEFT JOIN users t2 ON t1.user_id = t2.id`,
);
});
test("combines multiple relationship conditions between joined tables", () => {
const relationships = [
normalizeCustomDiagramRelationship({
name: "orders_customer_id",
sourceTable: "orders",
sourceColumn: "customer_id",
targetTable: "customers",
targetColumn: "id",
sourceCardinality: "N",
targetCardinality: "1",
}),
normalizeCustomDiagramRelationship({
name: "orders_customer_region",
sourceTable: "orders",
sourceColumn: "customer_region",
targetTable: "customers",
targetColumn: "region",
sourceCardinality: "N",
targetCardinality: "1",
}),
].map((relationship) => ({ ...relationship, kind: "custom" as const }));
assert.equal(
buildDiagramJoinSql(relationships),
`SELECT
t1.*,
t2.*
FROM orders t1
LEFT JOIN customers t2 ON t1.customer_id = t2.id AND t1.customer_region = t2.region`,
);
});