Source

model/Model.ts

import { Serialization } from "../utils/serialization";
import { BuilderRegistry } from "../utils/registry";
import { ModelErrorDefinition } from "./ModelErrorDefinition";
import {
  Comparable,
  Constructor,
  Hashable,
  ModelArg,
  ModelBuilderFunction,
  ModelConstructor,
  Serializable,
  Validatable,
} from "./types";
import { DecoratorMetadata, isEqual, Reflection } from "@decaf-ts/reflection";
import { validate } from "./validation";
import { Hashing } from "../utils/hashing";
import { ModelKeys } from "../utils/constants";
import { ValidationKeys } from "../validation/Validators/constants";
import { jsTypes, ReservedModels } from "./constants";
import { getMetadata, getModelKey } from "./utils";
import { ConditionalAsync } from "../types";
import { ASYNC_META_KEY } from "../constants";

let modelBuilderFunction: ModelBuilderFunction | undefined;
let actingModelRegistry: BuilderRegistry<any>;

/**
 * @description Registry type for storing and retrieving model constructors
 * @summary The ModelRegistry type defines a registry for model constructors that extends
 * the BuilderRegistry interface. It provides a standardized way to register, retrieve,
 * and build model instances, enabling the model system to work with different types of models.
 *
 * @interface ModelRegistry
 * @template T Type of model that can be registered, must extend Model
 * @extends BuilderRegistry<T>
 * @memberOf module:decorator-validation
 * @category Model
 */
export type ModelRegistry<T extends Model> = BuilderRegistry<T>;

/**
 * @description Registry manager for model constructors that enables serialization and rebuilding
 * @summary The ModelRegistryManager implements the ModelRegistry interface and provides
 * functionality for registering, retrieving, and building model instances. It maintains
 * a cache of model constructors indexed by name, allowing for efficient lookup and instantiation.
 * This class is essential for the serialization and deserialization of model objects.
 *
 * @param {function(Record<string, any>): boolean} [testFunction] - Function to test if an object is a model, defaults to {@link Model#isModel}
 *
 * @class ModelRegistryManager
 * @template M Type of model that can be registered, must extend Model
 * @implements ModelRegistry<M>
 * @category Model
 *
 * @example
 * ```typescript
 * // Create a model registry
 * const registry = new ModelRegistryManager();
 *
 * // Register a model class
 * registry.register(User);
 *
 * // Retrieve a model constructor by name
 * const UserClass = registry.get("User");
 *
 * // Build a model instance from a plain object
 * const userData = { name: "John", age: 30 };
 * const user = registry.build(userData, "User");
 * ```
 *
 * @mermaid
 * sequenceDiagram
 *   participant C as Client
 *   participant R as ModelRegistryManager
 *   participant M as Model Class
 *
 *   C->>R: new ModelRegistryManager(testFunction)
 *   C->>R: register(ModelClass)
 *   R->>R: Store in cache
 *   C->>R: get("ModelName")
 *   R-->>C: ModelClass constructor
 *   C->>R: build(data, "ModelName")
 *   R->>R: Get constructor from cache
 *   R->>M: new ModelClass(data)
 *   M-->>R: Model instance
 *   R-->>C: Model instance
 */
