@peter.prib/renderengine 0.2.10 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +22 -6
  2. package/REFERENCE.md +123 -2
  3. package/dist/renderengine-workspace.umd.js +3015 -1221
  4. package/dist/renderengine.umd.js +1220 -176
  5. package/package.json +1 -1
  6. package/src/charting/ModelCheckDialog.js +10 -3
  7. package/src/charting/ModelComparisonDialog.js +10 -3
  8. package/src/charting/ModelFileFormat.js +4 -1
  9. package/src/charting/archimate/ArchimateCanvasEditor.js +14 -2
  10. package/src/charting/archimate/ArchimateElementTemplate.js +23 -0
  11. package/src/charting/arrangement/layouts.js +9 -4
  12. package/src/charting/datamodel/DataModel.js +18 -71
  13. package/src/charting/datamodel/DataModelChecker.js +1 -5
  14. package/src/charting/datamodel/DataModelCompared.js +1 -31
  15. package/src/charting/datamodel/LevelSchema.js +23 -0
  16. package/src/charting/datamodel/SqlDdl.js +18 -13
  17. package/src/charting/diagramModel/ListCompared.js +35 -0
  18. package/src/charting/model/Benchmark.js +59 -0
  19. package/src/charting/model/EditableModel.js +104 -0
  20. package/src/charting/{datamodel/DataModelEditCommand.js → model/ModelEditCommand.js} +47 -47
  21. package/src/charting/requirements/RequirementsChecker.js +76 -0
  22. package/src/charting/requirements/RequirementsCompared.js +33 -0
  23. package/src/charting/requirements/RequirementsFileFormat.js +32 -0
  24. package/src/charting/requirements/RequirementsImportFormats.js +48 -0
  25. package/src/charting/requirements/RequirementsModel.js +305 -0
  26. package/src/charting/requirements/Storyboard.js +94 -0
  27. package/src/charting/requirements/TrackerExport.js +273 -0
  28. package/src/charting/requirements/index.js +7 -0
  29. package/src/index.js +6 -0
  30. package/src/workspace/Workspace.js +329 -321
  31. package/src/workspace/index.js +2 -0
  32. package/src/workspace/parts/ArchimateCanvasPart.js +31 -1
  33. package/src/workspace/parts/DataModelPart.js +668 -735
  34. package/src/workspace/parts/EditorPart.js +1 -1
  35. package/src/workspace/parts/ModelFile.js +3 -1
  36. package/src/workspace/parts/ModelHosted.js +176 -0
  37. package/src/workspace/parts/ModelsPart.js +146 -7
  38. package/src/workspace/parts/RequirementsPart.js +465 -0
  39. package/src/workspace/storage/ModelLibrary.js +8 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peter.prib/renderengine",
3
- "version": "0.2.10",
3
+ "version": "0.4.0",
4
4
  "description": "ArchiMate modeller, diagram editor and chart library for the browser, with no runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -5,7 +5,9 @@ import { FormDialog } from './FormDialog.js';
5
5
  * ModelCheckDialog — a model check's findings (ModelChecker#run), listed:
6
6
  * each a button (⚠ a warning, ℹ for a look) that shows what it names
7
7
  * (`onReveal`), with what can be done about it beside (`actionsFor`), or
8
- * "nothing found". Any editor or part that checks a model shows it this way.
8
+ * "nothing found". Any editor or part that checks a model shows it this way;
9
+ * anything else listed to be opened (search results) too, with its own
10
+ * `title` and `empty` text.
9
11
  */
10
12
  export class ModelCheckDialog {
11
13
  /**
@@ -14,9 +16,13 @@ export class ModelCheckDialog {
14
16
  * @param {(finding: Object) => void} [options.onReveal] - Show what a finding names
15
17
  * @param {(finding: Object) => Array<{label: string, action: Function}>} [options.actionsFor]
16
18
  * @param {Document} [options.document]
19
+ * @param {string} [options.title] - In place of "Model check: n findings"
20
+ * @param {string} [options.empty] - What an empty list says
17
21
  */
18
- constructor({ findings, onReveal = () => {}, actionsFor = () => [], document: doc = globalThis.document }) {
22
+ constructor({ findings, onReveal = () => {}, actionsFor = () => [], document: doc = globalThis.document, title = null, empty = 'Nothing found: the model passes every check.' }) {
19
23
  this.findings = findings;
24
+ this._title = title;
25
+ this.empty = empty;
20
26
  this.onReveal = onReveal;
21
27
  this.actionsFor = actionsFor;
22
28
  this.document = doc;
@@ -24,6 +30,7 @@ export class ModelCheckDialog {
24
30
 
25
31
  /** The dialog's title: how many findings. */
26
32
  get title() {
33
+ if (this._title) return this._title;
27
34
  const n = this.findings.length;
28
35
  return `Model check: ${n} finding${n === 1 ? '' : 's'}`;
29
36
  }
@@ -49,7 +56,7 @@ export class ModelCheckDialog {
49
56
  on: { click: () => { dialog?.close(); act.action(); } },
50
57
  })),
51
58
  ],
52
- })) : [{ text: 'Nothing found: the model passes every check.' }],
59
+ })) : [{ text: this.empty }],
53
60
  }, this.document);
