@peter.prib/renderengine 0.2.6 → 0.2.7

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 (49) hide show
  1. package/README.md +4 -0
  2. package/REFERENCE.md +180 -2
  3. package/dist/renderengine-workspace.umd.js +8704 -5310
  4. package/dist/renderengine.umd.js +5675 -3398
  5. package/examples/archimate.html +68 -66
  6. package/examples/data-model.html +223 -13
  7. package/package.json +1 -1
  8. package/src/bian.js +182 -0
  9. package/src/charting/DiagramEditor.js +5 -26
  10. package/src/charting/DiagramTypeRegistry.js +8 -2
  11. package/src/charting/ModelCheckDialog.js +63 -0
  12. package/src/charting/ModelChecker.js +26 -0
  13. package/src/charting/ViewTabStrip.js +94 -81
  14. package/src/charting/datamodel/BianLandscapeReader.js +185 -0
  15. package/src/charting/datamodel/BianSemanticApiReader.js +183 -0
  16. package/src/charting/datamodel/DataModel.js +356 -10
  17. package/src/charting/datamodel/DataModelChecker.js +92 -0
  18. package/src/charting/datamodel/DataModelCompared.js +127 -0
  19. package/src/charting/datamodel/DataModelFileFormat.js +33 -0
  20. package/src/charting/datamodel/DataModelPerspective.js +45 -0
  21. package/src/charting/datamodel/DataType.js +108 -0
  22. package/src/charting/datamodel/EnumerationType.js +51 -0
  23. package/src/charting/datamodel/ServiceDomain.js +177 -0
  24. package/src/charting/datamodel/ServiceLandscape.js +104 -0
  25. package/src/charting/datamodel/StructuredType.js +63 -0
  26. package/src/charting/datamodel/index.js +10 -0
  27. package/src/charting/diagramModel/MergeModelCommand.js +43 -37
  28. package/src/charting/diagramModel/OpenViewList.js +75 -0
  29. package/src/charting/diagramModel/ViewSetChecker.js +22 -34
  30. package/src/charting/diagrams/DataModelRelationshipStrategy.js +12 -4
  31. package/src/charting/diagrams/EdgeGeometry.js +578 -553
  32. package/src/charting/diagrams/GraphDiagram.js +11 -1
  33. package/src/charting/diagrams/NodeGeometry.js +169 -158
  34. package/src/charting/glyphs/PersonGlyph.js +24 -23
  35. package/src/index.js +2666 -2664
  36. package/src/workspace/EditorShell.js +166 -67
  37. package/src/workspace/PanelLayout.js +56 -0
  38. package/src/workspace/layout/TabsNode.js +205 -191
  39. package/src/workspace/parts/ArchimateCanvasPart.js +5 -7
  40. package/src/workspace/parts/DataModelPart.js +339 -29
  41. package/src/workspace/parts/DiagramEditorPart.js +2 -8
  42. package/src/workspace/parts/EditorPart.js +461 -407
  43. package/src/workspace/parts/ModelFile.js +216 -187
  44. package/src/workspace/parts/ModelsPart.js +483 -192
  45. package/src/workspace/storage/BackupFolder.js +145 -0
  46. package/src/workspace/storage/KeyValueStore.js +92 -0
  47. package/src/workspace/storage/ModelLibrary.js +65 -0
  48. package/src/workspace/workArea.js +417 -413
  49. package/src/yaml/YamlReader.js +372 -0
@@ -1,8 +1,19 @@
1
1
  import { DataModelPerspective } from './DataModelPerspective.js';
2
2
  import { DataModelContext } from './DataModelContext.js';
3
+ import { DataType } from './DataType.js';
4
+ import './EnumerationType.js';
5
+ import './StructuredType.js';
3
6
  import { LevelSchema } from './LevelSchema.js';
4
7
  import { LevelMapping } from './LevelMapping.js';
5
8
  import { NodeGeometry } from '../diagrams/NodeGeometry.js';
9
+ import { ModelComparison } from '../diagramModel/ModelComparison.js';
10
+ import { DataModelChecker } from './DataModelChecker.js';
11
+ import { CommandHistory } from '../CommandHistory.js';
12
+ import { MergeModelCommand } from '../diagramModel/MergeModelCommand.js';
13
+ import { ServiceLandscape } from './ServiceLandscape.js';
14
+ import {
15
+ LevelEntitiesCompared, LevelRelationshipsCompared, MappingsCompared, ContextsCompared, PerspectivesCompared, DataTypesCompared,
16
+ } from './DataModelCompared.js';
6
17
 
7
18
  /** The levels, outermost first: each realised by the next. */
8
19
  const LEVELS = Object.freeze(['conceptual', 'logical', 'physical']);
@@ -21,6 +32,10 @@ const LEVELS = Object.freeze(['conceptual', 'logical', 'physical']);
21
32
  * The older form, one entity per thing with each level's detail on it
