Source: Model.ts

/**
 * Represents a MongoDB model that provides methods for interacting with a specific collection.
 *
 * @class
 */
import Message from './Message';
import QueryBuilder from './QueryBuilder';
import {
  FieldOptions,
  IndexOptions,
  OtherOptions,
  MongoAggregate,
  MongoFindOne,
  MongoFind,
  MongoCount,
  MongoFindOneAndUpdate,
  MongoUpdateOne,
  MongoUpdateMany,
  MongoInsertOne,
  MongoInsertMany,
  MongoDelete,
  MongoFindOneOrCreate,
  MongoDocument,
  MongoDispatchAction
} from './types';
import FieldTypes from './FieldTypes';

class Model {
  /**
   * The `QueryBuilder` instance
   */
  private $queryBuilder: QueryBuilder;

  /**
   * The name of the MongoDB collection associated with the model.
   * @private
   * @type {string}
   */
  private $name: string = '';

  /**
   * An array of field options specifying the schema for the MongoDB collection.
   * @private
   * @type {FieldOptions[]}
   */
  private $fieldOptions: FieldOptions[] = [];

  /**
   * An array of index options specifying the indexes to be created for the MongoDB collection.
   * @private
   * @type {IndexOptions[]}
   */
  private $indexOptions: IndexOptions[] = [];

  /**
   * Additional options for the model.
   * @private
   * @type {OtherOptions}
   */
  private $otherOptions: OtherOptions = {
    debug: false,
    log: -1
  };

  /**
   * Creates an instance of the Model class.
   *
   * @constructor
   * @param {string} collectionName - The name of the MongoDB collection.
   * @param {FieldOptions[]} [fieldOptions=[]] - An array of field options specifying the schema.
   * @param {IndexOptions[]} [indexOptions=[]] - An array of index options specifying the indexes.
   * @param {OtherOptions} [otherOptions] - Additional options for the model.
   * @throws {Error} If the provided field options include reserved names like 'createdAt' or 'updatedAt'.
   */
  constructor(collectionName: string, fieldOptions: FieldOptions[] = [], indexOptions: IndexOptions[] = [], otherOptions?: OtherOptions) {
    this.$queryBuilder = new QueryBuilder();
    this.$name = String(collectionName);
    this.$fieldOptions = fieldOptions;
    this.$indexOptions = indexOptions;
    this.$otherOptions = {
      debug: otherOptions?.debug || false,
      log: otherOptions?.log || -1
    };

    this.$fieldOptions.forEach((field) => this.processDefault(field));

    const checkBadFields = this.$fieldOptions.filter((field) => ['createdAt', 'updatedAt'].includes(field.name));
    if (checkBadFields?.length !== 0) throw new Error(`You cannot use the field names createdAt or updatedAt as they are reserved for the ORM.`);
  }

  /**
   * Generates indexes for the MongoDB collection associated with the current instance.
   *
   * @private
   * @method
   * @async
   * @throws {Error} If an error occurs during the index creation process.
   * @returns {Promise<void>} A Promise that resolves when all indexes are successfully generated.
   *
   * @example
   * // Usage within the class:
   * await this.generateIndexes();
   */
  generateIndexes = async () => {
    this.$indexOptions.forEach(async (index) => {
      const params: { [key: string]: string | boolean } = {};
      if (index.unique) params.unique = true;
      if (index.name) params.name = index.name;
      await this.$queryBuilder.createIndex(this.$name, index.fields, params);
    });
    Message(`Generated indexes for ${this.$name} (${this.$indexOptions.length} total).`);
  };

  /**
   * Dispatches an asynchronous action with optional debugging and logging.
   *
   * @private
   * @method
   * @async
   * @param {Function} fn - The function to be executed.
   * @param {MongoQuery} [query={}] - The MongoDB query associated with the action.
   * @throws {Error} If an error occurs during the action execution.
   * @returns {Promise<any>} A Promise that resolves with the result of the action.
   *
   * @example
   * // Usage within the class:
   * const result = await this.dispatchAction(async () => await someAsyncFunction(), { key: 'value' });
   */
  private dispatchAction: MongoDispatchAction = async (fn, query = {}) => {
    if (this.$otherOptions.debug) {
      const start = new Date().getTime();
      const result = await fn();
      const end = new Date().getTime();
      const total = end - start;

      if (this.$otherOptions.log !== -1 && total > this.$otherOptions.log) {
        this.$queryBuilder.insertOne('_mongoOrmDebug', {
          model: this.$name,
          query: JSON.stringify(query, null, 2),
          time: total,
          date: new Date()
        });
      }
      return result;
    } else {
      return await fn();
    }
  };

