UNPKG

pouchdb-selector-core

Version:

PouchDB's core selector code

689 lines (590 loc) 20.8 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var pouchdbUtils = require('pouchdb-utils'); var pouchdbCollate = require('pouchdb-collate'); // this would just be "return doc[field]", but fields // can be "deep" due to dot notation function getFieldFromDoc(doc, parsedField) { var value = doc; for (var i = 0, len = parsedField.length; i < len; i++) { var key = parsedField[i]; value = value[key]; if (!value) { break; } } return value; } function setFieldInDoc(doc, parsedField, value) { for (var i = 0, len = parsedField.length; i < len-1; i++) { var elem = parsedField[i]; doc = doc[elem] = doc[elem] || {}; } doc[parsedField[len-1]] = value; } function compare(left, right) { return left < right ? -1 : left > right ? 1 : 0; } // Converts a string in dot notation to an array of its components, with backslash escaping function parseField(fieldName) { // fields may be deep (e.g. "foo.bar.baz"), so parse var fields = []; var current = ''; for (var i = 0, len = fieldName.length; i < len; i++) { var ch = fieldName[i]; if (i > 0 && fieldName[i - 1] === '\\' && (ch === '$' || ch === '.')) { // escaped delimiter current = current.substring(0, current.length - 1) + ch; } else if (ch === '.') { // When `.` is not escaped (above), it is a field delimiter fields.push(current); current = ''; } else { // normal character current += ch; } } fields.push(current); return fields; } var combinationFields = ['$or', '$nor', '$not']; function isCombinationalField(field) { return combinationFields.indexOf(field) > -1; } function getKey(obj) { return Object.keys(obj)[0]; } function getValue(obj) { return obj[getKey(obj)]; } // flatten an array of selectors joined by an $and operator function mergeAndedSelectors(selectors) { // sort to ensure that e.g. if the user specified // $and: [{$gt: 'a'}, {$gt: 'b'}], then it's collapsed into // just {$gt: 'b'} var res = {}; var first = {$or: true, $nor: true}; selectors.forEach(function (selector) { Object.keys(selector).forEach(function (field) { var matcher = selector[field]; if (typeof matcher !== 'object') { matcher = {$eq: matcher}; } if (isCombinationalField(field)) { // or, nor if (matcher instanceof Array) { if (first[field]) { first[field] = false; res[field] = matcher; return; } var entries = []; res[field].forEach(function (existing) { Object.keys(matcher).forEach(function (key) { var m = matcher[key]; var longest = Math.max(Object.keys(existing).length, Object.keys(m).length); var merged = mergeAndedSelectors([existing, m]); if (Object.keys(merged).length <= longest) { // we have a situation like: (a :{$eq :1} || ...) && (a {$eq: 2} || ...) // merging would produce a $eq 2 when actually we shouldn't ever match against these merged conditions // merged should always contain more values to be valid return; } entries.push(merged); }); }); res[field] = entries; } else { // not res[field] = mergeAndedSelectors([matcher]); } } else { var fieldMatchers = res[field] = res[field] || {}; Object.keys(matcher).forEach(function (operator) { var value = matcher[operator]; if (operator === '$gt' || operator === '$gte') { return mergeGtGte(operator, value, fieldMatchers); } else if (operator === '$lt' || operator === '$lte') { return mergeLtLte(operator, value, fieldMatchers); } else if (operator === '$ne') { return mergeNe(value, fieldMatchers); } else if (operator === '$eq') { return mergeEq(value, fieldMatchers); } else if (operator === "$regex") { return mergeRegex(value, fieldMatchers); } fieldMatchers[operator] = value; }); } }); }); return res; } // collapse logically equivalent gt/gte values function mergeGtGte(operator, value, fieldMatchers) { if (typeof fieldMatchers.$eq !== 'undefined') { return; // do nothing } if (typeof fieldMatchers.$gte !== 'undefined') { if (operator === '$gte') { if (value > fieldMatchers.$gte) { // more specificity fieldMatchers.$gte = value; } } else { // operator === '$gt' if (value >= fieldMatchers.$gte) { // more specificity delete fieldMatchers.$gte; fieldMatchers.$gt = value; } } } else if (typeof fieldMatchers.$gt !== 'undefined') { if (operator === '$gte') { if (value > fieldMatchers.$gt) { // more specificity delete fieldMatchers.$gt; fieldMatchers.$gte = value; } } else { // operator === '$gt' if (value > fieldMatchers.$gt) { // more specificity fieldMatchers.$gt = value; } } } else { fieldMatchers[operator] = value; } } // collapse logically equivalent lt/lte values function mergeLtLte(operator, value, fieldMatchers) { if (typeof fieldMatchers.$eq !== 'undefined') { return; // do nothing } if (typeof fieldMatchers.$lte !== 'undefined') { if (operator === '$lte') { if (value < fieldMatchers.$lte) { // more specificity fieldMatchers.$lte = value; } } else { // operator === '$gt' if (value <= fieldMatchers.$lte) { // more specificity delete fieldMatchers.$lte; fieldMatchers.$lt = value; } } } else if (typeof fieldMatchers.$lt !== 'undefined') { if (operator === '$lte') { if (value < fieldMatchers.$lt) { // more specificity delete fieldMatchers.$lt; fieldMatchers.$lte = value; } } else { // operator === '$gt' if (value < fieldMatchers.$lt) { // more specificity fieldMatchers.$lt = value; } } } else { fieldMatchers[operator] = value; } } // combine $ne values into one array function mergeNe(value, fieldMatchers) { if ('$ne' in fieldMatchers) { // there are many things this could "not" be fieldMatchers.$ne.push(value); } else { // doesn't exist yet fieldMatchers.$ne = [value]; } } // add $eq into the mix function mergeEq(value, fieldMatchers) { // these all have less specificity than the $eq // TODO: check for user errors here delete fieldMatchers.$gt; delete fieldMatchers.$gte; delete fieldMatchers.$lt; delete fieldMatchers.$lte; delete fieldMatchers.$ne; fieldMatchers.$eq = value; } // combine $regex values into one array function mergeRegex(value, fieldMatchers) { if ('$regex' in fieldMatchers) { // a value could match multiple regexes fieldMatchers.$regex.push(value); } else { // doesn't exist yet fieldMatchers.$regex = [value]; } } //#7458: execute function mergeAndedSelectors on nested $and function mergeAndedSelectorsNested(obj) { for (var prop in obj) { if (Array.isArray(obj)) { for (var i in obj) { if (obj[i]['$and']) { obj[i] = mergeAndedSelectors(obj[i]['$and']); } } } var value = obj[prop]; if (typeof value === 'object') { mergeAndedSelectorsNested(value); // <- recursive call } } return obj; } //#7458: determine id $and is present in selector (at any level) function isAndInSelector(obj, isAnd) { for (var prop in obj) { if (prop === '$and') { isAnd = true; } var value = obj[prop]; if (typeof value === 'object') { isAnd = isAndInSelector(value, isAnd); // <- recursive call } } return isAnd; } // // normalize the selector // function massageSelector(input) { var result = pouchdbUtils.clone(input); //#7458: if $and is present in selector (at any level) merge nested $and if (isAndInSelector(result, false)) { result = mergeAndedSelectorsNested(result); if ('$and' in result) { result = mergeAndedSelectors(result['$and']); } } ['$or', '$nor'].forEach(function (orOrNor) { if (orOrNor in result) { // message each individual selector // e.g. {foo: 'bar'} becomes {foo: {$eq: 'bar'}} result[orOrNor].forEach(function (subSelector) { var fields = Object.keys(subSelector); for (var i = 0; i < fields.length; i++) { var field = fields[i]; var matcher = subSelector[field]; if (typeof matcher !== 'object' || matcher === null) { subSelector[field] = {$eq: matcher}; } } }); } }); if ('$not' in result) { //This feels a little like forcing, but it will work for now, //I would like to come back to this and make the merging of selectors a little more generic result['$not'] = mergeAndedSelectors([result['$not']]); } var fields = Object.keys(result); for (var i = 0; i < fields.length; i++) { var field = fields[i]; var matcher = result[field]; if (typeof matcher !== 'object' || matcher === null) { matcher = {$eq: matcher}; } result[field] = matcher; } normalizeArrayOperators(result); return result; } // // The $ne and $regex values must be placed in an array because these operators can be used multiple times on the same field. // When $and is used, mergeAndedSelectors takes care of putting some of them into arrays, otherwise it's done here. // function normalizeArrayOperators(selector) { Object.keys(selector).forEach(function (field) { var matcher = selector[field]; if (Array.isArray(matcher)) { matcher.forEach(function (matcherItem) { if (matcherItem && typeof matcherItem === 'object') { normalizeArrayOperators(matcherItem); } }); } else if (field === '$ne') { selector.$ne = [matcher]; } else if (field === '$regex') { selector.$regex = [matcher]; } else if (matcher && typeof matcher === 'object') { normalizeArrayOperators(matcher); } }); } // create a comparator based on the sort object function createFieldSorter(sort) { function getFieldValuesAsArray(doc) { return sort.map(function (sorting) { var fieldName = getKey(sorting); var parsedField = parseField(fieldName); var docFieldValue = getFieldFromDoc(doc, parsedField); return docFieldValue; }); } return function (aRow, bRow) { var aFieldValues = getFieldValuesAsArray(aRow.doc); var bFieldValues = getFieldValuesAsArray(bRow.doc); var collation = pouchdbCollate.collate(aFieldValues, bFieldValues); if (collation !== 0) { return collation; } // this is what mango seems to do return compare(aRow.doc._id, bRow.doc._id); }; } function filterInMemoryFields(rows, requestDef, inMemoryFields) { rows = rows.filter(function (row) { return rowFilter(row.doc, requestDef.selector, inMemoryFields); }); if (requestDef.sort) { // in-memory sort var fieldSorter = createFieldSorter(requestDef.sort); rows = rows.sort(fieldSorter); if (typeof requestDef.sort[0] !== 'string' && getValue(requestDef.sort[0]) === 'desc') { rows = rows.reverse(); } } if ('limit' in requestDef || 'skip' in requestDef) { // have to do the limit in-memory var skip = requestDef.skip || 0; var limit = ('limit' in requestDef ? requestDef.limit : rows.length) + skip; rows = rows.slice(skip, limit); } return rows; } function rowFilter(doc, selector, inMemoryFields) { return inMemoryFields.every(function (field) { var matcher = selector[field]; var parsedField = parseField(field); var docFieldValue = getFieldFromDoc(doc, parsedField); if (isCombinationalField(field)) { return matchCominationalSelector(field, matcher, doc); } return matchSelector(matcher, doc, parsedField, docFieldValue); }); } function matchSelector(matcher, doc, parsedField, docFieldValue) { if (!matcher) { // no filtering necessary; this field is just needed for sorting return true; } // is matcher an object, if so continue recursion if (typeof matcher === 'object') { return Object.keys(matcher).every(function (maybeUserOperator) { var userValue = matcher[ maybeUserOperator ]; // explicit operator if (maybeUserOperator.indexOf("$") === 0) { return match(maybeUserOperator, doc, userValue, parsedField, docFieldValue); } else { var subParsedField = parseField(maybeUserOperator); if ( docFieldValue === undefined && typeof userValue !== "object" && subParsedField.length > 0 ) { // the field does not exist, return or getFieldFromDoc will throw return false; } var subDocFieldValue = getFieldFromDoc(docFieldValue, subParsedField); if (typeof userValue === "object") { // field value is an object that might contain more operators return matchSelector(userValue, doc, parsedField, subDocFieldValue); } // implicit operator return match("$eq", doc, userValue, subParsedField, subDocFieldValue); } }); } // no more depth, No need to recurse further return matcher === docFieldValue; } function matchCominationalSelector(field, matcher, doc) { if (field === '$or') { return matcher.some(function (orMatchers) { return rowFilter(doc, orMatchers, Object.keys(orMatchers)); }); } if (field === '$not') { return !rowFilter(doc, matcher, Object.keys(matcher)); } //`$nor` return !matcher.find(function (orMatchers) { return rowFilter(doc, orMatchers, Object.keys(orMatchers)); }); } function match(userOperator, doc, userValue, parsedField, docFieldValue) { if (!matchers[userOperator]) { /* istanbul ignore next */ throw new Error('unknown operator "' + userOperator + '" - should be one of $eq, $lte, $lt, $gt, $gte, $exists, $ne, $in, ' + '$nin, $size, $mod, $regex, $elemMatch, $type, $allMatch or $all'); } return matchers[userOperator](doc, userValue, parsedField, docFieldValue); } function fieldExists(docFieldValue) { return typeof docFieldValue !== 'undefined' && docFieldValue !== null; } function fieldIsNotUndefined(docFieldValue) { return typeof docFieldValue !== 'undefined'; } function modField(docFieldValue, userValue) { if (typeof docFieldValue !== "number" || parseInt(docFieldValue, 10) !== docFieldValue) { return false; } var divisor = userValue[0]; var mod = userValue[1]; return docFieldValue % divisor === mod; } function arrayContainsValue(docFieldValue, userValue) { return userValue.some(function (val) { if (docFieldValue instanceof Array) { return docFieldValue.some(function (docFieldValueItem) { return pouchdbCollate.collate(val, docFieldValueItem) === 0; }); } return pouchdbCollate.collate(val, docFieldValue) === 0; }); } function arrayContainsAllValues(docFieldValue, userValue) { return userValue.every(function (val) { return docFieldValue.some(function (docFieldValueItem) { return pouchdbCollate.collate(val, docFieldValueItem) === 0; }); }); } function arraySize(docFieldValue, userValue) { return docFieldValue.length === userValue; } function regexMatch(docFieldValue, userValue) { var re = new RegExp(userValue); return re.test(docFieldValue); } function typeMatch(docFieldValue, userValue) { switch (userValue) { case 'null': return docFieldValue === null; case 'boolean': return typeof (docFieldValue) === 'boolean'; case 'number': return typeof (docFieldValue) === 'number'; case 'string': return typeof (docFieldValue) === 'string'; case 'array': return docFieldValue instanceof Array; case 'object': return ({}).toString.call(docFieldValue) === '[object Object]'; } } var matchers = { '$elemMatch': function (doc, userValue, parsedField, docFieldValue) { if (!Array.isArray(docFieldValue)) { return false; } if (docFieldValue.length === 0) { return false; } if (typeof docFieldValue[0] === 'object' && docFieldValue[0] !== null) { return docFieldValue.some(function (val) { return rowFilter(val, userValue, Object.keys(userValue)); }); } return docFieldValue.some(function (val) { return matchSelector(userValue, doc, parsedField, val); }); }, '$allMatch': function (doc, userValue, parsedField, docFieldValue) { if (!Array.isArray(docFieldValue)) { return false; } /* istanbul ignore next */ if (docFieldValue.length === 0) { return false; } if (typeof docFieldValue[0] === 'object' && docFieldValue[0] !== null) { return docFieldValue.every(function (val) { return rowFilter(val, userValue, Object.keys(userValue)); }); } return docFieldValue.every(function (val) { return matchSelector(userValue, doc, parsedField, val); }); }, '$eq': function (doc, userValue, parsedField, docFieldValue) { return fieldIsNotUndefined(docFieldValue) && pouchdbCollate.collate(docFieldValue, userValue) === 0; }, '$gte': function (doc, userValue, parsedField, docFieldValue) { return fieldIsNotUndefined(docFieldValue) && pouchdbCollate.collate(docFieldValue, userValue) >= 0; }, '$gt': function (doc, userValue, parsedField, docFieldValue) { return fieldIsNotUndefined(docFieldValue) && pouchdbCollate.collate(docFieldValue, userValue) > 0; }, '$lte': function (doc, userValue, parsedField, docFieldValue) { return fieldIsNotUndefined(docFieldValue) && pouchdbCollate.collate(docFieldValue, userValue) <= 0; }, '$lt': function (doc, userValue, parsedField, docFieldValue) { return fieldIsNotUndefined(docFieldValue) && pouchdbCollate.collate(docFieldValue, userValue) < 0; }, '$exists': function (doc, userValue, parsedField, docFieldValue) { //a field that is null is still considered to exist if (userValue) { return fieldIsNotUndefined(docFieldValue); } return !fieldIsNotUndefined(docFieldValue); }, '$mod': function (doc, userValue, parsedField, docFieldValue) { return fieldExists(docFieldValue) && modField(docFieldValue, userValue); }, '$ne': function (doc, userValue, parsedField, docFieldValue) { return userValue.every(function (neValue) { return pouchdbCollate.collate(docFieldValue, neValue) !== 0; }); }, '$in': function (doc, userValue, parsedField, docFieldValue) { return fieldExists(docFieldValue) && arrayContainsValue(docFieldValue, userValue); }, '$nin': function (doc, userValue, parsedField, docFieldValue) { return fieldExists(docFieldValue) && !arrayContainsValue(docFieldValue, userValue); }, '$size': function (doc, userValue, parsedField, docFieldValue) { return fieldExists(docFieldValue) && Array.isArray(docFieldValue) && arraySize(docFieldValue, userValue); }, '$all': function (doc, userValue, parsedField, docFieldValue) { return Array.isArray(docFieldValue) && arrayContainsAllValues(docFieldValue, userValue); }, '$regex': function (doc, userValue, parsedField, docFieldValue) { return fieldExists(docFieldValue) && typeof docFieldValue == "string" && userValue.every(function (regexValue) { return regexMatch(docFieldValue, regexValue); }); }, '$type': function (doc, userValue, parsedField, docFieldValue) { return typeMatch(docFieldValue, userValue); } }; // return true if the given doc matches the supplied selector function matchesSelector(doc, selector) { /* istanbul ignore if */ if (typeof selector !== 'object') { // match the CouchDB error message throw new Error('Selector error: expected a JSON object'); } selector = massageSelector(selector); var row = { doc }; var rowsMatched = filterInMemoryFields([row], { selector }, Object.keys(selector)); return rowsMatched && rowsMatched.length === 1; } exports.massageSelector = massageSelector; exports.matchesSelector = matchesSelector; exports.filterInMemoryFields = filterInMemoryFields; exports.createFieldSorter = createFieldSorter; exports.rowFilter = rowFilter; exports.isCombinationalField = isCombinationalField; exports.getKey = getKey; exports.getValue = getValue; exports.getFieldFromDoc = getFieldFromDoc; exports.setFieldInDoc = setFieldInDoc; exports.compare = compare; exports.parseField = parseField;