Source

Reflection.ts

import {
  ClassDecoratorsList,
  DecoratorMetadata,
  FullPropertyDecoratorList,
  PropertyDecoratorList,
} from "./types";
import { ReflectionKeys } from "./constants";
import { isEqual } from "./equality";

/**
 * @description A utility class for handling reflection metadata in TypeScript
 * @summary Namespace class holding reflection API that provides functionality to handle reflection metadata, type checking, and decorator management
 * @param {string} annotationPrefix - The prefix used to filter decorators in various methods
 * @param {object} target - The target object or class to retrieve metadata from
 * @param {string|symbol} propertyName - The name of the property to retrieve metadata for
 * @param {boolean} [ignoreType] - Whether to ignore type metadata in certain operations
 * @param {boolean} [recursive] - Whether to recursively traverse the prototype chain
 * @param {DecoratorMetadata[]} [accumulator] - Used to accumulate metadata during recursive operations
 * @class Reflection
 * @example
 * // Get all property decorators with a specific prefix
 * const model = new MyClass();
 * const decorators = Reflection.getAllPropertyDecorators(model, 'prefix');
 *
 * // Check if a value matches a specific type
 * const isString = Reflection.checkTypes(value, ['string']);
 *
 * // Get all properties of an object including inherited ones
 * const props = Reflection.getAllProperties(obj, true);
 *
 * // Get class decorators with a specific prefix
 * const classDecorators = Reflection.getClassDecorators('prefix', myClassInstance);
 *
 * // Get property type from decorator
 * const propType = Reflection.getTypeFromDecorator(model, 'propertyName');
 * @mermaid
 * sequenceDiagram
 *   participant Client
 *   participant Reflection
 *   participant ReflectAPI
 *
 *   Client->>Reflection: getAllPropertyDecorators(model, prefix)
 *   Reflection->>Reflection: getPropertyDecorators(prefix, model, propKey)
 *   Reflection->>ReflectAPI: getMetadataKeys(target, propertyName)
 *   ReflectAPI-->>Reflection: keys[]
 *   Reflection->>ReflectAPI: getMetadata(key, target, propertyName)
 *   ReflectAPI-->>Reflection: metadata
 *   Reflection-->>Client: decorators
 */
export class Reflection {
  private constructor() {}

  /**
   * @description Checks if a value matches a specified type name
   * @summary Utility function to verify if a value's type matches the provided type name
   * @param {unknown} value - The value to check the type of
   * @param {string} acceptedType - The type name to check against
   * @return {boolean} Returns true if the value matches the accepted type, false otherwise
   */
  private static checkType(value: unknown, acceptedType: string) {
    if (typeof value === acceptedType.toLowerCase()) return true;
    if (typeof value === "undefined") return false;
    if (typeof value !== "object") return false;
    return (
      (value as object).constructor &&
      (value as object).constructor.name.toLowerCase() ===
        acceptedType.toLowerCase()
    );
  }

  /**
   * @description Checks if a value matches any of the specified type names
   * @summary Utility function to verify if a value's type matches any of the provided type names
   * @param {unknown} value - The value to check the type of
   * @param {string[]} acceptedTypes - Array of type names to check against
   * @return {boolean} Returns true if the value matches any of the accepted types, false otherwise
   */
  static checkTypes(value: unknown, acceptedTypes: string[]) {
    return !acceptedTypes.every((t) => !this.checkType(value, t));
  }

  /**
   * @description Evaluates if a value matches the specified type metadata
   * @summary Compares a value against type metadata to determine if they match
   * @param {unknown} value - The value to evaluate
   * @param {string | string[] | {name: string}} types - Type metadata to check against, can be a string, array of strings, or an object with a name property
   * @return {boolean} Returns true if the value matches the type metadata, false otherwise
   */
  static evaluateDesignTypes(
    value: unknown,
    types: string | string[] | { name: string }
  ) {
    switch (typeof types) {
      case "string":
        return this.checkType(value, types);
      case "object":
        if (Array.isArray(types)) return Reflection.checkTypes(value, types);
        return true;
      case "function":
        if (types.name && types.name !== "Object")
          return this.checkType(value, types.name);
        return true;
      default:
        return true;
    }
  }

