diff --git a/src/backend/graph.js b/src/backend/graph.js index 1e80937..df55853 100644 --- a/src/backend/graph.js +++ b/src/backend/graph.js @@ -1,12 +1,8 @@ // @flow import deepEqual from "lodash.isequal"; - -export type Address = {| - +repositoryName: string, - +pluginName: string, - +id: string, -|}; +import type {Address, Addressable} from "./address"; +import {AddressMap} from "./address"; export type Node = {| +address: Address, @@ -21,28 +17,26 @@ export type Edge = {| |}; export class Graph { - _nodes: {[nodeAddress: string]: Node}; - _edges: {[edgeAddress: string]: Edge}; + _nodes: AddressMap>; + _edges: AddressMap>; // The keyset of each of the following fields should equal the keyset // of `_nodes`. If `e` is an edge from `u` to `v`, then `e.address` // should appear exactly once in `_outEdges[u.address]` and exactly // once in `_inEdges[v.address]` (and every entry in `_inEdges` and // `_outEdges` should be of this form). - _outEdges: {[nodeAddress: string]: Address[]}; - _inEdges: {[nodeAddress: string]: Address[]}; + _outEdges: AddressMap<{|+address: Address, +edges: Address[]|}>; + _inEdges: AddressMap<{|+address: Address, +edges: Address[]|}>; constructor() { - this._nodes = {}; - this._edges = {}; - this._outEdges = {}; - this._inEdges = {}; + this._nodes = new AddressMap(); + this._edges = new AddressMap(); + this._outEdges = new AddressMap(); + this._inEdges = new AddressMap(); } equals(that: Graph): boolean { - return ( - deepEqual(this._nodes, that._nodes) && deepEqual(this._edges, that._edges) - ); + return this._nodes.equals(that._nodes) && this._edges.equals(that._edges); } addNode(node: Node) { @@ -54,10 +48,9 @@ export class Graph { `node at address ${JSON.stringify(node.address)} already exists` ); } - const addressString = addressToString(node.address); - this._nodes[addressString] = node; - this._outEdges[addressString] = []; - this._inEdges[addressString] = []; + this._nodes.add(node); + this._outEdges.add({address: node.address, edges: []}); + this._inEdges.add({address: node.address, edges: []}); return this; } @@ -76,18 +69,18 @@ export class Graph { if (this.getNode(edge.dst) === undefined) { throw new Error(`source ${JSON.stringify(edge.dst)} does not exist`); } - this._edges[addressToString(edge.address)] = edge; - this._outEdges[addressToString(edge.src)].push(edge.address); - this._inEdges[addressToString(edge.dst)].push(edge.address); + this._edges.add(edge); + this._outEdges.get(edge.src).edges.push(edge.address); + this._inEdges.get(edge.dst).edges.push(edge.address); return this; } getNode(address: Address): Node { - return this._nodes[addressToString(address)]; + return this._nodes.get(address); } getEdge(address: Address): Edge { - return this._edges[addressToString(address)]; + return this._edges.get(address); } /** @@ -98,11 +91,11 @@ export class Graph { if (nodeAddress == null) { throw new Error(`address is ${String(nodeAddress)}`); } - const addresses = this._outEdges[addressToString(nodeAddress)]; - if (addresses === undefined) { + const result = this._outEdges.get(nodeAddress); + if (result === undefined) { throw new Error(`no node for address ${JSON.stringify(nodeAddress)}`); } - return addresses.map((e) => this.getEdge(e)); + return result.edges.map((e) => this.getEdge(e)); } /** @@ -113,25 +106,25 @@ export class Graph { if (nodeAddress == null) { throw new Error(`address is ${String(nodeAddress)}`); } - const addresses = this._inEdges[addressToString(nodeAddress)]; - if (addresses === undefined) { + const result = this._inEdges.get(nodeAddress); + if (result === undefined) { throw new Error(`no node for address ${JSON.stringify(nodeAddress)}`); } - return addresses.map((e) => this.getEdge(e)); + return result.edges.map((e) => this.getEdge(e)); } /** * Gets all nodes in the graph, in unspecified order. */ getAllNodes(): Node[] { - return Object.keys(this._nodes).map((k) => this._nodes[k]); + return this._nodes.getAll(); } /** * Gets all edges in the graph, in unspecified order. */ getAllEdges(): Edge[] { - return Object.keys(this._edges).map((k) => this._edges[k]); + return this._edges.getAll(); } /** @@ -185,7 +178,7 @@ export class Graph { * raise an error. */ static mergeConservative(g1: Graph, g2: Graph) { - function conservativeReducer( + function conservativeReducer( kinds: string /* used for an error message on mismatch */, a: T, b: T @@ -194,7 +187,7 @@ export class Graph { return a; } else { throw new Error( - `distinct ${kinds} with address ${addressToString(a.address)}` + `distinct ${kinds} with address ${JSON.stringify(a.address)}` ); } } @@ -206,38 +199,3 @@ export class Graph { ); } } - -export function addressToString(address: Address) { - if (address == null) { - throw new Error(`address is ${String(address)}`); - } - if (address.repositoryName.includes("$")) { - const escaped = JSON.stringify(address.repositoryName); - throw new Error(`address.repositoryName must not include "\$": ${escaped}`); - } - if (address.pluginName.includes("$")) { - const escaped = JSON.stringify(address.pluginName); - throw new Error(`address.pluginName must not include "\$": ${escaped}`); - } - if (address.id.includes("$")) { - const escaped = JSON.stringify(address.id); - throw new Error(`address.id must not include "\$": ${escaped}`); - } - return `${address.repositoryName}\$${address.pluginName}\$${address.id}`; -} - -export function stringToAddress(string: string) { - if (string == null) { - throw new Error(`address string is ${String(string)}`); - } - const parts = string.split("$"); - if (parts.length !== 3) { - const escaped = JSON.stringify(string); - throw new Error(`Input should have exactly two \$s: ${escaped}`); - } - return { - repositoryName: parts[0], - pluginName: parts[1], - id: parts[2], - }; -} diff --git a/src/backend/graph.test.js b/src/backend/graph.test.js index 022fd5e..eb1571f 100644 --- a/src/backend/graph.test.js +++ b/src/backend/graph.test.js @@ -1,23 +1,15 @@ // @flow -import type {Address} from "./graph"; -import {Graph, addressToString, stringToAddress} from "./graph"; +import type {Address, Addressable} from "./address"; +import {sortedByAddress} from "./address"; +import {Graph} from "./graph"; describe("graph", () => { describe("#Graph", () => { // Some Graph functions return a set of results represented as an - // array with undefined order. We use these functions to - // canonicalize the ordering so that we can then test equality with - // `expect(...).toEqual(...)`. - function sortedByAddress(xs: T[]) { - function cmp(x1: T, x2: T) { - const a1 = addressToString(x1.address); - const a2 = addressToString(x2.address); - return a1 > a2 ? 1 : a1 < a2 ? -1 : 0; - } - return [...xs].sort(cmp); - } - function expectSameSorted(xs: T[], ys: T[]) { + // array with undefined order. We canonicalize the ordering so that + // we can then test equality with `expect(...).toEqual(...)`. + function expectSameSorted(xs: T[], ys: T[]) { expect(sortedByAddress(xs)).toEqual(sortedByAddress(ys)); } @@ -564,97 +556,4 @@ describe("graph", () => { }); }); }); - - describe("string functions", () => { - describe("addressToString", () => { - const makeSimpleAddress = () => ({ - repositoryName: "megacorp/megawidget", - pluginName: "widgets", - id: "issue#123", - }); - it("stringifies a simple Address", () => { - const input = makeSimpleAddress(); - const expected = "megacorp/megawidget$widgets$issue#123"; - expect(addressToString(input)).toEqual(expected); - }); - function expectRejection(attribute, value) { - const input = {...makeSimpleAddress(), [attribute]: value}; - expect(() => addressToString(input)).toThrow(RegExp(attribute)); - // (escaping regexp in JavaScript is a nightmare; ignore it) - } - it("rejects an Address with $-signs in plugin name", () => { - expectRejection("pluginName", "widg$ets"); - }); - it("rejects an Address with $-signs in repository name", () => { - expectRejection("repositoryName", "megacorp$megawidget"); - }); - it("rejects an Address with $-signs in id", () => { - expectRejection("id", "issue$123"); - }); - it("rejects a null address", () => { - expect(() => addressToString((null: any))).toThrow("address is null"); - }); - it("rejects a undefined address", () => { - expect(() => addressToString((undefined: any))).toThrow( - "address is undefined" - ); - }); - }); - - describe("stringToAddress", () => { - it("parses a simple Address-string", () => { - const input = "megacorp/megawidget$widgets$issue#123"; - const expected = { - repositoryName: "megacorp/megawidget", - pluginName: "widgets", - id: "issue#123", - }; - expect(stringToAddress(input)).toEqual(expected); - }); - [0, 1, 3, 4].forEach((n) => { - it(`rejects an Address-string with ${n} occurrences of "\$"`, () => { - const dollars = Array(n + 1).join("$"); - const input = `mega${dollars}corp`; - expect(() => stringToAddress(input)).toThrow(/exactly two \$s/); - }); - }); - it("rejects a null address string", () => { - expect(() => stringToAddress((null: any))).toThrow( - "address string is null" - ); - }); - it("rejects a undefined address string", () => { - expect(() => stringToAddress((undefined: any))).toThrow( - "address string is undefined" - ); - }); - }); - - describe("stringToAddress and addressToString interop", () => { - const examples = () => [ - { - object: { - repositoryName: "megacorp/megawidget", - pluginName: "widgets", - id: "issue#123", - }, - string: "megacorp/megawidget$widgets$issue#123", - }, - ]; - examples().forEach((example, index) => { - describe(`for example at 0-index ${index}`, () => { - it("has stringToAddress a left identity for addressToString", () => { - expect(stringToAddress(addressToString(example.object))).toEqual( - example.object - ); - }); - it("has stringToAddress a right identity for addressToString", () => { - expect(addressToString(stringToAddress(example.string))).toEqual( - example.string - ); - }); - }); - }); - }); - }); });