export class ModelRegistryManager<M extends Model<true | false>>
  implements ModelRegistry<M>
{
  private cache: Record<string, ModelConstructor<M>> = {};
  private readonly testFunction: (obj: object) => boolean;

  constructor(
    testFunction: (obj: Record<string, any>) => boolean = Model.isModel
  ) {
    this.testFunction = testFunction;
  }

  /**
   * @description Registers a model constructor with the registry
   * @summary Adds a model constructor to the registry cache, making it available for
   * later retrieval and instantiation. If no name is provided, the constructor's name
   * property is used as the key in the registry.
   *
   * @param {ModelConstructor<M>} constructor - The model constructor to register
   * @param {string} [name] - Optional name to register the constructor under, defaults to constructor.name
   * @return {void}
   * @throws {Error} If the constructor is not a function
   */
  register(constructor: ModelConstructor<M>, name?: string): void {
    if (typeof constructor !== "function")
      throw new Error(
        "Model registering failed. Missing Class name or constructor"
      );
    name = name || constructor.name;
    this.cache[name] = constructor;
  }

  /**
   * @summary Gets a registered Model {@link ModelConstructor}
   * @param {string} name
   */
  get(name: string): ModelConstructor<M> | undefined {
    try {
      return this.cache[name];
      // eslint-disable-next-line @typescript-eslint/no-unused-vars
    } catch (e: any) {
      return undefined;
    }
  }

  /**
   * @param {Record<string, any>} obj
   * @param {string} [clazz] when provided, it will attempt to find the matching constructor
   *
   * @throws Error If clazz is not found, or obj is not a {@link Model} meaning it has no {@link ModelKeys.ANCHOR} property
   */
  build(obj: Record<string, any> = {}, clazz?: string): M {
    if (!clazz && !this.testFunction(obj))
      throw new Error("Provided obj is not a Model object");
    const name = clazz || Model.getMetadata(obj as any);
    if (!(name in this.cache))
      throw new Error(
        `Provided class ${name} is not a registered Model object`
      );
    return new this.cache[name](obj);
  }
}

/**
 * @summary Bulk Registers Models
 * @description Useful when using bundlers that might not evaluate all the code at once
 *
 * @template M extends Model
 * @param {Array<Constructor<M>> | Array<{name: string, constructor: Constructor<M>}>} [models]
 *
 * @memberOf module:decorator-validation
 * @category Model
 */
export function bulkModelRegister<M extends Model>(
  ...models: (Constructor<M> | { name: string; constructor: Constructor<M> })[]
) {
  models.forEach(
    (m: Constructor<M> | { name: string; constructor: Constructor<M> }) => {
      const constructor: Constructor<M> = (
        m.constructor ? m.constructor : m
      ) as Constructor<M>;
      Model.register(constructor, (m as Constructor<M>).name);
    }
  );
}

/**
 * @summary Abstract class representing a Validatable Model object
 * @description Meant to be used as a base class for all Model classes
 *
 * Model objects must:
 *  - Have all their required properties marked with '!';
 *  - Have all their optional properties marked as '?':
 *
 * @param {ModelArg<Model>} model base object from which to populate properties from
 *
 * @class Model
 * @category Model
 * @abstract
 * @implements Validatable
 * @implements Serializable
 *
 * @example
 *      class ClassName {
 *          @required()
 *          requiredPropertyName!: PropertyType;
 *
 *          optionalPropertyName?: PropertyType;
 *      }
 */
