Make Graph serializable (#69)

Summary:
This commit adds `toJSON()` and `static fromJSON()` on `Graph`. The main
benefit at this time is that this gets us free interoperability with
Jest’s snapshot testing.

The implementation of `fromJSON` is not performance-tuned, and could
probably be significantly optimized.

See #65 for discussion.

Test Plan:
New unit tests added: `yarn flow && yarn test`.

wchargin-branch: make-graph-serializable
This commit is contained in:
William Chargin 2018-03-05 16:22:58 -08:00 committed by GitHub
parent cee90fd10f
commit 5960eab6c1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 205 additions and 1 deletions

View File

@ -0,0 +1,138 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`graph #Graph JSON functions should serialize a simple graph 1`] = `
Object {
"edges": Object {
"{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"crab-self-assessment\\"}": Object {
"dst": Object {
"id": "razorclaw_crab#2",
"pluginName": "hill_cooking_pot",
"repositoryName": "sourcecred/eventide",
},
"payload": Object {
"evaluation": "not effective at avoiding hero",
},
"src": Object {
"id": "razorclaw_crab#2",
"pluginName": "hill_cooking_pot",
"repositoryName": "sourcecred/eventide",
},
},
"{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"hero_of_time#0@again_cooks@seafood_fruit_mix#3\\"}": Object {
"dst": Object {
"id": "hero_of_time#0",
"pluginName": "hill_cooking_pot",
"repositoryName": "sourcecred/eventide",
},
"payload": Object {
"crit": true,
"saveScummed": true,
},
"src": Object {
"id": "seafood_fruit_mix#3",
"pluginName": "hill_cooking_pot",
"repositoryName": "sourcecred/eventide",
},
},
"{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"hero_of_time#0@cooks@seafood_fruit_mix#3\\"}": Object {
"dst": Object {
"id": "hero_of_time#0",
"pluginName": "hill_cooking_pot",
"repositoryName": "sourcecred/eventide",
},
"payload": Object {
"crit": false,
},
"src": Object {
"id": "seafood_fruit_mix#3",
"pluginName": "hill_cooking_pot",
"repositoryName": "sourcecred/eventide",
},
},
"{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"hero_of_time#0@eats@seafood_fruit_mix#3\\"}": Object {
"dst": Object {
"id": "seafood_fruit_mix#3",
"pluginName": "hill_cooking_pot",
"repositoryName": "sourcecred/eventide",
},
"payload": Object {},
"src": Object {
"id": "hero_of_time#0",
"pluginName": "hill_cooking_pot",
"repositoryName": "sourcecred/eventide",
},
},
"{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"hero_of_time#0@grabs@razorclaw_crab#2\\"}": Object {
"dst": Object {
"id": "hero_of_time#0",
"pluginName": "hill_cooking_pot",
"repositoryName": "sourcecred/eventide",
},
"payload": Object {},
"src": Object {
"id": "razorclaw_crab#2",
"pluginName": "hill_cooking_pot",
"repositoryName": "sourcecred/eventide",
},
},
"{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"hero_of_time#0@picks@mighty_bananas#1\\"}": Object {
"dst": Object {
"id": "hero_of_time#0",
"pluginName": "hill_cooking_pot",
"repositoryName": "sourcecred/eventide",
},
"payload": Object {},
"src": Object {
"id": "mighty_bananas#1",
"pluginName": "hill_cooking_pot",
"repositoryName": "sourcecred/eventide",
},
},
"{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"mighty_bananas#1@included_in@seafood_fruit_mix#3\\"}": Object {
"dst": Object {
"id": "mighty_bananas#1",
"pluginName": "hill_cooking_pot",
"repositoryName": "sourcecred/eventide",
},
"payload": Object {},
"src": Object {
"id": "seafood_fruit_mix#3",
"pluginName": "hill_cooking_pot",
"repositoryName": "sourcecred/eventide",
},
},
"{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"razorclaw_crab#2@included_in@seafood_fruit_mix#3\\"}": Object {
"dst": Object {
"id": "razorclaw_crab#2",
"pluginName": "hill_cooking_pot",
"repositoryName": "sourcecred/eventide",
},
"payload": Object {},
"src": Object {
"id": "seafood_fruit_mix#3",
"pluginName": "hill_cooking_pot",
"repositoryName": "sourcecred/eventide",
},
},
},
"nodes": Object {
"{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"hero_of_time#0\\"}": Object {
"payload": Object {},
},
"{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"mighty_bananas#1\\"}": Object {
"payload": Object {},
},
"{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"razorclaw_crab#2\\"}": Object {
"payload": Object {},
},
"{\\"repositoryName\\":\\"sourcecred/eventide\\",\\"pluginName\\":\\"hill_cooking_pot\\",\\"id\\":\\"seafood_fruit_mix#3\\"}": Object {
"payload": Object {
"effect": Array [
"attack_power",
1,
],
},
},
},
}
`;

