Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ER diagram: allow other chars in a quoted entity name #3516

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 36 additions & 20 deletions demos/er.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,31 +15,47 @@

<body>
<pre class="mermaid">
erDiagram
title: This is a title
accDescription_ Test a description

CUSTOMER ||--o{ ORDER : places
ORDER ||--|{ LINE-ITEM : contains
CUSTOMER }|..|{ DELIVERY-ADDRESS : uses

DELIVERY-ADDRESS {
int customerId
string addressLine1
string addressLine2
string city
string county
string state
string region
string country
string postalCode
}

erDiagram
%% title This is a title
%% accDescription Test a description

"Person . CUSTOMER"||--o{ ORDER : places

ORDER ||--|{ "€£LINE_ITEM ¥" : contains

"Person . CUSTOMER" }|..|{ "Address//StreetAddress::[DELIVERY ADDRESS]" : uses

"Address//StreetAddress::[DELIVERY ADDRESS]" {
int customerID FK
string line1 "this is the first address line comment"
string line2
string city
string region
string state
string postal_code
string country
}

"a_~`!@#$^&*()-_=+[]{}|/;:'.?¡⁄™€£‹¢›∞fi§‡•°ª·º‚≠±œŒ∑„®†ˇ¥Á¨ˆˆØπ∏“«»åÅßÍ∂΃ϩ˙Ó∆Ô˚¬Ò…ÚæÆΩ¸≈π˛çÇ√◊∫ı˜µÂ≤¯≥˘÷¿" {
string name "this is an entity with an absurd name just to show characters that are now acceptable as long as the name is in double quotes"
}

"€£LINE_ITEM ¥" {
int orderID FK
int currencyId FK
number price
number quantity
number adjustment
number final_price
}
</pre>

<script type="module">
import mermaid from '../src/mermaid';
mermaid.initialize({
theme: 'base',
theme: 'default',

// themeCSS: '.node rect { fill: red; }',
logLevel: 3,
securityLevel: 'loose',
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
},
"dependencies": {
"@braintree/sanitize-url": "^6.0.0",
"@types/uuid": "^8.3.4",
"d3": "^7.0.0",
"dagre": "^0.8.5",
"dagre-d3": "^0.6.4",
Expand All @@ -75,7 +76,8 @@
"lodash": "^4.17.21",
"moment-mini": "^2.24.0",
"non-layered-tidy-tree-layout": "^2.0.2",
"stylis": "^4.1.2"
"stylis": "^4.1.2",
"uuid": "^9.0.0"
},
"devDependencies": {
"@applitools/eyes-cypress": "^3.25.7",
Expand Down
98 changes: 76 additions & 22 deletions src/diagrams/er/erRenderer.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
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';
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()
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -313,15 +321,18 @@ const drawEntities = function (svgNode, entities, graph) {
const keys = Object.keys(entities);
let firstOne;

keys.forEach(function (id) {
keys.forEach(function (entityName) {
const entityId = generateId(entityName, 'entity');
entityNameIds.set(entityName, entityId);

// Create a group for each entity
const groupNode = svgNode.append('g').attr('id', id);
const groupNode = svgNode.append('g').attr('id', entityId);

firstOne = firstOne === undefined ? id : firstOne;
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')
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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, '');
};
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -535,8 +562,6 @@ const drawRelationshipFromLayout = function (svg, rel, g, insert, diagObj) {
.attr('height', labelBBox.height)
.attr('fill', 'white')
.attr('fill-opacity', '85%');

return;
};

/**
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions src/diagrams/er/parser/erDiagram.jison
Original file line number Diff line number Diff line change
Expand Up @@ -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'; }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -156,6 +157,7 @@ relType

role
: 'WORD' { $$ = $1.replace(/"/g, ''); }
| 'ENTITY_NAME' { $$ = $1.replace(/"/g, ''); }
| 'ALPHANUM' { $$ = $1; }
;

Expand Down
Loading