mirror of
https://github.com/meteor/meteor.git
synced 2026-05-02 03:01:46 -04:00
1142 lines
41 KiB
JavaScript
1142 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. 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.
|
|
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 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;
|
|
};
|
|
};
|
|
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;
|
|
};
|