@peter.prib/renderengine 0.2.7 → 0.2.11

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 (46) hide show
  1. package/README.md +66 -9
  2. package/REFERENCE.md +129 -0
  3. package/dist/renderengine-workspace.umd.js +5068 -2892
  4. package/dist/renderengine.umd.js +4874 -2888
  5. package/examples/data-model.html +40 -18
  6. package/package.json +1 -1
  7. package/src/charting/FormDialog.js +398 -385
  8. package/src/charting/RowsField.js +127 -0
  9. package/src/charting/datamodel/ClassificationLens.js +33 -0
  10. package/src/charting/datamodel/ClassificationScheme.js +98 -0
  11. package/src/charting/datamodel/DataModel.js +220 -4
  12. package/src/charting/datamodel/DataModelChecker.js +244 -2
  13. package/src/charting/datamodel/DataModelContext.js +40 -10
  14. package/src/charting/datamodel/DataModelEditCommand.js +47 -0
  15. package/src/charting/datamodel/DataModelPerspective.js +12 -2
  16. package/src/charting/datamodel/EnumerationType.js +64 -51
  17. package/src/charting/datamodel/LevelSchema.js +23 -0
  18. package/src/charting/datamodel/ServiceDomain.js +9 -0
  19. package/src/charting/datamodel/SqlDdl.js +85 -0
  20. package/src/charting/datamodel/SqlDdlFileFormat.js +23 -0
  21. package/src/charting/datamodel/index.js +21 -15
  22. package/src/charting/datamodel/naming/CamelCaseStyle.js +13 -0
  23. package/src/charting/datamodel/naming/LowerWordsStyle.js +13 -0
  24. package/src/charting/datamodel/naming/NameStyle.js +57 -0
  25. package/src/charting/datamodel/naming/NamingStandard.js +132 -0
  26. package/src/charting/datamodel/naming/PascalCaseStyle.js +13 -0
  27. package/src/charting/datamodel/naming/SnakeCaseStyle.js +13 -0
  28. package/src/charting/datamodel/naming/TitleCaseStyle.js +25 -0
  29. package/src/charting/datamodel/naming/UpperSnakeStyle.js +13 -0
  30. package/src/charting/datamodel/naming/index.js +8 -0
  31. package/src/charting/datamodel/scorecard/CompletenessCategory.js +30 -0
  32. package/src/charting/datamodel/scorecard/ConsistencyCategory.js +33 -0
  33. package/src/charting/datamodel/scorecard/DefinitionsCategory.js +33 -0
  34. package/src/charting/datamodel/scorecard/ModelScorecard.js +113 -0
  35. package/src/charting/datamodel/scorecard/ReadabilityCategory.js +37 -0
  36. package/src/charting/datamodel/scorecard/ScorecardCategory.js +46 -0
  37. package/src/charting/datamodel/scorecard/StandardsCategory.js +29 -0
  38. package/src/charting/datamodel/scorecard/StructureCategory.js +32 -0
  39. package/src/charting/datamodel/scorecard/index.js +8 -0
  40. package/src/charting/diagrams/DataModelDiagram.js +4 -11
  41. package/src/charting/diagrams/DataModelRelationshipStrategy.js +7 -1
  42. package/src/charting/diagrams/NodeGeometry.js +6 -2
  43. package/src/charting/toolbar/ToolbarSelect.js +6 -0
  44. package/src/datamodel.js +402 -4
  45. package/src/index.js +2666 -2666
  46. package/src/workspace/parts/DataModelPart.js +197 -6
@@ -1,5 +1,14 @@
1
1
  import { ModelChecker } from '../ModelChecker.js';
2
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'];
3
12
 
4
13
  /**
5
14
  * DataModelChecker — checks over a DataModel (a ModelChecker): each business
@@ -8,12 +17,245 @@ import { BianNotation } from '../../bian.js';
8
17
  * wrong); service domains complete (a control record, qualifiers that are
9
18
  * entities, a pattern BIAN has); mappings joining entities that are there;
10
19
  * 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',
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',
12
25
  * 'perspective', 'mapping', 'data-type') and, for an entity, its `level`.
13
26
  */
14
27
  export class DataModelChecker extends ModelChecker {
15
28
  checks() {
16
- return [this.ownedTwice, this.unowned, this.serviceDomains, this.mappings, this.dataTypes];
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 related = (a, b) => schema.relationships.some(r => (r.source === a && r.target === b) || (r.source === b && r.target === a));
57
+ return schema.entities.flatMap(e => (e.attributes ?? []).filter(a => a.isFK).flatMap(a => {
58
+ if (!a.references) {
59
+ return schema.relationships.some(r => r.source === e.id || r.target === e.id) ? []
60
+ : [this._entity(level, e, 'foreign-key-unrelated', 'info', `"${e.name}.${a.name}" is a foreign key, but "${e.name}" has no relationship`)];
61
+ }
62
+ const target = schema.resolveReference(a.references)?.entity;
63
+ if (!target) return [this._entity(level, e, 'reference-missing', 'warning', `"${e.name}.${a.name}" references "${a.references}", which is not at the ${level} level`)];
64
+ return related(e.id, target.id) ? []
65
+ : [this._entity(level, e, 'foreign-key-without-relationship', 'warning', `"${e.name}.${a.name}" references "${target.name}", but they have no relationship`)];
66
+ }));
67
+ });
68
+ }
69
+
70
+ /** A many-to-many relationship left at the physical level (an associative table resolves it). */
71
+ manyToMany() {
72
+ const schema = this.model.level('physical');
73
+ return schema.relationships.filter(r => DataModelNotation.relationshipKind(r).hasCardinality && many(r.sourceCardinality) && many(r.targetCardinality)).map(r => ({
74
+ code: 'many-to-many', severity: 'warning', kind: 'relationship', level: 'physical', ids: [r.id ?? `${r.source}->${r.target}`],
75
+ 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`,
76
+ }));
77
+ }
78
+
79
+ /** Attributes of one entity that differ only by a number at the end (phone 1, phone 2): a repeating group. */
80
+ repeatingGroups() {
81
+ return ATTRIBUTED.flatMap(level => this.model.level(level).entities.flatMap(e => {
82
+ const stems = new Map();
83
+ for (const a of e.attributes ?? []) {
84
+ const m = /^(.*?\D)[\s_-]*\d+$/.exec(a.name);
85
+ if (m) stems.set(m[1].toLowerCase(), [...(stems.get(m[1].toLowerCase()) ?? []), a.name]);
86
+ }
87
+ return [...stems.values()].filter(names => names.length > 1)
88
+ .map(names => this._entity(level, e, 'repeating-group', 'warning', `"${e.name}" repeats ${names.join(', ')}: a repeating group, better an entity of its own`));
89
+ }));
90
+ }
91
+
92
+ /** One attribute name with different types at a level. */
93
+ typeConflicts() {
94
+ return ATTRIBUTED.flatMap(level => {
95
+ const byName = new Map();
96
+ for (const e of this.model.level(level).entities) {
97
+ for (const a of e.attributes ?? []) {
98
+ if (!a.type) continue;
99
+ const key = a.name.toLowerCase();
100
+ byName.set(key, [...(byName.get(key) ?? []), [e, a.type]]);
101
+ }
102
+ }
103
+ return [...byName].filter(([, uses]) => new Set(uses.map(([, t]) => t)).size > 1).map(([name, uses]) => ({
104
+ code: 'type-conflict', severity: 'info', kind: 'entity', level, ids: [...new Set(uses.map(([e]) => e.id))],
105
+ message: `"${name}" has different types at the ${level} level: ${[...new Set(uses.map(([e, t]) => `${t} (${e.name})`))].join(', ')}`,
106
+ }));
107
+ });
108
+ }
109
+
110
+ /** A concept without a definition; any entity whose definition only says its name. */
111
+ definitions() {
112
+ const plain = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '');
113
+ return [
114
+ ...this.model.level('conceptual').entities.filter(e => !String(e.description ?? '').trim())
115
+ .map(e => this._entity('conceptual', e, 'definition-missing', 'info', `Concept "${e.name}" has no definition`)),
116
+ ...this.model.constructor.LEVELS.flatMap(level => this.model.level(level).entities
117
+ .filter(e => e.description && plain(e.description) === plain(e.name))
118
+ .map(e => this._entity(level, e, 'definition-restates-name', 'info', `"${e.name}" is defined only by its name`))),
119
+ ];
120
+ }
121
+
122
+ /**
123
+ * Classification kept down the mappings: what realises a classified
124
+ * entity is classified at least as high and falls in its categories
125
+ * (`classification-lost`); a sensitivity the scheme does not have
126
+ * (`classification-unknown`).
127
+ */
128
+ classification() {
129
+ const scheme = this.model.classificationScheme;
130
+ const levels = this.model.constructor.LEVELS;
131
+ const findings = [];
132
+ for (const level of levels) {
133
+ for (const e of this.model.level(level).entities) {
134
+ const unknown = [e, ...(e.attributes ?? [])].filter(p => p.sensitivity && scheme.rank(p.sensitivity) < 0);
135
+ if (unknown.length) {
136
+ findings.push(this._entity(level, e, 'classification-unknown', 'info',
137
+ `"${e.name}" is classified ${[...new Set(unknown.map(p => p.sensitivity))].join(', ')}, which the classification scheme does not have`));
138
+ }
139
+ }
140
+ }
141
+ levels.slice(0, -1).forEach((from, i) => {
142
+ const to = levels[i + 1];
143
+ const mapping = this.model.mapping(from, to);
144
+ for (const e of this.model.level(from).entities) {
145
+ const own = scheme.effective(e);
146
+ if (!own.sensitivity && !own.categories.length) continue;
147
+ for (const id of mapping.targetsOf(e.id)) {
148
+ const target = this.model.level(to).entity(id);
149
+ if (!target) continue;
150
+ const theirs = scheme.effective(target);
151
+ const lower = scheme.rank(theirs.sensitivity) < scheme.rank(own.sensitivity);
152
+ const missing = own.categories.filter(c => !theirs.categories.includes(c));
153
+ if (!lower && !missing.length) continue;
154
+ findings.push(this._entity(to, target, 'classification-lost', 'warning',
155
+ `"${target.name}" realises "${e.name}" (${ClassificationScheme.describe(own)}) but is ${ClassificationScheme.describe(theirs) || 'unclassified'}`));
156
+ }
157
+ }
158
+ });
159
+ return findings;
160
+ }
161
+
162
+ /**
163
+ * A data quality rule on an attribute that nothing realising its concepts
164
+ * has (at the logical or physical level; names compared as words, so
165
+ * 'legal name' is 'legal_name'); a dimension DMBOK2 does not name.
166
+ */
167
+ qualityRules() {
168
+ const words = (name) => NameStyle.words(name).map(w => w.toLowerCase()).join(' ');
169
+ return this.model.contexts.flatMap(context => context.constraints.flatMap(rule => {
170
+ const findings = [];
171
+ const about = { kind: 'context', ids: [context.id] };
172
+ if (rule.dimension && !DataModelContext.QUALITY_DIMENSIONS.includes(rule.dimension)) {
173
+ findings.push({ ...about, code: 'quality-dimension-unknown', severity: 'info', message: `Rule "${rule.text}" (${context.name}) measures "${rule.dimension}", not a data quality dimension` });
174
+ }
175
+ if (rule.attribute) {
176
+ const want = words(rule.attribute);
177
+ const has = ATTRIBUTED.some(level => [...(this.model.realising(level, rule.applies) ?? [])]
178
+ .some(id => (this.model.level(level).entity(id)?.attributes ?? []).some(a => words(a.name) === want)));
179
+ 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` });
180
+ }
181
+ return findings;
182
+ }));
183
+ }
184
+
185
+ /**
186
+ * A dimensional scheme as Kimball would have it: a fact states its grain
187
+ * (`fact-no-grain`), is related to a dimension (`fact-no-dimension`) and
188
+ * has a measure (`fact-no-measure`); a measure is a fact's
189
+ * (`measure-outside-fact`); two facts are not joined directly (`fact-to-fact`).
190
+ */
191
+ dimensional() {
192
+ return ATTRIBUTED.flatMap(level => {
193
+ const schema = this.model.level(level);
194
+ const roleOf = (e) => DataModelNotation.entityRole(e)?.role;
195
+ const isFact = (id) => roleOf(schema.entity(id)) === 'fact';
196
+ const findings = [];
197
+ for (const e of schema.entities) {
198
+ const measures = (e.attributes ?? []).filter(a => a.measure);
199
+ if (roleOf(e) !== 'fact') {
200
+ 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`));
201
+ continue;
202
+ }
203
+ 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)`));
204
+ const dims = schema.relationships.filter(r => (r.source === e.id || r.target === e.id))
205
+ .map(r => schema.entity(r.source === e.id ? r.target : r.source)).filter(d => roleOf(d) === 'dimension');
206
+ if (!dims.length) findings.push(this._entity(level, e, 'fact-no-dimension', 'warning', `Fact "${e.name}" is related to no dimension`));
207
+ if (!measures.length) findings.push(this._entity(level, e, 'fact-no-measure', 'info', `Fact "${e.name}" has no measure`));
208
+ }
209
+ for (const r of schema.relationships) {
210
+ if (isFact(r.source) && isFact(r.target)) {
211
+ findings.push({ code: 'fact-to-fact', severity: 'info', kind: 'relationship', level, ids: [r.id ?? `${r.source}->${r.target}`],
212
+ message: `Facts "${schema.entity(r.source).name}" and "${schema.entity(r.target).name}" are joined directly; facts meet through their dimensions` });
213
+ }
214
+ }
215
+ return findings;
216
+ });
217
+ }
218
+
219
+ /** Generalizations that go round: an entity, through its supertypes, its own supertype. */
220
+ generalizationCycles() {
221
+ return this.model.constructor.LEVELS.flatMap(level => {
222
+ const schema = this.model.level(level);
223
+ const supertypes = new Map();
224
+ for (const r of schema.relationships) {
225
+ if (DataModelNotation.relationshipKind(r).hasCardinality) continue;
226
+ supertypes.set(r.source, [...(supertypes.get(r.source) ?? []), r.target]);
227
+ }
228
+ const cycles = new Map();
229
+ const walk = (id, path) => {
230
+ if (path.includes(id)) {
231
+ const cycle = path.slice(path.indexOf(id));
232
+ const key = [...cycle].sort().join(' ');
233
+ if (!cycles.has(key)) cycles.set(key, cycle); // as first found: from the first entity round
234
+ return;
235
+ }
236
+ for (const up of supertypes.get(id) ?? []) walk(up, [...path, id]);
237
+ };
238
+ for (const id of supertypes.keys()) walk(id, []);
239
+ const name = (id) => schema.entity(id)?.name ?? id;
240
+ return [...cycles.values()].map(cycle => ({
241
+ code: 'generalization-cycle', severity: 'warning', kind: 'entity', level, ids: cycle,
242
+ message: `Generalizations go round at the ${level} level: ${[...cycle, cycle[0]].map(name).join(' is a subtype of ')}`,
243
+ }));
244
+ });
245
+ }
246
+
247
+ /** Names that do not keep the model's naming standard (none: nothing to keep). */
248
+ naming() {
249
+ const standard = this.model.namingStandard;
250
+ if (!standard) return [];
251
+ return this.model.constructor.LEVELS.flatMap(level => this.model.level(level).entities.flatMap(e => {
252
+ const own = standard.check(level, 'entities', e.name);
253
+ const attributes = (e.attributes ?? []).map(a => [a, standard.check(level, 'attributes', a.name)]).filter(([, r]) => r.length);
254
+ return [
255
+ ...(own.length ? [this._entity(level, e, 'naming', 'info', `"${e.name}" ${own.join('; ')}`)] : []),
256
+ ...attributes.map(([a, reasons]) => this._entity(level, e, 'naming', 'info', `"${e.name}.${a.name}" ${reasons.join('; ')}`)),
257
+ ];
258
+ }));
17
259
  }
