Skip to content

Commit 70277fb

Browse files
authored
ENH: Add entity-relationship diagrams for schemas (#918)
1 parent f4d6772 commit 70277fb

File tree

9 files changed

+322
-1
lines changed

9 files changed

+322
-1
lines changed

packages/frontend/default.nix

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ let
6161
];
6262
};
6363
# See README.md
64-
hash = "sha256-ARVyVYi011iyTK1sLYc6RLNuwK2I3axKoQG8+7JOrQY=";
64+
hash = "sha256-rG23XhnuiIijV3D+qtP5sbHTO1xe5cKt1rCqsvCsMSI=";
6565
};
6666
};
6767

packages/frontend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"fast-equals": "^5.2.2",
5252
"fast-json-patch": "^3.1.1",
5353
"firebase": "^10.14.0",
54+
"html-escaper": "^3.0.3",
5455
"js-file-download": "^0.4.12",
5556
"katex": "^0.16.22",
5657
"lucide-solid": "^0.471.0",
@@ -71,6 +72,7 @@
7172
"devDependencies": {
7273
"@biomejs/biome": "^2.3.8",
7374
"@mdx-js/mdx": "^2.3.0",
75+
"@types/html-escaper": "^3.0.4",
7476
"@types/mdx": "^2.0.13",
7577
"@types/node": "^24.0.0",
7678
"rehype-katex": "^7.0.1",

packages/frontend/pnpm-lock.yaml

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#### User inputs
2+
3+
<dl>
4+
<dt>Direction: `Vertical | Horizontal`</dt>
5+
<dd>Switches between orienting the graph from top to bottom and from left to right</dd>
6+
</dl>

packages/frontend/src/help/logics/simple-schema.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,4 @@ In other words, the set corresponding to an attribute type starts as empty and n
8383
### Analyses
8484

8585
<HelpAnalysisById theory={props.theory} analysisId="diagram"/>
86+
<HelpAnalysisById theory={props.theory} analysisId="erd"/>

packages/frontend/src/stdlib/analyses.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { MorType, ObType } from "catlog-wasm";
44
import type { DiagramAnalysisMeta, ModelAnalysisMeta } from "../theory";
55
import * as GraphLayoutConfig from "../visualization/graph_layout_config";
66
import type * as Checkers from "./analyses/checker_types";
7+
import { defaultSchemaERDConfig, type SchemaERDConfig } from "./analyses/schema_erd_config";
78
import type * as Simulators from "./analyses/simulator_types";
89

910
type AnalysisOptions = {
@@ -183,6 +184,14 @@ export const modelGraph = (
183184

184185
const ModelGraph = lazy(() => import("./analyses/model_graph"));
185186

187+
export const schemaERD = (options: AnalysisOptions): ModelAnalysisMeta<SchemaERDConfig> => ({
188+
...options,
189+
component: (props) => <SchemaERD {...props} />,
190+
initialContent: defaultSchemaERDConfig,
191+
});
192+
193+
const SchemaERD = lazy(() => import("./analyses/schema_erd"));
194+
186195
export function motifFinding(
187196
options: AnalysisOptions & {
188197
findMotifs: Checkers.MotifFinder;
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import type * as Viz from "@viz-js/viz";
2+
import { escape as escapeHtml } from "html-escaper";
3+
import download from "js-file-download";
4+
import Download from "lucide-solid/icons/download";
5+
import { createResource, Show } from "solid-js";
6+
7+
import { Foldable, FormGroup, IconButton, SelectField } from "catcolab-ui-components";
8+
import type { DblModel, MorType, ObType } from "catlog-wasm";
9+
import type { ModelAnalysisProps } from "../../analysis";
10+
import { loadViz } from "../../visualization";
11+
import { Direction, type SchemaERDConfig } from "./schema_erd_config";
12+
13+
/** Visualize a schema as an Entity-Relationship Diagram.
14+
15+
This visualization is specifically designed for schemas (models of the simple-schema
16+
theory) and displays entities as tables with their attributes listed inside.
17+
*/
18+
export default function SchemaERD(props: ModelAnalysisProps<SchemaERDConfig>) {
19+
const graph = () => {
20+
const model = props.liveModel.elaboratedModel();
21+
if (model) {
22+
return schemaToERD(model);
23+
}
24+
};
25+
26+
const [vizResource] = createResource(loadViz);
27+
28+
const svgString = () => {
29+
const viz = vizResource();
30+
const g = graph();
31+
if (!viz || !g) {
32+
return undefined;
33+
}
34+
35+
const direction = props.content.direction ?? Direction.Vertical;
36+
return viz.renderString(g, {
37+
format: "svg",
38+
graphAttributes: {
39+
rankdir: direction === Direction.Horizontal ? "LR" : "TB",
40+
},
41+
});
42+
};
43+
44+
const schemaName = () => props.liveModel.liveDoc.doc.name || "Untitled";
45+
const header = () => (
46+
<>
47+
<IconButton
48+
onClick={() => {
49+
const svg = svgString();
50+
if (svg) {
51+
download(svg, `${schemaName()} - ERD.svg`, "image/svg+xml");
52+
}
53+
}}
54+
disabled={!svgString()}
55+
tooltip={`Export the entity-relationship diagram as SVG`}
56+
>
57+
<Download size={16} />
58+
</IconButton>
59+
</>
60+
);
61+
62+
return (
63+
<div class="graph-visualization-container">
64+
<Foldable title="Entity-relationship diagram" header={header()}>
65+
<FormGroup compact>
66+
<SelectField
67+
label="Direction"
68+
value={props.content.direction ?? Direction.Vertical}
69+
onChange={(evt) => {
70+
props.changeContent((content) => {
71+
content.direction = evt.currentTarget.value as Direction;
72+
});
73+
}}
74+
>
75+
<option value={Direction.Horizontal}>{"Horizontal"}</option>
76+
<option value={Direction.Vertical}>{"Vertical"}</option>
77+
</SelectField>
78+
</FormGroup>
79+
</Foldable>
80+
<div class="graph-visualization">
81+
<Show when={svgString()}>{(svg) => <div innerHTML={svg()} />}</Show>
82+
</div>
83+
</div>
84+
);
85+
}
86+
87+
/** Convert a schema model into an ERD-style Graphviz graph using HTML-like labels. */
88+
export function schemaToERD(model: DblModel): Viz.Graph {
89+
const entityType: ObType = { tag: "Basic", content: "Entity" };
90+
const attrType: MorType = { tag: "Basic", content: "Attr" };
91+
92+
const entities = model.obGeneratorsWithType(entityType);
93+
const nodes: Required<Viz.Graph>["nodes"] = [];
94+
95+
// Collect all mappings to know which entities they point to
96+
const mappingsByEntity = new Map<
97+
string,
98+
Array<{ id: string; name: string; targetEntity: string }>
99+
>();
100+
const mappingType: MorType = { tag: "Hom", content: entityType };
101+
for (const morId of model.morGeneratorsWithType(mappingType)) {
102+
const mor = model.morPresentation(morId);
103+
if (
104+
mor &&
105+
mor.dom.tag === "Basic" &&
106+
mor.cod.tag === "Basic" &&
107+
entities.includes(mor.dom.content) &&
108+
entities.includes(mor.cod.content)
109+
) {
110+
const mappingName = mor.label?.join(".") ?? "";
111+
const sourceEntity = mor.dom.content;
112+
const targetEntity = mor.cod.content;
113+
114+
if (!mappingsByEntity.has(sourceEntity)) {
115+
mappingsByEntity.set(sourceEntity, []);
116+
}
117+
mappingsByEntity.get(sourceEntity)?.push({
118+
id: morId,
119+
name: mappingName,
120+
targetEntity,
121+
});
122+
}
123+
}
124+
125+
// Build entity tables
126+
for (const entityId of entities) {
127+
const entity = model.obPresentation(entityId);
128+
129+
const attributes: Array<{ name: string; type: string }> = [];
130+
for (const morId of model.morGeneratorsWithType(attrType)) {
131+
const mor = model.morPresentation(morId);
132+
if (mor && mor.dom.tag === "Basic" && mor.cod.tag === "Basic") {
133+
const domainMatch = mor.dom.content === entityId;
134+
135+
if (domainMatch) {
136+
const attrName = mor.label?.join(".") ?? "";
137+
const attrTypeId = mor.cod.content;
138+
const attrTypeOb = model.obPresentation(attrTypeId);
139+
const attrTypeName = attrTypeOb?.label?.join(".") ?? "";
140+
attributes.push({ name: attrName, type: attrTypeName });
141+
}
142+
}
143+
}
144+
const mappings = mappingsByEntity.get(entityId) ?? [];
145+
146+
const entityLabel = escapeHtml(entity.label?.join(".") ?? "");
147+
const paddingLeft = computePaddingCenteredLeft(entityLabel);
148+
const paddingRight = computePaddingCenteredRight(entityLabel);
149+
// We cannot use our global CSS custom properties for this color
150+
const bgColor = "#a6f2f2";
151+
let tableRows = `
152+
<tr>
153+
<td port="${entityId}" bgcolor="${bgColor}" align="center" colspan="2"><b><font point-size="12">${paddingLeft}${entityLabel}${paddingRight}</font></b></td>
154+
</tr>
155+
`;
156+
157+
if (attributes.length === 0 && mappings.length === 0) {
158+
tableRows += `
159+
<tr>
160+
<td align="left" colspan="2"><font point-size="12"><i>(no attributes)</i>&#160;&#160;</font></td>
161+
</tr>
162+
`;
163+
} else {
164+
for (const attr of attributes) {
165+
const name = escapeHtml(attr.name);
166+
const label = escapeHtml(attr.type);
167+
const paddingName = computePadding(name);
168+
const paddingLabel = computePadding(label);
169+
tableRows += `
170+
<tr>
171+
<td align="left"><font point-size="12">${name}${paddingName}</font></td>
172+
<td align="left"><font point-size="12">${label}${paddingLabel}</font></td>
173+
</tr>
174+
`;
175+
}
176+
for (const mapping of mappings) {
177+
let label =
178+
model.obPresentation(mapping.targetEntity).label?.join(".") ||
179+
mapping.targetEntity;
180+
label = escapeHtml(label);
181+
label = `→ ${label}`;
182+
const paddingLabel = computePadding(label);
183+
const name = escapeHtml(mapping.name);
184+
const paddingName = computePadding(name);
185+
tableRows += `
186+
<tr>
187+
<td align="left" port="${escapeHtml(mapping.id)}"><font point-size="12">${name}${paddingName}</font></td>
188+
<td align="left"><font point-size="12">${label}${paddingLabel}</font></td>
189+
</tr>
190+
`;
191+
}
192+
}
193+
194+
nodes.push({
195+
name: entityId,
196+
attributes: {
197+
id: entityId,
198+
label: {
199+
html: `
200+
<table border="0" cellborder="1" cellspacing="0" cellpadding="4">
201+
${tableRows}
202+
</table>
203+
`,
204+
},
205+
},
206+
});
207+
}
208+
209+
// Add edges for entity-to-entity mappings (foreign keys) using ports
210+
const edges: Required<Viz.Graph>["edges"] = [];
211+
for (const [sourceEntity, mappings] of mappingsByEntity) {
212+
for (const mapping of mappings) {
213+
edges.push({
214+
tail: sourceEntity,
215+
head: mapping.targetEntity,
216+
attributes: {
217+
id: mapping.id,
218+
tailport: `${mapping.id}:w`,
219+
arrowhead: "none",
220+
arrowtail: "crow",
221+
dir: "both",
222+
},
223+
});
224+
}
225+
}
226+
227+
return {
228+
directed: true,
229+
nodes,
230+
edges,
231+
graphAttributes: {
232+
rankdir: "TB",
233+
bgcolor: "transparent",
234+
},
235+
nodeAttributes: {
236+
fontname: "sans-serif",
237+
fontsize: "10",
238+
shape: "plaintext",
239+
},
240+
edgeAttributes: {
241+
fontname: "sans-serif",
242+
fontsize: "9",
243+
color: "#666666",
244+
},
245+
};
246+
}
247+
248+
// These padding functions are a hack to get the Graphviz HTML-like table layout to contain our text properly
249+
function computePadding(text: string): string {
250+
const width = text.length;
251+
const padding = Math.ceil(width / 6 + Math.sqrt(width));
252+
253+
return Array(padding).fill("&#160;").join("");
254+
}
255+
256+
function computePaddingCenteredRight(text: string): string {
257+
const width = text.length;
258+
const padding = Math.ceil(width / 3);
259+
260+
return Array(padding).fill("&#160;").join("");
261+
}
262+
263+
function computePaddingCenteredLeft(text: string): string {
264+
const width = text.length;
265+
const padding = Math.ceil(width / 3 + Math.sqrt(width));
266+
267+
return Array(padding).fill("&#160;").join("");
268+
}

0 commit comments

Comments
 (0)