mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-01-28 07:03:17 +08:00
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:
commit
b6ba4b2fd8
@ -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');
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@ -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
|
||||||
|
@ -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';
|
||||||
|
@ -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
|
||||||
{
|
{
|
||||||
|
@ -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';
|
||||||
|
Loading…
x
Reference in New Issue
Block a user