  /**
   * Processes the default values for fields with default values defined in the schema.
   *
   * @private
   * @method
   * @param {FieldOptions} field - The field options for a specific field.
   * @throws {Error} If a default value is incompatible with the field type.
   */
  private processDefault = (field: FieldOptions) => {
    if (typeof field.default === 'undefined' || field.type === FieldTypes.Mixed) return;

    if (field.type === FieldTypes.String && typeof field.default !== 'string' && field.default !== null)
      throw new Error(`Field is of type string but the default value is not a string or null.`);
    else if (field.type === FieldTypes.Number && typeof field.default !== 'number' && field.default !== null)
      throw new Error(`Field is of type number but the default value is not a number or null.`);
    else if (field.type === FieldTypes.Boolean && typeof field.default !== 'boolean')
      throw new Error(`Field is of type boolean but the default value is not a boolean.`);
    else if (field.type === FieldTypes.Date && !(field.default instanceof Date)) throw new Error(`Field is of type date but the default value is not a date.`);
    else if (field.type === FieldTypes.Array && !Array.isArray(field.default)) throw new Error(`Field is of type array but the default value is not an array.`);
    else if (field.type === FieldTypes.Object && typeof field.default !== 'object' && field.default !== null)
      throw new Error(`Field is of type object but the default value is not an object or null.`);
    else if (field.type === FieldTypes.ObjectId && typeof field.default !== 'string' && field.default !== null)
      throw new Error(`Field is of type objectId but the default value is not a string or null.`);
  };

  /**
   * Processes a document before insertion or update, applying default values and type conversions.
   *
   * @private
   * @method
   * @param {MongoDocument} document - The document to be processed.
   * @param {boolean} [isUpdate=false] - Indicates whether the document is being updated.
   * @returns {MongoDocument} The processed document.
   */
  private processDocument = (document: MongoDocument, isUpdate: boolean = false) => {
    const processedDocument: MongoDocument = {};

    // If this is a new document, process the default values
    if (!isUpdate) {
      const fieldLength = this.$fieldOptions.length;

      for (let i = 0; i < fieldLength; i++) {
        const field = this.$fieldOptions[i];
        if (document[field.name]) {
          if (field.type === FieldTypes.Date) processedDocument[field.name] = new Date(document[field.name]);
          else if (field.type === FieldTypes.Number) processedDocument[field.name] = Number(document[field.name]);
          else if (field.type === FieldTypes.Boolean) processedDocument[field.name] = Boolean(document[field.name]);
          else if (field.type === FieldTypes.ObjectId) processedDocument[field.name] = String(document[field.name]);
          else if (field.type === FieldTypes.Array && !Array.isArray(document[field.name])) processedDocument[field.name] = Array(document[field.name]);
          else if (field.type === FieldTypes.Object) processedDocument[field.name] = Object(document[field.name]);
          else processedDocument[field.name] = document[field.name];
        } else if (typeof field.default !== 'undefined') processedDocument[field.name] = field.default;
        else if (!isUpdate && field.required)
          throw new Error(`Field ${field.name} is required but was not provided a value and does not have a default value to back up off.`);
      }
      // If this is NOT a new document, do not process fields that won't exist
    } else if (isUpdate) {
      const totalFields = Object.keys(document).length;

      for (let i = 0; i < totalFields; i++) {
        const field = this.$fieldOptions.find((f) => f.name === Object.keys(document)[i]);
        if (field) {
          if (field.type === FieldTypes.Date) processedDocument[field.name] = new Date(document[field.name]);
          else if (field.type === FieldTypes.Number) processedDocument[field.name] = Number(document[field.name]);
          else if (field.type === FieldTypes.Boolean) processedDocument[field.name] = Boolean(document[field.name]);
          else if (field.type === FieldTypes.ObjectId) processedDocument[field.name] = String(document[field.name]);
          else if (field.type === FieldTypes.Array && !Array.isArray(document[field.name])) processedDocument[field.name] = Array(document[field.name]);
          else if (field.type === FieldTypes.Object) processedDocument[field.name] = Object(document[field.name]);
          else processedDocument[field.name] = document[field.name];
        }
      }
    }

    processedDocument[isUpdate ? 'updatedAt' : 'createdAt'] = Math.ceil(new Date().getTime() / 1000);
    return processedDocument;
  };

