This commit is contained in:
seb
2026-07-30 23:56:42 +02:00
parent cff9ae2abb
commit 93e52cd513
7 changed files with 2678 additions and 216 deletions

741
generate-schema-graph.js Normal file
View File

@@ -0,0 +1,741 @@
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 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;
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, '&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',
'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 =
`<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.
*/
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) => {
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) =>
`<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) {
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"');
}
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 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;
}
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 = 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`
);
}