diff --git a/packages/devextreme/js/__internal/core/di/index.test.ts b/packages/devextreme/js/__internal/core/di/index.test.ts new file mode 100644 index 000000000000..4e5ece50a074 --- /dev/null +++ b/packages/devextreme/js/__internal/core/di/index.test.ts @@ -0,0 +1,186 @@ +/* eslint-disable @typescript-eslint/no-extraneous-class */ +/* eslint-disable prefer-const */ +/* eslint-disable @typescript-eslint/init-declarations */ +/* eslint-disable max-classes-per-file */ +/* eslint-disable class-methods-use-this */ +import { describe, expect, it } from '@jest/globals'; + +import { DIContext } from './index'; + +describe('basic', () => { + describe('register', () => { + class MyClass { + static dependencies = [] as const; + + getNumber(): number { + return 1; + } + } + + it('should return registered class', () => { + const ctx = new DIContext(); + ctx.register(MyClass); + + expect(ctx.get(MyClass)).toBeInstanceOf(MyClass); + expect(ctx.get(MyClass).getNumber()).toBe(1); + }); + + it('should return registered class with tryGet', () => { + const ctx = new DIContext(); + ctx.register(MyClass); + + expect(ctx.tryGet(MyClass)).toBeInstanceOf(MyClass); + expect(ctx.tryGet(MyClass)?.getNumber()).toBe(1); + }); + + it('should return same instance each time', () => { + const ctx = new DIContext(); + ctx.register(MyClass); + + expect(ctx.get(MyClass)).toBe(ctx.get(MyClass)); + }); + }); + + describe('registerInstance', () => { + class MyClass { + static dependencies = [] as const; + + getNumber(): number { + return 1; + } + } + + const ctx = new DIContext(); + const instance = new MyClass(); + ctx.registerInstance(MyClass, instance); + + it('should work', () => { + expect(ctx.get(MyClass)).toBe(instance); + }); + }); + + describe('non registered items', () => { + const ctx = new DIContext(); + class MyClass { + static dependencies = [] as const; + + getNumber(): number { + return 1; + } + } + it('should throw', () => { + expect(() => ctx.get(MyClass)).toThrow(); + }); + it('should not throw if tryGet', () => { + expect(ctx.tryGet(MyClass)).toBe(null); + }); + }); +}); + +describe('dependencies', () => { + class MyUtilityClass { + static dependencies = [] as const; + + getNumber(): number { + return 2; + } + } + + class MyClass { + static dependencies = [MyUtilityClass] as const; + + constructor(private readonly utility: MyUtilityClass) {} + + getSuperNumber(): number { + return this.utility.getNumber() * 2; + } + } + + const ctx = new DIContext(); + ctx.register(MyUtilityClass); + ctx.register(MyClass); + + it('should return registered class', () => { + expect(ctx.get(MyClass)).toBeInstanceOf(MyClass); + expect(ctx.get(MyUtilityClass)).toBeInstanceOf(MyUtilityClass); + }); + + it('dependecies should work', () => { + expect(ctx.get(MyClass).getSuperNumber()).toBe(4); + }); +}); + +describe('mocks', () => { + class MyClass { + static dependencies = [] as const; + + getNumber(): number { + return 1; + } + } + + class MyClassMock implements MyClass { + static dependencies = [] as const; + + getNumber(): number { + return 2; + } + } + + const ctx = new DIContext(); + ctx.register(MyClass, MyClassMock); + + it('should return mock class when they are registered', () => { + expect(ctx.get(MyClass)).toBeInstanceOf(MyClassMock); + expect(ctx.get(MyClass).getNumber()).toBe(2); + }); +}); + +it('should work regardless of registration order', () => { + class MyClass { + static dependencies = [] as const; + + getNumber(): number { + return 1; + } + } + + class MyDependentClass { + static dependencies = [MyClass] as const; + + constructor(private readonly myClass: MyClass) {} + + getSuperNumber(): number { + return this.myClass.getNumber() * 2; + } + } + + const ctx = new DIContext(); + ctx.register(MyDependentClass); + ctx.register(MyClass); + expect(ctx.get(MyDependentClass).getSuperNumber()).toBe(2); +}); + +describe('dependency cycle', () => { + class MyClass1 { + // @ts-expect-error + // eslint-disable-next-line @typescript-eslint/no-use-before-define + static dependencies = [MyClass2] as const; + + constructor(private readonly myClass2: MyClass2) {} + } + class MyClass2 { + static dependencies = [MyClass1] as const; + + constructor(private readonly myClass1: MyClass1) {} + } + + const ctx = new DIContext(); + ctx.register(MyClass1); + ctx.register(MyClass2); + + it('should throw', () => { + expect(() => ctx.get(MyClass1)).toThrow(); + expect(() => ctx.get(MyClass2)).toThrow(); + }); +}); diff --git a/packages/devextreme/js/__internal/core/di/index.ts b/packages/devextreme/js/__internal/core/di/index.ts new file mode 100644 index 000000000000..4ec4380fb408 --- /dev/null +++ b/packages/devextreme/js/__internal/core/di/index.ts @@ -0,0 +1,90 @@ +/* eslint-disable spellcheck/spell-checker */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +// eslint-disable-next-line @typescript-eslint/ban-types +interface AbstractType extends Function { + prototype: T; +} + +type Constructor = new(...deps: TDeps) => T; + +interface DIItem extends Constructor { + dependencies: readonly [...{ [P in keyof TDeps]: AbstractType }]; +} + +export class DIContext { + private readonly instances: Map = new Map(); + + private readonly fabrics: Map = new Map(); + + private readonly antiRecursionSet = new Set(); + + public register( + id: AbstractType, + fabric: DIItem, + ): void; + public register( + idAndFabric: DIItem, + ): void; + public register( + id: DIItem, + fabric?: DIItem, + ): void { + // eslint-disable-next-line no-param-reassign + fabric ??= id; + this.fabrics.set(id, fabric); + } + + public registerInstance( + id: AbstractType, + instance: T, + ): void { + this.instances.set(id, instance); + } + + public get( + id: AbstractType, + ): T { + const instance = this.tryGet(id); + + if (instance) { + return instance; + } + + throw new Error('DI item is not registered'); + } + + public tryGet( + id: AbstractType, + ): T | null { + if (this.instances.get(id)) { + return this.instances.get(id) as T; + } + + const fabric = this.fabrics.get(id); + if (fabric) { + const res: T = this.create(fabric as any); + this.instances.set(id, res); + this.instances.set(fabric, res); + return res; + } + + return null; + } + + private create(fabric: DIItem): T { + if (this.antiRecursionSet.has(fabric)) { + throw new Error('dependency cycle in DI'); + } + + this.antiRecursionSet.add(fabric); + + const args = fabric.dependencies.map((dependency) => this.get(dependency)); + + this.antiRecursionSet.delete(fabric); + + // eslint-disable-next-line new-cap + return new fabric(...args as any); + } +}