mirror of
https://github.com/meteor/meteor.git
synced 2026-05-02 03:01:46 -04:00
Fixes #2817. Regular Expressions in JavaScript keep the state when matching for the same string. When there are two documents with the same value of the matching field, the minimongo can incorrectly filter collection because regexp would return different values on different calls.
1139 lines
41 KiB
JavaScript
1139 lines
41 KiB
JavaScript
// 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})) ...
|
|
Minimongo.Matcher = function (selector) {
|
|
var self = this;
|
|
// 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).
|
|
self._paths = {};
|
|
// Set to true if compilation finds a $near.
|
|
self._hasGeoQuery = false;
|
|
// Set to true if compilation finds a $where.
|
|
self._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.
|
|
self._isSimple = true;
|
|
// Set to a dummy document which always matches this Matcher. Or set to null
|
|
// if such document is too hard to find.
|
|
self._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.
|
|
self._selector = null;
|
|
self._docMatcher = self._compileSelector(selector);
|
|
};
|
|
|
|
_.extend(Minimongo.Matcher.prototype, {
|
|
documentMatches: function (doc) {
|
|
if (!doc || typeof doc !== "object") {
|
|
throw Error("documentMatches needs a document");
|
|
}
|
|
return this._docMatcher(doc);
|
|
},
|
|
hasGeoQuery: function () {
|
|
return this._hasGeoQuery;
|
|
},
|
|
hasWhere: function () {
|
|
return this._hasWhere;
|
|
},
|
|
isSimple: function () {
|
|
return this._isSimple;
|
|
},
|
|
|
|
// Given a selector, return a function that takes one argument, a
|
|
// document. It returns a result object.
|
|
_compileSelector: function (selector) {
|
|
var self = this;
|
|
// you can pass a literal function instead of a selector
|
|
if (selector instanceof Function) {
|
|
self._isSimple = false;
|
|
self._selector = selector;
|
|
self._recordPathUsed('');
|
|
return function (doc) {
|
|
return {result: !!selector.call(doc)};
|
|
};
|
|
}
|
|
|
|
// shorthand -- scalars match _id
|
|
if (LocalCollection._selectorIsId(selector)) {
|
|
self._selector = {_id: selector};
|
|
self._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)) {
|
|
self._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);
|
|
|
|
self._selector = EJSON.clone(selector);
|
|
return compileDocumentSelector(selector, self, {isRoot: true});
|
|
},
|
|
_recordPathUsed: function (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: function () {
|
|
return _.keys(this._paths);
|
|
}
|
|
});
|
|
|
|
|
|
// 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) {
|
|
options = 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 (!_.has(LOGICAL_OPERATORS, 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) {
|
|
options = options || {};
|
|
return function (branches) {
|
|
var expanded = branches;
|
|
if (!options.dontExpandLeafArrays) {
|
|
expanded = expandArraysInBranches(
|
|
branches, options.dontIncludeLeafArrays);
|
|
}
|
|
var ret = {};
|
|
ret.result = _.any(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.
|
|
regexpElementMatcher = function (regexp) {
|
|
return function (value) {
|
|
if (value instanceof RegExp) {
|
|
// Comparing two regexps means seeing if the regexps are identical
|
|
// (really!). Underscore knows how.
|
|
return _.isEqual(value, 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
|
|
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.
|
|
equalityElementMatcher = function (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 LocalCollection._f._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 = _.contains(['$lt', '$lte', '$gt', '$gte'], operator) &&
|
|
_.isNumber(operand);
|
|
var simpleInequality = operator === '$ne' && !_.isObject(operand);
|
|
var simpleInclusion = _.contains(['$in', '$nin'], operator) &&
|
|
_.isArray(operand) && !_.any(operand, _.isObject);
|
|
|
|
if (! (operator === '$eq' || simpleRange ||
|
|
simpleInclusion || simpleInequality)) {
|
|
matcher._isSimple = false;
|
|
}
|
|
|
|
if (_.has(VALUE_OPERATORS, operator)) {
|
|
operatorMatchers.push(
|
|
VALUE_OPERATORS[operator](operand, valueSelector, matcher, isRoot));
|
|
} else if (_.has(ELEMENT_OPERATORS, 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) || _.isEmpty(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 = _.any(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 = _.all(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);
|
|
}
|
|
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 (!_.has(valueSelector, '$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 (_.isEmpty(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) && _.has(operand, '$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 (_.isNaN(x) || _.isNaN(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, _.identity);
|
|
};
|
|
|
|
// 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 = LocalCollection._f._type(operand);
|
|
|
|
return function (value) {
|
|
if (value === undefined)
|
|
value = null;
|
|
// Comparisons are never true among things of different type (except
|
|
// null vs undefined).
|
|
if (LocalCollection._f._type(value) !== operandType)
|
|
return false;
|
|
return cmpValueComparator(LocalCollection._f._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
|
|
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 _.any(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
|
|
&& LocalCollection._f._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.
|
|
makeLookupFunction = function (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 elideUnnecessaryFields = 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 [elideUnnecessaryFields({
|
|
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 [elideUnnecessaryFields({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;
|
|
};
|
|
};
|
|
MinimongoTest.makeLookupFunction = makeLookupFunction;
|
|
|
|
expandArraysInBranches = function (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 = _.all(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;
|
|
|
|
|
|
// helpers used by compiled selector code
|
|
LocalCollection._f = {
|
|
// XXX for _all and _in, consider building 'inquery' at compile time..
|
|
|
|
_type: function (v) {
|
|
if (typeof v === "number")
|
|
return 1;
|
|
if (typeof v === "string")
|
|
return 2;
|
|
if (typeof v === "boolean")
|
|
return 8;
|
|
if (isArray(v))
|
|
return 4;
|
|
if (v === null)
|
|
return 10;
|
|
if (v instanceof RegExp)
|
|
// note that typeof(/x/) === "object"
|
|
return 11;
|
|
if (typeof v === "function")
|
|
return 13;
|
|
if (v instanceof Date)
|
|
return 9;
|
|
if (EJSON.isBinary(v))
|
|
return 5;
|
|
if (v instanceof LocalCollection._ObjectID)
|
|
return 7;
|
|
return 3; // object
|
|
|
|
// XXX support some/all of these:
|
|
// 14, symbol
|
|
// 15, javascript code with scope
|
|
// 16, 18: 32-bit/64-bit integer
|
|
// 17, timestamp
|
|
// 255, minkey
|
|
// 127, maxkey
|
|
},
|
|
|
|
// deep equality test: use for literal document and array matches
|
|
_equal: function (a, b) {
|
|
return EJSON.equals(a, b, {keyOrderSensitive: true});
|
|
},
|
|
|
|
// maps a type code to a value that can be used to sort values of
|
|
// different types
|
|
_typeorder: function (t) {
|
|
// http://www.mongodb.org/display/DOCS/What+is+the+Compare+Order+for+BSON+Types
|
|
// XXX what is the correct sort position for Javascript code?
|
|
// ('100' in the matrix below)
|
|
// XXX minkey/maxkey
|
|
return [-1, // (not a type)
|
|
1, // number
|
|
2, // string
|
|
3, // object
|
|
4, // array
|
|
5, // binary
|
|
-1, // deprecated
|
|
6, // ObjectID
|
|
7, // bool
|
|
8, // Date
|
|
0, // null
|
|
9, // RegExp
|
|
-1, // deprecated
|
|
100, // JS code
|
|
2, // deprecated (symbol)
|
|
100, // JS code
|
|
1, // 32-bit int
|
|
8, // Mongo timestamp
|
|
1 // 64-bit int
|
|
][t];
|
|
},
|
|
|
|
// compare two values of unknown type according to BSON ordering
|
|
// semantics. (as an extension, consider 'undefined' to be less than
|
|
// any other value.) return negative if a is less, positive if b is
|
|
// less, or 0 if equal
|
|
_cmp: function (a, b) {
|
|
if (a === undefined)
|
|
return b === undefined ? 0 : -1;
|
|
if (b === undefined)
|
|
return 1;
|
|
var ta = LocalCollection._f._type(a);
|
|
var tb = LocalCollection._f._type(b);
|
|
var oa = LocalCollection._f._typeorder(ta);
|
|
var ob = LocalCollection._f._typeorder(tb);
|
|
if (oa !== ob)
|
|
return oa < ob ? -1 : 1;
|
|
if (ta !== tb)
|
|
// XXX need to implement this if we implement Symbol or integers, or
|
|
// Timestamp
|
|
throw Error("Missing type coercion logic in _cmp");
|
|
if (ta === 7) { // ObjectID
|
|
// Convert to string.
|
|
ta = tb = 2;
|
|
a = a.toHexString();
|
|
b = b.toHexString();
|
|
}
|
|
if (ta === 9) { // Date
|
|
// Convert to millis.
|
|
ta = tb = 1;
|
|
a = a.getTime();
|
|
b = b.getTime();
|
|
}
|
|
|
|
if (ta === 1) // double
|
|
return a - b;
|
|
if (tb === 2) // string
|
|
return a < b ? -1 : (a === b ? 0 : 1);
|
|
if (ta === 3) { // Object
|
|
// this could be much more efficient in the expected case ...
|
|
var to_array = function (obj) {
|
|
var ret = [];
|
|
for (var key in obj) {
|
|
ret.push(key);
|
|
ret.push(obj[key]);
|
|
}
|
|
return ret;
|
|
};
|
|
return LocalCollection._f._cmp(to_array(a), to_array(b));
|
|
}
|
|
if (ta === 4) { // Array
|
|
for (var i = 0; ; i++) {
|
|
if (i === a.length)
|
|
return (i === b.length) ? 0 : -1;
|
|
if (i === b.length)
|
|
return 1;
|
|
var s = LocalCollection._f._cmp(a[i], b[i]);
|
|
if (s !== 0)
|
|
return s;
|
|
}
|
|
}
|
|
if (ta === 5) { // binary
|
|
// Surprisingly, a small binary blob is always less than a large one in
|
|
// Mongo.
|
|
if (a.length !== b.length)
|
|
return a.length - b.length;
|
|
for (i = 0; i < a.length; i++) {
|
|
if (a[i] < b[i])
|
|
return -1;
|
|
if (a[i] > b[i])
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
if (ta === 8) { // boolean
|
|
if (a) return b ? 0 : 1;
|
|
return b ? -1 : 0;
|
|
}
|
|
if (ta === 10) // null
|
|
return 0;
|
|
if (ta === 11) // regexp
|
|
throw Error("Sorting not supported on regular expression"); // XXX
|
|
// 13: javascript code
|
|
// 14: symbol
|
|
// 15: javascript code with scope
|
|
// 16: 32-bit integer
|
|
// 17: timestamp
|
|
// 18: 64-bit integer
|
|
// 255: minkey
|
|
// 127: maxkey
|
|
if (ta === 13) // javascript code
|
|
throw Error("Sorting not supported on Javascript code"); // XXX
|
|
throw Error("Unknown type to sort");
|
|
}
|
|
};
|
|
|
|
// Oddball function used by upsert.
|
|
LocalCollection._removeDollarOperators = function (selector) {
|
|
var selectorDoc = {};
|
|
for (var k in selector)
|
|
if (k.substr(0, 1) !== '$')
|
|
selectorDoc[k] = selector[k];
|
|
return selectorDoc;
|
|
};
|