@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
@@ -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
- /** Draws a table-card node's header + one row per column into wrapper `g`. */
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
- /** One data-modelling abstraction level. See DataModelNotation.levels(). */
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 DataModelLevel('conceptual', 'Entities and their relationships only — no attributes, no keys, no types.'),
95
- logical: new DataModelLevel('logical', 'Attributes and keys, still database-agnostic — no column types or engine specifics.'),
96
- physical: new DataModelLevel('physical', 'Real column types, keys, and constraints as they exist in an actual database.'),
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
  };