From 6994bc2b079943801566a9d48a2e8f6a8677e5c4 Mon Sep 17 00:00:00 2001 From: Evyweb Date: Sun, 10 Nov 2024 08:15:04 +0100 Subject: [PATCH] refactor: boyscout rules --- specs/scope.spec.ts | 251 +++++++++++++++++++++++++++++--------------- src/container.ts | 15 +-- src/module.ts | 29 ++--- src/types.ts | 8 +- 4 files changed, 191 insertions(+), 112 deletions(-) diff --git a/specs/scope.spec.ts b/specs/scope.spec.ts index 20cee7e..3401510 100644 --- a/specs/scope.spec.ts +++ b/specs/scope.spec.ts @@ -2,14 +2,14 @@ import {Container, createContainer, Scope} from "../src"; import {DI} from "./examples/DI"; import {MyService} from "./examples/MyService"; import {MyServiceInterface} from "./examples/MyServiceInterface"; -import {vi} from "vitest"; -import {sayHelloWorld} from "./examples/sayHelloWorld"; -import {SayHelloType} from "./examples/SayHelloType"; +import {Mock, vi} from "vitest"; +import {MyServiceClass} from "./examples/MyServiceClass"; +import {MyServiceClassInterface} from "./examples/MyServiceClassInterface"; describe('Scope', () => { let container: Container; - let factoryCalls = vi.fn(); + let factoryCalls: Mock; beforeEach(() => { container = createContainer(); @@ -18,109 +18,186 @@ describe('Scope', () => { factoryCalls = vi.fn(); }); - describe.each([ - {scope: undefined}, - {scope: 'singleton'}, - ])('When the scope is default or defined to "singleton"', ({scope}) => { - it('should return the same instance', () => { - // Arrange - container.bind(DI.MY_SERVICE).toFactory(() => { - factoryCalls(); - return MyService({ - dep1: container.get(DI.DEP1), - dep2: container.get(DI.DEP2) + describe('Factories', () => { + + describe.each([ + {scope: undefined}, + {scope: 'singleton'}, + ])('When the scope is default or defined to "singleton"', ({scope}) => { + it('should return the same instance', () => { + // Arrange + container.bind(DI.MY_SERVICE).toFactory(() => { + factoryCalls(); + return MyService({ + dep1: container.get(DI.DEP1), + dep2: container.get(DI.DEP2) + }); + }, scope as Scope); + + const myService1 = container.get(DI.MY_SERVICE); + + // Act + const myService2 = container.get(DI.MY_SERVICE); + + // Assert + expect(myService1).toBe(myService2); + expect(factoryCalls).toHaveBeenCalledTimes(1); + }); + }); + + describe('When the scope is defined to "transient"', () => { + it('should return a new instance each time', () => { + // Arrange + container.bind(DI.MY_SERVICE).toFactory(() => { + factoryCalls(); + return MyService({ + dep1: container.get(DI.DEP1), + dep2: container.get(DI.DEP2) + }); + }, 'transient'); + + const myService1 = container.get(DI.MY_SERVICE); + + // Act + const myService2 = container.get(DI.MY_SERVICE); + + // Assert + expect(myService1).not.toBe(myService2); + expect(factoryCalls).toHaveBeenCalledTimes(2); + }); + }); + + describe('When the scope is defined to "scoped"', () => { + it('should return the same instance within the same scope', () => { + // Arrange + container.bind(DI.DEP1).toValue('dependency1'); + container.bind(DI.DEP2).toValue(42); + container.bind(DI.MY_SERVICE).toFactory(() => { + factoryCalls(); + return MyService({ + dep1: container.get(DI.DEP1), + dep2: container.get(DI.DEP2) + }); + }, 'scoped'); + + let myService1: MyServiceInterface | undefined; + let myService2: MyServiceInterface | undefined; + + // Act + container.runInScope(() => { + myService1 = container.get(DI.MY_SERVICE); + myService2 = container.get(DI.MY_SERVICE); }); - }, scope as Scope); - const myService1 = container.get(DI.MY_SERVICE); + // Assert + expect(myService1).toBeDefined(); + expect(myService2).toBeDefined(); + expect(myService1).toBe(myService2); + expect(factoryCalls).toHaveBeenCalledTimes(1); + }); + + it('should return different instances in different scopes', () => { + // Arrange + container.bind(DI.MY_SERVICE).toFactory(() => { + factoryCalls(); + return MyService({ + dep1: container.get(DI.DEP1), + dep2: container.get(DI.DEP2) + }); + }, 'scoped'); + + let myService1: MyServiceInterface | undefined; + let myService2: MyServiceInterface | undefined; + + container.runInScope(() => { + myService1 = container.get(DI.MY_SERVICE); + }); - // Act - const myService2 = container.get(DI.MY_SERVICE); + // Act + container.runInScope(() => { + myService2 = container.get(DI.MY_SERVICE); + }); - // Assert - expect(myService1).toBe(myService2); - expect(factoryCalls).toHaveBeenCalledTimes(1); + // Assert + expect(myService1).toBeDefined(); + expect(myService2).toBeDefined(); + expect(myService1).not.toBe(myService2); + expect(factoryCalls).toHaveBeenCalledTimes(2); + }); }); }); - describe('When the scope is defined to "transient"', () => { - it('should return a new instance each time', () => { - // Arrange - container.bind(DI.MY_SERVICE).toFactory(() => { - factoryCalls(); - return MyService({ - dep1: container.get(DI.DEP1), - dep2: container.get(DI.DEP2) - }); - }, 'transient'); + describe('Classes', () => { - const myService1 = container.get(DI.MY_SERVICE); + describe.each([ + {scope: undefined}, + {scope: 'singleton'}, + ])('When the scope is default or defined to "singleton"', ({scope}) => { + it('should return the same instance', () => { + // Arrange + container.bind(DI.MY_SERVICE).toClass(MyServiceClass, [DI.DEP1, DI.DEP2], scope as Scope); - // Act - const myService2 = container.get(DI.MY_SERVICE); + const myService1 = container.get(DI.MY_SERVICE); - // Assert - expect(myService1).not.toBe(myService2); - expect(factoryCalls).toHaveBeenCalledTimes(2); + // Act + const myService2 = container.get(DI.MY_SERVICE); + + // Assert + expect(myService1).toBe(myService2); + }); }); - }); - describe('When the scope is defined to "scoped"', () => { - it('should return the same instance within the same scope', () => { - // Arrange - container.bind(DI.DEP1).toValue('dependency1'); - container.bind(DI.DEP2).toValue(42); - container.bind(DI.MY_SERVICE).toFactory(() => { - factoryCalls(); - return MyService({ - dep1: container.get(DI.DEP1), - dep2: container.get(DI.DEP2) - }); - }, 'scoped'); + describe('When the scope is defined to "transient"', () => { + it('should return a new instance each time', () => { + // Arrange + container.bind(DI.MY_SERVICE).toClass(MyServiceClass, [DI.DEP1, DI.DEP2], 'transient'); - let myService1: MyServiceInterface | undefined; - let myService2: MyServiceInterface | undefined; + const myService1 = container.get(DI.MY_SERVICE); - // Act - container.runInScope(() => { - myService1 = container.get(DI.MY_SERVICE); - myService2 = container.get(DI.MY_SERVICE); - }); + // Act + const myService2 = container.get(DI.MY_SERVICE); - // Assert - expect(myService1).toBeDefined(); - expect(myService2).toBeDefined(); - expect(myService1).toBe(myService2); - expect(factoryCalls).toHaveBeenCalledTimes(1); + // Assert + expect(myService1).not.toBe(myService2); + }); }); + }); - it('should return different instances in different scopes', () => { - // Arrange - container.bind(DI.MY_SERVICE).toFactory(() => { - factoryCalls(); - return MyService({ - dep1: container.get(DI.DEP1), - dep2: container.get(DI.DEP2) - }); - }, 'scoped'); + describe('Higher order functions', () => { - let myService1: MyServiceInterface | undefined; - let myService2: MyServiceInterface | undefined; + describe.each([ + {scope: undefined}, + {scope: 'singleton'}, + ])('When the scope is default or defined to "singleton"', ({scope}) => { + it('should return the same instance', () => { + // Arrange + container.bind(DI.MY_SERVICE) + .toHigherOrderFunction(MyService, {dep1: DI.DEP1, dep2: DI.DEP2}, scope as Scope); - container.runInScope(() => { - myService1 = container.get(DI.MY_SERVICE); - }); + const myService1 = container.get(DI.MY_SERVICE); + + // Act + const myService2 = container.get(DI.MY_SERVICE); - // Act - container.runInScope(() => { - myService2 = container.get(DI.MY_SERVICE); + // Assert + expect(myService1).toBe(myService2); }); + }); + + describe('When the scope is defined to "transient"', () => { + it('should return a new instance each time', () => { + // Arrange + container.bind(DI.MY_SERVICE) + .toHigherOrderFunction(MyService, {dep1: DI.DEP1, dep2: DI.DEP2}, 'transient'); - // Assert - expect(myService1).toBeDefined(); - expect(myService2).toBeDefined(); - expect(myService1).not.toBe(myService2); - expect(factoryCalls).toHaveBeenCalledTimes(2); + const myService1 = container.get(DI.MY_SERVICE); + + // Act + const myService2 = container.get(DI.MY_SERVICE); + + // Assert + expect(myService1).not.toBe(myService2); + }); }); }); @@ -139,10 +216,10 @@ describe('Scope', () => { describe('When an unknown scope is used during binding', () => { it('should throw an error', () => { // Arrange - container.bind(DI.MY_SERVICE).toFunction(sayHelloWorld, 'unknown' as any); + container.bind(DI.MY_SERVICE).toClass(MyServiceClass, [], 'unknown' as any); // Act & Assert - expect(() => container.get(DI.MY_SERVICE)) + expect(() => container.get(DI.MY_SERVICE)) .toThrowError('Unknown scope: unknown'); }); }); diff --git a/src/container.ts b/src/container.ts index 03d5e30..6e4cbac 100644 --- a/src/container.ts +++ b/src/container.ts @@ -1,5 +1,5 @@ -import {Binding, Container, Module} from "./types"; -import {createModule} from "./module"; +import { Binding, Container, Module } from "./types"; +import { createModule } from "./module"; export function createContainer(): Container { const modules = new Map(); @@ -7,8 +7,9 @@ export function createContainer(): Container { const scopedInstances = new Map>(); let currentScopeId: symbol | undefined; + const DEFAULT_MODULE_KEY = Symbol("DEFAULT"); const defaultModule = createModule(); - modules.set(Symbol('DEFAULT'), defaultModule); + modules.set(DEFAULT_MODULE_KEY, defaultModule); const bind = (key: symbol) => defaultModule.bind(key); @@ -37,18 +38,18 @@ export function createContainer(): Container { const { factory, scope } = binding; - if (scope === 'singleton') { + if (scope === "singleton") { if (!singletonInstances.has(key)) { singletonInstances.set(key, factory(resolveDependency)); } return singletonInstances.get(key) as T; } - if (scope === 'transient') { + if (scope === "transient") { return factory(resolveDependency) as T; } - if (scope === 'scoped') { + if (scope === "scoped") { if (!currentScopeId) throw new Error(`Cannot resolve scoped binding outside of a scope: ${key.toString()}`); if (!scopedInstances.has(currentScopeId)) { @@ -71,7 +72,7 @@ export function createContainer(): Container { const runInScope = (callback: () => T): T => { const previousScopeId = currentScopeId; - currentScopeId = Symbol('scope'); + currentScopeId = Symbol("scope"); try { return callback(); } finally { diff --git a/src/module.ts b/src/module.ts index 56cc72f..996dd45 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,14 +1,15 @@ -import { DependencyArray, DependencyObject, Module, ResolveFunction } from "./types"; +import { DependencyArray, DependencyObject, Module, ResolveFunction, Scope } from "./types"; interface Binding { factory: (resolve: ResolveFunction) => unknown; - scope: 'singleton' | 'transient' | 'scoped'; + scope: Scope; } export function createModule(): Module { const bindings = new Map(); - const resolveDependenciesArray = (dependencies: DependencyArray, resolve: ResolveFunction) => dependencies.map(resolve); + const resolveDependenciesArray = (dependencies: DependencyArray, resolve: ResolveFunction) => + dependencies.map(resolve); const resolveDependenciesObject = (dependencies: DependencyObject, resolve: ResolveFunction) => { const entries = Object.entries(dependencies); @@ -19,24 +20,24 @@ export function createModule(): Module { Array.isArray(dependencies); const isDependencyObject = (dependencies: DependencyArray | DependencyObject): dependencies is DependencyObject => - dependencies !== null && typeof dependencies === 'object' && !Array.isArray(dependencies); + dependencies !== null && typeof dependencies === "object" && !Array.isArray(dependencies); const bind = (key: symbol) => { - const toValue = (value: unknown, scope: 'singleton' | 'transient' | 'scoped' = 'singleton') => { - bindings.set(key, { factory: () => value, scope }); + const toValue = (value: unknown) => { + bindings.set(key, { factory: () => value, scope: 'singleton' }); }; - const toFunction = (fn: CallableFunction, scope: 'singleton' | 'transient' | 'scoped' = 'singleton') => { - bindings.set(key, { factory: () => fn, scope }); + const toFunction = (fn: CallableFunction) => { + bindings.set(key, { factory: () => fn, scope: 'singleton' }); }; const toHigherOrderFunction = ( fn: CallableFunction, dependencies?: DependencyArray | DependencyObject, - scope: 'singleton' | 'transient' | 'scoped' = 'singleton' + scope: Scope = 'singleton' ) => { if (dependencies && !isDependencyArray(dependencies) && !isDependencyObject(dependencies)) { - throw new Error('Invalid dependencies type'); + throw new Error("Invalid dependencies type"); } const factory = (resolve: ResolveFunction) => { @@ -54,17 +55,17 @@ export function createModule(): Module { bindings.set(key, { factory, scope }); }; - const toFactory = (factory: CallableFunction, scope: 'singleton' | 'transient' | 'scoped' = 'singleton') => { + const toFactory = (factory: CallableFunction, scope: Scope = 'singleton') => { bindings.set(key, { factory: (resolve: ResolveFunction) => factory(resolve), scope }); }; const toClass = ( AnyClass: new (...args: unknown[]) => unknown, dependencies: DependencyArray = [], - scope: 'singleton' | 'transient' | 'scoped' = 'singleton' + scope: Scope = 'singleton' ) => { const factory = (resolve: ResolveFunction) => { - const resolvedDeps = dependencies.map(dep => resolve(dep)); + const resolvedDeps = dependencies.map((dep) => resolve(dep)); return new AnyClass(...resolvedDeps); }; @@ -76,7 +77,7 @@ export function createModule(): Module { toFunction, toFactory, toClass, - toHigherOrderFunction + toHigherOrderFunction, }; }; diff --git a/src/types.ts b/src/types.ts index f7d7de3..a111a40 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,8 +8,8 @@ export type Scope = 'singleton' | 'transient' | 'scoped'; export interface Container { bind(key: symbol): { - toValue: (value: unknown, scope?: Scope) => void; - toFunction: (fn: CallableFunction, scope?: Scope) => void; + toValue: (value: unknown) => void; + toFunction: (fn: CallableFunction) => void; toHigherOrderFunction: ( fn: CallableFunction, dependencies?: DependencyArray | DependencyObject, @@ -34,8 +34,8 @@ export interface Container { export interface Module { bind(key: symbol): { - toValue: (value: unknown, scope?: Scope) => void; - toFunction: (fn: CallableFunction, scope?: Scope) => void; + toValue: (value: unknown) => void; + toFunction: (fn: CallableFunction) => void; toHigherOrderFunction: ( fn: CallableFunction, dependencies?: DependencyArray | DependencyObject,