18
260
 
19
261
  /** The service domains (perspectives that own business objects). @private */
@@ -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
+ }
@@ -67,11 +67,21 @@ export class DataModelPerspective {
67
67
  return [];
68
68
  }
69
69
 
70
- /** How an entity it shows is marked at a level: `{ color?, details? }` (nothing here). */
71
- decorate(entity, level) {
70
+ /** How an entity it shows is marked at a level (the model given): `{ color?, details? }` (nothing here). */
71
+ decorate(entity, level, model) {
72
72
  return {};
73
73
  }
74
74
 
75
+ /**
76
+ * Forget an entity removed from a level: at the conceptual level, the
77
+ * concepts it shows and the names it gives them.
78
+ */
79
+ forgetEntity(level, id) {
80
+ if (level !== 'conceptual') return;
81
+ if (this.entities) this.entities = this.entities.filter(e => e !== id);
82
+ delete this.names[id];
83
+ }
84
+
75
85
  /** The business objects (logical entities) it owns, as a BIAN service domain does (none here). */
76
86
  ownedEntities() {
77
87
  return [];
@@ -1,51 +1,64 @@
1
- import { DataType } from './DataType.js';
2
-
3
- /**
4
- * EnumerationType — a data type whose values are a fixed list, each perhaps
5
- * with what it means (BIAN's `…typevalues`, a code list): `values` as
6
- * `[{ name, description? }]`. Drawn as a box with a row per value.
7
- */
8
- export class EnumerationType extends DataType {
9
- static kind = 'enumeration';
10
- static kindLabel = 'Enumeration';
11
-
12
- /** @param {Object} spec - As DataType, plus `values: [{ name, description? }]` (or plain names) */
13
- constructor({ values = [], ...spec } = {}) {
14
- super(spec);
15
- this.values = values.map(v => (typeof v === 'object' && v ? { name: String(v.name), ...(v.description && { description: v.description }) } : { name: String(v) }));
16
- }
17
-
18
- summary() {
19
- const shown = this.values.slice(0, 6).map(v => v.name).join(', ');
20
- return `${this.name}: one of ${shown}${this.values.length > 6 ? `, … (${this.values.length})` : ''}`;
21
- }
22
-
23
- rows() {
24
- return this.values.map(v => ({ name: v.name }));
25
- }
26
-
27
- toJSON() {
28
- return { ...super.toJSON(), values: this.values.map(v => ({ ...v })) };
29
- }
30
-
31
- /** A plain type's fields, then its values a line each: `name | what it means`. */
32
- static fields(model, type = null) {
33
- return [
34
- ...DataType.fields(model, type),
35
- {
36
- key: 'values', label: 'Values', type: 'textarea', rows: 6,
37
- value: (type?.values ?? []).map(v => (v.description ? `${v.name} | ${v.description}` : v.name)).join('\n'),
38
- hint: 'One a line: value | what it means (optional)',
39
- },
40
- ];
41
- }
42
-
43
- static specFrom(values) {
44
- return {
45
- ...super.specFrom(values),
46
- values: DataType.lines(values.values).map(([name, description]) => ({ name, ...(description && { description }) })),
47
- };
48
- }
49
- }
50
-
51
- DataType.register(EnumerationType);
1
+ import { DataType } from './DataType.js';
2
+
3
+ /**
4
+ * EnumerationType — a data type whose values are a fixed list, each perhaps
5
+ * with what it means (BIAN's `…typevalues`, a code list): `values` as
6
+ * `[{ name, description? }]`. As reference data (DMBOK2) a code set also says
7
+ * where it comes from (`source`: ISO 4217, a regulator, the bank), who keeps it
8
+ * (`owner`) and which version it is (`version`). Drawn as a box with a row per value.
9
+ */
10
+ export class EnumerationType extends DataType {
11
+ static kind = 'enumeration';
12
+ static kindLabel = 'Enumeration';
13
+
14
+ /** @param {Object} spec - As DataType, plus `values: [{ name, description? }]` (or plain names) */
15
+ constructor({ values = [], source = '', owner = '', version = '', ...spec } = {}) {
16
+ super(spec);
17
+ Object.assign(this, { source, owner, version });
18
+ this.values = values.map(v => (typeof v === 'object' && v ? { name: String(v.name), ...(v.description && { description: v.description }) } : { name: String(v) }));
19
+ }
20
+
21
+ summary() {
22
+ const shown = this.values.slice(0, 6).map(v => v.name).join(', ');
23
+ return `${this.name}: one of ${shown}${this.values.length > 6 ? `, … (${this.values.length})` : ''}`;
24
+ }
25
+
26
+ rows() {
27
+ return this.values.map(v => ({ name: v.name }));
28
+ }
29
+
30
+ /** Where it comes from, who keeps it, which version: what is said of them (none: {}). */
31
+ get codeSet() {
32
+ return Object.fromEntries([['source', this.source], ['owner', this.owner], ['version', this.version]].filter(([, v]) => v));
33
+ }
34
+
35
+ toJSON() {
36
+ return { ...super.toJSON(), ...this.codeSet, values: this.values.map(v => ({ ...v })) };
37
+ }
38
+
39
+ /** A plain type's fields, then its values a line each: `name | what it means`. */
40
+ static fields(model, type = null) {
41
+ return [
42
+ ...DataType.fields(model, type),
43
+ { key: 'source', label: 'Source', type: 'text', value: type?.source ?? '', placeholder: 'ISO 4217, the regulator, the bank' },
44
+ { key: 'owner', label: 'Owner', type: 'text', value: type?.owner ?? '' },
45
+ { key: 'version', label: 'Version', type: 'text', value: type?.version ?? '' },
46
+ {
47
+ key: 'values', label: 'Values', type: 'textarea', rows: 6,
48
+ value: (type?.values ?? []).map(v => (v.description ? `${v.name} | ${v.description}` : v.name)).join('\n'),
49
+ hint: 'One a line: value | what it means (optional)',
50
+ },
51
+ ];
52
+ }
53
+
54
+ static specFrom(values) {
55
+ const said = (key) => String(values[key] ?? '').trim();
56
+ return {
57
+ ...super.specFrom(values),
58
+ ...Object.fromEntries(['source', 'owner', 'version'].map(k => [k, said(k)]).filter(([, v]) => v)),
59
+ values: DataType.lines(values.values).map(([name, description]) => ({ name, ...(description && { description }) })),
60
+ };
61
+ }
62
+ }
63
+
64
+ DataType.register(EnumerationType);
@@ -27,6 +27,29 @@ export class LevelSchema {
27
27
  return this.entities.find(e => e.id === id) ?? null;
28
28
  }
29
29
 
30
+ /** An entity by id, else by name (any case), or null. */
31
+ named(text) {
32
+ const s = String(text ?? '').trim();
33
+ return this.entity(s) ?? this.entities.find(e => String(e.name ?? '').toLowerCase() === s.toLowerCase()) ?? null;
34
+ }
35
+
36
+ /**
37
+ * What a foreign key's `references` names: `{ entity, column }` (column
38
+ * null when it names the table alone), or null. `table` or `table.column`,
39
+ * the table by id or name; a table's own name may hold dots (a
40
+ * schema-qualified `sales.customer`), so the whole text is tried as a
41
+ * table first, then what is before its last dot, the rest its column.
42
+ */
43
+ resolveReference(ref) {
44
+ const text = String(ref ?? '').trim();
45
+ if (!text) return null;
46
+ const whole = this.named(text);
47
+ if (whole) return { entity: whole, column: null };
48
+ const dot = text.lastIndexOf('.');
49
+ const entity = dot > 0 ? this.named(text.slice(0, dot)) : null;
50
+ return entity ? { entity, column: text.slice(dot + 1).trim() || null } : null;
51
+ }
52
+
30
53
  /** Add an entity (its id unique at this level). */
31
54
  addEntity(entity) {
32
55
  if (!entity?.id) throw new TypeError('An entity needs an id');
@@ -69,6 +69,15 @@ export class ServiceDomain extends DataModelPerspective {
69
69
  });
70
70
  }
71
71
 
72
+ /** As a perspective, and at the logical level its control record, its qualifiers' entities and what it owns. */
73
+ forgetEntity(level, id) {
74
+ super.forgetEntity(level, id);
75
+ if (level !== 'logical') return;
76
+ if (this.controlRecord === id) this.controlRecord = null;
77
+ this.qualifiers = this.qualifiers.map(q => (q.entity === id ? (({ entity, ...rest }) => rest)(q) : q));
78
+ this.owns = this.owns.filter(e => e !== id);
79
+ }
80
+
72
81
  /** The business objects it owns: its control record, its qualifiers' entities, and those it names (`owns`). */
73
82
  ownedEntities() {
74
83
  return [...new Set([this.controlRecord, ...this.qualifiers.map(q => q.entity), ...this.owns].filter(Boolean))];