Implement `Graph.toJSON` and `Graph.fromJSON` (#374)

The serialization scheme uses `IndexedEdge`s:

```js
type Integer = number;
type IndexedEdge = {|
  Address: EdgeAddressT,
  srcIndex: Integer,
  dstIndex: Integer,
|}
```

The nodes are first sorted. Then, we generate indexed edges from the
regular edges by replacing each node address with its index in the
sorted order. This encoding reduces the number of addresses serialized
from `n + 3e` to `n + e` (where `n` is the number of nodes and `e` is
the number of edges).

This is based on work in #295, but in contrast to that PR, we do not
index the in-memory representations of graphs. Only the JSON
representation is indexed.

Test plan:
Unit tests added. A snapshot test is also included, both to make it easy
to inspect an example of a JSON-serialized graph, and to ensure
backwards-compatibility. (The snapshot likely should not change
independent of the VERSION string.)
This commit is contained in:
Dandelion Mané 2018-06-11 11:57:29 -07:00 committed by GitHub
parent 6177f6c740
commit 5fc0d42c1f
3 changed files with 172 additions and 4 deletions

View File

@ -0,0 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`core/graph toJSON / fromJSON toJSON matches snapshot 1`] = `
Array [
Object {
"type": "sourcecred/graph",
"version": "0.4.0",
},
Object {
"edges": Array [
Object {
"address": Array [
"edge",
],
"dstIndex": 0,
"srcIndex": 1,
},
Object {
"address": Array [
"edge",
"2",
],
"dstIndex": 1,
"srcIndex": 0,
},
],
"nodes": Array [
Array [
"dst",
],
Array [
"src",
],
],
},
]
`;

View File

@ -1,9 +1,11 @@
// @flow
import deepEqual from "lodash.isequal";
import sortBy from "lodash.sortby";
import {makeAddressModule, type AddressModule} from "./address";
import {toCompat, fromCompat, type Compatible} from "../util/compat";
export opaque type NodeAddressT: string = string;
export opaque type EdgeAddressT: string = string;
export const NodeAddress: AddressModule<NodeAddressT> = (makeAddressModule({
@ -23,6 +25,8 @@ export type Edge = {|
+dst: NodeAddressT,
|};
const COMPAT_INFO = {type: "sourcecred/graph", version: "0.4.0"};
export type Neighbor = {|+node: NodeAddressT, +edge: Edge|};
export opaque type DirectionT = Symbol;
@ -42,7 +46,18 @@ export type NeighborsOptions = {|
+edgePrefix: EdgeAddressT,
|};
export opaque type GraphJSON = any; // TODO
type AddressJSON = string[]; // Result of calling {Node,Edge}Address.toParts
type Integer = number;
type IndexedEdgeJSON = {|
+address: AddressJSON,
+srcIndex: Integer,
+dstIndex: Integer,
|};
export opaque type GraphJSON = Compatible<{|
+nodes: AddressJSON[],
+edges: IndexedEdgeJSON[],
|}>;
type ModificationCount = number;
@ -316,12 +331,38 @@ export class Graph {
}
toJSON(): GraphJSON {
throw new Error("toJSON");
const sortedNodes = Array.from(this.nodes()).sort();
const nodeToSortedIndex = new Map();
sortedNodes.forEach((node, i) => {
nodeToSortedIndex.set(node, i);
});
const sortedEdges = sortBy(Array.from(this.edges()), (x) => x.address);
const indexedEdges = sortedEdges.map(({src, dst, address}) => {
const srcIndex = nodeToSortedIndex.get(src);
const dstIndex = nodeToSortedIndex.get(dst);
if (srcIndex == null || dstIndex == null) {
throw new Error(`Invariant violation`);
}
return {srcIndex, dstIndex, address: EdgeAddress.toParts(address)};
});
const rawJSON = {
nodes: sortedNodes.map((x) => NodeAddress.toParts(x)),
edges: indexedEdges,
};
return toCompat(COMPAT_INFO, rawJSON);
}
static fromJSON(json: GraphJSON): Graph {
const _ = json;
throw new Error("fromJSON");
const {nodes: nodesJSON, edges} = fromCompat(COMPAT_INFO, json);
const result = new Graph();
const nodes = nodesJSON.map((x) => NodeAddress.fromParts(x));
nodes.forEach((n) => result.addNode(n));
edges.forEach(({address, srcIndex, dstIndex}) => {
const src = nodes[srcIndex];
const dst = nodes[dstIndex];
result.addEdge({address: EdgeAddress.fromParts(address), src, dst});
});
return result;
}
static merge(graphs: Iterable<Graph>): Graph {

View File

@ -947,6 +947,96 @@ describe("core/graph", () => {
});
});
describe("toJSON / fromJSON", () => {
const src = NodeAddress.fromParts(["src"]);
const dst = NodeAddress.fromParts(["dst"]);
const edge1 = () => ({
src,
dst,
address: EdgeAddress.fromParts(["edge"]),
});
const edge2 = () => ({
src: dst,
dst: src,
address: EdgeAddress.fromParts(["edge", "2"]),
});
it("toJSON matches snapshot", () => {
const graph = new Graph()
.addNode(src)
.addNode(dst)
.addEdge(edge1())
.addEdge(edge2());
expect(graph.toJSON()).toMatchSnapshot();
});
describe("compose to identity", () => {
function expectCompose(g) {
const json = g.toJSON();
const newGraph = Graph.fromJSON(json);
const newJSON = newGraph.toJSON();
expect(newGraph.equals(g)).toBe(true);
expect(newJSON).toEqual(json);
}
it("for an empty graph", () => {
expectCompose(new Graph());
});
it("for a graph with some nodes", () => {
const g = new Graph().addNode(src).addNode(dst);
expectCompose(g);
});
it("for a graph with nodes added and removed", () => {
const g = new Graph()
.addNode(src)
.addNode(dst)
.removeNode(src);
expectCompose(g);
});
it("for a graph with nodes and edges", () => {
const g = new Graph()
.addNode(src)
.addNode(dst)
.addEdge(edge1())
.addEdge(edge2());
expectCompose(g);
});
});
describe("toJSON representation is canonical", () => {
function expectCanonicity(g1, g2) {
expect(g1.toJSON()).toEqual(g2.toJSON());
}
it("for an empty graph", () => {
expectCanonicity(new Graph(), new Graph());
});
it("for graph with nodes added in different order", () => {
const g1 = new Graph().addNode(src).addNode(dst);
const g2 = new Graph().addNode(dst).addNode(src);
expectCanonicity(g1, g2);
});
it("for a graph with nodes added and removed", () => {
const g1 = new Graph()
.addNode(src)
.addNode(dst)
.removeNode(src);
const g2 = new Graph().addNode(dst);
expectCanonicity(g1, g2);
});
it("for a graph with edges added and removed", () => {
const g1 = new Graph()
.addNode(src)
.addNode(dst)
.addEdge(edge1())
.removeEdge(edge1().address)
.addEdge(edge2());
const g2 = new Graph()
.addNode(src)
.addNode(dst)
.addEdge(edge2());
expectCanonicity(g1, g2);
});
});
});
describe("edgeToString", () => {
it("works", () => {
const edge = {