index.js

import nql from '@tryghost/nql'
import {
  collectConnections, collectAdapters, collectFeatures, collectModels
} from './lib/helper.js'

/**
 * @typedef {Object} TPropertyType
 * @memberof DoboModel
 * @property {Object} integer
 * @property {string} [integer.validator=number]
 * @property {Object} smallint
 * @property {string} [smallint.validator=number]
 * @property {Object} text
 * @property {string} [text.validator=string]
 * @property {string} [text.textType=string]
 * @property {string[]} [text.values=['text', 'mediumtext', 'longtext']]
 * @property {Object} string
 * @property {string} [string.validator=string]
 * @property {maxLength} [string.maxLength=255]
 * @property {minLength} [string.minLength=0]
 * @property {Object} float
 * @property {string} [float.validator=number]
 * @property {Object} double
 * @property {string} [double.validator=number]
 * @property {Object} boolean
 * @property {string} [boolean.validator=boolean]
 * @property {Object} datetime
 * @property {string} [datetime.validator=date]
 * @property {Object} date
 * @property {string} [date.validator=date]
 * @property {Object} time
 * @property {string} [time.validator=date]
 * @property {Object} timestamp
 * @property {string} [timestamp.validator=timestamp]
 * @property {Object} object={}
 * @property {Object} array={}
 */
const propertyType = {
  integer: {
    validator: 'number',
    rules: []
  },
  smallint: {
    validator: 'number',
    rules: []
  },
  text: {
    validator: 'string',
    textType: 'text',
    // values: ['text', 'mediumtext', 'longtext'], TODO: should check against textType, NOT value
    rules: []
  },
  string: {
    validator: 'string',
    maxLength: 50,
    minLength: 0,
    rules: []
  },
  float: {
    validator: 'number',
    rules: []
  },
  double: {
    validator: 'number',
    rules: []
  },
  boolean: {
    validator: 'boolean',
    rules: []
  },
  datetime: {
    validator: 'date',
    rules: []
  },
  object: {
    validator: 'object',
    rules: []
  },
  array: {
    validator: 'array',
    rules: []
  }
}

const commonPropertyTypes = ['name', 'type', 'required', 'rules', 'validator', 'ref', 'default', 'values', 'rulesMsg', 'immutable', 'feature', 'format', 'getValue', 'virtual']

/**
 * Plugin factory
 *
 * @name pluginFactory
 * @async
 * @method
 * @param {string} pkgName - NPM package name
 * @returns {class}
 */
