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

This commit is contained in:
sunzhengbo authored and GitHub committed 2026-09-04 10:25:14 +08:00
1 parent d16645121d
commit 6d39eccdbf
18 files changed
+844 -173

No files matched your search

+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;