UNPKG

mongoose-datatable

Version:

Server side dataTable request support for mongoose

675 lines 27.6 kB
"use strict"; /** @format */ Object.defineProperty(exports, "__esModule", { value: true }); exports.DataTableModule = void 0; const flat = require("flat"); const lodash_1 = require("lodash"); const mongoose_1 = require("mongoose"); const util = require("util"); const SearchOperator = (0, lodash_1.orderBy)(['>', '>=', '≥', '<', '≤', '<>', '≤≥', '><', '≥≤'], ['length'], ['desc']); class DataTableModule { get config() { return this._config; } get logger() { return this._config.logger; } static configure(config) { if (config) { DataTableModule.CONFIG = (0, lodash_1.assign)(DataTableModule.CONFIG, config); } return DataTableModule.CONFIG; } static init(schema, config) { const dataTableModule = new DataTableModule(schema); schema.statics.dataTableConfig = (0, lodash_1.merge)({}, DataTableModule.CONFIG, config); schema.statics.dataTable = function (query, options) { options = (0, lodash_1.merge)({}, schema.statics.dataTableConfig, options); dataTableModule.model = this; return dataTableModule.dataTable(query, options); }; } constructor(schema) { this.schema = schema; this._config = DataTableModule.CONFIG; } dataTable(query, options = {}) { this.debug(options.logger, 'quey:', util.inspect(query, { depth: null })); const aggregate = { projection: null, populate: [], sort: this.buildSort(query), pagination: this.pagination(query), groupBy: query.groupBy, }; this.updateAggregateOptions(options, query, aggregate); return (options.disableCount === true ? Promise.resolve(-1) : this.recordsTotal(options)).then(recordsTotal => { return (options.disableCount === true ? Promise.resolve(-1) : this.recordsFiltered(options, aggregate, recordsTotal)).then(recordsFiltered => { return this.data(options, aggregate).then(data => { return Promise.resolve({ draw: query.draw, recordsTotal, recordsFiltered, data, }); }); }); }); } buildSort(query) { if (!query.order || query.order.length === 0) { return null; } const sort = {}; query.order.forEach(order => { const column = query.columns[order.column]; if (column) { if (this.isFalse(column.orderable)) { return; } sort[column.data] = order.dir === 'asc' ? 1 : -1; } }); return !!Object.keys(sort).length ? sort : null; } updateAggregateOptions(options, query, aggregate) { let search = [], csearch = [], psearch = []; const projection = {}; if (query.search && query.search.value !== undefined && query.search.value !== '') { query.search.chunks = this.getChunkSearch(query.search.value); } query.columns.forEach(column => { const finfo = this.fetchField(options, query, column, aggregate.populate); if (!finfo?.field) return; if (!this.isSelectable(finfo.field)) return; if (this.isTrue(column.searchable)) { if (column.search && column.search.value !== undefined && column.search.value !== '') { column.search.chunks = this.getChunkSearch(column.search.value); } const s = this.getSearch(options, query, column, finfo.field); search = search.concat(s.search); if (finfo.populated) { psearch = psearch.concat(s.csearch); } else { csearch = csearch.concat(s.csearch); } } projection[column.data] = 1; }); this.addProjection(options, aggregate, projection); aggregate.search = this.addSearch(csearch); aggregate.afterPopulateSearch = this.addSearch(psearch, search, options.conditions); } getModel(base, modelName) { try { return base.db.model(modelName); } catch (err) { } return null; } addFieldRef(data) { data.populated = true; data.model = this.getModel(data.model, data.field.options.ref); if (!data.model) return; data.schema = data.model.schema; if (!data.populate.find((l) => l.$lookup && l.$lookup.localField === data.base)) { data.populate.push({ $lookup: { from: data.model.collection.collectionName, localField: data.base, foreignField: '_id', as: data.base, }, }); data.populate.push({ $unwind: { path: `$${data.base}`, preserveNullAndEmptyArrays: true }, }); } } addFieldArrayRef(data) { data.populated = true; data.model = this.getModel(data.model, data.field.options.ref); if (!data.model) return; data.schema = data.model.schema; if (!data.populate.find((l) => l.$lookup && l.$lookup.localField === data.base)) { const refProperty = data.base.substr(data.inArray.length + 1); const lookupProperty = `${data.inArray}__${refProperty}`; data.populate.push({ $lookup: { from: data.model.collection.collectionName, localField: data.base, foreignField: '_id', as: lookupProperty, }, }); data.populate.push({ $addFields: { [data.inArray]: { $map: { input: `$${data.inArray}`, as: 'elmt', in: { $mergeObjects: [ '$$elmt', { [refProperty]: { $arrayElemAt: [ { $filter: { input: `$${lookupProperty}`, as: 'lookup', cond: { $eq: [`$$elmt.${refProperty}`, '$$lookup._id'], }, }, }, 0, ], }, }, ], }, }, }, }, }); } } fieldNotFound(options, column, data) { if (!data?.model) { this.warn(options.logger, `field path ${column.data} refered model ${data.field?.options?.ref} not found !`); } else this.warn(options.logger, `field path ${column.data} not found !`); if (!options.processUnknownFields) return; return { field: { path: column.data }, populated: false }; } fetchField(options, query, column, populate) { let populated = false; let field = this.schema.path(column.data); if (field) return { field, populated }; const data = { populate, populated, field, path: column.data, model: this.model, schema: this.schema, base: '', inArray: null, }; while (data.path.length) { data.field = data.schema.path(data.path) || this.getField(data.schema, data.path); if (!data.field) return this.fieldNotFound(options, column, data); data.base += (data.base.length ? '.' : '') + data.field.path; data.path = data.path.substring(data.field.path.length + 1); // ref field if (data.field.options && data.field.options.ref && !data.inArray) { this.addFieldRef(data); if (!data.model) return this.fieldNotFound(options, column, data); continue; } // ref field in array if (data.field.options && data.field.options.ref && !!data.inArray) { this.addFieldArrayRef(data); if (!data.model) return this.fieldNotFound(options, column, data); continue; } // ref array field ref if (data.field.instance === 'Array' && data.field.caster && data.field.caster.options && data.field.caster.options.ref) { data.populated = true; data.model = this.getModel(data.model, data.field.caster.options.ref); if (!data.model) { this.warn(options.logger, `field path ${column.data} refered model ${data.field.caster.options.ref} not found !`); return; } data.schema = data.model.schema; if (!populate.find((l) => l.$lookup && l.$lookup.localField === data.base)) { populate.push({ $lookup: { from: data.model.collection.collectionName, localField: data.base, foreignField: '_id', as: data.base, }, }); } continue; } // array field if (data.field.instance === 'Array') { data.inArray = data.base; if (data.field.schema) { data.schema = data.field.schema; } continue; } // sub schema if (data.field.schema) { data.schema = data.field.schema; continue; } break; } if (!data.field) { this.warn(options.logger, `field path ${column.data} not found !`); } return { field: data.field, populated: data.populated }; } getField(schema, path) { var baseField, tail = path, base, indexSep, index = -1, count = 0; while ((indexSep = tail.indexOf('.')) != -1) { if (++count > 10) break; index += indexSep + 1; var base = path.substring(0, index); baseField = schema.path(base); if (baseField) { return baseField; } tail = path.substring(base.length + 1); } } addProjection(options, aggregate, projection) { if (options.select || Object.keys(projection).length) { const select = typeof options.select === 'string' ? options.select.split(' ').reduce((p, c) => ((p[c] = 1), p), {}) : Array.isArray(options.select) ? options.select.reduce((p, c) => ((p[c] = 1), p), {}) : options.select; aggregate.projection = flat.unflatten((0, lodash_1.merge)(select, projection || {}), { overwrite: true, }); } } addSearch(csearch, search = [], conditions) { let asearch; if (conditions || search.length || csearch.length) { asearch = { $and: [] }; if (conditions) { asearch.$and.push(conditions); } if (search.length) { asearch.$and.push({ $or: search }); } if (csearch.length) { asearch.$and = asearch.$and.concat(csearch); } } return asearch; } getSearch(options, query, column, field) { const search = [], csearch = []; if (query.search && query.search.value !== undefined && query.search.value !== '') { const s = this.buildColumnSearch(options, query, column, field, query.search, true); if (s) { search.push(s); } } if (column.search && column.search.value !== undefined && column.search.value !== '') { const s = this.buildColumnSearch(options, query, column, field, column.search, false); if (s) { csearch.push(s); } } return { search, csearch }; } getChunkSearch(search) { let chunks = []; if ((0, lodash_1.isArray)(search)) { (0, lodash_1.each)(search, s => (chunks = (0, lodash_1.concat)(chunks, this.getChunkSearch(s)))); return chunks; } search = search !== null && search !== undefined ? search.toString() : ''; return search .replace(/(?:"([^"]*)")/g, (match, word) => { if (word && word.length > 0) { chunks.push(word); } return ''; }) .split(/[ \t]+/) .filter(s => (0, lodash_1.trim)(s) !== ''); } buildColumnSearch(options, query, column, field, search, global) { let instance = field.instance; if (options.handlers && options.handlers[instance]) { return options.handlers[instance](query, column, field, search, global); } if (this.config.handlers[instance]) { return this.config.handlers[instance](query, column, field, search, global); } if (search.value === null) { const columnSearch = {}; return (columnSearch[column.data] = null); } if (instance === 'Mixed') { if (column.type) instance = column.type; else instance = this.tryDeductMixedFromValue(search.value); } switch (instance) { case 'String': return this.buildColumnSearchString(options, column, search, global); case 'Boolean': return this.buildColumnSearchBoolean(options, column, search, global); case 'Number': return this.buildColumnSearchNumber(options, column, search, global); case 'Date': return this.buildColumnSearchDate(options, column, search, global); case 'ObjectID': case 'ObjectId': return this.buildColumnSearchObjectId(options, column, search, global); default: if (options.handlers && options.handlers.default) { return options.handlers.default(query, column, field, search, global); } if (this.config.handlers.default) { return this.config.handlers.default(query, column, field, search, global); } this.warn(options.logger, `buildColumnSearch column [${column.data}] type [${instance}] not managed !`); } return null; } tryDeductMixedFromValue(value) { if (value instanceof Date) return 'Date'; switch (typeof value) { case 'string': if (mongoose_1.Types.ObjectId.isValid(value)) { return 'ObjectID'; } if (/^(=|>|>=|<=|<|<>|<=>)?([0-9.]+)(?:,([0-9.]+))?$/.test(value)) { return 'Number'; } if (/^(=|>|>=|<=|<|<>|<=>|><=|>=<)?([0-9.\/-]+)(?:,([0-9.\/-]+))?$/.test(value)) { return 'Date'; } return 'String'; case 'boolean': return 'Boolean'; case 'number': return 'Number'; } return 'Mixed'; } buildColumnSearchString(options, column, search, global = false) { this.debug(options.logger, 'buildColumnSearchString:', column.data, search); const values = global ? search.chunks : (0, lodash_1.isArray)(search.value) ? search.value : [search.value]; const s = (0, lodash_1.map)(values, val => { if (typeof val === 'string' && search.regex) return { [column.data]: new RegExp(`${val}`, 'gi') }; return { [column.data]: val }; }); return s.length > 0 ? (s.length > 1 ? { $or: s } : s[0]) : null; } buildColumnSearchBoolean(options, column, search, global = false) { if (global) return; this.debug(options.logger, 'buildColumnSearchBoolean:', column.data, search); if (['string', 'boolean'].includes(typeof search.value)) { const value = typeof search.value === 'boolean' ? search.value : (0, lodash_1.lowerCase)((0, lodash_1.trim)(search.value)); if (value === 'true' || value === true) { return { [column.data]: true }; } else if (value === 'false' || value === false) { return { [column.data]: false }; } } } buildCompare(property, op, values) { switch (op) { case '>': return { [property]: { $gt: values[0] } }; case '≥': return { [property]: { $gte: values[0] } }; case '<': return { [property]: { $lt: values[0] } }; case '≤': return { [property]: { $lte: values[0] } }; case '<>': return { [property]: { $gt: values[0], $lt: values[1] } }; case '≤≥': return { [property]: { $gte: values[0], $lte: values[1] } }; case '><': return { [property]: { $gt: values[0], $lt: values[1] } }; case '≥≤': return { [property]: { $gte: values[0], $lte: values[1] } }; default: return { [property]: { $eq: values[0] } }; } } buildColumnSearchNumber(options, column, search, global = false) { this.debug(options.logger, 'buildColumnSearchNumber:', column.data, search); const values = global ? search.chunks : (0, lodash_1.isArray)(search.value) ? search.value : [search.value]; const s = []; (0, lodash_1.each)(values, val => { const values = []; if (typeof val === 'string') values.push(...this.getNumberStringValues(val)); else if (typeof val === 'number') values.push(val); else if (val?.from || val?.to) { const from = val?.from ? parseFloat(val.from) : undefined; if (from && !isNaN(from.valueOf())) values.push(from); const to = val?.to ? parseFloat(val.to) : undefined; if (to && !isNaN(to.valueOf())) values.push(to); } if (values.length) s.push(this.buildCompare(column.data, search.operator, values)); }); return s.length > 0 ? (s.length > 1 ? { $or: s } : s[0]) : null; } getNumberStringValues(val) { return val .split(';') .map(v => parseFloat(v)) .filter(v => !isNaN(v.valueOf())); } buildColumnSearchDate(options, column, search, global = false) { this.debug(options.logger, 'buildColumnSearchDate:', column.data, search); const values = global ? search.chunks : (0, lodash_1.isArray)(search.value) ? search.value : [search.value]; const s = []; (0, lodash_1.each)(values, val => { const values = []; if (typeof val === 'string') values.push(...this.getDateStringValues(val)); else if (typeof val === 'number') values.push(new Date(val)); else if (val instanceof Date) values.push(val); else if (val?.from || val?.to) { const from = val?.from ? new Date(val.from) : undefined; if (from && !isNaN(from.valueOf())) values.push(from); const to = val?.to ? new Date(val.to) : undefined; if (to && !isNaN(to.valueOf())) values.push(to); } if (values.length) s.push(this.buildCompare(column.data, search.operator, values)); }); return s.length > 0 ? (s.length > 1 ? { $or: s } : s[0]) : null; } getDateStringValues(val) { return val .split(';') .map(v => new Date(v)) .filter(v => !isNaN(v.valueOf())); } buildColumnSearchObjectId(options, column, search, global = false) { if (global) return; this.debug(options.logger, 'buildColumnSearchObjectId:', column.data, search); const values = (0, lodash_1.isArray)(search.value) ? search.value : [search.value]; const s = []; (0, lodash_1.each)(values, val => { if (mongoose_1.Types.ObjectId.isValid(val)) return s.push({ [column.data]: new mongoose_1.Types.ObjectId(val) }); this.warn(options.logger, `buildColumnSearchObjectId unmanaged search value '${search.value}'`); }); return s.length > 0 ? (s.length > 1 ? { $or: s } : s[0]) : null; } pagination(query) { const start = this.parseNumber(query.start, 0); const length = this.parseNumber(query.length, undefined); return { start: isNaN(start) ? 0 : start, length: isNaN(length) ? undefined : length, }; } parseNumber(data, def) { if ((0, lodash_1.isNil)(data)) return def; if (typeof data === 'string') return parseInt(data, 10); if (typeof data === 'number') return data; return def; } isTrue(data) { return data === true || data === 'true'; } isFalse(data) { return data === false || data === 'false'; } isSelectable(field) { return !field.options || (field.options.select !== false && field.options.dataTableSelect !== false); } async recordsTotal(options) { if (!!options.unwind?.length) { const aggregate = []; options.unwind.forEach($unwind => aggregate.push({ $unwind })); aggregate.push({ $match: options.conditions }); // aggregate.push({ $group: { _id: null, count: { $sum: 1 } } }); // return get(head(await this.model.aggregate(aggregate)), 'count'); aggregate.push({ $count: 'count' }); return this.model.aggregate(aggregate).then(data => (data.length === 1 ? data[0].count : 0)); } return this.model.countDocuments(options.conditions); } async recordsFiltered(options, aggregateOptions, recordsTotal) { if (!aggregateOptions.search && !aggregateOptions.afterPopulateSearch) { return Promise.resolve(recordsTotal); } const aggregate = []; (options.unwind || []).forEach($unwind => aggregate.push({ $unwind })); if (aggregateOptions.search) aggregate.push({ $match: aggregateOptions.search }); aggregateOptions.populate.forEach(data => aggregate.push(data)); if (aggregateOptions.afterPopulateSearch) aggregate.push({ $match: aggregateOptions.afterPopulateSearch }); aggregate.push({ $count: 'count' }); return this.model.aggregate(aggregate).then(data => (data.length === 1 ? data[0].count : 0)); } async data(options, aggregateOptions) { const aggregate = []; (options.unwind || []).forEach($unwind => aggregate.push({ $unwind })); if (aggregateOptions.search) { aggregate.push({ $match: aggregateOptions.search }); } aggregateOptions.populate.forEach(data => aggregate.push(data)); if (aggregateOptions.afterPopulateSearch) { aggregate.push({ $match: aggregateOptions.afterPopulateSearch }); } if (aggregateOptions.projection) { aggregate.push({ $project: aggregateOptions.projection }); } if (aggregateOptions.groupBy) { this.buildGroupBy(aggregateOptions).forEach(gb => aggregate.push(gb)); } if (aggregateOptions.sort) { aggregate.push({ $sort: aggregateOptions.sort }); } if (aggregateOptions.pagination) { if (aggregateOptions.pagination.start) { aggregate.push({ $skip: aggregateOptions.pagination.start * aggregateOptions.pagination.length, }); } if (aggregateOptions.pagination.length) { aggregate.push({ $limit: aggregateOptions.pagination.length }); } } this.debug(options.logger, util.inspect(aggregate, { depth: null })); return this.model.aggregate(aggregate).allowDiskUse(true); } buildGroupBy(aggregateOptions) { const aggregate = []; const _id = {}; let id = []; aggregateOptions.groupBy.forEach((gb, i) => { (0, lodash_1.set)(_id, gb, `$${gb}`); id = id.concat({ $toString: `$_id.${gb}` }); const groupBy = {}; if (i < aggregateOptions.groupBy.length - 1) { groupBy[`gb${i}`] = { id: { $concat: id }, count: '$groupByCount', field: gb, value: `$_id.${gb}`, }; } else { groupBy.groupBy = []; while (i--) { groupBy.groupBy.push(`$data.gb${i}`); } groupBy.groupBy.push({ id: { $concat: id }, count: '$groupByCount', field: gb, value: `$_id.${gb}`, }); } aggregate.push({ $group: { _id: (0, lodash_1.clone)(_id), groupByCount: { $sum: 1 }, data: { $push: '$$ROOT' }, }, }); aggregate.push({ $unwind: '$data' }); aggregate.push({ $replaceRoot: { newRoot: { $mergeObjects: ['$data', groupBy] } }, }); }); return aggregate; } debug(logger, ...args) { const l = logger || this.logger; if (l && l.debug) { l.debug.apply(l, args); } } warn(logger, ...args) { const l = logger || this.logger; if (l && l.warn) { l.warn.apply(l, args); } } } exports.DataTableModule = DataTableModule; DataTableModule.CONFIG = { logger: null, handlers: {}, processUnknownFields: true, }; exports.default = DataTableModule; //# sourceMappingURL=datatable.js.map