async function factory (pkgName) {
  const me = this
  const { breakNsPath } = this.app.bajo

  const { find, filter, isString, map, pick, groupBy, isEmpty } = this.app.lib._

  /**
   * Dobo Database Framework for {@link https://github.com/ardhi/bajo|Bajo}.
   *
   * See {@tutorial ecosystem} for available adapters & tools
   *
   * @class
   */
  class Dobo extends this.app.baseClass.Base {
    /**
     * @constant {string[]}
     * @memberof Dobo
     * @default ['count', 'avg', 'min', 'max', 'sum']
     */
    static aggregateTypes = ['count', 'avg', 'min', 'max', 'sum']

    /**
     * @constant {string[]}
     * @memberof Dobo
     * @default ['string', 'integer', 'smallint']
     */
    static idTypes = ['string', 'integer', 'smallint']

    /**
     * @constant {string[]}
     * @memberof Dobo
     * @default ['daily', 'monthly', 'annually']
     */
    static histogramTypes = ['daily', 'monthly', 'yearly']

    /**
     * @constant {TPropertyType}
     * @memberof Dobo
     */
    static propertyType = propertyType

    /**
     * @constant {string[]}
     * @memberof Dobo
     * @default ['index', 'unique', 'primary', 'fulltext']
     */
    static indexTypes = ['index', 'unique', 'primary', 'fulltext']

    constructor () {
      super(pkgName, me.app)
      this.config = {
        connections: [],
        validationParams: {
          abortEarly: false,
          convert: false,
          allowUnknown: true
        },
        default: {
          filter: {
            limit: 25, // num of records per page
            maxLimit: 200, // max num of records per page
            maxPage: 400, // max allowed page number
            sort: ['dt:-1', 'updatedAt:-1', 'createdAt:-1', 'ts:-1', 'username', 'name']
          },
          cache: {
            ttlDur: '10s'
          },
          hardCap: 10000, // max returned records
          warnings: true,
          attachment: {
            thumbSizes: ['s', 'm', 'l']
          }
        },
        memDb: {
          persistenceDur: '1s'
        },
        applet: {
          confirmation: false
        }
      }

      /**
       * @type {Object[]}
       */
      this.adapters = []

      /**
       * @type {Object[]}
       */
      this.connections = []

      /**
       * @type {Object[]}
       */
      this.features = []

      /**
       * @type {Object[]}
       */
      this.models = []
    }

    /**
     * Get allowed property keys by field type
     *
     * @param {string} type
     * @returns {string[]}
     */
    getPropertyKeysByType = (type) => {
      const keys = [...commonPropertyTypes]
      if (['string'].includes(type)) keys.push('minLength', 'maxLength', 'values')
      if (['text'].includes(type)) keys.push('textType')
      if (['smallint', 'integer'].includes(type)) keys.push('autoInc', 'values')
      if (['float', 'double'].includes(type)) keys.push('values')
      return keys
    }

    /**
     * Get all allowed property keys
     *
     * @returns {string[]}
     */

    getAllPropertyKeys = (adapter) => {
      const { uniq, isEmpty } = this.app.lib._
      const keys = [...commonPropertyTypes]
      for (const type in propertyType) {
        keys.push(...Object.keys(propertyType[type]))
      }
      if (adapter && !isEmpty(adapter.constructor.propertyKeys)) keys.push(...adapter.constructor.propertyKeys)
      return uniq(keys)
    }

    /**
     * Initialize plugin and performing the following tasks:
     * - {@link module:Lib.collectAdapters|Collecting all adapters}
     * - {@link module:Lib.collectConnections|Collecting all connections}
     * - {@link module:Lib.collectFeatures|Collecting all features}
     * - {@link module:Lib.collectModels|Collecting all models}
     * @method
     * @async
     */
    init = async () => {
      const { fs } = this.app.lib
      await collectAdapters.call(this)
      await collectConnections.call(this)
      await collectFeatures.call(this)
      await collectModels.call(this)
      const attDir = `${this.app.getPluginDataDir('dobo')}/attachment`
      fs.ensureDirSync(attDir)
    }

    /**
     * Start plugin
     *
     * @method
     * @async
     * @param {(string|Array)} [conns=all] - Which connections should be run on start
     * @param {boolean} [noRebuild=true] - Set ```false``` to not rebuild model on start. Yes, only set it to ```false``` if you REALLY know what you're doing!!!
     */
    start = async (conns = 'all', noRebuild = true) => {
      if (conns === 'all') conns = this.connections
      else if (isString(conns)) conns = filter(this.connections, { name: conns })
      else conns = map(conns, c => find(this.connections, { name: c }))
      this.log.debug('dbInit')
      for (const connection of conns) {
        await connection.connect(noRebuild)
        this.log.trace('dbInit%s%s%s', connection.adapter.plugin.ns, connection.adapter.name, connection.name)
      }
    }

    /**
     * Get connection by name. It returns the first connection named after "{name}"
     *
     * @param {string} name - Connection name
     * @param {boolean} [silent] - If ```true``` and connection is not found, it won't throw error
     * @returns {Adapter} Return connection instance or ```undefined``` if silent is ```true```
     */
    getConnection = (name, silent) => {
      const conn = find(this.connections, { name })
      if (!conn && !silent) throw this.error('unknown%s%s', this.t('connection'), name)
      return conn
    }

    /**
     * Get adapter by name. It returns the first adapter named after "{name}"
     *
     * Also support NsPath format for those who load the same named adapter but from different provider/plugin.
     *
     * @param {string} name - Adapter name
     * @param {boolean} [silent] - If ```true``` and adapter is not found, it won't throw error
     * @returns {Adapter} Returns adapter instance or ```undefined``` if silent is ```true```
     */
    getAdapter = (name, silent) => {
      const { breakNsPath } = this.app.bajo
      let adapter
      if (!name.includes(':')) {
        adapter = find(this.adapters, { name })
        if (adapter) return adapter
        adapter = filter(this.adapters, d => d.plugin.ns === name)
        if (adapter.length === 1) return adapter[0]
        if (!silent) throw this.error('unknown%s%s', this.t('adapter'), name)
        return
      }
      const { ns, path } = breakNsPath(name)
      adapter = find(this.adapters, d => d.name === path && d.plugin.ns === ns)
      if (!adapter && !silent) throw this.error('unknown%s%s', this.t('adapter'), name)
      return adapter
    }

    /**
     * Get feature by name. It returns the first adapter named after "{name}"
     *
     * Also support NsPath format for those who load the same named adapter but from different provider/plugin.
     *
     * @param {string} - Feature name
     * @returns {Feature} Return feature instance
     */
    getFeature = name => {
      if (!name.includes(':')) return find(this.features, { name })
      const { ns, path } = breakNsPath(name)
      const feat = find(this.features, d => d.name === path && d.plugin.ns === ns)
      if (!feat) throw this.error('unknown%s%s', this.t('feature'), name)
      return feat
    }

    /**
     * Get model by name
     *
     * @param {string} name - Model name
     * @param {boolean} [silent] - If ```true``` and model is not found, it won't throw error
     * @retuns {Model} Returns model instance or ```undefined``` if silent is ```true```
     */
    getModel = (name, silent) => {
      const { pascalCase } = this.app.lib.aneka
      let model = find(this.models, { name })
      if (!model) model = find(this.models, { name: pascalCase(name) })
      if (!model && !silent) throw this.error('unknown%s%s', this.t('model'), name)
      return model
    }

    /**
     * Get all models that bound to connection ```name```
     *
     * @param {string} name - Connection name
     * @returns {Array}
     */
    getModelsByConnection = name => {
      const conn = this.getConnection(name)
      return this.models.filter(s => s.connection.name === conn.name)
    }

    validationErrorMessage = (err) => {
      let text = err.message
      if (err.details) {
        text += ' -> '
        text += this.app.bajo.join(err.details.map((d, idx) => {
          return `${d.field}@${err.model}: ${d.error} (${d.value})`
        }))
      }
      return text
    }

    /**
     * Sanitize value as a date/time value. Parse/format string using {@link https://day.js.org/docs/en/display/format|dayjs format}
     *
     * @method
     * @memberof Dobo
     * @param {(number|string)} value - Value to sanitize
     * @param {Object} [options={}] - Options object
     * @param {boolean} [options.silent=true] - If ```true``` (default) and value isn't valid, returns empty
     * @param {string} [options.inputFormat] - If provided, parse value using this option
     * @param {string} [options.outputFormat] - If not provided or ```native```, returns Javascript Date. Otherwise returns formatted date/time string
     * @returns {(string|Date)}
     */
    sanitizeDate = (value, { inputFormat, outputFormat, silent = true } = {}) => {
      const { dayjs } = this.app.lib
      if (value === 0) return null
      const dt = dayjs(value, inputFormat)
      if (!dt.isValid()) {
        if (silent) return null
        throw this.error('invalidDate')
      }
      if (outputFormat === 'native' || !outputFormat) return dt.toDate()
      return dt.format(outputFormat)
    }

    sanitizeFloat = (value, { strict = false } = {}) => {
      const { isNumber } = this.app.lib._
      if (isNumber(value)) return value
      if (strict) return Number(value)
      return parseFloat(value) || null
    }

    sanitizeInt = (value, { strict = false } = {}) => {
      const { isNumber } = this.app.lib._
      if (isNumber(value)) return value
      if (strict) return Number(value)
      return parseInt(value) || null
    }

    sanitizeObject = (value, { strict = false, action, model } = {}) => {
      const { isString } = this.app.lib._
      let result = null
      if (isString(value)) {
        try {
          result = JSON.parse(value)
        } catch (err) {
          if (strict) throw err
        }
      } else {
        try {
          result = JSON.parse(JSON.stringify(value))
        } catch (err) {
          if (strict) throw err
        }
      }
      return result
    }

    sanitizeBoolean = (value) => {
      return value === null ? null : (['true', true].includes(value))
    }

    sanitizeTimestamp = (value) => {
      const { isNumber } = this.app.lib._
      const { dayjs } = this.app.lib
      if (!isNumber(value)) return -1
      const dt = dayjs.unix(value)
      return dt.isValid() ? dt.unix() : -1
    }

    sanitizeString = (value) => {
      return value + ''
    }

    sanitizeByType = (value, type, opts = {}) => {
      const { isNaN } = this.app.lib._
      let result = value
      if (['object', 'array'].includes(type)) result = this.sanitizeObject(value, opts)
      else if (type === 'boolean') result = this.sanitizeBoolean(value)
      else if (['float', 'double'].includes(type)) result = this.sanitizeFloat(value, opts)
      else if (['integer', 'smallint'].includes(type)) result = this.sanitizeInt(value, opts)
      else if (['string', 'text'].includes(type)) result = this.sanitizeString(result, opts)
      else if (['datetime'].includes(type)) result = this.sanitizeDate(result, opts)
      if (!opts.strict && isNaN(result)) result = null
      return result
    }

    _calcStats = (items, field, aggregates) => {
      const { generateId, isSet } = this.app.lib.aneka
      const result = { id: generateId, count: 0, avg: null, min: null, max: null }
      let sum = 0
      for (const item of items) {
        const value = Number(item[field]) ?? 0
        if (aggregates.includes('count')) result.count++
        if (aggregates.includes('avg')) sum = sum + value
        if (aggregates.includes('min') && (!isSet(result.min) || value < result.min)) result.min = value
        if (aggregates.includes('max') && (!isSet(result.max) || value > result.max)) result.max = value
      }
      result.avg = sum / items.length
      return result
    }

    calcAggregate = ({ data = [], group = '', field = '', aggregates = ['count'] } = {}) => {
      this.checkAggregateParams({ group, field, aggregates })
      const { pick, groupBy } = this.app.lib._
      const grouped = groupBy(data, group)
      const all = []
      for (const key in grouped) {
        const items = grouped[key]
        const result = this._calcStats(items, field, aggregates)
        result[group] = key
        all.push(pick(result, ['id', group, ...aggregates]))
      }
      return all
    }

    calcHistogram = ({ data = [], type = '', group = '', field = '', aggregates = ['count'] } = {}) => {
      this.checkHistogramParams({ group, field, type, aggregates })
      const { dayjs } = this.app.lib
      const pattern = { daily: ['YYYY-MM-DD', 'date'], monthly: ['YYYY-MM', 'month'], yearly: ['YYYY', 'year'] }
      for (const d of data) {
        d._group = dayjs(d[group]).format(pattern[type][0])
      }
      const grouped = groupBy(data, '_group')
      const all = []
      for (const key in grouped) {
        const items = grouped[key]
        const result = this._calcStats(items, field, aggregates)
        const title = pattern[type][1]
        result[title] = key
        all.push(pick(result, ['id', title, ...aggregates]))
      }
      return all
    }

    checkAggregateParams = (params = {}) => {
      let { group, field, aggregates } = params
      if (isString(aggregates)) aggregates = [aggregates]
      params.aggregates = aggregates
      if (isEmpty(group)) throw this.error('fieldGroupMissing')
      if (isEmpty(field)) throw this.error('fieldCalcMissing')
      for (const agg of aggregates) {
        if (!this.constructor.aggregateTypes.includes(agg)) throw this.error('unsupportedAggregateType%s', agg)
      }
    }

    checkHistogramParams = (params = {}) => {
      this.checkAggregateParams(params)
      const { type } = params
      if (isEmpty(type)) throw this.error('histogramTypeMissing')
      if (!this.constructor.histogramTypes.includes(type)) throw this.error('unsupportedHistogramType%s', type)
    }

    runModelHook = async (model, hookName, ...args) => {
      const { orderBy } = this.app.lib._
      const hooks = orderBy(model.hooks.filter(hook => hook.name === hookName), ['level'])
      for (const hook of hooks) {
        if (hook.noWait) hook.handler.call(model, ...args)
        else await hook.handler.call(model, ...args)
      }
    }

    getDefaultValues = (options = {}) => {
      const { defaultsDeep } = this.app.lib.aneka
      const key = 'default.filter'
      let config = this.getConfig(key)
      config.hardCap = this.config.default.hardCap
      config.warnings = this.config.default.warnings
      if (options.req) config = defaultsDeep({}, options.req.getSetting('dobo:default'), config)
      const { limit, maxLimit, maxPage, hardCap, warnings } = config
      const t = options.req ? options.req.t : this.t
      return { limit, maxLimit, hardCap, maxPage, warnings, t }
    }

    handleLastPage = (params = {}, options = {}) => {
      const { count, limit, page } = params
      const { hardCap } = this.getDefaultValues(options)
      if (count > hardCap) {
        const lastPage = Math.floor(hardCap / limit)
        if (page > lastPage) {
          const warnings = [this.t('hardCapWarning%s%s', count, hardCap)]
          return { data: [], count: hardCap, warnings }
        }
      }
    }

    parseNql = (text) => {
      const sanitized = text.split('+').map(item => {
        const [key, ...rest] = item.split(':').map(i => i.trim())
        let value = rest.join(':')
        const neg = value[1] === '-' ? '-' : ''
        if ((value[0] === '{' || value[1] === '{') && value[value.length - 1] === '}') {
          if (value[0] === '-') value = value.slice(1)
          const items = value.slice(1, -1).split(',')
          value = `${neg}>=${items[0]}+${key}:${neg}<=${items[1]}`
        }
        return `${key}:${value}`
      }).join('+')
      return nql(sanitized).parse()
    }

    parseAny = (query, model) => {
      const { isEmpty } = this.app.lib._
      let q = {}
      if (!model) throw this.error('invalidQuery')
      let scanables = [...model.scanables]
      if (scanables.length === 0) scanables = [...model.sortables]
      const fields = scanables.filter(f => {
        const field = find(model.properties, { name: f, type: 'string' })
        return !!field
      })
      const parts = fields.map(f => {
        if (query[0] === '*') return `${f}:~$'${query.replaceAll('*', '')}'`
        if (query[query.length - 1] === '*') return `${f}:~^'${query.replaceAll('*', '')}'`
        return `${f}:~'${query.replaceAll('*', '')}'`
      })
      if (parts.length === 1) q = this.parseNql(parts[0])
      else if (parts.length > 1) q = this.parseNql(parts.join(','))
      if (isEmpty(q)) throw this.error('invalidQuery')
      return q
    }

    parseQuery = (query, model, silent = true) => {
      const { isEmpty, isPlainObject, trim } = this.app.lib._
      let result = {}
      if (isPlainObject(query)) result = query
      else {
        query = trim(query)
        if (isEmpty(query)) return result
        try {
          if (query.startsWith('{')) result = JSON.parse(query)
          else if (query.includes(':')) result = this.parseNql(query)
          else result = this.parseAny(query, model)
        } catch (err) {
          if (silent) return {}
          throw err
        }
      }
      let strQ = this.replaceRegexInJson(result)
      if (model && model.adapter.idField.name !== 'id') strQ = strQ.replaceAll('"id"', `"${model.adapter.idField.name}"`)
      try {
        result = this.reviveRegexInJson(strQ)
      } catch (err) {}
      return result
    }

    replaceRegexInJson = (input = {}, returnString = true) => {
      const { isString } = this.app.lib._
      if (isString(input)) input = JSON.parse(input) ?? {}
      const result = JSON.stringify(input, (key, value) => {
        if (value instanceof RegExp) return ['__REGEXP__', value.source, value.flags]
        return value
      })
      if (returnString) return result
      return JSON.parse(result)
    }

    reviveRegexInJson = (input, returnObject = true) => {
      const { isPlainObject } = this.app.lib._
      if (isPlainObject(input)) input = JSON.stringify(input)
      const result = JSON.parse(input, (key, value) => {
        if (Array.isArray(value) && value[0] === '__REGEXP__') return new RegExp(value[1], value[2])
        return value
      })
      return returnObject ? result : JSON.stringify(result)
    }
  }

  return Dobo
}

export default factory