1070 lines
32 KiB
JavaScript
1070 lines
32 KiB
JavaScript
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;
|
||
|
||
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, '&')
|
||
.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',
|
||
'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', 'file'],
|
||
},
|
||
{
|
||
id: 'manifests',
|
||
label: 'Documents',
|
||
tables: ['manifest', 'manifestarticle', 'manifestattachment', 'ledger', 'warehouse'],
|
||
},
|
||
{
|
||
id: 'commerce',
|
||
label: 'Pricing',
|
||
tables: ['tax', 'price'],
|
||
},
|
||
{ id: 'units', label: 'Units', tables: ['unit'] },
|
||
{ id: 'pos', label: 'POS', tables: ['posterminal', 'posterminalcategory', 'printer'] },
|
||
];
|
||
|
||
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.
|
||
*/
|
||
function optimizeAnchorsWithLayout(graphviz, tables, edges, positions, anchors) {
|
||
const tailCompass = new Map(anchors.tailCompass);
|
||
const headPort = new Map(anchors.headPort);
|
||
const inboundCounts = anchors.inboundCounts;
|
||
let current = { headPort, tailCompass, inboundCounts };
|
||
|
||
const measure = () => {
|
||
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 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;
|
||
|
||
for (const edge of edges) {
|
||
if (edge.from === edge.to) continue;
|
||
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()) {
|
||
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}">—</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 bumps Graphviz's own crossing-minimization iterations.
|
||
` graph [rankdir=TB, splines=spline, overlap=false, bgcolor="${COLORS.background}",`,
|
||
' nodesep=0.95, ranksep=1.0, pad=0.5, fontname="Helvetica",',
|
||
' compound=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="${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);
|
||
}
|
||
|
||
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 e1 = 0; e1 < edgeSegs.length; e1++) {
|
||
for (let e2 = e1 + 1; e2 < edgeSegs.length; e2++) {
|
||
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->\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</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++) {
|
||
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.
|
||
const baseAnchors = assignEdgeAnchors(edges, positions);
|
||
const optimized = optimizeAnchorsWithLayout(
|
||
graphviz,
|
||
tables,
|
||
edges,
|
||
positions,
|
||
baseAnchors
|
||
);
|
||
|
||
// 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`
|
||
);
|
||
} |