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:
parent
33763a9f35
commit
9edd7ac069
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue