@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
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** A ratio as a whole score out of 100 (none: null). */
|
|
2
|
+
export const percent = (part, whole) => (whole ? Math.round((100 * part) / whole) : null);
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ScorecardCategory — one of the Data Model Scorecard's ten categories (Steve
|
|
6
|
+
* Hoberman, *Data Model Scorecard*, 2015): its `key`, `label` and the question
|
|
7
|
+
* it asks. This base is one a reviewer scores (`measure` says nothing); a
|
|
8
|
+
* subclass measures it from the model, a score out of 100 and notes on why,
|
|
9
|
+
* or null when the model cannot say. A reviewer's score overrides either.
|
|
10
|
+
*/
|
|
11
|
+
export class ScorecardCategory {
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} key - 'correctness'
|
|
14
|
+
* @param {string} label - 'Correctness'
|
|
15
|
+
* @param {string} question - What it asks of the model
|
|
16
|
+
*/
|
|
17
|
+
constructor(key, label, question) {
|
|
18
|
+
Object.assign(this, { key, label, question });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Whether the model says how it scores (else a reviewer must). */
|
|
22
|
+
get measured() {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Its score from the model: `{ score: 0..100 | null, notes: string[] }`.
|
|
28
|
+
* @param {DataModel} model
|
|
29
|
+
* @param {Array<Object>} findings - The model check's findings (DataModelChecker#run)
|
|
30
|
+
*/
|
|
31
|
+
measure(model, findings) {
|
|
32
|
+
return { score: null, notes: ['for a reviewer to score'] };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A category measured from the model: a subclass says how. */
|
|
37
|
+
export class MeasuredCategory extends ScorecardCategory {
|
|
38
|
+
get measured() {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @abstract */
|
|
43
|
+
measure(model, findings) {
|
|
44
|
+
throw new Error(`${this.constructor.name} must implement measure()`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { MeasuredCategory, percent } from './ScorecardCategory.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Standards: are names as the model's naming standard says? Measured as the
|
|
5
|
+
* share of entity and attribute names that keep it; without a standard the
|
|
6
|
+
* model cannot say (a reviewer may).
|
|
7
|
+
*/
|
|
8
|
+
export class StandardsCategory extends MeasuredCategory {
|
|
9
|
+
constructor() {
|
|
10
|
+
super('standards', 'Standards', 'Are names written as the naming standard says?');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
measure(model) {
|
|
14
|
+
const standard = model.namingStandard;
|
|
15
|
+
if (!standard) return { score: null, notes: ['no naming standard: set one to measure this'] };
|
|
16
|
+
let names = 0, kept = 0;
|
|
17
|
+
for (const level of model.constructor.LEVELS) {
|
|
18
|
+
for (const e of model.level(level).entities) {
|
|
19
|
+
names++;
|
|
20
|
+
if (!standard.check(level, 'entities', e.name).length) kept++;
|
|
21
|
+
for (const a of e.attributes ?? []) {
|
|
22
|
+
names++;
|
|
23
|
+
if (!standard.check(level, 'attributes', a.name).length) kept++;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return names ? { score: percent(kept, names), notes: [`${kept} of ${names} names keep the naming standard`] } : { score: null, notes: ['no names'] };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { MeasuredCategory, percent } from './ScorecardCategory.js';
|
|
2
|
+
|
|
3
|
+
/** The model check's findings that are about structure. */
|
|
4
|
+
const STRUCTURAL = new Set([
|
|
5
|
+
'no-primary-key', 'reference-missing', 'foreign-key-without-relationship', 'foreign-key-unrelated',
|
|
6
|
+
'many-to-many', 'repeating-group', 'generalization-cycle',
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Structure: is the model sound? Measured as the share of logical entities and
|
|
11
|
+
* tables the model check finds nothing structural on (keys, foreign keys,
|
|
12
|
+
* many-to-many, repeating groups, generalizations going round).
|
|
13
|
+
*/
|
|
14
|
+
export class StructureCategory extends MeasuredCategory {
|
|
15
|
+
static STRUCTURAL = STRUCTURAL;
|
|
16
|
+
|
|
17
|
+
constructor() {
|
|
18
|
+
super('structure', 'Structure', 'Is it sound: keys, foreign keys, no repeating groups or unresolved many-to-many?');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
measure(model, findings) {
|
|
22
|
+
const entities = ['logical', 'physical'].flatMap(level => model.level(level).entities.map(e => `${level}:${e.id}`));
|
|
23
|
+
if (!entities.length) return { score: null, notes: ['no logical entities or tables'] };
|
|
24
|
+
const structural = findings.filter(f => STRUCTURAL.has(f.code));
|
|
25
|
+
const touched = new Set(structural.flatMap(f => (f.ids ?? []).map(id => `${f.level}:${id}`)).filter(k => entities.includes(k)));
|
|
26
|
+
const counts = structural.reduce((m, f) => ((m[f.code] = (m[f.code] ?? 0) + 1), m), {});
|
|
27
|
+
return {
|
|
28
|
+
score: percent(entities.length - touched.size, entities.length),
|
|
29
|
+
notes: [`${entities.length - touched.size} of ${entities.length} logical entities and tables sound`, ...Object.entries(counts).map(([code, n]) => `${n} ${code}`)],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { ScorecardCategory, MeasuredCategory } from './ScorecardCategory.js';
|
|
2
|
+
export { CompletenessCategory } from './CompletenessCategory.js';
|
|
3
|
+
export { StructureCategory } from './StructureCategory.js';
|
|
4
|
+
export { StandardsCategory } from './StandardsCategory.js';
|
|
5
|
+
export { DefinitionsCategory } from './DefinitionsCategory.js';
|
|
6
|
+
export { ConsistencyCategory } from './ConsistencyCategory.js';
|
|
7
|
+
export { ReadabilityCategory } from './ReadabilityCategory.js';
|
|
8
|
+
export { ModelScorecard } from './ModelScorecard.js';
|
|
@@ -94,8 +94,8 @@ export class DataModelDiagram extends Diagram {
|
|
|
94
94
|
* note to what it is about, or (the `links` option) a mapping between levels.
|
|
95
95
|
* @param {{id?: string, source: string, target: string, label?: string}} link
|
|
96
96
|
*/
|
|
97
|
-
static buildLink({ id, source, target, label }) {
|
|
98
|
-
return { ...(id && { id }), source, target, ...(label && { label }), lineStyle: 'dotted', arrowhead: 'none', arrowheadStart: 'none' };
|
|
97
|
+
static buildLink({ id, source, target, label, color }) {
|
|
98
|
+
return { ...(id && { id }), source, target, ...(label && { label }), ...(color && { color }), lineStyle: 'dotted', arrowhead: 'none', arrowheadStart: 'none' };
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
static typeGroup(type) {
|
|
@@ -146,15 +146,8 @@ export class DataModelDiagram extends Diagram {
|
|
|
146
146
|
};
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/** A relationship's edge in a notation, as its kind draws it (DataModelNotation.relationshipKind). */
|
|
149
150
|
static buildEdge(relationship, notation) {
|
|
150
|
-
return
|
|
151
|
-
...relationship,
|
|
152
|
-
label: relationship.name,
|
|
153
|
-
lineStyle: relationship.identifying === false ? 'dashed' : 'solid',
|
|
154
|
-
arrowheadStart: notation.cardinalityArrowhead(relationship.sourceCardinality) || 'none',
|
|
155
|
-
arrowhead: notation.cardinalityArrowhead(relationship.targetCardinality) || 'none',
|
|
156
|
-
sourceLabel: notation.cardinalityLabel(relationship.sourceCardinality),
|
|
157
|
-
targetLabel: notation.cardinalityLabel(relationship.targetCardinality),
|
|
158
|
-
};
|
|
151
|
+
return DataModelNotation.relationshipKind(relationship).edge(relationship, notation);
|
|
159
152
|
}
|
|
160
153
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DataModelNotation } from '../../datamodel.js';
|
|
1
2
|
/**
|
|
2
3
|
* Strategy interface for turning data-model relationships into diagram nodes
|
|
3
4
|
* and edges. Crow's Foot and UML keep relationships as direct edges; Chen
|
|
@@ -68,12 +69,17 @@ export class RelationshipAsNodeStrategy extends DataModelRelationshipStrategy {
|
|
|
68
69
|
return best; // none clear: the least covered
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
apply(relationships, { notation, entity = () => undefined, occupied = [] }) {
|
|
72
|
+
apply(relationships, { notation, buildEdge, entity = () => undefined, occupied = [] }) {
|
|
72
73
|
const nodes = [];
|
|
73
74
|
const edges = [];
|
|
74
75
|
const taken = [...occupied];
|
|
75
76
|
const { width, height } = RelationshipAsNodeStrategy.DIAMOND;
|
|
76
77
|
relationships.forEach((relationship, i) => {
|
|
78
|
+
// A kind drawn as a line in this notation (a generalization) is one.
|
|
79
|
+
if (!DataModelNotation.relationshipKind(relationship).drawnAsNode(notation)) {
|
|
80
|
+
edges.push(buildEdge(relationship, notation));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
77
83
|
const relId = relationship.id || `${relationship.source}__${relationship.target}__rel${i}`;
|
|
78
84
|
const lineStyle = relationship.identifying === false ? 'dashed' : 'solid';
|
|
79
85
|
const [from, to] = [entity(relationship.source), entity(relationship.target)];
|
|
@@ -55,7 +55,11 @@ export class NodeGeometry {
|
|
|
55
55
|
return posEntry.y - size.h / 2 + headerH + idx * rowH + rowH / 2;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Draws a table-card node's header + one row per column into wrapper `g`;
|
|
60
|
+
* the node's own `color` tints the card's body (a lens, a marked control
|
|
61
|
+
* record), the header staying as it is so its name reads.
|
|
62
|
+
*/
|
|
59
63
|
static buildTableNodeContent(renderer, g, node) {
|
|
60
64
|
const { headerH, rowH } = NodeGeometry.TABLE_NODE;
|
|
61
65
|
const { w, h } = NodeGeometry.tableNodeSize(node);
|
|
@@ -65,7 +69,7 @@ export class NodeGeometry {
|
|
|
65
69
|
const borderWidth = node.selected ? 2 : 1;
|
|
66
70
|
|
|
67
71
|
const TYPE_COLORS = { NUMBER: '#74b9ff', TEXT: '#a29bfe', TIMESTAMP: '#55efc4', BOOLEAN: '#ffeaa7', JSON: '#fd79a8', UNKNOWN: '#dfe6e9' };
|
|
68
|
-
g.appendChild(renderer.rect(x0, y0, w, h, '#ffffff', { color: borderColor, width: borderWidth }));
|
|
72
|
+
g.appendChild(renderer.rect(x0, y0, w, h, node.color || '#ffffff', { color: borderColor, width: borderWidth }));
|
|
69
73
|
g.appendChild(renderer.rect(x0, y0, w, headerH, '#1976d2', null));
|
|
70
74
|
g.appendChild(renderer.text(LabelHelper.resolveNodeLabel(node), x0 + 8, y0 + headerH / 2, {
|
|
71
75
|
fill: '#fff', textAnchor: 'start', verticalAlign: 'middle', fontSize: '11px', fontWeight: 'bold', 'pointer-events': 'none',
|
|
@@ -35,4 +35,10 @@ export class ToolbarSelect extends ToolbarItem {
|
|
|
35
35
|
],
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
|
+
|
|
39
|
+
/** Show the value as it is now (it may have been chosen other than through this select). */
|
|
40
|
+
refresh(node) {
|
|
41
|
+
const select = [...(node?.children ?? [])].find(c => c.tagName === 'SELECT');
|
|
42
|
+
if (select) select.value = this.value();
|
|
43
|
+
}
|
|
38
44
|
}
|
package/src/datamodel.js
CHANGED
|
@@ -35,7 +35,19 @@
|
|
|
35
35
|
* the same calibration archimateDiagram's own icons use.
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
-
/**
|
|
38
|
+
/** Text from a dialog field, trimmed. */
|
|
39
|
+
const text = (v) => String(v ?? '').trim();
|
|
40
|
+
/** Categories as written ('PII, PCI') as a list. */
|
|
41
|
+
const list = (v) => (Array.isArray(v) ? v : String(v ?? '').split(/[,;]/)).map(x => String(x).trim()).filter(Boolean);
|
|
42
|
+
/** Who answers for an entity (DMBOK2's stewardship), in its dialog's order. */
|
|
43
|
+
const STEWARDS = Object.freeze([['owner', 'Business owner'], ['steward', 'Data steward'], ['custodian', 'Technical custodian']]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One data-modelling abstraction level (see DataModelNotation.levels()): what
|
|
47
|
+
* it is, and what its entities and relationships hold, as the dialogs that
|
|
48
|
+
* edit them show it. A level's entity has a name and a definition; a level
|
|
49
|
+
* whose entities have attributes (AttributedLevel) adds them as a table.
|
|
50
|
+
*/
|
|
39
51
|
export class DataModelLevel {
|
|
40
52
|
constructor(name, description) {
|
|
41
53
|
this.name = name;
|
|
@@ -46,6 +58,195 @@ export class DataModelLevel {
|
|
|
46
58
|
get label() {
|
|
47
59
|
return this.name.charAt(0).toUpperCase() + this.name.slice(1);
|
|
48
60
|
}
|
|
61
|
+
|
|
62
|
+
/** What an entity is called at this level ('Concept', 'Entity', 'Table'). */
|
|
63
|
+
get entityNoun() {
|
|
64
|
+
return 'Entity';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* An entity's dialog fields (FormDialog): its name and definition, who
|
|
69
|
+
* answers for it (business owner, data steward, technical custodian), and
|
|
70
|
+
* how it is classified (a sensitivity of the model's scheme, categories).
|
|
71
|
+
* @param {Object|null} entity - null for a new one
|
|
72
|
+
* @param {Object} [context] - What the fields may offer: `typeNames` (the model's data types),
|
|
73
|
+
* `sensitivities` and `categories` (its classification scheme's)
|
|
74
|
+
*/
|
|
75
|
+
entityFields(entity, context = {}) {
|
|
76
|
+
const { sensitivities = [], categories = [] } = context;
|
|
77
|
+
return [
|
|
78
|
+
{ key: 'name', label: 'Name', type: 'text', value: entity?.name ?? '' },
|
|
79
|
+
{ key: 'description', label: 'Definition', type: 'textarea', rows: 3, value: entity?.description ?? '', placeholder: 'What it is, in the business\'s words' },
|
|
80
|
+
{ type: 'heading', label: 'Stewardship' },
|
|
81
|
+
...STEWARDS.map(([key, label]) => ({ key: `stewardship.${key}`, label, type: 'text', value: entity?.stewardship?.[key] ?? '' })),
|
|
82
|
+
{ type: 'heading', label: 'Classification' },
|
|
83
|
+
{ key: 'sensitivity', label: 'Sensitivity', type: 'select', value: entity?.sensitivity ?? '', choices: [['', 'Unclassified'], ...sensitivities.map(s => [s, s])] },
|
|
84
|
+
{ key: 'categories', label: 'Categories', type: 'text', value: list(entity?.categories).join(', '), placeholder: categories.join(', ') },
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** What those fields say, laid over the entity (what they do not show kept; an empty one taken away). */
|
|
89
|
+
entitySpecFrom(values, entity = null) {
|
|
90
|
+
const out = { ...(entity ?? {}), name: text(values.name) };
|
|
91
|
+
const keep = (key, value) => { if (value === '' || (Array.isArray(value) && !value.length)) delete out[key]; else out[key] = value; };
|
|
92
|
+
keep('description', text(values.description));
|
|
93
|
+
const stewardship = Object.fromEntries(STEWARDS.map(([key]) => [key, text(values[`stewardship.${key}`])]).filter(([, v]) => v));
|
|
94
|
+
keep('stewardship', Object.keys(stewardship).length ? stewardship : '');
|
|
95
|
+
keep('sensitivity', text(values.sensitivity));
|
|
96
|
+
keep('categories', list(values.categories));
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A relationship's dialog fields: its two ends (entities at this level),
|
|
102
|
+
* its name, each end's cardinality, whether it is identifying, a definition.
|
|
103
|
+
* @param {Array<Object>} entities - The level's entities
|
|
104
|
+
* @param {Object|null} relationship - null for a new one
|
|
105
|
+
*/
|
|
106
|
+
relationshipFields(entities, relationship = null) {
|
|
107
|
+
const ends = entities.map(e => [e.id, e.name]);
|
|
108
|
+
const cardinalities = DataModelNotation.cardinalities().map(c => [c, c]);
|
|
109
|
+
return [
|
|
110
|
+
{
|
|
111
|
+
key: 'kind', label: 'Kind', type: 'select', value: RelationshipKind.of(relationship).kind,
|
|
112
|
+
choices: RelationshipKind.all().map(k => [k.kind, k.label]),
|
|
113
|
+
hint: 'A generalization: From is a subtype of To; its cardinalities are not used',
|
|
114
|
+
},
|
|
115
|
+
{ key: 'source', label: 'From', type: 'select', value: relationship?.source ?? ends[0]?.[0] ?? '', choices: ends },
|
|
116
|
+
{ key: 'sourceCardinality', label: 'Its cardinality', type: 'select', value: relationship?.sourceCardinality ?? '1', choices: cardinalities },
|
|
117
|
+
{ key: 'name', label: 'Name', type: 'text', value: relationship?.name ?? '', placeholder: 'has, places, is for…' },
|
|
118
|
+
{ key: 'target', label: 'To', type: 'select', value: relationship?.target ?? ends[1]?.[0] ?? ends[0]?.[0] ?? '', choices: ends },
|
|
119
|
+
{ key: 'targetCardinality', label: 'Its cardinality', type: 'select', value: relationship?.targetCardinality ?? '0..*', choices: cardinalities },
|
|
120
|
+
{ key: 'identifying', label: 'Identifying', type: 'checkbox', value: relationship ? relationship.identifying !== false : true },
|
|
121
|
+
{ key: 'complete', label: 'Complete (generalization)', type: 'checkbox', value: !!relationship?.complete },
|
|
122
|
+
{ key: 'disjoint', label: 'Disjoint (generalization)', type: 'checkbox', value: relationship ? relationship.disjoint !== false : true },
|
|
123
|
+
{ key: 'description', label: 'Definition', type: 'textarea', rows: 2, value: relationship?.description ?? '' },
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** What those fields say, laid over the relationship. */
|
|
128
|
+
relationshipSpecFrom(values, relationship = null) {
|
|
129
|
+
const out = {
|
|
130
|
+
...(relationship ?? {}),
|
|
131
|
+
source: values.source, target: values.target, name: text(values.name),
|
|
132
|
+
sourceCardinality: values.sourceCardinality, targetCardinality: values.targetCardinality,
|
|
133
|
+
};
|
|
134
|
+
if (values.identifying) delete out.identifying; else out.identifying = false;
|
|
135
|
+
if (text(values.description)) out.description = text(values.description); else delete out.description;
|
|
136
|
+
const kind = RelationshipKind.KINDS.get(values.kind) ?? RelationshipKind.of(relationship);
|
|
137
|
+
delete out.complete; delete out.disjoint; delete out.kind;
|
|
138
|
+
if (kind.kind !== AssociationKind.kind) out.kind = kind.kind;
|
|
139
|
+
if (!kind.hasCardinality) {
|
|
140
|
+
delete out.sourceCardinality; delete out.targetCardinality; delete out.identifying;
|
|
141
|
+
out.complete = !!values.complete;
|
|
142
|
+
out.disjoint = values.disjoint !== false;
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A level whose entities have attributes (logical attributes, physical
|
|
150
|
+
* columns): its entity dialog adds them as a table (a FormDialog `rows`
|
|
151
|
+
* field), a row each: the attribute's name, type, keys, whether it is
|
|
152
|
+
* required, what a foreign key references, its definition.
|
|
153
|
+
*/
|
|
154
|
+
export class AttributedLevel extends DataModelLevel {
|
|
155
|
+
/** What its attributes are called ('Attributes', 'Columns'). */
|
|
156
|
+
get attributeNoun() {
|
|
157
|
+
return 'Attributes';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The types its type column suggests. @param {Object} context */
|
|
161
|
+
typeSuggestions(context) {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The attribute table's columns. */
|
|
166
|
+
attributeColumns(context = {}) {
|
|
167
|
+
return [
|
|
168
|
+
{ key: 'name', label: 'Name' },
|
|
169
|
+
{ key: 'type', label: 'Type', suggestions: this.typeSuggestions(context), width: '130px' },
|
|
170
|
+
{ key: 'isPK', label: 'PK', type: 'checkbox' },
|
|
171
|
+
{ key: 'isFK', label: 'FK', type: 'checkbox' },
|
|
172
|
+
{ key: 'isUnique', label: 'Unique', type: 'checkbox' },
|
|
173
|
+
{ key: 'required', label: 'Required', type: 'checkbox' },
|
|
174
|
+
{ key: 'references', label: 'References', placeholder: 'entity.attribute', width: '140px' },
|
|
175
|
+
{ key: 'description', label: 'Definition' },
|
|
176
|
+
{ key: 'sensitivity', label: 'Sensitivity', type: 'select', choices: [['', '—'], ...(context.sensitivities ?? []).map(x => [x, x])] },
|
|
177
|
+
{ key: 'categories', label: 'Categories', placeholder: (context.categories ?? []).slice(0, 2).join(', '), width: '90px' },
|
|
178
|
+
{ key: 'measure', label: 'Measure', type: 'select', choices: [['', '—'], ...MEASURES.map(m => [m, m])] },
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
entityFields(entity, context = {}) {
|
|
183
|
+
return [
|
|
184
|
+
...super.entityFields(entity, context),
|
|
185
|
+
{ type: 'heading', label: 'Dimensional' },
|
|
186
|
+
{ key: 'role', label: 'Role', type: 'select', value: EntityRole.of(entity).role, choices: EntityRole.all().map(r => [r.role, r.label]) },
|
|
187
|
+
{ key: 'grain', label: 'Grain (a fact)', type: 'text', value: entity?.grain ?? '', placeholder: 'One row per account per day' },
|
|
188
|
+
{ key: 'scdType', label: 'History (a dimension)', type: 'select', value: entity?.scdType ?? '', choices: [['', '—'], ...DimensionRole.SCD_TYPES] },
|
|
189
|
+
{ key: 'conformed', label: 'Conformed (a dimension)', type: 'checkbox', value: !!entity?.conformed },
|
|
190
|
+
{
|
|
191
|
+
key: 'attributes', label: this.attributeNoun, type: 'rows', columns: this.attributeColumns(context),
|
|
192
|
+
// Categories shown as written, a list read back (entitySpecFrom).
|
|
193
|
+
value: (entity?.attributes ?? []).map(a => (Array.isArray(a.categories) ? { ...a, categories: a.categories.join(', ') } : a)),
|
|
194
|
+
addLabel: this.attributeNoun.replace(/s$/, '').toLowerCase(),
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
entitySpecFrom(values, entity = null) {
|
|
200
|
+
// An attribute's categories as a list, as an entity's are.
|
|
201
|
+
const attributes = (values.attributes ?? []).filter(a => text(a.name)).map(a => {
|
|
202
|
+
if (a.categories === undefined) return a;
|
|
203
|
+
const categories = list(a.categories);
|
|
204
|
+
const { categories: _, ...rest } = a;
|
|
205
|
+
return categories.length ? { ...rest, categories } : rest;
|
|
206
|
+
});
|
|
207
|
+
const out = { ...super.entitySpecFrom(values, entity), attributes };
|
|
208
|
+
// Its role, and only what that role has (a fact's grain, a dimension's history).
|
|
209
|
+
const role = EntityRole.ROLES.get(values.role ?? EntityRole.of(entity).role) ?? EntityRole.of(null);
|
|
210
|
+
for (const key of ['role', 'grain', 'scdType', 'conformed']) delete out[key];
|
|
211
|
+
if (role.role) out.role = role.role;
|
|
212
|
+
if (role.keys.includes('grain') && text(values.grain)) out.grain = text(values.grain);
|
|
213
|
+
if (role.keys.includes('scdType') && values.scdType !== '' && values.scdType != null) out.scdType = String(values.scdType);
|
|
214
|
+
if (role.keys.includes('conformed') && values.conformed) out.conformed = true;
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The logical level: entities and their attributes, typed by the model's data types or plainly. */
|
|
220
|
+
export class LogicalLevel extends AttributedLevel {
|
|
221
|
+
static PLAIN_TYPES = Object.freeze(['Text', 'Number', 'Decimal', 'Amount', 'Date', 'Date/Time', 'Boolean', 'Identifier', 'Code']);
|
|
222
|
+
|
|
223
|
+
typeSuggestions({ typeNames = [] } = {}) {
|
|
224
|
+
return [...typeNames, ...LogicalLevel.PLAIN_TYPES];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The physical level: tables and their columns, typed as a database types them. */
|
|
229
|
+
export class PhysicalLevel extends AttributedLevel {
|
|
230
|
+
static SQL_TYPES = Object.freeze(['VARCHAR(40)', 'TEXT', 'INT', 'BIGINT', 'DECIMAL(18,2)', 'DATE', 'TIMESTAMP', 'BOOLEAN', 'CHAR(3)', 'JSONB']);
|
|
231
|
+
|
|
232
|
+
get entityNoun() {
|
|
233
|
+
return 'Table';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
get attributeNoun() {
|
|
237
|
+
return 'Columns';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
typeSuggestions() {
|
|
241
|
+
return PhysicalLevel.SQL_TYPES;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The conceptual level: concepts with a name and a definition, no attributes. */
|
|
246
|
+
export class ConceptualLevel extends DataModelLevel {
|
|
247
|
+
get entityNoun() {
|
|
248
|
+
return 'Concept';
|
|
249
|
+
}
|
|
49
250
|
}
|
|
50
251
|
|
|
51
252
|
/** The canonical cardinality vocabulary every NotationStyle renders
|
|
@@ -91,9 +292,9 @@ export class NotationStyle {
|
|
|
91
292
|
}
|
|
92
293
|
|
|
93
294
|
const LEVELS = {
|
|
94
|
-
conceptual: new
|
|
95
|
-
logical: new
|
|
96
|
-
physical: new
|
|
295
|
+
conceptual: new ConceptualLevel('conceptual', 'Entities and their relationships only — no attributes, no keys, no types.'),
|
|
296
|
+
logical: new LogicalLevel('logical', 'Attributes and keys, still database-agnostic — no column types or engine specifics.'),
|
|
297
|
+
physical: new PhysicalLevel('physical', 'Real column types, keys, and constraints as they exist in an actual database.'),
|
|
97
298
|
};
|
|
98
299
|
|
|
99
300
|
const NOTATIONS = {
|
|
@@ -120,6 +321,197 @@ const NOTATIONS = {
|
|
|
120
321
|
}),
|
|
121
322
|
};
|
|
122
323
|
|
|
324
|
+
/**
|
|
325
|
+
* RelationshipKind — what a relationship between two entities is: an
|
|
326
|
+
* association (`AssociationKind`, a cardinality at each end, a Chen diamond)
|
|
327
|
+
* or a generalization (`GeneralizationKind`, the source a subtype of the
|
|
328
|
+
* target). A relationship's `kind` names one (none: an association); each
|
|
329
|
+
* draws its own edge in a notation, says whether a notation draws it as a node
|
|
330
|
+
* of its own, and whether it has cardinalities.
|
|
331
|
+
*/
|
|
332
|
+
export class RelationshipKind {
|
|
333
|
+
static kind = '';
|
|
334
|
+
static label = '';
|
|
335
|
+
|
|
336
|
+
/** Relationship kinds by their `kind`. */
|
|
337
|
+
static KINDS = new Map();
|
|
338
|
+
|
|
339
|
+
static register(KindClass) {
|
|
340
|
+
RelationshipKind.KINDS.set(KindClass.kind, new KindClass());
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** A relationship's kind (an association when it names none, or one not known). */
|
|
344
|
+
static of(relationship) {
|
|
345
|
+
return RelationshipKind.KINDS.get(relationship?.kind) ?? RelationshipKind.KINDS.get('association');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Every kind, in the order registered. */
|
|
349
|
+
static all() {
|
|
350
|
+
return [...RelationshipKind.KINDS.values()];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
get kind() { return this.constructor.kind; }
|
|
354
|
+
get label() { return this.constructor.label; }
|
|
355
|
+
|
|
356
|
+
/** Whether it has a cardinality at each end. */
|
|
357
|
+
get hasCardinality() {
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Whether a notation draws it as a node of its own (Chen's diamond). */
|
|
362
|
+
drawnAsNode(notation) {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** The graphDiagram edge it is in a notation. @abstract */
|
|
367
|
+
edge(relationship, notation) {
|
|
368
|
+
throw new Error(`${this.constructor.name} must implement edge()`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** An association: a cardinality at each end, drawn as each notation draws one (a Chen diamond). */
|
|
373
|
+
export class AssociationKind extends RelationshipKind {
|
|
374
|
+
static kind = 'association';
|
|
375
|
+
static label = 'Association';
|
|
376
|
+
|
|
377
|
+
drawnAsNode(notation) {
|
|
378
|
+
return !!notation.relationshipAsNode;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
edge(relationship, notation) {
|
|
382
|
+
return {
|
|
383
|
+
...relationship,
|
|
384
|
+
label: relationship.name,
|
|
385
|
+
lineStyle: relationship.identifying === false ? 'dashed' : 'solid',
|
|
386
|
+
arrowheadStart: notation.cardinalityArrowhead(relationship.sourceCardinality) || 'none',
|
|
387
|
+
arrowhead: notation.cardinalityArrowhead(relationship.targetCardinality) || 'none',
|
|
388
|
+
sourceLabel: notation.cardinalityLabel(relationship.sourceCardinality),
|
|
389
|
+
targetLabel: notation.cardinalityLabel(relationship.targetCardinality),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* A generalization (DMBOK2's subtypes and supertypes): the source a subtype
|
|
396
|
+
* of the target. `complete` (every instance of the supertype is one of its
|
|
397
|
+
* subtypes) and `disjoint` (an instance is one subtype at most) are said on
|
|
398
|
+
* it. Drawn in every notation as a line to the supertype ending in a hollow
|
|
399
|
+
* triangle, its constraints its label (Chen's an ISA one); no cardinalities.
|
|
400
|
+
*/
|
|
401
|
+
export class GeneralizationKind extends RelationshipKind {
|
|
402
|
+
static kind = 'generalization';
|
|
403
|
+
static label = 'Generalization (subtype of)';
|
|
404
|
+
|
|
405
|
+
get hasCardinality() {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Its constraints in words: '{complete, disjoint}'. */
|
|
410
|
+
static constraints(relationship) {
|
|
411
|
+
return `{${relationship.complete ? 'complete' : 'incomplete'}, ${relationship.disjoint === false ? 'overlapping' : 'disjoint'}}`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
edge(relationship, notation) {
|
|
415
|
+
const { sourceCardinality, targetCardinality, ...rest } = relationship;
|
|
416
|
+
const words = GeneralizationKind.constraints(relationship);
|
|
417
|
+
return {
|
|
418
|
+
...rest,
|
|
419
|
+
label: notation.relationshipAsNode ? `ISA ${words}` : words,
|
|
420
|
+
lineStyle: 'solid', arrowhead: 'triangle-hollow', arrowheadStart: 'none',
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
RelationshipKind.register(AssociationKind);
|
|
426
|
+
RelationshipKind.register(GeneralizationKind);
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* EntityRole — what an entity is in a dimensional scheme (DMBOK2's data
|
|
430
|
+
* warehousing and BI, after Kimball): a plain entity (`EntityRole`, none), a
|
|
431
|
+
* fact (`FactRole`: what is measured, at a stated `grain`, its attributes
|
|
432
|
+
* `measure`s) or a dimension (`DimensionRole`: what facts are looked at by,
|
|
433
|
+
* its `scdType` how it keeps history, `conformed` when facts share it). An
|
|
434
|
+
* entity's `role` names one; each says how it is drawn in the star view.
|
|
435
|
+
*/
|
|
436
|
+
export class EntityRole {
|
|
437
|
+
static role = '';
|
|
438
|
+
static label = 'None';
|
|
439
|
+
/** The colour its entities are drawn in, in the star view. */
|
|
440
|
+
static color = null;
|
|
441
|
+
|
|
442
|
+
/** Entity roles by their `role` ('' the plain one). */
|
|
443
|
+
static ROLES = new Map();
|
|
444
|
+
|
|
445
|
+
static register(RoleClass) {
|
|
446
|
+
EntityRole.ROLES.set(RoleClass.role, new RoleClass());
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** An entity's role (the plain one when it names none, or one not known). */
|
|
450
|
+
static of(entity) {
|
|
451
|
+
return EntityRole.ROLES.get(entity?.role ?? '') ?? EntityRole.ROLES.get('');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
static all() {
|
|
455
|
+
return [...EntityRole.ROLES.values()];
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
get role() { return this.constructor.role; }
|
|
459
|
+
get label() { return this.constructor.label; }
|
|
460
|
+
get color() { return this.constructor.color; }
|
|
461
|
+
|
|
462
|
+
/** What is said of an entity in this role, for a hover: `{}` here. */
|
|
463
|
+
details(entity) {
|
|
464
|
+
return {};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** The keys this role adds to an entity (FactRole's grain...): none here. */
|
|
468
|
+
get keys() {
|
|
469
|
+
return [];
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** A fact: measured, at a grain. */
|
|
474
|
+
export class FactRole extends EntityRole {
|
|
475
|
+
static role = 'fact';
|
|
476
|
+
static label = 'Fact';
|
|
477
|
+
static color = '#fde68a';
|
|
478
|
+
|
|
479
|
+
details(entity) {
|
|
480
|
+
return { role: 'fact', ...(entity.grain && { grain: entity.grain }) };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
get keys() {
|
|
484
|
+
return ['grain'];
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** A dimension: what facts are looked at by. */
|
|
489
|
+
export class DimensionRole extends EntityRole {
|
|
490
|
+
static role = 'dimension';
|
|
491
|
+
static label = 'Dimension';
|
|
492
|
+
static color = '#bfdbfe';
|
|
493
|
+
/** Slowly changing dimension types: how a dimension keeps history. */
|
|
494
|
+
static SCD_TYPES = Object.freeze([['0', 'Type 0: never changes'], ['1', 'Type 1: overwritten'], ['2', 'Type 2: a row per version'], ['3', 'Type 3: the previous value kept']]);
|
|
495
|
+
|
|
496
|
+
details(entity) {
|
|
497
|
+
return {
|
|
498
|
+
role: entity.conformed ? 'conformed dimension' : 'dimension',
|
|
499
|
+
...(entity.scdType !== undefined && entity.scdType !== '' && { history: DimensionRole.SCD_TYPES.find(([t]) => t === String(entity.scdType))?.[1] ?? `type ${entity.scdType}` }),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
get keys() {
|
|
504
|
+
return ['scdType', 'conformed'];
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
EntityRole.register(EntityRole);
|
|
509
|
+
EntityRole.register(FactRole);
|
|
510
|
+
EntityRole.register(DimensionRole);
|
|
511
|
+
|
|
512
|
+
/** How a fact's measure adds up across its dimensions. */
|
|
513
|
+
const MEASURES = Object.freeze(['additive', 'semi-additive', 'non-additive']);
|
|
514
|
+
|
|
123
515
|
export const DataModelNotation = {
|
|
124
516
|
levels() { return Object.values(LEVELS); },
|
|
125
517
|
levelNames() { return Object.keys(LEVELS); },
|
|
@@ -129,4 +521,10 @@ export const DataModelNotation = {
|
|
|
129
521
|
notation(name) { return NOTATIONS[name] || NOTATIONS.crowsfoot; },
|
|
130
522
|
isCardinality(value) { return CARDINALITIES.has(value); },
|
|
131
523
|
cardinalities() { return [...CARDINALITIES]; },
|
|
524
|
+
/** A relationship's kind (RelationshipKind). */
|
|
525
|
+
relationshipKind(relationship) { return RelationshipKind.of(relationship); },
|
|
526
|
+
/** An entity's role in a dimensional scheme (EntityRole). */
|
|
527
|
+
entityRole(entity) { return EntityRole.of(entity); },
|
|
528
|
+
/** How a fact's measures add up: additive, semi-additive, non-additive. */
|
|
529
|
+
measures() { return [...MEASURES]; },
|
|
132
530
|
};
|