View File

@ -1,7 +1,7 @@
// @flow // @flow
import deepEqual from "lodash.isequal"; import deepEqual from "lodash.isequal";
import type {Address, Addressable} 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> = {|
@ -16,6 +16,11 @@ export type Edge<T> = {|
+payload: T, +payload: T,
|}; |};
export type GraphJSON = {|
+nodes: AddressMapJSON<Node<mixed>>,
+edges: AddressMapJSON<Edge<mixed>>,
|};
export class Graph { export class Graph {
_nodes: AddressMap<Node<mixed>>; _nodes: AddressMap<Node<mixed>>;
_edges: AddressMap<Edge<mixed>>; _edges: AddressMap<Edge<mixed>>;
@ -39,6 +44,28 @@ export class Graph {
return this._nodes.equals(that._nodes) && this._edges.equals(that._edges); return this._nodes.equals(that._nodes) && this._edges.equals(that._edges);
} }
toJSON(): GraphJSON {
return {
nodes: this._nodes.toJSON(),
edges: this._edges.toJSON(),
};
}
static fromJSON(json: GraphJSON): Graph {
const result = new Graph();
AddressMap.fromJSON(json.nodes)
.getAll()
.forEach((node) => {
result.addNode(node);
});
AddressMap.fromJSON(json.edges)
.getAll()
.forEach((edge) => {
result.addEdge(edge);
});
return result;
}
addNode(node: Node<mixed>) { addNode(node: Node<mixed>) {
if (node == null) { if (node == null) {
throw new Error(`node is ${String(node)}`); throw new Error(`node is ${String(node)}`);

View File

@ -555,5 +555,44 @@ describe("graph", () => {
expect(merged.equals(new Graph())).toBe(true); expect(merged.equals(new Graph())).toBe(true);
}); });
}); });
describe("JSON functions", () => {
it("should serialize a simple graph", () => {
expect(advancedMealGraph().toJSON()).toMatchSnapshot();
});
it("should work transparently with JSON.stringify", () => {
// (This is guaranteed by the `JSON.stringify` API, and is more
// as documentation than actual test.)
expect(JSON.stringify(advancedMealGraph())).toEqual(
JSON.stringify(advancedMealGraph().toJSON())
);
});
it("should canonicalize away node insertion order", () => {
const g1 = new Graph().addNode(heroNode()).addNode(mealNode());
const g2 = new Graph().addNode(mealNode()).addNode(heroNode());
expect(g1.toJSON()).toEqual(g2.toJSON());
});
it("should canonicalize away edge insertion order", () => {
const g1 = new Graph()
.addNode(heroNode())
.addNode(mealNode())
.addEdge(cookEdge())
.addEdge(duplicateCookEdge());
const g2 = new Graph()
.addNode(heroNode())
.addNode(mealNode())
.addEdge(duplicateCookEdge())
.addEdge(cookEdge());
expect(g1.toJSON()).toEqual(g2.toJSON());
});
it("should no-op on a serialization--deserialization roundtrip", () => {
const g = () => advancedMealGraph();
expect(Graph.fromJSON(g().toJSON()).equals(g())).toBe(true);
});
it("should no-op on a deserialization--serialization roundtrip", () => {
const json = () => advancedMealGraph().toJSON();
expect(Graph.fromJSON(json()).toJSON()).toEqual(json());
});
});
}); });
}); });