Create an AddressMap abstraction (#67)

Summary:
This commit reifies the concept of an `Addressable`, which is any object
that has a covariant `address: Address` attribute, and implements a
simple data structure for storing addressable items keyed against their
addresses. Instances of `AddressMap` can replace the four fields of
`Graph`:
```js
_nodes: AddressMap<Node<mixed>>;
_edges: AddressMap<Edge<mixed>>;
_outEdges: AddressMap<{|+address: Address, +edges: Address[]|}>;
_inEdges: AddressMap<{|+address: Address, +edges: Address[]|}>;
```

Test Plan:
New unit tests included, with 100% coverage: `yarn flow && yarn test`.

wchargin-branch: address-map
This commit is contained in:
William Chargin 2018-03-05 16:18:20 -08:00 committed by GitHub
parent a798f9bac2
commit a8da44c94b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 280 additions and 0 deletions

View File

@ -0,0 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`address AddressMap stringifies to JSON 1`] = `
Object {
"{\\"repositoryName\\":\\"sourcecred/suburbia\\",\\"pluginName\\":\\"houseville\\",\\"id\\":\\"mansion\\"}": Object {
"baths": 5,
"beds": 10,
},
"{\\"repositoryName\\":\\"sourcecred/suburbia\\",\\"pluginName\\":\\"houseville\\",\\"id\\":\\"mattressStore\\"}": Object {
"baths": 1,
"beds": 99,
},
}
`;

112
src/backend/address.js Normal file
View File

@ -0,0 +1,112 @@
// @flow
import deepEqual from "lodash.isequal";
export type Address = {|
+repositoryName: string,
+pluginName: string,
+id: string,
|};
export interface Addressable {
+address: Address;
}
export type SansAddress<T: Addressable> = $Exact<$Diff<T, {+address: Address}>>;
export type AddressMapJSON<T: Addressable> = {
[serializedAddress: string]: SansAddress<T>,
};
/**
* A data structure for storing addressable objects, keyed by their
* addresses.
*/
export class AddressMap<T: Addressable> {
// TODO(@wchargin): Evaluate performance gains from using a triple-map
// here. Cf. https://jsperf.com/address-string-302039074.
_data: {[serializedAddress: 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((key) => {
const node = {...this._data[key]};
delete node.address;
result[key] = node;
});
return result;
}
static fromJSON(json: AddressMapJSON<T>): AddressMap<T> {
const result: AddressMap<T> = new AddressMap();
Object.keys(json).forEach((key) => {
result._data[key] = {...json[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 {
if (t.address == null) {
throw new Error(`address is ${String(t.address)}`);
}
const key = JSON.stringify(t.address);
this._data[key] = 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 key = JSON.stringify(address);
return this._data[key];
}
/**
* Get all objects stored in the map, in some unspecified order.
*/
getAll(): T[] {
return Object.keys(this._data).map((k) => this._data[k]);
}
}
/**
* Create a copy of the given array and sort its elements by their
* addresses. The original array and its elements are not modified.
*/
export function sortedByAddress<T: Addressable>(xs: T[]) {
function cmp(x1: T, x2: T): -1 | 0 | 1 {
// TODO(@wchargin): This can be replaced by three string-comparisons
// to avoid stringifying.
const a1 = JSON.stringify(x1.address);
const a2 = JSON.stringify(x2.address);
return a1 > a2 ? 1 : a1 < a2 ? -1 : 0;
}
return xs.slice().sort(cmp);
}

154
src/backend/address.test.js Normal file
View File

@ -0,0 +1,154 @@
// @flow
import type {Address} from "./address";
import {AddressMap, sortedByAddress} from "./address";
describe("address", () => {
// Some test data using objects that have addresses, like houses.
type House = {|
+address: Address,
+beds: number,
+baths: number,
|};
function makeAddress(id: string): Address {
return {
repositoryName: "sourcecred/suburbia",
pluginName: "houseville",
id,
};
}
const mansion = (): House => ({
address: makeAddress("mansion"),
beds: 10,
baths: 5,
});
const fakeMansion = (): House => ({
// Same address, different content.
address: makeAddress("mansion"),
beds: 33,
baths: 88,
});
const mattressStore = (): House => ({
address: makeAddress("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()];
expect(sortedByAddress(actual)).toEqual(sortedByAddress(expected));
});
it("stringifies to JSON", () => {
expect(makeMap().toJSON()).toMatchSnapshot();
});
it("stringifies elements sans addresses", () => {
const json = makeMap().toJSON();
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 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("sortedByAddress", () => {
it("sorts the empty array", () => {
expect(sortedByAddress([])).toEqual([]);
});
it("sorts a sorted array", () => {
const input = () => [mansion(), mattressStore()];
const output = () => [mansion(), mattressStore()];
expect(sortedByAddress(input())).toEqual(output());
});
it("sorts a reverse-sorted array", () => {
const input = () => [mattressStore(), mansion()];
const output = () => [mansion(), mattressStore()];
expect(sortedByAddress(input())).toEqual(output());
});
it("sorts an array with duplicates", () => {
const input = () => [mattressStore(), mansion(), mattressStore()];
const output = () => [mansion(), mattressStore(), mattressStore()];
expect(sortedByAddress(input())).toEqual(output());
});
it("doesn't mutate its input", () => {
const input = () => [mattressStore(), mansion()];
const x = input();
expect(x).toEqual(input());
expect(sortedByAddress(x)).not.toEqual(input());
expect(x).toEqual(input());
});
});
});