UNPKG

graphx

Version:

Visualize and analyse GraphQL query results in a true Graph.

256 lines (237 loc) 11.9 kB
/** * Copyright (c) 2017, Neap Pty Ltd. * All rights reserved. * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. */ const _ = require('lodash') const shortid = require('shortid') const { getSchemaAST } = require('graphql-s2s').graphqls2s const { chain, throwError, isScalarType, getEdge, log, set, astParse, flattenNodes, getQueryFields } = require('./utilities') /** * Parses a string GraphQL query to an AST enriched with metadata from the GraphQL Schema AST. * * @param {String} query Raw string GraphQL query. * @param {Array} schemaAST Array of schema objects. Use 'graphql-s2s' npm package('getSchemaParts' method) to get that AST. * @return {Array} Query AST. */ const getQueryAST = (query, schemaAST) => chain(throwError(!query, 'Error in method \'getQueryAST\': Parameter \'query\' is required.')) .next(() => _(schemaAST)) .next(schemaAST => chain(schemaAST.find(x => x.type == 'TYPE' && x.name == 'Query')) .next(queryType => !queryType ? throwError(log(!queryType, 'QUERY TYPE'), 'Error in method \'getQueryAST\': The GraphQL schema does not define a \'Query\' type.') : chain(astParse(query)).next(ast => ast ? ast.map(prop => getQueryFields(prop, queryType, schemaAST)) : []).val()) .val()) .val() /** * Extracts the nodes located in the root properties of the 'fieldValue' param. Each node will also contain * their immediate related children (that means that non-continuous relations are ignored). * * @param {Object} fieldDef [description] * @param {Object} fieldValue [description] * @param {Object} predecessorEdge [description] * @return {Array} e.g. [{ * _node: 'Brand', * _uuid: 'dcwj379', * _edge: null, * _successors:[{_node: 'Post', _uuid: 'cken863', _edge: { name: 'ABOUT', direction: < }, _successors:[]}] * }] */ const extractNodes = (fieldDef, fieldValue, predecessorEdge = {}, predecessor) => (!fieldDef || !fieldValue || typeof(fieldValue) != 'object') ? [] : fieldValue.length != undefined ? _.flatten(fieldValue.map(fv => extractNodes(fieldDef, fv, predecessorEdge))) : chain(_(fieldDef.properties).reduce((a, p) => isScalarType(p.type) ? chain(a.scalarTypes.push(p)).next(() => a).val() : p.edge ? chain(a.nonScalarTypesWithEdges.push(p)).next(() => a).val() : chain(a.nonScalarTypes.push(p)).next(() => a).val(), // I'm storing the props with edges, but so far, I don't see any usage for it. Maybe later. { scalarTypes: [], nonScalarTypes: [], nonScalarTypesWithEdges: []})) .next(props => //e.g. "p": posts, "fieldValue[p.name]": { data:[...], cursor: ... }, "getEdge(p.edge)": { leftnode: "default", rightnode: "data",edge: { label: 'ABOUT', direction: '<' } } chain(_.flatten(_.toArray(_(props.nonScalarTypesWithEdges).map(p => chain(getEdge(p.edge)).next(edge => edge.rightnode == 'default' // that means that the current property 'p' is supposed to be the successor ? extractNodes(p, fieldValue[p.name], edge, fieldValue) // that means that the current property 'p' is not the immediate successor, but instead a property of 'p' : chain({ nodeProp: _(p.properties).find(x => x.name == edge.rightnode), nodeValue: (fieldValue[p.name] ? fieldValue[p.name][edge.rightnode] : null) }) .next(v => extractNodes(v.nodeProp, v.nodeValue, edge, fieldValue)) .val() ).val())))) .next(successors => fieldDef.isNode // CREATE NODE ? [_(props.scalarTypes).reduce((a, p) => { a[p.name] = fieldValue[p.name]; return a }, { _node: fieldDef.type.replace(/(\[|\])/g,''), // Describe the type of node _uuid: shortid.generate(), // uniquely identify that node _successors: successors, // Array of all successor nodes _edge: predecessorEdge && predecessorEdge.relation // relation between this node and its predecessor ? { name: predecessorEdge.relation.generate(predecessor, fieldValue, null), direction: predecessorEdge.relation.direction } : null })] // If the current field is not a node, then keep looking for some under the other properties : _.flatten(_.toArray(_(props.nonScalarTypes).map(p => extractNodes(p, fieldValue[p.name])))) ) //.next(node => log(node, "NODE")) .val()) .val() /** * D3Obj constructor * * @param {Array} nodes * @param {Array} edges */ const D3Obj = function (nodes, edges) { this.nodes = nodes this.edges = edges const _createClasses = {} const _classifiers = {} const _findClassFns = {} this.addClassifier = (name, createClasses, classMatch) => { try { const classes = createClasses(this.nodes) if (classes != undefined && classes.length == undefined) throw new Error(`The 'createClasses' function must return an Array. Current created type is '${typeof(classes)}'.`) _classifiers[name] = _(classes) _findClassFns[name] = classMatch _createClasses[name] = createClasses return this } catch(err) { throw new Error(`Error in method 'D3Obj.addClassifier': ${JSON.stringify(err)}`) } } this.findClass = (node, classifierName) => { try { const classes = _classifiers[classifierName] if (classes == undefined) throw new Error(`Classifier with name '${classifierName}' does not exist.`) return classes.find(c => _findClassFns[classifierName](node, c)) } catch(err) { throw new Error(`Error in method 'D3Obj.findClass': ${JSON.stringify(err)}`) } } this.newWithSameClassifier = (nodes, edges) => Object.keys(_createClasses).reduce( (graph, classifierName) => graph.addClassifier(classifierName, _createClasses[classifierName], _findClassFns[classifierName]) ,new D3Obj(nodes, edges)) } /** * Formats the ouput of the nodes extracted from the method 'extractNodes' into a D3 array. * * @param {Array} graphqlnodes Nodes from 'extractNodes' * @return {Object} result * @return {Array} result.nodes Array of all nodes. * @return {Array} result.edges Array of all edges between 'nodes'. */ const d3Flatten = graphqlnodes => chain(flattenNodes(graphqlnodes)) .next(v => ({ nodes: _.toArray(_(v.nodes).sortBy(x => x._position).map(x => { delete x._successors delete x._edge return x })), edges: _.toArray(_(v.links).sortBy(x => x.predecessor).map(x => ({ source: x.direction == '>' ? x.predecessor : x.successor, target: x.direction == '>' ? x.successor : x.predecessor, name: x.name }))) })) .next(v => new D3Obj(v.nodes, v.edges)) .val() /** * Coalesces nodes based on some rules. This method will at max decrease the number of nodes, but will always * leave the number of edges unchanged, unless the exact same couple existed more than once in the original graph. * * @param {Object} graph Previous D3 graph that has either been generated from 'compileGraphDataToD3' or 'd3Flatten' * @param {Array} graph.nodes e.g. [{ id:1, _node: 'Brand' }, { id:2, _node: 'Brand' }, { id:1, _node: 'Brand' }] * @param {Array} graph.edges e.g. [{ source:0, target:1 }, { source:0, target:2 }, { source:0, target:3 }] * @param {Function|Array} rules e.g. (a,b) => a.id == b.id this rule will treat all nodes with the same id as the same node. * 'rules' accepts either a 2-arity function or an array of 2-arity functions. * @return {Object} newgraph * @return {Object} newgraph.nodes Possibly less nodes * @return {Object} newgraph.edges The exact same number of edges unless the exact same couple existed more than once in * the original graph. */ const coalesceD3nodes = (graph, rules) => !rules || rules.length == 0 || !graph || !graph.nodes || !graph.nodes.length ? graph : typeof(rules) != 'function' && rules.length > 0 && _(rules).some(r => typeof(r) != 'function') ? throwError(true, 'Error in method \'coalesceD3nodes\': The \'rules\' argument contains elements that are not functions.') : !rules.length && typeof(rules) != 'function' ? throwError(true, 'Error in method \'coalesceD3nodes\': The \'rules\' argument is neither a function nor an array of functions.') : chain(typeof(rules) != 'function' ? rules : [rules]).val().reduce((seedGraph, rule) => chain(seedGraph.nodes.reduce((a, b) => a[b._position] // this node has alredy been processed ? a // this node has not been processed yet : chain(a.originNodes.filter(node => rule(node, b))) // find nodes identical to b .next(duplicates => duplicates.size() > 0 ? _.toArray(duplicates) : [b]) // In case the current node does not match the merging rule, we still need to preserve it .next(duplicates => ({ duplicates, acc: duplicates.reduce((acc, dup) => set(acc, dup._position || '0', { masterPos: b._position, newPos: acc.counter }), a) })) .next(({ duplicates, acc }) => chain(acc.counter) .next(counter => set(acc, 'counter', counter + 1, x => x.nodes.push(set(Object.assign({}, b), ['_position', '_slavedPos'], [counter, duplicates.map(y => y._position)])))) .val()) .val() , { originNodes: _(seedGraph.nodes), nodes:[], counter: 0 })) .next(mergedData => mergedData.nodes.reduce( (a, node) => chain(_.flatten(node._slavedPos.map(oldPos => _.toArray(a.originEdges.filter(x => x.source == oldPos) .map(edge => ({ source: node._position, name: edge.name, target: mergedData[edge.target].newPos })))))) .next(edges => chain(delete node._slavedPos).next(() => set(a, 'edges', a.edges.concat(edges), x => x.nodes.push(node))).val()) .val() ,{ nodes:[], edges:[], originEdges: _(seedGraph.edges) })) .next(newGraph => seedGraph.newWithSameClassifier(newGraph.nodes, newGraph.edges)) .val(), graph) /** * Format & enrich the GraphQL response so it contains relations that can be visualized in a D3 app. * * @param {String} query Raw GraphQL query. * @param {Object} resp GraphQL response * @param {String|Array} schemaAST Either the raw GraphQL schema or the AST version faster performance(use 'graphql-s2s' npm package) * @return {Object} result * @return {Array} result.nodes Array of all nodes. * @return {Array} result.edges Array of all edges between 'nodes'. */ const compileGraphDataToD3 = (query = '', resp, schemaAST) => chain(throwError(!schemaAST, 'Error in method \'compileGraphDataToD3\': Parameter \'schemaAST\' is required.')) .next(() => throwError(!resp, 'Error in method \'compileGraphDataToD3\': Parameter \'resp\' is required.')) .next(() => throwError(!resp.data, 'Error in method \'compileGraphDataToD3\': Parameter \'resp.data\' is required.')) .next(() => throwError(!query, 'Error in method \'compileGraphDataToD3\': Parameter \'query\' is required.')) .next(() => typeof(schemaAST) == 'string' ? getSchemaAST(schemaAST) : schemaAST) .next(schemaAST => getQueryAST(query, schemaAST)) .next(queryAST => queryAST && queryAST.length > 0 ? chain(_.flatten(_.toArray(_(queryAST) .map(request => ({ request, response: resp.data[request.name] })) .filter(x => x.response) .map(x => extractNodes(x.request, x.response))))) .next(nodes => nodes && nodes.length > 0 ? d3Flatten(nodes) : { nodes:[], edges:[] }) .val() : throwError(true, `Error in method 'compileGraphDataToD3': Query '${query}' failed to be parsed to a valid AST.`)) .val() const graphx = { extractNodes, d3Flatten, getQueryAST, compileGraphDataToD3, coalesceD3nodes, D3Obj } if (typeof window != 'undefined') window.graphx = graphx module.exports.graphx = graphx