diff --git a/apps/desktop/src/components/diagram/SchemaDiagramDialog.vue b/apps/desktop/src/components/diagram/SchemaDiagramDialog.vue index 5156e95f9e..5af29d711a 100644 --- a/apps/desktop/src/components/diagram/SchemaDiagramDialog.vue +++ b/apps/desktop/src/components/diagram/SchemaDiagramDialog.vue @@ -14,7 +14,7 @@ import { DIAGRAM_SQL_TYPES, isSchemaAware as isSchemaAwareDatabase } from "@/lib import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions"; import { buildDiagramJoinSql, buildDiagramRelationships, filterDiagramTables, layoutDiagramTables, normalizeCustomDiagramRelationship, type CustomDiagramRelationship, type DiagramPosition, type DiagramRelationship, type DiagramTable } from "@/lib/diagram/erDiagram"; import { buildEngineeringDiagram } from "@/lib/diagram/engineeringDiagram"; -import { buildEngineeringDiagramSvg, buildTableDiagramSvg, diagramSvgFileName } from "@/lib/export/diagramSvgExport"; +import { buildEngineeringDiagramSvg, buildTableDiagramSvg, diagramSvgFileName, type TableDiagramRelationshipLayout } from "@/lib/export/diagramSvgExport"; import { clampDiagramZoom, zoomFromGestureScale, zoomFromWheelDelta } from "@/lib/diagram/diagramZoom"; import { Copy, Download, KeyRound, Link2, Loader2, Maximize2, Network, Plus, RefreshCw, Search, Table2, Trash2, X, ZoomIn, ZoomOut } from "@lucide/vue"; import { useToast } from "@/composables/useToast"; @@ -88,7 +88,7 @@ const relationshipDraft = ref({ sourceColumn: "", targetTable: "", targetColumn: "", - cardinality: "one-to-many" as "one-to-one" | "one-to-many" | "many-to-one", + cardinality: "one-to-many" as "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many", }); const sqlConnections = computed(() => store.connections.filter((connection) => DIAGRAM_SQL_TYPES.has(connection.db_type))); @@ -253,6 +253,7 @@ function defaultRelationshipName(relationship: Omit { if (relationshipDraft.value.cardinality === "one-to-one") return { sourceCardinality: "1", targetCardinality: "1" }; if (relationshipDraft.value.cardinality === "many-to-one") return { sourceCardinality: "N", targetCardinality: "1" }; + if (relationshipDraft.value.cardinality === "many-to-many") return { sourceCardinality: "N", targetCardinality: "N" }; return { sourceCardinality: "1", targetCardinality: "N" }; } @@ -423,10 +424,17 @@ function candidateRouteXs(source: TableRect, target: TableRect): number[] { }); } -function relationshipPath(relationship: DiagramRelationship): string { +function relationshipLayout(relationship: DiagramRelationship): TableDiagramRelationshipLayout { const source = getTableRect(relationship.sourceTable); const target = getTableRect(relationship.targetTable); - if (!source || !target) return ""; + if (!source || !target) { + return { + path: "", + routePoints: [], + sourceCardinality: { x: 0, y: 0 }, + targetCardinality: { x: 0, y: 0 }, + }; + } const y1 = columnAnchorY(relationship.sourceTable, relationship.sourceColumn); const y2 = columnAnchorY(relationship.targetTable, relationship.targetColumn); @@ -444,9 +452,29 @@ function relationshipPath(relationship: DiagramRelationship): string { const x1 = routeSideX(source, routeX, 2); const x2 = routeSideX(target, routeX, 2); - return `M ${x1} ${y1} L ${routeX} ${y1} L ${routeX} ${y2} L ${x2} ${y2}`; + const cardinalityOffset = 14; + const cardinalityLift = 10; + return { + path: `M ${x1} ${y1} L ${routeX} ${y1} L ${routeX} ${y2} L ${x2} ${y2}`, + routePoints: [ + { x: x1, y: y1 }, + { x: routeX, y: y1 }, + { x: routeX, y: y2 }, + { x: x2, y: y2 }, + ], + sourceCardinality: { + x: x1 + (routeX < source.x ? -cardinalityOffset : cardinalityOffset), + y: y1 - cardinalityLift, + }, + targetCardinality: { + x: x2 + (routeX < target.x ? -cardinalityOffset : cardinalityOffset), + y: y2 - cardinalityLift, + }, + }; } +const tableRelationshipLayouts = computed>(() => Object.fromEntries(visibleRelationships.value.map((relationship) => [relationship.id, relationshipLayout(relationship)]))); + function engineeringEntityCenter(tableName: string): DiagramPosition { const entity = engineeringDiagram.value.entities.find((item) => item.name === tableName); return entity ? { x: entity.x + entity.width / 2, y: entity.y + entity.height / 2 } : { x: 0, y: 0 }; @@ -541,8 +569,12 @@ async function setSchema(value: string) { async function loadTableDiagramData(tableName: string, querySchema: string): Promise { try { - const [columns, foreignKeys] = await Promise.all([api.getColumns(connectionId.value, database.value, querySchema, tableName), api.listForeignKeys(connectionId.value, database.value, querySchema, tableName).catch(() => [])]); - return { name: tableName, columns, foreignKeys }; + const [columns, foreignKeys, indexes] = await Promise.all([ + api.getColumns(connectionId.value, database.value, querySchema, tableName), + api.listForeignKeys(connectionId.value, database.value, querySchema, tableName).catch(() => []), + api.listIndexes(connectionId.value, database.value, querySchema, tableName).catch(() => []), + ]); + return { name: tableName, columns, foreignKeys, indexes }; } catch (e) { failedTableCount.value += 1; console.warn(`[diagram] failed to load table metadata: ${tableName}`, e); @@ -655,10 +687,6 @@ function resetZoomAndLayout() { resetLayout(); } -function tableRelationshipPaths(): Record { - return Object.fromEntries(visibleRelationships.value.map((relationship) => [relationship.id, relationshipPath(relationship)])); -} - function currentDiagramSvg(): string { if (diagramMode.value === "engineering") { return buildEngineeringDiagramSvg(engineeringDiagram.value); @@ -668,7 +696,7 @@ function currentDiagramSvg(): string { tables: visibleTables.value, relationships: visibleRelationships.value, positions: positions.value, - relationshipPaths: tableRelationshipPaths(), + relationshipLayouts: tableRelationshipLayouts.value, canvas: canvasSize.value, cardWidth: CARD_WIDTH, cardHeaderHeight: CARD_HEADER_HEIGHT, @@ -941,6 +969,7 @@ onUnmounted(stopDrag); {{ t("diagram.cardinalityOneToMany") }} {{ t("diagram.cardinalityManyToOne") }} + {{ t("diagram.cardinalityManyToMany") }} {{ t("diagram.cardinalityOneToOne") }} @@ -1026,9 +1055,39 @@ onUnmounted(stopDrag); - - {{ relationshipTitle(relationship) }} - +
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): 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, })), ); diff --git a/apps/desktop/src/lib/export/diagramSvgExport.ts b/apps/desktop/src/lib/export/diagramSvgExport.ts index 127ca89199..50f9433f4a 100644 --- a/apps/desktop/src/lib/export/diagramSvgExport.ts +++ b/apps/desktop/src/lib/export/diagramSvgExport.ts @@ -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; - relationshipPaths: Record; + relationshipLayouts: Record; 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 [``, ''].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 [ + ``, + ``, + ].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 { 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 `${escapeXml(label)}`; } @@ -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(), '']; + 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(`` + `${escapeXml(`${relationship.sourceTable}.${relationship.sourceColumn} -> ${relationship.targetTable}.${relationship.targetColumn}`)}` + ""); + const layout = options.relationshipLayouts[relationship.id]; + if (!layout?.path) continue; + parts.push(``); + parts.push( + `` + + `${escapeXml(`${relationship.sourceTable}.${relationship.sourceColumn} (${relationship.sourceCardinality}:${relationship.targetCardinality}) -> ${relationship.targetTable}.${relationship.targetColumn}`)}` + + "", + ); + 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(""); } - parts.push(""); for (const table of options.tables) { const position = options.positions[table.name] ?? { x: 0, y: 0 }; diff --git a/packages/app-tests/diagramSvgExport.test.ts b/packages/app-tests/diagramSvgExport.test.ts index a69254391c..5668440ec5 100644 --- a/packages/app-tests/diagramSvgExport.test.ts +++ b/packages/app-tests/diagramSvgExport.test.ts @@ -2,7 +2,7 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; import { buildEngineeringDiagram } from "../../apps/desktop/src/lib/diagram/engineeringDiagram.ts"; import { buildEngineeringDiagramSvg, buildTableDiagramSvg, diagramSvgFileName } from "../../apps/desktop/src/lib/export/diagramSvgExport.ts"; -import { buildDiagramRelationships, type DiagramTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts"; +import { buildDiagramRelationships, normalizeCustomDiagramRelationship, type DiagramTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts"; const tables: DiagramTable[] = [ { @@ -32,8 +32,16 @@ test("exports the table diagram as standalone SVG", () => { users: { x: 40, y: 40 }, orders: { x: 360, y: 40 }, }, - relationshipPaths: { - [relationships[0].id]: "M 360 96 L 310 96", + relationshipLayouts: { + [relationships[0].id]: { + path: "M 360 96 L 310 96", + routePoints: [ + { x: 360, y: 96 }, + { x: 310, y: 96 }, + ], + sourceCardinality: { x: 346, y: 86 }, + targetCardinality: { x: 324, y: 86 }, + }, }, canvas: { width: 720, height: 320 }, cardWidth: 270, @@ -45,12 +53,132 @@ test("exports the table diagram as standalone SVG", () => { assert.match(svg, /^]*>N<\/text>/); + assert.match(svg, /data-cardinality-end="target"[^>]*>1<\/text>/); + assert.match(svg, /orders\.user_id \(N:1\) -> users\.id/); assert.match(svg, />usersordersname & note { + const customRelationship = { + ...normalizeCustomDiagramRelationship({ + name: "users_orders", + sourceTable: "users", + sourceColumn: "id", + targetTable: "orders", + targetColumn: "id", + sourceCardinality: "N", + targetCardinality: "N", + }), + kind: "custom" as const, + }; + const svg = buildTableDiagramSvg({ + tables, + relationships: [customRelationship], + positions: { + users: { x: 40, y: 40 }, + orders: { x: 360, y: 40 }, + }, + relationshipLayouts: { + [customRelationship.id]: { + path: "M 310 96 L 360 96", + routePoints: [ + { x: 310, y: 96 }, + { x: 360, y: 96 }, + ], + sourceCardinality: { x: 324, y: 86 }, + targetCardinality: { x: 346, y: 86 }, + }, + }, + canvas: { width: 720, height: 320 }, + cardWidth: 270, + cardHeaderHeight: 44, + columnRowHeight: 24, + maxVisibleColumns: 9, + }); + + assert.match(svg, /data-cardinality-end="source"[^>]*>N<\/text>/); + assert.match(svg, /data-cardinality-end="target"[^>]*>N<\/text>/); + assert.match(svg, /users\.id \(N:N\) -> orders\.id/); +}); + +test("infers one-to-one foreign keys from primary and unique source columns", () => { + const primaryKeyTables: DiagramTable[] = [ + tables[0], + { + ...tables[1], + columns: tables[1].columns.map((column) => ({ ...column, is_primary_key: column.name === "user_id" })), + }, + ]; + assert.equal(buildDiagramRelationships(primaryKeyTables)[0].sourceCardinality, "1"); + + const uniqueIndexTables: DiagramTable[] = [ + tables[0], + { + ...tables[1], + indexes: [{ name: "orders_user_id_unique", columns: ["user_id"], is_unique: true, is_primary: false }], + }, + ]; + assert.equal(buildDiagramRelationships(uniqueIndexTables)[0].sourceCardinality, "1"); + + const sourceColumnsContainingUniqueKey: DiagramTable[] = [ + tables[0], + { + ...tables[1], + columns: [...tables[1].columns, { name: "tenant_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null }], + foreignKeys: [...tables[1].foreignKeys, { name: "orders_user_id_fk", column: "tenant_id", ref_table: "users", ref_column: "tenant_id" }], + indexes: [{ name: "orders_user_id_unique", columns: ["user_id"], is_unique: true, is_primary: false }], + }, + ]; + assert.equal(buildDiagramRelationships(sourceColumnsContainingUniqueKey)[0].sourceCardinality, "1"); + + const partialUniqueIndexTables: DiagramTable[] = [ + tables[0], + { + ...tables[1], + indexes: [{ name: "orders_user_id_active_unique", columns: ["user_id"], is_unique: true, is_primary: false, filter: "deleted_at IS NULL" }], + }, + ]; + assert.equal(buildDiagramRelationships(partialUniqueIndexTables)[0].sourceCardinality, "N"); +}); + +test("expands the exported viewBox for a relationship routed left of the canvas", () => { + const relationships = buildDiagramRelationships(tables); + const svg = buildTableDiagramSvg({ + tables, + relationships, + positions: { + users: { x: 16, y: 40 }, + orders: { x: 336, y: 40 }, + }, + relationshipLayouts: { + [relationships[0].id]: { + path: "M 14 96 L -20 96 L -20 120 L 14 120", + routePoints: [ + { x: 14, y: 96 }, + { x: -20, y: 96 }, + { x: -20, y: 120 }, + { x: 14, y: 120 }, + ], + sourceCardinality: { x: 0, y: 86 }, + targetCardinality: { x: 0, y: 110 }, + }, + }, + canvas: { width: 720, height: 320 }, + cardWidth: 270, + cardHeaderHeight: 44, + columnRowHeight: 24, + maxVisibleColumns: 9, + }); + + assert.match(svg, /viewBox="-40 0 760 320"/); + assert.match(svg, / { const relationships = buildDiagramRelationships(tables); const diagram = buildEngineeringDiagram(tables, relationships, {