Use versioning for `Graph` and `AddressMap` (#293)

This commit adds explicit versioning to the `Graph` and `AddressMap`
JSON representations, using the new `compat` module. This will make it
safer to change the serialization format for these classes.

(Note: I don't expect we'll add backcompat handlers for these classes
soon, but having versioning means that we can change the serialization
format in a way that breaks old data cleanly and explicitly, rather than
introducing undefined behavior.)

Test plan: The changes are slight, and well-captured by the snapshot
tests. Note that after this commit, the SourceCred commands will fail on
old data, so old data will need to be regenerated.
This commit is contained in:
Dandelion Mané 2018-05-21 11:47:05 -07:00 committed by GitHub
parent 1fc860bd56
commit 5a40bb0a30
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 4189 additions and 3976 deletions

View File

@ -1,16 +1,22 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`address AddressMap stringifies to JSON 1`] = `
Object {
"{\\"id\\":\\"mansion\\",\\"pluginName\\":\\"houseville\\",\\"type\\":\\"HOME\\"}": Object {
"baths": 5,
"beds": 10,
Array [
Object {
"type": "sourcecred/sourcecred/AddressMap",
"version": "0.1.0",
},
"{\\"id\\":\\"mattressStore\\",\\"pluginName\\":\\"houseville\\",\\"type\\":\\"BUSINESS\\"}": Object {
"baths": 1,
"beds": 99,
Object {
"{\\"id\\":\\"mansion\\",\\"pluginName\\":\\"houseville\\",\\"type\\":\\"HOME\\"}": Object {
"baths": 5,
"beds": 10,
},
"{\\"id\\":\\"mattressStore\\",\\"pluginName\\":\\"houseville\\",\\"type\\":\\"BUSINESS\\"}": Object {
"baths": 1,
"beds": 99,
},
},
}
]
`;
exports[`address toString and fromString serialization looks good in snapshot review 1`] = `

View File

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

View File

@ -3,6 +3,9 @@
import deepEqual from "lodash.isequal";
import stringify from "json-stable-stringify";
import {toCompat, fromCompat} from "../util/compat";
import type {Compatible} from "../util/compat";
export type Address = {|
+pluginName: string,
+id: string,
@ -15,9 +18,12 @@ export interface Addressable {
export type SansAddress<T: Addressable> = $Exact<$Diff<T, {+address: Address}>>;
export type AddressMapJSON<T: Addressable> = {
export type AddressMapJSON<T: Addressable> = Compatible<{
[serializedAddress: string]: SansAddress<T>,
};
}>;
export const COMPAT_TYPE = "sourcecred/sourcecred/AddressMap";
export const COMPAT_VERSION = "0.1.0";
/**
* A data structure for storing addressable objects, keyed by their
@ -70,13 +76,26 @@ export class AddressMap<T: Addressable> {
});
});
});
return result;
return toCompat(
{
type: COMPAT_TYPE,
version: COMPAT_VERSION,
},
result
);
}
static fromJSON(json: AddressMapJSON<T>): AddressMap<T> {
const decompat = fromCompat(
{
type: COMPAT_TYPE,
version: COMPAT_VERSION,
},
json
);
const result: AddressMap<T> = new AddressMap();
Object.keys(json).forEach((key) => {
result.add({...json[key], address: JSON.parse(key)});
Object.keys(decompat).forEach((key) => {
result.add({...decompat[key], address: JSON.parse(key)});
});
return result;
}

View File

@ -3,8 +3,15 @@
import sortBy from "lodash.sortby";
import stringify from "json-stable-stringify";
import {fromCompat} from "../util/compat";
import type {Address} from "./address";
import {AddressMap, fromString, toString} from "./address";
import {
AddressMap,
fromString,
toString,
COMPAT_TYPE,
COMPAT_VERSION,
} from "./address";
describe("address", () => {
// Some test data using objects that have addresses, like houses.
@ -70,7 +77,11 @@ describe("address", () => {
});
it("stringifies elements sans addresses", () => {
const json = makeMap().toJSON();
const compatJson = makeMap().toJSON();
const json = fromCompat(
{type: COMPAT_TYPE, version: COMPAT_VERSION},
compatJson
);
Object.keys(json).forEach((k) => {
const value = json[k];
expect(Object.keys(value).sort()).toEqual(["baths", "beds"]);

View File

@ -4,6 +4,8 @@ import deepEqual from "lodash.isequal";
import stringify from "json-stable-stringify";
import type {Address, Addressable, AddressMapJSON} from "./address";
import {AddressMap} from "./address";
import {toCompat, fromCompat} from "../util/compat";
import type {Compatible} from "../util/compat";
export type Node<+T> = {|
+address: Address,
@ -17,10 +19,13 @@ export type Edge<+T> = {|
+payload: T,
|};
export type GraphJSON<NP, EP> = {|
const COMPAT_TYPE = "sourcecred/sourcecred/Graph";
const COMPAT_VERSION = "0.1.0";
export type GraphJSON<NP, EP> = Compatible<{|
+nodes: AddressMapJSON<Node<NP>>,
+edges: AddressMapJSON<Edge<EP>>,
|};
|}>;
export class Graph<NP, EP> {
_nodes: AddressMap<Node<NP>>;
@ -50,20 +55,30 @@ export class Graph<NP, EP> {
}
toJSON(): GraphJSON<NP, EP> {
return {
nodes: this._nodes.toJSON(),
edges: this._edges.toJSON(),
};
return toCompat(
{type: COMPAT_TYPE, version: COMPAT_VERSION},
{
nodes: this._nodes.toJSON(),
edges: this._edges.toJSON(),
}
);
}
static fromJSON<NP, EP>(json: GraphJSON<NP, EP>): Graph<NP, EP> {
const compatJson = fromCompat(
{
type: COMPAT_TYPE,
version: COMPAT_VERSION,
},
json
);
const result = new Graph();
AddressMap.fromJSON(json.nodes)
AddressMap.fromJSON(compatJson.nodes)
.getAll()
.forEach((node) => {
result.addNode(node);
});
AddressMap.fromJSON(json.edges)
AddressMap.fromJSON(compatJson.edges)
.getAll()
.forEach((edge) => {
result.addEdge(edge);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff