Add AdapterSet and FallbackAdapter (#640)

Issue #631 revealed that our current plugin-handling code is fragile -
we aren't robust to having nodes from a plugin without having that
plugin present in the frontend. This commit adds `StaticAdapterSet` and
`DynamicAdapterSet`, which are abstractions over finding the matching
plugin adapter or type for a node or edge. It's a robust abstraction,
because the adapter sets always include the `StaticFallbackAdapter` or
`DynamicFallbackAdapter`, which can match any node, so we'll never get
an error like #631 due to not having an adapter / type available.

Also relevant: #465

Test plan:
Unit tests included.
This commit is contained in:
Dandelion Mané 2018-08-10 18:08:49 -07:00 committed by GitHub
parent 33763a9f35
commit 9edd7ac069
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 427 additions and 0 deletions

View File

@ -0,0 +1,153 @@
// @flow
import {Graph, type NodeAddressT, type EdgeAddressT} from "../../core/graph";
import {NodeTrie, EdgeTrie} from "../../core/trie";
import type {Repo} from "../../core/repo";
import type {
StaticPluginAdapter,
DynamicPluginAdapter,
NodeType,
EdgeType,
} from "./pluginAdapter";
import {FallbackStaticAdapter} from "./fallbackAdapter";
export class StaticAdapterSet {
_adapters: $ReadOnlyArray<StaticPluginAdapter>;
_adapterNodeTrie: NodeTrie<StaticPluginAdapter>;
_adapterEdgeTrie: EdgeTrie<StaticPluginAdapter>;
_typeNodeTrie: NodeTrie<NodeType>;
_typeEdgeTrie: EdgeTrie<EdgeType>;
constructor(adapters: $ReadOnlyArray<StaticPluginAdapter>) {
this._adapters = [new FallbackStaticAdapter(), ...adapters];
this._adapterNodeTrie = new NodeTrie();
this._adapterEdgeTrie = new EdgeTrie();
this._typeNodeTrie = new NodeTrie();
this._typeEdgeTrie = new EdgeTrie();
const usedPluginNames = new Set();
this._adapters.forEach((a) => {
const name = a.name();
if (usedPluginNames.has(name)) {
throw new Error(`Multiple plugins with name "${name}"`);
}
usedPluginNames.add(name);
this._adapterNodeTrie.add(a.nodePrefix(), a);
this._adapterEdgeTrie.add(a.edgePrefix(), a);
});
this.nodeTypes().forEach((t) => this._typeNodeTrie.add(t.prefix, t));
this.edgeTypes().forEach((t) => this._typeEdgeTrie.add(t.prefix, t));
}
adapters(): $ReadOnlyArray<StaticPluginAdapter> {
return this._adapters;
}
nodeTypes(): NodeType[] {
return [].concat(...this._adapters.map((x) => x.nodeTypes()));
}
edgeTypes(): EdgeType[] {
return [].concat(...this._adapters.map((x) => x.edgeTypes()));
}
adapterMatchingNode(x: NodeAddressT): StaticPluginAdapter {
const adapters = this._adapterNodeTrie.get(x);
if (adapters.length === 0) {
throw new Error(
"Invariant violation: Fallback adapter matches all nodes"
);
}
return adapters[adapters.length - 1];
}
adapterMatchingEdge(x: EdgeAddressT): StaticPluginAdapter {
const adapters = this._adapterEdgeTrie.get(x);
if (adapters.length === 0) {
throw new Error(
"Invariant violation: Fallback adapter matches all edges"
);
}
return adapters[adapters.length - 1];
}
typeMatchingNode(x: NodeAddressT): NodeType {
const types = this._typeNodeTrie.get(x);
if (types.length === 0) {
throw new Error(
"Invariant violation: Fallback adapter's type matches all nodes"
);
}
return types[types.length - 1];
}
typeMatchingEdge(x: EdgeAddressT): EdgeType {
const types = this._typeEdgeTrie.get(x);
if (types.length === 0) {
throw new Error(
"Invariant violation: Fallback adapter's type matches all edges"
);
}
return types[types.length - 1];
}
load(repo: Repo): Promise<DynamicAdapterSet> {
return Promise.all(this._adapters.map((a) => a.load(repo))).then(
(adapters) => new DynamicAdapterSet(this, adapters)
);
}
}
export class DynamicAdapterSet {
_adapters: $ReadOnlyArray<DynamicPluginAdapter>;
_staticAdapterSet: StaticAdapterSet;
_adapterNodeTrie: NodeTrie<DynamicPluginAdapter>;
_adapterEdgeTrie: EdgeTrie<DynamicPluginAdapter>;
constructor(
staticAdapterSet: StaticAdapterSet,
adapters: $ReadOnlyArray<DynamicPluginAdapter>
) {
this._staticAdapterSet = staticAdapterSet;
this._adapters = adapters;
this._adapterNodeTrie = new NodeTrie();
this._adapterEdgeTrie = new EdgeTrie();
this._adapters.forEach((a) => {
this._adapterNodeTrie.add(a.static().nodePrefix(), a);
this._adapterEdgeTrie.add(a.static().edgePrefix(), a);
});
}
adapterMatchingNode(x: NodeAddressT): DynamicPluginAdapter {
const adapters = this._adapterNodeTrie.get(x);
if (adapters.length === 0) {
throw new Error(
"Invariant violation: Fallback adapter matches all nodes"
);
}
return adapters[adapters.length - 1];
}
adapterMatchingEdge(x: EdgeAddressT): DynamicPluginAdapter {
const adapters = this._adapterEdgeTrie.get(x);
if (adapters.length === 0) {
throw new Error(
"Invariant violation: Fallback adapter matches all edges"
);
}
return adapters[adapters.length - 1];
}
adapters(): $ReadOnlyArray<DynamicPluginAdapter> {
return this._adapters;
}
graph(): Graph {
return Graph.merge(this._adapters.map((x) => x.graph()));
}
static() {
return this._staticAdapterSet;
}
}

View File

@ -0,0 +1,213 @@
// @flow
import {
NodeAddress,
EdgeAddress,
type NodeAddressT,
Graph,
} from "../../core/graph";
import type {DynamicPluginAdapter} from "./pluginAdapter";
import {StaticAdapterSet} from "./adapterSet";
import {FallbackStaticAdapter, FALLBACK_NAME} from "./fallbackAdapter";
import {makeRepo, type Repo} from "../../core/repo";
describe("app/adapters/adapterSet", () => {
class TestStaticPluginAdapter {
loadingMock: Function;
constructor() {
this.loadingMock = jest.fn();
}
name() {
return "other plugin";
}
nodePrefix() {
return NodeAddress.fromParts(["other"]);
}
edgePrefix() {
return EdgeAddress.fromParts(["other"]);
}
nodeTypes() {
return [
{
name: "other1",
defaultWeight: 0,
prefix: NodeAddress.fromParts(["other", "1"]),
},
{
name: "other2",
defaultWeight: 0,
prefix: NodeAddress.fromParts(["other", "2"]),
},
];
}
edgeTypes() {
return [
{
forwardName: "others_1",
backwardName: "othered_by_1",
prefix: EdgeAddress.fromParts(["other", "1"]),
},
{
forwardName: "others_2",
backwardName: "othered_by_2",
prefix: EdgeAddress.fromParts(["other", "2"]),
},
];
}
load(_unused_repo: Repo) {
return this.loadingMock().then(() => new TestDynamicPluginAdapter());
}
}
class TestDynamicPluginAdapter implements DynamicPluginAdapter {
graph() {
return new Graph().addNode(NodeAddress.fromParts(["other1", "example"]));
}
nodeDescription(x: NodeAddressT) {
return `Node from the test plugin: ${NodeAddress.toString(x)}`;
}
static() {
return new TestStaticPluginAdapter();
}
}
describe("StaticAdapterSet", () => {
function example() {
const x = new TestStaticPluginAdapter();
const fallback = new FallbackStaticAdapter();
const sas = new StaticAdapterSet([x]);
return {x, fallback, sas};
}
it("errors if two plugins have the same name", () => {
const x = new TestStaticPluginAdapter();
const shouldError = () => new StaticAdapterSet([x, x]);
expect(shouldError).toThrowError("Multiple plugins with name");
});
it("always includes the fallback plugin", () => {
const {sas} = example();
expect(sas.adapters()[0].name()).toBe(FALLBACK_NAME);
});
it("includes the manually provided plugin adapters", () => {
const {x, sas} = example();
expect(sas.adapters()[1].name()).toBe(x.name());
});
it("aggregates NodeTypes across plugins", () => {
const {sas} = example();
const nodeTypes = sas.nodeTypes();
expect(nodeTypes).toHaveLength(3);
});
it("aggregates EdgeTypes across plugins", () => {
const {sas} = example();
const edgeTypes = sas.edgeTypes();
expect(edgeTypes).toHaveLength(3);
});
it("finds adapter matching a node", () => {
const {x, sas} = example();
const matching = sas.adapterMatchingNode(
NodeAddress.fromParts(["other", "foo"])
);
expect(matching.name()).toBe(x.name());
});
it("finds adapter matching an edge", () => {
const {x, sas} = example();
const matching = sas.adapterMatchingEdge(
EdgeAddress.fromParts(["other", "foo"])
);
expect(matching.name()).toBe(x.name());
});
it("finds fallback adapter for unregistered node", () => {
const {sas} = example();
const adapter = sas.adapterMatchingNode(NodeAddress.fromParts(["weird"]));
expect(adapter.name()).toBe(FALLBACK_NAME);
});
it("finds fallback adapter for unregistered edge", () => {
const {sas} = example();
const adapter = sas.adapterMatchingEdge(EdgeAddress.fromParts(["weird"]));
expect(adapter.name()).toBe(FALLBACK_NAME);
});
it("finds type matching a node", () => {
const {sas} = example();
const type = sas.typeMatchingNode(
NodeAddress.fromParts(["other", "1", "foo"])
);
expect(type.name).toBe("other1");
});
it("finds type matching an edge", () => {
const {sas} = example();
const type = sas.typeMatchingEdge(
EdgeAddress.fromParts(["other", "1", "foo"])
);
expect(type.forwardName).toBe("others_1");
});
it("finds fallback type for unregistered node", () => {
const {sas} = example();
const type = sas.typeMatchingNode(
NodeAddress.fromParts(["wombat", "1", "foo"])
);
expect(type.name).toBe("(unknown node)");
});
it("finds fallback type for unregistered edge", () => {
const {sas} = example();
const type = sas.typeMatchingEdge(
EdgeAddress.fromParts(["wombat", "1", "foo"])
);
expect(type.forwardName).toBe("(unknown edge→)");
});
it("loads a dynamicAdapterSet", async () => {
const {x, sas} = example();
x.loadingMock.mockResolvedValue();
const das = await sas.load(makeRepo("foo", "bar"));
expect(das).toEqual(expect.anything());
});
});
describe("DynamicAdapterSet", () => {
async function example() {
const x = new TestStaticPluginAdapter();
const sas = new StaticAdapterSet([x]);
x.loadingMock.mockResolvedValue();
const das = await sas.load(makeRepo("foo", "bar"));
return {x, sas, das};
}
it("allows retrieval of the original StaticAdapterSet", async () => {
const {sas, das} = await example();
expect(das.static()).toBe(sas);
});
it("allows accessing the dynamic adapters", async () => {
const {sas, das} = await example();
expect(das.adapters().map((a) => a.static().name())).toEqual(
sas.adapters().map((a) => a.name())
);
});
it("allows retrieval of the aggregated graph", async () => {
const {das} = await example();
const expectedGraph = Graph.merge(das.adapters().map((x) => x.graph()));
expect(das.graph().equals(expectedGraph)).toBe(true);
});
it("finds adapter matching a node", async () => {
const {x, das} = await example();
const matching = das.adapterMatchingNode(
NodeAddress.fromParts(["other", "foo"])
);
expect(matching.static().name()).toBe(x.name());
});
it("finds adapter matching an edge", async () => {
const {x, das} = await example();
const matching = das.adapterMatchingEdge(
EdgeAddress.fromParts(["other", "foo"])
);
expect(matching.static().name()).toBe(x.name());
});
it("finds fallback adapter for unregistered node", async () => {
const {das} = await example();
const adapter = das.adapterMatchingNode(NodeAddress.fromParts(["weird"]));
expect(adapter.static().name()).toBe(FALLBACK_NAME);
});
it("finds fallback adapter for unregistered edge", async () => {
const {das} = await example();
const adapter = das.adapterMatchingEdge(EdgeAddress.fromParts(["weird"]));
expect(adapter.static().name()).toBe(FALLBACK_NAME);
});
});
});

View File

@ -0,0 +1,61 @@
// @flow
import {
Graph,
NodeAddress,
type NodeAddressT,
EdgeAddress,
} from "../../core/graph";
import type {Repo} from "../../core/repo";
import type {StaticPluginAdapter, DynamicPluginAdapter} from "./pluginAdapter";
export const FALLBACK_NAME = "FALLBACK_ADAPTER";
export class FallbackStaticAdapter implements StaticPluginAdapter {
name() {
return FALLBACK_NAME;
}
nodePrefix() {
return NodeAddress.empty;
}
edgePrefix() {
return EdgeAddress.empty;
}
nodeTypes() {
return [
{name: "(unknown node)", prefix: NodeAddress.empty, defaultWeight: 1},
];
}
edgeTypes() {
return [
{
forwardName: "(unknown edge→)",
backwardName: "(unknown edge←)",
prefix: EdgeAddress.empty,
},
];
}
load(_unused_repo: Repo) {
return Promise.resolve(new FallbackDynamicAdapter());
}
}
export class FallbackDynamicAdapter implements DynamicPluginAdapter {
graph() {
return new Graph();
}
nodeDescription(x: NodeAddressT) {
return NodeAddress.toString(x);
}
static() {
return new FallbackStaticAdapter();
}
}