import { IInitializable } from '../interfaces/IInitializable'; const decoratorName: string = 'initializable'; const defaultDescriptor: PropertyDescriptor = { configurable: true, enumerable: true }; const initializedTargetMetadataKey: string = '_initialized'; const initializablePropertiesSetMetadataKey: string = '_initializablePropertiesSet'; const wrappedMethodsSetMetadataKey: string = '_wrappedMethodsSet'; const constructorMethodName: 'constructor' = 'constructor'; const initializeMethodName: 'initialize' = 'initialize'; /** * @param {string} initializeMethodName * @returns {(target: IInitializable, propertyKey: (string | symbol)) => any} */ export function initializable (): (target: IInitializable, propertyKey: string | symbol) => any { return (target: IInitializable, propertyKey: string | symbol): PropertyDescriptor => { const initializeMethod: Function = target[initializeMethodName]; const isInvalidInitializeMethod = !initializeMethod || typeof initializeMethod !== 'function'; if (isInvalidInitializeMethod) { throw new Error(`\`${initializeMethodName}\` method with initialization logic not ` + `found. \`@${decoratorName}\` decorator requires \`${initializeMethodName}\` method`); } /** * Stage #1: initialize target metadata */ initializeTargetMetadata(initializedTargetMetadataKey, false, target); initializeTargetMetadata(initializablePropertiesSetMetadataKey, new Set(), target); initializeTargetMetadata(wrappedMethodsSetMetadataKey, new Set(), target); /** * Stage #2: wrap target methods */ wrapTargetMethodsInInitializedCheck(target); wrapInitializeMethodInInitializeCheck(target, propertyKey); /** * Stage #3: wrap target properties */ return wrapInitializableProperty(target, propertyKey); }; } /** * @param {string} metadataKey * @param metadataValue * @param {IInitializable} target */ function initializeTargetMetadata (metadataKey: string, metadataValue: any, target: IInitializable): void { const hasInitializedMetadata: boolean = Reflect.hasMetadata(metadataKey, target); if (!hasInitializedMetadata) { Reflect.defineMetadata(metadataKey, metadataValue, target); } } /** * Wraps all target methods with additional logic that check that this methods will called after `initialize` method * * @param {IInitializable} target */ function wrapTargetMethodsInInitializedCheck (target: IInitializable): void { const ownPropertyNames: string[] = Object.getOwnPropertyNames(target); const prohibitedPropertyNames: Set = new Set([initializeMethodName, constructorMethodName]); ownPropertyNames.forEach((propertyName: string) => { const initializablePropertiesSet: Set = Reflect .getMetadata(initializablePropertiesSetMetadataKey, target); const wrappedMethodsSet: Set = Reflect .getMetadata(wrappedMethodsSetMetadataKey, target); const isProhibitedPropertyName: boolean = prohibitedPropertyNames.has(propertyName) || initializablePropertiesSet.has(propertyName) || wrappedMethodsSet.has(propertyName); if (isProhibitedPropertyName) { return; } const targetProperty: IInitializable[keyof IInitializable] = target[propertyName]; if (typeof targetProperty !== 'function') { return; } const methodDescriptor: PropertyDescriptor = Object .getOwnPropertyDescriptor(target, propertyName) ?? defaultDescriptor; const originalMethod: Function = methodDescriptor.value; Object.defineProperty(target, propertyName, { ...methodDescriptor, value (): void { if (!Reflect.getMetadata(initializedTargetMetadataKey, this)) { throw new Error(`Class should be initialized with \`${initializeMethodName}()\` method`); } return originalMethod.apply(this, arguments); } }); wrappedMethodsSet.add(propertyName); }); } /** * Wraps `initialize` method with additional logic to check that `initialized` properties will set * * @param {IInitializable} target * @param {string | symbol} propertyKey */ function wrapInitializeMethodInInitializeCheck ( target: IInitializable, propertyKey: string | symbol ): void { const methodDescriptor: PropertyDescriptor = Object .getOwnPropertyDescriptor(target, initializeMethodName) ?? defaultDescriptor; const originalMethod: Function = methodDescriptor.value; Object.defineProperty(target, initializeMethodName, { ...methodDescriptor, value: function (): typeof originalMethod { /** * should define metadata before `initialize` method call, * because of cases when other methods will called inside `initialize` method */ Reflect.defineMetadata(initializedTargetMetadataKey, true, this); const result: typeof originalMethod = originalMethod.apply(this, arguments); if (this[propertyKey]) {} return result; } }); } /** * Wraps initializable property in additional checks * * @param {IInitializable} target * @param {string | symbol} propertyKey * @returns {PropertyDescriptor} */ function wrapInitializableProperty (target: IInitializable, propertyKey: string | symbol): PropertyDescriptor { const initializablePropertiesSet: Set = Reflect .getMetadata(initializablePropertiesSetMetadataKey, target); initializablePropertiesSet.add(propertyKey); const initializablePropertyMetadataKey: string = `_${propertyKey.toString()}`; const propertyDescriptor: PropertyDescriptor = Object .getOwnPropertyDescriptor(target, initializablePropertyMetadataKey) ?? defaultDescriptor; Object.defineProperty(target, propertyKey, { ...propertyDescriptor, get: function (): any { if (this[initializablePropertyMetadataKey] === undefined) { throw new Error(`Property \`${propertyKey.toString()}\` is not initialized! Initialize it first!`); } return this[initializablePropertyMetadataKey]; }, set: function (newVal: any): void { this[initializablePropertyMetadataKey] = newVal; } }); return propertyDescriptor; }