mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(diagram): support multi-schema and full database relationship views
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -4032,6 +4032,13 @@ export default withEnglishFallback({
|
||||
selectConnection: "接続を選択",
|
||||
selectDatabase: "データベースを選択",
|
||||
selectSchema: "スキーマを選択",
|
||||
allSchemas: "データベース全体(すべてのスキーマ)",
|
||||
entireDatabase: "データベース全体",
|
||||
selectedSchemasCount: "{count}個のスキーマを選択",
|
||||
selectAllSchemas: "すべてのスキーマを選択",
|
||||
deselectAllSchemas: "選択をクリア",
|
||||
onlyThisSchema: "このスキーマのみ",
|
||||
searchSchemas: "スキーマを検索...",
|
||||
searchTables: "テーブル、列、キーを検索...",
|
||||
refresh: "図を更新",
|
||||
loading: "リレーションを読み取り中...",
|
||||
|
||||
@@ -3925,6 +3925,13 @@ export default withEnglishFallback({
|
||||
selectConnection: "연결 선택",
|
||||
selectDatabase: "데이터베이스 선택",
|
||||
selectSchema: "스키마 선택",
|
||||
allSchemas: "전체 데이터베이스(모든 스키마)",
|
||||
entireDatabase: "전체 데이터베이스",
|
||||
selectedSchemasCount: "스키마 {count}개 선택됨",
|
||||
selectAllSchemas: "모든 스키마 선택",
|
||||
deselectAllSchemas: "선택 지우기",
|
||||
onlyThisSchema: "이 스키마만",
|
||||
searchSchemas: "스키마 검색...",
|
||||
searchTables: "테이블, 컬럼, 키 검색...",
|
||||
refresh: "다이어그램 새로고침",
|
||||
loading: "관계를 읽는 중...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -4154,6 +4154,13 @@ export default withEnglishFallback({
|
||||
selectConnection: "选择连接",
|
||||
selectDatabase: "选择数据库",
|
||||
selectSchema: "选择模式",
|
||||
allSchemas: "整个数据库 (全部模式)",
|
||||
entireDatabase: "整个数据库",
|
||||
selectedSchemasCount: "已选 {count} 个模式",
|
||||
selectAllSchemas: "全选模式",
|
||||
deselectAllSchemas: "清空选择",
|
||||
onlyThisSchema: "仅选",
|
||||
searchSchemas: "搜索模式...",
|
||||
searchTables: "搜索表/字段/外键...",
|
||||
refresh: "刷新关系图",
|
||||
refreshConfirm: "刷新将重新加载表结构并重置画布布局与撤回历史,本地已保存的建模关系不受影响。确定继续?",
|
||||
|
||||
@@ -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: "正在讀取關係……",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
+191
-1
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user