diff --git a/src/core/graph.js b/src/core/graph.js index c72a476..1f4933e 100644 --- a/src/core/graph.js +++ b/src/core/graph.js @@ -169,78 +169,70 @@ export class Graph { } /** - * Gets the array of all out-edges from the node at the given address. - * The order of the resulting array is unspecified. - */ - outEdges( - nodeAddress: Address, - typeOptions?: {+nodeType?: string, +edgeType?: string} - ): Edge[] { - if (nodeAddress == null) { - throw new Error(`address is ${String(nodeAddress)}`); - } - let result = this._lookupEdges(this._outEdges, nodeAddress).map((e) => - this.edge(e) - ); - - if (typeOptions != null && typeOptions.edgeType != null) { - const edgeType = typeOptions.edgeType; - result = result.filter((e) => e.address.type === edgeType); - } - if (typeOptions != null && typeOptions.nodeType != null) { - const nodeType = typeOptions.nodeType; - result = result.filter((e) => e.dst.type === nodeType); - } - return result; - } - - /** - * Gets the array of all in-edges to the node at the given address. - * The order of the resulting array is unspecified. - */ - inEdges( - nodeAddress: Address, - typeOptions?: {+nodeType?: string, +edgeType?: string} - ): Edge[] { - if (nodeAddress == null) { - throw new Error(`address is ${String(nodeAddress)}`); - } - let result = this._lookupEdges(this._inEdges, nodeAddress).map((e) => - this.edge(e) - ); - - if (typeOptions != null && typeOptions.edgeType != null) { - const edgeType = typeOptions.edgeType; - result = result.filter((e) => e.address.type === edgeType); - } - if (typeOptions != null && typeOptions.nodeType != null) { - const nodeType = typeOptions.nodeType; - result = result.filter((e) => e.src.type === nodeType); - } - return result; - } - - /** - * Find the neighborhood of a given nodeAddress + * Find the neighborhood of the node at the given address. * - * By neighborhood, we mean every `{edge, neighborAddress}` such that edge - * has `nodeAddress` as either `src` or `dst`, and `neighborAddress` is the + * By neighborhood, we mean every `{edge, neighbor}` such that edge + * has `nodeAddress` as either `src` or `dst`, and `neighbor` is the * address at the other end of the edge. + * + * The returned neighbors are filtered according to the `options`. Callers + * can filter by nodeType, edgeType, and whether it should be an "IN" edge + * (i.e. the provided node is the dst), an "OUT" edge (i.e. provided node is + * the src), or "ANY". */ neighborhood( nodeAddress: Address, - typeOptions?: {+nodeType?: string, +edgeType?: string} - ): {+edge: Edge, +neighborAddress: Address}[] { - const inNeighbors = this.inEdges(nodeAddress, typeOptions).map((e) => { - return {edge: e, neighborAddress: e.src}; - }); - const outNeighbors = this.outEdges(nodeAddress, typeOptions) - // If there are self-reference edges, avoid double counting them. - .filter((e) => !deepEqual(e.src, e.dst)) - .map((e) => { - return {edge: e, neighborAddress: e.dst}; - }); - return [].concat(inNeighbors, outNeighbors); + options?: {| + +nodeType?: string, + +edgeType?: string, + +direction?: "IN" | "OUT" | "ANY", + |} + ): {+edge: Edge, +neighbor: Address}[] { + if (nodeAddress == null) { + throw new Error(`address is ${String(nodeAddress)}`); + } + + let result: {+edge: Edge, +neighbor: Address}[] = []; + const direction = (options != null && options.direction) || "ANY"; + + if (direction === "ANY" || direction === "IN") { + let inNeighbors = this._lookupEdges(this._inEdges, nodeAddress).map( + (e) => { + const edge = this.edge(e); + return {edge, neighbor: edge.src}; + } + ); + + result = result.concat(inNeighbors); + } + + if (direction === "ANY" || direction === "OUT") { + let outNeighbors = this._lookupEdges(this._outEdges, nodeAddress).map( + (e) => { + const edge = this.edge(e); + return {edge, neighbor: edge.dst}; + } + ); + + if (direction === "ANY") { + // If direction is ANY, we already counted self-referencing edges as + // an inNeighbor + outNeighbors = outNeighbors.filter( + ({edge}) => !deepEqual(edge.src, edge.dst) + ); + } + + result = result.concat(outNeighbors); + } + if (options != null && options.edgeType != null) { + const edgeType = options.edgeType; + result = result.filter(({edge}) => edge.address.type === edgeType); + } + if (options != null && options.nodeType != null) { + const nodeType = options.nodeType; + result = result.filter(({neighbor}) => neighbor.type === nodeType); + } + return result; } /** diff --git a/src/core/graph.test.js b/src/core/graph.test.js index 4e0afbd..23a0154 100644 --- a/src/core/graph.test.js +++ b/src/core/graph.test.js @@ -65,13 +65,8 @@ describe("graph", () => { `address is ${String(bad)}` ); }); - it(`getting ${String(bad)} in-edges`, () => { - expect(() => new Graph().inEdges((bad: any))).toThrow( - `address is ${String(bad)}` - ); - }); - it(`getting ${String(bad)} out-edges`, () => { - expect(() => new Graph().outEdges((bad: any))).toThrow( + it(`getting ${String(bad)} neighborhood`, () => { + expect(() => new Graph().neighborhood((bad: any))).toThrow( `address is ${String(bad)}` ); }); @@ -298,12 +293,16 @@ describe("graph", () => { it("allows creating self-loops", () => { const g = demoData.simpleMealGraph(); g.addEdge(demoData.crabLoopEdge()); - expect(g.outEdges(demoData.crabNode().address)).toContainEqual( - demoData.crabLoopEdge() - ); - expect(g.inEdges(demoData.crabNode().address)).toContainEqual( - demoData.crabLoopEdge() - ); + expect( + g + .neighborhood(demoData.crabNode().address, {direction: "OUT"}) + .map(({edge}) => edge) + ).toContainEqual(demoData.crabLoopEdge()); + expect( + g + .neighborhood(demoData.crabNode().address, {direction: "IN"}) + .map(({edge}) => edge) + ).toContainEqual(demoData.crabLoopEdge()); const crabNeighbors = g.neighborhood(demoData.crabNode().address); const crabLoops = crabNeighbors.filter(({edge}) => deepEqual(edge, demoData.crabLoopEdge()) @@ -315,7 +314,11 @@ describe("graph", () => { const g = demoData.simpleMealGraph(); g.addEdge(demoData.duplicateCookEdge()); [demoData.cookEdge(), demoData.duplicateCookEdge()].forEach((e) => { - expect(g.outEdges(demoData.mealNode().address)).toContainEqual(e); + expect( + g + .neighborhood(demoData.mealNode().address, {direction: "OUT"}) + .map(({edge}) => edge) + ).toContainEqual(e); expect(g.edge(e.address)).toEqual(e); }); }); @@ -381,7 +384,7 @@ describe("graph", () => { }); }); - describe("inEdges and outEdges", () => { + describe("neighborhood detection", () => { describe("type filtering", () => { class ExampleGraph { graph: Graph<{}, {}>; @@ -441,14 +444,26 @@ describe("graph", () => { const exampleGraph = new ExampleGraph(); [ [ - "inEdges", + "neighborhood IN", exampleGraph.inEdges, - (opts) => exampleGraph.graph.inEdges(exampleGraph.root, opts), + (opts) => + exampleGraph.graph + .neighborhood(exampleGraph.root, { + ...(opts || {}), + direction: "IN", + }) + .map(({edge}) => edge), ], [ - "outEdges", + "neighborhood OUT", exampleGraph.outEdges, - (opts) => exampleGraph.graph.outEdges(exampleGraph.root, opts), + (opts) => + exampleGraph.graph + .neighborhood(exampleGraph.root, { + ...(opts || {}), + direction: "OUT", + }) + .map(({edge}) => edge), ], ].forEach(([choice, {a1, a2, b1, b2}, edges]) => { describe(choice, () => { @@ -516,23 +531,23 @@ describe("graph", () => { neighborhood: [ { edge: demoData.eatEdge(), - neighborAddress: demoData.mealNode().address, + neighbor: demoData.mealNode().address, }, { edge: demoData.pickEdge(), - neighborAddress: demoData.bananasNode().address, + neighbor: demoData.bananasNode().address, }, { edge: demoData.grabEdge(), - neighborAddress: demoData.crabNode().address, + neighbor: demoData.crabNode().address, }, { edge: demoData.cookEdge(), - neighborAddress: demoData.mealNode().address, + neighbor: demoData.mealNode().address, }, { edge: demoData.duplicateCookEdge(), - neighborAddress: demoData.mealNode().address, + neighbor: demoData.mealNode().address, }, ], }, @@ -541,11 +556,11 @@ describe("graph", () => { neighborhood: [ { edge: demoData.pickEdge(), - neighborAddress: demoData.heroNode().address, + neighbor: demoData.heroNode().address, }, { edge: demoData.bananasIngredientEdge(), - neighborAddress: demoData.mealNode().address, + neighbor: demoData.mealNode().address, }, ], }, @@ -554,15 +569,15 @@ describe("graph", () => { neighborhood: [ { edge: demoData.crabIngredientEdge(), - neighborAddress: demoData.mealNode().address, + neighbor: demoData.mealNode().address, }, { edge: demoData.grabEdge(), - neighborAddress: demoData.heroNode().address, + neighbor: demoData.heroNode().address, }, { edge: demoData.crabLoopEdge(), - neighborAddress: demoData.crabNode().address, + neighbor: demoData.crabNode().address, }, ], }, @@ -571,23 +586,23 @@ describe("graph", () => { neighborhood: [ { edge: demoData.bananasIngredientEdge(), - neighborAddress: demoData.bananasNode().address, + neighbor: demoData.bananasNode().address, }, { edge: demoData.crabIngredientEdge(), - neighborAddress: demoData.crabNode().address, + neighbor: demoData.crabNode().address, }, { edge: demoData.cookEdge(), - neighborAddress: demoData.heroNode().address, + neighbor: demoData.heroNode().address, }, { edge: demoData.eatEdge(), - neighborAddress: demoData.heroNode().address, + neighbor: demoData.heroNode().address, }, { edge: demoData.duplicateCookEdge(), - neighborAddress: demoData.heroNode().address, + neighbor: demoData.heroNode().address, }, ], }, @@ -617,7 +632,10 @@ describe("graph", () => { ], ]; nodeAndExpectedEdgePairs.forEach(([node, expectedEdges]) => { - const actual = demoData.advancedMealGraph().outEdges(node.address); + const actual = demoData + .advancedMealGraph() + .neighborhood(node.address, {direction: "OUT"}) + .map(({edge}) => edge); expectSameSorted(actual, expectedEdges); }); }); @@ -641,7 +659,10 @@ describe("graph", () => { [demoData.mealNode(), [demoData.eatEdge()]], ]; nodeAndExpectedEdgePairs.forEach(([node, expectedEdges]) => { - const actual = demoData.advancedMealGraph().inEdges(node.address); + const actual = demoData + .advancedMealGraph() + .neighborhood(node.address, {direction: "IN"}) + .map(({edge}) => edge); expectSameSorted(actual, expectedEdges); }); }); @@ -649,14 +670,18 @@ describe("graph", () => { it("gets empty out-edges for a nonexistent node", () => { const result = demoData .simpleMealGraph() - .outEdges(demoData.makeAddress("hinox", "NPC")); + .neighborhood(demoData.makeAddress("hinox", "NPC"), { + direction: "OUT", + }); expect(result).toEqual([]); }); it("gets empty in-edges for a nonexistent node", () => { const result = demoData .simpleMealGraph() - .inEdges(demoData.makeAddress("hinox", "NPC")); + .neighborhood(demoData.makeAddress("hinox", "NPC"), { + direction: "IN", + }); expect(result).toEqual([]); }); @@ -690,7 +715,9 @@ describe("graph", () => { .addEdge(fullyDanglingEdge()) .removeNode(danglingSrc().address) .removeNode(danglingDst().address); - const inEdges = g.inEdges(fullyDanglingEdge().dst); + const inEdges = g + .neighborhood(fullyDanglingEdge().dst, {direction: "IN"}) + .map(({edge}) => edge); expect(inEdges).toEqual([fullyDanglingEdge()]); }); @@ -702,7 +729,9 @@ describe("graph", () => { .addEdge(fullyDanglingEdge()) .removeNode(danglingSrc().address) .removeNode(danglingDst().address); - const outEdges = g.outEdges(fullyDanglingEdge().src); + const outEdges = g + .neighborhood(fullyDanglingEdge().src, {direction: "OUT"}) + .map(({edge}) => edge); expect(outEdges).toEqual([fullyDanglingEdge()]); }); @@ -713,8 +742,10 @@ describe("graph", () => { .addNode(danglingDst()) .addEdge(fullyDanglingEdge()) .removeEdge(fullyDanglingEdge().address); - const outEdges = g.inEdges(fullyDanglingEdge().dst); - expect(outEdges).toEqual([]); + const inEdges = g.neighborhood(fullyDanglingEdge().dst, { + direction: "IN", + }); + expect(inEdges).toEqual([]); }); it("has lack of out-edges for deleted edge", () => { @@ -724,19 +755,25 @@ describe("graph", () => { .addNode(danglingDst()) .addEdge(fullyDanglingEdge()) .removeEdge(fullyDanglingEdge().address); - const outEdges = g.outEdges(fullyDanglingEdge().src); + const outEdges = g.neighborhood(fullyDanglingEdge().src, { + direction: "OUT", + }); expect(outEdges).toEqual([]); }); it("has in-edges for non-existent node with dangling edge", () => { const g = demoData.simpleMealGraph().addEdge(fullyDanglingEdge()); - const inEdges = g.inEdges(fullyDanglingEdge().dst); + const inEdges = g + .neighborhood(fullyDanglingEdge().dst, {direction: "IN"}) + .map(({edge}) => edge); expect(inEdges).toEqual([fullyDanglingEdge()]); }); it("has out-edges for non-existent node with dangling edge", () => { const g = demoData.simpleMealGraph().addEdge(fullyDanglingEdge()); - const outEdges = g.outEdges(fullyDanglingEdge().src); + const outEdges = g + .neighborhood(fullyDanglingEdge().src, {direction: "OUT"}) + .map(({edge}) => edge); expect(outEdges).toEqual([fullyDanglingEdge()]); }); @@ -745,7 +782,9 @@ describe("graph", () => { .simpleMealGraph() .addEdge(fullyDanglingEdge()) .addNode(danglingDst()); - const inEdges = g.inEdges(fullyDanglingEdge().dst); + const inEdges = g + .neighborhood(fullyDanglingEdge().dst, {direction: "IN"}) + .map(({edge}) => edge); expect(inEdges).toEqual([fullyDanglingEdge()]); }); @@ -754,7 +793,9 @@ describe("graph", () => { .simpleMealGraph() .addEdge(fullyDanglingEdge()) .addNode(danglingSrc()); - const outEdges = g.outEdges(fullyDanglingEdge().src); + const outEdges = g + .neighborhood(fullyDanglingEdge().src, {direction: "OUT"}) + .map(({edge}) => edge); expect(outEdges).toEqual([fullyDanglingEdge()]); }); } @@ -777,15 +818,23 @@ describe("graph", () => { it("is idempotent in terms of in-edges", () => { const g1 = originalGraph(); const g2 = modifiedGraph(); - const e1 = g1.inEdges(taredge().address); - const e2 = g2.inEdges(taredge().address); + const e1 = g1 + .neighborhood(taredge().address, {direction: "IN"}) + .map(({edge}) => edge); + const e2 = g2 + .neighborhood(taredge().address, {direction: "IN"}) + .map(({edge}) => edge); expectSameSorted(e1, e2); }); it("is idempotent in terms of out-edges", () => { const g1 = originalGraph(); const g2 = modifiedGraph(); - const e1 = g1.outEdges(taredge().address); - const e2 = g2.outEdges(taredge().address); + const e1 = g1 + .neighborhood(taredge().address, {direction: "OUT"}) + .map(({edge}) => edge); + const e2 = g2 + .neighborhood(taredge().address, {direction: "OUT"}) + .map(({edge}) => edge); expectSameSorted(e1, e2); }); }); @@ -848,21 +897,25 @@ describe("graph", () => { return originalGraph.nodes().map((node) => { const miniGraph = new Graph(); miniGraph.addNode(node); - originalGraph.outEdges(node.address).forEach((edge) => { - if (miniGraph.node(edge.dst) === undefined) { - miniGraph.addNode(originalGraph.node(edge.dst)); - } - miniGraph.addEdge(edge); - }); - originalGraph.inEdges(node.address).forEach((edge) => { - if (miniGraph.node(edge.src) === undefined) { - miniGraph.addNode(originalGraph.node(edge.src)); - } - if (miniGraph.edge(edge.address) === undefined) { - // This check is necessary to prevent double-adding loops. + originalGraph + .neighborhood(node.address, {direction: "OUT"}) + .forEach(({edge}) => { + if (miniGraph.node(edge.dst) === undefined) { + miniGraph.addNode(originalGraph.node(edge.dst)); + } miniGraph.addEdge(edge); - } - }); + }); + originalGraph + .neighborhood(node.address, {direction: "IN"}) + .forEach(({edge}) => { + if (miniGraph.node(edge.src) === undefined) { + miniGraph.addNode(originalGraph.node(edge.src)); + } + if (miniGraph.edge(edge.address) === undefined) { + // This check is necessary to prevent double-adding loops. + miniGraph.addEdge(edge); + } + }); return miniGraph; }); } diff --git a/src/plugins/artifact/editor/ContributionList.test.js b/src/plugins/artifact/editor/ContributionList.test.js index b509b20..787eb55 100644 --- a/src/plugins/artifact/editor/ContributionList.test.js +++ b/src/plugins/artifact/editor/ContributionList.test.js @@ -90,7 +90,9 @@ function createTestData(): * { }> { render() { const {graph, node} = this.props; - const neighborCount = graph.outEdges(node.address).length; + const neighborCount = graph.neighborhood(node.address, { + direction: "OUT", + }).length; return ( {node.address.id} has neighbor count{" "} diff --git a/src/plugins/artifact/editor/adapters/githubPluginAdapter.js b/src/plugins/artifact/editor/adapters/githubPluginAdapter.js index 4b12c30..ec79905 100644 --- a/src/plugins/artifact/editor/adapters/githubPluginAdapter.js +++ b/src/plugins/artifact/editor/adapters/githubPluginAdapter.js @@ -38,11 +38,12 @@ const adapter: PluginAdapter = { // previously queried IDs.) function extractParentTitles(node: Node): string[] { return graph - .inEdges(node.address) - .filter((e) => e.address.type === CONTAINS_EDGE_TYPE) - .map((e) => graph.node(e.src)) - .map((container) => { - return adapter.extractTitle(graph, container); + .neighborhood(node.address, { + direction: "IN", + edgeType: CONTAINS_EDGE_TYPE, + }) + .map(({neighbor}) => { + return adapter.extractTitle(graph, graph.node(neighbor)); }); } function extractIssueOrPrTitle( diff --git a/src/plugins/git/createGraph.test.js b/src/plugins/git/createGraph.test.js index a166819..083da0a 100644 --- a/src/plugins/git/createGraph.test.js +++ b/src/plugins/git/createGraph.test.js @@ -62,26 +62,31 @@ describe("createGraph", () => { id: hash, }; - const entryChildren = graph.outEdges(address, { + const entryChildren = graph.neighborhood(address, { nodeType: TREE_ENTRY_NODE_TYPE, edgeType: INCLUDES_EDGE_TYPE, + direction: "OUT", }); expect(entryChildren).toHaveLength( Object.keys(data.trees[hash].entries).length ); - expect(graph.outEdges(address)).toHaveLength(entryChildren.length); + expect(graph.neighborhood(address, {direction: "OUT"})).toHaveLength( + entryChildren.length + ); expect(graph.node(address)).toEqual({address, payload: {}}); - const owningCommits = graph.inEdges(address, { + const owningCommits = graph.neighborhood(address, { nodeType: COMMIT_NODE_TYPE, edgeType: HAS_TREE_EDGE_TYPE, + direction: "IN", }); expect(owningCommits.length).toBeLessThanOrEqual(1); - const parentTreeEntries = graph.inEdges(address, { + const parentTreeEntries = graph.neighborhood(address, { nodeType: TREE_ENTRY_NODE_TYPE, edgeType: HAS_CONTENTS_EDGE_TYPE, + direction: "IN", }); - expect(graph.inEdges(address)).toHaveLength( + expect(graph.neighborhood(address, {direction: "IN"})).toHaveLength( owningCommits.length + parentTreeEntries.length ); }); @@ -110,14 +115,18 @@ describe("createGraph", () => { id: treeEntryId(hash, name), }; expect( - graph.inEdges(entryAddress, { + graph.neighborhood(entryAddress, { nodeType: TREE_NODE_TYPE, edgeType: INCLUDES_EDGE_TYPE, + direction: "IN", }) ).toHaveLength(1); const shouldHaveContents = tree.entries[name].type !== "commit"; expect( - graph.outEdges(entryAddress, {edgeType: HAS_CONTENTS_EDGE_TYPE}) + graph.neighborhood(entryAddress, { + edgeType: HAS_CONTENTS_EDGE_TYPE, + direction: "OUT", + }) ).toHaveLength(shouldHaveContents ? 1 : 0); expect(graph.neighborhood(entryAddress)).toHaveLength( shouldHaveContents ? 2 : 1 @@ -142,7 +151,7 @@ describe("createGraph", () => { .neighborhood(nodeAddress, filter) .filter((x) => predicate(x)); expect(edges).toHaveLength(1); - return edges[0].neighborAddress; + return edges[0].neighbor; } function uniqueTree(graph, commitAddress) { @@ -218,7 +227,9 @@ describe("createGraph", () => { expect(graph.node(treeEntryAddress)).toEqual(expect.anything()); // Submodule commits never have contents, because the commit nodes // are from an unknown repository. - expect(graph.outEdges(treeEntryAddress)).toHaveLength(0); + expect( + graph.neighborhood(treeEntryAddress, {direction: "OUT"}) + ).toHaveLength(0); }); }); }); diff --git a/src/plugins/github/api.js b/src/plugins/github/api.js index a518a3c..4d7d6bd 100644 --- a/src/plugins/github/api.js +++ b/src/plugins/github/api.js @@ -113,7 +113,7 @@ class Post< edgeType: AUTHORS_EDGE_TYPE, nodeType: AUTHOR_NODE_TYPE, }) - .map(({neighborAddress}) => new Author(this.graph, neighborAddress)); + .map(({neighbor}) => new Author(this.graph, neighbor)); } body(): string { @@ -130,7 +130,7 @@ class Commentable extends Post< edgeType: CONTAINS_EDGE_TYPE, nodeType: COMMENT_NODE_TYPE, }) - .map(({neighborAddress}) => new Comment(this.graph, neighborAddress)); + .map(({neighbor}) => new Comment(this.graph, neighbor)); } } diff --git a/src/plugins/github/parser.test.js b/src/plugins/github/parser.test.js index b93a071..35cbe67 100644 --- a/src/plugins/github/parser.test.js +++ b/src/plugins/github/parser.test.js @@ -38,14 +38,16 @@ describe("GithubParser", () => { .filter((n) => n.address.type === "COMMENT"); expect(comments).not.toHaveLength(0); comments.forEach((c) => { - const authorEdges = graph - .outEdges(c.address) - .filter((e) => e.address.type === AUTHORS_EDGE_TYPE); - expect(authorEdges.length).toBe(1); - const containerEdges = graph - .inEdges(c.address) - .filter((e) => e.address.type === CONTAINS_EDGE_TYPE); - expect(containerEdges.length).toBe(1); + const authorNeighbors = graph.neighborhood(c.address, { + edgeType: AUTHORS_EDGE_TYPE, + direction: "OUT", + }); + expect(authorNeighbors.length).toBe(1); + const containerNeighbors = graph.neighborhood(c.address, { + direction: "IN", + edgeType: CONTAINS_EDGE_TYPE, + }); + expect(containerNeighbors.length).toBe(1); }); }); @@ -57,11 +59,11 @@ describe("GithubParser", () => { ); expect(issuesAndPRs).not.toHaveLength(0); issuesAndPRs.forEach((x) => { - const outEdges = graph.outEdges(x.address); - const authorEdges = outEdges.filter( - (e) => e.address.type === AUTHORS_EDGE_TYPE - ); - expect(authorEdges.length).toBe(1); + const authorNeighbors = graph.neighborhood(x.address, { + edgeType: AUTHORS_EDGE_TYPE, + direction: "OUT", + }); + expect(authorNeighbors.length).toBe(1); }); }); });