22
33
  * (`{ entities, relationships }`, as dataModelDiagram takes them), is read as
23
34
  * three levels mapped one to one.
35
+ *
36
+ * It knows whether it changed since it was saved (`edited()`, `markSaved()`,
37
+ * `isDirty()`, `onEdit(fn)`), as a ViewSetModel does, so a part can show it,
38
+ * keep it and ask before it is lost.
24
39
  */
25
40
  export class DataModel {
26
41
  /** The levels, outermost first. */
@@ -29,6 +44,45 @@ export class DataModel {
29
44
  /** The view of how the levels map (a `level` for view()). */
30
45
  static MAPPING = 'mapping';
31
46
 
47
+ /** The view of its service domains in their business areas and domains (a `level` for view()). */
48
+ static LANDSCAPE = 'landscape';
49
+
50
+ /** The view of its data types (a `level` for view()). */
51
+ static DATA_TYPES = 'datatypes';
52
+
53
+ /** How many data type boxes side by side in the data types view, and how far apart. */
54
+ static DATA_TYPE_COLUMNS = 4;
55
+ static DATA_TYPE_SPACING = { x: 260, gap: 40 };
56
+
57
+ /**
58
+ * The views that are no level of the schema, each one way to read the
59
+ * whole model: its name (a `level` for view()), label and description,
60
+ * whether a model has it to show (`offered`), whether a box dragged in it
61
+ * keeps its place (`keepsPlaces`), and what it draws.
62
+ */
63
+ static OTHER_VIEWS = Object.freeze([
64
+ Object.freeze({
65
+ name: 'mapping', label: 'Mapping', description: 'The levels side by side, joined where one realises another',
66
+ offered: () => true, keepsPlaces: true,
67
+ view: (model, choice) => model.mappingView(choice),
68
+ }),
69
+ Object.freeze({
70
+ name: 'datatypes', label: 'Data types', description: 'Its data types: enumerations with their values, structures with their fields',
71
+ offered: (model) => model.dataTypes.length > 0, keepsPlaces: false,
72
+ view: (model) => model.dataTypesView(),
73
+ }),
74
+ Object.freeze({
75
+ name: 'landscape', label: 'Service landscape', description: 'The service domains in their business areas and business domains (BIAN)',
76
+ offered: (model) => model.serviceDomains().length > 0, keepsPlaces: false,
77
+ view: (model) => ServiceLandscape.view(model.serviceDomains()),
78
+ }),
79
+ ]);
80
+
81
+ /** One of OTHER_VIEWS by name, or null for a level of the schema. */
82
+ static otherView(name) {
83
+ return DataModel.OTHER_VIEWS.find(v => v.name === name) ?? null;
84
+ }
85
+
32
86
  /** The room between the mapping view's columns, and between the boxes in one. */
33
87
  static MAPPING_COLUMN = 380;
34
88
  static MAPPING_GAP = 40;
@@ -46,10 +100,17 @@ export class DataModel {
46
100
  * (the conceptual level's is the rule's own x, y)
47
101
  * @param {Array<Object|DataModelPerspective>} [spec.perspectives]
48
102
  * @param {Array<Object|DataModelContext>} [spec.contexts]
103
+ * @param {Array<Object|DataType>} [spec.dataTypes] - Its data types (DataType and its kinds)
49
104
  * @param {Array<Object>} [spec.entities] - The older, one-entity form (levelsFromCombined)
50
105
  * @param {Array<Object>} [spec.relationships]
106
+ * @param {string} [spec.name] - What the model is called
51
107
  */
52
108
  constructor(spec = {}) {
109
+ /** What the model is called. */
110
+ this.name = spec.name ?? '';
111
+ this._version = 0;
112
+ this._saved = 0;
113
+ this._editListeners = [];
53
114
  const { levels, mappings = [], mappingLayout = {}, noteLayout = {} } = spec.levels ? spec : DataModel.levelsFromCombined(spec);
54
115
  this.levels = Object.fromEntries(LEVELS.map(level => [level, new LevelSchema(level, levels?.[level] ?? {})]));
55
116
  this.mappings = LEVELS.slice(1).map((to, i) => {
@@ -59,8 +120,10 @@ export class DataModel {
59
120
  });
60
121
  this.mappingLayout = { ...mappingLayout };
61
122
  this.noteLayout = Object.fromEntries(Object.entries(noteLayout).map(([level, at]) => [level, { ...at }]));
62
- this.perspectives = (spec.perspectives ?? []).map(p => (p instanceof DataModelPerspective ? p : new DataModelPerspective(p)));
123
+ this.perspectives = (spec.perspectives ?? []).map(p => (p instanceof DataModelPerspective ? p : DataModelPerspective.from(p)));
63
124
  this.contexts = (spec.contexts ?? []).map(c => (c instanceof DataModelContext ? c : new DataModelContext(c)));
125
+ /** Its data types: the types attributes name (`attribute.type`, a data type's id or name). */
126
+ this.dataTypes = (spec.dataTypes ?? []).map(t => (t instanceof DataType ? t : DataType.from(t)));
64
127
  }
65
128
 
66
129
  /**
@@ -85,6 +148,64 @@ export class DataModel {
85
148
  return { levels, mappings: [oneToOne('conceptual', 'logical'), oneToOne('logical', 'physical')] };
86
149
  }
87
150
 
151
+ // ===== Compared and merged =====
152
+
153
+ /**
154
+ * What two data models are compared on and merged by, what is needed first:
155
+ * each level's entities, then each level's relationships, the mappings,
156
+ * contexts and perspectives.
157
+ */
158
+ comparedKinds() {
159
+ return [
160
+ ...LEVELS.map(level => new LevelEntitiesCompared(level)),
161
+ ...LEVELS.map(level => new LevelRelationshipsCompared(level)),
162
+ new MappingsCompared(),
163
+ new ContextsCompared(),
164
+ new DataTypesCompared(),
165
+ new PerspectivesCompared(),
166
+ ];
167
+ }
168
+
169
+ /** How this model differs from another (ModelComparison), which can merge the other's into it. */
170
+ compareWith(other) {
171
+ return new ModelComparison(this, other);
172
+ }
173
+
174
+ /** Its undo history: a merge is one step on it. */
175
+ get history() {
176
+ return (this._history ??= new CommandHistory());
177
+ }
178
+
179
+ // ===== Changed since saved =====
180
+
181
+ /** Something changed (a perspective or context, a box or note moved): listeners hear of it. */
182
+ edited() {
183
+ this._version++;
184
+ this._emitEdit();
185
+ }
186
+
187
+ /** This state is the saved one (a file opened or saved). */
188
+ markSaved() {
189
+ this._saved = this._version;
190
+ this._emitEdit();
191
+ }
192
+
193
+ /** Whether it changed since markSaved. */
194
+ isDirty() {
195
+ return this._version !== this._saved;
196
+ }
197
+
198
+ /** Call `fn(model)` after every change (and markSaved). Returns an unsubscribe function. */
199
+ onEdit(fn) {
200
+ this._editListeners.push(fn);
201
+ return () => { this._editListeners = this._editListeners.filter(l => l !== fn); };
202
+ }
203
+
204
+ /** @private */
205
+ _emitEdit() {
206
+ for (const fn of this._editListeners) fn(this);
207
+ }
208
+
88
209
  /** A level's schema (LevelSchema). */
89
210
  level(name) {
90
211
  const schema = this.levels[name];
@@ -123,6 +244,16 @@ export class DataModel {
123
244
  return [...ids];
124
245
  }
125
246
 
247
+ /** The perspectives (service domains) that own a business object (a logical entity). */
248
+ ownersOf(entityId) {
249
+ return this.perspectives.filter(p => p.ownedEntities().includes(entityId));
250
+ }
251
+
252
+ /** The checks this model is held to (DataModelChecker): ownership, service domains, mappings. */
253
+ checker() {
254
+ return new DataModelChecker(this);
255
+ }
256
+
126
257
  /** A perspective by id, or null (the whole schema). */
127
258
  perspective(id) {
128
259
  return this.perspectives.find(p => p.id === id) ?? null;
@@ -148,11 +279,14 @@ export class DataModel {
148
279
  * @returns {{entities, relationships, annotations, links}}
149
280
  */
150
281
  view({ perspective = null, context = null, level = 'conceptual' } = {}) {
151
- if (level === DataModel.MAPPING) return this.mappingView({ perspective, context });
282
+ const other = DataModel.otherView(level);
283
+ if (other) return other.view(this, { perspective, context });
152
284
  const lens = this.perspective(perspective);
153
285
  const meaning = this.context(context);
154
286
  const schema = this.level(level);
155
287
  const allowed = this.realising(level, lens ? this.concepts.filter(c => lens.shows(c.id)).map(c => c.id) : null);
288
+ // A kind of perspective may show more at a level (a service domain: its control record and qualifiers).
289
+ for (const id of lens?.alsoShows(level) ?? []) allowed?.add(id);
156
290
  const shown = schema.entities.filter(e => !allowed || allowed.has(e.id));
157
291
  const ids = new Set(shown.map(e => e.id));
158
292
  const conceptName = (id) => {
@@ -164,31 +298,41 @@ export class DataModel {
164
298
  const name = level === 'conceptual' && lens ? lens.nameOf(entity) : entity.name;
165
299
  const meanings = meaning ? concepts.map(c => meaning.meaningOf(c)).filter(Boolean) : [];
166
300
  const rules = meaning ? [...new Set(concepts.flatMap(c => meaning.constraintsOn(c).map(r => r.text)))] : [];
301
+ const owners = level === 'logical' ? this.ownersOf(entity.id).map(o => o.name) : [];
302
+ const typed = this.typedAttributes(entity);
303
+ const iso = this.iso20022Of(entity);
167
304
  const details = {
168
305
  ...entity.details,
306
+ ...(owners.length && { 'owned by': owners.join(', ') }),
307
+ ...(typed.length && { 'data types': typed.map(({ attribute, type }) => `${attribute.name}: ${type.summary()}`) }),
308
+ ...(iso.length && { 'ISO 20022': iso }),
169
309
  ...(name !== entity.name && { schema: entity.name }),
170
310
  ...(level !== 'conceptual' && concepts.length && { concepts: concepts.map(conceptName).join(', ') }),
171
311
  ...(meaning && { context: meaning.name }),
172
312
  ...(rules.length && { rules }),
173
313
  };
174
314
  const { attributes, ...rest } = entity;
315
+ const mark = lens?.decorate(entity, level) ?? {};
316
+ const allDetails = { ...details, ...mark.details };
175
317
  return {
176
318
  ...rest,
177
319
  name,
320
+ ...(mark.color && { color: mark.color }),
178
321
  description: meanings.length ? meanings.join(' ') : entity.description,
179
322
  ...(level !== 'conceptual' && { [level]: { attributes: attributes ?? [], ...(level === 'physical' && { tableName: entity.name }) } }),
180
- ...(Object.keys(details).length && { details }),
323
+ ...(Object.keys(allDetails).length && { details: allDetails }),
181
324
  };
182
325
  });
183
326
  const relationships = schema.relationships
184
327
  .filter(r => ids.has(r.source) && ids.has(r.target))
185
328
  .map(r => (lens && level === 'conceptual' ? { ...r, name: lens.relationshipNameOf(r) } : r));
186
- const notes = (meaning?.constraints ?? [])
187
- .map(c => {
329
+ const notes = [
330
+ ...(meaning?.constraints ?? []).map(c => {
188
331
  const on = this.realising(level, c.applies) ?? new Set(c.applies);
189
332
  return { id: c.id, text: c.text, attachTo: [...on].filter(id => ids.has(id)), ...this.notePlace(level, c) };
190
- })
191
- .filter(a => a.attachTo.length);
333
+ }),
334
+ ...(lens?.notes(level) ?? []).map(n => ({ ...n, attachTo: n.attachTo.filter(id => ids.has(id)), ...this.notePlace(level, n) })),
335
+ ].filter(a => a.attachTo.length);
192
336
  // A note with no place on this level goes in a row below every box, so it covers none.
193
337
  const below = Math.max(0, ...shown.map(e => (Number.isFinite(e.y) ? e.y : 0))) + DataModel.NOTE_ROW;
194
338
  let free = 0;
@@ -241,10 +385,203 @@ export class DataModel {
241
385
 
242
386
  // ===== Perspectives and contexts of one's own =====
243
387
 
388
+ // ===== Data types =====
389
+
390
+ /** A data type by its id, else its name (any case), or null. */
391
+ dataType(ref) {
392
+ if (!ref) return null;
393
+ const name = String(ref).toLowerCase();
394
+ return this.dataTypes.find(t => t.id === ref) ?? this.dataTypes.find(t => t.name.toLowerCase() === name) ?? null;
395
+ }
396
+
397
+ /** An attribute's data type (its `type` naming one), or null for a plain type ('VARCHAR(20)'). */
398
+ typeOf(attribute) {
399
+ return this.dataType(attribute?.type);
400
+ }
401
+
402
+ /** An entity's attributes that have a data type: `[{ attribute, type }]`. */
403
+ typedAttributes(entity) {
404
+ return (entity.attributes ?? []).map(attribute => ({ attribute, type: this.typeOf(attribute) })).filter(a => a.type);
405
+ }
406
+
407
+ /**
408
+ * What an entity says of ISO 20022, a line each: its own type, then each
409
+ * attribute's (its own `iso20022`, else its data type's).
410
+ */
411
+ iso20022Of(entity) {
412
+ return [
413
+ ...(entity.iso20022 ? [`${entity.name}: ${entity.iso20022}`] : []),
414
+ ...(entity.attributes ?? []).map(a => [a.name, a.iso20022 || this.typeOf(a)?.iso20022]).filter(([, t]) => t).map(([n, t]) => `${n}: ${t}`),
415
+ ];
416
+ }
417
+
418
+ /** Where a data type is used: `{ attributes: [{ level, entity, attribute }], types: [DataType] }`. */
419
+ usesOf(id) {
420
+ const type = this.dataType(id);
421
+ if (!type) return { attributes: [], types: [] };
422
+ const names = (ref) => this.dataType(ref) === type;
423
+ return {
424
+ attributes: LEVELS.flatMap(level => this.level(level).entities.flatMap(entity => (entity.attributes ?? [])
425
+ .filter(a => names(a.type)).map(attribute => ({ level, entity, attribute })))),
426
+ types: this.dataTypes.filter(t => t !== type && t.uses().some(names)),
427
+ };
428
+ }
429
+
430
+ /** Add a data type (a DataType spec, its `kind` saying which; an id is made from its name when it has none). */
431
+ addDataType(spec) {
432
+ const type = DataType.from({ ...spec, id: spec.id ?? this._freshId(spec.name, this.dataTypes) });
433
+ this.dataTypes.push(type);
434
+ this.edited();
435
+ return type;
436
+ }
437
+
438
+ /** Change a data type (its id and kind stay). */
439
+ updateDataType(id, spec) {
440
+ const i = this.dataTypes.findIndex(t => t.id === id);
441
+ if (i < 0) return null;
442
+ this.dataTypes[i] = DataType.from({ ...spec, kind: this.dataTypes[i].constructor.kind, id });
443
+ this.edited();
444
+ return this.dataTypes[i];
445
+ }
446
+
447
+ removeDataType(id) {
448
+ this.dataTypes = this.dataTypes.filter(t => t.id !== id);
449
+ this.edited();
450
+ }
451
+
452
+ /**
453
+ * The data types view: each a box with a row per value or field (as a
454
+ * table), enumerations and structures coloured apart, in a grid; a link
455
+ * from a structure to each data type its fields are.
456
+ */
457
+ dataTypesView() {
458
+ const { x: across, gap } = DataModel.DATA_TYPE_SPACING;
459
+ const columns = DataModel.DATA_TYPE_COLUMNS;
460
+ const color = { enumeration: '#fef3c7', structure: '#e0f2fe', type: '#f3f4f6' };
461
+ const heights = this.dataTypes.map(t => DataModel.boxHeight('datatypes', { datatypes: { attributes: t.rows() } }));
462
+ const tops = [];
463
+ this.dataTypes.forEach((t, i) => {
464
+ const above = i - columns;
465
+ tops.push(above < 0 ? 60 : tops[above] + heights[above] + gap);
466
+ });
467
+ const entities = this.dataTypes.map((t, i) => ({
468
+ id: `type:${t.id}`, name: `${t.constructor === DataType ? '' : `«${t.constructor.kindLabel.toLowerCase()}» `}${t.name}`,
469
+ level: 'datatypes', datatypes: { attributes: t.rows().length ? t.rows() : [{ name: t.basedOn || '—' }] },
470
+ x: 140 + (i % columns) * across, y: tops[i] + heights[i] / 2,
471
+ color: color[t.constructor.kind] ?? color.type,
472
+ description: t.description,
473
+ details: { kind: t.constructor.kindLabel, ...(t.iso20022 && { 'ISO 20022': t.iso20022 }), used: this._usedSummary(t) },
474
+ }));
475
+ const links = this.dataTypes.flatMap(t => [...new Set(t.uses().map(ref => this.dataType(ref)).filter(u => u && u !== t))]
476
+ .map(u => ({ id: `uses:${t.id}->${u.id}`, source: `type:${t.id}`, target: `type:${u.id}` })));
477
+ return { entities, relationships: [], annotations: [], links, notation: 'crowsfoot' };
478
+ }
479
+
480
+ /** Where a data type is used, in a few words. @private */
481
+ _usedSummary(type) {
482
+ const { attributes, types } = this.usesOf(type.id);
483
+ const parts = [
484
+ ...(attributes.length ? [`${attributes.length} attribute${attributes.length === 1 ? '' : 's'}`] : []),
485
+ ...(types.length ? [`by ${types.map(t => t.name).join(', ')}`] : []),
486
+ ];
487
+ return parts.join('; ') || 'nowhere';
488
+ }
489
+
490
+ /** Its service domains (the perspectives that are ServiceDomains). */
491
+ serviceDomains() {
492
+ return this.perspectives.filter(p => p.constructor.kind === 'service-domain');
493
+ }
494
+
495
+ /**
496
+ * Service domains read from elsewhere (BianLandscapeReader), as one undo
497
+ * step (bringCommand). Null when there is nothing to bring.
498
+ * @param {Array<Object>} specs - ServiceDomain specs
499
+ * @param {string} [source] - Where they were read from, for the undo label
500
+ * @returns {MergeModelCommand|null}
501
+ */
502
+ serviceDomainsCommand(specs, source = 'a service landscape') {
503
+ return this.bringCommand({ serviceDomains: specs },
504
+ `Read ${specs.length} service domain${specs.length === 1 ? '' : 's'} from ${source}`);
505
+ }
506
+
507
+ /**
508
+ * Things read from elsewhere (a BIAN landscape, a BIAN semantic API)
509
+ * brought in as one undo step (a MergeModelCommand). A logical entity
510
+ * already here (by id, else by name) stays as it is, and what was read
511
+ * refers to it; so does a data type already here by name. Only the data
512
+ * types what is brought needs come (those its new entities' attributes
513
+ * are, and theirs in turn). A service domain already here (by id, else by
514
+ * name) keeps all it has, taking its business area and domain from what
515
+ * was read, its asset, pattern and control record where it has none, and
516
+ * the behaviour qualifiers it lacks (by name or entity); one that would
517
+ * come out the same is left alone. Null when there is nothing to bring.
518
+ * @param {Object} read
519
+ * @param {Array<Object>} [read.entities] - Logical entities
520
+ * @param {Array<Object>} [read.relationships] - Logical relationships between them
521
+ * @param {Array<Object>} [read.dataTypes] - DataType specs
522
+ * @param {Array<Object>} [read.serviceDomains] - ServiceDomain specs
523
+ * @param {string} label - The undo label
524
+ * @returns {MergeModelCommand|null}
525
+ */
526
+ bringCommand({ entities = [], relationships = [], dataTypes = [], serviceDomains = [] }, label) {
527
+ const logical = this.level('logical');
528
+ const sameName = (list, name) => list.find(x => String(x.name).toLowerCase() === String(name).toLowerCase());
529
+ // What was read refers to an entity already here by that one's id.
530
+ const idHere = new Map(entities.map(e => [e.id, (logical.entity(e.id) ?? sameName(logical.entities, e.name))?.id ?? null]));
531
+ const as = (id) => idHere.get(id) ?? id;
532
+ const newEntities = entities.filter(e => !idHere.get(e.id));
533
+ const newRelationships = relationships.map(r => ({ ...r, source: as(r.source), target: as(r.target) }))
534
+ .filter(r => !logical.relationships.some(h => h.source === r.source && h.target === r.target && (h.name ?? '') === (r.name ?? '')));
535
+ // The data types the new entities need, then the ones those need; none already here by id or name.
536
+ const readType = (ref) => dataTypes.find(t => t.id === ref) ?? sameName(dataTypes, ref);
537
+ const needed = new Set();
538
+ const need = (ref) => {
539
+ const t = readType(ref);
540
+ if (!t || needed.has(t) || this.dataType(t.id) || sameName(this.dataTypes, t.name)) return;
541
+ needed.add(t);
542
+ DataType.from(t).uses().forEach(need);
543
+ };
544
+ newEntities.flatMap(e => e.attributes ?? []).forEach(a => need(a.type));
545
+ const newTypes = dataTypes.filter(t => needed.has(t));
546
+ const domains = serviceDomains.map(spec => {
547
+ const read = { ...spec, controlRecord: spec.controlRecord ? as(spec.controlRecord) : spec.controlRecord, qualifiers: (spec.qualifiers ?? []).map(q => ({ ...q, ...(q.entity && { entity: as(q.entity) }) })) };
548
+ const here = this.perspective(read.id) ?? this.perspectives.find(p => p.name.toLowerCase() === String(read.name).toLowerCase());
549
+ if (!here) return read;
550
+ const kept = here.toJSON();
551
+ return {
552
+ ...kept, kind: read.kind,
553
+ businessArea: read.businessArea || kept.businessArea || '', businessDomain: read.businessDomain || kept.businessDomain || '',
554
+ asset: kept.asset || read.asset || '', pattern: here.functionalPattern ? kept.pattern : (read.pattern || kept.pattern || ''),
555
+ controlRecord: kept.controlRecord || read.controlRecord || null,
556
+ qualifiers: [...kept.qualifiers, ...read.qualifiers.filter(q => !kept.qualifiers.some(k =>
557
+ k.name.toLowerCase() === String(q.name).toLowerCase() || (q.entity && k.entity === q.entity)))],
558
+ };
559
+ }).filter(spec => {
560
+ // One here that would come out the same is nothing to bring.
561
+ const here = this.perspective(spec.id);
562
+ return !here || JSON.stringify(DataModelPerspective.from(spec).toJSON()) !== JSON.stringify(here.toJSON());
563
+ });
564
+ if (!newEntities.length && !newRelationships.length && !newTypes.length && !domains.length) return null;
565
+ const from = new DataModel({
566
+ name: label,
567
+ levels: { logical: { entities: newEntities, relationships: newRelationships } },
568
+ dataTypes: newTypes, perspectives: domains,
569
+ });
570
+ const kinds = new Map(this.comparedKinds().map(k => [k.key, k]));
571
+ const steps = [
572
+ ...from.level('logical').entities.map(item => ({ kind: kinds.get('logical-entity'), item })),
573
+ ...from.level('logical').relationships.map(item => ({ kind: kinds.get('logical-relationship'), item })),
574
+ ...from.dataTypes.map(item => ({ kind: kinds.get('data-type'), item })),
575
+ ...from.perspectives.map(item => ({ kind: kinds.get('perspective'), item })),
576
+ ];
577
+ return new MergeModelCommand(this, from, steps, label);
578
+ }
579
+
244
580
  /** Add a perspective (DataModelPerspective spec; an id is made from its name when it has none). */
245
581
  addPerspective(spec) {
246
- const perspective = new DataModelPerspective({ ...spec, id: spec.id ?? this._freshId(spec.name, this.perspectives) });
582
+ const perspective = DataModelPerspective.from({ ...spec, id: spec.id ?? this._freshId(spec.name, this.perspectives) });
247
583
  this.perspectives.push(perspective);
584
+ this.edited();
248
585
  return perspective;
249
586
  }
250
587
 
@@ -252,17 +589,21 @@ export class DataModel {
252
589
  updatePerspective(id, spec) {
253
590
  const i = this.perspectives.findIndex(p => p.id === id);
254
591
  if (i < 0) return null;
255
- return (this.perspectives[i] = new DataModelPerspective({ ...spec, id }));
592
+ this.perspectives[i] = DataModelPerspective.from({ kind: this.perspectives[i].constructor.kind, ...spec, id });
593
+ this.edited();
594
+ return this.perspectives[i];
256
595
  }
257
596
 
258
597
  removePerspective(id) {
259
598
  this.perspectives = this.perspectives.filter(p => p.id !== id);
599
+ this.edited();
260
600
  }
261
601
 
262
602
  /** Add a context (DataModelContext spec; an id is made from its name when it has none). */
263
603
  addContext(spec) {
264
604
  const context = new DataModelContext({ ...spec, id: spec.id ?? this._freshId(spec.name, this.contexts) });
265
605
  this.contexts.push(context);
606
+ this.edited();
266
607
  return context;
267
608
  }
268
609
 
@@ -270,13 +611,16 @@ export class DataModel {
270
611
  updateContext(id, spec) {
271
612
  const i = this.contexts.findIndex(c => c.id === id);
272
613
  if (i < 0) return null;
273
- return (this.contexts[i] = new DataModelContext({ ...spec, id }));
614
+ this.contexts[i] = new DataModelContext({ ...spec, id });
615
+ this.edited();
616
+ return this.contexts[i];
274
617
  }
275
618
 
276
619
  /** Remove a context; a perspective read in it is then read in none. */
277
620
  removeContext(id) {
278
621
  this.contexts = this.contexts.filter(c => c.id !== id);
279
622
  for (const p of this.perspectives) if (p.context === id) p.context = null;
623
+ this.edited();
280
624
  }
281
625
 
282
626
  /** An id from a name, not yet among `taken`. @private */
@@ -289,11 +633,13 @@ export class DataModel {
289
633
 
290
634
  toJSON() {
291
635
  return {
636
+ name: this.name,
292
637
  levels: Object.fromEntries(LEVELS.map(level => [level, this.level(level).toJSON()])),
293
638
  mappings: this.mappings.map(m => m.toJSON()),
294
639
  mappingLayout: { ...this.mappingLayout },
295
640
  noteLayout: Object.fromEntries(Object.entries(this.noteLayout).map(([level, at]) => [level, { ...at }])),
296
641
  perspectives: this.perspectives.map(p => p.toJSON()), contexts: this.contexts.map(c => c.toJSON()),
642
+ ...(this.dataTypes.length && { dataTypes: this.dataTypes.map(t => t.toJSON()) }),
297
643
  };
298
644
  }
299
645
  }
@@ -0,0 +1,92 @@
1
+ import { ModelChecker } from '../ModelChecker.js';
2
+ import { BianNotation } from '../../bian.js';
3
+
4
+ /**
5
+ * DataModelChecker — checks over a DataModel (a ModelChecker): each business
6
+ * object (a logical entity) owned by one service domain, as BIAN has it
7
+ * (none, once there are service domains, is worth a look; more than one is
8
+ * wrong); service domains complete (a control record, qualifiers that are
9
+ * entities, a pattern BIAN has); mappings joining entities that are there;
10
+ * data types sound (an enumeration with values, each once; one name a type)
11
+ * and used. Each finding names what it is about (`kind`: 'entity',
12
+ * 'perspective', 'mapping', 'data-type') and, for an entity, its `level`.
13
+ */
14
+ export class DataModelChecker extends ModelChecker {
15
+ checks() {
16
+ return [this.ownedTwice, this.unowned, this.serviceDomains, this.mappings, this.dataTypes];
17
+ }
18
+
19
+ /** The service domains (perspectives that own business objects). @private */
20
+ _owners() {
21
+ return this.model.perspectives.filter(p => p.ownedEntities().length);
22
+ }
23
+
24
+ /** A business object owned by more than one service domain. */
25
+ ownedTwice() {
26
+ return this.model.level('logical').entities
27
+ .map(e => [e, this.model.ownersOf(e.id)])
28
+ .filter(([, owners]) => owners.length > 1)
29
+ .map(([e, owners]) => ({
30
+ code: 'owned-twice', severity: 'warning', kind: 'entity', level: 'logical', ids: [e.id],
31
+ message: `"${e.name}" is owned by ${owners.length} service domains (${owners.map(o => o.name).join(', ')}); BIAN gives a business object one`,
32
+ }));
33
+ }
34
+
35
+ /** A business object no service domain owns, once some own business objects. */
36
+ unowned() {
37
+ if (!this._owners().length) return [];
38
+ return this.model.level('logical').entities
39
+ .filter(e => !this.model.ownersOf(e.id).length)
40
+ .map(e => ({
41
+ code: 'unowned', severity: 'info', kind: 'entity', level: 'logical', ids: [e.id],
42
+ message: `"${e.name}" is owned by no service domain`,
43
+ }));
44
+ }
45
+
46
+ /** A service domain with no control record, a pattern BIAN lacks, or a qualifier that is no entity. */
47
+ serviceDomains() {
48
+ const logical = this.model.level('logical');
49
+ return this._serviceDomains().flatMap(sd => [
50
+ ...(!sd.controlRecord ? [{ code: 'no-control-record', severity: 'warning', message: `Service domain "${sd.name}" has no control record` }]
51
+ : !logical.entity(sd.controlRecord) ? [{ code: 'control-record-missing', severity: 'warning', message: `Service domain "${sd.name}": its control record "${sd.controlRecord}" is no logical entity` }]
52
+ : []),
53
+ ...(!BianNotation.functionalPattern(sd.pattern) ? [{ code: 'unknown-pattern', severity: 'warning', message: `Service domain "${sd.name}": "${sd.pattern}" is no BIAN functional pattern` }] : []),
54
+ ...sd.qualifiers.filter(q => !q.entity || !logical.entity(q.entity)).map(q => ({
55
+ code: 'qualifier-entity-missing', severity: 'info', message: `Service domain "${sd.name}": behaviour qualifier "${q.name}" is no logical entity`,
56
+ })),
57
+ ].map(f => ({ ...f, kind: 'perspective', ids: [sd.id] })));
58
+ }
59
+
60
+ /** The service domains among the perspectives. @private */
61
+ _serviceDomains() {
62
+ return this.model.perspectives.filter(p => p.constructor.kind === 'service-domain');
63
+ }
64
+
65
+ /** An enumeration with no values or a value twice, two data types of one name, a data type nothing uses. */
66
+ dataTypes() {
67
+ const types = this.model.dataTypes;
68
+ const finding = (t, code, severity, message) => ({ code, severity, kind: 'data-type', ids: [t.id], message });
69
+ return types.flatMap(t => {
70
+ const values = (t.values ?? []).map(v => v.name);
71
+ const twice = [...new Set(values.filter((v, i) => values.indexOf(v) !== i))];
72
+ const sameName = types.filter(o => o !== t && o.name.toLowerCase() === t.name.toLowerCase());
73
+ const { attributes, types: users } = this.model.usesOf(t.id);
74
+ return [
75
+ ...(t.values && !values.length ? [finding(t, 'enumeration-empty', 'warning', `Enumeration "${t.name}" has no values`)] : []),
76
+ ...(twice.length ? [finding(t, 'value-twice', 'warning', `Enumeration "${t.name}" has ${twice.map(v => `"${v}"`).join(', ')} more than once`)] : []),
77
+ ...(sameName.length && types.indexOf(t) < types.indexOf(sameName[0]) ? [finding(t, 'type-name-twice', 'warning', `${sameName.length + 1} data types are called "${t.name}": an attribute naming it gets the first`)] : []),
78
+ ...(!attributes.length && !users.length ? [finding(t, 'type-unused', 'info', `Data type "${t.name}" is used by no attribute or other data type`)] : []),
79
+ ];
80
+ });
81
+ }
82
+
83
+ /** A mapping pair joining an entity that is not there. */
84
+ mappings() {
85
+ return this.model.mappings.flatMap(m => m.pairs
86
+ .filter(([a, b]) => !this.model.level(m.from).entity(a) || !this.model.level(m.to).entity(b))
87
+ .map(([a, b]) => ({
88
+ code: 'mapping-dangling', severity: 'warning', kind: 'mapping', ids: [`${m.from}:${a}->${m.to}:${b}`],
89
+ message: `A mapping from ${m.from} "${a}" to ${m.to} "${b}" joins an entity that is not there`,
90
+ })));
91
+ }
92
+ }