@peter.prib/renderengine 0.2.7 → 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 (45) hide show
  1. package/README.md +66 -9
  2. package/REFERENCE.md +123 -0
  3. package/dist/renderengine-workspace.umd.js +5044 -2892
  4. package/dist/renderengine.umd.js +4850 -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 +248 -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/ServiceDomain.js +9 -0
  18. package/src/charting/datamodel/SqlDdl.js +80 -0
  19. package/src/charting/datamodel/SqlDdlFileFormat.js +23 -0
  20. package/src/charting/datamodel/index.js +21 -15
  21. package/src/charting/datamodel/naming/CamelCaseStyle.js +13 -0
  22. package/src/charting/datamodel/naming/LowerWordsStyle.js +13 -0
  23. package/src/charting/datamodel/naming/NameStyle.js +57 -0
  24. package/src/charting/datamodel/naming/NamingStandard.js +132 -0
  25. package/src/charting/datamodel/naming/PascalCaseStyle.js +13 -0
  26. package/src/charting/datamodel/naming/SnakeCaseStyle.js +13 -0
  27. package/src/charting/datamodel/naming/TitleCaseStyle.js +25 -0
  28. package/src/charting/datamodel/naming/UpperSnakeStyle.js +13 -0
  29. package/src/charting/datamodel/naming/index.js +8 -0
  30. package/src/charting/datamodel/scorecard/CompletenessCategory.js +30 -0
  31. package/src/charting/datamodel/scorecard/ConsistencyCategory.js +33 -0
  32. package/src/charting/datamodel/scorecard/DefinitionsCategory.js +33 -0
  33. package/src/charting/datamodel/scorecard/ModelScorecard.js +113 -0
  34. package/src/charting/datamodel/scorecard/ReadabilityCategory.js +37 -0
  35. package/src/charting/datamodel/scorecard/ScorecardCategory.js +46 -0
  36. package/src/charting/datamodel/scorecard/StandardsCategory.js +29 -0
  37. package/src/charting/datamodel/scorecard/StructureCategory.js +32 -0
  38. package/src/charting/datamodel/scorecard/index.js +8 -0
  39. package/src/charting/diagrams/DataModelDiagram.js +4 -11
  40. package/src/charting/diagrams/DataModelRelationshipStrategy.js +7 -1
  41. package/src/charting/diagrams/NodeGeometry.js +6 -2
  42. package/src/charting/toolbar/ToolbarSelect.js +6 -0
  43. package/src/datamodel.js +402 -4
  44. package/src/index.js +2666 -2666
  45. 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,249 @@ 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 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
+ }));
17
263
  }
18
264
 
