feat(diagram): support multi-schema and full database relationship views

This commit is contained in:
sunzhengbo
2026-09-04 10:25:14 +08:00
committed by GitHub
parent d16645121d
commit 6d39eccdbf
18 changed files with 844 additions and 173 deletions
@@ -61,9 +61,11 @@ const structureCapabilities = computed(() => getTableStructureCapabilities(props
const selectedTable = computed(() => {
const target = props.target;
if (target?.kind !== "table") return null;
return props.tables.find((t) => t.name === target.tableName) ?? null;
return props.tables.find((t) => t.name === target.tableName || (t.schema && `${t.schema}.${t.name}` === target.tableName)) ?? null;
});
const selectedTableIdentifier = computed(() => (props.target?.kind === "table" ? props.target.tableName : (selectedTable.value?.name ?? "")));
const selectedEdge = computed(() => {
const target = props.target;
if (target?.kind !== "edge") return null;
@@ -253,7 +255,7 @@ function confirmDeleteLiveTable() {
const table = selectedTable.value;
if (!table || isDraftTable(table)) return;
confirmingDropTable.value = false;
emit("delete-live-table", table.name);
emit("delete-live-table", selectedTableIdentifier.value);
}
function cancelDeleteLiveTable() {
@@ -554,7 +556,7 @@ const dataTypeOptionsForColumn = computed(() => {
</template>
</template>
<Button v-if="editable" type="button" variant="destructive" size="sm" class="w-full h-8 text-xs" @click="emit('delete-draft-table', selectedTable.name)">
<Button v-if="editable" type="button" variant="destructive" size="sm" class="w-full h-8 text-xs" @click="emit('delete-draft-table', selectedTableIdentifier)">
{{ t("diagram.deleteDraftTable") }}
</Button>
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { CheckSquare, Square, ChevronDown, Search, Database, Layers, Loader2, X } from "@lucide/vue";
const props = withDefaults(
defineProps<{
schemas: string[];
selectedSchemas: string[];
loading?: boolean;
disabled?: boolean;
}>(),
{
loading: false,
disabled: false,
},
);
const emit = defineEmits<{
(e: "update:selectedSchemas", value: string[]): void;
}>();
const { t } = useI18n();
const open = ref(false);
const searchQuery = ref("");
const selectedSet = computed(() => new Set(props.selectedSchemas));
const isAllSelected = computed(() => props.schemas.length > 0 && props.schemas.every((s) => selectedSet.value.has(s)));
const filteredSchemas = computed(() => {
const q = searchQuery.value.trim().toLowerCase();
if (!q) return props.schemas;
return props.schemas.filter((s) => s.toLowerCase().includes(q));
});
const triggerLabel = computed(() => {
if (props.loading) return t("common.loading");
if (props.schemas.length === 0) return t("diagram.selectSchema");
if (isAllSelected.value) return t("diagram.allSchemas");
if (props.selectedSchemas.length === 1) return props.selectedSchemas[0];
if (props.selectedSchemas.length > 1) {
return t("diagram.selectedSchemasCount", { count: props.selectedSchemas.length });
}
return t("diagram.selectSchema");
});
function toggleSchema(name: string) {
if (selectedSet.value.has(name)) {
emit(
"update:selectedSchemas",
props.selectedSchemas.filter((s) => s !== name),
);
} else {
emit("update:selectedSchemas", [...props.selectedSchemas, name]);
}
}
function selectOnly(name: string, event: MouseEvent) {
event.stopPropagation();
emit("update:selectedSchemas", [name]);
}
function toggleAll() {
if (isAllSelected.value) {
emit("update:selectedSchemas", []);
} else {
emit("update:selectedSchemas", [...props.schemas]);
}
}
function clearAll() {
emit("update:selectedSchemas", []);
}
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" role="combobox" :aria-expanded="open" :disabled="disabled || !schemas.length || loading" class="h-8 min-w-36 max-w-56 justify-between gap-1.5 px-2.5 text-xs font-normal" :title="triggerLabel">
<div class="flex min-w-0 items-center gap-1.5 overflow-hidden">
<Loader2 v-if="loading" class="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
<Database v-else-if="isAllSelected" class="h-3.5 w-3.5 shrink-0 text-primary" />
<Layers v-else-if="selectedSchemas.length > 1" class="h-3.5 w-3.5 shrink-0 text-primary" />
<Layers v-else class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="truncate">{{ triggerLabel }}</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<Badge v-if="isAllSelected" variant="secondary" class="h-4 px-1 text-[10px] font-normal">
{{ schemas.length }}
</Badge>
<Badge v-else-if="selectedSchemas.length > 1" variant="secondary" class="h-4 px-1 text-[10px] font-normal">
{{ selectedSchemas.length }}
</Badge>
<ChevronDown class="h-3.5 w-3.5 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent class="w-64 p-2 text-xs" align="start">
<!-- Quick actions -->
<div class="flex items-center justify-between gap-2 border-b pb-2 mb-2">
<button type="button" class="flex items-center gap-1.5 text-left font-medium text-xs hover:text-primary transition-colors" @click="toggleAll">
<CheckSquare v-if="isAllSelected" class="h-3.5 w-3.5 text-primary" />
<Square v-else class="h-3.5 w-3.5 text-muted-foreground" />
<span>{{ t("diagram.allSchemas") }}</span>
</button>
<button v-if="selectedSchemas.length > 0" type="button" class="text-[11px] text-muted-foreground hover:text-foreground transition-colors" @click="clearAll">
{{ t("diagram.deselectAllSchemas") }}
</button>
</div>
<!-- Search box when schemas > 5 -->
<div v-if="schemas.length > 5" class="relative mb-2">
<Search class="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
<Input v-model="searchQuery" class="h-7 pl-6 pr-6 text-xs" :placeholder="t('diagram.searchSchemas')" />
<button v-if="searchQuery" type="button" class="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" @click="searchQuery = ''">
<X class="h-3 w-3" />
</button>
</div>
<!-- Schemas List -->
<div class="max-h-52 overflow-y-auto space-y-0.5">
<div v-for="name in filteredSchemas" :key="name" class="group flex items-center justify-between rounded px-2 py-1.5 hover:bg-accent cursor-pointer transition-colors" @click="toggleSchema(name)">
<div class="flex min-w-0 items-center gap-2">
<CheckSquare v-if="selectedSet.has(name)" class="h-3.5 w-3.5 shrink-0 text-primary" />
<Square v-else class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="truncate font-mono text-[11px]" :title="name">{{ name }}</span>
</div>
<button type="button" class="opacity-0 group-hover:opacity-100 text-[10px] text-primary/80 hover:text-primary px-1 py-0.5 rounded hover:bg-primary/10 transition-opacity" :title="t('diagram.onlyThisSchema')" @click="selectOnly(name, $event)">
{{ t("diagram.onlyThisSchema") }}
</button>
</div>
<div v-if="filteredSchemas.length === 0" class="py-3 text-center text-muted-foreground text-[11px]">
{{ t("common.noMatches") || "No matches" }}
</div>
</div>
</PopoverContent>
</Popover>
</template>
@@ -8,6 +8,7 @@ import { Copy, Download, Link2, Loader2, Maximize2, Minimize2, Network, Plus, Re
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import ConnectionGroupBadge from "@/components/connection/ConnectionGroupBadge.vue";
import DiagramSchemaSelect from "./DiagramSchemaSelect.vue";
import type { ConnectionConfig } from "@/types/database";
import type { DiagramExportFormat } from "@/lib/export/diagramFormats";
@@ -17,6 +18,7 @@ const props = defineProps<{
connectionId: string;
database: string;
schema: string;
selectedSchemas?: string[];
databases: string[];
schemas: string[];
sqlConnections: ConnectionConfig[];
@@ -47,6 +49,7 @@ const emit = defineEmits<{
(e: "set-connection", value: string): void;
(e: "set-database", value: string): void;
(e: "set-schema", value: string): void;
(e: "set-selected-schemas", value: string[]): void;
(e: "update:table-search", value: string): void;
(e: "set-diagram-mode", value: "table" | "engineering"): void;
(e: "toggle-match-panel"): void;
@@ -116,14 +119,7 @@ function connectionIconType(id: string) {
</SelectContent>
</Select>
<Select v-if="isSchemaAware" :model-value="schema" :disabled="!schemas.length || loadingSchemas" @update:model-value="(value: any) => emit('set-schema', String(value))">
<SelectTrigger class="h-8 w-40 text-xs">
<SelectValue :placeholder="loadingSchemas ? t('common.loading') : t('diagram.selectSchema')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="name in schemas" :key="name" :value="name">{{ name }}</SelectItem>
</SelectContent>
</Select>
<DiagramSchemaSelect v-if="isSchemaAware" :schemas="schemas" :selected-schemas="selectedSchemas || (schema ? [schema] : [])" :loading="loadingSchemas" :disabled="!schemas.length || loadingSchemas" @update:selected-schemas="(value: string[]) => emit('set-selected-schemas', value)" />
<div class="relative min-w-40 flex-1">
<Search class="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
@@ -4,13 +4,14 @@ import { useI18n } from "vue-i18n";
import { Plus, Trash2, Eye, EyeOff, ChevronDown, ChevronRight, Edit3, Check, X, CheckSquare, Square, Lock, Unlock } from "@lucide/vue";
import { useLayerStore } from "@/lib/diagram/layer-store";
import type { DiagramTable } from "@/lib/diagram/erDiagram";
import { filterAssignableDiagramTables } from "@/lib/diagram/erDiagram";
import type { LayerLayoutMode } from "@/types/diagram";
import { diagramTableId, filterAssignableDiagramTables } from "@/lib/diagram/erDiagram";
import type { DiagramLayer, LayerLayoutMode } from "@/types/diagram";
import { useToast } from "@/composables/useToast";
const props = defineProps<{
tables: DiagramTable[];
recordHistory?: () => void;
isMultiSchema?: boolean;
}>();
const emit = defineEmits<{
@@ -94,28 +95,37 @@ function isTableSelected(layerId: string, tableName: string): boolean {
return getSelectedTables(layerId).has(tableName);
}
function tableKey(table: DiagramTable): string {
return diagramTableId(table, props.isMultiSchema ?? false);
}
function findLayerForTable(table: DiagramTable, excludeLayerId?: string): DiagramLayer | undefined {
const key = tableKey(table);
return store.layers.find((l) => (!excludeLayerId || l.id !== excludeLayerId) && (l.tableNames.includes(key) || l.tableNames.includes(table.name)));
}
const assignableTables = computed(() => filterAssignableDiagramTables(props.tables));
const availableTables = computed(() => {
return assignableTables.value.filter((table) => !store.getLayerByTable(table.name));
return assignableTables.value.filter((table) => !findLayerForTable(table));
});
function matchesAddTableFilter(name: string): boolean {
function matchesAddTableFilter(table: DiagramTable): boolean {
const q = addTableFilter.value.trim().toLowerCase();
if (!q) return true;
return name.toLowerCase().includes(q);
return table.name.toLowerCase().includes(q) || (table.schema?.toLowerCase().includes(q) ?? false) || tableKey(table).toLowerCase().includes(q);
}
const filteredAvailableTables = computed(() => {
return availableTables.value.filter((table) => matchesAddTableFilter(table.name));
return availableTables.value.filter(matchesAddTableFilter);
});
function getOtherLayerTables(excludeLayerId: string): DiagramTable[] {
return assignableTables.value.filter((table) => getOtherLayerForTable(table.name, excludeLayerId));
return assignableTables.value.filter((table) => !!findLayerForTable(table, excludeLayerId));
}
function getFilteredOtherLayerTables(excludeLayerId: string): DiagramTable[] {
return getOtherLayerTables(excludeLayerId).filter((table) => matchesAddTableFilter(table.name));
return getOtherLayerTables(excludeLayerId).filter(matchesAddTableFilter);
}
function isOtherLayersExpanded(layerId: string): boolean {
@@ -132,9 +142,8 @@ function toggleOtherLayersExpanded(layerId: string) {
otherLayersExpandedByLayer.value = next;
}
function getOtherLayerForTable(tableName: string, excludeLayerId: string): string | null {
const layer = store.layers.find((l) => l.id !== excludeLayerId && l.tableNames.includes(tableName));
return layer?.name || null;
function getOtherLayerForTable(table: DiagramTable, excludeLayerId: string): string | null {
return findLayerForTable(table, excludeLayerId)?.name || null;
}
function handleAddSelectedTables(layerId: string) {
@@ -142,12 +151,15 @@ function handleAddSelectedTables(layerId: string) {
if (selected.size === 0) return;
beforeMutate();
selected.forEach((tableName) => {
const currentLayer = store.getLayerByTable(tableName);
if (currentLayer) {
store.removeTableFromLayer(currentLayer.id, tableName);
selected.forEach((tableId) => {
const table = props.tables.find((t) => tableKey(t) === tableId || t.name === tableId);
const key = table ? tableKey(table) : tableId;
const raw = table?.name;
for (const l of store.layers) {
if (l.tableNames.includes(key)) store.removeTableFromLayer(l.id, key);
if (raw && l.tableNames.includes(raw)) store.removeTableFromLayer(l.id, raw);
}
store.addTableToLayer(layerId, tableName);
store.addTableToLayer(layerId, key);
});
selectedTableNames.value.delete(layerId);
@@ -159,14 +171,14 @@ function handleAddSelectedTables(layerId: string) {
}
function selectAllAvailableTables(layerId: string) {
const all = new Set(filteredAvailableTables.value.map((t) => t.name));
const all = new Set(filteredAvailableTables.value.map((t) => tableKey(t)));
selectedTableNames.value.set(layerId, all);
}
function selectAllTablesFromOtherLayers(layerId: string) {
const selected = getSelectedTables(layerId);
getFilteredOtherLayerTables(layerId).forEach((table) => {
selected.add(table.name);
selected.add(tableKey(table));
});
selectedTableNames.value.set(layerId, selected);
}
@@ -255,7 +267,12 @@ async function startRename(layerId: string) {
function handleRemoveTable(layerId: string, tableName: string) {
beforeMutate();
const table = props.tables.find((t) => t.name === tableName || tableKey(t) === tableName);
store.removeTableFromLayer(layerId, tableName);
if (table) {
store.removeTableFromLayer(layerId, tableKey(table));
store.removeTableFromLayer(layerId, table.name);
}
emit("layer-changed");
}
@@ -292,7 +309,7 @@ function handleSetActiveLayer(layerId: string) {
function getLayerTables(layerId: string): DiagramTable[] {
const layer = store.layers.find((l) => l.id === layerId);
if (!layer) return [];
return assignableTables.value.filter((table) => layer.tableNames.includes(table.name));
return assignableTables.value.filter((table) => layer.tableNames.includes(tableKey(table)) || layer.tableNames.includes(table.name));
}
function getLayerColor(layerId: string): string {
@@ -353,12 +370,12 @@ function getLayerColor(layerId: string): string {
<div v-if="!layer.collapsed" class="px-2 pb-2">
<div class="space-y-0.5">
<div v-for="table in getLayerTables(layer.id)" :key="table.name" class="flex items-center gap-1 px-2 py-1 rounded text-xs bg-muted/50">
<span class="flex-1 min-w-0 truncate">{{ table.name }}</span>
<button type="button" class="p-0.5 rounded hover:bg-background transition-colors" @click.stop="handleRemoveTable(layer.id, table.name)" :title="t('diagram.removeFromLayer')">
<div v-for="table in getLayerTables(layer.id)" :key="tableKey(table)" class="flex items-center gap-1 px-2 py-1 rounded text-xs bg-muted/50">
<span class="flex-1 min-w-0 truncate" :title="tableKey(table)">{{ tableKey(table) }}</span>
<button type="button" class="p-0.5 rounded hover:bg-background transition-colors" @click.stop="handleRemoveTable(layer.id, tableKey(table))" :title="t('diagram.removeFromLayer')">
<X class="h-3 w-3 text-muted-foreground" />
</button>
<button type="button" class="p-0.5 rounded hover:bg-background transition-colors" @click.stop="handleDeleteTable(table.name)" :title="t('diagram.deleteLiveTable')">
<button type="button" class="p-0.5 rounded hover:bg-background transition-colors" @click.stop="handleDeleteTable(tableKey(table))" :title="t('diagram.deleteLiveTable')">
<Trash2 class="h-3 w-3 text-red-500" />
</button>
</div>
@@ -387,10 +404,10 @@ function getLayerColor(layerId: string): string {
<input v-model="addTableFilter" type="search" class="w-full h-7 text-[11px] bg-background border border-border rounded px-2 outline-none focus:border-primary" :placeholder="t('diagram.filterTables')" @click.stop />
<div class="space-y-0.5 max-h-48 overflow-y-auto border border-border rounded">
<div v-for="table in filteredAvailableTables" :key="table.name" class="flex items-center gap-1.5 px-2 py-1 text-xs hover:bg-muted/50 cursor-pointer" @click.stop="toggleSelectedTable(layer.id, table.name)">
<CheckSquare v-if="isTableSelected(layer.id, table.name)" class="h-3 w-3 text-primary shrink-0" />
<div v-for="table in filteredAvailableTables" :key="tableKey(table)" class="flex items-center gap-1.5 px-2 py-1 text-xs hover:bg-muted/50 cursor-pointer" @click.stop="toggleSelectedTable(layer.id, tableKey(table))">
<CheckSquare v-if="isTableSelected(layer.id, tableKey(table))" class="h-3 w-3 text-primary shrink-0" />
<Square v-else class="h-3 w-3 text-muted-foreground shrink-0" />
<span class="flex-1 min-w-0 truncate">{{ table.name }}</span>
<span class="flex-1 min-w-0 truncate" :title="tableKey(table)">{{ tableKey(table) }}</span>
</div>
<div v-if="filteredAvailableTables.length === 0" class="px-2 py-2 text-[10px] text-muted-foreground text-center">
{{ t("diagram.noMatchingTables") }}
@@ -409,15 +426,15 @@ function getLayerColor(layerId: string): string {
</button>
</div>
<div v-if="isOtherLayersExpanded(layer.id)" class="space-y-0.5 max-h-32 overflow-y-auto border border-border rounded bg-muted/20">
<div v-for="table in getFilteredOtherLayerTables(layer.id)" :key="table.name" class="flex items-center gap-1.5 px-2 py-1 text-xs hover:bg-muted/50 cursor-pointer" @click.stop="toggleSelectedTable(layer.id, table.name)">
<div v-for="table in getFilteredOtherLayerTables(layer.id)" :key="tableKey(table)" class="flex items-center gap-1.5 px-2 py-1 text-xs hover:bg-muted/50 cursor-pointer" @click.stop="toggleSelectedTable(layer.id, tableKey(table))">
<div class="relative shrink-0">
<CheckSquare v-if="isTableSelected(layer.id, table.name)" class="h-3 w-3 text-primary" />
<CheckSquare v-if="isTableSelected(layer.id, tableKey(table))" class="h-3 w-3 text-primary" />
<Square v-else class="h-3 w-3 text-muted-foreground" />
<div class="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full border border-background" :style="{ backgroundColor: getLayerColor(store.layers.find((l) => l.id !== layer.id && l.tableNames.includes(table.name))?.id || '') }" />
<div class="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full border border-background" :style="{ backgroundColor: getLayerColor(findLayerForTable(table, layer.id)?.id || '') }" />
</div>
<span class="flex-1 min-w-0 truncate opacity-70">{{ table.name }}</span>
<span class="flex-1 min-w-0 truncate opacity-70" :title="tableKey(table)">{{ tableKey(table) }}</span>
<span class="text-[9px] px-1 py-0.5 rounded bg-muted-foreground/10 text-muted-foreground shrink-0">
{{ getOtherLayerForTable(table.name, layer.id) }}
{{ getOtherLayerForTable(table, layer.id) }}
</span>
</div>
<div v-if="getFilteredOtherLayerTables(layer.id).length === 0" class="px-2 py-2 text-[10px] text-muted-foreground text-center">
@@ -17,7 +17,7 @@ import { useGraphStore } from "@/lib/diagram/graph-store";
import * as api from "@/lib/backend/api";
import { DIAGRAM_SQL_TYPES, isSchemaAware as isSchemaAwareDatabase } from "@/lib/database/databaseCapabilities";
import { databaseOptionsForConnection, fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions";
import { buildDiagramJoinSql, buildDiagramRelationships, filterDiagramTables, mergeRelationshipsWithInferred, normalizeCustomDiagramRelationship, type CustomDiagramRelationship, type DiagramPosition, type DiagramTable, isDraftTable, needsDiagramSync } from "@/lib/diagram/erDiagram";
import { buildDiagramJoinSql, buildDiagramRelationships, diagramTableId, filterDiagramTables, mergeRelationshipsWithInferred, normalizeCustomDiagramRelationship, type CustomDiagramRelationship, type DiagramPosition, type DiagramTable, isDraftTable, needsDiagramSync } from "@/lib/diagram/erDiagram";
import { createDraftTable } from "@/lib/diagram/draft-table";
import { cardinalityPairFromChoice } from "@/lib/diagram/cardinality";
import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities";
@@ -123,7 +123,10 @@ function handleEdgesChange(changes: EdgeChange[]) {
function tableHeightsMap(): Record<string, number> {
const heights: Record<string, number> = {};
for (const table of tables.value) {
heights[table.name] = tableHeight(table);
const h = tableHeight(table);
heights[table.name] = h;
const key = getTableKey(table);
heights[key] = h;
}
return heights;
}
@@ -292,6 +295,7 @@ const METADATA_BATCH_SIZE = 4;
const connectionId = ref("");
const database = ref("");
const schema = ref("");
const selectedSchemas = ref<string[]>([]);
const databases = ref<string[]>([]);
const schemas = ref<string[]>([]);
const tables = ref<DiagramTable[]>([]);
@@ -391,9 +395,36 @@ const selectedConnection = computed(() => (connectionId.value ? store.getConfig(
const isSchemaAware = computed(() => isSchemaAwareDatabase(selectedConnection.value?.db_type));
const tableMap = computed(() => new Map(tables.value.map((table) => [table.name, table])));
const isAllSchemasSelected = computed(() => isSchemaAware.value && schemas.value.length > 0 && schemas.value.every((s) => selectedSchemas.value.includes(s)));
const allRelationships = computed(() => buildDiagramRelationships(tables.value, customRelationships.value));
const isMultiSchema = computed(() => isSchemaAware.value && (selectedSchemas.value.length > 1 || isAllSchemasSelected.value));
function getTableKey(table: { name: string; schema?: string }): string {
return diagramTableId(table, isMultiSchema.value);
}
const effectiveSchemaKey = computed(() => {
// Non-schema-aware drivers historically persisted with the database name
// in this slot (loadSchemas assigned schema = database); keep legacy keys.
if (!isSchemaAware.value) return database.value;
if (isAllSchemasSelected.value) return "__all__";
if (selectedSchemas.value.length === 1) return selectedSchemas.value[0];
if (selectedSchemas.value.length > 1) return selectedSchemas.value.slice().sort().join(",");
return schema.value || "";
});
const tableMap = computed(() => {
const map = new Map<string, DiagramTable>();
for (const table of tables.value) {
map.set(getTableKey(table), table);
if (!map.has(table.name)) {
map.set(table.name, table);
}
}
return map;
});
const allRelationships = computed(() => buildDiagramRelationships(tables.value, customRelationships.value, isMultiSchema.value));
const relatedTableNames = computed(() => {
const selected = props.focusTableNames?.filter((name) => name.trim());
@@ -415,36 +446,50 @@ const visibleTables = computed(() => {
tableSearch.value,
);
if ((props.focusTableNames?.length ?? 0) > 1 && !showAllTables.value && !tableSearch.value.trim()) {
return filtered.filter((table) => relatedTableNames.value.has(table.name));
return filtered.filter((table) => relatedTableNames.value.has(table.name) || relatedTableNames.value.has(getTableKey(table)));
}
if (props.focusTableName && !showAllTables.value && !tableSearch.value.trim()) {
return filtered.filter((table) => relatedTableNames.value.has(table.name));
return filtered.filter((table) => relatedTableNames.value.has(table.name) || relatedTableNames.value.has(getTableKey(table)));
}
return filtered;
});
const visibleRelationships = computed(() => {
const baseRelationships = buildDiagramRelationships(visibleTables.value, customRelationships.value);
const baseRelationships = buildDiagramRelationships(visibleTables.value, customRelationships.value, isMultiSchema.value);
if (!isAutoMatchEnabled()) return baseRelationships;
const visibleTableKeys = new Set(visibleTables.value.map((t) => getTableKey(t)));
const visibleTableNames = new Set(visibleTables.value.map((t) => t.name));
const inferredVisible = matchResult.value.relationships.filter((r) => visibleTableNames.has(r.sourceTable) && visibleTableNames.has(r.targetTable));
// Inferred relationships carry plain table names; map them to composite node ids in multi-schema mode
const inferredVisible = matchResult.value.relationships
.filter((r) => (visibleTableKeys.has(r.sourceTable) || visibleTableNames.has(r.sourceTable)) && (visibleTableKeys.has(r.targetTable) || visibleTableNames.has(r.targetTable)))
.map((r) => {
const source = tableMap.value.get(r.sourceTable);
const target = tableMap.value.get(r.targetTable);
if (!source && !target) return r;
return {
...r,
sourceTable: source ? getTableKey(source) : r.sourceTable,
targetTable: target ? getTableKey(target) : r.targetTable,
};
});
return mergeRelationshipsWithInferred(baseRelationships, inferredVisible);
});
/** Tables/edges currently drawn on the canvas (excludes members of hidden layers). */
const canvasVisibleTables = computed(() => visibleTables.value.filter((table) => isTableCanvasVisible(table.name, layerStore.layers)));
const canvasVisibleTables = computed(() => visibleTables.value.filter((table) => isTableCanvasVisible(getTableKey(table), layerStore.layers) || isTableCanvasVisible(table.name, layerStore.layers)));
const canvasVisibleRelationships = computed(() => visibleRelationships.value.filter((rel) => isTableCanvasVisible(rel.sourceTable, layerStore.layers) && isTableCanvasVisible(rel.targetTable, layerStore.layers)));
const edgeObstacles = computed<ObstacleRect[]>(() => {
const rects: ObstacleRect[] = [];
for (const table of canvasVisibleTables.value) {
const pos = positions.value[table.name];
const key = getTableKey(table);
const pos = positions.value[key] || positions.value[table.name];
if (!pos) continue;
rects.push({
id: table.name,
id: key,
kind: "table",
x: pos.x,
y: pos.y,
@@ -468,7 +513,7 @@ const edgeObstacles = computed<ObstacleRect[]>(() => {
});
provide(DIAGRAM_EDGE_OBSTACLES_KEY, edgeObstacles);
const diagramReady = computed(() => !!connectionId.value && !!database.value && (!isSchemaAware.value || !!schema.value));
const diagramReady = computed(() => !!connectionId.value && !!database.value && (!isSchemaAware.value || selectedSchemas.value.length > 0));
const showRightPanel = computed(() => diagramReady.value && (showMatchPanel.value || !!inspectorTarget.value));
@@ -492,7 +537,8 @@ const canvasSize = computed(() => {
let width = 960;
let height = 540;
for (const table of canvasVisibleTables.value) {
const position = positions.value[table.name];
const key = getTableKey(table);
const position = positions.value[key] || positions.value[table.name];
if (!position) continue;
width = Math.max(width, position.x + CARD_WIDTH + 80);
height = Math.max(height, position.y + tableHeight(table) + 80);
@@ -528,12 +574,15 @@ async function applyAutoLayout(options?: { skipHistory?: boolean }) {
if (!options?.skipHistory) recordHistory();
const diagramNodes: DiagramNode[] = layoutTables.map((table) => ({
id: table.name,
type: "table",
position: positions.value[table.name] ? { ...positions.value[table.name] } : { x: 0, y: 0 },
data: { table },
}));
const diagramNodes: DiagramNode[] = layoutTables.map((table) => {
const key = getTableKey(table);
return {
id: key,
type: "table",
position: positions.value[key] || positions.value[table.name] ? { ...(positions.value[key] || positions.value[table.name]) } : { x: 0, y: 0 },
data: { table },
};
});
const diagramEdges: DiagramEdge[] = canvasVisibleRelationships.value.map((rel) => ({
id: rel.id,
source: rel.sourceTable,
@@ -621,7 +670,7 @@ async function handleLayerLayoutModeChanged(payload: { layerId: string; layoutMo
}
function openTableData(tableName: string) {
const table = tables.value.find((t) => t.name === tableName);
const table = tables.value.find((t) => getTableKey(t) === tableName || t.name === tableName);
if (table && isDraftTable(table)) {
openInspectorForTable(tableName);
return;
@@ -630,8 +679,8 @@ function openTableData(tableName: string) {
emit("open-target", {
connectionId: connectionId.value,
database: database.value,
schema: isSchemaAware.value ? schema.value || undefined : undefined,
tableName,
schema: isSchemaAware.value ? table?.schema || schema.value || undefined : undefined,
tableName: table?.name || tableName,
tableType: "TABLE",
});
}
@@ -643,7 +692,7 @@ const canSyncStructure = computed(() => supportsTableStructureEditing(selectedCo
function persistDraftAndLayers() {
if (!connectionId.value || !database.value) return;
const schemaKey = schema.value || "";
const schemaKey = effectiveSchemaKey.value;
saveDraftTables(tables.value, connectionId.value, database.value, schemaKey);
saveLiveTablePatches(tables.value, connectionId.value, database.value, schemaKey);
savePersistedLayers(layerStore.toJSON(), connectionId.value, database.value, schemaKey);
@@ -665,6 +714,7 @@ function handleCreateDraftTable(payload: { name: string; layerId: string | null;
if (!canCreateDraftTable.value) return;
recordHistory();
const draft = createDraftTable(payload.name, { withDefaultId: payload.withDefaultId, databaseType: selectedConnection.value?.db_type });
draft.schema = selectedSchemas.value[0] || schema.value || undefined;
tables.value = [...tables.value, draft];
const targetLayerId = payload.layerId || layerStore.activeLayerId;
const targetLayer = targetLayerId ? layerStore.layers.find((l) => l.id === targetLayerId) : undefined;
@@ -694,68 +744,125 @@ function handleCreateDraftTable(payload: { name: string; layerId: string | null;
y = MARGIN + Math.floor(Object.keys(positions.value).length / 3) * 200;
}
positions.value = { ...positions.value, [draft.name]: { x, y } };
const draftKey = getTableKey(draft);
positions.value = { ...positions.value, [draftKey]: { x, y }, [draft.name]: { x, y } };
if (targetLayerId) {
layerStore.addTableToLayer(targetLayerId, draft.name);
layerStore.addTableToLayer(targetLayerId, draftKey);
const layer = layerStore.layers.find((l) => l.id === targetLayerId);
if (layer) sizeLayerToFit(layer, positions.value, tableHeightsMap());
}
persistDraftAndLayers();
syncVueFlowNodes();
openInspectorForTable(draft.name);
openInspectorForTable(draftKey);
}
function handleInspectorUpdateTable(next: DiagramTable) {
recordHistory();
const targetName = inspectorTarget.value?.kind === "table" ? inspectorTarget.value.tableName : next.name;
const oldName = tables.value.find((t) => t.name === targetName)?.name;
if (!oldName) return;
tables.value = tables.value.map((t) => (t.name === oldName ? next : t));
// inspectorTarget may hold the composite canvas node id (`schema.table`) in multi-schema mode
const oldTable = tables.value.find((t) => t.name === targetName || getTableKey(t) === targetName);
if (!oldTable) return;
const oldKey = getTableKey(oldTable);
const oldName = oldTable.name;
// Accurately update ONLY the matched table instance, avoiding unintentional overwrite of same-name tables in other schemas
tables.value = tables.value.map((t) => (t === oldTable || getTableKey(t) === oldKey ? next : t));
if (oldName !== next.name) {
const pos = positions.value[oldName];
const { [oldName]: _removed, ...rest } = positions.value;
positions.value = pos ? { ...rest, [next.name]: pos } : rest;
for (const layer of layerStore.layers) {
const idx = layer.tableNames.indexOf(oldName);
if (idx >= 0) layer.tableNames[idx] = next.name;
const newKey = getTableKey(next);
const pos = positions.value[oldKey] || positions.value[oldName];
const nextPositions = { ...positions.value };
delete nextPositions[oldKey];
delete nextPositions[oldName];
if (pos) {
nextPositions[newKey] = pos;
nextPositions[next.name] = pos;
}
positions.value = nextPositions;
for (const layer of layerStore.layers) {
let modified = false;
for (const key of [oldKey, oldName]) {
const idx = layer.tableNames.indexOf(key);
if (idx >= 0) {
layer.tableNames[idx] = newKey;
modified = true;
}
}
if (modified) {
sizeLayerToFit(layer, positions.value, tableHeightsMap());
}
}
const oldCandidateNames = new Set([oldName, oldKey]);
customRelationships.value = customRelationships.value.map((r) => ({
...r,
sourceTable: r.sourceTable === oldName ? next.name : r.sourceTable,
targetTable: r.targetTable === oldName ? next.name : r.targetTable,
sourceTable: oldCandidateNames.has(r.sourceTable) ? newKey : r.sourceTable,
targetTable: oldCandidateNames.has(r.targetTable) ? newKey : r.targetTable,
}));
saveCustomRelationships();
inspectorTarget.value = { kind: "table", tableName: next.name };
inspectorTarget.value = { kind: "table", tableName: newKey };
}
persistDraftAndLayers();
const layer = layerStore.getLayerByTable(next.name);
const layer = layerStore.getLayerByTable(next.name) || layerStore.getLayerByTable(getTableKey(next));
if (layer) sizeLayerToFit(layer, positions.value, tableHeightsMap());
syncVueFlowNodes();
}
function handleDeleteDraftTable(tableName: string) {
recordHistory();
tables.value = tables.value.filter((t) => t.name !== tableName);
const { [tableName]: _removed, ...rest } = positions.value;
positions.value = rest;
const layer = layerStore.getLayerByTable(tableName);
if (layer) {
layerStore.removeTableFromLayer(layer.id, tableName);
sizeLayerToFit(layer, positions.value, tableHeightsMap());
const deletedTable = tables.value.find((table) => getTableKey(table) === tableName || table.name === tableName);
if (!deletedTable) return;
const targetKey = getTableKey(deletedTable);
const rawName = deletedTable.name;
const isUniqueName = tables.value.filter((t) => t.name === rawName).length === 1;
// Accurately filter out ONLY the deleted table by reference or canonical composite key
tables.value = tables.value.filter((t) => t !== deletedTable && getTableKey(t) !== targetKey);
const nextPositions = { ...positions.value };
delete nextPositions[targetKey];
delete nextPositions[tableName];
if (!isMultiSchema.value || isUniqueName) {
delete nextPositions[rawName];
}
customRelationships.value = customRelationships.value.filter((r) => r.sourceTable !== tableName && r.targetTable !== tableName);
positions.value = nextPositions;
const targetNamesToClean = (!isMultiSchema.value || isUniqueName) && rawName !== targetKey ? [targetKey, rawName] : [targetKey];
for (const layer of layerStore.layers) {
let modified = false;
for (const name of targetNamesToClean) {
if (layer.tableNames.includes(name)) {
layerStore.removeTableFromLayer(layer.id, name);
modified = true;
}
}
if (modified) {
sizeLayerToFit(layer, positions.value, tableHeightsMap());
}
}
const removeRelSet = new Set(targetNamesToClean);
customRelationships.value = customRelationships.value.filter((r) => !removeRelSet.has(r.sourceTable) && !removeRelSet.has(r.targetTable));
saveCustomRelationships();
if (inspectorTarget.value?.kind === "table" && inspectorTarget.value.tableName === tableName) {
if (inspectorTarget.value?.kind === "table" && (inspectorTarget.value.tableName === targetKey || inspectorTarget.value.tableName === tableName)) {
inspectorTarget.value = null;
}
persistDraftAndLayers();
syncVueFlowNodes();
}
function handleDeleteLiveTable(tableName: string) {
recordHistory();
const deletedTable = tables.value.find((table) => getTableKey(table) === tableName || table.name === tableName);
if (!deletedTable) return;
const targetKey = getTableKey(deletedTable);
const rawName = deletedTable.name;
const isUniqueName = tables.value.filter((t) => t.name === rawName).length === 1;
// Mark strictly the matched table as pendingDrop
tables.value = tables.value.map((table) => {
if (table.name !== tableName || isDraftTable(table)) return table;
if ((table !== deletedTable && getTableKey(table) !== targetKey) || isDraftTable(table)) return table;
return {
...table,
pendingDrop: true,
@@ -763,22 +870,36 @@ function handleDeleteLiveTable(tableName: string) {
droppedColumnNames: undefined,
};
});
const layer = layerStore.getLayerByTable(tableName);
if (layer) {
layerStore.removeTableFromLayer(layer.id, tableName);
sizeLayerToFit(layer, positions.value, tableHeightsMap());
const targetNamesToClean = (!isMultiSchema.value || isUniqueName) && rawName !== targetKey ? [targetKey, rawName] : [targetKey];
for (const layer of layerStore.layers) {
let modified = false;
for (const name of targetNamesToClean) {
if (layer.tableNames.includes(name)) {
layerStore.removeTableFromLayer(layer.id, name);
modified = true;
}
}
if (modified) {
sizeLayerToFit(layer, positions.value, tableHeightsMap());
}
}
customRelationships.value = customRelationships.value.filter((r) => r.sourceTable !== tableName && r.targetTable !== tableName);
const removeRelSet = new Set(targetNamesToClean);
customRelationships.value = customRelationships.value.filter((r) => !removeRelSet.has(r.sourceTable) && !removeRelSet.has(r.targetTable));
saveCustomRelationships();
if (inspectorTarget.value?.kind === "table" && inspectorTarget.value.tableName === tableName) {
if (inspectorTarget.value?.kind === "table" && (inspectorTarget.value.tableName === targetKey || inspectorTarget.value.tableName === tableName)) {
inspectorTarget.value = null;
}
persistDraftAndLayers();
syncVueFlowNodes();
}
function deleteTableFromDiagram(tableName: string) {
const table = tables.value.find((t) => t.name === tableName);
const table = tables.value.find((t) => t.name === tableName || getTableKey(t) === tableName);
if (!table || table.pendingDrop) return;
if (isDraftTable(table)) {
handleDeleteDraftTable(tableName);
@@ -809,7 +930,7 @@ async function handleSyncedDraftTables(_names: string[]) {
function syncVueFlowNodes() {
const heights = tableHeightsMap();
const tableNodes = toVueFlowNodes(visibleTables.value, positions.value);
const tableNodes = toVueFlowNodes(visibleTables.value, positions.value, isMultiSchema.value);
const layerNodes = toVueFlowLayerNodes(layerStore.layers);
const vueEdges = toVueFlowEdges(visibleRelationships.value, positions.value, edgeWaypoints.value, heights, edgeHandleHints.value);
setNodes([...layerNodes, ...tableNodes]);
@@ -818,7 +939,7 @@ function syncVueFlowNodes() {
function relationshipStorageKey(): string {
if (!connectionId.value || !database.value) return "";
return ["dbx", "diagram", "relationships", "v1", connectionId.value, database.value, schema.value || ""].join(":");
return ["dbx", "diagram", "relationships", "v1", connectionId.value, database.value, effectiveSchemaKey.value].join(":");
}
function isStoredRelationship(value: unknown): value is CustomDiagramRelationship {
@@ -857,7 +978,7 @@ function saveCustomRelationships() {
function loadMatchData() {
if (!connectionId.value || !database.value) return;
const querySchema = schema.value || database.value;
const querySchema = effectiveSchemaKey.value || database.value;
matchConfirms.value = loadMatchConfirms(connectionId.value, database.value, querySchema);
matchIgnores.value = loadMatchIgnores(connectionId.value, database.value, querySchema);
@@ -869,7 +990,7 @@ function loadMatchData() {
function saveMatchData() {
if (!connectionId.value || !database.value) return;
const querySchema = schema.value || database.value;
const querySchema = effectiveSchemaKey.value || database.value;
saveMatchConfirms(matchConfirms.value, connectionId.value, database.value, querySchema);
saveMatchIgnores(matchIgnores.value, connectionId.value, database.value, querySchema);
}
@@ -1143,9 +1264,11 @@ async function loadDatabases(id: string) {
async function loadSchemas() {
schemas.value = [];
schema.value = "";
selectedSchemas.value = [];
if (!connectionId.value || !database.value) return;
if (!isSchemaAware.value) {
schema.value = database.value;
selectedSchemas.value = [database.value];
return;
}
@@ -1153,7 +1276,14 @@ async function loadSchemas() {
try {
const names = await api.listSchemas(connectionId.value, database.value);
schemas.value = names;
schema.value = props.prefillSchema && names.includes(props.prefillSchema) ? props.prefillSchema : names.includes("public") ? "public" : (names[0] ?? "");
if (props.prefillSchema && names.includes(props.prefillSchema)) {
schema.value = props.prefillSchema;
selectedSchemas.value = [props.prefillSchema];
} else {
const defaultSchema = names.includes("public") ? "public" : (names[0] ?? "");
schema.value = defaultSchema;
selectedSchemas.value = defaultSchema ? [defaultSchema] : [];
}
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
@@ -1165,6 +1295,7 @@ async function setConnection(id: string) {
connectionId.value = id;
database.value = "";
schema.value = "";
selectedSchemas.value = [];
tables.value = [];
customRelationships.value = [];
positions.value = {};
@@ -1180,6 +1311,7 @@ async function setConnection(id: string) {
async function setDatabase(value: string) {
database.value = value;
tables.value = [];
selectedSchemas.value = [];
customRelationships.value = [];
positions.value = {};
matchConfirms.value = [];
@@ -1191,6 +1323,19 @@ async function setDatabase(value: string) {
async function setSchema(value: string) {
schema.value = value;
selectedSchemas.value = value ? [value] : [];
tables.value = [];
customRelationships.value = [];
positions.value = {};
matchConfirms.value = [];
matchIgnores.value = [];
matchResult.value = { relationships: [], conflicts: [], pending: [], stats: { total: 0, high: 0, medium: 0 } };
if (diagramReady.value) await loadDiagram();
}
async function setSelectedSchemas(values: string[]) {
selectedSchemas.value = [...values];
schema.value = values.length === 1 ? values[0] : "";
tables.value = [];
customRelationships.value = [];
positions.value = {};
@@ -1209,6 +1354,7 @@ async function loadTableDiagramData(tableName: string, querySchema: string): Pro
]);
return {
name: tableName,
schema: querySchema,
columns,
foreignKeys,
indexes: indexes.map((index) => ({
@@ -1227,8 +1373,8 @@ async function loadTableDiagramData(tableName: string, querySchema: string): Pro
};
} catch (e) {
failedTableCount.value += 1;
console.warn(`[diagram] failed to load table metadata: ${tableName}`, e);
return { name: tableName, columns: [], foreignKeys: [], indexes: [] };
console.warn(`[diagram] failed to load table metadata: ${querySchema}.${tableName}`, e);
return { name: tableName, schema: querySchema, columns: [], foreignKeys: [], indexes: [] };
}
}
@@ -1243,24 +1389,38 @@ async function loadDiagram() {
failedTableCount.value = 0;
try {
await store.ensureConnected(connectionId.value);
const querySchema = schema.value || database.value;
const tableInfos = await api.listTables(connectionId.value, database.value, querySchema);
const baseTables = tableInfos.filter((table) => table.table_type !== "VIEW" && table.table_type !== "MATERIALIZED_VIEW").sort((a, b) => a.name.localeCompare(b.name));
const targetSchemas = isSchemaAware.value ? selectedSchemas.value : [database.value];
const schemaTableLists = await Promise.all(
targetSchemas.map(async (sc) => {
try {
const list = await api.listTables(connectionId.value, database.value, sc);
return list.filter((table) => table.table_type !== "VIEW" && table.table_type !== "MATERIALIZED_VIEW").map((table) => ({ name: table.name, schema: sc }));
} catch (e) {
console.warn(`[diagram] failed to list tables for schema ${sc}`, e);
return [];
}
}),
);
const baseTables = schemaTableLists.flat().sort((a, b) => {
if (a.schema !== b.schema) return a.schema.localeCompare(b.schema);
return a.name.localeCompare(b.name);
});
totalTableCount.value = baseTables.length;
const loadedTables: DiagramTable[] = [];
for (let index = 0; index < baseTables.length; index += METADATA_BATCH_SIZE) {
const batch = baseTables.slice(index, index + METADATA_BATCH_SIZE);
const batchTables = await Promise.all(batch.map((table) => loadTableDiagramData(table.name, querySchema)));
const batchTables = await Promise.all(batch.map((item) => loadTableDiagramData(item.name, item.schema)));
loadedTables.push(...batchTables);
loadedTableCount.value = loadedTables.length;
}
tables.value = loadedTables;
const schemaKey = schema.value || "";
const schemaKey = effectiveSchemaKey.value;
const drafts = loadDraftTables(connectionId.value, database.value, schemaKey);
const liveNames = new Set(loadedTables.map((t) => t.name));
const pendingDrafts = drafts.filter((d) => !liveNames.has(d.name));
const liveKeys = new Set(loadedTables.map((t) => getTableKey(t)));
const pendingDrafts = drafts.filter((d) => !liveKeys.has(getTableKey(d)) && !liveKeys.has(d.name));
if (pendingDrafts.length) {
tables.value = [...loadedTables, ...pendingDrafts];
}
@@ -1268,9 +1428,26 @@ async function loadDiagram() {
tables.value = applyLiveTablePatches(tables.value, livePatches);
saveDraftTables(tables.value.filter(isDraftTable), connectionId.value, database.value, schemaKey);
saveLiveTablePatches(tables.value, connectionId.value, database.value, schemaKey);
const savedLayers = loadPersistedLayers(connectionId.value, database.value, schema.value || "");
const savedLayers = loadPersistedLayers(connectionId.value, database.value, schemaKey);
if (savedLayers.length) {
layerStore.loadLayers(savedLayers);
if (isMultiSchema.value) {
const rawNameCounts = new Map<string, number>();
for (const table of tables.value) {
rawNameCounts.set(table.name, (rawNameCounts.get(table.name) ?? 0) + 1);
}
for (const layer of layerStore.layers) {
layer.tableNames = layer.tableNames.map((name) => {
if (!name.includes(".")) {
const matchedTable = tables.value.find((t) => t.name === name);
if (matchedTable && rawNameCounts.get(name) === 1 && matchedTable.schema) {
return `${matchedTable.schema}.${matchedTable.name}`;
}
}
return name;
});
}
}
}
loadCustomRelationships();
loadMatchData();
@@ -1295,15 +1472,17 @@ async function loadDiagram() {
/** Restore saved table positions, or run layer-aware LTR layout when none exist. */
async function restoreOrInitializeLayout() {
const schemaKey = schema.value || "";
const schemaKey = effectiveSchemaKey.value;
const tableKeys = tables.value.map((t) => getTableKey(t));
const tableNames = tables.value.map((t) => t.name);
const savedPositions = loadPersistedPositions(connectionId.value, database.value, schemaKey);
if (hasUsablePersistedPositions(savedPositions, tableNames)) {
if (hasUsablePersistedPositions(savedPositions, tableKeys) || hasUsablePersistedPositions(savedPositions, tableNames)) {
const next: Record<string, DiagramPosition> = {};
for (const name of tableNames) {
const pos = savedPositions[name];
if (pos) next[name] = { ...pos };
for (const table of tables.value) {
const key = getTableKey(table);
const pos = savedPositions[key] || savedPositions[table.name];
if (pos) next[key] = { ...pos };
}
positions.value = next;
fillMissingTablePositions();
@@ -1321,17 +1500,18 @@ async function restoreOrInitializeLayout() {
/** Place tables that have no saved position into their layer (or canvas grid). */
function fillMissingTablePositions() {
const heights = tableHeightsMap();
const missing = tables.value.filter((t) => !positions.value[t.name]);
const missing = tables.value.filter((t) => !positions.value[getTableKey(t)] && !positions.value[t.name]);
if (missing.length === 0) return;
for (const table of missing) {
const layer = layerStore.getLayerByTable(table.name);
const key = getTableKey(table);
const layer = layerStore.getLayerByTable(key) || layerStore.getLayerByTable(table.name);
if (layer?.position) {
const memberPositions = layer.tableNames.map((name) => positions.value[name]).filter((pos): pos is DiagramPosition => !!pos);
if (memberPositions.length === 0) {
positions.value = {
...positions.value,
[table.name]: {
[key]: {
x: layer.position.x + LAYER_CONTENT_PADDING,
y: layer.position.y + LAYER_HEADER_HEIGHT + LAYER_CONTENT_PADDING,
},
@@ -1347,14 +1527,14 @@ function fillMissingTablePositions() {
}
positions.value = {
...positions.value,
[table.name]: { x: leftMost, y: maxBottom + GAP_Y / 2 },
[key]: { x: leftMost, y: maxBottom + GAP_Y / 2 },
};
}
} else {
const count = Object.keys(positions.value).length;
positions.value = {
...positions.value,
[table.name]: {
[key]: {
x: MARGIN + (count % 3) * (CARD_WIDTH + GAP_X),
y: MARGIN + Math.floor(count / 3) * 200,
},
@@ -1394,6 +1574,15 @@ async function applyInitialLayerAwareLayout() {
});
const nextPositions: Record<string, DiagramPosition> = { ...positions.value, ...result.positions };
for (const table of layoutTables) {
const key = getTableKey(table);
if (result.positions[table.name] && !nextPositions[key]) {
nextPositions[key] = result.positions[table.name];
}
if (result.positions[key] && !nextPositions[table.name]) {
nextPositions[table.name] = result.positions[key];
}
}
positions.value = nextPositions;
for (const updated of result.layers) {
@@ -1707,7 +1896,7 @@ function exportFormatLabel(format: DiagramExportFormat): string {
async function exportDiagram(format: DiagramExportFormat) {
try {
const scopeName = isSchemaAware.value && schema.value ? `${database.value}-${schema.value}` : database.value;
const scopeName = isSchemaAware.value && effectiveSchemaKey.value ? `${database.value}-${effectiveSchemaKey.value}` : database.value;
const defaultPath = diagramExportFileName(selectedConnection.value?.name ?? "", scopeName, diagramMode.value, format);
let saved = false;
@@ -1722,12 +1911,13 @@ async function exportDiagram(format: DiagramExportFormat) {
meta: {
connectionName: selectedConnection.value?.name ?? "",
database: database.value,
schema: schema.value,
schema: effectiveSchemaKey.value,
mode: diagramMode.value,
exportedAt: new Date().toISOString(),
},
tables: visibleTables.value.map((table) => ({
name: table.name,
schema: table.schema,
columns: table.columns.map((column) => ({
name: column.name,
dataType: column.data_type,
@@ -1877,6 +2067,7 @@ onUnmounted(() => {
:connection-id="connectionId"
:database="database"
:schema="schema"
:selected-schemas="selectedSchemas"
:databases="databases"
:schemas="schemas"
:sql-connections="sqlConnections"
@@ -1904,6 +2095,7 @@ onUnmounted(() => {
@set-connection="setConnection"
@set-database="setDatabase"
@set-schema="setSchema"
@set-selected-schemas="setSelectedSchemas"
@update:table-search="(value) => (tableSearch = value)"
@set-diagram-mode="setDiagramMode"
@toggle-match-panel="handleToggleMatchPanel"
@@ -1926,6 +2118,7 @@ onUnmounted(() => {
<LayerPanel
:tables="tables"
:record-history="recordHistory"
:is-multi-schema="isMultiSchema"
class="h-full overflow-y-auto"
@add-layer="handleAddLayer"
@layer-changed="handleLayerChanged"
@@ -2133,7 +2326,7 @@ onUnmounted(() => {
<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>
<SelectItem v-for="table in tables" :key="`source-${getTableKey(table)}`" :value="getTableKey(table)" :disabled="table.columns.length === 0">{{ getTableKey(table) }}</SelectItem>
</SelectContent>
</Select>
<Select v-model="relationshipDraft.sourceColumn">
@@ -2160,7 +2353,7 @@ onUnmounted(() => {
<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>
<SelectItem v-for="table in tables" :key="`target-${getTableKey(table)}`" :value="getTableKey(table)" :disabled="table.columns.length === 0">{{ getTableKey(table) }}</SelectItem>
</SelectContent>
</Select>
<Select v-model="relationshipDraft.targetColumn">
@@ -2194,6 +2387,6 @@ onUnmounted(() => {
</Dialog>
<CreateDraftTableDialog v-model:open="showCreateTableDialog" :layers="layerStore.layers" :active-layer-id="layerStore.activeLayerId" :existing-names="tables.map((t) => t.name)" @create="handleCreateDraftTable" />
<DiagramSyncDialog v-model:open="showSyncDialog" :tables="tables" :connection-id="connectionId" :database="database" :schema="schema" :database-type="selectedConnection?.db_type" @synced="handleSyncedDraftTables" />
<DiagramSyncDialog v-model:open="showSyncDialog" :tables="tables" :connection-id="connectionId" :database="database" :schema="schema || selectedSchemas[0] || ''" :database-type="selectedConnection?.db_type" @synced="handleSyncedDraftTables" />
<DangerConfirmDialog v-model:open="showRefreshConfirm" :title="t('diagram.refresh')" :message="t('diagram.refreshConfirm')" :confirm-label="t('diagram.refresh')" @confirm="confirmRefreshDiagram" />
</template>
@@ -15,6 +15,7 @@ const props = defineProps<{
data: {
table: DiagramTable;
relationships?: (DiagramRelationship | InferredRelationship)[];
isMultiSchema?: boolean;
};
selected?: boolean;
}>();
@@ -34,7 +35,10 @@ function isForeignKeyColumn(table: DiagramTable, columnName: string): boolean {
function isRelationshipColumn(table: DiagramTable, columnName: string): boolean {
if (!props.data.relationships) return false;
return props.data.relationships.some((relationship) => (relationship.sourceTable === table.name && relationship.sourceColumn === columnName) || (relationship.targetTable === table.name && relationship.targetColumn === columnName));
const tableId = props.data.isMultiSchema && table.schema ? `${table.schema}.${table.name}` : table.name;
return props.data.relationships.some(
(relationship) => ((relationship.sourceTable === tableId || relationship.sourceTable === table.name) && relationship.sourceColumn === columnName) || ((relationship.targetTable === tableId || relationship.targetTable === table.name) && relationship.targetColumn === columnName),
);
}
function truncateLabel(value: string, maxChars: number): string {
@@ -42,7 +46,9 @@ function truncateLabel(value: string, maxChars: number): string {
return `${value.slice(0, Math.max(1, maxChars - 1))}…`;
}
const layerColor = computed(() => layerStore.getLayerColor(props.data.table.name));
const tableIdentifier = computed(() => (props.data.isMultiSchema && props.data.table.schema ? `${props.data.table.schema}.${props.data.table.name}` : props.data.table.name));
const layerColor = computed(() => layerStore.getLayerColor(tableIdentifier.value) || layerStore.getLayerColor(props.data.table.name));
const handleOffsetStyle = computed(() =>
EDGE_HANDLE_OUTSET > 0
@@ -69,9 +75,14 @@ const handleOffsetStyle = computed(() =>
<div class="overflow-hidden rounded-[inherit]">
<div class="flex h-11 cursor-grab items-center gap-2 border-b bg-muted/40 px-3 active:cursor-grabbing">
<Table2 class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-sm font-medium" :title="data.table.name">
{{ truncateLabel(data.table.name, TABLE_NAME_MAX_CHARS) }}
</span>
<div class="min-w-0 flex-1 flex flex-col justify-center">
<span v-if="data.isMultiSchema && data.table.schema" class="truncate text-[10px] text-muted-foreground font-mono leading-none mb-0.5" :title="data.table.schema">
{{ data.table.schema }}
</span>
<span class="min-w-0 truncate text-sm font-medium leading-tight" :title="data.isMultiSchema && data.table.schema ? `${data.table.schema}.${data.table.name}` : data.table.name">
{{ truncateLabel(data.table.name, TABLE_NAME_MAX_CHARS) }}
</span>
</div>
<Badge v-if="isDraft" variant="outline" class="h-5 shrink-0 px-1.5 text-[10px] border-amber-500/50 text-amber-700 dark:text-amber-400">Draft</Badge>
<Badge variant="outline" class="h-5 px-1.5 text-[10px]">{{ visibleColumns(data.table).length }}</Badge>
</div>
+7
View File
@@ -4170,6 +4170,13 @@ export default {
selectConnection: "Select connection",
selectDatabase: "Select database",
selectSchema: "Select schema",
allSchemas: "Entire database (All schemas)",
entireDatabase: "Entire database",
selectedSchemasCount: "{count} schemas selected",
selectAllSchemas: "Select all schemas",
deselectAllSchemas: "Clear selection",
onlyThisSchema: "Only",
searchSchemas: "Search schemas...",
searchTables: "Search tables, columns, keys...",
refresh: "Refresh diagram",
refreshConfirm: "Refreshing reloads table metadata and resets the canvas layout and undo history. Saved modeled relationships are kept. Continue?",
+7
View File
@@ -3967,6 +3967,13 @@ export default withEnglishFallback({
selectConnection: "Seleccionar conexión",
selectDatabase: "Seleccionar base de datos",
selectSchema: "Seleccionar esquema",
allSchemas: "Base de datos completa (todos los esquemas)",
entireDatabase: "Base de datos completa",
selectedSchemasCount: "{count} esquemas seleccionados",
selectAllSchemas: "Seleccionar todos los esquemas",
deselectAllSchemas: "Borrar selección",
onlyThisSchema: "Solo",
searchSchemas: "Buscar esquemas...",
searchTables: "Buscar tablas, columnas, claves...",
refresh: "Actualizar diagrama",
loading: "Leyendo relaciones...",
+7
View File
@@ -3965,6 +3965,13 @@ export default withEnglishFallback({
selectConnection: "Seleziona connessione",
selectDatabase: "Seleziona database",
selectSchema: "Seleziona schema",
allSchemas: "Intero database (tutti gli schemi)",
entireDatabase: "Intero database",
selectedSchemasCount: "{count} schemi selezionati",
selectAllSchemas: "Seleziona tutti gli schemi",
deselectAllSchemas: "Cancella selezione",
onlyThisSchema: "Solo",
searchSchemas: "Cerca schemi...",
searchTables: "Cerca tabelle, colonne, chiavi...",
refresh: "Aggiorna diagramma",
loading: "Lettura relazioni...",
+7
View File
@@ -4032,6 +4032,13 @@ export default withEnglishFallback({
selectConnection: "接続を選択",
selectDatabase: "データベースを選択",
selectSchema: "スキーマを選択",
allSchemas: "データベース全体(すべてのスキーマ)",
entireDatabase: "データベース全体",
selectedSchemasCount: "{count}個のスキーマを選択",
selectAllSchemas: "すべてのスキーマを選択",
deselectAllSchemas: "選択をクリア",
onlyThisSchema: "このスキーマのみ",
searchSchemas: "スキーマを検索...",
searchTables: "テーブル、列、キーを検索...",
refresh: "図を更新",
loading: "リレーションを読み取り中...",
+7
View File
@@ -3925,6 +3925,13 @@ export default withEnglishFallback({
selectConnection: "연결 선택",
selectDatabase: "데이터베이스 선택",
selectSchema: "스키마 선택",
allSchemas: "전체 데이터베이스(모든 스키마)",
entireDatabase: "전체 데이터베이스",
selectedSchemasCount: "스키마 {count}개 선택됨",
selectAllSchemas: "모든 스키마 선택",
deselectAllSchemas: "선택 지우기",
onlyThisSchema: "이 스키마만",
searchSchemas: "스키마 검색...",
searchTables: "테이블, 컬럼, 키 검색...",
refresh: "다이어그램 새로고침",
loading: "관계를 읽는 중...",
+7
View File
@@ -3967,6 +3967,13 @@ export default withEnglishFallback({
selectConnection: "Selecionar conexão",
selectDatabase: "Selecionar banco de dados",
selectSchema: "Selecionar schema",
allSchemas: "Banco de dados inteiro (todos os schemas)",
entireDatabase: "Banco de dados inteiro",
selectedSchemasCount: "{count} schemas selecionados",
selectAllSchemas: "Selecionar todos os schemas",
deselectAllSchemas: "Limpar seleção",
onlyThisSchema: "Somente",
searchSchemas: "Pesquisar schemas...",
searchTables: "Pesquisar tabelas, colunas, chaves...",
refresh: "Atualizar diagrama",
loading: "Lendo relacionamentos...",
+7
View File
@@ -4154,6 +4154,13 @@ export default withEnglishFallback({
selectConnection: "选择连接",
selectDatabase: "选择数据库",
selectSchema: "选择模式",
allSchemas: "整个数据库 (全部模式)",
entireDatabase: "整个数据库",
selectedSchemasCount: "已选 {count} 个模式",
selectAllSchemas: "全选模式",
deselectAllSchemas: "清空选择",
onlyThisSchema: "仅选",
searchSchemas: "搜索模式...",
searchTables: "搜索表/字段/外键...",
refresh: "刷新关系图",
refreshConfirm: "刷新将重新加载表结构并重置画布布局与撤回历史,本地已保存的建模关系不受影响。确定继续?",
+7
View File
@@ -3710,6 +3710,13 @@ export default withEnglishFallback({
selectConnection: "選擇連線",
selectDatabase: "選擇資料庫",
selectSchema: "選擇 Schema",
allSchemas: "整個資料庫(所有 Schema)",
entireDatabase: "整個資料庫",
selectedSchemasCount: "已選取 {count} 個 Schema",
selectAllSchemas: "選取所有 Schema",
deselectAllSchemas: "清除選取",
onlyThisSchema: "僅此 Schema",
searchSchemas: "搜尋 Schema...",
searchTables: "搜尋資料表/欄位/外鍵……",
refresh: "重新整理關係圖",
loading: "正在讀取關係……",
+74 -30
View File
@@ -16,6 +16,7 @@ export function editableStructureIndexes(table: DiagramTable): EditableStructure
export interface DiagramTable {
name: string;
schema?: string;
columns: ColumnInfo[];
foreignKeys: ForeignKeyInfo[];
/** Unique/PK indexes used for FK cardinality; drafts also sync via buildCreateTableSql */
@@ -118,8 +119,12 @@ export interface DiagramLayoutOptions {
margin?: number;
}
function relationshipId(sourceTable: string, fk: ForeignKeyInfo): string {
return [sourceTable, fk.name || "foreign_key", fk.column, fk.ref_table, fk.ref_column].join(":");
export function diagramTableId(table: { name: string; schema?: string }, isMultiSchema = false): string {
return isMultiSchema && table.schema ? `${table.schema}.${table.name}` : table.name;
}
function relationshipId(sourceId: string, fk: ForeignKeyInfo, targetId?: string): string {
return [sourceId, fk.name || "foreign_key", fk.column, targetId || fk.ref_table, fk.ref_column].join(":");
}
function columnExists(table: DiagramTable | undefined, columnName: string): boolean {
@@ -176,33 +181,66 @@ export function normalizeCustomDiagramRelationship(input: Omit<CustomDiagramRela
};
}
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]));
export function buildDiagramRelationships(tables: DiagramTable[], customRelationships: CustomDiagramRelationship[] = [], isMultiSchema = false): DiagramRelationship[] {
const tableIdMap = new Map(tables.map((table) => [diagramTableId(table, isMultiSchema), table]));
const tableBySchemaAndName = new Map<string, DiagramTable>();
const tableByName = new Map<string, DiagramTable>();
for (const table of tables) {
const schemaAndNameKey = `${table.schema ?? ""}\0${table.name}`;
if (!tableBySchemaAndName.has(schemaAndNameKey)) tableBySchemaAndName.set(schemaAndNameKey, table);
if (!tableByName.has(table.name)) tableByName.set(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: foreignKeySourceCardinality(table, fk),
targetCardinality: "1" as const,
})),
);
const foreignKeyRelationships = tables.flatMap((table) => {
const sourceId = diagramTableId(table, isMultiSchema);
return table.foreignKeys
.map((fk): DiagramRelationship | null => {
let targetTable: DiagramTable | undefined;
if (fk.ref_schema) {
// Specified ref_schema: only match target within that schema
targetTable = tableBySchemaAndName.get(`${fk.ref_schema}\0${fk.ref_table}`);
} else if (table.schema) {
// No ref_schema but source table has schema: only match target in the same schema
targetTable = tableBySchemaAndName.get(`${table.schema}\0${fk.ref_table}`);
// In multi-schema mode, do not blindly fall back across other schemas
} else if (!isMultiSchema) {
// Single-schema mode without schema qualification: fall back to bare table name
targetTable = tableByName.get(fk.ref_table);
}
if (!targetTable) return null;
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,
}));
const targetId = diagramTableId(targetTable, isMultiSchema);
return {
id: relationshipId(sourceId, fk, targetId),
name: fk.name,
kind: "foreign-key" as const,
sourceTable: sourceId,
sourceColumn: fk.column,
targetTable: targetId,
targetColumn: fk.ref_column,
sourceCardinality: foreignKeySourceCardinality(table, fk),
targetCardinality: "1" as const,
};
})
.filter((rel): rel is DiagramRelationship => rel !== null);
});
// Custom relationships may reference tables by plain name or composite `schema.table` id
// (match panel uses the latter); normalize both to the canvas node id.
const custom = customRelationships.flatMap((relationship) => {
const sourceTable = tableIdMap.get(relationship.sourceTable) ?? tables.find((t) => t.name === relationship.sourceTable);
const targetTable = tableIdMap.get(relationship.targetTable) ?? tables.find((t) => t.name === relationship.targetTable);
if (!sourceTable || !targetTable) return [];
if (!columnExists(sourceTable, relationship.sourceColumn) || !columnExists(targetTable, relationship.targetColumn)) return [];
return [
{
...relationship,
sourceTable: diagramTableId(sourceTable, isMultiSchema),
targetTable: diagramTableId(targetTable, isMultiSchema),
kind: "custom" as const,
},
];
});
return deduplicateRelationships([...foreignKeyRelationships, ...custom]);
}
@@ -265,13 +303,15 @@ export function filterDiagramTables(tables: DiagramTable[], query: string): Diag
if (!q) return tables;
return tables.filter((table) => {
if (table.schema?.toLowerCase().includes(q)) return true;
if (table.name.toLowerCase().includes(q)) return true;
if (table.schema && `${table.schema}.${table.name}`.toLowerCase().includes(q)) return true;
if (table.columns.some((column) => column.name.toLowerCase().includes(q) || column.data_type.toLowerCase().includes(q))) return true;
return table.foreignKeys.some((fk) => fk.name.toLowerCase().includes(q) || fk.column.toLowerCase().includes(q) || fk.ref_table.toLowerCase().includes(q) || fk.ref_column.toLowerCase().includes(q));
return table.foreignKeys.some((fk) => fk.name.toLowerCase().includes(q) || fk.column.toLowerCase().includes(q) || fk.ref_table.toLowerCase().includes(q) || fk.ref_column.toLowerCase().includes(q) || (fk.ref_schema && fk.ref_schema.toLowerCase().includes(q)));
});
}
export function layoutDiagramTables(tables: Pick<DiagramTable, "name" | "columns">[], options: DiagramLayoutOptions = {}): Record<string, DiagramPosition> {
export function layoutDiagramTables(tables: (Pick<DiagramTable, "name" | "columns"> & { schema?: string })[], options: DiagramLayoutOptions = {}, isMultiSchema = false): Record<string, DiagramPosition> {
const columnsPerRow = Math.max(1, options.columnsPerRow ?? Math.ceil(Math.sqrt(Math.max(tables.length, 1))));
const cardWidth = options.cardWidth ?? 260;
const gapX = options.gapX ?? 56;
@@ -291,10 +331,14 @@ export function layoutDiagramTables(tables: Pick<DiagramTable, "name" | "columns
const rowTables = tables.slice(start, start + columnsPerRow);
const rowContentHeight = Math.max(...rowTables.map(measureHeight));
rowTables.forEach((table, col) => {
positions[table.name] = {
const pos = {
x: margin + col * (cardWidth + gapX),
y,
};
positions[table.name] = pos;
if (isMultiSchema && table.schema) {
positions[`${table.schema}.${table.name}`] = pos;
}
});
y += rowContentHeight + gapY;
}
@@ -1,4 +1,4 @@
import type { DiagramTable, DiagramRelationship } from "./erDiagram";
import { diagramTableId, type DiagramTable, type DiagramRelationship } from "./erDiagram";
import type { InferredRelationship, DiagramNode, DiagramEdge, DiagramLayer } from "@/types/diagram";
import type { Node, Edge } from "@vue-flow/core";
import { useLayerStore } from "./layer-store";
@@ -14,34 +14,49 @@ export type RelationshipEdgeData = {
};
/** Table is on canvas when unlayered, or when its layer is visible. */
export function isTableCanvasVisible(tableName: string, layers: DiagramLayer[]): boolean {
const layer = layers.find((l) => l.tableNames.includes(tableName));
export function isTableCanvasVisible(tableName: string, layers: DiagramLayer[], rawName?: string): boolean {
const layer = layers.find((l) => l.tableNames.includes(tableName) || (rawName && l.tableNames.includes(rawName)));
if (!layer) return true;
return layer.visible;
}
/** positions are absolute canvas coordinates; converted to relative when parented to a visible layer */
export function toVueFlowNodes(tables: DiagramTable[], positions?: Record<string, { x: number; y: number }>): Node<{ table: DiagramTable }>[] {
export function toVueFlowNodes(tables: DiagramTable[], positions?: Record<string, { x: number; y: number }>, isMultiSchema = false): Node<{ table: DiagramTable; isMultiSchema?: boolean }>[] {
const layerStore = useLayerStore();
const layers = layerStore.layers;
// Track raw name frequencies to avoid ambiguous fallback when multiple schemas have identical table names
const rawNameCounts = new Map<string, number>();
if (isMultiSchema) {
for (const table of tables) {
rawNameCounts.set(table.name, (rawNameCounts.get(table.name) ?? 0) + 1);
}
}
return tables
.filter((table) => !table.pendingDrop && isTableCanvasVisible(table.name, layers))
.filter((table) => {
if (table.pendingDrop) return false;
const tableId = diagramTableId(table, isMultiSchema);
const allowRawFallback = !isMultiSchema || rawNameCounts.get(table.name) === 1;
return isTableCanvasVisible(tableId, layers, allowRawFallback ? table.name : undefined);
})
.map((table) => {
const layer = layerStore.getLayerByTable(table.name);
const absolute = positions?.[table.name] || { x: 0, y: 0 };
const tableId = diagramTableId(table, isMultiSchema);
const allowRawFallback = !isMultiSchema || rawNameCounts.get(table.name) === 1;
const layer = layerStore.getLayerByTable(tableId) || (allowRawFallback ? layerStore.getLayerByTable(table.name) : undefined);
const absolute = positions?.[tableId] || (allowRawFallback ? positions?.[table.name] : undefined) || { x: 0, y: 0 };
const visibleParent = layer?.visible ? layer : undefined;
const layerPos = visibleParent?.position || { x: 0, y: 0 };
const relative = visibleParent ? { x: absolute.x - layerPos.x, y: absolute.y - layerPos.y } : absolute;
return {
id: table.name,
id: tableId,
type: "table",
position: relative,
parentNode: visibleParent?.id,
expandParent: false,
zIndex: TABLE_Z_INDEX,
data: { table },
data: { table, isMultiSchema },
};
});
}
@@ -13,6 +13,7 @@ export interface DiagramJsonSnapshot {
};
tables: Array<{
name: string;
schema?: string;
columns: Array<{
name: string;
dataType: string;
+191 -1
View File
@@ -1,6 +1,7 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { buildDiagramJoinSql, buildDiagramRelationships, filterDiagramTables, layoutDiagramTables, normalizeCustomDiagramRelationship } from "../../apps/desktop/src/lib/diagram/erDiagram.ts";
import { buildDiagramJoinSql, buildDiagramRelationships, diagramTableId, filterDiagramTables, layoutDiagramTables, normalizeCustomDiagramRelationship } from "../../apps/desktop/src/lib/diagram/erDiagram.ts";
import { isTableCanvasVisible } from "../../apps/desktop/src/lib/diagram/vue-flow-adapter.ts";
test("builds relationships only between tables in the diagram", () => {
const relationships = buildDiagramRelationships([
@@ -390,3 +391,192 @@ test("E7: custom relationship cardinalities are preserved", () => {
assert.equal(relationships[0].sourceCardinality, "N");
assert.equal(relationships[0].targetCardinality, "N");
});
test("diagramTableId formats identifier based on isMultiSchema flag", () => {
const table = { name: "users", schema: "iam" };
assert.equal(diagramTableId(table, false), "users");
assert.equal(diagramTableId(table, true), "iam.users");
assert.equal(diagramTableId({ name: "orders" }, true), "orders");
});
test("buildDiagramRelationships resolves cross-schema foreign keys in multi-schema mode", () => {
const iamUserRoles = {
name: "user_roles",
schema: "iam",
columns: [
{ name: "id", data_type: "uuid", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "user_id", data_type: "uuid", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
],
foreignKeys: [
{
name: "fk_iam_user_roles_user_id",
column: "user_id",
ref_schema: "public",
ref_table: "users",
ref_column: "id",
},
],
};
const publicUsers = {
name: "users",
schema: "public",
columns: [
{ name: "id", data_type: "uuid", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
],
foreignKeys: [],
};
const rels = buildDiagramRelationships([iamUserRoles, publicUsers], [], true);
assert.equal(rels.length, 1);
assert.equal(rels[0].sourceTable, "iam.user_roles");
assert.equal(rels[0].sourceColumn, "user_id");
assert.equal(rels[0].targetTable, "public.users");
assert.equal(rels[0].targetColumn, "id");
});
test("buildDiagramRelationships normalizes custom relationship tables to composite ids in multi-schema mode", () => {
const publicUsers = {
name: "users",
schema: "public",
columns: [
{ name: "id", data_type: "uuid", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
],
foreignKeys: [],
};
const auditLogs = {
name: "audit_log",
schema: "audit",
columns: [
{ name: "actor_id", data_type: "uuid", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
],
foreignKeys: [],
};
// mixed references: match panel stores composite ids, promoted inferred relationships store plain names
const custom = normalizeCustomDiagramRelationship({
name: "user_audit",
sourceTable: "public.users",
sourceColumn: "id",
targetTable: "audit_log",
targetColumn: "actor_id",
sourceCardinality: "1",
targetCardinality: "N",
});
const rels = buildDiagramRelationships([publicUsers, auditLogs], [custom], true);
assert.equal(rels.length, 1);
assert.equal(rels[0].kind, "custom");
assert.equal(rels[0].sourceTable, "public.users");
assert.equal(rels[0].targetTable, "audit.audit_log");
assert.equal(rels[0].sourceColumn, "id");
assert.equal(rels[0].targetColumn, "actor_id");
});
test("filterDiagramTables matches schema name in search query", () => {
const tables = [
{ name: "logs", schema: "audit", columns: [], foreignKeys: [] },
{ name: "users", schema: "iam", columns: [], foreignKeys: [] },
{ name: "products", schema: "public", columns: [], foreignKeys: [] },
];
const auditMatch = filterDiagramTables(tables, "audit");
assert.equal(auditMatch.length, 1);
assert.equal(auditMatch[0].name, "logs");
const qualifiedMatch = filterDiagramTables(tables, "iam.users");
assert.equal(qualifiedMatch.length, 1);
assert.equal(qualifiedMatch[0].name, "users");
});
test("layoutDiagramTables populates schema-qualified keys in multi-schema mode", () => {
const tables = [
{ name: "logs", schema: "audit", columns: [] },
{ name: "users", schema: "iam", columns: [] },
];
const positions = layoutDiagramTables(tables, { columnsPerRow: 2 }, true);
assert.ok(positions["audit.logs"]);
assert.ok(positions["logs"]);
assert.ok(positions["iam.users"]);
assert.ok(positions["users"]);
assert.deepEqual(positions["audit.logs"], positions["logs"]);
});
test("buildDiagramRelationships applies strict schema-scoped foreign key resolution", () => {
const iamUserRoles = {
name: "user_roles",
schema: "iam",
columns: [
{ name: "id", data_type: "uuid", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "role_id", data_type: "uuid", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
],
foreignKeys: [
{
name: "fk_user_roles_role_id",
column: "role_id",
// no ref_schema: should strictly look in "iam"
ref_table: "roles",
ref_column: "id",
},
],
};
const publicRoles = {
name: "roles",
schema: "public",
columns: [
{ name: "id", data_type: "uuid", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
],
foreignKeys: [],
};
const iamRoles = {
name: "roles",
schema: "iam",
columns: [
{ name: "id", data_type: "uuid", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
],
foreignKeys: [],
};
// In multi-schema mode: without ref_schema, matches iam.roles, never public.roles
const relsWithIam = buildDiagramRelationships([iamUserRoles, publicRoles, iamRoles], [], true);
assert.equal(relsWithIam.length, 1);
assert.equal(relsWithIam[0].sourceTable, "iam.user_roles");
assert.equal(relsWithIam[0].targetTable, "iam.roles");
// In multi-schema mode: if iam.roles does NOT exist, do NOT fall back across schemas
const relsWithoutIam = buildDiagramRelationships([iamUserRoles, publicRoles], [], true);
assert.equal(relsWithoutIam.length, 0);
// In single-schema mode: falls back to matching table by bare name
const singleSchemaUserRoles = { ...iamUserRoles, schema: undefined };
const singleSchemaRoles = { ...publicRoles, schema: undefined };
const singleSchemaRels = buildDiagramRelationships([singleSchemaUserRoles, singleSchemaRoles], [], false);
assert.equal(singleSchemaRels.length, 1);
assert.equal(singleSchemaRels[0].sourceTable, "user_roles");
assert.equal(singleSchemaRels[0].targetTable, "roles");
});
test("isTableCanvasVisible respects schema qualification and raw fallback when unambiguous", () => {
const layer = {
id: "layer-1",
name: "Layer 1",
color: "#ff0000",
tableNames: ["iam.users", "legacy_orders"],
collapsed: false,
visible: false,
};
// iam.users is hidden by the layer
assert.equal(isTableCanvasVisible("iam.users", [layer], "users"), false);
// public.users is NOT in layer-1, and since rawName "users" is NOT in layer.tableNames, it stays visible
assert.equal(isTableCanvasVisible("public.users", [layer], undefined), true);
// legacy_orders using bare name fallback when unambiguous
assert.equal(isTableCanvasVisible("public.legacy_orders", [layer], "legacy_orders"), false);
});