pouchdb-selector-core
Version:
PouchDB's core selector code
689 lines (590 loc) • 20.8 kB
JavaScript
;
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;