diff --git a/cypress/integration/rendering/erDiagram.spec.js b/cypress/integration/rendering/erDiagram.spec.js new file mode 100644 index 000000000..1f625e2e9 --- /dev/null +++ b/cypress/integration/rendering/erDiagram.spec.js @@ -0,0 +1,92 @@ +/* eslint-env jest */ +import { imgSnapshotTest } from '../../helpers/util'; + +describe('Entity Relationship Diagram', () => { + it('should render a simple ER diagram', () => { + imgSnapshotTest( + ` + erDiagram + CUSTOMER !-?< ORDER : places + ORDER !-!< LINE-ITEM : contains + `, + {logLevel : 1} + ); + cy.get('svg'); + }); + + it('should render an ER diagram with a recursive relationship', () => { + imgSnapshotTest( + ` + erDiagram + CUSTOMER !-?< CUSTOMER : refers + CUSTOMER !-?< ORDER : places + ORDER !-!< LINE-ITEM : contains + `, + {logLevel : 1} + ); + cy.get('svg'); + }); + + it('should render an ER diagram with multiple relationships between the same two entities', () => { + imgSnapshotTest( + ` + erDiagram + CUSTOMER !-!< ADDRESS : "invoiced at" + CUSTOMER !-!< ADDRESS : "receives goods at" + `, + {logLevel : 1} + ); + cy.get('svg'); + }); + + it('should render a cyclical ER diagram', () => { + imgSnapshotTest( + ` + erDiagram + A !-!< B : likes + B !-!< C : likes + C !-!< A : likes + `, + {logLevel : 1} + ); + cy.get('svg'); + + }); + + it('should render a not-so-simple ER diagram', () => { + imgSnapshotTest( + ` + erDiagram + DELIVERY-ADDRESS !-?< ORDER : receives + CUSTOMER >!-!< DELIVERY-ADDRESS : has + CUSTOMER !-?< ORDER : places + CUSTOMER !-?< INVOICE : "liable for" + INVOICE !-!< ORDER : covers + ORDER !-!< ORDER-ITEM : includes + PRODUCT-CATEGORY !-!< PRODUCT : contains + PRODUCT !-?< ORDER-ITEM : "ordered in" + `, + {logLevel : 1} + ); + cy.get('svg'); + }); + + it('should render multiple ER diagrams', () => { + imgSnapshotTest( + [ + ` + erDiagram + CUSTOMER !-?< ORDER : places + ORDER !-!< LINE-ITEM : contains + `, + ` + erDiagram + CUSTOMER !-?< ORDER : places + ORDER !-!< LINE-ITEM : contains + ` + ], + {logLevel : 1} + ); + cy.get('svg'); + }); +}); diff --git a/docs/entityRelationshipDiagram.md b/docs/entityRelationshipDiagram.md new file mode 100644 index 000000000..6b5e46bb3 --- /dev/null +++ b/docs/entityRelationshipDiagram.md @@ -0,0 +1,31 @@ +# Entity Relationship Diagrams + +> An entity–relationship model (or ER model) describes interrelated things of interest in a specific domain of knowledge. A basic ER model is composed of entity types (which classify the things of interest) and specifies relationships that can exist between entities (instances of those entity types). Wikipedia. + +Note that practitioners of ER modelling almost always refer to entity types simply as entities. For example the CUSTOMER entity type would be referred to simply as the CUSTOMER entity. This is so common it would be inadvisable to do anything else, but technically an entity is an abstract instance of an entity type, and this is what an ER diagram shows - abstract instances, and the relationships between them. This is why entities are always named using singular nouns. + +Mermaid can render ER diagrams +``` +erDiagram + CUSTOMER !-?< ORDER : places + ORDER !-!< LINE-ITEM : contains +``` +```mermaid +erDiagram + CUSTOMER !-?< ORDER : places + ORDER !-!< LINE-ITEM : contains +``` + +Entity names are often capitalised, although there is no accepted standard on this, and it is not required in Mermaid. + +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 are a new feature in Mermaid and are **experimental**. There are likely to be a few bugs and constraints, and enhancements will be made in due course. + +## Syntax + +### Entities and Relationships + +To be completed diff --git a/src/diagrams/er/erDb.js b/src/diagrams/er/erDb.js new file mode 100644 index 000000000..556eb2f4f --- /dev/null +++ b/src/diagrams/er/erDb.js @@ -0,0 +1,83 @@ +/** + * + */ +import { logger } from '../../logger'; + +let entities = {}; +let relationships = []; +let title = ''; + +const Cardinality = { + ONLY_ONE_TO_ONE_OR_MORE: 'ONLY_ONE_TO_ONE_OR_MORE', + ONLY_ONE_TO_ZERO_OR_MORE: 'ONLY_ONE_TO_ZERO_OR_MORE', + ZERO_OR_ONE_TO_ZERO_OR_MORE: 'ZERO_OR_ONE_TO_ZERO_OR_MORE', + ZERO_OR_ONE_TO_ONE_OR_MORE: 'ZERO_OR_ONE_TO_ONE_OR_MORE', + ONE_OR_MORE_TO_ONLY_ONE: 'ONE_OR_MORE_TO_ONLY_ONE', + ZERO_OR_MORE_TO_ONLY_ONE: 'ZERO_OR_MORE_TO_ONLY_ONE', + ZERO_OR_MORE_TO_ZERO_OR_ONE: 'ZERO_OR_MORE_TO_ZERO_OR_ONE', + ONE_OR_MORE_TO_ZERO_OR_ONE: 'ONE_OR_MORE_TO_ZERO_OR_ONE', + ZERO_OR_ONE_TO_ONLY_ONE: 'ZERO_OR_ONE_TO_ONLY_ONE', + ONLY_ONE_TO_ONLY_ONE: 'ONLY_ONE_TO_ONLY_ONE', + ONLY_ONE_TO_ZERO_OR_ONE: 'ONLY_ONE_TO_ZERO_OR_ONE', + ZERO_OR_ONE_TO_ZERO_OR_ONE: 'ZERO_OR_ONE_TO_ZERO_OR_ONE', + ZERO_OR_MORE_TO_ZERO_OR_MORE: 'ZERO_OR_MORE_TO_ZERO_OR_MORE', + ZERO_OR_MORE_TO_ONE_OR_MORE: 'ZERO_OR_MORE_TO_ONE_OR_MORE', + ONE_OR_MORE_TO_ZERO_OR_MORE: 'ONE_OR_MORE_TO_ZERO_OR_MORE', + ONE_OR_MORE_TO_ONE_OR_MORE: 'ONE_OR_MORE_TO_ONE_OR_MORE' +}; + +const addEntity = function(name) { + if (typeof entities[name] === 'undefined') { + entities[name] = name; + logger.debug('Added new entity :', name); + } +}; + +const getEntities = () => entities; + +/** + * Add a relationship + * @param entA The first entity in the relationship + * @param rolA The role played by the first entity in relation to the second + * @param entB The second entity in the relationship + * @param card The cardinality of the relationship between the two entities + */ +const addRelationship = function(entA, rolA, entB, card) { + let rel = { + entityA: entA, + roleA: rolA, + entityB: entB, + cardinality: card + }; + + relationships.push(rel); + logger.debug('Added new relationship :', rel); +}; + +const getRelationships = () => relationships; + +// Keep this - TODO: revisit...allow the diagram to have a title +const setTitle = function(txt) { + title = txt; +}; + +const getTitle = function() { + return title; +}; + +const clear = function() { + entities = {}; + relationships = []; + title = ''; +}; + +export default { + Cardinality, + addEntity, + getEntities, + addRelationship, + getRelationships, + clear, + setTitle, + getTitle +}; diff --git a/src/diagrams/er/erMarkers.js b/src/diagrams/er/erMarkers.js new file mode 100644 index 000000000..3107671fd --- /dev/null +++ b/src/diagrams/er/erMarkers.js @@ -0,0 +1,168 @@ +const ERMarkers = { + ONLY_ONE_START: 'ONLY_ONE_START', + ONLY_ONE_END: 'ONLY_ONE_END', + ZERO_OR_ONE_START: 'ZERO_OR_ONE_START', + ZERO_OR_ONE_END: 'ZERO_OR_ONE_END', + ONE_OR_MORE_START: 'ONE_OR_MORE_START', + ONE_OR_MORE_END: 'ONE_OR_MORE_END', + ZERO_OR_MORE_START: 'ZERO_OR_MORE_START', + ZERO_OR_MORE_END: 'ZERO_OR_MORE_END' +}; + +/** + * Put the markers into the svg DOM for later use with edge paths + */ +const insertMarkers = function(elem, conf) { + let marker; + + elem + .append('defs') + .append('marker') + .attr('id', ERMarkers.ONLY_ONE_START) + .attr('refX', 0) + .attr('refY', 9) + .attr('markerWidth', 18) + .attr('markerHeight', 18) + .attr('orient', 'auto') + .append('path') + .attr('stroke', conf.stroke) + .attr('fill', 'none') + .attr('d', 'M9,0 L9,18 M15,0 L15,18'); + + elem + .append('defs') + .append('marker') + .attr('id', ERMarkers.ONLY_ONE_END) + .attr('refX', 18) + .attr('refY', 9) + .attr('markerWidth', 18) + .attr('markerHeight', 18) + .attr('orient', 'auto') + .append('path') + .attr('stroke', conf.stroke) + .attr('fill', 'none') + .attr('d', 'M3,0 L3,18 M9,0 L9,18'); + + marker = elem + .append('defs') + .append('marker') + .attr('id', ERMarkers.ZERO_OR_ONE_START) + .attr('refX', 0) + .attr('refY', 9) + .attr('markerWidth', 30) + .attr('markerHeight', 18) + .attr('orient', 'auto'); + marker + .append('circle') + .attr('stroke', conf.stroke) + .attr('fill', 'white') + .attr('cx', 21) + .attr('cy', 9) + .attr('r', 6); + marker + .append('path') + .attr('stroke', conf.stroke) + .attr('fill', 'none') + .attr('d', 'M9,0 L9,18'); + + marker = elem + .append('defs') + .append('marker') + .attr('id', ERMarkers.ZERO_OR_ONE_END) + .attr('refX', 30) + .attr('refY', 9) + .attr('markerWidth', 30) + .attr('markerHeight', 18) + .attr('orient', 'auto'); + marker + .append('circle') + .attr('stroke', conf.stroke) + .attr('fill', 'white') + .attr('cx', 9) + .attr('cy', 9) + .attr('r', 6); + marker + .append('path') + .attr('stroke', conf.stroke) + .attr('fill', 'none') + .attr('d', 'M21,0 L21,18'); + + elem + .append('defs') + .append('marker') + .attr('id', ERMarkers.ONE_OR_MORE_START) + .attr('refX', 18) + .attr('refY', 18) + .attr('markerWidth', 45) + .attr('markerHeight', 36) + .attr('orient', 'auto') + .append('path') + .attr('stroke', conf.stroke) + .attr('fill', 'none') + .attr('d', 'M0,18 Q 18,0 36,18 Q 18,36 0,18 M42,9 L42,27'); + + elem + .append('defs') + .append('marker') + .attr('id', ERMarkers.ONE_OR_MORE_END) + .attr('refX', 27) + .attr('refY', 18) + .attr('markerWidth', 45) + .attr('markerHeight', 36) + .attr('orient', 'auto') + .append('path') + .attr('stroke', conf.stroke) + .attr('fill', 'none') + .attr('d', 'M3,9 L3,27 M9,18 Q27,0 45,18 Q27,36 9,18'); + + marker = elem + .append('defs') + .append('marker') + .attr('id', ERMarkers.ZERO_OR_MORE_START) + .attr('refX', 18) + .attr('refY', 18) + .attr('markerWidth', 57) + .attr('markerHeight', 36) + .attr('orient', 'auto'); + marker + .append('circle') + .attr('stroke', conf.stroke) + .attr('fill', 'white') + .attr('cx', 48) + .attr('cy', 18) + .attr('r', 6); + marker + .append('path') + .attr('stroke', conf.stroke) + .attr('fill', 'none') + .attr('d', 'M0,18 Q18,0 36,18 Q18,36 0,18'); + + marker = elem + .append('defs') + .append('marker') + .attr('id', ERMarkers.ZERO_OR_MORE_END) + .attr('refX', 39) + .attr('refY', 18) + .attr('markerWidth', 57) + .attr('markerHeight', 36) + .attr('orient', 'auto'); + marker + .append('circle') + .attr('stroke', conf.stroke) + .attr('fill', 'white') + .attr('cx', 9) + .attr('cy', 18) + .attr('r', 6); + marker + .append('path') + .attr('stroke', conf.stroke) + .attr('fill', 'none') + .attr('d', 'M21,18 Q39,0 57,18 Q39,36 21,18'); + + return; +}; + +export default { + ERMarkers, + insertMarkers +}; diff --git a/src/diagrams/er/erRenderer.js b/src/diagrams/er/erRenderer.js new file mode 100644 index 000000000..3f37fd296 --- /dev/null +++ b/src/diagrams/er/erRenderer.js @@ -0,0 +1,414 @@ +import graphlib from 'graphlib'; +import * as d3 from 'd3'; +import erDb from './erDb'; +import erParser from './parser/erDiagram'; +import dagre from 'dagre'; +import { getConfig } from '../../config'; +import { logger } from '../../logger'; +import erMarkers from './erMarkers'; + +const conf = {}; + +/** + * 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() imported from the config module + */ +export const setConf = function(cnf) { + const keys = Object.keys(cnf); + for (let i = 0; i < keys.length; i++) { + conf[keys[i]] = cnf[keys[i]]; + } +}; + +/** + * Use D3 to construct the svg elements for the entities + * @param svgNode the svg node that contains the diagram + * @param entities The entities to be drawn + * @param g The graph that contains the vertex and edge definitions post-layout + * @return The first entity that was inserted + */ +const drawEntities = function(svgNode, entities, graph) { + const keys = Object.keys(entities); + let firstOne; + + keys.forEach(function(id) { + // Create a group for each entity + const groupNode = svgNode.append('g').attr('id', id); + + firstOne = firstOne === undefined ? id : 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 textNode = groupNode + .append('text') + .attr('id', textId) + .attr('x', 0) + .attr('y', (conf.fontSize + 2 * conf.entityPadding) / 2) + .attr('dominant-baseline', 'middle') + .attr('text-anchor', 'middle') + .attr('style', 'font-family: ' + getConfig().fontFamily + '; font-size: ' + conf.fontSize) + .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 + ')'); + + // Draw the rectangle - insert it before the text so that the text is not obscured + const rectNode = groupNode + .insert('rect', '#' + textId) + .attr('fill', conf.fill) + .attr('fill-opacity', conf.fillOpacity) + .attr('stroke', conf.stroke) + .attr('x', 0) + .attr('y', 0) + .attr('width', entityWidth) + .attr('height', entityHeight); + + const rectBBox = rectNode.node().getBBox(); + + // Add the entity to the graph + graph.setNode(id, { + width: rectBBox.width, + height: rectBBox.height, + shape: 'rect', + id: id + }); + }); + return firstOne; +}; // drawEntities + +const adjustEntities = function(svgNode, graph) { + graph.nodes().forEach(function(v) { + if (typeof v !== 'undefined' && typeof graph.node(v) !== 'undefined') { + svgNode + .select('#' + v) + .attr( + 'transform', + 'translate(' + + (graph.node(v).x - graph.node(v).width / 2) + + ',' + + (graph.node(v).y - graph.node(v).height / 2) + + ' )' + ); + } + }); + return; +}; + +const getEdgeName = function(rel) { + return (rel.entityA + rel.roleA + rel.entityB).replace(/\s/g, ''); +}; + +/** + * Add each relationship to the graph + * @param relationships the relationships to be added + * @param g the graph + * @return {Array} The array of relationships + */ +const addRelationships = function(relationships, g) { + relationships.forEach(function(r) { + g.setEdge(r.entityA, r.entityB, { relationship: r }, getEdgeName(r)); + }); + return relationships; +}; // addRelationships + +let relCnt = 0; +/** + * Draw a relationship using edge information from the graph + * @param svg the svg node + * @param rel the relationship to draw in the svg + * @param g the graph containing the edge information + */ +const drawRelationshipFromLayout = function(svg, rel, g, insert) { + relCnt++; + + // Find the edge relating to this relationship + const edge = g.edge(rel.entityA, rel.entityB, getEdgeName(rel)); + + // Get a function that will generate the line path + const lineFunction = d3 + .line() + .x(function(d) { + return d.x; + }) + .y(function(d) { + return d.y; + }) + .curve(d3.curveBasis); + + // Insert the line at the right place + const svgPath = svg + .insert('path', '#' + insert) + .attr('d', lineFunction(edge.points)) + .attr('stroke', conf.stroke) + .attr('fill', 'none'); + + // TODO: Understand this better + let url = ''; + if (conf.arrowMarkerAbsolute) { + url = + window.location.protocol + + '//' + + window.location.host + + window.location.pathname + + window.location.search; + url = url.replace(/\(/g, '\\('); + url = url.replace(/\)/g, '\\)'); + } + + // Decide which start and end markers it needs. It may be possible to be more concise here + // by reversing a start marker to make an end marker...but this will do for now + switch (rel.cardinality) { + case erDb.Cardinality.ONLY_ONE_TO_ONE_OR_MORE: + svgPath.attr('marker-start', 'url(' + url + '#' + erMarkers.ERMarkers.ONLY_ONE_START + ')'); + svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ONE_OR_MORE_END + ')'); + break; + case erDb.Cardinality.ONLY_ONE_TO_ZERO_OR_MORE: + svgPath.attr('marker-start', 'url(' + url + '#' + erMarkers.ERMarkers.ONLY_ONE_START + ')'); + svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_MORE_END + ')'); + break; + case erDb.Cardinality.ZERO_OR_ONE_TO_ZERO_OR_MORE: + svgPath.attr( + 'marker-start', + 'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_ONE_START + ')' + ); + svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_MORE_END + ')'); + break; + case erDb.Cardinality.ZERO_OR_ONE_TO_ONE_OR_MORE: + svgPath.attr( + 'marker-start', + 'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_ONE_START + ')' + ); + svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ONE_OR_MORE_END + ')'); + break; + case erDb.Cardinality.ONE_OR_MORE_TO_ONLY_ONE: + svgPath.attr( + 'marker-start', + 'url(' + url + '#' + erMarkers.ERMarkers.ONE_OR_MORE_START + ')' + ); + svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ONLY_ONE_END + ')'); + break; + case erDb.Cardinality.ZERO_OR_MORE_TO_ONLY_ONE: + svgPath.attr( + 'marker-start', + 'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_MORE_START + ')' + ); + svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ONLY_ONE_END + ')'); + break; + case erDb.Cardinality.ZERO_OR_MORE_TO_ZERO_OR_ONE: + svgPath.attr( + 'marker-start', + 'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_MORE_START + ')' + ); + svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_ONE_END + ')'); + break; + case erDb.Cardinality.ONE_OR_MORE_TO_ZERO_OR_ONE: + svgPath.attr( + 'marker-start', + 'url(' + url + '#' + erMarkers.ERMarkers.ONE_OR_MORE_START + ')' + ); + svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_ONE_END + ')'); + break; + case erDb.Cardinality.ZERO_OR_ONE_TO_ONLY_ONE: + svgPath.attr( + 'marker-start', + 'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_ONE_START + ')' + ); + svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ONLY_ONE_END + ')'); + break; + case erDb.Cardinality.ONLY_ONE_TO_ONLY_ONE: + svgPath.attr('marker-start', 'url(' + url + '#' + erMarkers.ERMarkers.ONLY_ONE_START + ')'); + svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ONLY_ONE_END + ')'); + break; + case erDb.Cardinality.ONLY_ONE_TO_ZERO_OR_ONE: + svgPath.attr('marker-start', 'url(' + url + '#' + erMarkers.ERMarkers.ONLY_ONE_START + ')'); + svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_ONE_END + ')'); + break; + case erDb.Cardinality.ZERO_OR_ONE_TO_ZERO_OR_ONE: + svgPath.attr( + 'marker-start', + 'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_ONE_START + ')' + ); + svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_ONE_END + ')'); + break; + case erDb.Cardinality.ZERO_OR_MORE_TO_ZERO_OR_MORE: + svgPath.attr( + 'marker-start', + 'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_MORE_START + ')' + ); + svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_MORE_END + ')'); + break; + case erDb.Cardinality.ZERO_OR_MORE_TO_ONE_OR_MORE: + svgPath.attr( + 'marker-start', + 'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_MORE_START + ')' + ); + svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ONE_OR_MORE_END + ')'); + break; + case erDb.Cardinality.ONE_OR_MORE_TO_ZERO_OR_MORE: + svgPath.attr( + 'marker-start', + 'url(' + url + '#' + erMarkers.ERMarkers.ONE_OR_MORE_START + ')' + ); + svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_MORE_END + ')'); + break; + case erDb.Cardinality.ONE_OR_MORE_TO_ONE_OR_MORE: + svgPath.attr( + 'marker-start', + 'url(' + url + '#' + erMarkers.ERMarkers.ONE_OR_MORE_START + ')' + ); + svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ONE_OR_MORE_END + ')'); + break; + } + + // Now label the relationship + + // Find the half-way point + const len = svgPath.node().getTotalLength(); + const labelPoint = svgPath.node().getPointAtLength(len * 0.5); + + // Append a text node containing the label + const labelId = 'rel' + relCnt; + + const labelNode = svg + .append('text') + .attr('id', labelId) + .attr('x', labelPoint.x) + .attr('y', labelPoint.y) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .attr('style', 'font-family: ' + getConfig().fontFamily + '; font-size: ' + conf.fontSize) + .text(rel.roleA); + + // Figure out how big the opaque 'container' rectangle needs to be + const labelBBox = labelNode.node().getBBox(); + + // Insert the opaque rectangle in front of the text label + svg + .insert('rect', '#' + labelId) + .attr('x', labelPoint.x - labelBBox.width / 2) + .attr('y', labelPoint.y - labelBBox.height / 2) + .attr('width', labelBBox.width) + .attr('height', labelBBox.height) + .attr('fill', 'white') + .attr('fill-opacity', '85%'); + + return; +}; + +/** + * Draw en E-R diagram in the tag with id: id based on the text definition of the diagram + * @param text the text of the diagram + * @param id the unique id of the DOM node that contains the diagram + */ +export const draw = function(text, id) { + logger.info('Drawing ER diagram'); + erDb.clear(); + const parser = erParser.parser; + parser.yy = erDb; + + // Parse the text to populate erDb + try { + parser.parse(text); + } catch (err) { + logger.debug('Parsing failed'); + } + + // Get a reference to the svg node that contains the text + const svg = d3.select(`[id='${id}']`); + + // Add cardinality marker definitions to the svg + erMarkers.insertMarkers(svg, conf); + + // Now we have to construct the diagram in a specific way: + // --- + // 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 aswell + // 4. Let dagre do its magic to layout 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 + // 5. Now assign a transform to each entity in the svg node so that it gets drawn in the correct place, as determined by + // its centre point, which is obtained from the graph, and it's width and height + // 6. And finally, create all the edges in the svg node using information from the graph + // --- + + // Create the graph + let g; + + // TODO: Explore directed vs undirected graphs, and how the layout is affected + // An E-R diagram could be said to be undirected, but there is merit in setting + // the direction from parent to child in a one-to-many as this influences graphlib to + // put the parent above the child (does it?), which is intuitive. Most relationships + // in ER diagrams are one-to-many. + g = new graphlib.Graph({ + multigraph: true, + directed: true, + compound: false + }) + .setGraph({ + rankdir: conf.layoutDirection, + marginx: 20, + marginy: 20, + nodesep: 100, + edgesep: 100, + ranksep: 100 + }) + .setDefaultEdgeLabel(function() { + return {}; + }); + + // Draw the entities (at 0,0), returning the first svg node that got + // inserted - this represents the insertion point for relationship paths + const firstEntity = drawEntities(svg, erDb.getEntities(), g); + + // TODO: externalise the addition of entities to the graph - it's a bit 'buried' in the above + + // Add all the relationships to the graph + const relationships = addRelationships(erDb.getRelationships(), g); + + dagre.layout(g); // Node and edge positions will be updated + + // Adjust the positions of the entities so that they adhere to the layout + adjustEntities(svg, g); + + // Draw the relationships + relationships.forEach(function(rel) { + drawRelationshipFromLayout(svg, rel, g, firstEntity); + }); + + const padding = 8; // TODO: move this to config + + const svgBounds = svg.node().getBBox(); + const width = svgBounds.width + padding * 4; + const height = svgBounds.height + padding * 4; + logger.debug( + `new ViewBox 0 0 ${width} ${height}`, + `translate(${padding - g._label.marginx}, ${padding - g._label.marginy})` + ); + + if (conf.useMaxWidth) { + svg.attr('width', '100%'); + svg.attr('style', `max-width: ${width}px;`); + } else { + svg.attr('height', height); + svg.attr('width', width); + } + + svg.attr('viewBox', `0 0 ${width} ${height}`); + svg + .select('g') + .attr('transform', `translate(${padding - g._label.marginx}, ${padding - svgBounds.y})`); +}; // draw + +export default { + setConf, + draw +}; diff --git a/src/diagrams/er/parser/erDiagram.jison b/src/diagrams/er/parser/erDiagram.jison new file mode 100644 index 000000000..52ac48d3e --- /dev/null +++ b/src/diagrams/er/parser/erDiagram.jison @@ -0,0 +1,84 @@ +%lex + +%x string +%options case-insensitive + +%% +\s+ /* skip whitespace */ +[\s]+ return 'SPACE'; +["] { this.begin("string");} +["] { this.popState(); } +[^"]* { return 'STR'; } +"erDiagram" return 'ER_DIAGRAM'; +[A-Za-z][A-Za-z0-9\-]* return 'ALPHANUM'; +\>\?\-\?\< return 'ZERO_OR_MORE_TO_ZERO_OR_MORE'; +\>\?\-\!\< return 'ZERO_OR_MORE_TO_ONE_OR_MORE'; +\>\!\-\!\< return 'ONE_OR_MORE_TO_ONE_OR_MORE'; +\>\!\-\?\< return 'ONE_OR_MORE_TO_ZERO_OR_MORE'; +\!\-\!\< return 'ONLY_ONE_TO_ONE_OR_MORE'; +\!\-\?\< return 'ONLY_ONE_TO_ZERO_OR_MORE'; +\?\-\?\< return 'ZERO_OR_ONE_TO_ZERO_OR_MORE'; +\?\-\!\< return 'ZERO_OR_ONE_TO_ONE_OR_MORE'; +\>\!\-\! return 'ONE_OR_MORE_TO_ONLY_ONE'; +\>\?\-\! return 'ZERO_OR_MORE_TO_ONLY_ONE'; +\>\?\-\? return 'ZERO_OR_MORE_TO_ZERO_OR_ONE'; +\>\!\-\? return 'ONE_OR_MORE_TO_ZERO_OR_ONE'; +\?\-\! return 'ZERO_OR_ONE_TO_ONLY_ONE'; +\!\-\! return 'ONLY_ONE_TO_ONLY_ONE'; +\!\-\? return 'ONLY_ONE_TO_ZERO_OR_ONE'; +\?\-\? return 'ZERO_OR_ONE_TO_ZERO_OR_ONE'; +. return yytext[0]; +<> return 'EOF'; + + +/lex + +%start start +%% /* language grammar */ + +start + : 'ER_DIAGRAM' document 'EOF' { /*console.log('finished parsing');*/ } + ; + +document + : /* empty */ + | document statement + ; + +statement + : entityName relationship entityName ':' role + { + yy.addEntity($1); + yy.addEntity($3); + yy.addRelationship($1, $5, $3, $2); + /*console.log($1 + $2 + $3 + ':' + $5);*/ + }; + +entityName + : 'ALPHANUM' { $$ = $1; } + ; + +relationship + : 'ONLY_ONE_TO_ONE_OR_MORE' { $$ = yy.Cardinality.ONLY_ONE_TO_ONE_OR_MORE; } + | 'ONLY_ONE_TO_ZERO_OR_MORE' { $$ = yy.Cardinality.ONLY_ONE_TO_ZERO_OR_MORE; } + | 'ZERO_OR_ONE_TO_ZERO_OR_MORE' { $$ = yy.Cardinality.ZERO_OR_ONE_TO_ZERO_OR_MORE; } + | 'ZERO_OR_ONE_TO_ONE_OR_MORE' { $$ = yy.Cardinality.ZERO_OR_ONE_TO_ONE_OR_MORE; } + | 'ONE_OR_MORE_TO_ONLY_ONE' { $$ = yy.Cardinality.ONE_OR_MORE_TO_ONLY_ONE; } + | 'ZERO_OR_MORE_TO_ONLY_ONE' { $$ = yy.Cardinality.ZERO_OR_MORE_TO_ONLY_ONE; } + | 'ZERO_OR_MORE_TO_ZERO_OR_ONE' { $$ = yy.Cardinality.ZERO_OR_MORE_TO_ZERO_OR_ONE; } + | 'ONE_OR_MORE_TO_ZERO_OR_ONE' { $$ = yy.Cardinality.ONE_OR_MORE_TO_ZERO_OR_ONE; } + | 'ZERO_OR_ONE_TO_ONLY_ONE' { $$ = yy.Cardinality.ZERO_OR_ONE_TO_ONLY_ONE; } + | 'ONLY_ONE_TO_ONLY_ONE' { $$ = yy.Cardinality.ONLY_ONE_TO_ONLY_ONE; } + | 'ONLY_ONE_TO_ZERO_OR_ONE' { $$ = yy.Cardinality.ONLY_ONE_TO_ZERO_OR_ONE; } + | 'ZERO_OR_ONE_TO_ZERO_OR_ONE' { $$ = yy.Cardinality.ZERO_OR_ONE_TO_ZERO_OR_ONE; } + | 'ZERO_OR_MORE_TO_ZERO_OR_MORE' { $$ = yy.Cardinality.ZERO_OR_MORE_TO_ZERO_OR_MORE; } + | 'ZERO_OR_MORE_TO_ONE_OR_MORE' { $$ = yy.Cardinality.ZERO_OR_MORE_TO_ONE_OR_MORE; } + | 'ONE_OR_MORE_TO_ONE_OR_MORE' { $$ = yy.Cardinality.ONE_OR_MORE_TO_ONE_OR_MORE; } + | 'ONE_OR_MORE_TO_ZERO_OR_MORE' { $$ = yy.Cardinality.ONE_OR_MORE_TO_ZERO_OR_MORE; } + ; + +role + : 'STR' { $$ = $1; } + | 'ALPHANUM' { $$ = $1; } + ; +%% diff --git a/src/diagrams/er/parser/erDiagram.spec.js b/src/diagrams/er/parser/erDiagram.spec.js new file mode 100644 index 000000000..accdc4a40 --- /dev/null +++ b/src/diagrams/er/parser/erDiagram.spec.js @@ -0,0 +1,224 @@ +import erDb from '../erDb'; +import erDiagram from './erDiagram'; +import { setConfig } from '../../../config'; +import logger from '../../../logger'; + +setConfig({ + securityLevel: 'strict' +}); + +describe('when parsing ER diagram it...', function() { + + beforeEach(function() { + erDiagram.parser.yy = erDb; + erDiagram.parser.yy.clear(); + }); + + it('should associate two entities correctly', function() { + erDiagram.parser.parse('erDiagram\nCAR !-?< 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(relationships.length).toBe(1); + expect(relationships[0].cardinality).toBe(erDb.Cardinality.ONLY_ONE_TO_ZERO_OR_MORE); + }); + + it('should not create duplicate entities', function() { + const line1 = 'CAR !-?< DRIVER : "insured for"'; + const line2 = 'DRIVER !-! LICENSE : has'; + erDiagram.parser.parse(`erDiagram\n${line1}\n${line2}`); + const entities = erDb.getEntities(); + + expect(Object.keys(entities).length).toBe(3); + }); + + it('should create the role specified', function() { + const teacherRole = 'is teacher of'; + const line1 = `TEACHER >?-?< STUDENT : "${teacherRole}"`; + erDiagram.parser.parse(`erDiagram\n${line1}`); + const rels = erDb.getRelationships(); + + expect(rels[0].roleA).toBe(`${teacherRole}`); + }); + + it('should allow recursive relationships', function() { + erDiagram.parser.parse('erDiagram\nNODE !-?< NODE : "leads to"'); + expect(Object.keys(erDb.getEntities()).length).toBe(1); + }); + + it('should allow more than one relationship between the same two entities', function() { + const line1 = 'CAR !-?< PERSON : "insured for"'; + const line2 = 'CAR >?-! PERSON : "owned by"'; + erDiagram.parser.parse(`erDiagram\n${line1}\n${line2}`); + const entities = erDb.getEntities(); + const rels = erDb.getRelationships(); + + expect(Object.keys(entities).length).toBe(2); + expect(rels.length).toBe(2); + }); + + it('should limit the number of relationships between the same two entities', function() { + /* TODO */ + }); + + it ('should not allow multiple relationships between the same two entities unless the roles are different', function() { + /* TODO */ + }); + + it('should handle only-one-to-one-or-more relationships', function() { + erDiagram.parser.parse('erDiagram\nA !-!< B : has'); + const rels = erDb.getRelationships(); + + expect(Object.keys(erDb.getEntities()).length).toBe(2); + expect(rels.length).toBe(1); + expect(rels[0].cardinality).toBe(erDb.Cardinality.ONLY_ONE_TO_ONE_OR_MORE); + }); + + it('should handle only-one-to-zero-or-more relationships', function() { + erDiagram.parser.parse('erDiagram\nA !-?< B : has'); + const rels = erDb.getRelationships(); + + expect(Object.keys(erDb.getEntities()).length).toBe(2); + expect(rels.length).toBe(1); + expect(rels[0].cardinality).toBe(erDb.Cardinality.ONLY_ONE_TO_ZERO_OR_MORE); + + }); + + it('should handle zero-or-one-to-zero-or-more relationships', function() { + erDiagram.parser.parse('erDiagram\nA ?-?< B : has'); + const rels = erDb.getRelationships(); + + expect(Object.keys(erDb.getEntities()).length).toBe(2); + expect(rels.length).toBe(1); + expect(rels[0].cardinality).toBe(erDb.Cardinality.ZERO_OR_ONE_TO_ZERO_OR_MORE); + }); + + it('should handle zero-or-one-to-one-or-more relationships', function() { + erDiagram.parser.parse('erDiagram\nA ?-!< B : has'); + const rels = erDb.getRelationships(); + + expect(Object.keys(erDb.getEntities()).length).toBe(2); + expect(rels.length).toBe(1); + expect(rels[0].cardinality).toBe(erDb.Cardinality.ZERO_OR_ONE_TO_ONE_OR_MORE); + }); + + it('should handle one-or-more-to-only-one relationships', function() { + erDiagram.parser.parse('erDiagram\nA >!-! B : has'); + const rels = erDb.getRelationships(); + + expect(Object.keys(erDb.getEntities()).length).toBe(2); + expect(rels.length).toBe(1); + expect(rels[0].cardinality).toBe(erDb.Cardinality.ONE_OR_MORE_TO_ONLY_ONE); + }); + + it('should handle zero-or-more-to-only-one relationships', function() { + erDiagram.parser.parse('erDiagram\nA >?-! B : has'); + const rels = erDb.getRelationships(); + + expect(Object.keys(erDb.getEntities()).length).toBe(2); + expect(rels.length).toBe(1); + expect(rels[0].cardinality).toBe(erDb.Cardinality.ZERO_OR_MORE_TO_ONLY_ONE); + }); + + it('should handle zero-or-more-to-zero-or-one relationships', function() { + erDiagram.parser.parse('erDiagram\nA >?-? B : has'); + const rels = erDb.getRelationships(); + + expect(Object.keys(erDb.getEntities()).length).toBe(2); + expect(rels.length).toBe(1); + expect(rels[0].cardinality).toBe(erDb.Cardinality.ZERO_OR_MORE_TO_ZERO_OR_ONE); + }); + + it('should handle one-or-more-to-zero-or-one relationships', function() { + erDiagram.parser.parse('erDiagram\nA >!-? B : has'); + const rels = erDb.getRelationships(); + + expect(Object.keys(erDb.getEntities()).length).toBe(2); + expect(rels.length).toBe(1); + expect(rels[0].cardinality).toBe(erDb.Cardinality.ONE_OR_MORE_TO_ZERO_OR_ONE); + }); + + it('should handle zero-or-one-to-only-one relationships', function() { + erDiagram.parser.parse('erDiagram\nA ?-! B : has'); + const rels = erDb.getRelationships(); + + expect(Object.keys(erDb.getEntities()).length).toBe(2); + expect(rels.length).toBe(1); + expect(rels[0].cardinality).toBe(erDb.Cardinality.ZERO_OR_ONE_TO_ONLY_ONE); + }); + + it('should handle only-one-to-only-one relationships', function() { + erDiagram.parser.parse('erDiagram\nA !-! B : has'); + const rels = erDb.getRelationships(); + + expect(Object.keys(erDb.getEntities()).length).toBe(2); + expect(rels.length).toBe(1); + expect(rels[0].cardinality).toBe(erDb.Cardinality.ONLY_ONE_TO_ONLY_ONE); + }); + + it('should handle only-one-to-zero-or-one relationships', function() { + erDiagram.parser.parse('erDiagram\nA !-? B : has'); + const rels = erDb.getRelationships(); + + expect(Object.keys(erDb.getEntities()).length).toBe(2); + expect(rels.length).toBe(1); + expect(rels[0].cardinality).toBe(erDb.Cardinality.ONLY_ONE_TO_ZERO_OR_ONE); + }); + + it('should handle zero-or-one-to-zero-or-one relationships', function() { + erDiagram.parser.parse('erDiagram\nA ?-? B : has'); + const rels = erDb.getRelationships(); + + expect(Object.keys(erDb.getEntities()).length).toBe(2); + expect(rels.length).toBe(1); + expect(rels[0].cardinality).toBe(erDb.Cardinality.ZERO_OR_ONE_TO_ZERO_OR_ONE); + }); + + it('should handle zero-or-more-to-zero-or-more relationships', function() { + erDiagram.parser.parse('erDiagram\nA >?-?< B : has'); + const rels = erDb.getRelationships(); + + expect(Object.keys(erDb.getEntities()).length).toBe(2); + expect(rels.length).toBe(1); + expect(rels[0].cardinality).toBe(erDb.Cardinality.ZERO_OR_MORE_TO_ZERO_OR_MORE); + }); + + it('should handle one-or-more-to-one-or-more relationships', function() { + erDiagram.parser.parse('erDiagram\nA >!-!< B : has'); + const rels = erDb.getRelationships(); + + expect(Object.keys(erDb.getEntities()).length).toBe(2); + expect(rels.length).toBe(1); + expect(rels[0].cardinality).toBe(erDb.Cardinality.ONE_OR_MORE_TO_ONE_OR_MORE); + }); + + it('should handle zero-or-more-to-one-or-more relationships', function() { + erDiagram.parser.parse('erDiagram\nA >?-!< B : has'); + const rels = erDb.getRelationships(); + + expect(Object.keys(erDb.getEntities()).length).toBe(2); + expect(rels.length).toBe(1); + expect(rels[0].cardinality).toBe(erDb.Cardinality.ZERO_OR_MORE_TO_ONE_OR_MORE); + }); + + it('should handle one-or-more-to-zero-or-more relationships', function() { + erDiagram.parser.parse('erDiagram\nA >!-?< B : has'); + const rels = erDb.getRelationships(); + + expect(Object.keys(erDb.getEntities()).length).toBe(2); + expect(rels.length).toBe(1); + expect(rels[0].cardinality).toBe(erDb.Cardinality.ONE_OR_MORE_TO_ZERO_OR_MORE); + }); + + it('should not accept a syntax error', function() { + const doc = 'erDiagram\nA xxx B : has'; + expect(() => { + erDiagram.parser.parse(doc); + }).toThrowError(); + }); + +}); diff --git a/src/mermaidAPI.js b/src/mermaidAPI.js index 54f9f6cad..32ca5b07f 100644 --- a/src/mermaidAPI.js +++ b/src/mermaidAPI.js @@ -41,6 +41,9 @@ import infoDb from './diagrams/info/infoDb'; import pieRenderer from './diagrams/pie/pieRenderer'; import pieParser from './diagrams/pie/parser/pie'; import pieDb from './diagrams/pie/pieDb'; +import erDb from './diagrams/er/erDb'; +import erParser from './diagrams/er/parser/erDiagram'; +import erRenderer from './diagrams/er/erRenderer'; const themes = {}; for (const themeName of ['default', 'forest', 'dark', 'neutral']) { @@ -346,6 +349,54 @@ const config = { edgeLengthFactor: '20', compositTitleSize: 35, radius: 5 + }, + + /** + * The object containing configurations specific for entity relationship diagrams + */ + er: { + /** + * Directional bias for layout of entities. Can be either 'TB', 'BT', 'LR', or 'RL', + * where T = top, B = bottom, L = left, and R = right. + */ + layoutDirection: 'TB', + + /** + * The mimimum width of an entity box + */ + minEntityWidth: 100, + + /** + * The minimum height of an entity box + */ + minEntityHeight: 75, + + /** + * The minimum internal padding between the text in an entity box and the enclosing box borders + */ + entityPadding: 15, + + /** + * Stroke color of box edges and lines + */ + stroke: 'gray', + + /** + * Fill color of entity boxes + */ + fill: 'honeydew', + + /** + * Opacity of entity boxes - if you want to see how the crows feet + * retain their elegant joins to the boxes regardless of the angle of incidence + * then override this to something less than 100% + */ + fillOpacity: '100%', + + /** + * Font size + */ + fontSize: '12px' } }; @@ -398,6 +449,11 @@ function parse(text) { parser = pieParser; parser.parser.yy = pieDb; break; + case 'er': + logger.debug('er'); + parser = erParser; + parser.parser.yy = erDb; + break; } parser.parser.yy.parseError = (str, hash) => { @@ -620,6 +676,10 @@ const render = function(id, _txt, cb, container) { pieRenderer.setConf(config.class); pieRenderer.draw(txt, id, pkg.version); break; + case 'er': + erRenderer.setConf(config.er); + erRenderer.draw(txt, id, pkg.version); + break; } d3.select(`[id="${id}"]`) diff --git a/src/utils.js b/src/utils.js index c6e37bb9d..faff453f2 100644 --- a/src/utils.js +++ b/src/utils.js @@ -52,6 +52,10 @@ export const detectType = function(text) { return 'pie'; } + if (text.match(/^\s*erDiagram/)) { + return 'er'; + } + return 'flowchart'; };