fix(diagram): display relationship cardinalities
This commit is contained in:
12 files changed
+316
-28
No files matched your search
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user