diff --git a/src/v3/core/graph.js b/src/v3/core/graph.js index 5e7939b..9729259 100644 --- a/src/v3/core/graph.js +++ b/src/v3/core/graph.js @@ -52,6 +52,11 @@ export class Graph { // // Invariant: If an edge `e` is in the graph, then `e.src` and `e.dst` // are both in the graph. + // + // Invariant: For an edge `e`, the following are equivalent: + // - `e` is in the graph; + // - `_inEdges.get(e.dst)` contains `e` exactly once; + // - `_outEdges.get(e.src)` contains `e` exactly once. _nodes: Set; _edges: Map; _inEdges: Map; @@ -97,21 +102,31 @@ export class Graph { addNode(a: NodeAddressT): this { NodeAddress.assertValid(a); - this._nodes.add(a); + if (!this._nodes.has(a)) { + this._nodes.add(a); + this._inEdges.set(a, []); + this._outEdges.set(a, []); + } this._markModification(); return this; } removeNode(a: NodeAddressT): this { NodeAddress.assertValid(a); - for (const e of this.edges()) { - if (e.src === a || e.dst === a) { - const srcOrDst = e.src === a ? "src" : "dst"; - throw new Error( - `Attempted to remove ${srcOrDst} of ${edgeToString(e)}` - ); - } + const existingInEdges = this._inEdges.get(a) || []; + const existingOutEdges = this._outEdges.get(a) || []; + const existingEdges = existingInEdges.concat(existingOutEdges); + if (existingEdges.length > 0) { + const strAddress = NodeAddress.toString(a); + const strExampleEdge = edgeToString(existingEdges[0]); + throw new Error( + `Attempted to remove ${strAddress}, which is incident to ${ + existingEdges.length + } edge(s), e.g.: ${strExampleEdge}` + ); } + this._inEdges.delete(a); + this._outEdges.delete(a); this._nodes.delete(a); this._markModification(); return this; @@ -160,6 +175,15 @@ export class Graph { `conflict between new edge ${strEdge} and existing ${strExisting}` ); } + } else { + this._edges.set(edge.address, edge); + const inEdges = this._inEdges.get(edge.dst); + const outEdges = this._outEdges.get(edge.src); + if (inEdges == null || outEdges == null) { + throw new Error(`Invariant violation on edge ${edgeToString(edge)}`); + } + inEdges.push(edge); + outEdges.push(edge); } this._edges.set(edge.address, edge); this._markModification(); @@ -168,7 +192,28 @@ export class Graph { removeEdge(address: EdgeAddressT): this { EdgeAddress.assertValid(address); - this._edges.delete(address); + const edge = this._edges.get(address); + if (edge != null) { + this._edges.delete(address); + const inEdges = this._inEdges.get(edge.dst); + const outEdges = this._outEdges.get(edge.src); + if (inEdges == null || outEdges == null) { + throw new Error(`Invariant violation on ${edgeToString(edge)}`); + } + // TODO(perf): This is linear in the degree of the endpoints of the + // edge. Consider storing in non-list form (e.g., `_inEdges` and + // `_outEdges` could be `Map>`). + [inEdges, outEdges].forEach((edges) => { + const index = edges.findIndex((edge) => edge.address === address); + if (index === -1) { + const strAddress = EdgeAddress.toString(address); + throw new Error( + `Invariant violation when removing edge@${strAddress}` + ); + } + edges.splice(index, 1); + }); + } this._markModification(); return this; } @@ -196,8 +241,55 @@ export class Graph { } neighbors(node: NodeAddressT, options: NeighborsOptions): Iterator { - const _ = {node, options}; - throw new Error("neighbors"); + if (!this.hasNode(node)) { + throw new Error(`Node does not exist: ${NodeAddress.toString(node)}`); + } + return this._neighbors(node, options, this._modificationCount); + } + + *_neighbors( + node: NodeAddressT, + options: NeighborsOptions, + initialModificationCount: ModificationCount + ): Iterator { + const nodeFilter = (n) => NodeAddress.hasPrefix(n, options.nodePrefix); + const edgeFilter = (e) => EdgeAddress.hasPrefix(e, options.edgePrefix); + const direction = options.direction; + const adjacencies: {edges: Edge[], direction: string}[] = []; + if (direction === Direction.IN || direction === Direction.ANY) { + const inEdges = this._inEdges.get(node); + if (inEdges == null) { + throw new Error( + `Invariant violation: No inEdges for ${NodeAddress.toString(node)}` + ); + } + adjacencies.push({edges: inEdges, direction: "IN"}); + } + if (direction === Direction.OUT || direction === Direction.ANY) { + const outEdges = this._outEdges.get(node); + if (outEdges == null) { + throw new Error( + `Invariant violation: No outEdges for ${NodeAddress.toString(node)}` + ); + } + adjacencies.push({edges: outEdges, direction: "OUT"}); + } + + for (const adjacency of adjacencies) { + for (const edge of adjacency.edges) { + if (direction === Direction.ANY && adjacency.direction === "IN") { + if (edge.src === edge.dst) { + continue; // don't yield loop edges twice. + } + } + const neighborNode = adjacency.direction === "IN" ? edge.src : edge.dst; + if (nodeFilter(neighborNode) && edgeFilter(edge.address)) { + this._checkForComodification(initialModificationCount); + yield {edge, node: neighborNode}; + } + } + } + this._checkForComodification(initialModificationCount); } copy(): Graph { diff --git a/src/v3/core/graph.test.js b/src/v3/core/graph.test.js index 749a493..c628d07 100644 --- a/src/v3/core/graph.test.js +++ b/src/v3/core/graph.test.js @@ -1,7 +1,11 @@ // @flow +import sortBy from "lodash.sortby"; + import { type EdgeAddressT, + type Neighbor, + type NeighborsOptions, type NodeAddressT, Direction, EdgeAddress, @@ -94,12 +98,12 @@ describe("core/graph", () => { .addEdge(edge()); it("src", () => { expect(() => graph().removeNode(src)).toThrow( - "Attempted to remove src of" + "Attempted to remove" ); }); it("dst", () => { expect(() => graph().removeNode(dst)).toThrow( - "Attempted to remove dst of" + "Attempted to remove" ); }); }); @@ -410,6 +414,15 @@ describe("core/graph", () => { .addEdge({src, dst, address}) .addEdge({src, dst, address}); expect(edgeArray(g)).toEqual([{src, dst, address}]); + expect( + Array.from( + g.neighbors(src, { + direction: Direction.ANY, + nodePrefix: NodeAddress.fromParts([]), + edgePrefix: EdgeAddress.fromParts([]), + }) + ) + ).toHaveLength(1); }); it("`removeEdge`", () => { const g = new Graph() @@ -457,6 +470,239 @@ describe("core/graph", () => { expect(g._modificationCount).not.toEqual(before); }); }); + + describe("neighbors", () => { + const foo = NodeAddress.fromParts(["foo", "suffix"]); + const loop = NodeAddress.fromParts(["loop"]); + const isolated = NodeAddress.fromParts(["isolated"]); + + const foo_loop = { + src: foo, + dst: loop, + address: EdgeAddress.fromParts(["foo", "1"]), + }; + const loop_foo = { + src: loop, + dst: foo, + address: EdgeAddress.fromParts(["foo", "2"]), + }; + const loop_loop = { + src: loop, + dst: loop, + address: EdgeAddress.fromParts(["loop"]), + }; + const repeated_loop_foo = { + src: loop, + dst: foo, + address: EdgeAddress.fromParts(["repeated", "foo"]), + }; + function quiver() { + return new Graph() + .addNode(foo) + .addNode(loop) + .addNode(isolated) + .addEdge(foo_loop) + .addEdge(loop_foo) + .addEdge(loop_loop) + .addEdge(repeated_loop_foo); + } + + function expectNeighbors( + node: NodeAddressT, + options: NeighborsOptions, + expected: Neighbor[] + ) { + const g = quiver(); + const actual = Array.from(g.neighbors(node, options)); + const sorter = (arr) => + sortBy(arr, (neighbor) => neighbor.edge.address); + expect(sorter(actual)).toEqual(sorter(expected)); + } + + it("re-adding a node does not suppress its edges", () => { + const graph = quiver().addNode(foo); + expect( + Array.from( + graph.neighbors(foo, { + direction: Direction.ANY, + nodePrefix: NodeAddress.fromParts([]), + edgePrefix: EdgeAddress.fromParts([]), + }) + ) + ).not.toHaveLength(0); + }); + + it("isolated node has no neighbors", () => { + expectNeighbors( + isolated, + { + direction: Direction.ANY, + nodePrefix: NodeAddress.fromParts([]), + edgePrefix: EdgeAddress.fromParts([]), + }, + [] + ); + }); + + function expectLoopNeighbors(dir, nodeParts, edgeParts, expected) { + const options = { + direction: dir, + nodePrefix: NodeAddress.fromParts(nodeParts), + edgePrefix: EdgeAddress.fromParts(edgeParts), + }; + expectNeighbors(loop, options, expected); + } + + describe("direction filtering", () => { + it("IN", () => { + expectLoopNeighbors( + Direction.IN, + [], + [], + [{node: loop, edge: loop_loop}, {node: foo, edge: foo_loop}] + ); + }); + it("OUT", () => { + expectLoopNeighbors( + Direction.OUT, + [], + [], + [ + {node: loop, edge: loop_loop}, + {node: foo, edge: repeated_loop_foo}, + {node: foo, edge: loop_foo}, + ] + ); + }); + // verifies that the loop edge is not double-counted. + it("ANY", () => { + expectLoopNeighbors( + Direction.ANY, + [], + [], + [ + {node: loop, edge: loop_loop}, + {node: foo, edge: repeated_loop_foo}, + {node: foo, edge: loop_foo}, + {node: foo, edge: foo_loop}, + ] + ); + }); + }); + + describe("node prefix filtering", () => { + function nodeExpectNeighbors(parts, expected) { + expectNeighbors( + loop, + { + direction: Direction.ANY, + nodePrefix: NodeAddress.fromParts(parts), + edgePrefix: EdgeAddress.fromParts([]), + }, + expected + ); + } + it("returns nodes exactly matching prefix", () => { + nodeExpectNeighbors(["loop"], [{node: loop, edge: loop_loop}]); + }); + it("returns nodes inexactly matching prefix", () => { + nodeExpectNeighbors( + ["foo"], + [ + {node: foo, edge: loop_foo}, + {node: foo, edge: foo_loop}, + {node: foo, edge: repeated_loop_foo}, + ] + ); + }); + it("returns empty for non-existent prefix", () => { + nodeExpectNeighbors(["qux"], []); + }); + }); + + describe("edge prefix filtering", () => { + function edgeExpectNeighbors(parts, expected) { + expectNeighbors( + loop, + { + direction: Direction.ANY, + nodePrefix: NodeAddress.fromParts([]), + edgePrefix: EdgeAddress.fromParts(parts), + }, + expected + ); + } + it("works for an exact address match", () => { + edgeExpectNeighbors( + ["repeated", "foo"], + [{node: foo, edge: repeated_loop_foo}] + ); + }); + it("works for a proper prefix match", () => { + edgeExpectNeighbors( + ["foo"], + [{node: foo, edge: foo_loop}, {node: foo, edge: loop_foo}] + ); + }); + it("works when there are no matching edges", () => { + edgeExpectNeighbors(["wat"], []); + }); + }); + + it("works for node and edge filter combined", () => { + expectNeighbors( + loop, + { + direction: Direction.ANY, + nodePrefix: NodeAddress.fromParts(["foo"]), + edgePrefix: EdgeAddress.fromParts(["repeated"]), + }, + [{node: foo, edge: repeated_loop_foo}] + ); + }); + + describe("errors on", () => { + const defaultOptions = () => ({ + direction: Direction.ANY, + edgePrefix: EdgeAddress.fromParts([]), + nodePrefix: NodeAddress.fromParts([]), + }); + function throwsWith(node, options, message) { + // $ExpectFlowError + expect(() => new Graph().neighbors(node, options)).toThrow(message); + } + it("invalid address", () => { + // This is a proxy for testing that NodeAddress.assertValid is called. + // Thus we don't need to exhaustively test every bad case. + throwsWith( + EdgeAddress.fromParts([]), + defaultOptions(), + "NodeAddress" + ); + }); + it("absent node", () => { + throwsWith( + NodeAddress.fromParts([]), + defaultOptions(), + "Node does not exist" + ); + }); + describe("concurrent modification", () => { + it("while in the middle of iteration", () => { + const g = quiver(); + const iterator = g.neighbors(loop, defaultOptions()); + g._modificationCount++; + expect(() => iterator.next()).toThrow("Concurrent modification"); + }); + it("at exhaustion", () => { + const g = quiver(); + const iterator = g.neighbors(isolated, defaultOptions()); + g._modificationCount++; + expect(() => iterator.next()).toThrow("Concurrent modification"); + }); + }); + }); + }); }); describe("edgeToString", () => {