Merge pull request #2237 from RonaldZielaznicki/2070_more_columns_for_entity_relationship_diagrams

2070: Update ER Diagram to have keys and comments.
This commit is contained in:
Knut Sveidqvist 2021-09-09 17:50:38 +02:00 committed by GitHub
commit b6ba4b2fd8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 190 additions and 26 deletions

View File

@ -186,4 +186,15 @@ describe('Entity Relationship Diagram', () => {
cy.get('svg'); cy.get('svg');
}); });
it('should render entities with keys and comments', () => {
renderGraph(
`
erDiagram
BOOK { string title PK "comment"}
`,
{ logLevel : 1 }
);
cy.get('svg');
});
}); });

View File

@ -191,6 +191,28 @@ erDiagram
The `type` and `name` values must begin with an alphabetic character and may contain digits, hyphens or underscores. Other than that, there are no restrictions, and there is no implicit set of valid data types. The `type` and `name` values must begin with an alphabetic character and may contain digits, hyphens or underscores. Other than that, there are no restrictions, and there is no implicit set of valid data types.
#### Attribute Keys and Comments
Attributes may also have a `key` or comment defined. Keys can be "PK" or "FK", for Primary Key or Foreign Key. And a `comment` is defined by quotes at the end of an attribute. Comments themselves cannot have quote characters in them.
```mermaid
erDiagram
CAR ||--o{ NAMED-DRIVER : allows
CAR {
string allowedDriver FK 'The license of the allowed driver'
string registrationNumber
string make
string model
}
PERSON ||--o{ NAMED-DRIVER : is
PERSON {
string driversLicense PK 'The license #'
string firstName
string lastName
int age
}
```
### Other Things ### Other Things
- If you want the relationship label to be more than one word, you must use double quotes around the phrase - If you want the relationship label to be more than one word, you must use double quotes around the phrase

View File

