lib_helper.js

import path from 'path'
import adapterFactory from './factory/adapter.js'
import connectionFactory from './factory/connection.js'
import featureFactory from './factory/feature.js'
import modelFactory from './factory/model.js'
import actionFactory from './factory/action.js'

/**
 * @module Helper
 */

/**
 * Sanitizes a property definition for a model, ensuring it adheres to the expected structure and types.
 * It handles various property attributes such as type, index, required status, and more.
 *
 * If the property is virtual, it processes it accordingly; otherwise, it adds it to the model's properties
 * and indexes.
 * @param {DoboModel} model
 * @param {DoboModel.TProperty} prop
 * @param {DoboModel.TIndex[]} indexes
 */
async function sanitizeProp (model, prop, indexes) {
  const { isEmpty, isString, keys, pick, isArray, isPlainObject, omit, isFunction } = this.app.lib._
  const allPropKeys = this.getAllPropertyKeys(model.connection.adapter)
  const propType = this.constructor.propertyType
  if (isString(prop)) {
    let [name, type, maxLength, idx, required] = prop.split(',').map(m => m.trim())
    if (isEmpty(type)) type = 'string'
    maxLength = isEmpty(maxLength) ? propType.string.maxLength : parseInt(maxLength)
    prop = { name, type, maxLength }
    if (!isEmpty(idx)) prop.index = idx
    prop.required = required === 'true'
  }
  prop.type = prop.type ?? 'string'
  if (isArray(prop.values)) {
    prop.values = prop.values.map(item => {
      if (isPlainObject(item)) return pick(item, ['value', 'text'])
      return { value: item, text: item }
    })
  } else if (!(isString(prop.values) || isFunction(prop.values))) delete prop.values
  if (prop.hidden) model.hidden.push(prop.name)
  if (prop.scanable) model.scanables.push(prop.scanable)
  if (prop.virtual) {
    const keys = Object.keys(propType)
    if (!keys.includes(prop.type)) this.fatal('unknownPropType%s%s', `${prop.name}:${prop.type}`, model.name)
    for (const key of ['required', 'rules', 'index', 'validator', 'ref', 'rulesMsg', 'immutable']) {
      delete prop[key]
    }
    model.properties.push(prop)
  } else {
    if (prop.index) {
      if (prop.index === true || prop.index === 'true') prop.index = 'index'
      const [idx, idxName] = prop.index.split(':')
      const index = { name: idxName ?? `${model.collName}_${prop.name}_${idx}`, fields: [prop.name], type: idx }
      indexes.push(index)
    }
    if (keys(propType).includes(prop.type)) model.properties.push(pick(prop, allPropKeys))
    else {
      const feature = this.getFeature(prop.type)
      if (!feature) this.fatal('unknownPropType%s%s', prop.type, model.name)
      const opts = omit(prop, ['name', 'type'])
      opts.field = prop.name
      await applyFeature.call(this, model, feature, opts, indexes)
    }
  }
}

/**
 * Finds and sanitizes all properties for a given model schema, ensuring that the 'id' property is included and
 * that all properties are properly processed.
 * @param {object} schema - The model schema
 * @param {array} inputs - The list of input properties to process
 * @param {array} indexes - The list of indexes to update
 */
async function findAllProps (schema, inputs = [], indexes = []) {
  const { isPlainObject, cloneDeep } = this.app.lib._
  const isIdProp = inputs.find(p => {
    return isPlainObject(p) ? p.name === 'id' : p.startsWith('id,')
  })
  if (!isIdProp) {
    const idField = cloneDeep(schema.connection.adapter.idField)
    idField.name = 'id'
    inputs.unshift(idField)
  }
  for (const prop of inputs) {
    await sanitizeProp.call(this, schema, prop, indexes)
  }
}

/**
 * Applies a feature to a model schema, adding properties, rules, hooks, and other
 * configurations as defined by the feature.
 * @param {object} schema - The model schema
 * @param {object} feature - The feature to apply
 * @param {object} options - The options for the feature
 * @param {array} indexes - The list of indexes to update
 */
