mongoose-query-parser
Version:
Convert url query string to MongooseJs friendly query object including advanced filtering, sorting, population, string template, type casting and many more...
400 lines • 16 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.MongooseQueryParser = void 0;
var qs = require("querystring");
var _ = require("lodash");
var utils_1 = require("./utils");
var MongooseQueryParser = /** @class */ (function () {
function MongooseQueryParser(options) {
var _this = this;
if (options === void 0) { options = {}; }
this.options = options;
this.builtInCaster = {
string: function (val) { return String(val); },
date: function (val) {
var dt = utils_1.toDate(val, _this.options.dateFormat);
if (dt.isValid) {
return dt.toJSDate();
}
else {
throw new Error("Invalid date string: [" + val + "]");
}
}
};
this.operators = [
{ operator: 'select', method: this.castSelect, defaultKey: 'select' },
{ operator: 'populate', method: this.castPopulate, defaultKey: 'populate' },
{ operator: 'sort', method: this.castSort, defaultKey: 'sort' },
{ operator: 'skip', method: this.castSkip, defaultKey: 'skip' },
{ operator: 'limit', method: this.castLimit, defaultKey: 'limit' },
{ operator: 'filter', method: this.castFilter, defaultKey: 'filter' },
];
// add builtInCaster
this.options.casters = Object.assign(this.builtInCaster, options.casters);
// build blacklist
this.options.blacklist = options.blacklist || [];
this.operators.forEach(function (_a) {
var operator = _a.operator, method = _a.method, defaultKey = _a.defaultKey;
_this.options.blacklist.push(_this.options[operator + "Key"] || defaultKey);
});
}
/**
* parses query string/object to Mongoose friendly query object/QueryOptions
* @param {string | Object} query
* @param {Object} [context]
* @return {QueryOptions}
*/
MongooseQueryParser.prototype.parse = function (query, context) {
var _this = this;
var params = _.isString(query) ? qs.parse(query) : query;
var options = this.options;
var result = {};
this.operators.forEach(function (_a) {
var operator = _a.operator, method = _a.method, defaultKey = _a.defaultKey;
var key = options[operator + "Key"] || defaultKey;
var value = params[key];
if (value || operator === 'filter') {
result[operator] = method.call(_this, value, params);
}
}, this);
result = this.parsePredefinedQuery(result, context);
return result;
};
/**
* parses string to typed values
* This methods will apply auto type casting on Number, RegExp, Date, Boolean and null
* Also, it will apply defined casters in given options of the instance
* @param {string} value
* @param {string} key
* @return {any} typed value
*/
MongooseQueryParser.prototype.parseValue = function (value, key) {
var me = this;
var options = this.options;
// Apply casters
// Match type casting operators like: string(true), _caster(123), $('test')
var casters = options.casters;
var casting = value.match(/^([a-zA-Z_$][0-9a-zA-Z_$]*)\((.*)\)$/);
if (casting && casters[casting[1]]) {
return casters[casting[1]](casting[2]);
}
// Apply casters per params
if (key && options.castParams && options.castParams[key] && casters[options.castParams[key]]) {
return casters[options.castParams[key]](value);
}
// cast array
if (value.includes(',')) {
return value.split(',').map(function (val) { return me.parseValue(val, key); });
}
// Apply type casting for Number, RegExp, Date, Boolean and null
// Match regex operators like /foo_\d+/i
var regex = value.match(/^\/(.*)\/(i?)$/);
if (regex) {
return new RegExp(regex[1], regex[2]);
}
// Match boolean values
if (value === 'true') {
return true;
}
if (value === 'false') {
return false;
}
// Match null
if (value === 'null') {
return null;
}
// Match numbers (string padded with zeros are not numbers)
if (value !== '' && !isNaN(Number(value)) && !/^0[0-9]+/.test(value)) {
return Number(value);
}
// Match dates
var dt = utils_1.toDate(value, this.options.dateFormat);
if (dt.isValid) {
return dt.toJSDate();
}
return value;
};
MongooseQueryParser.prototype.excludeFilterKeys = function (obj, blacklist) {
for (var i in obj) {
if (!obj.hasOwnProperty(i)) {
continue;
}
if (typeof obj[i] == 'object') {
this.excludeFilterKeys(obj[i], blacklist);
}
else if (blacklist.indexOf(i) !== -1) {
delete obj[i];
}
}
return _.isArray(obj) ? _.remove(obj, function (el) { return _.isEmpty(el); }) : obj;
};
MongooseQueryParser.prototype.castFilter = function (filter, params) {
var _this = this;
var options = this.options;
var parsedFilter = filter ? this.parseFilter(filter) : {};
// filter out blacklisted keys in JSON filter query
var subsetParsedFilter = this.excludeFilterKeys(parsedFilter, options.blacklist);
return Object.keys(params)
.map(function (val) {
var join = params[val] ? val + "=" + params[val] : val;
// Separate key, operators and value
var _a = join.match(/(!?)([^><!=]+)([><]=?|!?=|)(.*)/), prefix = _a[1], key = _a[2], op = _a[3], value = _a[4];
return { prefix: prefix, key: key, op: _this.parseOperator(op), value: _this.parseValue(value, key) };
})
.filter(function (_a) {
var key = _a.key;
return options.blacklist.indexOf(key) === -1;
})
.reduce(function (result, _a) {
var prefix = _a.prefix, key = _a.key, op = _a.op, value = _a.value;
if (!result[key]) {
result[key] = {};
}
if (Array.isArray(value)) {
result[key][op === '$ne' ? '$nin' : '$in'] = value;
}
else if (op === '$exists') {
result[key][op] = prefix !== '!';
}
else if (op === '$eq') {
result[key] = value;
}
else if (op === '$ne' && typeof value === 'object') {
result[key].$not = value;
}
else {
result[key][op] = value;
}
return result;
}, subsetParsedFilter);
};
MongooseQueryParser.prototype.parseFilter = function (filter) {
try {
if (typeof filter === 'object') {
return filter;
}
return JSON.parse(filter);
}
catch (err) {
throw new Error("Invalid JSON string: " + filter);
}
};
MongooseQueryParser.prototype.parseOperator = function (operator) {
if (operator === '=') {
return '$eq';
}
else if (operator === '!=') {
return '$ne';
}
else if (operator === '>') {
return '$gt';
}
else if (operator === '>=') {
return '$gte';
}
else if (operator === '<') {
return '$lt';
}
else if (operator === '<=') {
return '$lte';
}
else if (!operator) {
return '$exists';
}
};
/**
* cast select query to object like:
* select=a,b or select=-a,-b
* =>
* {select: { a: 1, b: 1 }} or {select: { a: 0, b: 0 }}
* @param val
*/
MongooseQueryParser.prototype.castSelect = function (val) {
var fields = this.parseUnaries(val, { plus: 1, minus: 0 });
/*
From the MongoDB documentation:
"A projection cannot contain both include and exclude specifications, except for the exclusion of the _id field."
*/
var hasMixedValues = Object.keys(fields)
.reduce(function (set, key) {
if (key !== '_id') {
set.add(fields[key]);
}
return set;
}, new Set()).size > 1;
if (hasMixedValues) {
Object.keys(fields)
.forEach(function (key) {
if (fields[key] === 1) {
delete fields[key];
}
});
}
return fields;
};
/**
* cast populate query to object
* see mongoose doc here: https://mongoosejs.com/docs/populate.html#deep-populate
* considering the following schemas:
* Users => { _id, name:string, email:string, friends: [{ref: 'User'}] }
* Posts => { createdBy: {ref: 'User'}, likedBy: [{ref: 'User'}], contents: string, title: string }
* Example 1:
* populate=createdBy.name,createdBy.email,likedBy
* =>
* [{path: 'createdBy', select: 'name email'}, {path: 'likedBy'}]
* Example 2 (deep populate):
* populate=createdBy:friends.name,createdBy:friends.email,createdBy.name,createdBy.email
* =>
* [{path: 'createdBy', select: 'name email', populate: {path: 'friends', select: 'name email'}}]
* @param val
*/
MongooseQueryParser.prototype.castPopulate = function (val) {
var ls = val.split(',').map(function (s) { return s.split(':'); });
var populates = [];
var buildPopulate = function (prop, pObj) {
var _a;
var _b = prop.split('.', 2), path = _b[0], select = _b[1];
if (!pObj) {
pObj = populates.find(function (p) { return p.path == path; });
if (!pObj) {
// create new populate query object
pObj = { path: path };
populates.push(pObj);
}
}
else {
if (((_a = pObj.populate) === null || _a === void 0 ? void 0 : _a.path) !== path) {
// create deep populate
pObj.populate = { path: path };
}
pObj = pObj.populate;
}
if (select) {
pObj.select = pObj.select ? (pObj.select + ' ' + select) : select;
}
return pObj;
};
for (var _i = 0, ls_1 = ls; _i < ls_1.length; _i++) {
var s = ls_1[_i];
var pObj = undefined;
for (var _a = 0, s_1 = s; _a < s_1.length; _a++) {
var prop = s_1[_a];
pObj = buildPopulate(prop, pObj);
}
}
return populates;
};
/**
* cast sort query to object like
* sort=-a,b
* =>
* {sort: {a: -1, b: 1}}
* @param sort
*/
MongooseQueryParser.prototype.castSort = function (sort) {
return this.parseUnaries(sort);
};
/**
* Map/reduce helper to transform list of unaries
* like '+a,-b,c' to {a: 1, b: -1, c: 1}
*/
MongooseQueryParser.prototype.parseUnaries = function (unaries, values) {
if (values === void 0) { values = { plus: 1, minus: -1 }; }
var unariesAsArray = _.isString(unaries)
? unaries.split(',')
: unaries;
return unariesAsArray
.map(function (x) { return x.match(/^(\+|-)?(.*)/); })
.reduce(function (result, _a) {
var val = _a[1], key = _a[2];
result[key.trim()] = val === '-' ? values.minus : values.plus;
return result;
}, {});
};
/**
* cast skip query to object like
* skip=100
* =>
* {skip: 100}
* @param skip
*/
MongooseQueryParser.prototype.castSkip = function (skip) {
return Number(skip);
};
/**
* cast limit query to object like
* limit=10
* =>
* {limit: 10}
* @param limit
*/
MongooseQueryParser.prototype.castLimit = function (limit) {
return Number(limit);
};
/**
* transform predefined query strings defined in query string to the actual query object out of the given context
* @param query
* @param context
*/
MongooseQueryParser.prototype.parsePredefinedQuery = function (query, context) {
if (context) {
// check if given string is the format as predefined query i.e. ${query}
var _match_1 = function (str) {
var reg = /^\$\{([a-zA-Z_$][0-9a-zA-Z_$]*)\}$/;
var match = str.match(reg);
var val = undefined;
if (match) {
val = _.property(match[1])(context);
if (val === undefined) {
throw new Error("No predefined query found for the provided reference [" + match[1] + "]");
}
}
return { match: !!match, val: val };
};
var _transform_1 = function (obj) {
return _.reduce(obj, function (prev, curr, key) {
var _a, _b;
var val = undefined, match = undefined;
if (_.isString(key)) {
(_a = _match_1(key), match = _a.match, val = _a.val);
if (match) {
if (_.has(curr, '$exists')) {
// 1). as a key: {'${qry}': {$exits: true}} => {${qry object}}
return _.merge(prev, val);
}
else if (_.isString(val)) {
// 1). as a key: {'${qry}': 'something'} => {'${qry object}': 'something'}
key = val;
}
else {
throw new Error("Invalid query string at " + key);
}
}
}
if (_.isString(curr)) {
(_b = _match_1(curr), match = _b.match, val = _b.val);
if (match) {
_.isNumber(key)
? prev.push(val) // 3). as an item of array: ['${qry}', ...] => [${qry object}, ...]
: (prev[key] = val); // 2). as a value: {prop: '${qry}'} => {prop: ${qry object}}
return prev;
}
}
if (_.isObject(curr) && !_.isRegExp(curr) && !_.isDate(curr)) {
// iterate all props & keys recursively
_.isNumber(key) ? prev.push(_transform_1(curr)) : (prev[key] = _transform_1(curr));
}
else {
_.isNumber(key) ? prev.push(curr) : (prev[key] = curr);
}
return prev;
}, _.isArray(obj) ? [] : {});
};
return _transform_1(query);
}
return query;
};
return MongooseQueryParser;
}());
exports.MongooseQueryParser = MongooseQueryParser;
//# sourceMappingURL=index.js.map