  /**
   * @description Retrieves all properties of an object
   * @summary Collects all property names from an object, optionally including those from its prototype chain
   * @param {Record<string, unknown>} obj - The object to retrieve properties from
   * @param {boolean} [climbTree=true] - Whether to crawl up the prototype chain
   * @param {string} [stopAt="Object"] - The constructor name at which to stop climbing the prototype chain
   * @return {string[]} An array of all property names found in the object
   */
  static getAllProperties(
    obj: Record<string, unknown>,
    climbTree: boolean = true,
    stopAt: string = "Object"
  ) {
    const allProps = new Set<string>();
    let curr: Record<string, unknown> = obj;

    const keepAtIt = function () {
      if (!climbTree) return;
      const prototype = Object.getPrototypeOf(curr);
      if (!prototype || prototype.constructor.name === stopAt) return;
      curr = prototype;
      return curr;
    };

    do {
      Object.getOwnPropertyNames(curr).forEach((prop) => allProps.add(prop));
    } while (keepAtIt());

    return Array.from(allProps);
  }

  /**
   * @description Retrieves all class decorators with a specific prefix
   * @summary Utility function to extract class-level decorators that start with a given prefix
   * @param {string} annotationPrefix - The prefix to filter decorators by
   * @param {object} target - The class instance to retrieve decorators from
   * @return {ClassDecoratorsList} An array of objects containing decorator keys and their properties
   */
  static getClassDecorators(
    annotationPrefix: string,
    target: object
  ): ClassDecoratorsList {
    const keys: string[] = Reflect.getOwnMetadataKeys(target.constructor);
    const result: ClassDecoratorsList = [];

    for (const key of keys) {
      if (key.startsWith(annotationPrefix)) {
        result.push({
          key: key.slice(annotationPrefix.length),
          props: Reflect.getMetadata(key, target.constructor),
        });
      }
    }

    return result;
  }

  /**
   * @description Retrieves all property decorators with specific prefixes for an object
   * @summary Collects all decorators for an object's properties that start with any of the provided prefixes
   * @template M - Type of the model object
   * @param {M} model - The object to retrieve property decorators from
   * @param {string[]} prefixes - Array of prefixes to filter decorators by
   * @return {Record<string, DecoratorMetadata[]> | undefined} A record mapping property names to their decorators, or undefined if none found
   */
  static getAllPropertyDecorators<M extends object>(
    model: M,
    ...prefixes: string[]
  ): PropertyDecoratorList | undefined {
    if (!prefixes || prefixes.length === 0) return undefined;

    const result: PropertyDecoratorList = {};
    const properties = Object.getOwnPropertyNames(model);

    for (const propKey of properties) {
      for (let i = 0; i < prefixes.length; i++) {
        const decorators = Reflection.getPropertyDecorators(
          prefixes[i],
          model,
          propKey,
          i !== 0
        );
        if (decorators.decorators.length > 0) {
          if (!result[propKey]) {
            result[propKey] = [];
          }
          result[propKey].push(...decorators.decorators);
        }
      }
    }

    return Object.keys(result).length > 0 ? result : undefined;
  }

  /**
   * @description Uses metadata to discover the type of a property from its decorator
   * @summary Extracts the type information from a property's decorator metadata
   * @param {object} model - The object containing the property
   * @param {string | symbol} propKey - The key of the property to get the type for
   * @return {string | undefined} The type name of the property, or undefined if not found or if the type is Function
   */
  static getTypeFromDecorator(
    model: object,
    propKey: string | symbol
  ): string | undefined {
    const decorators: PropertyDecoratorList = Reflection.getPropertyDecorators(
      ReflectionKeys.TYPE,
      model,
      propKey,
      false
    );
    if (!decorators || !decorators.decorators) return;

    const typeDecorator: DecoratorMetadata =
      decorators.decorators.shift() as DecoratorMetadata;
    const name = typeDecorator.props
      ? (typeDecorator.props.name as string)
      : undefined;
    return name !== "Function" ? name : undefined;
  }