  /**
   * Performs an aggregation operation on the MongoDB collection associated with the current instance.
   *
   * @async
   * @method
   * @memberof MongoODM.Model
   * @param {Object[]} query - The aggregation pipeline stages.
   * @param {Object} options - Additional options for the aggregation.
   * @throws {Error} If an error occurs during the aggregation process.
   * @returns {Promise<any>} A Promise that resolves with the result of the aggregation.
   *
   * @example
   * // Usage within the class:
   * const aggregationResult = await this.aggregate([{ $match: { status: 'active' } }]);
   */
  aggregate: MongoAggregate = async (query, options) => {
    return await this.dispatchAction(async () => await this.$queryBuilder.aggregate(this.$name, query, options), query);
  };

  /**
   * Performs a find operation on the MongoDB collection associated with the current instance,
   * returning the first document that matches the specified query.
   *
   * @async
   * @method
   * @memberof MongoODM.Model
   * @param {Object} query - The query criteria.
   * @param {Object} options - Additional options for the find operation.
   * @throws {Error} If an error occurs during the find operation.
   * @returns {Promise<any | null>} A Promise that resolves with the found document or null if not found.
   *
   * @example
   * // Usage within the class:
   * const foundDocument = await this.findOne({ username: 'john_doe' });
   */
  findOne: MongoFindOne = async (query, options) => {
    return await this.dispatchAction(async () => await this.$queryBuilder.findOne(this.$name, query, options), query);
  };

  /**
   * Performs a find operation on the MongoDB collection associated with the current instance,
   * returning a cursor to the documents that match the specified query.
   *
   * @async
   * @method
   * @memberof MongoODM.Model
   * @param {Object} query - The query criteria.
   * @param {Object} options - Additional options for the find operation.
   * @throws {Error} If an error occurs during the find operation.
   * @returns {Promise<any>} A Promise that resolves with the cursor to the found documents.
   *
   * @example
   * // Usage within the class:
   * const cursor = await this.find({ status: 'active' });
   */
  find: MongoFind = async (query, options) => {
    return await this.dispatchAction(async () => await this.$queryBuilder.find(this.$name, query, options), query);
  };

  /**
   * Counts the number of documents in the MongoDB collection associated with the current instance
   * that match the specified query.
   *
   * @async
   * @method
   * @memberof MongoODM.Model
   * @param {Object} query - The query criteria.
   * @throws {Error} If an error occurs during the count operation.
   * @returns {Promise<number>} A Promise that resolves with the count of matching documents.
   *
   * @example
   * // Usage within the class:
   * const documentCount = await this.count({ status: 'active' });
   */
  count: MongoCount = async (query) => {
    return await this.dispatchAction(async () => await this.$queryBuilder.count(this.$name, query), query);
  };

  /**
   * Performs a find-and-modify operation on the MongoDB collection associated with the current instance,
   * returning the modified document.
   *
   * @async
   * @method
   * @memberof MongoODM.Model
   * @param {Object} query - The query criteria for finding the document to update.
   * @param {Object} update - The update operation to apply to the found document.
   * @param {boolean} [upsert=false] - If true, creates a new document when no document matches the query criteria.
   * @param {string} [useModifier='$set'] - The modifier to use for the update operation.
   * @throws {Error} If an error occurs during the update operation.
   * @returns {Promise<any | null>} A Promise that resolves with the modified document or null if not found.
   *
   * @example
   * // Usage within the class:
   * const updatedDocument = await this.findOneAndUpdate({ username: 'john_doe' }, { $set: { status: 'inactive' } });
   */
  findOneAndUpdate: MongoFindOneAndUpdate = async (query, update, upsert = false, useModifier = '$set') => {
    return await this.dispatchAction(
      async () => await this.$queryBuilder.findOneAndUpdate(this.$name, query, this.processDocument(update, true), upsert, useModifier),
      query
    );
  };

  /**
   * Updates a single document in the MongoDB collection associated with the current instance.
   *
   * @async
   * @method
   * @memberof MongoODM.Model
   * @param {Object} query - The query criteria for finding the document to update.
   * @param {Object} update - The update operation to apply to the found document.
   * @param {boolean} [upsert=false] - If true, creates a new document when no document matches the query criteria.
   * @param {string} [useModifier='$set'] - The modifier to use for the update operation.
   * @throws {Error} If an error occurs during the update operation.
   * @returns {Promise<boolean>} A Promise that resolves with a boolean indicating the success of the update operation.
   *
   * @example
   * // Usage within the class:
   * const isUpdated = await this.updateOne({ username: 'john_doe' }, { status: 'inactive' }, '$set');
   */
  updateOne: MongoUpdateOne = async (query, update, upsert = false, useModifier = '$set') => {
    return await this.dispatchAction(
      async () => await this.$queryBuilder.updateOne(this.$name, query, this.processDocument(update, true), upsert, useModifier),
      query
    );
  };

