packages/minimongo/common.js
import LocalCollection from './local_collection.js';
export const hasOwn = Object.prototype.hasOwnProperty;
// 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 const ELEMENT_OPERATORS = {
$lt: makeInequality(cmpValue => cmpValue < 0),
$gt: makeInequality(cmpValue => cmpValue > 0),
$lte: makeInequality(cmpValue => cmpValue <= 0),
$gte: makeInequality(cmpValue => cmpValue >= 0),
$mod: {
compileElementSelector(operand) {
if (!(Array.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
const divisor = operand[0];
const remainder = operand[1];
return value => (
typeof value === 'number' && value % divisor === remainder
);
},
},
$in: {
compileElementSelector(operand) {
if (!Array.isArray(operand)) {
throw Error('$in needs an array');
}
const elementMatchers = operand.map(option => {
if (option instanceof RegExp) {
return regexpElementMatcher(option);
}
if (isOperatorObject(option)) {
throw Error('cannot nest $ under $in');
}
return equalityElementMatcher(option);
});
return value => {
// Allow {a: {$in: [null]}} to match when 'a' does not exist.
if (value === undefined) {
value = null;
}
return elementMatchers.some(matcher => matcher(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(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 value => Array.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(operand) {
if (typeof operand === 'string') {
const operandAliasMap = {
'double': 1,
'string': 2,
'object': 3,
'array': 4,
'binData': 5,
'undefined': 6,
'objectId': 7,
'bool': 8,
'date': 9,
'null': 10,
'regex': 11,
'dbPointer': 12,
'javascript': 13,
'symbol': 14,
'javascriptWithScope': 15,
'int': 16,
'timestamp': 17,
'long': 18,
'decimal': 19,
'minKey': -1,
'maxKey': 127,
};
if (!hasOwn.call(operandAliasMap, operand)) {
throw Error(`unknown string alias for $type: ${operand}`);
}
operand = operandAliasMap[operand];
} else if (typeof operand === 'number') {
if (operand === 0 || operand < -1
|| (operand > 19 && operand !== 127)) {
throw Error(`Invalid numerical $type code: ${operand}`);
}
} else {
throw Error('argument to $type is not a number or a string');
}
return value => (
value !== undefined && LocalCollection._f._type(value) === operand
);
},
},
$bitsAllSet: {
compileElementSelector(operand) {
const mask = getOperandBitmask(operand, '$bitsAllSet');
return value => {
const bitmask = getValueBitmask(value, mask.length);
return bitmask && mask.every((byte, i) => (bitmask[i] & byte) === byte);
};
},
},
$bitsAnySet: {
compileElementSelector(operand) {
const mask = getOperandBitmask(operand, '$bitsAnySet');
return value => {
const bitmask = getValueBitmask(value, mask.length);
return bitmask && mask.some((byte, i) => (~bitmask[i] & byte) !== byte);
};
},
},
$bitsAllClear: {
compileElementSelector(operand) {
const mask = getOperandBitmask(operand, '$bitsAllClear');
return value => {
const bitmask = getValueBitmask(value, mask.length);
return bitmask && mask.every((byte, i) => !(bitmask[i] & byte));
};
},
},
$bitsAnyClear: {
compileElementSelector(operand) {
const mask = getOperandBitmask(operand, '$bitsAnyClear');
return value => {
const bitmask = getValueBitmask(value, mask.length);
return bitmask && mask.some((byte, i) => (bitmask[i] & byte) !== byte);
};
},
},
$regex: {
compileElementSelector(operand, valueSelector) {
if (!(typeof operand === 'string' || operand instanceof RegExp)) {
throw Error('$regex has to be a string or RegExp');
}
let regexp;
if (valueSelector.$options !== undefined) {
// Options passed in $options (even the empty string) always overrides
// options in the RegExp object itself.
// 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');
}
const source = operand instanceof RegExp ? operand.source : operand;
regexp = new RegExp(source, valueSelector.$options);
} else if (operand instanceof RegExp) {
regexp = operand;
} else {
regexp = new RegExp(operand);
}
return regexpElementMatcher(regexp);
},
},
$elemMatch: {
dontExpandLeafArrays: true,
compileElementSelector(operand, valueSelector, matcher) {
if (!LocalCollection._isPlainObject(operand)) {
throw Error('$elemMatch need an object');
}
const isDocMatcher = !isOperatorObject(
Object.keys(operand)
.filter(key => !hasOwn.call(LOGICAL_OPERATORS, key))
.reduce((a, b) => Object.assign(a, {[b]: operand[b]}), {}),
true);
let subMatcher;
if (isDocMatcher) {
// 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});
} else {
subMatcher = compileValueSelector(operand, matcher);
}
return value => {
if (!Array.isArray(value)) {
return false;
}
for (let i = 0; i < value.length; ++i) {
const arrayElement = value[i];
let 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 (!isIndexable(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;
};
},
},
};
// Operators that appear at the top level of a document selector.
const LOGICAL_OPERATORS = {
$and(subSelector, matcher, inElemMatch) {
return andDocumentMatchers(
compileArrayOfDocumentSelectors(subSelector, matcher, inElemMatch)
);
},
$or(subSelector, matcher, inElemMatch) {
const 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 doc => {
const result = matchers.some(fn => fn(doc).result);
// $or does NOT set arrayIndices when it has multiple
// sub-expressions. (Tested against MongoDB.)
return {result};
};
},
$nor(subSelector, matcher, inElemMatch) {
const matchers = compileArrayOfDocumentSelectors(
subSelector,
matcher,
inElemMatch
);
return doc => {
const result = matchers.every(fn => !fn(doc).result);
// Never set arrayIndices, because we only match if nothing in particular
// 'matched' (and because this is consistent with MongoDB).
return {result};
};
},
$where(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}`);
}
// We make the document available as both `this` and `obj`.
// // XXX not sure what we should do if this throws
return doc => ({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() {
return () => ({result: true});
},
};
// 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".
const VALUE_OPERATORS = {
$eq(operand) {
return convertElementMatcherToBranchedMatcher(
equalityElementMatcher(operand)
);
},
$not(operand, valueSelector, matcher) {
return invertBranchedMatcher(compileValueSelector(operand, matcher));
},
$ne(operand) {
return invertBranchedMatcher(
convertElementMatcherToBranchedMatcher(equalityElementMatcher(operand))
);
},
$nin(operand) {
return invertBranchedMatcher(
convertElementMatcherToBranchedMatcher(
ELEMENT_OPERATORS.$in.compileElementSelector(operand)
)
);
},
$exists(operand) {
const exists = convertElementMatcherToBranchedMatcher(
value => value !== undefined
);
return operand ? exists : invertBranchedMatcher(exists);
},
// $options just provides options for $regex; its logic is inside $regex
$options(operand, valueSelector) {
if (!hasOwn.call(valueSelector, '$regex')) {
throw Error('$options needs a $regex');
}
return everythingMatcher;
},
// $maxDistance is basically an argument to $near
$maxDistance(operand, valueSelector) {
if (!valueSelector.$near) {
throw Error('$maxDistance needs a $near');
}
return everythingMatcher;
},
$all(operand, valueSelector, matcher) {
if (!Array.isArray(operand)) {
throw Error('$all requires array');
}
// Not sure why, but this seems to be what MongoDB does.
if (operand.length === 0) {
return nothingMatcher;
}
const branchedMatchers = operand.map(criterion => {
// XXX handle $all/$elemMatch combination
if (isOperatorObject(criterion)) {
throw Error('no $ expressions in $all');
}
// This is always a regexp or equality selector.
return compileValueSelector(criterion, matcher);
});
// andBranchedMatchers does NOT require all selectors to return true on the
// SAME branch.
return andBranchedMatchers(branchedMatchers);
},
$near(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: legacy coordinate pairs and
// GeoJSON. They use different distance metrics, too. GeoJSON queries are
// marked with a $geometry property, though legacy coordinates can be
// matched using $geometry.
let maxDistance, point, distance;
if (LocalCollection._isPlainObject(operand) && hasOwn.call(operand, '$geometry')) {
// GeoJSON "2dsphere" mode.
maxDistance = operand.$maxDistance;
point = operand.$geometry;
distance = 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) {
return null;
}
if (!value.type) {
return GeoJSON.pointDistance(
point,
{type: 'Point', coordinates: pointToArray(value)}
);
}
if (value.type === 'Point') {
return GeoJSON.pointDistance(point, value);
}
return GeoJSON.geometryWithinRadius(value, point, maxDistance)
? 0
: maxDistance + 1;
};
} else {
maxDistance = valueSelector.$maxDistance;
if (!isIndexable(operand)) {
throw Error('$near argument must be coordinate pair or GeoJSON');
}
point = pointToArray(operand);
distance = value => {
if (!isIndexable(value)) {
return null;
}
return distanceCoordinatePairs(point, value);
};
}
return 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.
const result = {result: false};
expandArraysInBranches(branchedValues).every(branch => {
// if operation is an update, don't skip branches, just return the first
// one (#3599)
let curDistance;
if (!matcher._isUpdate) {
if (!(typeof branch.value === 'object')) {
return true;
}
curDistance = distance(branch.value);
// Skip branches that aren't real points or are too far away.
if (curDistance === null || curDistance > maxDistance) {
return true;
}
// Skip anything that's a tie.
if (result.distance !== undefined && result.distance <= curDistance) {
return true;
}
}
result.result = true;
result.distance = curDistance;
if (branch.arrayIndices) {
result.arrayIndices = branch.arrayIndices;
} else {
delete result.arrayIndices;
}
return !matcher._isUpdate;
});
return result;
};
},
};
// 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'.
function andSomeMatchers(subMatchers) {
if (subMatchers.length === 0) {
return everythingMatcher;
}
if (subMatchers.length === 1) {
return subMatchers[0];
}
return docOrBranches => {
const match = {};
match.result = subMatchers.every(fn => {
const subResult = fn(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 &&
match.distance === undefined) {
match.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) {
match.arrayIndices = subResult.arrayIndices;
}
return subResult.result;
});
// If we didn't actually match, forget any extra metadata we came up with.
if (!match.result) {
delete match.distance;
delete match.arrayIndices;
}
return match;
};
}
const andDocumentMatchers = andSomeMatchers;
const andBranchedMatchers = andSomeMatchers;
function compileArrayOfDocumentSelectors(selectors, matcher, inElemMatch) {
if (!Array.isArray(selectors) || selectors.length === 0) {
throw Error('$and/$or/$nor must be nonempty array');
}
return selectors.map(subSelector => {
if (!LocalCollection._isPlainObject(subSelector)) {
throw Error('$or/$and/$nor entries need to be full objects');
}
return compileDocumentSelector(subSelector, matcher, {inElemMatch});
});
}
// 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.)
export function compileDocumentSelector(docSelector, matcher, options = {}) {
const docMatchers = Object.keys(docSelector).map(key => {
const subSelector = docSelector[key];
if (key.substr(0, 1) === '$') {
// Outer operators are either logical operators (they recurse back into
// this function), or $where.
if (!hasOwn.call(LOGICAL_OPERATORS, key)) {
throw new Error(`Unrecognized logical operator: ${key}`);
}
matcher._isSimple = false;
return LOGICAL_OPERATORS[key](subSelector, matcher, options.inElemMatch);
}
// 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);
}
// Don't add a matcher if subSelector is a function -- this is to match
// the behavior of Meteor on the server (inherited from the node mongodb
// driver), which is to ignore any part of a selector which is a function.
if (typeof subSelector === 'function') {
return undefined;
}
const lookUpByIndex = makeLookupFunction(key);
const valueMatcher = compileValueSelector(
subSelector,
matcher,
options.isRoot
);
return doc => valueMatcher(lookUpByIndex(doc));
}).filter(Boolean);
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.
function compileValueSelector(valueSelector, matcher, isRoot) {
if (valueSelector instanceof RegExp) {
matcher._isSimple = false;
return convertElementMatcherToBranchedMatcher(
regexpElementMatcher(valueSelector)
);
}
if (isOperatorObject(valueSelector)) {
return operatorBranchedMatcher(valueSelector, matcher, isRoot);
}
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).
function convertElementMatcherToBranchedMatcher(elementMatcher, options = {}) {
return branches => {
const expanded = options.dontExpandLeafArrays
? branches
: expandArraysInBranches(branches, options.dontIncludeLeafArrays);
const match = {};
match.result = expanded.some(element => {
let 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) {
match.arrayIndices = element.arrayIndices;
}
return matched;
});
return match;
};
}
// Helpers for $near.
function distanceCoordinatePairs(a, b) {
const pointA = pointToArray(a);
const pointB = pointToArray(b);
return Math.hypot(pointA[0] - pointB[0], pointA[1] - pointB[1]);
}
// 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.
// undefined or null
if (elementSelector == null) {
return value => value == null;
}
return value => LocalCollection._f._equal(elementSelector, value);
}
function everythingMatcher(docOrBranchedValues) {
return {result: true};
}
export function expandArraysInBranches(branches, skipTheArrays) {
const branchesOut = [];
branches.forEach(branch => {
const thisIsArray = Array.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({arrayIndices: branch.arrayIndices, value: branch.value});
}
if (thisIsArray && !branch.dontIterate) {
branch.value.forEach((value, i) => {
branchesOut.push({
arrayIndices: (branch.arrayIndices || []).concat(i),
value
});
});
}
});
return branchesOut;
}
// Helpers for $bitsAllSet/$bitsAnySet/$bitsAllClear/$bitsAnyClear.
function getOperandBitmask(operand, selector) {
// numeric bitmask
// You can provide a numeric bitmask to be matched against the operand field.
// It must be representable as a non-negative 32-bit signed integer.
// Otherwise, $bitsAllSet will return an error.
if (Number.isInteger(operand) && operand >= 0) {
return new Uint8Array(new Int32Array([operand]).buffer);
}
// bindata bitmask
// You can also use an arbitrarily large BinData instance as a bitmask.
if (EJSON.isBinary(operand)) {
return new Uint8Array(operand.buffer);
}
// position list
// If querying a list of bit positions, each <position> must be a non-negative
// integer. Bit positions start at 0 from the least significant bit.
if (Array.isArray(operand) &&
operand.every(x => Number.isInteger(x) && x >= 0)) {
const buffer = new ArrayBuffer((Math.max(...operand) >> 3) + 1);
const view = new Uint8Array(buffer);
operand.forEach(x => {
view[x >> 3] |= 1 << (x & 0x7);
});
return view;
}
// bad operand
throw Error(
`operand to ${selector} must be a numeric bitmask (representable as a ` +
'non-negative 32-bit signed integer), a bindata bitmask or an array with ' +
'bit positions (non-negative integers)'
);
}
function getValueBitmask(value, length) {
// The field value must be either numerical or a BinData instance. Otherwise,
// $bits... will not match the current document.
// numerical
if (Number.isSafeInteger(value)) {
// $bits... will not match numerical values that cannot be represented as a
// signed 64-bit integer. This can be the case if a value is either too
// large or small to fit in a signed 64-bit integer, or if it has a
// fractional component.
const buffer = new ArrayBuffer(
Math.max(length, 2 * Uint32Array.BYTES_PER_ELEMENT)
);
let view = new Uint32Array(buffer, 0, 2);
view[0] = value % ((1 << 16) * (1 << 16)) | 0;
view[1] = value / ((1 << 16) * (1 << 16)) | 0;
// sign extension
if (value < 0) {
view = new Uint8Array(buffer, 2);
view.forEach((byte, i) => {
view[i] = 0xff;
});
}
return new Uint8Array(buffer);
}
// bindata
if (EJSON.isBinary(value)) {
return new Uint8Array(value.buffer);
}
// no match
return false;
}
// Actually inserts a key value into the selector document
// However, this checks there is no ambiguity in setting
// the value for the given key, throws otherwise
function insertIntoDocument(document, key, value) {
Object.keys(document).forEach(existingKey => {
if (
(existingKey.length > key.length && existingKey.indexOf(`${key}.`) === 0) ||
(key.length > existingKey.length && key.indexOf(`${existingKey}.`) === 0)
) {
throw new Error(
`cannot infer query fields to set, both paths '${existingKey}' and ` +
`'${key}' are matched`
);
} else if (existingKey === key) {
throw new Error(
`cannot infer query fields to set, path '${key}' is matched twice`
);
}
});
document[key] = value;
}
// 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.
function invertBranchedMatcher(branchedMatcher) {
return 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: !branchedMatcher(branchValues).result};
};
}
export function isIndexable(obj) {
return Array.isArray(obj) || LocalCollection._isPlainObject(obj);
}
export function isNumericKey(s) {
return /^[0-9]+$/.test(s);
}
// Returns true if this is an object with at least one key and all keys begin
// with $. Unless inconsistentOK is set, throws if some keys begin with $ and
// others don't.
export function isOperatorObject(valueSelector, inconsistentOK) {
if (!LocalCollection._isPlainObject(valueSelector)) {
return false;
}
let theseAreOperators = undefined;
Object.keys(valueSelector).forEach(selKey => {
const thisIsOperator = selKey.substr(0, 1) === '$' || selKey === 'diff';
if (theseAreOperators === undefined) {
theseAreOperators = thisIsOperator;
} else if (theseAreOperators !== thisIsOperator) {
if (!inconsistentOK) {
throw new Error(
`Inconsistent operator: ${JSON.stringify(valueSelector)}`
);
}
theseAreOperators = false;
}
});
return !!theseAreOperators; // {} has no operators
}
// Helper for $lt/$gt/$lte/$gte.
function makeInequality(cmpValueComparator) {
return {
compileElementSelector(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 (Array.isArray(operand)) {
return () => false;
}
// Special case: consider undefined and null the same (so true with
// $gte/$lte).
if (operand === undefined) {
operand = null;
}
const operandType = LocalCollection._f._type(operand);
return 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));
};
},
};
}
// 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 = {}) {
const parts = key.split('.');
const firstPart = parts.length ? parts[0] : '';
const lookupRest = (
parts.length > 1 &&
makeLookupFunction(parts.slice(1).join('.'), options)
);
function buildResult(arrayIndices, dontIterate, value) {
return arrayIndices && arrayIndices.length
? dontIterate
? [{ arrayIndices, dontIterate, value }]
: [{ arrayIndices, value }]
: dontIterate
? [{ dontIterate, value }]
: [{ value }];
}
// Doc will always be a plain object or an array.
// apply an explicit numeric index, an array.
return (doc, arrayIndices) => {
if (Array.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 (!(isNumericKey(firstPart) && 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 ? arrayIndices.concat(+firstPart, 'x') : [+firstPart, 'x'];
}
// Do our first lookup.
const 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 buildResult(
arrayIndices,
Array.isArray(doc) && Array.isArray(firstLevel),
firstLevel,
);
}
// 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 (Array.isArray(doc)) {
return [];
}
return buildResult(arrayIndices, false, undefined);
}
const result = [];
const appendToResult = more => {
result.push(...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 (Array.isArray(firstLevel) &&
!(isNumericKey(parts[1]) && options.forSort)) {
firstLevel.forEach((branch, arrayIndex) => {
if (LocalCollection._isPlainObject(branch)) {
appendToResult(lookupRest(branch, arrayIndices ? arrayIndices.concat(arrayIndex) : [arrayIndex]));
}
});
}
return result;
};
}
// Object exported only for unit testing.
// Use it to export private functions to test in Tinytest.
MinimongoTest = {makeLookupFunction};
MinimongoError = (message, options = {}) => {
if (typeof message === 'string' && options.field) {
message += ` for field '${options.field}'`;
}
const error = new Error(message);
error.name = 'MinimongoError';
return error;
};
export function nothingMatcher(docOrBranchedValues) {
return {result: false};
}
// Takes an operator object (an object with $ keys) and returns a branched
// matcher for it.
function operatorBranchedMatcher(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.
const operatorMatchers = Object.keys(valueSelector).map(operator => {
const operand = valueSelector[operator];
const simpleRange = (
['$lt', '$lte', '$gt', '$gte'].includes(operator) &&
typeof operand === 'number'
);
const simpleEquality = (
['$ne', '$eq'].includes(operator) &&
operand !== Object(operand)
);
const simpleInclusion = (
['$in', '$nin'].includes(operator)
&& Array.isArray(operand)
&& !operand.some(x => x === Object(x))
);
if (!(simpleRange || simpleInclusion || simpleEquality)) {
matcher._isSimple = false;
}
if (hasOwn.call(VALUE_OPERATORS, operator)) {
return VALUE_OPERATORS[operator](operand, valueSelector, matcher, isRoot);
}
if (hasOwn.call(ELEMENT_OPERATORS, operator)) {
const options = ELEMENT_OPERATORS[operator];
return convertElementMatcherToBranchedMatcher(
options.compileElementSelector(operand, valueSelector, matcher),
options
);
}
throw new Error(`Unrecognized operator: ${operator}`);
});
return andBranchedMatchers(operatorMatchers);
}
// paths - Array: list of mongo style paths
// newLeafFn - Function: of form function(path) should return a scalar value to
// put into list created for that path
// conflictFn - Function: of form function(node, path, fullPath) is called
// when building a tree path for 'fullPath' node on
// 'path' was already a leaf with a value. Must return a
// conflict resolution.
// initial tree - Optional Object: starting tree.
// @returns - Object: tree represented as a set of nested objects
export function pathsToTree(paths, newLeafFn, conflictFn, root = {}) {
paths.forEach(path => {
const pathArray = path.split('.');
let tree = root;
// use .every just for iteration with break
const success = pathArray.slice(0, -1).every((key, i) => {
if (!hasOwn.call(tree, key)) {
tree[key] = {};
} else if (tree[key] !== Object(tree[key])) {
tree[key] = conflictFn(
tree[key],
pathArray.slice(0, i + 1).join('.'),
path
);
// break out of loop if we are failing for this path
if (tree[key] !== Object(tree[key])) {
return false;
}
}
tree = tree[key];
return true;
});
if (success) {
const lastKey = pathArray[pathArray.length - 1];
if (hasOwn.call(tree, lastKey)) {
tree[lastKey] = conflictFn(tree[lastKey], path, path);
} else {
tree[lastKey] = newLeafFn(path);
}
}
});
return root;
}
// 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]
function pointToArray(point) {
return Array.isArray(point) ? point.slice() : [point.x, point.y];
}
// Creating a document from an upsert is quite tricky.
// E.g. this selector: {"$or": [{"b.foo": {"$all": ["bar"]}}]}, should result
// in: {"b.foo": "bar"}
// But this selector: {"$or": [{"b": {"foo": {"$all": ["bar"]}}}]} should throw
// an error
// Some rules (found mainly with trial & error, so there might be more):
// - handle all childs of $and (or implicit $and)
// - handle $or nodes with exactly 1 child
// - ignore $or nodes with more than 1 child
// - ignore $nor and $not nodes
// - throw when a value can not be set unambiguously
// - every value for $all should be dealt with as separate $eq-s
// - threat all children of $all as $eq setters (=> set if $all.length === 1,
// otherwise throw error)
// - you can not mix '$'-prefixed keys and non-'$'-prefixed keys
// - you can only have dotted keys on a root-level
// - you can not have '$'-prefixed keys more than one-level deep in an object
// Handles one key/value pair to put in the selector document
function populateDocumentWithKeyValue(document, key, value) {
if (value && Object.getPrototypeOf(value) === Object.prototype) {
populateDocumentWithObject(document, key, value);
} else if (!(value instanceof RegExp)) {
insertIntoDocument(document, key, value);
}
}
// Handles a key, value pair to put in the selector document
// if the value is an object
function populateDocumentWithObject(document, key, value) {
const keys = Object.keys(value);
const unprefixedKeys = keys.filter(op => op[0] !== '$');
if (unprefixedKeys.length > 0 || !keys.length) {
// Literal (possibly empty) object ( or empty object )
// Don't allow mixing '$'-prefixed with non-'$'-prefixed fields
if (keys.length !== unprefixedKeys.length) {
throw new Error(`unknown operator: ${unprefixedKeys[0]}`);
}
validateObject(value, key);
insertIntoDocument(document, key, value);
} else {
Object.keys(value).forEach(op => {
const object = value[op];
if (op === '$eq') {
populateDocumentWithKeyValue(document, key, object);
} else if (op === '$all') {
// every value for $all should be dealt with as separate $eq-s
object.forEach(element =>
populateDocumentWithKeyValue(document, key, element)
);
}
});
}
}
// Fills a document with certain fields from an upsert selector
export function populateDocumentWithQueryFields(query, document = {}) {
if (Object.getPrototypeOf(query) === Object.prototype) {
// handle implicit $and
Object.keys(query).forEach(key => {
const value = query[key];
if (key === '$and') {
// handle explicit $and
value.forEach(element =>
populateDocumentWithQueryFields(element, document)
);
} else if (key === '$or') {
// handle $or nodes with exactly 1 child
if (value.length === 1) {
populateDocumentWithQueryFields(value[0], document);
}
} else if (key[0] !== '$') {
// Ignore other '$'-prefixed logical selectors
populateDocumentWithKeyValue(document, key, value);
}
});
} else {
// Handle meteor-specific shortcut for selecting _id
if (LocalCollection._selectorIsId(query)) {
insertIntoDocument(document, '_id', query);
}
}
return document;
}
// Traverses the keys of passed projection and constructs a tree where all
// leaves are either all True or all False
// @returns Object:
// - tree - Object - tree representation of keys involved in projection
// (exception for '_id' as it is a special case handled separately)
// - including - Boolean - "take only certain fields" type of projection
export function projectionDetails(fields) {
// Find the non-_id keys (_id is handled specially because it is included
// unless explicitly excluded). Sort the keys, so that our code to detect
// overlaps like 'foo' and 'foo.bar' can assume that 'foo' comes first.
let fieldsKeys = Object.keys(fields).sort();
// If _id is the only field in the projection, do not remove it, since it is
// required to determine if this is an exclusion or exclusion. Also keep an
// inclusive _id, since inclusive _id follows the normal rules about mixing
// inclusive and exclusive fields. If _id is not the only field in the
// projection and is exclusive, remove it so it can be handled later by a
// special case, since exclusive _id is always allowed.
if (!(fieldsKeys.length === 1 && fieldsKeys[0] === '_id') &&
!(fieldsKeys.includes('_id') && fields._id)) {
fieldsKeys = fieldsKeys.filter(key => key !== '_id');
}
let including = null; // Unknown
fieldsKeys.forEach(keyPath => {
const rule = !!fields[keyPath];
if (including === null) {
including = rule;
}
// This error message is copied from MongoDB shell
if (including !== rule) {
throw MinimongoError(
'You cannot currently mix including and excluding fields.'
);
}
});
const projectionRulesTree = pathsToTree(
fieldsKeys,
path => including,
(node, path, fullPath) => {
// Check passed projection fields' keys: If you have two rules such as
// 'foo.bar' and 'foo.bar.baz', then the result becomes ambiguous. If
// that happens, there is a probability you are doing something wrong,
// framework should notify you about such mistake earlier on cursor
// compilation step than later during runtime. Note, that real mongo
// doesn't do anything about it and the later rule appears in projection
// project, more priority it takes.
//
// Example, assume following in mongo shell:
// > db.coll.insert({ a: { b: 23, c: 44 } })
// > db.coll.find({}, { 'a': 1, 'a.b': 1 })
// {"_id": ObjectId("520bfe456024608e8ef24af3"), "a": {"b": 23}}
// > db.coll.find({}, { 'a.b': 1, 'a': 1 })
// {"_id": ObjectId("520bfe456024608e8ef24af3"), "a": {"b": 23, "c": 44}}
//
// Note, how second time the return set of keys is different.
const currentPath = fullPath;
const anotherPath = path;
throw MinimongoError(
`both ${currentPath} and ${anotherPath} found in fields option, ` +
'using both of them may trigger unexpected behavior. Did you mean to ' +
'use only one of them?'
);
});
return {including, tree: projectionRulesTree};
}
// Takes a RegExp object and returns an element matcher.
export function regexpElementMatcher(regexp) {
return value => {
if (value instanceof RegExp) {
return value.toString() === regexp.toString();
}
// 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);
};
}
// Validates the key in a path.
// Objects that are nested more then 1 level cannot have dotted fields
// or fields starting with '$'
function validateKeyInPath(key, path) {
if (key.includes('.')) {
throw new Error(
`The dotted field '${key}' in '${path}.${key} is not valid for storage.`
);
}
if (key[0] === '$') {
throw new Error(
`The dollar ($) prefixed field '${path}.${key} is not valid for storage.`
);
}
}
// Recursively validates an object that is nested more than one level deep
function validateObject(object, path) {
if (object && Object.getPrototypeOf(object) === Object.prototype) {
Object.keys(object).forEach(key => {
validateKeyInPath(key, path);
validateObject(object[key], path + '.' + key);
});
}
}