  /**
   * @description Retrieves all decorators for a specific property
   * @summary Utility function to extract property-level decorators that start with a given prefix, with options for recursive prototype chain traversal
   * @param {string} annotationPrefix - The prefix to filter decorators by
   * @param {object} target - The object containing the property
   * @param {string | symbol} propertyName - The name of the property to retrieve decorators for
   * @param {boolean} [ignoreType=false] - Whether to ignore the TYPE metadata key
   * @param {boolean} [recursive=true] - Whether to climb the prototype chain looking for more decorators
   * @param {DecoratorMetadata[]} [accumulator] - Used internally to accumulate decorators during recursive calls
   * @return {FullPropertyDecoratorList} An object containing the property name and its decorators
   * @mermaid
   * sequenceDiagram
   *   participant Client
   *   participant Reflection
   *   participant InnerFunction
   *   participant ReflectAPI
   *
   *   Client->>Reflection: getPropertyDecorators(prefix, target, propName)
   *   Reflection->>InnerFunction: getPropertyDecoratorsForModel(prefix, target, propName)
   *   InnerFunction->>ReflectAPI: getMetadataKeys(target, propertyName)
   *   ReflectAPI-->>InnerFunction: keys[]
   *   InnerFunction->>ReflectAPI: getMetadata(key, target, propertyName)
   *   ReflectAPI-->>InnerFunction: metadata
   *   InnerFunction-->>Reflection: {prop, decorators}
   *
   *   alt recursive && not Object.prototype
   *     Reflection->>Reflection: getPropertyDecorators(prefix, prototype, propName, true, recursive, result.decorators)
   *   else
   *     Reflection->>Reflection: trim(result.decorators)
   *   end
   *
   *   Reflection-->>Client: {prop, decorators}
   */
  static getPropertyDecorators(
    annotationPrefix: string,
    target: object,
    propertyName: string | symbol,
    ignoreType: boolean = false,
    recursive = true,
    accumulator?: DecoratorMetadata[]
  ): FullPropertyDecoratorList {
    const getPropertyDecoratorsForModel = function (
      annotationPrefix: string,
      target: object,
      propertyName: string | symbol,
      ignoreType: boolean = false,
      accumulator?: DecoratorMetadata[]
    ): { prop: string; decorators: DecoratorMetadata[] } {
      // get info about keys that used in current property
      const keys: string[] = Reflect.getMetadataKeys(target, propertyName);
      const decorators: DecoratorMetadata[] = keys
        // filter your custom decorators
        .filter((key) => {
          if (ignoreType) return key.toString().startsWith(annotationPrefix);
          return (
            key === ReflectionKeys.TYPE ||
            key.toString().startsWith(annotationPrefix)
          );
        })
        .reduce((values, key) => {
          // get metadata value.
          const currValues = {
            key:
              key !== ReflectionKeys.TYPE
                ? key.substring(annotationPrefix.length)
                : key,
            props: Reflect.getMetadata(key, target, propertyName),
          };
          return values.concat(currValues);
        }, accumulator || []);

      return {
        prop: propertyName.toString(),
        decorators: decorators,
      };
    };

    const result: { prop: string; decorators: DecoratorMetadata[] } =
      getPropertyDecoratorsForModel(
        annotationPrefix,
        target,
        propertyName,
        ignoreType,
        accumulator
      );

    const trim = function (items: DecoratorMetadata[]) {
      const cache: Record<string, DecoratorMetadata> = {};
      return items.filter((item: DecoratorMetadata) => {
        if (item.key in cache) {
          if (!isEqual(item.props, cache[item.key]))
            console.log(
              `Found a similar decorator for the ${item.key} property` +
                `of a ${target.constructor.name} model but with different attributes.` +
                "The original one will be kept"
            );
          return false;
        }

        cache[item.key.toString()] = item.props as DecoratorMetadata;
        return true;
      });
    };

    if (!recursive || Object.getPrototypeOf(target) === Object.prototype) {
      return {
        prop: result.prop as any,
        decorators: trim(result.decorators),
      };
    }

    // We choose to ignore type here, because in inheritance the expected type is from the lowest child class
    return Reflection.getPropertyDecorators(
      annotationPrefix,
      Object.getPrototypeOf(target.constructor),
      propertyName,
      true,
      recursive,
      result.decorators
    );
  }
}