fix(diagram): display relationship cardinalities

This commit is contained in:
zipg authored and GitHub committed 2026-08-02 16:34:35 +08:00
1 parent 86c53479a1
commit fb7b2878fd
12 files changed
+316 -28

No files matched your search

+21 -2
View File
@@ -1,9 +1,10 @@
import type { ColumnInfo, ForeignKeyInfo } from "@/types/database";
import type { ColumnInfo, ForeignKeyInfo, IndexInfo } from "@/types/database";
export interface DiagramTable {
name: string;
columns: ColumnInfo[];
foreignKeys: ForeignKeyInfo[];
indexes?: IndexInfo[];
}
export interface DiagramRelationship {
@@ -56,6 +57,24 @@ function columnExists(table: DiagramTable | undefined, columnName: string): bool
return !!table?.columns.some((column) => column.name === columnName);
}
function sourceColumnsContainUniqueKey(sourceColumns: string[], keyColumns: string[]): boolean {
if (keyColumns.length === 0) return false;
const sourceColumnSet = new Set(sourceColumns);
return keyColumns.every((column) => sourceColumnSet.has(column));
}
function foreignKeySourceColumns(table: DiagramTable, foreignKey: ForeignKeyInfo): string[] {
if (!foreignKey.name) return [foreignKey.column];
return table.foreignKeys.filter((candidate) => candidate.name === foreignKey.name && candidate.ref_table === foreignKey.ref_table).map((candidate) => candidate.column);
}
function foreignKeySourceCardinality(table: DiagramTable, foreignKey: ForeignKeyInfo): "1" | "N" {
const sourceColumns = foreignKeySourceColumns(table, foreignKey);
const primaryKeyColumns = table.columns.filter((column) => column.is_primary_key).map((column) => column.name);
if (sourceColumnsContainUniqueKey(sourceColumns, primaryKeyColumns)) return "1";
return table.indexes?.some((index) => (index.is_unique || index.is_primary) && !index.filter?.trim() && sourceColumnsContainUniqueKey(sourceColumns, index.columns)) ? "1" : "N";
}
function customRelationshipId(relationship: Omit<CustomDiagramRelationship, "id">): string {
return ["custom", relationship.sourceTable, relationship.sourceColumn, relationship.targetTable, relationship.targetColumn, relationship.sourceCardinality, relationship.targetCardinality].join(":");
}
@@ -82,7 +101,7 @@ export function buildDiagramRelationships(tables: DiagramTable[], customRelation
sourceColumn: fk.column,
targetTable: fk.ref_table,
targetColumn: fk.ref_column,
sourceCardinality: "N" as const,
sourceCardinality: foreignKeySourceCardinality(table, fk),
targetCardinality: "1" as const,
})),
);
@@ -8,11 +8,18 @@ interface DiagramCanvas {
height: number;
}
export interface TableDiagramRelationshipLayout {
path: string;
routePoints: DiagramPosition[];
sourceCardinality: DiagramPosition;
targetCardinality: DiagramPosition;
}
export interface TableDiagramSvgOptions {
tables: DiagramTable[];
relationships: DiagramRelationship[];
positions: Record<string, DiagramPosition>;
relationshipPaths: Record<string, string>;
relationshipLayouts: Record<string, TableDiagramRelationshipLayout>;
canvas: DiagramCanvas;
cardWidth: number;
cardHeaderHeight: number;
@@ -30,8 +37,18 @@ function svgNumber(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, "");
}
function svgHeader(canvas: DiagramCanvas): string {
return [`<svg xmlns="http://www.w3.org/2000/svg" width="${svgNumber(canvas.width)}" height="${svgNumber(canvas.height)}" viewBox="0 0 ${svgNumber(canvas.width)} ${svgNumber(canvas.height)}">`, '<rect width="100%" height="100%" fill="#fafafa"/>'].join("");
interface SvgViewport {
x: number;
y: number;
width: number;
height: number;
}
function svgHeader(canvas: DiagramCanvas, viewport: SvgViewport = { x: 0, y: 0, width: canvas.width, height: canvas.height }): string {
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${svgNumber(viewport.width)}" height="${svgNumber(viewport.height)}" viewBox="${svgNumber(viewport.x)} ${svgNumber(viewport.y)} ${svgNumber(viewport.width)} ${svgNumber(viewport.height)}">`,
`<rect x="${svgNumber(viewport.x)}" y="${svgNumber(viewport.y)}" width="${svgNumber(viewport.width)}" height="${svgNumber(viewport.height)}" fill="#fafafa"/>`,
].join("");
}
function svgText(
@@ -45,12 +62,22 @@ function svgText(
anchor?: "start" | "middle" | "end";
family?: string;
decoration?: string;
stroke?: string;
strokeWidth?: number;
paintOrder?: string;
attributes?: Record<string, string>;
} = {},
): string {
const attrs = [`x="${svgNumber(x)}"`, `y="${svgNumber(y)}"`, `fill="${options.fill ?? "#18181b"}"`, `font-size="${options.size ?? 12}"`, `font-family="${options.family ?? "Arial, Helvetica, sans-serif"}"`, 'dominant-baseline="middle"'];
if (options.weight) attrs.push(`font-weight="${options.weight}"`);
if (options.anchor) attrs.push(`text-anchor="${options.anchor}"`);
if (options.decoration) attrs.push(`text-decoration="${options.decoration}"`);
if (options.stroke) attrs.push(`stroke="${options.stroke}"`);
if (options.strokeWidth) attrs.push(`stroke-width="${svgNumber(options.strokeWidth)}"`);
if (options.paintOrder) attrs.push(`paint-order="${options.paintOrder}"`);
for (const [name, value] of Object.entries(options.attributes ?? {})) {
attrs.push(`${name}="${escapeXml(value)}"`);
}
return `<text ${attrs.join(" ")}>${escapeXml(label)}</text>`;
}
@@ -68,15 +95,62 @@ function isForeignKeyColumn(table: DiagramTable, columnName: string): boolean {
return table.foreignKeys.some((fk) => fk.column === columnName);
}
function tableDiagramViewport(options: TableDiagramSvgOptions): SvgViewport {
const relationshipPadding = 20;
let minX = 0;
let minY = 0;
let maxX = options.canvas.width;
let maxY = options.canvas.height;
for (const layout of Object.values(options.relationshipLayouts)) {
const points = [...layout.routePoints, layout.sourceCardinality, layout.targetCardinality];
for (const point of points) {
minX = Math.min(minX, point.x - relationshipPadding);
minY = Math.min(minY, point.y - relationshipPadding);
maxX = Math.max(maxX, point.x + relationshipPadding);
maxY = Math.max(maxY, point.y + relationshipPadding);
}
}
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
export function buildTableDiagramSvg(options: TableDiagramSvgOptions): string {
const parts = [svgHeader(options.canvas), tableDiagramDefs(), '<g fill="none" stroke="#2563eb" stroke-opacity="0.58" stroke-width="1.6">'];
const parts = [svgHeader(options.canvas, tableDiagramViewport(options)), tableDiagramDefs()];
for (const relationship of options.relationships) {
const path = options.relationshipPaths[relationship.id];
if (!path) continue;
parts.push(`<path d="${escapeXml(path)}" marker-end="url(#dbx-diagram-arrow)">` + `<title>${escapeXml(`${relationship.sourceTable}.${relationship.sourceColumn} -> ${relationship.targetTable}.${relationship.targetColumn}`)}</title>` + "</path>");
const layout = options.relationshipLayouts[relationship.id];
if (!layout?.path) continue;
parts.push(`<g data-relationship-id="${escapeXml(relationship.id)}">`);
parts.push(
`<path d="${escapeXml(layout.path)}" fill="none" stroke="#2563eb" stroke-opacity="0.58" stroke-width="1.6" marker-end="url(#dbx-diagram-arrow)">` +
`<title>${escapeXml(`${relationship.sourceTable}.${relationship.sourceColumn} (${relationship.sourceCardinality}:${relationship.targetCardinality}) -> ${relationship.targetTable}.${relationship.targetColumn}`)}</title>` +
"</path>",
);
parts.push(
svgText(relationship.sourceCardinality, layout.sourceCardinality.x, layout.sourceCardinality.y, {
size: 12,
weight: "600",
anchor: "middle",
stroke: "#fafafa",
strokeWidth: 4,
paintOrder: "stroke",
attributes: { "data-cardinality-end": "source" },
}),
);
parts.push(
svgText(relationship.targetCardinality, layout.targetCardinality.x, layout.targetCardinality.y, {
size: 12,
weight: "600",
anchor: "middle",
stroke: "#fafafa",
strokeWidth: 4,
paintOrder: "stroke",
attributes: { "data-cardinality-end": "target" },
}),
);
parts.push("</g>");
}
parts.push("</g>");
for (const table of options.tables) {
const position = options.positions[table.name] ?? { x: 0, y: 0 };