diff --git a/src/app/adapters/adapterSet.js b/src/app/adapters/adapterSet.js new file mode 100644 index 0000000..456c17a --- /dev/null +++ b/src/app/adapters/adapterSet.js @@ -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; + _adapterNodeTrie: NodeTrie; + _adapterEdgeTrie: EdgeTrie; + _typeNodeTrie: NodeTrie; + _typeEdgeTrie: EdgeTrie; + + constructor(adapters: $ReadOnlyArray) { + 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 { + 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 { + return Promise.all(this._adapters.map((a) => a.load(repo))).then( + (adapters) => new DynamicAdapterSet(this, adapters) + ); + } +} + +export class DynamicAdapterSet { + _adapters: $ReadOnlyArray; + _staticAdapterSet: StaticAdapterSet; + _adapterNodeTrie: NodeTrie; + _adapterEdgeTrie: EdgeTrie; + + constructor( + staticAdapterSet: StaticAdapterSet, + adapters: $ReadOnlyArray + ) { + 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 { + return this._adapters; + } + + graph(): Graph { + return Graph.merge(this._adapters.map((x) => x.graph())); + } + + static() { + return this._staticAdapterSet; + } +} diff --git a/src/app/adapters/adapterSet.test.js b/src/app/adapters/adapterSet.test.js new file mode 100644 index 0000000..67dd3ed --- /dev/null +++ b/src/app/adapters/adapterSet.test.js @@ -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); + }); + }); +}); diff --git a/src/app/adapters/fallbackAdapter.js b/src/app/adapters/fallbackAdapter.js new file mode 100644 index 0000000..7a96f06 --- /dev/null +++ b/src/app/adapters/fallbackAdapter.js @@ -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(); + } +}