mobdb
Version:
MarsDB is a lightweight client-side MongoDB-like database, Promise based, written in ES6
1,033 lines (952 loc) • 39.2 kB
JavaScript
import _check from 'check-types';
import _each from 'fast.js/forEach';
import _keys from 'fast.js/object/keys';
import _map from 'fast.js/map';
import _some from 'fast.js/array/some';
import _every from 'fast.js/array/every';
import _indexOf from 'fast.js/array/indexOf';
import GeoJSON from 'geojson-utils';
import EJSON from './EJSON';
import {selectorIsId, isArray, isPlainObject,
isIndexable, isOperatorObject, isNumericKey,
MongoTypeComp} from './Document';
// The minimongo selector compiler!
// Terminology:
// - a 'selector' is the EJSON object representing a selector
// - a 'matcher' is its compiled form (whether a full Minimongo.Matcher
// object or one of the component lambdas that matches parts of it)
// - a 'result object' is an object with a 'result' field and maybe
// distance and arrayIndices.
// - a 'branched value' is an object with a 'value' field and maybe
// 'dontIterate' and 'arrayIndices'.
// - a 'document' is a top-level object that can be stored in a collection.
// - a 'lookup function' is a function that takes in a document and returns
// an array of 'branched values'.
// - a 'branched matcher' maps from an array of branched values to a result
// object.
// - an 'element matcher' maps from a single value to a bool.
// Main entry point.
// var matcher = new Minimongo.Matcher({a: {$gt: 5}});
// if (matcher.documentMatches({a: 7})) ...
export class DocumentMatcher {
constructor(selector) {
// A set (object mapping string -> *) of all of the document paths looked
// at by the selector. Also includes the empty string if it may look at any
// path (eg, $where).
this._paths = {};
// Set to true if compilation finds a $near.
this._hasGeoQuery = false;
// Set to true if compilation finds a $where.
this._hasWhere = false;
// Set to false if compilation finds anything other than a simple equality or
// one or more of '$gt', '$gte', '$lt', '$lte', '$ne', '$in', '$nin' used with
// scalars as operands.
this._isSimple = true;
// Set to a dummy document which always matches this Matcher. Or set to null
// if such document is too hard to find.
this._matchingDocument = undefined;
// A clone of the original selector. It may just be a function if the user
// passed in a function; otherwise is definitely an object (eg, IDs are
// translated into {_id: ID} first. Used by canBecomeTrueByModifier and
// Sorter._useWithMatcher.
this._selector = null;
this._docMatcher = this._compileSelector(selector);
}
documentMatches(doc) {
if (!doc || typeof doc !== 'object') {
throw Error('documentMatches needs a document');
}
return this._docMatcher(doc);
}
get hasGeoQuery() {
return this._hasGeoQuery;
}
get hasWhere() {
return this._hasWhere;
}
get isSimple() {
return this._isSimple;
}
// Given a selector, return a function that takes one argument, a
// document. It returns a result object.
_compileSelector(selector) {
// you can pass a literal function instead of a selector
if (selector instanceof Function) {
this._isSimple = false;
this._selector = selector;
this._recordPathUsed('');
return function(doc) {
return {result: !!selector.call(doc)};
};
}
// shorthand -- scalars match _id
if (selectorIsId(selector)) {
this._selector = {_id: selector};
this._recordPathUsed('_id');
return function(doc) {
return {result: EJSON.equals(doc._id, selector)};
};
}
// protect against dangerous selectors. falsey and {_id: falsey} are both
// likely programmer error, and not what you want, particularly for
// destructive operations.
if (!selector || (('_id' in selector) && !selector._id)) {
this._isSimple = false;
return nothingMatcher;
}
// Top level can't be an array or true or binary.
if (
typeof(selector) === 'boolean' || isArray(selector) ||
EJSON.isBinary(selector)
) {
throw new Error('Invalid selector: ' + selector);
}
this._selector = EJSON.clone(selector);
return compileDocumentSelector(selector, this, {isRoot: true});
}
_recordPathUsed(path) {
this._paths[path] = true;
}
// Returns a list of key paths the given selector is looking for. It includes
// the empty string if there is a $where.
_getPaths() {
return _check.object(this._paths)
? _keys(this._paths) : null;
}
}
export default DocumentMatcher;
// Takes in a selector that could match a full document (eg, the original
// selector). Returns a function mapping document->result object.
//
// matcher is the Matcher object we are compiling.
//
// If this is the root document selector (ie, not wrapped in $and or the like),
// then isRoot is true. (This is used by $near.)
var compileDocumentSelector = function(docSelector, matcher, options = {}) {
var docMatchers = [];
_each(docSelector, function(subSelector, key) {
if (key.substr(0, 1) === '$') {
// Outer operators are either logical operators (they recurse back into
// this function), or $where.
if (!LOGICAL_OPERATORS.hasOwnProperty(key)) {
throw new Error('Unrecognized logical operator: ' + key);
}
matcher._isSimple = false;
docMatchers.push(LOGICAL_OPERATORS[key](
subSelector, matcher, options.inElemMatch
));
} else {
// Record this path, but only if we aren't in an elemMatcher, since in an
// elemMatch this is a path inside an object in an array, not in the doc
// root.
if (!options.inElemMatch) {
matcher._recordPathUsed(key);
}
var lookUpByIndex = makeLookupFunction(key);
var valueMatcher =
compileValueSelector(subSelector, matcher, options.isRoot);
docMatchers.push(function(doc) {
var branchValues = lookUpByIndex(doc);
return valueMatcher(branchValues);
});
}
});
return andDocumentMatchers(docMatchers);
};
// Takes in a selector that could match a key-indexed value in a document; eg,
// {$gt: 5, $lt: 9}, or a regular expression, or any non-expression object (to
// indicate equality). Returns a branched matcher: a function mapping
// [branched value]->result object.
var compileValueSelector = function(valueSelector, matcher, isRoot) {
if (valueSelector instanceof RegExp) {
matcher._isSimple = false;
return convertElementMatcherToBranchedMatcher(
regexpElementMatcher(valueSelector));
} else if (isOperatorObject(valueSelector)) {
return operatorBranchedMatcher(valueSelector, matcher, isRoot);
} else {
return convertElementMatcherToBranchedMatcher(
equalityElementMatcher(valueSelector));
}
};
// Given an element matcher (which evaluates a single value), returns a branched
// value (which evaluates the element matcher on all the branches and returns a
// more structured return value possibly including arrayIndices).
var convertElementMatcherToBranchedMatcher = function(
elementMatcher, options = {}) {
return function(branches) {
var expanded = branches;
if (!options.dontExpandLeafArrays) {
expanded = expandArraysInBranches(
branches, options.dontIncludeLeafArrays);
}
var ret = {};
ret.result = _some(expanded, function(element) {
var matched = elementMatcher(element.value);
// Special case for $elemMatch: it means 'true, and use this as an array
// index if I didn't already have one'.
if (typeof matched === 'number') {
// XXX This code dates from when we only stored a single array index
// (for the outermost array). Should we be also including deeper array
// indices from the $elemMatch match?
if (!element.arrayIndices) {
element.arrayIndices = [matched];
}
matched = true;
}
// If some element matched, and it's tagged with array indices, include
// those indices in our result object.
if (matched && element.arrayIndices) {
ret.arrayIndices = element.arrayIndices;
}
return matched;
});
return ret;
};
};
// Takes a RegExp object and returns an element matcher.
export function regexpElementMatcher(regexp) {
return function(value) {
if (value instanceof RegExp) {
// Comparing two regexps means seeing if the regexps are identical
// (really!). Underscore knows how.
return String(value) === String(regexp);
}
// Regexps only work against strings.
if (typeof value !== 'string') {
return false;
}
// Reset regexp's state to avoid inconsistent matching for objects with the
// same value on consecutive calls of regexp.test. This happens only if the
// regexp has the 'g' flag. Also note that ES6 introduces a new flag 'y' for
// which we should *not* change the lastIndex but MongoDB doesn't support
// either of these flags.
regexp.lastIndex = 0;
return regexp.test(value);
};
}
// Takes something that is not an operator object and returns an element matcher
// for equality with that thing.
export function equalityElementMatcher(elementSelector) {
if (isOperatorObject(elementSelector)) {
throw Error('Can\'t create equalityValueSelector for operator object');
}
// Special-case: null and undefined are equal (if you got undefined in there
// somewhere, or if you got it due to some branch being non-existent in the
// weird special case), even though they aren't with EJSON.equals.
if (elementSelector == null) { // undefined or null
return function(value) {
return value == null; // undefined or null
};
}
return function(value) {
return MongoTypeComp._equal(elementSelector, value);
};
}
// Takes an operator object (an object with $ keys) and returns a branched
// matcher for it.
var operatorBranchedMatcher = function(valueSelector, matcher, isRoot) {
// Each valueSelector works separately on the various branches. So one
// operator can match one branch and another can match another branch. This
// is OK.
var operatorMatchers = [];
_each(valueSelector, function(operand, operator) {
// XXX we should actually implement $eq, which is new in 2.6
var simpleRange = _indexOf(['$lt', '$lte', '$gt', '$gte'], operator) >= 0 &&
_check.number(operand);
var simpleInequality = operator === '$ne' && !_check.object(operand);
var simpleInclusion = _indexOf(['$in', '$nin'], operator) >= 0 &&
_check.array(operand) && !_some(operand, _check.object);
if (! (operator === '$eq' || simpleRange ||
simpleInclusion || simpleInequality)) {
matcher._isSimple = false;
}
if (VALUE_OPERATORS.hasOwnProperty(operator)) {
operatorMatchers.push(
VALUE_OPERATORS[operator](operand, valueSelector, matcher, isRoot));
} else if (ELEMENT_OPERATORS.hasOwnProperty(operator)) {
var options = ELEMENT_OPERATORS[operator];
operatorMatchers.push(
convertElementMatcherToBranchedMatcher(
options.compileElementSelector(
operand, valueSelector, matcher),
options));
} else {
throw new Error('Unrecognized operator: ' + operator);
}
});
return andBranchedMatchers(operatorMatchers);
};
var compileArrayOfDocumentSelectors = function(
selectors, matcher, inElemMatch) {
if (!isArray(selectors) || _check.emptyArray(selectors)) {
throw Error('$and/$or/$nor must be nonempty array');
}
return _map(selectors, function(subSelector) {
if (!isPlainObject(subSelector)) {
throw Error('$or/$and/$nor entries need to be full objects');
}
return compileDocumentSelector(
subSelector, matcher, {inElemMatch: inElemMatch});
});
};
// Operators that appear at the top level of a document selector.
var LOGICAL_OPERATORS = {
$and: function(subSelector, matcher, inElemMatch) {
var matchers = compileArrayOfDocumentSelectors(
subSelector, matcher, inElemMatch);
return andDocumentMatchers(matchers);
},
$or: function(subSelector, matcher, inElemMatch) {
var matchers = compileArrayOfDocumentSelectors(
subSelector, matcher, inElemMatch);
// Special case: if there is only one matcher, use it directly, *preserving*
// any arrayIndices it returns.
if (matchers.length === 1) {
return matchers[0];
}
return function(doc) {
var result = _some(matchers, function(f) {
return f(doc).result;
});
// $or does NOT set arrayIndices when it has multiple
// sub-expressions. (Tested against MongoDB.)
return {result: result};
};
},
$nor: function(subSelector, matcher, inElemMatch) {
var matchers = compileArrayOfDocumentSelectors(
subSelector, matcher, inElemMatch);
return function(doc) {
var result = _every(matchers, function(f) {
return !f(doc).result;
});
// Never set arrayIndices, because we only match if nothing in particular
// 'matched' (and because this is consistent with MongoDB).
return {result: result};
};
},
$where: function(selectorValue, matcher) {
// Record that *any* path may be used.
matcher._recordPathUsed('');
matcher._hasWhere = true;
if (!(selectorValue instanceof Function)) {
// XXX MongoDB seems to have more complex logic to decide where or or not
// to add 'return'; not sure exactly what it is.
selectorValue = Function('obj', 'return ' + selectorValue); //eslint-disable-line no-new-func
}
return function(doc) {
// We make the document available as both `this` and `obj`.
// XXX not sure what we should do if this throws
return {result: selectorValue.call(doc, doc)};
};
},
// This is just used as a comment in the query (in MongoDB, it also ends up in
// query logs); it has no effect on the actual selection.
$comment: function() {
return function() {
return {result: true};
};
},
};
// Returns a branched matcher that matches iff the given matcher does not.
// Note that this implicitly 'deMorganizes' the wrapped function. ie, it
// means that ALL branch values need to fail to match innerBranchedMatcher.
var invertBranchedMatcher = function(branchedMatcher) {
return function(branchValues) {
var invertMe = branchedMatcher(branchValues);
// We explicitly choose to strip arrayIndices here: it doesn't make sense to
// say 'update the array element that does not match something', at least
// in mongo-land.
return {result: !invertMe.result};
};
};
// Operators that (unlike LOGICAL_OPERATORS) pertain to individual paths in a
// document, but (unlike ELEMENT_OPERATORS) do not have a simple definition as
// 'match each branched value independently and combine with
// convertElementMatcherToBranchedMatcher'.
var VALUE_OPERATORS = {
$not: function(operand, valueSelector, matcher) {
return invertBranchedMatcher(compileValueSelector(operand, matcher));
},
$ne: function(operand) {
return invertBranchedMatcher(convertElementMatcherToBranchedMatcher(
equalityElementMatcher(operand)));
},
$nin: function(operand) {
return invertBranchedMatcher(convertElementMatcherToBranchedMatcher(
ELEMENT_OPERATORS.$in.compileElementSelector(operand)));
},
$exists: function(operand) {
var exists = convertElementMatcherToBranchedMatcher(function(value) {
return value !== undefined;
});
return operand ? exists : invertBranchedMatcher(exists);
},
// $options just provides options for $regex; its logic is inside $regex
$options: function(operand, valueSelector) {
if (
!_check.object(valueSelector) ||
!valueSelector.hasOwnProperty('$regex')
) {
throw Error('$options needs a $regex');
}
return everythingMatcher;
},
// $maxDistance is basically an argument to $near
$maxDistance: function(operand, valueSelector) {
if (!valueSelector.$near) {
throw Error('$maxDistance needs a $near');
}
return everythingMatcher;
},
$all: function(operand, valueSelector, matcher) {
if (!isArray(operand)) {
throw Error('$all requires array');
}
// Not sure why, but this seems to be what MongoDB does.
if (_check.emptyArray(operand)) {
return nothingMatcher;
}
var branchedMatchers = [];
_each(operand, function(criterion) {
// XXX handle $all/$elemMatch combination
if (isOperatorObject(criterion)) {
throw Error('no $ expressions in $all');
}
// This is always a regexp or equality selector.
branchedMatchers.push(compileValueSelector(criterion, matcher));
});
// andBranchedMatchers does NOT require all selectors to return true on the
// SAME branch.
return andBranchedMatchers(branchedMatchers);
},
$near: function(operand, valueSelector, matcher, isRoot) {
if (!isRoot) {
throw Error('$near can\'t be inside another $ operator');
}
matcher._hasGeoQuery = true;
// There are two kinds of geodata in MongoDB: coordinate pairs and
// GeoJSON. They use different distance metrics, too. GeoJSON queries are
// marked with a $geometry property.
var maxDistance, point, distance;
if (isPlainObject(operand) && operand.hasOwnProperty('$geometry')) {
// GeoJSON '2dsphere' mode.
maxDistance = operand.$maxDistance;
point = operand.$geometry;
distance = function(value) {
// XXX: for now, we don't calculate the actual distance between, say,
// polygon and circle. If people care about this use-case it will get
// a priority.
if (!value || !value.type) {
return null;
}
if (value.type === 'Point') {
return GeoJSON.pointDistance(point, value);
} else {
return GeoJSON.geometryWithinRadius(value, point, maxDistance)
? 0 : maxDistance + 1;
}
};
} else {
maxDistance = valueSelector.$maxDistance;
if (!isArray(operand) && !isPlainObject(operand)) {
throw Error('$near argument must be coordinate pair or GeoJSON');
}
point = pointToArray(operand);
distance = function(value) {
if (!isArray(value) && !isPlainObject(value)) {
return null;
}
return distanceCoordinatePairs(point, value);
};
}
return function(branchedValues) {
// There might be multiple points in the document that match the given
// field. Only one of them needs to be within $maxDistance, but we need to
// evaluate all of them and use the nearest one for the implicit sort
// specifier. (That's why we can't just use ELEMENT_OPERATORS here.)
//
// Note: This differs from MongoDB's implementation, where a document will
// actually show up *multiple times* in the result set, with one entry for
// each within-$maxDistance branching point.
branchedValues = expandArraysInBranches(branchedValues);
var result = {result: false};
_each(branchedValues, function(branch) {
var curDistance = distance(branch.value);
// Skip branches that aren't real points or are too far away.
if (curDistance === null || curDistance > maxDistance) {
return;
}
// Skip anything that's a tie.
if (result.distance !== undefined && result.distance <= curDistance) {
return;
}
result.result = true;
result.distance = curDistance;
if (!branch.arrayIndices) {
delete result.arrayIndices;
} else {
result.arrayIndices = branch.arrayIndices;
}
});
return result;
};
},
};
// Helpers for $near.
var distanceCoordinatePairs = function(a, b) {
a = pointToArray(a);
b = pointToArray(b);
var x = a[0] - b[0];
var y = a[1] - b[1];
if (!_check.number(x) || !_check.number(y)) {
return null;
}
return Math.sqrt(x * x + y * y);
};
// Makes sure we get 2 elements array and assume the first one to be x and
// the second one to y no matter what user passes.
// In case user passes { lon: x, lat: y } returns [x, y]
var pointToArray = function(point) {
return _map(point, x => x);
};
// Helper for $lt/$gt/$lte/$gte.
var makeInequality = function(cmpValueComparator) {
return {
compileElementSelector: function(operand) {
// Arrays never compare false with non-arrays for any inequality.
// XXX This was behavior we observed in pre-release MongoDB 2.5, but
// it seems to have been reverted.
// See https://jira.mongodb.org/browse/SERVER-11444
if (isArray(operand)) {
return function() {
return false;
};
}
// Special case: consider undefined and null the same (so true with
// $gte/$lte).
if (operand === undefined) {
operand = null;
}
var operandType = MongoTypeComp._type(operand);
return function(value) {
if (value === undefined) {
value = null;
}
// Comparisons are never true among things of different type (except
// null vs undefined).
if (MongoTypeComp._type(value) !== operandType) {
return false;
}
return cmpValueComparator(MongoTypeComp._cmp(value, operand));
};
},
};
};
// Each element selector contains:
// - compileElementSelector, a function with args:
// - operand - the 'right hand side' of the operator
// - valueSelector - the 'context' for the operator (so that $regex can find
// $options)
// - matcher - the Matcher this is going into (so that $elemMatch can compile
// more things)
// returning a function mapping a single value to bool.
// - dontExpandLeafArrays, a bool which prevents expandArraysInBranches from
// being called
// - dontIncludeLeafArrays, a bool which causes an argument to be passed to
// expandArraysInBranches if it is called
export var ELEMENT_OPERATORS = {
$lt: makeInequality(function(cmpValue) {
return cmpValue < 0;
}),
$gt: makeInequality(function(cmpValue) {
return cmpValue > 0;
}),
$lte: makeInequality(function(cmpValue) {
return cmpValue <= 0;
}),
$gte: makeInequality(function(cmpValue) {
return cmpValue >= 0;
}),
$mod: {
compileElementSelector: function(operand) {
if (!(isArray(operand) && operand.length === 2
&& typeof(operand[0]) === 'number'
&& typeof(operand[1]) === 'number')) {
throw Error('argument to $mod must be an array of two numbers');
}
// XXX could require to be ints or round or something
var divisor = operand[0];
var remainder = operand[1];
return function(value) {
return typeof value === 'number' && value % divisor === remainder;
};
},
},
$in: {
compileElementSelector: function(operand) {
if (!isArray(operand)) {
throw Error('$in needs an array');
}
var elementMatchers = [];
_each(operand, function(option) {
if (option instanceof RegExp) {
elementMatchers.push(regexpElementMatcher(option));
} else if (isOperatorObject(option)) {
throw Error('cannot nest $ under $in');
} else {
elementMatchers.push(equalityElementMatcher(option));
}
});
return function(value) {
// Allow {a: {$in: [null]}} to match when 'a' does not exist.
if (value === undefined) {
value = null;
}
return _some(elementMatchers, function(e) {
return e(value);
});
};
},
},
$size: {
// {a: [[5, 5]]} must match {a: {$size: 1}} but not {a: {$size: 2}}, so we
// don't want to consider the element [5,5] in the leaf array [[5,5]] as a
// possible value.
dontExpandLeafArrays: true,
compileElementSelector: function(operand) {
if (typeof operand === 'string') {
// Don't ask me why, but by experimentation, this seems to be what Mongo
// does.
operand = 0;
} else if (typeof operand !== 'number') {
throw Error('$size needs a number');
}
return function(value) {
return isArray(value) && value.length === operand;
};
},
},
$type: {
// {a: [5]} must not match {a: {$type: 4}} (4 means array), but it should
// match {a: {$type: 1}} (1 means number), and {a: [[5]]} must match {$a:
// {$type: 4}}. Thus, when we see a leaf array, we *should* expand it but
// should *not* include it itself.
dontIncludeLeafArrays: true,
compileElementSelector: function(operand) {
if (typeof operand !== 'number') {
throw Error('$type needs a number');
}
return function(value) {
return value !== undefined
&& MongoTypeComp._type(value) === operand;
};
},
},
$regex: {
compileElementSelector: function(operand, valueSelector) {
if (!(typeof operand === 'string' || operand instanceof RegExp)) {
throw Error('$regex has to be a string or RegExp');
}
var regexp;
if (valueSelector.$options !== undefined) {
// Options passed in $options (even the empty string) always overrides
// options in the RegExp object itself. (See also
// Mongo.Collection._rewriteSelector.)
// Be clear that we only support the JS-supported options, not extended
// ones (eg, Mongo supports x and s). Ideally we would implement x and s
// by transforming the regexp, but not today...
if (/[^gim]/.test(valueSelector.$options)) {
throw new Error('Only the i, m, and g regexp options are supported');
}
var regexSource = operand instanceof RegExp ? operand.source : operand;
regexp = new RegExp(regexSource, valueSelector.$options);
} else if (operand instanceof RegExp) {
regexp = operand;
} else {
regexp = new RegExp(operand);
}
return regexpElementMatcher(regexp);
},
},
$elemMatch: {
dontExpandLeafArrays: true,
compileElementSelector: function(operand, valueSelector, matcher) {
if (!isPlainObject(operand)) {
throw Error('$elemMatch need an object');
}
var subMatcher, isDocMatcher;
if (isOperatorObject(operand, true)) {
subMatcher = compileValueSelector(operand, matcher);
isDocMatcher = false;
} else {
// This is NOT the same as compileValueSelector(operand), and not just
// because of the slightly different calling convention.
// {$elemMatch: {x: 3}} means 'an element has a field x:3', not
// 'consists only of a field x:3'. Also, regexps and sub-$ are allowed.
subMatcher = compileDocumentSelector(operand, matcher,
{inElemMatch: true});
isDocMatcher = true;
}
return function(value) {
if (!isArray(value)) {
return false;
}
for (var i = 0; i < value.length; ++i) {
var arrayElement = value[i];
var arg;
if (isDocMatcher) {
// We can only match {$elemMatch: {b: 3}} against objects.
// (We can also match against arrays, if there's numeric indices,
// eg {$elemMatch: {'0.b': 3}} or {$elemMatch: {0: 3}}.)
if (!isPlainObject(arrayElement) && !isArray(arrayElement)) {
return false;
}
arg = arrayElement;
} else {
// dontIterate ensures that {a: {$elemMatch: {$gt: 5}}} matches
// {a: [8]} but not {a: [[8]]}
arg = [{value: arrayElement, dontIterate: true}];
}
// XXX support $near in $elemMatch by propagating $distance?
if (subMatcher(arg).result) {
return i; // specially understood to mean 'use as arrayIndices'
}
}
return false;
};
},
},
};
// makeLookupFunction(key) returns a lookup function.
//
// A lookup function takes in a document and returns an array of matching
// branches. If no arrays are found while looking up the key, this array will
// have exactly one branches (possibly 'undefined', if some segment of the key
// was not found).
//
// If arrays are found in the middle, this can have more than one element, since
// we 'branch'. When we 'branch', if there are more key segments to look up,
// then we only pursue branches that are plain objects (not arrays or scalars).
// This means we can actually end up with no branches!
//
// We do *NOT* branch on arrays that are found at the end (ie, at the last
// dotted member of the key). We just return that array; if you want to
// effectively 'branch' over the array's values, post-process the lookup
// function with expandArraysInBranches.
//
// Each branch is an object with keys:
// - value: the value at the branch
// - dontIterate: an optional bool; if true, it means that 'value' is an array
// that expandArraysInBranches should NOT expand. This specifically happens
// when there is a numeric index in the key, and ensures the
// perhaps-surprising MongoDB behavior where {'a.0': 5} does NOT
// match {a: [[5]]}.
// - arrayIndices: if any array indexing was done during lookup (either due to
// explicit numeric indices or implicit branching), this will be an array of
// the array indices used, from outermost to innermost; it is falsey or
// absent if no array index is used. If an explicit numeric index is used,
// the index will be followed in arrayIndices by the string 'x'.
//
// Note: arrayIndices is used for two purposes. First, it is used to
// implement the '$' modifier feature, which only ever looks at its first
// element.
//
// Second, it is used for sort key generation, which needs to be able to tell
// the difference between different paths. Moreover, it needs to
// differentiate between explicit and implicit branching, which is why
// there's the somewhat hacky 'x' entry: this means that explicit and
// implicit array lookups will have different full arrayIndices paths. (That
// code only requires that different paths have different arrayIndices; it
// doesn't actually 'parse' arrayIndices. As an alternative, arrayIndices
// could contain objects with flags like 'implicit', but I think that only
// makes the code surrounding them more complex.)
//
// (By the way, this field ends up getting passed around a lot without
// cloning, so never mutate any arrayIndices field/var in this package!)
//
//
// At the top level, you may only pass in a plain object or array.
//
// See the test 'minimongo - lookup' for some examples of what lookup functions
// return.
export function makeLookupFunction(key, options) {
options = options || {};
var parts = key.split('.');
var firstPart = parts.length ? parts[0] : '';
var firstPartIsNumeric = isNumericKey(firstPart);
var nextPartIsNumeric = parts.length >= 2 && isNumericKey(parts[1]);
var lookupRest;
if (parts.length > 1) {
lookupRest = makeLookupFunction(parts.slice(1).join('.'));
}
var omitUnnecessaryFields = function(retVal) {
if (!retVal.dontIterate) {
delete retVal.dontIterate;
}
if (retVal.arrayIndices && !retVal.arrayIndices.length) {
delete retVal.arrayIndices;
}
return retVal;
};
// Doc will always be a plain object or an array.
// apply an explicit numeric index, an array.
return function(doc, arrayIndices) {
if (!arrayIndices) {
arrayIndices = [];
}
if (isArray(doc)) {
// If we're being asked to do an invalid lookup into an array (non-integer
// or out-of-bounds), return no results (which is different from returning
// a single undefined result, in that `null` equality checks won't match).
if (!(firstPartIsNumeric && firstPart < doc.length)) {
return [];
}
// Remember that we used this array index. Include an 'x' to indicate that
// the previous index came from being considered as an explicit array
// index (not branching).
arrayIndices = arrayIndices.concat(+firstPart, 'x');
}
// Do our first lookup.
var firstLevel = doc[firstPart];
// If there is no deeper to dig, return what we found.
//
// If what we found is an array, most value selectors will choose to treat
// the elements of the array as matchable values in their own right, but
// that's done outside of the lookup function. (Exceptions to this are $size
// and stuff relating to $elemMatch. eg, {a: {$size: 2}} does not match {a:
// [[1, 2]]}.)
//
// That said, if we just did an *explicit* array lookup (on doc) to find
// firstLevel, and firstLevel is an array too, we do NOT want value
// selectors to iterate over it. eg, {'a.0': 5} does not match {a: [[5]]}.
// So in that case, we mark the return value as 'don't iterate'.
if (!lookupRest) {
return [omitUnnecessaryFields({
value: firstLevel,
dontIterate: isArray(doc) && isArray(firstLevel),
arrayIndices: arrayIndices})];
}
// We need to dig deeper. But if we can't, because what we've found is not
// an array or plain object, we're done. If we just did a numeric index into
// an array, we return nothing here (this is a change in Mongo 2.5 from
// Mongo 2.4, where {'a.0.b': null} stopped matching {a: [5]}). Otherwise,
// return a single `undefined` (which can, for example, match via equality
// with `null`).
if (!isIndexable(firstLevel)) {
if (isArray(doc)) {
return [];
}
return [omitUnnecessaryFields({
value: undefined,
arrayIndices: arrayIndices,
})];
}
var result = [];
var appendToResult = function(more) {
Array.prototype.push.apply(result, more);
};
// Dig deeper: look up the rest of the parts on whatever we've found.
// (lookupRest is smart enough to not try to do invalid lookups into
// firstLevel if it's an array.)
appendToResult(lookupRest(firstLevel, arrayIndices));
// If we found an array, then in *addition* to potentially treating the next
// part as a literal integer lookup, we should also 'branch': try to look up
// the rest of the parts on each array element in parallel.
//
// In this case, we *only* dig deeper into array elements that are plain
// objects. (Recall that we only got this far if we have further to dig.)
// This makes sense: we certainly don't dig deeper into non-indexable
// objects. And it would be weird to dig into an array: it's simpler to have
// a rule that explicit integer indexes only apply to an outer array, not to
// an array you find after a branching search.
//
// In the special case of a numeric part in a *sort selector* (not a query
// selector), we skip the branching: we ONLY allow the numeric part to mean
// 'look up this index' in that case, not 'also look up this index in all
// the elements of the array'.
if (isArray(firstLevel) && !(nextPartIsNumeric && options.forSort)) {
_each(firstLevel, function(branch, arrayIndex) {
if (isPlainObject(branch)) {
appendToResult(lookupRest(
branch,
arrayIndices.concat(arrayIndex)));
}
});
}
return result;
};
}
export function expandArraysInBranches(branches, skipTheArrays) {
var branchesOut = [];
_each(branches, function(branch) {
var thisIsArray = isArray(branch.value);
// We include the branch itself, *UNLESS* we it's an array that we're going
// to iterate and we're told to skip arrays. (That's right, we include some
// arrays even skipTheArrays is true: these are arrays that were found via
// explicit numerical indices.)
if (!(skipTheArrays && thisIsArray && !branch.dontIterate)) {
branchesOut.push({
value: branch.value,
arrayIndices: branch.arrayIndices,
});
}
if (thisIsArray && !branch.dontIterate) {
_each(branch.value, function(leaf, i) {
branchesOut.push({
value: leaf,
arrayIndices: (branch.arrayIndices || []).concat(i),
});
});
}
});
return branchesOut;
}
var nothingMatcher = function(docOrBranchedValues) {
return {result: false};
};
var everythingMatcher = function(docOrBranchedValues) {
return {result: true};
};
// NB: We are cheating and using this function to implement 'AND' for both
// 'document matchers' and 'branched matchers'. They both return result objects
// but the argument is different: for the former it's a whole doc, whereas for
// the latter it's an array of 'branched values'.
var andSomeMatchers = function(subMatchers) {
if (subMatchers.length === 0) {
return everythingMatcher;
}
if (subMatchers.length === 1) {
return subMatchers[0];
}
return function(docOrBranches) {
var ret = {};
ret.result = _every(subMatchers, function(f) {
var subResult = f(docOrBranches);
// Copy a 'distance' number out of the first sub-matcher that has
// one. Yes, this means that if there are multiple $near fields in a
// query, something arbitrary happens; this appears to be consistent with
// Mongo.
if (subResult.result && subResult.distance !== undefined
&& ret.distance === undefined) {
ret.distance = subResult.distance;
}
// Similarly, propagate arrayIndices from sub-matchers... but to match
// MongoDB behavior, this time the *last* sub-matcher with arrayIndices
// wins.
if (subResult.result && subResult.arrayIndices) {
ret.arrayIndices = subResult.arrayIndices;
}
return subResult.result;
});
// If we didn't actually match, forget any extra metadata we came up with.
if (!ret.result) {
delete ret.distance;
delete ret.arrayIndices;
}
return ret;
};
};
var andDocumentMatchers = andSomeMatchers;
var andBranchedMatchers = andSomeMatchers;