diff --git a/.changeset/nine-drinks-enjoy.md b/.changeset/nine-drinks-enjoy.md new file mode 100644 index 00000000..fe3fbefd --- /dev/null +++ b/.changeset/nine-drinks-enjoy.md @@ -0,0 +1,5 @@ +--- +"@inversifyjs/core": minor +--- + +Added `ClassMetadata`. diff --git a/.changeset/sixty-emus-change.md b/.changeset/sixty-emus-change.md new file mode 100644 index 00000000..d3115219 --- /dev/null +++ b/.changeset/sixty-emus-change.md @@ -0,0 +1,5 @@ +--- +"@inversifyjs/core": minor +--- + +Added `getClassMetadata`. diff --git a/packages/container/libraries/core/package.json b/packages/container/libraries/core/package.json index 763057a1..16d99cb9 100644 --- a/packages/container/libraries/core/package.json +++ b/packages/container/libraries/core/package.json @@ -4,6 +4,10 @@ "url": "https://github.com/inversify/monorepo/issues" }, "description": "InversifyJs core package", + "dependencies": { + "@inversifyjs/common": "workspace:*", + "@inversifyjs/reflect-metadata-utils": "workspace:*" + }, "devDependencies": { "@eslint/js": "9.13.0", "@jest/globals": "29.7.0", diff --git a/packages/container/libraries/core/src/index.ts b/packages/container/libraries/core/src/index.ts index cb0ff5c3..80c67210 100644 --- a/packages/container/libraries/core/src/index.ts +++ b/packages/container/libraries/core/src/index.ts @@ -1 +1,23 @@ -export {}; +import { getClassMetadata } from './metadata/calculations/getClassMetadata'; +import { ClassElementMetadata } from './metadata/models/ClassElementMetadata'; +import { ClassElementMetadataKind } from './metadata/models/ClassElementMetadataKind'; +import { ClassMetadata } from './metadata/models/ClassMetadata'; +import { ClassMetadataLifecycle } from './metadata/models/ClassMetadataLifecycle'; +import { ManagedClassElementMetadata } from './metadata/models/ManagedClassElementMetadata'; +import { MetadataName } from './metadata/models/MetadataName'; +import { MetadataTag } from './metadata/models/MetadataTag'; +import { MetadataTargetName } from './metadata/models/MetadataTargetName'; +import { UnmanagedClassElementMetadata } from './metadata/models/UnmanagedClassElementMetadata'; + +export type { + ClassElementMetadata, + ClassMetadata, + ClassMetadataLifecycle, + ManagedClassElementMetadata, + MetadataName, + MetadataTag, + MetadataTargetName, + UnmanagedClassElementMetadata, +}; + +export { ClassElementMetadataKind, getClassMetadata }; diff --git a/packages/container/libraries/core/src/metadata/calculations/getClassElementMetadataFromLegacyMetadata.spec.ts b/packages/container/libraries/core/src/metadata/calculations/getClassElementMetadataFromLegacyMetadata.spec.ts new file mode 100644 index 00000000..44e3005c --- /dev/null +++ b/packages/container/libraries/core/src/metadata/calculations/getClassElementMetadataFromLegacyMetadata.spec.ts @@ -0,0 +1,276 @@ +import { beforeAll, describe, expect, it } from '@jest/globals'; + +import { ServiceIdentifier } from '@inversifyjs/common'; + +import { + INJECT_TAG, + MULTI_INJECT_TAG, + NAME_TAG, + NAMED_TAG, + OPTIONAL_TAG, + UNMANAGED_TAG, +} from '../../reflectMetadata/data/keys'; +import { ClassElementMetadata } from '../models/ClassElementMetadata'; +import { ClassElementMetadataKind } from '../models/ClassElementMetadataKind'; +import { LegacyMetadata } from '../models/LegacyMetadata'; +import { MetadataName } from '../models/MetadataName'; +import { MetadataTag } from '../models/MetadataTag'; +import { MetadataTargetName } from '../models/MetadataTargetName'; +import { getClassElementMetadataFromLegacyMetadata } from './getClassElementMetadataFromLegacyMetadata'; + +describe(getClassElementMetadataFromLegacyMetadata.name, () => { + describe('having an empty metadata list', () => { + let metadataListFixture: LegacyMetadata[]; + + beforeAll(() => { + metadataListFixture = []; + }); + + describe('when called', () => { + let result: unknown; + + beforeAll(() => { + try { + getClassElementMetadataFromLegacyMetadata(metadataListFixture); + } catch (error: unknown) { + result = error; + } + }); + + it('should throw an Error', () => { + const expectedErrorProperties: Partial = { + message: 'Expected @inject, @multiInject or @unmanaged metadata', + }; + + expect(result).toBeInstanceOf(Error); + expect(result).toStrictEqual( + expect.objectContaining(expectedErrorProperties), + ); + }); + }); + }); + + describe('having a metadata list with unmanaged metadata', () => { + let metadataListFixture: LegacyMetadata[]; + + beforeAll(() => { + metadataListFixture = [ + { + key: UNMANAGED_TAG, + value: true, + }, + ]; + }); + + describe('when called', () => { + let result: unknown; + + beforeAll(() => { + result = getClassElementMetadataFromLegacyMetadata(metadataListFixture); + }); + + it('should return ClassElementMetadata', () => { + const expectedClassElementMetadata: ClassElementMetadata = { + kind: ClassElementMetadataKind.unmanaged, + }; + + expect(result).toStrictEqual(expectedClassElementMetadata); + }); + }); + }); + + describe.each<[string, LegacyMetadata]>([ + [ + 'inject', + { + key: INJECT_TAG, + value: Symbol(), + }, + ], + [ + 'multi inject', + { + key: MULTI_INJECT_TAG, + value: Symbol(), + }, + ], + ])( + 'having a metadata list with both unmanaged and %s metadata', + (_: string, metadata: LegacyMetadata) => { + let metadataListFixture: LegacyMetadata[]; + + beforeAll(() => { + metadataListFixture = [ + { + key: UNMANAGED_TAG, + value: true, + }, + metadata, + ]; + }); + + describe('when called', () => { + let result: unknown; + + beforeAll(() => { + try { + getClassElementMetadataFromLegacyMetadata(metadataListFixture); + } catch (error: unknown) { + result = error; + } + }); + + it('should throw an Error', () => { + const expectedErrorProperties: Partial = { + message: + 'Expected a single @inject, @multiInject or @unmanaged metadata', + }; + + expect(result).toBeInstanceOf(Error); + expect(result).toStrictEqual( + expect.objectContaining(expectedErrorProperties), + ); + }); + }); + }, + ); + + describe.each<[string, ClassElementMetadataKind, LegacyMetadata]>([ + [ + 'inject', + ClassElementMetadataKind.singleInjection, + { + key: INJECT_TAG, + value: Symbol(), + }, + ], + [ + 'multi inject', + ClassElementMetadataKind.multipleInjection, + { + key: MULTI_INJECT_TAG, + value: Symbol(), + }, + ], + ])( + 'having a metadata list with % metadata', + ( + _: string, + classElementMetadataKind: ClassElementMetadataKind, + metadata: LegacyMetadata, + ) => { + let metadataListFixture: LegacyMetadata[]; + + beforeAll(() => { + metadataListFixture = [metadata]; + }); + + describe('when called', () => { + let result: unknown; + + beforeAll(() => { + result = + getClassElementMetadataFromLegacyMetadata(metadataListFixture); + }); + + it('should return ClassElementMetadata', () => { + const expectedClassElementMetadata: ClassElementMetadata = { + kind: classElementMetadataKind, + name: undefined, + optional: false, + tags: new Map(), + targetName: undefined, + value: metadata.value as ServiceIdentifier, + }; + + expect(result).toStrictEqual(expectedClassElementMetadata); + }); + }); + }, + ); + + describe.each<[string, ClassElementMetadataKind, LegacyMetadata]>([ + [ + 'inject', + ClassElementMetadataKind.singleInjection, + { + key: INJECT_TAG, + value: Symbol(), + }, + ], + [ + 'multi inject', + ClassElementMetadataKind.multipleInjection, + { + key: MULTI_INJECT_TAG, + value: Symbol(), + }, + ], + ])( + 'having a metadata list with % metadata', + ( + _: string, + classElementMetadataKind: ClassElementMetadataKind, + metadata: LegacyMetadata, + ) => { + let customTagMetadataFixture: LegacyMetadata; + let nameMetadataFixture: LegacyMetadata; + let optionalMetadataFixture: LegacyMetadata; + let targetNameMetadataFixture: LegacyMetadata; + let metadataListFixture: LegacyMetadata[]; + + beforeAll(() => { + customTagMetadataFixture = { + key: 'customTag', + value: 'customTagValue', + }; + nameMetadataFixture = { + key: NAME_TAG, + value: 'name-fixture', + }; + optionalMetadataFixture = { + key: OPTIONAL_TAG, + value: true, + }; + targetNameMetadataFixture = { + key: NAMED_TAG, + value: 'target-name-fixture', + }; + metadataListFixture = [ + metadata, + customTagMetadataFixture, + nameMetadataFixture, + optionalMetadataFixture, + targetNameMetadataFixture, + ]; + }); + + describe('when called', () => { + let result: unknown; + + beforeAll(() => { + result = + getClassElementMetadataFromLegacyMetadata(metadataListFixture); + }); + + it('should return ClassElementMetadata', () => { + const expectedClassElementMetadata: ClassElementMetadata = { + kind: classElementMetadataKind, + name: nameMetadataFixture.value as MetadataName, + optional: true, + tags: new Map([ + [ + customTagMetadataFixture.key as MetadataTag, + customTagMetadataFixture.value, + ], + ]), + targetName: targetNameMetadataFixture.value as MetadataTargetName, + value: metadata.value as ServiceIdentifier, + }; + + expect(result).toStrictEqual(expectedClassElementMetadata); + }); + }); + }, + ); +}); diff --git a/packages/container/libraries/core/src/metadata/calculations/getClassElementMetadataFromLegacyMetadata.ts b/packages/container/libraries/core/src/metadata/calculations/getClassElementMetadataFromLegacyMetadata.ts new file mode 100644 index 00000000..23de23c6 --- /dev/null +++ b/packages/container/libraries/core/src/metadata/calculations/getClassElementMetadataFromLegacyMetadata.ts @@ -0,0 +1,101 @@ +import { LazyServiceIdentifier, ServiceIdentifier } from '@inversifyjs/common'; + +import { + INJECT_TAG, + MULTI_INJECT_TAG, + NAME_TAG, + NAMED_TAG, + NON_CUSTOM_TAG_KEYS, + OPTIONAL_TAG, + UNMANAGED_TAG, +} from '../../reflectMetadata/data/keys'; +import { ClassElementMetadata } from '../models/ClassElementMetadata'; +import { ClassElementMetadataKind } from '../models/ClassElementMetadataKind'; +import { LegacyMetadata } from '../models/LegacyMetadata'; +import { ManagedClassElementMetadata } from '../models/ManagedClassElementMetadata'; +import { MetadataName } from '../models/MetadataName'; +import { MetadataTag } from '../models/MetadataTag'; +import { MetadataTargetName } from '../models/MetadataTargetName'; +import { UnmanagedClassElementMetadata } from '../models/UnmanagedClassElementMetadata'; + +export function getClassElementMetadataFromLegacyMetadata( + metadataList: LegacyMetadata[], +): ClassElementMetadata { + const injectMetadata: LegacyMetadata | undefined = metadataList.find( + (metadata: LegacyMetadata): boolean => metadata.key === INJECT_TAG, + ); + const multiInjectMetadata: LegacyMetadata | undefined = metadataList.find( + (metadata: LegacyMetadata): boolean => metadata.key === MULTI_INJECT_TAG, + ); + const unmanagedMetadata: LegacyMetadata | undefined = metadataList.find( + (metadata: LegacyMetadata): boolean => metadata.key === UNMANAGED_TAG, + ); + + if (unmanagedMetadata !== undefined) { + return getUnmanagedClassElementMetadata( + injectMetadata, + multiInjectMetadata, + ); + } + + if (multiInjectMetadata === undefined && injectMetadata === undefined) { + throw new Error('Expected @inject, @multiInject or @unmanaged metadata'); + } + + const nameMetadata: LegacyMetadata | undefined = metadataList.find( + (metadata: LegacyMetadata): boolean => metadata.key === NAME_TAG, + ); + + const optionalMetadata: LegacyMetadata | undefined = metadataList.find( + (metadata: LegacyMetadata): boolean => metadata.key === OPTIONAL_TAG, + ); + + const targetNameMetadata: LegacyMetadata | undefined = metadataList.find( + (metadata: LegacyMetadata): boolean => metadata.key === NAMED_TAG, + ); + + const managedClassElementMetadata: ManagedClassElementMetadata = { + kind: + injectMetadata === undefined + ? ClassElementMetadataKind.multipleInjection + : ClassElementMetadataKind.singleInjection, + name: nameMetadata?.value as MetadataName | undefined, + optional: optionalMetadata !== undefined, + tags: new Map( + metadataList + .filter((metadata: LegacyMetadata): boolean => + NON_CUSTOM_TAG_KEYS.every( + (customTagKey: string): boolean => metadata.key !== customTagKey, + ), + ) + .map((metadata: LegacyMetadata): [MetadataTag, unknown] => [ + metadata.key, + metadata.value, + ]), + ), + targetName: targetNameMetadata?.value as MetadataTargetName | undefined, + value: + injectMetadata === undefined + ? (multiInjectMetadata?.value as + | ServiceIdentifier + | LazyServiceIdentifier) + : (injectMetadata.value as ServiceIdentifier | LazyServiceIdentifier), + }; + + return managedClassElementMetadata; +} + +function getUnmanagedClassElementMetadata( + injectMetadata: LegacyMetadata | undefined, + multiInjectMetadata: LegacyMetadata | undefined, +): UnmanagedClassElementMetadata { + if (multiInjectMetadata !== undefined || injectMetadata !== undefined) { + throw new Error( + 'Expected a single @inject, @multiInject or @unmanaged metadata', + ); + } + + return { + kind: ClassElementMetadataKind.unmanaged, + }; +} diff --git a/packages/container/libraries/core/src/metadata/calculations/getClassElementMetadataFromNewable.spec.ts b/packages/container/libraries/core/src/metadata/calculations/getClassElementMetadataFromNewable.spec.ts new file mode 100644 index 00000000..3112d634 --- /dev/null +++ b/packages/container/libraries/core/src/metadata/calculations/getClassElementMetadataFromNewable.spec.ts @@ -0,0 +1,34 @@ +import { beforeAll, describe, expect, it } from '@jest/globals'; + +import { Newable } from '@inversifyjs/common'; + +import { ClassElementMetadata } from '../models/ClassElementMetadata'; +import { ClassElementMetadataKind } from '../models/ClassElementMetadataKind'; +import { getClassElementMetadataFromNewable } from './getClassElementMetadataFromNewable'; + +describe(getClassElementMetadataFromNewable.name, () => { + describe('when called', () => { + let typeFixture: Newable; + + let result: unknown; + + beforeAll(() => { + typeFixture = class {}; + + result = getClassElementMetadataFromNewable(typeFixture); + }); + + it('should return ClassElementMetadata', () => { + const expected: ClassElementMetadata = { + kind: ClassElementMetadataKind.singleInjection, + name: undefined, + optional: false, + tags: new Map(), + targetName: undefined, + value: typeFixture, + }; + + expect(result).toStrictEqual(expected); + }); + }); +}); diff --git a/packages/container/libraries/core/src/metadata/calculations/getClassElementMetadataFromNewable.ts b/packages/container/libraries/core/src/metadata/calculations/getClassElementMetadataFromNewable.ts new file mode 100644 index 00000000..03be162e --- /dev/null +++ b/packages/container/libraries/core/src/metadata/calculations/getClassElementMetadataFromNewable.ts @@ -0,0 +1,17 @@ +import { Newable } from '@inversifyjs/common'; + +import { ClassElementMetadata } from '../models/ClassElementMetadata'; +import { ClassElementMetadataKind } from '../models/ClassElementMetadataKind'; + +export function getClassElementMetadataFromNewable( + type: Newable, +): ClassElementMetadata { + return { + kind: ClassElementMetadataKind.singleInjection, + name: undefined, + optional: false, + tags: new Map(), + targetName: undefined, + value: type, + }; +} diff --git a/packages/container/libraries/core/src/metadata/calculations/getClassMetadata.spec.ts b/packages/container/libraries/core/src/metadata/calculations/getClassMetadata.spec.ts new file mode 100644 index 00000000..2dee6907 --- /dev/null +++ b/packages/container/libraries/core/src/metadata/calculations/getClassMetadata.spec.ts @@ -0,0 +1,94 @@ +import { afterAll, beforeAll, describe, expect, it, jest } from '@jest/globals'; + +jest.mock('@inversifyjs/reflect-metadata-utils'); + +import { getReflectMetadata } from '@inversifyjs/reflect-metadata-utils'; + +jest.mock('./getClassMetadataConstructorArguments'); +jest.mock('./getClassMetadataProperties'); + +import { ClassElementMetadata } from '../models/ClassElementMetadata'; +import { ClassElementMetadataKind } from '../models/ClassElementMetadataKind'; +import { ClassMetadata } from '../models/ClassMetadata'; +import { LegacyMetadata } from '../models/LegacyMetadata'; +import { getClassMetadata } from './getClassMetadata'; +import { getClassMetadataConstructorArguments } from './getClassMetadataConstructorArguments'; +import { getClassMetadataProperties } from './getClassMetadataProperties'; + +describe(getClassMetadata.name, () => { + describe('when called, and getReflectMetadata() returns LegacyMetadata', () => { + let constructorArgumentsMetadataFixture: ClassElementMetadata[]; + let propertiesMetadataFixture: Map; + let postConstructMetadataFixture: LegacyMetadata; + let preDestroyMetadataFixture: LegacyMetadata; + + let result: unknown; + + beforeAll(() => { + constructorArgumentsMetadataFixture = [ + { + kind: ClassElementMetadataKind.unmanaged, + }, + ]; + + propertiesMetadataFixture = new Map([ + [ + 'property-fixture', + { + kind: ClassElementMetadataKind.singleInjection, + name: undefined, + optional: false, + tags: new Map(), + targetName: undefined, + value: Symbol(), + }, + ], + ]); + + postConstructMetadataFixture = { + key: 'post-construct-key-fixture', + value: 'post-construct-value-fixture', + }; + + preDestroyMetadataFixture = { + key: 'pre-destroy-key-fixture', + value: 'pre-destroy-value-fixture', + }; + + ( + getClassMetadataConstructorArguments as jest.Mock< + typeof getClassMetadataConstructorArguments + > + ).mockReturnValueOnce(constructorArgumentsMetadataFixture); + + ( + getClassMetadataProperties as jest.Mock< + typeof getClassMetadataProperties + > + ).mockReturnValueOnce(propertiesMetadataFixture); + + (getReflectMetadata as jest.Mock) + .mockReturnValueOnce(postConstructMetadataFixture) + .mockReturnValueOnce(preDestroyMetadataFixture); + + result = getClassMetadata(class {}); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should return ClassMetadata', () => { + const expected: ClassMetadata = { + constructorArguments: constructorArgumentsMetadataFixture, + lifecycle: { + postConstructMethodName: postConstructMetadataFixture.value as string, + preDestroyMethodName: preDestroyMetadataFixture.value as string, + }, + properties: propertiesMetadataFixture, + }; + + expect(result).toStrictEqual(expected); + }); + }); +}); diff --git a/packages/container/libraries/core/src/metadata/calculations/getClassMetadata.ts b/packages/container/libraries/core/src/metadata/calculations/getClassMetadata.ts new file mode 100644 index 00000000..f6216a4c --- /dev/null +++ b/packages/container/libraries/core/src/metadata/calculations/getClassMetadata.ts @@ -0,0 +1,28 @@ +import { Newable } from '@inversifyjs/common'; +import { getReflectMetadata } from '@inversifyjs/reflect-metadata-utils'; + +import { POST_CONSTRUCT, PRE_DESTROY } from '../../reflectMetadata/data/keys'; +import { ClassMetadata } from '../models/ClassMetadata'; +import { LegacyMetadata } from '../models/LegacyMetadata'; +import { getClassMetadataConstructorArguments } from './getClassMetadataConstructorArguments'; +import { getClassMetadataProperties } from './getClassMetadataProperties'; + +export function getClassMetadata( + type: Newable, +): ClassMetadata { + const postConstructMetadata: LegacyMetadata | undefined = + getReflectMetadata(type, POST_CONSTRUCT); + const preDestroyMetadata: LegacyMetadata | undefined = + getReflectMetadata(type, PRE_DESTROY); + + const classMetadata: ClassMetadata = { + constructorArguments: getClassMetadataConstructorArguments(type), + lifecycle: { + postConstructMethodName: postConstructMetadata?.value as string, + preDestroyMethodName: preDestroyMetadata?.value as string, + }, + properties: getClassMetadataProperties(type), + }; + + return classMetadata; +} diff --git a/packages/container/libraries/core/src/metadata/calculations/getClassMetadataConstructorArguments.spec.ts b/packages/container/libraries/core/src/metadata/calculations/getClassMetadataConstructorArguments.spec.ts new file mode 100644 index 00000000..7dbcfeb5 --- /dev/null +++ b/packages/container/libraries/core/src/metadata/calculations/getClassMetadataConstructorArguments.spec.ts @@ -0,0 +1,236 @@ +import { afterAll, beforeAll, describe, expect, it, jest } from '@jest/globals'; + +jest.mock('@inversifyjs/reflect-metadata-utils'); + +import { Newable } from '@inversifyjs/common'; +import { getReflectMetadata } from '@inversifyjs/reflect-metadata-utils'; + +jest.mock('./getClassElementMetadataFromLegacyMetadata'); +jest.mock('./getClassElementMetadataFromNewable'); + +import { DESIGN_PARAM_TYPES, TAGGED } from '../../reflectMetadata/data/keys'; +import { ClassElementMetadata } from '../models/ClassElementMetadata'; +import { ClassElementMetadataKind } from '../models/ClassElementMetadataKind'; +import { LegacyMetadata } from '../models/LegacyMetadata'; +import { LegacyMetadataMap } from '../models/LegacyMetadataMap'; +import { getClassElementMetadataFromLegacyMetadata } from './getClassElementMetadataFromLegacyMetadata'; +import { getClassElementMetadataFromNewable } from './getClassElementMetadataFromNewable'; +import { getClassMetadataConstructorArguments } from './getClassMetadataConstructorArguments'; + +describe(getClassMetadataConstructorArguments.name, () => { + describe('when called, and getReflectMetadata() provides typescript metadata', () => { + let typescriptTypeFixture: Newable; + let typeFixture: Newable; + + let classElementMetadataFixture: ClassElementMetadata; + + let result: unknown; + + beforeAll(() => { + typescriptTypeFixture = class {}; + + typeFixture = class {}; + + classElementMetadataFixture = { + kind: ClassElementMetadataKind.unmanaged, + }; + + (getReflectMetadata as jest.Mock) + .mockReturnValueOnce([typescriptTypeFixture]) + .mockReturnValueOnce(undefined); + + ( + getClassElementMetadataFromNewable as jest.Mock< + typeof getClassElementMetadataFromNewable + > + ).mockReturnValueOnce(classElementMetadataFixture); + + result = getClassMetadataConstructorArguments(typeFixture); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should call getReflectMetadata()', () => { + expect(getReflectMetadata).toHaveBeenCalledTimes(2); + expect(getReflectMetadata).toHaveBeenNthCalledWith( + 1, + typeFixture, + DESIGN_PARAM_TYPES, + ); + expect(getReflectMetadata).toHaveBeenNthCalledWith( + 2, + typeFixture, + TAGGED, + ); + }); + + it('should call getClassElementMetadataFromNewable()', () => { + expect(getClassElementMetadataFromNewable).toHaveBeenCalledTimes(1); + expect(getClassElementMetadataFromNewable).toHaveBeenCalledWith( + typescriptTypeFixture, + ); + }); + + it('should return ClassElementMetadata[]', () => { + expect(result).toStrictEqual([classElementMetadataFixture]); + }); + }); + + describe('when called, and getReflectMetadata() provides tag metadata', () => { + let legacyMetadataMapPropertyFixture: string | symbol; + let legacyMetadataListFixture: LegacyMetadata[]; + + let typeFixture: Newable; + + let classElementMetadataFixture: ClassElementMetadata; + + let result: unknown; + + beforeAll(() => { + legacyMetadataMapPropertyFixture = '0'; + legacyMetadataListFixture = [ + { + key: 'key-fixture', + value: 'value-fixture', + }, + ]; + + const legacyMetadataMap: LegacyMetadataMap = { + [legacyMetadataMapPropertyFixture]: legacyMetadataListFixture, + }; + + typeFixture = class {}; + + classElementMetadataFixture = { + kind: ClassElementMetadataKind.unmanaged, + }; + + (getReflectMetadata as jest.Mock) + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(legacyMetadataMap); + + ( + getClassElementMetadataFromLegacyMetadata as jest.Mock< + typeof getClassElementMetadataFromLegacyMetadata + > + ).mockReturnValueOnce(classElementMetadataFixture); + + result = getClassMetadataConstructorArguments(typeFixture); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should call getReflectMetadata()', () => { + expect(getReflectMetadata).toHaveBeenCalledTimes(2); + expect(getReflectMetadata).toHaveBeenNthCalledWith( + 1, + typeFixture, + DESIGN_PARAM_TYPES, + ); + expect(getReflectMetadata).toHaveBeenNthCalledWith( + 2, + typeFixture, + TAGGED, + ); + }); + + it('should call getClassElementMetadataFromLegacyMetadata()', () => { + expect(getClassElementMetadataFromLegacyMetadata).toHaveBeenCalledTimes( + 1, + ); + expect(getClassElementMetadataFromLegacyMetadata).toHaveBeenCalledWith( + legacyMetadataListFixture, + ); + }); + + it('should return ClassElementMetadata[]', () => { + expect(result).toStrictEqual([classElementMetadataFixture]); + }); + }); + + describe('when called, and getReflectMetadata() provides both typescript and tag metadata', () => { + let legacyMetadataMapPropertyFixture: string | symbol; + let legacyMetadataListFixture: LegacyMetadata[]; + + let typescriptTypeFixture: Newable; + + let typeFixture: Newable; + + let classElementMetadataFixture: ClassElementMetadata; + + let result: unknown; + + beforeAll(() => { + legacyMetadataMapPropertyFixture = '0'; + legacyMetadataListFixture = [ + { + key: 'key-fixture', + value: 'value-fixture', + }, + ]; + + const legacyMetadataMap: LegacyMetadataMap = { + [legacyMetadataMapPropertyFixture]: legacyMetadataListFixture, + }; + + typescriptTypeFixture = class {}; + + typeFixture = class {}; + + classElementMetadataFixture = { + kind: ClassElementMetadataKind.unmanaged, + }; + + (getReflectMetadata as jest.Mock) + .mockReturnValueOnce([typescriptTypeFixture]) + .mockReturnValueOnce(legacyMetadataMap); + + ( + getClassElementMetadataFromLegacyMetadata as jest.Mock< + typeof getClassElementMetadataFromLegacyMetadata + > + ).mockReturnValueOnce(classElementMetadataFixture); + + result = getClassMetadataConstructorArguments(typeFixture); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should call getReflectMetadata()', () => { + expect(getReflectMetadata).toHaveBeenCalledTimes(2); + expect(getReflectMetadata).toHaveBeenNthCalledWith( + 1, + typeFixture, + DESIGN_PARAM_TYPES, + ); + expect(getReflectMetadata).toHaveBeenNthCalledWith( + 2, + typeFixture, + TAGGED, + ); + }); + + it('should call getClassElementMetadataFromLegacyMetadata()', () => { + expect(getClassElementMetadataFromLegacyMetadata).toHaveBeenCalledTimes( + 1, + ); + expect(getClassElementMetadataFromLegacyMetadata).toHaveBeenCalledWith( + legacyMetadataListFixture, + ); + }); + + it('should not call getClassElementMetadataFromNewable()', () => { + expect(getClassElementMetadataFromNewable).not.toHaveBeenCalled(); + }); + + it('should return ClassElementMetadata[]', () => { + expect(result).toStrictEqual([classElementMetadataFixture]); + }); + }); +}); diff --git a/packages/container/libraries/core/src/metadata/calculations/getClassMetadataConstructorArguments.ts b/packages/container/libraries/core/src/metadata/calculations/getClassMetadataConstructorArguments.ts new file mode 100644 index 00000000..c482ee0e --- /dev/null +++ b/packages/container/libraries/core/src/metadata/calculations/getClassMetadataConstructorArguments.ts @@ -0,0 +1,49 @@ +import { Newable } from '@inversifyjs/common'; +import { getReflectMetadata } from '@inversifyjs/reflect-metadata-utils'; + +import { DESIGN_PARAM_TYPES, TAGGED } from '../../reflectMetadata/data/keys'; +import { ClassElementMetadata } from '../models/ClassElementMetadata'; +import { LegacyMetadataMap } from '../models/LegacyMetadataMap'; +import { getClassElementMetadataFromLegacyMetadata } from './getClassElementMetadataFromLegacyMetadata'; +import { getClassElementMetadataFromNewable } from './getClassElementMetadataFromNewable'; + +export function getClassMetadataConstructorArguments< + TInstance, + TArgs extends unknown[], +>(type: Newable): ClassElementMetadata[] { + const typescriptMetadataList: Newable[] | undefined = getReflectMetadata( + type, + DESIGN_PARAM_TYPES, + ); + + const constructorParametersLegacyMetadata: LegacyMetadataMap | undefined = + getReflectMetadata(type, TAGGED); + + const constructorArgumentsMetadata: ClassElementMetadata[] = []; + + if (constructorParametersLegacyMetadata !== undefined) { + for (const [stringifiedIndex, metadataList] of Object.entries( + constructorParametersLegacyMetadata, + )) { + const index: number = parseInt(stringifiedIndex); + + constructorArgumentsMetadata[index] = + getClassElementMetadataFromLegacyMetadata(metadataList); + } + } + + if (typescriptMetadataList !== undefined) { + for (let i: number = 0; i < typescriptMetadataList.length; ++i) { + if (constructorArgumentsMetadata[i] === undefined) { + const typescriptMetadata: Newable = typescriptMetadataList[ + i + ] as Newable; + + constructorArgumentsMetadata[i] = + getClassElementMetadataFromNewable(typescriptMetadata); + } + } + } + + return constructorArgumentsMetadata; +} diff --git a/packages/container/libraries/core/src/metadata/calculations/getClassMetadataProperties.spec.ts b/packages/container/libraries/core/src/metadata/calculations/getClassMetadataProperties.spec.ts new file mode 100644 index 00000000..4cf4140a --- /dev/null +++ b/packages/container/libraries/core/src/metadata/calculations/getClassMetadataProperties.spec.ts @@ -0,0 +1,196 @@ +import { afterAll, beforeAll, describe, expect, it, jest } from '@jest/globals'; + +jest.mock('@inversifyjs/reflect-metadata-utils'); + +import { getReflectMetadata } from '@inversifyjs/reflect-metadata-utils'; + +jest.mock('./getClassElementMetadataFromLegacyMetadata'); + +import { Newable } from '@inversifyjs/common'; + +import { TAGGED_PROP } from '../../reflectMetadata/data/keys'; +import { ClassElementMetadata } from '../models/ClassElementMetadata'; +import { ClassElementMetadataKind } from '../models/ClassElementMetadataKind'; +import { LegacyMetadata } from '../models/LegacyMetadata'; +import { LegacyMetadataMap } from '../models/LegacyMetadataMap'; +import { getClassElementMetadataFromLegacyMetadata } from './getClassElementMetadataFromLegacyMetadata'; +import { getClassMetadataProperties } from './getClassMetadataProperties'; + +describe(getClassMetadataProperties.name, () => { + describe('when called, and getReflectMetadata returns undefined', () => { + let typeFixture: Newable; + + let result: unknown; + + beforeAll(() => { + typeFixture = class {}; + + ( + getReflectMetadata as jest.Mock + ).mockReturnValueOnce(undefined); + + result = getClassMetadataProperties(typeFixture); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should call getReflectMetadata()', () => { + expect(getReflectMetadata).toHaveBeenCalledTimes(1); + expect(getReflectMetadata).toHaveBeenCalledWith(typeFixture, TAGGED_PROP); + }); + + it('should return an empty Map', () => { + expect(result).toStrictEqual(new Map()); + }); + }); + + describe('when called, and getReflectMetadata returns LegacyMetadataMap with a symbol property', () => { + let legacyMetadataMapPropertyFixture: string | symbol; + let legacyMetadataListFixture: LegacyMetadata[]; + + let classElementMetadataFixture: ClassElementMetadata; + + let typeFixture: Newable; + + let result: unknown; + + beforeAll(() => { + legacyMetadataMapPropertyFixture = Symbol(); + legacyMetadataListFixture = [ + { + key: 'key-fixture', + value: 'value-fixture', + }, + ]; + + classElementMetadataFixture = { + kind: ClassElementMetadataKind.singleInjection, + name: undefined, + optional: false, + tags: new Map(), + targetName: undefined, + value: Symbol(), + }; + + typeFixture = class {}; + + const legacyMetadataMap: LegacyMetadataMap = { + [legacyMetadataMapPropertyFixture]: legacyMetadataListFixture, + }; + + ( + getReflectMetadata as jest.Mock + ).mockReturnValueOnce(legacyMetadataMap); + + ( + getClassElementMetadataFromLegacyMetadata as jest.Mock< + typeof getClassElementMetadataFromLegacyMetadata + > + ).mockReturnValueOnce(classElementMetadataFixture); + + result = getClassMetadataProperties(typeFixture); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should call getReflectMetadata()', () => { + expect(getReflectMetadata).toHaveBeenCalledTimes(1); + expect(getReflectMetadata).toHaveBeenCalledWith(typeFixture, TAGGED_PROP); + }); + + it('should call getClassElementMetadataFromLegacyMetadata()', () => { + expect(getClassElementMetadataFromLegacyMetadata).toHaveBeenCalledTimes( + 1, + ); + expect(getClassElementMetadataFromLegacyMetadata).toHaveBeenCalledWith( + legacyMetadataListFixture, + ); + }); + + it('should return an empty Map', () => { + const expected: Map = new Map([ + [legacyMetadataMapPropertyFixture, classElementMetadataFixture], + ]); + + expect(result).toStrictEqual(expected); + }); + }); + + describe('when called, and getReflectMetadata returns LegacyMetadataMap with a string property', () => { + let legacyMetadataMapPropertyFixture: string | symbol; + let legacyMetadataListFixture: LegacyMetadata[]; + + let classElementMetadataFixture: ClassElementMetadata; + + let typeFixture: Newable; + + let result: unknown; + + beforeAll(() => { + legacyMetadataMapPropertyFixture = 'property-fixture'; + legacyMetadataListFixture = [ + { + key: 'key-fixture', + value: 'value-fixture', + }, + ]; + + classElementMetadataFixture = { + kind: ClassElementMetadataKind.singleInjection, + name: undefined, + optional: false, + tags: new Map(), + targetName: undefined, + value: Symbol(), + }; + + typeFixture = class {}; + + const legacyMetadataMap: LegacyMetadataMap = { + [legacyMetadataMapPropertyFixture]: legacyMetadataListFixture, + }; + + ( + getReflectMetadata as jest.Mock + ).mockReturnValueOnce(legacyMetadataMap); + + ( + getClassElementMetadataFromLegacyMetadata as jest.Mock< + typeof getClassElementMetadataFromLegacyMetadata + > + ).mockReturnValueOnce(classElementMetadataFixture); + + result = getClassMetadataProperties(typeFixture); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should call getReflectMetadata()', () => { + expect(getReflectMetadata).toHaveBeenCalledTimes(1); + expect(getReflectMetadata).toHaveBeenCalledWith(typeFixture, TAGGED_PROP); + }); + + it('should call getClassElementMetadataFromLegacyMetadata()', () => { + expect(getClassElementMetadataFromLegacyMetadata).toHaveBeenCalledTimes( + 1, + ); + expect(getClassElementMetadataFromLegacyMetadata).toHaveBeenCalledWith( + legacyMetadataListFixture, + ); + }); + + it('should return an empty Map', () => { + const expected: Map = new Map([ + [legacyMetadataMapPropertyFixture, classElementMetadataFixture], + ]); + + expect(result).toStrictEqual(expected); + }); + }); +}); diff --git a/packages/container/libraries/core/src/metadata/calculations/getClassMetadataProperties.ts b/packages/container/libraries/core/src/metadata/calculations/getClassMetadataProperties.ts new file mode 100644 index 00000000..97afa502 --- /dev/null +++ b/packages/container/libraries/core/src/metadata/calculations/getClassMetadataProperties.ts @@ -0,0 +1,32 @@ +import { Newable } from '@inversifyjs/common'; +import { getReflectMetadata } from '@inversifyjs/reflect-metadata-utils'; + +import { TAGGED_PROP } from '../../reflectMetadata/data/keys'; +import { ClassElementMetadata } from '../models/ClassElementMetadata'; +import { LegacyMetadata } from '../models/LegacyMetadata'; +import { LegacyMetadataMap } from '../models/LegacyMetadataMap'; +import { getClassElementMetadataFromLegacyMetadata } from './getClassElementMetadataFromLegacyMetadata'; + +export function getClassMetadataProperties( + type: Newable, +): Map { + const propertiesLegacyMetadata: LegacyMetadataMap | undefined = + getReflectMetadata(type, TAGGED_PROP); + + const propertiesMetadata: Map = + new Map(); + + if (propertiesLegacyMetadata !== undefined) { + for (const property of Reflect.ownKeys(propertiesLegacyMetadata)) { + const legacyMetadata: LegacyMetadata[] = propertiesLegacyMetadata[ + property + ] as LegacyMetadata[]; + propertiesMetadata.set( + property, + getClassElementMetadataFromLegacyMetadata(legacyMetadata), + ); + } + } + + return propertiesMetadata; +} diff --git a/packages/container/libraries/core/src/metadata/models/BaseClassElementMetadata.ts b/packages/container/libraries/core/src/metadata/models/BaseClassElementMetadata.ts new file mode 100644 index 00000000..a97c7ae1 --- /dev/null +++ b/packages/container/libraries/core/src/metadata/models/BaseClassElementMetadata.ts @@ -0,0 +1,7 @@ +import { ClassElementMetadataKind } from './ClassElementMetadataKind'; + +export interface BaseClassElementMetadata< + TKind extends ClassElementMetadataKind, +> { + kind: TKind; +} diff --git a/packages/container/libraries/core/src/metadata/models/ClassElementMetadata.ts b/packages/container/libraries/core/src/metadata/models/ClassElementMetadata.ts new file mode 100644 index 00000000..2ba44e73 --- /dev/null +++ b/packages/container/libraries/core/src/metadata/models/ClassElementMetadata.ts @@ -0,0 +1,6 @@ +import { ManagedClassElementMetadata } from './ManagedClassElementMetadata'; +import { UnmanagedClassElementMetadata } from './UnmanagedClassElementMetadata'; + +export type ClassElementMetadata = + | ManagedClassElementMetadata + | UnmanagedClassElementMetadata; diff --git a/packages/container/libraries/core/src/metadata/models/ClassElementMetadataKind.ts b/packages/container/libraries/core/src/metadata/models/ClassElementMetadataKind.ts new file mode 100644 index 00000000..130f87c7 --- /dev/null +++ b/packages/container/libraries/core/src/metadata/models/ClassElementMetadataKind.ts @@ -0,0 +1,5 @@ +export enum ClassElementMetadataKind { + multipleInjection, + singleInjection, + unmanaged, +} diff --git a/packages/container/libraries/core/src/metadata/models/ClassMetadata.ts b/packages/container/libraries/core/src/metadata/models/ClassMetadata.ts new file mode 100644 index 00000000..03bc3d09 --- /dev/null +++ b/packages/container/libraries/core/src/metadata/models/ClassMetadata.ts @@ -0,0 +1,8 @@ +import { ClassElementMetadata } from './ClassElementMetadata'; +import { ClassMetadataLifecycle } from './ClassMetadataLifecycle'; + +export interface ClassMetadata { + constructorArguments: ClassElementMetadata[]; + lifecycle: ClassMetadataLifecycle; + properties: Map; +} diff --git a/packages/container/libraries/core/src/metadata/models/ClassMetadataLifecycle.ts b/packages/container/libraries/core/src/metadata/models/ClassMetadataLifecycle.ts new file mode 100644 index 00000000..f1a09374 --- /dev/null +++ b/packages/container/libraries/core/src/metadata/models/ClassMetadataLifecycle.ts @@ -0,0 +1,4 @@ +export interface ClassMetadataLifecycle { + postConstructMethodName: string | undefined; + preDestroyMethodName: string | undefined; +} diff --git a/packages/container/libraries/core/src/metadata/models/LegacyMetadata.ts b/packages/container/libraries/core/src/metadata/models/LegacyMetadata.ts new file mode 100644 index 00000000..fda9fdb8 --- /dev/null +++ b/packages/container/libraries/core/src/metadata/models/LegacyMetadata.ts @@ -0,0 +1,4 @@ +export interface LegacyMetadata { + key: string | number | symbol; + value: unknown; +} diff --git a/packages/container/libraries/core/src/metadata/models/LegacyMetadataMap.ts b/packages/container/libraries/core/src/metadata/models/LegacyMetadataMap.ts new file mode 100644 index 00000000..9bfae942 --- /dev/null +++ b/packages/container/libraries/core/src/metadata/models/LegacyMetadataMap.ts @@ -0,0 +1,5 @@ +import { LegacyMetadata } from './LegacyMetadata'; + +export interface LegacyMetadataMap { + [propertyNameOrArgumentIndex: string | symbol]: LegacyMetadata[]; +} diff --git a/packages/container/libraries/core/src/metadata/models/ManagedClassElementMetadata.ts b/packages/container/libraries/core/src/metadata/models/ManagedClassElementMetadata.ts new file mode 100644 index 00000000..822ce8c3 --- /dev/null +++ b/packages/container/libraries/core/src/metadata/models/ManagedClassElementMetadata.ts @@ -0,0 +1,19 @@ +import { LazyServiceIdentifier, ServiceIdentifier } from '@inversifyjs/common'; + +import { BaseClassElementMetadata } from './BaseClassElementMetadata'; +import { ClassElementMetadataKind } from './ClassElementMetadataKind'; +import { MetadataName } from './MetadataName'; +import { MetadataTag } from './MetadataTag'; +import { MetadataTargetName } from './MetadataTargetName'; + +export interface ManagedClassElementMetadata + extends BaseClassElementMetadata< + | ClassElementMetadataKind.singleInjection + | ClassElementMetadataKind.multipleInjection + > { + value: ServiceIdentifier | LazyServiceIdentifier; + name: MetadataName | undefined; + optional: boolean; + tags: Map; + targetName: MetadataTargetName | undefined; +} diff --git a/packages/container/libraries/core/src/metadata/models/MetadataName.ts b/packages/container/libraries/core/src/metadata/models/MetadataName.ts new file mode 100644 index 00000000..5ff359d8 --- /dev/null +++ b/packages/container/libraries/core/src/metadata/models/MetadataName.ts @@ -0,0 +1 @@ +export type MetadataName = number | string | symbol; diff --git a/packages/container/libraries/core/src/metadata/models/MetadataTag.ts b/packages/container/libraries/core/src/metadata/models/MetadataTag.ts new file mode 100644 index 00000000..67c4fa5d --- /dev/null +++ b/packages/container/libraries/core/src/metadata/models/MetadataTag.ts @@ -0,0 +1 @@ +export type MetadataTag = number | string | symbol; diff --git a/packages/container/libraries/core/src/metadata/models/MetadataTargetName.ts b/packages/container/libraries/core/src/metadata/models/MetadataTargetName.ts new file mode 100644 index 00000000..51c5f33b --- /dev/null +++ b/packages/container/libraries/core/src/metadata/models/MetadataTargetName.ts @@ -0,0 +1 @@ +export type MetadataTargetName = string; diff --git a/packages/container/libraries/core/src/metadata/models/UnmanagedClassElementMetadata.ts b/packages/container/libraries/core/src/metadata/models/UnmanagedClassElementMetadata.ts new file mode 100644 index 00000000..d9d758ca --- /dev/null +++ b/packages/container/libraries/core/src/metadata/models/UnmanagedClassElementMetadata.ts @@ -0,0 +1,5 @@ +import { BaseClassElementMetadata } from './BaseClassElementMetadata'; +import { ClassElementMetadataKind } from './ClassElementMetadataKind'; + +export type UnmanagedClassElementMetadata = + BaseClassElementMetadata; diff --git a/packages/container/libraries/core/src/reflectMetadata/data/keys.ts b/packages/container/libraries/core/src/reflectMetadata/data/keys.ts new file mode 100644 index 00000000..1589adf6 --- /dev/null +++ b/packages/container/libraries/core/src/reflectMetadata/data/keys.ts @@ -0,0 +1,48 @@ +// Used for named bindings +export const NAMED_TAG: string = 'named'; + +// The name of the target at design time +export const NAME_TAG: string = 'name'; + +// The for unmanaged injections (in base classes when using inheritance) +export const UNMANAGED_TAG: string = 'unmanaged'; + +// The for optional injections +export const OPTIONAL_TAG: string = 'optional'; + +// The type of the binding at design time +export const INJECT_TAG: string = 'inject'; + +// The type of the binding at design type for multi-injections +export const MULTI_INJECT_TAG: string = 'multi_inject'; + +// used to store constructor arguments tags +export const TAGGED: string = 'inversify:tagged'; + +// used to store class properties tags +export const TAGGED_PROP: string = 'inversify:tagged_props'; + +// used to store types to be injected +export const PARAM_TYPES: string = 'inversify:paramtypes'; + +// used to access design time types +export const DESIGN_PARAM_TYPES: string = 'design:paramtypes'; + +// used to identify postConstruct functions +export const POST_CONSTRUCT: string = 'post_construct'; + +// used to identify preDestroy functions +export const PRE_DESTROY: string = 'pre_destroy'; + +function getNonCustomTagKeys(): string[] { + return [ + INJECT_TAG, + MULTI_INJECT_TAG, + NAME_TAG, + UNMANAGED_TAG, + NAMED_TAG, + OPTIONAL_TAG, + ]; +} + +export const NON_CUSTOM_TAG_KEYS: string[] = getNonCustomTagKeys();