diff --git a/cypress/integration/rendering/erDiagram.spec.js b/cypress/integration/rendering/erDiagram.spec.js index 1b4b0b9a2..fc93fb5bb 100644 --- a/cypress/integration/rendering/erDiagram.spec.js +++ b/cypress/integration/rendering/erDiagram.spec.js @@ -186,4 +186,15 @@ describe('Entity Relationship Diagram', () => { cy.get('svg'); }); + it('should render entities with keys and comments', () => { + renderGraph( + ` + erDiagram + BOOK { string title PK "comment"} + `, + { logLevel : 1 } + ); + cy.get('svg'); + }); + }); diff --git a/docs/entityRelationshipDiagram.md b/docs/entityRelationshipDiagram.md index ba539cbe4..4213cd9b6 100644 --- a/docs/entityRelationshipDiagram.md +++ b/docs/entityRelationshipDiagram.md @@ -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. +#### 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 - If you want the relationship label to be more than one word, you must use double quotes around the phrase diff --git a/src/diagrams/er/erRenderer.js b/src/diagrams/er/erRenderer.js index d96fd8957..27b125828 100644 --- a/src/diagrams/er/erRenderer.js +++ b/src/diagrams/er/erRenderer.js @@ -35,13 +35,20 @@ const drawAttributes = (groupNode, entityTextNode, attributes) => { const attrFontSize = conf.fontSize * 0.85; const labelBBox = entityTextNode.node().getBBox(); 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 maxNameWidth = 0; + let maxKeyWidth = 0; + let maxCommentWidth = 0; let cumulativeHeight = labelBBox.height + heightPadding * 2; let attrNum = 1; attributes.forEach((item) => { const attrPrefix = `${entityTextNode.node().id}-attr-${attrNum}`; + let nodeWidth = 0; + let nodeHeight = 0; // Add a text node for the attribute type const typeNode = groupNode @@ -73,16 +80,70 @@ const drawAttributes = (groupNode, entityTextNode, attributes) => { ) .text(item.attributeName); - // Keep a reference to the nodes so that we can iterate through them later - attributeNodes.push({ tn: typeNode, nn: nameNode }); + const attributeNode = {}; + attributeNode.tn = typeNode; + attributeNode.nn = nameNode; const typeBBox = typeNode.node().getBBox(); const nameBBox = nameNode.node().getBBox(); - maxTypeWidth = Math.max(maxTypeWidth, typeBBox.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; }); @@ -90,10 +151,7 @@ const drawAttributes = (groupNode, entityTextNode, attributes) => { const bBox = { width: Math.max( conf.minEntityWidth, - Math.max( - labelBBox.width + conf.entityPadding * 2, - maxTypeWidth + maxNameWidth + widthPadding * 4 - ) + Math.max(labelBBox.width + conf.entityPadding * 2, maxWidth + widthPadding * 4) ), height: 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 - 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) { // 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 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 - const alignY = - heightOffset + - heightPadding + - Math.max(nodePair.tn.node().getBBox().height, nodePair.nn.node().getBBox().height) / 2; + const alignY = heightOffset + heightPadding + attributeNode.height / 2; // 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 const typeRect = groupNode - .insert('rect', '#' + nodePair.tn.node().id) + .insert('rect', '#' + attributeNode.tn.node().id) .attr('class', `er ${attribStyle}`) .attr('fill', conf.fill) .attr('fill-opacity', '100%') .attr('stroke', conf.stroke) .attr('x', 0) .attr('y', heightOffset) - .attr('width', maxTypeWidth + widthPadding * 2 + spareWidth / 2) - .attr('height', nodePair.tn.node().getBBox().height + heightPadding * 2); + .attr('width', maxTypeWidth * 2 + spareWidth / 2) + .attr('height', attributeNode.tn.node().getBBox().height + heightPadding * 2); // Position the name of the attribute - nodePair.nn.attr( + attributeNode.nn.attr( 'transform', 'translate(' + (parseFloat(typeRect.attr('width')) + widthPadding) + ',' + alignY + ')' ); // Insert a rectangle for the name groupNode - .insert('rect', '#' + nodePair.nn.node().id) + .insert('rect', '#' + attributeNode.nn.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('x', maxTypeWidth + (widthPadding * 2)) .attr('y', heightOffset) .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 - heightOffset += - Math.max(nodePair.tn.node().getBBox().height, nodePair.nn.node().getBBox().height) + - heightPadding * 2; + heightOffset += attributeNode.height + heightPadding * 2; // Flip the attribute style for row banding attribStyle = attribStyle == 'attributeBoxOdd' ? 'attributeBoxEven' : 'attributeBoxOdd'; diff --git a/src/diagrams/er/parser/erDiagram.jison b/src/diagrams/er/parser/erDiagram.jison index 6e4815f28..7c023640e 100644 --- a/src/diagrams/er/parser/erDiagram.jison +++ b/src/diagrams/er/parser/erDiagram.jison @@ -18,7 +18,9 @@ "erDiagram" return 'ER_DIAGRAM'; "{" { this.begin("block"); return 'BLOCK_START'; } \s+ /* skip whitespace in block */ -[A-Za-z][A-Za-z0-9\-_]* { return 'ATTRIBUTE_WORD'; } +(?:PK)|(?:FK) return 'ATTRIBUTE_KEY' +[A-Za-z][A-Za-z0-9\-_]* return 'ATTRIBUTE_WORD' +\"[^"]*\" return 'COMMENT'; [\n]+ /* nothing */ "}" { this.popState(); return 'BLOCK_STOP'; } . return yytext[0]; @@ -95,6 +97,9 @@ attributes attribute : 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 @@ -105,6 +110,10 @@ attributeName : ATTRIBUTE_WORD { $$=$1; } ; +attributeKeyType + : ATTRIBUTE_KEY { $$=$1; } + ; + relSpec : cardinality relType cardinality { diff --git a/src/diagrams/er/parser/erDiagram.spec.js b/src/diagrams/er/parser/erDiagram.spec.js index 3afd5fe93..8089606ac 100644 --- a/src/diagrams/er/parser/erDiagram.spec.js +++ b/src/diagrams/er/parser/erDiagram.spec.js @@ -42,6 +42,36 @@ describe('when parsing ER diagram it...', function () { 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 () { const entity = 'BOOK'; const attribute1 = 'string title';