lib_factory_model_helper.js

import path from 'path'

/**
 * Helper functions for model operations in the Dobo framework.
 *
 * @module Helper/Model
 */

/**
 * Clone options object and omit some keys to avoid reference issues
 *
 * @method
 * @name cloneOptions
 * @memberof module:Helper/Model
 * @param {DoboModel.TOptions} [options={}]
 * @returns {DoboModel.TOptions} Cloned options object
 */
export function cloneOptions (options = {}) {
  const { cloneDeep, omit } = this.app.lib._
  const omittedOptionsKeys = ['req', 'reply', 'trx']
  const nOptions = cloneDeep(omit(options, omittedOptionsKeys))
  for (const key of omittedOptionsKeys) {
    nOptions[key] = options[key]
  }
  return nOptions
}

/**
 * Executes a hook function with the provided name and arguments.
 * @async
 * @memberof module:Helper/Model
 * @method
 * @name execHook
 * @param {string} name - The name of the hook to execute.
 * @param  {...any} args - Arguments to pass to the hook function.
 * @returns {Promise<void>}
 */
export async function execHook (name, ...args) {
  const { runHook } = this.app.bajo
  const { camelCase, last, kebabCase } = this.app.lib._
  const { noHook } = last(args)
  const { ns } = this.app.dobo
  let [prefix, ...action] = kebabCase(name).split('-')
  action = camelCase(action.join(' '))
  if (!noHook) {
    if (prefix === 'before') await runHook(`${ns}:beforeAction`, action, this.name, ...args)
    await runHook(`${ns}:${name}`, this.name, ...args)
    if (prefix === 'after') await runHook(`${ns}:afterAction`, action, this.name, ...args)
    if (prefix === 'before') await runHook(`${ns}.${camelCase(this.name)}:beforeAction`, action, ...args)
    await runHook(`${ns}.${camelCase(this.name)}:${name}`, ...args)
    if (prefix === 'after') await runHook(`${ns}.${camelCase(this.name)}:afterAction`, action, ...args)
  }
}

/**
 * Executes a model hook function with the provided name and arguments.
 * @async
 * @memberof module:Helper/Model
 * @method
 * @name execModelHook
 * @param {string} name - The name of the model hook to execute.
 * @param  {...any} args - Arguments to pass to the model hook function.
 * @returns {Promise<void>}
 */
export async function execModelHook (name, ...args) {
  const { last } = this.app.lib._
  const { runModelHook } = this.app.dobo
  const { noModelHook } = last(args)
  if (!noModelHook) await runModelHook(this, name, ...args)
}

/**
 * Executes a dynamic hook function with the provided name and arguments.
 * @async
 * @memberof module:Helper/Model
 * @method
 * @name execDynHook
 * @param {string} name - The name of the dynamic hook to execute.
 * @param  {...any} args - Arguments to pass to the dynamic hook function.
 * @returns {Promise<void>}
 */
export async function execDynHook (name, ...args) {
  const { last, orderBy } = this.app.lib._
  const opts = last(args)
  const results = []
  if (!opts.noDynHook) {
    const hooks = orderBy((opts.dynHooks ?? []).filter(hook => hook.name === name), ['level'])
    for (const hook of hooks) {
      if (hook.noWait) hook.handler.call(this, ...args)
      else await hook.handler.call(this, ...args)
    }
  }
  return results
}

/**
 * Executes validation on the provided body with the given options.
 * @async
 * @memberof module:Helper/Model
 * @method
 * @name execValidation
 * @param {Object} body - The data to validate.
 * @param {DoboModel.TOptions} [options={}] - Validation options.
 * @returns {Promise<Object>} The result of the validation.
 */
export async function execValidation (body, options = {}) {
  const { uniq } = this.app.lib._
  const { validation = {} } = options
  const fields = uniq([...Object.keys(body), ...(options.fields ?? [])])
  await execHook.call(this, 'beforeRecordValidation', body, options)
  await execModelHook.call(this, 'beforeRecordValidation', body, options)
  const result = await this.validate(body, validation, { fields, ...options })
  await execModelHook.call(this, 'afterRecordValidation', body, result, options)
  await execHook.call(this, 'afterRecordValidation', body, result, options)
  return result
}