async function applyFeature (schema, feature, options, indexes) {
  const { isArray, findIndex } = this.app.lib._
  if (feature.name === 'dobo:unique' && options.field === 'id') {
    const idx = findIndex(schema.properties, { name: 'id' })
    if (idx > -1) schema.properties.pullAt(idx)
  }
  const item = await feature.handler(options)
  if (item.rules) schema.rules.push(...item.rules)
  if (item.scanables) schema.scanables.push(...item.scanables)
  if (!isArray(item.properties)) item.properties = [item.properties]
  for (const prop of item.properties) {
    prop.feature = `${feature.plugin.ns}:${feature.name}`
    await sanitizeProp.call(this, schema, prop, indexes)
  }
  if (item.hooks) {
    item.hooks = item.hooks.map(hook => {
      hook.level = hook.level ?? 999
      return hook
    })
    schema.hooks.push(...item.hooks)
  }
}

/**
 * Finds and applies all features for a given model schema, ensuring that each feature is properly processed
 * @param {object} schema - The model schema
 * @param {array} inputs - The list of features to apply
 * @param {array} indexes - The list of indexes to update
 */
async function findAllFeats (schema, inputs = [], indexes = []) {
  const { isString, omit } = this.app.lib._
  for (let feat of inputs) {
    if (isString(feat)) feat = { name: feat }
    const featName = feat.name.indexOf(':') === -1 ? `dobo:${feat.name}` : feat.name
    const feature = this.app.dobo.getFeature(featName)
    if (!feature) this.fatal('invalidFeature%s%s', schema.name, featName)
    await applyFeature.call(this, schema, feature, omit(feat, 'name'), indexes)
  }
}

/**
 * Finds and applies all indexes for a given model schema, ensuring that each index is properly processed
 * @param {object} schema - The model schema
 * @param {array} inputs - The list of indexes to apply
 * @param {array} indexes - The list of existing indexes to update
 */
async function findAllIndexes (schema, inputs = [], indexes = []) {
  const items = []
  for (const index of inputs) {
    index.type = index.type ?? 'index'
    index.fields = index.fields ?? []
    if (!index.name) index.name = `${schema.name}_${index.fields.join('_')}_${index.type}`
    items.push(index)
  }
  schema.indexes = [...items, ...indexes]
}

/**
 * Sanitizes the references in a model schema, ensuring that all references point to valid models and properties.
 * It checks for duplicate reference keys and validates the existence of referenced models and properties.
 * @async
 * @param {object} schema - The model schema to sanitize
 * @param {object[]} schemas - The list of all model schemas
 * @returns {Promise<void>}
 */
export async function sanitizeRef (schema, schemas) {
  const { find, isString, pullAt } = this.app.lib._
  const _refKeys = []
  for (const prop of schema.properties) {
    const ignored = []
    for (const key in prop.ref ?? {}) {
      _refKeys.push(key)
      let ref = prop.ref[key]
      if (isString(ref)) {
        ref = { field: ref }
      }
      ref.field = ref.field ?? 'id'
      ref.type = ref.type ?? '1:1'
      ref.searchField = ref.searchField ?? ref.field
      ref.labelField = ref.labelField ?? ref.searchField
      const rModel = find(schemas, { name: ref.model })
      if (!rModel) {
        ignored.push(key)
        this.log.warn('notFound%s%s', this.t('model'), ref.model)
        continue
      }
      const rProp = find(rModel.properties, { name: ref.field })
      if (!rProp) {
        ignored.push(key)
        this.log.warn('notFound%s%s', this.t('property'), `${ref.field}@${ref.model}`)
        continue
      }
      ref.fields = ref.fields ?? '*'
      if (['*', 'all'].includes(ref.fields)) ref.fields = rModel.properties.map(p => p.name)
      if (ref.fields.length > 0 && !ref.fields.includes('id')) ref.fields.unshift('id')
      const removed = []
      for (const idx in ref.fields) {
        const p = find(rModel.properties, { name: ref.fields[idx] })
        if (!p) removed.push(ref.fields[idx])
      }
      pullAt(ref.fields, removed)
      prop.ref[key] = ref
    }
    for (const key of ignored) {
      delete prop.ref[key]
    }
  }
  const dupes = _refKeys.filter((item, index) => _refKeys.indexOf(item) !== index)
  if (dupes.length > 0) throw this.error('duplicateRefKeys%s%s', schema.name, dupes.join(', '))
}

/**
 * Sanitizes all model schemas, ensuring that properties, indexes, and references are properly defined and validated.
 * It also determines the sortable fields based on the defined indexes.
 * @async
 * @param {object} schema - The model schema
 * @returns {Promise<void>}
 */
export async function sanitizeAll (schema) {
  const { runHook } = this.app.bajo
  const { pick, keys, map, uniq, camelCase, filter } = this.app.lib._
  const { defaultsDeep } = this.app.lib.aneka
  const allPropNames = uniq(map(schema.properties, 'name'))
  const propType = this.constructor.propertyType
  const indexTypes = this.constructor.indexTypes

  await runHook(`dobo.${camelCase(schema.name)}:beforeSanitizeModel`, schema)
  // properties
  for (const idx in schema.properties) {
    let prop = schema.properties[idx]
    const def = propType[prop.type]
    prop = pick(defaultsDeep(prop, def), this.getPropertyKeysByType(prop.type))
    if (!keys(propType).includes(prop.type)) this.fatal('unknownPropType%s%s', `${prop.name}.${def.name}`, prop.type)
    if (prop.type === 'string') {
      prop.minLength = parseInt(prop.minLength) ?? 0
      prop.maxLength = parseInt(prop.maxLength) ?? 255
      if (prop.minLength > 0) prop.required = true
      if (prop.minLength === 0) delete prop.minLength
    }
    schema.properties[idx] = prop
  }
  // indexes
  for (const index of schema.indexes) {
    if (!indexTypes.includes(index.type)) this.fatal('unknownIndexType%s%s', index.type, schema.name)
    for (const field of index.fields) {
      if (!allPropNames.includes(field)) this.fatal('unknownPropNameOnIndex%s%s', field, schema.name)
    }
  }
  await runHook(`dobo.${camelCase(schema.name)}:afterSanitizeModel`, schema)
  // sorting only possible using these fields
  schema.sortables = []
  for (const index of schema.indexes) {
    schema.sortables.push(...index.fields)
  }
  schema.hidden = filter(uniq(schema.hidden), prop => allPropNames.includes(prop))
}

/**
 * Creates a model schema by sanitizing its properties, features, and indexes, and ensuring that all references are valid.
 * It also applies any buildStart and buildEnd handlers defined in the schema.
 * @param {object} item - The model definition object
 * @returns {object} - The sanitized model schema
 */
async function createSchema (item) {
  const { find, orderBy, get } = this.app.lib._
  const { mergeObjectsByKey, defaultsDeep, parseObject } = this.app.lib.aneka
  const feats = item.features ?? []
  const props = item.properties ?? []
  const indexes = item.indexes ?? []
  item.features = []
  item.properties = []
  item.indexes = []
  item.hidden = item.hidden ?? []
  item.rules = item.rules ?? []
  item.buildLevel = item.buildLevel ?? 999
  item.hooks = item.hooks ?? []
  item.disabled = item.disabled ?? []
  item.scanables = item.scanables ?? []
  if (item.disabled === 'all') item.disabled = ['find', 'get', 'create', 'update', 'remove']
  else if (item.disabled === 'readonly') item.disabled = ['create', 'update', 'remove']
  const conn = item.connection ?? 'default'
  if (!(conn instanceof this.app.baseClass.DoboConnection)) {
    item.connection = null
    // Is there any overwritten connection?
    const newConn = find(this.connections, c => c.options.models.includes(item.name))
    if (newConn) item.connection = newConn
    else {
      item.connection = this.getConnection(conn, true)
      if (!item.connection && conn === 'default') item.connection = this.getConnection('memory')
    }
    if (!item.connection) this.fatal('unknownConn%s%s', conn, item.name)
  }
  // cache settings
  const defCache = defaultsDeep({}, item.connection.options.cache, get(this, 'app.bajoCache.config.default', this.config.default.cache))
  if (item.cache === false) item.cache = { ttlDur: 0 }
  else if (item.cache === true) item.cache = defCache
  else item.cache = defaultsDeep({}, item.cache, defCache)
  item.cache = parseObject(item.cache)
  if (item.connection.name === 'memory') item.cache.ttlDur = 0

  // let's run
  await findAllProps.call(this, item, props, indexes)
  await findAllFeats.call(this, item, feats, indexes)
  await findAllIndexes.call(this, item, indexes)
  for (const key of ['properties', 'indexes']) {
    item[key] = mergeObjectsByKey(item[key], 'name')
  }
  item.hooks = orderBy(item.hooks, ['name', 'level'])
  delete item.features
  await sanitizeAll.call(this, item)
  return item
}

/**
 * Collects all model schemas from the application, sanitizes them, and creates model instances.
 * It also handles any buildStart and buildEnd handlers defined in the model definitions.
 * @async
 * @method
 * @returns {Promise<void>}
 */
export async function collectModels () {
  const { eachPlugins, callHandler } = this.app.bajo
  const { orderBy, has, omit } = this.app.lib._
  await actionFactory.call(this)
  await modelFactory.call(this)

  this.log.trace('collecting%s', this.t('model'))
  const me = this
  let schemas = []
  await eachPlugins(async function ({ file }) {
    const { readConfig, callHandler } = this.app.bajo
    const { pascalCase } = this.app.lib.aneka
    const { isPlainObject, isEmpty, isArray } = this.app.lib._

    let items = await readConfig(file, { ns: this.ns, baseNs: me.ns, merge: true })
    if (isEmpty(items)) return undefined
    if (isPlainObject(items)) {
      items.baseName = items.baseName ?? path.basename(file, path.extname(file))
      items.name = items.name ?? pascalCase(`${this.alias} ${items.baseName}`)
    }
    if (!isArray(items)) items = [items]
    for (const item of items) {
      if (!item.baseName) me.fatal('missing%s%s', 'baseName', file)
      item.name = item.name ?? pascalCase(`${this.alias} ${item.baseName}`)
      me.log.trace('- %s', item.name)
      item.collName = item.collName ?? item.name
      item.options = item.options ?? {}
      item.options.attachment = item.options.attachment ?? true
      item.options.persistence = item.options.persistence ?? true
      item.ns = this.ns
      if (item.buildStart) await callHandler(this, item.buildStart, item)
      const schema = await createSchema.call(me, item)
      schemas.push(schema)
    }
  }, { glob: ['model/*.*', 'model.*'], prefix: this.ns })
  schemas = orderBy(schemas, ['buildLevel', 'name'])
  for (const schema of schemas) {
    const idProp = schema.properties.find(p => p.name === 'id')
    if (!this.constructor.idTypes.includes(idProp.type)) this.fatal('invalidIdType%s%s', schema.name, this.constructor.idTypes.join(', '))
    if (idProp.type === 'string' && !has(idProp, 'maxLength')) idProp.maxLength = 50
    const plugin = this.app[schema.ns]
    await sanitizeRef.call(this, schema, schemas)
    for (const item of schema.indexes) {
      for (const field of item.fields) {
        const prop = schema.properties.find(p => p.name === field)
        if (!prop || (prop && prop.virtual)) throw this.error('virtualFieldIn%s%s%s', field, 'index', schema.name)
      }
    }
    for (const field of schema.scanables) {
      const prop = schema.properties.find(p => p.name === field)
      if (!prop || (prop && prop.virtual)) throw this.error('virtualFieldIn%s%s%s', field, 'scanable', schema.name)
    }
    const model = new this.app.baseClass.DoboModel(plugin, omit(schema, ['beforeCreate', 'afterCreate']))
    me.models.push(model)
    if (schema.buildEnd) await callHandler(plugin, schema.buildEnd, model)
  }
  schemas = []
  this.log.debug('collected%s%d', this.t('model'), this.models.length)
}

/**
 * Collects all model features from loaded plugins
 * @async
 * @method
 * @returns {Promise<void>}
 */
export async function collectFeatures () {
  const { eachPlugins } = this.app.bajo
  await featureFactory.call(this)

  this.log.trace('collecting%s', this.t('feature'))
  const me = this
  await eachPlugins(async function ({ file }) {
    const { importModule } = this.app.bajo
    const { camelCase, isFunction } = this.app.lib._

    const name = camelCase(path.basename(file, '.js'))
    const handler = await importModule(file)
    if (!isFunction(handler)) this.fatal('invalidFeatureHandler%s%s', this.ns, name)
    me.features.push(new this.app.baseClass.DoboFeature(this, { name, handler }))
    me.log.trace('- %s:%s', this.ns, name)
  }, { glob: 'feature/*.js', prefix: this.ns })
  this.log.debug('collected%s%d', this.t('feature'), this.features.length)
}

/**
 * Collects all database connections from loaded plugins, initializes their adapters, and ensures that a default connection is available.
 * It also handles any memory-based connections and merges their models into a single memory connection.
 * @async
 * @method
 * @returns {Promise<void>}
 */
export async function collectConnections () {
  const { buildCollections } = this.app.bajo
  const { pullAt } = this.app.lib._
  const { filterIndex } = this.app.lib.aneka
  await connectionFactory.call(this)

  async function handler ({ item }) {
    const { has } = this.app.lib._
    if (!has(item, 'adapter')) this.fatal('unknownDbAdapter%s')
    const conn = new this.app.baseClass.DoboConnection(this, item)
    await conn.initAdapter(item.adapter)
    return conn
  }
  const memIndexes = filterIndex(this.config.connections, current => current.adapter === 'dobo:memory' || current.name === 'memory')
  const models = memIndexes.map(idx => [...(this.config.connections[idx].models ?? [])])
  pullAt(this.config.connections, memIndexes)
  this.config.connections.push({
    adapter: 'dobo:memory',
    name: 'memory',
    models
  })
  this.connections = await buildCollections({ ns: this.ns, container: 'connections', handler, dupChecks: ['name'] })
  const defConn = this.connections.find(conn => conn.name === 'default')
  if (!defConn) this.log.warn('noDefaultConnection')
}

/**
 * Collects all database adapters from loaded plugins.
 * @async
 * @method
 * @returns {Promise<void>}
 */
export async function collectAdapters () {
  const { eachPlugins, runHook } = this.app.bajo
  const { importModule } = this.app.bajo
  const { camelCase, isFunction } = this.app.lib._
  await adapterFactory.call(this)

  this.log.trace('collecting%s', this.t('adapter'))
  const me = this
  await runHook(`${this.ns}:beforeCollectAdapters`)
  await eachPlugins(async function ({ file }) {
    const name = camelCase(path.basename(file, '.js'))
    const factory = await importModule(file)
    if (!isFunction(factory)) this.fatal('invalidAdapterClassFactory%s%s', this.ns, name)
    const Cls = await factory.call(this)
    const instance = new Cls(this, name)
    if (!(instance instanceof this.app.baseClass.DoboAdapter)) this.fatal('invalidAdapterClass%s%s', this.ns, name)
    me.adapters.push(instance)
    me.log.trace('- %s:%s', this.ns, name)
  }, { glob: 'adapter/*.js', prefix: this.ns })
  await runHook(`${this.ns}:afterCollectAdapters`)
  this.log.debug('collected%s%d', this.t('adapter'), this.adapters.length)
}