Source

admin-bro-mongoose/src/resource.js

/* eslint-disable no-param-reassign */

const {
  BaseResource,
  BaseRecord,
} = require('admin-bro')
const _ = require('lodash')
const { unflatten } = require('flat')
const Property = require('./property')
const convertFilter = require('./utils/convert-filter')
const createValidationError = require('./utils/create-validation-error')
const createDuplicateError = require('./utils/create-duplicate-error')
const createCastError = require('./utils/create-cast-error')

const {
  MONGOOSE_CAST_ERROR,
  MONGOOSE_DUPLICATE_ERROR_CODE,
  MONGOOSE_VALIDATION_ERROR,
} = require('./utils/errors')

/**
 * Adapter for mongoose resource
 * @private
 */
class Resource extends BaseResource {
  static isAdapterFor(MoongooseModel) {
    return _.get(MoongooseModel, 'base.constructor.name') === 'Mongoose'
  }

  /**
     * @typedef {Object} MongooseModel
     * @private
     * @see https://mongoosejs.com/docs/models.html
     */

  /**
     * Initialize the class with the Resource name
     * @param {MongooseModel} MongooseModel Class which subclass mongoose.Model
     * @memberof Resource
     */
  constructor(MongooseModel) {
    super(MongooseModel)
    this.dbType = 'mongodb'
    this.MongooseModel = MongooseModel
  }

  databaseName() {
    return this.MongooseModel.db.name
  }

  databaseType() {
    return this.dbType
  }

  name() {
    return this.MongooseModel.modelName
  }

  id() {
    return this.MongooseModel.modelName
  }

  properties() {
    return Object.entries(this.MongooseModel.schema.paths).map(([, path], position) => (
      new Property(path, position)
    ))
  }

  property(name) {
    const position = this.properties().findIndex(property => property.path() === name)
    if (position >= 0) {
      return this.properties()[position]
    }
    return null
  }

  async count(filters) {
    return this.MongooseModel.find(convertFilter(filters)).countDocuments()
  }

  async find(filters = {}, { limit = 20, offset = 0, sort = {} }) {
    const { direction, sortBy } = sort
    const sortingParam = {
      [sortBy]: direction,
    }
    const mongooseObjects = await this.MongooseModel
      .find(convertFilter(filters), {}, {
        autopopulate: false, skip: offset, limit, sort: sortingParam,
      })
    return mongooseObjects.map(mongooseObject => new BaseRecord(
      Resource.stringifyId(mongooseObject), this,
    ))
  }

  fillPopulatedData(baseRecord, path, recordsHash) {
    const id = baseRecord.param(path)
    if (recordsHash[id]) {
      const referenceRecord = new BaseRecord(
        Resource.stringifyId(recordsHash[id]), this,
      )
      baseRecord.populated[path] = referenceRecord
    }
  }

  async populate(baseRecords, property) {
    const ids = baseRecords.reduce((memo, baseRecord) => {
      if (property.isArray()) {
        const array = baseRecord.param(property.name()) || []
        return [...memo, ...array]
      }
      return [...memo, baseRecord.param(property.name())]
    }, [])
    const records = await this.MongooseModel.find({ _id: ids })
    const recordsHash = records.reduce((memo, record) => {
      memo[record._id] = record
      return memo
    }, {})

    baseRecords.forEach((baseRecord) => {
      if (property.isArray()) {
        const filtered = baseRecord.namespaceParams(property.name()) || {}
        for (const path of Object.keys(filtered)) {
          this.fillPopulatedData(baseRecord, path, recordsHash)
        }
      } else {
        this.fillPopulatedData(baseRecord, property.name(), recordsHash)
      }
    })
  }

  async findOne(id) {
    const mongooseObject = await this.MongooseModel.findById(id, {}, { autopopulate: false })
    return new BaseRecord(Resource.stringifyId(mongooseObject), this)
  }

  async findMany(ids) {
    const mongooseObjects = await this.MongooseModel.find({ _id: ids }, {}, { autopopulate: false })
    return mongooseObjects.map(mongooseObject => (
      new BaseRecord(Resource.stringifyId(mongooseObject), this)
    ))
  }

  build(params) {
    return new BaseRecord(Resource.stringifyId(params), this)
  }