/**
 * Prepares and returns the filter and options for a given action.
 * @async
 * @memberof module:Helper/Model
 * @method
 * @name getFilterAndOptions
 * @param {DoboModel.TFilter} filter - The filter criteria.
 * @param {DoboModel.TOptions} options - The options for the action.
 * @param {string} action - The action being performed.
 * @returns {Promise<{filter: DoboModel.TFilter, options: DoboModel.TOptions}>} The prepared filter and options.
 */
export async function getFilterAndOptions (filter = {}, options = {}, action) {
  const { cloneDeep } = this.app.lib._
  const nFilter = cloneDeep(filter || {})
  const nOptions = cloneOptions.call(this, options)
  if (options.noMagic) {
    nOptions.noHook = true
    nOptions.noDynHook = true
    nOptions.noValidation = true
    nOptions.noCache = true
    nOptions.throwNotFound = false
    delete options.noMagic
    delete nOptions.noMagic
  }
  nOptions.action = action
  nOptions.dataOnly = false
  nOptions.truncateString = nOptions.truncateString ?? false
  nOptions.throwNotFound = nOptions.throwNotFound ?? true
  nFilter.orgQuery = nFilter.query
  nFilter.orgSearch = nFilter.search
  nFilter.query = buildFilterQuery.call(this, nFilter) ?? {}
  nFilter.search = buildFilterSearch.call(this, nFilter) ?? {}
  const { limit, page, skip, sort } = preparePagination.call(this, nFilter, nOptions)
  nFilter.limit = limit
  nFilter.page = page
  nFilter.skip = skip
  nFilter.sort = sort
  if (nOptions.queryHandler) {
    const scope = nOptions.req ? this.app[nOptions.req.ns] : this.plugin
    nFilter.query = await options.queryHandler.call(scope, nFilter.query, nOptions.req)
  }
  return { filter: nFilter, options: nOptions }
}

/**
 * Handles a request for a given action trigger.
 * @async
 * @memberof module:Helper/Model
 * @method
 * @name handleReq
 * @param {string|number} id - The ID of the record.
 * @param {string} trigger - The action trigger (e.g., 'created', 'updated', 'removed').
 * @param {DoboModel.TOptions} [options={}] - Additional options for handling the request.
 * @returns {Promise<void>}
 */
export async function handleReq (id, trigger, options = {}) {
  const { upperFirst } = this.app.lib._
  if (options.req) {
    if (options.req.file && trigger !== 'removed') await handleAttachmentUpload.call(this, id, trigger, options)
    if (options.req.flash && !options.noFlash) options.req.flash('notify', options.req.t(`record${upperFirst(trigger)}`))
  }
}

/**
 * Merges attachment information into the given record.
 * @async
 * @memberof module:Helper/Model
 * @method
 * @name mergeAttachmentInfo
 * @param {Object} rec - The record to merge attachment info into.
 * @param {string} source - The source file path of the attachment.
 * @param {Object} options - Additional options including mimeType, stats, and fullPath.
 * @returns {Promise<void>}
 */
export async function mergeAttachmentInfo (rec, source, options = {}) {
  if (!this.app.waibu) return
  const { mimeType, stats, fullPath } = options
  const { importPkg } = this.app.bajo
  const { fs } = this.app.lib
  const { pick } = this.app.lib._
  const mime = await importPkg('waibu:mime')

  if (mimeType) rec.mimeType = mime.getType(rec.file)
  if (fullPath) rec.fullPath = source
  if (stats) {
    const s = fs.statSync(source)
    rec.stats = pick(s, ['size', 'atime', 'ctime', 'mtime'])
  }
}

/**
 * Gets the attachment path for a given record and field.
 * @async
 * @memberof module:Helper/Model
 * @method
 * @name getAttachmentPath
 * @param {string|number} id - The ID of the record.
 * @param {string} field - The field name of the attachment.
 * @param {string} file - The file name of the attachment.
 * @param {Object} options - Additional options, including dirOnly.
 * @returns {Promise<string>} The path to the attachment.
 */
export async function getAttachmentPath (id, field, file, options = {}) {
  const { fs } = this.app.lib
  const dir = `${this.app.getPluginDataDir(this.app.dobo.ns)}/attachment/${this.name}/${id}`
  if (options.dirOnly) return dir
  const path = field ? `${dir}/${field}/${file}` : `${dir}/${file}`
  if (!fs.existsSync(path)) throw this.app.dobo.error('notFound')
  return path
}

