@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.
- package/README.md +70 -9
- package/REFERENCE.md +303 -2
- package/dist/renderengine-workspace.umd.js +8851 -3305
- package/dist/renderengine.umd.js +5621 -1382
- package/examples/archimate.html +68 -66
- package/examples/data-model.html +251 -19
- package/package.json +1 -1
- package/src/bian.js +182 -0
- package/src/charting/DiagramEditor.js +5 -26
- package/src/charting/DiagramTypeRegistry.js +8 -2
- package/src/charting/FormDialog.js +398 -385
- package/src/charting/ModelCheckDialog.js +63 -0
- package/src/charting/ModelChecker.js +26 -0
- package/src/charting/RowsField.js +127 -0
- package/src/charting/ViewTabStrip.js +94 -81
- package/src/charting/datamodel/BianLandscapeReader.js +185 -0
- package/src/charting/datamodel/BianSemanticApiReader.js +183 -0
- package/src/charting/datamodel/ClassificationLens.js +33 -0
- package/src/charting/datamodel/ClassificationScheme.js +98 -0
- package/src/charting/datamodel/DataModel.js +574 -12
- package/src/charting/datamodel/DataModelChecker.js +338 -0
- package/src/charting/datamodel/DataModelCompared.js +127 -0
- package/src/charting/datamodel/DataModelContext.js +40 -10
- package/src/charting/datamodel/DataModelEditCommand.js +47 -0
- package/src/charting/datamodel/DataModelFileFormat.js +33 -0
- package/src/charting/datamodel/DataModelPerspective.js +55 -0
- package/src/charting/datamodel/DataType.js +108 -0
- package/src/charting/datamodel/EnumerationType.js +64 -0
- package/src/charting/datamodel/ServiceDomain.js +186 -0
- package/src/charting/datamodel/ServiceLandscape.js +104 -0
- package/src/charting/datamodel/SqlDdl.js +80 -0
- package/src/charting/datamodel/SqlDdlFileFormat.js +23 -0
- package/src/charting/datamodel/StructuredType.js +63 -0
- package/src/charting/datamodel/index.js +21 -5
- package/src/charting/datamodel/naming/CamelCaseStyle.js +13 -0
- package/src/charting/datamodel/naming/LowerWordsStyle.js +13 -0
- package/src/charting/datamodel/naming/NameStyle.js +57 -0
- package/src/charting/datamodel/naming/NamingStandard.js +132 -0
- package/src/charting/datamodel/naming/PascalCaseStyle.js +13 -0
- package/src/charting/datamodel/naming/SnakeCaseStyle.js +13 -0
- package/src/charting/datamodel/naming/TitleCaseStyle.js +25 -0
- package/src/charting/datamodel/naming/UpperSnakeStyle.js +13 -0
- package/src/charting/datamodel/naming/index.js +8 -0
- package/src/charting/datamodel/scorecard/CompletenessCategory.js +30 -0
- package/src/charting/datamodel/scorecard/ConsistencyCategory.js +33 -0
- package/src/charting/datamodel/scorecard/DefinitionsCategory.js +33 -0
- package/src/charting/datamodel/scorecard/ModelScorecard.js +113 -0
- package/src/charting/datamodel/scorecard/ReadabilityCategory.js +37 -0
- package/src/charting/datamodel/scorecard/ScorecardCategory.js +46 -0
- package/src/charting/datamodel/scorecard/StandardsCategory.js +29 -0
- package/src/charting/datamodel/scorecard/StructureCategory.js +32 -0
- package/src/charting/datamodel/scorecard/index.js +8 -0
- package/src/charting/diagramModel/MergeModelCommand.js +43 -37
- package/src/charting/diagramModel/OpenViewList.js +75 -0
- package/src/charting/diagramModel/ViewSetChecker.js +22 -34
- package/src/charting/diagrams/DataModelDiagram.js +4 -11
- package/src/charting/diagrams/DataModelRelationshipStrategy.js +19 -5
- package/src/charting/diagrams/EdgeGeometry.js +578 -553
- package/src/charting/diagrams/GraphDiagram.js +11 -1
- package/src/charting/diagrams/NodeGeometry.js +173 -158
- package/src/charting/glyphs/PersonGlyph.js +24 -23
- package/src/charting/toolbar/ToolbarSelect.js +6 -0
- package/src/datamodel.js +402 -4
- package/src/index.js +6 -4
- package/src/workspace/EditorShell.js +166 -67
- package/src/workspace/PanelLayout.js +56 -0
- package/src/workspace/layout/TabsNode.js +205 -191
- package/src/workspace/parts/ArchimateCanvasPart.js +5 -7
- package/src/workspace/parts/DataModelPart.js +534 -33
- package/src/workspace/parts/DiagramEditorPart.js +2 -8
- package/src/workspace/parts/EditorPart.js +461 -407
- package/src/workspace/parts/ModelFile.js +216 -187
- package/src/workspace/parts/ModelsPart.js +483 -192
- package/src/workspace/storage/BackupFolder.js +145 -0
- package/src/workspace/storage/KeyValueStore.js +92 -0
- package/src/workspace/storage/ModelLibrary.js +65 -0
- package/src/workspace/workArea.js +417 -413
- 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: '
|
|
74
|
-
|
|
75
|
-
|
|
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 =
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
+
}
|