diff --git a/.changeset/modern-grapes-fly.md b/.changeset/modern-grapes-fly.md index 6dca5633..c611a802 100644 --- a/.changeset/modern-grapes-fly.md +++ b/.changeset/modern-grapes-fly.md @@ -2,4 +2,4 @@ "@inversifyjs/core": minor --- -Added `resolveBindingDeactivations`. +Added `resolveServiceDeactivations`. diff --git a/packages/container/libraries/core/src/binding/calculations/isScopedBinding.spec.ts b/packages/container/libraries/core/src/binding/calculations/isScopedBinding.spec.ts new file mode 100644 index 00000000..b9b19c61 --- /dev/null +++ b/packages/container/libraries/core/src/binding/calculations/isScopedBinding.spec.ts @@ -0,0 +1,70 @@ +import { beforeAll, describe, expect, it } from '@jest/globals'; + +import { bindingScopeValues } from '../models/BindingScope'; +import { bindingTypeValues } from '../models/BindingType'; +import { ConstantValueBinding } from '../models/ConstantValueBinding'; +import { ServiceRedirectionBinding } from '../models/ServiceRedirectionBinding'; +import { isScopedBinding } from './isScopedBinding'; + +describe(isScopedBinding.name, () => { + describe('having a ServiceRedirectionBinding', () => { + let serviceRedirectionBindingFixture: ServiceRedirectionBinding; + + beforeAll(() => { + serviceRedirectionBindingFixture = { + id: 1, + isSatisfiedBy: () => true, + moduleId: undefined, + serviceIdentifier: 'service-id', + targetServiceIdentifier: 'target-service-id', + type: bindingTypeValues.ServiceRedirection, + }; + }); + + describe('when called', () => { + let result: unknown; + + beforeAll(() => { + result = isScopedBinding(serviceRedirectionBindingFixture); + }); + + it('should return false', () => { + expect(result).toBe(false); + }); + }); + }); + + describe('having a ConstantValueBinding', () => { + let constantValueBindingFixture: ConstantValueBinding; + + beforeAll(() => { + constantValueBindingFixture = { + cache: { + isRight: false, + value: undefined, + }, + id: 1, + isSatisfiedBy: () => true, + moduleId: undefined, + onActivation: undefined, + onDeactivation: undefined, + scope: bindingScopeValues.Singleton, + serviceIdentifier: 'service-id', + type: bindingTypeValues.ConstantValue, + value: 'foo', + }; + }); + + describe('when called', () => { + let result: unknown; + + beforeAll(() => { + result = isScopedBinding(constantValueBindingFixture); + }); + + it('should return true', () => { + expect(result).toBe(true); + }); + }); + }); +}); diff --git a/packages/container/libraries/core/src/binding/calculations/isScopedBinding.ts b/packages/container/libraries/core/src/binding/calculations/isScopedBinding.ts new file mode 100644 index 00000000..e9c2cb9a --- /dev/null +++ b/packages/container/libraries/core/src/binding/calculations/isScopedBinding.ts @@ -0,0 +1,15 @@ +import { + Binding, + BindingScope, + BindingType, + ScopedBinding, +} from '@inversifyjs/core'; + +export function isScopedBinding( + binding: Binding, +): binding is Binding & ScopedBinding { + return ( + (binding as Partial>).scope !== + undefined + ); +} diff --git a/packages/container/libraries/core/src/binding/fixtures/ConstantValueBindingFixtures.ts b/packages/container/libraries/core/src/binding/fixtures/ConstantValueBindingFixtures.ts new file mode 100644 index 00000000..018d5a92 --- /dev/null +++ b/packages/container/libraries/core/src/binding/fixtures/ConstantValueBindingFixtures.ts @@ -0,0 +1,43 @@ +import { bindingScopeValues } from '../models/BindingScope'; +import { bindingTypeValues } from '../models/BindingType'; +import { ConstantValueBinding } from '../models/ConstantValueBinding'; + +export class ConstantValueBindingFixtures { + public static get any(): ConstantValueBinding { + return { + cache: { + isRight: false, + value: undefined, + }, + id: 1, + isSatisfiedBy: () => true, + moduleId: undefined, + onActivation: undefined, + onDeactivation: undefined, + scope: bindingScopeValues.Singleton, + serviceIdentifier: 'service-id', + type: bindingTypeValues.ConstantValue, + value: Symbol.for('constant-value-binding-fixture-value'), + }; + } + + public static get withCacheWithIsRightFalse(): ConstantValueBinding { + return { + ...ConstantValueBindingFixtures.any, + cache: { + isRight: false, + value: undefined, + }, + }; + } + + public static get withCacheWithIsRightTrue(): ConstantValueBinding { + return { + ...ConstantValueBindingFixtures.any, + cache: { + isRight: true, + value: Symbol.for('constant-value-binding-fixture-value'), + }, + }; + } +} diff --git a/packages/container/libraries/core/src/binding/fixtures/InstanceBindingFixtures.ts b/packages/container/libraries/core/src/binding/fixtures/InstanceBindingFixtures.ts new file mode 100644 index 00000000..70c6c00e --- /dev/null +++ b/packages/container/libraries/core/src/binding/fixtures/InstanceBindingFixtures.ts @@ -0,0 +1,30 @@ +import { bindingScopeValues } from '../models/BindingScope'; +import { bindingTypeValues } from '../models/BindingType'; +import { InstanceBinding } from '../models/InstanceBinding'; + +export class InstanceBindingFixtures { + public static get any(): InstanceBinding { + return { + cache: { + isRight: false, + value: undefined, + }, + id: 1, + implementationType: class Foo {}, + isSatisfiedBy: () => true, + moduleId: undefined, + onActivation: undefined, + onDeactivation: undefined, + scope: bindingScopeValues.Singleton, + serviceIdentifier: 'service-id', + type: bindingTypeValues.Instance, + }; + } + + public static get withCacheWithScopeSingleton(): InstanceBinding { + return { + ...InstanceBindingFixtures.any, + scope: bindingScopeValues.Singleton, + }; + } +} diff --git a/packages/container/libraries/core/src/index.ts b/packages/container/libraries/core/src/index.ts index 75d75b41..a07ceb60 100644 --- a/packages/container/libraries/core/src/index.ts +++ b/packages/container/libraries/core/src/index.ts @@ -58,7 +58,7 @@ import { PlanServiceNodeParent } from './planning/models/PlanServiceNodeParent'; import { PlanServiceRedirectionBindingNode } from './planning/models/PlanServiceRedirectionBindingNode'; import { PlanTree } from './planning/models/PlanTree'; import { resolve } from './resolution/actions/resolve'; -import { resolveBindingDeactivations } from './resolution/actions/resolveBindingDeactivations'; +import { resolveServiceDeactivations } from './resolution/actions/resolveServiceDeactivations'; import { DeactivationParams } from './resolution/models/DeactivationParams'; import { GetOptions } from './resolution/models/GetOptions'; import { GetOptionsTagConstraint } from './resolution/models/GetOptionsTagConstraint'; @@ -135,7 +135,7 @@ export { plan, preDestroy, resolve, - resolveBindingDeactivations, + resolveServiceDeactivations, tagged, unmanaged, }; diff --git a/packages/container/libraries/core/src/metadata/fixtures/ClassMetadataFixtures.ts b/packages/container/libraries/core/src/metadata/fixtures/ClassMetadataFixtures.ts index 5f2bcfbd..ea055c82 100644 --- a/packages/container/libraries/core/src/metadata/fixtures/ClassMetadataFixtures.ts +++ b/packages/container/libraries/core/src/metadata/fixtures/ClassMetadataFixtures.ts @@ -14,4 +14,18 @@ export class ClassMetadataFixtures { return fixture; } + + public static get withPreDestroyMethodName(): ClassMetadata { + const fixture: ClassMetadata = { + constructorArguments: [], + lifecycle: { + postConstructMethodName: undefined, + preDestroyMethodName: 'preDestroy', + }, + properties: new Map(), + scope: undefined, + }; + + return fixture; + } } diff --git a/packages/container/libraries/core/src/resolution/actions/resolveBindingDeactivations.spec.ts b/packages/container/libraries/core/src/resolution/actions/resolveBindingDeactivations.spec.ts index fececcc6..834e90bf 100644 --- a/packages/container/libraries/core/src/resolution/actions/resolveBindingDeactivations.spec.ts +++ b/packages/container/libraries/core/src/resolution/actions/resolveBindingDeactivations.spec.ts @@ -14,8 +14,11 @@ describe(resolveBindingDeactivations.name, () => { beforeAll(() => { paramsMock = { + getBindings: jest.fn(), getDeactivations: jest.fn(), - }; + } as Partial< + jest.Mocked + > as jest.Mocked; serviceIdentifierFixture = 'service-id'; valueFixture = Symbol(); }); @@ -133,8 +136,11 @@ describe(resolveBindingDeactivations.name, () => { beforeAll(() => { paramsMock = { + getBindings: jest.fn(), getDeactivations: jest.fn(), - }; + } as Partial< + jest.Mocked + > as jest.Mocked; serviceIdentifierFixture = 'service-id'; valueFixture = Symbol(); }); diff --git a/packages/container/libraries/core/src/resolution/actions/resolveServiceDeactivations.spec.ts b/packages/container/libraries/core/src/resolution/actions/resolveServiceDeactivations.spec.ts new file mode 100644 index 00000000..57327d30 --- /dev/null +++ b/packages/container/libraries/core/src/resolution/actions/resolveServiceDeactivations.spec.ts @@ -0,0 +1,251 @@ +import { afterAll, beforeAll, describe, expect, it, jest } from '@jest/globals'; + +import { ServiceIdentifier } from '@inversifyjs/common'; + +jest.mock('./resolveBindingDeactivations'); + +import { ConstantValueBindingFixtures } from '../../binding/fixtures/ConstantValueBindingFixtures'; +import { InstanceBindingFixtures } from '../../binding/fixtures/InstanceBindingFixtures'; +import { ConstantValueBinding } from '../../binding/models/ConstantValueBinding'; +import { InstanceBinding } from '../../binding/models/InstanceBinding'; +import { ClassMetadataFixtures } from '../../metadata/fixtures/ClassMetadataFixtures'; +import { ClassMetadata } from '../../metadata/models/ClassMetadata'; +import { DeactivationParams } from '../models/DeactivationParams'; +import { resolveBindingDeactivations } from './resolveBindingDeactivations'; +import { resolveServiceDeactivations } from './resolveServiceDeactivations'; + +describe(resolveServiceDeactivations.name, () => { + let paramsMock: jest.Mocked; + let serviceIdentifierFixture: ServiceIdentifier; + + beforeAll(() => { + paramsMock = { + getBindings: jest.fn() as unknown, + getClassMetadata: jest.fn(), + getDeactivations: jest.fn(), + } as Partial< + jest.Mocked + > as jest.Mocked; + serviceIdentifierFixture = 'service-id'; + }); + + describe('when called, and params.getBindings() returns undefined', () => { + let result: unknown; + + beforeAll(() => { + result = resolveServiceDeactivations( + paramsMock, + serviceIdentifierFixture, + ); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should call params.getBindings()', () => { + expect(paramsMock.getBindings).toHaveBeenCalledTimes(1); + expect(paramsMock.getBindings).toHaveBeenCalledWith( + serviceIdentifierFixture, + ); + }); + + it('should return undefined', () => { + expect(result).toBeUndefined(); + }); + }); + + describe('when called, and params.getBindings() returns an array with singleton ScopedBinding with no cached value', () => { + let result: unknown; + + beforeAll(() => { + paramsMock.getBindings.mockReturnValueOnce([ + ConstantValueBindingFixtures.withCacheWithIsRightFalse, + ]); + + result = resolveServiceDeactivations( + paramsMock, + serviceIdentifierFixture, + ); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should call params.getBindings()', () => { + expect(paramsMock.getBindings).toHaveBeenCalledTimes(1); + expect(paramsMock.getBindings).toHaveBeenCalledWith( + serviceIdentifierFixture, + ); + }); + + it('should return undefined', () => { + expect(result).toBeUndefined(); + }); + }); + + describe('when called, and params.getBindings() returns an array with non instance singleton ScopedBinding with cached value', () => { + let bindingFixture: ConstantValueBinding; + + let result: unknown; + + beforeAll(() => { + bindingFixture = ConstantValueBindingFixtures.withCacheWithIsRightTrue; + + paramsMock.getBindings.mockReturnValueOnce([bindingFixture]); + + result = resolveServiceDeactivations( + paramsMock, + serviceIdentifierFixture, + ); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should call params.getBindings()', () => { + expect(paramsMock.getBindings).toHaveBeenCalledTimes(1); + expect(paramsMock.getBindings).toHaveBeenCalledWith( + serviceIdentifierFixture, + ); + }); + + it('should call resolveBindingDeactivations()', () => { + expect(resolveBindingDeactivations).toHaveBeenCalledTimes(1); + expect(resolveBindingDeactivations).toHaveBeenCalledWith( + paramsMock, + serviceIdentifierFixture, + bindingFixture.cache.value, + ); + }); + + it('should return undefined', () => { + expect(result).toBeUndefined(); + }); + }); + + describe('when called, and params.getBindings() returns an array with instance singleton ScopedBinding with cached value and params.getClassMetadata() returns metadata with preDestroy method, and preDestroy method returns undefined', () => { + let bindingFixture: InstanceBinding; + let classMetadataFixture: ClassMetadata; + let preDestoyMock: jest.Mock<() => void>; + + let result: unknown; + + beforeAll(() => { + classMetadataFixture = ClassMetadataFixtures.withPreDestroyMethodName; + preDestoyMock = jest.fn(); + + bindingFixture = { + ...InstanceBindingFixtures.withCacheWithScopeSingleton, + cache: { + isRight: true, + value: { + [classMetadataFixture.lifecycle.preDestroyMethodName as string]: + preDestoyMock, + }, + }, + }; + + paramsMock.getBindings.mockReturnValueOnce([bindingFixture]); + paramsMock.getClassMetadata.mockReturnValueOnce(classMetadataFixture); + + result = resolveServiceDeactivations( + paramsMock, + serviceIdentifierFixture, + ); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should call params.getBindings()', () => { + expect(paramsMock.getBindings).toHaveBeenCalledTimes(1); + expect(paramsMock.getBindings).toHaveBeenCalledWith( + serviceIdentifierFixture, + ); + }); + + it('should call preDestroy method', () => { + expect(preDestoyMock).toHaveBeenCalledTimes(1); + expect(preDestoyMock).toHaveBeenCalledWith(); + }); + + it('should call resolveBindingDeactivations()', () => { + expect(resolveBindingDeactivations).toHaveBeenCalledTimes(1); + expect(resolveBindingDeactivations).toHaveBeenCalledWith( + paramsMock, + serviceIdentifierFixture, + bindingFixture.cache.value, + ); + }); + + it('should return undefined', () => { + expect(result).toBeUndefined(); + }); + }); + + describe('when called, and params.getBindings() returns an array with instance singleton ScopedBinding with cached value and params.getClassMetadata() returns metadata with preDestroy method, and preDestroy method returns promise', () => { + let bindingFixture: InstanceBinding; + let classMetadataFixture: ClassMetadata; + let preDestoyMock: jest.Mock<() => Promise>; + + let result: unknown; + + beforeAll(async () => { + classMetadataFixture = ClassMetadataFixtures.withPreDestroyMethodName; + preDestoyMock = jest.fn(); + + bindingFixture = { + ...InstanceBindingFixtures.withCacheWithScopeSingleton, + cache: { + isRight: true, + value: { + [classMetadataFixture.lifecycle.preDestroyMethodName as string]: + preDestoyMock, + }, + }, + }; + + paramsMock.getBindings.mockReturnValueOnce([bindingFixture]); + paramsMock.getClassMetadata.mockReturnValueOnce(classMetadataFixture); + preDestoyMock.mockResolvedValueOnce(undefined); + + result = await resolveServiceDeactivations( + paramsMock, + serviceIdentifierFixture, + ); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should call params.getBindings()', () => { + expect(paramsMock.getBindings).toHaveBeenCalledTimes(1); + expect(paramsMock.getBindings).toHaveBeenCalledWith( + serviceIdentifierFixture, + ); + }); + + it('should call preDestroy method', () => { + expect(preDestoyMock).toHaveBeenCalledTimes(1); + expect(preDestoyMock).toHaveBeenCalledWith(); + }); + + it('should call resolveBindingDeactivations()', () => { + expect(resolveBindingDeactivations).toHaveBeenCalledTimes(1); + expect(resolveBindingDeactivations).toHaveBeenCalledWith( + paramsMock, + serviceIdentifierFixture, + bindingFixture.cache.value, + ); + }); + + it('should return undefined', () => { + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/container/libraries/core/src/resolution/actions/resolveServiceDeactivations.ts b/packages/container/libraries/core/src/resolution/actions/resolveServiceDeactivations.ts new file mode 100644 index 00000000..96eb03b5 --- /dev/null +++ b/packages/container/libraries/core/src/resolution/actions/resolveServiceDeactivations.ts @@ -0,0 +1,104 @@ +import { ServiceIdentifier } from '@inversifyjs/common'; + +import { isScopedBinding } from '../../binding/calculations/isScopedBinding'; +import { Binding } from '../../binding/models/Binding'; +import { bindingScopeValues } from '../../binding/models/BindingScope'; +import { + BindingType, + bindingTypeValues, +} from '../../binding/models/BindingType'; +import { ScopedBinding } from '../../binding/models/ScopedBinding'; +import { ClassMetadata } from '../../metadata/models/ClassMetadata'; +import { DeactivationParams } from '../models/DeactivationParams'; +import { resolveBindingDeactivations } from './resolveBindingDeactivations'; + +type SingletonScopedBinding = Binding & + ScopedBinding; + +export function resolveServiceDeactivations( + params: DeactivationParams, + serviceIdentifier: ServiceIdentifier, +): void | Promise { + const bindings: Binding[] | undefined = params.getBindings(serviceIdentifier); + + if (bindings === undefined) { + return; + } + + const singletonScopedBindings: SingletonScopedBinding[] = + filterSinglentonScopedBindings(bindings); + + const deactivationPromiseResults: Promise[] = []; + + for (const binding of singletonScopedBindings) { + if (binding.cache.isRight) { + const preDestroyResult: void | Promise = resolveBindingPreDestroy( + params, + binding, + ); + + let deactivationResult: void | Promise; + + if (preDestroyResult === undefined) { + deactivationResult = resolveBindingDeactivations( + params, + serviceIdentifier, + binding.cache.value, + ); + } else { + deactivationResult = preDestroyResult.then((): void | Promise => + resolveBindingDeactivations( + params, + serviceIdentifier, + binding.cache.value, + ), + ); + } + + if (deactivationResult !== undefined) { + deactivationPromiseResults.push(deactivationResult); + } + } + } + + if (deactivationPromiseResults.length > 0) { + return Promise.all(deactivationPromiseResults).then(() => undefined); + } +} + +function filterSinglentonScopedBindings( + bindings: Binding[], +): SingletonScopedBinding[] { + return bindings.filter( + (binding: Binding): binding is SingletonScopedBinding => + isScopedBinding(binding) && + binding.scope === bindingScopeValues.Singleton, + ); +} + +function resolveBindingPreDestroy( + params: DeactivationParams, + binding: SingletonScopedBinding, +): void | Promise { + if (binding.type === bindingTypeValues.Instance) { + const classMetadata: ClassMetadata = params.getClassMetadata( + binding.implementationType, + ); + + if (classMetadata.lifecycle.preDestroyMethodName !== undefined) { + const instance: Record = binding.cache + .value as Record; + + if ( + typeof instance[classMetadata.lifecycle.preDestroyMethodName] === + 'function' + ) { + return ( + instance[ + classMetadata.lifecycle.preDestroyMethodName + ] as () => void | Promise + )(); + } + } + } +} diff --git a/packages/container/libraries/core/src/resolution/models/DeactivationParams.ts b/packages/container/libraries/core/src/resolution/models/DeactivationParams.ts index c9d4b8a4..c8b0fedd 100644 --- a/packages/container/libraries/core/src/resolution/models/DeactivationParams.ts +++ b/packages/container/libraries/core/src/resolution/models/DeactivationParams.ts @@ -1,8 +1,14 @@ -import { ServiceIdentifier } from '@inversifyjs/common'; +import { Newable, ServiceIdentifier } from '@inversifyjs/common'; +import { Binding } from '../../binding/models/Binding'; import { BindingDeactivation } from '../../binding/models/BindingDeactivation'; +import { ClassMetadata } from '../../metadata/models/ClassMetadata'; export interface DeactivationParams { + getBindings: ( + serviceIdentifier: ServiceIdentifier, + ) => Binding[] | undefined; + getClassMetadata: (type: Newable) => ClassMetadata; getDeactivations: ( serviceIdentifier: ServiceIdentifier, ) => Iterable> | undefined;