trailsjs/trailpack-sequelize

View on GitHub
api/services/FootprintService.js

Summary

Maintainability
F
3 days
Test Coverage
'use strict'

const _ = require('lodash')
const Service = require('trails/lib/Service')
const ModelError = require('../../lib').ModelError

const manageError = err => {
  if (err.name === 'SequelizeValidationError') {
    return Promise.reject(new ModelError('E_VALIDATION', err.message, err.errors))
  }
  return Promise.reject(err)
}

/**
 * Trails Service that maps abstract ORM methods to their respective Waterine
 * methods. This service can be thought of as an "adapter" between trails and
 * Sequelize. All methods return native ES6 Promises.
 */
module.exports = class FootprintService extends Service {

  /**
   * Internal method to retreive model object
   * @param modelName name of the model to retreive
   * @returns {*} sequelize model object
   * @private
   */
  _getModel(modelName) {
    return this.app.orm[modelName] || this.app.packs.sequelize.orm[modelName]
  }

  /**
   * Create a model, or models. Multiple models will be created if "values" is
   * an array.
   *
   * @param modelName The name of the model to create
   * @param values The model's values
   * @param options to pass to sequelize
   * @return Promise
   */
  create(modelName, values, options) {
    const Model = this._getModel(modelName)
    const modelOptions = _.defaultsDeep({}, options, this.app.config.get('footprints.models.options'))
    if (!Model) {
      return Promise.reject(new ModelError('E_NOT_FOUND', `${modelName} can't be found`))
    }
    if (modelOptions.populate) {
      modelOptions.include = this._createIncludeField(Model, modelOptions.populate)
    }
    return Model.create(values, modelOptions).catch(manageError)
  }

  _createIncludeField(model, populate) {
    if (populate === true || populate === 'all') return {all: true}
    if (_.isPlainObject(populate) || _.isArray(populate)) return populate

    const fields = populate.split(',')
    const includes = []

    fields.forEach((value, key) => {
      includes.push(model.associations[value])
    })

    return includes
  }

  /**
   * Find all models that satisfy the given criteria. If a primary key is given,
   * the return value will be a single Object instead of an Array.
   *
   * @param modelName The name of the model
   * @param criteria The criteria that filter the model resultset
   * @param options to pass to sequelize
   * @return Promise
   */
  find(modelName, criteria, options) {
    const Model = this._getModel(modelName)
    const modelOptions = _.defaultsDeep({}, options, _.get(this.app.config, 'footprints.models.options'))
    let query
    if (!Model) {
      return Promise.reject(new ModelError('E_NOT_FOUND', `${modelName} can't be found`))
    }
    if (modelOptions.populate) {
      modelOptions.include = this._createIncludeField(Model, modelOptions.populate)
    }
    delete modelOptions.populate

    if (!_.isPlainObject(criteria) || modelOptions.findOne === true) {
      if (modelOptions.findOne === true) {
        if (!criteria.where) {
          criteria = {where: criteria}
        }

        query = Model.find(_.defaults(criteria, modelOptions))
      }
      else {
        query = Model.find(_.defaults({
          where: {
            [Model.primaryKeyAttribute]: criteria
          }
        }, modelOptions))
      }
    }
    else {
      if (!criteria.where) {
        criteria = {where: criteria}
      }
      if (modelOptions.sort) {
        criteria.order = modelOptions.sort
        delete modelOptions.sort
      }
      query = Model.findAll(_.defaults(criteria, modelOptions))
    }

    return query.catch(manageError)
  }

  /**
   * Update an existing model, or models, matched by the given by criteria, with
   * the given values. If the criteria given is the primary key, then return
   * exactly the object that is updated; otherwise, return an array of objects.
   *
   * @param modelName The name of the model
   * @param criteria The criteria that determine which models are to be updated   *
   * @param [id] A optional model id; overrides "criteria" if both are specified.
   * @param values to update
   * @param options extends { where: criteria } then passed to sequelize
   * @return Promise
   */
  update(modelName, criteria, values, options) {
    const Model = this._getModel(modelName)
    if (!options) options = {}
    if (!Model) {
      return Promise.reject(new ModelError('E_NOT_FOUND', `${modelName} can't be found`))
    }
    let query
    if (!criteria) {
      criteria = {}
    }

    if (_.isArray(options.populate) || _.isPlainObject(options.populate)) {
      options.include = options.populate
      delete options.populate
    }

    if (_.isPlainObject(criteria)) {
      if (!criteria.where) {
        criteria = {where: criteria}
      }
      query = Model.update(values, _.extend(criteria, options))
    }
    else {
      criteria = {
        where: {
          [Model.primaryKeyAttribute]: criteria
        }
      }

      query = Model.update(values, _.extend(criteria, options))
    }

    return query.catch(manageError)
  }

  /**
   * Destroy (delete) the model, or models, that match the given criteria.
   *
   * @param modelName The name of the model
   * @param criteria The criteria that determine which models are to be updated
   * @param options to pass to sequelize
   * @return Promise
   */
  destroy(modelName, criteria, options) {
    const Model = this._getModel(modelName)
    if (!options) options = {}
    let query
    if (!Model) {
      return Promise.reject(new ModelError('E_NOT_FOUND', `${modelName} can't be found`))
    }

    if (_.isArray(options.populate) || _.isPlainObject(options.populate)) {
      options.include = options.populate
      delete options.populate
    }

    if (_.isPlainObject(criteria)) {
      if (!criteria.where) {
        criteria = {where: criteria}
      }
      if (_.isArray(options.populate) || _.isPlainObject(options.populate)) {
        criteria.include = options.populate
        delete options.populate
      }
      query = Model.destroy(_.extend(criteria, options))
    }
    else {
      criteria = {
        where: {
          [Model.primaryKeyAttribute]: criteria
        }
      }
      query = Model.destroy(_.extend(criteria, options)).then(results => results[0])
    }

    return query.catch(manageError)
  }

  _findNativeAssociation(model, childAttributeName) {
    return model.associations[childAttributeName] ||
      model.associations[childAttributeName.charAt(0).toUpperCase() + childAttributeName.slice(1)]
  }

  /**
   * Create a model, and associate it with its parent model.
   *
   * @param parentModelName The name of the model's parent
   * @param childAttributeName The name of the model to create
   * @param parentId The id (required) of the parent model
   * @param values The model's values
   * @param options to pass to sequelize
   * @return Promise
   */
  createAssociation(parentModelName, parentId, childAttributeName, values, options) {
    const parentModel = this._getModel(parentModelName)
    if (!parentModel) {
      return Promise.reject(new ModelError('E_NOT_FOUND', `${parentModelName} can't be found`))
    }
    const association = this._findNativeAssociation(parentModel, childAttributeName)
    if (!association) {
      return Promise.reject(new ModelError('E_NOT_FOUND', `${parentModelName}'s association ${childAttributeName} can't be found`))
    }
    const childModelName = association.target.name
    const childModel = this._getModel(childModelName)

    if (!options) {
      options = {}
    }
    // Used for things like hasMany
    if (association.foreignKeyField || (association.sourceKey && !association.targetKey)) {
      values[association.foreignKeyField || association.foreignKey] = parentId
    }
    return parentModel.sequelize.transaction(t => {
      options.transaction = t
      return this.create(childModelName, values, options).then(child => {
        let promise = Promise.resolve()
        // Used for things like belongsToMany
        if (association.throughModel) {
          promise = promise.then(association.throughModel.create({
            [association.identifierField]: parentId,
            [association.foreignIdentifierField]: child[childModel.primaryKeyAttribute]
          }, options))
        }
        // Used for things like belongsTo
        if (!association.foreignKeyField) {
          promise = promise.then(this.update(parentModelName, parentId, {
            [association.identifierField]: child[childModel.primaryKeyAttribute]
          }, options).then(() => child))
        }
        promise = promise.then(() => child)
        return promise
      })
    })
  }

  /**
   * Find all models that satisfy the given criteria, and which is associated
   * with the given Parent Model.
   *
   * @param parentModelName The name of the model's parent
   * @param childAttributeName The name of the model to create
   * @param parentId The id (required) of the parent model
   * @param criteria The search criteria
   * @param options to pass to sequelize
   * @return Promise
   */
  findAssociation(parentModelName, parentId, childAttributeName, criteria, options) {
    const parentModel = this._getModel(parentModelName)
    if (!parentModel) {
      return Promise.reject(new ModelError('E_NOT_FOUND', `${parentModelName} can't be found`))
    }
    const association = this._findNativeAssociation(parentModel, childAttributeName)
    if (!association) {
      return Promise.reject(new ModelError('E_NOT_FOUND', `${parentModelName}'s association ${childAttributeName} can't be found`))
    }
    const childModelName = association.target.name
    const childModel = this._getModel(childModelName)
    if (!childModel) {
      return Promise.reject(new ModelError('E_NOT_FOUND', `${childModelName} can't be found`))
    }

    // Used for things like hasMany
    if (association.foreignKeyField || (association.sourceKey && !association.targetKey)) {
      if (!criteria) {
        criteria = {}
      }
      else if (!_.isPlainObject(criteria)) {
        criteria = {
          [childModel.primaryKeyAttribute]: criteria
        }
      }
      criteria[association.foreignKeyField || association.foreignKey] = parentId
      if (!options) {
        options = {}
      }
      return this.find(childModelName, criteria, options)
    }
    // Used for things like belongsToMany
    else if (association.throughModel) {
      // Get all through tables with the parent
      return this._getThroughModelAssociation(association, parentId)
        .then(ids => childModel.findAll(_.extend({
          where: {
            $and: [
              criteria || {},
              {
                [childModel.primaryKeyAttribute]: {
                  $in: ids
                }
              }
            ]
          }
        }, options)))
        .catch(manageError)
    }
    // Used for things like belongsTo
    else {
      return this.find(parentModelName, parentId)
        .then(parent => this.find(childModelName, parent[association.identifierField], options))
    }
  }

  /**
   * Update models by criteria, and which is associated with the given
   * Parent Model.
   *
   * @param parentModelName The name of the model's parent
   * @param parentId The id (required) of the parent model
   * @param childAttributeName The name of the model to create
   * @param criteria The search criteria
   * @param values to update
   * @param options to pass to sequelize
   * @return Promise
   */
  updateAssociation(parentModelName, parentId, childAttributeName, criteria, values, options) {
    const parentModel = this._getModel(parentModelName)
    if (!parentModel) {
      return Promise.reject(new ModelError('E_NOT_FOUND', `${parentModelName} can't be found`))
    }
    const association = this._findNativeAssociation(parentModel, childAttributeName)
    if (!association) {
      return Promise.reject(new ModelError('E_NOT_FOUND', `${parentModelName}'s association ${childAttributeName} can't be found`))
    }
    const childModelName = association.target.name
    const childModel = this._getModel(childModelName)
    if (!childModel) {
      return Promise.reject(new ModelError('E_NOT_FOUND', `${childModelName} can't be found`))
    }

    // Used for things like hasMany
    if (association.foreignKeyField || (association.sourceKey && !association.targetKey)) {
      if (!options) {
        options = {}
      }
      if (!criteria) {
        criteria = {}
      }
      else if (!_.isPlainObject(criteria)) {
        criteria = {
          [childModel.primaryKeyAttribute]: criteria
        }
        options.findOne = true
      }
      criteria[association.foreignKeyField || association.foreignKey] = parentId
      return this.update(childModelName, criteria, values, options)
    }
    // Used for things like belongsToMany
    else if (association.throughModel) {
      // Get all through tables with the parent
      return this._getThroughModelAssociation(association, parentId)
        .then(ids => childModel.update(values, _.extend({
          where: {
            $and: [
              criteria || {},
              {
                [childModel.primaryKeyAttribute]: {
                  $in: ids
                }
              }
            ]
          }
        }, options)))
        .catch(manageError)
    }
    // Used for things like belongsTo
    else {
      return this.find(parentModelName, parentId)
        .then(parent => this.update(childModelName, parent[association.identifierField], values, options))
    }
  }

  _getThroughModelAssociation(association, parentId) {
    return association.throughModel.findAll({
      where: {
        [association.identifierField]: parentId
      },
      attributes: [association.foreignIdentifierField]
    })
      // Get the childrens' IDs
      .then(throughTables => throughTables.map(throughTable => throughTable[association.foreignIdentifierField]))
  }

  /**
   * Destroy models by criteria, and which is associated with the
   * given Parent Model.
   *
   * @param parentModelName The name of the model's parent
   * @param parentId The id (required) of the parent model
   * @param childAttributeName The name of the model to create
   * @param criteria The search criteria
   * @param options to pass to sequelize
   * @return Promise
   */
  destroyAssociation(parentModelName, parentId, childAttributeName, criteria, options) {
    return this
      .findAssociation(parentModelName, parentId, childAttributeName, criteria, options)
      .then(records => {
        // If criteria is the ID for instance
        if (!(records instanceof Array)) {
          records = [records]
        }
        return Promise.all(records.map(record => {
          return record.destroy()
        }))
      })
      .catch(manageError)
  }
}