import { readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Graphviz } from '@hpcc-js/wasm-graphviz'; const __dirname = dirname(fileURLToPath(import.meta.url)); const schemaPath = join(__dirname, 'src', 'schema.js'); const outputPath = join(__dirname, 'schema-graph.svg'); const dotOutputPath = join(__dirname, 'schema-graph.dot'); function parseSchema(source) { const tables = new Map(); const edges = []; const initRegex = /export const (INIT_\w+)\s*=\s*`([\s\S]*?)`;/g; let initMatch; while ((initMatch = initRegex.exec(source)) !== null) { const initName = initMatch[1]; const sqlBlock = initMatch[2]; const createRegex = /CREATE TABLE\s+(\w+)\s*\(([\s\S]*?)\);/gi; let createMatch; while ((createMatch = createRegex.exec(sqlBlock)) !== null) { const tableName = createMatch[1]; const body = createMatch[2]; const columns = []; const constraints = []; for (const line of body.split('\n')) { const trimmed = line.trim().replace(/,$/, ''); if (!trimmed || trimmed.startsWith('CONSTRAINT')) { const fkMatch = trimmed.match( /CONSTRAINT\s+(\w+)\s+FOREIGN KEY\s*\(([^)]+)\)\s+REFERENCES\s+(\w+)\(([^)]+)\)(?:\s+ON DELETE\s+(.+))?/i ); if (fkMatch) { const constraintName = fkMatch[1]; const localCols = fkMatch[2] .split(',') .map((c) => c.trim().replace(/^\[|\]$/g, '')); const refTable = fkMatch[3]; const refCols = fkMatch[4] .split(',') .map((c) => c.trim().replace(/^\[|\]$/g, '')); const onDelete = fkMatch[5]?.trim() ?? null; constraints.push({ name: constraintName, localCols, refTable, refCols, onDelete, }); edges.push({ from: tableName, to: refTable, label: localCols.join(', '), localCols, refCols: refCols.join(', '), constraint: constraintName, onDelete, }); } continue; } const colMatch = trimmed.match( /^(?:\[(\w+)\]|(\w+))\s+([\w]+(?:\([^)]*\))?(?:\s+IDENTITY\([^)]*\))?)/i ); if (!colMatch) continue; const name = (colMatch[1] ?? colMatch[2]).replace(/^\[|\]$/g, ''); const type = colMatch[3].replace(/\s+IDENTITY\([^)]*\)/i, ''); const nullable = /\bNULL\b/i.test(trimmed) && !/\bNOT NULL\b/i.test(trimmed); const isPk = /\bPRIMARY KEY\b/i.test(trimmed); columns.push({ name, type, nullable, isPk }); } tables.set(tableName, { name: tableName, initName, columns, constraints, }); } const alterAddColumnRegex = /ALTER TABLE\s+(\w+)\s+ADD\s+(\w+)\s+([\w]+(?:\([^)]*\))?)(?:\s+(NOT NULL|NULL))?\s*;/gi; let alterColumnMatch; while ((alterColumnMatch = alterAddColumnRegex.exec(sqlBlock)) !== null) { const tableName = alterColumnMatch[1]; const name = alterColumnMatch[2]; const type = alterColumnMatch[3]; const nullable = alterColumnMatch[4]?.toUpperCase() !== 'NOT NULL'; const table = tables.get(tableName); if (!table || table.columns.some((c) => c.name === name)) continue; table.columns.push({ name, type, nullable, isPk: false }); } const alterFkRegex = /ALTER TABLE\s+(\w+)\s+ADD CONSTRAINT\s+(\w+)\s+FOREIGN KEY\s*\(([^)]+)\)\s+REFERENCES\s+(\w+)\(([^)]+)\)(?:\s+ON DELETE\s+([\w\s]+?))?\s*;/gi; let alterMatch; while ((alterMatch = alterFkRegex.exec(sqlBlock)) !== null) { const tableName = alterMatch[1]; const constraintName = alterMatch[2]; const localCols = alterMatch[3] .split(',') .map((c) => c.trim().replace(/^\[|\]$/g, '')); const refTable = alterMatch[4]; const refCols = alterMatch[5] .split(',') .map((c) => c.trim().replace(/^\[|\]$/g, '')); const onDelete = alterMatch[6]?.trim() ?? null; const table = tables.get(tableName); if (table) { table.constraints.push({ name: constraintName, localCols, refTable, refCols, onDelete, }); } edges.push({ from: tableName, to: refTable, label: localCols.join(', '), localCols, refCols: refCols.join(', '), constraint: constraintName, onDelete, }); } } addInferredEdges(tables, edges); markForeignKeyColumns(tables, edges); return { tables, edges }; } /** * Some columns follow a `_id` naming convention that implies a * relationship (e.g. `articlelist_id` -> `articlelist`) but never got an * explicit FOREIGN KEY constraint in the SQL (often because the referenced * table is declared later in the file). These are surfaced as dashed * "inferred" edges so the gap is visible in the diagram instead of the * column silently looking unrelated. */ function addInferredEdges(tables, edges) { const explicitlyConstrained = new Set(); for (const edge of edges) { explicitlyConstrained.add(`${edge.from}.${edge.label}`); } for (const [tableName, table] of tables) { for (const column of table.columns) { if (column.isPk) continue; const match = column.name.match(/^(.+)_id$/i); if (!match) continue; const candidate = match[1]; if (!tables.has(candidate) || candidate === tableName) continue; if (explicitlyConstrained.has(`${tableName}.${column.name}`)) continue; edges.push({ from: tableName, to: candidate, label: column.name, localCols: [column.name], refCols: 'id', constraint: null, onDelete: null, inferred: true, }); } } } function markForeignKeyColumns(tables, edges) { for (const edge of edges) { const table = tables.get(edge.from); if (!table) continue; for (const localCol of edge.localCols ?? []) { const column = table.columns.find((c) => c.name === localCol); if (column) column.isFk = true; } } } const EDGE_COLORS = [ '#c53030', '#2f855a', '#2b6cb0', '#b7791f', '#6b46c1', '#c05621', '#319795', '#97266d', '#4c51bf', '#276749', '#9c4221', '#0987a0', '#553c9a', '#742a2a', '#22543d', '#1a365d', '#702459', '#744210', '#285e61', '#d53f8c', '#dd6b20', '#38a169', '#3182ce', '#805ad5', ]; function edgeKey(edge) { return `${edge.from}|${edge.to}|${edge.label}|${edge.inferred ? 'inferred' : 'fk'}`; } function assignEdgeColors(edges) { const colors = new Map(); let next = 0; for (const edge of edges) { const key = edgeKey(edge); if (!colors.has(key)) { colors.set(key, EDGE_COLORS[next % EDGE_COLORS.length]); next++; } } return colors; } function escapeHtml(text) { return String(text) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } /** * Domain grouping. Graphviz keeps the members of a cluster physically * together, which is what turns the diagram from one big hairball into * readable regions. New tables that are not listed here still render, they * just float outside any cluster - the script logs them so they can be * assigned. */ const CLUSTERS = [ { id: 'identity', label: 'Identity & Company', tables: [ 'meta', 'google_auth', 'companyuser', 'syncpos', 'company', 'companysetting', 'customergroup', 'entity', 'numbers', ], }, { id: 'i18n', label: 'Localization', tables: ['language', 'categorytext', 'articletext', 'unittext'], }, { id: 'catalog', label: 'Catalog', tables: [ 'category', 'categoryarticle', 'brand', 'article', 'articleean', 'articlehistory', 'articlelist', 'articlelistarticle', 'optionslist', 'optionslistarticle', ], }, { id: 'media', label: 'Media', tables: ['pic', 'articlepic', 'categorypic', 'brandpic'], }, { id: 'commerce', label: 'Pricing & Documents', tables: ['tax', 'price', 'manifest', 'manifestarticle'], }, { id: 'units', label: 'Units', tables: ['unit'] }, ]; const COLORS = { background: '#ebf4ff', tableBorder: '#a0aec0', headerBg: '#bee3f8', headerText: '#1a365d', rowBg: '#ffffff', rowBgAlt: '#f7fafc', keyBg: '#ebf8ff', text: '#2d3748', muted: '#718096', }; /** * Reorder columns so FK ports line up with the horizontal position of their * targets (barycenter heuristic on column/port order). PK stays on top, * plain columns stay at the bottom. This is the classic "swap columns to * reduce edge crossings" trick for port-based ERDs. */ function reorderColumnsForCrossingReduction(tables, edges, positions) { const targetX = (tableName) => positions.get(tableName)?.x ?? 0; for (const [tableName, table] of tables) { const outEdges = edges.filter((e) => e.from === tableName); const score = new Map(); for (const edge of outEdges) { const x = targetX(edge.to); for (const col of edge.localCols ?? []) { if (!score.has(col)) score.set(col, []); score.get(col).push(x); } } const median = (values) => { const sorted = [...values].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; }; const pk = []; const fk = []; const rest = []; for (const column of table.columns) { if (column.isPk) pk.push(column); else if (column.isFk || score.has(column.name)) fk.push(column); else rest.push(column); } fk.sort((a, b) => { const aVals = score.get(a.name) ?? [Number.POSITIVE_INFINITY]; const bVals = score.get(b.name) ?? [Number.POSITIVE_INFINITY]; return median(aVals) - median(bVals); }); table.columns = [...pk, ...fk, ...rest]; } } function parsePlainPositions(plain) { const positions = new Map(); for (const line of plain.split('\n')) { if (!line.startsWith('node ')) continue; // node name x y width height ... const match = line.match(/^node\s+(\S+)\s+([-\d.]+)\s+([-\d.]+)\s+/); if (!match) continue; positions.set(match[1], { x: Number(match[2]), y: Number(match[3]) }); } return positions; } /** * Sort cluster members by their first-pass x so Graphviz's crossing * minimizer starts from a better initial order within each cluster. */ function orderClusterMembers(cluster, positions) { return [...cluster.tables].sort((a, b) => { const ax = positions.get(a)?.x ?? Number.POSITIVE_INFINITY; const bx = positions.get(b)?.x ?? Number.POSITIVE_INFINITY; return ax - bx; }); } function buildTableLabel(table, inboundCount = 0) { const titleFont = `${escapeHtml(table.name)}`; let headerInner; if (inboundCount > 0) { // Ports sit on the top edge of the title bar so arrows end on the // header itself instead of crossing through it toward the id row. const portCells = []; for (let i = 0; i < inboundCount; i++) { portCells.push( `` ); } headerInner = `
` + `${portCells.join('')}` + `` + `
` + `${titleFont}
`; } else { headerInner = titleFont; } const rows = [ `` + `${headerInner}`, ]; for (const [i, column] of table.columns.entries()) { const marker = column.isPk ? 'PK' : column.isFk ? 'FK' : ''; const isKey = Boolean(marker); const bg = isKey ? COLORS.keyBg : i % 2 === 0 ? COLORS.rowBg : COLORS.rowBgAlt; const nameMarkup = isKey ? `${escapeHtml(column.name)}` : escapeHtml(column.name); const nullMarker = column.nullable ? '?' : ''; rows.push( `` + `` + `` + `` + `` + `
` + `${nameMarkup}${nullMarker}` + `${escapeHtml(column.type)}` + (marker ? ` ${marker}` : '') + `
` ); } return ( `<` + rows.join('') + `
>` ); } /** * Head: unique in* ports on the target title bar, ordered by source x. * Tail: FK column port, always leaving sideways or diagonally * (e/w/ne/nw/se/sw) — never straight up/down, which stacks stubs on the * row edge. */ function assignEdgeAnchors(edges, positions) { const headPort = new Map(); const tailCompass = new Map(); const inboundCounts = new Map(); const byTarget = new Map(); const bySourceCol = new Map(); for (const edge of edges) { if (!byTarget.has(edge.to)) byTarget.set(edge.to, []); byTarget.get(edge.to).push(edge); const colKey = `${edge.from}|${edge.localCols?.[0] ?? 'id'}`; if (!bySourceCol.has(colKey)) bySourceCol.set(colKey, []); bySourceCol.get(colKey).push(edge); } for (const [target, group] of byTarget) { group.sort((a, b) => { const ax = positions?.get(a.from)?.x ?? 0; const bx = positions?.get(b.from)?.x ?? 0; if (ax !== bx) return ax - bx; return a.label.localeCompare(b.label); }); inboundCounts.set(target, group.length); group.forEach((edge, i) => { headPort.set(edgeKey(edge), `in${i}`); }); } for (const group of bySourceCol.values()) { group.sort((a, b) => { const ax = positions?.get(a.to)?.x ?? 0; const bx = positions?.get(b.to)?.x ?? 0; if (ax !== bx) return ax - bx; return (positions?.get(a.to)?.y ?? 0) - (positions?.get(b.to)?.y ?? 0); }); group.forEach((edge, i) => { // Self-loops: leave via top diagonals so the arc sits above the table // instead of curling SE into neighboring edges. if (edge.from === edge.to) { tailCompass.set(edgeKey(edge), i % 2 === 0 ? 'ne' : 'nw'); return; } const fromX = positions?.get(edge.from)?.x ?? 0; const toX = positions?.get(edge.to)?.x ?? fromX; const fromY = positions?.get(edge.from)?.y ?? 0; const toY = positions?.get(edge.to)?.y ?? fromY; const dx = toX - fromX; const dy = toY - fromY; const absDx = Math.abs(dx); const absDy = Math.abs(dy); // Graphviz y grows upward; smaller y = visually lower under rankdir=TB. const goEast = dx > 0 || (dx === 0 && edge.from <= edge.to); const goDown = dy < 0 || (dy === 0 && goEast); const fan = group.length === 1 ? 0 : (i / (group.length - 1)) * 2 - 1; let compass; if (absDx >= absDy) { // Mostly horizontal — cardinal side, fan to diagonals if shared. if (group.length === 1) { compass = goEast ? 'e' : 'w'; } else { compass = goEast ? fan < -0.33 ? 'ne' : fan > 0.33 ? 'se' : 'e' : fan < -0.33 ? 'nw' : fan > 0.33 ? 'sw' : 'w'; } } else { // Mostly vertical — still leave via a diagonal/side, never n/s. compass = goEast ? goDown ? 'se' : 'ne' : goDown ? 'sw' : 'nw'; } tailCompass.set(edgeKey(edge), compass); }); } return { headPort, tailCompass, inboundCounts }; } function buildLegendLabel() { const row = (swatch, text) => `${swatch}` + `${text}`; return ( `<` + `` + row(`PK`, 'primary key') + row(`FK`, 'foreign key') + row(`?`, 'nullable column') + row( `—`, 'FOREIGN KEY constraint' ) + row( `- -`, 'naming convention only, no constraint' ) + `
` + `Legend
>` ); } function buildDot(tables, edges, positions = null) { const edgeColors = assignEdgeColors(edges); const { headPort, tailCompass, inboundCounts } = assignEdgeAnchors( edges, positions ); const lines = [ 'digraph schema {', // TB = landscape for this schema (many tables per rank spread horizontally). // mclimit bumps Graphviz's own crossing-minimization iterations. ` graph [rankdir=TB, splines=spline, overlap=false, bgcolor="${COLORS.background}",`, ' nodesep=0.55, ranksep=1.0, pad=0.5, fontname="Helvetica",', ' compound=true, newrank=true, mclimit=4.0];', ' node [shape=plaintext, fontname="Helvetica"];', ' edge [fontname="Helvetica", fontsize=9, penwidth=1.4, arrowsize=0.7];', '', ]; const unassigned = new Set(tables.keys()); const emitNode = (name, indent) => { const table = tables.get(name); const label = buildTableLabel(table, inboundCounts.get(name) ?? 0); lines.push(`${indent}${name} [label=${label}];`); }; for (const cluster of CLUSTERS) { const members = ( positions ? orderClusterMembers(cluster, positions) : cluster.tables ).filter((name) => tables.has(name)); if (members.length === 0) continue; lines.push(` subgraph cluster_${cluster.id} {`); lines.push( ` label="${escapeHtml(cluster.label)}"; labeljust="l"; fontsize=15;`, ` style="rounded,filled"; fillcolor="#ffffff60"; color="${COLORS.tableBorder}";`, ` fontcolor="${COLORS.headerText}"; margin=14;` ); for (const name of members) { emitNode(name, ' '); unassigned.delete(name); } lines.push(' }'); } const leftover = positions ? [...unassigned].sort( (a, b) => (positions.get(a)?.x ?? 0) - (positions.get(b)?.x ?? 0) ) : [...unassigned]; for (const name of leftover) { emitNode(name, ' '); } lines.push(''); lines.push(` __legend [label=${buildLegendLabel()}];`); lines.push(''); for (const edge of edges) { const key = edgeKey(edge); const color = edgeColors.get(key); const sourcePort = edge.localCols?.[0] ?? 'id'; const targetPort = headPort.get(key) ?? 'in0'; const tail = tailCompass.get(key) ?? 'e'; const attrs = [ `color="${color}"`, `fontcolor="${color}"`, `tooltip="${escapeHtml(edge.constraint ?? `${edge.from}.${edge.label} -> ${edge.to}`)}"`, ]; if (edge.inferred) { attrs.push('style=dashed', 'label="inferred"'); } // Self-FK: leave the FK column, enter the PK — loop on the east side // of those rows (e→e). Node-level ne→nw looked above the table but // detached from the actual columns; column→header with :ne still dipped SE. if (edge.from === edge.to) { lines.push( ` ${edge.from}:${sourcePort}:e -> ${edge.to}:id:e [${attrs.join(', ')}];` ); continue; } lines.push( ` ${edge.from}:${sourcePort}:${tail} -> ${edge.to}:${targetPort}:n [${attrs.join(', ')}];` ); } lines.push('}'); return lines.join('\n'); } function countSvgCrossings(svg) { const paths = [ ...svg.matchAll(/]*d="([^"]+)"/g), ].map((m) => m[1]); const segs = []; for (const d of paths) { const nums = [...d.matchAll(/([-\d.]+)[,\s]+([-\d.]+)/g)].map((m) => [ Number(m[1]), Number(m[2]), ]); for (let i = 0; i < nums.length - 1; i++) segs.push([nums[i], nums[i + 1]]); } const orient = (a, b, c) => (b[1] - a[1]) * (c[0] - b[0]) - (b[0] - a[0]) * (c[1] - b[1]); const crosses = (a, b, c, d) => { const o1 = orient(a, b, c); const o2 = orient(a, b, d); const o3 = orient(c, d, a); const o4 = orient(c, d, b); return o1 * o2 < 0 && o3 * o4 < 0; }; let n = 0; for (let i = 0; i < segs.length; i++) { for (let j = i + 1; j < segs.length; j++) { const [a, b] = segs[i]; const [c, d] = segs[j]; if ( (a[0] === c[0] && a[1] === c[1]) || (a[0] === d[0] && a[1] === d[1]) || (b[0] === c[0] && b[1] === c[1]) || (b[0] === d[0] && b[1] === d[1]) ) { continue; } if (crosses(a, b, c, d)) n++; } } return n; } /** * Graphviz self-edges from a lower FK row to id:e dip southeast. Rebuild the * spline so it bulges east + north (up) while keeping start/end ports. * In this SVG, more-negative y is visually higher. */ function fixSelfLoopSvg(svg) { return svg.replace( /\s*(\w+):e->\2:e<\/title>\s*<g id="(a_edge\d+)"><a ([^>]+)>\s*<path([^>]*) d="([^"]+)"\/>\s*<polygon([^>]*) points="([^"]+)"\/>\s*<\/a>\s*<\/g>\s*<\/g>/g, (match, edgeId, _table, aId, aAttrs, pathAttrs, d, polyAttrs) => { const pts = [...d.matchAll(/([-\d.]+)[,\s]+([-\d.]+)/g)].map((m) => [ Number(m[1]), Number(m[2]), ]); if (pts.length < 2) return match; const [x0, y0] = pts[0]; const [x1, y1] = pts[pts.length - 1]; const east = Math.max(x0, x1) + 36; const north = Math.min(y0, y1) - 28; // Cubic end tangent is P3-P2; orient arrow along that arrival direction. const tx = x1 - east; const ty = y1 - north; const len = Math.hypot(tx, ty) || 1; const dx = tx / len; const dy = ty / len; const arrowLen = 9; const arrowHalf = 3.5; const baseX = x1 - dx * arrowLen; const baseY = y1 - dy * arrowLen; const px = -dy * arrowHalf; const py = dx * arrowHalf; const fmt = (n) => (Math.round(n * 100) / 100).toString(); const newDFmt = `M${fmt(x0)},${fmt(y0)}C${fmt(east)},${fmt(y0)} ${fmt(east)},${fmt(north)} ${fmt(baseX)},${fmt(baseY)}`; const newPointsFmt = `${fmt(x1)},${fmt(y1)} ` + `${fmt(baseX + px)},${fmt(baseY + py)} ` + `${fmt(baseX - px)},${fmt(baseY - py)} ` + `${fmt(x1)},${fmt(y1)}`; return ( `<g id="${edgeId}" class="edge">\n` + `<title>${_table}:e->${_table}:e\n` + `\n` + `\n` + `\n` + `\n` + `\n` + `` ); } ); } const schemaSource = readFileSync(schemaPath, 'utf8'); const { tables, edges } = parseSchema(schemaSource); if (tables.size === 0) { console.error('No tables found in schema.js'); process.exit(1); } const clustered = new Set(CLUSTERS.flatMap((c) => c.tables)); const unclustered = [...tables.keys()].filter((name) => !clustered.has(name)); if (unclustered.length > 0) { console.warn( `Not assigned to a cluster (add them to CLUSTERS): ${unclustered.join(', ')}` ); } const graphviz = await Graphviz.load(); // Two layout passes: learn positions, then reorder columns/cluster members // (barycenter) so FK ports and neighbor tables line up and crossings drop. let positions = null; for (let pass = 0; pass < 2; pass++) { const probeDot = buildDot(tables, edges, positions); const plain = graphviz.layout(probeDot, 'plain', 'dot'); positions = parsePlainPositions(plain); reorderColumnsForCrossingReduction(tables, edges, positions); } const dot = buildDot(tables, edges, positions); writeFileSync(dotOutputPath, dot, 'utf8'); const svg = fixSelfLoopSvg(graphviz.layout(dot, 'svg', 'dot')); writeFileSync(outputPath, svg, 'utf8'); const dim = svg.match(/width="([\d.]+)pt" height="([\d.]+)pt"/); const crossings = countSvgCrossings(svg); console.log(`Parsed ${tables.size} tables and ${edges.length} foreign keys`); console.log(`Written ${dotOutputPath}`); console.log(`Written ${outputPath}`); if (dim) { console.log( `Layout ${dim[1]}pt × ${dim[2]}pt · ~${crossings} segment crossings` ); }