/**
 * Copies attachments for a given record.
 * @name copyAttachment
 * @async
 * @memberof module:Helper/Model
 * @method
 * @param {string|number} id - The ID of the record.
 * @param {Object} options - Additional options for copying attachments.
 * @returns {Promise<Array>} The copied attachment records.
 */
export async function copyAttachment (id, options = {}) {
  if (!this.app.waibu) return
  if (!this.options.attachment) return
  const { fs } = this.app.lib
  const { req, setField, setFile, mimeType, stats } = options
  const { dir, files } = await this.app.waibu.getUploadedFiles(req.id, false, true)
  const result = []
  if (files.length === 0) return result
  for (const f of files) {
    let [field, ...parts] = path.basename(f).split('@')
    if (parts.length === 0) continue
    field = setField ?? field
    const file = setFile ?? parts.join('@')
    const opts = { source: f, field, file, mimeType, stats, req }
    const rec = await this.createAttachment(id, opts)
    if (!rec) continue
    delete rec.dir
    result.push(rec)
    if (setField || setFile) break
  }
  fs.removeSync(dir)
  return result
}

/**
 * Handles attachment uploads for a given record and trigger.
 * @async
 * @memberof module:Helper/Model
 * @method
 * @name handleAttachmentUpload
 * @param {string|number} id - The ID of the record.
 * @param {string} trigger - The action trigger (e.g., 'added', 'removed').
 * @param {Object} options - Additional options for handling the upload.
 * @returns {Promise<void>}
 */
export async function handleAttachmentUpload (id, trigger, options = {}) {
  if (!this.options.attachment) return
  const { fs } = this.app.lib
  const { req, mimeType, stats, setFile, setField } = options
  if (trigger === 'removed') {
    const dir = `${this.app.getPluginDataDir(this.app.dobo.ns)}/attachment/${this.name}/${id}`
    await fs.remove(dir)
    return
  }
  return copyAttachment.call(this, id, { req, mimeType, stats, setFile, setField })
}

/**
 * Gets reference records for the given records.
 * @async
 * @memberof module:Helper/Model
 * @method
 * @name getRefs
 * @param {Array<Object>} records - The records to get references for.
 * @param {Object} options - Additional options for fetching references.
 * @returns {Promise<void>}
 */
export async function getRefs (records = [], options = {}) {
  const { isSet } = this.app.lib.aneka
  const { uniq, without, get } = this.app.lib._
  const { parseQuery } = this.app.dobo
  const props = this.getNonVirtualProperties().filter(p => isSet(p.ref) && !(options.hidden ?? []).includes(p.name))
  options.refs = options.refs ?? []
  if (props.length > 0) {
    for (const prop of props) {
      for (const key in prop.ref) {
        try {
          if (records.length === 0) return
          const isValues = Array.isArray(records[0][prop.name])
          if (get(records, `0._ref.${key}`)) return
          const ref = prop.ref[key]
          const rModel = this.app.dobo.getModel(ref.model, true)
          if (!rModel) return
          let matches = []
          for (const rec of records) {
            const items = isValues ? [...(rec[prop.name] ?? [])] : (rec[prop.name] ? [rec[prop.name]] : [])
            matches.push(...items.map(item => prop.name === 'id' ? rModel.sanitizeId(item) : item))
          }
          matches = uniq(without(matches, undefined, null, NaN)).map(i => i + '')
          let query = {}
          query[ref.field] = { $in: matches }

          const siteIdProp = this.properties.find(item => item.name === 'siteId')
          const siteIdRProp = rModel.properties.find(item => item.name === 'siteId')
          if (siteIdProp && siteIdRProp) query.siteId = records[0].siteId

          if (ref.query) query = { $and: [query, parseQuery(ref.query, rModel)] }
          const filter = { query, limit: matches.length }
          if (!((typeof options.refs === 'string' && ['*', 'all'].includes(options.refs)) || options.refs.includes(key))) return
          if (ref.fields.length === 0) return
          const { fmt, req } = options
          const fields = [...ref.fields]
          if (!fields.includes(prop.name)) fields.push(prop.name)
          const rOptions = { dataOnly: true, refs: [], fmt, req, fields }
          const results = await rModel.findRecord(filter, rOptions)
          for (const i in records) {
            records[i]._ref = records[i]._ref ?? {}
            const rec = records[i]
            let items = isValues ? [...(rec[prop.name] ?? [])] : (rec[prop.name] ? [rec[prop.name]] : [])
            items = items.map(item => item + '')
            const res = results.filter(r => items.includes(r[ref.field] + ''))
            if (res.length === 0) records[i]._ref[key] = isValues ? [] : {}
            else records[i]._ref[key] = isValues ? res : res[0]
          }
        } catch (err) {
          if (this.app.bajo.config.log.level === 'trace') console.error(err)
        }
      }
    }
  }
}

