Files
dashlibstorage/generate-schema-graph.js
2026-10-22 14:39:45 +02:00

1165 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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', 'providers', 'mssql', '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 `<table>_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;
// Obituary tables store references to deleted entities, not active foreign keys.
if (tableName.includes('obituary') || tableName.includes('tombstone')) 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 = [
'#ff6b6b',
'#6bdf9a',
'#5aa7ff',
'#f5c15c',
'#c79bff',
'#ff9f5a',
'#5cd6d6',
'#ff7fc2',
'#8f95ff',
'#58d18a',
'#ff8f66',
'#4fc4e8',
'#a98cff',
'#ff6f6f',
'#4ecb8f',
'#5aa7e6',
'#ff7aa8',
'#e0b25a',
'#56c8c8',
'#ff5f9e',
'#ffb25a',
'#56d68a',
'#66b3ff',
'#c79bff',
];
function edgeKey(edge) {
return `${edge.from}|${edge.to}|${edge.label}|${edge.inferred ? 'inferred' : 'fk'}`;
}
/**
* Only a spanning forest of FK edges constrains Graphviz ranks. Extra FKs
* from the same table get constraint=false so they may connect peers on the
* same level instead of forcing another rank (saves vertical space).
*
* Per source table, pick one "primary" parent for ranking:
* 1. real FK over inferred
* 2. ownership-style parent (parent name is a prefix of the child, e.g.
* manifest → manifestarticle)
* 3. highest in-degree target (hubs like company stay above)
*/
function selectRankingEdgeKeys(edges) {
const ranking = new Set();
const inDegree = new Map();
for (const edge of edges) {
if (edge.from === edge.to) continue;
inDegree.set(edge.to, (inDegree.get(edge.to) ?? 0) + 1);
}
const ownershipScore = (child, parent) => {
const c = child.toLowerCase();
const p = parent.toLowerCase();
if (c.startsWith(p) && c.length > p.length) return p.length;
return 0;
};
const bySource = new Map();
for (const edge of edges) {
if (edge.from === edge.to) continue;
if (!bySource.has(edge.from)) bySource.set(edge.from, []);
bySource.get(edge.from).push(edge);
}
for (const group of bySource.values()) {
group.sort((a, b) => {
const aInferred = a.inferred ? 1 : 0;
const bInferred = b.inferred ? 1 : 0;
if (aInferred !== bInferred) return aInferred - bInferred;
const aOwn = ownershipScore(a.from, a.to);
const bOwn = ownershipScore(b.from, b.to);
if (aOwn !== bOwn) return bOwn - aOwn;
const aDeg = inDegree.get(a.to) ?? 0;
const bDeg = inDegree.get(b.to) ?? 0;
if (aDeg !== bDeg) return bDeg - aDeg;
return a.label.localeCompare(b.label);
});
ranking.add(edgeKey(group[0]));
}
return ranking;
}
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* 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',
'company',
'companyhistory',
'companysetting',
'customergroup',
'entity',
'entityhistory',
'entityaddress',
'entitycontact',
'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',
'articlesupplier',
],
},
{
id: 'media',
label: 'Media',
tables: ['pic', 'articlepic', 'categorypic', 'brandpic', 'attributevaluepic', 'file'],
},
{
id: 'manifests',
label: 'Documents & Shipping',
tables: [
'manifest',
'manifestarticle',
'manifestattachment',
'articlewarehousesummary',
'deliverysummary',
'ordersummary',
'quotesummary',
'shipmentsummary',
'ledger',
'warehouse',
'shipping',
'package',
],
},
{
id: 'commerce',
label: 'Pricing & Payments',
tables: ['tax', 'price', 'account', 'payment'],
},
{ id: 'units', label: 'Units', tables: ['unit'] },
{ id: 'pos', label: 'POS', tables: ['posterminal', 'posterminalcategory', 'posterminalobituary', 'printer', 'labeltemplate'] },
{
id: 'ai',
label: 'AI & Models',
tables: ['modelconversation', 'modelmessage', 'modelvfs', 'subagentschedule'],
},
{
id: 'attributes',
label: 'Properties & Attributes',
tables: [
'propertyname',
'articleproperty',
'categoryproperty',
'attributename',
'attributevalue',
'articleattributevalue',
'categoryattributevalue',
],
},
];
const COLORS = {
background: '#1e2430',
tableBorder: '#3f4c5f',
headerBg: '#33415c',
headerText: '#e2e8f0',
rowBg: '#2a3242',
rowBgAlt: '#272e3d',
keyBg: '#2b3a52',
text: '#d8dee9',
muted: '#8b95a7',
clusterFill: '#202a3a',
};
/**
* 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+([-\d.]+)\s+([-\d.]+)/
);
if (!match) continue;
positions.set(match[1], {
x: Number(match[2]),
y: Number(match[3]),
width: Number(match[4]),
height: Number(match[5]),
});
}
return positions;
}
function flipCompassLR(compass) {
return (
{ e: 'w', w: 'e', ne: 'nw', nw: 'ne', se: 'sw', sw: 'se' }[compass] ??
compass
);
}
function flipCompassNS(compass) {
return (
{ ne: 'se', se: 'ne', nw: 'sw', sw: 'nw', e: 'e', w: 'w' }[compass] ??
compass
);
}
/**
* Greedy exit/port tweaks against real Graphviz splines. Tries L/R flips,
* N/S diagonal flips, and adjacent head-port swaps; keeps moves that cut
* SVG segment crossings.
*
* Each trial is a full Graphviz layout, so the search is bounded by
* `maxLayouts` and reports progress — otherwise a large schema silently
* runs for many minutes with no output.
*/
function optimizeAnchorsWithLayout(
graphviz,
tables,
edges,
positions,
anchors,
maxLayouts = 400
) {
const tailCompass = new Map(anchors.tailCompass);
const headPort = new Map(anchors.headPort);
const inboundCounts = anchors.inboundCounts;
let current = { headPort, tailCompass, inboundCounts };
let layouts = 0;
const started = Date.now();
const measure = () => {
layouts++;
if (layouts % 25 === 0) {
console.log(
` optimizing anchors: ${layouts} layouts, ${best} crossings, ${((Date.now() - started) / 1000).toFixed(0)}s`
);
}
const dot = buildDot(tables, edges, positions, current);
const svg = fixSelfLoopSvg(graphviz.layout(dot, 'svg', 'dot'));
return { svg, crossings: countSvgCrossings(svg), dot };
};
let { crossings: best, svg: bestSvg, dot: bestDot } = measure();
const acceptIfBetter = (trial) => {
if (trial.crossings < best) {
best = trial.crossings;
bestSvg = trial.svg;
bestDot = trial.dot;
return true;
}
return false;
};
const budgetLeft = () => layouts < maxLayouts;
const trySeed = (seedCompass) => {
const saved = new Map(tailCompass);
for (const [k, v] of seedCompass) tailCompass.set(k, v);
current = { headPort, tailCompass, inboundCounts };
const trial = measure();
if (!acceptIfBetter(trial)) {
for (const [k, v] of saved) tailCompass.set(k, v);
current = { headPort, tailCompass, inboundCounts };
}
};
// Also try the globally mirrored L/R assignment as an alternate seed.
trySeed(
new Map([...tailCompass.entries()].map(([k, v]) => [k, flipCompassLR(v)]))
);
const byTarget = new Map();
for (const edge of edges) {
if (edge.from === edge.to) continue;
if (!byTarget.has(edge.to)) byTarget.set(edge.to, []);
byTarget.get(edge.to).push(edge);
}
let improved = true;
for (let sweep = 0; sweep < 4 && improved; sweep++) {
improved = false;
if (!budgetLeft()) break;
for (const edge of edges) {
if (edge.from === edge.to) continue;
if (!budgetLeft()) break;
const key = edgeKey(edge);
const prev = tailCompass.get(key);
if (!prev) continue;
for (const flip of [flipCompassLR, flipCompassNS]) {
const next = flip(prev);
if (next === prev) continue;
tailCompass.set(key, next);
current = { headPort, tailCompass, inboundCounts };
const trial = measure();
if (acceptIfBetter(trial)) {
improved = true;
break;
}
tailCompass.set(key, prev);
current = { headPort, tailCompass, inboundCounts };
}
}
for (const group of byTarget.values()) {
if (!budgetLeft()) break;
const keys = group
.map((e) => edgeKey(e))
.sort(
(a, b) =>
Number(String(headPort.get(a)).replace(/^in/, '')) -
Number(String(headPort.get(b)).replace(/^in/, ''))
);
for (let i = 0; i < keys.length - 1; i++) {
const a = headPort.get(keys[i]);
const b = headPort.get(keys[i + 1]);
if (!a || !b || a === b) continue;
headPort.set(keys[i], b);
headPort.set(keys[i + 1], a);
current = { headPort, tailCompass, inboundCounts };
const trial = measure();
if (acceptIfBetter(trial)) {
improved = true;
} else {
headPort.set(keys[i], a);
headPort.set(keys[i + 1], b);
current = { headPort, tailCompass, inboundCounts };
}
}
}
}
return { anchors: current, crossings: best, svg: bestSvg, dot: bestDot };
}
/**
* 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 =
`<FONT COLOR="${COLORS.headerText}" POINT-SIZE="12"><B>${escapeHtml(table.name)}</B></FONT>`;
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(
`<TD PORT="in${i}" BGCOLOR="${COLORS.headerBg}" BORDER="0" ` +
`WIDTH="10" HEIGHT="4" CELLPADDING="0"></TD>`
);
}
headerInner =
`<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE">` +
`<TR>${portCells.join('')}</TR>` +
`<TR><TD COLSPAN="${inboundCount}" ALIGN="CENTER" CELLPADDING="3">` +
`${titleFont}</TD></TR>` +
`</TABLE>`;
} else {
headerInner = titleFont;
}
const rows = [
`<TR><TD COLSPAN="2" BGCOLOR="${COLORS.headerBg}" ALIGN="CENTER" ` +
`CELLPADDING="${inboundCount > 0 ? 1 : 4}" BORDER="1">` +
`${headerInner}</TD></TR>`,
];
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
? `<B>${escapeHtml(column.name)}</B>`
: escapeHtml(column.name);
const nullMarker = column.nullable ? '?' : '';
rows.push(
`<TR><TD PORT="${escapeHtml(column.name)}" COLSPAN="2" BGCOLOR="${bg}" ` +
`BORDER="1" CELLPADDING="0">` +
`<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE">` +
`<TR>` +
`<TD ALIGN="LEFT" CELLPADDING="4">` +
`<FONT COLOR="${COLORS.text}">${nameMarkup}${nullMarker}</FONT></TD>` +
`<TD ALIGN="RIGHT" CELLPADDING="4">` +
`<FONT COLOR="${COLORS.muted}" POINT-SIZE="9">${escapeHtml(column.type)}` +
(marker ? ` <B>${marker}</B>` : '') +
`</FONT></TD>` +
`</TR></TABLE></TD></TR>`
);
}
return (
`<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" ` +
`COLOR="${COLORS.tableBorder}">` +
rows.join('') +
`</TABLE>>`
);
}
/**
* 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. Exit side aims at the head-port x (not just the target center)
* so left-header arrivals leave west and right-header arrivals leave east.
*/
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 from = positions?.get(edge.from);
const to = positions?.get(edge.to);
const fromX = from?.x ?? 0;
const fromY = from?.y ?? 0;
const toY = to?.y ?? fromY;
// Aim at the specific header port, not the node center — otherwise
// edges to the left/right of a wide header pick the wrong exit side.
const key = edgeKey(edge);
const hp = headPort.get(key) ?? 'in0';
const hi = Number(String(hp).replace(/^in/, '')) || 0;
const nIn = Math.max(1, inboundCounts.get(edge.to) ?? 1);
const toW = to?.width ?? 1;
const toX =
to != null
? to.x - toW / 2 + ((hi + 0.5) / nIn) * toW
: fromX;
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(key, compass);
});
}
return { headPort, tailCompass, inboundCounts };
}
function buildLegendLabel() {
const row = (swatch, text) =>
`<TR><TD ALIGN="LEFT">${swatch}</TD><TD ALIGN="LEFT">` +
`<FONT COLOR="${COLORS.text}" POINT-SIZE="9">${text}</FONT></TD></TR>`;
return (
`<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" ` +
`COLOR="${COLORS.tableBorder}" BGCOLOR="${COLORS.rowBg}">` +
`<TR><TD COLSPAN="2" BGCOLOR="${COLORS.headerBg}">` +
`<FONT COLOR="${COLORS.headerText}" POINT-SIZE="11"><B>Legend</B></FONT></TD></TR>` +
row(`<FONT COLOR="${COLORS.text}"><B>PK</B></FONT>`, 'primary key') +
row(`<FONT COLOR="${COLORS.text}"><B>FK</B></FONT>`, 'foreign key') +
row(`<FONT COLOR="${COLORS.text}"><B>?</B></FONT>`, 'nullable column') +
row(
`<FONT COLOR="${COLORS.muted}">&#8212;</FONT>`,
'FOREIGN KEY constraint'
) +
row(
`<FONT COLOR="${COLORS.muted}">- -</FONT>`,
'naming convention only, no constraint'
) +
`</TABLE>>`
);
}
function buildDot(tables, edges, positions = null, anchors = null) {
const edgeColors = assignEdgeColors(edges);
const rankingEdgeKeys = selectRankingEdgeKeys(edges);
const { headPort, tailCompass, inboundCounts } =
anchors ?? assignEdgeAnchors(edges, positions);
const lines = [
'digraph schema {',
// TB = landscape for this schema (many tables per rank spread horizontally).
// mclimit is Graphviz's crossing-minimization iteration budget. 4.0 (the
// old value) costs ~12x the layout time for only ~15% fewer crossings,
// which multiplied by the ~157 optimizer trials below. 1.0 is the default.
` graph [rankdir=TB, splines=spline, overlap=false, bgcolor="${COLORS.background}",`,
' nodesep=0.95, ranksep=1.0, pad=0.5, fontname="Helvetica",',
' compound=true, mclimit=1.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="${COLORS.clusterFill}"; 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) {
attrs.push('constraint=false');
lines.push(
` ${edge.from}:${sourcePort}:e -> ${edge.to}:id:e [${attrs.join(', ')}];`
);
continue;
}
// Non-primary FKs may share a rank with their target (lateral links).
if (!rankingEdgeKeys.has(key)) {
attrs.push('constraint=false', 'weight=1');
} else {
// Prefer ranking edges in crossing minimization / length tradeoffs.
attrs.push('weight=3');
}
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(/<g id="edge\d+"[\s\S]*?<path[^>]*d="([^"]+)"/g),
].map((m) => m[1]);
const edgeSegs = [];
for (const d of paths) {
const nums = [...d.matchAll(/([-\d.]+)[,\s]+([-\d.]+)/g)].map((m) => [
Number(m[1]),
Number(m[2]),
]);
const segs = [];
for (let i = 0; i < nums.length - 1; i++) segs.push([nums[i], nums[i + 1]]);
edgeSegs.push(segs);
}
// Two splines can only intersect if their bounding boxes overlap, so the
// sweep below can skip almost every pair. Without this the check is
// O(edges^2 * segments^2) and dominates each optimizer trial.
const boxes = edgeSegs.map((segs) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const [a] of segs) {
if (a[0] < minX) minX = a[0];
if (a[0] > maxX) maxX = a[0];
if (a[1] < minY) minY = a[1];
if (a[1] > maxY) maxY = a[1];
}
return { minX, minY, maxX, maxY };
});
const order = boxes
.map((_, i) => i)
.sort((a, b) => boxes[a].minX - boxes[b].minX);
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 ii = 0; ii < order.length; ii++) {
const e1 = order[ii];
const b1 = boxes[e1];
for (let jj = ii + 1; jj < order.length; jj++) {
const e2 = order[jj];
const b2 = boxes[e2];
// Sorted by minX: once a box starts past b1's right edge, no later
// box can overlap it either.
if (b2.minX > b1.maxX) break;
if (b1.minX > b2.maxX || b1.minY > b2.maxY || b2.minY > b1.maxY) continue;
let pairCrosses = false;
outer: for (const [a, b] of edgeSegs[e1]) {
for (const [c, d] of edgeSegs[e2]) {
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)) {
pairCrosses = true;
break outer;
}
}
}
if (pairCrosses) 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(
/<g id="(edge\d+)" class="edge">\s*<title>(\w+):e&#45;&gt;\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&#45;&gt;${_table}:e</title>\n` +
`<g id="${aId}"><a ${aAttrs}>\n` +
`<path${pathAttrs} d="${newDFmt}"/>\n` +
`<polygon${polyAttrs} points="${newPointsFmt}"/>\n` +
`</a>\n` +
`</g>\n` +
`</g>`
);
}
);
}
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();
// Layout passes: learn positions, reorder columns/cluster members (barycenter).
let positions = null;
for (let pass = 0; pass < 3; pass++) {
console.log(`Layout pass ${pass + 1}/3…`);
const probeDot = buildDot(tables, edges, positions);
const plain = graphviz.layout(probeDot, 'plain', 'dot');
positions = parsePlainPositions(plain);
reorderColumnsForCrossingReduction(tables, edges, positions);
}
// Hill-climb L/R exits against real spline crossings. Each trial is a full
// layout, so the budget keeps the worst case bounded; raise it via
// SCHEMA_GRAPH_MAX_LAYOUTS if you want a longer search.
const maxLayouts = Number(process.env.SCHEMA_GRAPH_MAX_LAYOUTS ?? 400);
const baseAnchors = assignEdgeAnchors(edges, positions);
const optimized = optimizeAnchorsWithLayout(
graphviz,
tables,
edges,
positions,
baseAnchors,
maxLayouts
);
// Fill the whole canvas (including Graphviz's pad margin) so there is no
// transparent/white gutter around the dark graph when opened in a browser.
const finalsvg = optimized.svg.replace(
/<svg([^>]*?)>/,
`<svg$1 style="background-color:${COLORS.background};">`
);
writeFileSync(dotOutputPath, optimized.dot, 'utf8');
writeFileSync(outputPath, finalsvg, 'utf8');
const dim = optimized.svg.match(/width="([\d.]+)pt" height="([\d.]+)pt"/);
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 · ~${optimized.crossings} edge-pair crossings`
);
}