@peter.prib/renderengine 0.2.6 → 0.2.10

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 (78) hide show
  1. package/README.md +70 -9
  2. package/REFERENCE.md +303 -2
  3. package/dist/renderengine-workspace.umd.js +8851 -3305
  4. package/dist/renderengine.umd.js +5621 -1382
  5. package/examples/archimate.html +68 -66
  6. package/examples/data-model.html +251 -19
  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/FormDialog.js +398 -385
  12. package/src/charting/ModelCheckDialog.js +63 -0
  13. package/src/charting/ModelChecker.js +26 -0
  14. package/src/charting/RowsField.js +127 -0
  15. package/src/charting/ViewTabStrip.js +94 -81
  16. package/src/charting/datamodel/BianLandscapeReader.js +185 -0
  17. package/src/charting/datamodel/BianSemanticApiReader.js +183 -0
  18. package/src/charting/datamodel/ClassificationLens.js +33 -0
  19. package/src/charting/datamodel/ClassificationScheme.js +98 -0
  20. package/src/charting/datamodel/DataModel.js +574 -12
  21. package/src/charting/datamodel/DataModelChecker.js +338 -0
  22. package/src/charting/datamodel/DataModelCompared.js +127 -0
  23. package/src/charting/datamodel/DataModelContext.js +40 -10
  24. package/src/charting/datamodel/DataModelEditCommand.js +47 -0
  25. package/src/charting/datamodel/DataModelFileFormat.js +33 -0
  26. package/src/charting/datamodel/DataModelPerspective.js +55 -0
  27. package/src/charting/datamodel/DataType.js +108 -0
  28. package/src/charting/datamodel/EnumerationType.js +64 -0
  29. package/src/charting/datamodel/ServiceDomain.js +186 -0
  30. package/src/charting/datamodel/ServiceLandscape.js +104 -0
  31. package/src/charting/datamodel/SqlDdl.js +80 -0
  32. package/src/charting/datamodel/SqlDdlFileFormat.js +23 -0
  33. package/src/charting/datamodel/StructuredType.js +63 -0
  34. package/src/charting/datamodel/index.js +21 -5
  35. package/src/charting/datamodel/naming/CamelCaseStyle.js +13 -0
  36. package/src/charting/datamodel/naming/LowerWordsStyle.js +13 -0
  37. package/src/charting/datamodel/naming/NameStyle.js +57 -0
  38. package/src/charting/datamodel/naming/NamingStandard.js +132 -0
  39. package/src/charting/datamodel/naming/PascalCaseStyle.js +13 -0
  40. package/src/charting/datamodel/naming/SnakeCaseStyle.js +13 -0
  41. package/src/charting/datamodel/naming/TitleCaseStyle.js +25 -0
  42. package/src/charting/datamodel/naming/UpperSnakeStyle.js +13 -0
  43. package/src/charting/datamodel/naming/index.js +8 -0
  44. package/src/charting/datamodel/scorecard/CompletenessCategory.js +30 -0
  45. package/src/charting/datamodel/scorecard/ConsistencyCategory.js +33 -0
  46. package/src/charting/datamodel/scorecard/DefinitionsCategory.js +33 -0
  47. package/src/charting/datamodel/scorecard/ModelScorecard.js +113 -0
  48. package/src/charting/datamodel/scorecard/ReadabilityCategory.js +37 -0
  49. package/src/charting/datamodel/scorecard/ScorecardCategory.js +46 -0
  50. package/src/charting/datamodel/scorecard/StandardsCategory.js +29 -0
  51. package/src/charting/datamodel/scorecard/StructureCategory.js +32 -0
  52. package/src/charting/datamodel/scorecard/index.js +8 -0
  53. package/src/charting/diagramModel/MergeModelCommand.js +43 -37
  54. package/src/charting/diagramModel/OpenViewList.js +75 -0
  55. package/src/charting/diagramModel/ViewSetChecker.js +22 -34
  56. package/src/charting/diagrams/DataModelDiagram.js +4 -11
  57. package/src/charting/diagrams/DataModelRelationshipStrategy.js +19 -5
  58. package/src/charting/diagrams/EdgeGeometry.js +578 -553
  59. package/src/charting/diagrams/GraphDiagram.js +11 -1
  60. package/src/charting/diagrams/NodeGeometry.js +173 -158
  61. package/src/charting/glyphs/PersonGlyph.js +24 -23
  62. package/src/charting/toolbar/ToolbarSelect.js +6 -0
  63. package/src/datamodel.js +402 -4
  64. package/src/index.js +6 -4
  65. package/src/workspace/EditorShell.js +166 -67
  66. package/src/workspace/PanelLayout.js +56 -0
  67. package/src/workspace/layout/TabsNode.js +205 -191
  68. package/src/workspace/parts/ArchimateCanvasPart.js +5 -7
  69. package/src/workspace/parts/DataModelPart.js +534 -33
  70. package/src/workspace/parts/DiagramEditorPart.js +2 -8
  71. package/src/workspace/parts/EditorPart.js +461 -407
  72. package/src/workspace/parts/ModelFile.js +216 -187
  73. package/src/workspace/parts/ModelsPart.js +483 -192
  74. package/src/workspace/storage/BackupFolder.js +145 -0
  75. package/src/workspace/storage/KeyValueStore.js +92 -0
  76. package/src/workspace/storage/ModelLibrary.js +65 -0
  77. package/src/workspace/workArea.js +417 -413
  78. package/src/yaml/YamlReader.js +372 -0