/**
 * Builds a sanitized filter query for the given filter.
 * @memberof module:Helper/Model
 * @method
 * @name buildFilterQuery
 * @param {Object} filter - The filter object containing query parameters.
 * @returns {Object} The sanitized query object.
 */
export function buildFilterQuery (filter = {}) {
  const { parseQuery } = this.app.dobo
  const query = parseQuery(filter.query ?? {}, this, false)
  return sanitizeQuery.call(this, query)
}

/**
 * Sanitizes a query object by ensuring that its fields and values conform to the model's schema.
 * @memberof module:Helper/Model
 * @method
 * @name sanitizeQuery
 * @param {Object} query - The query object to sanitize.
 * @param {string} parent - The parent field name, if applicable.
 * @returns {Object} The sanitized query object.
 */
export function sanitizeQuery (query = {}, parent) {
  const { isPlainObject, isArray, find, cloneDeep } = this.app.lib._
  const { isSet } = this.app.lib.aneka
  const { dayjs } = this.app.lib
  const obj = cloneDeep(query)
  const keys = Object.keys(obj)

  const sanitizeField = (prop, val) => {
    if (!prop) return val
    if (val instanceof RegExp) return val
    if (['datetime'].includes(prop.type)) {
      const dt = dayjs(val)
      return dt.isValid() ? dt.toDate() : val
    } else if (['smallint', 'integer'].includes(prop.type)) return parseInt(val) || val
    else if (['float', 'double'].includes(prop.type)) return parseFloat(val) || val
    else if (['boolean'].includes(prop.type)) return !!val
    else if (['string', 'text'].includes(prop.type)) return val + ''
    return val
  }

  const sanitizeChild = (key, val, p) => {
    if (!isSet(val)) return val
    const prop = find(this.properties, { name: key.startsWith('$') ? p : key })
    if (!prop) return val
    return sanitizeField(prop, val)
  }

  keys.forEach(k => {
    const v = obj[k]
    const props = this.getProperties({ noVirtual: true })
    const fields = props.map(p => p.name)
    const prop = find(props, { name: k })
    if (k[0] !== '$' && !fields.includes(k)) {
      delete keys[k]
    } else {
      if (isPlainObject(v)) obj[k] = sanitizeQuery.call(this, v, k)
      else if (isArray(v)) {
        v.forEach((i, idx) => {
          if (isPlainObject(i)) obj[k][idx] = sanitizeQuery.call(this, i, k)
          else obj[k][idx] = sanitizeField(prop, i)
        })
      } else obj[k] = sanitizeChild(k, v, parent)
    }
  })
  return obj
}

/**
 * Builds a search filter from the given filter object.
 * @memberof module:Helper/Model
 * @method
 * @name buildFilterSearch
 * @param {Object} filter - The filter object containing search parameters.
 * @returns {Object} The constructed search filter.
 */
export function buildFilterSearch (filter = {}) {
  const { isPlainObject, trim, has, uniq } = this.app.lib._
  const search = filter.search ?? {}
  let input = search
  if (isPlainObject(input)) return input
  const split = (value) => {
    let [field, val] = value.split(':').map(i => i.trim())
    if (!val) {
      val = field
      field = '*'
    }
    return { field, value: val }
  }
  input = trim(input)
  let items = {}
  if (isPlainObject(input)) items = input
  else if (input[0] === '{') {
    try {
      items = JSON.parse(input)
    } catch (err) {}
  } else {
    for (const item of input.split('+').map(i => i.trim())) {
      const part = split(item, ' ')
      if (!items[part.field]) items[part.field] = []
      items[part.field].push(...part.value.split(' ').filter(v => ![''].includes(v)))
    }
  }
  let s = {}
  for (const index of this.indexes.filter(i => i.type === 'fulltext')) {
    for (const f of index.fields) {
      const value = []
      if (typeof items[f] === 'string') items[f] = [items[f]]
      if (has(items, f)) value.push(...items[f])
      if (!s[f]) s[f] = []
      s[f] = uniq([...s[f], ...value])
    }
  }
  if (has(items, '*')) s['*'] = items['*']
  if (this.adapter.idField.name !== 'id') {
    const search = JSON.stringify(s).replaceAll('"id"', `"${this.adapter.idField.name}"`)
    try {
      s = JSON.parse(search)
    } catch (err) {}
  }
  return s
}

/**
 * Prepare pagination parameters for a query:
 * - Ensures that the limit does not exceed the maximum allowed limit.
 * - Ensures that the page number is within the allowed range.
 * - Calculates the number of records to skip based on the page and limit.
 * - Builds the sort order based on the provided sort input.
 *
 * @async
 * @memberof module:Helper/Model
 * @method
 * @name preparePagination
 * @param {DoboModel.TFilter} filter - The filter object containing pagination parameters.
 * @param {DoboModel.TOptions} options - Additional options for pagination.
 * @returns {Object} The prepared pagination parameters including limit, page, skip, and sort.
 */
export function preparePagination (filter = {}, options = {}) {
  const { isEmpty, map, each, isPlainObject, isString, trim, keys } = this.app.lib._
  const { getDefaultValues, config } = this.app.dobo
  const { limit: defLimit, maxLimit: defMaxLimit, maxPage: defMaxPage } = getDefaultValues(options)

  const buildPageSkipLimit = (filter) => {
    let limit = parseInt(filter.limit) || defLimit
    if (limit === -1) limit = defMaxLimit
    if (limit > defMaxLimit) {
      options.warnings = options.warnings ?? []
      options.warnings.push(options.req ? options.req.t('maxLimitWarning%s%s', limit, defMaxLimit) : this.plugin.t('maxLimitWarning%s', limit, defMaxLimit))
      limit = defMaxLimit // TODO: notify as warning in response object
    }
    if (limit < 1) limit = 1
    let page = parseInt(filter.page) || 1
    if (page < 1) page = 1
    if (page > defMaxPage) throw this.plugin.error('maxPageError%s%s', page, defMaxPage)
    let skip = (page - 1) * limit
    if (filter.skip) {
      skip = parseInt(filter.skip) || skip
      page = undefined
    }
    if (skip < 0) skip = 0
    return { page, skip, limit }
  }

  const buildSort = (input, allowSortUnindexed) => {
    let sort
    if (isEmpty(input)) {
      const columns = map(this.properties ?? [], 'name')
      each(config.default.filter.sort, s => {
        const [col] = s.split(':')
        if (columns.includes(col)) {
          input = s
          return false
        }
      })
    }
    if (!isEmpty(input)) {
      if (isPlainObject(input)) sort = input
      else if (isString(input)) {
        const item = {}
        each(input.split('+'), text => {
          let [col, dir] = map(trim(text).split(':'), i => trim(i))
          dir = (dir ?? '').toUpperCase()
          dir = dir === 'DESC' ? -1 : parseInt(dir) || 1
          item[col] = dir / Math.abs(dir)
        })
        sort = item
      }
      const items = keys(sort)
      each(items, i => {
        if (!this.sortables.includes(i) && !allowSortUnindexed) throw this.app.dobo.error('sortOnUnindexedField%s%s', i, this.name)
        // if (model.fullText.fields.includes(i)) throw this.error('Can\'t sort on full-text index: \'%s@%s\'', i, model.name)
      })
    }
    return sort
  }

  const { page, skip, limit } = buildPageSkipLimit(filter)
  let sortInput = filter.sort
  try {
    sortInput = JSON.parse(sortInput)
  } catch (err) {
  }
  const sort = buildSort(sortInput, options.allowSortUnindexed)
  return { limit, page, skip, sort }
}

/**
 * Clears the cache for a specific record ID and related find operations.
 * @async
 * @memberof module:Helper/Model
 * @method
 * @name clearCache
 * @param {string|number} id - The ID of the record for which to clear the cache.
 * @returns {Promise<void>}
 */
export async function clearCache (id) {
  const { clear } = this.app.bajoCache ?? {}
  if (!clear) return
  await clear({ key: `dobo|${this.name}|getRecord|${id}` })
  await clear({ key: `dobo|${this.name}|findRecord` })
  await clear({ key: `dobo|${this.name}|findAllRecord` })
  await clear({ key: `dobo|${this.name}|findOneRecord` })
}