19
265
  /** 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);
@@ -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))];
@@ -0,0 +1,80 @@
1
+ /** Words SQL keeps for itself that a name may not be left bare as. */
2
+ const RESERVED = new Set(['user', 'order', 'group', 'table', 'select', 'from', 'where', 'by', 'to', 'key', 'check', 'default', 'index', 'primary', 'foreign', 'references', 'unique', 'column', 'constraint', 'value', 'values', 'date', 'time', 'timestamp', 'level', 'position', 'role']);
3
+
4
+ /**
5
+ * SqlDdl — a model's physical level as SQL DDL (DMBOK2's data integration: the
6
+ * design handed to a database): a CREATE TABLE per table, its columns with
7
+ * their types (`defaultType` where a column has none) and NOT NULL where
8
+ * required, its primary key and UNIQUE columns; a FOREIGN KEY per column that
9
+ * references another (`references`: a table, or `table.column`, by id or name;
10
+ * a table alone: its primary key); and COMMENT ONs for definitions. Names are
11
+ * quoted where SQL would not take them bare. ANSI SQL, as PostgreSQL reads it.
12
+ */
13
+ export class SqlDdl {
14
+ /**
15
+ * @param {Object} [options]
16
+ * @param {string} [options.defaultType] - A column's type when it names none
17
+ * @param {boolean} [options.comments] - Whether definitions become COMMENT ONs
18
+ */
19
+ constructor({ defaultType = 'VARCHAR(255)', comments = true } = {}) {
20
+ this.defaultType = defaultType;
21
+ this.comments = comments;
22
+ }
23
+
24
+ /** A name as SQL takes it: bare when it can be, else double-quoted. */
25
+ static name(text) {
26
+ const s = String(text);
27
+ return /^[a-z_][a-z0-9_]*$/.test(s) && !RESERVED.has(s) ? s : `"${s.replace(/"/g, '""')}"`;
28
+ }
29
+
30
+ /** A text as an SQL string literal. */
31
+ static literal(text) {
32
+ return `'${String(text).replace(/'/g, "''")}'`;
33
+ }
34
+
35
+ /** The DDL for a model's physical level. @returns {string} */
36
+ write(model) {
37
+ const schema = model.level('physical');
38
+ const tables = schema.entities;
39
+ const find = (ref) => {
40
+ const [table, column] = String(ref).split('.').map(s => s.trim());
41
+ const target = schema.entity(table) ?? tables.find(t => t.name.toLowerCase() === table.toLowerCase());
42
+ return target ? { target, column } : null;
43
+ };
44
+ const out = [`-- ${model.name || 'Data model'}: the physical level, ${tables.length} table${tables.length === 1 ? '' : 's'}`, ''];
45
+ for (const t of tables) {
46
+ const columns = t.attributes ?? [];
47
+ const keys = columns.filter(c => c.isPK).map(c => SqlDdl.name(c.name));
48
+ const lines = [
49
+ ...columns.map(c => ` ${SqlDdl.name(c.name)} ${c.type || this.defaultType}${c.required || c.isPK ? ' NOT NULL' : ''}`),
50
+ ...(keys.length ? [` PRIMARY KEY (${keys.join(', ')})`] : []),
51
+ ...columns.filter(c => c.isUnique && !c.isPK).map(c => ` UNIQUE (${SqlDdl.name(c.name)})`),
52
+ ];
53
+ out.push(`CREATE TABLE ${SqlDdl.name(t.name)} (`, lines.join(',\n'), ');', '');
54
+ }
55
+ for (const t of tables) {
56
+ for (const c of (t.attributes ?? []).filter(a => a.isFK && a.references)) {
57
+ const ref = find(c.references);
58
+ if (!ref) {
59
+ out.push(`-- ${t.name}.${c.name} references ${c.references}, which is not a table here`);
60
+ continue;
61
+ }
62
+ const theirs = ref.column ? [ref.column] : (ref.target.attributes ?? []).filter(a => a.isPK).map(a => a.name);
63
+ if (!theirs.length) {
64
+ out.push(`-- ${t.name}.${c.name} references ${ref.target.name}, which has no primary key`);
65
+ continue;
66
+ }
67
+ out.push(`ALTER TABLE ${SqlDdl.name(t.name)} ADD CONSTRAINT ${SqlDdl.name(`fk_${t.name}_${c.name}`.toLowerCase().replace(/[^a-z0-9_]+/g, '_'))}`
68
+ + ` FOREIGN KEY (${SqlDdl.name(c.name)}) REFERENCES ${SqlDdl.name(ref.target.name)} (${theirs.map(SqlDdl.name).join(', ')});`);
69
+ }
70
+ }
71
+ if (this.comments) {
72
+ const notes = tables.flatMap(t => [
73
+ ...(t.description ? [`COMMENT ON TABLE ${SqlDdl.name(t.name)} IS ${SqlDdl.literal(t.description)};`] : []),
74
+ ...(t.attributes ?? []).filter(c => c.description).map(c => `COMMENT ON COLUMN ${SqlDdl.name(t.name)}.${SqlDdl.name(c.name)} IS ${SqlDdl.literal(c.description)};`),
75
+ ]);
76
+ if (notes.length) out.push('', ...notes);
77
+ }
78
+ return `${out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
79
+ }
80
+ }