@@ -0,0 +1,338 @@
1
+ import { ModelChecker } from '../ModelChecker.js';
2
+ import { BianNotation } from '../../bian.js';
3
+ import { ClassificationScheme } from './ClassificationScheme.js';
4
+ import { DataModelNotation } from '../../datamodel.js';
5
+ import { DataModelContext } from './DataModelContext.js';
6
+ import { NameStyle } from './naming/NameStyle.js';
7
+
8
+ /** Whether a cardinality is many. */
9
+ const many = (cardinality) => /\*/.test(String(cardinality ?? ''));
10
+ /** The levels whose entities have attributes. */
11
+ const ATTRIBUTED = ['logical', 'physical'];
12
+
13
+ /**
14
+ * DataModelChecker — checks over a DataModel (a ModelChecker): each business
15
+ * object (a logical entity) owned by one service domain, as BIAN has it
16
+ * (none, once there are service domains, is worth a look; more than one is
17
+ * wrong); service domains complete (a control record, qualifiers that are
18
+ * entities, a pattern BIAN has); mappings joining entities that are there;
19
+ * data types sound (an enumeration with values, each once; one name a type)
20
+ * and used; and the model sound as DMBOK2 has a data model reviewed: keys,
21
+ * foreign keys agreeing with relationships, no repeating groups, one name one
22
+ * type, no many-to-many left at the physical level, concepts defined (and not
23
+ * by their name), names as the model's naming standard says (if it has one).
24
+ * Each finding names what it is about (`kind`: 'entity', 'relationship',
25
+ * 'perspective', 'mapping', 'data-type') and, for an entity, its `level`.
26
+ */
27
+ export class DataModelChecker extends ModelChecker {
28
+ checks() {
29
+ return [
30
+ this.ownedTwice, this.unowned, this.serviceDomains, this.mappings, this.dataTypes,
31
+ this.keys, this.foreignKeys, this.manyToMany, this.repeatingGroups, this.typeConflicts, this.definitions, this.naming,
32
+ this.classification, this.generalizationCycles, this.qualityRules, this.dimensional,
33
+ ];
34
+ }
35
+
36
+ /** A finding on an entity at a level. @private */
37
+ _entity(level, entity, code, severity, message) {
38
+ return { code, severity, kind: 'entity', level, ids: [entity.id], message };
39
+ }
40
+
41
+ /** An entity at a logical or physical level, with attributes, none of them its primary key. */
42
+ keys() {
43
+ return ATTRIBUTED.flatMap(level => this.model.level(level).entities
44
+ .filter(e => (e.attributes ?? []).length && !e.attributes.some(a => a.isPK))
45
+ .map(e => this._entity(level, e, 'no-primary-key', 'warning', `${level === 'physical' ? 'Table' : 'Entity'} "${e.name}" has no primary key`)));
46
+ }
47
+
48
+ /**
49
+ * A foreign key that references what is not there, or an entity it has no
50
+ * relationship with; one that says nothing of what it references, on an
51
+ * entity with no relationship at all.
52
+ */
53
+ foreignKeys() {
54
+ return ATTRIBUTED.flatMap(level => {
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
+ const related = (a, b) => schema.relationships.some(r => (r.source === a && r.target === b) || (r.source === b && r.target === a));
61
+ return schema.entities.flatMap(e => (e.attributes ?? []).filter(a => a.isFK).flatMap(a => {
62
+ if (!a.references) {
63
+ return schema.relationships.some(r => r.source === e.id || r.target === e.id) ? []
64
+ : [this._entity(level, e, 'foreign-key-unrelated', 'info', `"${e.name}.${a.name}" is a foreign key, but "${e.name}" has no relationship`)];
65
+ }
66
+ const target = find(a.references);
67
+ 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
+ return related(e.id, target.id) ? []
69
+ : [this._entity(level, e, 'foreign-key-without-relationship', 'warning', `"${e.name}.${a.name}" references "${target.name}", but they have no relationship`)];
70
+ }));
71
+ });
72
+ }
73
+
74
+ /** A many-to-many relationship left at the physical level (an associative table resolves it). */
75
+ manyToMany() {
76
+ const schema = this.model.level('physical');
77
+ return schema.relationships.filter(r => DataModelNotation.relationshipKind(r).hasCardinality && many(r.sourceCardinality) && many(r.targetCardinality)).map(r => ({
78
+ code: 'many-to-many', severity: 'warning', kind: 'relationship', level: 'physical', ids: [r.id ?? `${r.source}->${r.target}`],
79
+ message: `"${schema.entity(r.source)?.name ?? r.source}" and "${schema.entity(r.target)?.name ?? r.target}" are many-to-many; a table between them resolves it`,
80
+ }));
81
+ }
82
+
83
+ /** Attributes of one entity that differ only by a number at the end (phone 1, phone 2): a repeating group. */
84
+ repeatingGroups() {
85
+ return ATTRIBUTED.flatMap(level => this.model.level(level).entities.flatMap(e => {
86
+ const stems = new Map();
87
+ for (const a of e.attributes ?? []) {
88
+ const m = /^(.*?\D)[\s_-]*\d+$/.exec(a.name);
89
+ if (m) stems.set(m[1].toLowerCase(), [...(stems.get(m[1].toLowerCase()) ?? []), a.name]);
90
+ }
91
+ return [...stems.values()].filter(names => names.length > 1)
92
+ .map(names => this._entity(level, e, 'repeating-group', 'warning', `"${e.name}" repeats ${names.join(', ')}: a repeating group, better an entity of its own`));
93
+ }));
94
+ }
95
+
96
+ /** One attribute name with different types at a level. */
97
+ typeConflicts() {
98
+ return ATTRIBUTED.flatMap(level => {
99
+ const byName = new Map();
100
+ for (const e of this.model.level(level).entities) {
101
+ for (const a of e.attributes ?? []) {
102
+ if (!a.type) continue;
103
+ const key = a.name.toLowerCase();
104
+ byName.set(key, [...(byName.get(key) ?? []), [e, a.type]]);
105
+ }
106
+ }
107
+ return [...byName].filter(([, uses]) => new Set(uses.map(([, t]) => t)).size > 1).map(([name, uses]) => ({
108
+ code: 'type-conflict', severity: 'info', kind: 'entity', level, ids: [...new Set(uses.map(([e]) => e.id))],
109
+ message: `"${name}" has different types at the ${level} level: ${[...new Set(uses.map(([e, t]) => `${t} (${e.name})`))].join(', ')}`,
110
+ }));
111
+ });
112
+ }
113
+
114
+ /** A concept without a definition; any entity whose definition only says its name. */
115
+ definitions() {
116
+ const plain = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '');
117
+ return [
118
+ ...this.model.level('conceptual').entities.filter(e => !String(e.description ?? '').trim())
119
+ .map(e => this._entity('conceptual', e, 'definition-missing', 'info', `Concept "${e.name}" has no definition`)),
120
+ ...this.model.constructor.LEVELS.flatMap(level => this.model.level(level).entities
121
+ .filter(e => e.description && plain(e.description) === plain(e.name))
122
+ .map(e => this._entity(level, e, 'definition-restates-name', 'info', `"${e.name}" is defined only by its name`))),
123
+ ];
124
+ }
125
+
126
+ /**
127
+ * Classification kept down the mappings: what realises a classified
128
+ * entity is classified at least as high and falls in its categories
129
+ * (`classification-lost`); a sensitivity the scheme does not have
130
+ * (`classification-unknown`).
131
+ */
132
+ classification() {
133
+ const scheme = this.model.classificationScheme;
134
+ const levels = this.model.constructor.LEVELS;
135
+ const findings = [];
136
+ for (const level of levels) {
137
+ for (const e of this.model.level(level).entities) {
138
+ const unknown = [e, ...(e.attributes ?? [])].filter(p => p.sensitivity && scheme.rank(p.sensitivity) < 0);
139
+ if (unknown.length) {
140
+ findings.push(this._entity(level, e, 'classification-unknown', 'info',
141
+ `"${e.name}" is classified ${[...new Set(unknown.map(p => p.sensitivity))].join(', ')}, which the classification scheme does not have`));
142
+ }
143
+ }
144
+ }
145
+ levels.slice(0, -1).forEach((from, i) => {
146
+ const to = levels[i + 1];
147
+ const mapping = this.model.mapping(from, to);
148
+ for (const e of this.model.level(from).entities) {
149
+ const own = scheme.effective(e);
150
+ if (!own.sensitivity && !own.categories.length) continue;
151
+ for (const id of mapping.targetsOf(e.id)) {
152
+ const target = this.model.level(to).entity(id);
153
+ if (!target) continue;
154
+ const theirs = scheme.effective(target);
155
+ const lower = scheme.rank(theirs.sensitivity) < scheme.rank(own.sensitivity);
156
+ const missing = own.categories.filter(c => !theirs.categories.includes(c));
157
+ if (!lower && !missing.length) continue;
158
+ findings.push(this._entity(to, target, 'classification-lost', 'warning',
159
+ `"${target.name}" realises "${e.name}" (${ClassificationScheme.describe(own)}) but is ${ClassificationScheme.describe(theirs) || 'unclassified'}`));
160
+ }
161
+ }
162
+ });
163
+ return findings;
164
+ }
165
+
166
+ /**
167
+ * A data quality rule on an attribute that nothing realising its concepts
168
+ * has (at the logical or physical level; names compared as words, so
169
+ * 'legal name' is 'legal_name'); a dimension DMBOK2 does not name.
170
+ */
171
+ qualityRules() {
172
+ const words = (name) => NameStyle.words(name).map(w => w.toLowerCase()).join(' ');
173
+ return this.model.contexts.flatMap(context => context.constraints.flatMap(rule => {
174
+ const findings = [];
175
+ const about = { kind: 'context', ids: [context.id] };
176
+ if (rule.dimension && !DataModelContext.QUALITY_DIMENSIONS.includes(rule.dimension)) {
177
+ findings.push({ ...about, code: 'quality-dimension-unknown', severity: 'info', message: `Rule "${rule.text}" (${context.name}) measures "${rule.dimension}", not a data quality dimension` });
178
+ }
179
+ if (rule.attribute) {
180
+ const want = words(rule.attribute);
181
+ const has = ATTRIBUTED.some(level => [...(this.model.realising(level, rule.applies) ?? [])]
182
+ .some(id => (this.model.level(level).entity(id)?.attributes ?? []).some(a => words(a.name) === want)));
183
+ if (!has) findings.push({ ...about, code: 'rule-attribute-missing', severity: 'warning', message: `Rule "${rule.text}" (${context.name}) is on "${rule.attribute}", which nothing realising its concepts has` });
184
+ }
185
+ return findings;
186
+ }));
187
+ }
188
+
189
+ /**
190
+ * A dimensional scheme as Kimball would have it: a fact states its grain
191
+ * (`fact-no-grain`), is related to a dimension (`fact-no-dimension`) and
192
+ * has a measure (`fact-no-measure`); a measure is a fact's
193
+ * (`measure-outside-fact`); two facts are not joined directly (`fact-to-fact`).
194
+ */
195
+ dimensional() {
196
+ return ATTRIBUTED.flatMap(level => {
197
+ const schema = this.model.level(level);
198
+ const roleOf = (e) => DataModelNotation.entityRole(e)?.role;
199
+ const isFact = (id) => roleOf(schema.entity(id)) === 'fact';
200
+ const findings = [];
201
+ for (const e of schema.entities) {
202
+ const measures = (e.attributes ?? []).filter(a => a.measure);
203
+ if (roleOf(e) !== 'fact') {
204
+ if (measures.length) findings.push(this._entity(level, e, 'measure-outside-fact', 'info', `"${e.name}" has measures (${measures.map(a => a.name).join(', ')}) but is no fact`));
205
+ continue;
206
+ }
207
+ if (!String(e.grain ?? '').trim()) findings.push(this._entity(level, e, 'fact-no-grain', 'warning', `Fact "${e.name}" does not say its grain (what one row is)`));
208
+ const dims = schema.relationships.filter(r => (r.source === e.id || r.target === e.id))
209
+ .map(r => schema.entity(r.source === e.id ? r.target : r.source)).filter(d => roleOf(d) === 'dimension');
210
+ if (!dims.length) findings.push(this._entity(level, e, 'fact-no-dimension', 'warning', `Fact "${e.name}" is related to no dimension`));
211
+ if (!measures.length) findings.push(this._entity(level, e, 'fact-no-measure', 'info', `Fact "${e.name}" has no measure`));
212
+ }
213
+ for (const r of schema.relationships) {
214
+ if (isFact(r.source) && isFact(r.target)) {
215
+ findings.push({ code: 'fact-to-fact', severity: 'info', kind: 'relationship', level, ids: [r.id ?? `${r.source}->${r.target}`],
216
+ message: `Facts "${schema.entity(r.source).name}" and "${schema.entity(r.target).name}" are joined directly; facts meet through their dimensions` });
217
+ }
218
+ }
219
+ return findings;
220
+ });
221
+ }
222
+
223
+ /** Generalizations that go round: an entity, through its supertypes, its own supertype. */
224
+ generalizationCycles() {
225
+ return this.model.constructor.LEVELS.flatMap(level => {
226
+ const schema = this.model.level(level);
227
+ const supertypes = new Map();
228
+ for (const r of schema.relationships) {
229
+ if (DataModelNotation.relationshipKind(r).hasCardinality) continue;
230
+ supertypes.set(r.source, [...(supertypes.get(r.source) ?? []), r.target]);
231
+ }
232
+ const cycles = new Map();
233
+ const walk = (id, path) => {
234
+ if (path.includes(id)) {
235
+ const cycle = path.slice(path.indexOf(id));
236
+ const key = [...cycle].sort().join(' ');
237
+ if (!cycles.has(key)) cycles.set(key, cycle); // as first found: from the first entity round
238
+ return;
239
+ }
240
+ for (const up of supertypes.get(id) ?? []) walk(up, [...path, id]);
241
+ };
242
+ for (const id of supertypes.keys()) walk(id, []);
243
+ const name = (id) => schema.entity(id)?.name ?? id;
244
+ return [...cycles.values()].map(cycle => ({
245
+ code: 'generalization-cycle', severity: 'warning', kind: 'entity', level, ids: cycle,
246
+ message: `Generalizations go round at the ${level} level: ${[...cycle, cycle[0]].map(name).join(' is a subtype of ')}`,
247
+ }));
248
+ });
249
+ }
250
+
251
+ /** Names that do not keep the model's naming standard (none: nothing to keep). */
252
+ naming() {
253
+ const standard = this.model.namingStandard;
254
+ if (!standard) return [];
255
+ return this.model.constructor.LEVELS.flatMap(level => this.model.level(level).entities.flatMap(e => {
256
+ const own = standard.check(level, 'entities', e.name);
257
+ const attributes = (e.attributes ?? []).map(a => [a, standard.check(level, 'attributes', a.name)]).filter(([, r]) => r.length);
258
+ return [
259
+ ...(own.length ? [this._entity(level, e, 'naming', 'info', `"${e.name}" ${own.join('; ')}`)] : []),
260
+ ...attributes.map(([a, reasons]) => this._entity(level, e, 'naming', 'info', `"${e.name}.${a.name}" ${reasons.join('; ')}`)),
261
+ ];
262
+ }));
263
+ }
264
+
265
+ /** The service domains (perspectives that own business objects). @private */
266
+ _owners() {
267
+ return this.model.perspectives.filter(p => p.ownedEntities().length);
268
+ }
269
+
270
+ /** A business object owned by more than one service domain. */
271
+ ownedTwice() {
272
+ return this.model.level('logical').entities
273
+ .map(e => [e, this.model.ownersOf(e.id)])
274
+ .filter(([, owners]) => owners.length > 1)
275
+ .map(([e, owners]) => ({
276
+ code: 'owned-twice', severity: 'warning', kind: 'entity', level: 'logical', ids: [e.id],
277
+ message: `"${e.name}" is owned by ${owners.length} service domains (${owners.map(o => o.name).join(', ')}); BIAN gives a business object one`,
278
+ }));
279
+ }
280
+
281
+ /** A business object no service domain owns, once some own business objects. */
282
+ unowned() {
283
+ if (!this._owners().length) return [];
284
+ return this.model.level('logical').entities
285
+ .filter(e => !this.model.ownersOf(e.id).length)
286
+ .map(e => ({
287
+ code: 'unowned', severity: 'info', kind: 'entity', level: 'logical', ids: [e.id],
288
+ message: `"${e.name}" is owned by no service domain`,
289
+ }));
290
+ }
291
+
292
+ /** A service domain with no control record, a pattern BIAN lacks, or a qualifier that is no entity. */
293
+ serviceDomains() {
294
+ const logical = this.model.level('logical');
295
+ return this._serviceDomains().flatMap(sd => [
296
+ ...(!sd.controlRecord ? [{ code: 'no-control-record', severity: 'warning', message: `Service domain "${sd.name}" has no control record` }]
297
+ : !logical.entity(sd.controlRecord) ? [{ code: 'control-record-missing', severity: 'warning', message: `Service domain "${sd.name}": its control record "${sd.controlRecord}" is no logical entity` }]
298
+ : []),
299
+ ...(!BianNotation.functionalPattern(sd.pattern) ? [{ code: 'unknown-pattern', severity: 'warning', message: `Service domain "${sd.name}": "${sd.pattern}" is no BIAN functional pattern` }] : []),
300
+ ...sd.qualifiers.filter(q => !q.entity || !logical.entity(q.entity)).map(q => ({
301
+ code: 'qualifier-entity-missing', severity: 'info', message: `Service domain "${sd.name}": behaviour qualifier "${q.name}" is no logical entity`,
302
+ })),
303
+ ].map(f => ({ ...f, kind: 'perspective', ids: [sd.id] })));
304
+ }
305
+
306
+ /** The service domains among the perspectives. @private */
307
+ _serviceDomains() {
308
+ return this.model.perspectives.filter(p => p.constructor.kind === 'service-domain');
309
+ }
310
+
311
+ /** An enumeration with no values or a value twice, two data types of one name, a data type nothing uses. */
312
+ dataTypes() {
313
+ const types = this.model.dataTypes;
314
+ const finding = (t, code, severity, message) => ({ code, severity, kind: 'data-type', ids: [t.id], message });
315
+ return types.flatMap(t => {
316
+ const values = (t.values ?? []).map(v => v.name);
317
+ const twice = [...new Set(values.filter((v, i) => values.indexOf(v) !== i))];
318
+ const sameName = types.filter(o => o !== t && o.name.toLowerCase() === t.name.toLowerCase());
319
+ const { attributes, types: users } = this.model.usesOf(t.id);
320
+ return [
321
+ ...(t.values && !values.length ? [finding(t, 'enumeration-empty', 'warning', `Enumeration "${t.name}" has no values`)] : []),
322
+ ...(twice.length ? [finding(t, 'value-twice', 'warning', `Enumeration "${t.name}" has ${twice.map(v => `"${v}"`).join(', ')} more than once`)] : []),
323
+ ...(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`)] : []),
324
+ ...(!attributes.length && !users.length ? [finding(t, 'type-unused', 'info', `Data type "${t.name}" is used by no attribute or other data type`)] : []),
325
+ ];
326
+ });
327
+ }
328
+
329
+ /** A mapping pair joining an entity that is not there. */
330
+ mappings() {
331
+ return this.model.mappings.flatMap(m => m.pairs
332
+ .filter(([a, b]) => !this.model.level(m.from).entity(a) || !this.model.level(m.to).entity(b))
333
+ .map(([a, b]) => ({
334
+ code: 'mapping-dangling', severity: 'warning', kind: 'mapping', ids: [`${m.from}:${a}->${m.to}:${b}`],
335
+ message: `A mapping from ${m.from} "${a}" to ${m.to} "${b}" joins an entity that is not there`,
336
+ })));
337
+ }
338
+ }
@@ -0,0 +1,127 @@
1
+ import { ComparedKind } from '../diagramModel/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
+ /**
7
+ * The kinds two DataModels are compared on and merged by (ModelComparison,
8
+ * as for ArchiMate models): each level's entities and relationships, the
9
+ * mappings between levels, contexts and perspectives, in the order a merge
10
+ * brings them (what is needed first). Where a box sits is layout, not content:
11
+ * two models alike but for where their boxes are, are alike.
12
+ */
13
+
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
+ /** One level's entities: compared on all but where they are drawn. */
46
+ export class LevelEntitiesCompared extends ListCompared {
47
+ constructor(level) {
48
+ super();
49
+ this.level = level;
50
+ }
51
+
52
+ get key() { return `${this.level}-entity`; }
53
+ get label() { return `${this.level.charAt(0).toUpperCase()}${this.level.slice(1)} entities`; }
54
+ list(model) { return model.level(this.level).entities; }
55
+
56
+ content(entity) {
57
+ const { x, y, ...rest } = entity;
58
+ return copy(rest);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * One level's relationships: known by their id, else by their ends and name
64
+ * (a relationship often has no id); each needs its two ends at that level.
65
+ */
66
+ export class LevelRelationshipsCompared extends ListCompared {
67
+ constructor(level) {
68
+ super();
69
+ this.level = level;
70
+ }
71
+
72
+ get key() { return `${this.level}-relationship`; }
73
+ get label() { return `${this.level.charAt(0).toUpperCase()}${this.level.slice(1)} relationships`; }
74
+ list(model) { return model.level(this.level).relationships; }
75
+ idOf(r) { return r.id ?? `${r.source}->${r.target}${r.name ? `:${r.name}` : ''}`; }
76
+ nameOf(r) { return `${r.source} ${r.name || '→'} ${r.target}`; }
77
+ content(r) { return copy(r); }
78
+ requires(r) { return [[`${this.level}-entity`, r.source], [`${this.level}-entity`, r.target]]; }
79
+ }
80
+
81
+ /** The mappings between levels, a pair each: a pair needs its two entities. */
82
+ export class MappingsCompared extends ComparedKind {
83
+ get key() { return 'mapping'; }
84
+ get label() { return 'Mappings'; }
85
+
86
+ items(model) {
87
+ return model.mappings.flatMap(m => m.pairs.map(([a, b]) => ({ id: `${m.from}:${a}->${m.to}:${b}`, from: m.from, to: m.to, a, b })));
88
+ }
89
+
90
+ nameOf(p) { return `${p.a} → ${p.b} (${p.from} to ${p.to})`; }
91
+ content() { return {}; }
92
+ requires(p) { return [[`${p.from}-entity`, p.a], [`${p.to}-entity`, p.b]]; }
93
+
94
+ bring(model, p) {
95
+ const mapping = model.mapping(p.from, p.to);
96
+ if (mapping.pairs.some(([a, b]) => a === p.a && b === p.b)) return () => {};
97
+ mapping.add(p.a, p.b);
98
+ return () => { mapping.pairs = mapping.pairs.filter(([a, b]) => !(a === p.a && b === p.b)); };
99
+ }
100
+ }
101
+
102
+ /** Perspectives and contexts are classes; their JSON is what counts, and a copy is one made from it. */
103
+ class ModelPartsCompared extends ListCompared {
104
+ content(item) { return copy(item.toJSON()); }
105
+ copyOf(item) { return new item.constructor(copy(item.toJSON())); }
106
+ }
107
+
108
+ export class ContextsCompared extends ModelPartsCompared {
109
+ get key() { return 'context'; }
110
+ get label() { return 'Contexts'; }
111
+ list(model) { return model.contexts; }
112
+ }
113
+
114
+ /** Data types: an attribute names one by its id or name; a structure's fields others. */
115
+ export class DataTypesCompared extends ModelPartsCompared {
116
+ get key() { return 'data-type'; }
117
+ get label() { return 'Data types'; }
118
+ list(model) { return model.dataTypes; }
119
+ }
120
+
121
+ /** A perspective needs the context it is read in. */
122
+ export class PerspectivesCompared extends ModelPartsCompared {
123
+ get key() { return 'perspective'; }
124
+ get label() { return 'Perspectives'; }
125
+ list(model) { return model.perspectives; }
126
+ requires(p) { return p.context ? [['context', p.context]] : []; }
127
+ }
@@ -7,14 +7,27 @@
7
7
  * meaning, scope and constraints.
8
8
  */
9
9
  export class DataModelContext {
10
+ /** The dimensions of data quality a rule may measure (DMBOK2's data quality). */
11
+ static QUALITY_DIMENSIONS = Object.freeze(['Completeness', 'Validity', 'Uniqueness', 'Timeliness', 'Accuracy', 'Consistency']);
12
+
13
+ /**
14
+ * A rule as it reads in a note or on hover: its dimension and the attribute
15
+ * it is on before its text ('Completeness of legal name: always given').
16
+ */
17
+ static ruleText(rule) {
18
+ const on = [rule.dimension, rule.attribute && (rule.dimension ? `of ${rule.attribute}` : rule.attribute)].filter(Boolean).join(' ');
19
+ return on ? `${on}: ${rule.text}` : rule.text;
20
+ }
21
+
10
22
  /**
11
23
  * @param {Object} spec
12
24
  * @param {string} spec.id
13
25
  * @param {string} [spec.name]
14
26
  * @param {string} [spec.description]
15
27
  * @param {Object<string, string>} [spec.meanings] - What an entity means here, by entity id
16
- * @param {Array<{id?: string, text: string, applies?: string[], x?: number, y?: number}>} [spec.constraints]
17
- * Rules that hold here, each on the entities it `applies` to (drawn as notes attached to them)
28
+ * @param {Array<{id?: string, text: string, applies?: string[], dimension?: string, attribute?: string, x?: number, y?: number}>} [spec.constraints]
29
+ * Rules that hold here, each on the entities it `applies` to (drawn as notes attached to them); a data quality
30
+ * rule says which dimension it measures (QUALITY_DIMENSIONS) and may be on one attribute of what realises them
18
31
  * @param {string} [spec.jurisdiction]
19
32
  * @param {string} [spec.validFrom] - ISO date
20
33
  * @param {string} [spec.validTo] - ISO date
@@ -37,6 +50,15 @@ export class DataModelContext {
37
50
  }
38
51
 
39
52
  /** The rules that hold here on an entity. */
53
+ /** Forget a concept removed: what it means here, and the rules' hold on it (a rule on nothing else goes). */
54
+ forgetEntity(level, id) {
55
+ if (level !== 'conceptual') return;
56
+ delete this.meanings[id];
57
+ this.constraints = this.constraints
58
+ .filter(c => !(c.applies.length === 1 && c.applies[0] === id))
59
+ .map(c => ({ ...c, applies: c.applies.filter(a => a !== id) }));
60
+ }
61
+
40
62
  constraintsOn(entityId) {
41
63
  return this.constraints.filter(c => c.applies.includes(entityId));
42
64
  }
@@ -70,9 +92,15 @@ export class DataModelContext {
70
92
  ...concepts.map(c => ({ key: `meaning:${c.id}`, label: c.name, type: 'textarea', rows: 2, value: context?.meanings[c.id] ?? '', placeholder: 'as the schema describes it' })),
71
93
  { type: 'heading', label: 'Rules' },
72
94
  {
73
- key: 'rules', label: '', type: 'textarea', rows: 5,
74
- value: (context?.constraints ?? []).map(c => `${c.text} | ${c.applies.map(nameOf).join(', ')}`).join('\n'),
75
- hint: 'One a line: the rule | the concepts it applies to (by name)',
95
+ key: 'rules', label: '', type: 'rows', addLabel: 'rule',
96
+ columns: [
97
+ { key: 'text', label: 'Rule' },
98
+ { key: 'applies', label: 'On concepts', placeholder: 'by name, commas between', suggestions: concepts.map(c => c.name), width: '160px' },
99
+ { key: 'dimension', label: 'Quality dimension', type: 'select', choices: [['', '—'], ...DataModelContext.QUALITY_DIMENSIONS.map(d => [d, d])] },
100
+ { key: 'attribute', label: 'Attribute', placeholder: 'legal name', width: '120px' },
101
+ ],
102
+ // Concepts shown by name, read back by name or id (specFrom).
103
+ value: (context?.constraints ?? []).map(c => ({ ...c, applies: c.applies.map(nameOf).join(', ') })),
76
104
  },
77
105
  ];
78
106
  }
@@ -85,11 +113,13 @@ export class DataModelContext {
85
113
  const concepts = model.concepts;
86
114
  const conceptOf = (word) => concepts.find(c => c.id === word || c.name.toLowerCase() === word.toLowerCase())?.id ?? null;
87
115
  const meanings = Object.fromEntries(concepts.map(c => [c.id, (values[`meaning:${c.id}`] ?? '').trim()]).filter(([, text]) => text));
88
- const constraints = String(values.rules ?? '').split('\n').map(line => line.trim()).filter(Boolean).map(line => {
89
- const [text, on = ''] = line.split('|').map(part => part.trim());
90
- const applies = on.split(',').map(w => w.trim()).filter(Boolean).map(conceptOf).filter(Boolean);
91
- const kept = context?.constraints.find(c => c.text === text);
92
- return { text, applies, ...(kept && { id: kept.id, ...(Number.isFinite(kept.x) && { x: kept.x, y: kept.y }) }) };
116
+ const constraints = (values.rules ?? []).filter(r => String(r.text ?? '').trim()).map(r => {
117
+ const { text, applies = '', dimension, attribute, ...rest } = r;
118
+ return {
119
+ ...rest, text: String(text).trim(),
120
+ applies: String(applies).split(',').map(w => w.trim()).filter(Boolean).map(conceptOf).filter(Boolean),
121
+ ...(dimension && { dimension }), ...(attribute && { attribute: String(attribute).trim() }),
122
+ };
93
123
  });
94
124
  return {
95
125
  name: (values.name ?? '').trim(), description: (values.description ?? '').trim(),
@@ -0,0 +1,47 @@
1
+ import { Command } from '../CommandHistory.js';
2
+
3
+ /**
4
+ * DataModelEditCommand — one edit of a DataModel (an entity, an attribute, a
5
+ * relationship, a delete) as one undo step: run, it makes the change and keeps
6
+ * the model as it was before and after (DataModel#toJSON); undone or redone, it
7
+ * puts the model back as it was (DataModel#restore). Any change to a model is
8
+ * undoable this way, whatever it touches.
9
+ */
10
+ export class DataModelEditCommand extends Command {
11
+ /** A model edit is no edit on one view: undoing it shows no other. */
12
+ viewId = null;
13
+
14
+ /**
15
+ * @param {DataModel} model
16
+ * @param {string} label - What the undo menu calls it ('Edit Customer')
17
+ * @param {(model: DataModel) => *} change - Makes the change
18
+ */
19
+ constructor(model, label, change) {
20
+ super(label);
21
+ this.model = model;
22
+ this.change = change;
23
+ this.before = null;
24
+ this.after = null;
25
+ }
26
+
27
+ execute() {
28
+ if (this.after) {
29
+ this.model.restore(this.after);
30
+ } else {
31
+ this.before = DataModelEditCommand.snapshot(this.model);
32
+ this.result = this.change(this.model);
33
+ this.after = DataModelEditCommand.snapshot(this.model);
34
+ }
35
+ this.model.edited();
36
+ }
37
+
38
+ undo() {
39
+ this.model.restore(this.before);
40
+ this.model.edited();
41
+ }
42
+
43
+ /** The model as it is, a copy nothing later changes (its toJSON shares some arrays). */
44
+ static snapshot(model) {
45
+ return JSON.parse(JSON.stringify(model.toJSON()));
46
+ }
47
+ }
@@ -0,0 +1,33 @@
1
+ import { ModelFileFormat } from '../ModelFileFormat.js';
2
+ import { DataModel } from './DataModel.js';
3
+
4
+ /**
5
+ * DataModelFileFormat — a data model as a file (`.datamodel.json`): its name,
6
+ * its levels, the mappings between them, where its boxes and notes are, and
7
+ * its perspectives and contexts (DataModel#toJSON), read back by `new
8
+ * DataModel(json)`. The older one-entity form reads too.
9
+ */
10
+ export class DataModelFileFormat extends ModelFileFormat {
11
+ get label() { return '.datamodel.json'; }
12
+ get accept() { return '.datamodel.json,.json'; }
13
+ get fileName() { return 'model.datamodel.json'; }
14
+ get featureName() { return 'DataModel'; }
15
+
16
+ /** JSON with a data model's shape: levels, or the older entities. */
17
+ recognizes(text) {
18
+ try {
19
+ const json = JSON.parse(text);
20
+ return !!json && typeof json === 'object' && (!!json.levels || Array.isArray(json.entities));
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ export(model) {
27
+ return { data: JSON.stringify(model.toJSON(), null, 2), mimeType: 'application/json' };
28
+ }
29
+
30
+ import(text) {
31
+ return new DataModel(JSON.parse(text));
32
+ }
33
+ }