export abstract class Model<Async extends boolean = false>
  implements
    Validatable<Async>,
    Serializable,
    Hashable,
    Comparable<Model<Async>>
{
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  protected constructor(arg: ModelArg<Model> | undefined = undefined) {}

  public isAsync(): boolean {
    const self = this as any;
    return !!(self[ASYNC_META_KEY] ?? self?.constructor[ASYNC_META_KEY]);
  }

  /**
   * @description Validates the model object against its defined validation rules
   * @summary Validates the object according to its decorated properties, returning any validation errors
   *
   * @param {any[]} [exceptions] - Properties in the object to be ignored for the validation. Marked as 'any' to allow for extension but expects strings
   * @return {ModelErrorDefinition | undefined} - Returns a ModelErrorDefinition object if validation errors exist, otherwise undefined
   */
  public hasErrors(
    ...exceptions: any[]
  ): ConditionalAsync<Async, ModelErrorDefinition | undefined> {
    return validate<any, Async>(
      this,
      this.isAsync() as any,
      ...exceptions
    ) as any;
  }

  /**
   * @description Determines if this model is equal to another object
   * @summary Compare object equality recursively, checking all properties unless excluded
   *
   * @param {any} obj - Object to compare to
   * @param {string[]} [exceptions] - Property names to be excluded from the comparison
   * @return {boolean} - True if objects are equal, false otherwise
   */
  public equals(obj: any, ...exceptions: string[]): boolean {
    return isEqual(this, obj, ...exceptions);
  }

  /**
   * @description Converts the model to a serialized string representation
   * @summary Returns the serialized model according to the currently defined {@link Serializer}
   *
   * @return {string} - The serialized string representation of the model
   */
  serialize(): string {
    return Model.serialize(this);
  }

  /**
   * @description Provides a human-readable string representation of the model
   * @summary Override the implementation for js's 'toString()' to provide a more useful representation
   *
   * @return {string} - A string representation of the model including its class name and JSON representation
   * @override
   */
  public toString(): string {
    return this.constructor.name + ": " + JSON.stringify(this, undefined, 2);
  }

  /**
   * @description Generates a hash string for the model object
   * @summary Defines a default implementation for object hash, relying on a basic implementation based on Java's string hash
   *
   * @return {string} - A hash string representing the model
   */
  public hash(): string {
    return Model.hash(this);
  }

  /**
   * @description Converts a serialized string back into a model instance
   * @summary Deserializes a Model from its string representation
   *
   * @param {string} str - The serialized string to convert back to a model
   * @return {any} - The deserialized model instance
   * @throws {Error} If it fails to parse the string, or if it fails to build the model
   */
  static deserialize(str: string) {
    const metadata = Reflect.getMetadata(
      Model.key(ModelKeys.SERIALIZATION),
      this.constructor
    );

    if (metadata && metadata.serializer)
      return Serialization.deserialize(
        str,
        metadata.serializer,
        ...(metadata.args || [])
      );
    return Serialization.deserialize(str);
  }

  /**
   * @description Copies properties from a source object to a model instance
   * @summary Repopulates the Object properties with the ones from the new object
   *
   * @template T
   * @param {T} self - The target model instance to update
   * @param {T | Record<string, any>} [obj] - The source object containing properties to copy
   * @return {T} - The updated model instance
   */
  static fromObject<T extends Model<any>>(
    self: T,
    obj?: T | Record<string, any>
  ): T {
    if (!obj) obj = {};
    for (const prop of Model.getAttributes(self)) {
      (self as any)[prop] = (obj as any)[prop] || undefined;
    }
    return self;
  }

  /**
   * @description Copies and rebuilds properties from a source object to a model instance, handling nested models
   * @summary Repopulates the instance with properties from the new Model Object, recursively rebuilding nested models
   *
   * @template T
   * @param {T} self - The target model instance to update
   * @param {T | Record<string, any>} [obj] - The source object containing properties to copy
   * @return {T} - The updated model instance with rebuilt nested models
   *
   * @mermaid
   * sequenceDiagram
   *   participant C as Client
   *   participant M as Model.fromModel
   *   participant B as Model.build
   *   participant R as Reflection
   *
   *   C->>M: fromModel(self, obj)
   *   M->>M: Get attributes from self
   *   loop For each property
   *     M->>M: Copy property from obj to self
   *     alt Property is a model
   *       M->>M: Check if property is a model
   *       M->>B: build(property, modelType)
   *       B-->>M: Return built model
   *     else Property is a complex type
   *       M->>R: Get property decorators
   *       R-->>M: Return decorators
   *       M->>M: Filter type decorators
   *       alt Property is Array/Set with list decorator
   *         M->>M: Process each item in collection
   *         loop For each item
   *           M->>B: build(item, itemModelType)
   *           B-->>M: Return built model
   *         end
   *       else Property is another model type
   *         M->>B: build(property, propertyType)
   *         B-->>M: Return built model
   *       end
   *     end
   *   end
   *   M-->>C: Return updated self
   */
  static fromModel<T extends Model>(self: T, obj?: T | Record<string, any>): T {
    if (!obj) obj = {};

    let decorators: DecoratorMetadata[], dec: DecoratorMetadata;

    const props = Model.getAttributes(self);

    for (const prop of props) {
      (self as Record<string, any>)[prop] =
        (obj as Record<string, any>)[prop] ?? undefined;
      if (typeof (self as any)[prop] !== "object") continue;
      const propM = Model.isPropertyModel(self, prop);
      if (propM) {
        try {
          (self as Record<string, any>)[prop] = Model.build(
            (self as Record<string, any>)[prop],
            typeof propM === "string" ? propM : undefined
          );
        } catch (e: any) {
          console.log(e);
        }
        continue;
      }

      const allDecorators: DecoratorMetadata[] =
        Reflection.getPropertyDecorators(
          ValidationKeys.REFLECT,
          self,
          prop
        ).decorators;
      decorators = allDecorators.filter(
        (d: DecoratorMetadata) =>
          [ModelKeys.TYPE, ValidationKeys.TYPE as string].indexOf(d.key) !== -1
      );
      if (!decorators || !decorators.length)
        throw new Error(`failed to find decorators for property ${prop}`);
      dec = decorators.pop() as DecoratorMetadata;
      const clazz = dec.props.name
        ? [dec.props.name]
        : Array.isArray(dec.props.customTypes)
          ? dec.props.customTypes
          : [dec.props.customTypes];
      const reserved = Object.values(ReservedModels).map((v) =>
        v.toLowerCase()
      ) as string[];

      clazz.forEach((c) => {
        if (reserved.indexOf(c.toLowerCase()) === -1)
          try {
            switch (c) {
              case "Array":
              case "Set":
                if (allDecorators.length) {
                  const listDec = allDecorators.find(
                    (d) => d.key === ValidationKeys.LIST
                  );
                  if (listDec) {
                    const clazzName = (listDec.props.clazz as string[]).find(
                      (t: string) => !jsTypes.includes(t.toLowerCase())
                    );
                    if (c === "Array")
                      (self as Record<string, any>)[prop] = (
                        self as Record<string, any>
                      )[prop].map((el: any) => {
                        return ["object", "function"].includes(typeof el) &&
                          clazzName
                          ? Model.build(el, clazzName)
                          : el;
                      });
                    if (c === "Set") {
                      const s = new Set();
                      for (const v of (self as Record<string, any>)[prop]) {
                        if (
                          ["object", "function"].includes(typeof v) &&
                          clazzName
                        ) {
                          s.add(Model.build(v, clazzName));
                        } else {
                          s.add(v);
                        }
                      }
                      (self as Record<string, any>)[prop] = s;
                    }
                  }
                }
                break;
              default:
                if (
                  typeof self[prop as keyof typeof self] !== "undefined" &&
                  Model.get(c)
                )
                  (self as Record<string, any>)[prop] = Model.build(
                    (self as any)[prop],
                    c
                  );
            }
          } catch (e: any) {
            console.log(e);
            // do nothing. we have no registry of this class
          }
      });
    }
    return self;
  }

  /**
   * @description Configures the global model builder function
   * @summary Sets the Global {@link ModelBuilderFunction} used for building model instances
   *
   * @param {ModelBuilderFunction} [builder] - The builder function to set as the global builder
   * @return {void}
   */
  static setBuilder(builder?: ModelBuilderFunction) {
    modelBuilderFunction = builder;
  }

  /**
   * @description Retrieves the currently configured global model builder function
   * @summary Returns the current global {@link ModelBuilderFunction} used for building model instances
   *
   * @return {ModelBuilderFunction | undefined} - The current global builder function or undefined if not set
   */
  static getBuilder(): ModelBuilderFunction | undefined {
    return modelBuilderFunction || Model.fromModel;
  }

  /**
   * @description Provides access to the current model registry
   * @summary Returns the current {@link ModelRegistryManager} instance, creating one if it doesn't exist
   *
   * @return {ModelRegistry<any>} - The current model registry, defaults to a new {@link ModelRegistryManager} if not set
   * @private
   */
  private static getRegistry() {
    if (!actingModelRegistry) actingModelRegistry = new ModelRegistryManager();
    return actingModelRegistry;
  }

  /**
   * @description Configures the model registry to be used by the Model system
   * @summary Sets the current model registry to a custom implementation
   *
   * @param {BuilderRegistry<any>} modelRegistry - The new implementation of Registry to use
   * @return {void}
   */
  static setRegistry(modelRegistry: BuilderRegistry<any>) {
    actingModelRegistry = modelRegistry;
  }

  /**
   * @description Registers a model constructor with the model registry
   * @summary Registers new model classes to make them available for serialization and deserialization
   *
   * @template T
   * @param {ModelConstructor<T>} constructor - The model constructor to register
   * @param {string} [name] - Optional name to register the constructor under, defaults to constructor.name
   * @return {void}
   *
   * @see ModelRegistry
   */
  static register<T extends Model>(
    constructor: ModelConstructor<T>,
    name?: string
  ): void {
    return Model.getRegistry().register(constructor, name);
  }

  /**
   * @description Retrieves a registered model constructor by name
   * @summary Gets a registered Model {@link ModelConstructor} from the model registry
   *
   * @template T
   * @param {string} name - The name of the model constructor to retrieve
   * @return {ModelConstructor<T> | undefined} - The model constructor if found, undefined otherwise
   *
   * @see ModelRegistry
   */
  static get<T extends Model>(name: string): ModelConstructor<T> | undefined {
    return Model.getRegistry().get(name);
  }

  /**
   * @description Creates a model instance from a plain object
   * @summary Builds a model instance using the model registry, optionally specifying the model class
   *
   * @template T
   * @param {Record<string, any>} obj - The source object to build the model from
   * @param {string} [clazz] - When provided, it will attempt to find the matching constructor by name
   * @return {T} - The built model instance
   * @throws {Error} If clazz is not found, or obj is not a {@link Model} meaning it has no {@link ModelKeys.ANCHOR} property
   *
   * @see ModelRegistry
   */
  static build<T extends Model>(
    obj: Record<string, any> = {},
    clazz?: string
  ): T {
    return Model.getRegistry().build(obj, clazz);
  }

  /**
   * @description Retrieves the model metadata from a model instance
   * @summary Gets the metadata associated with a model instance, typically the model class name
   *
   * @template M
   * @param {M} model - The model instance to get metadata from
   * @return {string} - The model metadata (typically the class name)
   */
  static getMetadata<M extends Model>(model: M) {
    return getMetadata<M>(model);
  }

  /**
   * @description Retrieves all attribute names from a model class or instance
   * @summary Gets all attributes defined in a model, traversing the prototype chain to include inherited attributes
   *
   * @template V
   * @param {Constructor<V> | V} model - The model class or instance to get attributes from
   * @return {string[]} - Array of attribute names defined in the model
   */
  static getAttributes<V extends Model>(model: Constructor<V> | V) {
    const result: string[] = [];
    let prototype =
      model instanceof Model
        ? Object.getPrototypeOf(model)
        : (model as any).prototype;
    while (prototype != null) {
      const props: string[] = prototype[ModelKeys.ATTRIBUTE];
      if (props) {
        result.push(...props);
      }
      prototype = Object.getPrototypeOf(prototype);
    }
    return result;
  }

  /**
   * @description Compares two model instances for equality
   * @summary Determines if two model instances are equal by comparing their properties
   *
   * @template M
   * @param {M} obj1 - First model instance to compare
   * @param {M} obj2 - Second model instance to compare
   * @param {any[]} [exceptions] - Property names to exclude from comparison
   * @return {boolean} - True if the models are equal, false otherwise
   */
  static equals<M extends Model>(obj1: M, obj2: M, ...exceptions: any[]) {
    return isEqual(obj1, obj2, ...exceptions);
  }

  /**
   * @description Validates a model instance against its validation rules
   * @summary Checks if a model has validation errors, optionally ignoring specified properties
   *
   * @template M
   * @param {M} model - The model instance to validate
   * @param {boolean} async - A flag indicating whether validation should be asynchronous.
   * @param {string[]} [propsToIgnore] - Properties to exclude from validation
   * @return {ModelErrorDefinition | undefined} - Returns validation errors if any, otherwise undefined
   */
  static hasErrors<M extends Model, Async extends boolean = false>(
    model: M,
    async: Async,
    ...propsToIgnore: string[]
  ): ConditionalAsync<Async, ModelErrorDefinition | undefined> {
    return validate<any, Async>(model, async, ...propsToIgnore) as any;
  }

  /**
   * @description Converts a model instance to a serialized string
   * @summary Serializes a model instance using the configured serializer or the default one
   *
   * @template M
   * @param {M} model - The model instance to serialize
   * @return {string} - The serialized string representation of the model
   */
  static serialize<M extends Model<boolean>>(model: M) {
    const metadata = Reflect.getMetadata(
      Model.key(ModelKeys.SERIALIZATION),
      model.constructor
    );

    if (metadata && metadata.serializer)
      return Serialization.serialize(
        this,
        metadata.serializer,
        ...(metadata.args || [])
      );
    return Serialization.serialize(model);
  }

  /**
   * @description Generates a hash string for a model instance
   * @summary Creates a hash representation of a model using the configured algorithm or the default one
   *
   * @template M
   * @param {M} model - The model instance to hash
   * @return {string} - The hash string representing the model
   */
  static hash<M extends Model<boolean>>(model: M) {
    const metadata = Reflect.getMetadata(
      Model.key(ModelKeys.HASHING),
      model.constructor
    );

    if (metadata && metadata.algorithm)
      return Hashing.hash(model, metadata.algorithm, ...(metadata.args || []));
    return Hashing.hash(model);
  }

  /**
   * @description Creates a metadata key for use with the Reflection API
   * @summary Builds the key to store as Metadata under Reflections
   *
   * @param {string} str - The base key to concatenate with the model reflection prefix
   * @return {string} - The complete metadata key
   */
  static key(str: string) {
    return getModelKey(str);
  }

  /**
   * @description Determines if an object is a model instance or has model metadata
   * @summary Checks whether a given object is either an instance of the Model class or
   * has model metadata attached to it. This function is essential for serialization and
   * deserialization processes, as it helps identify model objects that need special handling.
   * It safely handles potential errors during metadata retrieval.
   *
   * @param {Record<string, any>} target - The object to check
   * @return {boolean} True if the object is a model instance or has model metadata, false otherwise
   *
   * @example
   * ```typescript
   * // Check if an object is a model
   * const user = new User({ name: "John" });
   * const isUserModel = isModel(user); // true
   *
   * // Check a plain object
   * const plainObject = { name: "John" };
   * const isPlainObjectModel = isModel(plainObject); // false
   * ```
   */
  static isModel(target: Record<string, any>) {
    try {
      return target instanceof Model || !!Model.getMetadata(target as any);
      // eslint-disable-next-line @typescript-eslint/no-unused-vars
    } catch (e: any) {
      return false;
    }
  }

  /**
   * @description Checks if a property of a model is itself a model or has a model type
   * @summary Determines whether a specific property of a model instance is either a model instance
   * or has a type that is registered as a model
   *
   * @template M
   * @param {M} target - The model instance to check
   * @param {string} attribute - The property name to check
   * @return {boolean | string | undefined} - Returns true if the property is a model instance,
   * the model name if the property has a model type, or undefined if not a model
   */
  static isPropertyModel<M extends Model>(
    target: M,
    attribute: string
  ): boolean | string | undefined {
    if (Model.isModel((target as Record<string, any>)[attribute])) return true;
    const metadata = Reflect.getMetadata(ModelKeys.TYPE, target, attribute);
    return Model.get(metadata.name) ? metadata.name : undefined;
  }

  static describe<M extends Model>(model: M | Constructor<M>, key?: keyof M) {
    const descKey = Model.key(ModelKeys.DESCRIPTION);
    if (key) {
      model = model instanceof Model ? model : new model();
      return (
        Reflect.getMetadataKeys(model.constructor, key.toString())
          .find((k) => k === descKey)
          ?.toString() || model.toString()
      );
    }

    return (
      Reflect.getMetadata(
        Model.key(ModelKeys.DESCRIPTION),
        model instanceof Model ? model.constructor : model
      ) || model.toString()
    );
  }
}