Source: storages/databases/mongodb/MongoDatabase.js

'use strict';

/**
 * @module storages/MongoDatabase
 */

var util = require('util');
var mongodb = require('mongodb');
var MongoStore = require('connect-mongo');
var Database = process.requireApi('lib/storages/databases/Database.js');
var databaseErrors = process.requireApi('lib/storages/databases/databaseErrors.js');
var ResourceFilter = process.requireApi('lib/storages/ResourceFilter.js');
var StorageError = process.requireApi('lib/errors/StorageError.js');
var MongoClient = mongodb.MongoClient;

/**
 * Defines a MongoDB Database.
 *
 * @class MongoDatabase
 * @extends module:storages/Database~Database
 * @constructor
 * @param {Object} configuration A database configuration object
 * @param {String} configuration.host MongoDB server host
 * @param {Number} configuration.port MongoDB server port
 * @param {String} configuration.database The name of the database
 * @param {String} configuration.username The name of the database user
 * @param {String} configuration.password The password of the database user
 * @param {String} [configuration.replicaSet] The name of the ReplicaSet
 * @param {String} [configuration.seedlist] The comma separated list of secondary servers
 */
function MongoDatabase(configuration) {
  MongoDatabase.super_.call(this, configuration);

  Object.defineProperties(this,

    /** @lends module:storages/MongoDatabase~MongoDatabase */
    {

      /**
       * The name of the replica set.
       *
       * @type {String}
       * @instance
       * @readonly
       */
      replicaSet: {value: configuration.replicaSet},

      /**
       * A comma separated list of secondary servers.
       *
       * @type {String}
       * @instance
       * @readonly
       */
      seedlist: {value: configuration.seedlist},

      /**
       * The connected database.
       *
       * @type {Object}
       * @instance
       * @readonly
       */
      db: {
        value: null,
        writable: true
      },

      /**
       * The MongoDB client instance.
       *
       * @type {Object}
       * @instance
       */
      client: {
        value: null,
        writable: true
      }

    }

  );
}

module.exports = MongoDatabase;
util.inherits(MongoDatabase, Database);

/**
 * Builds MongoDb filter from a ResourceFilter.
 *
 * @static
 * @param {module:storages/ResourceFilter~ResourceFilter} resourceFilter The common resource filter
 * @return {Object} The MongoDB like filter description object
 * @throws {TypeError} If an operation is not supported
 */
MongoDatabase.buildFilter = function(resourceFilter) {
  var filter = {};

  if (!resourceFilter) return filter;

  /**
   * Builds a list of filters.
   *
   * @param {Array} filters The list of filters to build
   * @return {Array} The list of built filters
   */
  function buildFilters(filters) {
    var builtFilters = [];
    filters.forEach(function(filter) {
      builtFilters.push(MongoDatabase.buildFilter(filter));
    });
    return builtFilters;
  }

  resourceFilter.operations.forEach(function(operation) {
    if (operation.field === '_id') {
      var valueType = Object.prototype.toString.call(operation.value);

      if (valueType === '[object String]') {
        operation.value = new mongodb.ObjectId(operation.value);
      } else if (valueType === '[object Array]') {
        operation.value = operation.value.map(function(value) {
          return new mongodb.ObjectId(value);
        });
      }
    }

    switch (operation.type) {
      case ResourceFilter.OPERATORS.EQUAL:
        if (!filter[operation.field]) filter[operation.field] = {};
        filter[operation.field]['$eq'] = operation.value;
        break;
      case ResourceFilter.OPERATORS.EXISTS:
        if (!filter[operation.field]) filter[operation.field] = {};
        filter[operation.field]['$exists'] = operation.value;
        break;
      case ResourceFilter.OPERATORS.NOT_EQUAL:
        if (!filter[operation.field]) filter[operation.field] = {};
        filter[operation.field]['$ne'] = operation.value;
        break;
      case ResourceFilter.OPERATORS.GREATER_THAN:
        if (!filter[operation.field]) filter[operation.field] = {};
        filter[operation.field]['$gt'] = operation.value;
        break;
      case ResourceFilter.OPERATORS.GREATER_THAN_EQUAL:
        if (!filter[operation.field]) filter[operation.field] = {};
        filter[operation.field]['$gte'] = operation.value;
        break;
      case ResourceFilter.OPERATORS.LESSER_THAN:
        if (!filter[operation.field]) filter[operation.field] = {};
        filter[operation.field]['$lt'] = operation.value;
        break;
      case ResourceFilter.OPERATORS.LESSER_THAN_EQUAL:
        if (!filter[operation.field]) filter[operation.field] = {};
        filter[operation.field]['$lte'] = operation.value;
        break;
      case ResourceFilter.OPERATORS.IN:
        if (!filter[operation.field]) filter[operation.field] = {};
        filter[operation.field]['$in'] = operation.value;
        break;
      case ResourceFilter.OPERATORS.NOT_IN:
        if (!filter[operation.field]) filter[operation.field] = {};
        filter[operation.field]['$nin'] = operation.value;
        break;
      case ResourceFilter.OPERATORS.REGEX:
        if (!filter[operation.field]) filter[operation.field] = {};
        filter[operation.field]['$regex'] = operation.value;
        break;
      case ResourceFilter.OPERATORS.AND:
        filter['$and'] = buildFilters(operation.filters);
        break;
      case ResourceFilter.OPERATORS.OR:
        filter['$or'] = buildFilters(operation.filters);
        break;
      case ResourceFilter.OPERATORS.NOR:
        filter['$nor'] = buildFilters(operation.filters);
        break;
      case ResourceFilter.OPERATORS.SEARCH:
        filter['$text'] = {
          $search: operation.value
        };
        break;
      default:
        throw new StorageError(
          'Operation ' + operation.type + ' not supported',
          databaseErrors.BUILD_FILTERS_UNKNOWN_OPERATION_ERROR
        );
    }
  });

  return filter;
};

/**
 * Builds MongoDb fields projection.
 *
 * @static
 * @param {Array} fields The list of fields to include or exclude
 * @param {Boolean} doesInclude true to include fields and exclude all other fields or false to exclude fields and
 * include all other fields
 * @return {Object} The MongoDB projection description object
 */
MongoDatabase.buildFields = function(fields, doesInclude) {
  var projection = {_id: 0};

  if (!fields) return projection;

  fields.forEach(function(field) {
    projection[field] = doesInclude ? 1 : 0;
  });

  return projection;
};

/**
 * Builds MongoDB sort object.
 *
 * Concretely it just replaces "score" by "{ $meta: 'textScore' }", "asc" by 1 and "desc" by -1.
 *
 * @static
 * @param {Object} [sort] The list of fields to sort by with the field name as key and the sort order as
 * value (e.g. {field1: 'asc', field2: 'desc', field3: 'score'})
 * @return {Object} The MongoDB sort description object
 */
MongoDatabase.buildSort = function(sort) {
  var mongoSort = {};

  if (!sort) return mongoSort;
  for (var field in sort) {
    if (sort[field] === 'score') mongoSort[field] = {$meta: 'textScore'};
    else mongoSort[field] = sort[field] === 'asc' ? 1 : -1;
  }

  return mongoSort;
};

/**
 * Establishes connection to the database.
 *
 * @param {callback} callback The function to call when connection to the database is established
 */
MongoDatabase.prototype.connect = function(callback) {
  var name = encodeURIComponent(this.username);
  var password = encodeURIComponent(this.password);
  var connectionUrl = 'mongodb://' + name + ':' + password + '@' + this.host + ':' + this.port;
  var database = '/' + this.name;
  var seedlist = ',' + this.seedlist;
  var replicaset = '?replicaSet=' + this.replicaSet + '&readPreference=secondary';

  // Connect to a Replica Set or not
  if (this.seedlist != undefined &&
      this.seedlist != '' &&
      this.replicaSet != undefined &&
      this.replicaSet != '') {
    connectionUrl = connectionUrl + seedlist + database + replicaset;
  } else
    connectionUrl = connectionUrl + database;

  MongoClient.connect(
    connectionUrl, {
      useUnifiedTopology: true,
      useNewUrlParser: true
    }, function(error, client) {

      // Connection succeeded
      if (!error) {
        this.client = client;
        this.db = client.db(this.name);
      }

      callback(error);
    }.bind(this)
  );
};

/**
 * Closes connection to the database.
 *
 * @param {callback} callback The function to call when connection is closed
 */
MongoDatabase.prototype.close = function(callback) {
  this.client.close(callback);
};

/**
 * Inserts several documents into a collection.
 *
 * @param {String} collection The collection to work on
 * @param {Array} documents Document(s) to insert into the collection
 * @param {module:storages/MongoDatabase~MongoDatabase~addCallback} callback The function to call when it's done
 */
MongoDatabase.prototype.add = function(collection, documents, callback) {
  this.db.collection(collection).insertMany(documents, function(error, insertResult) {
    if (error) return callback(error);

    this.get(
      collection,
      new ResourceFilter().in(
        '_id',
        Object.values(insertResult.insertedIds).map(function(objectId) {
          return objectId.toHexString();
        })
      ),
      null,
      insertResult.insertedCount,
      0,
      null,
      function(getError, insertedDocuments) {
        if (getError) return callback(getError);
        callback(null, insertResult.insertedCount, insertedDocuments);
      }
    );
  }.bind(this));
};

/**
 * Removes several documents from a collection.
 *
 * @param {String} collection The collection to work on
 * @param {module:storages/ResourceFilter~ResourceFilter} [filter] Rules to filter documents to remove
 * @param {module:storages/MongoDatabase~MongoDatabase~removeCallback} callback The function to call when it's done
 */
MongoDatabase.prototype.remove = function(collection, filter, callback) {
  filter = MongoDatabase.buildFilter(filter);

  this.db.collection(collection).deleteMany(filter, function(error, result) {
    if (error)
      callback(error);
    else
      callback(null, result.deletedCount);
  });
};

/**
 * Removes a property from documents of a collection.
 *
 * @param {String} collection The collection to work on
 * @param {String} property The name of the property to remove
 * @param {module:storages/ResourceFilter~ResourceFilter} [filter] Rules to filter documents to update
 * @param {module:storages/MongoDatabase~MongoDatabase~removeFieldCallback} callback The function to call when it's done
 */
MongoDatabase.prototype.removeField = function(collection, property, filter, callback) {
  filter = MongoDatabase.buildFilter(filter);
  filter[property] = {$exists: true};

  var update = {};
  update['$unset'] = {};
  update['$unset'][property] = '';

  this.db.collection(collection).updateMany(filter, update, function(error, result) {
    if (error)
      callback(error);
    else
      callback(null, result.modifiedCount);
  });
};

/**
 * Updates a document from collection.
 *
 * @param {String} collection The collection to work on
 * @param {module:storages/ResourceFilter~ResourceFilter} [filter] Rules to filter the document to update
 * @param {Object} data The modifications to perform
 * @param {module:storages/MongoDatabase~MongoDatabase~updateOneCallback} callback The function to call when it's done
 */
MongoDatabase.prototype.updateOne = function(collection, filter, data, callback) {
  var update = {$set: data};
  filter = MongoDatabase.buildFilter(filter);

  this.db.collection(collection).updateOne(filter, update, function(error, result) {
    if (error)
      callback(error);
    else
      callback(null, result.modifiedCount);
  });
};

/**
 * Fetches documents from the collection.
 *
 * @param {String} collection The collection to work on
 * @param {module:storages/ResourceFilter~ResourceFilter} [filter] Rules to filter documents
 * @param {Object} [fields] Expected resource fields to be included or excluded from the response, by default all
 * fields are returned. Only "exclude" or "include" can be specified, not both
 * @param {Array} [fields.include] The list of fields to include in the response, all other fields are excluded
 * @param {Array} [fields.exclude] The list of fields to exclude from response, all other fields are included. Ignored
 * if include is also specified.
 * @param {Number} [limit] A limit number of documents to retrieve (10 by default)
 * @param {Number} [page] The page number started at 0 for the first page
 * @param {Object} sort The list of fields to sort by with the field name as key and the sort order as
 * value (e.g. {field1: 'asc', field2: 'desc', field3: 'score'})
 * @param {module:storages/MongoDatabase~MongoDatabase~getCallback} callback The function to call when it's done
 */
MongoDatabase.prototype.get = function(collection, filter, fields, limit, page, sort, callback) {
  limit = limit || 10;
  fields = fields || {};
  page = page || 0;
  filter = MongoDatabase.buildFilter(filter);
  sort = MongoDatabase.buildSort(sort);
  var projection = MongoDatabase.buildFields(fields.include || fields.exclude, fields.include ? true : false);
  var skip = limit * page || 0;

  // Automatically add the textScore projection if sorting by textScore
  for (var field in sort) {
    if (Object.prototype.hasOwnProperty.call(sort[field], '$meta') && sort[field].$meta === 'textScore') {
      projection[field] = sort[field];
      break;
    }
  }

  this.db.collection(collection).find(filter).project(projection).sort(sort).skip(skip).limit(limit).toArray(
    function(error, documents) {
      if (error) return callback(error);

      this.db.collection(collection).countDocuments(filter, function(countError, count) {
        if (countError) return callback(countError);

        callback(null, documents || [], {
          limit: limit,
          page: page,
          pages: Math.ceil(count / limit),
          size: count
        });
      });
    }.bind(this)
  );
};

/**
 * Fetches a single document from the storage.
 *
 * @param {String} collection The collection to work on
 * @param {module:storages/ResourceFilter~ResourceFilter} [filter] Rules to filter documents
 * @param {Object} [fields] Expected document fields to be included or excluded from the response, by default all
 * fields are returned. Only "exclude" or "include" can be specified, not both
 * @param {Array} [fields.include] The list of fields to include in the response, all other fields are excluded
 * @param {Array} [fields.exclude] The list of fields to exclude from response, all other fields are included. Ignored
 * if include is also specified.
 * @param {module:storages/MongoDatabase~MongoDatabase~getOneCallback} callback The function to call when it's done
 */
MongoDatabase.prototype.getOne = function(collection, filter, fields, callback) {
  filter = MongoDatabase.buildFilter(filter);
  fields = fields || {};
  var projection = MongoDatabase.buildFields(fields.include || fields.exclude, fields.include ? true : false);

  this.db.collection(collection).findOne(filter, projection, callback);
};

/**
 * Gets the list of indexes for a collection.
 *
 * @param {String} collection The collection to work on
 * @param {module:storages/MongoDatabase~MongoDatabase~getIndexesCallback} callback The function to call when it's done
 */
MongoDatabase.prototype.getIndexes = function(collection, callback) {
  this.db.collection(collection).indexes(callback);
};

/**
 * Creates indexes for a collection.
 *
 * @param {String} collection The collection to work on
 * @param {Array} indexes A list of indexes using MongoDB format
 * @param {module:storages/MongoDatabase~MongoDatabase~createIndexesCallback} callback The function to call when it's
 * done
 */
MongoDatabase.prototype.createIndexes = function(collection, indexes, callback) {
  this.db.collection(collection).createIndexes(indexes, callback);
};

/**
 * Drops an index from a collection.
 *
 * @param {String} collection The collection to work on
 * @param {Array} indexName The name of the index to drop
 * @param {module:storages/MongoDatabase~MongoDatabase~dropIndexCallback} callback The function to call when it's done
 */
MongoDatabase.prototype.dropIndex = function(collection, indexName, callback) {
  this.db.collection(collection).dropIndex(indexName, callback);
};

/**
 * Gets an express-session store for this database.
 *
 * @param {String} collection The collection to work on
 * @return {Object} An express-session store
 */
MongoDatabase.prototype.getStore = function(collection) {
  return new MongoStore({client: this.client, collectionName: collection});
};

/**
 * Renames a collection.
 *
 * @param {String} collection The collection to work on
 * @param {String} target The new name of the collection
 * @param {callback} callback The function to call when it's done
 */
MongoDatabase.prototype.renameCollection = function(collection, target, callback) {
  this.db.listCollections({name: collection}).toArray(function(error, collections) {
    if (error) return callback(error);
    if (!collections || !collections.length) {
      return callback(
        new StorageError('Collection "' + collection + '" not found', databaseErrors.RENAME_COLLECTION_NOT_FOUND_ERROR)
      );
    }

    this.db.collection(collection).rename(target, callback);
  }.bind(this));
};

/**
 * Removes a collection from the database.
 *
 * @param {String} collection The collection to work on
 * @param {callback} callback The function to call when it's done
 */
MongoDatabase.prototype.removeCollection = function(collection, callback) {
  this.db.listCollections({name: collection}).toArray(function(error, collections) {
    if (error) return callback(error);
    if (!collections || !collections.length) {
      return callback(
        new StorageError('Collection "' + collection + '" not found', databaseErrors.REMOVE_COLLECTION_NOT_FOUND_ERROR)
      );
    }

    this.db.dropCollection(collection, callback);
  }.bind(this));
};

/**
 * @callback module:storages/MongoDatabase~MongoDatabase~addCallback
 * @param {(Error|null)} error The error if an error occurred, null otherwise
 * @param {(Number|Undefined)} total The total amount of documents inserted
 * @param {(Array|Undefined)} documents The list of inserted documents
 */

/**
 * @callback module:storages/MongoDatabase~MongoDatabase~removeCallback
 * @param {(Error|null)} error The error if an error occurred, null otherwise
 * @param {(Number|Undefined)} total The number of deleted documents
 */

/**
 * @callback module:storages/MongoDatabase~MongoDatabase~removeFieldCallback
 * @param {(Error|null)} error The error if an error occurred, null otherwise
 * @param {(Number|Undefined)} total The number of updated documents
 */

/**
 * @callback module:storages/MongoDatabase~MongoDatabase~updateOneCallback
 * @param {(Error|null)} error The error if an error occurred, null otherwise
 * @param {(Number|Undefined)} total 1 if everything went fine
 */

/**
 * @callback module:storages/MongoDatabase~MongoDatabase~getCallback
 * @param {(Error|null)} error The error if an error occurred, null otherwise
 * @param {(Array|Undefined)} documents The list of retrieved documents
 * @param {(Object|Undefined)} pagination Pagination information
 * @param {(Number|Undefined)} limit The specified limit
 * @param {(Number|Undefined)} page The actual page
 * @param {(Number|Undefined)} pages The total number of pages
 * @param {(Number|Undefined)} size The total number of documents
 */

/**
 * @callback module:storages/MongoDatabase~MongoDatabase~getOneCallback
 * @param {(Error|null)} error The error if an error occurred, null otherwise
 * @param {(Object|Undefined)} document The document
 */

/**
 * @callback module:storages/MongoDatabase~MongoDatabase~getIndexesCallback
 * @param {(Error|null)} error The error if an error occurred, null otherwise
 * @param {(Array|Undefined)} indexes The list of indexes
 */

/**
 * @callback module:storages/MongoDatabase~MongoDatabase~createIndexesCallback
 * @param {(Error|null)} error The error if an error occurred, null otherwise
 * @param {(Object|Undefined)} result Information about the operation
 */

/**
 * @callback module:storages/MongoDatabase~MongoDatabase~dropIndexCallback
 * @param {(Error|null)} error The error if an error occurred, null otherwise
 * @param {(Object|Undefined)} result Information about the operation
 */