diff --git a/packages/backend-app-api/src/lib/DependencyTree.test.ts b/packages/backend-app-api/src/lib/DependencyTree.test.ts new file mode 100644 index 0000000000..5364dd4257 --- /dev/null +++ b/packages/backend-app-api/src/lib/DependencyTree.test.ts @@ -0,0 +1,124 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DependencyTree } from './DependencyTree'; + +describe('DependencyTree', () => { + it('should be empty', () => { + const empty = DependencyTree.fromMap({}); + expect(Array.from(empty.nodes)).toEqual([]); + expect(empty.findUnsatisfiedDeps()).toEqual([]); + expect(empty.detectCircularDependency()).toBeUndefined(); + }); + + it('should reject multiple producers', () => { + expect(() => + DependencyTree.fromMap({ + 1: { produces: ['a'] }, + 2: { produces: ['a'] }, + }), + ).toThrow( + "Dependency conflict detected, 'a' may not be produced by both '1' and '2'", + ); + }); + + it('should detect circular dependencies', () => { + expect( + DependencyTree.fromMap({ + 1: {}, + 2: {}, + 3: {}, + 4: {}, + }).detectCircularDependency(), + ).toBeUndefined(); + + expect( + DependencyTree.fromMap({ + 1: { produces: ['a'] }, + 2: { consumes: ['a'], produces: ['b', 'c'] }, + 3: { consumes: ['b'] }, + 4: { consumes: ['c'] }, + }).detectCircularDependency(), + ).toBeUndefined(); + + expect( + DependencyTree.fromMap({ + 1: { produces: ['a'], consumes: ['a'] }, + }).detectCircularDependency(), + ).toEqual(['1', '1']); + + expect( + DependencyTree.fromMap({ + 1: { produces: ['a'], consumes: ['b'] }, + 2: { produces: ['b'], consumes: ['a'] }, + }).detectCircularDependency(), + ).toEqual(['1', '2', '1']); + + expect( + DependencyTree.fromMap({ + 1: { produces: ['a'] }, + 2: { produces: ['b'], consumes: ['a', 'e'] }, + 3: { produces: ['c'], consumes: ['b'] }, + 4: { produces: ['d', 'e'], consumes: ['c', 'a'] }, + }).detectCircularDependency(), + ).toEqual(['2', '3', '4', '2']); + }); + + it('should find unsatisfied dependencies', () => { + expect( + DependencyTree.fromMap({ + 1: {}, + 2: {}, + 3: {}, + 4: {}, + }).findUnsatisfiedDeps(), + ).toEqual([]); + + expect( + DependencyTree.fromMap({ + 1: { produces: ['a'] }, + 2: { consumes: ['a'], produces: ['b', 'c'] }, + 3: { consumes: ['b'] }, + 4: { consumes: ['c'] }, + }).findUnsatisfiedDeps(), + ).toEqual([]); + + expect( + DependencyTree.fromMap({ + 1: { consumes: ['a'] }, + }).findUnsatisfiedDeps(), + ).toEqual([{ id: '1', unsatisfied: ['a'] }]); + + expect( + DependencyTree.fromMap({ + 1: { produces: ['a'], consumes: ['b'] }, + 2: { produces: ['b'], consumes: ['a', 'd', 'e'] }, + }).findUnsatisfiedDeps(), + ).toEqual([{ id: '2', unsatisfied: ['d', 'e'] }]); + + expect( + DependencyTree.fromMap({ + 1: { produces: ['a'] }, + 2: { produces: ['b'], consumes: ['a', 'd', 'e'] }, + 3: { produces: [], consumes: ['b'] }, + 4: { produces: [], consumes: ['c', 'a'] }, + }).findUnsatisfiedDeps(), + ).toEqual([ + { id: '2', unsatisfied: ['d', 'e'] }, + { id: '4', unsatisfied: ['c'] }, + ]); + }); +}); diff --git a/packages/backend-app-api/src/lib/DependencyTree.ts b/packages/backend-app-api/src/lib/DependencyTree.ts new file mode 100644 index 0000000000..80cf10ecb2 --- /dev/null +++ b/packages/backend-app-api/src/lib/DependencyTree.ts @@ -0,0 +1,139 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConflictError, InputError } from '@backstage/errors'; + +interface NodeInput { + id: string; + consumes?: Iterable; + produces?: Iterable; +} + +/** @internal */ +class Node { + static from(input: NodeInput) { + return new Node( + input.id, + input.consumes ? new Set(input.consumes) : new Set(), + input.produces ? new Set(input.produces) : new Set(), + ); + } + + private constructor( + readonly id: string, + readonly consumes: Set, + readonly produces: Set, + ) {} +} + +/** @internal */ +export class DependencyTree { + static fromMap(nodes: Record>) { + return this.fromIterable( + Object.entries(nodes).map(([id, node]) => ({ id, ...node })), + ); + } + + static fromIterable(nodeInputs: Iterable) { + const nodes = new Map(); + for (const nodeInput of nodeInputs) { + const node = Node.from(nodeInput); + if (nodes.has(node.id)) { + throw new InputError(`Duplicate node with id ${node.id}`); + } + nodes.set(node.id, node); + } + + return new DependencyTree(nodes); + } + + #allProduced: Set; + #allConsumed: Set; + #producedBy: Map; + #consumedBy: Map>; + + private constructor(readonly nodes: Map) { + this.#allProduced = new Set(); + this.#allConsumed = new Set(); + this.#producedBy = new Map(); + this.#consumedBy = new Map(); + + for (const node of this.nodes.values()) { + for (const produced of node.produces) { + this.#allProduced.add(produced); + if (this.#producedBy.has(produced)) { + throw new ConflictError( + `Dependency conflict detected, '${produced}' may not be produced by both '${this.#producedBy.get( + produced, + )}' and '${node.id}'`, + ); + } + this.#producedBy.set(produced, node.id); + } + for (const consumed of node.consumes) { + this.#allConsumed.add(consumed); + if (!this.#consumedBy.get(consumed)?.add(node.id)) { + this.#consumedBy.set(consumed, new Set([node.id])); + } + } + } + } + + findUnsatisfiedDeps(): Array<{ id: string; unsatisfied: string[] }> { + const unsatisfiedDependencies = []; + for (const node of this.nodes.values()) { + const unsatisfied = Array.from(node.consumes).filter( + id => !this.#allProduced.has(id), + ); + if (unsatisfied.length > 0) { + unsatisfiedDependencies.push({ id: node.id, unsatisfied }); + } + } + return unsatisfiedDependencies; + } + + detectCircularDependency(): string[] | undefined { + for (const nodeId of this.nodes.keys()) { + const visited = new Set(); + const stack = new Array<[id: string, path: string[]]>([nodeId, [nodeId]]); + + while (stack.length > 0) { + const [id, path] = stack.pop()!; + if (visited.has(id)) { + continue; + } + visited.add(id); + const node = this.nodes.get(id); + if (node) { + for (const produced of node.produces) { + const consumers = this.#consumedBy.get(produced); + if (consumers) { + for (const consumer of consumers) { + if (consumer === nodeId) { + return [...path, nodeId]; + } + if (!visited.has(consumer)) { + stack.push([consumer, [...path, consumer]]); + } + } + } + } + } + } + } + return undefined; + } +}