diff --git a/src/services/graph-grammar.js b/src/services/graph-grammar.js index 0ec1581..2093652 100644 --- a/src/services/graph-grammar.js +++ b/src/services/graph-grammar.js @@ -7,6 +7,12 @@ import uuid from 'uuid/v4'; const grammar = ` Grapher { + paths + = pathWithSeparator+ path --multiplePaths + | path --singlePath + + pathWithSeparator = path ";" + path = partialPath+ node --partials | node --node @@ -36,21 +42,6 @@ Grapher { } `; -const mapGroup = (group) => { - return { - id: uuid(), - name: group.name, - }; -}; -const mapNode = (node) => - _omitBy( - { - id: node.id, - groups: !!node.groups ? node.groups.map(mapGroup) : undefined, - }, - _isNil - ); - class GraphGrammar { grammar; semantics; @@ -58,40 +49,17 @@ class GraphGrammar { async initialize() { this.grammar = ohm.grammar(grammar); this.semantics = this.grammar.createSemantics().addOperation('eval', { - path_partials: (partialPaths, node) => { - const entities = _flattenDeep([...partialPaths.eval(), node.eval()]); - const groups = entities.filter(({ type }) => type === 'group'); - const nodesAndLinks = entities.filter(({ type }) => type !== 'group'); - return { - nodes: nodesAndLinks.filter((entity) => entity.type === 'node').map(mapNode), - links: nodesAndLinks - .map((entity, index) => [entity, index]) - .filter(([entity]) => entity.type === 'link') - .map(([entity, index]) => { - const source = entity.direction === 'back' ? nodesAndLinks[index + 1].id : nodesAndLinks[index - 1].id; - const target = entity.direction === 'back' ? nodesAndLinks[index - 1].id : nodesAndLinks[index + 1].id; - const label = entity.label || `${source}-${target}`; - return { - id: entity.id, - label, - source, - target, - groups: !!entity.groups ? entity.groups.map(mapGroup) : undefined, - }; - }), - groups: groups.map(mapGroup).filter((item, index, groups) => groups.findIndex((candidate) => candidate.name === item.name) === index), - }; + paths_multiplePaths: (pathWithSeparators, path) => { + const entities = _flattenDeep([...pathWithSeparators.eval(), ...path.eval()]); + return mapEntities(entities); }, - path_node: (node) => { - const entities = node.eval(); - return { - nodes: entities.filter(({ type }) => type === 'node').map(mapNode), - groups: entities - .filter(({ type }) => type === 'group') - .map(mapGroup) - .filter((item, index, groups) => groups.findIndex((candidate) => candidate.name === item.name) === index), - }; + paths_singlePath: (path) => { + const entities = path.eval(); + return mapEntities(entities); }, + pathWithSeparator: (path, separator) => path.eval(), + path_partials: (partialPaths, node) => _flattenDeep([...partialPaths.eval(), node.eval()]), + path_node: (node) => node.eval(), partialPath: (node, link) => _flattenDeep([node.eval(), link.eval()]), node_nodeNoGroups: (open, identifier, close) => [ { @@ -200,3 +168,84 @@ class GraphGrammar { const graphGrammar = new GraphGrammar(); graphGrammar.initialize(); export default graphGrammar; + +const mapGroup = (group) => { + return { + id: uuid(), + name: group.name, + }; +}; + +const mapNode = (node) => + _omitBy( + { + id: node.id, + groups: !!node.groups ? node.groups.map(mapGroup) : [], + }, + _isNil + ); + +const mapEntities = (entities) => { + const groups = entities + .filter(({ type }) => type === 'group') + .map(mapGroup) + .filter((item, index, groups) => groups.findIndex((candidate) => candidate.name === item.name) === index); + const nodesAndLinks = entities.filter(({ type }) => type !== 'group'); + const nodes = entities + .filter(({ type }) => type === 'node') + .map(mapNode) + .map(({ groups, ...node }) => ({ + ...node, + groups: groups.map(({ name }) => groups.find(({ name: candidateName }) => candidateName === name)), + })) + .reduce((allNodes, node) => { + const existingNode = allNodes[node.id] || {}; + const existingGroups = existingNode.groups || []; + return { + ...allNodes, + [node.id]: { + ...existingNode, + ...node, + groups: [...existingGroups, ...node.groups].filter( + (item, index, groups) => groups.findIndex((candidate) => candidate.name === item.name) === index + ), + }, + }; + }, {}); + const links = nodesAndLinks + .map((entity, index) => [entity, index]) + .filter(([entity]) => entity.type === 'link') + .map(([entity, index]) => { + const source = entity.direction === 'back' ? nodesAndLinks[index + 1].id : nodesAndLinks[index - 1].id; + const target = entity.direction === 'back' ? nodesAndLinks[index - 1].id : nodesAndLinks[index + 1].id; + const label = entity.label || `${source}-${target}`; + const linkGroups = (entity.groups || []).map(({ name }) => groups.find(({ name: candidateName }) => candidateName === name)); + return { + id: entity.id, + label, + source, + target, + groups: linkGroups, + }; + }) + .reduce((linksBySourceAndTarget, link) => { + const sourceAndTargetId = `${link.source}-${link.target}`; + const existingLink = linksBySourceAndTarget[sourceAndTargetId] || {}; + const existingGroups = existingLink.groups || []; + return { + ...linksBySourceAndTarget, + [sourceAndTargetId]: { + ...existingLink, + ...link, + groups: [...existingGroups, ...link.groups].filter( + (item, index, groups) => groups.findIndex((candidate) => candidate.name === item.name) === index + ), + }, + }; + }, {}); + return { + nodes: Object.keys(nodes).map((nodeId) => nodes[nodeId]), + links: Object.keys(links).map((key) => links[key]), + groups, + }; +}; diff --git a/src/services/graph-grammar.spec.js b/src/services/graph-grammar.spec.js index 10b4b9e..5798153 100644 --- a/src/services/graph-grammar.spec.js +++ b/src/services/graph-grammar.spec.js @@ -77,6 +77,16 @@ describe('graph-grammar', () => { const matchResult = graphGrammar.match('(foo)-[:baz:qux quux]->(corge)'); expect(matchResult.succeeded()).toBeTruthy(); }); + + it('matches a list of nodes', () => { + const matchResult = graphGrammar.match('(foo);(bar);(baz)'); + expect(matchResult.succeeded()).toBeTruthy(); + }); + + it('matches a list of paths and nodes', () => { + const matchResult = graphGrammar.match('(foo)-[:baz:qux quux]->(corge);(bar);(foo)-[baz]->(bar)'); + expect(matchResult.succeeded()).toBeTruthy(); + }); }); describe('#eval', () => { @@ -95,8 +105,10 @@ describe('graph-grammar', () => { nodes: [ { id: 'foo', + groups: [], }, ], + links: [], groups: [], }); }); @@ -128,14 +140,17 @@ describe('graph-grammar', () => { label: expect.anything(), source: 'foo', target: 'bar', + groups: [], }, ], nodes: [ { id: 'foo', + groups: [], }, { id: 'bar', + groups: [], }, ], groups: [], @@ -151,23 +166,28 @@ describe('graph-grammar', () => { source: 'foo', target: 'bar', label: expect.anything(), + groups: [], }, { id: expect.anything(), source: 'baz', target: 'bar', label: expect.anything(), + groups: [], }, ], nodes: [ { id: 'foo', + groups: [], }, { id: 'bar', + groups: [], }, { id: 'baz', + groups: [], }, ], groups: [], @@ -183,23 +203,28 @@ describe('graph-grammar', () => { label: 'qux', source: 'foo', target: 'bar', + groups: [], }, { id: expect.anything(), label: 'quux', source: 'baz', target: 'bar', + groups: [], }, ], nodes: [ { id: 'foo', + groups: [], }, { id: 'bar', + groups: [], }, { id: 'baz', + groups: [], }, ], groups: [], @@ -215,14 +240,17 @@ describe('graph-grammar', () => { label: 'qux quux', source: 'foo bar', target: 'bar baz', + groups: [], }, ], nodes: [ { id: 'foo bar', + groups: [], }, { id: 'bar baz', + groups: [], }, ], groups: [], @@ -243,6 +271,7 @@ describe('graph-grammar', () => { ], }, ], + links: [], groups: [ { id: expect.anything(), @@ -272,9 +301,11 @@ describe('graph-grammar', () => { nodes: [ { id: 'foo', + groups: [], }, { id: 'bar', + groups: [], }, ], groups: [ @@ -343,5 +374,165 @@ describe('graph-grammar', () => { ], }); }); + + it('transforms a list of nodes', () => { + const result = graphGrammar.eval(graphGrammar.match('(Aragorn);(Gandalf)')); + expect(result).toEqual({ + nodes: [ + { + id: 'Aragorn', + groups: [], + }, + { + id: 'Gandalf', + groups: [], + }, + ], + links: [], + groups: [], + }); + }); + + it('transforms a path and a node', () => { + const result = graphGrammar.eval(graphGrammar.match('(Aragorn)<-(Gandalf);(Elrond)')); + expect(result).toEqual({ + nodes: [ + { + id: 'Aragorn', + groups: [], + }, + { + id: 'Gandalf', + groups: [], + }, + { + id: 'Elrond', + groups: [], + }, + ], + links: [ + { + id: expect.anything(), + label: expect.anything(), + source: 'Gandalf', + target: 'Aragorn', + groups: [], + }, + ], + groups: [], + }); + }); + + it('does not duplicate nodes', () => { + const result = graphGrammar.eval(graphGrammar.match('(Aragorn)<-(Gandalf);(Aragorn);(Aragorn)->(Boromir)')); + expect(result).toEqual({ + nodes: [ + { + id: 'Aragorn', + groups: [], + }, + { + id: 'Gandalf', + groups: [], + }, + { + id: 'Boromir', + groups: [], + }, + ], + links: expect.anything(), + groups: [], + }); + }); + + it('does not duplicate links', () => { + const result = graphGrammar.eval(graphGrammar.match('(Aragorn)<-(Gandalf);(Gandalf)->(Aragorn)')); + expect(result).toEqual({ + nodes: expect.anything(), + links: [ + { + id: expect.anything(), + label: expect.anything(), + source: 'Gandalf', + target: 'Aragorn', + groups: [], + }, + ], + groups: [], + }); + }); + + it('does not duplicate groups', () => { + const result = graphGrammar.eval( + graphGrammar.match('(Aragorn:Dunedain)<-[:knows]-(Gandalf:Maia);(Aragorn:Dunedain)-[:knows]->(Boromir:Dunedain)') + ); + expect(result).toEqual({ + nodes: expect.anything(), + links: expect.anything(), + groups: [ + { + id: expect.anything(), + name: 'Dunedain', + }, + { + id: expect.anything(), + name: 'knows', + }, + { + id: expect.anything(), + name: 'Maia', + }, + ], + }); + }); + + it('does not duplicate groups in nodes', () => { + const result = graphGrammar.eval(graphGrammar.match('(Aragorn:Dunedain:Heir);(Aragorn:Dunedain)')); + expect(result).toEqual({ + nodes: [ + { + id: 'Aragorn', + groups: [ + { + id: expect.anything(), + name: 'Dunedain', + }, + { + id: expect.anything(), + name: 'Heir', + }, + ], + }, + ], + links: expect.anything(), + groups: expect.anything(), + }); + }); + + it('does not duplicate groups in links', () => { + const result = graphGrammar.eval(graphGrammar.match('(Aragorn)<-[:knows]-(Gandalf);(Gandalf)-[:knows:respects]->(Aragorn)')); + expect(result).toEqual({ + nodes: expect.anything(), + links: [ + { + id: expect.anything(), + label: expect.anything(), + source: 'Gandalf', + target: 'Aragorn', + groups: [ + { + id: expect.anything(), + name: 'knows', + }, + { + id: expect.anything(), + name: 'respects', + }, + ], + }, + ], + groups: expect.anything(), + }); + }); }); });