  async create(params) {
    const parsedParams = this.parseParams(params)
    let mongooseDocument = new this.MongooseModel(parsedParams)
    try {
      mongooseDocument = await mongooseDocument.save()
    } catch (error) {
      if (error.name === MONGOOSE_VALIDATION_ERROR) {
        throw createValidationError(error)
      }
      if (error.code === MONGOOSE_DUPLICATE_ERROR_CODE) {
        throw createDuplicateError(error, mongooseDocument.toJSON())
      }
      throw error
    }
    return mongooseDocument.toObject()
  }

  async update(id, params) {
    const parsedParams = this.parseParams(params)
    const unflattedParams = unflatten(parsedParams)
    try {
      const mongooseObject = await this.MongooseModel.findOneAndUpdate({
        _id: id,
      }, {
        $set: unflattedParams,
      }, {
        new: true,
        runValidators: true,
      })
      return mongooseObject.toObject()
    } catch (error) {
      if (error.name === MONGOOSE_VALIDATION_ERROR) {
        throw createValidationError(error)
      }
      if (error.code === MONGOOSE_DUPLICATE_ERROR_CODE) {
        throw createDuplicateError(error, unflattedParams)
      }
      // In update cast errors are not wrapped into a validation errors (as it happens in create).
      // that is why we have to have a different way of handling them - check out tests to see
      // example error
      if (error.name === MONGOOSE_CAST_ERROR) {
        throw createCastError(error)
      }
      throw error
    }
  }

  async delete(id) {
    return this.MongooseModel.findOneAndRemove({ _id: id })
  }

  static stringifyId(mongooseObj) {
    // By default Id field is an ObjectID and when we change entire mongoose model to
    // raw object it changes _id field not to a string but to an object.
    // stringify/parse is a path found here: https://github.com/Automattic/mongoose/issues/2790
    // @todo We can somehow speed this up
    const strinigified = JSON.stringify(mongooseObj)
    return JSON.parse(strinigified)
  }

  /**
     * Check all params against values they hold. In case of wrong value it corrects it.
     *
     * What it does esactly:
     * - changes all empty strings to `null`s for the ObjectID properties.
     * - changes all empty strings to [] for array fields
     *
     * @param   {Object}  params  received from AdminBro form
     *
     * @return  {Object}          converted params
     */
  parseParams(params) {
    const parasedParams = { ...params }

    // this function handles ObjectIDs and Arrays recursively
    const handleProperty = (prefix = '') => (property) => {
      const {
        path,
        schema,
        instance,
      } = property
      // mongoose doesn't supply us with the same path as we're using in our data
      // so we need to improvise
      const fullPath = [prefix, path].filter(Boolean).join('.')
      const value = parasedParams[fullPath]

      // this handles missing ObjectIDs
      if (instance === 'ObjectID') {
        if (value === '') {
          parasedParams[fullPath] = null
        }
      }

      // this handles empty Arrays or recurses into all properties of a filled Array
      if (instance === 'Array') {
        if (value === '') {
          parasedParams[fullPath] = []
        } else if (schema && schema.paths) { // we only want arrays of objects (with sub-paths)
          const subProperties = Object.values(schema.paths)
          // eslint-disable-next-line no-plusplus, no-constant-condition
          for (let i = 0; true; i++) { // loop over every item
            const newPrefix = `${fullPath}.${i}`
            if (parasedParams[newPrefix] === '') {
              // this means we have an empty object here
              parasedParams[newPrefix] = {}
            } else if (!Object.keys(parasedParams).some(key => key.startsWith(newPrefix))) {
              // we're past the last index of this array
              break
            } else {
              // recurse into the object
              subProperties.forEach(handleProperty(newPrefix))
            }
          }
        }
      }

      // this handles all properties of an object
      if (instance === 'Embedded') {
        if (parasedParams[fullPath] === '') {
          parasedParams[fullPath] = {}
        } else {
          const subProperties = Object.values(schema.paths)
          subProperties.forEach(handleProperty(fullPath))
        }
      }
    }

    this.properties().forEach(({ mongoosePath }) => handleProperty()(mongoosePath))

    return parasedParams
  }
}

module.exports = Resource