Parameterize `Graph` over node and edge payloads (#83)

Summary:
Closes #82. This affords clients type-safety without needing to
verbosely annotate every node or edge passed into the graph functions.
It also enables graph algorithms to be more expressive in their types:
for instance, the merge function now clearly indicates from its type
that the first graph’s nodes are passed as the first argument to the
node reducer, and the second graph’s nodes to the second. Clients can
upgrade immediately by using `Graph<*, *>`.

Thankfully, Flow supports variance well enough for this all to be
possible without too much trouble.

Test Plan:
Existing unit tests pass statically and at runtime. I added a test case
to demonstrate that merging works covariantly.

To see some failures, change `string` to an incompatible type, like
`number`, in the definitions of `makeGraph` in test functions for
conservatively rejecting graphs with conflicting nodes/edges
(ll. 446, 462).

wchargin-branch: parameterize-graph
This commit is contained in:
William Chargin 2018-03-17 11:36:53 -07:00 committed by GitHub
parent 894d6a2291
commit 1083540d21
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 84 additions and 35 deletions

View File

@ -4,26 +4,26 @@ import deepEqual from "lodash.isequal";
import type {Address, Addressable, AddressMapJSON} from "./address"; import type {Address, Addressable, AddressMapJSON} from "./address";
import {AddressMap} from "./address"; import {AddressMap} from "./address";
export type Node<T> = {| export type Node<+T> = {|
+address: Address, +address: Address,
+payload: T, +payload: T,
|}; |};
export type Edge<T> = {| export type Edge<+T> = {|
+address: Address, +address: Address,
+src: Address, +src: Address,
+dst: Address, +dst: Address,
+payload: T, +payload: T,
|}; |};
export type GraphJSON = {| export type GraphJSON<NP, EP> = {|
+nodes: AddressMapJSON<Node<any>>, +nodes: AddressMapJSON<Node<NP>>,
+edges: AddressMapJSON<Edge<any>>, +edges: AddressMapJSON<Edge<EP>>,
|}; |};
export class Graph { export class Graph<NP, EP> {
_nodes: AddressMap<Node<any>>; _nodes: AddressMap<Node<NP>>;
_edges: AddressMap<Edge<any>>; _edges: AddressMap<Edge<EP>>;
// The keyset of each of the following fields should equal the keyset // 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` // of `_nodes`. If `e` is an edge from `u` to `v`, then `e.address`
@ -40,18 +40,18 @@ export class Graph {
this._inEdges = new AddressMap(); this._inEdges = new AddressMap();
} }
equals(that: Graph): boolean { equals(that: Graph<NP, EP>): boolean {
return this._nodes.equals(that._nodes) && this._edges.equals(that._edges); return this._nodes.equals(that._nodes) && this._edges.equals(that._edges);
} }
toJSON(): GraphJSON { toJSON(): GraphJSON<NP, EP> {
return { return {
nodes: this._nodes.toJSON(), nodes: this._nodes.toJSON(),
edges: this._edges.toJSON(), edges: this._edges.toJSON(),
}; };
} }
static fromJSON(json: GraphJSON): Graph { static fromJSON<NP, EP>(json: GraphJSON<NP, EP>): Graph<NP, EP> {
const result = new Graph(); const result = new Graph();
AddressMap.fromJSON(json.nodes) AddressMap.fromJSON(json.nodes)
.getAll() .getAll()
@ -66,7 +66,7 @@ export class Graph {
return result; return result;
} }
addNode(node: Node<any>) { addNode(node: Node<NP>): Graph<NP, EP> {
if (node == null) { if (node == null) {
throw new Error(`node is ${String(node)}`); throw new Error(`node is ${String(node)}`);
} }
@ -88,7 +88,7 @@ export class Graph {
return this; return this;
} }
addEdge(edge: Edge<any>) { addEdge(edge: Edge<EP>): Graph<NP, EP> {
if (edge == null) { if (edge == null) {
throw new Error(`edge is ${String(edge)}`); throw new Error(`edge is ${String(edge)}`);
} }
@ -116,11 +116,11 @@ export class Graph {
return this; return this;
} }
getNode(address: Address): Node<mixed> { getNode(address: Address): Node<NP> {
return this._nodes.get(address); return this._nodes.get(address);
} }
getEdge(address: Address): Edge<mixed> { getEdge(address: Address): Edge<EP> {
return this._edges.get(address); return this._edges.get(address);
} }
@ -128,7 +128,7 @@ export class Graph {
* Gets the array of all out-edges from the node at the given address. * Gets the array of all out-edges from the node at the given address.
* The order of the resulting array is unspecified. * The order of the resulting array is unspecified.
*/ */
getOutEdges(nodeAddress: Address): Edge<mixed>[] { getOutEdges(nodeAddress: Address): Edge<EP>[] {
if (nodeAddress == null) { if (nodeAddress == null) {
throw new Error(`address is ${String(nodeAddress)}`); throw new Error(`address is ${String(nodeAddress)}`);
} }
@ -143,7 +143,7 @@ export class Graph {
* Gets the array of all in-edges to the node at the given address. * Gets the array of all in-edges to the node at the given address.
* The order of the resulting array is unspecified. * The order of the resulting array is unspecified.
*/ */
getInEdges(nodeAddress: Address): Edge<mixed>[] { getInEdges(nodeAddress: Address): Edge<EP>[] {
if (nodeAddress == null) { if (nodeAddress == null) {
throw new Error(`address is ${String(nodeAddress)}`); throw new Error(`address is ${String(nodeAddress)}`);
} }
@ -157,14 +157,14 @@ export class Graph {
/** /**
* Gets all nodes in the graph, in unspecified order. * Gets all nodes in the graph, in unspecified order.
*/ */
getAllNodes(): Node<mixed>[] { getAllNodes(): Node<NP>[] {
return this._nodes.getAll(); return this._nodes.getAll();
} }
/** /**
* Gets all edges in the graph, in unspecified order. * Gets all edges in the graph, in unspecified order.
*/ */
getAllEdges(): Edge<mixed>[] { getAllEdges(): Edge<EP>[] {
return this._edges.getAll(); return this._edges.getAll();
} }
@ -176,13 +176,13 @@ export class Graph {
* *
* The existing graph objects are not modified. * The existing graph objects are not modified.
*/ */
static merge( static merge<NP, EP, N1: NP, E1: EP, N2: NP, E2: EP>(
g1: Graph, g1: Graph<N1, E1>,
g2: Graph, g2: Graph<N2, E2>,
nodeResolver: (Node<mixed>, Node<mixed>) => Node<mixed>, nodeResolver: (Node<N1>, Node<N2>) => Node<NP>,
edgeResolver: (Edge<mixed>, Edge<mixed>) => Edge<mixed> edgeResolver: (Edge<E1>, Edge<E2>) => Edge<EP>
) { ): Graph<NP, EP> {
const result = new Graph(); const result: Graph<NP, EP> = new Graph();
g1.getAllNodes().forEach((node) => { g1.getAllNodes().forEach((node) => {
if (g2.getNode(node.address) !== undefined) { if (g2.getNode(node.address) !== undefined) {
const resolved = nodeResolver(node, g2.getNode(node.address)); const resolved = nodeResolver(node, g2.getNode(node.address));
@ -218,8 +218,11 @@ export class Graph {
* for edges). If this assumption does not hold, this function will * for edges). If this assumption does not hold, this function will
* raise an error. * raise an error.
*/ */
static mergeConservative(g1: Graph, g2: Graph) { static mergeConservative<NP, EP, N1: NP, E1: EP, N2: NP, E2: EP>(
function conservativeReducer<T: Addressable>( g1: Graph<N1, E1>,
g2: Graph<N2, E2>
): Graph<NP, EP> {
function conservativeResolver<T: Addressable>(
kinds: string /* used for an error message on mismatch */, kinds: string /* used for an error message on mismatch */,
a: T, a: T,
b: T b: T
@ -232,11 +235,12 @@ export class Graph {
); );
} }
} }
return Graph.merge( const result: Graph<NP, EP> = Graph.merge(
g1, g1,
g2, g2,
(u, v) => conservativeReducer("nodes", u, v), (u, v) => conservativeResolver("nodes", u, v),
(e, f) => conservativeReducer("edges", e, f) (e, f) => conservativeResolver("edges", e, f)
); );
return result;
} }
} }

View File

@ -376,7 +376,9 @@ describe("graph", () => {
* node `u`, create a graph with just that node, its neighbors, * node `u`, create a graph with just that node, its neighbors,
* and its incident edges (in both directions). * and its incident edges (in both directions).
*/ */
function neighborhoodDecomposition(originalGraph: Graph): Graph[] { function neighborhoodDecomposition<NP, EP>(
originalGraph: Graph<NP, EP>
): Graph<NP, EP>[] {
return originalGraph.getAllNodes().map((node) => { return originalGraph.getAllNodes().map((node) => {
const miniGraph = new Graph(); const miniGraph = new Graph();
miniGraph.addNode(node); miniGraph.addNode(node);
@ -403,7 +405,9 @@ describe("graph", () => {
* Decompose the given graph into edge graphs: for each edge `e`, * Decompose the given graph into edge graphs: for each edge `e`,
* create a graph with just that edge and its two endpoints. * create a graph with just that edge and its two endpoints.
*/ */
function edgeDecomposition(originalGraph: Graph): Graph[] { function edgeDecomposition<NP, EP>(
originalGraph: Graph<NP, EP>
): Graph<NP, EP>[] {
return originalGraph.getAllEdges().map((edge) => { return originalGraph.getAllEdges().map((edge) => {
const miniGraph = new Graph(); const miniGraph = new Graph();
miniGraph.addNode(originalGraph.getNode(edge.src)); miniGraph.addNode(originalGraph.getNode(edge.src));
@ -439,8 +443,47 @@ describe("graph", () => {
expect(result.equals(demoData.advancedMealGraph())).toBe(true); expect(result.equals(demoData.advancedMealGraph())).toBe(true);
}); });
it("conservatively merges graphs of different payload types", () => {
const data = {
a: () => ({address: demoData.makeAddress("a"), payload: "alpha"}),
b: () => ({address: demoData.makeAddress("b"), payload: "bravo"}),
u: () => ({
address: demoData.makeAddress("u"),
src: demoData.makeAddress("a"),
dst: demoData.makeAddress("b"),
payload: 21,
}),
c: () => ({address: demoData.makeAddress("c"), payload: true}),
d: () => ({address: demoData.makeAddress("d"), payload: false}),
v: () => ({
address: demoData.makeAddress("v"),
src: demoData.makeAddress("c"),
dst: demoData.makeAddress("d"),
payload: null,
}),
};
const g1: Graph<string, number> = new Graph()
.addNode(data.a())
.addNode(data.b())
.addEdge(data.u());
const g2: Graph<boolean, null> = new Graph()
.addNode(data.c())
.addNode(data.d())
.addEdge(data.v());
type ResultGraph = Graph<string | boolean, number | null>;
const result: ResultGraph = Graph.mergeConservative(g1, g2);
const expected: ResultGraph = new Graph()
.addNode(data.a())
.addNode(data.b())
.addEdge(data.u())
.addNode(data.c())
.addNode(data.d())
.addEdge(data.v());
expect(result.equals(expected)).toBe(true);
});
it("conservatively rejects a graph with conflicting nodes", () => { it("conservatively rejects a graph with conflicting nodes", () => {
const makeGraph: (nodePayload: string) => Graph = (nodePayload) => const makeGraph: (nodePayload: string) => Graph<*, *> = (nodePayload) =>
new Graph().addNode({ new Graph().addNode({
address: demoData.makeAddress("conflicting-node"), address: demoData.makeAddress("conflicting-node"),
payload: nodePayload, payload: nodePayload,
@ -455,7 +498,7 @@ describe("graph", () => {
it("conservatively rejects a graph with conflicting edges", () => { it("conservatively rejects a graph with conflicting edges", () => {
const srcAddress = demoData.makeAddress("src"); const srcAddress = demoData.makeAddress("src");
const dstAddress = demoData.makeAddress("dst"); const dstAddress = demoData.makeAddress("dst");
const makeGraph: (edgePayload: string) => Graph = (edgePayload) => const makeGraph: (edgePayload: string) => Graph<*, *> = (edgePayload) =>
new Graph() new Graph()
.addNode({address: srcAddress, payload: {}}) .addNode({address: srcAddress, payload: {}})
.addNode({address: dstAddress, payload: {}}) .addNode({address: dstAddress, payload: {}})
@ -558,6 +601,7 @@ describe("graph", () => {
address: demoData.makeAddress("hello"), address: demoData.makeAddress("hello"),
payload: 17, payload: 17,
}; };
// This will be a Graph<string | number, *>.
new Graph().addNode(stringNode).addNode(numberNode); new Graph().addNode(stringNode).addNode(numberNode);
}); });
}); });
@ -578,6 +622,7 @@ describe("graph", () => {
dst: dst.address, dst: dst.address,
payload: 18, payload: 18,
}; };
// This will be a Graph<{}, string | number>.
new Graph() new Graph()
.addNode(src) .addNode(src)
.addNode(dst) .addNode(dst)