lib_factory_adapter.js

import { ulid } from 'ulid'
import { v4 as uuidv4, v7 as uuidv7 } from 'uuid'
import crypto from 'crypto'

const defIdField = {
  name: '_id',
  type: 'string',
  maxLength: 50,
  required: true,
  index: 'primary'
}

/**
 * @external Tools
 * @see {@link https://ardhi.github.io/bajo/Tools.html|Bajo Tools}
 */

/**
 * @typedef TIdField
 * @type {object}
 * @memberof DoboAdapter
 * @property {string} [name='_id'] - The name of the ID field.
 * @property {string} [type='string'] - The data type of the ID field.
 * @property {number} [maxLength=50] - The maximum length of the ID field.
 * @property {boolean} [required=true] - Indicates if the ID field is required.
 * @property {string} [index='primary'] - The index type of the ID field.
 */

/**
 * @typedef TSupport
 * @memberof DoboAdapter
 * @type {object}
 * @property {object} [propType={}] - An object indicating support for various property types.
 * @property {boolean} [propType.object=false] - Indicates if object property type is supported.
 * @property {boolean} [propType.array=false] - Indicates if array property type is supported.
 * @property {boolean} [propType.datetime=true] - Indicates if datetime property type is supported.
 * @property {boolean} [search=false] - Indicates if search functionality is supported.
 * @property {boolean} [uniqueIndex=false] - Indicates if unique index functionality is supported.
 * @property {boolean} [nullableField=true] - Indicates if nullable fields are supported.
 * @property {boolean} [transaction=false] - Indicates if transaction functionality is supported.
 */

/**
 * Adapter factory function.
 *
 * @async
 * @returns {Promise<DoboAdapter>}
 */
async function adapterFactory () {
  const { Tools } = this.app.baseClass
  const { pick, cloneDeep, has, uniq, without, isEmpty, omit, isFunction, camelCase, last } = this.app.lib._
  const { isSet } = this.app.lib.aneka
  const { runHook } = this.app.bajo

  /**
   * DoboAdapter class serves as a base class for all database adapters in the Dobo framework. It provides common functionality for managing models, records, and database operations.
   * Child classes should implement the abstract methods to provide specific database functionality.
   *
   * @class
   * @extends external:Tools
   */
  class DoboAdapter extends Tools {
    /**
     * Constructor.
     */
    constructor (plugin, name, options = {}) {
      super(plugin)

      /**
       * Adapter name
       * @type {string}
       */
      this.name = name

      /**
       * ID field configuration
       * @type {DoboAdapter.TIdField}
       */
      this.idField = cloneDeep(defIdField)
      this.propertyType = {}

      /**
       * Support configuration for the adapter
       * @type {DoboAdapter.TSupport}
       */
      this.support = {
        propType: {
          object: false,
          array: false,
          datetime: true
        },
        search: false,
        uniqueIndex: false,
        nullableField: true,
        transaction: false
      }

      /**
       * Indicates whether to use UTC for datetime fields
       * @type {boolean}
       */
      this.useUtc = false

      /**
       * Maximum chunk size for bulk operations
       * @type {number}
       */
      this.maxChunkSize = 500

      /**
       * Indicates whether the adapter uses in-memory storage
       * @type {boolean}
       */
      this.memory = false

      /**
       * Adapter options
       * @type {object}
       */
      this.options = options
    }

    /**
     * Sanitize connection object
     * @async
     * @method
     * @param {Object} conn - Connection object
     * @returns {Promise<void>}
     */
    async sanitizeConnection (conn) {
      conn.proto = conn.proto ?? 'http' // used by adapter that use url based connection
      conn.memory = false
    }

    /**
     * Sanitizes the body of a record before creating or updating it. It ensures that all required fields
     * are present and have valid values, and converts data types as necessary.
     * @param {DoboModel} model - The model instance for which the body is being sanitized
     * @param {object} body - The body of the record to be sanitized
     * @param {boolean} [partial=false] - Indicates whether to perform a partial update
     * @returns {object} - Sanitized body
     */
    sanitizeBody (model, body = {}, partial) {
      const { keys, pick } = this.app.lib._
      const item = cloneDeep(body)
      let newId = false
      if (has(item, 'id') && this.idField.name !== 'id') {
        item[this.idField.name] = item.id
        newId = true
      }
      for (const prop of model.getNonVirtualProperties()) {
        if (item[prop.name] === 'null') item[prop.name] = null
        if (!isSet(item[prop.name]) && !this.support.nullableField) {
          switch (prop.type) {
            case 'datetime': item[prop.name] = new Date(0); break
            case 'float':
            case 'double': item[prop.name] = 0; break
            case 'string':
            case 'text': item[prop.name] = ''; break
            case 'object': item[prop.name] = {}; break
            case 'array': item[prop.name] = []; break
          }
        }
        if (isSet(item[prop.name]) && !this.support.propType[prop.type]) {
          if (prop.type === 'datetime') item[prop.name] = item[prop.name].toISOString()
          else if (['object', 'array'].includes(prop.type)) item[prop.name] = JSON.stringify(item[prop.name])
        }
      }
      const result = partial ? pick(item, keys(body)) : item
      if (newId) delete result.id
      return result
    }

    /**
     * Sanitizes a record retrieved from the database, converting data types as necessary
     * and ensuring that the record conforms to the model's schema.
     * @param {DoboModel} model - The model instance for which the record is being sanitized
     * @param {object} record - The record retrieved from the database
     * @returns {object} - Sanitized record
     */
    sanitizeRecord (model, record = {}) {
      const { dayjs } = this.app.lib
      const { isString } = this.app.lib._
      const item = { ...record }
      if (has(item, this.idField.name) && this.idField.name !== 'id') {
        item.id = item[this.idField.name]
        delete item[this.idField.name]
      }
      for (const prop of model.properties) {
        if (isSet(item[prop.name])) {
          if (!this.support.propType[prop.type]) {
            try {
              if (prop.type === 'datetime') {
                const dt = this.useUtc ? dayjs.utc(item[prop.name]) : dayjs(item[prop.name])
                item[prop.name] = dt.toDate()
              } else if (['object', 'array'].includes(prop.type)) item[prop.name] = JSON.parse(item[prop.name])
            } catch (err) {
              item[prop.name] = null
            }
          }
          if (prop.type === 'datetime' && isString(item[prop.name])) {
            const dt = this.useUtc ? dayjs.utc(item[prop.name]) : dayjs(item[prop.name])
            item[prop.name] = dt.toDate()
          }
          if (prop.type === 'boolean' && isSet(item[prop.name])) item[prop.name] = Boolean(item[prop.name])
        }
      }
      return item
    }

    /**
     * Utility method to get the real fields of a model, excluding virtual fields.
     * This is useful for operations that require only the actual stored properties of a model.
     * @param {*} model
     * @returns {string[]} - Array of real field names
     */
    getRealFields (model) {
      return model.getProperties({ noVirtual: true, namesOnly: true })
    }

    /**
     * Utility method to get the virtual fields of a model.
     * This is useful for operations that need to work with computed or derived properties.
     * @param {DoboModel} model - The model instance
     * @returns {string[]} - Array of virtual field names
     */
    getVirtualFields (model) {
      return model.getVirtualProperties({ namesOnly: true })
    }

    /**
     * Get returning fields for a model based on the provided options. If the adapter supports returning fields,
     * it will return the specified fields or all model properties. It ensures that the ID field is always
     * included in the returned fields.
     * @param {DoboModel} model - The model instance for which to get the returning fields
     * @param {object} options - Options that may include the fields to return
     * @returns {string[]} - Array of field names to be returned
     */
    _getReturningFields (model, options = {}) {
      const { fields = [] } = options
      if (!this.support.returning) return []
      let items = fields.length > 0 ? [...fields] : model.properties.map(prop => prop.name)
      if (!items.includes(this.idField.name)) items.unshift(this.idField.name)
      if (this.idField.name !== 'id') items = without(items, ['id'])
      return uniq(items)
    }

    /**
     * Attaches hooks to the model for various operations. It runs the appropriate hooks
     * before and after the specified operation, allowing for custom behavior to be injected
     * into the model's lifecycle.
     * @internal
     * @async
     * @method
     * @param {string} name - The name of the hook
     * @param {DoboModel} model - The model instance to which the hook is being attached
     * @param  {...any} args - Additional arguments to be passed to the hook
     */
    async _attachHook (name, model, ...args) {
      const { ns } = this.app.dobo
      const { kebabCase } = this.app.lib._
      const options = last(args)
      if (!options.noAdapterHook) {
        const prefix = kebabCase(name).split('-')[0]
        await runHook(`${ns}.adapter:${prefix}Any`, model, options)
        await runHook(`${ns}.adapter:${name}`, model, ...args)
        await runHook(`${ns}.adapter.${camelCase(model.name)}:${name}`, model, ...args)
      }
    }

    /**
     * Checks the uniqueness of fields with a unique index.
     * @async
     * @method
     * @internal
     * @param {DoboModel} model - The model instance to check
     * @param {object} body - The data to be checked for uniqueness
     * @param {object} options - Additional options, including the action being performed
     * @returns {Promise<void>} - Resolves if unique, throws an error if not
     */
    _checkUnique = async (model, body = {}, options = {}) => {
      const { isSet } = this.app.lib.aneka
      const { filter, map, isEmpty, forOwn } = this.app.lib._
      const indexes = filter(model.indexes ?? [], idx => idx.type === 'unique')
      for (const index of indexes) {
        const query = {}
        for (const field of index.fields) {
          if (isSet(body[field])) query[field] = body[field]
        }
        if (isEmpty(query)) continue
        const { data } = await model.findOneRecord({ query }, options)
        if (!isEmpty(data)) {
          if (['updateRecord', 'upsertRecord'].includes(options.action)) {
            let eq = true
            forOwn(query, (v, k) => {
              if (data[k] !== v) eq = false
            })
            if (!eq) continue
          }
          const error = this.app.dobo.t('uniqueConstraintError')
          const details = map(index.fields, field => {
            return { field, error }
          })
          throw this.app.dobo.error(error, { details, body })
        }
      }
    }

    // Internal calls that will be called by model

    /**
     * Wrapper for the `modelExists` method, called internally by `model` to make sure
     * all adapter hooks are executed accordingly, and all inputs and outputs are sanitized.
     *
     * @internal
     * @async
     * @method
     * @param {DoboModel} model
     * @param {object} options
     * @returns {Promise<boolean>}
     */
    async _modelExists (model, options = {}) {
      return await this.modelExists(model, options)
    }

    /**
     * Wrapper for the `buildModel` method, called internally by `model` to make sure
     * all adapter hooks are executed accordingly, and all inputs and outputs are sanitized.
     * @internal
     * @async
     * @method
     * @param {DoboModel} model
     * @param {object} options
     * @returns {Promise<object>}
     */
    async _buildModel (model, options = {}) {
      return await this.buildModel(model, options)
    }

    /**
     * Wrapper for the `dropModel` method, called internally by `model` to make sure
     * all adapter hooks are executed accordingly, and all inputs and outputs are sanitized.
     * @internal
     * @async
     * @method
     * @param {DoboModel} model
     * @param {object} options
     * @returns {Promise<object>}
     */
    async _dropModel (model, options = {}) {
      return await this.dropModel(model, options)
    }

    /**
     * Prepares the body of a record for creation by populating default values for properties
     * that are not set. It handles various types of default values, including functions,
     * special strings (like 'now', 'uuid', etc.), and static values.
     * @internal
     * @async
     * @method
     * @param {DoboModel} model - The model instance for which the body is being prepared
     * @param {object} body - The data to be prepared for creation
     * @param {object} options - Additional options that may affect the preparation
     * @returns {Promise<object>} - The prepared body with default values populated
     */
    async _prepBodyForCreate (model, body = {}, options = {}) {
      const { callHandler } = this.app.bajo
      const { isSet, generateId } = this.app.lib.aneka
      for (const prop of model.getProperties({ noVirtual: true })) {
        if (isSet(prop.default) && (!options.noDefault) && (!isSet(body[prop.name]) || body[prop.name] === prop.default)) {
          if (isFunction(prop.default)) body[prop.name] = await prop.default.call(model)
          else if (typeof prop.default !== 'string') body[prop.name] = prop.default
          else {
            if (['now'].includes(prop.default) && prop.type === 'datetime') {
              body[prop.name] = new Date()
            } else if (['uuid', 'uuidv4'].includes(prop.default) && prop.type === 'string') {
              body[prop.name] = uuidv4().slice(0, prop.maxLength)
            } else if (prop.default === 'uuidv7' && prop.type === 'string') {
              body[prop.name] = uuidv7().slice(0, prop.maxLength)
            } else if (prop.default === 'ulid' && prop.type === 'string') {
              body[prop.name] = ulid().slice(0, prop.maxLength)
            } else if (prop.default === 'generateid' && prop.type === 'string') {
              body[prop.name] = generateId()
            } else if (prop.default.startsWith('handler:')) {
              const [, ...args] = prop.default.split(':')
              if (args.length > 0) body[prop.name] = await callHandler(args.join(':'))
            } else if (prop.default.startsWith('md5:') && prop.type === 'string') {
              const [, field] = prop.default.split(':')
              const fields = field.split(',')
              if (model.properties.filter(item => fields.includes(item.name)).length === fields.length) {
                const values = fields.map(f => body[f])
                body[prop.name] = crypto.createHash('md5').update(values.join(':')).digest('hex')
              }
            } else {
              body[prop.name] = prop.default
            }
          }
        }
      }
      return pick(body, this.getRealFields(model))
    }

    /**
     * Prepares the ID for a record before creation. It generates an ID if it is not set in the body.
     *
     * @internal
     * @async
     * @method
     * @param {DoboModel} model - The model instance for which the ID is being prepared
     * @param {object} body - The data containing the ID
     * @param {object} options - Additional options that may affect ID generation
     * @returns {Promise<void>} - Resolves when the ID has been prepared
     */
    async _prepIdForCreate (model, body = {}, options = {}) {
      const { isSet, generateId } = this.app.lib.aneka
      const { isFunction } = this.app.lib._
      const prop = model.properties.find(p => p.name === 'id')
      if (!isSet(body.id) && prop.type === 'string') {
        if (this.idGenerator) {
          if (['uuid', 'uuidv4'].includes(this.idGenerator)) body.id = uuidv4()
          else if (['uuidv7'].includes(this.idGenerator)) body.id = uuidv7()
          else if (this.idGenerator === 'generateId') body.id = generateId()
          else if (isFunction(this.idGenerator)) body.id = await this.idGenerator(model, body, options)
        }
        if (!body.id) body.id = ulid()
        body.id = body.id.slice(0, prop.maxLength)
      }
    }

    _injectMeta (result = {}, options = {}) {
      result.warnings = result.warnings ?? []
      result.warnings.push(...(options.warnings ?? []))
    }

    /**
     * Wrapper for the {@link DoboAdapter#createRecord} method, called internally by `model` to make sure
     * all adapter hooks are executed accordingly, and all inputs and outputs are sanitized.
     * @internal
     * @async
     * @method
     * @param {DoboModel} model
     * @param {object} input
     * @param {object} options
     * @returns {Promise<object>}
     */
    async _createRecord (model, input = {}, options = {}) {
      const { isSet } = this.app.lib.aneka
      let body = await this._prepBodyForCreate(model, input, options)
      await this._prepIdForCreate(model, body, options)
      if (!options.noUniqueCheck) {
        if (!this.support.uniqueIndex) await this._checkUnique(model, body, options)
      }
      if (!options.noIdCheck && isSet(body.id)) {
        const resp = await this.getRecord(model, body.id, { noMagic: true })
        if (!isEmpty(resp.data)) throw this.plugin.error('recordExists%s%s', body.id, model.name)
      }
      body = this.sanitizeBody(model, body)

      await this._attachHook('beforeCreateRecord', model, body, options)
      const result = await this.createRecord(model, body, options)
      await this._attachHook('afterCreateRecord', model, body, result, options)

      if (options.noResult) return
      result.data = this.sanitizeRecord(model, result.data)
      this._injectMeta(result, options)
      return result
    }

    /**
     * Wrapper for the {@link DoboAdapter#bulkCreateRecord} method, called internally by `model` to make sure
     * all adapter hooks are executed accordingly, and all inputs and outputs are sanitized.
     * @internal
     * @async
     * @method
     * @param {DoboModel} model
     * @param {Array<object>} bodies
     * @param {object} options
     * @returns {Promise<void>}
     */
    async _bulkCreateRecord (model, bodies = [], options = {}) {
      const { chunk } = this.app.lib._
      let { chunkSize = this.maxChunkSize } = options
      if (chunkSize > this.maxChunkSize) chunkSize = this.maxChunkSize
      for (const idx in bodies) {
        const body = await this._prepBodyForCreate(model, bodies[idx], options)
        await this._prepIdForCreate(model, body, options)
        bodies[idx] = this.sanitizeBody(model, body)
      }

      await this._attachHook('beforeBulkCreateRecord', model, bodies, options)
      const items = chunk(bodies, chunkSize)
      for (const item of items) {
        await this.bulkCreateRecord(model, item, options)
      }
      await this._attachHook('afterBulkCreateRecord', model, bodies, [], options)
    }

    /**
     * Wrapper for the {@link DoboAdapter#getRecord} method, called internally by `model` to make sure
     * all adapter hooks are executed accordingly, and all inputs and outputs are sanitized.
     * @internal
     * @async
     * @method
     * @param {DoboModel} model
     * @param {string|number} id
     * @param {object} options
     * @returns {Promise<object>}
     */
    async _getRecord (model, id, options = {}) {
      await this._attachHook('beforeGetRecord', model, id, options)
      const result = await this.getRecord(model, id, options)
      await this._attachHook('afterGetRecord', model, id, result, options)

      if (isEmpty(result.data) && options.throwNotFound) throw this.plugin.error('recordNotFound%s%s', id, model.name)
      result.data = this.sanitizeRecord(model, result.data)
      this._injectMeta(result, options)
      return result
    }

    /**
     * Wrapper for the {@link DoboAdapter#updateRecord} method, called internally by `model` to make sure
     * all adapter hooks are executed accordingly, and all inputs and outputs are sanitized.
     * @internal
     * @async
     * @method
     * @param {DoboModel} model
     * @param {string|number} id
     * @param {object} input
     * @param {object} options
     * @returns {Promise<object>}
     */
    async _updateRecord (model, id, input = {}, options = {}) {
      let body = omit(input, this.getVirtualFields(model))
      if (!options.noUniqueCheck) {
        if (!this.support.uniqueIndex) await this._checkUnique(model, body, options)
      }
      if (!options._data) {
        const resp = await this.getRecord(model, id, { noMagic: true })
        if (!resp.data) throw this.plugin.error('recordNotFound%s%s', id, model.name)
        options._data = resp.data
      }
      body = this.sanitizeBody(model, body, true)
      delete body.id

      await this._attachHook('beforeUpdateRecord', model, id, body, options)
      const result = await this.updateRecord(model, id, body, options)
      await this._attachHook('afterUpdateRecord', model, id, body, result, options)

      if (options.noResult) return
      result.oldData = this.sanitizeRecord(model, result.oldData)
      result.data = this.sanitizeRecord(model, result.data)
      this._injectMeta(result, options)
      return result
    }

    /**
     * Upserts a record for the given model.
     * This method will only run if child adapter does not implement {@link DoboAdapter#upsertRecord}.
     *
     * @internal
     * @async
     * @method
     * @param {DoboModel} model - The model instance for which the record is being upserted
     * @param {object} input - The input data for the upsert
     * @param {object} options - Additional options that may affect record upserting
     * @returns {Promise<object>} - The result of the record upsert
     */
    async _upsertRecord (model, input = {}, options = {}) {
      let body = omit(input, this.getVirtualFields(model))
      if (!options.noUniqueCheck) {
        if (!this.support.uniqueIndex) await this._checkUnique(model, body, options)
      }
      if (isSet(body.id)) {
        if (!options._data) {
          const resp = await this.getRecord(model, body.id, { noMagic: true })
          if (!resp.data) throw this.plugin.error('recordNotFound%s%s', body.id, model.name)
          options._data = resp.data
        }
      }
      body = this.sanitizeBody(model, body)

      await this._attachHook('beforeUpsertRecord', model, body, options)
      const result = await this.upsertRecord(model, body, options)
      await this._attachHook('afterUpsertRecord', model, body, result, options)

      if (options.noResult) return
      if (result.oldData) result.oldData = this.sanitizeRecord(model, result.oldData)
      result.data = this.sanitizeRecord(model, result.data)
      this._injectMeta(result, options)
      return result
    }

    /**
     * Wrapper for the {@link DoboAdapter#removeRecord} method, called internally by `model` to make sure
     * all adapter hooks are executed accordingly, and all inputs and outputs are sanitized.
     * @internal
     * @async
     * @method
     * @param {DoboModel} model
     * @param {string|number} id
     * @param {object} options
     * @returns {Promise<object>}
     */
    async _removeRecord (model, id, options = {}) {
      if (!options._data) {
        const resp = await this.getRecord(model, id, { noMagic: true })
        if (!resp.data) throw this.plugin.error('recordNotFound%s%s', id, model.name)
        options._data = resp.data
      }

      await this._attachHook('beforeRemoveRecord', model, id, options)
      const result = await this.removeRecord(model, id, options)
      await this._attachHook('afterRemoveRecord', model, id, result, options)

      if (options.noResult) return
      result.oldData = this.sanitizeRecord(model, result.oldData)
      this._injectMeta(result, options)
      return result
    }

    /**
     * Wrapper for the {@link DoboAdapter#clearRecord} method, called internally by `model` to make sure
     * all adapter hooks are executed accordingly, and all inputs and outputs are sanitized.
     * @internal
     * @async
     * @method
     * @param {DoboModel} model
     * @param {object} options
     * @returns {Promise<object>}
     */
    async _clearRecord (model, options = {}) {
      await this._attachHook('beforeClearRecord', model, options)
      const result = await this.clearRecord(model, options)
      await this._attachHook('afterClearRecord', model, result, options)

      this._injectMeta(result, options)
      return result
    }

    /**
     * Wrapper for the {@link DoboAdapter#findRecord} method, called internally by `model` to make sure
     * all adapter hooks are executed accordingly, and all inputs and outputs are sanitized.
     * @internal
     * @async
     * @method
     * @param {DoboModel} model
     * @param {object} filter
     * @param {object} options
     * @returns {Promise<object>}
     */
    async _findRecord (model, filter = {}, options = {}) {
      let result
      try {
        await this._attachHook('beforeFindRecord', model, filter, options)
        result = await this.findRecord(model, filter, options)
        await this._attachHook('afterFindRecord', model, filter, result, options)
      } catch (err) {
        if (!['_emptyColumnQuery', '_abortAction'].includes(err.message)) throw err
        result = {
          data: [],
          count: 0
          // warnings: [] // TODO: should generate warnings?
        }
      }

      for (const idx in result.data) {
        result.data[idx] = this.sanitizeRecord(model, result.data[idx])
      }
      this._injectMeta(result, options)
      return result
    }

    /**
     * Finds all records for the given model based on the provided filter.
     * This method will only run if child adapter does not implement {@link DoboAdapter#findAllRecord}.
     *
     * @internal
     * @async
     * @method
     * @param {DoboModel} model - The model instance for which the records are being found
     * @param {object} filter - The filter criteria for finding records
     * @param {object} options - Additional options that may affect record finding
     * @returns {Promise<object>} - The result of the record finding
     */
    async _findAllRecord (model, filter = {}, options = {}) {
      let result
      try {
        await this._attachHook('beforeFindAllRecord', model, filter, options)
        result = await this.findAllRecord(model, filter, options)
        await this._attachHook('afterFindAllRecord', model, filter, result, options)
      } catch (err) {
        if (err.message !== '_emptyColumnQuery') throw err
        result = {
          data: [],
          count: 0
          // warnings: [] // TODO: should generate warnings?
        }
      }

      for (const idx in result.data) {
        result.data[idx] = this.sanitizeRecord(model, result.data[idx])
      }
      this._injectMeta(result, options)
      return result
    }

    /**
     * Wrapper for the {@link DoboAdapter#countRecord} method, called internally by `model` to make sure
     * all adapter hooks are executed accordingly, and all inputs and outputs are sanitized.
     * @internal
     * @async
     * @method
     * @param {DoboModel} model
     * @param {object} filter
     * @param {object} options
     * @returns {Promise<object>}
     */
    async _countRecord (model, filter = {}, options = {}) {
      let result
      try {
        await this._attachHook('beforeCountRecord', model, filter, options)
        result = await this.countRecord(model, filter, options)
        await this._attachHook('afterCountRecord', model, filter, result, options)
      } catch (err) {
        if (err.message !== '_emptyColumnQuery') throw err
        result = { data: 0 }
      }

      return result
    }

    /**
     * Wrapper for the {@link DoboAdapter#createAggregate} method, called internally by `model` to make sure
     * all adapter hooks are executed accordingly, and all inputs and outputs are sanitized.
     * @internal
     * @async
     * @method
     * @param {DoboModel} model
     * @param {object} filter
     * @param {object} params
     * @param {object} options
     * @returns {Promise<object>}
     */
    async _createAggregate (model, filter = {}, params = {}, options = {}) {
      const fieldPropTypes = ['integer', 'smallint', 'float', 'double']
      const groupPropTypes = ['string', ...fieldPropTypes]
      this.app.dobo.checkAggregateParams(params)
      const { group, field } = params

      let prop = model.properties.find(p => p.name === group)
      if (!prop) throw this.plugin.error('unknown%s%s', this.plugin.t('field.field'), group)
      if (!groupPropTypes.includes(prop.type)) throw this.plugin.error('allowedPropType%s%s', group, groupPropTypes.join(', '))

      prop = model.properties.find(p => p.name === field)
      if (!prop) throw this.plugin.error('unknown%s%s', this.plugin.t('field.field'), field)
      // if (!fieldPropTypes.includes(prop.type)) throw this.plugin.error('allowedPropType%s%s', field, fieldPropTypes.join(', '))

      let result
      try {
        await this._attachHook('beforeCreateAggregate', model, filter, params, options)
        result = await this.createAggregate(model, filter, params, options)
        await this._attachHook('afterCreateAggregate', model, filter, params, result, options)
      } catch (err) {
        if (err.message !== '_emptyColumnQuery') throw err
        result = { data: [] }
      }

      for (const idx in result.data) {
        result.data[idx] = this.sanitizeRecord(model, result.data[idx])
      }
      this._injectMeta(result, options)
      return result
    }

    /**
     * Wrapper for the {@link DoboAdapter#createHistogram} method, called internally by `model` to make sure
     * all adapter hooks are executed accordingly, and all inputs and outputs are sanitized.
     * @internal
     * @async
     * @method
     * @param {DoboModel} model
     * @param {object} filter
     * @param {object} params
     * @param {object} options
     * @returns {Promise<object>}
     */
    async _createHistogram (model, filter = {}, params, options = {}) {
      // const fieldPropTypes = ['integer', 'smallint', 'float', 'double']
      const groupPropTypes = ['datetime', 'date']
      this.app.dobo.checkHistogramParams(params)
      const { group, field } = params

      let prop = model.properties.find(p => p.name === group)
      if (!prop) throw this.plugin.error('unknown%s%s', this.plugin.t('field.field'), group)
      if (!groupPropTypes.includes(prop.type)) throw this.plugin.error('allowedPropType%s%s', group, groupPropTypes.join(', '))

      prop = model.properties.find(p => p.name === field)
      if (!prop) throw this.plugin.error('unknown%s%s', this.plugin.t('field.field'), field)

      let result
      try {
        await this._attachHook('beforeCreateHistogram', model, filter, params, options)
        result = await this.createHistogram(model, filter, params, options)
        await this._attachHook('afterCreateHistogram', model, filter, params, result, options)
      } catch (err) {
        if (err.message !== '_emptyColumnQuery') throw err
        result = { data: [] }
      }

      for (const idx in result.data) {
        result.data[idx] = this.sanitizeRecord(model, result.data[idx])
      }
      this._injectMeta(result, options)
      return result
    }

    // Public calls that need to be implemented by child adapters

    /**
     * Connects to the database using the provided connection parameters.
     * @param {*} connection - The connection parameters for the database
     * @param {*} noRebuild - Flag indicating whether to skip rebuilding the database schema
     * @returns {Promise<void>} - Resolves when the connection is established
     */
    async connect (connection, noRebuild) {
    }

    /**
     * Checks if the model exists in the database.
     *
     * Must be implemented by child classes to provide specific database functionality for model existence checking,
     * or throw an error if the operation is not supported by the adapter.
     *
     * @param {DoboModel} model - The model instance to check for existence
     * @param {object} options - Additional options that may affect the existence check
     */
    async modelExists (model, options = {}) {
      throw this.plugin.error('notSupportedAdapter%s%s%s', this.app.t('method'), 'modelExists', this.name)
    }

    /**
     * Builds the model in the database.
     *
     * Must be implemented by child classes to provide specific database functionality for model building,
     * or throw an error if the operation is not supported by the adapter.
     *
     * @param {DoboModel} model - The model instance for which the record is being built
     * @param {object} options - Additional options that may affect model building
     */
    async buildModel (model, options = {}) {
      throw this.plugin.error('notSupportedAdapter%s%s%s', this.app.t('method'), 'buildModel', this.name)
    }

    /**
     * Drops the model from the database.
     *
     * Must be implemented by child classes to provide specific database functionality for dropping a model,
     * or throw an error if the operation is not supported by the adapter.
     *
     * @param {DoboModel} model - The model instance for which the record is being dropped
     * @param {object} options - Additional options that may affect record dropping
     */
    async dropModel (model, options = {}) {
      throw this.plugin.error('notSupportedAdapter%s%s%s', this.app.t('method'), 'dropModel', this.name)
    }

    /**
     * Creates a new record for the given model.
     *
     * Must be implemented by child classes to provide specific database functionality for record creation,
     * or throw an error if the operation is not supported by the adapter.
     *
     * @param {DoboModel} model - The model instance for which the record is being created
     * @param {object} input - The input data for the new record
     * @param {object} options - Additional options that may affect record creation
     * @returns {Promise<object>} - The result of the record creation
     */
    async createRecord (model, body = {}, options = {}) {
      throw this.plugin.error('notSupportedAdapter%s%s%s', this.app.t('method'), 'createRecord', this.name)
    }

    /**
     * Retrieves a record for the given model by its ID.
     *
     * Must be implemented by child classes to provide specific database functionality for record retrieval,
     * or throw an error if the operation is not supported by the adapter.
     *
     * @param {DoboModel} model - The model instance for which the record is being retrieved
     * @param {string|number} id - The ID of the record to retrieve
     * @param {object} options - Additional options that may affect record retrieval
     * @returns {Promise<object>} - The result of the record retrieval
     */
    async getRecord (model, id, options = {}) {
      throw this.plugin.error('notSupportedAdapter%s%s%s', this.app.t('method'), 'getRecord', this.name)
    }

    /**
     * Updates a record for the given model by its ID.
     *
     * Must be implemented by child classes to provide specific database functionality for record updating,
     * or throw an error if the operation is not supported by the adapter.
     *
     * @param {DoboModel} model - The model instance for which the record is being updated
     * @param {string|number} id - The ID of the record to update
     * @param {object} input - The input data for the update
     * @param {object} options - Additional options that may affect record updating
     * @returns {Promise<object>} - The result of the record update
     */
    async updateRecord (model, id, body = {}, options = {}) {
      throw this.plugin.error('notSupportedAdapter%s%s%s', this.app.t('method'), 'updateRecord', this.name)
    }

    /**
     * Removes a record for the given model by its ID.
     *
     * Must be implemented by child classes to provide specific database functionality for record removal,
     * or throw an error if the operation is not supported by the adapter.
     *
     * @param {DoboModel} model - The model instance for which the record is being removed
     * @param {string|number} id - The ID of the record to remove
     * @param {object} options - Additional options that may affect record removal
     * @returns {Promise<object>} - The result of the record removal
     */
    async removeRecord (model, id, options = {}) {
      throw this.plugin.error('notSupportedAdapter%s%s%s', this.app.t('method'), 'removeRecord', this.name)
    }

    /**
     * Clears all records for the given model.
     *
     * Must be implemented by child classes to provide specific database functionality for record clearing,
     * or throw an error if the operation is not supported by the adapter.
     *
     * @param {DoboModel} model - The model instance for which the records are being cleared
     * @param {object} options - Additional options that may affect record clearing
     * @returns {Promise<object>} - The result of the record clearing
     */
    async clearRecord (model, options = {}) {
      throw this.plugin.error('notSupportedAdapter%s%s%s', this.app.t('method'), 'clearRecord', this.name)
    }

    /**
     * Finds records for the given model based on the provided filter.
     *
     * Must be implemented by child classes to provide specific database functionality for record finding,
     * or throw an error if the operation is not supported by the adapter.
     *
     * @param {DoboModel} model - The model instance for which the records are being found
     * @param {object} filter - The filter criteria for finding records
     * @param {object} options - Additional options that may affect record finding
     * @returns {Promise<object>} - The result of the record finding
     */
    async findRecord (model, filter = {}, options = {}) {
      throw this.plugin.error('notSupportedAdapter%s%s%s', this.app.t('method'), 'findRecord', this.name)
    }

    /**
     * Bulk creates records for the given model.
     *
     * Must be implemented by child classes to provide specific database functionality for bulk record creation,
     * or throw an error if the operation is not supported by the adapter.
     *
     * @param {DoboModel} model - The model instance for which the records are being created
     * @param {Array<object>} bodies - The array of input data for the new records
     * @param {object} options - Additional options that may affect record creation
     * @returns {Promise<void>} - Resolves when the records have been created
     */
    async bulkCreateRecord (model, bodies = [], options = {}) {
      throw this.plugin.error('notSupportedAdapter%s%s%s', this.app.t('method'), 'bulkCreateRecord', this.name)
    }

    /**
     * Counts the records for the given model based on the provided filter.
     *
     * Must be implemented by child classes to provide specific database functionality for record counting,
     * or throw an error if the operation is not supported by the adapter.
     *
     * @param {DoboModel} model - The model instance for which the records are being counted
     * @param {object} filter - The filter criteria for counting records
     * @param {object} options - Additional options that may affect record counting
     * @returns {Promise<object>} - The result of the record counting
     */
    async countRecord (model, filter = {}, options = {}) {
      throw this.plugin.error('notSupportedAdapter%s%s%s', this.app.t('method'), 'countRecord', this.name)
    }

    /**
     * Creates an aggregate for the given model based on the provided filter and parameters.
     *
     * Must be implemented by child classes to provide specific database functionality for aggregate creation,
     * or throw an error if the operation is not supported by the adapter.
     *
     * @param {DoboModel} model - The model instance for which the aggregate is being created
     * @param {object} filter - The filter criteria for creating the aggregate
     * @param {object} params - The parameters for the aggregate creation
     * @param {object} options - Additional options that may affect aggregate creation
     * @returns {Promise<object>} - The result of the aggregate creation
     */
    async createAggregate (model, filter = {}, params = {}, options = {}) {
      throw this.plugin.error('notSupportedAdapter%s%s%s', this.app.t('method'), 'createAggregate', this.name)
    }

    /**
     * Creates a histogram for the given model based on the provided filter and parameters.
     *
     * Must be implemented by child classes to provide specific database functionality for histogram creation,
     * or throw an error if the operation is not supported by the adapter.
     *
     * @param {DoboModel} model - The model instance for which the histogram is being created
     * @param {object} filter - The filter criteria for creating the histogram
     * @param {object} params - The parameters for the histogram creation
     * @param {object} options - Additional options that may affect histogram creation
     * @returns {Promise<object>} - The result of the histogram creation
     */
    async createHistogram (model, filter = {}, params = {}, options = {}) {
      throw this.plugin.error('notSupportedAdapter%s%s%s', this.app.t('method'), 'createHistogram', this.name)
    }

    /**
     * Executes a transaction for the given model.
     *
     * Must be implemented by child classes to provide specific database functionality for transactions,
     * or throw an error if the operation is not supported by the adapter.
     *
     * @param {DoboModel} model - The model instance for which the transaction is being executed
     * @param {Function} handler - The transaction handler function
     * @param  {...any} args - Additional arguments for the transaction handler
     */
    async transaction (model, handler, ...args) {
      throw this.plugin.error('notSupportedAdapter%s%s%s', this.app.t('method'), 'transaction', this.name)
    }

    /**
     * Disposes of the adapter, performing any necessary cleanup operations.
     *
     * @returns {Promise<void>} - Resolves when the adapter has been disposed
     */
    async dispose () {
      await super.dispose()
    }
  }

  class DoboNullAdapter extends DoboAdapter {
    constructor (plugin, name = 'null', options = {}) {
      super(plugin, name, options)
      this.memory = true
    }
  }

  this.app.baseClass.DoboAdapter = DoboAdapter
  this.app.baseClass.DoboNullAdapter = DoboNullAdapter
}

export default adapterFactory