54
61
  dialog = new FormDialog({
55
62
  title: this.title,
@@ -26,16 +26,23 @@ export class ModelCompareDialog {
26
26
  * @param {string} options.mineName - What the model compared is called
27
27
  * @param {string} options.theirsName - What the other is called
28
28
  * @param {() => void} [options.onMerge] - Merge from the other (offered when there is something to merge)
29
+ * @param {string} [options.note] - Said above the differences: why the other is offered (a proposal's rationale)
29
30
  * @param {Document} [options.document]
30
31
  */
31
- constructor({ comparison, mineName, theirsName, onMerge = null, document: doc = globalThis.document }) {
32
+ constructor({ comparison, mineName, theirsName, onMerge = null, note = '', document: doc = globalThis.document }) {
32
33
  this.comparison = comparison;
33
34
  this.mineName = mineName;
34
35
  this.theirsName = theirsName;
35
36
  this.onMerge = onMerge;
37
+ this.note = note;
36
38
  this.document = doc;
37
39
  }
38
40
 
41
+ /** The dialog's description: the note, then `text`. */
42
+ described(text) {
43
+ return this.note ? `${this.note}\n\n${text}` : text;
44
+ }
45
+
39
46
  get title() {
40
47
  return `Compare "${this.mineName}" with "${this.theirsName}"`;
41
48
  }
@@ -74,7 +81,7 @@ export class ModelCompareDialog {
74
81
  const canMerge = !!this.onMerge && this.comparison.mergeable().length > 0;
75
82
  return new FormDialog({
76
83
  title: this.title,
77
- description: this.summaryText(),
84
+ description: this.described(this.summaryText()),
78
85
  fields: this.fields(),
79
86
  submitLabel: canMerge ? 'Merge…' : 'Close',
80
87
  cancelLabel: 'Close',
@@ -107,7 +114,7 @@ export class ModelMergeDialog extends ModelCompareDialog {
107
114
  const nothing = !this.comparison.mergeable().length;
108
115
  return new FormDialog({
109
116
  title: this.title,
110
- description: nothing ? `"${this.mineName}" has everything "${this.theirsName}" has.` : `${this.summaryText()}\n\nWhat the chosen need (a view's elements, a relationship's ends) comes too.`,
117
+ description: this.described(nothing ? `"${this.mineName}" has everything "${this.theirsName}" has.` : `${this.summaryText()}\n\nWhat the chosen need (a view's elements, a relationship's ends) comes too.`),
111
118
  fields: this.fields(),
112
119
  submitLabel: nothing ? 'Close' : 'Merge',
113
120
  idPrefix: 'model-merge-',
@@ -28,6 +28,9 @@ export class ModelFileFormat {
28
28
  /** Whether files in this format can be imported (opened) as well as exported. A subclass that only exports says no. */
29
29
  get imports() { return true; }
30
30
 
31
+ /** Whether a model can be exported in this format. A subclass that only reads (another tool's export) says no. */
32
+ get exports() { return true; }
33
+
31
34
  /** True when import takes the file's bytes (a zip archive), else its text. */
32
35
  get readsBytes() { return false; }
33
36
 
@@ -87,7 +90,7 @@ export function modelFileMenuItems(formats, {
87
90
  const choose = chooseFile ?? ((accept) => pickFile(accept, doc()));
88
91
  const tell = report ?? ((message) => (doc().defaultView ?? globalThis).alert(message));
89
92
  const groups = formats.map(format => [
90
- only !== 'import' && enabled(format, 'export') && { label: only ? format.label : `Export ${format.label}`, action: () => save(format.export(model()), format.fileName) },
93
+ only !== 'import' && format.exports !== false && enabled(format, 'export') && { label: only ? format.label : `Export ${format.label}`, action: () => save(format.export(model()), format.fileName) },
91
94
  only !== 'export' && format.imports && enabled(format, 'import') && {
92
95
  label: only ? `${format.label}…` : `Import ${format.label}…`,
93
96
  action: () => importFile(format, { choose, onImport, report: tell }),
@@ -77,8 +77,12 @@ export class ArchimateCanvasEditor extends DiagramEditor {
77
77
  * elements (null cancels). Without it the first of KIND_PREFERENCE that is allowed is used.
78
78
  * @param {(nodeOptions: Object) => void} [options.onNodeOptionsChange] - The ⚙ Settings menu changed
79
79
  * the version or distinct shapes (the host may need to redraw more than the canvas, e.g. a palette)
80
+ * @param {(element: Object) => ({text: string, color?: string}|null)} [options.elementStatus] - A host's
81
+ * live status for an element (a component's lifecycle state), drawn on its boxes
82
+ * @param {(element: Object) => Array<Object>} [options.elementMenuExtras] - A host's menu items for an
83
+ * element (its transitions), at the end of a box's right-click menu
80
84
  */
81
- constructor({ view, model, nodeOptions = {}, chooseKind = null, onNodeOptionsChange = null, templateMenu = null, customViewpoints = [], settingsManager = null, ...options }) {
85
+ constructor({ view, model, nodeOptions = {}, chooseKind = null, onNodeOptionsChange = null, templateMenu = null, customViewpoints = [], settingsManager = null, elementStatus = null, elementMenuExtras = null, ...options }) {
82
86
  super({ ...options, templates: new Map(), edges: new Map(), history: view.history, arrangement: view.arrangement });
83
87
  this.view = view;
84
88
  this.model = model;
@@ -91,6 +95,9 @@ export class ArchimateCanvasEditor extends DiagramEditor {
91
95
  this.settingsManager = settingsManager;
92
96
  /** Canvas templates (a CanvasTemplateMenu): placed on the canvas, saved from its shapes. */
93
97
  this.templateMenu = templateMenu;
98
+ /** A host's live status per element, drawn on its boxes, and its menu items per element. */
99
+ this.elementStatus = elementStatus;
100
+ this.elementMenuExtras = elementMenuExtras;
94
101
  this.addDropHandler({
95
102
  accepts: (dt) => Array.from(dt.types).includes(ARCHIMATE_TYPE_MIME),
96
103
  drop: (dt, point) => this.addElement(dt.getData(ARCHIMATE_TYPE_MIME), point),
@@ -128,6 +135,7 @@ export class ArchimateCanvasEditor extends DiagramEditor {
128
135
  ...(missing.length ? [{ label: `Add Missing Relationships (${missing.length})`, action: () => this.addMissingRelationships(elementId) }] : []),
129
136
  ...hintMenuItems(this.view, elementId, { onChange: () => this.render() }),
130
137
  ...(items.length ? [{ label: 'Save as template', items }] : []),
138
+ ...(element && this.elementMenuExtras ? this.elementMenuExtras(element) ?? [] : []),
131
139
  ];
132
140
  }
133
141
 
@@ -352,7 +360,11 @@ export class ArchimateCanvasEditor extends DiagramEditor {
352
360
  templates.set(inst.id, existing?.instance === inst ? existing
353
361
  : new ArchimateElementTemplate(inst, model, { nodeOptions: this.nodeOptions }));
354
362
  }
355
- for (const t of templates.values()) t.nodeOptions = this.nodeOptions;
363
+ for (const t of templates.values()) {
364
+ t.nodeOptions = this.nodeOptions;
365
+ // Asked afresh at every sync: a host's status is live, not part of the model.
366
+ t.status = this.elementStatus ? this.elementStatus(t.instance.resolve(model)) ?? null : null;
367
+ }
356
368
  // The view's groups (Archi groups: model folders with bounds on this view).
357
369
  for (const folder of view.placedFolders) {
358
370
  const existing = this.templates.get(folder.id);
@@ -41,6 +41,8 @@ export class ArchimateElementTemplate extends GlyphTemplate {
41
41
  this.instance = instance;
42
42
  this.model = model;
43
43
  this.nodeOptions = nodeOptions;
44
+ /** A host's live status, `{text, color}` (the editor's elementStatus), or null: drawn as a pill. */
45
+ this.status = null;
44
46
  }
45
47
 
46
48
  /** State lives on the instance and the model element, not here. @protected */
@@ -156,6 +158,27 @@ export class ArchimateElementTemplate extends GlyphTemplate {
156
158
  * gets no glyph badge (buildNode leaves iconShape unset) but keeps an icon the element sets.
157
159
  */
158
160
  decorations(ctx) {
161
+ return [...this._badge(ctx), ...this.statusPill(ctx)];
162
+ }
163
+
164
+ /**
165
+ * The host's status (`status`), as a small pill inside the box's bottom
166
+ * right corner: its text, on its colour. Nothing without one.
167
+ */
168
+ statusPill({ w, h }) {
169
+ const text = this.status?.text;
170
+ if (!text) return [];
171
+ const label = String(text);
172
+ const ph = 12, pw = Math.min(w - 6, label.length * 5.5 + 10);
173
+ const x = w / 2 - pw - 3, y = h / 2 - ph - 3;
174
+ return [Glyph.group([
175
+ Glyph.rect(x, y, pw, ph, this.status.color || '#455a64', null, { rx: 6, ry: 6 }),
176
+ Glyph.text(label, x + pw / 2, y + ph / 2, { fill: '#ffffff', textAnchor: 'middle', verticalAlign: 'middle', fontSize: '8px' }),
177
+ ], { 'pointer-events': 'none', class: 'element-status' })];
178
+ }
179
+
180
+ /** The type badge (decorations). @private */
181
+ _badge(ctx) {
159
182
  const node = this.node;
160
183
  const { w, h } = ctx;
161
184
  const fixed = ArchimateElementTemplate.badgeSize(w, h);
@@ -153,14 +153,19 @@ function tree(boxes, { edges, gap }, direction) {
153
153
 
154
154
  /**
155
155
  * Fruchterman–Reingold: lines pull their ends together, every pair of boxes
156
- * pushes apart, cooling over the iterations. Deterministic: it starts from
157
- * where the boxes are (boxes on one spot are spread round it first).
156
+ * pushes apart, cooling over the iterations, all within a frame — a square
157
+ * round where the boxes were, room for them a grid's worth — so a box with no
158
+ * lines is not pushed away for ever. Deterministic: it starts from where the
159
+ * boxes are (boxes on one spot are spread round it first).
158
160
  */
159
161
  function forceDirected(boxes, edges, gap) {
160
162
  const size = Math.max(...boxes.map(b => Math.max(b.width, b.height))) + gap;
161
163
  const k = size * 1.2;
162
164
  const pos = boxes.map((b, i) => ({ x: b.left + b.width / 2 + (i % 3) * 0.5, y: b.top + b.height / 2 + Math.floor(i / 3) * 0.5 }));
163
165
  const index = new Map(boxes.map((b, i) => [b.id, i]));
166
+ const centre = { x: pos.reduce((t, p) => t + p.x, 0) / pos.length, y: pos.reduce((t, p) => t + p.y, 0) / pos.length };
167
+ const half = (k * (Math.ceil(Math.sqrt(boxes.length)) + 1)) / 2;
168
+ const inFrame = (v, c) => Math.min(c + half, Math.max(c - half, v));
164
169
  let temperature = size * 2;
165
170
  for (let iteration = 0; iteration < 120; iteration++) {
166
171
  const shift = pos.map(() => ({ x: 0, y: 0 }));
@@ -185,8 +190,8 @@ function forceDirected(boxes, edges, gap) {
185
190
  for (let i = 0; i < pos.length; i++) {
186
191
  const d = Math.max(1e-9, Math.hypot(shift[i].x, shift[i].y));
187
192
  const step = Math.min(d, temperature);
188
- pos[i].x += (shift[i].x / d) * step;
189
- pos[i].y += (shift[i].y / d) * step;
193
+ pos[i].x = inFrame(pos[i].x + (shift[i].x / d) * step, centre.x);
194
+ pos[i].y = inFrame(pos[i].y + (shift[i].y / d) * step, centre.y);
190
195
  }
191
196
  temperature *= 0.95;
192
197
  }
@@ -6,11 +6,10 @@ import './StructuredType.js';
6
6
  import { LevelSchema } from './LevelSchema.js';
7
7
  import { LevelMapping } from './LevelMapping.js';
8
8
  import { NodeGeometry } from '../diagrams/NodeGeometry.js';
9
- import { ModelComparison } from '../diagramModel/ModelComparison.js';
10
9
  import { DataModelChecker } from './DataModelChecker.js';
11
- import { CommandHistory } from '../CommandHistory.js';
12
10
  import { MergeModelCommand } from '../diagramModel/MergeModelCommand.js';
13
- import { DataModelEditCommand } from './DataModelEditCommand.js';
11
+ import { EditableModel } from '../model/EditableModel.js';
12
+ import { Benchmark } from '../model/Benchmark.js';
14
13
  import { DataModelNotation } from '../../datamodel.js';
15
14
  import { NamingStandard } from './naming/NamingStandard.js';
16
15
  import { ClassificationScheme } from './ClassificationScheme.js';
@@ -38,11 +37,13 @@ const LEVELS = Object.freeze(['conceptual', 'logical', 'physical']);
38
37
  * (`{ entities, relationships }`, as dataModelDiagram takes them), is read as
39
38
  * three levels mapped one to one.
40
39
  *
41
- * It knows whether it changed since it was saved (`edited()`, `markSaved()`,
42
- * `isDirty()`, `onEdit(fn)`), as a ViewSetModel does, so a part can show it,
43
- * keep it and ask before it is lost.
40
+ * What any model a part edits has — whether it changed since saved, its undo
41
+ * history, compare and merge — is EditableModel's.
44
42
  */
45
- export class DataModel {
43
+ export class DataModel extends EditableModel {
44
+ /** What a snapshot puts back (EditableModel#restore). */
45
+ static STATE = Object.freeze(['name', 'levels', 'mappings', 'mappingLayout', 'noteLayout', 'perspectives', 'contexts', 'dataTypes', 'namingStandard', 'classificationScheme', 'scorecard']);
46
+
46
47
  /** The levels, outermost first. */
47
48
  static LEVELS = LEVELS;
48
49
 
@@ -126,11 +127,7 @@ export class DataModel {
126
127
  * @param {string} [spec.name] - What the model is called
127
128
  */
128
129
  constructor(spec = {}) {
129
- /** What the model is called. */
130
- this.name = spec.name ?? '';
131
- this._version = 0;
132
- this._saved = 0;
133
- this._editListeners = [];
130
+ super(spec);
134
131
  const { levels, mappings = [], mappingLayout = {}, noteLayout = {} } = spec.levels ? spec : DataModel.levelsFromCombined(spec);
135
132
  this.levels = Object.fromEntries(LEVELS.map(level => [level, new LevelSchema(level, levels?.[level] ?? {})]));
136
133
  this.mappings = LEVELS.slice(1).map((to, i) => {
@@ -157,6 +154,14 @@ export class DataModel {
157
154
  return new ModelScorecard(this);
158
155
  }
159
156
 
157
+ /** What it is measured by (Benchmark): its check's findings, and its Scorecard total. */
158
+ benchmark() {
159
+ return [
160
+ ...Benchmark.findings(this),
161
+ { key: 'scorecard', label: 'Scorecard', value: this.scored().total().score, better: 'higher', format: 'score' },
162
+ ];
163
+ }
164
+
160
165
  /** Keep a reviewer's scores and the weights (ModelScorecard.specFrom's), as one undo step. */
161
166
  scorecardCommand(spec) {
162
167
  return this.editCommand('Score the model', (model) => {
@@ -204,46 +209,6 @@ export class DataModel {
204
209
  ];
205
210
  }
206
211
 
207
- /** How this model differs from another (ModelComparison), which can merge the other's into it. */
208
- compareWith(other) {
209
- return new ModelComparison(this, other);
210
- }
211
-
212
- /** Its undo history: a merge is one step on it. */
213
- get history() {
214
- return (this._history ??= new CommandHistory());
215
- }
216
-
217
- // ===== Changed since saved =====
218
-
219
- /** Something changed (a perspective or context, a box or note moved): listeners hear of it. */
220
- edited() {
221
- this._version++;
222
- this._emitEdit();
223
- }
224
-
225
- /** This state is the saved one (a file opened or saved). */
226
- markSaved() {
227
- this._saved = this._version;
228
- this._emitEdit();
229
- }
230
-
231
- /** Whether it changed since markSaved. */
232
- isDirty() {
233
- return this._version !== this._saved;
234
- }
235
-
236
- /** Call `fn(model)` after every change (and markSaved). Returns an unsubscribe function. */
237
- onEdit(fn) {
238
- this._editListeners.push(fn);
239
- return () => { this._editListeners = this._editListeners.filter(l => l !== fn); };
240
- }
241
-
242
- /** @private */
243
- _emitEdit() {
244
- for (const fn of this._editListeners) fn(this);
245
- }
246
-
247
212
  /** A level's schema (LevelSchema). */
248
213
  level(name) {
249
214
  const schema = this.levels[name];
@@ -459,13 +424,7 @@ export class DataModel {
459
424
 
460
425
  // ===== Perspectives and contexts of one's own =====
461
426
 
462
- // ===== Editing what a level holds (each a DataModelEditCommand: one undo step) =====
463
-
464
- /** Put the model back as a snapshot says (DataModelEditCommand): its levels, mappings, layout, perspectives, contexts, data types. */
465
- restore(json) {
466
- const back = new DataModel(JSON.parse(JSON.stringify(json)));
467
- for (const key of ['name', 'levels', 'mappings', 'mappingLayout', 'noteLayout', 'perspectives', 'contexts', 'dataTypes', 'namingStandard', 'classificationScheme', 'scorecard']) this[key] = back[key];
468
- }
427
+ // ===== Editing what a level holds (each an editCommand: one undo step) =====
469
428
 
470
429
  /** Set how its data is classified (a ClassificationScheme spec), as one undo step. */
471
430
  classificationSchemeCommand(spec) {
@@ -481,11 +440,6 @@ export class DataModel {
481
440
  });
482
441
  }
483
442
 
484
- /** A change made as one undo step. */
485
- editCommand(label, change) {
486
- return new DataModelEditCommand(this, label, change);
487
- }
488
-
489
443
  /**
490
444
  * Add an entity at a level (`id` null: one made from its name) or change
491
445
  * one (what the spec leaves out, where it is drawn among it, kept).
@@ -837,13 +791,6 @@ export class DataModel {
837
791
  }
838
792
 
839
793
  /** An id from a name, not yet among `taken`. @private */
840
- _freshId(name, taken) {
841
- const base = String(name ?? 'item').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'item';
842
- let id = base;
843
- for (let n = 2; taken.some(t => t.id === id); n++) id = `${base}-${n}`;
844
- return id;
845
- }
846
-
847
794
  toJSON() {
848
795
  return {
849
796
  name: this.name,
@@ -53,17 +53,13 @@ export class DataModelChecker extends ModelChecker {
53
53
  foreignKeys() {
54
54
  return ATTRIBUTED.flatMap(level => {
55
55
  const schema = this.model.level(level);
56
- const find = (ref) => {
57
- const [name] = String(ref).split('.');
58
- return schema.entity(name) ?? schema.entities.find(e => e.name.toLowerCase() === name.trim().toLowerCase()) ?? null;
59
- };
60
56
  const related = (a, b) => schema.relationships.some(r => (r.source === a && r.target === b) || (r.source === b && r.target === a));
61
57
  return schema.entities.flatMap(e => (e.attributes ?? []).filter(a => a.isFK).flatMap(a => {
62
58
  if (!a.references) {
63
59
  return schema.relationships.some(r => r.source === e.id || r.target === e.id) ? []
64
60
  : [this._entity(level, e, 'foreign-key-unrelated', 'info', `"${e.name}.${a.name}" is a foreign key, but "${e.name}" has no relationship`)];
65
61
  }
66
- const target = find(a.references);
62
+ const target = schema.resolveReference(a.references)?.entity;
67
63
  if (!target) return [this._entity(level, e, 'reference-missing', 'warning', `"${e.name}.${a.name}" references "${a.references}", which is not at the ${level} level`)];
68
64
  return related(e.id, target.id) ? []
69
65
  : [this._entity(level, e, 'foreign-key-without-relationship', 'warning', `"${e.name}.${a.name}" references "${target.name}", but they have no relationship`)];
@@ -1,4 +1,5 @@
1
1
  import { ComparedKind } from '../diagramModel/ComparedKind.js';
2
+ import { ListCompared } from '../diagramModel/ListCompared.js';
2
3
 
3
4
  /** A plain copy of JSON-shaped data. */
4
5
  const copy = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
@@ -11,37 +12,6 @@ const copy = (value) => (value === undefined ? undefined : JSON.parse(JSON.strin
11
12
  * two models alike but for where their boxes are, are alike.
12
13
  */
13
14
 
14
- /** A list of a model's held in an array (`list(model)`), each by an id; bring puts a copy in place or adds it. */
15
- class ListCompared extends ComparedKind {
16
- constructor() {
17
- super();
18
- if (new.target === ListCompared) throw new TypeError('ListCompared is abstract — subclass it');
19
- }
20
-
21
- /** The array the model holds them in. @abstract */
22
- list(model) { throw new Error(`${this.constructor.name} must implement list()`); }
23
-
24
- items(model) { return this.list(model); }
25
- get(model, id) { return this.items(model).find(item => this.idOf(item) === id) ?? null; }
26
-
27
- /** Put a copy of `item` at its place (in place of the one with its id, else last); undone, as it was. */
28
- bring(model, item) {
29
- const list = this.list(model);
30
- const twin = this.copyOf(item);
31
- const at = list.findIndex(x => this.idOf(x) === this.idOf(item));
32
- if (at < 0) {
33
- list.push(twin);
34
- return () => { const i = list.indexOf(twin); if (i >= 0) list.splice(i, 1); };
35
- }
36
- const old = list[at];
37
- list[at] = twin;
38
- return () => { const i = list.indexOf(twin); if (i >= 0) list[i] = old; };
39
- }
40
-
41
- /** A copy to put in the other model. */
42
- copyOf(item) { return copy(item); }
43
- }
44
-
45
15
  /** One level's entities: compared on all but where they are drawn. */
46
16
  export class LevelEntitiesCompared extends ListCompared {
47
17
  constructor(level) {
@@ -27,6 +27,29 @@ export class LevelSchema {
27
27
  return this.entities.find(e => e.id === id) ?? null;
28
28
  }
29
29
 
30
+ /** An entity by id, else by name (any case), or null. */
31
+ named(text) {
32
+ const s = String(text ?? '').trim();
33
+ return this.entity(s) ?? this.entities.find(e => String(e.name ?? '').toLowerCase() === s.toLowerCase()) ?? null;
34
+ }
35
+
36
+ /**
37
+ * What a foreign key's `references` names: `{ entity, column }` (column
38
+ * null when it names the table alone), or null. `table` or `table.column`,
39
+ * the table by id or name; a table's own name may hold dots (a
40
+ * schema-qualified `sales.customer`), so the whole text is tried as a
41
+ * table first, then what is before its last dot, the rest its column.
42
+ */
43
+ resolveReference(ref) {
44
+ const text = String(ref ?? '').trim();
45
+ if (!text) return null;
46
+ const whole = this.named(text);
47
+ if (whole) return { entity: whole, column: null };
48
+ const dot = text.lastIndexOf('.');
49
+ const entity = dot > 0 ? this.named(text.slice(0, dot)) : null;
50
+ return entity ? { entity, column: text.slice(dot + 1).trim() || null } : null;
51
+ }
52
+
30
53
  /** Add an entity (its id unique at this level). */
31
54
  addEntity(entity) {
32
55
  if (!entity?.id) throw new TypeError('An entity needs an id');
@@ -27,6 +27,15 @@ export class SqlDdl {
27
27
  return /^[a-z_][a-z0-9_]*$/.test(s) && !RESERVED.has(s) ? s : `"${s.replace(/"/g, '""')}"`;
28
28
  }
29
29
 
30
+ /**
31
+ * A table's name as SQL takes it: a schema-qualified one (`sales.customer`)
32
+ * each part as name() has it, so it names the table in its schema rather
33
+ * than one table called "sales.customer".
34
+ */
35
+ static tableName(text) {
36
+ return String(text).split('.').map(SqlDdl.name).join('.');
37
+ }
38
+
30
39
  /** A text as an SQL string literal. */
31
40
  static literal(text) {
32
41
  return `'${String(text).replace(/'/g, "''")}'`;
@@ -36,11 +45,7 @@ export class SqlDdl {
36
45
  write(model) {
37
46
  const schema = model.level('physical');
38
47
  const tables = schema.entities;
39
- const find = (ref) => {
40
- const [table, column] = String(ref).split('.').map(s => s.trim());
41
- const target = schema.entity(table) ?? tables.find(t => t.name.toLowerCase() === table.toLowerCase());
42
- return target ? { target, column } : null;
43
- };
48
+ const table = SqlDdl.tableName;
44
49
  const out = [`-- ${model.name || 'Data model'}: the physical level, ${tables.length} table${tables.length === 1 ? '' : 's'}`, ''];
45
50
  for (const t of tables) {
46
51
  const columns = t.attributes ?? [];
@@ -50,28 +55,28 @@ export class SqlDdl {
50
55
  ...(keys.length ? [` PRIMARY KEY (${keys.join(', ')})`] : []),
51
56
  ...columns.filter(c => c.isUnique && !c.isPK).map(c => ` UNIQUE (${SqlDdl.name(c.name)})`),
52
57
  ];
53
- out.push(`CREATE TABLE ${SqlDdl.name(t.name)} (`, lines.join(',\n'), ');', '');
58
+ out.push(`CREATE TABLE ${table(t.name)} (`, lines.join(',\n'), ');', '');
54
59
  }
55
60
  for (const t of tables) {
56
61
  for (const c of (t.attributes ?? []).filter(a => a.isFK && a.references)) {
57
- const ref = find(c.references);
62
+ const ref = schema.resolveReference(c.references);
58
63
  if (!ref) {
59
64
  out.push(`-- ${t.name}.${c.name} references ${c.references}, which is not a table here`);
60
65
  continue;
61
66
  }
62
- const theirs = ref.column ? [ref.column] : (ref.target.attributes ?? []).filter(a => a.isPK).map(a => a.name);
67
+ const theirs = ref.column ? [ref.column] : (ref.entity.attributes ?? []).filter(a => a.isPK).map(a => a.name);
63
68
  if (!theirs.length) {
64
- out.push(`-- ${t.name}.${c.name} references ${ref.target.name}, which has no primary key`);
69
+ out.push(`-- ${t.name}.${c.name} references ${ref.entity.name}, which has no primary key`);
65
70
  continue;
66
71
  }
67
- out.push(`ALTER TABLE ${SqlDdl.name(t.name)} ADD CONSTRAINT ${SqlDdl.name(`fk_${t.name}_${c.name}`.toLowerCase().replace(/[^a-z0-9_]+/g, '_'))}`
68
- + ` FOREIGN KEY (${SqlDdl.name(c.name)}) REFERENCES ${SqlDdl.name(ref.target.name)} (${theirs.map(SqlDdl.name).join(', ')});`);
72
+ out.push(`ALTER TABLE ${table(t.name)} ADD CONSTRAINT ${SqlDdl.name(`fk_${t.name}_${c.name}`.toLowerCase().replace(/[^a-z0-9_]+/g, '_'))}`
73
+ + ` FOREIGN KEY (${SqlDdl.name(c.name)}) REFERENCES ${table(ref.entity.name)} (${theirs.map(SqlDdl.name).join(', ')});`);
69
74
  }
70
75
  }
71
76
  if (this.comments) {
72
77
  const notes = tables.flatMap(t => [
73
- ...(t.description ? [`COMMENT ON TABLE ${SqlDdl.name(t.name)} IS ${SqlDdl.literal(t.description)};`] : []),
74
- ...(t.attributes ?? []).filter(c => c.description).map(c => `COMMENT ON COLUMN ${SqlDdl.name(t.name)}.${SqlDdl.name(c.name)} IS ${SqlDdl.literal(c.description)};`),
78
+ ...(t.description ? [`COMMENT ON TABLE ${table(t.name)} IS ${SqlDdl.literal(t.description)};`] : []),
79
+ ...(t.attributes ?? []).filter(c => c.description).map(c => `COMMENT ON COLUMN ${table(t.name)}.${SqlDdl.name(c.name)} IS ${SqlDdl.literal(c.description)};`),
75
80
  ]);
76
81
  if (notes.length) out.push('', ...notes);
77
82
  }
@@ -0,0 +1,35 @@
1
+ import { ComparedKind } from './ComparedKind.js';
2
+
3
+ /** A plain copy of JSON-shaped data. */
4
+ const copy = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
5
+
6
+ /** A list of a model's held in an array (`list(model)`), each by an id; bring puts a copy in place or adds it. */
7
+ export class ListCompared extends ComparedKind {
8
+ constructor() {
9
+ super();
10
+ if (new.target === ListCompared) throw new TypeError('ListCompared is abstract — subclass it');
11
+ }
12
+
13
+ /** The array the model holds them in. @abstract */
14
+ list(model) { throw new Error(`${this.constructor.name} must implement list()`); }
15
+
16
+ items(model) { return this.list(model); }
17
+ get(model, id) { return this.items(model).find(item => this.idOf(item) === id) ?? null; }
18
+
19
+ /** Put a copy of `item` at its place (in place of the one with its id, else last); undone, as it was. */
20
+ bring(model, item) {
21
+ const list = this.list(model);
22
+ const twin = this.copyOf(item);
23
+ const at = list.findIndex(x => this.idOf(x) === this.idOf(item));
24
+ if (at < 0) {
25
+ list.push(twin);
26
+ return () => { const i = list.indexOf(twin); if (i >= 0) list.splice(i, 1); };
27
+ }
28
+ const old = list[at];
29
+ list[at] = twin;
30
+ return () => { const i = list.indexOf(twin); if (i >= 0) list[i] = old; };
31
+ }
32
+
33
+ /** A copy to put in the other model. */
34
+ copyOf(item) { return copy(item); }
35
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Benchmark — what a model is measured by, so a change to it (a proposal, a
3
+ * merge) says whether it made the model better. Each measure:
4
+ * `{ key, label, value, better: 'lower'|'higher', format?: 'count'|'share'|'score' }`
5
+ * (a share is 0..1; a value of null is not measured yet).
6
+ *
7
+ * Every model with a checker is measured by its findings — warnings counted
8
+ * apart (`Benchmark.findings`); a model with more to say gives its own
9
+ * `benchmark()` (a data model its Scorecard, a requirements model how many
10
+ * stories say when they are done and trace to something), starting from those.
11
+ */
12
+ export class Benchmark {
13
+ /** A model's measures: its own benchmark(), else its checker's findings, else none. */
14
+ static of(model) {
15
+ if (typeof model?.benchmark === 'function') return model.benchmark();
16
+ return Benchmark.findings(model);
17
+ }
18
+
19
+ /** The check's warnings and all its findings, each fewer the better. */
20
+ static findings(model) {
21
+ if (typeof model?.checker !== 'function') return [];
22
+ const found = model.checker().run();
23
+ return [
24
+ { key: 'warnings', label: 'Check warnings', value: found.filter(f => f.severity === 'warning').length, better: 'lower', format: 'count' },
25
+ { key: 'findings', label: 'Check findings', value: found.length, better: 'lower', format: 'count' },
26
+ ];
27
+ }
28
+
29
+ /**
30
+ * Two models' measures side by side, matched by key: `{key, label, format,
31
+ * before, after, change}` — change 'better', 'worse', 'same', or null when
32
+ * either is not measured.
33
+ */
34
+ static compare(before, after) {
35
+ const then = new Map(before.map(m => [m.key, m]));
36
+ return after.filter(m => then.has(m.key)).map(m => {
37
+ const was = then.get(m.key).value, now = m.value;
38
+ const change = was == null || now == null ? null
39
+ : was === now ? 'same'
40
+ : (now < was) === (m.better === 'lower') ? 'better' : 'worse';
41
+ return { key: m.key, label: m.label, format: m.format ?? 'count', before: was, after: now, change };
42
+ });
43
+ }
44
+
45
+ /** A measure's value as it reads ('3', '50%', '72 of 100', '—'). */
46
+ static format(value, format = 'count') {
47
+ if (value == null) return '—';
48
+ if (format === 'share') return `${Math.round(value * 100)}%`;
49
+ if (format === 'score') return `${value} of 100`;
50
+ return String(value);
51
+ }
52
+
53
+ /** A comparison in a line: "Check findings 7 → 3 (better); …" — what did not change left out unless nothing did. */
54
+ static text(rows) {
55
+ const moved = rows.filter(r => r.change !== 'same');
56
+ const shown = moved.length ? moved : rows;
57
+ return shown.map(r => `${r.label} ${Benchmark.format(r.before, r.format)} → ${Benchmark.format(r.after, r.format)}${r.change && r.change !== 'same' ? ` (${r.change})` : ''}`).join('; ');
58
+ }
59
+ }