Setup `src/core2` as graph refactor staging area (#304)
In #190, @wchargin lays out an ambitious proposal for refactoring away the graph's address-payload distinction. This has proven to be a complicated refactor to land, so we are going to achieve it by forking parts of the project into v2, updating incrementally in v2, and then replacing original components with their v2 versions. Test plan: No new code added. `yarn travis` passes. Paired with @wchargin
This commit is contained in:
parent
c68b78f959
commit
f22cf04e75
|
@ -0,0 +1,49 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`address AddressMap stringifies to JSON 1`] = `
|
||||
Array [
|
||||
Object {
|
||||
"type": "sourcecred/sourcecred/AddressMap",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
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`] = `
|
||||
Array [
|
||||
Array [
|
||||
Object {
|
||||
"id": "mansion",
|
||||
"pluginName": "houseville",
|
||||
"type": "HOME",
|
||||
},
|
||||
"{\\"id\\":\\"mansion\\",\\"pluginName\\":\\"houseville\\",\\"type\\":\\"HOME\\"}",
|
||||
],
|
||||
Array [
|
||||
Object {
|
||||
"id": "mansion",
|
||||
"pluginName": "houseville",
|
||||
"type": "HOME",
|
||||
},
|
||||
"{\\"id\\":\\"mansion\\",\\"pluginName\\":\\"houseville\\",\\"type\\":\\"HOME\\"}",
|
||||
],
|
||||
Array [
|
||||
Object {
|
||||
"id": "mattressStore",
|
||||
"pluginName": "houseville",
|
||||
"type": "BUSINESS",
|
||||
},
|
||||
"{\\"id\\":\\"mattressStore\\",\\"pluginName\\":\\"houseville\\",\\"type\\":\\"BUSINESS\\"}",
|
||||
],
|
||||
]
|
||||
`;
|
|
@ -0,0 +1,195 @@
|
|||
// @flow
|
||||
|
||||
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,
|
||||
+type: string,
|
||||
|};
|
||||
|
||||
export interface Addressable {
|
||||
+address: Address;
|
||||
}
|
||||
|
||||
export type SansAddress<T: Addressable> = $Exact<$Diff<T, {+address: Address}>>;
|
||||
|
||||
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
|
||||
* addresses.
|
||||
*/
|
||||
export class AddressMap<T: Addressable> {
|
||||
/*
|
||||
* Nested structure for fast access.
|
||||
*
|
||||
* It is an representation invariant that there are no empty objects
|
||||
* in this structure (except possibly the top-level object). In
|
||||
* particular, if `_data[somePluginName]` is not `undefined`, then it
|
||||
* is a non-empty object. This is required so that two `AddressMap`s
|
||||
* are logically equal exactly if their `_data` fields are deep-equal.
|
||||
*
|
||||
* This nested structure is significantly more performant than a
|
||||
* simpler version using a flat object keyed by `stringify(address)`.
|
||||
* For basic performance tests, see:
|
||||
* https://jsperf.com/address-string-302039074
|
||||
*/
|
||||
_data: {[pluginName: string]: {[type: string]: {[id: string]: T}}};
|
||||
|
||||
/**
|
||||
* Create an empty `AddressMap`.
|
||||
*/
|
||||
constructor() {
|
||||
this._data = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether this map logically equals another map. Two maps are
|
||||
* logically equal if they contain the same keys and the values at
|
||||
* each key are deep-equal.
|
||||
*/
|
||||
equals(that: AddressMap<T>): boolean {
|
||||
return deepEqual(this._data, that._data);
|
||||
}
|
||||
|
||||
toJSON(): AddressMapJSON<T> {
|
||||
const result = {};
|
||||
Object.keys(this._data).forEach((pluginName) => {
|
||||
const dataForPluginName = this._data[pluginName];
|
||||
Object.keys(dataForPluginName).forEach((type) => {
|
||||
const dataForType = dataForPluginName[type];
|
||||
Object.keys(dataForType).forEach((id) => {
|
||||
const address = {pluginName, id, type};
|
||||
const datum = {...dataForType[id]};
|
||||
delete datum.address;
|
||||
result[toString(address)] = datum;
|
||||
});
|
||||
});
|
||||
});
|
||||
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(decompat).forEach((key) => {
|
||||
result.add({...decompat[key], address: JSON.parse(key)});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the given object to the map, replacing any existing value for
|
||||
* the same address.
|
||||
*
|
||||
* Returns `this` for easy chaining.
|
||||
*/
|
||||
add(t: T): this {
|
||||
const {address} = t;
|
||||
if (address == null) {
|
||||
throw new Error(`address is ${String(t.address)}`);
|
||||
}
|
||||
let dataForPluginName = this._data[address.pluginName];
|
||||
if (dataForPluginName === undefined) {
|
||||
this._data[address.pluginName] = dataForPluginName = {};
|
||||
}
|
||||
let dataForType = dataForPluginName[address.type];
|
||||
if (dataForType === undefined) {
|
||||
dataForPluginName[address.type] = dataForType = {};
|
||||
}
|
||||
dataForType[address.id] = t;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the object at the given address, if it exists, or `undefined`
|
||||
* otherwise.
|
||||
*/
|
||||
get(address: Address): T {
|
||||
if (address == null) {
|
||||
throw new Error(`address is ${String(address)}`);
|
||||
}
|
||||
const dataForPluginName = this._data[address.pluginName];
|
||||
if (dataForPluginName === undefined) {
|
||||
return (undefined: any);
|
||||
}
|
||||
const dataForType = dataForPluginName[address.type];
|
||||
if (dataForType === undefined) {
|
||||
return (undefined: any);
|
||||
}
|
||||
return dataForType[address.id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all objects stored in the map, in some unspecified order.
|
||||
*/
|
||||
getAll(): T[] {
|
||||
const result = [];
|
||||
Object.keys(this._data).forEach((pluginName) => {
|
||||
const dataForPluginName = this._data[pluginName];
|
||||
Object.keys(dataForPluginName).forEach((type) => {
|
||||
const dataForType = dataForPluginName[type];
|
||||
Object.keys(dataForType).forEach((id) => {
|
||||
result.push(dataForType[id]);
|
||||
});
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any object with the given address. If none exists, this
|
||||
* method does nothing.
|
||||
*/
|
||||
remove(address: Address): this {
|
||||
if (address == null) {
|
||||
throw new Error(`address is ${String(address)}`);
|
||||
}
|
||||
const dataForPluginName = this._data[address.pluginName];
|
||||
if (dataForPluginName === undefined) {
|
||||
return this;
|
||||
}
|
||||
const dataForType = dataForPluginName[address.type];
|
||||
if (dataForType === undefined) {
|
||||
return this;
|
||||
}
|
||||
delete dataForType[address.id];
|
||||
if (Object.keys(dataForType).length === 0) {
|
||||
delete dataForPluginName[address.type];
|
||||
if (Object.keys(dataForPluginName).length === 0) {
|
||||
delete this._data[address.pluginName];
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export function toString(x: Address): string {
|
||||
return stringify(x);
|
||||
}
|
||||
|
||||
export function fromString(s: string): Address {
|
||||
return JSON.parse(s);
|
||||
}
|
|
@ -0,0 +1,182 @@
|
|||
// @flow
|
||||
|
||||
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,
|
||||
COMPAT_TYPE,
|
||||
COMPAT_VERSION,
|
||||
} from "./address";
|
||||
|
||||
describe("address", () => {
|
||||
// Some test data using objects that have addresses, like houses.
|
||||
type House = {|
|
||||
+address: Address,
|
||||
+beds: number,
|
||||
+baths: number,
|
||||
|};
|
||||
function makeAddress(type: "HOME" | "BUSINESS", id: string): Address {
|
||||
return {
|
||||
pluginName: "houseville",
|
||||
id,
|
||||
type,
|
||||
};
|
||||
}
|
||||
const mansion = (): House => ({
|
||||
address: makeAddress("HOME", "mansion"),
|
||||
beds: 10,
|
||||
baths: 5,
|
||||
});
|
||||
const fakeMansion = (): House => ({
|
||||
// Same address, different content.
|
||||
address: makeAddress("HOME", "mansion"),
|
||||
beds: 33,
|
||||
baths: 88,
|
||||
});
|
||||
const mattressStore = (): House => ({
|
||||
address: makeAddress("BUSINESS", "mattressStore"),
|
||||
beds: 99,
|
||||
baths: 1,
|
||||
});
|
||||
|
||||
describe("AddressMap", () => {
|
||||
const makeMap = (): AddressMap<House> =>
|
||||
new AddressMap().add(mansion()).add(mattressStore());
|
||||
|
||||
it("creates a simple map", () => {
|
||||
makeMap();
|
||||
});
|
||||
|
||||
it("gets objects by key", () => {
|
||||
expect(makeMap().get(mansion().address)).toEqual(mansion());
|
||||
expect(makeMap().get(mattressStore().address)).toEqual(mattressStore());
|
||||
});
|
||||
|
||||
it("gets all objects, in some order", () => {
|
||||
const actual = makeMap().getAll();
|
||||
const expected = [mansion(), mattressStore()];
|
||||
const sort = (xs) => sortBy(xs, (x) => stringify(x.address));
|
||||
expect(sort(actual)).toEqual(sort(expected));
|
||||
});
|
||||
|
||||
it("removes objects by key", () => {
|
||||
expect(
|
||||
makeMap()
|
||||
.remove(mansion().address)
|
||||
.get(mansion().address)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stringifies to JSON", () => {
|
||||
expect(makeMap().toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("stringifies elements sans addresses", () => {
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("rehydrates elements with addresses", () => {
|
||||
const newMap: AddressMap<House> = AddressMap.fromJSON(makeMap().toJSON());
|
||||
newMap.getAll().forEach((house) => {
|
||||
expect(Object.keys(house).sort()).toEqual(["address", "baths", "beds"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves equality over a JSON roundtrip", () => {
|
||||
const result = AddressMap.fromJSON(makeMap().toJSON());
|
||||
expect(result.equals(makeMap())).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes reference equality", () => {
|
||||
const x = makeMap();
|
||||
expect(x.equals(x)).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes deep equality", () => {
|
||||
expect(makeMap().equals(makeMap())).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes equality invariant of construction order", () => {
|
||||
const m1 = new AddressMap().add(mansion()).add(mattressStore());
|
||||
const m2 = new AddressMap().add(mattressStore()).add(mansion());
|
||||
expect(m1.equals(m2)).toBe(true);
|
||||
expect(m2.equals(m1)).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes disequality when element lists differ", () => {
|
||||
expect(makeMap().equals(new AddressMap())).toBe(false);
|
||||
expect(new AddressMap().equals(makeMap())).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes disequality when contents differ", () => {
|
||||
const m1 = new AddressMap().add(mattressStore()).add(mansion());
|
||||
const m2 = new AddressMap().add(mattressStore()).add(fakeMansion());
|
||||
expect(m1.equals(m2)).toBe(false);
|
||||
expect(m2.equals(m1)).toBe(false);
|
||||
});
|
||||
|
||||
describe("has nice error messages", () => {
|
||||
[null, undefined].forEach((bad) => {
|
||||
it(`when getting ${String(bad)} elements`, () => {
|
||||
const message = `address is ${String(bad)}`;
|
||||
expect(() => makeMap().get((bad: any))).toThrow(message);
|
||||
});
|
||||
it(`when removing ${String(bad)} elements`, () => {
|
||||
const message = `address is ${String(bad)}`;
|
||||
expect(() => makeMap().remove((bad: any))).toThrow(message);
|
||||
});
|
||||
it(`when adding elements with ${String(bad)} address`, () => {
|
||||
const message = `address is ${String(bad)}`;
|
||||
const element = {
|
||||
address: (bad: any),
|
||||
beds: 23,
|
||||
baths: 45,
|
||||
};
|
||||
expect(() => makeMap().add(element)).toThrow(message);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toString and fromString", () => {
|
||||
const examples = () => [mansion(), fakeMansion(), mattressStore()];
|
||||
it("simple round trips work", () => {
|
||||
examples().forEach((x) => {
|
||||
expect(x.address).toEqual(fromString(toString(x.address)));
|
||||
});
|
||||
});
|
||||
it("serialization looks good in snapshot review", () => {
|
||||
const serialized = examples().map((x) => [
|
||||
x.address,
|
||||
toString(x.address),
|
||||
]);
|
||||
expect(serialized).toMatchSnapshot();
|
||||
});
|
||||
it("Order of insertion does not matter", () => {
|
||||
const a1 = {
|
||||
pluginName: "foo",
|
||||
type: "bar",
|
||||
id: "zoombat",
|
||||
};
|
||||
const a2 = {
|
||||
id: "zoombat",
|
||||
type: "bar",
|
||||
pluginName: "foo",
|
||||
};
|
||||
expect(toString(a1)).toEqual(toString(a2));
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue