OpenVeo server API for plugins

API Docs for: 7.0.0
Show:

File: lib/storages/databases/mongodb/MongoDatabase.js

'use strict';

/**
 * @module storages
 */

var util = require('util');
var mongodb = require('mongodb');
var session = require('express-session');
var MongoStore = require('connect-mongo')(session);
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 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, {

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

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

    /**
     * The connected database.
     *
     * @property database
     * @type Db
     * @final
     */
    db: {
      value: null,
      writable: true
    },

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

  });
}

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

/**
 * Builds MongoDb filter from a ResourceFilter.
 *
 * @method buildFilter
 * @static
 * @param {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) {
    switch (operation.type) {
      case ResourceFilter.OPERATORS.EQUAL:
        if (!filter[operation.field]) filter[operation.field] = {};
        filter[operation.field]['$eq'] = 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.
 *
 * @method buildFields
 * @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.
 *
 * @method buildSort
 * @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.
 *
 * @method connect
 * @async
 * @param {Function} callback The function to call when connection to the database is established
 *   - **Error** The error if an error occurred, null otherwise
 */
MongoDatabase.prototype.connect = function(callback) {
  var self = this;
  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) {
        self.client = client;
        self.db = client.db(self.name);
      }

      callback(error);
    }
  );
};

/**
 * Closes connection to the database.
 *
 * @method close
 * @async
 * @param {Function} callback The function to call when connection is closed
 *   - **Error** The error if an error occurred, null otherwise
 */
MongoDatabase.prototype.close = function(callback) {
  this.client.close(callback);
};

/**
 * Inserts several documents into a collection.
 *
 * @method add
 * @async
 * @param {String} collection The collection to work on
 * @param {Array} documents Document(s) to insert into the collection
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** The total amount of documents inserted
 *   - **Array** The list of inserted documents
 */
MongoDatabase.prototype.add = function(collection, documents, callback) {
  this.db.collection(collection, function(error, fetchedCollection) {
    if (error)
      return callback(error);

    fetchedCollection.insertMany(documents, function(error, result) {
      if (error)
        callback(error);
      else
        callback(null, result.insertedCount, result.ops);
    });
  });
};

/**
 * Removes several documents from a collection.
 *
 * @method remove
 * @async
 * @param {String} collection The collection to work on
 * @param {ResourceFilter} [filter] Rules to filter documents to remove
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** The number of deleted documents
 */
MongoDatabase.prototype.remove = function(collection, filter, callback) {
  filter = MongoDatabase.buildFilter(filter);

  this.db.collection(collection, function(error, fetchedCollection) {
    if (error)
      return callback(error);

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

/**
 * Removes a property from documents of a collection.
 *
 * @method removeField
 * @async
 * @param {String} collection The collection to work on
 * @param {String} property The name of the property to remove
 * @param {ResourceFilter} [filter] Rules to filter documents to update
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** The number of updated documents
 */
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, function(error, fetchedCollection) {
    if (error)
      return callback(error);

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

/**
 * Updates a document from collection.
 *
 * @method updateOne
 * @async
 * @param {String} collection The collection to work on
 * @param {ResourceFilter} [filter] Rules to filter the document to update
 * @param {Object} data The modifications to perform
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** 1 if everything went fine
 */
MongoDatabase.prototype.updateOne = function(collection, filter, data, callback) {
  var update = {$set: data};
  filter = MongoDatabase.buildFilter(filter);

  this.db.collection(collection, function(error, fetchedCollection) {
    if (error) return callback(error);

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

/**
 * Fetches documents from the collection.
 *
 * @method get
 * @async
 * @param {String} collection The collection to work on
 * @param {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 {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Array** The list of retrieved documents
 *   - **Object** Pagination information
 *     - **Number** limit The specified limit
 *     - **Number** page The actual page
 *     - **Number** pages The total number of pages
 *     - **Number** size The total number of documents
 */
MongoDatabase.prototype.get = function(collection, filter, fields, limit, page, sort, callback) {
  this.db.collection(collection, function(error, fetchedCollection) {
    if (error) return callback(error);

    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;
      }
    }

    var cursor = fetchedCollection.find(filter).project(projection).sort(sort).skip(skip).limit(limit);
    cursor.toArray(function(toArrayError, documents) {
      if (toArrayError) return callback(toArrayError);

      cursor.count(false, null, function(countError, count) {
        if (countError) callback(countError);

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

  });
};

/**
 * Fetches a single document from the storage.
 *
 * @method getOne
 * @async
 * @param {String} collection The collection to work on
 * @param {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 {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Object** The document
 */
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, function(error, fetchedCollection) {
    if (error) return callback(error);
    fetchedCollection.findOne(filter, projection, callback);
  });
};

/**
 * Gets the list of indexes for a collection.
 *
 * @method getIndexes
 * @async
 * @param {String} collection The collection to work on
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Array** The list of indexes
 */
MongoDatabase.prototype.getIndexes = function(collection, callback) {
  this.db.collection(collection, function(error, fetchedCollection) {
    if (error)
      return callback(error);

    fetchedCollection.indexes(callback);
  });
};

/**
 * Creates indexes for a collection.
 *
 * @method createIndexes
 * @async
 * @param {String} collection The collection to work on
 * @param {Array} indexes A list of indexes using MongoDB format
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Object** Information about the operation
 */
MongoDatabase.prototype.createIndexes = function(collection, indexes, callback) {
  this.db.collection(collection, function(error, fetchedCollection) {
    if (error)
      return callback(error);

    fetchedCollection.createIndexes(indexes, callback);
  });
};

/**
 * Drops an index from a collection.
 *
 * @method dropIndex
 * @async
 * @param {String} collection The collection to work on
 * @param {Array} indexName The name of the index to drop
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Object** Information about the operation
 */
MongoDatabase.prototype.dropIndex = function(collection, indexName, callback) {
  this.db.collection(collection, function(error, fetchedCollection) {
    if (error)
      return callback(error);

    fetchedCollection.dropIndex(indexName, callback);
  });
};

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

/**
 * Renames a collection.
 *
 * @method renameCollection
 * @async
 * @param {String} collection The collection to work on
 * @param {String} target The new name of the collection
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 */
MongoDatabase.prototype.renameCollection = function(collection, target, callback) {
  var self = this;

  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)
      );
    }

    self.db.collection(collection, function(error, fetchedCollection) {
      if (error) return callback(error);

      fetchedCollection.rename(target, function(error) {
        callback(error);
      });
    });
  });
};

/**
 * Removes a collection from the database.
 *
 * @method removeCollection
 * @async
 * @param {String} collection The collection to work on
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 */
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));
};