@peter.prib/renderengine 0.2.6 → 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 +70 -9
- package/REFERENCE.md +303 -2
- package/dist/renderengine-workspace.umd.js +8851 -3305
- package/dist/renderengine.umd.js +5621 -1382
- package/examples/archimate.html +68 -66
- package/examples/data-model.html +251 -19
- package/package.json +1 -1
- package/src/bian.js +182 -0
- package/src/charting/DiagramEditor.js +5 -26
- package/src/charting/DiagramTypeRegistry.js +8 -2
- package/src/charting/FormDialog.js +398 -385
- package/src/charting/ModelCheckDialog.js +63 -0
- package/src/charting/ModelChecker.js +26 -0
- package/src/charting/RowsField.js +127 -0
- package/src/charting/ViewTabStrip.js +94 -81
- package/src/charting/datamodel/BianLandscapeReader.js +185 -0
- package/src/charting/datamodel/BianSemanticApiReader.js +183 -0
- package/src/charting/datamodel/ClassificationLens.js +33 -0
- package/src/charting/datamodel/ClassificationScheme.js +98 -0
- package/src/charting/datamodel/DataModel.js +574 -12
- package/src/charting/datamodel/DataModelChecker.js +338 -0
- package/src/charting/datamodel/DataModelCompared.js +127 -0
- package/src/charting/datamodel/DataModelContext.js +40 -10
- package/src/charting/datamodel/DataModelEditCommand.js +47 -0
- package/src/charting/datamodel/DataModelFileFormat.js +33 -0
- package/src/charting/datamodel/DataModelPerspective.js +55 -0
- package/src/charting/datamodel/DataType.js +108 -0
- package/src/charting/datamodel/EnumerationType.js +64 -0
- package/src/charting/datamodel/ServiceDomain.js +186 -0
- package/src/charting/datamodel/ServiceLandscape.js +104 -0
- package/src/charting/datamodel/SqlDdl.js +80 -0
- package/src/charting/datamodel/SqlDdlFileFormat.js +23 -0
- package/src/charting/datamodel/StructuredType.js +63 -0
- package/src/charting/datamodel/index.js +21 -5
- 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/diagramModel/MergeModelCommand.js +43 -37
- package/src/charting/diagramModel/OpenViewList.js +75 -0
- package/src/charting/diagramModel/ViewSetChecker.js +22 -34
- package/src/charting/diagrams/DataModelDiagram.js +4 -11
- package/src/charting/diagrams/DataModelRelationshipStrategy.js +19 -5
- package/src/charting/diagrams/EdgeGeometry.js +578 -553
- package/src/charting/diagrams/GraphDiagram.js +11 -1
- package/src/charting/diagrams/NodeGeometry.js +173 -158
- package/src/charting/glyphs/PersonGlyph.js +24 -23
- package/src/charting/toolbar/ToolbarSelect.js +6 -0
- package/src/datamodel.js +402 -4
- package/src/index.js +6 -4
- package/src/workspace/EditorShell.js +166 -67
- package/src/workspace/PanelLayout.js +56 -0
- package/src/workspace/layout/TabsNode.js +205 -191
- package/src/workspace/parts/ArchimateCanvasPart.js +5 -7
- package/src/workspace/parts/DataModelPart.js +534 -33
- package/src/workspace/parts/DiagramEditorPart.js +2 -8
- package/src/workspace/parts/EditorPart.js +461 -407
- package/src/workspace/parts/ModelFile.js +216 -187
- package/src/workspace/parts/ModelsPart.js +483 -192
- package/src/workspace/storage/BackupFolder.js +145 -0
- package/src/workspace/storage/KeyValueStore.js +92 -0
- package/src/workspace/storage/ModelLibrary.js +65 -0
- package/src/workspace/workArea.js +417 -413
- package/src/yaml/YamlReader.js +372 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { DataType } from './DataType.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* StructuredType — a data type made of named fields, each of a type (a plain
|
|
5
|
+
* type's name or another data type's): BIAN's Amount, Datetime, Identifier.
|
|
6
|
+
* `fields` as `[{ name, type?, description?, iso20022? }]`. Drawn as a box with
|
|
7
|
+
* a row per field, joined to the data types its fields are.
|
|
8
|
+
*/
|
|
9
|
+
export class StructuredType extends DataType {
|
|
10
|
+
static kind = 'structure';
|
|
11
|
+
static kindLabel = 'Structure';
|
|
12
|
+
|
|
13
|
+
/** @param {Object} spec - As DataType, plus `fields: [{ name, type?, description?, iso20022? }]` */
|
|
14
|
+
constructor({ fields = [], ...spec } = {}) {
|
|
15
|
+
super(spec);
|
|
16
|
+
this.fields = fields.map(f => ({
|
|
17
|
+
name: String(f.name),
|
|
18
|
+
...(f.type && { type: String(f.type) }),
|
|
19
|
+
...(f.description && { description: f.description }),
|
|
20
|
+
...(f.iso20022 && { iso20022: f.iso20022 }),
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
summary() {
|
|
25
|
+
return `${this.name}: ${this.fields.map(f => (f.type ? `${f.name} (${f.type})` : f.name)).join(', ')}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
rows() {
|
|
29
|
+
return this.fields.map(f => ({ name: f.name, ...(f.type && { type: f.type }) }));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
uses() {
|
|
33
|
+
return [...super.uses(), ...this.fields.map(f => f.type).filter(Boolean)];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
toJSON() {
|
|
37
|
+
return { ...super.toJSON(), fields: this.fields.map(f => ({ ...f })) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A plain type's fields, then its fields a line each: `name | type`. */
|
|
41
|
+
static fields(model, type = null) {
|
|
42
|
+
return [
|
|
43
|
+
...DataType.fields(model, type),
|
|
44
|
+
{
|
|
45
|
+
key: 'fields', label: 'Fields', type: 'textarea', rows: 6,
|
|
46
|
+
value: (type?.fields ?? []).map(f => (f.type ? `${f.name} | ${f.type}` : f.name)).join('\n'),
|
|
47
|
+
hint: 'One a line: name | type (a plain type, or a data type by name)',
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static specFrom(values, model, type = null) {
|
|
53
|
+
return {
|
|
54
|
+
...super.specFrom(values),
|
|
55
|
+
fields: DataType.lines(values.fields).map(([name, fieldType]) => {
|
|
56
|
+
const kept = type?.fields.find(f => f.name === name);
|
|
57
|
+
return { ...kept, name, ...(fieldType ? { type: fieldType } : { type: undefined }) };
|
|
58
|
+
}).map(f => JSON.parse(JSON.stringify(f))),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
DataType.register(StructuredType);
|
|
@@ -1,5 +1,21 @@
|
|
|
1
|
-
export { DataModel } from './DataModel.js';
|
|
2
|
-
export { DataModelPerspective } from './DataModelPerspective.js';
|
|
3
|
-
export { DataModelContext } from './DataModelContext.js';
|
|
4
|
-
export {
|
|
5
|
-
export {
|
|
1
|
+
export { DataModel } from './DataModel.js';
|
|
2
|
+
export { DataModelPerspective } from './DataModelPerspective.js';
|
|
3
|
+
export { DataModelContext } from './DataModelContext.js';
|
|
4
|
+
export { ServiceDomain } from './ServiceDomain.js';
|
|
5
|
+
export { DataModelChecker } from './DataModelChecker.js';
|
|
6
|
+
export { ModelScorecard, ScorecardCategory, MeasuredCategory } from './scorecard/index.js';
|
|
7
|
+
export { ClassificationScheme } from './ClassificationScheme.js';
|
|
8
|
+
export { ClassificationLens } from './ClassificationLens.js';
|
|
9
|
+
export { NamingStandard, NameStyle } from './naming/index.js';
|
|
10
|
+
export { DataType } from './DataType.js';
|
|
11
|
+
export { EnumerationType } from './EnumerationType.js';
|
|
12
|
+
export { StructuredType } from './StructuredType.js';
|
|
13
|
+
export { ServiceLandscape } from './ServiceLandscape.js';
|
|
14
|
+
export { BianLandscapeReader } from './BianLandscapeReader.js';
|
|
15
|
+
export { BianSemanticApiReader } from './BianSemanticApiReader.js';
|
|
16
|
+
export { LevelSchema } from './LevelSchema.js';
|
|
17
|
+
export { LevelMapping } from './LevelMapping.js';
|
|
18
|
+
export { DataModelFileFormat } from './DataModelFileFormat.js';
|
|
19
|
+
export { SqlDdl } from './SqlDdl.js';
|
|
20
|
+
export { SqlDdlFileFormat } from './SqlDdlFileFormat.js';
|
|
21
|
+
export { LevelEntitiesCompared, LevelRelationshipsCompared, MappingsCompared, ContextsCompared, PerspectivesCompared, DataTypesCompared } from './DataModelCompared.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { NameStyle } from './NameStyle.js';
|
|
2
|
+
|
|
3
|
+
/** camelCase: words run together, each but the first capitalised ('partyId'). */
|
|
4
|
+
export class CamelCaseStyle extends NameStyle {
|
|
5
|
+
static key = 'camel';
|
|
6
|
+
static label = 'camelCase';
|
|
7
|
+
|
|
8
|
+
format(words) {
|
|
9
|
+
return words.map((w, i) => (i === 0 ? w.toLowerCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())).join('');
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
NameStyle.register(CamelCaseStyle);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { NameStyle } from './NameStyle.js';
|
|
2
|
+
|
|
3
|
+
/** lower case words apart ('party id'). */
|
|
4
|
+
export class LowerWordsStyle extends NameStyle {
|
|
5
|
+
static key = 'lower';
|
|
6
|
+
static label = 'lower case words';
|
|
7
|
+
|
|
8
|
+
format(words) {
|
|
9
|
+
return words.map(w => w.toLowerCase()).join(' ');
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
NameStyle.register(LowerWordsStyle);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NameStyle — one way names are written (Title Case, snake_case, ...): whether
|
|
3
|
+
* a name is written so (`matches`), and how it would be (`suggest`). A name is
|
|
4
|
+
* read as its words whatever its style (spaces, underscores, hyphens, camel
|
|
5
|
+
* humps), so any name can be suggested in any style. A subclass per style
|
|
6
|
+
* (`format(words)`, and `matches` where it allows more than its own format);
|
|
7
|
+
* `NameStyle.register` makes one known by its `key`.
|
|
8
|
+
*/
|
|
9
|
+
export class NameStyle {
|
|
10
|
+
/** What a spec calls it ('title'), and what a menu does ('Title Case'). */
|
|
11
|
+
static key = '';
|
|
12
|
+
static label = '';
|
|
13
|
+
|
|
14
|
+
/** Name styles by key. */
|
|
15
|
+
static STYLES = new Map();
|
|
16
|
+
|
|
17
|
+
static register(StyleClass) {
|
|
18
|
+
NameStyle.STYLES.set(StyleClass.key, new StyleClass());
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A style by key, or null (none: any name will do). */
|
|
22
|
+
static get(key) {
|
|
23
|
+
return NameStyle.STYLES.get(key) ?? null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Every style, in the order registered. */
|
|
27
|
+
static all() {
|
|
28
|
+
return [...NameStyle.STYLES.values()];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A name's words: split at spaces, underscores, hyphens and camel humps ('partyID' is party, ID). */
|
|
32
|
+
static words(name) {
|
|
33
|
+
return String(name ?? '')
|
|
34
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
35
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
36
|
+
.split(/[\s_-]+/)
|
|
37
|
+
.filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get key() { return this.constructor.key; }
|
|
41
|
+
get label() { return this.constructor.label; }
|
|
42
|
+
|
|
43
|
+
/** Words written in this style. @abstract */
|
|
44
|
+
format(words) {
|
|
45
|
+
throw new Error(`${this.constructor.name} must implement format()`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Whether a name is written in this style. */
|
|
49
|
+
matches(name) {
|
|
50
|
+
return this.suggest(name) === name;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The name written in this style. */
|
|
54
|
+
suggest(name) {
|
|
55
|
+
return this.format(NameStyle.words(name));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { NameStyle } from './NameStyle.js';
|
|
2
|
+
import './TitleCaseStyle.js';
|
|
3
|
+
import './LowerWordsStyle.js';
|
|
4
|
+
import './SnakeCaseStyle.js';
|
|
5
|
+
import './UpperSnakeStyle.js';
|
|
6
|
+
import './CamelCaseStyle.js';
|
|
7
|
+
import './PascalCaseStyle.js';
|
|
8
|
+
|
|
9
|
+
/** What a standard says of names, by level, then of entities and of attributes. */
|
|
10
|
+
const KINDS = Object.freeze({
|
|
11
|
+
conceptual: ['entities'],
|
|
12
|
+
logical: ['entities', 'attributes'],
|
|
13
|
+
physical: ['entities', 'attributes'],
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* NamingStandard — how a model's names are written, level by level (DMBOK2's
|
|
18
|
+
* naming standards): for its entities, and its attributes, a rule each —
|
|
19
|
+
* `style` (a NameStyle key; none: any), `classWords` (the words an attribute
|
|
20
|
+
* name must end with: Amount, Date, Code, Identifier...), `maxLength`,
|
|
21
|
+
* `abbreviations` (`{ word: abbreviation }`, as a physical name must use them).
|
|
22
|
+
* A model keeps its own (`model.namingStandard`, null: no standard), so the
|
|
23
|
+
* standard is the organisation's to choose; CONVENTIONAL is one to start from.
|
|
24
|
+
*/
|
|
25
|
+
export class NamingStandard {
|
|
26
|
+
static KINDS = KINDS;
|
|
27
|
+
|
|
28
|
+
/** A common standard: concepts and logical entities in Title Case, logical attributes in words ending in a class word, tables and columns in snake_case, abbreviated. */
|
|
29
|
+
static CONVENTIONAL = Object.freeze({
|
|
30
|
+
conceptual: { entities: { style: 'title' } },
|
|
31
|
+
logical: {
|
|
32
|
+
entities: { style: 'title' },
|
|
33
|
+
attributes: { style: 'lower', classWords: ['identifier', 'id', 'name', 'code', 'date', 'time', 'amount', 'number', 'count', 'rate', 'percentage', 'indicator', 'flag', 'text', 'description', 'type', 'status', 'quantity', 'value'] },
|
|
34
|
+
},
|
|
35
|
+
physical: {
|
|
36
|
+
entities: { style: 'snake', maxLength: 30 },
|
|
37
|
+
attributes: { style: 'snake', maxLength: 30, abbreviations: { number: 'num', identifier: 'id', amount: 'amt', quantity: 'qty', description: 'desc' } },
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** @param {Object} [spec] - `{ level: { entities?: rule, attributes?: rule } }` */
|
|
42
|
+
constructor(spec = {}) {
|
|
43
|
+
this.rules = Object.fromEntries(Object.entries(KINDS).map(([level, kinds]) => [level, Object.fromEntries(kinds.map(kind => {
|
|
44
|
+
const rule = spec?.[level]?.[kind] ?? {};
|
|
45
|
+
return [kind, {
|
|
46
|
+
style: rule.style ?? '',
|
|
47
|
+
classWords: (rule.classWords ?? []).map(w => String(w).toLowerCase()),
|
|
48
|
+
maxLength: Number(rule.maxLength) || 0,
|
|
49
|
+
abbreviations: Object.fromEntries(Object.entries(rule.abbreviations ?? {}).map(([w, a]) => [w.toLowerCase(), a])),
|
|
50
|
+
}];
|
|
51
|
+
}))]));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The rule for a level's entities or attributes. */
|
|
55
|
+
rule(level, kind) {
|
|
56
|
+
return this.rules[level]?.[kind] ?? null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What a name breaks of the rule for its level and kind: a reason each,
|
|
61
|
+
* none when it keeps it.
|
|
62
|
+
* @param {string} level
|
|
63
|
+
* @param {'entities'|'attributes'} kind
|
|
64
|
+
* @param {string} name
|
|
65
|
+
* @returns {string[]}
|
|
66
|
+
*/
|
|
67
|
+
check(level, kind, name) {
|
|
68
|
+
const rule = this.rule(level, kind);
|
|
69
|
+
if (!rule || !name) return [];
|
|
70
|
+
const reasons = [];
|
|
71
|
+
const style = NameStyle.get(rule.style);
|
|
72
|
+
if (style && !style.matches(name)) reasons.push(`is not ${style.label} (${style.suggest(name)})`);
|
|
73
|
+
const words = NameStyle.words(name).map(w => w.toLowerCase());
|
|
74
|
+
if (rule.classWords.length && !rule.classWords.includes(words[words.length - 1])) {
|
|
75
|
+
reasons.push(`does not end in a class word (${rule.classWords.slice(0, 5).join(', ')}…)`);
|
|
76
|
+
}
|
|
77
|
+
if (rule.maxLength && name.length > rule.maxLength) reasons.push(`is longer than ${rule.maxLength}`);
|
|
78
|
+
for (const word of words) {
|
|
79
|
+
if (rule.abbreviations[word]) reasons.push(`has "${word}" where the standard writes "${rule.abbreviations[word]}"`);
|
|
80
|
+
}
|
|
81
|
+
return reasons;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
toJSON() {
|
|
85
|
+
return Object.fromEntries(Object.entries(this.rules).map(([level, kinds]) => [level, Object.fromEntries(Object.entries(kinds).map(([kind, rule]) => [kind, {
|
|
86
|
+
...(rule.style && { style: rule.style }),
|
|
87
|
+
...(rule.classWords.length && { classWords: [...rule.classWords] }),
|
|
88
|
+
...(rule.maxLength && { maxLength: rule.maxLength }),
|
|
89
|
+
...(Object.keys(rule.abbreviations).length && { abbreviations: { ...rule.abbreviations } }),
|
|
90
|
+
}]))]));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Its dialog's fields: a heading per level, and for its entities and
|
|
95
|
+
* attributes the style (any, or one of NameStyle's), the class words, the
|
|
96
|
+
* longest a name may be, and abbreviations (`word = abbreviation` a line).
|
|
97
|
+
* @param {NamingStandard|null} standard
|
|
98
|
+
*/
|
|
99
|
+
static fields(standard) {
|
|
100
|
+
const styles = [['', 'Any'], ...NameStyle.all().map(s => [s.key, s.label])];
|
|
101
|
+
return Object.entries(KINDS).flatMap(([level, kinds]) => [
|
|
102
|
+
{ type: 'heading', label: `${level.charAt(0).toUpperCase()}${level.slice(1)} level` },
|
|
103
|
+
...kinds.flatMap(kind => {
|
|
104
|
+
const rule = standard?.rule(level, kind);
|
|
105
|
+
const key = (what) => `${level}.${kind}.${what}`;
|
|
106
|
+
return [
|
|
107
|
+
{ key: key('style'), label: `${kind === 'entities' ? 'Entity' : 'Attribute'} names`, type: 'select', value: rule?.style ?? '', choices: styles },
|
|
108
|
+
...(kind === 'attributes' ? [{ key: key('classWords'), label: 'Class words', type: 'text', value: (rule?.classWords ?? []).join(', '), placeholder: 'id, name, date, amount, code…' }] : []),
|
|
109
|
+
{ key: key('maxLength'), label: 'At most (characters)', type: 'number', value: rule?.maxLength || '', min: 0 },
|
|
110
|
+
...(level === 'physical' ? [{
|
|
111
|
+
key: key('abbreviations'), label: 'Abbreviations', type: 'textarea', rows: 2,
|
|
112
|
+
value: Object.entries(rule?.abbreviations ?? {}).map(([w, a]) => `${w} = ${a}`).join('\n'), placeholder: 'number = num',
|
|
113
|
+
}] : []),
|
|
114
|
+
];
|
|
115
|
+
}),
|
|
116
|
+
]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A standard's spec from those fields. */
|
|
120
|
+
static specFrom(values) {
|
|
121
|
+
return Object.fromEntries(Object.entries(KINDS).map(([level, kinds]) => [level, Object.fromEntries(kinds.map(kind => {
|
|
122
|
+
const v = (what) => values[`${level}.${kind}.${what}`];
|
|
123
|
+
return [kind, {
|
|
124
|
+
style: v('style') || '',
|
|
125
|
+
classWords: String(v('classWords') ?? '').split(/[,\s]+/).filter(Boolean),
|
|
126
|
+
maxLength: Number(v('maxLength')) || 0,
|
|
127
|
+
abbreviations: Object.fromEntries(String(v('abbreviations') ?? '').split('\n')
|
|
128
|
+
.map(l => l.split('=').map(x => x.trim())).filter(([w, a]) => w && a)),
|
|
129
|
+
}];
|
|
130
|
+
}))]));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { NameStyle } from './NameStyle.js';
|
|
2
|
+
|
|
3
|
+
/** PascalCase: words run together, each capitalised ('PartyId'), as BIAN names its schemas. */
|
|
4
|
+
export class PascalCaseStyle extends NameStyle {
|
|
5
|
+
static key = 'pascal';
|
|
6
|
+
static label = 'PascalCase';
|
|
7
|
+
|
|
8
|
+
format(words) {
|
|
9
|
+
return words.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('');
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
NameStyle.register(PascalCaseStyle);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { NameStyle } from './NameStyle.js';
|
|
2
|
+
|
|
3
|
+
/** snake_case: lower case words joined by underscores ('party_id'). */
|
|
4
|
+
export class SnakeCaseStyle extends NameStyle {
|
|
5
|
+
static key = 'snake';
|
|
6
|
+
static label = 'snake_case';
|
|
7
|
+
|
|
8
|
+
format(words) {
|
|
9
|
+
return words.map(w => w.toLowerCase()).join('_');
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
NameStyle.register(SnakeCaseStyle);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { NameStyle } from './NameStyle.js';
|
|
2
|
+
|
|
3
|
+
/** Words that stay small inside a title. */
|
|
4
|
+
const MINOR = new Set(['a', 'an', 'and', 'as', 'at', 'by', 'for', 'in', 'of', 'on', 'or', 'the', 'to', 'with']);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Title Case: words apart, each capitalised ('Party Reference Data'), small
|
|
8
|
+
* words inside it may stay small ('Terms of Use'), and an acronym stays as it is
|
|
9
|
+
* ('ECM And DCM').
|
|
10
|
+
*/
|
|
11
|
+
export class TitleCaseStyle extends NameStyle {
|
|
12
|
+
static key = 'title';
|
|
13
|
+
static label = 'Title Case';
|
|
14
|
+
|
|
15
|
+
format(words) {
|
|
16
|
+
return words.map((w, i) => (i > 0 && MINOR.has(w.toLowerCase()) ? w.toLowerCase() : w.charAt(0).toUpperCase() + w.slice(1))).join(' ');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
matches(name) {
|
|
20
|
+
const words = String(name ?? '').split(' ');
|
|
21
|
+
return words.every(Boolean) && words.every((w, i) => /^[A-Z0-9]/.test(w) || (i > 0 && MINOR.has(w)));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
NameStyle.register(TitleCaseStyle);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { NameStyle } from './NameStyle.js';
|
|
2
|
+
|
|
3
|
+
/** UPPER_SNAKE_CASE: upper case words joined by underscores ('PARTY_ID'). */
|
|
4
|
+
export class UpperSnakeStyle extends NameStyle {
|
|
5
|
+
static key = 'upper-snake';
|
|
6
|
+
static label = 'UPPER_SNAKE_CASE';
|
|
7
|
+
|
|
8
|
+
format(words) {
|
|
9
|
+
return words.map(w => w.toUpperCase()).join('_');
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
NameStyle.register(UpperSnakeStyle);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { NameStyle } from './NameStyle.js';
|
|
2
|
+
export { TitleCaseStyle } from './TitleCaseStyle.js';
|
|
3
|
+
export { LowerWordsStyle } from './LowerWordsStyle.js';
|
|
4
|
+
export { SnakeCaseStyle } from './SnakeCaseStyle.js';
|
|
5
|
+
export { UpperSnakeStyle } from './UpperSnakeStyle.js';
|
|
6
|
+
export { CamelCaseStyle } from './CamelCaseStyle.js';
|
|
7
|
+
export { PascalCaseStyle } from './PascalCaseStyle.js';
|
|
8
|
+
export { NamingStandard } from './NamingStandard.js';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { MeasuredCategory, percent } from './ScorecardCategory.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Completeness: is what the model should carry there? Measured as the share,
|
|
5
|
+
* averaged, of entities with a definition, of concepts realised at the logical
|
|
6
|
+
* level, of logical entities realised by a table, and of logical entities with
|
|
7
|
+
* someone answering for them (an owner or a steward). A share with nothing to
|
|
8
|
+
* count is left out.
|
|
9
|
+
*/
|
|
10
|
+
export class CompletenessCategory extends MeasuredCategory {
|
|
11
|
+
constructor() {
|
|
12
|
+
super('completeness', 'Completeness', 'Does the model have what it should: definitions, every level realised, stewards?');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
measure(model) {
|
|
16
|
+
const all = model.constructor.LEVELS.flatMap(level => model.level(level).entities);
|
|
17
|
+
const concepts = model.level('conceptual').entities;
|
|
18
|
+
const logical = model.level('logical').entities;
|
|
19
|
+
const realised = (from, to) => (id) => model.mapping(from, to).targetsOf(id).length > 0;
|
|
20
|
+
const shares = [
|
|
21
|
+
['have a definition', all.filter(e => String(e.description ?? '').trim()).length, all.length],
|
|
22
|
+
['concepts realised at the logical level', concepts.filter(c => realised('conceptual', 'logical')(c.id)).length, concepts.length],
|
|
23
|
+
['logical entities realised by a table', logical.filter(e => realised('logical', 'physical')(e.id)).length, logical.length],
|
|
24
|
+
['logical entities with an owner or steward', logical.filter(e => e.stewardship?.owner || e.stewardship?.steward).length, logical.length],
|
|
25
|
+
].filter(([, , whole]) => whole);
|
|
26
|
+
if (!shares.length) return { score: null, notes: ['nothing to measure'] };
|
|
27
|
+
const score = Math.round(shares.reduce((sum, [, part, whole]) => sum + percent(part, whole), 0) / shares.length);
|
|
28
|
+
return { score, notes: shares.map(([what, part, whole]) => `${part} of ${whole} ${what}`) };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { MeasuredCategory, percent } from './ScorecardCategory.js';
|
|
2
|
+
|
|
3
|
+
/** The model check's findings about consistency. */
|
|
4
|
+
const INCONSISTENT = new Set(['type-conflict', 'classification-lost', 'rule-attribute-missing', 'mapping-dangling']);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Consistency: does the model agree with itself? Measured as the share of
|
|
8
|
+
* logical entities and tables the model check finds nothing inconsistent on
|
|
9
|
+
* (one name with two types, a classification lost down the mappings, a rule on
|
|
10
|
+
* an attribute not there, a mapping to nothing).
|
|
11
|
+
*/
|
|
12
|
+
export class ConsistencyCategory extends MeasuredCategory {
|
|
13
|
+
static INCONSISTENT = INCONSISTENT;
|
|
14
|
+
|
|
15
|
+
constructor() {
|
|
16
|
+
super('consistency', 'Consistency', 'Does it agree with itself: one name one type, classifications kept across levels?');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
measure(model, findings) {
|
|
20
|
+
const entities = ['logical', 'physical'].flatMap(level => model.level(level).entities.map(e => `${level}:${e.id}`));
|
|
21
|
+
const found = findings.filter(f => INCONSISTENT.has(f.code));
|
|
22
|
+
if (!entities.length) return { score: null, notes: ['no logical entities or tables'] };
|
|
23
|
+
const touched = new Set(found.flatMap(f => (f.ids ?? []).map(id => `${f.level}:${id}`)).filter(k => entities.includes(k)));
|
|
24
|
+
// A finding on no entity (a rule, a mapping) counts as one entity's worth.
|
|
25
|
+
const elsewhere = found.filter(f => !(f.ids ?? []).some(id => entities.includes(`${f.level}:${id}`))).length;
|
|
26
|
+
const counts = found.reduce((m, f) => ((m[f.code] = (m[f.code] ?? 0) + 1), m), {});
|
|
27
|
+
const sound = Math.max(0, entities.length - touched.size - elsewhere);
|
|
28
|
+
return {
|
|
29
|
+
score: percent(sound, entities.length),
|
|
30
|
+
notes: [`${sound} of ${entities.length} logical entities and tables consistent`, ...Object.entries(counts).map(([code, n]) => `${n} ${code}`)],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { MeasuredCategory, percent } from './ScorecardCategory.js';
|
|
2
|
+
|
|
3
|
+
/** Words a definition has at least, to say more than a label would. */
|
|
4
|
+
const ENOUGH_WORDS = 4;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Definitions: do the definitions there say something? Measured as the share
|
|
8
|
+
* of definitions (of entities, at every level) that do more than restate the
|
|
9
|
+
* name and run to at least a few words. How many are missing is Completeness's.
|
|
10
|
+
*/
|
|
11
|
+
export class DefinitionsCategory extends MeasuredCategory {
|
|
12
|
+
static ENOUGH_WORDS = ENOUGH_WORDS;
|
|
13
|
+
|
|
14
|
+
constructor() {
|
|
15
|
+
super('definitions', 'Definitions', 'Are the definitions clear: more than the name, in a sentence?');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
measure(model) {
|
|
19
|
+
const plain = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
20
|
+
const defined = model.constructor.LEVELS.flatMap(level => model.level(level).entities).filter(e => String(e.description ?? '').trim());
|
|
21
|
+
if (!defined.length) return { score: null, notes: ['no definitions to judge (see Completeness)'] };
|
|
22
|
+
const restating = defined.filter(e => plain(e.description) === plain(e.name));
|
|
23
|
+
const short = defined.filter(e => !restating.includes(e) && String(e.description).trim().split(/\s+/).length < ENOUGH_WORDS);
|
|
24
|
+
return {
|
|
25
|
+
score: percent(defined.length - restating.length - short.length, defined.length),
|
|
26
|
+
notes: [
|
|
27
|
+
`${defined.length - restating.length - short.length} of ${defined.length} definitions say something`,
|
|
28
|
+
...(restating.length ? [`${restating.length} only restate the name`] : []),
|
|
29
|
+
...(short.length ? [`${short.length} fewer than ${ENOUGH_WORDS} words`] : []),
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { ScorecardCategory } from './ScorecardCategory.js';
|
|
2
|
+
import { CompletenessCategory } from './CompletenessCategory.js';
|
|
3
|
+
import { StructureCategory } from './StructureCategory.js';
|
|
4
|
+
import { StandardsCategory } from './StandardsCategory.js';
|
|
5
|
+
import { DefinitionsCategory } from './DefinitionsCategory.js';
|
|
6
|
+
import { ConsistencyCategory } from './ConsistencyCategory.js';
|
|
7
|
+
import { ReadabilityCategory } from './ReadabilityCategory.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The ten categories in the Scorecard's order: those the model measures, and
|
|
11
|
+
* those a reviewer scores (Correctness, Scheme, Abstraction, Data).
|
|
12
|
+
*/
|
|
13
|
+
const CATEGORIES = Object.freeze([
|
|
14
|
+
new ScorecardCategory('correctness', 'Correctness', 'Does the model capture the requirements?'),
|
|
15
|
+
new CompletenessCategory(),
|
|
16
|
+
new ScorecardCategory('scheme', 'Scheme', 'Is the scheme (relational, dimensional...) the right one for what it is for?'),
|
|
17
|
+
new StructureCategory(),
|
|
18
|
+
new ScorecardCategory('abstraction', 'Abstraction', 'Is it as general as it should be, and no more?'),
|
|
19
|
+
new StandardsCategory(),
|
|
20
|
+
new ReadabilityCategory(),
|
|
21
|
+
new DefinitionsCategory(),
|
|
22
|
+
new ConsistencyCategory(),
|
|
23
|
+
new ScorecardCategory('data', 'Data', 'Does the data in the database match what the model says of it?'),
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* ModelScorecard — a data model scored as DMBOK2 has a model reviewed, on the
|
|
28
|
+
* Data Model Scorecard's ten categories (Steve Hoberman, 2015). Six the model
|
|
29
|
+
* measures (ScorecardCategory subclasses); four a reviewer scores; a reviewer's
|
|
30
|
+
* score, kept in the model (`model.scorecard.reviews[key]: { score, note }`),
|
|
31
|
+
* overrides a measured one too. The total is the weighted average of the
|
|
32
|
+
* categories with a score, out of 100. Weights are the model's own
|
|
33
|
+
* (`model.scorecard.weights`); by default equal, 10 each: the book gives its
|
|
34
|
+
* own weighting, and an organisation sets the one it reviews by.
|
|
35
|
+
*/
|
|
36
|
+
export class ModelScorecard {
|
|
37
|
+
static CATEGORIES = CATEGORIES;
|
|
38
|
+
static DEFAULT_WEIGHT = 10;
|
|
39
|
+
|
|
40
|
+
/** @param {DataModel} model */
|
|
41
|
+
constructor(model) {
|
|
42
|
+
this.model = model;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What the model keeps of the scorecard: `{ weights, reviews }`. */
|
|
46
|
+
get kept() {
|
|
47
|
+
return this.model.scorecard ?? {};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A category's weight: the model's, else the default. */
|
|
51
|
+
weight(key) {
|
|
52
|
+
const w = Number(this.kept.weights?.[key]);
|
|
53
|
+
return Number.isFinite(w) && w >= 0 ? w : ModelScorecard.DEFAULT_WEIGHT;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Each category: `{ category, weight, measured: {score, notes}, review: {score, note}|null,
|
|
58
|
+
* score (the review's, else the measured, or null), source: 'reviewed'|'measured'|null }`.
|
|
59
|
+
*/
|
|
60
|
+
results() {
|
|
61
|
+
const findings = this.model.checker().run();
|
|
62
|
+
return CATEGORIES.map(category => {
|
|
63
|
+
const measured = category.measure(this.model, findings);
|
|
64
|
+
const kept = this.kept.reviews?.[category.key];
|
|
65
|
+
const review = Number.isFinite(Number(kept?.score)) && kept?.score !== '' && kept?.score != null ? { score: Number(kept.score), note: kept.note ?? '' } : null;
|
|
66
|
+
const score = review ? review.score : measured.score;
|
|
67
|
+
return { category, weight: this.weight(category.key), measured, review, score, source: review ? 'reviewed' : (measured.score == null ? null : 'measured') };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The weighted average of the categories with a score (null: none has), and how much of the weight that is. */
|
|
72
|
+
total(results = this.results()) {
|
|
73
|
+
const scored = results.filter(r => r.score != null && r.weight > 0);
|
|
74
|
+
const weight = scored.reduce((s, r) => s + r.weight, 0);
|
|
75
|
+
const all = results.reduce((s, r) => s + r.weight, 0);
|
|
76
|
+
return {
|
|
77
|
+
score: weight ? Math.round(scored.reduce((s, r) => s + r.score * r.weight, 0) / weight) : null,
|
|
78
|
+
coverage: all ? Math.round((100 * weight) / all) : 0,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Its dialog's fields: a heading per category (its question as the hint),
|
|
84
|
+
* what the model measured (shown), the reviewer's score and note, its weight.
|
|
85
|
+
* The score and weight are text, not number, fields: a number field reads
|
|
86
|
+
* empty as 0, and an empty score means "not scored", not nought.
|
|
87
|
+
*/
|
|
88
|
+
fields(results = this.results()) {
|
|
89
|
+
return results.flatMap(({ category, measured, review, weight }) => [
|
|
90
|
+
{ type: 'heading', label: category.label },
|
|
91
|
+
{
|
|
92
|
+
key: `${category.key}.measured`, label: 'Measured', type: 'text', readOnly: true,
|
|
93
|
+
value: measured.score == null ? '—' : `${measured.score}`, hint: [category.question, ...measured.notes].join(' · '),
|
|
94
|
+
},
|
|
95
|
+
{ key: `${category.key}.score`, label: 'Reviewer\'s score', type: 'text', value: review?.score ?? '', placeholder: category.measured ? 'the measured one' : '0–100' },
|
|
96
|
+
{ key: `${category.key}.note`, label: 'Note', type: 'text', value: review?.note ?? '' },
|
|
97
|
+
{ key: `${category.key}.weight`, label: 'Weight', type: 'text', value: weight },
|
|
98
|
+
]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** What the model keeps from those fields: weights other than the default, and the reviews given. */
|
|
102
|
+
static specFrom(values) {
|
|
103
|
+
const weights = {}, reviews = {};
|
|
104
|
+
for (const { key } of CATEGORIES) {
|
|
105
|
+
const weight = values[`${key}.weight`];
|
|
106
|
+
if (String(weight ?? '').trim() !== '' && Number(weight) !== ModelScorecard.DEFAULT_WEIGHT && Number.isFinite(Number(weight))) weights[key] = Number(weight);
|
|
107
|
+
const score = values[`${key}.score`];
|
|
108
|
+
const note = String(values[`${key}.note`] ?? '').trim();
|
|
109
|
+
if (String(score ?? '').trim() !== '' && Number.isFinite(Number(score))) reviews[key] = { score: Math.max(0, Math.min(100, Number(score))), ...(note && { note }) };
|
|
110
|
+
}
|
|
111
|
+
return { ...(Object.keys(weights).length && { weights }), ...(Object.keys(reviews).length && { reviews }) };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { MeasuredCategory, percent } from './ScorecardCategory.js';
|
|
2
|
+
import { DataModelDiagram } from '../../diagrams/DataModelDiagram.js';
|
|
3
|
+
import { DataModelNotation } from '../../../datamodel.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Readability: can it be read as drawn? Measured as the average of the share
|
|
7
|
+
* of placed boxes, at each level, clear of every other box, and the share of
|
|
8
|
+
* associations with a name (a generalization needs none).
|
|
9
|
+
*/
|
|
10
|
+
export class ReadabilityCategory extends MeasuredCategory {
|
|
11
|
+
constructor() {
|
|
12
|
+
super('readability', 'Readability', 'Can it be read as drawn: boxes clear of each other, relationships named?');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
measure(model) {
|
|
16
|
+
let placed = 0, clear = 0, associations = 0, named = 0;
|
|
17
|
+
for (const level of model.constructor.LEVELS) {
|
|
18
|
+
const notation = DataModelNotation.notation('crowsfoot');
|
|
19
|
+
const boxes = model.level(level).entities
|
|
20
|
+
.map(e => DataModelDiagram.boxOf(DataModelDiagram.buildNode({ ...e, [level]: { attributes: e.attributes ?? [] } }, { level, notation })))
|
|
21
|
+
.filter(Boolean);
|
|
22
|
+
placed += boxes.length;
|
|
23
|
+
clear += boxes.filter((a, i) => !boxes.some((b, j) => j !== i && a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1)).length;
|
|
24
|
+
for (const r of model.level(level).relationships) {
|
|
25
|
+
if (!DataModelNotation.relationshipKind(r).hasCardinality) continue;
|
|
26
|
+
associations++;
|
|
27
|
+
if (String(r.name ?? '').trim()) named++;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const shares = [['boxes clear of the others', clear, placed], ['relationships named', named, associations]].filter(([, , whole]) => whole);
|
|
31
|
+
if (!shares.length) return { score: null, notes: ['nothing drawn yet'] };
|
|
32
|
+
return {
|
|
33
|
+
score: Math.round(shares.reduce((sum, [, part, whole]) => sum + percent(part, whole), 0) / shares.length),
|
|
34
|
+
notes: shares.map(([what, part, whole]) => `${part} of ${whole} ${what}`),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|