adapt-authoring-mongodb/lib/MongoDBModule.js

import { AbstractModule } from 'adapt-authoring-core'
import { MongoClient } from 'mongodb'
import ObjectIdUtils from './ObjectIdUtils.js'
/**
 * Represents a single MongoDB server instance
 * @memberof mongodb
 * @extends {AbstractModule}
 */
class MongoDBModule extends AbstractModule {
  /** @override */
  async init () {
    await this.app.waitForModule('config')
    /**
     * Reference to the MongDB client
     * @type {external:MongoDBMongoClient}
     */
    this.client = new MongoClient(this.getConfig('connectionUri'), { ignoreUndefined: true })
    await this.connect()
    // add custom keywords to JSON Schema validator
    await ObjectIdUtils.addSchemaKeyword()
  }

  /**
   * Connects to the database
   * @return {Promise}
   */
  async connect () {
    try {
      await this.client.connect()
      const { hosts, dbName } = this.client.options
      this.log('info', `connected to ${dbName} on ${hosts}`)
    } catch (e) {
      throw this.app.errors.MONGO_CONN_FAILED
        .setData({ error: e.message })
    }
  }

  /**
   * Get all the db statistics
   * @return {Promise}
   * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Db.html#stats
   */
  async getStats () {
    return this.client.db().stats()
  }

  /**
   * Returns the associated MongoDB collection
   * @param {String} collectionName The name of the MongoDB collection
   * @return {external:MongoDBCollection}
   * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html
   */
  getCollection (collectionName) {
    return this.client.db().collection(collectionName)
  }

  /**
   * Set an index on a MongoDB collection
   * @param {String} collectionName The name of the MongoDB collection
   * @param {String|Array|Object} fieldOrSpec Definition of the index
   * @param {external:MongoDBCreateIndexesOptions} options Options to pass to the MongoDB driver
   * @return {Promise}
   * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html#createIndex
   */
  async setIndex (collectionName, fieldOrSpec, options) {
    try {
      await this.getCollection(collectionName).createIndex(fieldOrSpec, options)
    } catch (e) {
      this.log('warn', e.toString())
    }
  }

  /**
   * Makes sure options are in the correct format.
   * @param {Object} options The options to parse
   */
  parseOptions (options) {
    if (!options) {
      return
    }
    ['limit', 'skip'].forEach(o => {
      if (options[o] === undefined) return
      try {
        options[o] = parseInt(options[o])
      } catch (e) {
        this.log('warn', `value for option '${o}' is in an unexpected format and will be ignored`)
        delete options[o]
      }
    })
  }

  /**
   * Adds a new object to the database
   * @param {String} collectionName The name of the MongoDB collection
   * @param {Object} data
   * @param {external:MongoDBInsertOneOptions} options Options to pass to the MongoDB driver
   * @return {Promise} promise
   * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html#insertOne
   */
  async insert (collectionName, data, options) {
    this.ObjectId.parseIds(data)
    this.parseOptions(options)
    // MongoDB doesn't like the explicit setting of _id
    delete data._id
    if (data.$set) delete data.$set._id
    try {
      const { insertedId } = await this.getCollection(collectionName).insertOne(data, options)
      const [doc] = await this.find(collectionName, { _id: insertedId })
      return doc
    } catch (e) {
      this.log('error', `failed to insert doc, ${e.message}`)
      throw this.getError(collectionName, 'insert', e)
    }
  }

  /**
   * Retrieves a new object from the database
   * @param {String} collectionName The name of the MongoDB collection
   * @param {Object} query
   * @param {external:MongoDBFindOptions} options Options to pass to the MongoDB driver
   * @return {Promise} promise
   * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html#find
   */
  async find (collectionName, query, options) {
    this.ObjectId.parseIds(query)
    this.parseOptions(options)
    try {
      return await this.getCollection(collectionName).find(query, options).toArray()
    } catch (e) {
      this.log('error', `failed to find docs, ${e.message}`)
      throw this.getError(collectionName, 'find', e)
    }
  }