@ -35,13 +35,20 @@ const drawAttributes = (groupNode, entityTextNode, attributes) => {
const attrFontSize = conf.fontSize * 0.85; const attrFontSize = conf.fontSize * 0.85;
const labelBBox = entityTextNode.node().getBBox(); const labelBBox = entityTextNode.node().getBBox();
const attributeNodes = []; // Intermediate storage for attribute nodes created so that we can do a second pass const attributeNodes = []; // Intermediate storage for attribute nodes created so that we can do a second pass
let hasKeyType = false;
let hasComment = false;
let maxWidth = 0;
let maxTypeWidth = 0; let maxTypeWidth = 0;
let maxNameWidth = 0; let maxNameWidth = 0;
let maxKeyWidth = 0;
let maxCommentWidth = 0;
let cumulativeHeight = labelBBox.height + heightPadding * 2; let cumulativeHeight = labelBBox.height + heightPadding * 2;
let attrNum = 1; let attrNum = 1;
attributes.forEach((item) => { attributes.forEach((item) => {
const attrPrefix = `${entityTextNode.node().id}-attr-${attrNum}`; const attrPrefix = `${entityTextNode.node().id}-attr-${attrNum}`;
let nodeWidth = 0;
let nodeHeight = 0;
// Add a text node for the attribute type // Add a text node for the attribute type
const typeNode = groupNode const typeNode = groupNode
@ -73,16 +80,70 @@ const drawAttributes = (groupNode, entityTextNode, attributes) => {
) )
.text(item.attributeName); .text(item.attributeName);
// Keep a reference to the nodes so that we can iterate through them later const attributeNode = {};
attributeNodes.push({ tn: typeNode, nn: nameNode }); attributeNode.tn = typeNode;
attributeNode.nn = nameNode;
const typeBBox = typeNode.node().getBBox(); const typeBBox = typeNode.node().getBBox();
const nameBBox = nameNode.node().getBBox(); const nameBBox = nameNode.node().getBBox();
maxTypeWidth = Math.max(maxTypeWidth, typeBBox.width); maxTypeWidth = Math.max(maxTypeWidth, typeBBox.width);
maxNameWidth = Math.max(maxNameWidth, nameBBox.width); maxNameWidth = Math.max(maxNameWidth, nameBBox.width);
nodeWidth += typeBBox.width;
nodeWidth += nameBBox.width;
cumulativeHeight += Math.max(typeBBox.height, nameBBox.height) + heightPadding * 2; nodeHeight = Math.max(typeBBox.height, nameBBox.height);
if (hasKeyType || item.attributeKeyType !== undefined) {
const keyTypeNode = groupNode
.append('text')
.attr('class', 'er entityLabel')
.attr('id', `${attrPrefix}-name`)
.attr('x', 0)
.attr('y', 0)
.attr('dominant-baseline', 'middle')
.attr('text-anchor', 'left')
.attr(
'style',
'font-family: ' + getConfig().fontFamily + '; font-size: ' + attrFontSize + 'px'
)
.text(item.attributeKeyType || '');
attributeNode.kn = keyTypeNode;
const keyTypeBBox = keyTypeNode.node().getBBox();
nodeWidth += keyTypeBBox.width;
maxKeyWidth = Math.max(maxKeyWidth, nodeWidth);
nodeHeight = Math.max(nodeHeight, keyTypeBBox.height);
hasKeyType = true;
}
if (hasComment || item.attributeComment !== undefined) {
const commentNode = groupNode
.append('text')
.attr('class', 'er entityLabel')
.attr('id', `${attrPrefix}-name`)
.attr('x', 0)
.attr('y', 0)
.attr('dominant-baseline', 'middle')
.attr('text-anchor', 'left')
.attr(
'style',
'font-family: ' + getConfig().fontFamily + '; font-size: ' + attrFontSize + 'px'
)
.text(item.attributeComment || '');
attributeNode.cn = commentNode;
const commentNodeBBox = commentNode.node().getBBox();
nodeWidth += commentNodeBBox.width;
maxCommentWidth = Math.max(nodeWidth, nameBBox.width);
nodeHeight = Math.max(nodeHeight, commentNodeBBox.height);
hasComment = true;
}
attributeNode.height = nodeHeight;
// Keep a reference to the nodes so that we can iterate through them later
attributeNodes.push(attributeNode);
maxWidth = Math.max(maxWidth, nodeWidth);
cumulativeHeight += nodeHeight + heightPadding * 2;
attrNum += 1; attrNum += 1;
}); });
@ -90,10 +151,7 @@ const drawAttributes = (groupNode, entityTextNode, attributes) => {
const bBox = { const bBox = {
width: Math.max( width: Math.max(
conf.minEntityWidth, conf.minEntityWidth,
Math.max( Math.max(labelBBox.width + conf.entityPadding * 2, maxWidth + widthPadding * 4)
labelBBox.width + conf.entityPadding * 2,
maxTypeWidth + maxNameWidth + widthPadding * 4
)
), ),
height: height:
attributes.length > 0 attributes.length > 0
@ -102,7 +160,7 @@ const drawAttributes = (groupNode, entityTextNode, attributes) => {
}; };
// There might be some spare width for padding out attributes if the entity name is very long // There might be some spare width for padding out attributes if the entity name is very long
const spareWidth = Math.max(0, bBox.width - (maxTypeWidth + maxNameWidth) - widthPadding * 4); const spareWidth = Math.max(0, bBox.width - maxWidth - widthPadding * 4);
if (attributes.length > 0) { if (attributes.length > 0) {
// Position the entity label near the top of the entity bounding box // Position the entity label near the top of the entity bounding box
@ -115,51 +173,85 @@ const drawAttributes = (groupNode, entityTextNode, attributes) => {
let heightOffset = labelBBox.height + heightPadding * 2; // Start at the bottom of the entity label let heightOffset = labelBBox.height + heightPadding * 2; // Start at the bottom of the entity label
let attribStyle = 'attributeBoxOdd'; // We will flip the style on alternate rows to achieve a banded effect let attribStyle = 'attributeBoxOdd'; // We will flip the style on alternate rows to achieve a banded effect
attributeNodes.forEach((nodePair) => { attributeNodes.forEach((attributeNode) => {
// Calculate the alignment y co-ordinate for the type/name of the attribute // Calculate the alignment y co-ordinate for the type/name of the attribute
const alignY = const alignY = heightOffset + heightPadding + attributeNode.height / 2;
heightOffset +
heightPadding +
Math.max(nodePair.tn.node().getBBox().height, nodePair.nn.node().getBBox().height) / 2;
// Position the type of the attribute // Position the type of the attribute
nodePair.tn.attr('transform', 'translate(' + widthPadding + ',' + alignY + ')'); attributeNode.tn.attr('transform', 'translate(' + widthPadding + ',' + alignY + ')');
// Insert a rectangle for the type // Insert a rectangle for the type
const typeRect = groupNode const typeRect = groupNode
.insert('rect', '#' + nodePair.tn.node().id) .insert('rect', '#' + attributeNode.tn.node().id)
.attr('class', `er ${attribStyle}`) .attr('class', `er ${attribStyle}`)
.attr('fill', conf.fill) .attr('fill', conf.fill)
.attr('fill-opacity', '100%') .attr('fill-opacity', '100%')
.attr('stroke', conf.stroke) .attr('stroke', conf.stroke)
.attr('x', 0) .attr('x', 0)
.attr('y', heightOffset) .attr('y', heightOffset)
.attr('width', maxTypeWidth + widthPadding * 2 + spareWidth / 2) .attr('width', maxTypeWidth * 2 + spareWidth / 2)
.attr('height', nodePair.tn.node().getBBox().height + heightPadding * 2); .attr('height', attributeNode.tn.node().getBBox().height + heightPadding * 2);
// Position the name of the attribute // Position the name of the attribute
nodePair.nn.attr( attributeNode.nn.attr(
'transform', 'transform',
'translate(' + (parseFloat(typeRect.attr('width')) + widthPadding) + ',' + alignY + ')' 'translate(' + (parseFloat(typeRect.attr('width')) + widthPadding) + ',' + alignY + ')'
); );
// Insert a rectangle for the name // Insert a rectangle for the name
groupNode groupNode
.insert('rect', '#' + nodePair.nn.node().id) .insert('rect', '#' + attributeNode.nn.node().id)
.attr('class', `er ${attribStyle}`) .attr('class', `er ${attribStyle}`)
.attr('fill', conf.fill) .attr('fill', conf.fill)
.attr('fill-opacity', '100%') .attr('fill-opacity', '100%')
.attr('stroke', conf.stroke) .attr('stroke', conf.stroke)
.attr('x', `${typeRect.attr('x') + typeRect.attr('width')}`) .attr('x', `${typeRect.attr('x') + typeRect.attr('width')}`)
//.attr('x', maxTypeWidth + (widthPadding * 2))
.attr('y', heightOffset) .attr('y', heightOffset)
.attr('width', maxNameWidth + widthPadding * 2 + spareWidth / 2) .attr('width', maxNameWidth + widthPadding * 2 + spareWidth / 2)
.attr('height', nodePair.nn.node().getBBox().height + heightPadding * 2); .attr('height', attributeNode.nn.node().getBBox().height + heightPadding * 2);
if (hasKeyType) {
// Position the name of the attribute
attributeNode.kn.attr(
'transform',
'translate(' + (parseFloat(typeRect.attr('width')) + widthPadding) + ',' + alignY + ')'
);
// Insert a rectangle for the name
groupNode
.insert('rect', '#' + attributeNode.kn.node().id)
.attr('class', `er ${attribStyle}`)
.attr('fill', conf.fill)
.attr('fill-opacity', '100%')
.attr('stroke', conf.stroke)
.attr('x', `${typeRect.attr('x') + typeRect.attr('width')}`)
.attr('y', heightOffset)
.attr('width', maxKeyWidth + widthPadding * 2 + spareWidth / 2)
.attr('height', attributeNode.kn.node().getBBox().height + heightPadding * 2);
}
if (hasComment) {
// Position the name of the attribute
attributeNode.cn.attr(
'transform',
'translate(' + (parseFloat(typeRect.attr('width')) + widthPadding) + ',' + alignY + ')'
);
// Insert a rectangle for the name
groupNode
.insert('rect', '#' + attributeNode.cn.node().id)
.attr('class', `er ${attribStyle}`)
.attr('fill', conf.fill)
.attr('fill-opacity', '100%')
.attr('stroke', conf.stroke)
.attr('x', `${typeRect.attr('x') + typeRect.attr('width')}`)
.attr('y', heightOffset)
.attr('width', maxCommentWidth + widthPadding * 2 + spareWidth / 2)
.attr('height', attributeNode.cn.node().getBBox().height + heightPadding * 2);
}
// Increment the height offset to move to the next row // Increment the height offset to move to the next row
heightOffset += heightOffset += attributeNode.height + heightPadding * 2;
Math.max(nodePair.tn.node().getBBox().height, nodePair.nn.node().getBBox().height) +
heightPadding * 2;
// Flip the attribute style for row banding // Flip the attribute style for row banding
attribStyle = attribStyle == 'attributeBoxOdd' ? 'attributeBoxEven' : 'attributeBoxOdd'; attribStyle = attribStyle == 'attributeBoxOdd' ? 'attributeBoxEven' : 'attributeBoxOdd';

View File

@ -18,7 +18,9 @@
"erDiagram" return 'ER_DIAGRAM'; "erDiagram" return 'ER_DIAGRAM';
"{" { this.begin("block"); return 'BLOCK_START'; } "{" { this.begin("block"); return 'BLOCK_START'; }
<block>\s+ /* skip whitespace in block */ <block>\s+ /* skip whitespace in block */
<block>[A-Za-z][A-Za-z0-9\-_]* { return 'ATTRIBUTE_WORD'; } <block>(?:PK)|(?:FK) return 'ATTRIBUTE_KEY'
<block>[A-Za-z][A-Za-z0-9\-_]* return 'ATTRIBUTE_WORD'
<block>\"[^"]*\" return 'COMMENT';
<block>[\n]+ /* nothing */ <block>[\n]+ /* nothing */
<block>"}" { this.popState(); return 'BLOCK_STOP'; } <block>"}" { this.popState(); return 'BLOCK_STOP'; }
<block>. return yytext[0]; <block>. return yytext[0];
@ -95,6 +97,9 @@ attributes
attribute attribute
: attributeType attributeName { $$ = { attributeType: $1, attributeName: $2 }; } : attributeType attributeName { $$ = { attributeType: $1, attributeName: $2 }; }
| attributeType attributeName attributeKeyType { $$ = { attributeType: $1, attributeName: $2, attributeKeyType: $3 }; }
| attributeType attributeName COMMENT { $$ = { attributeType: $1, attributeName: $2, attributeComment: $3 }; }
| attributeType attributeName attributeKeyType COMMENT { $$ = { attributeType: $1, attributeName: $2, attributeKeyType: $3, attributeComment: $4 }; }
; ;
attributeType attributeType
@ -105,6 +110,10 @@ attributeName
: ATTRIBUTE_WORD { $$=$1; } : ATTRIBUTE_WORD { $$=$1; }
; ;
attributeKeyType
: ATTRIBUTE_KEY { $$=$1; }
;
relSpec relSpec
: cardinality relType cardinality : cardinality relType cardinality
{ {

View File

@ -42,6 +42,36 @@ describe('when parsing ER diagram it...', function () {
expect(entities[entity].attributes.length).toBe(1); expect(entities[entity].attributes.length).toBe(1);
}); });
it('should allow an entity with a single attribute to be defined with a key', function () {
const entity = 'BOOK';
const attribute = 'string title PK';
erDiagram.parser.parse(`erDiagram\n${entity} {\n${attribute}\n}`);
const entities = erDb.getEntities();
expect(Object.keys(entities).length).toBe(1);
expect(entities[entity].attributes.length).toBe(1);
});
it('should allow an entity with a single attribute to be defined with a comment', function () {
const entity = 'BOOK';
const attribute = `string title "comment"`;
erDiagram.parser.parse(`erDiagram\n${entity} {\n${attribute}\n}`);
const entities = erDb.getEntities();
expect(Object.keys(entities).length).toBe(1);
expect(entities[entity].attributes.length).toBe(1);
});
it('should allow an entity with a single attribute to be defined with a key and a comment', function () {
const entity = 'BOOK';
const attribute = `string title PK "comment"`;
erDiagram.parser.parse(`erDiagram\n${entity} {\n${attribute}\n}`);
const entities = erDb.getEntities();
expect(Object.keys(entities).length).toBe(1);
expect(entities[entity].attributes.length).toBe(1);
});
it('should allow an entity with multiple attributes to be defined', function () { it('should allow an entity with multiple attributes to be defined', function () {
const entity = 'BOOK'; const entity = 'BOOK';
const attribute1 = 'string title'; const attribute1 = 'string title';