Merge branch 'develop' into 3061_making_a_monorepo

This commit is contained in:
Knut Sveidqvist
2022-09-29 16:28:57 +02:00
7 changed files with 270 additions and 74 deletions

View File

@@ -1,7 +1,5 @@
import graphlib from 'graphlib';
import { line, curveBasis, select } from 'd3';
// import erDb from './erDb';
// import erParser from './parser/erDiagram';
import dagre from 'dagre';
import { getConfig } from '../../config';
import { log } from '../../logger';
@@ -9,9 +7,17 @@ import erMarkers from './erMarkers';
import { configureSvgSize } from '../../setupGraphViewbox';
import addSVGAccessibilityFields from '../../accessibility';
import { parseGenericTypes } from '../common/common';
import { v4 as uuid4 } from 'uuid';
/** Regex used to remove chars from the entity name so the result can be used in an id */
const BAD_ID_CHARS_REGEXP = /[^A-Za-z0-9]([\W])*/g;
// Configuration
let conf = {};
// Map so we can look up the id of an entity based on the name
let entityNameIds = new Map();
/**
* Allows the top-level API module to inject config specific to this renderer, storing it in the
* local conf object. Note that generic config still needs to be retrieved using getConfig()
@@ -31,8 +37,10 @@ export const setConf = function (cnf) {
*
* @param groupNode The svg group node for the entity
* @param entityTextNode The svg node for the entity label text
* @param attributes An array of attributes defined for the entity (each attribute has a type and a name)
* @returns {object} The bounding box of the entity, after attributes have been added. The bounding box has a .width and .height
* @param attributes An array of attributes defined for the entity (each attribute has a type and a
* name)
* @returns {object} The bounding box of the entity, after attributes have been added. The bounding
* box has a .width and .height
*/
const drawAttributes = (groupNode, entityTextNode, attributes) => {
const heightPadding = conf.entityPadding / 3; // Padding internal to attribute boxes
@@ -288,7 +296,7 @@ const drawAttributes = (groupNode, entityTextNode, attributes) => {
heightOffset += attributeNode.height + heightPadding * 2;
// Flip the attribute style for row banding
attribStyle = attribStyle == 'attributeBoxOdd' ? 'attributeBoxEven' : 'attributeBoxOdd';
attribStyle = attribStyle === 'attributeBoxOdd' ? 'attributeBoxEven' : 'attributeBoxOdd';
});
} else {
// Ensure the entity box is a decent size without any attributes
@@ -313,15 +321,18 @@ const drawEntities = function (svgNode, entities, graph) {
const keys = Object.keys(entities);
let firstOne;
keys.forEach(function (id) {
// Create a group for each entity
const groupNode = svgNode.append('g').attr('id', id);
keys.forEach(function (entityName) {
const entityId = generateId(entityName, 'entity');
entityNameIds.set(entityName, entityId);
firstOne = firstOne === undefined ? id : firstOne;
// Create a group for each entity
const groupNode = svgNode.append('g').attr('id', entityId);
firstOne = firstOne === undefined ? entityId : firstOne;
// Label the entity - this is done first so that we can get the bounding box
// which then determines the size of the rectangle
const textId = 'entity-' + id;
const textId = 'text-' + entityId;
const textNode = groupNode
.append('text')
.attr('class', 'er entityLabel')
@@ -334,12 +345,12 @@ const drawEntities = function (svgNode, entities, graph) {
'style',
'font-family: ' + getConfig().fontFamily + '; font-size: ' + conf.fontSize + 'px'
)
.text(id);
.text(entityName);
const { width: entityWidth, height: entityHeight } = drawAttributes(
groupNode,
textNode,
entities[id].attributes
entities[entityName].attributes
);
// Draw the rectangle - insert it before the text so that the text is not obscured
@@ -356,12 +367,12 @@ const drawEntities = function (svgNode, entities, graph) {
const rectBBox = rectNode.node().getBBox();
// Add the entity to the graph
graph.setNode(id, {
// Add the entity to the graph using the entityId
graph.setNode(entityId, {
width: rectBBox.width,
height: rectBBox.height,
shape: 'rect',
id: id,
id: entityId,
});
});
return firstOne;
@@ -382,9 +393,16 @@ const adjustEntities = function (svgNode, graph) {
);
}
});
return;
};
/**
* Construct a name for an edge based on the names of the 2 entities and the role (relationship)
* between them. Remove any spaces from it
*
* @param rel - A (parsed) relationship (e.g. one of the objects in the list returned by
* erDb.getRelationships)
* @returns {string}
*/
const getEdgeName = function (rel) {
return (rel.entityA + rel.roleA + rel.entityB).replace(/\s/g, '');
};
@@ -393,12 +411,17 @@ const getEdgeName = function (rel) {
* Add each relationship to the graph
*
* @param relationships The relationships to be added
* @param g The graph
* @param {Graph} g The graph
* @returns {Array} The array of relationships
*/
const addRelationships = function (relationships, g) {
relationships.forEach(function (r) {
g.setEdge(r.entityA, r.entityB, { relationship: r }, getEdgeName(r));
g.setEdge(
entityNameIds.get(r.entityA),
entityNameIds.get(r.entityB),
{ relationship: r },
getEdgeName(r)
);
});
return relationships;
}; // addRelationships
@@ -418,7 +441,11 @@ const drawRelationshipFromLayout = function (svg, rel, g, insert, diagObj) {
relCnt++;
// Find the edge relating to this relationship
const edge = g.edge(rel.entityA, rel.entityB, getEdgeName(rel));
const edge = g.edge(
entityNameIds.get(rel.entityA),
entityNameIds.get(rel.entityB),
getEdgeName(rel)
);
// Get a function that will generate the line path
const lineFunction = line()
@@ -535,8 +562,6 @@ const drawRelationshipFromLayout = function (svg, rel, g, insert, diagObj) {
.attr('height', labelBBox.height)
.attr('fill', 'white')
.attr('fill-opacity', '85%');
return;
};
/**
@@ -552,7 +577,7 @@ export const draw = function (text, id, _version, diagObj) {
log.info('Drawing ER diagram');
// diag.db.clear();
const securityLevel = getConfig().securityLevel;
// Handle root and Document for when rendering in sanbox mode
// Handle root and Document for when rendering in sandbox mode
let sandboxElement;
if (securityLevel === 'sandbox') {
sandboxElement = select('#i' + id);
@@ -581,7 +606,7 @@ export const draw = function (text, id, _version, diagObj) {
// 1. Create all the entities in the svg node at 0,0, but with the correct dimensions (allowing for text content)
// 2. Make sure they are all added to the graph
// 3. Add all the edges (relationships) to the graph as well
// 4. Let dagre do its magic to layout the graph. This assigns:
// 4. Let dagre do its magic to lay out the graph. This assigns:
// - the centre co-ordinates for each node, bearing in mind the dimensions and edge relationships
// - the path co-ordinates for each edge
// But it has no impact on the svg child nodes - the diagram remains with every entity rooted at 0,0
@@ -647,6 +672,35 @@ export const draw = function (text, id, _version, diagObj) {
addSVGAccessibilityFields(diagObj.db, svg, id);
}; // draw
/**
* Return a unique id based on the given string. Start with the prefix, then a hyphen, then the
* simplified str, then a hyphen, then a unique uuid. (Hyphens are only included if needed.)
* Although the official XML standard for ids says that many more characters are valid in the id,
* this keeps things simple by accepting only A-Za-z0-9.
*
* @param {string} [str?=''] Given string to use as the basis for the id. Default is `''`
* @param {string} [prefix?=''] String to put at the start, followed by '-'. Default is `''`
* @param str
* @param prefix
* @returns {string}
* @see https://www.w3.org/TR/xml/#NT-Name
*/
export function generateId(str = '', prefix = '') {
const simplifiedStr = str.replace(BAD_ID_CHARS_REGEXP, '');
return `${strWithHyphen(prefix)}${strWithHyphen(simplifiedStr)}${uuid4()}`;
}
/**
* Append a hyphen to a string only if the string isn't empty
*
* @param {string} str
* @returns {string}
* @todo This could be moved into a string utility file/class.
*/
function strWithHyphen(str = '') {
return str.length > 0 ? `${str}-` : '';
}
export default {
setConf,
draw,

View File

@@ -24,6 +24,7 @@ accDescr\s*"{"\s* { this.begin("acc_descr_multili
[\n]+ return 'NEWLINE';
\s+ /* skip whitespace */
[\s]+ return 'SPACE';
\"[^"%\r\n\v\b\\]+\" return 'ENTITY_NAME';
\"[^"]*\" return 'WORD';
"erDiagram" return 'ER_DIAGRAM';
"{" { this.begin("block"); return 'BLOCK_START'; }
@@ -102,8 +103,8 @@ statement
;
entityName
: 'ALPHANUM' { $$ = $1; /*console.log('Entity: ' + $1);*/ }
| 'ALPHANUM' '.' entityName { $$ = $1 + $2 + $3; }
: 'ALPHANUM' { $$ = $1; }
| 'ENTITY_NAME' { $$ = $1.replace(/"/g, ''); }
;
attributes
@@ -156,6 +157,7 @@ relType
role
: 'WORD' { $$ = $1.replace(/"/g, ''); }
| 'ENTITY_NAME' { $$ = $1.replace(/"/g, ''); }
| 'ALPHANUM' { $$ = $1; }
;

View File

@@ -1,6 +1,6 @@
import { setConfig } from '../../../config';
import erDb from '../erDb';
import erDiagram from './erDiagram';
import erDiagram from './erDiagram'; // jison file
setConfig({
securityLevel: 'strict',
@@ -21,14 +21,118 @@ describe('when parsing ER diagram it...', function () {
expect(erDb.getRelationships().length).toBe(0);
});
it('should allow hyphens and underscores in entity names', function () {
const line1 = 'DUCK-BILLED-PLATYPUS';
const line2 = 'CHARACTER_SET';
erDiagram.parser.parse(`erDiagram\n${line1}\n${line2}`);
describe('entity name', () => {
it('cannot be empty quotes ""', function () {
const name = '""';
expect(() => {
erDiagram.parser.parse(`erDiagram\n ${name}\n`);
const entities = erDb.getEntities();
expect(entities.hasOwnProperty(name)).toBe(false);
}).toThrow();
});
describe('has non A-Za-z0-9_- chars', function () {
// these were entered using the Mac keyboard utility.
const chars =
"~ ` ! @ # $ ^ & * ( ) - _ = + [ ] { } | / ; : ' . ? ¡ ™ € £ ¢ ∞ fi § ‡ • ° ª · º ≠ ± œ Œ ∑ „ ® † ˇ ¥ Á ¨ ˆ ˆ Ø π ∏ “ « » å Å ß Í ∂ Î ƒ Ï © ˙ Ó ∆ Ô ˚  ¬ Ò … Ú æ Æ Ω ¸ ≈ π ˛ ç Ç √ ◊ ∫ ı ˜ µ  ≤ ¯ ≥ ˘ ÷ ¿";
const allowed = chars.split(' ');
const entities = erDb.getEntities();
expect(entities.hasOwnProperty('DUCK-BILLED-PLATYPUS')).toBe(true);
expect(entities.hasOwnProperty('CHARACTER_SET')).toBe(true);
allowed.forEach((allowedChar) => {
const singleOccurrence = `Blo${allowedChar}rf`;
const repeatedOccurrence = `Blo${allowedChar}${allowedChar}rf`;
const cannontStartWith = `${allowedChar}Blorf`;
const endsWith = `Blorf${allowedChar}`;
it(`${singleOccurrence} fails if not surrounded by quotes`, function () {
const name = singleOccurrence;
expect(() => {
erDiagram.parser.parse(`erDiagram\n ${name}\n`);
const entities = erDb.getEntities();
expect(entities.hasOwnProperty(name)).toBe(false);
}).toThrow();
});
it(`"${singleOccurrence}" single occurrence`, function () {
const name = singleOccurrence;
erDiagram.parser.parse(`erDiagram\n "${name}"\n`);
const entities = erDb.getEntities();
expect(entities.hasOwnProperty(name)).toBe(true);
});
it(`"${repeatedOccurrence}" repeated occurrence`, function () {
const name = repeatedOccurrence;
erDiagram.parser.parse(`erDiagram\n "${name}"\n`);
const entities = erDb.getEntities();
expect(entities.hasOwnProperty(name)).toBe(true);
});
it(`"${singleOccurrence}" ends with`, function () {
const name = endsWith;
erDiagram.parser.parse(`erDiagram\n "${name}"\n`);
const entities = erDb.getEntities();
expect(entities.hasOwnProperty(name)).toBe(true);
});
it(`"${cannontStartWith}" cannot start with the character`, function () {
const name = repeatedOccurrence;
expect(() => {
erDiagram.parser.parse(`erDiagram\n "${name}"\n`);
const entities = erDb.getEntities();
expect(entities.hasOwnProperty(name)).toBe(false);
}).toThrow();
});
});
const allCombined = allowed.join('');
it(`a${allCombined} (all non-alphanumerics) in one, starting with 'a'`, function () {
const name = 'a' + allCombined;
erDiagram.parser.parse(`erDiagram\n "${name}"\n`);
const entities = erDb.getEntities();
expect(entities.hasOwnProperty(name)).toBe(true);
});
});
it('cannot contain % because it interfers with parsing comments', function () {
expect(() => {
erDiagram.parser.parse(`erDiagram\n "Blo%rf"\n`);
const entities = erDb.getEntities();
expect(entities.hasOwnProperty(name)).toBe(false);
}).toThrow();
});
it('cannot contain \\ because it could start and escape code', function () {
expect(() => {
erDiagram.parser.parse(`erDiagram\n "Blo\\rf"\n`);
const entities = erDb.getEntities();
expect(entities.hasOwnProperty(name)).toBe(false);
}).toThrow();
});
it('cannot newline, backspace, or vertical characters', function () {
const disallowed = ['\n', '\r', '\b', '\v'];
disallowed.forEach((badChar) => {
const badName = `Blo${badChar}rf`;
expect(() => {
erDiagram.parser.parse(`erDiagram\n "${badName}"\n`);
const entities = erDb.getEntities();
expect(entities.hasOwnProperty(badName)).toBe(false);
}).toThrow();
});
});
// skip this: jison cannot handle non-english letters
it.skip('[skipped test] can contain àáâäæãåā', function () {
const beyondEnglishName = 'DUCK-àáâäæãåā';
erDiagram.parser.parse(`erDiagram\n${beyondEnglishName}\n`);
const entities = erDb.getEntities();
expect(entities.hasOwnProperty(beyondEnglishName)).toBe(true);
});
it('can contain - _ without needing ""', function () {
const hyphensUnderscore = 'DUCK-BILLED_PLATYPUS';
erDiagram.parser.parse(`erDiagram\n${hyphensUnderscore}\n`);
const entities = erDb.getEntities();
expect(entities.hasOwnProperty(hyphensUnderscore)).toBe(true);
});
});
it('should allow an entity with a single attribute to be defined', function () {
@@ -447,27 +551,23 @@ describe('when parsing ER diagram it...', function () {
}).toThrowError();
});
it('should allow an empty quoted label', function () {
erDiagram.parser.parse('erDiagram\nCUSTOMER ||--|{ ORDER : ""');
const rels = erDb.getRelationships();
expect(rels[0].roleA).toBe('');
});
describe('relationship labels', function () {
it('should allow an empty quoted label', function () {
erDiagram.parser.parse('erDiagram\nCUSTOMER ||--|{ ORDER : ""');
const rels = erDb.getRelationships();
expect(rels[0].roleA).toBe('');
});
it('should allow an non-empty quoted label', function () {
erDiagram.parser.parse('erDiagram\nCUSTOMER ||--|{ ORDER : "places"');
const rels = erDb.getRelationships();
expect(rels[0].roleA).toBe('places');
});
it('should allow an non-empty quoted label', function () {
erDiagram.parser.parse('erDiagram\nCUSTOMER ||--|{ ORDER : "places"');
const rels = erDb.getRelationships();
expect(rels[0].roleA).toBe('places');
});
it('should allow an non-empty unquoted label', function () {
erDiagram.parser.parse('erDiagram\nCUSTOMER ||--|{ ORDER : places');
const rels = erDb.getRelationships();
expect(rels[0].roleA).toBe('places');
});
it('should allow an entity name with a dot', function () {
erDiagram.parser.parse('erDiagram\nCUSTOMER.PROP ||--|{ ORDER : places');
const rels = erDb.getRelationships();
expect(rels[0].entityA).toBe('CUSTOMER.PROP');
it('should allow an non-empty unquoted label', function () {
erDiagram.parser.parse('erDiagram\nCUSTOMER ||--|{ ORDER : places');
const rels = erDb.getRelationships();
expect(rels[0].roleA).toBe('places');
});
});
});

View File

@@ -37,7 +37,7 @@
[0-9]+(?=[ \n]+) return 'NUM';
"participant" { this.begin('ID'); return 'participant'; }
"actor" { this.begin('ID'); return 'participant_actor'; }
<ID>[^\->:\n,;]+?(?=((?!\n)\s)+"as"(?!\n)\s|[#\n;]|$) { yytext = yytext.trim(); this.begin('ALIAS'); return 'ACTOR'; }
<ID>[^\->:\n,;]+?([\-]*[^\->:\n,;]+?)*?(?=((?!\n)\s)+"as"(?!\n)\s|[#\n;]|$) { yytext = yytext.trim(); this.begin('ALIAS'); return 'ACTOR'; }
<ALIAS>"as" { this.popState(); this.popState(); this.begin('LINE'); return 'AS'; }
<ALIAS>(?:) { this.popState(); this.popState(); return 'NEWLINE'; }
"loop" { this.begin('LINE'); return 'loop'; }

View File

@@ -249,7 +249,7 @@ Bob-->Alice-in-Wonderland:I am good thanks!`;
mermaidAPI.parse(str);
const actors = diagram.db.getActors();
expect(actors['Alice-in-Wonderland'].description).toBe('Alice-in-Wonderland');
actors.Bob.description = 'Bob';
expect(actors.Bob.description).toBe('Bob');
const messages = diagram.db.getMessages();
@@ -257,6 +257,28 @@ Bob-->Alice-in-Wonderland:I am good thanks!`;
expect(messages[0].from).toBe('Alice-in-Wonderland');
expect(messages[1].from).toBe('Bob');
});
it('should handle dashes in participant names', function () {
const str = `
sequenceDiagram
participant Alice-in-Wonderland
participant Bob
Alice-in-Wonderland->Bob:Hello Bob, how are - you?
Bob-->Alice-in-Wonderland:I am good thanks!`;
mermaidAPI.parse(str);
const actors = diagram.db.getActors();
expect(Object.keys(actors)).toEqual(['Alice-in-Wonderland', 'Bob']);
expect(actors['Alice-in-Wonderland'].description).toBe('Alice-in-Wonderland');
expect(actors.Bob.description).toBe('Bob');
const messages = diagram.db.getMessages();
expect(messages.length).toBe(2);
expect(messages[0].from).toBe('Alice-in-Wonderland');
expect(messages[1].from).toBe('Bob');
});
it('should alias participants', function () {
const str = `
sequenceDiagram