dobo
Version:
DBMS for Bajo Framework
578 lines (505 loc) • 22.4 kB
JavaScript
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'
}
async function driverFactory () {
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
/**
* Driver class
*
* ```this.plugin``` should be the one who owned this driver
*
* @class
*/
class DoboDriver extends Tools {
constructor (plugin, name, options = {}) {
super(plugin)
this.name = name
this.idField = cloneDeep(defIdField)
this.propertyType = {}
this.support = {
propType: {
object: false,
array: false,
datetime: true
},
search: false,
uniqueIndex: false,
nullableField: true,
transaction: false
}
this.useUtc = false
this.maxChunkSize = 500
this.memory = false
this.options = options
}
/**
* Sanitize connection object
*
* @method
* @param {Object} conn - Connection object
*/
async sanitizeConnection (conn) {
conn.proto = conn.proto ?? 'http' // used by driver that use url based connection
conn.memory = false
}
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
}
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
}
_getReturningFields (model, { fields = [] } = {}) {
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)
}
async _attachHook (name, model, ...args) {
const { ns } = this.app.dobo
const { kebabCase } = this.app.lib._
const options = last(args)
if (!options.noDriverHook) {
const prefix = kebabCase(name).split('-')[0]
await runHook(`${ns}.driver:${prefix}Any`, model, options)
await runHook(`${ns}.driver:${name}`, model, ...args)
await runHook(`${ns}.driver.${camelCase(model.name)}:${name}`, model, ...args)
}
}
/**
* Check uniqueness of fields with unique index
*
* @param {Object} [params]
*/
_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
async _modelExists (model, options = {}) {
return await this.modelExists(model, options)
}
async _buildModel (model, options = {}) {
return await this.buildModel(model, options)
}
async _dropModel (model, options = {}) {
return await this.dropModel(model, options)
}
getRealFields (model) {
return model.getProperties({ noVirtual: true, namesOnly: true })
}
getVirtualFields (model) {
return model.getVirtualProperties({ namesOnly: true })
}
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))
}
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 ?? []))
}
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
}
async _bulkCreateRecords (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)
}
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
}
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
}
async _upsertRecord (model, input = {}, options = {}) {
let body = omit(input, this.getVirtualFields(model))
if (!options.noUniqueCheck) {
if (!this.uniqueIndexSupport) 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
}
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
}
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
}
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
}
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
}
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
}
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
}
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 drivers
async connect (connection, noRebuild) {
}
async modelExists (model, options = {}) {
throw this.plugin.error('notSupportedDriver%s%s%s', this.app.t('method'), 'modelExists', this.name)
}
async buildModel (model, options = {}) {
throw this.plugin.error('notSupportedDriver%s%s%s', this.app.t('method'), 'buildModel', this.name)
}
async dropModel (model, options = {}) {
throw this.plugin.error('notSupportedDriver%s%s%s', this.app.t('method'), 'dropModel', this.name)
}
async createRecord (model, body = {}, options = {}) {
throw this.plugin.error('notSupportedDriver%s%s%s', this.app.t('method'), 'createRecord', this.name)
}
async getRecord (model, id, options = {}) {
throw this.plugin.error('notSupportedDriver%s%s%s', this.app.t('method'), 'getRecord', this.name)
}
async updateRecord (model, id, body = {}, options = {}) {
throw this.plugin.error('notSupportedDriver%s%s%s', this.app.t('method'), 'updateRecord', this.name)
}
async removeRecord (model, id, options = {}) {
throw this.plugin.error('notSupportedDriver%s%s%s', this.app.t('method'), 'removeRecord', this.name)
}
async clearRecord (model, options = {}) {
throw this.plugin.error('notSupportedDriver%s%s%s', this.app.t('method'), 'clearRecord', this.name)
}
async findRecord (model, filter = {}, options = {}) {
throw this.plugin.error('notSupportedDriver%s%s%s', this.app.t('method'), 'findRecord', this.name)
}
async bulkCreateRecord (model, bodies = [], options = {}) {
throw this.plugin.error('notSupportedDriver%s%s%s', this.app.t('method'), 'bulkCreateRecord', this.name)
}
async countRecord (model, filter = {}, options = {}) {
throw this.plugin.error('notSupportedDriver%s%s%s', this.app.t('method'), 'countRecord', this.name)
}
async createAggregate (model, filter = {}, params = {}, options = {}) {
throw this.plugin.error('notSupportedDriver%s%s%s', this.app.t('method'), 'createAggregate', this.name)
}
async createHistogram (model, filter = {}, params = {}, options = {}) {
throw this.plugin.error('notSupportedDriver%s%s%s', this.app.t('method'), 'createHistogram', this.name)
}
async transaction (model, handler, ...args) {
throw this.plugin.error('notSupportedDriver%s%s%s', this.app.t('method'), 'transaction', this.name)
}
async dispose () {
await super.dispose()
}
}
class DoboNullDriver extends DoboDriver {
constructor (plugin, name = 'null', options = {}) {
super(plugin, name, options)
this.memory = true
}
}
this.app.baseClass.DoboDriver = DoboDriver
this.app.baseClass.DoboNullDriver = DoboNullDriver
}
export default driverFactory