@peter.prib/renderengine 0.2.7 → 0.2.11
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 +129 -0
- package/dist/renderengine-workspace.umd.js +5068 -2892
- package/dist/renderengine.umd.js +4874 -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 +244 -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/LevelSchema.js +23 -0
- package/src/charting/datamodel/ServiceDomain.js +9 -0
- package/src/charting/datamodel/SqlDdl.js +85 -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,85 @@
|
|
|
1
|
+
/** Words SQL keeps for itself that a name may not be left bare as. */
|
|
2
|
+
const RESERVED = new Set(['user', 'order', 'group', 'table', 'select', 'from', 'where', 'by', 'to', 'key', 'check', 'default', 'index', 'primary', 'foreign', 'references', 'unique', 'column', 'constraint', 'value', 'values', 'date', 'time', 'timestamp', 'level', 'position', 'role']);
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SqlDdl — a model's physical level as SQL DDL (DMBOK2's data integration: the
|
|
6
|
+
* design handed to a database): a CREATE TABLE per table, its columns with
|
|
7
|
+
* their types (`defaultType` where a column has none) and NOT NULL where
|
|
8
|
+
* required, its primary key and UNIQUE columns; a FOREIGN KEY per column that
|
|
9
|
+
* references another (`references`: a table, or `table.column`, by id or name;
|
|
10
|
+
* a table alone: its primary key); and COMMENT ONs for definitions. Names are
|
|
11
|
+
* quoted where SQL would not take them bare. ANSI SQL, as PostgreSQL reads it.
|
|
12
|
+
*/
|
|
13
|
+
export class SqlDdl {
|
|
14
|
+
/**
|
|
15
|
+
* @param {Object} [options]
|
|
16
|
+
* @param {string} [options.defaultType] - A column's type when it names none
|
|
17
|
+
* @param {boolean} [options.comments] - Whether definitions become COMMENT ONs
|
|
18
|
+
*/
|
|
19
|
+
constructor({ defaultType = 'VARCHAR(255)', comments = true } = {}) {
|
|
20
|
+
this.defaultType = defaultType;
|
|
21
|
+
this.comments = comments;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A name as SQL takes it: bare when it can be, else double-quoted. */
|
|
25
|
+
static name(text) {
|
|
26
|
+
const s = String(text);
|
|
27
|
+
return /^[a-z_][a-z0-9_]*$/.test(s) && !RESERVED.has(s) ? s : `"${s.replace(/"/g, '""')}"`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A table's name as SQL takes it: a schema-qualified one (`sales.customer`)
|
|
32
|
+
* each part as name() has it, so it names the table in its schema rather
|
|
33
|
+
* than one table called "sales.customer".
|
|
34
|
+
*/
|
|
35
|
+
static tableName(text) {
|
|
36
|
+
return String(text).split('.').map(SqlDdl.name).join('.');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A text as an SQL string literal. */
|
|
40
|
+
static literal(text) {
|
|
41
|
+
return `'${String(text).replace(/'/g, "''")}'`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The DDL for a model's physical level. @returns {string} */
|
|
45
|
+
write(model) {
|
|
46
|
+
const schema = model.level('physical');
|
|
47
|
+
const tables = schema.entities;
|
|
48
|
+
const table = SqlDdl.tableName;
|
|
49
|
+
const out = [`-- ${model.name || 'Data model'}: the physical level, ${tables.length} table${tables.length === 1 ? '' : 's'}`, ''];
|
|
50
|
+
for (const t of tables) {
|
|
51
|
+
const columns = t.attributes ?? [];
|
|
52
|
+
const keys = columns.filter(c => c.isPK).map(c => SqlDdl.name(c.name));
|
|
53
|
+
const lines = [
|
|
54
|
+
...columns.map(c => ` ${SqlDdl.name(c.name)} ${c.type || this.defaultType}${c.required || c.isPK ? ' NOT NULL' : ''}`),
|
|
55
|
+
...(keys.length ? [` PRIMARY KEY (${keys.join(', ')})`] : []),
|
|
56
|
+
...columns.filter(c => c.isUnique && !c.isPK).map(c => ` UNIQUE (${SqlDdl.name(c.name)})`),
|
|
57
|
+
];
|
|
58
|
+
out.push(`CREATE TABLE ${table(t.name)} (`, lines.join(',\n'), ');', '');
|
|
59
|
+
}
|
|
60
|
+
for (const t of tables) {
|
|
61
|
+
for (const c of (t.attributes ?? []).filter(a => a.isFK && a.references)) {
|
|
62
|
+
const ref = schema.resolveReference(c.references);
|
|
63
|
+
if (!ref) {
|
|
64
|
+
out.push(`-- ${t.name}.${c.name} references ${c.references}, which is not a table here`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const theirs = ref.column ? [ref.column] : (ref.entity.attributes ?? []).filter(a => a.isPK).map(a => a.name);
|
|
68
|
+
if (!theirs.length) {
|
|
69
|
+
out.push(`-- ${t.name}.${c.name} references ${ref.entity.name}, which has no primary key`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
out.push(`ALTER TABLE ${table(t.name)} ADD CONSTRAINT ${SqlDdl.name(`fk_${t.name}_${c.name}`.toLowerCase().replace(/[^a-z0-9_]+/g, '_'))}`
|
|
73
|
+
+ ` FOREIGN KEY (${SqlDdl.name(c.name)}) REFERENCES ${table(ref.entity.name)} (${theirs.map(SqlDdl.name).join(', ')});`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (this.comments) {
|
|
77
|
+
const notes = tables.flatMap(t => [
|
|
78
|
+
...(t.description ? [`COMMENT ON TABLE ${table(t.name)} IS ${SqlDdl.literal(t.description)};`] : []),
|
|
79
|
+
...(t.attributes ?? []).filter(c => c.description).map(c => `COMMENT ON COLUMN ${table(t.name)}.${SqlDdl.name(c.name)} IS ${SqlDdl.literal(c.description)};`),
|
|
80
|
+
]);
|
|
81
|
+
if (notes.length) out.push('', ...notes);
|
|
82
|
+
}
|
|
83
|
+
return `${out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ModelFileFormat } from '../ModelFileFormat.js';
|
|
2
|
+
import { SqlDdl } from './SqlDdl.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SqlDdlFileFormat — a data model's physical level as SQL DDL (SqlDdl), a file
|
|
6
|
+
* to export; DDL is not read back.
|
|
7
|
+
*/
|
|
8
|
+
export class SqlDdlFileFormat extends ModelFileFormat {
|
|
9
|
+
/** @param {Object} [options] - SqlDdl's */
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
super();
|
|
12
|
+
this.ddl = new SqlDdl(options);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
get label() { return 'SQL DDL'; }
|
|
16
|
+
get accept() { return '.sql'; }
|
|
17
|
+
get fileName() { return 'model.sql'; }
|
|
18
|
+
get imports() { return false; }
|
|
19
|
+
|
|
20
|
+
export(model) {
|
|
21
|
+
return { data: this.ddl.write(model), mimeType: 'application/sql' };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -1,15 +1,21 @@
|
|
|
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 {
|
|
7
|
-
export {
|
|
8
|
-
export {
|
|
9
|
-
export {
|
|
10
|
-
export {
|
|
11
|
-
export {
|
|
12
|
-
export {
|
|
13
|
-
export {
|
|
14
|
-
export {
|
|
15
|
-
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
|
+
}
|