  /**
   * Updates multiple documents in the MongoDB collection associated with the current instance.
   *
   * @async
   * @method
   * @memberof MongoODM.Model
   * @param {Object} query - The query criteria for finding the documents to update.
   * @param {Object} document - The update operation to apply to the found documents.
   * @param {string} [useModifier='$set'] - The modifier to use for the update operation.
   * @throws {Error} If an error occurs during the update operation.
   * @returns {Promise<boolean>} A Promise that resolves with a boolean indicating the success of the update operation.
   *
   * @example
   * // Usage within the class:
   * const areUpdated = await this.updateMany({ status: 'active' }, { status: 'inactive' }, '$set');
   */
  updateMany: MongoUpdateMany = async (query, document, useModifier = '$set') => {
    return await this.dispatchAction(
      async () => await this.$queryBuilder.updateMany(this.$name, query, this.processDocument(document, true), useModifier),
      query
    );
  };

  /**
   * Deletes multiple documents in the MongoDB collection associated with the current instance.
   *
   * @async
   * @method
   * @memberof MongoODM.Model
   * @param {Object} query - The query criteria for finding the documents to delete.
   * @throws {Error} If an error occurs during the delete operation.
   * @returns {Promise<boolean>} A Promise that resolves with a boolean indicating the success of the delete operation.
   *
   * @example
   * // Usage within the class:
   * const areDeleted = await this.deleteMany({ status: 'inactive' });
   */
  deleteMany: MongoDelete = async (query) => await this.dispatchAction(async () => await this.$queryBuilder.deleteMany(this.$name, query));

  /**
   * Deletes a single document in the MongoDB collection associated with the current instance.
   *
   * @async
   * @method
   * @memberof MongoODM.Model
   * @param {Object} query - The query criteria for finding the document to delete.
   * @throws {Error} If an error occurs during the delete operation.
   * @returns {Promise<boolean>} A Promise that resolves with a boolean indicating the success of the delete operation.
   *
   * @example
   * // Usage within the class:
   * const isDeleted = await this.deleteOne({ status: 'inactive' });
   */
  deleteOne: MongoDelete = async (query) => {
    return await this.dispatchAction(async () => await this.$queryBuilder.deleteOne(this.$name, query));
  };
  /**
   * Inserts a single document into the MongoDB collection associated with the current instance.
   *
   * @async
   * @method
   * @memberof MongoODM.Model
   * @param {Object} document - The document to be inserted.
   * @throws {Error} If an error occurs during the insert operation.
   * @returns {Promise<any | null>} A Promise that resolves with the inserted document or null if insertion fails.
   *
   * @example
   * // Usage within the class:
   * const insertedDocument = await this.insertOne({ username: 'john_doe', status: 'active' });
   */
  insertOne: MongoInsertOne = async (document) => {
    return await this.dispatchAction(async () => await this.$queryBuilder.insertOne(this.$name, this.processDocument(document)));
  };

  /**
   * Inserts multiple documents into the MongoDB collection associated with the current instance.
   *
   * @async
   * @method
   * @memberof MongoODM.Model
   * @param {Object[]} documents - An array of documents to be inserted.
   * @throws {Error} If an error occurs during the insert operation.
   * @returns {Promise<any | null>} A Promise that resolves with the inserted documents or null if insertion fails.
   *
   * @example
   * // Usage within the class:
   * const insertedDocuments = await this.insertMany([{ username: 'john_doe', status: 'active' }, { username: 'jane_doe', status: 'inactive' }]);
   */
  insertMany: MongoInsertMany = async (documents) => {
    return await this.dispatchAction(
      async () =>
        await this.$queryBuilder.insertMany(
          this.$name,
          documents.map((doc) => this.processDocument(doc))
        )
    );
  };

  /**
   * Finds a document in the MongoDB collection associated with the current instance based on the provided query.
   * If no document is found, a new document is inserted into the collection using the provided document.
   *
   * @async
   * @method
   * @memberof MongoODM.Model
   * @param {MongoQuery} query - The query criteria to find an existing document.
   * @param {MongoDocument} document - The document to insert if no existing document is found.
   * @throws {Error} If an error occurs during the find or insert operation.
   * @returns {Promise<MongoDocument | null>} A Promise that resolves with the found or inserted document, or null if an error occurs.
   *
   * @example
   * // Usage within the class:
   * const query = { username: 'john_doe' };
   * const newDocument = { username: 'john_doe', email: 'john@example.com' };
   * const result = await this.findOneOrCreate(query, newDocument);
   */
  findOneOrCreate: MongoFindOneOrCreate = async (query, document) => {
    return await this.dispatchAction(async () => await this.$queryBuilder.findOneOrCreate(this.$name, query, this.processDocument(document)), query);
  };
}

export default Model;