  /**
   * Updates an existing object in the database
   * @param {String} collectionName The name of the MongoDB collection
   * @param {Object} query
   * @param {Object} data
   * @param {external:MongoDBFindOneAndUpdateOptions} options Options to pass to the MongoDB driver
   * @return {Promise} promise
   * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html#findOneAndUpdate
   */
  async update (collectionName, query, data, options) {
    const opts = Object.assign({ includeResultMetadata: false, returnDocument: 'after' }, options)
    this.parseOptions(opts)
    this.ObjectId.parseIds(query)
    this.ObjectId.parseIds(data)
    // MongoDB doesn't like the explicit setting of _id
    delete data._id
    if (data.$set) delete data.$set._id
    try {
      return await this.getCollection(collectionName).findOneAndUpdate(query, data, opts)
    } catch (e) {
      this.log('error', `failed to update doc, ${e.message}`)
      throw this.getError(collectionName, 'update', e)
    }
  }

  /**
   * Replaces an existing object in the database
   * @param {String} collectionName The name of the MongoDB collection
   * @param {Object} query
   * @param {Object} data
   * @param {external:MongoDBFindOneAndReplaceOptions} options Options to pass to the MongoDB driver
   * @return {Promise} promise
   * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html#findOneAndReplace
   */
  async replace (collectionName, query, data, options) {
    const opts = Object.assign({ includeResultMetadata: false, returnDocument: 'after' }, options)
    this.ObjectId.parseIds(query)
    this.ObjectId.parseIds(data)
    this.parseOptions(options)
    // MongoDB doesn't like the explicit setting of _id
    delete data._id
    if (data.$set) delete data.$set._id
    try {
      return await this.getCollection(collectionName).findOneAndReplace(query, data, opts)
    } catch (e) {
      this.log('error', `failed to replace doc, ${e.message}`)
      throw this.getError(collectionName, 'replace', e)
    }
  }

  /**
   * Removes an existing object from the database
   * @param {String} collectionName The name of the MongoDB collection
   * @param {Object} query
   * @param {external:MongoDBDeleteOptions} options Options to pass to the MongoDB driver
   * @return {Promise} promise
   * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html#deleteOne
   */
  async delete (collectionName, query, options) {
    this.ObjectId.parseIds(query)
    this.parseOptions(options)
    try {
      await this.getCollection(collectionName).deleteOne(query, options)
    } catch (e) {
      this.log('error', `failed to delete doc, ${e.message}`)
      throw this.getError(collectionName, 'delete', e)
    }
  }

  /**
   * Removes multiple objects from the database
   * @param {String} collectionName The name of the MongoDB collection
   * @param {Object} query
   * @param {external:MongoDBDeleteOptions} options Options to pass to the MongoDB driver
   * @return {Promise} promise
   * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html#deleteMany
   */
  async deleteMany (collectionName, query, options) {
    this.ObjectId.parseIds(query)
    this.parseOptions(options)
    try {
      await this.getCollection(collectionName).deleteMany(query, options)
    } catch (e) {
      this.log('error', `failed to delete docs, ${e.message}`)
      throw this.getError(collectionName, 'delete', e)
    }
  }

  /**
   * Returns the relevant AdaptError instance to match the MongoError
   * @param {String} collectionName DB collection being processed
   * @param {String} action DB action being performed
   * @param {String} error The error message
   * @returns {AdaptError}
   */
  getError (collectionName, action, error) {
    let e = this.app.errors.MONGO_ERROR
    if (error.code === 66) e = this.app.errors.MONGO_IMMUTABLE_FIELD
    else if (error.code === 11000) e = this.app.errors.MONGO_DUPL_INDEX
    return e.setData({ collectionName, action, error: error.message })
  }

  /**
   * ObjectId utility functions
   * @type {ObjectIdUtils}
   */
  get ObjectId () {
    return ObjectIdUtils
  }
}

export default MongoDBModule