feat(diagram): add modeled ERD relationships
This commit is contained in:
parent
3e8c4273b2
commit
2b99cd3b62
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}列",
|
||||
|
|
|
|||
|
|
@ -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} 个字段",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue