UNPKG

dobo

Version:

DBMS for Bajo Framework

417 lines (389 loc) 15.4 kB
import path from 'path' export const omittedOptionsKeys = ['req', 'reply', 'trx'] export function cloneOptions (options = {}) { const { cloneDeep, omit } = this.app.lib._ const nOptions = cloneDeep(omit(options, omittedOptionsKeys)) for (const key of omittedOptionsKeys) { nOptions[key] = options[key] } return nOptions } 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) } } 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) } 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 } 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 } /** * Break any reference to the original and get the new options * * @param {Object} options * @returns {Object} */ 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 } } 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)}`)) } } 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']) } } 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 } 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 } 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 }) } 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) } } } } } export function buildFilterQuery (filter = {}) { const { parseQuery } = this.app.dobo const query = parseQuery(filter.query ?? {}, this, false) return sanitizeQuery.call(this, query) } 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 } 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.driver.idField.name !== 'id') { const search = JSON.stringify(s).replaceAll('"id"', `"${this.driver.idField.name}"`) try { s = JSON.parse(search) } catch (err) {} } return s } /** * Prepare records pagination: * - making sure records limit is obeyed * - making sure page is a positive value * - if skip is given, recalculate limit to use skip instead of page number * - Build sort info * * @method * @async * @param {Object} [filter={}] - Filter object * @param {Object} options - Options * @returns {TRecordPagination} */ 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 } } 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` }) }