Merge pull request #1750 from spopida/feature/1531_ERD_attributes

Feature/1531 erd attributes
This commit is contained in:
Knut Sveidqvist 2020-11-04 19:24:22 +01:00 committed by GitHub
commit ee4571071d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 427 additions and 42 deletions

View File

@ -157,5 +157,33 @@ describe('Entity Relationship Diagram', () => {
cy.get('svg');
});
it('should render entities with and without attributes', () => {
renderGraph(
`
erDiagram
BOOK { string title }
AUTHOR }|..|{ BOOK : writes
BOOK { float price }
`,
{ logLevel : 1 }
);
cy.get('svg');
});
it('should render entities and attributes with big and small entity names', () => {
renderGraph(
`
erDiagram
PRIVATE_FINANCIAL_INSTITUTION {
string name
int turnover
}
PRIVATE_FINANCIAL_INSTITUTION ||..|{ EMPLOYEE : employs
EMPLOYEE { bool officer_of_firm }
`,
{ logLevel : 1 }
);
cy.get('svg');
});
});

View File

@ -25,9 +25,49 @@ Entity names are often capitalised, although there is no accepted standard on th
Relationships between entities are represented by lines with end markers representing cardinality. Mermaid uses the most popular crow's foot notation. The crow's foot intuitively conveys the possibility of many instances of the entity that it connects to.
## Status
ER diagrams can be used for various purposes, ranging from abstract logical models devoid of any implementation details, through to physical models of relational database tables. It can be useful to include attribute definitions on ER diagrams to aid comprehension of the purpose and meaning of entities. These do not necessarily need to be exhaustive; often a small subset of attributes is enough. Mermaid allows to be defined in terms of their *type* and *name*.
ER diagrams are a relatively new feature in Mermaid, so there are likely to be a few bugs and constraints, and enhancements will be made in due course. Currently you can only define entities and relationships, but not attributes. Inclusion of attributes is now actively being worked on.
```markdown
erDiagram
CUSTOMER ||--o{ ORDER : places
CUSTOMER {
string name
string custNumber
string sector
}
ORDER ||--|{ LINE-ITEM : contains
ORDER {
int orderNumber
string deliveryAddress
}
LINE-ITEM {
string productCode
int quantity
float pricePerUnit
}
```
```mermaid
erDiagram
CUSTOMER ||--o{ ORDER : places
CUSTOMER {
string name
string custNumber
string sector
}
ORDER ||--|{ LINE-ITEM : contains
ORDER {
int orderNumber
string deliveryAddress
}
LINE-ITEM {
string productCode
int quantity
float pricePerUnit
}
```
When including attributes on ER diagrams, you must decide whether to include foreign keys as attributes. This probably depends on how closely you are trying to represent relational table structures. If your diagram is a *logical* model which is not meant to imply a relational implementation, then it is better to leave these out because the associative relationships already convey the way that entities are associated. For example, a JSON data structure can implement a one-to-many relationship without the need for foreign key properties, using arrays. Similarly an object-oriented programming language may use pointers or references to collections. Even for models that are intended for relational implementation, you might decide that inclusion of foreign key attributes duplicates information already portrayed by the relationships, and does not add meaning to entities. Ultimately, it's your choice.
## Syntax
@ -82,6 +122,43 @@ Relationships may be classified as either *identifying* or *non-identifying* and
PERSON ||--o{ NAMED-DRIVER : is
```
### Attributes
Attributes can be defined for entities by specifying the entity name followed by a block containing multiple `type name` pairs, where a block is delimited by an opening `{` and a closing `}`. For example:
```markdown
CAR ||--o{ NAMED-DRIVER : allows
CAR {
string registrationNumber
string make
string model
}
PERSON ||--o{ NAMED-DRIVER : is
PERSON {
string firstName
string lastName
int age
}
```
The attributes are rendered inside the entity boxes:
```mermaid
CAR ||--o{ NAMED-DRIVER : allows
CAR {
string registrationNumber
string make
string model
}
PERSON ||--o{ NAMED-DRIVER : is
PERSON {
string firstName
string lastName
int age
}
```
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.
### Other Things
- If you want the relationship label to be more than one word, you must use double quotes around the phrase
@ -93,10 +170,10 @@ Relationships may be classified as either *identifying* or *non-identifying* and
For simple color customization:
| Name | Used as |
| :------- | :------------------------------------------------------ |
| `fill` | Background color of an entity |
| `stroke` | Border color of an entity, line color of a relationship |
| Name | Used as |
| :------- | :------------------------------------------------------------------- |
| `fill` | Background color of an entity or attribute |
| `stroke` | Border color of an entity or attribute, line color of a relationship |
### Classes used
@ -104,6 +181,8 @@ The following CSS class selectors are available for richer styling:
| Selector | Description |
| :------------------------- | :---------------------------------------------------- |
| `.er.attributeBoxEven` | The box containing attributes on even-numbered rows |
| `.er.attributeBoxOdd` | The box containing attributes on odd-numbered rows |
| `.er.entityBox` | The box representing an entity |
| `.er.entityLabel` | The label for an entity |
| `.er.relationshipLabel` | The label for a relationship |

View File

@ -27,13 +27,26 @@ export const parseDirective = function(statement, context, type) {
const addEntity = function(name) {
if (typeof entities[name] === 'undefined') {
entities[name] = name;
logger.debug('Added new entity :', name);
entities[name] = { attributes: [] };
logger.info('Added new entity :', name);
}
return entities[name];
};
const getEntities = () => entities;
const addAttributes = function(entityName, attribs) {
let entity = addEntity(entityName); // May do nothing (if entity has already been added)
// Process attribs in reverse order due to effect of recursive construction (last attribute is first)
let i;
for (i = attribs.length - 1; i >= 0; i--) {
entity.attributes.push(attribs[i]);
logger.debug('Added attribute ', attribs[i].attributeName);
}
};
/**
* Add a relationship
* @param entA The first entity in the relationship
@ -76,6 +89,7 @@ export default {
parseDirective,
getConfig: () => configApi.getConfig().er,
addEntity,
addAttributes,
getEntities,
addRelationship,
getRelationships,

View File

@ -22,6 +22,154 @@ export const setConf = function(cnf) {
}
};
/**
* Draw attributes for an entity
* @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)
* @return the bounding box of the entity, after attributes have been added
*/
const drawAttributes = (groupNode, entityTextNode, attributes) => {
const heightPadding = conf.entityPadding / 3; // Padding internal to attribute boxes
const widthPadding = conf.entityPadding / 3; // Ditto
const attrFontSize = conf.fontSize * 0.8;
const labelBBox = entityTextNode.node().getBBox();
const attributeNodes = []; // Intermediate storage for attribute nodes created so that we can do a second pass
let maxTypeWidth = 0;
let maxNameWidth = 0;
let cumulativeHeight = labelBBox.height + heightPadding * 2;
let attrNum = 1;
attributes.forEach(item => {
const attrPrefix = `${entityTextNode.node().id}-attr-${attrNum}`;
// Add a text node for the attribute type
const typeNode = groupNode
.append('text')
.attr('class', 'er entityLabel')
.attr('id', `${attrPrefix}-type`)
.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.attributeType);
// Add a text node for the attribute name
const nameNode = 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.attributeName);
// Keep a reference to the nodes so that we can iterate through them later
attributeNodes.push({ tn: typeNode, nn: nameNode });
const typeBBox = typeNode.node().getBBox();
const nameBBox = nameNode.node().getBBox();
maxTypeWidth = Math.max(maxTypeWidth, typeBBox.width);
maxNameWidth = Math.max(maxNameWidth, nameBBox.width);
cumulativeHeight += Math.max(typeBBox.height, nameBBox.height) + heightPadding * 2;
attrNum += 1;
});
// Calculate the new bounding box of the overall entity, now that attributes have been added
const bBox = {
width: Math.max(
conf.minEntityWidth,
Math.max(labelBBox.width + widthPadding * 2, maxTypeWidth + maxNameWidth + widthPadding * 4)
),
height:
attributes.length > 0 ? cumulativeHeight : Math.max(conf.minEntityHeight, cumulativeHeight)
};
// 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);
if (attributes.length > 0) {
// Position the entity label near the top of the entity bounding box
entityTextNode.attr(
'transform',
'translate(' + bBox.width / 2 + ',' + (heightPadding + labelBBox.height / 2) + ')'
);
// Add rectangular boxes for the attribute types/names
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 => {
// 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;
// Position the type of the attribute
nodePair.tn.attr('transform', 'translate(' + widthPadding + ',' + alignY + ')');
// Insert a rectangle for the type
const typeRect = groupNode
.insert('rect', '#' + nodePair.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);
// Position the name of the attribute
nodePair.nn.attr(
'transform',
'translate(' + (parseFloat(typeRect.attr('width')) + widthPadding) + ',' + alignY + ')'
);
// Insert a rectangle for the name
groupNode
.insert('rect', '#' + nodePair.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);
// 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;
// Flip the attribute style for row banding
attribStyle = attribStyle == 'attributeBoxOdd' ? 'attributeBoxEven' : 'attributeBoxOdd';
});
} else {
// Ensure the entity box is a decent size without any attributes
bBox.height = Math.max(conf.minEntityHeight, cumulativeHeight);
// Position the entity label in the middle of the box
entityTextNode.attr('transform', 'translate(' + bBox.width / 2 + ',' + bBox.height / 2 + ')');
}
return bBox;
};
/**
* Use D3 to construct the svg elements for the entities
* @param svgNode the svg node that contains the diagram
@ -56,13 +204,11 @@ const drawEntities = function(svgNode, entities, graph) {
)
.text(id);
// Calculate the width and height of the entity
const textBBox = textNode.node().getBBox();
const entityWidth = Math.max(conf.minEntityWidth, textBBox.width + conf.entityPadding * 2);
const entityHeight = Math.max(conf.minEntityHeight, textBBox.height + conf.entityPadding * 2);
// Make sure the text gets centred relative to the entity box
textNode.attr('transform', 'translate(' + entityWidth / 2 + ',' + entityHeight / 2 + ')');
const { width: entityWidth, height: entityHeight } = drawAttributes(
groupNode,
textNode,
entities[id].attributes
);
// Draw the rectangle - insert it before the text so that the text is not obscured
const rectNode = groupNode

View File

@ -1,7 +1,7 @@
%lex
%options case-insensitive
%x open_directive type_directive arg_directive
%x open_directive type_directive arg_directive block
%%
\%\%\{ { this.begin('open_directive'); return 'open_directive'; }
@ -11,25 +11,31 @@
<arg_directive>((?:(?!\}\%\%).|\n)*) return 'arg_directive';
\%%(?!\{)[^\n]* /* skip comments */
[^\}]\%\%[^\n]* /* skip comments */
[\n]+ return 'NEWLINE';
\s+ /* skip whitespace */
[\s]+ return 'SPACE';
\"[^"]*\" return 'WORD';
"erDiagram" return 'ER_DIAGRAM';
\|o return 'ZERO_OR_ONE';
\}o return 'ZERO_OR_MORE';
\}\| return 'ONE_OR_MORE';
\|\| return 'ONLY_ONE';
o\| return 'ZERO_OR_ONE';
o\{ return 'ZERO_OR_MORE';
\|\{ return 'ONE_OR_MORE';
\.\. return 'NON_IDENTIFYING';
\-\- return 'IDENTIFYING';
\.\- return 'NON_IDENTIFYING';
\-\. return 'NON_IDENTIFYING';
[A-Za-z][A-Za-z0-9\-_]* return 'ALPHANUM';
. return yytext[0];
<<EOF>> return 'EOF';
[\n]+ return 'NEWLINE';
\s+ /* skip whitespace */
[\s]+ return 'SPACE';
\"[^"]*\" return 'WORD';
"erDiagram" return 'ER_DIAGRAM';
"{" { this.begin("block"); return 'BLOCK_START'; }
<block>\s+ /* skip whitespace in block */
<block>[A-Za-z][A-Za-z0-9\-_]+ { return 'ATTRIBUTE_WORD'; }
<block>[\n]+ /* nothing */
<block>"}" { this.popState(); return 'BLOCK_STOP'; }
<block>. return yytext[0];
\|o return 'ZERO_OR_ONE';
\}o return 'ZERO_OR_MORE';
\}\| return 'ONE_OR_MORE';
\|\| return 'ONLY_ONE';
o\| return 'ZERO_OR_ONE';
o\{ return 'ZERO_OR_MORE';
\|\{ return 'ONE_OR_MORE';
\.\. return 'NON_IDENTIFYING';
\-\- return 'IDENTIFYING';
\.\- return 'NON_IDENTIFYING';
\-\. return 'NON_IDENTIFYING';
[A-Za-z][A-Za-z0-9\-_]* return 'ALPHANUM';
. return yytext[0];
<<EOF>> return 'EOF';
/lex
@ -67,6 +73,14 @@ statement
yy.addRelationship($1, $5, $3, $2);
/*console.log($1 + $2 + $3 + ':' + $5);*/
}
| entityName BLOCK_START attributes BLOCK_STOP
{
/* console.log('detected block'); */
yy.addEntity($1);
yy.addAttributes($1, $3);
/* console.log('handled block'); */
}
| entityName BLOCK_START BLOCK_STOP { yy.addEntity($1); }
| entityName { yy.addEntity($1); }
;
@ -74,6 +88,23 @@ entityName
: 'ALPHANUM' { $$ = $1; /*console.log('Entity: ' + $1);*/ }
;
attributes
: attribute { $$ = [$1]; }
| attribute attributes { $2.push($1); $$=$2; }
;
attribute
: attributeType attributeName { $$ = { attributeType: $1, attributeName: $2 }; }
;
attributeType
: ATTRIBUTE_WORD { $$=$1; }
;
attributeName
: ATTRIBUTE_WORD { $$=$1; }
;
relSpec
: cardinality relType cardinality
{

View File

@ -20,7 +20,7 @@ describe('when parsing ER diagram it...', function() {
erDiagram.parser.parse(`erDiagram\n${line1}\n${line2}`);
expect(Object.keys(erDb.getEntities()).length).toBe(2);
expect (erDb.getRelationships().length).toBe(0);
expect(erDb.getRelationships().length).toBe(0);
});
it ('should allow hyphens and underscores in entity names', function() {
@ -29,19 +29,96 @@ describe('when parsing ER diagram it...', function() {
erDiagram.parser.parse(`erDiagram\n${line1}\n${line2}`);
const entities = erDb.getEntities();
expect (entities["DUCK-BILLED-PLATYPUS"]).toBe('DUCK-BILLED-PLATYPUS');
expect (entities.CHARACTER_SET).toBe('CHARACTER_SET');
expect(entities.hasOwnProperty('DUCK-BILLED-PLATYPUS')).toBe(true);
expect(entities.hasOwnProperty('CHARACTER_SET')).toBe(true);
});
it('should allow an entity with a single attribute to be defined', function() {
const entity = 'BOOK';
const attribute = 'string title';
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';
const attribute2 = 'string author';
const attribute3 = 'float price';
erDiagram.parser.parse(`erDiagram\n${entity} {\n${attribute1}\n${attribute2}\n${attribute3}\n}`);
const entities = erDb.getEntities();
expect(entities[entity].attributes.length).toBe(3);
});
it('should allow attribute definitions to be split into multiple blocks', function() {
const entity = 'BOOK';
const attribute1 = 'string title';
const attribute2 = 'string author';
const attribute3 = 'float price';
erDiagram.parser.parse(`erDiagram\n${entity} {\n${attribute1}\n}\n${entity} {\n${attribute2}\n${attribute3}\n}`);
const entities = erDb.getEntities();
expect(entities[entity].attributes.length).toBe(3);
});
it('should allow an empty attribute block', function() {
const entity = 'BOOK';
erDiagram.parser.parse(`erDiagram\n${entity} {}`);
const entities = erDb.getEntities();
expect(entities.hasOwnProperty('BOOK')).toBe(true);
expect(entities[entity].attributes.length).toBe(0);
});
it('should allow an attribute block to start immediately after the entity name', function() {
const entity = 'BOOK';
erDiagram.parser.parse(`erDiagram\n${entity}{}`);
const entities = erDb.getEntities();
expect(entities.hasOwnProperty('BOOK')).toBe(true);
expect(entities[entity].attributes.length).toBe(0);
});
it('should allow an attribute block to be separated from the entity name by spaces', function() {
const entity = 'BOOK';
erDiagram.parser.parse(`erDiagram\n${entity} {}`);
const entities = erDb.getEntities();
expect(entities.hasOwnProperty('BOOK')).toBe(true);
expect(entities[entity].attributes.length).toBe(0);
});
it('should allow whitespace before and after attribute definitions', function() {
const entity = 'BOOK';
const attribute = 'string title';
erDiagram.parser.parse(`erDiagram\n${entity} {\n \n\n ${attribute}\n\n \n}`);
const entities = erDb.getEntities();
expect(Object.keys(entities).length).toBe(1);
expect(entities[entity].attributes.length).toBe(1);
});
it('should allow no whitespace before and after attribute definitions', function() {
const entity = 'BOOK';
const attribute = 'string title';
erDiagram.parser.parse(`erDiagram\n${entity}{${attribute}}`);
const entities = erDb.getEntities();
expect(Object.keys(entities).length).toBe(1);
expect(entities[entity].attributes.length).toBe(1);
});
it('should associate two entities correctly', function() {
erDiagram.parser.parse('erDiagram\nCAR ||--o{ DRIVER : "insured for"');
const entities = erDb.getEntities();
const relationships = erDb.getRelationships();
const carEntity = entities.CAR;
const driverEntity = entities.DRIVER;
expect(carEntity).toBe('CAR');
expect(driverEntity).toBe('DRIVER');
expect(entities.hasOwnProperty('CAR')).toBe(true);
expect(entities.hasOwnProperty('DRIVER')).toBe(true);
expect(relationships.length).toBe(1);
expect(relationships[0].relSpec.cardA).toBe(erDb.Cardinality.ZERO_OR_MORE);
expect(relationships[0].relSpec.cardB).toBe(erDb.Cardinality.ONLY_ONE);

View File

@ -5,6 +5,16 @@ const getStyles = options =>
stroke: ${options.nodeBorder};
}
.attributeBoxOdd {
fill: #ffffff;
stroke: ${options.nodeBorder};
}
.attributeBoxEven {
fill: #f2f2f2;
stroke: ${options.nodeBorder};
}
.relationshipLabelBox {
fill: ${options.tertiaryColor};
opacity: 0.7;