@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.
- package/README.md +66 -9
- package/REFERENCE.md +123 -0
- package/dist/renderengine-workspace.umd.js +5044 -2892
- package/dist/renderengine.umd.js +4850 -2888
- package/examples/data-model.html +40 -18
- package/package.json +1 -1
- package/src/charting/FormDialog.js +398 -385
- package/src/charting/RowsField.js +127 -0
- package/src/charting/datamodel/ClassificationLens.js +33 -0
- package/src/charting/datamodel/ClassificationScheme.js +98 -0
- package/src/charting/datamodel/DataModel.js +220 -4
- package/src/charting/datamodel/DataModelChecker.js +248 -2
- package/src/charting/datamodel/DataModelContext.js +40 -10
- package/src/charting/datamodel/DataModelEditCommand.js +47 -0
- package/src/charting/datamodel/DataModelPerspective.js +12 -2
- package/src/charting/datamodel/EnumerationType.js +64 -51
- package/src/charting/datamodel/ServiceDomain.js +9 -0
- package/src/charting/datamodel/SqlDdl.js +80 -0
- package/src/charting/datamodel/SqlDdlFileFormat.js +23 -0
- package/src/charting/datamodel/index.js +21 -15
- 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/diagrams/DataModelDiagram.js +4 -11
- package/src/charting/diagrams/DataModelRelationshipStrategy.js +7 -1
- package/src/charting/diagrams/NodeGeometry.js +6 -2
- package/src/charting/toolbar/ToolbarSelect.js +6 -0
- package/src/datamodel.js +402 -4
- package/src/index.js +2666 -2666
- 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
|
|
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 [
|
|
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: '
|
|
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
|
+
}
|
|
@@ -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? }]`.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
+
}
|